diff --git a/README.ja.md b/README.ja.md index b956639..41851d6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -4,10 +4,18 @@ Bunランタイム向けの宣言的なTUIフレームワーク。 ## 特徴 -- **きめ細かなリアクティビティ**: 仮想DOMは使用しません。状態の変更に依存するコンポーネントのみが再描画されます。 -- **Flexboxベースのレイアウト**: Rust製のエンジンがFlexboxのサブセットを実装し、レスポンシブなレイアウトを実現します。 -- **Bunネイティブ**: Bunの高速なTTY、FFI、疑似ターミナルAPIと統合されています。 -- **型安全**: TypeScriptで書かれています。 +- **宣言的なUI**: コンポーネントのツリーでインターフェースを記述します。 +- **リアクティビティモデル**: 依存する状態(`ref`, `computed`)が変更されると、UIが自動的に更新されます。フレームワークは依存関係を追跡し、仮想DOMを使用せずに必要なコンポーネントのみを再描画します。 +- **Flexboxベースのレイアウト**: Rustベースのレイアウトエンジンである[Taffy](https://github.com/DioxusLabs/taffy)をFFI経由で使用し、Flexboxのようなレイアウトを計算します。 +- **最適化されたレンダリング**: レンダラーは、前回と現在の画面状態との差分を作成することで、TTYへの書き込みを削減します。また、スクロールパフォーマンスを最適化するための部分的な再描画もサポートしています。 +- **Bunネイティブ**: Bunランタイム向けに構築されており、その高速なTTY、FFI、および疑似ターミナルAPIを活用しています。 +- **型安全**: TypeScriptで記述されています。 + +## 開発体験 + +- **ホットリロード**: `btuin dev`コマンドは、ファイルの変更を監視する開発ランナーを提供し、変更時にTUIを自動的に再起動することで、高速なフィードバックループを可能にします。 + +- **ブラウザベースのDevTools**: 統合されたインスペクターを使用すると、Webブラウザでリアルタイムにコンポーネントツリーの表示、コンポーネントレベルのログの確認、レイアウトとレンダリングのデバッグが可能です。 ## インストール @@ -19,11 +27,13 @@ bun add btuin ## 使い方 +次のコードは、矢印キーで増減するシンプルなカウンターを作成します。 + ```ts import { createApp, ref, ui } from "btuin"; const app = createApp({ - // init: 状態とイベントリスナーをセットアップ + // `init`は状態とイベントリスナーをセットアップするために一度だけ呼び出されます。 init({ onKey, runtime }) { const count = ref(0); @@ -36,7 +46,7 @@ const app = createApp({ return { count }; }, - // render: UIツリーを返す。状態が変化すると再実行される。 + // `render`はUIツリーを返します。状態が変化するたびに再実行されます。 render({ count }) { return ui .VStack([ui.Text("Counter"), ui.Text(String(count.value))]) @@ -50,12 +60,86 @@ const app = createApp({ await app.mount(); ``` -## Inline モード +## より多くの例 + +### インラインプログレスバー + +ターミナル画面全体をクリアせずにUIをインラインでレンダリングできます。これは、プログレスバー、プロンプト、またはターミナルのスクロールバック履歴を妨げるべきではないインタラクティブツールに役立ちます。 + +`inline`モードがアクティブな場合、`stdout`と`stderr`は自動的にレンダリングされたUIの上にルーティングされます。 + +```ts +import { createApp, ref, ui } from "btuin"; + +const app = createApp({ + init({ onKey, onTick, runtime, setExitOutput }) { + const progress = ref(0); + + onKey((k) => k.name === "q" && runtime.exit(0)); + + onTick(() => { + progress.value++; + if (progress.value >= 100) { + setExitOutput("完了!"); + runtime.exit(0); + } + }, 25); + + return { progress }; + }, + render({ progress }) { + return ui.Text(`進捗: ${progress.value}%`); + }, +}); + +await app.mount({ + inline: true, + // 終了時に画面からUIをクリアする + inlineCleanupOnExit: true, +}); +``` + +### 仮想化リスト -ターミナル全体を消さずに描画します: +`btuin`は、仮想化された`Windowed`コンポーネントを使用して、アイテムの長いリストを効率的にレンダリングできます。表示されているアイテム(および「オーバースキャン」バッファー)のみがレンダリングされるため、何千ものアイテムがあっても高いパフォーマンスが維持されます。 ```ts -await app.mount({ inline: true, inlineCleanupOnExit: true }); +import { createApp, ref, ui } from "btuin"; + +const TOTAL = 50_000; +const items = Array.from({ length: TOTAL }, (_, i) => `アイテム ${i}`); + +const app = createApp({ + init({ onKey, runtime }) { + const scrollIndex = ref(0); + + onKey((k) => { + if (k.name === "q") runtime.exit(0); + // 注: `clampWindowedStartIndex`は、スクロールインデックスが + // 有効な範囲内に収まるようにするためのヘルパーです。 + if (k.name === "down") scrollIndex.value++; + if (k.name === "up") scrollIndex.value--; + if (k.name === "pagedown") scrollIndex.value += 20; + if (k.name === "pageup") scrollIndex.value -= 20; + }); + + return { scrollIndex }; + }, + render({ scrollIndex }) { + const list = ui.Windowed({ + items, + startIndex: scrollIndex.value, + renderItem: (item) => ui.Text(item), + }); + + return ui.VStack([ + ui.Text(`${items.length}個のアイテムを表示中(qで終了)`), + list, + ]); + }, +}); + +await app.mount(); ``` ## API概要 @@ -70,11 +154,9 @@ await app.mount({ inline: true, inlineCleanupOnExit: true }); ## リンク -- [**ドキュメント**](./docs/) (アーキテクチャ, ロードマップ) -- [**Inline モード**](./docs/inline-mode.ja.md) -- [**DevTools**](./docs/devtools.ja.md) -- [**ホットリロード**](./docs/hot-reload.ja.md) -- [**GitHub**](https://github.com/HALQME/btuin) (ソースコード, Issue) +- [**アーキテクチャ**](./docs/architecture.ja.md): コア設計、リアクティビティシステム、レンダリングパイプラインについて。 +- [**開発ツール**](./docs/devtools.ja.md): ブラウザベースのインスペクタとホットリロードの使い方。 +- [**GitHub**](https://github.com/HALQME/btuin): ソースコード、Issue、コントリビューション。 ## 言語 diff --git a/README.md b/README.md index 3de29a9..a942506 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,19 @@ Declarative TUI framework for the Bun runtime. ## Features -- **Fine-Grained Reactivity**: No virtual DOM. Only components that depend on changed state are re-rendered. -- **Flexbox-based Layout**: A Rust-powered engine that implements a subset of Flexbox for responsive layouts. -- **Bun Native**: Integrated with Bun's fast TTY, FFI, and pseudo-terminal APIs. +- **Declarative UI**: Describe your interface with a tree of components. +- **Reactivity Model**: The UI automatically updates when the state (`ref`, `computed`) it depends on changes. The framework tracks dependencies to re-render only necessary components, without using a Virtual DOM. +- **Flexbox-based Layout**: Uses [Taffy](https://github.com/DioxusLabs/taffy), a Rust-based layout engine, via FFI to calculate Flexbox-like layouts. +- **Optimized Rendering**: The renderer reduces TTY writes by creating a diff between the previous and current screen states. It also supports partial re-rendering for optimized scrolling performance. +- **Bun Native**: Built for the Bun runtime, utilizing its fast TTY, FFI, and pseudo-terminal APIs. - **Type-Safe**: Written in TypeScript. +## Developer Experience + +- **Hot Reloading**: The `btuin dev` command provides a file-watching development runner that automatically restarts your TUI on changes, enabling a fast feedback loop. + +- **Browser-Based DevTools**: An integrated inspector allows you to view the component tree, check component-level logs, and debug layout and rendering in real-time in your web browser. + ## Installation ```bash @@ -19,11 +27,13 @@ Publishing/install details: `docs/github-packages.md` ## Usage +The following code creates a simple counter that increments and decrements with the arrow keys. + ```ts import { createApp, ref, ui } from "btuin"; const app = createApp({ - // init: setup state and event listeners. + // `init` is called once to set up state and event listeners. init({ onKey, runtime }) { const count = ref(0); @@ -36,7 +46,7 @@ const app = createApp({ return { count }; }, - // render: returns the UI tree. Re-runs when state changes. + // `render` returns the UI tree. It re-runs whenever state changes. render({ count }) { return ui .VStack([ui.Text("Counter"), ui.Text(String(count.value))]) @@ -50,12 +60,86 @@ const app = createApp({ await app.mount(); ``` -## Inline Mode +## More Examples + +### Inline Progress Bar + +You can render a UI inline without clearing the entire terminal screen. This is useful for progress bars, prompts, or interactive tools that should not disrupt the terminal's scrollback history. + +When `inline` mode is active, `stdout` and `stderr` are automatically routed above the rendered UI. + +```ts +import { createApp, ref, ui } from "btuin"; + +const app = createApp({ + init({ onKey, onTick, runtime, setExitOutput }) { + const progress = ref(0); + + onKey((k) => k.name === "q" && runtime.exit(0)); + + onTick(() => { + progress.value++; + if (progress.value >= 100) { + setExitOutput("Done!"); + runtime.exit(0); + } + }, 25); + + return { progress }; + }, + render({ progress }) { + return ui.Text(`Progress: ${progress.value}%`); + }, +}); + +await app.mount({ + inline: true, + // Clear the UI from the screen on exit + inlineCleanupOnExit: true, +}); +``` + +### Virtualized List -Render without clearing the whole screen: +`btuin` can render long lists of items efficiently using a virtualized `Windowed` component. Only the visible items (plus an "overscan" buffer) are rendered, keeping performance high even with thousands of items. ```ts -await app.mount({ inline: true, inlineCleanupOnExit: true }); +import { createApp, ref, ui } from "btuin"; + +const TOTAL = 50_000; +const items = Array.from({ length: TOTAL }, (_, i) => `item ${i}`); + +const app = createApp({ + init({ onKey, runtime }) { + const scrollIndex = ref(0); + + onKey((k) => { + if (k.name === "q") runtime.exit(0); + // NOTE: `clampWindowedStartIndex` is a helper to ensure + // the scroll index stays within valid bounds. + if (k.name === "down") scrollIndex.value++; + if (k.name === "up") scrollIndex.value--; + if (k.name === "pagedown") scrollIndex.value += 20; + if (k.name === "pageup") scrollIndex.value -= 20; + }); + + return { scrollIndex }; + }, + render({ scrollIndex }) { + const list = ui.Windowed({ + items, + startIndex: scrollIndex.value, + renderItem: (item) => ui.Text(item), + }); + + return ui.VStack([ + ui.Text(`Displaying ${items.length} items (q to quit)`), + list, + ]); + }, +}); + +await app.mount(); ``` ## API Overview @@ -70,11 +154,9 @@ await app.mount({ inline: true, inlineCleanupOnExit: true }); ## Links -- [**Documentation**](./docs/) (Architecture, Roadmap) -- [**Inline Mode**](./docs/inline-mode.md) -- [**DevTools**](./docs/devtools.md) -- [**Hot Reload**](./docs/hot-reload.md) -- [**GitHub**](https://github.com/HALQME/btuin) (Source Code, Issues) +- [**Architecture**](./docs/architecture.md): Learn about the core design, reactivity system, and rendering pipeline. +- [**Developer Tools**](./docs/devtools.md): See how to use the browser-based inspector and hot reloading. +- [**GitHub**](https://github.com/HALQME/btuin): View the source code, open issues, and contribute. ## Language diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index ae0a88d..604dd1a 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -23,11 +23,11 @@ - [ ] ブラウザ側からのリアクティブ・ステート(Ref)の直接書き換え - [ ] リモートキーイベント送信(ブラウザ仮想キーボードからの入力注入) - [ ] **アーキテクチャ・最適化** - - [ ] **FFI通信の効率化** - - [ ] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) - - [ ] **大規模描画サポート** - - [ ] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 - - [ ] スクロールリージョン(DECSTBM)を活用した高速スクロール + - [x] **FFI通信の効率化** + - [x] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) + - [x] **大規模描画サポート** + - [x] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 + - [x] スクロールリージョン(DECSTBM)を活用した高速スクロール - [ ] **リアクティビティの高度化** - [ ] Effect Scope の導入(コンポーネントに紐付いた Effect の自動追跡・破棄) - [x] **開発体験 (DX) / 大規模開発サポート** @@ -35,7 +35,7 @@ - [x] `Provide/Inject` または `Context API` 相当の依存注入機能 - [ ] **安全性・堅牢性** - [x] FFI 境界の同期テスト - - [ ] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除) + - [x] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除) - [ ] **AI・アクセシビリティ** - [ ] セマンティック・メタデータのサポート(AIエージェントや将来のA11y支援用) - [ ] コンポーネント diff --git a/examples/virtual-list.ts b/examples/virtual-list.ts new file mode 100644 index 0000000..6579586 --- /dev/null +++ b/examples/virtual-list.ts @@ -0,0 +1,59 @@ +import { createApp, ref } from "@/index"; +import { Text, VStack, Windowed, clampWindowedStartIndex, getWindowedMetrics } from "@/view"; + +const TOTAL = 50_000; +const items = Array.from({ length: TOTAL }, (_, i) => `item ${i}`); + +const app = createApp({ + init({ onKey, runtime }) { + const scrollIndex = ref(0); + + onKey((k) => { + if (k.name === "q") runtime.exit(0); + if (k.name === "down") + scrollIndex.value = clampWindowedStartIndex({ + itemCount: items.length, + startIndex: scrollIndex.value + 1, + }); + if (k.name === "up") + scrollIndex.value = clampWindowedStartIndex({ + itemCount: items.length, + startIndex: scrollIndex.value - 1, + }); + if (k.name === "pagedown") + scrollIndex.value = clampWindowedStartIndex({ + itemCount: items.length, + startIndex: scrollIndex.value + 20, + }); + if (k.name === "pageup") + scrollIndex.value = clampWindowedStartIndex({ + itemCount: items.length, + startIndex: scrollIndex.value - 20, + }); + }); + + return { scrollIndex }; + }, + render({ scrollIndex }) { + // Reserve 2 rows for header+status and 2 rows for outline padding (1 top + 1 bottom). + const header = Text(`Windowed: ${items.length} items (q to quit)`).foreground("cyan").shrink(0); + const clamped = getWindowedMetrics({ + itemCount: items.length, + startIndex: scrollIndex.value, + }).startIndex; + const status = Text(`startIndex=${clamped}`).foreground("gray").shrink(0); + + const list = Windowed({ + items, + startIndex: clamped, + itemHeight: 1, + overscan: 2, + keyPrefix: "windowed", + renderItem: (item) => Text(item), + }); + + return VStack([header, status, list]).width("100%").outline({ style: "single", color: "blue" }); + }, +}); + +await app.mount(); diff --git a/scripts/profiler-limit.spec.ts b/scripts/profiler-limit.spec.ts index b94f4ba..a87b0cf 100644 --- a/scripts/profiler-limit.spec.ts +++ b/scripts/profiler-limit.spec.ts @@ -1,74 +1,81 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; -import { Block, Text, createApp, ref } from "@/index"; -import { createNullTerminalAdapter, type ProfilerLog } from "./profiler-core"; +import { Block, Text } from "@/index"; +import { createNullTerminalAdapter } from "./profiler-core"; +import { createRenderer } from "@/runtime/render-loop"; +import { FlatBuffer } from "@/renderer"; +import { Profiler } from "@/runtime/profiler"; +import { setDirtyVersions } from "@/view/dirty"; +import { markLayoutDirty } from "@/view/dirty"; // ---------------------------------------------------------------------------- // 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 FRAMES = 160; // Total frames per run +const START_NODES = 0; // Initial nodes +const STEP_NODES = 200; // Nodes added per frame + +type LimitFrame = { frameMs: number; nodeCount: number }; -const OUTPUT_FILE = `${import.meta.dirname}/profiles/limit-${Date.now()}.json`; +function runSingleIteration(iterationIndex: number): LimitFrame[] { + setDirtyVersions({ layout: 0, render: 0 }); -async function runSingleIteration(iterationIndex: number): Promise { - const tick = ref(0); - let resolveFinished: (() => void) | null = null; - const finished = new Promise((resolve) => { - resolveFinished = resolve; + const terminal = createNullTerminalAdapter({ rows: 40, cols: 120 }); + const profiler = new Profiler({ + enabled: true, + // +1 for the priming renderOnce(true) below. + maxFrames: FRAMES + 1, + nodeCount: true, }); - const app = createApp({ - init() { - let produced = 0; - const timer = setInterval(() => { - tick.value++; - produced++; - if (produced >= FRAMES) { - clearInterval(timer); - resolveFinished?.(); - } - }, INTERVAL_MS); - return {}; - }, - render() { - const count = START_NODES + tick.value * STEP_NODES; - const root = Block().direction("column"); + let tick = 0; + let nodeCount = START_NODES; + const renderer = createRenderer({ + getSize: () => terminal.getTerminalSize(), + write: terminal.write, + view: () => { + const root = Block().direction("column"); const children: ReturnType[] = []; - children.length = count; - for (let i = 0; i < count; i++) { + children.length = nodeCount; + for (let i = 0; i < nodeCount; 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"), - ); + root.children = children; + root.children.unshift(Text(`Iter: ${iterationIndex} Frame: ${tick}`).foreground("cyan")); return root; }, - terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), - profile: { - enabled: true, - outputFile: OUTPUT_FILE, - maxFrames: FRAMES, - nodeCount: true, + getState: () => ({}), + handleError: (ctx) => { + throw ctx.error; + }, + profiler, + deps: { + FlatBuffer, }, }); - await app.mount(); - await finished; - app.unmount(); + // Prime once to establish retained tree and buffers. + renderer.renderOnce(true); - if (!existsSync(OUTPUT_FILE)) { - throw new Error(`Profile output file not found: ${OUTPUT_FILE}`); + // Drive frames synchronously (no timer/coalescing artifacts). + for (let i = 0; i < FRAMES; i++) { + tick = i; + nodeCount = START_NODES + i * STEP_NODES; + // Ensure we actually measure layout + render + diff for scalability limits. + markLayoutDirty(); + renderer.renderOnce(false); } - const fileContent = await Bun.file(OUTPUT_FILE).text(); - return JSON.parse(fileContent) as ProfilerLog; + renderer.dispose(); + + // Drop the priming frame. + const frames = profiler.getFrames().slice(1); + return frames.map((f, idx) => ({ + frameMs: f.frameMs, + nodeCount: START_NODES + idx * STEP_NODES, + })); } function calculateStats(values: number[]) { @@ -104,23 +111,22 @@ describe("Scalability Limit Test (Statistical)", async () => { Bun.gc(true); - const log = await runSingleIteration(i); - - const smoothedFrames = log.frames.map((frame, idx, all) => { + const frames = runSingleIteration(i); + const smoothedFrames = 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 }; + return { ...frame, avgMs }; }); const validFrames = smoothedFrames.slice(5); + const maxNodes = START_NODES + (FRAMES - 1) * STEP_NODES; 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; - + const limitNodeCount = limitFrame ? limitFrame.nodeCount : maxNodes; results[t.fps]!.push(limitNodeCount); } console.log(`Done.`); @@ -137,7 +143,7 @@ describe("Scalability Limit Test (Statistical)", async () => { 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 ? "+" : ""; + const note = stats.avg >= START_NODES + (FRAMES - 1) * STEP_NODES ? "+" : ""; console.log( `| ${t.fps.toString().padStart(3)} FPS | ` + diff --git a/scripts/profiler-scroll.spec.ts b/scripts/profiler-scroll.spec.ts new file mode 100644 index 0000000..1d1a694 --- /dev/null +++ b/scripts/profiler-scroll.spec.ts @@ -0,0 +1,80 @@ +import { test, describe, expect } from "bun:test"; +import { existsSync } from "node:fs"; + +import { createApp, ref, Block, Text, ViewportSlice } from "@/index"; +import { createNullTerminalAdapter, printSummary, type ProfilerLog } from "./profiler-core"; + +const N = 50_000; +const FRAMES = 120; +const INTERVAL_MS = 16; +const HUD = false; +const OUTPUT_FILE = `${import.meta.dirname}/profiles/scroll-${Date.now()}.json`; + +const tick = ref(0); +let resolveFinished: (() => void) | null = null; +const finished = new Promise((resolve) => { + resolveFinished = resolve; +}); + +const items = Array.from({ length: N }, (_, i) => `item ${i}`); +const header = Text("scroll").foreground("cyan"); + +const app = createApp({ + init() { + let produced = 0; + const timer = setInterval(() => { + tick.value++; + produced++; + if (produced >= FRAMES) { + clearInterval(timer); + resolveFinished?.(); + } + }, INTERVAL_MS); + return {}; + }, + render() { + const startIndex = tick.value % (N - 1); + header.content = `scroll n=${N} start=${startIndex}`; + + const root = Block().direction("column"); + root.add(header); + root.add( + ViewportSlice({ + items, + startIndex, + viewportRows: 30, + overscan: 2, + keyPrefix: "items", + renderItem: (item) => Text(item).foreground("gray"), + }), + ); + return root; + }, + terminal: createNullTerminalAdapter({ rows: 40, cols: 120 }), + profile: { + enabled: true, + hud: HUD, + outputFile: OUTPUT_FILE, + maxFrames: FRAMES, + nodeCount: true, + }, +}); + +describe("Windowed Scroll Profiler", async () => { + Bun.gc(true); + await app.mount(); + expect(app.getComponent()).not.toBeNull(); + await finished; + app.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 steadyFrames = frames.slice(5); + + test("Steady State Max (Frame 5+) < 16.7ms (60 FPS)", () => { + const maxSteady = Math.max(...steadyFrames.map((f) => f.frameMs)); + expect(maxSteady).toBeLessThan(16.7); + }); +}); diff --git a/src/layout-engine/index.ts b/src/layout-engine/index.ts index f461d76..e5ef8c7 100644 --- a/src/layout-engine/index.ts +++ b/src/layout-engine/index.ts @@ -18,6 +18,14 @@ enum StyleProp { } const STYLE_STRIDE = StyleProp.TotalProps; +// --- Incremental FFI ops (must match Rust) --- +enum LayoutOp { + CreateLeaf = 1, + UpdateStyle = 2, + SetChildren = 3, + RemoveNode = 4, +} + // --- Helper Functions for Serialization --- function dimToFloat(dim: Dimension | undefined): number { @@ -25,6 +33,85 @@ function dimToFloat(dim: Dimension | undefined): number { return NaN; // Represents 'auto' } +function gapToPair(gap: LayoutStyle["gap"] | undefined): [number, number] { + if (typeof gap === "number") return [gap, gap]; + if (gap && typeof gap === "object") return [gap.height ?? 0, gap.width ?? 0]; + return [0, 0]; +} + +function boxToQuad( + value: LayoutStyle["margin"] | LayoutStyle["padding"] | undefined, +): [number, number, number, number] { + if (typeof value === "number") return [value, value, value, value]; + if (Array.isArray(value) && value.length === 4) return value as [number, number, number, number]; + return [0, 0, 0, 0]; +} + +function writeStyle(out: Float32Array, node: LayoutInputNode) { + out.fill(0); + const style: LayoutStyle = node; + + out[StyleProp.FlexGrow] = style.flexGrow ?? 0; + out[StyleProp.FlexShrink] = style.flexShrink ?? 1; + + const flexDirectionMap: Record = { + row: 0, + column: 1, + "row-reverse": 2, + "column-reverse": 3, + }; + out[StyleProp.FlexDirection] = flexDirectionMap[style.flexDirection ?? "row"] ?? 0; + + const [gapRow, gapColumn] = gapToPair(style.gap); + out[StyleProp.GapRow] = gapRow; + out[StyleProp.GapColumn] = gapColumn; + + const justifyContentMap: Record = { + "flex-start": 0, + "flex-end": 1, + center: 2, + "space-between": 3, + "space-around": 4, + "space-evenly": 5, + }; + out[StyleProp.JustifyContent] = justifyContentMap[style.justifyContent ?? "flex-start"] ?? 0; + + const alignItemsMap: Record = { + "flex-start": 0, + "flex-end": 1, + center: 2, + baseline: 3, + stretch: 4, + }; + out[StyleProp.AlignItems] = alignItemsMap[style.alignItems ?? "stretch"] ?? 4; + + const positionTypeMap: Record = { + relative: 0, + absolute: 1, + }; + out[StyleProp.PositionType] = positionTypeMap[style.position ?? "relative"] ?? 0; + + out[StyleProp.Width] = dimToFloat(style.width); + out[StyleProp.Height] = dimToFloat(style.height); + + const marginArr = boxToQuad(style.margin); + out.set(marginArr, StyleProp.MarginLeft); + + const paddingArr = boxToQuad(style.padding); + out.set(paddingArr, StyleProp.PaddingLeft); +} + +function sameFloat(a: number, b: number): boolean { + return a === b || (Number.isNaN(a) && Number.isNaN(b)); +} + +function sameStyle(a: Float32Array, b: Float32Array): boolean { + for (let i = 0; i < STYLE_STRIDE; i++) { + if (!sameFloat(a[i]!, b[i]!)) return false; + } + return true; +} + function serializeTree(root: LayoutInputNode): { flatNodes: LayoutInputNode[]; nodesBuffer: Float32Array; @@ -68,10 +155,9 @@ function serializeTree(root: LayoutInputNode): { nodesBuffer[offset + StyleProp.FlexDirection] = flexDirectionMap[style.flexDirection ?? "row"] ?? 0; - const gap = style.gap ?? 0; - const gapArr = Array.isArray(gap) ? gap : [gap, gap]; - nodesBuffer[offset + StyleProp.GapRow] = gapArr[0]; - nodesBuffer[offset + StyleProp.GapColumn] = gapArr[1]; + const [gapRow, gapColumn] = gapToPair(style.gap); + nodesBuffer[offset + StyleProp.GapRow] = gapRow; + nodesBuffer[offset + StyleProp.GapColumn] = gapColumn; const justifyContentMap: Record = { "flex-start": 0, @@ -103,12 +189,10 @@ function serializeTree(root: LayoutInputNode): { nodesBuffer[offset + StyleProp.Width] = dimToFloat(style.width); nodesBuffer[offset + StyleProp.Height] = dimToFloat(style.height); - const margin = style.margin ?? 0; - const marginArr = Array.isArray(margin) ? margin : [margin, margin, margin, margin]; + const marginArr = boxToQuad(style.margin); nodesBuffer.set(marginArr, offset + StyleProp.MarginLeft); - const padding = style.padding ?? 0; - const paddingArr = Array.isArray(padding) ? padding : [padding, padding, padding, padding]; + const paddingArr = boxToQuad(style.padding); nodesBuffer.set(paddingArr, offset + StyleProp.PaddingLeft); const children = node.children ?? []; @@ -155,6 +239,18 @@ const { symbols } = dlopen(libPath(), { args: [FFIType.ptr, FFIType.ptr, FFIType.u64, FFIType.ptr, FFIType.u64], returns: FFIType.i32, }, + apply_ops_and_compute: { + args: [ + FFIType.ptr, + FFIType.ptr, + FFIType.u64, + FFIType.ptr, + FFIType.u64, + FFIType.ptr, + FFIType.u64, + ], + returns: FFIType.i32, + }, get_results_ptr: { args: [FFIType.ptr], returns: FFIType.ptr }, get_results_len: { args: [FFIType.ptr], returns: FFIType.u64 }, }); @@ -170,6 +266,12 @@ const registry = new FinalizationRegistry((enginePtr: import("bun:ffi").Pointer) class LayoutEngineJS { private enginePtr: import("bun:ffi").Pointer | null; + private keyToId = new Map(); + private idToKey = new Map(); + private prevStyle = new Map(); + private prevChildren = new Map(); + private freeIds: number[] = []; + private nextId = 1; constructor() { this.enginePtr = symbols.create_engine(); @@ -178,6 +280,28 @@ class LayoutEngineJS { } compute(root: LayoutInputNode): ComputedLayout { + if (!this.enginePtr) throw new Error("Layout engine has been destroyed."); + if (process.env.BTUIN_DISABLE_FFI_DIRTY_CHECKING === "1") { + return this.computeFull(root); + } + try { + return this.computeDirty(root); + } catch { + this.resetDirtyCache(); + return this.computeFull(root); + } + } + + private resetDirtyCache() { + this.keyToId.clear(); + this.idToKey.clear(); + this.prevStyle.clear(); + this.prevChildren.clear(); + this.freeIds.length = 0; + this.nextId = 1; + } + + private computeFull(root: LayoutInputNode): ComputedLayout { if (!this.enginePtr) throw new Error("Layout engine has been destroyed."); const { flatNodes, nodesBuffer, childrenBuffer } = serializeTree(root); @@ -193,6 +317,155 @@ class LayoutEngineJS { throw new Error(`Layout computation failed with status: ${status}`); } + const computedLayout: ComputedLayout = {}; + this.readResultsToLayout(computedLayout, (id) => { + const node = flatNodes[id]; + return node?.key ?? node?.identifier; + }); + return computedLayout; + } + + private computeDirty(root: LayoutInputNode): ComputedLayout { + if (!this.enginePtr) throw new Error("Layout engine has been destroyed."); + + const rootKey = root.key ?? root.identifier; + if (!rootKey) return this.computeFull(root); + + const prevRootKey = this.idToKey.get(0); + if (prevRootKey && prevRootKey !== rootKey) { + this.keyToId.delete(prevRootKey); + } + this.keyToId.set(rootKey, 0); + this.idToKey.set(0, rootKey); + + const seen = new Set(); + + const ensureId = (key: string): number => { + const existing = this.keyToId.get(key); + if (existing !== undefined) return existing; + const id = this.freeIds.pop() ?? this.nextId++; + this.keyToId.set(key, id); + this.idToKey.set(id, key); + return id; + }; + + const styleScratch = new Float32Array(STYLE_STRIDE); + const stylePayloads: Float32Array[] = []; + const childrenPayloads: number[][] = []; + const ops: number[] = []; + let childrenCursor = 0; + + const pushStyle = (style: Float32Array): number => { + const offset = stylePayloads.length * STYLE_STRIDE; + stylePayloads.push(style); + return offset; + }; + const pushChildren = (children: number[]): { offset: number; count: number } => { + const offset = childrenCursor; + childrenPayloads.push(children); + childrenCursor += children.length; + return { offset, count: children.length }; + }; + + const visit = (node: LayoutInputNode): number => { + const key = node.key ?? node.identifier; + if (!key) throw new Error("[btuin] LayoutInputNode must have key or identifier."); + const id = ensureId(key); + seen.add(id); + + writeStyle(styleScratch, node); + const prev = this.prevStyle.get(id); + if (!prev) { + const stored = new Float32Array(STYLE_STRIDE); + stored.set(styleScratch); + this.prevStyle.set(id, stored); + const styleOffset = pushStyle(stored); + ops.push(LayoutOp.CreateLeaf, id, styleOffset); + } else if (!sameStyle(prev, styleScratch)) { + prev.set(styleScratch); + const styleOffset = pushStyle(prev); + ops.push(LayoutOp.UpdateStyle, id, styleOffset); + } + + const childrenIds = (node.children ?? []).map(visit); + const prevKids = this.prevChildren.get(id) ?? []; + let sameKids = prevKids.length === childrenIds.length; + if (sameKids) { + for (let i = 0; i < childrenIds.length; i++) { + if (prevKids[i] !== childrenIds[i]) { + sameKids = false; + break; + } + } + } + if (!sameKids) { + const { offset, count } = pushChildren(childrenIds); + ops.push(LayoutOp.SetChildren, id, offset, count); + this.prevChildren.set(id, childrenIds); + } + return id; + }; + + visit(root); + + // removals (after parents are updated) + for (const id of this.prevStyle.keys()) { + if (id === 0) continue; + if (seen.has(id)) continue; + ops.push(LayoutOp.RemoveNode, id); + this.prevStyle.delete(id); + this.prevChildren.delete(id); + const key = this.idToKey.get(id); + if (key) this.keyToId.delete(key); + this.idToKey.delete(id); + this.freeIds.push(id); + } + + if (ops.length === 0) { + const computedLayout: ComputedLayout = {}; + this.readResultsToLayout(computedLayout, (id) => this.idToKey.get(id)); + return computedLayout; + } + + const opsBuf = new Uint32Array(ops); + const styles = new Float32Array(stylePayloads.length * STYLE_STRIDE); + for (let i = 0; i < stylePayloads.length; i++) { + styles.set(stylePayloads[i]!, i * STYLE_STRIDE); + } + + const children = new Uint32Array(childrenCursor); + { + let offset = 0; + for (const arr of childrenPayloads) { + children.set(arr, offset); + offset += arr.length; + } + } + + const status = symbols.apply_ops_and_compute( + this.enginePtr, + opsBuf.length > 0 ? ptr(opsBuf) : null, + opsBuf.length, + styles.length > 0 ? ptr(styles) : null, + styles.length, + children.length > 0 ? ptr(children) : null, + children.length, + ); + + if (status !== 0) { + this.resetDirtyCache(); + return this.computeFull(root); + } + + const computedLayout: ComputedLayout = {}; + this.readResultsToLayout(computedLayout, (id) => this.idToKey.get(id)); + return computedLayout; + } + + private readResultsToLayout( + target: ComputedLayout, + resolveKey: (id: number) => string | undefined, + ) { const resultsPtr = symbols.get_results_ptr(this.enginePtr); const resultsLenU64 = symbols.get_results_len(this.enginePtr); const resultsLen = Number(resultsLenU64); @@ -200,7 +473,7 @@ class LayoutEngineJS { throw new Error(`Unexpected results length (u64): ${resultsLenU64.toString()}`); } - if (!resultsPtr || resultsLen === 0) return {}; + if (!resultsPtr || resultsLen === 0) return; const resultsArrayBuffer = toArrayBuffer( resultsPtr, @@ -209,7 +482,6 @@ class LayoutEngineJS { ); const resultsBuffer = new Float32Array(resultsArrayBuffer); - const computedLayout: ComputedLayout = {}; const resultStride = 5; // js_id, x, y, width, height const snap = (value: number): number => { @@ -220,21 +492,15 @@ class LayoutEngineJS { for (let i = 0; i < resultsLen; i += resultStride) { const jsId = resultsBuffer[i]!; - const node = flatNodes[jsId]; - if (!node) continue; - - const key = node.key ?? node.identifier; - if (key) { - computedLayout[key] = { - x: snap(resultsBuffer[i + 1]!), - y: snap(resultsBuffer[i + 2]!), - width: snap(resultsBuffer[i + 3]!), - height: snap(resultsBuffer[i + 4]!), - }; - } + const key = resolveKey(jsId); + if (!key) continue; + target[key] = { + x: snap(resultsBuffer[i + 1]!), + y: snap(resultsBuffer[i + 2]!), + width: snap(resultsBuffer[i + 3]!), + height: snap(resultsBuffer[i + 4]!), + }; } - - return computedLayout; } destroy() { diff --git a/src/layout-engine/src/lib.rs b/src/layout-engine/src/lib.rs index 8bf565a..50fd0cc 100644 --- a/src/layout-engine/src/lib.rs +++ b/src/layout-engine/src/lib.rs @@ -38,7 +38,15 @@ const STYLE_STRIDE: usize = StyleProp::TotalProps as usize; const RESULT_STRIDE: usize = 5; // js_id, x, y, width, height // Increment this when changing any exported FFI surface or buffer layout. -const LAYOUT_ENGINE_ABI_VERSION: u32 = 1; +const LAYOUT_ENGINE_ABI_VERSION: u32 = 2; + +#[repr(u32)] +enum OpCode { + CreateLeaf = 1, + UpdateStyle = 2, + SetChildren = 3, + RemoveNode = 4, +} pub struct LayoutEngineState { taffy: TaffyTree, @@ -56,55 +64,8 @@ impl LayoutEngineState { results_buffer: Vec::with_capacity(15000 * 5), } } -} - -#[unsafe(no_mangle)] -pub extern "C" fn create_engine() -> *mut LayoutEngineState { - Box::into_raw(Box::new(LayoutEngineState::new())) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn destroy_engine(ptr: *mut LayoutEngineState) { - if ptr.is_null() { - return; - } - unsafe { - drop(Box::from_raw(ptr)); - } -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn compute_layout_from_buffers( - engine_ptr: *mut LayoutEngineState, - nodes_buffer_ptr: *const f32, - nodes_buffer_len: usize, - children_buffer_ptr: *const u32, - children_buffer_len: usize, -) -> i32 { - if engine_ptr.is_null() { - return -1; - } - - let (engine, nodes_buffer, children_buffer) = unsafe { - ( - &mut *engine_ptr, - std::slice::from_raw_parts(nodes_buffer_ptr, nodes_buffer_len), - std::slice::from_raw_parts(children_buffer_ptr, children_buffer_len), - ) - }; - - let node_count = nodes_buffer_len / STYLE_STRIDE; - if nodes_buffer_len % STYLE_STRIDE != 0 { - return -2; - } - - engine.nodes.clear(); - engine.node_id_map.clear(); - engine.taffy.clear(); - for i in 0..node_count { - let node_id = i as u32; - let style_slice = &nodes_buffer[i * STYLE_STRIDE..(i + 1) * STYLE_STRIDE]; + fn style_from_slice(style_slice: &[f32]) -> Style { let mut style = Style::default(); let width = style_slice[StyleProp::Width as usize]; @@ -169,6 +130,84 @@ pub unsafe extern "C" fn compute_layout_from_buffers( bottom: length(style_slice[StyleProp::PaddingBottom as usize]), }; + style + } + + fn compute_results(&mut self, root_node: NodeId) { + self.taffy + .compute_layout(root_node, Size::MAX_CONTENT) + .unwrap(); + + self.results_buffer.clear(); + for (taffy_id, js_id) in &self.node_id_map { + if let Ok(layout) = self.taffy.layout(*taffy_id) { + self.results_buffer.push(*js_id as f32); + self.results_buffer.push(layout.location.x); + self.results_buffer.push(layout.location.y); + self.results_buffer.push(layout.size.width); + self.results_buffer.push(layout.size.height); + } + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn create_engine() -> *mut LayoutEngineState { + Box::into_raw(Box::new(LayoutEngineState::new())) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_engine(ptr: *mut LayoutEngineState) { + if ptr.is_null() { + return; + } + unsafe { + drop(Box::from_raw(ptr)); + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn compute_layout_from_buffers( + engine_ptr: *mut LayoutEngineState, + nodes_buffer_ptr: *const f32, + nodes_buffer_len: usize, + children_buffer_ptr: *const u32, + children_buffer_len: usize, +) -> i32 { + if engine_ptr.is_null() { + return -1; + } + + let engine = unsafe { &mut *engine_ptr }; + let nodes_buffer: &[f32] = if nodes_buffer_len == 0 { + &[] + } else if nodes_buffer_ptr.is_null() { + return -4; + } else { + unsafe { std::slice::from_raw_parts(nodes_buffer_ptr, nodes_buffer_len) } + }; + let children_buffer: &[u32] = if children_buffer_len == 0 { + &[] + } else if children_buffer_ptr.is_null() { + return -5; + } else { + unsafe { std::slice::from_raw_parts(children_buffer_ptr, children_buffer_len) } + }; + + let node_count = nodes_buffer_len / STYLE_STRIDE; + if nodes_buffer_len % STYLE_STRIDE != 0 { + return -2; + } + + engine.nodes.clear(); + engine.node_id_map.clear(); + engine.taffy.clear(); + + for i in 0..node_count { + let node_id = i as u32; + let style_slice = &nodes_buffer[i * STYLE_STRIDE..(i + 1) * STYLE_STRIDE]; + let style = LayoutEngineState::style_from_slice(style_slice); + let taffy_node = engine.taffy.new_leaf(style).unwrap(); engine.nodes.insert(node_id, taffy_node); engine.node_id_map.insert(taffy_node, node_id); @@ -196,26 +235,149 @@ pub unsafe extern "C" fn compute_layout_from_buffers( } } - if let Some(root_node) = engine.nodes.get(&0) { - engine - .taffy - .compute_layout(*root_node, Size::MAX_CONTENT) - .unwrap(); - } else { + let Some(root_node) = engine.nodes.get(&0).copied() else { return -3; + }; + + engine.compute_results(root_node); + + 0 +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn apply_ops_and_compute( + engine_ptr: *mut LayoutEngineState, + ops_ptr: *const u32, + ops_len: usize, + styles_ptr: *const f32, + styles_len: usize, + children_ptr: *const u32, + children_len: usize, +) -> i32 { + if engine_ptr.is_null() { + return -1; } - engine.results_buffer.clear(); - for (taffy_id, js_id) in &engine.node_id_map { - if let Ok(layout) = engine.taffy.layout(*taffy_id) { - engine.results_buffer.push(*js_id as f32); - engine.results_buffer.push(layout.location.x); - engine.results_buffer.push(layout.location.y); - engine.results_buffer.push(layout.size.width); - engine.results_buffer.push(layout.size.height); + let engine = unsafe { &mut *engine_ptr }; + let ops: &[u32] = if ops_len == 0 { + &[] + } else if ops_ptr.is_null() { + return -6; + } else { + unsafe { std::slice::from_raw_parts(ops_ptr, ops_len) } + }; + let styles: &[f32] = if styles_len == 0 { + &[] + } else if styles_ptr.is_null() { + return -7; + } else { + unsafe { std::slice::from_raw_parts(styles_ptr, styles_len) } + }; + let children: &[u32] = if children_len == 0 { + &[] + } else if children_ptr.is_null() { + return -8; + } else { + unsafe { std::slice::from_raw_parts(children_ptr, children_len) } + }; + + let mut i = 0; + while i < ops.len() { + let opcode = ops[i]; + i += 1; + + match opcode { + x if x == OpCode::CreateLeaf as u32 => { + if i + 2 > ops.len() { + return -10; + } + let node_id = ops[i]; + let style_offset = ops[i + 1] as usize; + i += 2; + + if style_offset + STYLE_STRIDE > styles.len() { + return -11; + } + + let style = LayoutEngineState::style_from_slice( + &styles[style_offset..style_offset + STYLE_STRIDE], + ); + + let taffy_node = engine.taffy.new_leaf(style).unwrap(); + engine.nodes.insert(node_id, taffy_node); + engine.node_id_map.insert(taffy_node, node_id); + } + x if x == OpCode::UpdateStyle as u32 => { + if i + 2 > ops.len() { + return -12; + } + let node_id = ops[i]; + let style_offset = ops[i + 1] as usize; + i += 2; + + if style_offset + STYLE_STRIDE > styles.len() { + return -13; + } + + let Some(taffy_node) = engine.nodes.get(&node_id).copied() else { + return -14; + }; + + let style = LayoutEngineState::style_from_slice( + &styles[style_offset..style_offset + STYLE_STRIDE], + ); + engine.taffy.set_style(taffy_node, style).unwrap(); + } + x if x == OpCode::SetChildren as u32 => { + if i + 3 > ops.len() { + return -15; + } + let node_id = ops[i]; + let children_offset = ops[i + 1] as usize; + let children_count = ops[i + 2] as usize; + i += 3; + + let Some(taffy_node) = engine.nodes.get(&node_id).copied() else { + return -16; + }; + + if children_offset + children_count > children.len() { + return -17; + } + + let mut taffy_children: Vec = Vec::with_capacity(children_count); + for child_id in &children[children_offset..children_offset + children_count] { + let Some(child_node) = engine.nodes.get(child_id).copied() else { + return -18; + }; + taffy_children.push(child_node); + } + engine + .taffy + .set_children(taffy_node, &taffy_children) + .unwrap(); + } + x if x == OpCode::RemoveNode as u32 => { + if i + 1 > ops.len() { + return -19; + } + let node_id = ops[i]; + i += 1; + + if let Some(taffy_node) = engine.nodes.remove(&node_id) { + engine.node_id_map.remove(&taffy_node); + let _ = engine.taffy.remove(taffy_node); + } + } + _ => return -20, } } + let Some(root_node) = engine.nodes.get(&0).copied() else { + return -3; + }; + + engine.compute_results(root_node); 0 } diff --git a/src/layout/renderer.ts b/src/layout/renderer.ts index abf37da..a6c847d 100644 --- a/src/layout/renderer.ts +++ b/src/layout/renderer.ts @@ -1,8 +1,126 @@ import type { ComputedLayout } from "../layout-engine/types"; -import { drawText, fillRect } from "../renderer"; +import { measureGraphemeWidth, resolveColor, segmentGraphemes } from "../renderer"; import type { Buffer2D } from "../renderer/types"; +import type { ColorValue } from "../renderer/types/color"; import { isBlock, isText, type ViewElement } from "../view/types/elements"; +type Rect = { x: number; y: number; width: number; height: number }; + +function intersectRect(a: Rect, b: Rect): Rect | null { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.width, b.x + b.width); + const y2 = Math.min(a.y + a.height, b.y + b.height); + const width = x2 - x1; + const height = y2 - y1; + if (width <= 0 || height <= 0) return null; + return { x: x1, y: y1, width, height }; +} + +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 }; +} + +function fillRectClipped( + buffer: Buffer2D, + rect: Rect, + char: string, + style: { fg?: ColorValue; bg?: ColorValue } | undefined, + clip: Rect, +) { + const intersection = intersectRect(rect, clip); + if (!intersection) return; + + const hasFg = style ? Object.prototype.hasOwnProperty.call(style, "fg") : false; + const hasBg = style ? Object.prototype.hasOwnProperty.call(style, "bg") : false; + 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; + + for (let r = intersection.y; r < intersection.y + intersection.height; r++) { + for (let c = intersection.x; c < intersection.x + intersection.width; c++) { + buffer.set(r, c, char, resolvedStyle); + } + } +} + +function drawTextClipped( + buffer: Buffer2D, + row: number, + col: number, + text: string, + style: { fg?: ColorValue; bg?: ColorValue } | undefined, + clip: Rect, +) { + row = Math.floor(row); + col = Math.floor(col); + if (row < clip.y || row >= clip.y + clip.height) return; + if (buffer.cols === 0) return; + + const hasFg = style ? Object.prototype.hasOwnProperty.call(style, "fg") : false; + const hasBg = style ? Object.prototype.hasOwnProperty.call(style, "bg") : false; + 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. + let isAscii = true; + for (let i = 0; i < text.length; i++) { + 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 >= clip.x + clip.width) break; + if (currentCol >= clip.x && currentCol < clip.x + clip.width) { + buffer.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 >= clip.x + clip.width) break; + if (currentCol + width <= clip.x) { + currentCol += width; + continue; + } + + // Only draw when the glyph origin is inside the clip; partial wide glyphs are skipped. + if (currentCol >= clip.x && currentCol + width <= clip.x + clip.width) { + buffer.set(row, currentCol, segment, resolvedStyle); + } + currentCol += width; + } +} + /** * Draw the element tree to the buffer. */ @@ -12,6 +130,7 @@ export function renderElement( layoutMap: ComputedLayout, _parentX = 0, _parentY = 0, + clipRect: Rect = { x: 0, y: 0, width: buffer.cols, height: buffer.rows }, ) { const key = element.identifier; if (!key) return; @@ -33,69 +152,98 @@ export function renderElement( return; } + const elementRect: Rect = { + x: Math.floor(absX), + y: Math.floor(absY), + width: Math.floor(width), + height: Math.floor(height), + }; + const elementClip = intersectRect(clipRect, elementRect); + if (!elementClip) return; + const bg = element.style?.background; if (bg !== undefined) { - fillRect( + fillRectClipped( buffer, - Math.floor(absY), - Math.floor(absX), - Math.floor(width), - Math.floor(height), + elementRect, " ", { bg, fg: undefined, }, + elementClip, ); } - const outline = element.style?.outline; - if (outline) { - const { color, style = "single" } = outline; - const chars = - style === "double" - ? { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" } - : { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" }; - - const x = Math.floor(absX); - const y = Math.floor(absY); - const w = Math.floor(width); - const h = Math.floor(height); - - const borderStyle = color !== undefined ? { fg: color } : undefined; - - fillRect(buffer, y, x, w, 1, chars.h, borderStyle); - fillRect(buffer, y + h - 1, x, w, 1, chars.h, borderStyle); - fillRect(buffer, y, x, 1, h, chars.v, borderStyle); - fillRect(buffer, y, x + w - 1, 1, h, chars.v, borderStyle); - - drawText(buffer, y, x, chars.tl, borderStyle); - drawText(buffer, y, x + w - 1, chars.tr, borderStyle); - drawText(buffer, y + h - 1, x, chars.bl, borderStyle); - drawText(buffer, y + h - 1, x + w - 1, chars.br, borderStyle); - } - if (isText(element)) { const fg = element.style?.foreground; const bg = element.style?.background; const style: { fg?: string | number; bg?: string | number } = {}; if (fg !== undefined) style.fg = fg; if (bg !== undefined) style.bg = bg; - drawText(buffer, Math.floor(absY), Math.floor(absX), element.content, style); + drawTextClipped(buffer, absY, absX, element.content, style, elementClip); } if (isBlock(element)) { + const pad = resolvePadding(element.style?.padding); + const contentRect: Rect = { + x: elementRect.x + Math.floor(pad.left), + y: elementRect.y + Math.floor(pad.top), + width: Math.max(0, elementRect.width - Math.floor(pad.left) - Math.floor(pad.right)), + height: Math.max(0, elementRect.height - Math.floor(pad.top) - Math.floor(pad.bottom)), + }; + const childClip = intersectRect(elementClip, contentRect); + if (!childClip) return; + const stack = element.style?.stack; if (stack === "z") { - // Layout engine already overlays children (absolute positioning). - // Keep normal render recursion so child layout positions are respected. for (const child of element.children) { - renderElement(child, buffer, layoutMap, absX, absY); + renderElement(child, buffer, layoutMap, absX, absY, childClip); } } else { for (const child of element.children) { - renderElement(child, buffer, layoutMap, absX, absY); + renderElement(child, buffer, layoutMap, absX, absY, childClip); } } } + + // Draw outline last so it always stays visible above contents. + const outline = element.style?.outline; + if (outline) { + const { color, style = "single" } = outline; + const chars = + style === "double" + ? { h: "═", v: "║", tl: "╔", tr: "╗", bl: "╚", br: "╝" } + : { h: "─", v: "│", tl: "┌", tr: "┐", bl: "└", br: "┘" }; + + const x = elementRect.x; + const y = elementRect.y; + const w = elementRect.width; + const h = elementRect.height; + + const borderStyle = + color !== undefined ? ({ fg: color } satisfies { fg?: ColorValue }) : undefined; + + fillRectClipped(buffer, { x, y, width: w, height: 1 }, chars.h, borderStyle, elementClip); + fillRectClipped( + buffer, + { x, y: y + h - 1, width: w, height: 1 }, + chars.h, + borderStyle, + elementClip, + ); + fillRectClipped(buffer, { x, y, width: 1, height: h }, chars.v, borderStyle, elementClip); + fillRectClipped( + buffer, + { x: x + w - 1, y, width: 1, height: h }, + chars.v, + borderStyle, + elementClip, + ); + + drawTextClipped(buffer, y, x, chars.tl, borderStyle, elementClip); + drawTextClipped(buffer, y, x + w - 1, chars.tr, borderStyle, elementClip); + drawTextClipped(buffer, y + h - 1, x, chars.bl, borderStyle, elementClip); + drawTextClipped(buffer, y + h - 1, x + w - 1, chars.br, borderStyle, elementClip); + } } diff --git a/src/renderer/buffer.ts b/src/renderer/buffer.ts index abb5113..a1fc5cc 100644 --- a/src/renderer/buffer.ts +++ b/src/renderer/buffer.ts @@ -208,4 +208,102 @@ export class FlatBuffer { nextCol++; } } + + copyFrom(other: FlatBuffer): void { + if (this.rows !== other.rows || this.cols !== other.cols) { + throw new Error("[btuin] FlatBuffer.copyFrom: size mismatch"); + } + this.codes.set(other.codes); + this.widths.set(other.widths); + this.extras.clear(); + for (const [idx, value] of other.extras) { + this.extras.set(idx, value); + } + for (let i = 0; i < this.fg.length; i++) { + this.fg[i] = other.fg[i]; + this.bg[i] = other.bg[i]; + } + this.asciiOnly = other.asciiOnly; + } + + clearRow(row: number): void { + if (row < 0 || row >= this.rows) return; + const start = row * this.cols; + const end = start + this.cols; + this.codes.fill(32, start, end); + this.widths.fill(1, start, end); + for (let i = start; i < end; i++) { + this.extras.delete(i); + this.fg[i] = undefined; + this.bg[i] = undefined; + } + } + + /** + * Scroll a full-width band of rows from `source` into this buffer. + * + * This matches terminal scroll region constraints (DECSTBM): row-only bands, + * no column sub-rectangles. + */ + scrollRowsFrom(source: FlatBuffer, top: number, bottom: number, dy: number): void { + if (this.rows !== source.rows || this.cols !== source.cols) { + throw new Error("[btuin] FlatBuffer.scrollRowsFrom: size mismatch"); + } + if (!Number.isFinite(dy)) return; + dy = Math.trunc(dy); + if (dy === 0) return; + + top = Math.max(0, Math.trunc(top)); + bottom = Math.min(this.rows - 1, Math.trunc(bottom)); + if (bottom < top) return; + + const height = bottom - top + 1; + const shift = Math.abs(dy); + if (shift >= height) { + for (let r = top; r <= bottom; r++) this.clearRow(r); + return; + } + + if (dy < 0) { + // Content moves up: dest top..bottom-shift gets source top+shift..bottom + for (let r = top; r <= bottom - shift; r++) { + this.copyRowFrom(source, r - dy, r); + } + for (let r = bottom - shift + 1; r <= bottom; r++) { + this.clearRow(r); + } + } else { + // Content moves down: dest top+shift..bottom gets source top..bottom-shift + for (let r = bottom; r >= top + shift; r--) { + this.copyRowFrom(source, r - dy, r); + } + for (let r = top; r < top + shift; r++) { + this.clearRow(r); + } + } + } + + private copyRowFrom(source: FlatBuffer, sourceRow: number, destRow: number) { + if (sourceRow < 0 || sourceRow >= source.rows) { + this.clearRow(destRow); + return; + } + + const srcStart = sourceRow * source.cols; + const dstStart = destRow * this.cols; + const len = this.cols; + + this.codes.set(source.codes.subarray(srcStart, srcStart + len), dstStart); + this.widths.set(source.widths.subarray(srcStart, srcStart + len), dstStart); + for (let i = 0; i < len; i++) { + const srcIdx = srcStart + i; + const dstIdx = dstStart + i; + this.fg[dstIdx] = source.fg[srcIdx]; + this.bg[dstIdx] = source.bg[srcIdx]; + this.extras.delete(dstIdx); + const extra = source.extras.get(srcIdx); + if (extra !== undefined) this.extras.set(dstIdx, extra); + } + this.asciiOnly = this.asciiOnly && source.asciiOnly; + } } diff --git a/src/renderer/diff.ts b/src/renderer/diff.ts index 5aa0792..b1e393e 100644 --- a/src/renderer/diff.ts +++ b/src/renderer/diff.ts @@ -8,9 +8,26 @@ export interface DiffStats { fgChanges: number; bgChanges: number; resets: number; + scrollOps?: number; ops: number; } +export interface RenderDiffOptions { + /** + * When provided, DECSTBM scroll optimization is only attempted within this band + * (0-based inclusive rows). This prevents false-positive scrolling and reduces + * detection overhead for UIs that do not use scroll regions. + */ + scrollRegion?: { top: number; bottom: number }; + /** + * Explicit DECSTBM scroll operation (0-based inclusive rows, and signed delta). + * + * When provided, scroll detection is skipped and this operation is used + * directly. The caller is responsible for ensuring it is safe/correct. + */ + scrollOp?: { top: number; bottom: number; delta: number }; +} + /** * Renders the difference between two buffers, only updating changed cells. * If buffer sizes differ (e.g., after terminal resize), forces a full redraw. @@ -25,8 +42,14 @@ export interface DiffStats { * @param prev - Previous buffer state * @param next - New buffer state to render * @param stats - Optional stats collector + * @param options - Optional scroll/optimization hints */ -export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): string { +export function renderDiff( + prev: Buffer2D, + next: Buffer2D, + stats?: DiffStats, + options?: RenderDiffOptions, +): string { const rows = next.rows; const cols = next.cols; if (rows === 0 || cols === 0) return ""; @@ -43,14 +66,64 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): s stats.fgChanges = 0; stats.bgChanges = 0; stats.resets = 0; + stats.scrollOps = 0; stats.ops = 0; } const asciiFastPath = prev.isAsciiOnly() && next.isAsciiOnly(); + const hint = options?.scrollRegion; + const explicit = options?.scrollOp; + const allowAuto = process.env.BTUIN_DECSTBM_AUTO === "1"; + + const scroll = + !sizeChanged && process.env.BTUIN_DISABLE_DECSTBM !== "1" + ? explicit + ? normalizeScrollOp(explicit, rows) + : hint || allowAuto + ? detectVerticalScrollRegion(prev, next, asciiFastPath, hint) + : null + : null; + const rowMap = scroll ? buildScrollRowMap(rows, scroll) : null; + const scrollPrefix = scroll ? buildDecstbmScrollPrefix(scroll) : ""; + + if (sizeChanged) { + const fullOutput = asciiFastPath + ? renderFullRedrawAscii(next, rows, cols, stats) + : renderFullRedraw(next, rows, cols, stats); + if (stats) { + stats.ops = + stats.cursorMoves + + stats.fgChanges + + stats.bgChanges + + stats.resets + + (stats.scrollOps ?? 0); + } + if (scrollPrefix) { + // Size changes never attempt DECSTBM; keep behavior consistent if caller + // passed an options object that would otherwise scroll. + return `${scrollPrefix}${fullOutput}`; + } + return fullOutput; + } + if (asciiFastPath) { - const asciiOutput = renderDiffAscii(prev, next, rows, cols, sizeChanged, stats); + const asciiOutput = renderDiffAscii( + prev, + next, + rows, + cols, + sizeChanged, + stats, + rowMap, + scrollPrefix, + ); if (stats) { - stats.ops = stats.cursorMoves + stats.fgChanges + stats.bgChanges + stats.resets; + stats.ops = + stats.cursorMoves + + stats.fgChanges + + stats.bgChanges + + stats.resets + + (stats.scrollOps ?? 0); } return asciiOutput; } @@ -58,78 +131,104 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): s let currentFg: string | undefined; let currentBg: string | undefined; let styleDirty = false; + let cursorRow = -1; + let cursorCol = -1; + let pendingText = ""; // Local output buffer to batch terminal writes const out: string[] = []; + const flushText = () => { + if (!pendingText) return; + out.push(pendingText); + pendingText = ""; + }; + const pushCtl = (seq: string) => { + flushText(); + out.push(seq); + }; + if (scrollPrefix) { + pushCtl(scrollPrefix); + if (stats) stats.scrollOps = (stats.scrollOps ?? 0) + 5; + } + + const moveTo = (r: number, c: number) => { + if (cursorRow === r && cursorCol === c) return; + pushCtl(`\x1b[${r + 1};${c + 1}H`); + cursorRow = r; + cursorCol = c; + if (stats) stats.cursorMoves++; + }; for (let r = 0; r < rows; r++) { + let inRun = false; for (let c = 0; c < cols; c++) { // Avoid printing into the terminal's bottom-right cell, which can trigger // an implicit line wrap/scroll on some terminals. - if (r === rows - 1 && c === cols - 1) continue; + if (r === rows - 1 && c === cols - 1) { + inRun = false; + continue; + } const idx = r * cols + c; - - const nextWidth = next.widths[idx]; + const nextWidth = next.widths[idx] ?? 1; if (nextWidth === 0) continue; - const prevWidth = prev.widths[idx] ?? 0; + const prevIdx = mapPrevIndex(rowMap, cols, r, c); + const prevWidth = prevIdx === -1 ? 1 : (prev.widths[prevIdx] ?? 0); const nextGlyphKey = next.glyphKeyAtIndex(idx); - const prevGlyphKey = prev.glyphKeyAtIndex(idx); + const prevGlyphKey = prevIdx === -1 ? 32 : prev.glyphKeyAtIndex(prevIdx); const nextFg = next.fg[idx]; const nextBg = next.bg[idx]; - const prevFg = prev.fg[idx]; - const prevBg = prev.bg[idx]; + const prevFg = prevIdx === -1 ? undefined : prev.fg[prevIdx]; + const prevBg = prevIdx === -1 ? undefined : prev.bg[prevIdx]; 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`); - - 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(next.glyphStringAtIndex(idx)); + if (!needsDraw) { + inRun = false; + continue; } + + if (stats) stats.changedCells++; + + if (!inRun) { + moveTo(r, c); + inRun = true; + } + + if (nextFg !== currentFg) { + pushCtl(nextFg === undefined ? "\x1b[39m" : nextFg); + currentFg = nextFg; + styleDirty = true; + if (stats) stats.fgChanges++; + } + if (nextBg !== currentBg) { + pushCtl(nextBg === undefined ? "\x1b[49m" : nextBg); + currentBg = nextBg; + styleDirty = true; + if (stats) stats.bgChanges++; + } + + pendingText += next.glyphStringAtIndex(idx); + cursorRow = r; + cursorCol = c + nextWidth; } } + flushText(); if (styleDirty) { out.push("\x1b[0m"); if (stats) stats.resets++; } if (stats) { - stats.ops = stats.cursorMoves + stats.fgChanges + stats.bgChanges + stats.resets; + stats.ops = + stats.cursorMoves + stats.fgChanges + stats.bgChanges + stats.resets + (stats.scrollOps ?? 0); } return out.length > 0 ? out.join("") : ""; @@ -142,70 +241,95 @@ function renderDiffAscii( cols: number, sizeChanged: boolean, stats?: DiffStats, + rowMap?: Int32Array | null, + scrollPrefix = "", ): string { const out: string[] = []; + if (scrollPrefix) { + out.push(scrollPrefix); + if (stats) stats.scrollOps = (stats.scrollOps ?? 0) + 5; + } let currentFg: string | undefined; let currentBg: string | undefined; let styleDirty = false; + let cursorRow = -1; + let cursorCol = -1; + let pendingText = ""; + + const flushText = () => { + if (!pendingText) return; + out.push(pendingText); + pendingText = ""; + }; + const pushCtl = (seq: string) => { + flushText(); + out.push(seq); + }; + + const moveTo = (r: number, c: number) => { + if (cursorRow === r && cursorCol === c) return; + pushCtl(`\x1b[${r + 1};${c + 1}H`); + cursorRow = r; + cursorCol = c; + if (stats) stats.cursorMoves++; + }; for (let r = 0; r < rows; r++) { + let inRun = false; for (let c = 0; c < cols; c++) { if (r === rows - 1 && c === cols - 1) { + inRun = false; continue; } const idx = r * cols + c; - const nextWidth = next.widths[idx]; + const nextWidth = next.widths[idx] ?? 1; if (nextWidth === 0) continue; - const prevWidth = prev.widths[idx] ?? 0; - const prevCode = prev.codes[idx] ?? 32; + const prevIdx = mapPrevIndex(rowMap, cols, r, c); + const prevWidth = prevIdx === -1 ? 1 : (prev.widths[prevIdx] ?? 0); + const prevCode = prevIdx === -1 ? 32 : (prev.codes[prevIdx] ?? 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 prevFg = prevIdx === -1 ? undefined : prev.fg[prevIdx]; + const prevBg = prevIdx === -1 ? undefined : prev.bg[prevIdx]; const needsDraw = - sizeChanged || - prevWidth !== nextWidth || - prevCode !== nextCode || - nextFg !== prevFg || - nextBg !== prevBg; + prevWidth !== nextWidth || prevCode !== nextCode || nextFg !== prevFg || nextBg !== prevBg; + + if (!needsDraw) { + inRun = false; + continue; + } - if (!needsDraw) continue; + if (stats) stats.changedCells++; - if (stats) { - stats.changedCells++; - stats.cursorMoves++; + if (!inRun) { + moveTo(r, c); + inRun = true; } - out.push(`\x1b[${r + 1};${c + 1}H`); if (nextFg !== currentFg) { - if (nextFg === undefined) { - out.push("\x1b[39m"); - } else { - out.push(nextFg); - } + pushCtl(nextFg === undefined ? "\x1b[39m" : nextFg); currentFg = nextFg; styleDirty = true; if (stats) stats.fgChanges++; } if (nextBg !== currentBg) { - if (nextBg === undefined) { - out.push("\x1b[49m"); - } else { - out.push(nextBg); - } + pushCtl(nextBg === undefined ? "\x1b[49m" : nextBg); currentBg = nextBg; styleDirty = true; if (stats) stats.bgChanges++; } - out.push(String.fromCharCode(nextCode)); + pendingText += String.fromCharCode(nextCode); + cursorRow = r; + cursorCol = c + 1; } } + flushText(); if (styleDirty) { out.push("\x1b[0m"); if (stats) stats.resets++; @@ -213,3 +337,408 @@ function renderDiffAscii( return out.length > 0 ? out.join("") : ""; } + +function renderFullRedraw(next: Buffer2D, rows: number, cols: number, stats?: DiffStats): string { + if (stats) { + stats.cursorMoves = 0; + stats.fgChanges = 0; + stats.bgChanges = 0; + stats.resets = 0; + } + + const out: string[] = []; + out.push("\x1b[0m\x1b[H"); + if (stats) { + stats.cursorMoves++; + stats.resets++; + } + + let currentFg: string | undefined; + let currentBg: string | undefined; + let styleDirty = false; + let pendingText = ""; + const flushText = () => { + if (!pendingText) return; + out.push(pendingText); + pendingText = ""; + }; + const pushCtl = (seq: string) => { + flushText(); + out.push(seq); + }; + + for (let r = 0; r < rows; r++) { + const lastCol = r === rows - 1 ? cols - 1 : cols; + for (let c = 0; c < lastCol; c++) { + const idx = r * cols + c; + const width = next.widths[idx] ?? 1; + if (width === 0) continue; + if (stats) stats.changedCells++; + + const nextFg = next.fg[idx]; + const nextBg = next.bg[idx]; + if (nextFg !== currentFg) { + pushCtl(nextFg === undefined ? "\x1b[39m" : nextFg); + currentFg = nextFg; + styleDirty = true; + if (stats) stats.fgChanges++; + } + if (nextBg !== currentBg) { + pushCtl(nextBg === undefined ? "\x1b[49m" : nextBg); + currentBg = nextBg; + styleDirty = true; + if (stats) stats.bgChanges++; + } + + pendingText += next.glyphStringAtIndex(idx); + } + flushText(); + if (r < rows - 1) out.push("\r\n"); + } + + flushText(); + if (styleDirty) { + out.push("\x1b[0m"); + if (stats) stats.resets++; + } + + return out.join(""); +} + +function renderFullRedrawAscii( + next: Buffer2D, + rows: number, + cols: number, + stats?: DiffStats, +): string { + if (stats) { + stats.cursorMoves = 0; + stats.fgChanges = 0; + stats.bgChanges = 0; + stats.resets = 0; + } + + const out: string[] = []; + out.push("\x1b[0m\x1b[H"); + if (stats) { + stats.cursorMoves++; + stats.resets++; + } + + let currentFg: string | undefined; + let currentBg: string | undefined; + let styleDirty = false; + let pendingText = ""; + const flushText = () => { + if (!pendingText) return; + out.push(pendingText); + pendingText = ""; + }; + const pushCtl = (seq: string) => { + flushText(); + out.push(seq); + }; + + for (let r = 0; r < rows; r++) { + const lastCol = r === rows - 1 ? cols - 1 : cols; + for (let c = 0; c < lastCol; c++) { + const idx = r * cols + c; + const width = next.widths[idx] ?? 1; + if (width === 0) continue; + if (stats) stats.changedCells++; + + const nextFg = next.fg[idx]; + const nextBg = next.bg[idx]; + if (nextFg !== currentFg) { + pushCtl(nextFg === undefined ? "\x1b[39m" : nextFg); + currentFg = nextFg; + styleDirty = true; + if (stats) stats.fgChanges++; + } + if (nextBg !== currentBg) { + pushCtl(nextBg === undefined ? "\x1b[49m" : nextBg); + currentBg = nextBg; + styleDirty = true; + if (stats) stats.bgChanges++; + } + + pendingText += String.fromCharCode(next.codes[idx] ?? 32); + } + flushText(); + if (r < rows - 1) out.push("\r\n"); + } + + flushText(); + if (styleDirty) { + out.push("\x1b[0m"); + if (stats) stats.resets++; + } + + return out.join(""); +} + +type ScrollRegion = { top: number; bottom: number; delta: number }; + +function normalizeScrollOp( + op: { top: number; bottom: number; delta: number }, + rows: number, +): ScrollRegion | null { + if (rows <= 0) return null; + const top = Math.max(0, Math.trunc(op.top)); + const bottom = Math.min(rows - 1, Math.trunc(op.bottom)); + const height = bottom - top + 1; + if (height <= 1) return null; + const delta = Math.trunc(op.delta); + if (delta === 0) return null; + if (Math.abs(delta) >= height) return null; + return { top, bottom, delta }; +} + +function buildDecstbmScrollPrefix(region: ScrollRegion): string { + const top = region.top + 1; + const bottom = region.bottom + 1; + const delta = region.delta; + const scrollCmd = delta > 0 ? `\x1b[${delta}S` : `\x1b[${-delta}T`; + // Reset SGR before scrolling so newly exposed lines are blank with default style. + // Place the cursor inside the scroll region to maximize terminal compatibility. + return `\x1b[0m\x1b[${top};${bottom}r\x1b[${top};1H${scrollCmd}\x1b[r`; +} + +function buildScrollRowMap(rows: number, region: ScrollRegion): Int32Array { + const map = new Int32Array(rows); + for (let r = 0; r < rows; r++) map[r] = r; + const top = region.top; + const bottom = region.bottom; + const delta = region.delta; + + if (delta > 0) { + for (let r = top; r <= bottom; r++) { + const source = r + delta; + map[r] = source <= bottom ? source : -1; + } + } else { + for (let r = top; r <= bottom; r++) { + const source = r + delta; + map[r] = source >= top ? source : -1; + } + } + + return map; +} + +function mapPrevIndex( + rowMap: Int32Array | null | undefined, + cols: number, + r: number, + c: number, +): number { + if (!rowMap) return r * cols + c; + const sourceRow = rowMap[r] ?? -1; + if (sourceRow < 0) return -1; + return sourceRow * cols + c; +} + +function detectVerticalScrollRegion( + prev: Buffer2D, + next: Buffer2D, + asciiFastPath: boolean, + hint?: { top: number; bottom: number }, +): ScrollRegion | null { + const rows = next.rows; + const cols = next.cols; + if (rows < 8) return null; + + if (hint) { + const top = Math.max(0, Math.trunc(hint.top)); + const bottom = Math.min(rows - 1, Math.trunc(hint.bottom)); + if (bottom - top + 1 < 8) return null; + return detectVerticalScrollWithinBand(prev, next, asciiFastPath, { top, bottom }, cols); + } + + // Keep the search window small; typical scrolling moves a few lines at a time. + const maxDelta = Math.min(5, rows - 1); + const deltas: number[] = []; + for (let d = 1; d <= maxDelta; d++) { + deltas.push(d, -d); + } + + const minMatchedRows = Math.max(6, Math.floor(rows * 0.35)); + const minRegionHeight = Math.max(7, Math.floor(rows * 0.4)); + + let best: + | { + delta: number; + matchStart: number; + matchLen: number; + regionTop: number; + regionBottom: number; + } + | undefined; + + for (const delta of deltas) { + let currentStart = -1; + let currentLen = 0; + + const flush = () => { + if (currentLen <= 0) return; + if (currentLen < minMatchedRows) { + currentStart = -1; + currentLen = 0; + return; + } + const matchStart = currentStart; + const matchEnd = currentStart + currentLen - 1; + const regionTop = delta > 0 ? matchStart : matchStart + delta; + const regionBottom = delta > 0 ? matchEnd + delta : matchEnd; + const regionHeight = regionBottom - regionTop + 1; + if (regionTop < 0 || regionBottom >= rows) { + currentStart = -1; + currentLen = 0; + return; + } + if (regionHeight < minRegionHeight) { + currentStart = -1; + currentLen = 0; + return; + } + if ( + !best || + currentLen > best.matchLen || + (currentLen === best.matchLen && Math.abs(delta) < Math.abs(best.delta)) + ) { + best = { delta, matchStart, matchLen: currentLen, regionTop, regionBottom }; + } + currentStart = -1; + currentLen = 0; + }; + + for (let r = 0; r < rows; r++) { + const prevRow = r + delta; + if (prevRow < 0 || prevRow >= rows) { + flush(); + continue; + } + const equal = asciiFastPath + ? rowsEqualAscii(prev, next, prevRow, r, cols) + : rowsEqual(prev, next, prevRow, r, cols); + if (equal) { + if (currentStart === -1) currentStart = r; + currentLen++; + } else { + flush(); + } + } + flush(); + } + + if (!best) return null; + const region = { top: best.regionTop, bottom: best.regionBottom, delta: best.delta }; + // Avoid scrolling the entire screen unless it looks extremely confident; + // full-screen scroll can be surprising with multiplexers or nonstandard terminals. + const regionHeight = region.bottom - region.top + 1; + if (region.top === 0 && region.bottom === rows - 1 && regionHeight < rows - 1) return null; + return region; +} + +function detectVerticalScrollWithinBand( + prev: Buffer2D, + next: Buffer2D, + asciiFastPath: boolean, + band: { top: number; bottom: number }, + cols: number, +): ScrollRegion | null { + const bandHeight = band.bottom - band.top + 1; + const maxDelta = Math.min(5, bandHeight - 1); + if (maxDelta <= 0) return null; + + let bestDelta = 0; + let bestMatches = 0; + + const deltas: number[] = []; + for (let d = 1; d <= maxDelta; d++) deltas.push(d, -d); + + for (const delta of deltas) { + const overlap = bandHeight - Math.abs(delta); + if (overlap < 6) continue; + + let matched = 0; + if (delta > 0) { + for (let r = band.top; r <= band.bottom - delta; r++) { + const equal = asciiFastPath + ? rowsEqualAscii(prev, next, r + delta, r, cols) + : rowsEqual(prev, next, r + delta, r, cols); + if (equal) matched++; + } + } else { + for (let r = band.top - delta; r <= band.bottom; r++) { + const equal = asciiFastPath + ? rowsEqualAscii(prev, next, r + delta, r, cols) + : rowsEqual(prev, next, r + delta, r, cols); + if (equal) matched++; + } + } + + const ratio = matched / overlap; + const minMatched = Math.max(6, Math.floor(overlap * 0.75)); + if (matched < minMatched || ratio < 0.75) continue; + + if ( + matched > bestMatches || + (matched === bestMatches && Math.abs(delta) < Math.abs(bestDelta)) + ) { + bestMatches = matched; + bestDelta = delta; + } + } + + if (bestDelta === 0) return null; + return { top: band.top, bottom: band.bottom, delta: bestDelta }; +} + +function rowsEqualAscii( + prev: Buffer2D, + next: Buffer2D, + prevRow: number, + nextRow: number, + cols: number, +): boolean { + const prevBase = prevRow * cols; + const nextBase = nextRow * cols; + for (let c = 0; c < cols; c++) { + const pi = prevBase + c; + const ni = nextBase + c; + if ((prev.widths[pi] ?? 0) !== (next.widths[ni] ?? 0)) return false; + if ((prev.codes[pi] ?? 32) !== (next.codes[ni] ?? 32)) return false; + if (prev.fg[pi] !== next.fg[ni]) return false; + if (prev.bg[pi] !== next.bg[ni]) return false; + } + return true; +} + +function rowsEqual( + prev: Buffer2D, + next: Buffer2D, + prevRow: number, + nextRow: number, + cols: number, +): boolean { + const prevBase = prevRow * cols; + const nextBase = nextRow * cols; + for (let c = 0; c < cols; c++) { + const pi = prevBase + c; + const ni = nextBase + c; + const pw = prev.widths[pi] ?? 0; + const nw = next.widths[ni] ?? 0; + if (pw !== nw) return false; + if (prev.fg[pi] !== next.fg[ni]) return false; + if (prev.bg[pi] !== next.bg[ni]) return false; + if (nw === 0) { + if ((prev.codes[pi] ?? 0) !== (next.codes[ni] ?? 0)) return false; + continue; + } + const pk = prev.glyphKeyAtIndex(pi); + const nk = next.glyphKeyAtIndex(ni); + if (pk !== nk) return false; + } + return true; +} diff --git a/src/renderer/sanitize.ts b/src/renderer/sanitize.ts index d3f23a7..e976e6a 100644 --- a/src/renderer/sanitize.ts +++ b/src/renderer/sanitize.ts @@ -4,15 +4,24 @@ */ /** - * Regular expression to match ANSI escape sequences - * Matches sequences like: \x1b[...m, \u001b[...H, etc. + * Regular expression to match ANSI escape sequences. + * + * We intentionally avoid overly-greedy patterns that can span across multiple + * CSI sequences and accidentally delete normal text when output contains + * interleaved cursor moves + SGR resets (common in render loops). */ -const ESC = String.fromCharCode(0x1b); -const CSI = `${ESC}\\[`; const ANSI_ESCAPE_REGEX = new RegExp( - `${CSI}[0-9;]*m|${CSI}[^m]*m|${CSI}[0-9;]*[A-Za-z]|${CSI}[^A-Za-z]*[A-Za-z]`, + [ + // CSI (Control Sequence Introducer): ESC [ ... final-byte + "\\u001b\\[[0-?]*[ -/]*[@-~]", + // OSC (Operating System Command): ESC ] ... BEL or ST + "\\u001b\\][^\\u0007\\u001b]*(?:\\u0007|\\u001b\\\\)", + // 2-byte ESC sequences + "\\u001b[@-Z\\\\-_]", + ].join("|"), "g", ); +const ESC = "\u001b"; /** * Regular expression to match other control characters (except common whitespace) diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index 3241e77..c6223c1 100644 --- a/src/runtime/loop.ts +++ b/src/runtime/loop.ts @@ -161,7 +161,7 @@ export class LoopManager implements ILoopManager { if (!state.isMounted || state.isUnmounting) return; if (state.renderMode !== "inline") return; uiSuspended = false; - renderer.renderOnce(false); + renderer.requestRender({ immediate: true }); }); }; @@ -170,6 +170,7 @@ export class LoopManager implements ILoopManager { uiSuspended = true; const seq = inline.cleanup(); if (seq) terminal.write(seq); + renderer.invalidate(); }; if (terminal.onStdout && terminal.writeStdout) { @@ -196,8 +197,7 @@ export class LoopManager implements ILoopManager { } } - renderer.renderOnce(true); - updaters.renderEffect(renderer.render()); + updaters.renderEffect(renderer.render({ forceFullRedraw: true })); if (state.renderEffect && state.mounted) { state.renderEffect.meta = { type: "render", @@ -234,7 +234,7 @@ export class LoopManager implements ILoopManager { if (state.renderMode !== "inline") { terminal.clearScreen(); } - renderer.renderOnce(true); + renderer.requestRender({ forceFullRedraw: true }); } catch (error) { this.handleError(createErrorContext("resize", error)); } diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index 36a39ed..f2d2511 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -4,10 +4,14 @@ import { layout, renderElement } from "../layout"; import type { DiffStats } from "../renderer/diff"; import type { Buffer2D } from "../renderer/types"; import { isBlock, type ViewElement } from "../view/types/elements"; +import { getDirtyVersions, getHasScrollRegion, setDirtyVersions } from "../view/dirty"; +import { reconcileTree } from "../view/reconcile"; import { createErrorContext } from "./error-boundary"; import type { Profiler } from "./profiler"; import type { ComputedLayout } from "../layout-engine/types"; +type Rect = { x: number; y: number; width: number; height: number }; + export interface BufferPoolLike { acquire(): Buffer2D; release(buffer: Buffer2D): void; @@ -16,7 +20,12 @@ export interface BufferPoolLike { export interface RenderLoopDeps { FlatBuffer: typeof FlatBuffer; getGlobalBufferPool: (rows: number, cols: number) => BufferPoolLike; - renderDiff: (prev: Buffer2D, next: Buffer2D, stats?: DiffStats) => string; + renderDiff: ( + prev: Buffer2D, + next: Buffer2D, + stats?: DiffStats, + options?: import("../renderer/diff").RenderDiffOptions, + ) => string; layout: (root: ViewElement, containerSize?: { width: number; height: number }) => ComputedLayout; renderElement: ( element: ViewElement, @@ -24,6 +33,7 @@ export interface RenderLoopDeps { layoutMap: ComputedLayout, parentX?: number, parentY?: number, + clipRect?: { x: number; y: number; width: number; height: number }, ) => void; } @@ -100,7 +110,170 @@ export function createRenderer(config: RenderLoopConfig) { let prevRootElement: ViewElement | null = null; let prevLayoutResult: ComputedLayout | null = null; let prevLayoutSizeKey: string | null = null; + let prevLayoutAtLayoutVersion: number | null = null; + let prevAbsRects: Map | null = null; + let prevRenderSigs: Map | null = null; + let prevDirtyVersions: { layout: number; render: number } | null = null; let renderEffect: ReactiveEffect | null = null; + let invalidated = false; + let scheduled = false; + let forceNextRender = false; + + function invalidate() { + invalidated = true; + } + + function requestRender(options: { forceFullRedraw?: boolean; immediate?: boolean } = {}) { + if (options.forceFullRedraw) forceNextRender = true; + invalidate(); + + // If render() hasn't been called yet, fall back to a direct render. + // (This is mostly for mount/bootstrapping paths.) + if (!renderEffect) { + renderOnce(!!options.forceFullRedraw); + return; + } + + // Run inside the ReactiveEffect context so dependency tracking stays correct. + if (options.immediate) { + renderEffect.run(); + return; + } + + if (scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + if (!renderEffect?.active) return; + renderEffect.run(); + }); + } + + 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 }; + } + + function intersectRect(a: Rect, b: Rect): Rect | null { + const x1 = Math.max(a.x, b.x); + const y1 = Math.max(a.y, b.y); + const x2 = Math.min(a.x + a.width, b.x + b.width); + const y2 = Math.min(a.y + a.height, b.y + b.height); + const width = x2 - x1; + const height = y2 - y1; + if (width <= 0 || height <= 0) return null; + return { x: x1, y: y1, width, height }; + } + + function collectAbsRectsAndFindScrollRegion( + root: ViewElement, + layoutMap: ComputedLayout, + ): { + rects: Map; + sigs: Map; + scrollRegion: { band: { top: number; bottom: number }; fullWidth: boolean } | null; + } { + const rects = new Map(); + const sigs = new Map(); + let scrollRegion: { band: { top: number; bottom: number }; fullWidth: boolean } | null = null; + let scrollRegionCount = 0; + + const signatureOf = (element: ViewElement): string => { + const bg = element.style?.background; + const fg = element.style?.foreground; + const outline = element.style?.outline; + const padding = element.style?.padding; + + if (element.type === "text") { + return `t|${element.content}|fg:${fg ?? ""}|bg:${bg ?? ""}`; + } + if (element.type === "input") { + return `i|${element.value}|fg:${fg ?? ""}|bg:${bg ?? ""}`; + } + // block + const outlineKey = + outline === undefined ? "" : `o:${outline.style ?? "single"}:${outline.color ?? ""}`; + const paddingKey = + padding === undefined + ? "" + : typeof padding === "number" + ? `p:${padding}` + : `p:${padding.join(",")}`; + return `b|bg:${bg ?? ""}|${outlineKey}|${paddingKey}`; + }; + + const walk = (element: ViewElement, parentX: number, parentY: number) => { + const key = element.identifier; + if (!key) return; + const layout = layoutMap[key]; + if (!layout) return; + + const absX = layout.x + parentX; + const absY = layout.y + parentY; + const rect: Rect = { + x: Math.floor(absX), + y: Math.floor(absY), + width: Math.floor(layout.width), + height: Math.floor(layout.height), + }; + rects.set(key, rect); + sigs.set(key, signatureOf(element)); + + if (isBlock(element)) { + for (const child of element.children) { + walk(child, absX, absY); + } + } + + if (element.style?.scrollRegion) { + const pad = resolvePadding(element.style?.padding); + const contentRect: Rect = { + x: rect.x, + y: rect.y + Math.floor(pad.top), + width: rect.width, + height: Math.max(0, rect.height - Math.floor(pad.top) - Math.floor(pad.bottom)), + }; + const screen = { + x: 0, + y: 0, + width: state.currentSize.cols, + height: state.currentSize.rows, + }; + const content = intersectRect(contentRect, screen); + if (content) { + scrollRegionCount++; + if (scrollRegionCount === 1) { + scrollRegion = { + band: { top: content.y, bottom: content.y + content.height - 1 }, + fullWidth: content.x === 0 && content.width === screen.width, + }; + } else { + // Multiple scroll regions are ambiguous for DECSTBM; disable optimization. + scrollRegion = null; + } + } + } + }; + + walk(root, 0, 0); + return { rects, sigs, scrollRegion }; + } /** * Performs a render cycle @@ -109,11 +282,14 @@ export function createRenderer(config: RenderLoopConfig) { */ function renderOnce(forceFullRedraw = false): void { try { + const localForceFullRedraw = forceFullRedraw || invalidated || forceNextRender; + invalidated = false; + forceNextRender = false; const newSize = config.getSize(); const sizeChanged = newSize.rows !== state.currentSize.rows || newSize.cols !== state.currentSize.cols; - if (sizeChanged || forceFullRedraw) { + if (sizeChanged || localForceFullRedraw) { // When size changes, re-create a pool bound to the new dimensions state.currentSize = newSize; pool = deps.getGlobalBufferPool(state.currentSize.rows, state.currentSize.cols); @@ -123,7 +299,18 @@ export function createRenderer(config: RenderLoopConfig) { state.prevBuffer = pool.acquire(); } - const rootElement = config.view(config.getState()); + const dirtyBeforeView = getDirtyVersions(); + const nextRootElement = config.view(config.getState()); + if (nextRootElement !== prevRootElement) { + // Immediate-mode render functions build new ViewElement instances every frame, and those + // constructors/method chains can trip dirty tracking. Roll back those "construction" + // marks and let reconciliation dirties reflect actual changes on the retained tree. + setDirtyVersions(dirtyBeforeView); + } + + const rootElement = reconcileTree(prevRootElement, nextRootElement); + const dirtyVersions = getDirtyVersions(); + const previousDirtyVersions = prevDirtyVersions; const layoutSizeKey = `${state.currentSize.cols}x${state.currentSize.rows}`; const nodeCount = @@ -132,11 +319,26 @@ export function createRenderer(config: RenderLoopConfig) { : undefined; const frame = config.profiler?.beginFrame(state.currentSize, { nodeCount }) ?? null; + if ( + !sizeChanged && + !localForceFullRedraw && + prevRootElement && + rootElement === prevRootElement && + prevDirtyVersions && + prevLayoutSizeKey === layoutSizeKey && + dirtyVersions.layout === prevDirtyVersions.layout && + dirtyVersions.render === prevDirtyVersions.render + ) { + config.profiler?.endFrame(frame); + return; + } + const layoutResult = rootElement === prevRootElement && prevLayoutResult && prevLayoutSizeKey === layoutSizeKey && - !sizeChanged + !sizeChanged && + prevLayoutAtLayoutVersion === dirtyVersions.layout ? prevLayoutResult : (config.profiler?.measure(frame, "layoutMs", () => deps.layout(rootElement, { @@ -152,6 +354,7 @@ export function createRenderer(config: RenderLoopConfig) { prevRootElement = rootElement; prevLayoutResult = layoutResult; prevLayoutSizeKey = layoutSizeKey; + prevLayoutAtLayoutVersion = dirtyVersions.layout; try { config.onLayout?.({ size: state.currentSize, rootElement, layoutMap: layoutResult }); @@ -167,12 +370,223 @@ export function createRenderer(config: RenderLoopConfig) { buf = new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols); } } + const fullClip: Rect = { + x: 0, + y: 0, + width: state.currentSize.cols, + height: state.currentSize.rows, + }; + + const previousRects = prevAbsRects; + const previousSigs = prevRenderSigs; + let absRects: Map | null = null; + let sigs: Map | null = null; + let scrollRegion: { band: { top: number; bottom: number }; fullWidth: boolean } | null = null; + const shouldCollectMaps = + !sizeChanged && + !localForceFullRedraw && + (getHasScrollRegion() || + previousRects !== null || + (previousDirtyVersions !== null && + dirtyVersions.layout === previousDirtyVersions.layout && + dirtyVersions.render !== previousDirtyVersions.render)); + + if (shouldCollectMaps) { + const collected = collectAbsRectsAndFindScrollRegion(rootElement, layoutResult); + absRects = collected.rects; + sigs = collected.sigs; + scrollRegion = collected.scrollRegion; + } else if (sizeChanged || localForceFullRedraw) { + prevAbsRects = null; + prevRenderSigs = null; + } + + const tryScrollFastPath = (): { + clips: Rect[]; + scrollOp: { top: number; bottom: number; delta: number }; + } | null => { + if (process.env.BTUIN_DISABLE_SCROLL_FASTPATH === "1") return null; + if (sizeChanged || localForceFullRedraw) return null; + if (!previousRects || !absRects || !scrollRegion) return null; + if (!scrollRegion.fullWidth) return null; + + const { top, bottom } = scrollRegion.band; + const bandHeight = bottom - top + 1; + if (bandHeight <= 1) return null; + + // Terminal scroll regions are full-width; skip when the band isn't. + if (fullClip.width <= 0) return null; + + const maxShift = Math.min(40, bandHeight - 1); + const counts = new Map(); + let compared = 0; + + for (const [key, prevRect] of previousRects) { + const nextRect = absRects.get(key); + if (!nextRect) continue; + + const prevIn = prevRect.y >= top && prevRect.y <= bottom; + const nextIn = nextRect.y >= top && nextRect.y <= bottom; + if (!prevIn || !nextIn) continue; + + if ( + prevRect.x !== nextRect.x || + prevRect.width !== nextRect.width || + prevRect.height !== nextRect.height + ) { + continue; + } + + const dy = nextRect.y - prevRect.y; + if (dy === 0) continue; + if (Math.abs(dy) > maxShift) continue; + + counts.set(dy, (counts.get(dy) ?? 0) + 1); + compared++; + } + + if (compared < 3) return null; + + let bestDy = 0; + let bestCount = 0; + for (const [dy, count] of counts) { + if (count > bestCount) { + bestCount = count; + bestDy = dy; + } + } + if (bestDy === 0) return null; + if (bestCount / compared < 0.6) return null; + + // Verify that things outside the band don't move, and inside the band only translate. + for (const [key, prevRect] of previousRects) { + const nextRect = absRects.get(key); + if (!nextRect) continue; + + const prevIn = prevRect.y >= top && prevRect.y <= bottom; + const nextIn = nextRect.y >= top && nextRect.y <= bottom; + + if (!prevIn && !nextIn) { + if ( + prevRect.x !== nextRect.x || + prevRect.y !== nextRect.y || + prevRect.width !== nextRect.width || + prevRect.height !== nextRect.height + ) { + return null; + } + continue; + } + + if (prevIn && nextIn) { + if ( + prevRect.x !== nextRect.x || + prevRect.width !== nextRect.width || + prevRect.height !== nextRect.height || + nextRect.y !== prevRect.y + bestDy + ) { + return null; + } + } + } + + const scrollTop = top; + const scrollBottom = bottom; + const dy = bestDy; + + const exposedHeight = Math.abs(dy); + if (exposedHeight <= 0) return null; + + const exposedY = dy < 0 ? scrollBottom - exposedHeight + 1 : scrollTop; + + if (!previousSigs || !sigs) return null; + + // Bail if something was removed (hard to "erase" safely without a full redraw). + for (const key of previousSigs.keys()) { + if (sigs.has(key)) continue; + return null; + } + + const clips: Rect[] = []; + clips.push({ x: 0, y: exposedY, width: fullClip.width, height: exposedHeight }); + + // Redraw any elements whose render-relevant props changed. + for (const [key, sig] of sigs) { + const prevSig = previousSigs.get(key); + if (prevSig === undefined || prevSig === sig) continue; + const rect = absRects.get(key); + if (!rect) continue; + const clipped = intersectRect(rect, fullClip); + if (clipped) clips.push(clipped); + } + + if (clips.length > 48) return null; + + // Build next buffer from prev by scrolling the band, then only render the newly exposed rows. + buf.copyFrom(state.prevBuffer); + buf.scrollRowsFrom(state.prevBuffer, scrollTop, scrollBottom, dy); + + return { clips, scrollOp: { top: scrollTop, bottom: scrollBottom, delta: dy } }; + }; + + const scrollFast = tryScrollFastPath(); + + const tryDirtyRects = (): Rect[] | null => { + if (sizeChanged || localForceFullRedraw) return null; + if (!previousRects || !absRects || !previousSigs || !sigs) return null; + if (!previousDirtyVersions) return null; + if (dirtyVersions.layout !== previousDirtyVersions.layout) return null; + if (dirtyVersions.render === previousDirtyVersions.render) return null; + + // If anything was removed, we must full redraw to clear it correctly. + for (const key of previousSigs.keys()) { + if (!sigs.has(key)) return null; + } + + const dirty: Rect[] = []; + for (const [key, sig] of sigs) { + const prevSig = previousSigs.get(key); + if (prevSig !== undefined && prevSig === sig) continue; + const rect = absRects.get(key); + if (!rect) continue; + const clipped = intersectRect(rect, fullClip); + if (clipped) dirty.push(clipped); + } + + if (dirty.length > 64) return null; + return dirty; + }; + + const dirtyRects = scrollFast ? null : tryDirtyRects(); + if (config.profiler && frame) { config.profiler.measure(frame, "renderMs", () => { - deps.renderElement(rootElement, buf, layoutResult, 0, 0); + if (scrollFast) { + for (const clip of scrollFast.clips) { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, clip); + } + } else if (dirtyRects !== null) { + buf.copyFrom(state.prevBuffer); + for (const clip of dirtyRects) { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, clip); + } + } else { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, fullClip); + } }); } else { - deps.renderElement(rootElement, buf, layoutResult, 0, 0); + if (scrollFast) { + for (const clip of scrollFast.clips) { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, clip); + } + } else if (dirtyRects !== null) { + buf.copyFrom(state.prevBuffer); + for (const clip of dirtyRects) { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, clip); + } + } else { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, fullClip); + } } config.profiler?.drawHud(buf); @@ -190,30 +604,30 @@ export function createRenderer(config: RenderLoopConfig) { } : undefined; - const prevForDiff = forceFullRedraw + const prevForDiff = localForceFullRedraw ? new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols) : state.prevBuffer; + const diffOptions = + scrollFast && process.env.BTUIN_DISABLE_DECSTBM !== "1" + ? ({ + scrollOp: scrollFast.scrollOp, + } satisfies import("../renderer/diff").RenderDiffOptions) + : undefined; + const output = config.profiler?.measure(frame, "diffMs", () => - deps.renderDiff(prevForDiff, buf, diffStats), - ) ?? deps.renderDiff(prevForDiff, buf); - const safeOutput = - output === "" - ? deps.renderDiff( - new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols), - buf, - ) - : output; + deps.renderDiff(prevForDiff, buf, diffStats, diffOptions), + ) ?? deps.renderDiff(prevForDiff, buf, undefined, diffOptions); if (frame && diffStats) { config.profiler?.recordDiffStats(frame, diffStats); } - if (safeOutput) { - config.profiler?.recordOutput(frame, safeOutput); + if (output) { + config.profiler?.recordOutput(frame, output); if (config.profiler && frame) { - config.profiler.measure(frame, "writeMs", () => config.write(safeOutput)); + config.profiler.measure(frame, "writeMs", () => config.write(output)); } else { - config.write(safeOutput); + config.write(output); } } @@ -221,17 +635,41 @@ export function createRenderer(config: RenderLoopConfig) { pool.release(state.prevBuffer); state.prevBuffer = buf; + if (shouldCollectMaps && absRects && sigs) { + prevAbsRects = absRects; + prevRenderSigs = sigs; + } + prevDirtyVersions = dirtyVersions; + config.profiler?.endFrame(frame); } catch (error) { config.handleError(createErrorContext("render", error)); } } - function render(): ReactiveEffect { + function render(options: { forceFullRedraw?: boolean } = {}): ReactiveEffect { if (renderEffect) { stop(renderEffect); } - renderEffect = effect(() => renderOnce(false)); + + if (options.forceFullRedraw) { + forceNextRender = true; + invalidated = true; + } + + scheduled = false; + const scheduleRender = (eff: ReactiveEffect) => { + if (!eff.active) return; + if (scheduled) return; + scheduled = true; + queueMicrotask(() => { + scheduled = false; + if (!eff.active) return; + eff.run(); + }); + }; + + renderEffect = effect(() => renderOnce(false), { scheduler: scheduleRender }); return renderEffect; } @@ -251,6 +689,8 @@ export function createRenderer(config: RenderLoopConfig) { return { render, renderOnce, + invalidate, + requestRender, dispose, getState, }; diff --git a/src/terminal/raw.ts b/src/terminal/raw.ts index 4f5dea4..8499544 100644 --- a/src/terminal/raw.ts +++ b/src/terminal/raw.ts @@ -1,6 +1,7 @@ import { AnsiInputParser } from "./parser/ansi"; import type { InputParser } from "./parser/types"; import type { KeyHandler } from "./types"; +import { disableBracketedPaste, showCursor } from "./io"; import { getUiInputStream } from "./tty-streams"; const ESCAPE_KEY_TIMEOUT_MS = 30; @@ -46,6 +47,7 @@ const terminalState = new TerminalState(); let escapeFlushTimer: ReturnType | null = null; let activeInputStream: ReturnType | null = null; +let uninstallFatalErrorSafetyNet: (() => void) | null = null; function clearEscapeFlushTimer() { if (escapeFlushTimer) { @@ -54,6 +56,63 @@ function clearEscapeFlushTimer() { } } +function restoreTerminalOnFatalError() { + try { + showCursor(); + } catch { + // ignore + } + try { + disableBracketedPaste(); + } catch { + // ignore + } + try { + cleanupWithoutClear(); + } catch { + // ignore + } +} + +function installFatalErrorSafetyNet() { + if (uninstallFatalErrorSafetyNet) return; + + const onUncaughtException = (error: unknown) => { + restoreTerminalOnFatalError(); + + // If we're the only uncaughtException listener, re-throw asynchronously so the + // runtime can preserve its default "print stack + exit" behavior. + try { + const listeners = process.listeners("uncaughtException"); + const otherListeners = listeners.filter((l) => l !== onUncaughtException); + if (otherListeners.length > 0) return; + } catch { + // ignore + } + + try { + process.off("uncaughtException", onUncaughtException); + } catch { + // ignore + } + + queueMicrotask(() => { + throw error; + }); + }; + + process.on("uncaughtException", onUncaughtException); + + uninstallFatalErrorSafetyNet = () => { + try { + process.off("uncaughtException", onUncaughtException); + } catch { + // ignore + } + uninstallFatalErrorSafetyNet = null; + }; +} + /** * Handles raw data from stdin and dispatches to registered handlers * @@ -116,6 +175,7 @@ export function setupRawMode() { if (!input.isTTY || typeof input.setRawMode !== "function") return; + installFatalErrorSafetyNet(); input.setRawMode(true); input.resume(); input.setEncoding("utf8"); @@ -149,6 +209,7 @@ export function cleanupWithoutClear() { return; } + uninstallFatalErrorSafetyNet?.(); terminalState.setRawModeActive(false); const input = activeInputStream ?? getUiInputStream(); @@ -177,6 +238,7 @@ export function cleanup() { return; } + uninstallFatalErrorSafetyNet?.(); terminalState.setRawModeActive(false); const input = activeInputStream ?? getUiInputStream(); diff --git a/src/view/base.ts b/src/view/base.ts index 2a8f59a..28f78aa 100644 --- a/src/view/base.ts +++ b/src/view/base.ts @@ -2,6 +2,79 @@ import type { KeyEventHook } from "../components/lifecycle"; import type { Dimension, LayoutStyle } from "../layout-engine/types"; import type { OutlineOptions } from "../renderer/types"; import type { KeyEvent } from "../terminal/types/key-event"; +import { markHasScrollRegion, markLayoutDirty, markRenderDirty } from "./dirty"; + +const layoutStyleKeys = new Set([ + "display", + "position", + "width", + "height", + "minWidth", + "minHeight", + "maxWidth", + "maxHeight", + "layoutBoundary", + "padding", + "margin", + "flexDirection", + "flexWrap", + "flexGrow", + "flexShrink", + "flexBasis", + "justifyContent", + "alignItems", + "alignSelf", + "gap", + "stack", +]); + +const renderStyleKeys = new Set(["foreground", "background", "outline", "scrollRegion"]); + +function createDirtyStyleProxy(style: T): T { + return new Proxy(style, { + set(target, prop, value) { + if (typeof prop === "string") { + // Avoid dirtying on idempotent writes. + const prev = (target as any)[prop]; + if (prev === value) return true; + (target as any)[prop] = value; + + if (layoutStyleKeys.has(prop)) { + markLayoutDirty(); + } else if (renderStyleKeys.has(prop)) { + if (prop === "scrollRegion" && value) markHasScrollRegion(); + markRenderDirty(); + } else { + // Unknown style keys are treated as layout-affecting for safety. + markLayoutDirty(); + } + return true; + } + + // Symbol keys: be conservative. + (target as any)[prop as any] = value; + markLayoutDirty(); + return true; + }, + deleteProperty(target, prop) { + if (typeof prop === "string") { + if (!Object.prototype.hasOwnProperty.call(target, prop)) return true; + delete (target as any)[prop]; + if (layoutStyleKeys.has(prop)) { + markLayoutDirty(); + } else if (renderStyleKeys.has(prop)) { + markRenderDirty(); + } else { + markLayoutDirty(); + } + return true; + } + delete (target as any)[prop as any]; + markLayoutDirty(); + return true; + }, + }); +} // 1. 基本的なプロパティ定義(スタイリング以外) export interface ViewProps { @@ -22,6 +95,7 @@ export interface ViewProps { background?: string | number; outline?: OutlineOptions; stack?: "z"; + scrollRegion?: boolean; }; } @@ -29,14 +103,14 @@ export interface ViewProps { // 名前を ViewElement から BaseView に変更して衝突回避 export abstract class BaseView implements ViewProps { // 実際のデータ保持場所 - public style: NonNullable = {}; + public style: NonNullable = createDirtyStyleProxy({}); public key?: string; public identifier?: string; public focusKey?: string; public keyHooks: KeyEventHook[] = []; constructor(props: ViewProps = {}) { - this.style = { ...props.style }; + this.style = createDirtyStyleProxy({ ...props.style }); const key = props.key ?? props.identifier; if (key) { this.key = key; @@ -103,14 +177,18 @@ export abstract class BaseView implements ViewProps { } setKey(value: string): this { + if (this.key === value && this.identifier === value) return this; this.key = value; this.identifier = value; + markLayoutDirty(); return this; } setIdentifier(value: string): this { + if (this.key === value && this.identifier === value) return this; this.key = value; this.identifier = value; + markLayoutDirty(); return this; } diff --git a/src/view/collections/index.ts b/src/view/collections/index.ts new file mode 100644 index 0000000..4a747bb --- /dev/null +++ b/src/view/collections/index.ts @@ -0,0 +1,3 @@ +export * from "./windowed"; +export * from "./viewport-slice"; +export * from "./layout"; diff --git a/src/view/layout.ts b/src/view/collections/layout.ts similarity index 87% rename from src/view/layout.ts rename to src/view/collections/layout.ts index 1c7bc9f..91b4d36 100644 --- a/src/view/layout.ts +++ b/src/view/collections/layout.ts @@ -1,5 +1,5 @@ -import { Block, type BlockElement } from "./primitives"; -import type { ViewElement } from "./types/elements"; +import { Block, type BlockElement } from "../primitives"; +import type { ViewElement } from "../types/elements"; // VStack は単に flex-direction: column な Block を作るだけ export function VStack(children: ViewElement[] = []): BlockElement { diff --git a/src/view/collections/viewport-slice.ts b/src/view/collections/viewport-slice.ts new file mode 100644 index 0000000..70e84c8 --- /dev/null +++ b/src/view/collections/viewport-slice.ts @@ -0,0 +1,84 @@ +import { Block, type BlockElement } from "../primitives/block"; +import type { ViewElement } from "../types/elements"; + +export interface ViewportSliceOptions { + items: readonly T[]; + /** + * Index of the first visible item (0-based). + * + * Clamp this in your state update logic; this function clamps defensively too. + */ + startIndex: number; + /** Viewport height in terminal rows (cells). */ + viewportRows: number; + /** Fixed item height in rows (cells). */ + itemHeight?: number; + /** + * Extra items to render after the viewport to reduce pop-in. + * + * Note: items before `startIndex` are not rendered (no negative offsets). + */ + overscan?: number; + /** + * Optional stable key prefix for item keys when the returned elements don't + * already have `key`/`identifier`. + */ + keyPrefix?: string; + renderItem: (item: T, index: number) => ViewElement; +} + +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + const v = Math.trunc(value); + if (v < min) return min; + if (v > max) return max; + return v; +} + +/** + * Low-level "window" primitive: returns a `Block` containing only the visible + * slice of a large item collection. + */ +export function ViewportSlice(options: ViewportSliceOptions): BlockElement { + const { items, renderItem, viewportRows, itemHeight = 1, overscan = 2, keyPrefix } = options; + + const safeItemHeight = Math.max(1, Math.trunc(itemHeight)); + const safeViewportRows = Math.max(0, Math.trunc(viewportRows)); + const safeOverscan = Math.max(0, Math.trunc(overscan)); + + const firstIndex = clampInt(options.startIndex, 0, Math.max(0, items.length - 1)); + const visibleCount = + safeViewportRows === 0 ? 0 : Math.ceil(safeViewportRows / safeItemHeight) + safeOverscan; + const endIndex = Math.min(items.length, firstIndex + visibleCount); + + const children: ViewElement[] = []; + for (let i = firstIndex; i < endIndex; i++) { + const child = renderItem(items[i]!, i); + // Windowed rendering relies on overflow+clipping; avoid flexbox shrinking items + // when overscan makes total child height exceed the viewport. + if (child.style?.flexShrink === undefined) { + child.style.flexShrink = 0; + } + if (safeItemHeight !== 1 && child.style?.height === undefined) { + child.style.height = safeItemHeight; + } + if (keyPrefix && !child.key && !child.identifier) { + const k = `${keyPrefix}/${i}`; + child.key = k; + child.identifier = k; + } + children.push(child); + } + + const container = Block(...children).direction("column"); + // This component is intended to scroll; this also enables DECSTBM hints upstream. + container.style.scrollRegion = true; + // Prevent overscan items from affecting flex layout sizing; the layout engine + // will only consider what fits inside the allocated height. + container.style.layoutBoundary = true; + // Match CSS's "min-height: 0" best practice for scrollable flex children. + if (container.style.minHeight === undefined) { + container.style.minHeight = 0; + } + return container; +} diff --git a/src/view/collections/windowed.ts b/src/view/collections/windowed.ts new file mode 100644 index 0000000..1af548b --- /dev/null +++ b/src/view/collections/windowed.ts @@ -0,0 +1,100 @@ +import type { BlockElement } from "../primitives/block"; +import type { ViewElement } from "../types/elements"; +import { ViewportSlice } from "./viewport-slice"; + +export interface WindowedOptions { + items: readonly T[]; + /** + * Index of the first visible item (0-based). + * + * Defaults to 0. + */ + startIndex?: number; + /** Fixed item height in rows (cells). */ + itemHeight?: number; + /** + * Extra items to render after the viewport to reduce pop-in. + * + * Note: items before `startIndex` are not rendered (no negative offsets). + */ + overscan?: number; + /** + * Optional stable key prefix for item keys when the returned elements don't + * already have `key`/`identifier`. + */ + keyPrefix?: string; + renderItem: (item: T, index: number) => ViewElement; +} + +export type WindowedMetrics = { + viewportRows: number; + visibleCount: number; + maxStartIndex: number; + startIndex: number; + endIndex: number; +}; + +export type WindowedMetricsInput = { + itemCount: number; + startIndex?: number; + viewportRows?: number; + itemHeight?: number; + overscan?: number; +}; + +function getFallbackViewportRows(): number { + // Fallback to terminal height when available; keep a sane default for tests/CI. + const rows = (process.stdout as { rows?: number } | undefined)?.rows; + return typeof rows === "number" && Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 24; +} + +function clampInt(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + const v = Math.trunc(value); + if (v < min) return min; + if (v > max) return max; + return v; +} + +export function getWindowedMetrics(input: WindowedMetricsInput): WindowedMetrics { + const safeItemCount = Math.max(0, Math.trunc(input.itemCount)); + const safeItemHeight = Math.max(1, Math.trunc(input.itemHeight ?? 1)); + const safeOverscan = Math.max(0, Math.trunc(input.overscan ?? 2)); + const safeViewportRows = Math.max(0, Math.trunc(input.viewportRows ?? getFallbackViewportRows())); + + const visibleCount = + safeViewportRows === 0 ? 0 : Math.ceil(safeViewportRows / safeItemHeight) + safeOverscan; + const maxStartIndex = Math.max(0, safeItemCount - Math.max(0, visibleCount)); + const startIndex = clampInt(input.startIndex ?? 0, 0, safeItemCount === 0 ? 0 : maxStartIndex); + const endIndex = Math.min(safeItemCount, startIndex + visibleCount); + + return { viewportRows: safeViewportRows, visibleCount, maxStartIndex, startIndex, endIndex }; +} + +export function clampWindowedStartIndex(input: WindowedMetricsInput): number { + return getWindowedMetrics(input).startIndex; +} + +/** + * User-facing windowed list helper. + * + * This version does not require `viewportRows`; it uses the current terminal + * height as a conservative bound. For more control, use `ViewportSlice`. + */ +export function Windowed(options: WindowedOptions): BlockElement { + const metrics = getWindowedMetrics({ + itemCount: options.items.length, + startIndex: options.startIndex ?? 0, + itemHeight: options.itemHeight, + overscan: options.overscan, + }); + return ViewportSlice({ + items: options.items, + startIndex: metrics.startIndex, + viewportRows: metrics.viewportRows, + itemHeight: options.itemHeight, + overscan: options.overscan, + keyPrefix: options.keyPrefix, + renderItem: options.renderItem, + }); +} diff --git a/src/view/dirty.ts b/src/view/dirty.ts new file mode 100644 index 0000000..d9e8636 --- /dev/null +++ b/src/view/dirty.ts @@ -0,0 +1,29 @@ +let layoutVersion = 0; +let renderVersion = 0; +let hasAnyScrollRegion = false; + +export function markLayoutDirty(): void { + layoutVersion++; + renderVersion++; +} + +export function markRenderDirty(): void { + renderVersion++; +} + +export function markHasScrollRegion(): void { + hasAnyScrollRegion = true; +} + +export function getHasScrollRegion(): boolean { + return hasAnyScrollRegion; +} + +export function getDirtyVersions(): { layout: number; render: number } { + return { layout: layoutVersion, render: renderVersion }; +} + +export function setDirtyVersions(versions: { layout: number; render: number }): void { + layoutVersion = versions.layout; + renderVersion = versions.render; +} diff --git a/src/view/index.ts b/src/view/index.ts index e882e8b..d45a080 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -1,2 +1,3 @@ export * from "./primitives"; -export * from "./layout"; +export * from "./collections"; +export * from "./retained"; diff --git a/src/view/primitives/block.ts b/src/view/primitives/block.ts index 985f079..8846368 100644 --- a/src/view/primitives/block.ts +++ b/src/view/primitives/block.ts @@ -1,14 +1,32 @@ import { BaseView } from "../base"; +import { markLayoutDirty } from "../dirty"; import type { BlockView, ViewElement } from "../types/elements"; export class BlockElement extends BaseView implements BlockView { type = "block" as const; - children: ViewElement[] = []; + children: ViewElement[]; constructor() { super(); // デフォルトは Flexbox の標準挙動 this.style.display = "flex"; + this.children = new Proxy([], { + set(target, prop, value) { + const prev = (target as any)[prop as any]; + if (prev === value) return true; + (target as any)[prop as any] = value; + // Children mutations affect both layout and rendering. + // BaseView's style proxy marks dirty for style changes; this covers children changes. + markLayoutDirty(); + return true; + }, + deleteProperty(target, prop) { + if (!Object.prototype.hasOwnProperty.call(target, prop)) return true; + delete (target as any)[prop as any]; + markLayoutDirty(); + return true; + }, + }); } // 子要素を追加するチェーンメソッド diff --git a/src/view/primitives/text.ts b/src/view/primitives/text.ts index 51af91c..6204f0b 100644 --- a/src/view/primitives/text.ts +++ b/src/view/primitives/text.ts @@ -1,11 +1,38 @@ import { BaseView } from "../base"; +import { markLayoutDirty, markRenderDirty } from "../dirty"; import type { TextView } from "../types/elements"; class TextElement extends BaseView implements TextView { type = "text" as const; - constructor(public content: string) { + #content: string; + + constructor(content: string) { super(); + this.#content = content; + } + + get content(): string { + return this.#content; + } + + set content(value: string) { + if (this.#content === value) return; + this.#content = value; + // Content changes can affect intrinsic measurement when width/height are auto/unspecified. + // In that case we must invalidate layout, otherwise the render loop may reuse a stale + // layout map and produce clipping/overlap artifacts. + if ( + this.style.width === undefined || + this.style.width === "auto" || + this.style.height === undefined || + this.style.height === "auto" + ) { + markLayoutDirty(); + return; + } + + markRenderDirty(); } bold(): this { diff --git a/src/view/reconcile.ts b/src/view/reconcile.ts new file mode 100644 index 0000000..cd6189e --- /dev/null +++ b/src/view/reconcile.ts @@ -0,0 +1,156 @@ +import { isBlock, type ViewElement } from "./types/elements"; + +function identityKey(element: ViewElement): string | undefined { + return element.key ?? element.identifier; +} + +function styleValueEquals(key: string, a: unknown, b: unknown): boolean { + if (a === b) return true; + if (key === "outline") { + if (!a || !b) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + const ao = a as any; + const bo = b as any; + return ao.style === bo.style && ao.color === bo.color; + } + if (key === "padding" || key === "margin") { + if (typeof a === "number" && typeof b === "number") return a === b; + if (Array.isArray(a) && Array.isArray(b) && a.length === 4 && b.length === 4) { + return a.every((v, i) => v === b[i]); + } + return false; + } + if (key === "gap") { + if (!a || !b) return false; + if (typeof a !== "object" || typeof b !== "object") return false; + const ao = a as any; + const bo = b as any; + return ao.width === bo.width && ao.height === bo.height; + } + return false; +} + +function syncStyle( + target: NonNullable, + source: NonNullable, +) { + const keys = new Set([...Object.keys(target), ...Object.keys(source)]); + for (const key of keys) { + const nextValue = (source as any)[key]; + if (nextValue === undefined) { + if (Object.prototype.hasOwnProperty.call(target, key)) { + delete (target as any)[key]; + } + continue; + } + const prevValue = (target as any)[key]; + if (styleValueEquals(key, prevValue, nextValue)) continue; + (target as any)[key] = nextValue; + } +} + +function canReuse(prev: ViewElement, next: ViewElement): boolean { + if (prev.type !== next.type) return false; + const prevKey = identityKey(prev); + const nextKey = identityKey(next); + if (nextKey) return prevKey === nextKey; + // Unkeyed nodes: reuse by slot (type + position), which is safe enough for retained-mode + // as long as parents reconcile by index when keys are absent. + return true; +} + +function reconcileChildren(prev: ViewElement, next: ViewElement): void { + if (!isBlock(prev) || !isBlock(next)) return; + + const nextChildren = next.children; + const prevChildren = prev.children; + + let anyNextKeyed = false; + for (const c of nextChildren) { + if (identityKey(c)) { + anyNextKeyed = true; + break; + } + } + + const updated: ViewElement[] = []; + if (anyNextKeyed) { + const prevByKey = new Map(); + for (const child of prevChildren) { + const k = identityKey(child); + if (k) prevByKey.set(k, child); + } + for (const nextChild of nextChildren) { + const k = identityKey(nextChild); + const prevChild = k ? prevByKey.get(k) : undefined; + updated.push(reconcileTree(prevChild, nextChild)); + } + } else { + for (let i = 0; i < nextChildren.length; i++) { + const prevChild = prevChildren[i]; + updated.push(reconcileTree(prevChild, nextChildren[i]!)); + } + } + + if ( + prevChildren.length === updated.length && + updated.every((child, i) => prevChildren[i] === child) + ) { + return; + } + + prevChildren.splice(0, prevChildren.length, ...updated); +} + +function syncIdentity(prev: ViewElement, next: ViewElement): void { + const nextKey = identityKey(next); + if (!nextKey) return; + if (identityKey(prev) === nextKey) return; + prev.setKey(nextKey); +} + +function syncCommonProps(prev: ViewElement, next: ViewElement): void { + // Key is used for layout addressing; keep it stable when explicitly provided. + syncIdentity(prev, next); + + if (next.focusKey !== undefined && prev.focusKey !== next.focusKey) { + prev.focus(next.focusKey); + } + + syncStyle(prev.style, next.style); +} + +function syncLeafProps(prev: ViewElement, next: ViewElement): void { + if (prev.type === "text" && next.type === "text") { + prev.content = next.content; + return; + } + if (prev.type === "input" && next.type === "input") { + prev.value = next.value; + } +} + +/** + * Reconciles an immediate-mode `next` tree into a retained `prev` tree. + * + * This enables retained-mode behavior (stable object identities) without forcing + * user code to cache ViewElement instances manually. + * + * - If `next` nodes have stable keys, reconciliation is key-based. + * - Otherwise, it falls back to index-based (slot) reuse. + * + * Mutations are applied via setters/proxies so dirty tracking can skip frames + * when nothing actually changed. + */ +export function reconcileTree( + prev: ViewElement | undefined | null, + next: ViewElement, +): ViewElement { + if (!prev) return next; + if (!canReuse(prev, next)) return next; + + syncCommonProps(prev, next); + reconcileChildren(prev, next); + syncLeafProps(prev, next); + return prev; +} diff --git a/src/view/retained.ts b/src/view/retained.ts new file mode 100644 index 0000000..340769a --- /dev/null +++ b/src/view/retained.ts @@ -0,0 +1,49 @@ +export interface RetainedCacheOptions { + /** + * If true (default), entries not referenced during a frame are dropped on `endFrame()`. + * Set false to retain entries indefinitely (you'll manage lifetime manually). + */ + gc?: boolean; +} + +export interface RetainedCache { + beginFrame(): void; + endFrame(): void; + use(key: string, factory: () => T): T; + drop(key: string): void; + clear(): void; +} + +export function createRetainedCache(options: RetainedCacheOptions = {}): RetainedCache { + const gc = options.gc ?? true; + const cache = new Map(); + let used: Set | null = null; + + return { + beginFrame() { + used = gc ? new Set() : null; + }, + endFrame() { + if (!used) return; + for (const key of cache.keys()) { + if (!used.has(key)) cache.delete(key); + } + used = null; + }, + use(key: string, factory: () => T): T { + if (!key) throw new Error("RetainedCache.use(key): key is required."); + if (used) used.add(key); + const existing = cache.get(key) as T | undefined; + if (existing !== undefined) return existing; + const created = factory(); + cache.set(key, created); + return created; + }, + drop(key: string) { + cache.delete(key); + }, + clear() { + cache.clear(); + }, + }; +} diff --git a/tests/units/layout-engine/ffi-boundary.test.ts b/tests/units/layout-engine/ffi-boundary.test.ts index 1c41ac0..9730f76 100644 --- a/tests/units/layout-engine/ffi-boundary.test.ts +++ b/tests/units/layout-engine/ffi-boundary.test.ts @@ -38,7 +38,7 @@ describe("Layout Engine FFI boundary", () => { layout_engine_style_prop_children_offset: { args: [], returns: FFIType.u32 }, }); - const expectedAbiVersion = 1; + const expectedAbiVersion = 2; const expectedStylePropIndex = { FlexDirection: 2, diff --git a/tests/units/layout-engine/index.test.ts b/tests/units/layout-engine/index.test.ts index ae0724d..90b9be0 100644 --- a/tests/units/layout-engine/index.test.ts +++ b/tests/units/layout-engine/index.test.ts @@ -96,4 +96,52 @@ describe("Layout Engine", () => { expect(layout.child2?.x).toBe(10 + 50 + 10); // root.padding + child1.width + gap expect(layout.child2?.y).toBe(10); }); + + it("should apply incremental updates and removals", () => { + const root1: LayoutInputNode = { + identifier: "root", + type: "block", + width: 10, + height: 10, + flexDirection: "column", + children: [ + { + identifier: "a", + type: "block", + width: 10, + height: 1, + }, + { + identifier: "b", + type: "block", + width: 10, + height: 1, + }, + ], + }; + + const layout1 = computeLayout(root1); + expect(layout1.a).toBeDefined(); + expect(layout1.b).toBeDefined(); + + const root2: LayoutInputNode = { + identifier: "root", + type: "block", + width: 10, + height: 10, + flexDirection: "column", + children: [ + { + identifier: "a", + type: "block", + width: 10, + height: 2, + }, + ], + }; + + const layout2 = computeLayout(root2); + expect(layout2.a?.height).toBe(2); + expect(layout2.b).toBeUndefined(); + }); }); diff --git a/tests/units/layout/index.test.ts b/tests/units/layout/index.test.ts index 9d9a7ff..117deaf 100644 --- a/tests/units/layout/index.test.ts +++ b/tests/units/layout/index.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "bun:test"; import { createLayout } from "@/layout"; import { Block, Text } from "@/view/primitives"; -import { LayoutBoundary } from "@/view/layout"; +import { LayoutBoundary } from "@/view/collections"; import type { ComputedLayout, LayoutInputNode } from "@/types"; // Mock the layout engine diff --git a/tests/units/layout/justify.test.ts b/tests/units/layout/justify.test.ts index f021691..5a9fb61 100644 --- a/tests/units/layout/justify.test.ts +++ b/tests/units/layout/justify.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { HStack, VStack } from "@/view/layout"; +import { HStack, VStack } from "@/view/collections"; import { Text } from "@/view/primitives"; import { layout } from "@/layout/index"; diff --git a/tests/units/layout/renderer.test.ts b/tests/units/layout/renderer.test.ts index 8c22805..288588e 100644 --- a/tests/units/layout/renderer.test.ts +++ b/tests/units/layout/renderer.test.ts @@ -108,4 +108,20 @@ describe("renderElement", () => { // absY = 1 + 1 = 2 expect(buffer.get(2, 3).char).toBe("O"); }); + + test("should clip children outside parent bounds", () => { + const child = Text({ value: "A" }).setKey("child").build(); + const parent = Block(child).setKey("parent").build(); + + const layoutMap = { + parent: { x: 0, y: 0, width: 10, height: 2 }, + // This child starts below the parent's bottom edge. + child: { x: 0, y: 2, width: 1, height: 1 }, + }; + const buffer = createBuffer(4, 10); + + renderElement(parent, buffer, layoutMap); + + expect(buffer.get(2, 0).char).toBe(" "); + }); }); diff --git a/tests/units/renderer/buffer-scroll.test.ts b/tests/units/renderer/buffer-scroll.test.ts new file mode 100644 index 0000000..3ffca95 --- /dev/null +++ b/tests/units/renderer/buffer-scroll.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "bun:test"; +import { FlatBuffer } from "@/renderer/buffer"; + +describe("FlatBuffer scroll helpers", () => { + it("scrollRowsFrom should scroll a band up and clear exposed rows", () => { + const prev = new FlatBuffer(5, 5); + for (let r = 0; r < 5; r++) { + for (let c = 0; c < 5; c++) { + prev.set(r, c, String(r), { fg: `c${r}` }); + } + } + prev.set(2, 0, "a\u0304"); // multi-code-point glyph stored in extras + + const next = new FlatBuffer(5, 5); + next.copyFrom(prev); + next.scrollRowsFrom(prev, 1, 3, -1); + + // unaffected rows + expect(next.get(0, 0).char).toBe("0"); + expect(next.get(4, 0).char).toBe("4"); + + // band moved up: row1 <- row2, row2 <- row3, row3 cleared + expect(next.get(1, 0).char).toBe("a\u0304"); + expect(next.get(2, 0).char).toBe("3"); + expect(next.get(3, 0).char).toBe(" "); + expect(next.get(3, 0).style.fg).toBeUndefined(); + }); + + it("scrollRowsFrom should scroll a band down and clear exposed rows", () => { + const prev = new FlatBuffer(5, 5); + for (let r = 0; r < 5; r++) { + for (let c = 0; c < 5; c++) { + prev.set(r, c, String(r), { bg: `b${r}` }); + } + } + + const next = new FlatBuffer(5, 5); + next.copyFrom(prev); + next.scrollRowsFrom(prev, 1, 3, 1); + + expect(next.get(0, 0).char).toBe("0"); + expect(next.get(4, 0).char).toBe("4"); + + // band moved down: row3 <- row2, row2 <- row1, row1 cleared + expect(next.get(3, 0).char).toBe("2"); + expect(next.get(2, 0).char).toBe("1"); + expect(next.get(1, 0).char).toBe(" "); + expect(next.get(1, 0).style.bg).toBeUndefined(); + }); +}); diff --git a/tests/units/renderer/diff.test.ts b/tests/units/renderer/diff.test.ts index 39b249d..546c5e1 100644 --- a/tests/units/renderer/diff.test.ts +++ b/tests/units/renderer/diff.test.ts @@ -66,9 +66,8 @@ describe("renderDiff", () => { const occurrences = (output.match(/b/g) || []).length; expect(occurrences).toBe(8); - // Check if it moves to each cell - expect(output).toContain("\x1b[1;1H"); - expect(output).toContain("\x1b[3;2H"); + // Starts from home for a fast full redraw + expect(output.startsWith("\x1b[0m\x1b[H")).toBe(true); }); it("should batch color changes", () => { @@ -84,9 +83,8 @@ describe("renderDiff", () => { const output = renderDiff(prev, next); // Expected: - // Move -> Set Green -> 'a' -> Move -> 'b' -> Move -> Set Blue -> 'c' -> Move -> 'd' -> Reset - const expected = - "\x1b[1;1H\x1b[32ma" + "\x1b[1;2Hb" + "\x1b[1;3H\x1b[34mc" + "\x1b[1;4Hd" + "\x1b[0m"; + // Move -> Set Green -> 'ab' -> Set Blue -> 'cd' -> Reset + const expected = "\x1b[1;1H\x1b[32mab\x1b[34mcd\x1b[0m"; expect(output).toBe(expected); }); @@ -102,7 +100,7 @@ describe("renderDiff", () => { const expected = "\x1b[1;1H\x1b[31ma" + - "\x1b[1;2H\x1b[39mb" + // After red 'a', resets to default fg for 'b' + "\x1b[39mb" + // After red 'a', resets to default fg for 'b' "\x1b[0m"; expect(output).toBe(expected); @@ -134,8 +132,88 @@ describe("renderDiff", () => { expect(output.length).toBeGreaterThan(0); expect(stats.changedCells).toBe(3); - expect(stats.cursorMoves).toBe(3); + // Two runs: row 1 col 1..2, then row 2 col 1. + expect(stats.cursorMoves).toBe(2); expect(stats.fgChanges).toBe(2); expect(stats.ops).toBe(stats.cursorMoves + stats.fgChanges + stats.bgChanges + stats.resets); }); + + it("should use DECSTBM scroll region when content shifts vertically", () => { + const rows = 10; + const cols = 5; + const prev = createMockBuffer(rows, cols, " "); + const next = createMockBuffer(rows, cols, " "); + + // Header + footer stay fixed, middle region scrolls up by 1. + for (let c = 0; c < cols; c++) { + prev.set(0, c, "H"); + next.set(0, c, "H"); + prev.set(rows - 1, c, "F"); + next.set(rows - 1, c, "F"); + } + + // Fill prev region rows 1..8 with per-row letters. + for (let r = 1; r <= 8; r++) { + const ch = String.fromCharCode("A".charCodeAt(0) + r); + for (let c = 0; c < cols; c++) { + prev.set(r, c, ch); + } + } + + // Next region rows 1..7 are shifted from prev 2..8. + for (let r = 1; r <= 7; r++) { + const ch = String.fromCharCode("A".charCodeAt(0) + (r + 1)); + for (let c = 0; c < cols; c++) { + next.set(r, c, ch); + } + } + // Newly exposed line at the bottom of the region (row 8). + next.set(8, 0, "Z"); + + const output = renderDiff(prev, next, undefined, { scrollRegion: { top: 1, bottom: 8 } }); + + // region is rows 2..9 (1-based) and scrolls up by 1. + const expectedPrefix = "\x1b[0m\x1b[2;9r\x1b[2;1H\x1b[1S\x1b[r"; + expect(output.startsWith(expectedPrefix)).toBe(true); + + // Only the new cell should be drawn after the scroll. + expect(output).toContain("\x1b[9;1HZ"); + }); + + it("should use explicit DECSTBM scroll op when provided", () => { + const rows = 10; + const cols = 5; + const prev = createMockBuffer(rows, cols, " "); + const next = createMockBuffer(rows, cols, " "); + + // Header + footer stay fixed, middle region scrolls up by 1. + for (let c = 0; c < cols; c++) { + prev.set(0, c, "H"); + next.set(0, c, "H"); + prev.set(rows - 1, c, "F"); + next.set(rows - 1, c, "F"); + } + + for (let r = 1; r <= 8; r++) { + const ch = String.fromCharCode("A".charCodeAt(0) + r); + for (let c = 0; c < cols; c++) { + prev.set(r, c, ch); + } + } + for (let r = 1; r <= 7; r++) { + const ch = String.fromCharCode("A".charCodeAt(0) + (r + 1)); + for (let c = 0; c < cols; c++) { + next.set(r, c, ch); + } + } + next.set(8, 0, "Z"); + + const output = renderDiff(prev, next, undefined, { + scrollOp: { top: 1, bottom: 8, delta: 1 }, + }); + + const expectedPrefix = "\x1b[0m\x1b[2;9r\x1b[2;1H\x1b[1S\x1b[r"; + expect(output.startsWith(expectedPrefix)).toBe(true); + expect(output).toContain("\x1b[9;1HZ"); + }); }); diff --git a/tests/units/runtime/render-loop.test.ts b/tests/units/runtime/render-loop.test.ts index 9e8c931..1d41133 100644 --- a/tests/units/runtime/render-loop.test.ts +++ b/tests/units/runtime/render-loop.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from "bun:test"; import { createRenderer } from "@/runtime/render-loop"; -import { Block } from "@/view/primitives"; +import { Block, Text } from "@/view/primitives"; import { FlatBuffer } from "@/renderer"; +import { setDirtyVersions } from "@/view/dirty"; +import { ref } from "@/reactivity"; import type { Buffer2D } from "@/types"; const mockLayoutResult = { root: { x: 0, y: 0, width: 80, height: 24 } }; @@ -54,6 +56,41 @@ describe("createRenderer", () => { renderer.dispose(); }); + it("should skip a whole frame when nothing changed (immediate-mode view)", () => { + setDirtyVersions({ layout: 0, render: 0 }); + + let layoutCalls = 0; + let diffCalls = 0; + + const renderer = createRenderer({ + getSize: () => ({ rows: 24, cols: 80 }), + write: () => {}, + view: () => Block(), + getState: () => ({}), + handleError: (e) => console.error(e), + deps: { + FlatBuffer, + getGlobalBufferPool: () => mockPool, + renderDiff: () => { + diffCalls++; + return "x"; + }, + layout: () => { + layoutCalls++; + return mockLayoutResult; + }, + renderElement: () => {}, + }, + }); + + renderer.renderOnce(); + renderer.renderOnce(); + + expect(layoutCalls).toBe(1); + expect(diffCalls).toBe(1); + renderer.dispose(); + }); + it("should call the error handler on render error", () => { let errorCaught: Error | null = null; const testError = new Error("Render failed"); @@ -83,4 +120,172 @@ describe("createRenderer", () => { expect(errorCaught!).toBe(testError); renderer.dispose(); }); + + it("should coalesce multiple reactive triggers into one render", async () => { + setDirtyVersions({ layout: 0, render: 0 }); + + const counter = ref(0); + let diffCalls = 0; + + const renderer = createRenderer({ + getSize: () => ({ rows: 24, cols: 80 }), + write: () => {}, + view: () => { + return Block(Text(String(counter.value))); + }, + getState: () => ({}), + handleError: (e) => console.error(e), + deps: { + FlatBuffer, + getGlobalBufferPool: () => mockPool, + renderDiff: () => { + diffCalls++; + return "x"; + }, + layout: () => mockLayoutResult, + renderElement: () => {}, + }, + }); + + renderer.render(); + expect(diffCalls).toBe(1); + + counter.value++; + counter.value++; + counter.value++; + + await new Promise((resolve) => queueMicrotask(resolve)); + + // Coalesced: one additional render for the 3 mutations. + expect(diffCalls).toBe(2); + renderer.dispose(); + }); + + it("requestRender should update reactive dependency tracking (non-reactive invalidation)", async () => { + setDirtyVersions({ layout: 0, render: 0 }); + + const a = ref(0); + const b = ref(0); + let mode = 0; // Non-reactive: simulates resize/layout-driven branching. + let diffCalls = 0; + + const renderer = createRenderer({ + getSize: () => ({ rows: 24, cols: 80 }), + write: () => {}, + view: () => { + return Block(Text(mode === 0 ? `b=${b.value}` : `a=${a.value}`)); + }, + getState: () => ({}), + handleError: (e) => console.error(e), + deps: { + FlatBuffer, + getGlobalBufferPool: () => mockPool, + renderDiff: () => { + diffCalls++; + return "x"; + }, + layout: () => mockLayoutResult, + renderElement: () => {}, + }, + }); + + renderer.render(); + expect(diffCalls).toBe(1); + + // Change non-reactive branch selector; must explicitly invalidate to re-track deps. + mode = 1; + renderer.requestRender(); + await new Promise((resolve) => queueMicrotask(resolve)); + expect(diffCalls).toBe(2); + + // After requestRender, `a` should now be tracked and trigger a render. + a.value++; + await new Promise((resolve) => queueMicrotask(resolve)); + expect(diffCalls).toBe(3); + + renderer.dispose(); + }); + + it("should use dirty-rect rendering when only render props change", () => { + setDirtyVersions({ layout: 0, render: 0 }); + + let value = "a"; + const clips: Array<{ x: number; y: number; width: number; height: number }> = []; + + const renderer = createRenderer({ + getSize: () => ({ rows: 24, cols: 80 }), + write: () => {}, + view: () => { + const child = Text(value).width(10).height(1).setKey("root/text"); + return Block(child).setKey("root"); + }, + getState: () => ({}), + handleError: (e) => console.error(e), + deps: { + FlatBuffer, + getGlobalBufferPool: () => mockPool, + renderDiff: () => "x", + layout: () => ({ + root: { x: 0, y: 0, width: 80, height: 24 }, + "root/text": { x: 0, y: 1, width: 10, height: 1 }, + }), + renderElement: (_el, _buf, _layout, _px, _py, clipRect) => { + clips.push(clipRect ?? { x: 0, y: 0, width: 80, height: 24 }); + }, + }, + }); + + renderer.renderOnce(); + + // 2nd render: collects maps but still does a full render (no previous sigs yet). + value = "b"; + renderer.renderOnce(); + + // 3rd render: should use dirty-rect path and pass a clipped rect. + value = "c"; + renderer.renderOnce(); + + const last = clips.at(-1); + expect(last).toEqual({ x: 0, y: 1, width: 10, height: 1 }); + renderer.dispose(); + }); + + it("should re-layout when intrinsic text content changes", () => { + setDirtyVersions({ layout: 0, render: 0 }); + + let value = "a"; + let layoutCalls = 0; + + const renderer = createRenderer({ + getSize: () => ({ rows: 24, cols: 80 }), + write: () => {}, + view: () => { + const child = Text(value).setKey("root/text"); + return Block(child).setKey("root"); + }, + getState: () => ({}), + handleError: (e) => console.error(e), + deps: { + FlatBuffer, + getGlobalBufferPool: () => mockPool, + renderDiff: () => "x", + layout: () => { + layoutCalls++; + return { + root: { x: 0, y: 0, width: 80, height: 24 }, + "root/text": { x: 0, y: 1, width: value.length, height: 1 }, + }; + }, + renderElement: () => {}, + }, + }); + + renderer.renderOnce(); + expect(layoutCalls).toBe(1); + + value = "aaaaa"; + renderer.renderOnce(); + expect(layoutCalls).toBe(2); + renderer.dispose(); + }); }); diff --git a/tests/units/runtime/terminal-size.test.ts b/tests/units/runtime/terminal-size.test.ts index b633b05..70bf9e7 100644 --- a/tests/units/runtime/terminal-size.test.ts +++ b/tests/units/runtime/terminal-size.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { getTerminalSize } from "@/terminal"; -import { VStack } from "@/view/layout"; +import { VStack } from "@/view/collections"; import { Text } from "@/view/primitives"; import { layout } from "@/layout"; diff --git a/tests/units/terminal/raw.test.ts b/tests/units/terminal/raw.test.ts index 92447b4..a282c43 100644 --- a/tests/units/terminal/raw.test.ts +++ b/tests/units/terminal/raw.test.ts @@ -6,6 +6,7 @@ mock.module("@/terminal/io", () => ({ clearScreen: () => {}, hideCursor: () => {}, showCursor: () => {}, + disableBracketedPaste: () => {}, })); // Mock process.stdin @@ -67,6 +68,21 @@ describe("Raw Mode and Key Handling", () => { expect(mockStdin._rawMode).toBe(false); }); + it("should force-disable raw mode on uncaughtException", () => { + setupRawMode(); + expect(mockStdin._rawMode).toBe(true); + + const swallow = () => {}; + process.on("uncaughtException", swallow); + try { + process.emit("uncaughtException", new Error("boom")); + } finally { + process.off("uncaughtException", swallow); + } + + expect(mockStdin._rawMode).toBe(false); + }); + it("should register a key handler and receive a simple key event", (done) => { onKey((event: KeyEvent) => { expect(event.name).toBe("a"); diff --git a/tests/units/view/layout.test.ts b/tests/units/view/layout.test.ts index 67df071..35a68db 100644 --- a/tests/units/view/layout.test.ts +++ b/tests/units/view/layout.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { VStack, HStack } from "@/view/layout"; +import { VStack, HStack } from "@/view/collections"; import { Text } from "@/view/primitives"; describe("Layout Components", () => { diff --git a/tests/units/view/windowed.test.ts b/tests/units/view/windowed.test.ts new file mode 100644 index 0000000..456e3f8 --- /dev/null +++ b/tests/units/view/windowed.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "bun:test"; +import { Windowed, ViewportSlice } from "@/view"; +import { Text } from "@/view/primitives"; + +describe("Windowed", () => { + it("renders without requiring viewportRows (fallback bound)", () => { + const items = Array.from({ length: 3 }, (_, i) => i); + const el = Windowed({ + items, + overscan: 0, + renderItem: (item) => Text(`item ${item}`), + }).build(); + + expect(el.type).toBe("block"); + expect(el.style.flexDirection).toBe("column"); + expect(el.children).toHaveLength(3); + expect((el.children[0] as any).content).toBe("item 0"); + expect((el.children[2] as any).content).toBe("item 2"); + }); +}); + +describe("ViewportSlice", () => { + it("renders a visible slice from startIndex", () => { + const items = Array.from({ length: 10 }, (_, i) => i); + const el = ViewportSlice({ + items, + startIndex: 3, + viewportRows: 4, + itemHeight: 1, + overscan: 0, + renderItem: (item) => Text(`item ${item}`), + }).build(); + + expect(el.type).toBe("block"); + expect(el.style.flexDirection).toBe("column"); + expect(el.style.layoutBoundary).toBe(true); + expect(el.style.scrollRegion).toBe(true); + expect(el.children).toHaveLength(4); + expect(el.children[0]!.type).toBe("text"); + expect((el.children[0] as any).content).toBe("item 3"); + expect((el.children[3] as any).content).toBe("item 6"); + }); + + it("applies overscan after the viewport", () => { + const items = Array.from({ length: 10 }, (_, i) => i); + const el = ViewportSlice({ + items, + startIndex: 0, + viewportRows: 3, + itemHeight: 1, + overscan: 2, + renderItem: (item) => Text(`item ${item}`), + }).build(); + + expect(el.children).toHaveLength(5); + expect((el.children[4] as any).content).toBe("item 4"); + }); + + it("uses keyPrefix for stable item keys when missing", () => { + const items = ["a", "b", "c"]; + const el = ViewportSlice({ + items, + startIndex: 1, + viewportRows: 2, + itemHeight: 1, + overscan: 0, + keyPrefix: "list", + renderItem: (item) => Text(item), + }).build(); + + expect(el.children).toHaveLength(2); + expect(el.children[0]!.key).toBe("list/1"); + expect(el.children[0]!.identifier).toBe("list/1"); + expect(el.children[1]!.key).toBe("list/2"); + }); +});