/** * Nested scroll behavior — the innermost scrollbox under the cursor wins. * * opentui bubbles wheel events up the renderable tree, so without a guard * every ancestor scrollbox scrolls in lockstep. This pins the fix from * `src/utils/nested-scroll.ts`: two nested scrollboxes (an inner one nested * inside an outer one, as a description pane sits inside a list pane) must * treat the wheel as owned by the innermost scrollbox under the cursor, and * only chain out to the outer one when the inner is at its boundary. */ import { describe, test, expect, afterAll } from "bun:test"; import { testRender } from "@opentui/solid"; import { installNestedScrollBehavior } from "../src/utils/nested-scroll"; import type { ScrollBoxRenderable } from "@opentui/core"; installNestedScrollBehavior(); type TestSetup = { renderOnce: () => Promise; mockMouse: { scroll: (x: number, y: number, direction: "up" | "down") => Promise; }; renderer: { destroy: () => Promise }; }; async function renderNested(): Promise<{ setup: TestSetup; outer: () => ScrollBoxRenderable; inner: () => ScrollBoxRenderable; destroy: () => Promise; }> { let outer: ScrollBoxRenderable | undefined; let inner: ScrollBoxRenderable | undefined; const setup = (await testRender( () => ( // Outer spans the full 25-row terminal; the inner scrollbox sits at // rows 3..12 (a top spacer above, a tall spacer below so the outer // has room to scroll). Inner holds 30 rows -> max scroll 20. (outer = el)} height="100%"> (inner = el)} height={10} width="100%" > {Array.from({ length: 30 }, (_, i) => ( row {i} ))} ), { width: 60, height: 25, useThread: false }, )) as unknown as TestSetup; // Give the renderer a chance to compute scrollbox layout (scrollHeight). for (let i = 0; i < 40 && (inner?.scrollHeight ?? 0) <= 10; i++) { await setup.renderOnce(); const { promise, resolve } = Promise.withResolvers(); setTimeout(resolve, 50); await promise; } if (!inner || !outer) throw new Error("scrollboxes did not render"); return { setup, outer: () => outer!, inner: () => inner!, destroy: async () => { setup.renderer.destroy(); }, }; } const cleanups: (() => void | Promise)[] = []; afterAll(async () => { for (const c of cleanups) { try { await c(); } catch { // renderer already torn down — ignore } } }); describe("nested scroll favors the innermost scrollbox under the cursor", () => { test("wheel over the inner section scrolls only the inner scrollbox", async () => { const { setup, inner, outer, destroy } = await renderNested(); cleanups.push(destroy); expect(inner().scrollTop).toBe(0); await setup.mockMouse.scroll(5, 5, "down"); // inside inner (rows 3..12) expect(inner().scrollTop).toBe(1); expect(outer().scrollTop).toBe(0); }); test("wheel over the outer section (outside the inner) scrolls only the outer", async () => { const { setup, inner, outer, destroy } = await renderNested(); cleanups.push(destroy); await setup.mockMouse.scroll(5, 20, "down"); // below inner, still in outer expect(outer().scrollTop).toBe(1); expect(inner().scrollTop).toBe(0); }); test("at the inner's bottom edge the wheel chains out to the outer scrollbox", async () => { const { setup, inner, outer, destroy } = await renderNested(); cleanups.push(destroy); for (let i = 0; i < 30; i++) await setup.mockMouse.scroll(5, 5, "down"); expect(inner().scrollTop).toBe(20); // pinned at max (30 rows - 10 viewport) const before = outer().scrollTop; await setup.mockMouse.scroll(5, 5, "down"); expect(inner().scrollTop).toBe(20); // inner stays pinned expect(outer().scrollTop).toBe(before + 1); // outer took over }); test("wheel up favors the inner again once it has room above", async () => { const { setup, inner, outer, destroy } = await renderNested(); cleanups.push(destroy); await setup.mockMouse.scroll(5, 5, "down"); expect(inner().scrollTop).toBe(1); await setup.mockMouse.scroll(5, 5, "up"); expect(inner().scrollTop).toBe(0); // inner wins again expect(outer().scrollTop).toBe(0); }); });