From 592bf74a8be700c06b93afe2359a006a209c5594 Mon Sep 17 00:00:00 2001 From: josemonteiro Date: Wed, 2 Sep 2026 15:38:50 +0100 Subject: [PATCH] feat(ui): fall back to pepper-only logo mark on narrow terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full 36-col word-art needs CHROME (3) + CELL_PAD (1 each side) + RIGHT_MIN (12) + a MIN_GUTTER on each side to stay readable — below 55 cols the tips column was squeezed to a sliver, and below ~40 the old padding cascade left every body row hard-truncated mid-glyph. The pepper segment of the logo is a standalone 7-col mark, so render() now picks it when width < fullLogoWidth + CHROME + 2*CELL_PAD + RIGHT_MIN + 2*MIN_GUTTER (= 55), keeping the box intact and the right column readable. The padding cascade is replaced by computeHeaderLayout(): the right column keeps its RIGHT_MIN floor then absorbs the remaining slack, while the logo cell keeps up to GUTTER_MAX (10) of symmetric per-side gutter — capped at the old 56-col max — so logo and info lines stay centered as width grows. All dimensions derive from logoWidth/logoHeight, so the variant swap flows through the existing centering and column math untouched. The pepper glyph rows are shared between buildLogoLines and buildCompactLogoLines so the two variants can't diverge, and art rebuilds go through a single rebuildArt() helper. logo-narrow.test.ts gains a variant-switch suite (pepper mark + intact box below 55, full word-art at 55+, re-switch after invalidate), allocation anchors for span/rightColWidth across 11-200 cols, per-regime right-column monotonicity checks, centering assertions, and an overflow sweep including 1-2 col degenerate terminals. Co-Authored-By: Kimchi --- src/components/logo-art.ts | 28 ++++- src/components/logo-narrow.test.ts | 172 ++++++++++++++++++++++++++++- src/components/logo.ts | 127 +++++++++++++-------- 3 files changed, 275 insertions(+), 52 deletions(-) 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)) } }