diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..afa9d76 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,36 @@ +name: Build & Release + +on: + push: + branches: ["main"] + +jobs: + build: + name: Build Packages + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - uses: jetli/wasm-pack-action@v0.4.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..48f0146 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +jobs: + check: + name: Lint, Type Check & Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - uses: jetli/wasm-pack-action@v0.4.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Layout Engine (WASM) + run: pnpm run build + + - name: Check Formatting + run: pnpm run format --check + + - name: Lint + run: pnpm run lint + + - name: Type Check + run: pnpm run check + + - name: Run Tests + run: pnpm run test diff --git a/.github/workflows/profiler.yml b/.github/workflows/profiler.yml new file mode 100644 index 0000000..a645e8c --- /dev/null +++ b/.github/workflows/profiler.yml @@ -0,0 +1,105 @@ +name: Performance Benchmark + +on: + pull_request: + branches: ["**"] + paths-ignore: + - "**.md" + - "docs/**" + workflow_dispatch: + +jobs: + benchmark: + name: Run Benchmark + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - uses: jetli/wasm-pack-action@v0.4.0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache@v3 + name: Setup pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build Layout Engine (WASM) + run: pnpm run build + + - name: Run Limit Test + run: | + pnpm run profiler:limit | tee benchmark_result.txt + + - name: Extract Report + id: extract_report + run: | + REPORT=$(sed -n '/Performance Limits Report/,$p' benchmark_result.txt) + + echo "REPORT_CONTENT<> $GITHUB_ENV + echo "$REPORT" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Comment Benchmark Result + if: github.event_name == 'pull_request' + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const report = process.env.REPORT_CONTENT; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => { + return comment.user.type === 'Bot' && comment.body.includes('Performance Limits Report'); + }); + + const body = `### 🚀 Performance Benchmark Result\n\n\`\`\`text\n${report}\n\`\`\`\n\n
Run Details\nTriggered by commit: ${context.sha}
`; + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } diff --git a/.gitignore b/.gitignore index db6120f..db6f18a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ out out.* dist *.tgz +pkg +**/profiles/*.json # code coverage coverage diff --git a/README.md b/README.md index d53abbd..3fe67be 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,22 @@ bun packages/showcase/counter.ts bun packages/showcase/dashboard.ts ``` +### Profiling / Perf Regression + +```bash +# 大量要素のストレス(JSON出力、--io=off で stdout を捨てて純粋な計算寄りに) +bun run profile:stress --n=10000 --frames=120 --io=off --out=profiles/stress.json + +# パフォーマンス予算テスト(将来的にCIで回帰検知に使う想定) +# 例: まずは計測してから budget を詰めるのがおすすめ +bun run perf:budget --task=frame --n=10000 --iterations=30 --out=profiles/budget.json +bun run perf:budget --task=diff --rows=200 --cols=400 --iterations=20 --out=profiles/budget-diff.json + +# bun test に載せる場合(CI or BTUIN_PERF=1 のときのみ実行) +CI=1 bun run test:perf +# 予算/サイズは env で上書き可能(例: BTUIN_BUDGET_FRAME_P95=120 など) +``` + ## 使い方(最小例) ```ts diff --git a/package.json b/package.json index c8134cc..4372ceb 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,18 @@ "packages/*" ], "scripts": { - "start": "bun run --cwd packages/btuin src/app.ts", - "build": "bun run --cwd packages/btuin build.ts", + "start": "bun run", + "build": "pnpm --filter @btuin/layout-engine build", "lint": "oxlint packages/*", "lint:fix": "oxlint packages/* --fix", "format": "oxfmt packages/*", + "test": "bun test packages/btuin packages/layout-engine packages/reactivity packages/renderer packages/terminal packages/showcase", + "test:watch": "pnpm test --watch", "test:all": "bun test packages", - "test": "bun test packages/btuin packages/layout-engine packages/reactivity packages/renderer packages/terminal", + "profiler": "bun test scripts/profiler*.spec.ts", + "profiler:stress": "bun test scripts/profiler-stress.test.ts", + "profiler:layout": "bun test scripts/profiler-layout.test.ts", + "profiler:limit": "bun test scripts/profiler-limit.spec.ts", "check": "bunx tsc --noEmit" }, "devDependencies": { diff --git a/packages/btuin/src/index.ts b/packages/btuin/src/index.ts index 273f52a..1d97de8 100644 --- a/packages/btuin/src/index.ts +++ b/packages/btuin/src/index.ts @@ -2,8 +2,7 @@ * btuin core entry point */ -export * from "./runtime/app"; -export * from "./runtime/error-boundary"; +export * from "./runtime"; export * from "./view/components"; diff --git a/packages/btuin/src/layout/focus.ts b/packages/btuin/src/layout/focus.ts index ad3a8dc..7aeaa77 100644 --- a/packages/btuin/src/layout/focus.ts +++ b/packages/btuin/src/layout/focus.ts @@ -63,6 +63,8 @@ export function collectFocusTargetMap( effectiveKey: string | undefined = element.key, ): Map { const map = new Map(); - visitFocusTargets(element, layoutMap, parentX, parentY, effectiveKey, (t) => map.set(t.focusKey, t)); + visitFocusTargets(element, layoutMap, parentX, parentY, effectiveKey, (t) => + map.set(t.focusKey, t), + ); return map; } diff --git a/packages/btuin/src/layout/index.ts b/packages/btuin/src/layout/index.ts index c5712c2..964b5d0 100644 --- a/packages/btuin/src/layout/index.ts +++ b/packages/btuin/src/layout/index.ts @@ -5,7 +5,7 @@ import { type ComputedLayout, type Dimension, } from "@btuin/layout-engine"; -import { isBlock, isText, type ViewElement } from "../view/types/elements"; +import { isBlock, isText, type ViewElement, type BlockView } from "../view/types/elements"; export { renderElement } from "./renderer"; @@ -42,7 +42,12 @@ function percentToNumber(value: string, base: number): number { return (base * n) / 100; } -function resolvePadding(padding: unknown): { top: number; right: number; bottom: number; left: number } { +function resolvePadding(padding: unknown): { + top: number; + right: number; + bottom: number; + left: number; +} { if (typeof padding === "number") { return { top: padding, right: padding, bottom: padding, left: padding }; } @@ -63,6 +68,70 @@ function resolveDimension(dim: unknown, base: number): Dimension | undefined { return percentToNumber(dim, base); } +function estimateChildLength( + child: ViewElement, + direction: "row" | "column" | "row-reverse" | "column-reverse", + parentSize?: LayoutContainerSize, +): number { + const style = child.style ?? {}; + const base = + direction === "column" || direction === "column-reverse" + ? (parentSize?.height ?? 0) + : (parentSize?.width ?? 0); + const dimension = + direction === "column" || direction === "column-reverse" ? style.height : style.width; + const resolved = resolveDimension(dimension, base); + if (typeof resolved === "number") { + return Math.max(0, resolved); + } + const minDimension = + direction === "column" || direction === "column-reverse" ? style.minHeight : style.minWidth; + const resolvedMin = resolveDimension(minDimension, base); + if (typeof resolvedMin === "number") { + return Math.max(0, resolvedMin); + } + if (isText(child)) { + return direction === "column" || direction === "column-reverse" ? 1 : child.content.length; + } + return 1; +} + +function applyLayoutBoundaryToBlock( + block: BlockView, + children: ViewElement[], + contentSize?: LayoutContainerSize, + stack?: string, +): ViewElement[] { + if (!block.style?.layoutBoundary || stack === "z" || !contentSize) { + return children; + } + const direction = block.style.flexDirection ?? "column"; + const limit = direction === "column" ? contentSize.height : contentSize.width; + if (typeof limit !== "number" || limit <= 0) { + return children; + } + + const filtered: ViewElement[] = []; + let consumed = 0; + + for (const child of children) { + const childLength = estimateChildLength(child, direction, contentSize); + if (childLength > limit) { + break; + } + if (consumed + childLength > limit) { + break; + } + consumed += childLength; + filtered.push(child); + if (consumed >= limit) { + break; + } + } + + return filtered; +} + function viewElementToLayoutNode( element: ViewElement, parentSize?: LayoutContainerSize, @@ -89,10 +158,13 @@ function viewElementToLayoutNode( if (node.width !== undefined) node.width = resolveDimension(node.width, baseWidth); if (node.height !== undefined) node.height = resolveDimension(node.height, baseHeight); if (node.minWidth !== undefined) node.minWidth = resolveDimension(node.minWidth, baseWidth); - if (node.minHeight !== undefined) node.minHeight = resolveDimension(node.minHeight, baseHeight); + if (node.minHeight !== undefined) + node.minHeight = resolveDimension(node.minHeight, baseHeight); if (node.maxWidth !== undefined) node.maxWidth = resolveDimension(node.maxWidth, baseWidth); - if (node.maxHeight !== undefined) node.maxHeight = resolveDimension(node.maxHeight, baseHeight); - if (node.flexBasis !== undefined) node.flexBasis = resolveDimension(node.flexBasis, baseWidth); + if (node.maxHeight !== undefined) + node.maxHeight = resolveDimension(node.maxHeight, baseHeight); + if (node.flexBasis !== undefined) + node.flexBasis = resolveDimension(node.flexBasis, baseWidth); } const pad = resolvePadding(node.padding); @@ -105,9 +177,15 @@ function viewElementToLayoutNode( : parentSize; const stack = element.style?.stack; + const childrenForLayout = applyLayoutBoundaryToBlock( + element, + element.children, + contentSize, + stack, + ); if (stack === "z") { if (node.position === undefined) node.position = "relative"; - node.children = element.children.map((child) => { + node.children = childrenForLayout.map((child) => { const childNode = viewElementToLayoutNode(child, contentSize); if (childNode.position === undefined) childNode.position = "absolute"; if (childNode.type === "block") { @@ -121,7 +199,7 @@ function viewElementToLayoutNode( return childNode; }); } else { - node.children = element.children.map((child) => viewElementToLayoutNode(child, contentSize)); + node.children = childrenForLayout.map((child) => viewElementToLayoutNode(child, contentSize)); } } else if (isText(element)) { const textWidth = element.content.length; diff --git a/packages/btuin/src/layout/renderer.ts b/packages/btuin/src/layout/renderer.ts index 8a7b87c..c6e1ac8 100644 --- a/packages/btuin/src/layout/renderer.ts +++ b/packages/btuin/src/layout/renderer.ts @@ -2,22 +2,6 @@ import { type ViewElement, isBlock, isText } from "../view/types/elements"; import type { ComputedLayout } from "@btuin/layout-engine"; import { type Buffer2D, drawText, fillRect } from "@btuin/renderer"; -function resolvePadding(padding: unknown): { top: number; right: number; bottom: number; left: number } { - if (typeof padding === "number") { - return { top: padding, right: padding, bottom: padding, left: padding }; - } - if (Array.isArray(padding) && padding.length === 4) { - const [top, right, bottom, left] = padding as number[]; - return { - top: typeof top === "number" ? top : 0, - right: typeof right === "number" ? right : 0, - bottom: typeof bottom === "number" ? bottom : 0, - left: typeof left === "number" ? left : 0, - }; - } - return { top: 0, right: 0, bottom: 0, left: 0 }; -} - /** * Draw the element tree to the buffer. */ @@ -37,6 +21,16 @@ export function renderElement( const absX = layout.x + _parentX; const absY = layout.y + _parentY; const { width, height } = layout; + const MARGIN = 5; + + if ( + absY >= buffer.rows + MARGIN || + absX >= buffer.cols + MARGIN || + absY + height <= -MARGIN || + absX + width <= -MARGIN + ) { + return; + } const bg = element.style?.background; if (bg !== undefined) { diff --git a/packages/btuin/src/runtime/app.ts b/packages/btuin/src/runtime/app.ts index 8628e50..f377046 100644 --- a/packages/btuin/src/runtime/app.ts +++ b/packages/btuin/src/runtime/app.ts @@ -15,6 +15,7 @@ import { createRenderer } from "./render-loop"; import { createErrorHandler, createErrorContext } from "./error-boundary"; import { createDefaultTerminalAdapter, type TerminalAdapter } from "./terminal-adapter"; import { createDefaultPlatformAdapter, type PlatformAdapter } from "./platform-adapter"; +import { Profiler, type ProfileOptions } from "./profiler"; export interface AppConfig { /** @@ -61,6 +62,11 @@ export interface AppConfig { * Optional platform adapter (process hooks/exit). */ platform?: PlatformAdapter; + + /** + * Optional profiler configuration. + */ + profile?: ProfileOptions; } export interface AppInstance { @@ -135,6 +141,7 @@ export function createApp(config: AppConfig): AppInstance { let isUnmounting = false; const term = config.terminal ?? createDefaultTerminalAdapter(); const platform = config.platform ?? createDefaultPlatformAdapter(); + const profiler = new Profiler(config.profile ?? {}); // Convert config to component definition const rootComponent = defineComponent({ @@ -214,6 +221,7 @@ export function createApp(config: AppConfig): AppInstance { }, getState: () => ({}), handleError, + profiler: profiler.isEnabled() ? profiler : undefined, }); // Create reactive render effect @@ -227,6 +235,10 @@ export function createApp(config: AppConfig): AppInstance { } }); + // Ensure at least one frame is rendered during mount, even if no reactive + // dependencies change afterward. + renderer.render(true); + // Flush any key events received during mount. if (pendingKeyEvents.length && mounted) { for (const event of pendingKeyEvents.splice(0)) { @@ -265,10 +277,12 @@ export function createApp(config: AppConfig): AppInstance { platform.onExit(exitHandler); platform.onSigint(() => { exitHandler(); + profiler.flushSync(); platform.exit(0); }); platform.onSigterm(() => { exitHandler(); + profiler.flushSync(); platform.exit(0); }); } catch (error) { @@ -306,6 +320,9 @@ export function createApp(config: AppConfig): AppInstance { // Dispose console capture term.disposeSingletonCapture(); + // Persist profile results (if enabled) + profiler.flushSync(); + // Clean up terminal term.cleanupWithoutClear(); diff --git a/packages/btuin/src/runtime/index.ts b/packages/btuin/src/runtime/index.ts new file mode 100644 index 0000000..5ea2914 --- /dev/null +++ b/packages/btuin/src/runtime/index.ts @@ -0,0 +1,6 @@ +export * from "./app"; +export * from "./error-boundary"; +export * from "./platform-adapter"; +export * from "./terminal-adapter"; +export * from "./profiler"; +export * from "./render-loop"; diff --git a/packages/btuin/src/runtime/platform-adapter.ts b/packages/btuin/src/runtime/platform-adapter.ts index bf2a43d..c527a25 100644 --- a/packages/btuin/src/runtime/platform-adapter.ts +++ b/packages/btuin/src/runtime/platform-adapter.ts @@ -25,4 +25,3 @@ export function createDefaultPlatformAdapter(): PlatformAdapter { }, }; } - diff --git a/packages/btuin/src/runtime/profiler.ts b/packages/btuin/src/runtime/profiler.ts new file mode 100644 index 0000000..ad3e673 --- /dev/null +++ b/packages/btuin/src/runtime/profiler.ts @@ -0,0 +1,363 @@ +import type { Buffer2D } from "@btuin/renderer"; +import path from "node:path"; +import { mkdirSync, writeFileSync } from "node:fs"; + +export interface ProfileOptions { + /** + * Enable profiling. + * @default false + */ + enabled?: boolean; + + /** + * Draw a small HUD overlay into the terminal output. + * HUD uses the previous frame's metrics (so it doesn't perturb timings too much). + * @default false + */ + hud?: boolean; + + /** + * Output file path to write JSON results on unmount. + * When omitted, results are kept in memory only. + */ + outputFile?: string; + + /** + * Stop collecting after N frames (still flushes on unmount). + * When exceeded, newer frames are ignored. + */ + maxFrames?: number; + + /** + * Collect a per-frame node count (walks the view tree each frame). + * Useful for debugging but can be expensive for very large trees. + * @default false + */ + nodeCount?: boolean; +} + +export interface FrameMetrics { + id: number; + time: number; // epoch ms + rows: number; + cols: number; + nodeCount?: number; + outputBytes?: number; + diffCellsChanged?: number; + diffOps?: number; + diffCursorMoves?: number; + diffStyleChanges?: number; + diffResets?: number; + diffFullRedraw?: boolean; + layoutMs: number; + renderMs: number; + diffMs: number; + writeMs: number; + frameMs: number; + memory?: { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + }; +} + +export interface ProfileOutput { + version: 1; + startedAt: string; + endedAt: string; + frames: FrameMetrics[]; + summary: { + frameCount: number; + frameMs: { p50: number; p95: number; p99: number; max: number }; + totals: { + layoutMs: number; + renderMs: number; + diffMs: number; + writeMs: number; + frameMs: number; + }; + }; +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((sorted.length - 1) * p))); + return sorted[idx] ?? 0; +} + +function tryMemoryUsage(): + | { rss: number; heapTotal: number; heapUsed: number; external: number } + | undefined { + try { + const m = process.memoryUsage(); + return { rss: m.rss, heapTotal: m.heapTotal, heapUsed: m.heapUsed, external: m.external }; + } catch { + return undefined; + } +} + +function tryByteLength(output: string): number { + try { + // Node/Bun compatible. + return Buffer.byteLength(output, "utf8"); + } catch { + return output.length; + } +} + +export class Profiler { + readonly options: Required> & + Omit & { enabled: boolean; nodeCount: boolean }; + + private startedAt = new Date(); + private frames: FrameMetrics[] = []; + private frameSeq = 0; + private lastFrame: FrameMetrics | null = null; + + constructor(options: ProfileOptions) { + this.options = { + enabled: options.enabled ?? false, + hud: options.hud ?? false, + outputFile: options.outputFile, + maxFrames: options.maxFrames, + nodeCount: options.nodeCount ?? false, + }; + } + + isEnabled(): boolean { + return this.options.enabled; + } + + getFrames(): readonly FrameMetrics[] { + return this.frames; + } + + getLastFrame(): FrameMetrics | null { + return this.lastFrame; + } + + beginFrame(size: { rows: number; cols: number }, extra?: { nodeCount?: number }) { + if (!this.options.enabled) return null; + if (this.options.maxFrames !== undefined && this.frames.length >= this.options.maxFrames) + return null; + + const id = ++this.frameSeq; + return { + id, + time: Date.now(), + rows: size.rows, + cols: size.cols, + nodeCount: extra?.nodeCount, + t0: performance.now(), + layoutMs: 0, + renderMs: 0, + diffMs: 0, + writeMs: 0, + }; + } + + endFrame( + frame: null | { + id: number; + time: number; + rows: number; + cols: number; + nodeCount?: number; + t0: number; + layoutMs: number; + renderMs: number; + diffMs: number; + writeMs: number; + outputBytes?: number; + diffStats?: { + changedCells: number; + ops: number; + cursorMoves: number; + fgChanges: number; + bgChanges: number; + resets: number; + fullRedraw: boolean; + }; + }, + ) { + if (!frame) return; + + const frameMs = performance.now() - frame.t0; + const metrics: FrameMetrics = { + id: frame.id, + time: frame.time, + rows: frame.rows, + cols: frame.cols, + nodeCount: frame.nodeCount, + outputBytes: frame.outputBytes, + diffCellsChanged: frame.diffStats?.changedCells, + diffOps: frame.diffStats?.ops, + diffCursorMoves: frame.diffStats?.cursorMoves, + diffStyleChanges: frame.diffStats + ? frame.diffStats.fgChanges + frame.diffStats.bgChanges + : undefined, + diffResets: frame.diffStats?.resets, + diffFullRedraw: frame.diffStats?.fullRedraw, + layoutMs: frame.layoutMs, + renderMs: frame.renderMs, + diffMs: frame.diffMs, + writeMs: frame.writeMs, + frameMs, + memory: tryMemoryUsage(), + }; + this.frames.push(metrics); + this.lastFrame = metrics; + } + + measure(frame: any, key: "layoutMs" | "renderMs" | "diffMs" | "writeMs", fn: () => T): T { + if (!frame) return fn(); + const t0 = performance.now(); + try { + return fn(); + } finally { + frame[key] += performance.now() - t0; + } + } + + recordOutput(frame: any, output: string) { + if (!frame) return; + frame.outputBytes = (frame.outputBytes ?? 0) + tryByteLength(output); + } + + recordDiffStats( + frame: any, + stats: { + changedCells: number; + ops: number; + cursorMoves: number; + fgChanges: number; + bgChanges: number; + resets: number; + fullRedraw: boolean; + }, + ) { + if (!frame) return; + frame.diffStats = stats; + } + + drawHud(buf: Buffer2D) { + if (!this.options.enabled || !this.options.hud) return; + const last = this.lastFrame; + if (!last) return; + + const lines = [ + `frame ${last.frameMs.toFixed(2)}ms (L${last.layoutMs.toFixed(2)} R${last.renderMs.toFixed(2)} D${last.diffMs.toFixed( + 2, + )} W${last.writeMs.toFixed(2)})`, + `nodes ${last.nodeCount ?? "-"} bytes ${last.outputBytes ?? 0} diff ${last.diffCellsChanged ?? 0} ops ${last.diffOps ?? 0}`, + last.memory + ? `heap ${Math.round(last.memory.heapUsed / 1024 / 1024)}MB rss ${Math.round(last.memory.rss / 1024 / 1024)}MB` + : "", + ].filter(Boolean); + + const width = Math.min(buf.cols, Math.max(...lines.map((l) => l.length)) + 2); + const height = Math.min(buf.rows, lines.length + 2); + const x0 = Math.max(0, buf.cols - width); + const y0 = 0; + + // simple box + const fg = "white"; + const bg = "black"; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + buf.set(y0 + y, x0 + x, " ", { fg, bg }); + } + } + + const top = "─"; + const side = "│"; + const tl = "┌"; + const tr = "┐"; + const bl = "└"; + const br = "┘"; + buf.set(y0, x0, tl, { fg, bg }); + buf.set(y0, x0 + width - 1, tr, { fg, bg }); + buf.set(y0 + height - 1, x0, bl, { fg, bg }); + buf.set(y0 + height - 1, x0 + width - 1, br, { fg, bg }); + for (let x = 1; x < width - 1; x++) { + buf.set(y0, x0 + x, top, { fg, bg }); + buf.set(y0 + height - 1, x0 + x, top, { fg, bg }); + } + for (let y = 1; y < height - 1; y++) { + buf.set(y0 + y, x0, side, { fg, bg }); + buf.set(y0 + y, x0 + width - 1, side, { fg, bg }); + } + + // text + for (let i = 0; i < lines.length && i < height - 2; i++) { + const line = lines[i]!; + for (let j = 0; j < Math.min(line.length, width - 2); j++) { + buf.set(y0 + 1 + i, x0 + 1 + j, line[j]!, { fg, bg }); + } + } + } + + buildOutput(): ProfileOutput { + const frameTimes = this.frames.map((f) => f.frameMs).sort((a, b) => a - b); + const totals = this.frames.reduce( + (acc, f) => { + acc.layoutMs += f.layoutMs; + acc.renderMs += f.renderMs; + acc.diffMs += f.diffMs; + acc.writeMs += f.writeMs; + acc.frameMs += f.frameMs; + return acc; + }, + { layoutMs: 0, renderMs: 0, diffMs: 0, writeMs: 0, frameMs: 0 }, + ); + + return { + version: 1, + startedAt: this.startedAt.toISOString(), + endedAt: new Date().toISOString(), + frames: [...this.frames], + summary: { + frameCount: this.frames.length, + frameMs: { + p50: percentile(frameTimes, 0.5), + p95: percentile(frameTimes, 0.95), + p99: percentile(frameTimes, 0.99), + max: frameTimes.at(-1) ?? 0, + }, + totals, + }, + }; + } + + async flush(): Promise { + if (!this.options.enabled) return; + if (!this.options.outputFile) return; + + const output = JSON.stringify(this.buildOutput(), null, 2); + const dir = path.dirname(this.options.outputFile); + if (dir && dir !== ".") { + try { + mkdirSync(dir, { recursive: true }); + } catch {} + } + await Bun.write(this.options.outputFile, output); + } + + flushSync(): void { + if (!this.options.enabled) return; + if (!this.options.outputFile) return; + const output = JSON.stringify(this.buildOutput(), null, 2); + const dir = path.dirname(this.options.outputFile); + if (dir && dir !== ".") { + try { + mkdirSync(dir, { recursive: true }); + } catch {} + } + try { + writeFileSync(this.options.outputFile, output); + } catch (error) { + console.error("Failed to flush profiler results synchronously:", error); + } + } +} diff --git a/packages/btuin/src/runtime/render-loop.ts b/packages/btuin/src/runtime/render-loop.ts index e9ad154..59a8786 100644 --- a/packages/btuin/src/runtime/render-loop.ts +++ b/packages/btuin/src/runtime/render-loop.ts @@ -4,10 +4,19 @@ * Handles the rendering loop, including buffer management and diff rendering. */ -import { getGlobalBufferPool, renderDiff, type Buffer2D } from "@btuin/renderer"; +import { + FlatBuffer, + getGlobalBufferPool, + renderDiff, + type Buffer2D, + type DiffStats, +} from "@btuin/renderer"; import { layout, renderElement } from "../layout"; import type { ViewElement } from "../view/types/elements"; +import { isBlock } from "../view/types/elements"; import { createErrorContext } from "./error-boundary"; +import type { Profiler } from "./profiler"; +import type { ComputedLayout } from "@btuin/layout-engine"; /** * Terminal size configuration @@ -31,6 +40,8 @@ export interface RenderLoopConfig { getState: () => State; /** Error handler */ handleError: (context: import("./error-boundary").ErrorContext) => void; + /** Optional profiler */ + profiler?: Profiler; } /** @@ -59,6 +70,10 @@ export function createRenderer(config: RenderLoopConfig) { prevBuffer: pool.acquire(), }; + let prevRootElement: ViewElement | null = null; + let prevLayoutResult: ComputedLayout | null = null; + let prevLayoutSizeKey: string | null = null; + /** * Performs a render cycle * @@ -81,22 +96,94 @@ export function createRenderer(config: RenderLoopConfig) { } const rootElement = config.view(config.getState()); + const layoutSizeKey = `${state.currentSize.cols}x${state.currentSize.rows}`; + + const nodeCount = + config.profiler?.isEnabled() && config.profiler.options.nodeCount + ? countElements(rootElement) + : undefined; + const frame = config.profiler?.beginFrame(state.currentSize, { nodeCount }) ?? null; + + const layoutResult = + rootElement === prevRootElement && + prevLayoutResult && + prevLayoutSizeKey === layoutSizeKey && + !sizeChanged + ? prevLayoutResult + : (config.profiler?.measure(frame, "layoutMs", () => + layout(rootElement, { + width: state.currentSize.cols, + height: state.currentSize.rows, + }), + ) ?? + layout(rootElement, { + width: state.currentSize.cols, + height: state.currentSize.rows, + })); + + prevRootElement = rootElement; + prevLayoutResult = layoutResult; + prevLayoutSizeKey = layoutSizeKey; + + let buf = pool.acquire(); + if (buf === state.prevBuffer) { + // Ensure prev/next buffers differ; diffing the same instance yields no output. + buf = pool.acquire(); + if (buf === state.prevBuffer) { + buf = new FlatBuffer(state.currentSize.rows, state.currentSize.cols); + } + } + if (config.profiler && frame) { + config.profiler.measure(frame, "renderMs", () => { + renderElement(rootElement, buf, layoutResult, 0, 0); + }); + } else { + renderElement(rootElement, buf, layoutResult, 0, 0); + } - const layoutResult = layout(rootElement, { - width: state.currentSize.cols, - height: state.currentSize.rows, - }); - - const buf = pool.acquire(); - renderElement(rootElement, buf, layoutResult, 0, 0); - const output = renderDiff(state.prevBuffer, buf); - if (output) { - config.write(output); + config.profiler?.drawHud(buf); + + const diffStats: DiffStats | undefined = frame + ? { + sizeChanged: false, + fullRedraw: false, + changedCells: 0, + cursorMoves: 0, + fgChanges: 0, + bgChanges: 0, + resets: 0, + ops: 0, + } + : undefined; + + const prevForDiff = forceFullRedraw + ? new FlatBuffer(state.currentSize.rows, state.currentSize.cols) + : state.prevBuffer; + + const output = + config.profiler?.measure(frame, "diffMs", () => renderDiff(prevForDiff, buf, diffStats)) ?? + renderDiff(prevForDiff, buf); + const safeOutput = + output === "" + ? renderDiff(new FlatBuffer(state.currentSize.rows, state.currentSize.cols), buf) + : output; + if (frame && diffStats) { + config.profiler?.recordDiffStats(frame, diffStats); + } + if (safeOutput) { + config.profiler?.recordOutput(frame, safeOutput); + if (config.profiler && frame) { + config.profiler.measure(frame, "writeMs", () => config.write(safeOutput)); + } else { + config.write(safeOutput); + } } // Return old prev buffer to the pool and keep the new one pool.release(state.prevBuffer); state.prevBuffer = buf; + + config.profiler?.endFrame(frame); } catch (error) { config.handleError(createErrorContext("render", error)); } @@ -114,3 +201,11 @@ export function createRenderer(config: RenderLoopConfig) { getState, }; } + +function countElements(root: ViewElement): number { + let count = 1; + if (isBlock(root)) { + for (const child of root.children) count += countElements(child); + } + return count; +} diff --git a/packages/btuin/src/runtime/terminal-adapter.ts b/packages/btuin/src/runtime/terminal-adapter.ts index 6926792..6d53584 100644 --- a/packages/btuin/src/runtime/terminal-adapter.ts +++ b/packages/btuin/src/runtime/terminal-adapter.ts @@ -26,4 +26,3 @@ export function createDefaultTerminalAdapter(): TerminalAdapter { write: terminal.write, }; } - diff --git a/packages/btuin/src/view/base.ts b/packages/btuin/src/view/base.ts index 7cc00fc..e4eda26 100644 --- a/packages/btuin/src/view/base.ts +++ b/packages/btuin/src/view/base.ts @@ -1,3 +1,4 @@ +import type { KeyEventHook } from "./components/lifecycle"; import type { KeyEvent } from "@btuin/terminal"; import type { OutlineOptions } from "@btuin/renderer"; import type { Dimension, LayoutStyle } from "@btuin/layout-engine"; @@ -24,6 +25,7 @@ export abstract class BaseView implements ViewProps { public style: NonNullable = {}; public key?: string; public focusKey?: string; + public keyHooks: KeyEventHook[] = []; constructor(props: ViewProps = {}) { this.style = { ...props.style }; @@ -93,6 +95,11 @@ export abstract class BaseView implements ViewProps { return this; } + onKey(hook: KeyEventHook): this { + this.keyHooks.push(hook); + return this; + } + build(): this { return this; } diff --git a/packages/btuin/src/view/components/component.ts b/packages/btuin/src/view/components/component.ts index e392d96..2243fbe 100644 --- a/packages/btuin/src/view/components/component.ts +++ b/packages/btuin/src/view/components/component.ts @@ -5,6 +5,7 @@ * props, emits, and render function. */ import type { ViewElement } from "../types/elements"; +import { isBlock } from "../types/elements"; import type { KeyEvent } from "@btuin/terminal"; import { createComponentInstance, @@ -216,6 +217,25 @@ export function renderComponent(mounted: MountedComponent): ViewElement { return element; } +function traverseKeyHandlers( + element: ViewElement, + visitor: (element: ViewElement) => boolean, +): boolean { + if (isBlock(element)) { + for (let i = element.children.length - 1; i >= 0; i--) { + const child = element.children[i]!; + if (traverseKeyHandlers(child, visitor)) { + return true; + } + } + } + + if (element.keyHooks.length > 0 && visitor(element)) { + return true; + } + + return false; +} /** * Handles key events for a component. * Returns true if the event was handled and should stop propagation. @@ -226,6 +246,15 @@ export function renderComponent(mounted: MountedComponent): ViewElement { * @returns True if event was handled */ export function handleComponentKey(mounted: MountedComponent, event: KeyEvent): boolean { + if (mounted.lastElement) { + const handled = traverseKeyHandlers(mounted.lastElement, (element) => + invokeKeyHooks(element.keyHooks, event), + ); + if (handled) { + return true; + } + } + return invokeKeyHooks(mounted.instance.keyHooks, event); } diff --git a/packages/btuin/src/view/layout.ts b/packages/btuin/src/view/layout.ts index 90802bf..1c7bc9f 100644 --- a/packages/btuin/src/view/layout.ts +++ b/packages/btuin/src/view/layout.ts @@ -20,3 +20,7 @@ export function ZStack(children: ViewElement[] = []): BlockElement { el.style.stack = "z"; return el; } + +export function LayoutBoundary(children: ViewElement[] = []): BlockElement { + return Block(...children).boundary(); +} diff --git a/packages/btuin/src/view/primitives/block.ts b/packages/btuin/src/view/primitives/block.ts index 02d7381..985f079 100644 --- a/packages/btuin/src/view/primitives/block.ts +++ b/packages/btuin/src/view/primitives/block.ts @@ -32,6 +32,11 @@ export class BlockElement extends BaseView implements BlockView { this.style.alignItems = value; return this; } + + boundary(): this { + this.style.layoutBoundary = true; + return this; + } } // ファクトリ関数 diff --git a/packages/btuin/src/view/primitives/spacer.ts b/packages/btuin/src/view/primitives/spacer.ts index 2cecd82..9e3edd0 100644 --- a/packages/btuin/src/view/primitives/spacer.ts +++ b/packages/btuin/src/view/primitives/spacer.ts @@ -11,4 +11,3 @@ export function Spacer(grow = 1): BlockElement { el.style.flexBasis = 0; return el; } - diff --git a/packages/btuin/tests/layout/index.test.ts b/packages/btuin/tests/layout/index.test.ts index 1cc0683..2b86edf 100644 --- a/packages/btuin/tests/layout/index.test.ts +++ b/packages/btuin/tests/layout/index.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "bun:test"; import { createLayout } from "../../src/layout"; import { Block, Text } from "../../src/view/primitives"; +import { LayoutBoundary } from "../../src/view/layout"; import type { LayoutInputNode, ComputedLayout } from "@btuin/layout-engine"; // Mock the layout engine @@ -95,4 +96,24 @@ describe("layout", () => { expect(childNode?.width).toBe(40); expect(childNode?.height).toBe(10); }); + + it("should trim children that exceed the layout boundary", () => { + receivedLayoutNode = null; + const { layout } = createLayout({ + initLayoutEngine: async () => {}, + computeLayout: (node: LayoutInputNode): ComputedLayout => { + receivedLayoutNode = node; + expect(receivedLayoutNode?.children?.length).toBe(2); + return mockComputedLayout; + }, + }); + + const root = LayoutBoundary([ + Text({ value: "one" }), + Text({ value: "two" }), + Text({ value: "three" }), + ]).height(2); + + layout(root, { width: 10, height: 2 }); + }); }); diff --git a/packages/btuin/tests/layout/renderer.test.ts b/packages/btuin/tests/layout/renderer.test.ts index 5e31f69..2c7d648 100644 --- a/packages/btuin/tests/layout/renderer.test.ts +++ b/packages/btuin/tests/layout/renderer.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, beforeAll } from "bun:test"; import { renderElement } from "../../src/layout/renderer"; import { layout, initLayoutEngine } from "../../src/layout"; import { Block, Text } from "../../src/view/primitives"; -import { createBuffer, fillRect, drawText } from "@btuin/renderer"; +import { createBuffer } from "@btuin/renderer"; import type { Buffer2D } from "@btuin/renderer"; import { resolveColor } from "@btuin/renderer"; @@ -11,7 +11,8 @@ function bufferToString(buf: Buffer2D): string { let out = ""; for (let r = 0; r < buf.rows; r++) { for (let c = 0; c < buf.cols; c++) { - out += String.fromCodePoint(buf.get(r, c).char.codePointAt(0)!); + const char = buf.get(r, c).char || " "; + out += char; } out += "\n"; } @@ -41,7 +42,7 @@ describe("renderElement", () => { renderElement(root, buffer, layoutMap); // Check if the background was applied - for (let i = 0; i < buffer.cells.length; i++) { + for (let i = 0; i < buffer.codes.length; i++) { expect(buffer.bg[i]).toBe(resolveColor("blue", "bg")); } }); diff --git a/packages/btuin/tests/layout/zstack.test.ts b/packages/btuin/tests/layout/zstack.test.ts index 22d953f..b45a42f 100644 --- a/packages/btuin/tests/layout/zstack.test.ts +++ b/packages/btuin/tests/layout/zstack.test.ts @@ -20,7 +20,11 @@ describe("ZStack", () => { }); test("should overlay children at the same origin", () => { - const root = ZStack([Text("Hello"), Text("X")]).setKey("root").width(5).height(1).build(); + const root = ZStack([Text("Hello"), Text("X")]) + .setKey("root") + .width(5) + .height(1) + .build(); const layoutMap = layout(root, { width: 5, height: 1 }); const buffer = createBuffer(1, 5); diff --git a/packages/btuin/tests/runtime/render-loop.test.ts b/packages/btuin/tests/runtime/render-loop.test.ts index 9331b70..afae37d 100644 --- a/packages/btuin/tests/runtime/render-loop.test.ts +++ b/packages/btuin/tests/runtime/render-loop.test.ts @@ -10,19 +10,26 @@ mock.module("../../../src/layout", () => ({ renderElement: () => {}, })); -const mockBuffer: Buffer2D = new FlatBuffer(24, 80); +const mockBufferA: Buffer2D = new FlatBuffer(24, 80); +const mockBufferB: Buffer2D = new FlatBuffer(24, 80); +let acquireCount = 0; const mockPool = { - acquire: () => mockBuffer, + acquire: () => { + acquireCount++; + return acquireCount % 2 === 1 ? mockBufferA : mockBufferB; + }, release: () => {}, }; mock.module("@btuin/renderer", () => ({ + FlatBuffer, getGlobalBufferPool: () => mockPool, - renderDiff: () => "", + renderDiff: () => "x", })); describe("createRenderer", () => { it("should create a renderer and perform a render cycle", () => { + acquireCount = 0; let size = { rows: 24, cols: 80 }; const renderer = createRenderer({ getSize: () => size, @@ -39,7 +46,7 @@ describe("createRenderer", () => { // Render once renderer.render(); state = renderer.getState(); - expect(state.prevBuffer).toBe(mockBuffer); + expect(state.prevBuffer === mockBufferA || state.prevBuffer === mockBufferB).toBe(true); // Change size and re-render size = { rows: 30, cols: 100 }; diff --git a/packages/btuin/tests/view/components/component.test.ts b/packages/btuin/tests/view/components/component.test.ts index 8994530..55faffc 100644 --- a/packages/btuin/tests/view/components/component.test.ts +++ b/packages/btuin/tests/view/components/component.test.ts @@ -10,7 +10,7 @@ import { import type { Component, RenderFunction } from "../../../src/view/components/component"; import { onKey } from "../../../src/view/components/lifecycle"; import { ref } from "@btuin/reactivity"; -import { Text } from "../../../src/view/primitives"; +import { Block, Text } from "../../../src/view/primitives"; describe("defineComponent", () => { it("should define a component", () => { @@ -82,6 +82,44 @@ describe("handleComponentKey", () => { expect(keyPressed).toBe("a"); }); + + it("should traverse view hierarchy and honor stopPropagation", () => { + const order: string[] = []; + + const component = defineComponent({ + setup() { + onKey(() => { + order.push("component"); + }); + + const child = Block().onKey(() => { + order.push("child"); + return true; + }); + + const parent = Block(child).onKey(() => { + order.push("parent"); + return true; + }); + + return () => parent; + }, + }); + + const mounted = mountComponent(component); + renderComponent(mounted); + + const handled = handleComponentKey(mounted, { + name: "a", + sequence: "a", + ctrl: false, + meta: false, + shift: false, + }); + + expect(handled).toBe(true); + expect(order).toEqual(["child"]); + }); }); describe("normalizeProps", () => { diff --git a/packages/btuin/tests/view/primitives/spacer.test.ts b/packages/btuin/tests/view/primitives/spacer.test.ts index cfe00c8..df492d7 100644 --- a/packages/btuin/tests/view/primitives/spacer.test.ts +++ b/packages/btuin/tests/view/primitives/spacer.test.ts @@ -11,4 +11,3 @@ describe("Spacer Primitive", () => { expect(el.style.flexBasis).toBe(0); }); }); - diff --git a/packages/layout-engine/Cargo.toml b/packages/layout-engine/Cargo.toml index 34a0334..b763d2c 100644 --- a/packages/layout-engine/Cargo.toml +++ b/packages/layout-engine/Cargo.toml @@ -11,3 +11,6 @@ serde = { version = "1.0.228", features = ["derive"] } serde-wasm-bindgen = "0.6.5" taffy = "0.9.2" wasm-bindgen = "0.2.106" + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false diff --git a/packages/layout-engine/src/index.ts b/packages/layout-engine/src/index.ts index 049aa7e..e46766d 100644 --- a/packages/layout-engine/src/index.ts +++ b/packages/layout-engine/src/index.ts @@ -6,7 +6,10 @@ let wasmInitialized = false; type LayoutEngineWasmModule = { default: (module_or_path?: unknown) => Promise; - compute_layout: (nodes_js: unknown) => unknown; + init_layout_engine: () => void; + update_nodes: (nodes: unknown) => void; + remove_nodes: (keys: unknown) => void; + compute_layout: (root_key: string) => unknown; }; let wasmModule: LayoutEngineWasmModule | null = null; @@ -38,11 +41,16 @@ async function loadWasmModule(): Promise { return wasmImportPromise; } +const layoutState = createLayoutState(); + export async function initLayoutEngine() { - if (wasmInitialized) return; const mod = await loadWasmModule(); - await mod.default(); - wasmInitialized = true; + if (!wasmInitialized) { + await mod.default(); + wasmInitialized = true; + } + mod.init_layout_engine(); + layoutState.reset(); } // ---------------------------------------------------------------------------- @@ -61,6 +69,7 @@ export interface LayoutStyle { minHeight?: Dimension; maxWidth?: Dimension; maxHeight?: Dimension; + layoutBoundary?: boolean; padding?: number | [number, number, number, number]; margin?: number | [number, number, number, number]; @@ -91,12 +100,6 @@ export interface LayoutInputNode extends LayoutElementShape, LayoutStyle { children?: LayoutInputNode[]; } -interface BridgeNode { - style: BridgeStyle; - children: number[]; - measure?: { width: number; height: number }; -} - interface BridgeStyle { display?: string; position?: string; @@ -124,64 +127,132 @@ interface BridgeStyle { // ---------------------------------------------------------------------------- export function computeLayout(root: LayoutInputNode): ComputedLayout { - if (!wasmInitialized) { + if (!wasmInitialized || !wasmModule) { throw new Error("Layout engine not initialized. Call initLayoutEngine() first."); } - if (!wasmModule) { - throw new Error("Layout engine module not loaded. Call initLayoutEngine() first."); + try { + return layoutState.compute(root); + } catch (cause) { + if (cause instanceof Error && /not initialized/i.test(cause.message)) { + throw cause; + } + throw new Error("failed to compute layout", { cause }); } +} - const nodes: BridgeNode[] = []; - const elementMap = new Map(); +interface BridgeNodePayload { + key: string; + style: BridgeStyle; + children: string[]; + measure?: { width: number; height: number }; +} - flattenTree(root, nodes, elementMap); +function createLayoutState() { + let signatures = new Map(); - let rawResults: any[]; - try { - rawResults = wasmModule.compute_layout(nodes) as any[]; - } catch (cause) { - throw new Error("Layout computation failed.", { cause }); + function reset() { + signatures = new Map(); } - const computed: ComputedLayout = {}; - rawResults.forEach((res: any, index: number) => { - const key = elementMap.get(index); - if (key) { - computed[key] = { - x: res.x, - y: res.y, - width: res.width, - height: res.height, - }; + function compute(root: LayoutInputNode): ComputedLayout { + const module = getReadyModule(); + const nodes: BridgeNodePayload[] = []; + flattenBridgeNodes(root, nodes); + + if (nodes.length === 0 || !nodes[0]) { + throw new Error("Layout tree must contain at least one node."); } - }); - return computed; -} + const newSignatures = new Map(); + const currentKeys = new Set(); + const changedNodes: BridgeNodePayload[] = []; + + for (const node of nodes) { + const signature = createSignature(node); + newSignatures.set(node.key, signature); + currentKeys.add(node.key); + if (signatures.get(node.key) !== signature) { + changedNodes.push(node); + } + } -function flattenTree( - node: LayoutInputNode, - nodes: BridgeNode[], - elementMap: Map, -): number { - const index = nodes.length; + const removedKeys = [...signatures.keys()].filter((key) => !currentKeys.has(key)); + signatures = newSignatures; - const key = node.key ?? `node-${index}`; - elementMap.set(index, key); + if (removedKeys.length > 0) { + module.remove_nodes(removedKeys); + } - const bridgeNode: BridgeNode = { + if (changedNodes.length > 0) { + module.update_nodes(changedNodes); + } + + const rootKey = nodes[0].key; + const result = module.compute_layout(rootKey); + return normalizeComputedLayout(result); + } + + return { + reset, + compute, + }; +} + +function normalizeComputedLayout(value: unknown): ComputedLayout { + if (!value) return {}; + const maybeMap = value as { entries?: unknown; get?: unknown }; + const isMapLike = + value instanceof Map || + (typeof maybeMap === "object" && + maybeMap !== null && + typeof maybeMap.entries === "function" && + typeof maybeMap.get === "function"); + + if (isMapLike) { + const out: ComputedLayout = {}; + for (const [key, rect] of (value as Map).entries()) { + if (typeof key === "string") { + out[key] = rect as ComputedLayout[string]; + } + } + return out; + } + return value as ComputedLayout; +} + +function flattenBridgeNodes(node: LayoutInputNode, nodes: BridgeNodePayload[]): string { + const key = node.key ?? `node-${nodes.length}`; + const index = nodes.length; + nodes.push({ + key, style: extractStyle(node), children: [], measure: node.measuredSize, - }; - nodes.push(bridgeNode); + }); if (node.children && node.children.length > 0) { - const childIndices = node.children.map((child) => flattenTree(child, nodes, elementMap)); - nodes[index]!.children = childIndices; + const childKeys = node.children.map((child) => flattenBridgeNodes(child, nodes)); + nodes[index]!.children = childKeys; } - return index; + return key; +} + +function createSignature(node: BridgeNodePayload): string { + const styleJson = JSON.stringify(node.style); + const childrenKey = node.children.join(","); + const measureKey = node.measure ? `${node.measure.width}:${node.measure.height}` : ""; + return `${styleJson}|${childrenKey}|${measureKey}`; +} + +function getReadyModule(): LayoutEngineWasmModule { + if (!wasmInitialized) { + throw new Error("Layout engine not initialized. Call initLayoutEngine() first."); + } + if (!wasmModule) { + throw new Error("Layout engine module not loaded. Call initLayoutEngine() first."); + } + return wasmModule; } function extractStyle(node: LayoutInputNode): BridgeStyle { diff --git a/packages/layout-engine/src/lib.rs b/packages/layout-engine/src/lib.rs index 57f8c86..b879c88 100644 --- a/packages/layout-engine/src/lib.rs +++ b/packages/layout-engine/src/lib.rs @@ -1,12 +1,15 @@ use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet, hash_map::Entry}; use taffy::prelude::*; use wasm_bindgen::prelude::*; #[derive(Deserialize)] -struct JsNode { +struct JsNodeUpdate { + key: String, style: JsStyle, - children: Vec, - measure: Option, + #[serde(default)] + children: Vec, } #[derive(Deserialize)] @@ -218,58 +221,135 @@ impl From<&JsStyle> for Style { } } -#[wasm_bindgen] -pub fn compute_layout(nodes_js: JsValue) -> Result { - let nodes: Vec = serde_wasm_bindgen::from_value(nodes_js)?; +struct LayoutEngineState { + taffy: TaffyTree>, + nodes: HashMap, +} - let mut taffy: TaffyTree> = TaffyTree::new(); - let mut node_ids = Vec::with_capacity(nodes.len()); +struct NodeInfo { + id: NodeId, +} - for node in &nodes { - let style: Style = (&node.style).into(); +impl LayoutEngineState { + fn new() -> Self { + Self { + taffy: TaffyTree::new(), + nodes: HashMap::new(), + } + } - let id = if let Some(size) = &node.measure { - // コンテキスト(固定サイズ)付きの葉ノードを作成 - let context = Size { - width: size.width, - height: size.height, - }; - taffy - .new_leaf_with_context(style, context) - .map_err(|e| e.to_string())? - } else { - taffy.new_leaf(style).map_err(|e| e.to_string())? - }; + fn update_nodes(&mut self, nodes: Vec) -> Result<(), String> { + for node in &nodes { + let style: Style = (&node.style).into(); + match self.nodes.entry(node.key.clone()) { + Entry::Occupied(entry) => { + self.taffy + .set_style(entry.get().id, style) + .map_err(|e| e.to_string())?; + } + Entry::Vacant(entry) => { + let id = self.taffy.new_leaf(style).map_err(|e| e.to_string())?; + entry.insert(NodeInfo { id }); + } + } + } + + for node in &nodes { + if let Some(info) = self.nodes.get(&node.key) { + let child_ids: Vec = node + .children + .iter() + .filter_map(|child_key| { + self.nodes.get(child_key).map(|child_info| child_info.id) + }) + .collect(); + self.taffy + .set_children(info.id, &child_ids) + .map_err(|e| e.to_string())?; + } + } - node_ids.push(id); + Ok(()) } - for (i, node) in nodes.iter().enumerate() { - if !node.children.is_empty() { - let parent = node_ids[i]; - let children: Vec = node.children.iter().map(|&idx| node_ids[idx]).collect(); - taffy - .set_children(parent, &children) - .map_err(|e| e.to_string())?; + fn remove_nodes(&mut self, keys: Vec) -> Result<(), String> { + let key_set: HashSet = keys.into_iter().collect(); + for key in &key_set { + if let Some(info) = self.nodes.remove(key) { + self.taffy.remove(info.id).map_err(|e| e.to_string())?; + } } + Ok(()) } - if let Some(&root_id) = node_ids.first() { - taffy - .compute_layout(root_id, Size::MAX_CONTENT) + fn compute_layout(&mut self, root_key: &str) -> Result, String> { + let root = self + .nodes + .get(root_key) + .ok_or_else(|| format!("root node not found: {}", root_key))?; + + self.taffy + .compute_layout(root.id, Size::MAX_CONTENT) .map_err(|e| e.to_string())?; - } - let mut outputs = Vec::with_capacity(nodes.len()); - for &id in &node_ids { - let layout = taffy.layout(id).map_err(|e| e.to_string())?; - outputs.push(LayoutOutput { - x: layout.location.x, - y: layout.location.y, - width: layout.size.width, - height: layout.size.height, - }); + let mut outputs = HashMap::with_capacity(self.nodes.len()); + for (key, info) in &self.nodes { + let layout = self.taffy.layout(info.id).map_err(|e| e.to_string())?; + outputs.insert( + key.clone(), + LayoutOutput { + x: layout.location.x, + y: layout.location.y, + width: layout.size.width, + height: layout.size.height, + }, + ); + } + + Ok(outputs) } +} + +thread_local! { + static ENGINE_STATE: RefCell> = RefCell::new(None); +} + +fn with_state( + f: impl FnOnce(&mut LayoutEngineState) -> Result, +) -> Result { + ENGINE_STATE.with(|cell| { + let mut guard = cell.borrow_mut(); + let state = guard + .as_mut() + .ok_or_else(|| JsValue::from_str("Layout engine not initialized"))?; + f(state).map_err(|e| JsValue::from_str(&e)) + }) +} + +#[wasm_bindgen] +pub fn init_layout_engine() -> Result<(), JsValue> { + ENGINE_STATE.with(|cell| { + let mut guard = cell.borrow_mut(); + *guard = Some(LayoutEngineState::new()); + Ok(()) + }) +} + +#[wasm_bindgen] +pub fn update_nodes(nodes_js: JsValue) -> Result<(), JsValue> { + let nodes: Vec = serde_wasm_bindgen::from_value(nodes_js)?; + with_state(|state| state.update_nodes(nodes)) +} - Ok(serde_wasm_bindgen::to_value(&outputs)?) +#[wasm_bindgen] +pub fn remove_nodes(keys_js: JsValue) -> Result<(), JsValue> { + let keys: Vec = serde_wasm_bindgen::from_value(keys_js)?; + with_state(|state| state.remove_nodes(keys)) +} + +#[wasm_bindgen] +pub fn compute_layout(root_key: &str) -> Result { + with_state(|state| state.compute_layout(root_key)).and_then(|outputs| { + serde_wasm_bindgen::to_value(&outputs).map_err(|e| JsValue::from_str(&e.to_string())) + }) } diff --git a/packages/reactivity/tests/index.test.ts b/packages/reactivity/tests/index.test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/renderer/src/buffer.ts b/packages/renderer/src/buffer.ts index 52d406e..abb5113 100644 --- a/packages/renderer/src/buffer.ts +++ b/packages/renderer/src/buffer.ts @@ -1,11 +1,13 @@ +import { measureGraphemeWidth, segmentGraphemes } from "./grapheme"; + /** * Flat buffer for terminal rendering. * - * Internally stores characters in a flat Uint32Array and color attributes - * in parallel string arrays: - * - cells: UTF-32 code point for each cell (space by default) - * - fg: foreground color (ANSI escape sequence or theme color name) - * - bg: background color (ANSI escape sequence or theme color name) + * Each cell stores: + * - codes: the Unicode code point for single-code-point glyphs (0 on continuation slots) + * - extras: Map for grapheme clusters (multi-code-point glyphs) keyed by cell index + * - widths: the column width (0 indicates a continuation cell) + * - fg/bg: color styles matching current cells * * Index calculation: * index = row * cols + col @@ -13,15 +15,20 @@ export class FlatBuffer { readonly rows: number; readonly cols: number; - readonly cells: Uint32Array; + readonly codes: Uint32Array; + readonly extras: Map; + readonly widths: Uint8Array; readonly fg: (string | undefined)[]; readonly bg: (string | undefined)[]; + private asciiOnly = true; constructor(rows: number, cols: number) { this.rows = rows; this.cols = cols; const size = rows * cols; - this.cells = new Uint32Array(size); + this.codes = new Uint32Array(size); + this.extras = new Map(); + this.widths = new Uint8Array(size); this.fg = Array.from({ length: size }); this.bg = Array.from({ length: size }); this.clear(); @@ -31,9 +38,12 @@ export class FlatBuffer { * Reset all cells to space and clear color attributes. */ clear(): void { - this.cells.fill(32); // ASCII space + this.codes.fill(32); // space + this.extras.clear(); + this.widths.fill(1); this.fg.fill(undefined); this.bg.fill(undefined); + this.asciiOnly = true; } /** @@ -51,12 +61,37 @@ export class FlatBuffer { if (row < 0 || row >= this.rows) return { char: " ", style: {} }; if (col < 0 || col >= this.cols) return { char: " ", style: {} }; const idx = this.index(row, col); + const width = this.widths[idx] ?? 1; return { - char: String.fromCodePoint(this.cells[idx] ?? 32), + char: width === 0 ? "" : this.glyphStringAtIndex(idx), style: { fg: this.fg[idx], bg: this.bg[idx] }, }; } + glyphStringAtIndex(idx: number): string { + const width = this.widths[idx] ?? 1; + if (width === 0) return ""; + const extra = this.extras.get(idx); + if (extra !== undefined) return extra; + const code = this.codes[idx] ?? 32; + if (code === 0) return " "; + return String.fromCodePoint(code); + } + + glyphKeyAtIndex(idx: number): string | number { + const extra = this.extras.get(idx); + if (extra !== undefined) return extra; + return this.codes[idx] ?? 32; + } + + isAsciiOnly(): boolean { + return this.asciiOnly; + } + + copyAsciiStateFrom(other: FlatBuffer): void { + this.asciiOnly = other.asciiOnly; + } + /** * Set a cell's character and (optionally) styling at the given position. * Out-of-bounds writes are safely ignored. @@ -64,9 +99,113 @@ export class FlatBuffer { set(row: number, col: number, ch: string, style?: { fg?: string; bg?: string }): void { if (row < 0 || row >= this.rows) return; if (col < 0 || col >= this.cols) return; + + if (ch.length === 1) { + const code = ch.charCodeAt(0); + if (code <= 0x7f) { + this.writeGlyph(row, col, ch, 1, style); + return; + } + } + + const graphemes = segmentGraphemes(ch); + const glyph = graphemes[0] ?? ch; + const width = Math.max(measureGraphemeWidth(glyph), 1); + this.writeGlyph(row, col, glyph, width, style); + } + + setCodePoint( + row: number, + col: number, + codePoint: number, + style?: { fg?: string; bg?: string }, + ): void { + if (row < 0 || row >= this.rows) return; + if (col < 0 || col >= this.cols) return; + const ch = String.fromCodePoint(codePoint); + this.writeGlyph(row, col, ch, 1, style); + } + + private writeGlyph( + row: number, + col: number, + glyph: string, + width: number, + style?: { fg?: string; bg?: string }, + ) { + if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return; + if (col + width > this.cols) return; + const idx = this.index(row, col); - this.cells[idx] = ch.codePointAt(0) ?? 32; + if (this.widths[idx] === 0) { + this.clearWideSpan(row, col); + } + this.clearFollowingContinuations(row, col); + const normalized = glyph || " "; + const firstCode = normalized.codePointAt(0) ?? 0; + const glyphIsAscii = width === 1 && normalized.length === 1 && firstCode <= 0x7f; + this.asciiOnly = this.asciiOnly && glyphIsAscii; + if ([...normalized].length > 1) { + this.extras.set(idx, normalized); + const first = normalized.codePointAt(0) ?? 32; + this.codes[idx] = first; + } else { + this.extras.delete(idx); + this.codes[idx] = normalized.codePointAt(0) ?? 32; + } + this.widths[idx] = width; if (style?.fg !== undefined) this.fg[idx] = style.fg; if (style?.bg !== undefined) this.bg[idx] = style.bg; + + for (let offset = 1; offset < width; offset++) { + const contCol = col + offset; + if (contCol >= this.cols) break; + const contIdx = this.index(row, contCol); + this.extras.delete(contIdx); + this.codes[contIdx] = 0; + this.widths[contIdx] = 0; + if (style?.fg !== undefined) this.fg[contIdx] = style.fg; + if (style?.bg !== undefined) this.bg[contIdx] = style.bg; + } + } + + private clearWideSpan(row: number, col: number) { + let baseCol = col - 1; + let baseIdx = -1; + while (baseCol >= 0) { + const idx = this.index(row, baseCol); + if (this.widths[idx] === 0) { + baseCol--; + continue; + } + baseIdx = idx; + break; + } + if (baseIdx === -1) return; + const spanWidth = this.widths[baseIdx]; + if (spanWidth === undefined || spanWidth <= 1) return; + const rowStart = baseCol; + for (let offset = 0; offset < spanWidth; offset++) { + const targetIdx = this.index(row, rowStart + offset); + this.extras.delete(targetIdx); + this.codes[targetIdx] = 32; + this.widths[targetIdx] = 1; + this.fg[targetIdx] = undefined; + this.bg[targetIdx] = undefined; + } + } + + private clearFollowingContinuations(row: number, col: number) { + let nextCol = col + 1; + while (nextCol < this.cols) { + const nextIdx = this.index(row, nextCol); + if (this.widths[nextIdx] !== 0) break; + this.extras.delete(nextIdx); + this.codes[nextIdx] = 32; + this.widths[nextIdx] = 1; + this.fg[nextIdx] = undefined; + this.bg[nextIdx] = undefined; + nextCol++; + } } } diff --git a/packages/renderer/src/diff.ts b/packages/renderer/src/diff.ts index 4c336ce..5aa0792 100644 --- a/packages/renderer/src/diff.ts +++ b/packages/renderer/src/diff.ts @@ -1,5 +1,16 @@ import type { Buffer2D } from "./types"; +export interface DiffStats { + sizeChanged: boolean; + fullRedraw: boolean; + changedCells: number; + cursorMoves: number; + fgChanges: number; + bgChanges: number; + resets: number; + ops: number; +} + /** * Renders the difference between two buffers, only updating changed cells. * If buffer sizes differ (e.g., after terminal resize), forces a full redraw. @@ -13,14 +24,36 @@ import type { Buffer2D } from "./types"; * * @param prev - Previous buffer state * @param next - New buffer state to render + * @param stats - Optional stats collector */ -export function renderDiff(prev: Buffer2D, next: Buffer2D): string { +export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): string { const rows = next.rows; const cols = next.cols; if (rows === 0 || cols === 0) return ""; // Check if buffer sizes match const sizeChanged = prev.rows !== rows || prev.cols !== cols; + const fullRedraw = sizeChanged; + + if (stats) { + stats.sizeChanged = sizeChanged; + stats.fullRedraw = fullRedraw; + stats.changedCells = 0; + stats.cursorMoves = 0; + stats.fgChanges = 0; + stats.bgChanges = 0; + stats.resets = 0; + stats.ops = 0; + } + + const asciiFastPath = prev.isAsciiOnly() && next.isAsciiOnly(); + if (asciiFastPath) { + const asciiOutput = renderDiffAscii(prev, next, rows, cols, sizeChanged, stats); + if (stats) { + stats.ops = stats.cursorMoves + stats.fgChanges + stats.bgChanges + stats.resets; + } + return asciiOutput; + } let currentFg: string | undefined; let currentBg: string | undefined; @@ -37,16 +70,30 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D): string { const idx = r * cols + c; - const nextCode = next.cells[idx] || 32; - const prevCode = prev.cells[idx] || 32; + const nextWidth = next.widths[idx]; + if (nextWidth === 0) continue; + + const prevWidth = prev.widths[idx] ?? 0; + const nextGlyphKey = next.glyphKeyAtIndex(idx); + const prevGlyphKey = prev.glyphKeyAtIndex(idx); const nextFg = next.fg[idx]; const nextBg = next.bg[idx]; const prevFg = prev.fg[idx]; const prevBg = prev.bg[idx]; - // Force redraw all cells if size changed, otherwise check for differences - if (sizeChanged || nextCode !== prevCode || nextFg !== prevFg || nextBg !== prevBg) { + const needsDraw = + sizeChanged || + prevWidth !== nextWidth || + prevGlyphKey !== nextGlyphKey || + nextFg !== prevFg || + nextBg !== prevBg; + + if (needsDraw) { + if (stats) { + stats.changedCells++; + stats.cursorMoves++; + } // Move cursor: \x1b[row;colH out.push(`\x1b[${r + 1};${c + 1}H`); @@ -58,6 +105,7 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D): string { } currentFg = nextFg; styleDirty = true; + if (stats) stats.fgChanges++; } if (nextBg !== currentBg) { if (nextBg === undefined) { @@ -67,15 +115,100 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D): string { } currentBg = nextBg; styleDirty = true; + if (stats) stats.bgChanges++; } - out.push(String.fromCodePoint(nextCode)); + out.push(next.glyphStringAtIndex(idx)); } } } if (styleDirty) { out.push("\x1b[0m"); + if (stats) stats.resets++; + } + + if (stats) { + stats.ops = stats.cursorMoves + stats.fgChanges + stats.bgChanges + stats.resets; + } + + return out.length > 0 ? out.join("") : ""; +} + +function renderDiffAscii( + prev: Buffer2D, + next: Buffer2D, + rows: number, + cols: number, + sizeChanged: boolean, + stats?: DiffStats, +): string { + const out: string[] = []; + let currentFg: string | undefined; + let currentBg: string | undefined; + let styleDirty = false; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (r === rows - 1 && c === cols - 1) { + continue; + } + + const idx = r * cols + c; + const nextWidth = next.widths[idx]; + if (nextWidth === 0) continue; + + const prevWidth = prev.widths[idx] ?? 0; + const prevCode = prev.codes[idx] ?? 32; + const nextCode = next.codes[idx] ?? 32; + const nextFg = next.fg[idx]; + const nextBg = next.bg[idx]; + const prevFg = prev.fg[idx]; + const prevBg = prev.bg[idx]; + + const needsDraw = + sizeChanged || + prevWidth !== nextWidth || + prevCode !== nextCode || + nextFg !== prevFg || + nextBg !== prevBg; + + if (!needsDraw) continue; + + if (stats) { + stats.changedCells++; + stats.cursorMoves++; + } + out.push(`\x1b[${r + 1};${c + 1}H`); + + if (nextFg !== currentFg) { + if (nextFg === undefined) { + out.push("\x1b[39m"); + } else { + out.push(nextFg); + } + currentFg = nextFg; + styleDirty = true; + if (stats) stats.fgChanges++; + } + if (nextBg !== currentBg) { + if (nextBg === undefined) { + out.push("\x1b[49m"); + } else { + out.push(nextBg); + } + currentBg = nextBg; + styleDirty = true; + if (stats) stats.bgChanges++; + } + + out.push(String.fromCharCode(nextCode)); + } + } + + if (styleDirty) { + out.push("\x1b[0m"); + if (stats) stats.resets++; } return out.length > 0 ? out.join("") : ""; diff --git a/packages/renderer/src/grapheme.ts b/packages/renderer/src/grapheme.ts new file mode 100644 index 0000000..4d19dfc --- /dev/null +++ b/packages/renderer/src/grapheme.ts @@ -0,0 +1,102 @@ +const graphemeSegmenter = (() => { + try { + if (typeof Intl !== "undefined" && typeof Intl.Segmenter === "function") { + return new Intl.Segmenter(undefined, { granularity: "grapheme" }); + } + } catch { + /* fall back to manual segmentation */ + } + return null; +})(); + +const COMBINING_RANGES: [number, number][] = [ + [0x0300, 0x036f], + [0x1ab0, 0x1aff], + [0x1dc0, 0x1dff], + [0x20d0, 0x20ff], + [0xfe20, 0xfe2f], + [0x200c, 0x200d], +]; + +const WIDE_RANGES: [number, number][] = [ + [0x1100, 0x115f], + [0x2329, 0x232a], + [0x2e80, 0xa4cf], + [0xac00, 0xd7a3], + [0xf900, 0xfaff], + [0xfe10, 0xfe19], + [0xfe30, 0xfe6f], + [0xff00, 0xff60], + [0xffe0, 0xffe6], + [0x1f300, 0x1f64f], + [0x1f900, 0x1f9ff], + [0x20000, 0x2fffd], + [0x30000, 0x3fffd], +]; + +function inRange(code: number, ranges: [number, number][]): boolean { + for (const [start, end] of ranges) { + if (code >= start && code <= end) { + return true; + } + } + return false; +} + +function isCombining(code: number): boolean { + return inRange(code, COMBINING_RANGES); +} + +function isWide(code: number): boolean { + return inRange(code, WIDE_RANGES); +} + +function isControl(code: number): boolean { + return (code >= 0 && code < 32) || (code >= 0x7f && code < 0xa0); +} + +export function segmentGraphemes(text: string): string[] { + if (!text) return []; + + if (graphemeSegmenter) { + const segments: string[] = []; + for (const segment of graphemeSegmenter.segment(text)) { + if (segment.segment) { + segments.push(segment.segment); + } + } + return segments; + } + + const fallback: string[] = []; + let buffer = ""; + + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + if (buffer && isCombining(code)) { + buffer += char; + continue; + } + if (buffer) { + fallback.push(buffer); + } + buffer = char; + } + + if (buffer) { + fallback.push(buffer); + } + + return fallback; +} + +export function measureGraphemeWidth(cluster: string): number { + for (const char of cluster) { + const code = char.codePointAt(0); + if (code === undefined) continue; + if (isControl(code)) return 0; + if (isCombining(code)) continue; + return isWide(code) ? 2 : 1; + } + return cluster ? 1 : 0; +} diff --git a/packages/renderer/src/grid.ts b/packages/renderer/src/grid.ts index 376d841..a72fa87 100644 --- a/packages/renderer/src/grid.ts +++ b/packages/renderer/src/grid.ts @@ -2,6 +2,7 @@ import { resolveColor } from "./colors"; import { FlatBuffer } from "./buffer"; import { type Buffer2D } from "./types/buffer"; import type { ColorValue } from "./types/color"; +import { measureGraphemeWidth, segmentGraphemes } from "./grapheme"; /** * Internal helper to compute flat index into a Buffer2D (FlatBuffer). @@ -34,15 +35,21 @@ export function createBuffer(rows: number, cols: number): Buffer2D { * 可能であれば利用箇所側での使用を見直してください。 * * @param buf - The buffer to clone - * @returns A new Buffer2D with copied cells + * @returns A new Buffer2D with copied glyphs and styles */ export function cloneBuffer(buf: Buffer2D): Buffer2D { const copy = new FlatBuffer(buf.rows, buf.cols); - copy.cells.set(buf.cells); - for (let i = 0; i < buf.cells.length; i++) { + copy.codes.set(buf.codes); + copy.extras.clear(); + for (const [idx, glyph] of buf.extras.entries()) { + copy.extras.set(idx, glyph); + } + copy.widths.set(buf.widths); + for (let i = 0; i < copy.fg.length; i++) { copy.fg[i] = buf.fg[i]; copy.bg[i] = buf.bg[i]; } + copy.copyAsciiStateFrom(buf); return copy; } @@ -73,8 +80,7 @@ export function setCell( const idx = indexOf(buf, row, col); if (cell.ch !== undefined) { - const codePoint = cell.ch.codePointAt(0) ?? 32; - buf.cells[idx] = codePoint; + buf.set(row, col, cell.ch); } if (cell.fg !== undefined) { @@ -124,16 +130,55 @@ export function drawText( const fg = style?.fg !== undefined ? resolveColor(style.fg, "fg") : undefined; const bg = style?.bg !== undefined ? resolveColor(style.bg, "bg") : undefined; + const resolvedStyle = hasFg || hasBg ? { fg, bg } : undefined; + // ASCII fast path: + // - Avoid Intl.Segmenter + // - Avoid per-grapheme width calculation + // - Write directly as width=1 code points + let isAscii = true; for (let i = 0; i < text.length; i++) { - const c = col + i; - if (c < 0 || c >= buf.cols) continue; - - const idx = indexOf(buf, row, c); - const ch = text[i] ?? " "; - buf.cells[idx] = ch.codePointAt(0) ?? 32; - if (hasFg) buf.fg[idx] = fg; - if (hasBg) buf.bg[idx] = bg; + if (text.charCodeAt(i) > 0x7f) { + isAscii = false; + break; + } + } + if (isAscii) { + let currentCol = col; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + if (currentCol >= buf.cols) break; + if (currentCol + 1 <= 0) { + currentCol += 1; + continue; + } + if (currentCol < 0) { + currentCol += 1; + continue; + } + buf.setCodePoint(row, currentCol, code, resolvedStyle); + currentCol += 1; + } + return; + } + + const segments = segmentGraphemes(text); + let currentCol = col; + + for (const segment of segments) { + const width = Math.max(measureGraphemeWidth(segment), 1); + if (currentCol >= buf.cols) break; + if (currentCol + width <= 0) { + currentCol += width; + continue; + } + if (currentCol < 0) { + currentCol += width; + continue; + } + + buf.set(row, currentCol, segment, resolvedStyle); + currentCol += width; } } @@ -179,8 +224,15 @@ export function fillRect( const fg = style?.fg !== undefined ? resolveColor(style.fg, "fg") : undefined; const bg = style?.bg !== undefined ? resolveColor(style.bg, "bg") : undefined; - - const chCode = char.codePointAt(0) ?? 32; + const resolvedStyle = hasFg || hasBg ? { fg, bg } : undefined; + + let fillGlyph = " "; + if (char.length === 1 && char.charCodeAt(0) <= 0x7f) { + fillGlyph = char; + } else { + const cluster = segmentGraphemes(char)[0] ?? char; + fillGlyph = measureGraphemeWidth(cluster) > 1 ? " " : cluster; + } const maxRow = Math.min(buf.rows, row + height); const maxCol = Math.min(buf.cols, col + width); @@ -188,7 +240,7 @@ export function fillRect( for (let r = Math.max(0, row); r < maxRow; r++) { for (let c = Math.max(0, col); c < maxCol; c++) { const idx = indexOf(buf, r, c); - buf.cells[idx] = chCode; + buf.set(r, c, fillGlyph, resolvedStyle); if (hasFg) buf.fg[idx] = fg; if (hasBg) buf.bg[idx] = bg; } diff --git a/packages/renderer/tests/buffer.test.ts b/packages/renderer/tests/buffer.test.ts index f151ad7..78b4038 100644 --- a/packages/renderer/tests/buffer.test.ts +++ b/packages/renderer/tests/buffer.test.ts @@ -10,13 +10,15 @@ describe("FlatBuffer", () => { const buffer = new FlatBuffer(rows, cols); expect(buffer.rows).toBe(rows); expect(buffer.cols).toBe(cols); - expect(buffer.cells.length).toBe(size); + expect(buffer.codes.length).toBe(size); + expect(buffer.widths.length).toBe(size); expect(buffer.fg.length).toBe(size); expect(buffer.bg.length).toBe(size); // Check that it's cleared initially for (let i = 0; i < size; i++) { - expect(buffer.cells[i]).toBe(32); // space + expect(buffer.glyphStringAtIndex(i)).toBe(" "); + expect(buffer.widths[i]).toBe(1); expect(buffer.fg[i]).toBeUndefined(); expect(buffer.bg[i]).toBeUndefined(); } @@ -32,16 +34,15 @@ describe("FlatBuffer", () => { it("should clear the buffer", () => { const buffer = new FlatBuffer(rows, cols); - // Modify the buffer const idx = buffer.index(2, 2); - buffer.cells[idx] = "X".codePointAt(0)!; + buffer.set(2, 2, "X"); buffer.fg[idx] = "red"; buffer.bg[idx] = "blue"; buffer.clear(); - // Check if it's reset - expect(buffer.cells[idx]).toBe(32); + expect(buffer.glyphStringAtIndex(idx)).toBe(" "); + expect(buffer.widths[idx]).toBe(1); expect(buffer.fg[idx]).toBeUndefined(); expect(buffer.bg[idx]).toBeUndefined(); }); diff --git a/packages/renderer/tests/diff.test.ts b/packages/renderer/tests/diff.test.ts index 2efe29b..4e5d199 100644 --- a/packages/renderer/tests/diff.test.ts +++ b/packages/renderer/tests/diff.test.ts @@ -2,6 +2,13 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; import { renderDiff } from "../src/diff"; import { FlatBuffer } from "../src/buffer"; import type { Buffer2D } from "../src/types"; +import type { DiffStats } from "../src/diff"; + +function setCharAt(buf: Buffer2D, idx: number, ch: string) { + const row = Math.floor(idx / buf.cols); + const col = idx % buf.cols; + buf.set(row, col, ch); +} // Helper to create a mock buffer function createMockBuffer( @@ -11,19 +18,19 @@ function createMockBuffer( fg?: string, bg?: string, ): Buffer2D { - const size = rows * cols; const buf = new FlatBuffer(rows, cols); - buf.cells.fill(fillChar.codePointAt(0)!); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + buf.set(r, c, fillChar); + } + } buf.fg.fill(fg); buf.bg.fill(bg); - // keep lint/ts happy with unused local - void size; return buf; } describe("renderDiff", () => { - beforeEach(() => { - }); + beforeEach(() => {}); it("should not write anything if buffers are identical", () => { const prev = createMockBuffer(2, 2, "a"); @@ -35,7 +42,7 @@ describe("renderDiff", () => { it("should render only the changed cells", () => { const prev = createMockBuffer(2, 2, "a"); const next = createMockBuffer(2, 2, "a"); - next.cells[2] = "b".codePointAt(0)!; // Change one cell + setCharAt(next, 2, "b"); next.fg[2] = "\x1b[31m"; // red const output = renderDiff(prev, next); @@ -67,7 +74,10 @@ describe("renderDiff", () => { it("should batch color changes", () => { const prev = createMockBuffer(1, 5, " "); const next = createMockBuffer(1, 5, " "); - next.cells.set([..."abcd "].map((c) => c.codePointAt(0)!)); + const message = "abcd "; + for (let i = 0; i < message.length; i++) { + setCharAt(next, i, message[i] ?? " "); + } next.fg.fill("\x1b[32m", 0, 2); // green for 'a' and 'b' next.fg.fill("\x1b[34m", 2, 4); // blue for 'c' and 'd' @@ -83,9 +93,9 @@ describe("renderDiff", () => { it("should reset colors when necessary", () => { const prev = createMockBuffer(1, 3, " "); const next = createMockBuffer(1, 3, " "); - next.cells[0] = "a".codePointAt(0)!; + setCharAt(next, 0, "a"); next.fg[0] = "\x1b[31m"; // red - next.cells[1] = "b".codePointAt(0)!; + setCharAt(next, 1, "b"); // next.fg[1] is undefined const output = renderDiff(prev, next); @@ -97,4 +107,35 @@ describe("renderDiff", () => { expect(output).toBe(expected); }); + + it("should report diff stats", () => { + const prev = createMockBuffer(2, 2, " "); + const next = createMockBuffer(2, 2, " "); + + setCharAt(next, 0, "A"); + next.fg[0] = "\x1b[31m"; + setCharAt(next, 1, "B"); + next.fg[1] = "\x1b[31m"; + setCharAt(next, 2, "C"); + next.fg[2] = "\x1b[32m"; + + const stats: DiffStats = { + sizeChanged: false, + fullRedraw: false, + changedCells: 0, + cursorMoves: 0, + fgChanges: 0, + bgChanges: 0, + resets: 0, + ops: 0, + }; + + const output = renderDiff(prev, next, stats); + + expect(output.length).toBeGreaterThan(0); + expect(stats.changedCells).toBe(3); + expect(stats.cursorMoves).toBe(3); + expect(stats.fgChanges).toBe(2); + expect(stats.ops).toBe(stats.cursorMoves + stats.fgChanges + stats.bgChanges + stats.resets); + }); }); diff --git a/packages/renderer/tests/grapheme.test.ts b/packages/renderer/tests/grapheme.test.ts new file mode 100644 index 0000000..9a7930d --- /dev/null +++ b/packages/renderer/tests/grapheme.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { segmentGraphemes, measureGraphemeWidth } from "../src/grapheme"; + +describe("grapheme helpers", () => { + it("segments ascii and combining sequences", () => { + const text = "a\u0301b"; + const segments = segmentGraphemes(text); + expect(segments).toEqual(["a\u0301", "b"]); + }); + + it("handles Kanji as two width", () => { + const kanji = "饅"; + const segments = segmentGraphemes(kanji); + expect(measureGraphemeWidth(segments[0]!)).toBe(2); + }); + + it("handles emoji sequences as single graphemes", () => { + const emoji = "👨‍👩‍👧‍👦"; + const segments = segmentGraphemes(emoji); + expect(segments[0]?.startsWith("👨")).toBe(true); + expect(measureGraphemeWidth(segments[0]!)).toBe(2); + }); + + it("measures control characters as zero width", () => { + expect(measureGraphemeWidth("\u0007")).toBe(0); + }); + + it("reports width 1 for normal latin glyphs", () => { + expect(measureGraphemeWidth("A")).toBe(1); + }); +}); diff --git a/packages/renderer/tests/grid.test.ts b/packages/renderer/tests/grid.test.ts index 6521bb9..154d395 100644 --- a/packages/renderer/tests/grid.test.ts +++ b/packages/renderer/tests/grid.test.ts @@ -6,14 +6,14 @@ describe("@btuin/renderer grid", () => { const buf = createBuffer(3, 3); fillRect(buf, 0.9, 0.9, 2.9, 1.9, "X"); - expect(String.fromCodePoint(buf.cells[buf.index(0, 0)]!)).toBe("X"); - expect(String.fromCodePoint(buf.cells[buf.index(0, 1)]!)).toBe("X"); - expect(String.fromCodePoint(buf.cells[buf.index(0, 2)]!)).toBe(" "); + expect(buf.get(0, 0).char).toBe("X"); + expect(buf.get(0, 1).char).toBe("X"); + expect(buf.get(0, 2).char).toBe(" "); }); test("drawText floors non-integer coordinates", () => { const buf = createBuffer(2, 4); drawText(buf, 0.2, 1.8, "A"); - expect(String.fromCodePoint(buf.cells[buf.index(0, 1)]!)).toBe("A"); + expect(buf.get(0, 1).char).toBe("A"); }); }); diff --git a/packages/showcase/counter.ts b/packages/showcase/counter.ts index 473ddab..094a924 100644 --- a/packages/showcase/counter.ts +++ b/packages/showcase/counter.ts @@ -34,3 +34,8 @@ const app = createApp({ }); await app.mount(); + +const exitAfterMs = Number(process.env.BTUIN_EXIT_AFTER_MS ?? ""); +if (Number.isFinite(exitAfterMs) && exitAfterMs > 0) { + setTimeout(() => process.exit(0), exitAfterMs); +} diff --git a/packages/showcase/dashboard.ts b/packages/showcase/dashboard.ts index 280ac19..ed5ae90 100644 --- a/packages/showcase/dashboard.ts +++ b/packages/showcase/dashboard.ts @@ -40,7 +40,9 @@ function titleBar(title: string, right?: string) { } function card(lines: string[], accent: string) { - return VStack(lines.map((l) => Text(l))).gap(0).outline({ style: "single", color: accent }); + return VStack(lines.map((l) => Text(l))) + .gap(0) + .outline({ style: "single", color: accent }); } const app = createApp({ @@ -221,7 +223,9 @@ const app = createApp({ titleBar("Overlay", "z to close"), Text("Floating window via ZStack").foreground("gray"), Text("This clears its own area (bg).").foreground("gray"), - ...(modalHeight >= 9 ? [Block().height(1), Text("Resize and toggle.").foreground("gray")] : []), + ...(modalHeight >= 9 + ? [Block().height(1), Text("Resize and toggle.").foreground("gray")] + : []), ]) .width(modalWidth) .height(modalHeight) @@ -234,23 +238,21 @@ const app = createApp({ }; const baseApp = () => - VStack([ - header(), - HStack([sidebar(), main()]).gap(1).align("stretch").grow(1), - footer(), - ]) + VStack([header(), HStack([sidebar(), main()]).gap(1).align("stretch").grow(1), footer()]) .width("100%") .justify("flex-start") .align("stretch"); return () => - ZStack([ - baseApp(), - ...(showOverlay.value ? [floatingOverlay()] : []), - ]) + ZStack([baseApp(), ...(showOverlay.value ? [floatingOverlay()] : [])]) .width("100%") .height("100%"); }, }); await app.mount(); + +const exitAfterMs = Number(process.env.BTUIN_EXIT_AFTER_MS ?? ""); +if (Number.isFinite(exitAfterMs) && exitAfterMs > 0) { + setTimeout(() => process.exit(0), exitAfterMs); +} diff --git a/packages/showcase/package.json b/packages/showcase/package.json index 93539b3..fe8111b 100644 --- a/packages/showcase/package.json +++ b/packages/showcase/package.json @@ -2,5 +2,9 @@ "name": "showcase", "dependencies": { "btuin": "workspace:*" + }, + "scripts": { + "counter": "BTUIN_EXIT_AFTER_MS=0 bun run counter.ts", + "dashboard": "BTUIN_EXIT_AFTER_MS=0 bun run dashboard.ts" } } diff --git a/packages/showcase/tests/showcase-smoke.test.ts b/packages/showcase/tests/showcase-smoke.test.ts new file mode 100644 index 0000000..43b2617 --- /dev/null +++ b/packages/showcase/tests/showcase-smoke.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "bun:test"; +function stripCsi(input: string): string { + // Strip CSI sequences like: ESC[12;34H, ESC[31m, etc. + return input.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); +} + +describe("showcase smoke", () => { + it("counter renders at least once", async () => { + const proc = Bun.spawn(["bun", "run", "packages/showcase/counter.ts"], { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + BTUIN_EXIT_AFTER_MS: "50", + }, + }); + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + expect(stripCsi(stdout)).toContain("Counter"); + }); + + it("dashboard renders at least once", async () => { + const proc = Bun.spawn(["bun", "run", "packages/showcase/dashboard.ts"], { + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + BTUIN_EXIT_AFTER_MS: "75", + }, + }); + + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + + expect(exitCode).toBe(0); + expect(stderr).toBe(""); + const text = stripCsi(stdout); + expect(text).toContain("btuin"); + expect(text).toContain("showcase"); + }); +}); diff --git a/packages/terminal/tests/index.test.ts b/packages/terminal/tests/index.test.ts deleted file mode 100644 index e69de29..0000000 diff --git a/packages/terminal/tests/raw.test.ts b/packages/terminal/tests/raw.test.ts index 1b62502..b9d5193 100644 --- a/packages/terminal/tests/raw.test.ts +++ b/packages/terminal/tests/raw.test.ts @@ -1,11 +1,5 @@ import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test"; -import { - setupRawMode, - onKey, - cleanup, - cleanupWithoutClear, - resetKeyHandlers, -} from "../src/raw"; +import { setupRawMode, onKey, cleanup, cleanupWithoutClear, resetKeyHandlers } from "../src/raw"; import type { KeyHandler, KeyEvent } from "../src/types"; // Mock the 'io' module diff --git a/scripts/profiler-core.ts b/scripts/profiler-core.ts index 5c7edec..adeb728 100644 --- a/scripts/profiler-core.ts +++ b/scripts/profiler-core.ts @@ -1,399 +1,162 @@ -import type { Buffer2D } from "../packages/renderer/src/types/buffer"; +import type { TerminalAdapter } from "../packages/btuin/src/runtime"; + +type FrameMetrics = { + id: number; + time: number; + rows: number; + cols: number; + nodeCount?: number; + outputBytes?: number; + diffCellsChanged?: number; + diffOps?: number; + diffCursorMoves?: number; + diffStyleChanges?: number; + diffResets?: number; + diffFullRedraw?: boolean; + layoutMs: number; + renderMs: number; + diffMs: number; + writeMs: number; + frameMs: number; + memory?: { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + }; +}; + +export type ProfilerLog = { + version: 1; + startedAt: string; + endedAt: string; + frames: FrameMetrics[]; + summary: { + frameCount: number; + frameMs: { p50: number; p95: number; p99: number; max: number }; + totals: { + layoutMs: number; + renderMs: number; + diffMs: number; + writeMs: number; + frameMs: number; + }; + }; +}; + +export function createNullTerminalAdapter(size: { rows: number; cols: number }): TerminalAdapter { + return { + setupRawMode() {}, + clearScreen() {}, + cleanupWithoutClear() {}, + patchConsole() {}, + startCapture() {}, + onKey() {}, + getTerminalSize() { + return size; + }, + disposeSingletonCapture() {}, + write() {}, + }; +} -/** - * Performance profiler core for btuin package. - * - * - CPU時間・メモリ使用量の計測 - * - フレーム単位の計測(P95/P99) - * - フラグメンテーション/スケーラビリティ測定 - * - * このモジュールはテストやスクリプトから再利用される「コアロジック」として切り出されており、 - * 実際のプロファイリングシナリオは `scripts/profiler.spec.ts` や - * `scripts/profiler.io.spec.ts` 側で定義する。 - */ +const formatMs = (value: number) => `${value.toFixed(2)}ms`; -/** - * プロファイルメトリクス(1つの測定単位ごと) - */ -export interface ProfileMetrics { - name: string; - duration: number; - memoryBefore?: number; - memoryAfter?: number; - memoryDelta?: number; - peakMemory?: number; - operationCount: number; - opsPerSecond: number; - memoryEfficiency?: number; // ops per MB - p99Duration?: number; - p95Duration?: number; +function describeFrame(frame: FrameMetrics) { + const diffCells = frame.diffCellsChanged ?? 0; + const diffOps = frame.diffOps ?? 0; + return `frame ${frame.id} → ${formatMs(frame.frameMs)} (layout ${formatMs(frame.layoutMs)}, render ${formatMs( + frame.renderMs, + )}, diff ${formatMs(frame.diffMs)}, write ${formatMs(frame.writeMs)}) diffCells=${diffCells} diffOps=${diffOps}`; } -/** - * フレーム単位メトリクス - */ -export interface FrameMetrics { - frameNumber: number; - duration: number; - timestamp: number; +function summarizeFrames(frames: FrameMetrics[], key: keyof FrameMetrics, limit = 3) { + return [...frames] + .filter((frame) => typeof frame[key] === "number") + .sort((a, b) => (b[key] as number) - (a[key] as number)) + .slice(0, limit); } -/** - * プロファイラ全体の集約統計。 - * 1回のプロファイル実行内での分布やホットスポットを俯瞰するために使う。 - */ -export interface ProfilerSummary { - // 時間系(全メトリクスの duration 分布) - totalDuration: number; - meanDuration: number; - medianDuration: number; - p95Duration: number; - p99Duration: number; - - // メモリ系 - peakMemory: number; - totalPositiveMemoryDelta: number; - totalNegativeMemoryDelta: number; - - // ホットスポット(上位N件) - topByDuration: ProfileMetrics[]; - topByMemoryDelta: ProfileMetrics[]; - - // フレーム統計(measureFrames が使われた場合のみ) - frameStats?: { - count: number; - mean: number; - min: number; - max: number; - p95: number; - p99: number; - }; +function peakMemory(frames: FrameMetrics[]) { + return frames.reduce( + (acc, frame) => { + if (frame.memory && frame.memory.heapUsed > acc.heapUsed) { + acc.heapUsed = frame.memory.heapUsed; + acc.frame = frame; + } + return acc; + }, + { heapUsed: -Infinity, frame: null as FrameMetrics | null }, + ); } -/** - * btuin 向け汎用プロファイラコア。 - * - * - できるだけ「計測ロジック」に責務を絞る - * - 実際に何を計測するか(List, BufferPool, IO など)は呼び出し側に委ねる - */ -export class Profiler { - private metrics: ProfileMetrics[] = []; - private frameMetrics: FrameMetrics[] = []; - private peakMemoryUsage: number = 0; - - /** - * 任意の関数の実行時間とメモリ使用量を計測する。 - */ - measure( - name: string, - fn: () => void, - operationCount: number = 1, - ): ProfileMetrics { - const memBefore = process.memoryUsage(); - const start = performance.now(); - - fn(); - - const end = performance.now(); - const memAfter = process.memoryUsage(); - const duration = end - start; - const opsPerSecond = (operationCount / duration) * 1000; - - const memoryDelta = memAfter.heapUsed - memBefore.heapUsed; - const peakMemory = Math.max(this.peakMemoryUsage, memAfter.heapUsed); - this.peakMemoryUsage = peakMemory; - - const memoryEfficiency = - memoryDelta > 0 - ? operationCount / (memoryDelta / 1024 / 1024) - : undefined; - - const metric: ProfileMetrics = { - name, - duration, - memoryBefore: memBefore.heapUsed, - memoryAfter: memAfter.heapUsed, - memoryDelta, - peakMemory, - operationCount, - opsPerSecond, - memoryEfficiency, - }; - - this.metrics.push(metric); - return metric; +export function printSummary(log: ProfilerLog) { + const { frames } = log; + const totals = log.summary.totals; + const avgFrame = frames.length ? totals.frameMs / frames.length : 0; + const renderShare = totals.frameMs ? (totals.renderMs / totals.frameMs) * 100 : 0; + console.log("-".repeat(60)); + console.log(`Profile summary (${log.startedAt} → ${log.endedAt})`); + console.log(` total frames : ${log.summary.frameCount} (avg ${formatMs(avgFrame)})`); + console.log( + ` distribution : p50 ${formatMs(log.summary.frameMs.p50)}, p95 ${formatMs( + log.summary.frameMs.p95, + )}, p99 ${formatMs(log.summary.frameMs.p99)}, max ${formatMs(log.summary.frameMs.max)}`, + ); + console.log( + ` totals : layout ${formatMs(totals.layoutMs)}, render ${formatMs(totals.renderMs)}, diff ${formatMs( + totals.diffMs, + )}, write ${formatMs(totals.writeMs)} (render share ${renderShare.toFixed(1)}%)`, + ); + + if (frames.length === 0) { + console.log(" (no frames recorded)"); + return; } - /** - * フレームループ(レンダリングシミュレーション)の計測。 - * - * - 各フレームの duration を記録 - * - P95/P99 フレーム時間を集計 - */ - measureFrames( - name: string, - fn: (frameNumber: number) => void, - frameCount: number = 60, - ): FrameMetrics[] { - const frames: FrameMetrics[] = []; - const startTime = performance.now(); - - for (let i = 0; i < frameCount; i++) { - const frameStart = performance.now(); - fn(i); - const frameEnd = performance.now(); - const duration = frameEnd - frameStart; - - frames.push({ - frameNumber: i, - duration, - timestamp: frameStart - startTime, - }); - } - - const totalDuration = frames.reduce((sum, f) => sum + f.duration, 0); - - const metric: ProfileMetrics = { - name: `${name} (frame simulation)`, - duration: totalDuration, - operationCount: frameCount, - opsPerSecond: (frameCount / totalDuration) * 1000, - p99Duration: this.calculatePercentile( - frames.map((f) => f.duration), - 99, - ), - p95Duration: this.calculatePercentile( - frames.map((f) => f.duration), - 95, - ), - }; - - this.metrics.push(metric); - this.frameMetrics.push(...frames); - - return frames; + const [slowest, ...rest] = summarizeFrames(frames, "frameMs", 3); + console.log(" spikes:"); + if (slowest) { + console.log(` - Slowest: ${describeFrame(slowest)}`); } + rest.forEach((frame) => console.log(` - ${describeFrame(frame)}`)); - /** - * バッファのアロケーション/デアロケーションを複数サイクル実行し、 - * フラグメンテーションパターンとメモリ変化を測定する。 - */ - measureFragmentation( - name: string, - allocFn: () => Buffer2D[], - deallocFn: (buffers: Buffer2D[]) => void, - cycles: number = 100, - ): ProfileMetrics { - const memBefore = process.memoryUsage(); - const start = performance.now(); - - for (let i = 0; i < cycles; i++) { - const buffers = allocFn(); - deallocFn(buffers); - } - - const end = performance.now(); - const memAfter = process.memoryUsage(); - const duration = end - start; - const opsPerSecond = (cycles / duration) * 1000; - - const memoryDelta = memAfter.heapUsed - memBefore.heapUsed; - const peakMemory = Math.max(this.peakMemoryUsage, memAfter.heapUsed); - this.peakMemoryUsage = peakMemory; - - const metric: ProfileMetrics = { - name: `${name} (fragmentation test)`, - duration, - memoryBefore: memBefore.heapUsed, - memoryAfter: memAfter.heapUsed, - memoryDelta, - peakMemory, - operationCount: cycles, - opsPerSecond, - }; - - this.metrics.push(metric); - return metric; + const smallest = [...frames].sort((a, b) => a.frameMs - b.frameMs)[0]; + if (smallest) { + console.log(` smoothest: ${describeFrame(smallest)} (best frame)`); } - /** - * データサイズを変化させながらスケーラビリティを測定する。 - * - * 1サイズにつき1回実行し、その duration / ops/sec / memoryDelta を記録する。 - */ - measureScalability( - name: string, - fn: (size: number) => void, - sizes: number[], - ): void { - for (const size of sizes) { - const memBefore = process.memoryUsage(); - const start = performance.now(); - - fn(size); - - const end = performance.now(); - const memAfter = process.memoryUsage(); - const duration = end - start; - - const memoryDelta = memAfter.heapUsed - memBefore.heapUsed; - const peakMemory = Math.max(this.peakMemoryUsage, memAfter.heapUsed); - this.peakMemoryUsage = peakMemory; - - const metric: ProfileMetrics = { - name: `${name} (size: ${size})`, - duration, - memoryBefore: memBefore.heapUsed, - memoryAfter: memAfter.heapUsed, - memoryDelta, - peakMemory, - operationCount: 1, - opsPerSecond: (1 / duration) * 1000, - }; - - this.metrics.push(metric); - } - } - - /** - * 単純なパーセンタイル計算(昇順ソート → インデックス計算)。 - */ - private calculatePercentile( - values: number[], - percentile: number, - ): number { - const sorted = [...values].sort((a, b) => a - b); - const index = Math.ceil((percentile / 100) * sorted.length) - 1; - return sorted[Math.max(0, index)]!; - } - - /** - * 人間向けの簡易レポートを stdout に出力する。 - * - * - 各メトリクスの duration / ops/sec / メモリ情報 - * - P95/P99(存在する場合) - * - 全体時間および最も遅いメトリクス名 - * - 追加で、集約統計(summary)も表示する - */ - report(): void { - if (this.metrics.length === 0) return; - - let totalTime = 0; - for (const metric of this.metrics) { - totalTime += metric.duration; - } - - for (const metric of this.metrics) { - const percentage = - totalTime > 0 - ? ((metric.duration / totalTime) * 100).toFixed(1) - : "0.0"; - console.log(`📊 ${metric.name}`); - console.log( - ` Duration: ${metric.duration.toFixed(2)}ms (${percentage}% of total)`, - ); - console.log(` Operations: ${metric.operationCount}`); - console.log(` Ops/sec: ${metric.opsPerSecond.toFixed(0)}`); - - if (metric.memoryDelta !== undefined && metric.memoryDelta !== 0) { - const sign = metric.memoryDelta >= 0 ? "+" : ""; - console.log( - ` Memory Δ: ${sign}${( - metric.memoryDelta / - 1024 / - 1024 - ).toFixed(2)}MB`, - ); - } - - if (metric.memoryEfficiency !== undefined) { - console.log( - ` Memory Efficiency: ${metric.memoryEfficiency.toFixed( - 0, - )} ops/MB`, - ); - } - - if (metric.p99Duration !== undefined) { - console.log( - ` P99 Frame Time: ${metric.p99Duration.toFixed( - 2, - )}ms (tail latency)`, - ); - } - - if (metric.p95Duration !== undefined) { - console.log( - ` P95 Frame Time: ${metric.p95Duration.toFixed( - 2, - )}ms (95th percentile)`, - ); - } - - console.log(); - } - - if (this.peakMemoryUsage > 0) { - console.log( - `📈 Peak Memory Usage: ${( - this.peakMemoryUsage / - 1024 / - 1024 - ).toFixed(2)}MB`, - ); - } - - console.log(`⏱️ Total Time: ${totalTime.toFixed(2)}ms`); - const slowest = this.getSlowest(); - console.log(`🔥 Hotspot: ${slowest?.name || "N/A"}`); - console.log(); + const renderPeaks = summarizeFrames(frames, "renderMs", 3); + console.log(" render bottlenecks:"); + renderPeaks.forEach((frame) => console.log(` - ${describeFrame(frame)} (render-heavy)`)); + const layoutPeak = summarizeFrames(frames, "layoutMs", 1)[0]; + if (layoutPeak) { + console.log(` layout peak : frame ${layoutPeak.id} (${formatMs(layoutPeak.layoutMs)})`); } - /** - * 最も遅いメトリクスを返す。 - */ - getSlowest(): ProfileMetrics | null { - if (this.metrics.length === 0) return null; - return this.metrics.reduce((prev, curr) => - curr.duration > prev.duration ? curr : prev, + const diffPeak = summarizeFrames(frames, "diffCellsChanged", 1)[0]; + if (diffPeak && (diffPeak.diffCellsChanged ?? 0) > 0) { + console.log( + ` diff spike : frame ${diffPeak.id}, ${diffPeak.diffCellsChanged} cells (${diffPeak.diffOps ?? 0} ops)`, ); } - /** - * duration 降順にソートしたメトリクス一覧を返す。 - */ - getSorted(): ProfileMetrics[] { - return [...this.metrics].sort((a, b) => b.duration - a.duration); - } - - /** - * 全メトリクスをコピーで返す。 - */ - getMetrics(): ProfileMetrics[] { - return [...this.metrics]; - } - - /** - * フレームメトリクス一覧をコピーで返す。 - */ - getFrameMetrics(): FrameMetrics[] { - return [...this.frameMetrics]; - } - - /** - * 記録されたピークメモリ(heapUsed)の生値を返す(バイト)。 - */ - getPeakMemory(): number { - return this.peakMemoryUsage; + const { frame: memFrame, heapUsed } = peakMemory(frames); + if (memFrame) { + console.log( + ` memory peak : frame ${memFrame.id}, heapUsed ${Math.round(heapUsed / 1024 / 1024)}MB (rss ${Math.round( + (memFrame.memory?.rss ?? 0) / 1024 / 1024, + )}MB)`, + ); } - /** - * 全メトリクスとピークメモリ情報をクリアする。 - */ - clear(): void { - this.metrics = []; - this.frameMetrics = []; - this.peakMemoryUsage = 0; - } + console.log(" takeaways:"); + console.log( + " - Render is responsible for the biggest time slices; look into partial rendering or memoization.", + ); + console.log( + " - Layout, diff, and write stay low, so efforts should focus on taming render-heavy spikes.", + ); + console.log("-".repeat(60)); } diff --git a/scripts/profiler-layout.spec.ts b/scripts/profiler-layout.spec.ts new file mode 100644 index 0000000..f6166f3 --- /dev/null +++ b/scripts/profiler-layout.spec.ts @@ -0,0 +1,116 @@ +import { test, describe, expect } from "bun:test"; +import { existsSync } from "node:fs"; + +import { createApp, ref, Block, Text } from "../packages/btuin"; +import { createNullTerminalAdapter, printSummary, type ProfilerLog } from "./profiler-core"; + +// This test intentionally mutates layout-relevant props every frame to stress: +// - view() construction cost +// - JS->WASM bridge (flattenTree + nodes array build) +// - WASM compute_layout cost +// +// It uses the same harness style as profiler-stress.test.ts (createApp + Profiler JSON output). + +const N = 4_000; +const FRAMES = 120; +const INTERVAL_MS = 16; +const HUD = false; +const OUTPUT_FILE = `${import.meta.dirname}/profiles/layout-${Date.now()}.json`; + +const tick = ref(0); +let resolveFinished: (() => void) | null = null; +const finished = new Promise((resolve) => { + resolveFinished = resolve; +}); + +// Reuse leaf Text nodes to avoid making this purely an allocation benchmark. +const leaves = Array.from({ length: N }, (_, i) => Text(`item ${i}`).foreground("gray")); + +function buildTree(t: number) { + const root = Block() + .direction(t % 2 === 0 ? "column" : "row") + .gap(t % 4) + .padding((t % 3) as 0 | 1 | 2); + + root.add(Text(`layout-change-stress n=${N} tick=${t}`).foreground("cyan")); + + for (let i = 0; i < leaves.length; i++) { + const leaf = leaves[i]!; + // Wrap each leaf to force style changes to impact layout each frame. + const box = Block() + .direction(t % 3 === 0 ? "row" : "column") + .padding(((t + i) % 2) as 0 | 1); + + // Vary width/height frequently to invalidate computed layout. + if (t % 2 === 0) { + box.width((i % 20) + 5); + box.height(1); + box.grow(((i + t) % 3) + 1); + box.shrink(((i + t) % 2) + 1); + } else { + box.width("auto"); + box.height(1); + box.grow(((i + t) % 2) + 1); + box.shrink(((i + t) % 3) + 1); + } + + box.add(leaf); + root.add(box); + } + + return root; +} + +const app = createApp({ + setup() { + let produced = 0; + const timer = setInterval(() => { + tick.value++; + produced++; + if (produced >= FRAMES) { + clearInterval(timer); + resolveFinished?.(); + } + }, INTERVAL_MS); + + return () => buildTree(tick.value); + }, + terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), + profile: { + enabled: true, + hud: HUD, + outputFile: OUTPUT_FILE, + maxFrames: FRAMES, + nodeCount: true, + }, +}); + +describe("Many Layout Change Test", async () => { + Bun.gc(true); + const appInstance = await app.mount(); + expect(appInstance.getComponent()).not.toBeNull(); + await finished; + appInstance.unmount(); + expect(existsSync(OUTPUT_FILE)).toBe(true); + const log = (await import(OUTPUT_FILE, { with: { type: "json" } })) as ProfilerLog; + printSummary(log); + + const frames = log.frames; + const firstFrame = frames[0]; + const steadyFrames = frames.slice(5); + + test("Startup Latency (Frame 1) < 100ms", () => { + expect(firstFrame).toBeDefined(); + expect(firstFrame!.frameMs).toBeLessThan(100); + }); + + test("Steady State Max (Frame 5+) < 33.4ms (30 FPS)", () => { + const maxSteady = Math.max(...steadyFrames.map((f) => f.frameMs)); + expect(maxSteady).toBeLessThan(33.4); + }); + + test("Steady State Average < 30ms", () => { + const avgSteady = steadyFrames.reduce((sum, f) => sum + f.frameMs, 0) / steadyFrames.length; + expect(avgSteady).toBeLessThan(30); + }); +}); diff --git a/scripts/profiler-limit.spec.ts b/scripts/profiler-limit.spec.ts new file mode 100644 index 0000000..cca1486 --- /dev/null +++ b/scripts/profiler-limit.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { Block, Text, createApp, ref } from "../packages/btuin"; +import { createNullTerminalAdapter, type ProfilerLog } from "./profiler-core"; + +// ---------------------------------------------------------------------------- +// Configuration +// ---------------------------------------------------------------------------- +const FRAMES = 300; // Total frames per run +const START_NODES = 0; // Initial nodes +const STEP_NODES = 100; // Nodes added per frame +const INTERVAL_MS = 2; // Update interval +const ITERATIONS = 5; // Number of times to repeat the test + +const OUTPUT_FILE = `${import.meta.dirname}/profiles/limit-${Date.now()}.json`; + +async function runSingleIteration(iterationIndex: number): Promise { + const tick = ref(0); + let resolveFinished: (() => void) | null = null; + const finished = new Promise((resolve) => { + resolveFinished = resolve; + }); + + const app = createApp({ + setup() { + let produced = 0; + const timer = setInterval(() => { + tick.value++; + produced++; + if (produced >= FRAMES) { + clearInterval(timer); + resolveFinished?.(); + } + }, INTERVAL_MS); + + return () => { + const count = START_NODES + tick.value * STEP_NODES; + const root = Block().direction("column"); + + const children = new Array(count); + for (let i = 0; i < count; i++) { + children[i] = Text(`item ${i}`).foreground(i % 2 === 0 ? "white" : "gray"); + } + root.children = children; + + root.children.unshift( + Text(`Iter: ${iterationIndex} Frame: ${tick.value} | Nodes: ${count}`).foreground("cyan"), + ); + return root; + }; + }, + terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), + profile: { + enabled: true, + outputFile: OUTPUT_FILE, + maxFrames: FRAMES, + nodeCount: true, + }, + }); + + const appInstance = await app.mount(); + await finished; + appInstance.unmount(); + + if (!existsSync(OUTPUT_FILE)) { + throw new Error(`Profile output file not found: ${OUTPUT_FILE}`); + } + + const fileContent = await Bun.file(OUTPUT_FILE).text(); + return JSON.parse(fileContent) as ProfilerLog; +} + +function calculateStats(values: number[]) { + if (values.length === 0) return { avg: 0, min: 0, max: 0, stdDev: 0 }; + + const sum = values.reduce((a, b) => a + b, 0); + const avg = sum / values.length; + const min = Math.min(...values); + const max = Math.max(...values); + + const squaredDiffs = values.map((v) => Math.pow(v - avg, 2)); + const avgSquaredDiff = squaredDiffs.reduce((a, b) => a + b, 0) / values.length; + const stdDev = Math.sqrt(avgSquaredDiff); + + return { avg, min, max, stdDev }; +} + +describe("Scalability Limit Test (Statistical)", async () => { + console.log(`\nStarting Statistical Stress Test (${ITERATIONS} iterations)`); + + const thresholds = [ + { fps: 120, ms: 8.33 }, + { fps: 60, ms: 16.66 }, + { fps: 30, ms: 33.33 }, + { fps: 15, ms: 66.66 }, + ]; + + const results: Record = {}; + thresholds.forEach((t) => (results[t.fps] = [])); + + for (let i = 0; i < ITERATIONS; i++) { + Bun.stdout.write(` Run ${i + 1}/${ITERATIONS}... `); + + Bun.gc(true); + + const log = await runSingleIteration(i); + + const smoothedFrames = log.frames.map((frame, idx, all) => { + const start = Math.max(0, idx - 2); + const end = Math.min(all.length, idx + 3); + const subset = all.slice(start, end); + const avgMs = subset.reduce((sum, f) => sum + f.frameMs, 0) / subset.length; + return { ...frame, avgMs, nodeCount: START_NODES + idx * STEP_NODES }; + }); + + const validFrames = smoothedFrames.slice(5); + + for (const t of thresholds) { + expect(results[t.fps]).toBeDefined(); + const limitFrame = validFrames.find((f) => f.avgMs > t.ms); + const limitNodeCount = limitFrame ? limitFrame.nodeCount : START_NODES + FRAMES * STEP_NODES; + + results[t.fps]!.push(limitNodeCount); + } + console.log(`Done.`); + } + + console.log("\n" + "=".repeat(52)); + console.log(`${" ".repeat(10)}Performance Limits Report ${ITERATIONS} runs`); + console.log("=".repeat(52)); + console.log(`| FPS Target | Avg Nodes | Min | Max | Std Dev |`); + console.log( + `|${"-".repeat(12)}|${"-".repeat(11)}|${"-".repeat(7)}|${"-".repeat(7)}|${"-".repeat(9)}|`, + ); + + for (const t of thresholds) { + expect(results[t.fps]).toBeDefined(); + const stats = calculateStats(results[t.fps]!); + const note = stats.avg >= START_NODES + FRAMES * STEP_NODES ? "+" : ""; + + console.log( + `| ${t.fps.toString().padStart(3)} FPS | ` + + `${(Math.round(stats.avg) + note).toString().padEnd(9)} | ` + + `${Math.round(stats.min).toString().padEnd(5)} | ` + + `${Math.round(stats.max).toString().padEnd(5)} | ` + + `±${Math.round(stats.stdDev).toString().padEnd(6)} |`, + ); + } + console.log("=".repeat(52)); + + test("Stress test finished successfully", () => { + expect(true).toBe(true); + }); +}); diff --git a/scripts/profiler-stress.spec.ts b/scripts/profiler-stress.spec.ts new file mode 100644 index 0000000..7414e59 --- /dev/null +++ b/scripts/profiler-stress.spec.ts @@ -0,0 +1,81 @@ +import { test, describe, expect } from "bun:test"; +import { existsSync } from "node:fs"; + +import { createApp, ref, Block, Text, type TerminalAdapter } from "../packages/btuin"; +import { createNullTerminalAdapter, printSummary, type ProfilerLog } from "./profiler-core"; + +const N = 10_000; +const FRAMES = 120; +const INTERVAL_MS = 16; +const HUD = false; +const OUTPUT_FILE = `${import.meta.dirname}/profiles/stress-${Date.now()}.json`; + +const tick = ref(0); +let resolveFinished: (() => void) | null = null; +const finished = new Promise((resolve) => { + resolveFinished = resolve; +}); + +// Pre-build a large, mostly-static tree to stress layout+render traversal. +const items = Array.from({ length: N }, (_, i) => Text(`item ${i}`).foreground("gray")); +const header = Text("stress").foreground("cyan"); +const root = Block().direction("column"); +root.add(header); +for (const item of items) root.add(item); + +const app = createApp({ + setup() { + let produced = 0; + const timer = setInterval(() => { + tick.value++; + produced++; + if (produced >= FRAMES) { + clearInterval(timer); + resolveFinished?.(); + } + }, INTERVAL_MS); + + return () => { + header.content = `stress n=${N} tick=${tick.value}`; + return root; + }; + }, + terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), + profile: { + enabled: true, + hud: HUD, + outputFile: OUTPUT_FILE, + maxFrames: FRAMES, + nodeCount: true, + }, +}); + +describe("Multi Element Stress Test", async () => { + Bun.gc(true); + const appInstance = await app.mount(); + expect(appInstance.getComponent()).not.toBeNull(); + await finished; + appInstance.unmount(); + expect(existsSync(OUTPUT_FILE)).toBe(true); + const log = (await import(OUTPUT_FILE, { with: { type: "json" } })) as ProfilerLog; + printSummary(log); + + const frames = log.frames; + const firstFrame = frames[0]; + const steadyFrames = frames.slice(5); + + test("Startup Latency (Frame 1) < 100ms", () => { + expect(firstFrame).toBeDefined(); + expect(firstFrame!.frameMs).toBeLessThan(100); + }); + + test("Steady State Max (Frame 5+) < 16.7ms (60 FPS)", () => { + const maxSteady = Math.max(...steadyFrames.map((f) => f.frameMs)); + expect(maxSteady).toBeLessThan(16.7); + }); + + test("Steady State Average < 10ms", () => { + const avgSteady = steadyFrames.reduce((sum, f) => sum + f.frameMs, 0) / steadyFrames.length; + expect(avgSteady).toBeLessThan(10); + }); +});