diff --git a/beehive/src/opentui-shell.ts b/beehive/src/opentui-shell.ts index 20e232ea1c..cd08b08397 100644 --- a/beehive/src/opentui-shell.ts +++ b/beehive/src/opentui-shell.ts @@ -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; } diff --git a/beehive/test/shell-opentui.test.ts b/beehive/test/shell-opentui.test.ts index 24267012ec..dd94b955e6 100644 --- a/beehive/test/shell-opentui.test.ts +++ b/beehive/test/shell-opentui.test.ts @@ -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, /[█▀�]/); + 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);