diff --git a/src/ui/View.tsx b/src/ui/View.tsx index fd73c40..3b1de9a 100644 --- a/src/ui/View.tsx +++ b/src/ui/View.tsx @@ -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"; @@ -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 diff --git a/src/ui/components/ShortcutFooter.tsx b/src/ui/components/ShortcutFooter.tsx index a5a1102..ce11d57 100644 --- a/src/ui/components/ShortcutFooter.tsx +++ b/src/ui/components/ShortcutFooter.tsx @@ -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[]; @@ -15,13 +19,7 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps {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 = []; @@ -49,20 +47,3 @@ export function ShortcutFooter({ shortcuts, showShortcuts }: ShortcutFooterProps ); } - -// 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); -} diff --git a/src/ui/layout.test.ts b/src/ui/layout.test.ts new file mode 100644 index 0000000..879bbbc --- /dev/null +++ b/src/ui/layout.test.ts @@ -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); + }); +}); diff --git a/src/ui/layout.ts b/src/ui/layout.ts new file mode 100644 index 0000000..6b9a806 --- /dev/null +++ b/src/ui/layout.ts @@ -0,0 +1,130 @@ +// Pure layout math for the TUI. Kept free of any ink imports so it can be +// unit-tested directly. The heights here describe the number of terminal rows +// each component renders, and the sum of all components for a given page must +// equal `terminalHeight` so no rows are wasted at the bottom. + +/** "Curse v{version}" + "Config: {filename}" — rendered by `View`. */ +export const VIEW_HEADER_HEIGHT = 2; + +/** ProcessTable border-and-header overhead: top border + header row + bottom border. */ +export const PROCESS_TABLE_OVERHEAD = 3; + +/** "Logs({name})[tail]" title bar in the log view. */ +export const LOG_PAGE_TITLE_HEIGHT = 1; + +/** Search bar row, when search mode is active. */ +export const SEARCH_BAR_HEIGHT = 1; + +/** Floor for the log preview on the main page so it stays usable. */ +export const MIN_LOG_PREVIEW_HEIGHT = 4; + +/** + * Number of columns the shortcut footer breaks into based on terminal width. + * Mirrors the layout used inside `ShortcutFooter`. + */ +export function getShortcutFooterColumns(terminalWidth: number): number { + if (terminalWidth < 80) return 1; + if (terminalWidth < 120) return 2; + if (terminalWidth < 160) return 3; + return 4; +} + +/** + * Number of rows the shortcut footer actually renders. + * + * When `showShortcuts` is false the footer still renders a single + * "? for shortcuts" row — historically this returned 0 here, which was the + * source of the wasted-row gap at the bottom of the terminal. + */ +export function getShortcutFooterHeight( + shortcutsCount: number, + terminalWidth: number, + showShortcuts: boolean, +): number { + if (!showShortcuts) return 1; + const numColumns = getShortcutFooterColumns(terminalWidth); + return Math.ceil(shortcutsCount / numColumns); +} + +export interface MainPageLayoutInput { + terminalHeight: number; + terminalWidth: number; + processCount: number; + shortcutsCount: number; + showShortcuts: boolean; +} + +export interface MainPageLayout { + headerHeight: number; + processTableHeight: number; + logPreviewHeight: number; + shortcutFooterHeight: number; +} + +/** + * Compute the row breakdown for the MainPage in normal display mode. + * + * Invariant (when terminalHeight is large enough): the four returned heights + * sum to exactly `terminalHeight`, leaving no empty rows at the bottom. + */ +export function computeMainPageLayout(input: MainPageLayoutInput): MainPageLayout { + const headerHeight = VIEW_HEADER_HEIGHT; + const processTableHeight = input.processCount + PROCESS_TABLE_OVERHEAD; + const shortcutFooterHeight = getShortcutFooterHeight( + input.shortcutsCount, + input.terminalWidth, + input.showShortcuts, + ); + const available = input.terminalHeight - headerHeight - processTableHeight - shortcutFooterHeight; + const logPreviewHeight = Math.max(MIN_LOG_PREVIEW_HEIGHT, available); + + return { + headerHeight, + processTableHeight, + logPreviewHeight, + shortcutFooterHeight, + }; +} + +export interface LogPageLayoutInput { + terminalHeight: number; + terminalWidth: number; + shortcutsCount: number; + showShortcuts: boolean; + isSearchMode: boolean; +} + +export interface LogPageLayout { + headerHeight: number; + titleHeight: number; + searchBarHeight: number; + logTableHeight: number; + shortcutFooterHeight: number; +} + +/** + * Compute the row breakdown for the LogPage. + * + * Invariant: the five returned heights sum to exactly `terminalHeight`, + * leaving no empty rows at the bottom. + */ +export function computeLogPageLayout(input: LogPageLayoutInput): LogPageLayout { + const headerHeight = VIEW_HEADER_HEIGHT; + const titleHeight = LOG_PAGE_TITLE_HEIGHT; + const searchBarHeight = input.isSearchMode ? SEARCH_BAR_HEIGHT : 0; + const shortcutFooterHeight = getShortcutFooterHeight( + input.shortcutsCount, + input.terminalWidth, + input.showShortcuts, + ); + const logTableHeight = + input.terminalHeight - headerHeight - titleHeight - searchBarHeight - shortcutFooterHeight; + + return { + headerHeight, + titleHeight, + searchBarHeight, + logTableHeight, + shortcutFooterHeight, + }; +} diff --git a/src/ui/views/LogPage.tsx b/src/ui/views/LogPage.tsx index 8082091..8cd53bd 100644 --- a/src/ui/views/LogPage.tsx +++ b/src/ui/views/LogPage.tsx @@ -9,7 +9,8 @@ import { useProgramState, ProgramStatus } from "../../hooks/useProgramState"; import { useRenderTick } from "../../hooks/useRenderTick"; import { Colors } from "../../lib/Colors"; import { preprocessLog } from "../../lib/LogProcessing"; -import { ShortcutFooter, getShortcutFooterHeight } from "../components/ShortcutFooter"; +import { ShortcutFooter } from "../components/ShortcutFooter"; +import { computeLogPageLayout } from "../layout"; function LogTable(props: { height: number; @@ -820,10 +821,13 @@ export function LogPage() { )}