Skip to content

Commit 7f1bc6e

Browse files
committed
feat(tui): scroll the overlay list with the host scrollbar
1 parent f7b53ac commit 7f1bc6e

5 files changed

Lines changed: 126 additions & 48 deletions

File tree

‎src/tui/allow-once-reprompt.test.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,8 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
484484
openSlash(shell);
485485
const list = defined(shell.overlayList, "slash list");
486486
expect(list.select.isDestroyed).toBe(false);
487-
expect(list.select.parent).toBe(shell.overlayView.body.content);
487+
expect(list.viewport.parent).toBe(shell.overlayView.body);
488+
expect(list.select.isDestroyed).toBe(false);
488489

489490
let resolved: unknown;
490491
emitter.emit("permission.gate", {
@@ -501,8 +502,8 @@ describe("CL-8792 overlay host: suspend preserves the surface instead of dismiss
501502
expect(shell.overlayKind).toBe("help");
502503
expect(shell.overlayList).toBe(list);
503504
expect(list.select.isDestroyed).toBe(false);
504-
expect(list.select.parent).toBe(shell.overlayView.body.content);
505-
expect(shell.overlayView.body.getChildren()).toContain(list.select);
505+
expect(list.viewport.parent).toBe(shell.overlayView.body);
506+
expect(shell.overlayView.body.getChildren()).toContain(list.viewport);
506507
});
507508
});
508509

‎src/tui/overlay-paint.test.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,15 +83,18 @@ async function paintOverlay(
8383
/**
8484
* Every interior row must be blank or exactly one expected overlay row; two
8585
* renderables sharing cells produces a hybrid string that matches nothing.
86+
* A native ScrollBar thumb occupies the last column when the list overflows;
87+
* that glyph is not a second copy of the row.
8688
*/
8789
function expectCleanInterior(
8890
interior: readonly string[],
8991
expected: readonly string[],
9092
): void {
9193
const allowed = new Set(expected.map((e) => e.trimEnd()));
9294
for (const row of interior) {
93-
if (row.trim().length === 0) continue;
94-
expect(allowed.has(row)).toBe(true);
95+
const withoutBar = row.replace(/[▀▄█▌▐░▒▓]+$/u, "").trimEnd();
96+
if (withoutBar.length === 0) continue;
97+
expect(allowed.has(withoutBar)).toBe(true);
9598
}
9699
}
97100

@@ -125,7 +128,8 @@ describe("overlay host never shares cells with the prompt border", () => {
125128
expectCleanInterior(interior.slice(1), expected);
126129

127130
// The selected row must be intact, not overwritten by the model label.
128-
expect(interior).toContain(` ${ITEMS[0]}`);
131+
// A ScrollBar thumb may sit on the same line, so match the label prefix.
132+
expect(interior.some((row) => row.startsWith(` ${ITEMS[0]}`))).toBe(true);
129133
for (const row of interior) {
130134
expect(row.includes(MODEL_LABEL)).toBe(false);
131135
expect(row.includes("alice")).toBe(false);

‎src/tui/overlay-view.ts‎

Lines changed: 10 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
BoxRenderable,
3-
ScrollBoxRenderable,
43
TextRenderable,
54
type RenderContext,
65
} from "@opentui/core";
@@ -317,24 +316,13 @@ export function createOverlayView(ctx: RenderContext) {
317316
content: " overlay",
318317
fg: UI.textDim,
319318
});
320-
const body = new ScrollBoxRenderable(ctx, {
319+
const body = new BoxRenderable(ctx, {
321320
id: "shell-overlay-body",
322321
width: "100%",
323322
flexGrow: 1,
324-
flexShrink: 1,
325-
// The host scrolls as one surface. Arrow keys stay on the list selection;
326-
// the scrollbox must not take focus and page itself.
327-
focusable: false,
328-
viewportCulling: true,
329-
scrollY: true,
330-
stickyScroll: false,
331-
rootOptions: { backgroundColor: UI.ground },
332-
contentOptions: { backgroundColor: UI.ground },
333-
viewportOptions: { backgroundColor: UI.ground },
323+
flexDirection: "column",
324+
backgroundColor: UI.ground,
334325
});
335-
body.horizontalScrollBar.visible = false;
336-
body.horizontalScrollBar.focusable = false;
337-
body.verticalScrollBar.focusable = false;
338326
host.add(title);
339327
host.add(body);
340328

@@ -385,8 +373,8 @@ export function createOverlayView(ctx: RenderContext) {
385373
name: line,
386374
description: "",
387375
}));
388-
body.add(list.select);
389-
revealList(list);
376+
body.add(list.viewport);
377+
list.scrollActiveIntoView();
390378
}
391379

392380
/** Rule + two-line description zone, painted when `describe` is set. */
@@ -427,18 +415,11 @@ export function createOverlayView(ctx: RenderContext) {
427415
}
428416

429417
/**
430-
* Detach the select before `clearBody` destroys the body's children — the
431-
* list owns it across paints. ScrollBox.add parents into content, so the
432-
* select's parent is not the scrollbox itself.
418+
* Detach the list viewport before `clearBody` destroys the body's children —
419+
* the list owns the select inside that ScrollBox across paints.
433420
*/
434421
function detachList(list: OverlayList): void {
435-
if (list.select.parent) body.remove(list.select);
436-
}
437-
438-
/** Keep the live choice inside the host viewport after a paint or move. */
439-
function revealList(list: OverlayList): void {
440-
if (list.select.parent === null || list.select.height <= 0) return;
441-
body.scrollChildIntoView(list.select.id);
422+
if (list.viewport.parent) body.remove(list.viewport);
442423
}
443424

444425
function paintList(
@@ -475,11 +456,11 @@ export function createOverlayView(ctx: RenderContext) {
475456
});
476457
// An empty list renders nothing: the renderable would claim a background
477458
// row the chooser did not reserve.
478-
if (presentation.items.length > 0) body.add(list.select);
459+
if (presentation.items.length > 0) body.add(list.viewport);
479460
else addOverlayRow(` ${OVERLAY_EMPTY_STATE}`, UI.textDim);
480461
paintAnswerRow(presentation.answer, contentWidth);
481462
paintDescriptionZone(presentation.describe, contentWidth);
482-
revealList(list);
463+
list.scrollActiveIntoView();
483464
}
484465

