mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
fix(beehive): keep help text clear of scrollbars
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user