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
5 changes: 4 additions & 1 deletion src/ui/View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useProcessManager } from "../hooks/useProcessManager";
import { ProgramStateProvider, useProgramState, ProgramStatus } from "../hooks/useProgramState";
import { Colors } from "../lib/Colors";
import type { CurseConfig } from "../parser";
import { MIN_LOG_PREVIEW_HEIGHT, PROCESS_TABLE_OVERHEAD, VIEW_HEADER_HEIGHT } from "./layout";
import { LogPage } from "./views/LogPage";
import { MainPage, type DisplayMode } from "./views/MainPage";

Expand All @@ -32,7 +33,9 @@ function View(props: { config: CurseConfig }) {

const terminalHeight = stdout?.rows ?? 24;
const processCount = processesRef.current.length;
const normalMinHeight = processCount + 13; // header(4) + table(N+3) + logPreview(5) + footer(1)
// header + processTable(N + overhead) + minLogPreview + collapsed footer (1 row)
const normalMinHeight =
VIEW_HEADER_HEIGHT + processCount + PROCESS_TABLE_OVERHEAD + MIN_LOG_PREVIEW_HEIGHT + 1;
const compactMinHeight = processCount + 1;
const displayMode: DisplayMode =
terminalHeight < compactMinHeight
Expand Down
29 changes: 5 additions & 24 deletions src/ui/components/ShortcutFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Box, Text, useStdout } from "ink";

import { Colors } from "../../lib/Colors";
import { getShortcutFooterColumns, getShortcutFooterHeight } from "../layout";

// Re-export so existing callers can keep importing it from this module.
export { getShortcutFooterHeight };

