Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 46 additions & 7 deletions src/tui-opentui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
isLanding,
paintLanding,
} from "./shell"
import { openOperatorOverlay } from "./overlays"
import { makeOperatorQuestion, openOperatorOverlay } from "./overlays"
import {
LANDING_HINTS,
LANDING_SUGGESTIONS,
Expand Down Expand Up @@ -328,7 +328,7 @@ describe("landing screen", () => {
)
})

test("an overlay covers the landing instead of moving it", async () => {
test("an overlay covers the landing, sliding it only as far as its content needs", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
Expand All @@ -345,15 +345,25 @@ describe("landing screen", () => {
before.findIndex((row) => row.includes(text)),
)
expect(was.every((index) => index > 0)).toBe(true)
// The anchors are listed top to bottom, so their positions climb
// together before the overlay opens.
expect(was).toEqual([...was].sort((a, b) => a - b))

openOperatorOverlay(shell)
await settle(h)
const after = rows(h)
// Every landing anchor is on the row it was on: the overlay covers
// the composition, it does not push it around.
expect(
anchors.map((text) => after.findIndex((row) => row.includes(text))),
).toEqual(was)
// Every landing anchor is still on screen and in the same relative
// order: the overlay is not letting the composition it covers spill
// off the viewport, overlap itself, or reshuffle. It may still
// slide the composition (up or down a little, as the mark re-grids
// for its new tier) when its own content needs more room than the
// even top/bottom split would otherwise leave it.
const nowAt = anchors.map((text) =>
after.findIndex((row) => row.includes(text)),
)
expect(nowAt.every((index) => index > 0)).toBe(true)
expect(nowAt).toEqual([...nowAt].sort((a, b) => a - b))
expect(new Set(nowAt).size).toBe(nowAt.length)
expect(h.captureCharFrame()).toContain("operator")
} finally {
shell.dispose()
Expand All @@ -363,6 +373,35 @@ describe("landing screen", () => {
)
})

// A question with more choices than the even top/bottom split would leave
// room for used to get its list starved down to whatever that split
// happened to allow — as little as one or two choices — because the float
// only asked the split for one choice row of headroom. It now asks for the
// overlay's real, already fraction-capped content height, so a terminal
// tall enough for that content shows every choice without scrolling.
test("a landing overlay with many choices shows them all when there is room", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 100, rows: 36 },
wireKeys: false,
run: "idle",
})
try {
openOperatorOverlay(shell)
await settle(h)
const frame = h.captureCharFrame()
for (const choice of makeOperatorQuestion().choices) {
expect(frame).toContain(choice)
}
} finally {
shell.dispose()
}
},
{ width: 100, height: 36 },
)
})

test("a short or narrow terminal shrinks the mark, never the prompt box", async () => {
for (const size of [
{ width: 100, height: 30 },
Expand Down
18 changes: 9 additions & 9 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,8 +1515,9 @@ function meterEquals(a: CostContextMeter | null, b: CostContextMeter | null): bo
* A floated overlay is clipped to the rows above the box so it never covers the
* thing the operator types into. Losing the tail of a long body to that clip is
* survivable; losing every choice is not, because then the surface cannot be
* answered. So the box slides down just far enough to keep the overlay's chrome
* and one choice on screen, and the starters below it pay for the move.
* answered. So the box slides down just far enough to keep the overlay's full,
* already fraction-capped height on screen, and the starters below it pay for
* the move.
*/
function landingSplitFor(
landingRows: number,
Expand Down Expand Up @@ -1572,14 +1573,13 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void {
const bag = internals.get(shell)
const landing = bag?.landing ?? null
const landingRows = transcriptH - padH - bottomPadH + (landing === null ? 0 : overlayH)
// The resolver already sized overlayH to the overlay's real content (list
// included) and capped it against the fraction/floor limits, so it is the
// correct minimum to ask the landing split to make room for — asking for
// less (e.g. just enough for one choice row) starves the list underneath
// the title down to nearly nothing once floatOverlayHost pins the host to it.
const split =
landing === null
? null
: landingSplitFor(
landingRows,
overlayH > 0 ? overlayHostRows(shell, shell.overlayBodyLines.length, 1) : 0,
padH,
)
landing === null ? null : landingSplitFor(landingRows, overlayH, padH)
if (bag !== undefined && landing !== null && split !== null) {
landing.above.box.height = Math.max(1, split.above)
// A new zone can seat a different tier, and a tier is a different grid, so
Expand Down
Loading