diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index e0700a2fe..811451583 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -475,8 +475,8 @@ export abstract class Renderable extends BaseRenderable { public set translateX(value: number) { if (this._translateX === value) return this._translateX = value - this.requestRender() if (this.parent) this.parent.childrenPrimarySortDirty = true + this.requestRender() } public get translateY(): number { @@ -486,8 +486,8 @@ export abstract class Renderable extends BaseRenderable { public set translateY(value: number) { if (this._translateY === value) return this._translateY = value - this.requestRender() if (this.parent) this.parent.childrenPrimarySortDirty = true + this.requestRender() } public get x(): number { @@ -1289,6 +1289,8 @@ export abstract class Renderable extends BaseRenderable { y: scissorRect.y, width: scissorRect.width, height: scissorRect.height, + screenX: this.x, + screenY: this.y, }) } const visibleChildren = this._getVisibleChildren() @@ -1524,6 +1526,8 @@ interface RenderCommandPushScissorRect extends RenderCommandBase { y: number width: number height: number + screenX: number + screenY: number } interface RenderCommandPopScissorRect extends RenderCommandBase { @@ -1594,6 +1598,7 @@ export class RootRenderable extends Renderable { this.updateLayout(deltaTime, this.renderList) // 3. Render all collected renderables + this._ctx.clearHitGridScissorRects() for (let i = 1; i < this.renderList.length; i++) { const command = this.renderList[i] switch (command.action) { @@ -1605,9 +1610,11 @@ export class RootRenderable extends Renderable { break case "pushScissorRect": buffer.pushScissorRect(command.x, command.y, command.width, command.height) + this._ctx.pushHitGridScissorRect(command.screenX, command.screenY, command.width, command.height) break case "popScissorRect": buffer.popScissorRect() + this._ctx.popHitGridScissorRect() break case "pushOpacity": buffer.pushOpacity(command.opacity) diff --git a/packages/core/src/examples/scrollbox-mouse-test.ts b/packages/core/src/examples/scrollbox-mouse-test.ts new file mode 100644 index 000000000..4f0da4c82 --- /dev/null +++ b/packages/core/src/examples/scrollbox-mouse-test.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env bun +import { BoxRenderable, type CliRenderer, createCliRenderer, TextRenderable, RGBA, t, fg, bold } from "../index" +import { ScrollBoxRenderable } from "../renderables/ScrollBox" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let scrollBox: ScrollBoxRenderable | null = null +let statusText: TextRenderable | null = null +let hoveredItem: string | null = null + +export function run(renderer: CliRenderer): void { + renderer.setBackgroundColor("#1a1b26") + + const mainContainer = new BoxRenderable(renderer, { + id: "main-container", + flexGrow: 1, + maxHeight: "100%", + maxWidth: "100%", + flexDirection: "column", + backgroundColor: "#1a1b26", + }) + + const header = new BoxRenderable(renderer, { + id: "header", + width: "100%", + height: 3, + backgroundColor: "#24283b", + paddingLeft: 1, + flexShrink: 0, + }) + + const title = new TextRenderable(renderer, { + content: t`${bold(fg("#7aa2f7")("ScrollBox Mouse Hit Test"))} - Scroll and hover items to test hit detection`, + }) + header.add(title) + + statusText = new TextRenderable(renderer, { + content: t`${fg("#565f89")("Hovered:")} ${fg("#c0caf5")("none")}`, + }) + header.add(statusText) + + scrollBox = new ScrollBoxRenderable(renderer, { + id: "scroll-box", + rootOptions: { + backgroundColor: "#24283b", + border: true, + }, + contentOptions: { + backgroundColor: "#16161e", + }, + }) + + for (let i = 0; i < 50; i++) { + const item = new BoxRenderable(renderer, { + id: `item-${i}`, + width: "100%", + height: 2, + backgroundColor: i % 2 === 0 ? "#292e42" : "#2f3449", + paddingLeft: 1, + onMouseOver: () => { + hoveredItem = `item-${i}` + updateStatus() + }, + onMouseOut: () => { + if (hoveredItem === `item-${i}`) { + hoveredItem = null + updateStatus() + } + }, + onClick: () => { + console.log(`Clicked item-${i}`) + }, + }) + + const text = new TextRenderable(renderer, { + content: t`${fg("#7aa2f7")(`[${i.toString().padStart(2, "0")}]`)} ${fg("#c0caf5")(`Item ${i} - Hover over me to test hit detection`)}`, + }) + item.add(text) + scrollBox.add(item) + } + + mainContainer.add(header) + mainContainer.add(scrollBox) + renderer.root.add(mainContainer) + + scrollBox.focus() + + function updateStatus() { + if (statusText) { + const hovered = hoveredItem || "none" + statusText.content = t`${fg("#565f89")("Hovered:")} ${fg("#9ece6a")(hovered)}` + } + } +} + +export function destroy(renderer: CliRenderer): void { + renderer.root.getChildren().forEach((child) => { + renderer.root.remove(child.id) + child.destroyRecursively() + }) + scrollBox = null + statusText = null + hoveredItem = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + + run(renderer) + setupCommonDemoKeys(renderer) +} diff --git a/packages/core/src/examples/scrollbox-overlay-hit-test.ts b/packages/core/src/examples/scrollbox-overlay-hit-test.ts new file mode 100644 index 000000000..554a37271 --- /dev/null +++ b/packages/core/src/examples/scrollbox-overlay-hit-test.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env bun +import { + BoxRenderable, + type CliRenderer, + createCliRenderer, + TextRenderable, + t, + fg, + bold, + type KeyEvent, +} from "../index" +import { ScrollBoxRenderable } from "../renderables/ScrollBox" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let overlay: BoxRenderable | null = null +let dialog: BoxRenderable | null = null +let scrollBox: ScrollBoxRenderable | null = null +let baseStatusText: TextRenderable | null = null +let dialogStatusText: TextRenderable | null = null +let keyHandler: ((key: KeyEvent) => void) | null = null +let dialogOpen = false +let lastClick = "none" + +const updateStatus = () => { + const content = t`${fg("#9aa5ce")("Last click:")} ${fg("#9ece6a")(lastClick)}` + if (baseStatusText) { + baseStatusText.content = content + } + if (dialogStatusText) { + dialogStatusText.content = content + } +} + +const setDialogVisible = (visible: boolean) => { + dialogOpen = visible + if (overlay) { + overlay.visible = visible + } +} + +const setLastClick = (value: string) => { + lastClick = value + updateStatus() +} + +export function run(renderer: CliRenderer): void { + renderer.setBackgroundColor("#1a1b26") + + const app = new BoxRenderable(renderer, { + id: "app", + flexDirection: "column", + width: "100%", + height: "100%", + backgroundColor: "#1a1b26", + paddingLeft: 1, + paddingTop: 1, + gap: 1, + }) + renderer.root.add(app) + + const title = new TextRenderable(renderer, { + content: t`${bold(fg("#7aa2f7")("Scrollbox Overlay Hit Test"))}`, + }) + app.add(title) + + const instructions = new TextRenderable(renderer, { + content: t`${fg("#c0caf5")("Press 'd' to toggle dialog, 'esc' to close, 'q' to quit")}`, + }) + app.add(instructions) + + baseStatusText = new TextRenderable(renderer, { + content: t`${fg("#9aa5ce")("Last click:")} ${fg("#9ece6a")(lastClick)}`, + }) + app.add(baseStatusText) + + overlay = new BoxRenderable(renderer, { + id: "overlay", + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + backgroundColor: "#ff000033", + zIndex: 100, + visible: false, + onMouseDown: () => { + setLastClick("overlay (red)") + setDialogVisible(false) + }, + }) + renderer.root.add(overlay) + + dialog = new BoxRenderable(renderer, { + id: "dialog", + position: "absolute", + top: "25%", + left: "25%", + width: "50%", + height: "50%", + flexDirection: "column", + gap: 1, + padding: 1, + backgroundColor: "#0f172a", + border: true, + borderColor: "#7aa2f7", + onMouseDown: (event) => { + setLastClick("dialog (blue)") + event.stopPropagation() + }, + }) + overlay.add(dialog) + + const dialogTitle = new TextRenderable(renderer, { + content: t`${bold(fg("#7aa2f7")("Dialog"))} ${fg("#565f89")("- scroll, then click outside the list")}`, + }) + dialog.add(dialogTitle) + + const dialogHint = new TextRenderable(renderer, { + content: t`${fg("#c0caf5")("Click the red overlay above/below the dialog to close it")}`, + }) + dialog.add(dialogHint) + + dialogStatusText = new TextRenderable(renderer, { + content: t`${fg("#9aa5ce")("Last click:")} ${fg("#9ece6a")(lastClick)}`, + }) + dialog.add(dialogStatusText) + + scrollBox = new ScrollBoxRenderable(renderer, { + id: "scrollbox", + flexGrow: 1, + scrollY: true, + onMouseDown: (event) => { + setLastClick("scrollbox (yellow)") + event.stopPropagation() + }, + rootOptions: { + backgroundColor: "#eab308", + border: true, + borderColor: "#0f172a", + }, + contentOptions: { + backgroundColor: "#111827", + }, + }) + dialog.add(scrollBox) + + for (let i = 0; i < 50; i++) { + const item = new BoxRenderable(renderer, { + id: `line-${i}`, + width: "100%", + height: 1, + paddingLeft: 1, + backgroundColor: i % 2 === 0 ? "#1f2937" : "#111827", + }) + const text = new TextRenderable(renderer, { + content: t`${fg("#cbd5f5")(`Line ${i + 1}: This is some content`)}`, + }) + item.add(text) + scrollBox.add(item) + } + + keyHandler = (key: KeyEvent) => { + if (key.name === "q") { + renderer.destroy() + process.exit(0) + } + if (key.name === "d") { + setDialogVisible(!dialogOpen) + } + if (key.name === "escape") { + setDialogVisible(false) + } + } + renderer.keyInput.on("keypress", keyHandler) + + updateStatus() +} + +export function destroy(renderer: CliRenderer): void { + if (keyHandler) { + renderer.keyInput.off("keypress", keyHandler) + } + + renderer.root.getChildren().forEach((child) => { + renderer.root.remove(child.id) + child.destroyRecursively() + }) + + overlay = null + dialog = null + scrollBox = null + baseStatusText = null + dialogStatusText = null + keyHandler = null + dialogOpen = false + lastClick = "none" +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + }) + + run(renderer) + setupCommonDemoKeys(renderer) +} diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 102c08923..c4a45461f 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -303,6 +303,8 @@ export class CliRenderer extends EventEmitter implements RenderContext { private exitSignals: NodeJS.Signals[] private _exitListenersAdded: boolean = false private _isDestroyed: boolean = false + private _destroyPending: boolean = false + private _destroyFinalized: boolean = false public nextRenderBuffer: OptimizedBuffer public currentRenderBuffer: OptimizedBuffer private _isRunning: boolean = false @@ -405,6 +407,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { }).bind(this) private _capabilities: any | null = null private _latestPointer: { x: number; y: number } = { x: 0, y: 0 } + private _hasPointer: boolean = false private _currentFocusedRenderable: Renderable | null = null private lifecyclePasses: Set = new Set() @@ -636,12 +639,31 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._currentFocusedRenderable = renderable } + private setCapturedRenderable(renderable: Renderable | undefined): void { + if (this.capturedRenderable === renderable) { + return + } + this.capturedRenderable = renderable + } + public addToHitGrid(x: number, y: number, width: number, height: number, id: number) { if (id !== this.capturedRenderable?.num) { this.lib.addToHitGrid(this.rendererPtr, x, y, width, height, id) } } + public pushHitGridScissorRect(x: number, y: number, width: number, height: number): void { + this.lib.hitGridPushScissorRect(this.rendererPtr, x, y, width, height) + } + + public popHitGridScissorRect(): void { + this.lib.hitGridPopScissorRect(this.rendererPtr) + } + + public clearHitGridScissorRects(): void { + this.lib.hitGridClearScissorRects(this.rendererPtr) + } + public get widthMethod(): WidthMethod { const caps = this.capabilities return caps?.unicode === "wcwidth" ? "wcwidth" : "unicode" @@ -906,7 +928,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private disableMouse(): void { this._useMouse = false - this.capturedRenderable = undefined + this.setCapturedRenderable(undefined) this.mouseParser.reset() this.lib.disableMouse(this.rendererPtr) } @@ -1051,6 +1073,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._latestPointer.x = mouseEvent.x this._latestPointer.y = mouseEvent.y + this._hasPointer = true if (this._console.visible) { const consoleBounds = this._console.bounds @@ -1067,7 +1090,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } if (mouseEvent.type === "scroll") { - const maybeRenderableId = this.lib.checkHit(this.rendererPtr, mouseEvent.x, mouseEvent.y) + const maybeRenderableId = this.hitTest(mouseEvent.x, mouseEvent.y) const maybeRenderable = Renderable.renderablesByNumber.get(maybeRenderableId) if (maybeRenderable) { @@ -1077,7 +1100,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { return true } - const maybeRenderableId = this.lib.checkHit(this.rendererPtr, mouseEvent.x, mouseEvent.y) + const maybeRenderableId = this.hitTest(mouseEvent.x, mouseEvent.y) const sameElement = maybeRenderableId === this.lastOverRenderableNum this.lastOverRenderableNum = maybeRenderableId const maybeRenderable = Renderable.renderablesByNumber.get(maybeRenderableId) @@ -1166,7 +1189,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } this.lastOverRenderable = this.capturedRenderable this.lastOverRenderableNum = this.capturedRenderable.num - this.capturedRenderable = undefined + this.setCapturedRenderable(undefined) // Dropping the renderable needs to push another frame when the renderer is not live // to update the hit grid, otherwise capturedRenderable won't be in the hit grid and will not receive mouse events this.requestRender() @@ -1175,14 +1198,14 @@ export class CliRenderer extends EventEmitter implements RenderContext { let event: MouseEvent | undefined = undefined if (maybeRenderable) { if (mouseEvent.type === "drag" && mouseEvent.button === MouseButton.LEFT) { - this.capturedRenderable = maybeRenderable + this.setCapturedRenderable(maybeRenderable) } else { - this.capturedRenderable = undefined + this.setCapturedRenderable(undefined) } event = new MouseEvent(maybeRenderable, mouseEvent) maybeRenderable.processMouseEvent(event) } else { - this.capturedRenderable = undefined + this.setCapturedRenderable(undefined) this.lastOverRenderable = undefined } @@ -1196,6 +1219,10 @@ export class CliRenderer extends EventEmitter implements RenderContext { return false } + public hitTest(x: number, y: number): number { + return this.lib.checkHit(this.rendererPtr, x, y) + } + private takeMemorySnapshot(): void { if (this._isDestroyed) return @@ -1274,7 +1301,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._terminalHeight = height this.queryPixelResolution() - this.capturedRenderable = undefined + this.setCapturedRenderable(undefined) this.mouseParser.reset() if (this._splitHeight > 0) { @@ -1551,6 +1578,20 @@ export class CliRenderer extends EventEmitter implements RenderContext { public destroy(): void { if (this._isDestroyed) return this._isDestroyed = true + this._destroyPending = true + + if (this.rendering) { + // Defer teardown until the active frame completes to avoid freeing native resources mid-render. + return + } + + this.finalizeDestroy() + } + + private finalizeDestroy(): void { + if (this._destroyFinalized) return + this._destroyFinalized = true + this._destroyPending = false process.removeListener("SIGWINCH", this.sigwinchHandler) process.removeListener("uncaughtException", this.handleError) @@ -1591,7 +1632,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._isRunning = false this.waitingForPixelResolution = false - this.capturedRenderable = undefined + this.setCapturedRenderable(undefined) try { this.root.destroyRecursively() @@ -1647,82 +1688,91 @@ export class CliRenderer extends EventEmitter implements RenderContext { clearTimeout(this.renderTimeout) this.renderTimeout = null } + try { + const now = Date.now() + const elapsed = now - this.lastTime - const now = Date.now() - const elapsed = now - this.lastTime - - const deltaTime = elapsed - this.lastTime = now - - this.frameCount++ - if (now - this.lastFpsTime >= 1000) { - this.currentFps = this.frameCount - this.frameCount = 0 - this.lastFpsTime = now - } + const deltaTime = elapsed + this.lastTime = now - this.renderStats.frameCount++ - this.renderStats.fps = this.currentFps - const overallStart = performance.now() + this.frameCount++ + if (now - this.lastFpsTime >= 1000) { + this.currentFps = this.frameCount + this.frameCount = 0 + this.lastFpsTime = now + } - const frameRequests = Array.from(this.animationRequest.values()) - this.animationRequest.clear() - const animationRequestStart = performance.now() - frameRequests.forEach((callback) => { - callback(deltaTime) - this.dropLive() - }) - const animationRequestEnd = performance.now() - const animationRequestTime = animationRequestEnd - animationRequestStart + this.renderStats.frameCount++ + this.renderStats.fps = this.currentFps + const overallStart = performance.now() - const start = performance.now() - for (const frameCallback of this.frameCallbacks) { - try { - await frameCallback(deltaTime) - } catch (error) { - console.error("Error in frame callback:", error) + const frameRequests = Array.from(this.animationRequest.values()) + this.animationRequest.clear() + const animationRequestStart = performance.now() + for (const callback of frameRequests) { + callback(deltaTime) + this.dropLive() } - } - const end = performance.now() - this.renderStats.frameCallbackTime = end - start + const animationRequestEnd = performance.now() + const animationRequestTime = animationRequestEnd - animationRequestStart + + const start = performance.now() + for (const frameCallback of this.frameCallbacks) { + try { + await frameCallback(deltaTime) + } catch (error) { + console.error("Error in frame callback:", error) + } + } + const end = performance.now() + this.renderStats.frameCallbackTime = end - start - // Render the renderable tree - this.root.render(this.nextRenderBuffer, deltaTime) + this.root.render(this.nextRenderBuffer, deltaTime) - for (const postProcessFn of this.postProcessFns) { - postProcessFn(this.nextRenderBuffer, deltaTime) - } + for (const postProcessFn of this.postProcessFns) { + postProcessFn(this.nextRenderBuffer, deltaTime) + } - this._console.renderToBuffer(this.nextRenderBuffer) + this._console.renderToBuffer(this.nextRenderBuffer) - if (!this._isDestroyed) { - this.renderNative() + // If destroy() was requested during this frame, skip native work and scheduling. + if (!this._isDestroyed) { + this.renderNative() - const overallFrameTime = performance.now() - overallStart + const overallFrameTime = performance.now() - overallStart - // TODO: Add animationRequestTime to stats - this.lib.updateStats(this.rendererPtr, overallFrameTime, this.renderStats.fps, this.renderStats.frameCallbackTime) + // TODO: Add animationRequestTime to stats + this.lib.updateStats( + this.rendererPtr, + overallFrameTime, + this.renderStats.fps, + this.renderStats.frameCallbackTime, + ) - if (this.gatherStats) { - this.collectStatSample(overallFrameTime) - } + if (this.gatherStats) { + this.collectStatSample(overallFrameTime) + } - if (this._isRunning || this.immediateRerenderRequested) { - const targetFrameTime = this.immediateRerenderRequested ? this.minTargetFrameTime : this.targetFrameTime - const delay = Math.max(1, targetFrameTime - Math.floor(overallFrameTime)) - this.immediateRerenderRequested = false - this.renderTimeout = setTimeout(() => { + if (this._isRunning || this.immediateRerenderRequested) { + const targetFrameTime = this.immediateRerenderRequested ? this.minTargetFrameTime : this.targetFrameTime + const delay = Math.max(1, targetFrameTime - Math.floor(overallFrameTime)) + this.immediateRerenderRequested = false + this.renderTimeout = setTimeout(() => { + this.renderTimeout = null + this.loop() + }, delay) + } else { + clearTimeout(this.renderTimeout!) this.renderTimeout = null - this.loop() - }, delay) - } else { - clearTimeout(this.renderTimeout!) - this.renderTimeout = null + } + } + } finally { + this.rendering = false + if (this._destroyPending) { + this.finalizeDestroy() } + this.resolveIdleIfNeeded() } - - this.rendering = false - this.resolveIdleIfNeeded() } public intermediateRender(): void { @@ -1863,7 +1913,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (this.currentSelection?.isSelecting) { const pointer = this._latestPointer - const maybeRenderableId = this.lib.checkHit(this.rendererPtr, pointer.x, pointer.y) + const maybeRenderableId = this.hitTest(pointer.x, pointer.y) const maybeRenderable = Renderable.renderablesByNumber.get(maybeRenderableId) this.updateSelection(maybeRenderable, pointer.x, pointer.y) diff --git a/packages/core/src/tests/renderer.destroy-during-render.test.ts b/packages/core/src/tests/renderer.destroy-during-render.test.ts index 811843977..20ad69b64 100644 --- a/packages/core/src/tests/renderer.destroy-during-render.test.ts +++ b/packages/core/src/tests/renderer.destroy-during-render.test.ts @@ -1,6 +1,12 @@ import { test, expect } from "bun:test" +import { Renderable } from "../Renderable" +import type { OptimizedBuffer } from "../buffer" import { createTestRenderer, type TestRenderer } from "../testing/test-renderer" +class DestroyingRenderable extends Renderable { + protected renderSelf(_buffer: OptimizedBuffer, _deltaTime: number): void {} +} + test("destroying renderer during frame callback should not crash", async () => { const { renderer } = await createTestRenderer({}) @@ -62,3 +68,43 @@ test("destroying renderer during root render should not crash", async () => { // If we got here without a segfault, the test passes }) + +test("destroying renderer during requestAnimationFrame should not crash", async () => { + const { renderer } = await createTestRenderer({}) + + let destroyedDuringAnimationFrame = false + + requestAnimationFrame(() => { + destroyedDuringAnimationFrame = true + renderer.destroy() + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(destroyedDuringAnimationFrame).toBe(true) +}) + +test("destroying renderer during renderBefore should not crash", async () => { + const { renderer } = await createTestRenderer({}) + + let destroyedDuringRenderBefore = false + + const renderable = new DestroyingRenderable(renderer, { + id: "destroy-render-before", + width: 10, + height: 1, + renderBefore() { + if (!destroyedDuringRenderBefore) { + destroyedDuringRenderBefore = true + renderer.destroy() + } + }, + }) + + renderer.root.add(renderable) + renderer.start() + + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(destroyedDuringRenderBefore).toBe(true) +}) diff --git a/packages/core/src/tests/scrollbox-hitgrid.test.ts b/packages/core/src/tests/scrollbox-hitgrid.test.ts new file mode 100644 index 000000000..e698b87b4 --- /dev/null +++ b/packages/core/src/tests/scrollbox-hitgrid.test.ts @@ -0,0 +1,393 @@ +import { test, expect, beforeEach, afterEach } from "bun:test" +import { createTestRenderer, type MockMouse, type TestRenderer } from "../testing" +import { ScrollBoxRenderable } from "../renderables/ScrollBox" +import { BoxRenderable } from "../renderables/Box" +import { Renderable } from "../Renderable" + +let testRenderer: TestRenderer +let mockMouse: MockMouse + +beforeEach(async () => { + ;({ renderer: testRenderer, mockMouse } = await createTestRenderer({ width: 50, height: 30 })) +}) + +afterEach(() => { + testRenderer.destroy() +}) + +test("hit grid updates after render when scrollbox scrolls", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 40, + height: 20, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + const items: BoxRenderable[] = [] + for (let i = 0; i < 30; i++) { + const item = new BoxRenderable(testRenderer, { + id: `item-${i}`, + height: 2, + backgroundColor: i % 2 === 0 ? "red" : "blue", + }) + items.push(item) + scrollBox.add(item) + } + + await testRenderer.idle() + + const item0 = items[0] + const item4 = items[4] + + expect(item0.y).toBe(0) + expect(item4.y).toBe(8) + + const checkHitAt = (x: number, y: number): Renderable | undefined => { + const renderableId = testRenderer.hitTest(x, y) + return Renderable.renderablesByNumber.get(renderableId) + } + + let hitAtItem0 = checkHitAt(5, item0.y) + expect(hitAtItem0?.id).toBe("item-0") + + let hitAtItem4 = checkHitAt(5, item4.y) + expect(hitAtItem4?.id).toBe("item-4") + + scrollBox.scrollTop = 10 + + expect(item0.y).toBe(-10) + expect(item4.y).toBe(-2) + + const item5 = items[5] + const item9 = items[9] + + expect(item5.y).toBe(0) + expect(item9.y).toBe(8) + + // Hit grid updates after render + await testRenderer.idle() + + const hitAtItem5 = checkHitAt(5, item5.y) + expect(hitAtItem5?.id).toBe("item-5") + + const hitAtItem9 = checkHitAt(5, item9.y) + expect(hitAtItem9?.id).toBe("item-9") +}) + +test("hover updates after scroll when pointer moves", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 20, + height: 6, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + const hoverEvents: string[] = [] + let hoveredId: string | null = null + + const items: BoxRenderable[] = [] + for (let i = 0; i < 5; i++) { + const itemId = `item-${i}` + const item = new BoxRenderable(testRenderer, { + id: itemId, + width: "100%", + height: 2, + onMouseOver: () => { + hoveredId = itemId + hoverEvents.push(`over:${itemId}`) + }, + onMouseOut: () => { + if (hoveredId === itemId) { + hoveredId = null + } + hoverEvents.push(`out:${itemId}`) + }, + }) + items.push(item) + scrollBox.add(item) + } + + await testRenderer.idle() + + const pointerX = items[0].x + 1 + const pointerY = items[0].y + 1 + + await mockMouse.moveTo(pointerX, pointerY) + expect(hoveredId).toBe("item-0") + expect(hoverEvents).toEqual(["over:item-0"]) + + scrollBox.scrollTop = 2 + await testRenderer.idle() + + // Hover updates when pointer moves after scroll and render + await mockMouse.moveTo(pointerX, pointerY) + expect(hoveredId).toBe("item-1") + expect(hoverEvents).toEqual(["over:item-0", "out:item-0", "over:item-1"]) +}) + +test("hit grid handles multiple scroll operations correctly", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 40, + height: 20, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + const items: BoxRenderable[] = [] + for (let i = 0; i < 40; i++) { + const item = new BoxRenderable(testRenderer, { + id: `item-${i}`, + height: 2, + }) + items.push(item) + scrollBox.add(item) + } + + await testRenderer.idle() + + const checkHitAt = (x: number, y: number): Renderable | undefined => { + const renderableId = testRenderer.hitTest(x, y) + return Renderable.renderablesByNumber.get(renderableId) + } + + scrollBox.scrollTop = 20 + expect(items[10].y).toBe(0) + await testRenderer.idle() + let hit = checkHitAt(5, items[10].y) + expect(hit?.id).toBe("item-10") + + scrollBox.scrollTop = 40 + expect(items[20].y).toBe(0) + await testRenderer.idle() + hit = checkHitAt(5, items[20].y) + expect(hit?.id).toBe("item-20") + + scrollBox.scrollTop = 0 + expect(items[0].y).toBe(0) + await testRenderer.idle() + hit = checkHitAt(5, items[0].y) + expect(hit?.id).toBe("item-0") +}) + +test("hit grid respects scrollbox viewport clipping when offset", async () => { + const container = new BoxRenderable(testRenderer, { + flexDirection: "column", + width: "100%", + height: "100%", + }) + testRenderer.root.add(container) + + const header = new BoxRenderable(testRenderer, { + id: "header", + height: 5, + width: "100%", + }) + container.add(header) + + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 40, + height: 10, + scrollY: true, + }) + container.add(scrollBox) + + const items: BoxRenderable[] = [] + for (let i = 0; i < 10; i++) { + const item = new BoxRenderable(testRenderer, { + id: `item-${i}`, + height: 2, + }) + items.push(item) + scrollBox.add(item) + } + + await testRenderer.idle() + + const checkHitAt = (x: number, y: number): Renderable | undefined => { + const renderableId = testRenderer.hitTest(x, y) + return Renderable.renderablesByNumber.get(renderableId) + } + + const headerHit = checkHitAt(2, header.y + 1) + expect(headerHit?.id).toBe("header") + + scrollBox.scrollTop = 4 + await testRenderer.idle() + + const headerHitAfterScroll = checkHitAt(2, header.y + 1) + expect(headerHitAfterScroll?.id).toBe("header") + + const viewportHit = checkHitAt(2, scrollBox.viewport.y + 1) + expect(viewportHit?.id).toBe("item-2") +}) + +test("captured renderable is not in hit grid during scroll", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 40, + height: 10, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + const items: BoxRenderable[] = [] + for (let i = 0; i < 20; i++) { + const item = new BoxRenderable(testRenderer, { + id: `item-${i}`, + height: 2, + }) + items.push(item) + scrollBox.add(item) + } + + await testRenderer.idle() + + const pointerX = 2 + const pointerY = scrollBox.viewport.y + 1 + + await mockMouse.pressDown(pointerX, pointerY) + await mockMouse.moveTo(pointerX, pointerY + 1) + + scrollBox.scrollTop = 4 + await testRenderer.idle() + + const renderableId = testRenderer.hitTest(pointerX, pointerY) + const hit = Renderable.renderablesByNumber.get(renderableId) + expect(hit?.id).toBe("item-2") +}) + +test("hit grid stays clipped after render", async () => { + const container = new BoxRenderable(testRenderer, { + id: "container", + width: 10, + height: 4, + overflow: "hidden", + }) + testRenderer.root.add(container) + + const child = new BoxRenderable(testRenderer, { + id: "child", + width: 20, + height: 4, + }) + container.add(child) + + await testRenderer.idle() + + const insideHitId = testRenderer.hitTest(container.x + 1, container.y + 1) + const insideHit = Renderable.renderablesByNumber.get(insideHitId) + expect(insideHit?.id).toBe("child") + + const outsideHitId = testRenderer.hitTest(container.x + container.width + 1, container.y + 1) + expect(outsideHitId).toBe(0) +}) + +test("buffered overflow scissor uses screen coordinates for hit grid", async () => { + const container = new BoxRenderable(testRenderer, { + id: "buffered-container", + width: 10, + height: 4, + overflow: "hidden", + buffered: true, + position: "absolute", + left: 10, + top: 5, + }) + testRenderer.root.add(container) + + const child = new BoxRenderable(testRenderer, { + id: "buffered-child", + width: 10, + height: 4, + }) + container.add(child) + + await testRenderer.idle() + + const hitId = testRenderer.hitTest(container.x + 1, container.y + 1) + const hit = Renderable.renderablesByNumber.get(hitId) + expect(hit?.id).toBe("buffered-child") +}) + +test("scrolling does not steal clicks outside the list", async () => { + let lastClick = "none" + + const overlay = new BoxRenderable(testRenderer, { + id: "overlay", + position: "absolute", + left: 0, + top: 0, + width: "100%", + height: "100%", + zIndex: 100, + onMouseDown: () => { + lastClick = "overlay" + }, + }) + testRenderer.root.add(overlay) + + const dialog = new BoxRenderable(testRenderer, { + id: "dialog", + position: "absolute", + left: 5, + top: 4, + width: 30, + height: 14, + flexDirection: "column", + padding: 1, + gap: 1, + onMouseDown: (event) => { + lastClick = "dialog" + event.stopPropagation() + }, + }) + overlay.add(dialog) + + const header = new BoxRenderable(testRenderer, { + id: "dialog-header", + width: "100%", + height: 2, + onMouseDown: (event) => { + lastClick = "header" + event.stopPropagation() + }, + }) + dialog.add(header) + + const scrollBox = new ScrollBoxRenderable(testRenderer, { + id: "dialog-scrollbox", + width: "100%", + height: 7, + scrollY: true, + onMouseDown: (event) => { + lastClick = "scrollbox" + event.stopPropagation() + }, + }) + dialog.add(scrollBox) + + for (let i = 0; i < 20; i++) { + const item = new BoxRenderable(testRenderer, { + id: `line-${i}`, + width: "100%", + height: 1, + }) + scrollBox.add(item) + } + + await testRenderer.idle() + + await mockMouse.click(scrollBox.viewport.x + 1, scrollBox.viewport.y + 1) + expect(lastClick).toBe("scrollbox") + + const headerClickY = header.y + 1 + const targetScrollTop = Math.max(1, scrollBox.viewport.y - headerClickY) + scrollBox.scrollTop = targetScrollTop + + await mockMouse.click(header.x + 1, headerClickY) + expect(lastClick).toBe("header") + + await mockMouse.click(dialog.x + 1, dialog.y - 1) + expect(lastClick).toBe("overlay") + + await testRenderer.idle() +}) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 5bd673060..e15c692a6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -54,6 +54,9 @@ export interface RendererEvents { export interface RenderContext extends EventEmitter { addToHitGrid: (x: number, y: number, width: number, height: number, id: number) => void + pushHitGridScissorRect: (x: number, y: number, width: number, height: number) => void + popHitGridScissorRect: () => void + clearHitGridScissorRects: () => void width: number height: number requestRender: () => void diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index cee0f3ea3..7528c6110 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -312,6 +312,26 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "i32", "i32", "u32", "u32", "u32"], returns: "void", }, + clearCurrentHitGrid: { + args: ["ptr"], + returns: "void", + }, + hitGridPushScissorRect: { + args: ["ptr", "i32", "i32", "u32", "u32"], + returns: "void", + }, + hitGridPopScissorRect: { + args: ["ptr"], + returns: "void", + }, + hitGridClearScissorRects: { + args: ["ptr"], + returns: "void", + }, + addToCurrentHitGridClipped: { + args: ["ptr", "i32", "i32", "u32", "u32", "u32"], + returns: "void", + }, checkHit: { args: ["ptr", "u32", "u32"], returns: "u32", @@ -1315,6 +1335,18 @@ export interface RenderLib { clearTerminal: (renderer: Pointer) => void setTerminalTitle: (renderer: Pointer, title: string) => void addToHitGrid: (renderer: Pointer, x: number, y: number, width: number, height: number, id: number) => void + clearCurrentHitGrid: (renderer: Pointer) => void + hitGridPushScissorRect: (renderer: Pointer, x: number, y: number, width: number, height: number) => void + hitGridPopScissorRect: (renderer: Pointer) => void + hitGridClearScissorRects: (renderer: Pointer) => void + addToCurrentHitGridClipped: ( + renderer: Pointer, + x: number, + y: number, + width: number, + height: number, + id: number, + ) => void checkHit: (renderer: Pointer, x: number, y: number) => number dumpHitGrid: (renderer: Pointer) => void dumpBuffers: (renderer: Pointer, timestamp?: number) => void @@ -2104,6 +2136,33 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.addToHitGrid(renderer, x, y, width, height, id) } + public clearCurrentHitGrid(renderer: Pointer) { + this.opentui.symbols.clearCurrentHitGrid(renderer) + } + + public hitGridPushScissorRect(renderer: Pointer, x: number, y: number, width: number, height: number) { + this.opentui.symbols.hitGridPushScissorRect(renderer, x, y, width, height) + } + + public hitGridPopScissorRect(renderer: Pointer) { + this.opentui.symbols.hitGridPopScissorRect(renderer) + } + + public hitGridClearScissorRects(renderer: Pointer) { + this.opentui.symbols.hitGridClearScissorRects(renderer) + } + + public addToCurrentHitGridClipped( + renderer: Pointer, + x: number, + y: number, + width: number, + height: number, + id: number, + ) { + this.opentui.symbols.addToCurrentHitGridClipped(renderer, x, y, width, height, id) + } + public checkHit(renderer: Pointer, x: number, y: number): number { return this.opentui.symbols.checkHit(renderer, x, y) } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index f76118211..8311a55a2 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -465,6 +465,26 @@ export fn addToHitGrid(rendererPtr: *renderer.CliRenderer, x: i32, y: i32, width rendererPtr.addToHitGrid(x, y, width, height, id); } +export fn clearCurrentHitGrid(rendererPtr: *renderer.CliRenderer) void { + rendererPtr.clearCurrentHitGrid(); +} + +export fn hitGridPushScissorRect(rendererPtr: *renderer.CliRenderer, x: i32, y: i32, width: u32, height: u32) void { + rendererPtr.hitGridPushScissorRect(x, y, width, height); +} + +export fn hitGridPopScissorRect(rendererPtr: *renderer.CliRenderer) void { + rendererPtr.hitGridPopScissorRect(); +} + +export fn hitGridClearScissorRects(rendererPtr: *renderer.CliRenderer) void { + rendererPtr.hitGridClearScissorRects(); +} + +export fn addToCurrentHitGridClipped(rendererPtr: *renderer.CliRenderer, x: i32, y: i32, width: u32, height: u32, id: u32) void { + rendererPtr.addToCurrentHitGridClipped(x, y, width, height, id); +} + export fn checkHit(rendererPtr: *renderer.CliRenderer, x: u32, y: u32) u32 { return rendererPtr.checkHit(x, y); } diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index 082b90e0a..cb351ae02 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -98,10 +98,29 @@ pub const CliRenderer = struct { currentOutputBuffer: []u8 = &[_]u8{}, currentOutputLen: usize = 0, + // Hit grid for mouse event dispatch. + // + // The hit grid is a screen-sized array where each cell stores the renderable ID + // at that position. Mouse events query checkHit(x, y) to find which element to + // dispatch to. + // + // Double buffering: During render, addToHitGrid writes to nextHitGrid. After + // render completes, the buffers swap. This keeps hit testing consistent during + // a frame. Queries see the previous frame's state, not a half-built grid. + // + // On-demand sync: When scroll/translate changes between renders, the TypeScript + // layer can rebuild currentHitGrid directly via addToCurrentHitGridClipped. This + // updates hover states immediately rather than waiting for the next render. + // + // Scissor clipping: The hitScissorStack mirrors overflow:hidden regions. Elements + // outside their parent's visible area are excluded from hit testing. The stack + // uses screen coordinates. Buffered renderables need getHitGridScissorRect() to + // convert from buffer-local (0,0) to their actual screen position. currentHitGrid: []u32, nextHitGrid: []u32, hitGridWidth: u32, hitGridHeight: u32, + hitScissorStack: std.ArrayListUnmanaged(buf.ClipRect), lastCursorStyleTag: ?u8 = null, lastCursorBlinking: ?bool = null, @@ -165,6 +184,7 @@ pub const CliRenderer = struct { const nextHitGrid = try allocator.alloc(u32, hitGridSize); @memset(currentHitGrid, 0); // Initialize with 0 (no renderable) @memset(nextHitGrid, 0); + const hitScissorStack: std.ArrayListUnmanaged(buf.ClipRect) = .{}; self.* = .{ .width = width, @@ -211,6 +231,7 @@ pub const CliRenderer = struct { .nextHitGrid = nextHitGrid, .hitGridWidth = width, .hitGridHeight = height, + .hitScissorStack = hitScissorStack, }; try currentBuffer.clear(.{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, CLEAR_CHAR); @@ -250,6 +271,7 @@ pub const CliRenderer = struct { self.allocator.free(self.currentHitGrid); self.allocator.free(self.nextHitGrid); + self.hitScissorStack.deinit(self.allocator); self.allocator.destroy(self); } @@ -773,6 +795,9 @@ pub const CliRenderer = struct { self.nextRenderBuffer.clear(.{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, null) catch {}; + // Swap hit grids: nextHitGrid (built this frame) becomes the active grid for + // hit testing. The old currentHitGrid becomes nextHitGrid and is cleared for + // the next frame. const temp = self.currentHitGrid; self.currentHitGrid = self.nextHitGrid; self.nextHitGrid = temp; @@ -791,11 +816,24 @@ pub const CliRenderer = struct { w.flush() catch {}; } + /// Write a renderable's bounds to nextHitGrid for the upcoming frame. + /// + /// Called during render for each visible renderable. The rect is clipped to + /// the current hit scissor stack, so elements inside overflow:hidden parents + /// only register hits within the visible region. Later renderables overwrite + /// earlier ones. Z-order is determined by render order. pub fn addToHitGrid(self: *CliRenderer, x: i32, y: i32, width: u32, height: u32, id: u32) void { - const startX = @max(0, x); - const startY = @max(0, y); - const endX = @min(@as(i32, @intCast(self.hitGridWidth)), x + @as(i32, @intCast(width))); - const endY = @min(@as(i32, @intCast(self.hitGridHeight)), y + @as(i32, @intCast(height))); + const clipped = self.clipRectToHitScissor(x, y, width, height) orelse return; + const startX = @max(0, clipped.x); + const startY = @max(0, clipped.y); + const endX = @min( + @as(i32, @intCast(self.hitGridWidth)), + clipped.x + @as(i32, @intCast(clipped.width)), + ); + const endY = @min( + @as(i32, @intCast(self.hitGridHeight)), + clipped.y + @as(i32, @intCast(clipped.height)), + ); if (startX >= endX or startY >= endY) return; @@ -813,6 +851,15 @@ pub const CliRenderer = struct { } } + /// Clear currentHitGrid before an immediate rebuild. + /// + /// Used by syncHitGridIfNeeded in TypeScript when scroll/translate changes + /// require updating hit targets without waiting for the next render. + pub fn clearCurrentHitGrid(self: *CliRenderer) void { + @memset(self.currentHitGrid, 0); + } + + /// Return the renderable ID at screen position (x, y), or 0 if none. pub fn checkHit(self: *CliRenderer, x: u32, y: u32) u32 { if (x >= self.hitGridWidth or y >= self.hitGridHeight) { return 0; @@ -822,6 +869,110 @@ pub const CliRenderer = struct { return self.currentHitGrid[index]; } + /// Return the current (topmost) hit scissor rect, or null if the stack is empty. + fn getCurrentHitScissorRect(self: *const CliRenderer) ?buf.ClipRect { + if (self.hitScissorStack.items.len == 0) return null; + return self.hitScissorStack.items[self.hitScissorStack.items.len - 1]; + } + + /// Intersect a rect with the current hit scissor. Returns null if fully clipped. + fn clipRectToHitScissor(self: *const CliRenderer, x: i32, y: i32, width: u32, height: u32) ?buf.ClipRect { + const scissor = self.getCurrentHitScissorRect() orelse return buf.ClipRect{ + .x = x, + .y = y, + .width = width, + .height = height, + }; + + const rect_end_x = x + @as(i32, @intCast(width)); + const rect_end_y = y + @as(i32, @intCast(height)); + const scissor_end_x = scissor.x + @as(i32, @intCast(scissor.width)); + const scissor_end_y = scissor.y + @as(i32, @intCast(scissor.height)); + + const intersect_x = @max(x, scissor.x); + const intersect_y = @max(y, scissor.y); + const intersect_end_x = @min(rect_end_x, scissor_end_x); + const intersect_end_y = @min(rect_end_y, scissor_end_y); + + if (intersect_x >= intersect_end_x or intersect_y >= intersect_end_y) { + return null; + } + + return buf.ClipRect{ + .x = intersect_x, + .y = intersect_y, + .width = @intCast(intersect_end_x - intersect_x), + .height = @intCast(intersect_end_y - intersect_y), + }; + } + + /// Push a scissor rect for hit grid clipping. + /// + /// The rect is intersected with any existing scissor, so nested overflow:hidden + /// containers compound correctly. All coordinates are in screen space. + pub fn hitGridPushScissorRect(self: *CliRenderer, x: i32, y: i32, width: u32, height: u32) void { + var rect = buf.ClipRect{ + .x = x, + .y = y, + .width = width, + .height = height, + }; + + if (self.getCurrentHitScissorRect() != null) { + const intersect = self.clipRectToHitScissor(rect.x, rect.y, rect.width, rect.height); + if (intersect) |clipped| { + rect = clipped; + } else { + rect = buf.ClipRect{ .x = 0, .y = 0, .width = 0, .height = 0 }; + } + } + + self.hitScissorStack.append(self.allocator, rect) catch |err| { + logger.warn("Failed to push hit-grid scissor rect: {}", .{err}); + }; + } + + pub fn hitGridPopScissorRect(self: *CliRenderer) void { + if (self.hitScissorStack.items.len > 0) { + _ = self.hitScissorStack.pop(); + } + } + + /// Clear all hit grid scissors. Called at start of render to reset state. + pub fn hitGridClearScissorRects(self: *CliRenderer) void { + self.hitScissorStack.clearRetainingCapacity(); + } + + /// Write directly to currentHitGrid with scissor clipping. + /// + /// Used for immediate hit grid sync when scroll/translate changes. Unlike + /// addToHitGrid (which writes to nextHitGrid for the upcoming frame), this + /// updates the grid that checkHit reads right now. Lets hover states update + /// without waiting for the next render. + pub fn addToCurrentHitGridClipped(self: *CliRenderer, x: i32, y: i32, width: u32, height: u32, id: u32) void { + const clipped = self.clipRectToHitScissor(x, y, width, height) orelse return; + + const startX = @max(0, clipped.x); + const startY = @max(0, clipped.y); + const endX = @min(@as(i32, @intCast(self.hitGridWidth)), clipped.x + @as(i32, @intCast(clipped.width))); + const endY = @min(@as(i32, @intCast(self.hitGridHeight)), clipped.y + @as(i32, @intCast(clipped.height))); + + if (startX >= endX or startY >= endY) return; + + const uStartX: u32 = @intCast(startX); + const uStartY: u32 = @intCast(startY); + const uEndX: u32 = @intCast(endX); + const uEndY: u32 = @intCast(endY); + + for (uStartY..uEndY) |row| { + const rowStart = row * self.hitGridWidth; + const startIdx = rowStart + uStartX; + const endIdx = rowStart + uEndX; + + @memset(self.currentHitGrid[startIdx..endIdx], id); + } + } + pub fn dumpHitGrid(self: *CliRenderer) void { const timestamp = std.time.timestamp(); var filename_buf: [64]u8 = undefined;