diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 811451583..c058a6026 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -596,6 +596,7 @@ export abstract class Renderable extends BaseRenderable { if (this._zIndex !== value) { this._zIndex = value this.parent?.requestZIndexSort() + this.requestRender() } } @@ -983,13 +984,15 @@ export abstract class Renderable extends BaseRenderable { const oldX = this._x const oldY = this._y + const oldWidth = this._widthValue + const oldHeight = this._heightValue this._x = layout.left this._y = layout.top const newWidth = Math.max(layout.width, 1) const newHeight = Math.max(layout.height, 1) - const sizeChanged = this.width !== newWidth || this.height !== newHeight + const sizeChanged = oldWidth !== newWidth || oldHeight !== newHeight this._widthValue = newWidth this._heightValue = newHeight @@ -998,7 +1001,8 @@ export abstract class Renderable extends BaseRenderable { this.onLayoutResize(newWidth, newHeight) } - if (oldX !== this._x || oldY !== this._y) { + const positionChanged = oldX !== this._x || oldY !== this._y + if (positionChanged) { if (this.parent) this.parent.childrenPrimarySortDirty = true } } diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index 6483af300..208b2f3fa 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -60,6 +60,8 @@ import * as keypressDebugDemo from "./keypress-debug-demo" import * as linkDemo from "./link-demo" import * as extmarksDemo from "./extmarks-demo" import * as opacityExample from "./opacity-example" +import * as scrollboxOverlayHitTest from "./scrollbox-overlay-hit-test" +import * as scrollboxMouseTest from "./scrollbox-mouse-test" import { setupCommonDemoKeys } from "./lib/standalone-keys" interface Example { @@ -245,6 +247,18 @@ const examples: Example[] = [ run: stickyScrollExample.run, destroy: stickyScrollExample.destroy, }, + { + name: "Scrollbox Mouse Test", + description: "Test scrollbox mouse hit detection with hover and click events", + run: scrollboxMouseTest.run, + destroy: scrollboxMouseTest.destroy, + }, + { + name: "Scrollbox Overlay Hit Test", + description: "Test scrollbox hit detection with overlays and dialogs", + run: scrollboxOverlayHitTest.run, + destroy: scrollboxOverlayHitTest.destroy, + }, { name: "Shader Cube", description: "3D cube with custom shaders", diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index c0c02e888..80ee9ecad 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -408,6 +408,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private _capabilities: any | null = null private _latestPointer: { x: number; y: number } = { x: 0, y: 0 } private _hasPointer: boolean = false + private _lastPointerModifiers: RawMouseEvent["modifiers"] = { shift: false, alt: false, ctrl: false } private _currentFocusedRenderable: Renderable | null = null private lifecyclePasses: Set = new Set() @@ -1088,6 +1089,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this._latestPointer.x = mouseEvent.x this._latestPointer.y = mouseEvent.y this._hasPointer = true + this._lastPointerModifiers = mouseEvent.modifiers if (this._console.visible) { const consoleBounds = this._console.bounds @@ -1233,6 +1235,52 @@ export class CliRenderer extends EventEmitter implements RenderContext { return false } + /** + * Recheck hover state after hit grid changes. + * Called after render when native code detects the hit grid changed. + * Fires out/over events if the element under the cursor changed. + */ + private recheckHoverState(): void { + if (this._isDestroyed || !this._hasPointer) return + if (this.capturedRenderable) return + + const hitId = this.hitTest(this._latestPointer.x, this._latestPointer.y) + const hitRenderable = Renderable.renderablesByNumber.get(hitId) + const lastOver = this.lastOverRenderable + + // No change + if (lastOver?.num === hitId) { + this.lastOverRenderableNum = hitId + return + } + + const baseEvent: RawMouseEvent = { + type: "move", + button: 0, + x: this._latestPointer.x, + y: this._latestPointer.y, + modifiers: this._lastPointerModifiers, + } + + // Fire out on old element + if (lastOver) { + const event = new MouseEvent(lastOver, { ...baseEvent, type: "out" }) + lastOver.processMouseEvent(event) + } + + this.lastOverRenderable = hitRenderable + this.lastOverRenderableNum = hitId + + // Fire over on new element + if (hitRenderable) { + const event = new MouseEvent(hitRenderable, { + ...baseEvent, + type: "over", + }) + hitRenderable.processMouseEvent(event) + } + } + public hitTest(x: number, y: number): number { return this.lib.checkHit(this.rendererPtr, x, y) } @@ -1753,6 +1801,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { if (!this._isDestroyed) { this.renderNative() + // Check if hit grid changed and recheck hover state if needed + if (this._useMouse && this.lib.getHitGridDirty(this.rendererPtr)) { + this.recheckHoverState() + } + const overallFrameTime = performance.now() - overallStart // TODO: Add animationRequestTime to stats diff --git a/packages/core/src/tests/scrollbox-hitgrid.test.ts b/packages/core/src/tests/scrollbox-hitgrid.test.ts index e698b87b4..105fa0c48 100644 --- a/packages/core/src/tests/scrollbox-hitgrid.test.ts +++ b/packages/core/src/tests/scrollbox-hitgrid.test.ts @@ -1,5 +1,5 @@ import { test, expect, beforeEach, afterEach } from "bun:test" -import { createTestRenderer, type MockMouse, type TestRenderer } from "../testing" +import { createTestRenderer, MouseButtons, type MockMouse, type TestRenderer } from "../testing" import { ScrollBoxRenderable } from "../renderables/ScrollBox" import { BoxRenderable } from "../renderables/Box" import { Renderable } from "../Renderable" @@ -7,8 +7,22 @@ import { Renderable } from "../Renderable" let testRenderer: TestRenderer let mockMouse: MockMouse +class MovingBoxRenderable extends BoxRenderable { + public shouldMove = false + + protected onUpdate(_deltaTime: number): void { + if (this.shouldMove) { + this.shouldMove = false + this.translateY = 3 + } + } +} + beforeEach(async () => { - ;({ renderer: testRenderer, mockMouse } = await createTestRenderer({ width: 50, height: 30 })) + ;({ renderer: testRenderer, mockMouse } = await createTestRenderer({ + width: 50, + height: 30, + })) }) afterEach(() => { @@ -125,6 +139,339 @@ test("hover updates after scroll when pointer moves", async () => { expect(hoverEvents).toEqual(["over:item-0", "out:item-0", "over:item-1"]) }) +test("hover updates after scroll without pointer movement", 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() + + expect(hoveredId).toBe("item-1") + expect(hoverEvents).toEqual(["over:item-0", "out:item-0", "over:item-1"]) +}) + +test("hover recheck uses neutral button and modifiers", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 20, + height: 6, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + const hoverEvents: Array<{ + type: "over" | "out" + button: number + modifiers: { shift: boolean; alt: boolean; ctrl: boolean } + }> = [] + 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: (event) => { + hoveredId = itemId + hoverEvents.push({ + type: "over", + button: event.button, + modifiers: { ...event.modifiers }, + }) + }, + onMouseOut: (event) => { + if (hoveredId === itemId) { + hoveredId = null + } + hoverEvents.push({ + type: "out", + button: event.button, + modifiers: { ...event.modifiers }, + }) + }, + }) + 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, { modifiers: { shift: true } }) + expect(hoveredId).toBe("item-0") + + await mockMouse.pressDown(pointerX, pointerY, MouseButtons.RIGHT, { modifiers: { shift: true } }) + + scrollBox.scrollTop = 2 + await testRenderer.idle() + + expect(hoveredId).toBe("item-1") + expect(hoverEvents).toHaveLength(3) + const outEvent = hoverEvents[1] + const overEvent = hoverEvents[2] + // Synthetic hover recheck uses neutral button but preserves last known modifiers + expect(outEvent.button).toBe(0) + expect(outEvent.modifiers).toEqual({ shift: true, alt: false, ctrl: false }) + expect(overEvent.button).toBe(0) + expect(overEvent.modifiers).toEqual({ shift: true, alt: false, ctrl: false }) +}) + +test("hover recheck over event has no source when not dragging", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 20, + height: 6, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + const hoverEvents: Array<{ + type: "over" | "out" + source: Renderable | undefined + }> = [] + + 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: (event) => { + hoverEvents.push({ + type: "over", + source: event.source, + }) + }, + onMouseOut: (event) => { + hoverEvents.push({ + type: "out", + source: event.source, + }) + }, + }) + items.push(item) + scrollBox.add(item) + } + + await testRenderer.idle() + + const pointerX = items[0].x + 1 + const pointerY = items[0].y + 1 + + // Move to item-0 (not dragging) + await mockMouse.moveTo(pointerX, pointerY) + expect(hoverEvents).toHaveLength(1) + expect(hoverEvents[0].type).toBe("over") + expect(hoverEvents[0].source).toBeUndefined() + + // Scroll to trigger hover recheck - should have no source since we're not dragging + scrollBox.scrollTop = 2 + await testRenderer.idle() + + expect(hoverEvents).toHaveLength(3) + // out event from item-0 + expect(hoverEvents[1].type).toBe("out") + expect(hoverEvents[1].source).toBeUndefined() + // over event to item-1 - source should be undefined (not dragging) + expect(hoverEvents[2].type).toBe("over") + expect(hoverEvents[2].source).toBeUndefined() +}) + +test("hover updates on multiple scroll changes", 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"]) + + // First scroll - hover recheck happens immediately after render + scrollBox.scrollTop = 2 + await testRenderer.idle() + expect(hoveredId).toBe("item-1") + + // Second scroll - another immediate hover recheck + scrollBox.scrollTop = 4 + await testRenderer.idle() + + expect(hoveredId).toBe("item-2") + // Each render triggers immediate hover recheck, so we see all transitions + expect(hoverEvents).toEqual(["over:item-0", "out:item-0", "over:item-1", "out:item-1", "over:item-2"]) +}) + +test("mouse move during scroll triggers normal hover", 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"]) + + // Scroll triggers render which triggers immediate hover recheck + scrollBox.scrollTop = 2 + await testRenderer.idle() + expect(hoveredId).toBe("item-1") + expect(hoverEvents).toEqual(["over:item-0", "out:item-0", "over:item-1"]) + + // Mouse move also works and doesn't duplicate events since we're already on item-1 + await mockMouse.moveTo(pointerX, pointerY) + expect(hoveredId).toBe("item-1") + expect(hoverEvents).toEqual(["over:item-0", "out:item-0", "over:item-1"]) +}) + +test("hover updates immediately after render", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 20, + height: 6, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + 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 + }, + onMouseOut: () => { + if (hoveredId === itemId) { + hoveredId = null + } + }, + }) + 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") + + // Hover updates immediately after render - no delay needed + scrollBox.scrollTop = 2 + await testRenderer.idle() + expect(hoveredId).toBe("item-1") +}) + test("hit grid handles multiple scroll operations correctly", async () => { const scrollBox = new ScrollBoxRenderable(testRenderer, { width: 40, @@ -221,6 +568,50 @@ test("hit grid respects scrollbox viewport clipping when offset", async () => { expect(viewportHit?.id).toBe("item-2") }) +test("hover recheck skips while dragging captured renderable", async () => { + const scrollBox = new ScrollBoxRenderable(testRenderer, { + width: 20, + height: 6, + scrollY: true, + }) + testRenderer.root.add(scrollBox) + + const hoverEvents: string[] = [] + + 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: () => { + hoverEvents.push(`over:${itemId}`) + }, + onMouseOut: () => { + 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) + await mockMouse.pressDown(pointerX, pointerY) + await mockMouse.moveTo(pointerX, pointerY) + + scrollBox.scrollTop = 2 + await testRenderer.idle() + + // Hover recheck is skipped when there's a captured renderable (during drag) + expect(hoverEvents).toEqual(["over:item-0"]) +}) + test("captured renderable is not in hit grid during scroll", async () => { const scrollBox = new ScrollBoxRenderable(testRenderer, { width: 40, @@ -308,6 +699,131 @@ test("buffered overflow scissor uses screen coordinates for hit grid", async () expect(hit?.id).toBe("buffered-child") }) +test("hover updates after translate animation", async () => { + const hoverEvents: string[] = [] + let hoveredId: string | null = null + + const under = new BoxRenderable(testRenderer, { + id: "under", + position: "absolute", + left: 2, + top: 2, + width: 6, + height: 2, + zIndex: 0, + onMouseOver: () => { + hoveredId = "under" + hoverEvents.push("over:under") + }, + onMouseOut: () => { + if (hoveredId === "under") { + hoveredId = null + } + hoverEvents.push("out:under") + }, + }) + testRenderer.root.add(under) + + const moving = new MovingBoxRenderable(testRenderer, { + id: "moving", + position: "absolute", + left: 2, + top: 2, + width: 6, + height: 2, + zIndex: 1, + onMouseOver: () => { + hoveredId = "moving" + hoverEvents.push("over:moving") + }, + onMouseOut: () => { + if (hoveredId === "moving") { + hoveredId = null + } + hoverEvents.push("out:moving") + }, + }) + testRenderer.root.add(moving) + + await testRenderer.idle() + + const pointerX = moving.x + 1 + const pointerY = moving.y + 1 + + await mockMouse.moveTo(pointerX, pointerY) + expect(hoveredId).toBe("moving") + expect(hoverEvents).toEqual(["over:moving"]) + + moving.shouldMove = true + moving.requestRender() + await testRenderer.idle() + + expect(hoveredId).toBe("under") + expect(hoverEvents).toEqual(["over:moving", "out:moving", "over:under"]) +}) + +test("hover updates after z-index change", async () => { + const hoverEvents: string[] = [] + let hoveredId: string | null = null + + const back = new BoxRenderable(testRenderer, { + id: "back", + position: "absolute", + left: 2, + top: 2, + width: 6, + height: 2, + zIndex: 0, + onMouseOver: () => { + hoveredId = "back" + hoverEvents.push("over:back") + }, + onMouseOut: () => { + if (hoveredId === "back") { + hoveredId = null + } + hoverEvents.push("out:back") + }, + }) + testRenderer.root.add(back) + + const front = new BoxRenderable(testRenderer, { + id: "front", + position: "absolute", + left: 2, + top: 2, + width: 6, + height: 2, + zIndex: 1, + onMouseOver: () => { + hoveredId = "front" + hoverEvents.push("over:front") + }, + onMouseOut: () => { + if (hoveredId === "front") { + hoveredId = null + } + hoverEvents.push("out:front") + }, + }) + testRenderer.root.add(front) + + await testRenderer.idle() + + const pointerX = front.x + 1 + const pointerY = front.y + 1 + + await mockMouse.moveTo(pointerX, pointerY) + expect(hoveredId).toBe("front") + expect(hoverEvents).toEqual(["over:front"]) + + back.zIndex = 2 + await testRenderer.idle() + + expect(hoveredId).toBe("back") + expect(hoverEvents).toEqual(["over:front", "out:front", "over:back"]) +}) + test("scrolling does not steal clicks outside the list", async () => { let lastClick = "none" diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 8482bc20a..b84c0b831 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -342,6 +342,10 @@ function getOpenTUILib(libPath?: string) { args: ["ptr", "u32", "u32"], returns: "u32", }, + getHitGridDirty: { + args: ["ptr"], + returns: "bool", + }, dumpHitGrid: { args: ["ptr"], returns: "void", @@ -1358,6 +1362,7 @@ export interface RenderLib { id: number, ) => void checkHit: (renderer: Pointer, x: number, y: number) => number + getHitGridDirty: (renderer: Pointer) => boolean dumpHitGrid: (renderer: Pointer) => void dumpBuffers: (renderer: Pointer, timestamp?: number) => void dumpStdoutBuffer: (renderer: Pointer, timestamp?: number) => void @@ -2178,6 +2183,10 @@ class FFIRenderLib implements RenderLib { return this.opentui.symbols.checkHit(renderer, x, y) } + public getHitGridDirty(renderer: Pointer): boolean { + return this.opentui.symbols.getHitGridDirty(renderer) + } + public dumpHitGrid(renderer: Pointer): void { this.opentui.symbols.dumpHitGrid(renderer) } diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index d3c1347f7..1a2b685a0 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -487,6 +487,10 @@ export fn checkHit(rendererPtr: *renderer.CliRenderer, x: u32, y: u32) u32 { return rendererPtr.checkHit(x, y); } +export fn getHitGridDirty(rendererPtr: *renderer.CliRenderer) bool { + return rendererPtr.getHitGridDirty(); +} + export fn dumpHitGrid(rendererPtr: *renderer.CliRenderer) void { rendererPtr.dumpHitGrid(); } diff --git a/packages/core/src/zig/renderer.zig b/packages/core/src/zig/renderer.zig index c0ccae8d5..fbdd6a913 100644 --- a/packages/core/src/zig/renderer.zig +++ b/packages/core/src/zig/renderer.zig @@ -122,6 +122,7 @@ pub const CliRenderer = struct { hitGridWidth: u32, hitGridHeight: u32, hitScissorStack: std.ArrayListUnmanaged(buf.ClipRect), + hitGridDirty: bool = false, lastCursorStyleTag: ?u8 = null, lastCursorBlinking: ?bool = null, @@ -797,6 +798,10 @@ pub const CliRenderer = struct { self.nextRenderBuffer.clear(.{ self.backgroundColor[0], self.backgroundColor[1], self.backgroundColor[2], self.backgroundColor[3] }, null) catch {}; + // Compare hit grids before swap to detect changes. This allows TypeScript to + // know if hover state needs rechecking without manually tracking dirty state. + self.hitGridDirty = !std.mem.eql(u32, self.currentHitGrid, self.nextHitGrid); + // 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. @@ -899,6 +904,13 @@ pub const CliRenderer = struct { @memset(self.currentHitGrid, 0); } + /// Return whether the hit grid changed during the last render. + /// This is set by comparing the previous and current hit grids after render. + /// TypeScript can use this to decide if hover state needs rechecking. + pub fn getHitGridDirty(self: *CliRenderer) bool { + return self.hitGridDirty; + } + /// 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) {