Skip to content

Commit 0d30d39

Browse files
authored
ui: fit menu dropdowns to their contents (#92)
1 parent 0807139 commit 0d30d39

4 files changed

Lines changed: 41 additions & 9 deletions

File tree

src/ui/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -929,6 +929,7 @@ function AppShell({
929929
activeMenuItemIndex={activeMenuItemIndex}
930930
activeMenuSpec={activeMenuSpec}
931931
activeMenuWidth={activeMenuWidth}
932+
terminalWidth={terminal.width}
932933
theme={activeTheme}
933934
onHoverItem={setActiveMenuItemIndex}
934935
onSelectItem={(entry) => {

src/ui/components/chrome/MenuDropdown.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function MenuDropdown({
3939
activeMenuItemIndex,
4040
activeMenuSpec,
4141
activeMenuWidth,
42+
terminalWidth,
4243
theme,
4344
onHoverItem,
4445
onSelectItem,
@@ -48,17 +49,21 @@ export function MenuDropdown({
4849
activeMenuItemIndex: number;
4950
activeMenuSpec: MenuSpec;
5051
activeMenuWidth: number;
52+
terminalWidth: number;
5153
theme: AppTheme;
5254
onHoverItem: (index: number) => void;
5355
onSelectItem: (entry: Extract<MenuEntry, { kind: "item" }>) => void;
5456
}) {
57+
const clampedWidth = Math.min(activeMenuWidth, Math.max(22, terminalWidth - 2));
58+
const clampedLeft = Math.max(1, Math.min(activeMenuSpec.left, terminalWidth - clampedWidth - 1));
59+
5560
return (
5661
<box
5762
style={{
5863
position: "absolute",
5964
top: 1,
60-
left: activeMenuSpec.left,
61-
width: activeMenuWidth,
65+
left: clampedLeft,
66+
width: clampedWidth,
6267
height: activeMenuEntries.length + 2,
6368
zIndex: 40,
6469
border: true,
@@ -73,9 +78,7 @@ export function MenuDropdown({
7378
key={`${activeMenuId}:separator:${index}`}
7479
style={{ height: 1, paddingLeft: 1, paddingRight: 1 }}
7580
>
76-
<text fg={theme.border}>
77-
{padText("-".repeat(activeMenuWidth - 4), activeMenuWidth - 2)}
78-
</text>
81+
<text fg={theme.border}>{padText("-".repeat(clampedWidth - 4), clampedWidth - 2)}</text>
7982
</box>
8083
) : (
8184
<box
@@ -90,7 +93,7 @@ export function MenuDropdown({
9093
onMouseOver={() => onHoverItem(index)}
9194
onMouseUp={() => onSelectItem(entry)}
9295
>
93-
{renderMenuLine(entry, activeMenuWidth - 2, theme, activeMenuItemIndex === index)}
96+
{renderMenuLine(entry, clampedWidth - 2, theme, activeMenuItemIndex === index)}
9497
</box>
9598
),
9699
)}

src/ui/components/chrome/menu.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,11 @@ function menuEntryText(entry: Extract<MenuEntry, { kind: "item" }>) {
7070
return `${check}${entry.label}${hint}`;
7171
}
7272

73-
/** Compute a dropdown width that fits its longest entry with a small floor. */
73+
/** Compute a dropdown content width that fits its longest entry with a little breathing room. */
7474
export function menuWidth(entries: MenuEntry[]) {
7575
return Math.max(
76-
18,
77-
...entries.map((entry) => (entry.kind === "separator" ? 6 : menuEntryText(entry).length)),
76+
20,
77+
...entries.map((entry) => (entry.kind === "separator" ? 6 : menuEntryText(entry).length + 2)),
7878
);
7979
}
8080

test/ui-components.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,7 @@ describe("UI components", () => {
593593
activeMenuItemIndex={0}
594594
activeMenuSpec={{ id: "view", left: 2, width: 6, label: "View" }}
595595
activeMenuWidth={24}
596+
terminalWidth={30}
596597
theme={theme}
597598
onHoverItem={() => {}}
598599
onSelectItem={() => {}}
@@ -613,6 +614,33 @@ describe("UI components", () => {
613614
expect(frame).toContain("m");
614615
});
615616

617+
test("MenuDropdown repositions wide menus to stay inside the terminal", async () => {
618+
const theme = resolveTheme("midnight", null);
619+
const frame = await captureFrame(
620+
<MenuDropdown
621+
activeMenuId="agent"
622+
activeMenuEntries={[
623+
{ kind: "item", label: "Next annotated file", action: () => {} },
624+
{ kind: "item", label: "Previous annotated file", action: () => {} },
625+
]}
626+
activeMenuItemIndex={0}
627+
activeMenuSpec={{ id: "agent", left: 22, width: 7, label: "Agent" }}
628+
activeMenuWidth={30}
629+
terminalWidth={34}
630+
theme={theme}
631+
onHoverItem={() => {}}
632+
onSelectItem={() => {}}
633+
/>,
634+
34,
635+
6,
636+
);
637+
638+
expect(frame).toContain("Next annotated file");
639+
expect(frame).toContain("Previous annotated file");
640+
expect(frame).toContain("┐");
641+
expect(frame).toContain("┘");
642+
});
643+
616644
test("StatusBar renders filter mode affordance", async () => {
617645
const theme = resolveTheme("midnight", null);
618646
const frame = await captureFrame(

0 commit comments

Comments
 (0)