fix(beehive): keep help text clear of scrollbars

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-18 18:28:47 -04:00
parent 182ad339c0
commit 917294bb17
2 changed files with 59 additions and 12 deletions

View File

@@ -28,12 +28,20 @@ const wrappedRows = (content: string, width: number) => content.split('\n').redu
}, 0);
/** Cell geometry shared by the help surface and its memory-renderer regressions. */
export function helpDialogGeometry(viewportWidth: number, viewportHeight: number, bodyRows = wrappedRows(helpBody, Math.max(1, Math.min(62, viewportWidth - 4) - 6))) {
export function helpDialogGeometry(viewportWidth: number, viewportHeight: number, content = helpBody) {
const width = Math.max(4, Math.min(62, viewportWidth - 4));
const maxHeight = Math.max(4, viewportHeight - 4);
const innerWidth = Math.max(1, width - 6); // border plus two-cell inset on both sides
const maxBodyHeight = Math.max(0, maxHeight - helpTitleRows - helpActionRows - helpVerticalChrome);
// ScrollBox's vertical bar is a sibling of its viewport in OpenTUI 0.5.11.
// Decide overflow at the unreserved width, then wrap at the viewport width it
// will actually receive once that sibling has claimed its one cell.
const scrollbar = wrappedRows(content, innerWidth) > maxBodyHeight;
const bodyWidth = Math.max(1, innerWidth - (scrollbar ? 1 : 0));
const bodyRows = wrappedRows(content, bodyWidth);
const naturalHeight = helpTitleRows + bodyRows + helpActionRows + helpVerticalChrome;
const height = Math.min(naturalHeight, maxHeight);
return { width, maxHeight, naturalHeight, height, bodyHeight: Math.max(0, height - helpTitleRows - helpActionRows - helpVerticalChrome) };
return { width, maxHeight, naturalHeight, height, bodyHeight: Math.max(0, height - helpTitleRows - helpActionRows - helpVerticalChrome), bodyWidth, scrollbar };
}
/** OpenTUI owns rendering, layout, input dispatch, pointer dispatch, and lifecycle. */
@@ -57,11 +65,12 @@ export class OpenTuiShell {
private readonly footer: TextRenderable;
private helpScrim?: BoxRenderable;
private help?: BoxRenderable;
private helpBody?: ScrollBoxRenderable;
private helpBody?: BoxRenderable;
private helpBodyScrollable = false;
private helpTitle?: TextRenderable;
private helpActions?: TextRenderable;
constructor(readonly renderer: CliRenderer) {
constructor(readonly renderer: CliRenderer, private readonly helpContent = helpBody) {
this.done = new Promise(resolve => { this.finish = resolve; });
this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: palette.surface });
renderer.root.add(this.root);
@@ -165,20 +174,18 @@ export class OpenTuiShell {
private syncHelp() {
if (!this.state.helpOpen) {
if (this.helpScrim) { this.help?.destroyRecursively(); this.helpScrim.destroyRecursively(); this.helpScrim = undefined; this.help = undefined; this.helpBody = undefined; this.helpTitle = undefined; this.helpActions = undefined; }
if (this.helpScrim) { this.help?.destroyRecursively(); this.helpScrim.destroyRecursively(); this.helpScrim = undefined; this.help = undefined; this.helpBody = undefined; this.helpBodyScrollable = false; this.helpTitle = undefined; this.helpActions = undefined; }
return;
}
const geometry = helpDialogGeometry(this.renderer.width, this.renderer.height);
const geometry = helpDialogGeometry(this.renderer.width, this.renderer.height, this.helpContent);
if (!this.help) {
this.helpScrim = new BoxRenderable(this.renderer, { position: 'absolute', left: 0, top: 0, width: '100%', height: '100%', backgroundColor: palette.scrim });
this.renderer.root.add(this.helpScrim);
this.help = new BoxRenderable(this.renderer, { position: 'absolute', border: true, borderColor: palette.dialog, backgroundColor: palette.surface, flexDirection: 'column', paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 });
this.renderer.root.add(this.help);
this.helpTitle = new TextRenderable(this.renderer, { fg: palette.focus, height: helpTitleRows, content: 'HELP\n──────────────────────────────────────────────────────────' });
this.helpBody = new ScrollBoxRenderable(this.renderer, { flexGrow: 1, scrollY: true, backgroundColor: palette.surface });
this.helpBody.add(new TextRenderable(this.renderer, { fg: palette.text, width: '100%', wrapMode: 'word', content: helpBody }));
this.helpActions = new TextRenderable(this.renderer, { fg: palette.muted, height: helpActionRows, content: '──────────────────────────────────────────────────────────\nEnter / Esc close' });
this.help.add(this.helpTitle); this.help.add(this.helpBody); this.help.add(this.helpActions);
this.help.add(this.helpTitle); this.help.add(this.helpActions);
}
this.help.width = geometry.width; this.help.height = geometry.height;
this.help.left = Math.floor((this.renderer.width - geometry.width) / 2);
@@ -186,6 +193,21 @@ export class OpenTuiShell {
const rule = '─'.repeat(Math.max(1, geometry.width - 6));
this.helpTitle!.content = `HELP\n${rule}`;
this.helpActions!.content = `${rule}\nEnter / Esc close`;
if (!this.helpBody || this.helpBodyScrollable !== geometry.scrollbar) {
this.helpBody?.destroyRecursively();
this.helpBodyScrollable = geometry.scrollbar;
this.helpBody = geometry.scrollbar
? new ScrollBoxRenderable(this.renderer, { flexGrow: 1, scrollY: true, backgroundColor: palette.surface })
: new BoxRenderable(this.renderer, { flexGrow: 1, backgroundColor: palette.surface });
// OpenTUI places the bar in the ScrollBox root rather than reducing a
// child's percentage width. Use the geometry's reserved viewport width
// explicitly so the bar can only occupy the trailing blank column.
this.helpBody.add(new TextRenderable(this.renderer, { fg: palette.text, width: geometry.bodyWidth, wrapMode: 'word', content: this.helpContent }));
this.help.insertBefore(this.helpBody, this.helpActions);
// A constrained help body is the only modal control that can consume
// navigation keys, so give its ScrollBox OpenTUI focus when it appears.
if (this.helpBodyScrollable) this.helpBody.focus();
}
this.helpBody!.height = geometry.bodyHeight;
}

View File

@@ -60,16 +60,36 @@ test('memory renderer layers a full scrim and naturally sized help surface with
try {
ui.mockInput.pressKey('?'); await ui.renderOnce();
const geometry = helpDialogGeometry(120, 40);
assert.deepEqual(geometry, { width: 62, maxHeight: 36, naturalHeight: 14, height: 14, bodyHeight: 6 });
assert.deepEqual(geometry, { width: 62, maxHeight: 36, naturalHeight: 14, height: 14, bodyHeight: 6, bodyWidth: 56, scrollbar: false });
assert.equal(rgb(cell(ui, 0, 0).bg), '7,9,7'); // scrim covers cells outside the dialog
assert.equal(rgb(cell(ui, 30, 14).bg), '13,16,14'); // surface is above the scrim
assert.equal(rgb(cell(ui, 29, 13).fg), '156,164,155'); // centered dialog boundary
// A longer body is constrained to H - 4 while title/actions retain their fixed rows.
assert.deepEqual(helpDialogGeometry(50, 20, 12), { width: 46, maxHeight: 16, naturalHeight: 20, height: 16, bodyHeight: 8 });
const overflowing = Array.from({ length: 12 }, (_, row) => `overflow row ${row}`).join('\n');
assert.deepEqual(helpDialogGeometry(50, 20, overflowing), { width: 46, maxHeight: 16, naturalHeight: 20, height: 16, bodyHeight: 8, bodyWidth: 39, scrollbar: true });
assert.equal(helpDialogGeometry(50, 20).height, 15); // short help stays natural, below its max of 16
} finally { shell.close(); }
});
test('memory renderer reserves the overflow scrollbar cell outside help text and chrome', async () => {
const content = Array.from({ length: 12 }, (_, row) => `overflow row ${row} ends here`).join('\n');
const ui = await createTestRenderer({ width: 50, height: 20, exitOnCtrlC: false });
const shell = new OpenTuiShell(ui.renderer, content);
try {
ui.mockInput.pressKey('?'); await ui.renderOnce();
const frame = text(ui.captureCharFrame());
// The 46-cell dialog begins at x=2. Its 40-cell inner body has 39 text
// cells (x=5..43) and an independently reserved scrollbar at x=44.
assert.equal(frame[6]!.slice(5, 44), 'overflow row 0 ends here'.padEnd(39));
assert.match(frame[6]![44]!, /[█▀]/, `missing reserved scrollbar: ${frame[6]}`);
assert.equal(frame[5]![44], '─'); // title divider owns its complete final cell
assert.equal(frame[14]![44], '─'); // action divider is outside the scrollbar viewport
assert.equal(frame[6]![47], '│'); // dialog border remains untouched
ui.mockInput.pressArrow('down'); await ui.renderOnce();
assert.match(text(ui.captureCharFrame())[6]!, /overflow row [1-9]/, 'the constrained body remains scrollable');
} finally { shell.close(); }
});
test('memory renderer preserves the 50x20 narrow shell, help, keyboard, and pointer activation', async () => {
const ui = await createTestRenderer({ width: 50, height: 20, exitOnCtrlC: false });
const shell = new OpenTuiShell(ui.renderer);
@@ -82,7 +102,12 @@ test('memory renderer preserves the 50x20 narrow shell, help, keyboard, and poin
assert.equal(shell.state.activeSection, 2); assert.equal(shell.state.narrowPane, 'list');
ui.mockInput.pressEnter(); await ui.renderOnce(); assert.equal(shell.state.narrowPane, 'detail');
ui.mockInput.pressEscape(); await waitForEscape(); await ui.renderOnce(); assert.equal(shell.state.narrowPane, 'list');
ui.mockInput.pressKey('?'); await ui.renderOnce(); frame = ui.captureCharFrame(); assert.ok(frame.includes('HELP')); assert.ok(frame.includes('Shift-Tab'));
ui.mockInput.pressKey('?'); await ui.renderOnce(); frame = ui.captureCharFrame();
assert.ok(frame.includes('HELP')); assert.ok(frame.includes('Shift-Tab'));
// The fitting body is a plain box: every wrapped line and its last cell are
// visible, with neither OpenTUI's scrollbar nor a replacement glyph.
assert.doesNotMatch(frame, /[█▀<E29688>]/);
for (const line of ['← → focus a destination', 'Enter activate the focused destination', 'Tab / Shift-Tab move between visible', 'regions', 'Esc return to the active header control', '? / Enter / Esc close help', 'q quit Beehive']) assert.ok(frame.includes(line), `missing complete help line: ${line}`);
ui.mockInput.pressEscape(); await waitForEscape(); await ui.renderOnce(); assert.ok(!ui.captureCharFrame().includes('Shift-Tab'));
ui.resize(49, 19); await ui.renderOnce(); assert.ok(ui.captureCharFrame().includes(footerGuides.minimum));
ui.mockInput.pressKey('q'); await ui.renderOnce(); assert.equal(shell.state.belowMinimum, true);