interface ShortcutFooterProps {
shortcuts: string[];
Expand All @@ -15,13 +19,7 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps
<Box marginLeft={1} flexDirection="row">
{showShortcuts ? (
(() => {
// Calculate number of columns based on terminal width
let numColumns;
if (terminalWidth < 80) numColumns = 1;
else if (terminalWidth < 120) numColumns = 2;
else if (terminalWidth < 160) numColumns = 3;
else numColumns = 4;

const numColumns = getShortcutFooterColumns(terminalWidth);
const itemsPerColumn = Math.ceil(shortcuts.length / numColumns);

const columns = [];
Expand Down Expand Up @@ -49,20 +47,3 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps
</Box>
);
}

// Helper function to calculate the height taken by shortcuts
export function getShortcutFooterHeight(
shortcutsCount: number,
terminalWidth: number,
showShortcuts: boolean,
): number {
if (!showShortcuts) return 0;

let numColumns;
if (terminalWidth < 80) numColumns = 1;
else if (terminalWidth < 120) numColumns = 2;
else if (terminalWidth < 160) numColumns = 3;
else numColumns = 4;

return Math.ceil(shortcutsCount / numColumns);
}
258 changes: 258 additions & 0 deletions src/ui/layout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { describe, it, expect } from "bun:test";

import {
computeLogPageLayout,
computeMainPageLayout,
getShortcutFooterColumns,
getShortcutFooterHeight,
LOG_PAGE_TITLE_HEIGHT,
MIN_LOG_PREVIEW_HEIGHT,
PROCESS_TABLE_OVERHEAD,
SEARCH_BAR_HEIGHT,
VIEW_HEADER_HEIGHT,
} from "./layout";

describe("getShortcutFooterColumns", () => {
it("uses 1 column when terminalWidth < 80", () => {
expect(getShortcutFooterColumns(40)).toBe(1);
expect(getShortcutFooterColumns(79)).toBe(1);
});

it("uses 2 columns for 80 <= width < 120", () => {
expect(getShortcutFooterColumns(80)).toBe(2);
expect(getShortcutFooterColumns(119)).toBe(2);
});

it("uses 3 columns for 120 <= width < 160", () => {
expect(getShortcutFooterColumns(120)).toBe(3);
expect(getShortcutFooterColumns(159)).toBe(3);
});

it("uses 4 columns for width >= 160", () => {
expect(getShortcutFooterColumns(160)).toBe(4);
expect(getShortcutFooterColumns(400)).toBe(4);
});
});

describe("getShortcutFooterHeight", () => {
// Regression: getShortcutFooterHeight previously returned 0 when shortcuts
// were collapsed, but the footer always renders the "? for shortcuts" row.
// That mismatch wasted 1 terminal row at the bottom of the screen.
it("returns 1 when shortcuts are hidden (the '? for shortcuts' row)", () => {
expect(getShortcutFooterHeight(9, 80, false)).toBe(1);
expect(getShortcutFooterHeight(0, 80, false)).toBe(1);
expect(getShortcutFooterHeight(20, 200, false)).toBe(1);
});

it("returns ceil(count / columns) when shortcuts are shown", () => {
// 80 width => 2 columns, 9 shortcuts => ceil(9 / 2) = 5
expect(getShortcutFooterHeight(9, 80, true)).toBe(5);
// 120 width => 3 columns, 9 shortcuts => 3
expect(getShortcutFooterHeight(9, 120, true)).toBe(3);
// 160 width => 4 columns, 9 shortcuts => 3
expect(getShortcutFooterHeight(9, 160, true)).toBe(3);
// 60 width => 1 column, 12 shortcuts => 12
expect(getShortcutFooterHeight(12, 60, true)).toBe(12);
});
});

describe("computeMainPageLayout", () => {
// Regression: previously the four heights summed to terminalHeight - 3,
// leaving three wasted rows at the bottom of the screen below the
// shortcut footer. The four sources of the gap were:
// - headerHeight hardcoded to 4 (actual View header is 2 rows)
// - an extra `- 1` safety buffer in the log preview height
// - getShortcutFooterHeight returning 0 when collapsed (vs. 1 actual)
// - LogPage's separate `6` constant being similarly miscounted
it("heights sum to terminalHeight when shortcuts are collapsed", () => {
const layout = computeMainPageLayout({
terminalHeight: 40,
terminalWidth: 120,
processCount: 5,
shortcutsCount: 9,
showShortcuts: false,
});
const total =
layout.headerHeight +
layout.processTableHeight +
layout.logPreviewHeight +
layout.shortcutFooterHeight;
expect(total).toBe(40);
});

it("heights sum to terminalHeight when shortcuts are expanded", () => {
const layout = computeMainPageLayout({
terminalHeight: 40,
terminalWidth: 120,
processCount: 5,
shortcutsCount: 9,
showShortcuts: true,
});
const total =
layout.headerHeight +
layout.processTableHeight +
layout.logPreviewHeight +
layout.shortcutFooterHeight;
expect(total).toBe(40);
});

it("uses correct fixed heights", () => {
const layout = computeMainPageLayout({
terminalHeight: 40,
terminalWidth: 120,
processCount: 5,
shortcutsCount: 9,
showShortcuts: false,
});
expect(layout.headerHeight).toBe(VIEW_HEADER_HEIGHT);
expect(layout.processTableHeight).toBe(5 + PROCESS_TABLE_OVERHEAD);
expect(layout.shortcutFooterHeight).toBe(1);
});

it("scales with process count and gives the rest to the log preview", () => {
const a = computeMainPageLayout({
terminalHeight: 40,
terminalWidth: 120,
processCount: 3,
shortcutsCount: 9,
showShortcuts: false,
});
const b = computeMainPageLayout({
terminalHeight: 40,
terminalWidth: 120,
processCount: 7,
shortcutsCount: 9,
showShortcuts: false,
});
expect(a.logPreviewHeight - b.logPreviewHeight).toBe(4);
});

it("expanding shortcuts shrinks the log preview by the same number of rows", () => {
const collapsed = computeMainPageLayout({
terminalHeight: 40,
terminalWidth: 120,
processCount: 3,
shortcutsCount: 9,
showShortcuts: false,
});
const expanded = computeMainPageLayout({
terminalHeight: 40,
terminalWidth: 120,
processCount: 3,
shortcutsCount: 9,
showShortcuts: true,
});
const footerDelta = expanded.shortcutFooterHeight - collapsed.shortcutFooterHeight;
expect(collapsed.logPreviewHeight - expanded.logPreviewHeight).toBe(footerDelta);
});

it("clamps the log preview to the minimum when the terminal is tight", () => {
const layout = computeMainPageLayout({
terminalHeight: 10, // very small
terminalWidth: 120,
processCount: 5,
shortcutsCount: 9,
showShortcuts: false,
});
expect(layout.logPreviewHeight).toBe(MIN_LOG_PREVIEW_HEIGHT);
});
});

describe("computeLogPageLayout", () => {
it("heights sum to terminalHeight when shortcuts are collapsed and not searching", () => {
const layout = computeLogPageLayout({
terminalHeight: 40,
terminalWidth: 120,
shortcutsCount: 12,
showShortcuts: false,
isSearchMode: false,
});
const total =
layout.headerHeight +
layout.titleHeight +
layout.searchBarHeight +
layout.logTableHeight +
layout.shortcutFooterHeight;
expect(total).toBe(40);
});

it("heights sum to terminalHeight when shortcuts are expanded", () => {
const layout = computeLogPageLayout({
terminalHeight: 40,
terminalWidth: 120,
shortcutsCount: 12,
showShortcuts: true,
isSearchMode: false,
});
const total =
layout.headerHeight +
layout.titleHeight +
layout.searchBarHeight +
layout.logTableHeight +
layout.shortcutFooterHeight;
expect(total).toBe(40);
});

it("heights sum to terminalHeight in search mode", () => {
const layout = computeLogPageLayout({
terminalHeight: 40,
terminalWidth: 120,
shortcutsCount: 12,
showShortcuts: false,
isSearchMode: true,
});
const total =
layout.headerHeight +
layout.titleHeight +
layout.searchBarHeight +
layout.logTableHeight +
layout.shortcutFooterHeight;
expect(total).toBe(40);
expect(layout.searchBarHeight).toBe(SEARCH_BAR_HEIGHT);
});

it("uses correct fixed heights", () => {
const layout = computeLogPageLayout({
terminalHeight: 40,
terminalWidth: 120,
shortcutsCount: 12,
showShortcuts: false,
isSearchMode: false,
});
expect(layout.headerHeight).toBe(VIEW_HEADER_HEIGHT);
expect(layout.titleHeight).toBe(LOG_PAGE_TITLE_HEIGHT);
expect(layout.searchBarHeight).toBe(0);
expect(layout.shortcutFooterHeight).toBe(1);
});

it("entering search mode reduces the log table by exactly one row", () => {
const inputs = {
terminalHeight: 40,
terminalWidth: 120,
shortcutsCount: 12,
showShortcuts: false,
};
const noSearch = computeLogPageLayout({ ...inputs, isSearchMode: false });
const searching = computeLogPageLayout({ ...inputs, isSearchMode: true });
expect(noSearch.logTableHeight - searching.logTableHeight).toBe(1);
});

it("expanding shortcuts shrinks the log table by the same number of rows", () => {
const collapsed = computeLogPageLayout({
terminalHeight: 40,
terminalWidth: 120,
shortcutsCount: 12,
showShortcuts: false,
isSearchMode: false,
});
const expanded = computeLogPageLayout({
terminalHeight: 40,
terminalWidth: 120,
shortcutsCount: 12,
showShortcuts: true,
isSearchMode: false,
});
const footerDelta = expanded.shortcutFooterHeight - collapsed.shortcutFooterHeight;
expect(collapsed.logTableHeight - expanded.logTableHeight).toBe(footerDelta);
});
});
Loading
Loading