From d3a67530e16daf51965a4f80aa6de220be1d0ef9 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:14:32 +0900 Subject: [PATCH 01/15] Add windowed virtual list and clipping renderer --- examples/virtual-list.ts | 51 ++++++ src/layout/renderer.ts | 193 +++++++++++++++++++--- src/runtime/render-loop.ts | 15 +- src/view/collections/index.ts | 2 + src/view/{ => collections}/layout.ts | 4 +- src/view/collections/windowed.ts | 88 ++++++++++ src/view/index.ts | 2 +- tests/units/layout/index.test.ts | 2 +- tests/units/layout/justify.test.ts | 2 +- tests/units/layout/renderer.test.ts | 16 ++ tests/units/runtime/terminal-size.test.ts | 2 +- tests/units/view/layout.test.ts | 2 +- tests/units/view/windowed.test.ts | 59 +++++++ 13 files changed, 407 insertions(+), 31 deletions(-) create mode 100644 examples/virtual-list.ts create mode 100644 src/view/collections/index.ts rename src/view/{ => collections}/layout.ts (87%) create mode 100644 src/view/collections/windowed.ts create mode 100644 tests/units/view/windowed.test.ts diff --git a/examples/virtual-list.ts b/examples/virtual-list.ts new file mode 100644 index 0000000..414952e --- /dev/null +++ b/examples/virtual-list.ts @@ -0,0 +1,51 @@ +import { createApp, ref } from "@/index"; +import { Text, VStack, Windowed } from "@/view"; + +const TOTAL = 50_000; +const items = Array.from({ length: TOTAL }, (_, i) => `item ${i}`); + +const app = createApp({ + init({ onKey, onResize, runtime }) { + const scrollIndex = ref(0); + const size = ref(runtime.getSize()); + + onResize(() => { + size.value = runtime.getSize(); + }); + + const clamp = (value: number) => { + const viewportRows = Math.max(0, size.value.rows - 2); + const maxScroll = Math.max(0, items.length - viewportRows); + return Math.max(0, Math.min(maxScroll, value)); + }; + + onKey((k) => { + if (k.name === "q") runtime.exit(0); + if (k.name === "down") scrollIndex.value = clamp(scrollIndex.value + 1); + if (k.name === "up") scrollIndex.value = clamp(scrollIndex.value - 1); + if (k.name === "pagedown") scrollIndex.value = clamp(scrollIndex.value + 20); + if (k.name === "pageup") scrollIndex.value = clamp(scrollIndex.value - 20); + }); + + return { scrollIndex, size }; + }, + render({ scrollIndex, size }) { + const viewportRows = Math.max(0, size.value.rows - 2); + const header = Text(`Windowed: ${items.length} items (q to quit)`).foreground("cyan").shrink(0); + const status = Text(`startIndex=${scrollIndex.value}`).foreground("gray").shrink(0); + + const list = Windowed({ + items, + startIndex: scrollIndex.value, + viewportRows, + 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/src/layout/renderer.ts b/src/layout/renderer.ts index abf37da..3247194 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,19 +152,26 @@ 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, ); } @@ -57,22 +183,35 @@ export function renderElement( ? { 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 x = elementRect.x; + const y = elementRect.y; + const w = elementRect.width; + const h = elementRect.height; - const borderStyle = color !== undefined ? { fg: color } : undefined; + const borderStyle = + color !== undefined ? ({ fg: color } satisfies { fg?: ColorValue }) : 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); + 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, + ); - 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); + 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); } if (isText(element)) { @@ -81,20 +220,30 @@ export function renderElement( 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); } } } diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index 36a39ed..b162d4c 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -24,6 +24,7 @@ export interface RenderLoopDeps { layoutMap: ComputedLayout, parentX?: number, parentY?: number, + clipRect?: { x: number; y: number; width: number; height: number }, ) => void; } @@ -169,10 +170,20 @@ export function createRenderer(config: RenderLoopConfig) { } if (config.profiler && frame) { config.profiler.measure(frame, "renderMs", () => { - deps.renderElement(rootElement, buf, layoutResult, 0, 0); + deps.renderElement(rootElement, buf, layoutResult, 0, 0, { + x: 0, + y: 0, + width: state.currentSize.cols, + height: state.currentSize.rows, + }); }); } else { - deps.renderElement(rootElement, buf, layoutResult, 0, 0); + deps.renderElement(rootElement, buf, layoutResult, 0, 0, { + x: 0, + y: 0, + width: state.currentSize.cols, + height: state.currentSize.rows, + }); } config.profiler?.drawHud(buf); diff --git a/src/view/collections/index.ts b/src/view/collections/index.ts new file mode 100644 index 0000000..5d4e9f7 --- /dev/null +++ b/src/view/collections/index.ts @@ -0,0 +1,2 @@ +export * from "./windowed"; +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/windowed.ts b/src/view/collections/windowed.ts new file mode 100644 index 0000000..33cabb0 --- /dev/null +++ b/src/view/collections/windowed.ts @@ -0,0 +1,88 @@ +import { Block, type BlockElement } from "../primitives/block"; +import type { ViewElement } from "../types/elements"; + +export interface WindowedOptions { + 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; +} + +/** + * Renders a "window" (visible slice) of a large item collection. + * + * This is a view-level helper that returns a `Block` with only the visible + * children, reducing render traversal costs for very large lists. + */ +export function Windowed(options: WindowedOptions): 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 = child.style ?? {}; + child.style.flexShrink = 0; + } + if (safeItemHeight !== 1 && child.style?.height === undefined) { + child.style = child.style ?? {}; + 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"); + // Prevent overscan items from affecting parent flex layout sizing. + // With no explicit height, the container's "base size" becomes children sum, + // which can exceed the viewport and cause siblings (e.g. headers) to shrink to 0 rows. + if (container.style.height === undefined) { + container.style.height = safeViewportRows; + } + if (container.style.flexShrink === undefined) { + container.style.flexShrink = 0; + } + return container; +} diff --git a/src/view/index.ts b/src/view/index.ts index e882e8b..a784fae 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -1,2 +1,2 @@ export * from "./primitives"; -export * from "./layout"; +export * from "./collections"; 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/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/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..d59779e --- /dev/null +++ b/tests/units/view/windowed.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "bun:test"; +import { Windowed } from "@/view"; +import { Text } from "@/view/primitives"; + +describe("Windowed", () => { + it("renders a visible slice from startIndex", () => { + const items = Array.from({ length: 10 }, (_, i) => i); + const el = Windowed({ + 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.height).toBe(4); + expect(el.style.flexShrink).toBe(0); + 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 = Windowed({ + 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 = Windowed({ + 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"); + }); +}); From 8f9b324ef1163d8a5c229fbb9873b2deb3411a9b Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:16:43 +0900 Subject: [PATCH 02/15] Draw contents before outline so outline is visible Move text and block rendering before the outline so borders and outlines are drawn last and always overlay content. Remove duplicate rendering block at the end of the function. Preserve stack='z' behavior and compute child clip from padding. --- src/layout/renderer.ts | 67 +++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 34 deletions(-) diff --git a/src/layout/renderer.ts b/src/layout/renderer.ts index 3247194..a6c847d 100644 --- a/src/layout/renderer.ts +++ b/src/layout/renderer.ts @@ -175,6 +175,39 @@ export function renderElement( ); } + 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; + 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") { + for (const child of element.children) { + renderElement(child, buffer, layoutMap, absX, absY, childClip); + } + } else { + for (const child of element.children) { + 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; @@ -213,38 +246,4 @@ export function renderElement( drawTextClipped(buffer, y + h - 1, x, chars.bl, borderStyle, elementClip); drawTextClipped(buffer, y + h - 1, x + w - 1, chars.br, borderStyle, elementClip); } - - 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; - 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, childClip); - } - } else { - for (const child of element.children) { - renderElement(child, buffer, layoutMap, absX, absY, childClip); - } - } - } } From 527bb24eaa6b5ddb5b713708db46ad5e9ccb03a1 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:19:25 +0900 Subject: [PATCH 03/15] Mark virtual scrolling implemented in JP roadmap --- docs/roadmap.ja.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index ae0a88d..0ec03e6 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -26,7 +26,7 @@ - [ ] **FFI通信の効率化** - [ ] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) - [ ] **大規模描画サポート** - - [ ] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 + - [x] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 - [ ] スクロールリージョン(DECSTBM)を活用した高速スクロール - [ ] **リアクティビティの高度化** - [ ] Effect Scope の導入(コンポーネントに紐付いた Effect の自動追跡・破棄) From d084e3587736b0a7f50cc7e16a6bd610781422e6 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:36:20 +0900 Subject: [PATCH 04/15] Add DECSTBM scroll detection and support Detect vertical content shifts and emit a DECSTBM scroll prefix to perform fast terminal scrolls. Map previous row indices to avoid redrawing scrolled lines and track scrollOps in DiffStats. Also update render paths, improve ANSI sanitize regex, and add unit tests. --- docs/roadmap.ja.md | 2 +- src/renderer/diff.ts | 246 ++++++++++++++++++++++++++++-- src/renderer/sanitize.ts | 19 ++- tests/units/renderer/diff.test.ts | 42 +++++ 4 files changed, 292 insertions(+), 17 deletions(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index 0ec03e6..a0456aa 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -27,7 +27,7 @@ - [ ] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) - [ ] **大規模描画サポート** - [x] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 - - [ ] スクロールリージョン(DECSTBM)を活用した高速スクロール + - [x] スクロールリージョン(DECSTBM)を活用した高速スクロール - [ ] **リアクティビティの高度化** - [ ] Effect Scope の導入(コンポーネントに紐付いた Effect の自動追跡・破棄) - [x] **開発体験 (DX) / 大規模開発サポート** diff --git a/src/renderer/diff.ts b/src/renderer/diff.ts index 5aa0792..31eb323 100644 --- a/src/renderer/diff.ts +++ b/src/renderer/diff.ts @@ -8,6 +8,7 @@ export interface DiffStats { fgChanges: number; bgChanges: number; resets: number; + scrollOps?: number; ops: number; } @@ -43,14 +44,36 @@ 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 scroll = + !sizeChanged && process.env.BTUIN_DISABLE_DECSTBM !== "1" + ? detectVerticalScrollRegion(prev, next, asciiFastPath) + : null; + const rowMap = scroll ? buildScrollRowMap(rows, scroll) : null; + const scrollPrefix = scroll ? buildDecstbmScrollPrefix(scroll) : ""; + 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; } @@ -61,6 +84,10 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): s // Local output buffer to batch terminal writes const out: string[] = []; + if (scrollPrefix) { + out.push(scrollPrefix); + if (stats) stats.scrollOps = (stats.scrollOps ?? 0) + 5; + } for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { @@ -69,18 +96,19 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): s if (r === rows - 1 && c === cols - 1) continue; const idx = r * cols + c; + const prevIdx = mapPrevIndex(rowMap, cols, r, c); const nextWidth = next.widths[idx]; if (nextWidth === 0) continue; - const prevWidth = prev.widths[idx] ?? 0; + 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 || @@ -129,7 +157,8 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): s } 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,8 +171,14 @@ 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; @@ -158,13 +193,14 @@ function renderDiffAscii( const nextWidth = next.widths[idx]; 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 || @@ -213,3 +249,191 @@ function renderDiffAscii( return out.length > 0 ? out.join("") : ""; } + +type ScrollRegion = { top: number; bottom: number; delta: number }; + +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, +): ScrollRegion | null { + const rows = next.rows; + const cols = next.cols; + if (rows < 8) return null; + + // 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 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/tests/units/renderer/diff.test.ts b/tests/units/renderer/diff.test.ts index 39b249d..ee97093 100644 --- a/tests/units/renderer/diff.test.ts +++ b/tests/units/renderer/diff.test.ts @@ -138,4 +138,46 @@ describe("renderDiff", () => { 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); + + // 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"); + }); }); From d7eff621810e37982f1e0b3ff828f70e8dbe3006 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 00:57:52 +0900 Subject: [PATCH 05/15] Bump layout engine ABI and add FlatBuffer scroll --- scripts/profiler-scroll.spec.ts | 80 +++++ src/layout-engine/index.ts | 314 ++++++++++++++++-- src/layout-engine/src/lib.rs | 288 ++++++++++++---- src/renderer/buffer.ts | 98 ++++++ src/runtime/render-loop.ts | 298 ++++++++++++++++- src/view/base.ts | 1 + src/view/collections/windowed.ts | 1 + .../units/layout-engine/ffi-boundary.test.ts | 2 +- tests/units/layout-engine/index.test.ts | 48 +++ tests/units/renderer/buffer-scroll.test.ts | 50 +++ 10 files changed, 1080 insertions(+), 100 deletions(-) create mode 100644 scripts/profiler-scroll.spec.ts create mode 100644 tests/units/renderer/buffer-scroll.test.ts diff --git a/scripts/profiler-scroll.spec.ts b/scripts/profiler-scroll.spec.ts new file mode 100644 index 0000000..aa2175d --- /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, Windowed } 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( + Windowed({ + 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/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/runtime/render-loop.ts b/src/runtime/render-loop.ts index b162d4c..edaee0b 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -8,6 +8,8 @@ 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; @@ -101,8 +103,129 @@ export function createRenderer(config: RenderLoopConfig) { let prevRootElement: ViewElement | null = null; let prevLayoutResult: ComputedLayout | null = null; let prevLayoutSizeKey: string | null = null; + let prevAbsRects: Map | null = null; + let prevRenderSigs: Map | null = null; let renderEffect: ReactiveEffect | null = null; + 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; + + 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 (!scrollRegion && 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) { + scrollRegion = { + band: { top: content.y, bottom: content.y + content.height - 1 }, + fullWidth: content.x === 0 && content.width === screen.width, + }; + } + } + }; + + walk(root, 0, 0); + return { rects, sigs, scrollRegion }; + } + /** * Performs a render cycle * @@ -154,6 +277,17 @@ export function createRenderer(config: RenderLoopConfig) { prevLayoutResult = layoutResult; prevLayoutSizeKey = layoutSizeKey; + const previousRects = prevAbsRects; + const previousSigs = prevRenderSigs; + const { + rects: absRects, + sigs, + scrollRegion, + } = collectAbsRectsAndFindScrollRegion(rootElement, layoutResult); + + prevAbsRects = absRects; + prevRenderSigs = sigs; + try { config.onLayout?.({ size: state.currentSize, rootElement, layoutMap: layoutResult }); } catch { @@ -168,22 +302,162 @@ 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 tryScrollFastPath = (): { clips: Rect[] } | null => { + if (process.env.BTUIN_DISABLE_SCROLL_FASTPATH === "1") return null; + if (sizeChanged || forceFullRedraw) 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 outside the scroll band was removed (hard to "erase" safely). + for (const key of previousSigs.keys()) { + if (sigs.has(key)) continue; + const prevRect = previousRects.get(key); + if (!prevRect) continue; + const prevIn = prevRect.y >= top && prevRect.y <= bottom; + if (!prevIn) return null; + } + + const clips: Rect[] = []; + clips.push({ x: 0, y: exposedY, width: fullClip.width, height: exposedHeight }); + + // Also redraw any elements outside the scroll band 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 inBand = rect.y >= top && rect.y <= bottom; + if (inBand) continue; + + const clipped = intersectRect(rect, fullClip); + if (clipped) clips.push(clipped); + } + + // 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 }; + }; + + const scrollFast = tryScrollFastPath(); + if (config.profiler && frame) { config.profiler.measure(frame, "renderMs", () => { - deps.renderElement(rootElement, buf, layoutResult, 0, 0, { - x: 0, - y: 0, - width: state.currentSize.cols, - height: state.currentSize.rows, - }); + if (scrollFast) { + for (const clip of scrollFast.clips) { + 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, { - x: 0, - y: 0, - width: state.currentSize.cols, - height: state.currentSize.rows, - }); + if (scrollFast) { + for (const clip of scrollFast.clips) { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, clip); + } + } else { + deps.renderElement(rootElement, buf, layoutResult, 0, 0, fullClip); + } } config.profiler?.drawHud(buf); diff --git a/src/view/base.ts b/src/view/base.ts index 2a8f59a..88d9716 100644 --- a/src/view/base.ts +++ b/src/view/base.ts @@ -22,6 +22,7 @@ export interface ViewProps { background?: string | number; outline?: OutlineOptions; stack?: "z"; + scrollRegion?: boolean; }; } diff --git a/src/view/collections/windowed.ts b/src/view/collections/windowed.ts index 33cabb0..6661cc8 100644 --- a/src/view/collections/windowed.ts +++ b/src/view/collections/windowed.ts @@ -75,6 +75,7 @@ export function Windowed(options: WindowedOptions): BlockElement { } const container = Block(...children).direction("column"); + container.style.scrollRegion = true; // Prevent overscan items from affecting parent flex layout sizing. // With no explicit height, the container's "base size" becomes children sum, // which can exceed the viewport and cause siblings (e.g. headers) to shrink to 0 rows. 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/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(); + }); +}); From 189d81913dd7d40a6dcffc14a153d2d67d5432ba Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 01:31:14 +0900 Subject: [PATCH 06/15] Add View dirty-tracking and render optimizations Introduce a view/dirty module and proxy-based tracking for style and children mutations. Renderer now samples dirty versions and the hasScrollRegion hint to skip layout/collect traversals and short- circuit renders when nothing changed. Add renderer.invalidate and call it from LoopManager cleanup. Make TextElement.content setter mark render-only dirty to avoid unnecessary layout. --- src/runtime/loop.ts | 1 + src/runtime/render-loop.ts | 82 ++++++++++++++++++++++++------------ src/view/base.ts | 79 +++++++++++++++++++++++++++++++++- src/view/dirty.ts | 24 +++++++++++ src/view/primitives/block.ts | 20 ++++++++- src/view/primitives/text.ts | 18 +++++++- 6 files changed, 194 insertions(+), 30 deletions(-) create mode 100644 src/view/dirty.ts diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index 3241e77..a22740f 100644 --- a/src/runtime/loop.ts +++ b/src/runtime/loop.ts @@ -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) { diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index edaee0b..6eebc43 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -4,6 +4,7 @@ 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 } from "../view/dirty"; import { createErrorContext } from "./error-boundary"; import type { Profiler } from "./profiler"; import type { ComputedLayout } from "../layout-engine/types"; @@ -103,9 +104,16 @@ 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; + + function invalidate() { + invalidated = true; + } function resolvePadding(padding: unknown): { top: number; @@ -233,11 +241,13 @@ export function createRenderer(config: RenderLoopConfig) { */ function renderOnce(forceFullRedraw = false): void { try { + const localForceFullRedraw = forceFullRedraw || invalidated; + invalidated = 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); @@ -248,6 +258,7 @@ export function createRenderer(config: RenderLoopConfig) { } const rootElement = config.view(config.getState()); + const dirtyVersions = getDirtyVersions(); const layoutSizeKey = `${state.currentSize.cols}x${state.currentSize.rows}`; const nodeCount = @@ -256,11 +267,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, { @@ -276,17 +302,8 @@ export function createRenderer(config: RenderLoopConfig) { prevRootElement = rootElement; prevLayoutResult = layoutResult; prevLayoutSizeKey = layoutSizeKey; - - const previousRects = prevAbsRects; - const previousSigs = prevRenderSigs; - const { - rects: absRects, - sigs, - scrollRegion, - } = collectAbsRectsAndFindScrollRegion(rootElement, layoutResult); - - prevAbsRects = absRects; - prevRenderSigs = sigs; + prevDirtyVersions = dirtyVersions; + prevLayoutAtLayoutVersion = dirtyVersions.layout; try { config.onLayout?.({ size: state.currentSize, rootElement, layoutMap: layoutResult }); @@ -309,9 +326,28 @@ export function createRenderer(config: RenderLoopConfig) { 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; + if (!sizeChanged && !localForceFullRedraw && getHasScrollRegion()) { + const collected = collectAbsRectsAndFindScrollRegion(rootElement, layoutResult); + absRects = collected.rects; + sigs = collected.sigs; + scrollRegion = collected.scrollRegion; + prevAbsRects = absRects; + prevRenderSigs = sigs; + } else { + // Avoid carrying stale maps across resizes/full redraws and don't pay the traversal cost + // when scroll regions are not used. + prevAbsRects = null; + prevRenderSigs = null; + } + const tryScrollFastPath = (): { clips: Rect[] } | null => { if (process.env.BTUIN_DISABLE_SCROLL_FASTPATH === "1") return null; - if (sizeChanged || forceFullRedraw) return null; + if (sizeChanged || localForceFullRedraw) return null; if (!previousRects || !absRects || !scrollRegion) return null; if (!scrollRegion.fullWidth) return null; @@ -475,7 +511,7 @@ export function createRenderer(config: RenderLoopConfig) { } : undefined; - const prevForDiff = forceFullRedraw + const prevForDiff = localForceFullRedraw ? new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols) : state.prevBuffer; @@ -483,22 +519,15 @@ export function createRenderer(config: RenderLoopConfig) { 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; 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); } } @@ -536,6 +565,7 @@ export function createRenderer(config: RenderLoopConfig) { return { render, renderOnce, + invalidate, dispose, getState, }; diff --git a/src/view/base.ts b/src/view/base.ts index 88d9716..3a33999 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 { @@ -30,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; @@ -106,12 +179,14 @@ export abstract class BaseView implements ViewProps { setKey(value: string): this { this.key = value; this.identifier = value; + markLayoutDirty(); return this; } setIdentifier(value: string): this { this.key = value; this.identifier = value; + markLayoutDirty(); return this; } diff --git a/src/view/dirty.ts b/src/view/dirty.ts new file mode 100644 index 0000000..fcf5970 --- /dev/null +++ b/src/view/dirty.ts @@ -0,0 +1,24 @@ +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 }; +} 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..818d0b0 100644 --- a/src/view/primitives/text.ts +++ b/src/view/primitives/text.ts @@ -1,11 +1,27 @@ import { BaseView } from "../base"; +import { 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; + // By default, treat content changes as render-only; layout should be driven by explicit + // layout styles rather than intrinsic text measurement (keeps TUI updates cheap). + markRenderDirty(); } bold(): this { From 39035622f0b982d01aa69d59f88b71212f20676b Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 02:09:32 +0900 Subject: [PATCH 07/15] Reconcile immediate-mode view trees Reconcile next immediate-mode ViewElement trees into the retained previous tree to preserve object identities and avoid spurious dirty marks. Add reconcileTree and setDirtyVersions, export a retained cache, optimize BaseView key/identifier setters, adjust windowed/virtual-list layout, and add a test to ensure unchanged frames are skipped. --- examples/virtual-list.ts | 5 +- scripts/profiler-limit.spec.ts | 116 +++++++++--------- src/renderer/diff.ts | 86 ++++++++++++- src/runtime/render-loop.ts | 32 ++++- src/view/base.ts | 2 + src/view/collections/windowed.ts | 2 - src/view/dirty.ts | 5 + src/view/index.ts | 1 + src/view/reconcile.ts | 156 ++++++++++++++++++++++++ src/view/retained.ts | 49 ++++++++ tests/units/renderer/diff.test.ts | 2 +- tests/units/runtime/render-loop.test.ts | 36 ++++++ 12 files changed, 424 insertions(+), 68 deletions(-) create mode 100644 src/view/reconcile.ts create mode 100644 src/view/retained.ts diff --git a/examples/virtual-list.ts b/examples/virtual-list.ts index 414952e..867194c 100644 --- a/examples/virtual-list.ts +++ b/examples/virtual-list.ts @@ -14,7 +14,7 @@ const app = createApp({ }); const clamp = (value: number) => { - const viewportRows = Math.max(0, size.value.rows - 2); + const viewportRows = Math.max(0, size.value.rows - 4); const maxScroll = Math.max(0, items.length - viewportRows); return Math.max(0, Math.min(maxScroll, value)); }; @@ -30,7 +30,8 @@ const app = createApp({ return { scrollIndex, size }; }, render({ scrollIndex, size }) { - const viewportRows = Math.max(0, size.value.rows - 2); + // Reserve 2 rows for header+status and 2 rows for outline padding (1 top + 1 bottom). + const viewportRows = Math.max(0, size.value.rows - 4); const header = Text(`Windowed: ${items.length} items (q to quit)`).foreground("cyan").shrink(0); const status = Text(`startIndex=${scrollIndex.value}`).foreground("gray").shrink(0); 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/src/renderer/diff.ts b/src/renderer/diff.ts index 31eb323..cc5c957 100644 --- a/src/renderer/diff.ts +++ b/src/renderer/diff.ts @@ -12,6 +12,15 @@ export interface DiffStats { 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 }; +} + /** * Renders the difference between two buffers, only updating changed cells. * If buffer sizes differ (e.g., after terminal resize), forces a full redraw. @@ -26,8 +35,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 ""; @@ -49,9 +64,11 @@ export function renderDiff(prev: Buffer2D, next: Buffer2D, stats?: DiffStats): s } const asciiFastPath = prev.isAsciiOnly() && next.isAsciiOnly(); + const hint = options?.scrollRegion; + const allowAuto = process.env.BTUIN_DECSTBM_AUTO === "1"; const scroll = - !sizeChanged && process.env.BTUIN_DISABLE_DECSTBM !== "1" - ? detectVerticalScrollRegion(prev, next, asciiFastPath) + !sizeChanged && process.env.BTUIN_DISABLE_DECSTBM !== "1" && (hint || allowAuto) + ? detectVerticalScrollRegion(prev, next, asciiFastPath, hint) : null; const rowMap = scroll ? buildScrollRowMap(rows, scroll) : null; const scrollPrefix = scroll ? buildDecstbmScrollPrefix(scroll) : ""; @@ -300,11 +317,19 @@ 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[] = []; @@ -390,6 +415,61 @@ function detectVerticalScrollRegion( 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, diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index 6eebc43..e2f5df4 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -4,7 +4,8 @@ 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 } from "../view/dirty"; +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"; @@ -19,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, @@ -257,7 +263,16 @@ 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 layoutSizeKey = `${state.currentSize.cols}x${state.currentSize.rows}`; @@ -515,10 +530,17 @@ export function createRenderer(config: RenderLoopConfig) { ? new deps.FlatBuffer(state.currentSize.rows, state.currentSize.cols) : state.prevBuffer; + const diffOptions = + !sizeChanged && !localForceFullRedraw && scrollRegion?.fullWidth + ? ({ + scrollRegion: scrollRegion.band, + } satisfies import("../renderer/diff").RenderDiffOptions) + : undefined; + const output = config.profiler?.measure(frame, "diffMs", () => - deps.renderDiff(prevForDiff, buf, diffStats), - ) ?? deps.renderDiff(prevForDiff, buf); + deps.renderDiff(prevForDiff, buf, diffStats, diffOptions), + ) ?? deps.renderDiff(prevForDiff, buf, undefined, diffOptions); if (frame && diffStats) { config.profiler?.recordDiffStats(frame, diffStats); } diff --git a/src/view/base.ts b/src/view/base.ts index 3a33999..28f78aa 100644 --- a/src/view/base.ts +++ b/src/view/base.ts @@ -177,6 +177,7 @@ 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(); @@ -184,6 +185,7 @@ export abstract class BaseView implements ViewProps { } setIdentifier(value: string): this { + if (this.key === value && this.identifier === value) return this; this.key = value; this.identifier = value; markLayoutDirty(); diff --git a/src/view/collections/windowed.ts b/src/view/collections/windowed.ts index 6661cc8..d5baa21 100644 --- a/src/view/collections/windowed.ts +++ b/src/view/collections/windowed.ts @@ -59,11 +59,9 @@ export function Windowed(options: WindowedOptions): BlockElement { // 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 = child.style ?? {}; child.style.flexShrink = 0; } if (safeItemHeight !== 1 && child.style?.height === undefined) { - child.style = child.style ?? {}; child.style.height = safeItemHeight; } if (keyPrefix && !child.key && !child.identifier) { diff --git a/src/view/dirty.ts b/src/view/dirty.ts index fcf5970..d9e8636 100644 --- a/src/view/dirty.ts +++ b/src/view/dirty.ts @@ -22,3 +22,8 @@ export function getHasScrollRegion(): boolean { 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 a784fae..d45a080 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -1,2 +1,3 @@ export * from "./primitives"; export * from "./collections"; +export * from "./retained"; 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/renderer/diff.test.ts b/tests/units/renderer/diff.test.ts index ee97093..ead39f9 100644 --- a/tests/units/renderer/diff.test.ts +++ b/tests/units/renderer/diff.test.ts @@ -171,7 +171,7 @@ describe("renderDiff", () => { // Newly exposed line at the bottom of the region (row 8). next.set(8, 0, "Z"); - const output = renderDiff(prev, next); + 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"; diff --git a/tests/units/runtime/render-loop.test.ts b/tests/units/runtime/render-loop.test.ts index 9e8c931..d212157 100644 --- a/tests/units/runtime/render-loop.test.ts +++ b/tests/units/runtime/render-loop.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "bun:test"; import { createRenderer } from "@/runtime/render-loop"; import { Block } from "@/view/primitives"; import { FlatBuffer } from "@/renderer"; +import { setDirtyVersions } from "@/view/dirty"; import type { Buffer2D } from "@/types"; const mockLayoutResult = { root: { x: 0, y: 0, width: 80, height: 24 } }; @@ -54,6 +55,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"); From 0d98eec5153ced7bb6da68790c8b649f9048a84f Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 02:27:32 +0900 Subject: [PATCH 08/15] Batch terminal writes and add full redraw paths Reduce excessive cursor moves by batching printable text into a pending buffer and flushing only when control sequences are emitted. Introduce pushCtl/flushText/moveTo helpers and an "inRun" fast-path to coalesce adjacent glyph writes. Add renderFullRedraw and renderFullRedrawAscii for size changes (update stats and preserve scrollPrefix behavior). Update tests to reflect batched output and new cursor move counts --- src/renderer/diff.ts | 313 ++++++++++++++++++++++++------ tests/units/renderer/diff.test.ts | 15 +- 2 files changed, 262 insertions(+), 66 deletions(-) diff --git a/src/renderer/diff.ts b/src/renderer/diff.ts index cc5c957..ac5ff0a 100644 --- a/src/renderer/diff.ts +++ b/src/renderer/diff.ts @@ -73,6 +73,26 @@ export function renderDiff( 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, @@ -98,26 +118,49 @@ export function renderDiff( 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) { - out.push(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 prevIdx = mapPrevIndex(rowMap, cols, r, c); - - const nextWidth = next.widths[idx]; + const nextWidth = next.widths[idx] ?? 1; if (nextWidth === 0) continue; + const prevIdx = mapPrevIndex(rowMap, cols, r, c); const prevWidth = prevIdx === -1 ? 1 : (prev.widths[prevIdx] ?? 0); const nextGlyphKey = next.glyphKeyAtIndex(idx); const prevGlyphKey = prevIdx === -1 ? 32 : prev.glyphKeyAtIndex(prevIdx); @@ -128,46 +171,43 @@ export function renderDiff( 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++; @@ -199,15 +239,38 @@ function renderDiffAscii( 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 prevIdx = mapPrevIndex(rowMap, cols, r, c); @@ -220,45 +283,40 @@ function renderDiffAscii( 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++; @@ -267,6 +325,145 @@ 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 buildDecstbmScrollPrefix(region: ScrollRegion): string { diff --git a/tests/units/renderer/diff.test.ts b/tests/units/renderer/diff.test.ts index ead39f9..3f6df5d 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,7 +132,8 @@ 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); }); From 61dd4a1cb2282b191e71798b29cb9171dc6587a3 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 15:39:36 +0900 Subject: [PATCH 09/15] Add ViewportSlice and refactor Windowed metrics Introduce ViewportSlice primitive for rendering visible list slices. Add getWindowedMetrics and clampWindowedStartIndex; Windowed now uses terminal-height fallback and delegates to ViewportSlice. Update examples, tests, and profiler script to use the new APIs. --- docs/roadmap.ja.md | 6 +- examples/virtual-list.ts | 53 +++++++----- scripts/profiler-scroll.spec.ts | 4 +- src/view/collections/index.ts | 1 + src/view/collections/viewport-slice.ts | 84 +++++++++++++++++++ src/view/collections/windowed.ts | 111 ++++++++++++++----------- tests/units/view/windowed.test.ts | 29 +++++-- 7 files changed, 205 insertions(+), 83 deletions(-) create mode 100644 src/view/collections/viewport-slice.ts diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index a0456aa..c22a5a5 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -23,9 +23,9 @@ - [ ] ブラウザ側からのリアクティブ・ステート(Ref)の直接書き換え - [ ] リモートキーイベント送信(ブラウザ仮想キーボードからの入力注入) - [ ] **アーキテクチャ・最適化** - - [ ] **FFI通信の効率化** - - [ ] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) - - [ ] **大規模描画サポート** + - [x] **FFI通信の効率化** + - [x] フルシリアライズの回避(Dirty Checking による部分的なレイアウト更新) + - [x] **大規模描画サポート** - [x] 仮想ウィンドウ化(Virtual Scrolling)による数万行のリスト表示 - [x] スクロールリージョン(DECSTBM)を活用した高速スクロール - [ ] **リアクティビティの高度化** diff --git a/examples/virtual-list.ts b/examples/virtual-list.ts index 867194c..6579586 100644 --- a/examples/virtual-list.ts +++ b/examples/virtual-list.ts @@ -1,44 +1,51 @@ import { createApp, ref } from "@/index"; -import { Text, VStack, Windowed } from "@/view"; +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, onResize, runtime }) { + init({ onKey, runtime }) { const scrollIndex = ref(0); - const size = ref(runtime.getSize()); - - onResize(() => { - size.value = runtime.getSize(); - }); - - const clamp = (value: number) => { - const viewportRows = Math.max(0, size.value.rows - 4); - const maxScroll = Math.max(0, items.length - viewportRows); - return Math.max(0, Math.min(maxScroll, value)); - }; onKey((k) => { if (k.name === "q") runtime.exit(0); - if (k.name === "down") scrollIndex.value = clamp(scrollIndex.value + 1); - if (k.name === "up") scrollIndex.value = clamp(scrollIndex.value - 1); - if (k.name === "pagedown") scrollIndex.value = clamp(scrollIndex.value + 20); - if (k.name === "pageup") scrollIndex.value = clamp(scrollIndex.value - 20); + 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, size }; + return { scrollIndex }; }, - render({ scrollIndex, size }) { + render({ scrollIndex }) { // Reserve 2 rows for header+status and 2 rows for outline padding (1 top + 1 bottom). - const viewportRows = Math.max(0, size.value.rows - 4); const header = Text(`Windowed: ${items.length} items (q to quit)`).foreground("cyan").shrink(0); - const status = Text(`startIndex=${scrollIndex.value}`).foreground("gray").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: scrollIndex.value, - viewportRows, + startIndex: clamped, itemHeight: 1, overscan: 2, keyPrefix: "windowed", diff --git a/scripts/profiler-scroll.spec.ts b/scripts/profiler-scroll.spec.ts index aa2175d..1d1a694 100644 --- a/scripts/profiler-scroll.spec.ts +++ b/scripts/profiler-scroll.spec.ts @@ -1,7 +1,7 @@ import { test, describe, expect } from "bun:test"; import { existsSync } from "node:fs"; -import { createApp, ref, Block, Text, Windowed } from "@/index"; +import { createApp, ref, Block, Text, ViewportSlice } from "@/index"; import { createNullTerminalAdapter, printSummary, type ProfilerLog } from "./profiler-core"; const N = 50_000; @@ -39,7 +39,7 @@ const app = createApp({ const root = Block().direction("column"); root.add(header); root.add( - Windowed({ + ViewportSlice({ items, startIndex, viewportRows: 30, diff --git a/src/view/collections/index.ts b/src/view/collections/index.ts index 5d4e9f7..4a747bb 100644 --- a/src/view/collections/index.ts +++ b/src/view/collections/index.ts @@ -1,2 +1,3 @@ export * from "./windowed"; +export * from "./viewport-slice"; export * from "./layout"; 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 index d5baa21..1af548b 100644 --- a/src/view/collections/windowed.ts +++ b/src/view/collections/windowed.ts @@ -1,16 +1,15 @@ -import { Block, type BlockElement } from "../primitives/block"; +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). * - * Clamp this in your state update logic; this function clamps defensively too. + * Defaults to 0. */ - startIndex: number; - /** Viewport height in terminal rows (cells). */ - viewportRows: number; + startIndex?: number; /** Fixed item height in rows (cells). */ itemHeight?: number; /** @@ -27,6 +26,28 @@ export interface WindowedOptions { 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); @@ -35,53 +56,45 @@ function clampInt(value: number, min: number, max: number): number { return v; } -/** - * Renders a "window" (visible slice) of a large item collection. - * - * This is a view-level helper that returns a `Block` with only the visible - * children, reducing render traversal costs for very large lists. - */ -export function Windowed(options: WindowedOptions): 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)); +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 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 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); - 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); - } + return { viewportRows: safeViewportRows, visibleCount, maxStartIndex, startIndex, endIndex }; +} - const container = Block(...children).direction("column"); - container.style.scrollRegion = true; - // Prevent overscan items from affecting parent flex layout sizing. - // With no explicit height, the container's "base size" becomes children sum, - // which can exceed the viewport and cause siblings (e.g. headers) to shrink to 0 rows. - if (container.style.height === undefined) { - container.style.height = safeViewportRows; - } - if (container.style.flexShrink === undefined) { - container.style.flexShrink = 0; - } - return container; +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/tests/units/view/windowed.test.ts b/tests/units/view/windowed.test.ts index d59779e..456e3f8 100644 --- a/tests/units/view/windowed.test.ts +++ b/tests/units/view/windowed.test.ts @@ -1,11 +1,28 @@ import { describe, it, expect } from "bun:test"; -import { Windowed } from "@/view"; +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 = Windowed({ + const el = ViewportSlice({ items, startIndex: 3, viewportRows: 4, @@ -16,8 +33,8 @@ describe("Windowed", () => { expect(el.type).toBe("block"); expect(el.style.flexDirection).toBe("column"); - expect(el.style.height).toBe(4); - expect(el.style.flexShrink).toBe(0); + 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"); @@ -26,7 +43,7 @@ describe("Windowed", () => { it("applies overscan after the viewport", () => { const items = Array.from({ length: 10 }, (_, i) => i); - const el = Windowed({ + const el = ViewportSlice({ items, startIndex: 0, viewportRows: 3, @@ -41,7 +58,7 @@ describe("Windowed", () => { it("uses keyPrefix for stable item keys when missing", () => { const items = ["a", "b", "c"]; - const el = Windowed({ + const el = ViewportSlice({ items, startIndex: 1, viewportRows: 2, From 5327b2ae21e66be6ef2794e4424700e79406890e Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:19:31 +0900 Subject: [PATCH 10/15] Coalesce reactive triggers into one render Use a scheduler with queueMicrotask and a `scheduled` flag so the render effect is run at most once per microtask cycle. Add a unit test that verifies multiple reactive updates are coalesced into a single additional render. --- src/runtime/render-loop.ts | 16 ++++++++- tests/units/runtime/render-loop.test.ts | 43 ++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index e2f5df4..c861c54 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -116,6 +116,7 @@ export function createRenderer(config: RenderLoopConfig) { let prevDirtyVersions: { layout: number; render: number } | null = null; let renderEffect: ReactiveEffect | null = null; let invalidated = false; + let scheduled = false; function invalidate() { invalidated = true; @@ -567,7 +568,20 @@ export function createRenderer(config: RenderLoopConfig) { if (renderEffect) { stop(renderEffect); } - renderEffect = effect(() => renderOnce(false)); + + 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; } diff --git a/tests/units/runtime/render-loop.test.ts b/tests/units/runtime/render-loop.test.ts index d212157..0ba1cc8 100644 --- a/tests/units/runtime/render-loop.test.ts +++ b/tests/units/runtime/render-loop.test.ts @@ -1,8 +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 } }; @@ -119,4 +120,44 @@ 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(); + }); }); From e7f4934d0d05d6a13bc158a143b6d6939a04d1cd Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:26:13 +0900 Subject: [PATCH 11/15] Add requestRender with force and immediate options --- src/runtime/loop.ts | 7 ++-- src/runtime/render-loop.ts | 38 +++++++++++++++++++-- tests/units/runtime/render-loop.test.ts | 45 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/runtime/loop.ts b/src/runtime/loop.ts index a22740f..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 }); }); }; @@ -197,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", @@ -235,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 c861c54..4445fb5 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -117,11 +117,38 @@ export function createRenderer(config: RenderLoopConfig) { 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; @@ -248,8 +275,9 @@ export function createRenderer(config: RenderLoopConfig) { */ function renderOnce(forceFullRedraw = false): void { try { - const localForceFullRedraw = forceFullRedraw || invalidated; + 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; @@ -564,11 +592,16 @@ export function createRenderer(config: RenderLoopConfig) { } } - function render(): ReactiveEffect { + function render(options: { forceFullRedraw?: boolean } = {}): ReactiveEffect { if (renderEffect) { stop(renderEffect); } + if (options.forceFullRedraw) { + forceNextRender = true; + invalidated = true; + } + scheduled = false; const scheduleRender = (eff: ReactiveEffect) => { if (!eff.active) return; @@ -602,6 +635,7 @@ export function createRenderer(config: RenderLoopConfig) { render, renderOnce, invalidate, + requestRender, dispose, getState, }; diff --git a/tests/units/runtime/render-loop.test.ts b/tests/units/runtime/render-loop.test.ts index 0ba1cc8..12cd025 100644 --- a/tests/units/runtime/render-loop.test.ts +++ b/tests/units/runtime/render-loop.test.ts @@ -160,4 +160,49 @@ describe("createRenderer", () => { 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(); + }); }); From 729e899c8e5390e1058efc81563ec014e6a639aa Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 16:51:11 +0900 Subject: [PATCH 12/15] Add explicit scroll op and dirty-rect fast paths Add RenderDiff.scrollOp to apply an explicit DECSTBM scroll (with normalization/validation) so callers can skip scroll detection. In the render loop restrict DECSTBM to a single scroll region, return the scroll op from the fast scroll path, and introduce a dirty-rect path that reuses previous buffers when only render props change. Update map collection logic and add unit tests for both behaviors. --- src/renderer/diff.ts | 32 +++++++- src/runtime/render-loop.ts | 105 ++++++++++++++++++------ tests/units/renderer/diff.test.ts | 37 +++++++++ tests/units/runtime/render-loop.test.ts | 44 ++++++++++ 4 files changed, 191 insertions(+), 27 deletions(-) diff --git a/src/renderer/diff.ts b/src/renderer/diff.ts index ac5ff0a..b1e393e 100644 --- a/src/renderer/diff.ts +++ b/src/renderer/diff.ts @@ -19,6 +19,13 @@ export interface RenderDiffOptions { * 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 }; } /** @@ -65,10 +72,16 @@ export function renderDiff( 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" && (hint || allowAuto) - ? detectVerticalScrollRegion(prev, next, asciiFastPath, hint) + !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) : ""; @@ -466,6 +479,21 @@ function renderFullRedrawAscii( 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; diff --git a/src/runtime/render-loop.ts b/src/runtime/render-loop.ts index 4445fb5..f2d2511 100644 --- a/src/runtime/render-loop.ts +++ b/src/runtime/render-loop.ts @@ -192,6 +192,7 @@ export function createRenderer(config: RenderLoopConfig) { 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; @@ -240,7 +241,7 @@ export function createRenderer(config: RenderLoopConfig) { } } - if (!scrollRegion && element.style?.scrollRegion) { + if (element.style?.scrollRegion) { const pad = resolvePadding(element.style?.padding); const contentRect: Rect = { x: rect.x, @@ -256,10 +257,16 @@ export function createRenderer(config: RenderLoopConfig) { }; const content = intersectRect(contentRect, screen); if (content) { - scrollRegion = { - band: { top: content.y, bottom: content.y + content.height - 1 }, - fullWidth: content.x === 0 && content.width === screen.width, - }; + 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; + } } } }; @@ -303,6 +310,7 @@ export function createRenderer(config: RenderLoopConfig) { const rootElement = reconcileTree(prevRootElement, nextRootElement); const dirtyVersions = getDirtyVersions(); + const previousDirtyVersions = prevDirtyVersions; const layoutSizeKey = `${state.currentSize.cols}x${state.currentSize.rows}`; const nodeCount = @@ -346,7 +354,6 @@ export function createRenderer(config: RenderLoopConfig) { prevRootElement = rootElement; prevLayoutResult = layoutResult; prevLayoutSizeKey = layoutSizeKey; - prevDirtyVersions = dirtyVersions; prevLayoutAtLayoutVersion = dirtyVersions.layout; try { @@ -375,21 +382,29 @@ export function createRenderer(config: RenderLoopConfig) { let absRects: Map | null = null; let sigs: Map | null = null; let scrollRegion: { band: { top: number; bottom: number }; fullWidth: boolean } | null = null; - if (!sizeChanged && !localForceFullRedraw && getHasScrollRegion()) { + 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; - prevAbsRects = absRects; - prevRenderSigs = sigs; - } else { - // Avoid carrying stale maps across resizes/full redraws and don't pay the traversal cost - // when scroll regions are not used. + } else if (sizeChanged || localForceFullRedraw) { prevAbsRects = null; prevRenderSigs = null; } - const tryScrollFastPath = (): { clips: Rect[] } | 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; @@ -486,46 +501,75 @@ export function createRenderer(config: RenderLoopConfig) { if (!previousSigs || !sigs) return null; - // Bail if something outside the scroll band was removed (hard to "erase" safely). + // Bail if something was removed (hard to "erase" safely without a full redraw). for (const key of previousSigs.keys()) { if (sigs.has(key)) continue; - const prevRect = previousRects.get(key); - if (!prevRect) continue; - const prevIn = prevRect.y >= top && prevRect.y <= bottom; - if (!prevIn) return null; + return null; } const clips: Rect[] = []; clips.push({ x: 0, y: exposedY, width: fullClip.width, height: exposedHeight }); - // Also redraw any elements outside the scroll band whose render-relevant props changed. + // 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 inBand = rect.y >= top && rect.y <= bottom; - if (inBand) 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 }; + 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", () => { 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); } @@ -535,6 +579,11 @@ export function createRenderer(config: RenderLoopConfig) { 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); } @@ -560,9 +609,9 @@ export function createRenderer(config: RenderLoopConfig) { : state.prevBuffer; const diffOptions = - !sizeChanged && !localForceFullRedraw && scrollRegion?.fullWidth + scrollFast && process.env.BTUIN_DISABLE_DECSTBM !== "1" ? ({ - scrollRegion: scrollRegion.band, + scrollOp: scrollFast.scrollOp, } satisfies import("../renderer/diff").RenderDiffOptions) : undefined; @@ -586,6 +635,12 @@ 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)); diff --git a/tests/units/renderer/diff.test.ts b/tests/units/renderer/diff.test.ts index 3f6df5d..546c5e1 100644 --- a/tests/units/renderer/diff.test.ts +++ b/tests/units/renderer/diff.test.ts @@ -179,4 +179,41 @@ describe("renderDiff", () => { // 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 12cd025..a0ba9e7 100644 --- a/tests/units/runtime/render-loop.test.ts +++ b/tests/units/runtime/render-loop.test.ts @@ -205,4 +205,48 @@ describe("createRenderer", () => { 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).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(); + }); }); From 7b229f747727904c1de5a558740b12c6011eb720 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:16:34 +0900 Subject: [PATCH 13/15] Invalidate layout on intrinsic text changes Mark layout dirty when Text content changes if width or height are auto/undefined. Add a test that verifies the renderer re-runs layout when intrinsic text content changes and adjust an existing test to use explicit sizing. --- src/view/primitives/text.ts | 17 ++++++++-- tests/units/runtime/render-loop.test.ts | 41 ++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/view/primitives/text.ts b/src/view/primitives/text.ts index 818d0b0..6204f0b 100644 --- a/src/view/primitives/text.ts +++ b/src/view/primitives/text.ts @@ -1,5 +1,5 @@ import { BaseView } from "../base"; -import { markRenderDirty } from "../dirty"; +import { markLayoutDirty, markRenderDirty } from "../dirty"; import type { TextView } from "../types/elements"; class TextElement extends BaseView implements TextView { @@ -19,8 +19,19 @@ class TextElement extends BaseView implements TextView { set content(value: string) { if (this.#content === value) return; this.#content = value; - // By default, treat content changes as render-only; layout should be driven by explicit - // layout styles rather than intrinsic text measurement (keeps TUI updates cheap). + // 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(); } diff --git a/tests/units/runtime/render-loop.test.ts b/tests/units/runtime/render-loop.test.ts index a0ba9e7..1d41133 100644 --- a/tests/units/runtime/render-loop.test.ts +++ b/tests/units/runtime/render-loop.test.ts @@ -216,7 +216,7 @@ describe("createRenderer", () => { getSize: () => ({ rows: 24, cols: 80 }), write: () => {}, view: () => { - const child = Text(value).setKey("root/text"); + const child = Text(value).width(10).height(1).setKey("root/text"); return Block(child).setKey("root"); }, getState: () => ({}), @@ -249,4 +249,43 @@ describe("createRenderer", () => { 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(); + }); }); From 0cb07d31f194689a5acba49328759180cb4e7b3f Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Fri, 26 Dec 2025 17:37:34 +0900 Subject: [PATCH 14/15] Install fatal-error safety net for terminal Add an uncaughtException listener that restores terminal state (show cursor, disable bracketed paste, and perform cleanup without clearing) to avoid leaving the TTY in raw mode after fatal errors. The listener removes itself on cleanup and rethrows the error asynchronously if it's the only listener to preserve default runtime behavior. Include a unit test and update the roadmap checkbox. --- docs/roadmap.ja.md | 2 +- src/terminal/raw.ts | 62 ++++++++++++++++++++++++++++++++ tests/units/terminal/raw.test.ts | 16 +++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/docs/roadmap.ja.md b/docs/roadmap.ja.md index c22a5a5..604dd1a 100644 --- a/docs/roadmap.ja.md +++ b/docs/roadmap.ja.md @@ -35,7 +35,7 @@ - [x] `Provide/Inject` または `Context API` 相当の依存注入機能 - [ ] **安全性・堅牢性** - [x] FFI 境界の同期テスト - - [ ] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除) + - [x] 致命的エラー時のセーフティネット(パニック時の Raw Mode 強制解除) - [ ] **AI・アクセシビリティ** - [ ] セマンティック・メタデータのサポート(AIエージェントや将来のA11y支援用) - [ ] コンポーネント 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/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"); From c1471222ad7d3d555a64fff7310cc7b6462f9a27 Mon Sep 17 00:00:00 2001 From: HAL <68320771+HALQME@users.noreply.github.com> Date: Sat, 27 Dec 2025 12:21:50 +0900 Subject: [PATCH 15/15] Update README with examples, DevTools and links --- README.ja.md | 110 ++++++++++++++++++++++++++++++++++++++++++++------- README.md | 108 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 191 insertions(+), 27 deletions(-) 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