diff --git a/src/components/logo-art.ts b/src/components/logo-art.ts index d52bd204a..e84bba28e 100644 --- a/src/components/logo-art.ts +++ b/src/components/logo-art.ts @@ -33,17 +33,37 @@ export function truncatePath(path: string, maxWidth: number): string { return `${path.slice(0, Math.max(0, maxWidth - 3))}...` } +// The pepper mark columns, shared by the full word-art and the compact +// variant so the two can't drift apart. +const PEPPER_ROWS = [" █▀", " ███", "▄ ▄███", "▀████▀"] + +// The "kimchi" word-art columns that follow the pepper in the full logo. +const WORD_ROWS = [ + " █ █ ▀█▀ █▄ ▄█ ▄▀▀ █ █ ▀█▀", + " █▀▄ █ █ ▀ █ █ █▀▀█ █", + " █ █ █ █ █ █▄▄ █ █ █", + " ▀ ▀ ▀▀▀ ▀ ▀ ▀▀ ▀ ▀ ▀▀▀", +] + export function buildLogoLines(theme: Theme): string[] { const L = theme.getFgAnsi("accent") const G = theme.getFgAnsi("bashMode") return [ - `${G} █▀${RST_FG} ${L}█ █ ▀█▀ █▄ ▄█ ▄▀▀ █ █ ▀█▀${RST_FG}`, - `${L} ███ █▀▄ █ █ ▀ █ █ █▀▀█ █${RST_FG}`, - `${L}▄ ▄███ █ █ █ █ █ █▄▄ █ █ █${RST_FG}`, - `${L}▀████▀ ▀ ▀ ▀▀▀ ▀ ▀ ▀▀ ▀ ▀ ▀▀▀${RST_FG}`, + `${G}${PEPPER_ROWS[0]}${RST_FG}${L}${WORD_ROWS[0]}${RST_FG}`, + ...PEPPER_ROWS.slice(1).map((pepper, i) => `${L}${pepper}${WORD_ROWS[i + 1]}${RST_FG}`), ] } +/** + * Pepper-only variant of the logo art for narrow terminals where the full + * word-art would leave no room for the rest of the header. + */ +export function buildCompactLogoLines(theme: Theme): string[] { + const L = theme.getFgAnsi("accent") + const G = theme.getFgAnsi("bashMode") + return [`${G}${PEPPER_ROWS[0]}${RST_FG}`, ...PEPPER_ROWS.slice(1).map((pepper) => `${L}${pepper}${RST_FG}`)] +} + export function buildInfoLines( theme: Theme, { folderMaxWidth, getBranch }: { folderMaxWidth?: number; getBranch?(): string | undefined } = {}, diff --git a/src/components/logo-narrow.test.ts b/src/components/logo-narrow.test.ts index 73f3900a6..a078c8d71 100644 --- a/src/components/logo-narrow.test.ts +++ b/src/components/logo-narrow.test.ts @@ -8,7 +8,7 @@ vi.mock("../utils.js", () => ({ getGitBranch: () => "main", })) -const { LogoHeader } = await import("./logo.js") +const { LogoHeader, computeHeaderLayout } = await import("./logo.js") function createMockTheme(): Theme { const COLOR_CODE: Record = { @@ -31,13 +31,24 @@ function createMockTheme(): Theme { } as unknown as Theme } +// Layout constants from src/components/logo.ts. The header is two cells: left +// holds logo + info lines centered in `span = logoWidth + 2*gutter`; right +// holds the tips in `rightColWidth` with one CELL_PAD space each side. +const CHROME = 3 +const CELL_PAD = 1 +const RIGHT_MIN = 12 +const MIN_GUTTER = 1 +const COMPACT_LOGO_WIDTH = 7 +const FULL_LOGO_WIDTH = 36 +const COMPACT_BREAKPOINT = FULL_LOGO_WIDTH + CHROME + 2 * CELL_PAD + RIGHT_MIN + 2 * MIN_GUTTER // 55 + describe("LogoHeader — narrow terminals", () => { // Regression: on terminals narrower than the fixed-width logo column the // header used to emit 40-cell body lines regardless of `width`, which // crashes pi-tui's doRender with "Rendered line N exceeds terminal width". // Every emitted line must fit the requested width, including absurd sizes // like a 1- or 2-column terminal. - for (const width of [1, 2, 3, 4, 5, 8, 10, 16, 20, 30, 39, 40]) { + for (const width of [1, 2, 3, 4, 5, 8, 10, 16, 20, 30, 39, 40, 46, 50, 54, 60, 80, 109, 120]) { it(`never emits a line wider than ${width}`, () => { const header = new LogoHeader(createMockTheme()) const lines = header.render(width) @@ -47,4 +58,161 @@ describe("LogoHeader — narrow terminals", () => { } }) } + + // The full word-art (36 cols) needs at least MIN_GUTTER on each side of + // the logo plus RIGHT_MIN for the tips to remain readable; below that + // threshold the header switches to the pepper-only mark. + describe("variant switch", () => { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping + const strip = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "") + + for (const width of [11, 20, 30, 39, 46, 50, 54]) { + it(`uses the pepper-only mark at width ${width}`, () => { + const header = new LogoHeader(createMockTheme()) + const lines = header.render(width).map(strip) + const pepperRow = lines.find((line) => line.includes("███")) + expect(pepperRow).toBeDefined() + // At non-degenerate widths the box fits without truncation, so the + // row still ends at the right border. Below ~20 cols pi-tui's + // tail-truncation may crop that border and the assertion is skipped. + if (width >= 20) { + expect(pepperRow?.trimEnd().endsWith("│")).toBe(true) + } + // Word-art fragment absent → compact art selected. + expect(lines.join("\n")).not.toContain("▀█▀") + }) + } + + for (const width of [55, 60, 80, 109, 120]) { + it(`uses the full word-art at width ${width}`, () => { + const header = new LogoHeader(createMockTheme()) + const text = header.render(width).map(strip).join("\n") + expect(text).toContain("▀█▀") + expect(text).toContain("███") + }) + } + + it("switches back to the full logo after invalidate", () => { + const header = new LogoHeader(createMockTheme()) + header.invalidate() + const narrow = header.render(30).map(strip).join("\n") + expect(narrow).not.toContain("▀█▀") + const wide = header.render(60).map(strip).join("\n") + expect(wide).toContain("▀█▀") + }) + }) + + // Layout invariants. The production allocation function must hand each + // variant the expected left-cell span and right-column width. + describe("allocation matches the spec at anchor widths", () => { + const anchors: Array<{ width: number; span: number; right: number }> = [ + { width: 11, span: 7, right: 1 }, + { width: 20, span: 7, right: 8 }, + { width: 26, span: 9, right: 12 }, + { width: 30, span: 13, right: 12 }, + { width: 38, span: 21, right: 12 }, + { width: 46, span: 27, right: 14 }, + { width: 54, span: 27, right: 22 }, + { width: 55, span: 38, right: 12 }, + { width: 60, span: 42, right: 13 }, + { width: 69, span: 52, right: 12 }, + { width: 80, span: 56, right: 19 }, + { width: 96, span: 56, right: 35 }, + { width: 109, span: 56, right: 48 }, + { width: 120, span: 56, right: 59 }, + { width: 126, span: 56, right: 65 }, + { width: 150, span: 56, right: 89 }, + { width: 200, span: 56, right: 139 }, + ] + + for (const { width, span, right } of anchors) { + it(`at width ${width}: span=${span}, rightCol=${right}`, () => { + const logoWidth = width < COMPACT_BREAKPOINT ? COMPACT_LOGO_WIDTH : FULL_LOGO_WIDTH + const layout = computeHeaderLayout(width, logoWidth) + expect(layout.span).toBe(span) + expect(layout.rightColWidth).toBe(right) + }) + } + }) + + // Monotonicity: as width grows, neither the divider nor the right column + // should lurch backwards by more than 1 col at a time. The only allowed + // jump is the single regime switch (compact→full). + describe("monotonic right column as width grows", () => { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping + const strip = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "") + + const rightSeg = (lines: string[]): number => { + const row = lines.find((l) => strip(l).split("│").length === 4) + return row ? (strip(row).split("│")[2]?.length ?? 0) : 0 + } + + it("right column grows monotonically in compact regime (width 26..54)", () => { + let prev = -Infinity + for (let w = 26; w <= 54; w++) { + const lines = new LogoHeader(createMockTheme()).render(w).map(strip) + const rightLen = rightSeg(lines) + // Right cell is CELL_PAD + rightColWidth + CELL_PAD. Allow a + // 1-col notch when gutters step up. + expect(rightLen).toBeGreaterThanOrEqual(prev - 1) + prev = rightLen + } + }) + + it("right column grows monotonically in full regime (width 55..200)", () => { + let prev = -Infinity + for (let w = 55; w <= 200; w++) { + const lines = new LogoHeader(createMockTheme()).render(w).map(strip) + const rightLen = rightSeg(lines) + expect(rightLen).toBeGreaterThanOrEqual(prev - 1) + prev = rightLen + } + }) + + it("logo cell is capped at the previous max of 56 cols in wide regime", () => { + for (const width of [126, 150, 200]) { + const logoWidth = width < COMPACT_BREAKPOINT ? COMPACT_LOGO_WIDTH : FULL_LOGO_WIDTH + const { span } = computeHeaderLayout(width, logoWidth) + expect(span).toBeLessThanOrEqual(56) + } + }) + }) + + // Centering: the pepper mark and info lines are always horizontally + // centered within the left cell span (the previous alignment fix). + describe("centering", () => { + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping + const strip = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, "") + const leftSegment = (row: string) => row.split("│")[1] + const findRow = (lines: string[], needle: string): string => { + const row = lines.find((l) => l.includes(needle)) + expect(row, `row containing ${needle}`).toBeDefined() + return row ?? "" + } + const assertCentered = (row: string) => { + const seg = leftSegment(row) + const lead = seg.match(/^ */)?.[0].length ?? 0 + const trail = seg.match(/ *$/)?.[0].length ?? 0 + expect(Math.abs(lead - trail)).toBeLessThanOrEqual(1) + } + + for (const width of [30, 38, 46]) { + it(`centers pepper + info lines within the left span at width ${width}`, () => { + const lines = new LogoHeader(createMockTheme()).render(width).map(strip) + assertCentered(findRow(lines, "▄ ▄███")) + assertCentered(findRow(lines, "v1.0.0")) + assertCentered(findRow(lines, "main")) + }) + } + }) + + // Vertical padding is generous and consistent at common widths. + describe("vertical padding", () => { + it("keeps the top and bottom borders intact and renders at least the minimum box", () => { + for (const width of [55, 69, 80, 109, 120]) { + const lines = new LogoHeader(createMockTheme()).render(width) + expect(lines.length).toBeGreaterThanOrEqual(11) + } + }) + }) }) diff --git a/src/components/logo.ts b/src/components/logo.ts index cebecaddd..80b383d94 100644 --- a/src/components/logo.ts +++ b/src/components/logo.ts @@ -3,74 +3,111 @@ import type { Component } from "@earendil-works/pi-tui" import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui" import { RST_FG } from "../ansi.js" import { getVersion } from "../utils.js" -import { buildInfoLines, buildLogoLines } from "./logo-art.js" +import { buildCompactLogoLines, buildInfoLines, buildLogoLines } from "./logo-art.js" + +// Box chrome: left border + divider + right border ("│" × 3). +const CHROME = 3 +// Spaces flanking the right-cell text. +const CELL_PAD = 1 +// Minimum readable tip measure for the right column. +const RIGHT_MIN = 12 +// Maximum per-side symmetric gutter around the logo content. +const GUTTER_MAX = 10 +// Minimum per-side gutter the full word-art needs to stay comfortably readable. +const MIN_GUTTER = 1 + +export interface HeaderLayout { + span: number + rightColWidth: number +} + +/** + * Compute the two-cell header layout for a requested terminal width. + * + * The right column is granted at least `RIGHT_MIN` columns and then takes + * all remaining width. The logo cell keeps up to `GUTTER_MAX` columns of + * symmetric gutter on each side, which caps the logo cell at the same + * 56-col maximum the old pad cascade had (10 + 36 + 10). At degenerate + * widths the right column is clamped to 1 and the final tail-truncation in + * the caller absorbs the overflow. + */ +export function computeHeaderLayout(width: number, logoWidth: number): HeaderLayout { + const slack = width - CHROME - 2 * CELL_PAD - logoWidth - RIGHT_MIN + let gutter = slack >= 0 ? Math.min(GUTTER_MAX, Math.floor(slack / 2)) : 0 + let rightColWidth = width - CHROME - 2 * CELL_PAD - logoWidth - 2 * gutter + // Degenerate widths (e.g. < 26 for compact) can't honour all floors. + if (rightColWidth < 1) { + rightColWidth = Math.max(1, width - CHROME - 2 * CELL_PAD - logoWidth) + gutter = 0 + } + const span = Math.max(logoWidth, logoWidth + 2 * gutter) + return { span, rightColWidth } +} export class LogoHeader implements Component { private readonly theme: Theme private readonly getBranch?: () => string | undefined private readonly getRightColumnNotice?: () => string | undefined - private logoLines: string[] + private logoLines!: string[] + private compactLogoLines!: string[] constructor(theme: Theme, opts?: { getBranch?(): string | undefined; getRightColumnNotice?(): string | undefined }) { this.theme = theme this.getBranch = opts?.getBranch this.getRightColumnNotice = opts?.getRightColumnNotice - this.logoLines = buildLogoLines(theme) + this.rebuildArt() } invalidate(): void { + this.rebuildArt() + } + + private rebuildArt(): void { this.logoLines = buildLogoLines(this.theme) + this.compactLogoLines = buildCompactLogoLines(this.theme) } render(width: number): string[] { const { theme } = this const accentOpen = theme.getFgAnsi("accent") - // Logo dimensions - const logoWidth = Math.max(...this.logoLines.map((l) => visibleWidth(l))) - const logoHeight = this.logoLines.length + // The header is a two-cell box: a left cell holding the logo + info + // lines, and a right cell holding the tips. The left cell's content + // is always horizontally centered in its span, which gives the logo + // symmetric gutters that grow smoothly with width and collapses + // gracefully when the right column claims more space. + + // Variant. The full word-art only fits comfortably when we can afford + // at least MIN_GUTTER on each side of the logo and RIGHT_MIN for the + // tips; below that, switch to the pepper-only mark. + const fullLogoWidth = Math.max(...this.logoLines.map((l) => visibleWidth(l))) + const isCompact = width < fullLogoWidth + CHROME + 2 * CELL_PAD + RIGHT_MIN + 2 * MIN_GUTTER + const logoLines = isCompact ? this.compactLogoLines : this.logoLines + const logoWidth = Math.max(...logoLines.map((l) => visibleWidth(l))) + const logoHeight = logoLines.length const midGap = 2 + // Allocation. Give the right column a bounded measure; let the logo + // cell keep up to GUTTER_MAX of symmetric padding on each side; once + // the right column hits RIGHT_MAX, any remaining slack returns to the + // gutters so the box stays centered at very wide widths. + const { span, rightColWidth } = computeHeaderLayout(width, logoWidth) + // Compute how much room the version prefix takes so we can tell // buildInfoLines how much space remains for the folder before the - // whole line would exceed the fixed logo width. + // whole line would exceed the left column width. const versionStr = getVersion() const versionPrefixWidth = 1 + versionStr.length + 3 // "v" + version + " · " - const folderMaxWidth = Math.max(4, logoWidth - versionPrefixWidth) + const folderMaxWidth = Math.max(4, span - versionPrefixWidth) const infoLines = buildInfoLines(theme, { folderMaxWidth, getBranch: this.getBranch }) - // Left column content width is fixed to logo width so the logo never - // shifts or deforms when the info line (branch name, folder) is long. - const leftContentWidth = logoWidth - - // Truncate each info line so it never exceeds the fixed left column width. + // Truncate each info line so it never exceeds the left column width. const infoLinesFitted = infoLines.map((line) => { const w = visibleWidth(line) - return w > leftContentWidth ? truncateToWidth(line, leftContentWidth) : line + return w > span ? truncateToWidth(line, span) : line }) - // Compute right column width with progressive padding reduction for narrow terminals - let leftPad = 10 - let midPad = 10 - let rightPad = 1 - let endPad = 1 - let rightColWidth = width - (2 + leftPad + leftContentWidth + midPad + 1 + rightPad + endPad) - - if (rightColWidth < 8) { - midPad = 0 - rightPad = 0 - rightColWidth = width - (2 + leftPad + leftContentWidth + 1 + endPad) - } - if (rightColWidth < 8) { - leftPad = 0 - endPad = 0 - rightColWidth = width - (2 + leftContentWidth + 1) - } - if (rightColWidth < 1) { - rightColWidth = 1 - } - // Right column content (static text — no dynamic tip mechanism exists yet) const accentText = (text: string) => theme.fg("accent", text) const labelLine = "Kimchi's special:" @@ -106,16 +143,16 @@ export class LogoHeader implements Component { for (let row = 0; row < totalHeight; row++) { let leftContent = "" if (row >= logoTop && row < logoTop + logoHeight) { - leftContent = this.logoLines[row - logoTop] + leftContent = logoLines[row - logoTop] } if (row >= infoRowStart && row < infoRowStart + infoLineCount) { leftContent = infoLinesFitted[row - infoRowStart] } - // Horizontally center content within leftContentWidth + // Horizontally center content within the left cell span. const contentWidth = visibleWidth(leftContent) - const hPad = Math.floor((leftContentWidth - contentWidth) / 2) - const leftPadded = " ".repeat(hPad) + leftContent + " ".repeat(leftContentWidth - contentWidth - hPad) + const hPad = Math.floor((span - contentWidth) / 2) + const leftPadded = " ".repeat(hPad) + leftContent + " ".repeat(span - contentWidth - hPad) const rightContent = rightLines[row] || "" const rightVisible = visibleWidth(rightContent) @@ -123,13 +160,11 @@ export class LogoHeader implements Component { const line = accentBorder("│") + - " ".repeat(leftPad) + leftPadded + - " ".repeat(midPad) + accentBorder("│") + - " ".repeat(rightPad) + + " ".repeat(CELL_PAD) + rightPadded + - " ".repeat(endPad) + + " ".repeat(CELL_PAD) + accentBorder("│") result.push(line) @@ -138,9 +173,9 @@ export class LogoHeader implements Component { // Bottom border result.push(accentBorder(`└${"─".repeat(borderInner)}┘`)) - // The left column is fixed at the logo width, so on terminals narrower - // than the logo every body line would overflow. pi-tui treats an - // over-wide line as a fatal crash, so hard-truncate every row here. + // On terminals narrower than the logo every body line would overflow; + // pi-tui treats an over-wide line as a fatal crash, so hard-truncate + // every row here. return result.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width) : line)) } }