fix inner scroll behavior
This commit is contained in:
9
scripts/_hv.ts
Normal file
9
scripts/_hv.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
const { ThemeProvider } = await import("../src/context/ThemeContext");
|
||||||
|
const { PaneRow } = await import("../src/components/PaneRow");
|
||||||
|
process.env.XDG_CONFIG_HOME = import.meta.dir + "/../.harness/config-home";
|
||||||
|
import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path";
|
||||||
|
process.env.XDG_CONFIG_HOME = mkdtempSync(join(tmpdir(), "hv-"));
|
||||||
|
const setup = (await testRender(
|
||||||
|
() => React.createElement...
|
||||||
|
));
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { onCleanup } from "solid-js";
|
import { onCleanup } from "solid-js";
|
||||||
import { setupTerminalRecovery } from "./utils/terminal-recovery";
|
import { setupTerminalRecovery } from "./utils/terminal-recovery";
|
||||||
|
import { installNestedScrollBehavior } from "./utils/nested-scroll";
|
||||||
import type { Feed } from "./types/feed"
|
import type { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
@@ -236,6 +237,8 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
|||||||
const { NavigationProvider } = await import("./context/NavigationContext");
|
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||||
const { DialogProvider } = await import("./ui/dialog");
|
const { DialogProvider } = await import("./ui/dialog");
|
||||||
const { CommandProvider } = await import("./ui/command");
|
const { CommandProvider } = await import("./ui/command");
|
||||||
|
// Nested scroll sections favor the innermost one under the cursor.
|
||||||
|
installNestedScrollBehavior();
|
||||||
|
|
||||||
function RendererSetup(props: { children: unknown }) {
|
function RendererSetup(props: { children: unknown }) {
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
|
|||||||
72
src/utils/nested-scroll.ts
Normal file
72
src/utils/nested-scroll.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Nested scroll sections favor the innermost one under the cursor.
|
||||||
|
*
|
||||||
|
* opentui bubbles a wheel event up the renderable tree, so every ancestor
|
||||||
|
* `ScrollBoxRenderable` that has room to move scrolls — nested sections (e.g.
|
||||||
|
* the episode-description scrollbox inside a page's list pane) scroll in
|
||||||
|
* lockstep. This patches the scrollbox's wheel handler so the innermost
|
||||||
|
* scrollbox under the cursor wins instead:
|
||||||
|
*
|
||||||
|
* • The first scrollbox that can move in the wheel's direction scrolls and
|
||||||
|
* stops propagation, so its ancestors don't also scroll.
|
||||||
|
* • When it is already at its boundary it lets the next outer scrollbox
|
||||||
|
* take over (wheel chaining), matching typical nested-scroll UX.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ScrollBoxRenderable } from "@opentui/core";
|
||||||
|
import type { MouseEvent } from "@opentui/core";
|
||||||
|
|
||||||
|
type ScrollDir = "up" | "down" | "left" | "right";
|
||||||
|
|
||||||
|
// The scrollbox's own wheel handler (scrolls, then bubbles to its parent).
|
||||||
|
const original = ScrollBoxRenderable.prototype.onMouseEvent;
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
|
||||||
|
/** True when `sb` has room to move in `dir` from its current position. */
|
||||||
|
function canScroll(sb: ScrollBoxRenderable, dir: ScrollDir): boolean {
|
||||||
|
const maxTop = Math.max(0, sb.scrollHeight - sb.viewport.height);
|
||||||
|
const maxLeft = Math.max(0, sb.scrollWidth - sb.viewport.width);
|
||||||
|
switch (dir) {
|
||||||
|
case "up":
|
||||||
|
return sb.scrollTop > 0;
|
||||||
|
case "down":
|
||||||
|
return sb.scrollTop < maxTop;
|
||||||
|
case "left":
|
||||||
|
return sb.scrollLeft > 0;
|
||||||
|
case "right":
|
||||||
|
return sb.scrollLeft < maxLeft;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = function (
|
||||||
|
this: ScrollBoxRenderable,
|
||||||
|
event: MouseEvent,
|
||||||
|
): void {
|
||||||
|
if (event.type !== "scroll" || !event.scroll?.direction) {
|
||||||
|
original.call(this, event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = event.scroll.direction;
|
||||||
|
const effective: ScrollDir = event.modifiers.shift
|
||||||
|
? (dir === "up" ? "left" : dir === "down" ? "right" : dir === "right" ? "down" : "up")
|
||||||
|
: dir;
|
||||||
|
|
||||||
|
const moves = canScroll(this, effective);
|
||||||
|
original.call(this, event);
|
||||||
|
// Only claim the wheel when this box actually moved; otherwise let the
|
||||||
|
// next outer scrollbox (also under the cursor) take over.
|
||||||
|
if (moves) event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function installNestedScrollBehavior(): void {
|
||||||
|
if (installed || typeof original !== "function") return;
|
||||||
|
installed = true;
|
||||||
|
// `onMouseEvent` is a well-known protected method; the cast only bypasses
|
||||||
|
// TypeScript's protected-access check and trusts the shipped class shape.
|
||||||
|
const scrollboxProto = ScrollBoxRenderable.prototype as unknown as {
|
||||||
|
onMouseEvent: typeof handleWheel;
|
||||||
|
};
|
||||||
|
scrollboxProto.onMouseEvent = handleWheel;
|
||||||
|
}
|
||||||
128
tests/nested-scroll.test.tsx
Normal file
128
tests/nested-scroll.test.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
mockMouse: {
|
||||||
|
scroll: (x: number, y: number, direction: "up" | "down") => Promise<void>;
|
||||||
|
};
|
||||||
|
renderer: { destroy: () => Promise<void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
async function renderNested(): Promise<{
|
||||||
|
setup: TestSetup;
|
||||||
|
outer: () => ScrollBoxRenderable;
|
||||||
|
inner: () => ScrollBoxRenderable;
|
||||||
|
destroy: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
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.
|
||||||
|
<box flexDirection="column" width={60} height={25}>
|
||||||
|
<scrollbox ref={(el: ScrollBoxRenderable) => (outer = el)} height="100%">
|
||||||
|
<box height={3} />
|
||||||
|
<scrollbox
|
||||||
|
ref={(el: ScrollBoxRenderable) => (inner = el)}
|
||||||
|
height={10}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 30 }, (_, i) => (
|
||||||
|
<box height={1}>
|
||||||
|
<text>row {i}</text>
|
||||||
|
</box>
|
||||||
|
))}
|
||||||
|
</scrollbox>
|
||||||
|
<box height={40} />
|
||||||
|
</scrollbox>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
{ 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<void>();
|
||||||
|
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<void>)[] = [];
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user