485466
function paintTitle(

‎src/tui/shell/internals.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ export interface AppShell {
356356
readonly overlayView: ReturnType<typeof createOverlayView>;
357357
readonly overlayHost: BoxRenderable;
358358
readonly overlayTitle: TextRenderable;
359-
readonly overlayBody: ScrollBoxRenderable;
359+
readonly overlayBody: BoxRenderable;
360360
readonly prompt: PromptInput;
361361
readonly promptBox: BoxRenderable;
362362
/** The input's own row, bordered left and right only. */
@@ -546,6 +546,8 @@ export interface OverlayListRange {
546546
* rebuilds on geometry change. */
547547
export interface OverlayList {
548548
readonly select: SelectRenderable;
549+
/** ScrollBox that clips the select and owns the host ScrollBar. */
550+
readonly viewport: ScrollBoxRenderable;
549551
readonly activeIndex: number;
550552
/** Item-row capacity reserved by layout (not the renderable's row height). */
551553
readonly height: number;
@@ -557,6 +559,8 @@ export interface OverlayList {
557559
jump(index: number): void;
558560
setCount(count: number): void;
559561
setHeight(items: number, rowsPerItem?: number): void;
562+
/** Move the host ScrollBox so the active row is inside the viewport. */
563+
scrollActiveIntoView(): void;
560564
visibleRange(): OverlayListRange;
561565
}
562566

‎src/tui/shell/overlay-list.ts‎

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* The overlay list wrapper (SelectRenderable) and its selection delegations.
33
*/
44
import {
5+
BoxRenderable,
6+
ScrollBoxRenderable,
57
SelectRenderable,
68
type KeyEvent,
79
type RenderContext,
@@ -133,20 +135,19 @@ function placeholderOptions(count: number): SelectOption[] {
133135
}
134136

135137
/**
136-
* SelectRenderable keeps its scroll offset private. The painted window is the
137-
* same centered slice Select computes from its public height and selection,
138-
* so callers can read it without reflecting into private fields.
138+
* Select keeps scroll offset private. The window is the same centered slice
139+
* it would paint, taken from the reserved slot count rather than a reflective
140+
* read. The ScrollBox viewport is what actually scrolls; this only names the
141+
* slice.
139142
*/
140-
function selectWindow(
141-
select: SelectRenderable,
143+
function listWindow(
144+
index: number,
142145
count: number,
143-
rowsPerItem: number,
146+
slots: number,
144147
): { offset: number; visible: number } {
145148
if (count === 0) return { offset: 0, visible: 0 };
146-
const linesPerItem = Math.max(1, rowsPerItem);
147-
const visible = Math.max(1, Math.floor(select.height / linesPerItem));
149+
const visible = Math.max(1, slots);
148150
const half = Math.floor(visible / 2);
149-
const index = select.getSelectedIndex();
150151
const offset = Math.max(
151152
0,
152153
Math.min(index - half, Math.max(0, count - visible)),
@@ -166,12 +167,17 @@ export function createOverlayList(
166167
const activeIndex = opts.activeIndex ?? 0;
167168
let options = placeholderOptions(count);
168169

170+
const rowSpan = (): number => Math.max(1, shape.rowsPerItem);
171+
169172
const build = (): SelectRenderable =>
170173
new SelectRenderable(ctx, {
171174
id: "shell-overlay-select",
172175
options,
173176
selectedIndex: activeIndex,
174-
height: shape.items * shape.rowsPerItem,
177+
// Full list, not the visible window. The viewport ScrollBox clips it and
178+
// owns the scrollbar; a window-sized select would scroll a second time
179+
// inside itself and the bar would never see overflow.
180+
height: Math.max(rowSpan(), Math.max(shape.items, count) * rowSpan()),
175181
width: "100%",
176182
flexShrink: 0,
177183
showDescription: shape.rowsPerItem > 1,
@@ -193,6 +199,72 @@ export function createOverlayList(
193199

194200
let select = build();
195201

202+
const viewport = new ScrollBoxRenderable(ctx, {
203+
id: "shell-overlay-list-scroll",
204+
width: "100%",
205+
height: Math.max(1, shape.items * rowSpan()),
206+
flexShrink: 0,
207+
// Arrow keys stay on the shell's selection handler. A focusable scrollbox
208+
// would page itself and steal the list.
209+
focusable: false,
210+
viewportCulling: true,
211+
scrollY: true,
212+
stickyScroll: false,
213+
rootOptions: { backgroundColor: UI.ground },
214+
contentOptions: { backgroundColor: UI.ground },
215+
viewportOptions: { backgroundColor: UI.ground },
216+
});
217+
viewport.horizontalScrollBar.visible = false;
218+
viewport.horizontalScrollBar.focusable = false;
219+
viewport.verticalScrollBar.focusable = false;
220+
// Vertical bar stays on the native auto rule: visible once the select is
221+
// taller than the slot window. Forcing it off is the transcript's choice,
222+
// not the overlay host's.
223+
const placed = viewport.content.onSizeChange;
224+
let revealing = false;
225+
226+
// Absolute marker at the active row. scrollChildIntoView on the whole select
227+
// is a no-op when the select is taller than the viewport (both edges outside).
228+
const anchor = new BoxRenderable(ctx, {
229+
id: "shell-overlay-active-anchor",
230+
position: "absolute",
231+
left: 0,
232+
width: 1,
233+
height: rowSpan(),
234+
top: 0,
235+
});
236+
viewport.add(select);
237+
viewport.add(anchor);
238+
239+
const reveal = (): void => {
240+
if (revealing) return;
241+
revealing = true;
242+
try {
243+
const row = rowSpan();
244+
const slots = Math.max(1, shape.items);
245+
viewport.height = slots * row;
246+
select.height = Math.max(row, Math.max(slots, count) * row);
247+
const index = select.getSelectedIndex();
248+
anchor.height = row;
249+
anchor.top = index * row;
250+
const { offset } = listWindow(index, count, slots);
251+
const y = count === 0 ? 0 : offset * row;
252+
viewport.scrollTop = y;
253+
if (count > 0 && anchor.y !== 0 && viewport.viewport.y !== 0) {
254+
viewport.scrollChildIntoView(anchor.id);
255+
}
256+
// Applied last: scrollTop clamps until the bar has a content size, and
257+
// that clamp rewrites translateY. The viewport paints this translation.
258+
viewport.content.translateY = -y;
259+
} finally {
260+
revealing = false;
261+
}
262+
};
263+
viewport.content.onSizeChange = () => {
264+
placed?.call(viewport.content);
265+
reveal();
266+
};
267+
196268
const reshape = (next: Partial<OverlayListShape>): void => {
197269
const merged = { ...shape, ...next };
198270
if (
@@ -205,16 +277,22 @@ export function createOverlayList(
205277
// (clamped to the new count) so a resize does not snap the cursor back.
206278
const current = select.getSelectedIndex();
207279
shape = merged;
280+
if (select.parent) viewport.remove(select);
208281
select = build();
282+
viewport.add(select, 0);
209283
select.setSelectedIndex(
210284
count === 0 ? 0 : Math.min(count - 1, Math.max(0, current)),
211285
);
286+
reveal();
212287
};
213288

289+
reveal();
290+
214291
return {
215292
get select() {
216293
return select;
217294
},
295+
viewport,
218296
get activeIndex() {
219297
return select.getSelectedIndex();
220298
},
@@ -225,14 +303,15 @@ export function createOverlayList(
225303
return shape.rowsPerItem;
226304
},
227305
get offset() {
228-
return selectWindow(select, count, shape.rowsPerItem).offset;
306+
return listWindow(select.getSelectedIndex(), count, shape.items).offset;
229307
},
230308
get count() {
231309
return count;
232310
},
233311
move(delta: number) {
234312
if (delta < 0) select.moveUp(-delta);
235313
else if (delta > 0) select.moveDown(delta);
314+
reveal();
236315
},
237316
page(dir: -1 | 1) {
238317
this.move(dir * (shape.items > 1 ? shape.items - 1 : 1));
@@ -242,20 +321,29 @@ export function createOverlayList(
242321
select.setSelectedIndex(
243322
Math.max(0, Math.min(count - 1, Math.floor(index))),
244323
);
324+
reveal();
245325
},
246326
setCount(next: number) {
247327
count = Math.max(0, Math.floor(next));
248328
options = placeholderOptions(count);
249329
select.options = options;
330+
reveal();
250331
},
251332
setHeight(items: number, rowsPerItem?: number) {
252333
reshape({
253334
items: Math.max(0, Math.floor(items)),
254335
...(rowsPerItem ? { rowsPerItem } : {}),
255336
});
256337
},
338+
scrollActiveIntoView() {
339+
reveal();
340+
},
257341
visibleRange() {
258-
const { offset, visible } = selectWindow(select, count, shape.rowsPerItem);
342+
const { offset, visible } = listWindow(
343+
select.getSelectedIndex(),
344+
count,
345+
shape.items,
346+
);
259347
return { start: offset, end: Math.min(count, offset + visible) };
260348
},
261349
};

0 commit comments

Comments
 (0)