From a5a115b3c5c234d5e014e628811fd189ff307cc0 Mon Sep 17 00:00:00 2001 From: stanlyzoolo <51911715+stanlyzoolo@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:21:21 +0300 Subject: [PATCH] fix: put blank lines around changelog headings on the card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GoReleaser release bodies pack headings tight against their lists (## Changelog\n### New features\n* ...), and markdownToLines only ever collapsed blank lines, never added its own — so on the card the section headings sat flush against the bullets with no air at all. The heading branch now emits a blank row on both sides through the existing emitBlank collapse: a body whose author already wrote the blanks comes out unchanged, and a heading that converts to nothing still yields exactly one blank. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- internal/model/textutil.go | 10 ++++++++++ internal/model/textutil_test.go | 31 ++++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85bce79..0e92be2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,7 +101,7 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu - **Tool-list search (`/` in `focusTools`)** is a commit/rollback transaction over `modeSearch`. `case "/"` captures `m.searchPrevName` (the selected tool's name; empty when the list is empty) before entering the mode, and `filteredMeta()` narrows the list live as the query changes. The predicate is `searchMatches()` (`model.go`): a tool matches when its **name OR its tag** contains the lowercased query (`matchingTag` still iterates the slice — it predates the one-tag invariant and stays correct under it), returning `[]searchMatch{meta, byTagOnly, tag}` so the renderer knows which rows matched only by tag; `filteredMeta()` is a thin projection over it, so all other callers (count, selection, cursor remap) keep seeing plain metas. While searching, the matched name substring renders peach-bold via `highlightNameMatch` (render.go), tag-only rows show the earning tag as a dim `#` suffix when it fits the row budget without wrapping, and the search status bar shows an `N/M` counter (matches / total tracked) between the query and the hints (a keystroke that changes the query text resets `metaSelected` to 0 — first match highlighted, marker visible during search; pure cursor movement like `left`/`right` keeps a user-moved highlight; that reset repaints `[3]` through **`setHelpContent()`**, not a bare `SetContent(renderHelpContent())`: the readme branch serves `m.helpBase`, which nothing else re-renders, so the cheaper call would leave the *previous* tool's README on screen under the new tool's name — no fetch is fired there, one per keystroke would spend the quota on rows merely typed past). Inside the mode: `↑`/`↓` move the highlight through the filtered list via `selectMeta` (modular wrap, full `j`/`k` parity; **never** forwarded to the textinput, so the query text is untouched — with zero matches they are consumed as no-ops); `enter` commits — exits to `modeNormal`, clears the query, remaps the cursor onto the unfiltered (but still update-grouped) list by name via `indexOfMeta(mt.Name)` — the search filter is gone once the mode is normal, but the update grouping is not, so `indexOfMeta` resolves the **displayed** index — and moves focus to `focusBrief` (no matches → no-op, search stays open); `esc` rolls back — unfiltered list with the cursor restored via `indexOfMeta(m.searchPrevName)` (fallback 0 when that tool was untracked mid-search). Both exits clear `searchPrevName` and go through `selectMeta`, so the help panel is re-synced too (an arrow move may have loaded another tool's help mid-search). `indexOfMeta(name)` lives next to `filteredMeta()` in `model.go`; the status bar echoes the live query plus the `N/M` counter and `[enter] open [↑/↓] move [esc] cancel` hints. - **Central panel actions (`focusBrief`)** operate on the data the card already shows: **`enter` installs the release the card is offering** (the panel's primary action, and the mirror of `enter`-runs-a-tool in `[1]` — same key, because in both panels it is the thing the user came to that panel to do; see **Update** below), `o` opens the repo in the browser, `c` opens the changelog/releases page, `r` force-refreshes the tool's data, `s` cycles the status (`loader.NextStatus`: `active → trying → inactive`, unknown values fall back to active), `e` edits the note, `#` edits the tool's single tag. **`#` rather than `t`**: `t` is now the global track verb, and a tag editor reachable only from the card is where the tag is shown. `o`/`c` go through `openURLCmd` (resolved per-`GOOS` by `browserCommand`); a tool with no `GitHub` sets `m.statusMsg` instead of launching. `s`/`e`/`#` mutate `m.meta` via `loader.UpsertMeta`, persist with `loader.SaveMeta`, then refresh the card with `m.briefViewport.SetContent(m.renderCard())`. The tags editor commits through `parseTag` (mode.go): the input is **one** tag — everything past the first comma is dropped, so typing `cli, foo` and loading a legacy `[cli, foo]` list both land on `cli` and the editor can never disagree with `LoadMeta`'s `Tags[:1]` migration about a tag's shape. Spaces inside a tag are kept (`dev tools`); empty input clears it to `nil`, which `omitempty` drops from `meta.yaml`. Everything downstream reads the one tag through `tagOf(mt)` rather than joining the slice. - **Clickable card lines**: `renderCard()` is a thin wrapper over **`buildCard() (string, map[int]string)`**, which returns the card text plus the index of its clickable lines (0-based content line → URL) — the **title line** (`https://` + `t.GitHub`, exactly what `[o]` opens: the **link is the full ref while the displayed value is the bare `owner/repo`** printed beside the tool's name, the host being implied; a ref `NormalizeRepo` rejects — an unsupported or spoofed host like `github.com.evil.com/x/y` — renders in full instead, since shortening exactly there would hide the host that makes the link not what it looks like, and the ref is appended only when it fits the line at all) and the **changelog heading** (`msg.htmlUrl` **verbatim** — the release's own page, unlike `[c]`'s `/releases`), registered **only when the heading's `release notes ↗` affordance actually fit**: a heading rendered without it looks like plain text and must not open a browser. Indices are recorded *while writing* (`strings.Count(sb.String(), "\n")` immediately before the line), because line heights vary — the metrics strip is several rows and the tagline and changelog bodies wrap. The wrapper keeps the ~30 `SetContent(renderCard())` call sites unchanged; `handleMouse` is the only consumer of the map and recomputes it per click, so it can never be stale. A **content line is a screen row** here: the viewport *truncates* a line wider than the panel rather than soft-wrapping it, which is what lets a click row map straight onto a content-line index — `TestBriefContentLineIsScreenRow` pins that, because a bubbles that wrapped instead would shift every link below an overlong line. No visual styling marks the links (no underline/bold) — deliberate. -- **Card changelog body**: the block is headed by `changelogHeading` — the word `changelog` in `EmphasisBold`, the version transition it covers (`v0.3.2 → v1.0.2`, stated rather than left for the reader to infer from two numbers in the strip above) and, right-aligned when it fits, `release notes ↗` in the `Link` role, with a border-colored **rule filling the gap** between them — it ties the two ends of the row into one heading and separates the notes from the meta line above, which nothing else does now that the card has no section headers. The transition is gated on **`hasUpdate`**, not on the two strings differing: both are printed through `DisplayVersion`, so a tool whose `--version` says `1.10.2` against a `v1.10.2` tag is up to date, and the raw compare printed it a `v1.10.2 → v1.10.2` arrow to nowhere on the one card with nothing to report. The notes themselves render through **`markdownToLines(body, max(m.cardWidth(), 10))`** (textutil.go — `cardWidth()` is the card's single width definition, the same one `buildCard`'s `indentLines` steps the finished text in by; the `briefW-2` it used to size on was one cell wider, and since the code plate is padded to the block's full width it painted over the right `panelGutter`), which replaced a `stripMarkdown` + `wrapText` pair that destroyed the markdown instead of respecting it — it ate list markers (`strings.Trim(line, "*_")` took a leading bullet), left `[text](url)` raw, and its `<…>` HTML strip swallowed `` autolinks whole. The converter is **one line pass** over the body carrying two state flags (inside a fenced block, inside an HTML comment) and returns **pre-wrapped** `mdLine{text, kind}` values: wrapping lives *inside* it because hanging indents need the block structure, and because styling must land on whole finished lines — `wrapLine` counts runes and knows nothing about ANSI. `renderChangelogBlock` is then a trivial consumer: every line is stepped in by `changelogIndent` (plain spaces outside the styling, so no escape sequence is split) so the notes read as belonging to the heading above them; `mdHeading` → `Styles.EmphasisBold`, `mdCode` → `Text` on the `Surface` plate **padded to the block's full width** (a background stopping at the last glyph is a ragged highlight, and a command in a release note is something to run rather than more prose; the plate alone does the raising — an `Emphasis` foreground here made every code line the loudest thing on the card), everything else → `Styles.Text`, and a blank line written as a bare `"\n"` (styling an empty string only emits an empty escape pair). It no longer prints the release URL itself — that link moved to the heading, which is also the line `buildCard` registers as clickable. There are **three kinds**, and each exists because the card renders it differently — heading, code, body; a fourth (list, quote) still has no reader, so what makes those blocks special lives inside the converter, in their indent and markers. `mdCode` covers the blank rows inside a fence too, or the plate would be punched through by them. Rules worth remembering: CRLF is normalized **first** (GitHub bodies routinely arrive CRLF from the web form; a `\r`-suffixed closing fence would fail to match and swallow the rest of the body as code); a heading and a list marker both **require the space** after the marker, so `#123 fixed …` stays an issue ref and `**Breaking**` stays a paragraph — the old `TrimLeft(line, "#")`/`Trim(line, "*_")` bug class cannot come back; `---`/`***`/`___` **and** a `===` run are rule lines that collapse to a blank and are **never** list items (`---` is the stock separator above "Full Changelog", and a bare `===` left in place put a literal row of equals signs in the card — full setext support is deliberately out: the text and the layout stay right, only the emphasis is lost); the code fence deliberately accepts **any** indent, unlike CommonMark's 3-space limit, because 4+ spaces there means an indented code block, which this converter does not implement — the strict form only mis-read a fence nested under a list item, leaking the language tag out as a body line reading `go`; a fence **closes on its own marker only** (`mdFenceOpenRe` captures the run, `rcFenceCloses` matches it — the same rule and the same helper as the README pass), so a `~~~` block wrapping ``` ``` ``` samples stays one block instead of ending at the inner fence and swallowing the tail after the real closer; a heading and a paragraph share **`mdEmitInline`**, so a line whose markup collapses to nothing (a badge-only line, a bare `
`, a heading whose only content was an image) can only ever become a blank through `emitBlank` — an empty line still tagged `mdHeading` would sit outside the collapse and hand the next reader of `kind` a line that is not a heading; bullets normalize to `•` (U+2022 — East-Asian **Ambiguous**, the same accepted class as `⏺`/`↑`/`─`, and it never enters the wrap math, which is rune-based) with nesting clamped to 2 levels of 2 spaces and continuations hanging under the first text column; **inline code is masked before any rule runs** (`rcMaskSpans`, the README preprocessor's own mechanism — the spans come back with their delimiter runs cut and their body verbatim, so nothing can rewrite what the author wrote as code: `--output ` used to lose its argument to the HTML strip and emphasis fired across two adjacent spans), on what is left the order is load-bearing (images → links → **autolinks before the HTML-tag strip** → emphasis), the strip itself is **`rcHTMLTagRe`'s allowlist and not a generic `<…>` eater** (release prose carries `Vec` and ``, which are not markup), and the underscore emphasis pattern requires a non-word rune outside both delimiters or `update_cmd` would silently lose its underscore. Two masking gaps stay open and are documented side by side on `mdInline`: `mdCutComments` runs upstream in the line loop and still cuts an HTML comment written inside a span, and the per-line invocation cannot mask a span split across two source lines. The mask sentinel is NUL, so `markdownToLines` drops NUL from the body on entry — the README path is sanitized by `cleanTerminalOutput` first, this one takes the API body raw and a forged placeholder would otherwise be substituted. An empty conversion result falls through to the existing `no release notes available.` branch, which therefore now also covers a **non-empty** body the converter consumed whole (all comments, all separators). The conversion is memoized in **`m.changelogRender`** (`changelogRenderCache` in render.go, one entry keyed by `(body, width)` — `markdownToLines`'s only two inputs), the same shape as `readmeRenderCache` and for the same reason: the whole card is rebuilt on **every spinner frame** (the `spinner.TickMsg` handler, ~12/s for as long as a `[r]` refresh or an update runs), so a large release body would go through the converter's regexes twelve times a second to animate one glyph — measured at 3.3 ms and 1 MB of garbage per pass on a 49 KB body against 15 ns on a hit. It hangs off `Model` as a **pointer**, because the card renderers are value receivers and could not fill a plain field, and its method **tolerates a nil receiver** (a cache-less but correct mode) since most tests build `Model{}` literals and never call `New()`. +- **Card changelog body**: the block is headed by `changelogHeading` — the word `changelog` in `EmphasisBold`, the version transition it covers (`v0.3.2 → v1.0.2`, stated rather than left for the reader to infer from two numbers in the strip above) and, right-aligned when it fits, `release notes ↗` in the `Link` role, with a border-colored **rule filling the gap** between them — it ties the two ends of the row into one heading and separates the notes from the meta line above, which nothing else does now that the card has no section headers. The transition is gated on **`hasUpdate`**, not on the two strings differing: both are printed through `DisplayVersion`, so a tool whose `--version` says `1.10.2` against a `v1.10.2` tag is up to date, and the raw compare printed it a `v1.10.2 → v1.10.2` arrow to nowhere on the one card with nothing to report. The notes themselves render through **`markdownToLines(body, max(m.cardWidth(), 10))`** (textutil.go — `cardWidth()` is the card's single width definition, the same one `buildCard`'s `indentLines` steps the finished text in by; the `briefW-2` it used to size on was one cell wider, and since the code plate is padded to the block's full width it painted over the right `panelGutter`), which replaced a `stripMarkdown` + `wrapText` pair that destroyed the markdown instead of respecting it — it ate list markers (`strings.Trim(line, "*_")` took a leading bullet), left `[text](url)` raw, and its `<…>` HTML strip swallowed `` autolinks whole. The converter is **one line pass** over the body carrying two state flags (inside a fenced block, inside an HTML comment) and returns **pre-wrapped** `mdLine{text, kind}` values: wrapping lives *inside* it because hanging indents need the block structure, and because styling must land on whole finished lines — `wrapLine` counts runes and knows nothing about ANSI. `renderChangelogBlock` is then a trivial consumer: every line is stepped in by `changelogIndent` (plain spaces outside the styling, so no escape sequence is split) so the notes read as belonging to the heading above them; `mdHeading` → `Styles.EmphasisBold`, `mdCode` → `Text` on the `Surface` plate **padded to the block's full width** (a background stopping at the last glyph is a ragged highlight, and a command in a release note is something to run rather than more prose; the plate alone does the raising — an `Emphasis` foreground here made every code line the loudest thing on the card), everything else → `Styles.Text`, and a blank line written as a bare `"\n"` (styling an empty string only emits an empty escape pair). It no longer prints the release URL itself — that link moved to the heading, which is also the line `buildCard` registers as clickable. There are **three kinds**, and each exists because the card renders it differently — heading, code, body; a fourth (list, quote) still has no reader, so what makes those blocks special lives inside the converter, in their indent and markers. `mdCode` covers the blank rows inside a fence too, or the plate would be punched through by them. Rules worth remembering: CRLF is normalized **first** (GitHub bodies routinely arrive CRLF from the web form; a `\r`-suffixed closing fence would fail to match and swallow the rest of the body as code); a heading and a list marker both **require the space** after the marker, so `#123 fixed …` stays an issue ref and `**Breaking**` stays a paragraph — the old `TrimLeft(line, "#")`/`Trim(line, "*_")` bug class cannot come back; `---`/`***`/`___` **and** a `===` run are rule lines that collapse to a blank and are **never** list items (`---` is the stock separator above "Full Changelog", and a bare `===` left in place put a literal row of equals signs in the card — full setext support is deliberately out: the text and the layout stay right, only the emphasis is lost); the code fence deliberately accepts **any** indent, unlike CommonMark's 3-space limit, because 4+ spaces there means an indented code block, which this converter does not implement — the strict form only mis-read a fence nested under a list item, leaking the language tag out as a body line reading `go`; a fence **closes on its own marker only** (`mdFenceOpenRe` captures the run, `rcFenceCloses` matches it — the same rule and the same helper as the README pass), so a `~~~` block wrapping ``` ``` ``` samples stays one block instead of ending at the inner fence and swallowing the tail after the real closer; a heading gets a blank row on **both** sides even when the source wrote none — GoReleaser bodies pack headings tight against their lists — with both inserts riding `emitBlank`'s collapse, so an authored blank never doubles and a heading that converted to nothing still yields exactly one blank; a heading and a paragraph share **`mdEmitInline`**, so a line whose markup collapses to nothing (a badge-only line, a bare `
`, a heading whose only content was an image) can only ever become a blank through `emitBlank` — an empty line still tagged `mdHeading` would sit outside the collapse and hand the next reader of `kind` a line that is not a heading; bullets normalize to `•` (U+2022 — East-Asian **Ambiguous**, the same accepted class as `⏺`/`↑`/`─`, and it never enters the wrap math, which is rune-based) with nesting clamped to 2 levels of 2 spaces and continuations hanging under the first text column; **inline code is masked before any rule runs** (`rcMaskSpans`, the README preprocessor's own mechanism — the spans come back with their delimiter runs cut and their body verbatim, so nothing can rewrite what the author wrote as code: `--output ` used to lose its argument to the HTML strip and emphasis fired across two adjacent spans), on what is left the order is load-bearing (images → links → **autolinks before the HTML-tag strip** → emphasis), the strip itself is **`rcHTMLTagRe`'s allowlist and not a generic `<…>` eater** (release prose carries `Vec` and ``, which are not markup), and the underscore emphasis pattern requires a non-word rune outside both delimiters or `update_cmd` would silently lose its underscore. Two masking gaps stay open and are documented side by side on `mdInline`: `mdCutComments` runs upstream in the line loop and still cuts an HTML comment written inside a span, and the per-line invocation cannot mask a span split across two source lines. The mask sentinel is NUL, so `markdownToLines` drops NUL from the body on entry — the README path is sanitized by `cleanTerminalOutput` first, this one takes the API body raw and a forged placeholder would otherwise be substituted. An empty conversion result falls through to the existing `no release notes available.` branch, which therefore now also covers a **non-empty** body the converter consumed whole (all comments, all separators). The conversion is memoized in **`m.changelogRender`** (`changelogRenderCache` in render.go, one entry keyed by `(body, width)` — `markdownToLines`'s only two inputs), the same shape as `readmeRenderCache` and for the same reason: the whole card is rebuilt on **every spinner frame** (the `spinner.TickMsg` handler, ~12/s for as long as a `[r]` refresh or an update runs), so a large release body would go through the converter's regexes twelve times a second to animate one glyph — measured at 3.3 ms and 1 MB of garbage per pass on a 49 KB body against 15 ns on a hit. It hangs off `Model` as a **pointer**, because the card renderers are value receivers and could not fill a plain field, and its method **tolerates a nil receiver** (a cache-less but correct mode) since most tests build `Model{}` literals and never call `New()`. - **Card meta block**: everything the user has told keepkit about the tool, plus what the repo says it is written in. It is **two blocks shaped by what they are**. The language stack is a *distribution*, so it gets the card's one picture: `languages · ● go 99% · ● shell 1%` (the full word heading its own list, separated from the first language by the same middot that separates two languages — it is the head of that list, not a caption over it), then **`renderLangBand`** under it, a row of exactly `inner` cells holding those same shares in **GitHub's own per-language colors**. Only the `●` carries color and the name+share read at one brightness — they are one fact, and five colored names would be a rainbow. The band's shares are normalized over the languages *actually listed* (`languagePercents` keeps the top five), because a band summed over the repo's total would leave a gap standing for languages the card never named; cells are handed out one per language first and the remainder by largest fractional part, so nothing listed is missing from the band and the row is exactly `inner` cells rather than `inner`±rounding. Its glyph is `▬` (U+25AC), **width-stable** like the gauge's `▮` — `█` is East-Asian Ambiguous and would double the band's footprint (`TestLanguageBandGlyphWidth`); the `●` beside a name deliberately is *not* in that class, since it rides in wrapped text where an over-wide measurement can only wrap a row early. The colors live in **`ui.LanguageColor`** (`internal/ui/lang.go`) and are the one thing in the app a theme switch must **not** repaint: `Theme` is keepkit's vocabulary of meanings, these are linguist's brand marks, and the whole value of a cyan dot beside `go` is that it is the cyan the reader has seen on every repo page. An unknown language falls back to `Dim` — unrecognized rather than wrong. Accepted caveat: linguist picks against a white page, so `lua`/`powershell`/`json` are near-black dots on a dark terminal. The band closes with **one blank row**: it runs the full width of the panel, so without one the line under it reads as a caption hanging off the bar rather than as the next thing (written bare — styling an empty string only emits an empty escape pair, the rule the changelog's blank rows follow). Below it, `status`, `tags` and `note` share **one wrapped line** — three short values that each took a whole row spent three rows on a sentence's worth of text, and the band above already gives the block its structure. Wrapping there is **by whole cells**: a cell carries ANSI, so it must never be cut mid-escape, and half a `note …` reads as noise anyway (the language block, which is not one line, is emitted above the loop rather than fed to it). Labels are `Dim`, values `Text`, and an **empty value does not get a line of its own**: it gets the key that fills it (`tags — # add`, `note — e write`), which is the only thing an empty field is good for. In edit mode the input replaces the value **in place**, so the card never jumps while it is being typed into. A value too wide for the panel is cut **before** it is styled and marked with `…` — cutting afterwards would land inside an escape sequence, which the viewport re-emits to the terminal verbatim. The card is not where a long note is read in full; the editor shows all of it. `TestMetaLineShape` pins the order and the band's exact width; `TestMetaLineFieldsWrapByWholeCells` pins that a narrow panel breaks the field line between two cells and never inside one. - **Card metrics strip**: `metricsStrip` is what the card's `[info]` section became — installed / latest / maintenance / stars laid out as **captioned columns on the `Theme.Surface` background** instead of six `label: value` lines whose labels ran down the left edge and pushed every value into a column of its own. Captions are uppercase (`INSTALLED`, `LATEST`, `MAINTENANCE`, `STARS`) because a terminal has no smaller type size to demote a label with, and the values are what the eye should land on. **`installed:` still has four states** and the two version-less ones stay distinct: a resolved version in `Text`, `✓ present` in `Ok` (a tool that is installed but won't name its version — a ratatui app that ignores `--version` — is a working install and reads affirmative), `✕ missing` in `Danger` (the one thing on the card that is actually wrong), and `detecting…` in `Dim` while the local probe is in flight. Both version-less values are **one word**, because the caption above them already says INSTALLED and the sentences they used to be were the only values in the strip too wide for a baseline-width column. `latest:` renders in `SignalBold` with a trailing ` ↑` when `hasUpdate`, otherwise `Text`, and the release date is a **second line under it** rather than a suffix on it. **The values sit at `Text`, one step below the tool's name and one above their own captions**: a terminal has a single font size — the grid belongs to the terminal, not to the app — so the three sizes the design draws in the card's head are three steps of weight and brightness here, and there is room for exactly one peak. Spending the brightest role on four measurements left the name nothing to be the peak of. The two exceptions carry meaning rather than rank: a pending release is one of the screen's three "act on this" points, and a broken install is its one alarm. The header block is separated from the strip by **one** plain blank row — the strip's own padding row is filled with the plate colour and already reads as air, so a second plain row on top of it reads as a hole. Both versions go through **`version.DisplayVersion`**, which puts a `v` in front of a bare version number: a tool's `--version` prints `1.10.2` where its release is tagged `v1.10.2`, and the two used to sit one letter apart for the same binary. It edits nothing else — `canonSemver` decides only *whether* the string is a version number (`nightly`, `cli-2.0` pass through untouched), and its own output is deliberately not what is displayed, since it drops zero-padding, a 4th segment and build metadata. The `\uf412` glyph the two version lines used to carry is gone with the labels that needed disambiguating: a caption says what the number is. A metric with nothing to report is **left out entirely**, so a tool with no GitHub ref shows a one-cell strip rather than three empty captions, and an empty strip is no strip at all. The grid **re-flows rather than truncates**, and it is sized by the widest *value* as well as the widest caption (`need`): sizing on captions alone cut `✕ not installed` to `✕ not insta` at the 80×24 baseline — the default terminal, and the exact state a tracker is opened in. The count is solved against the row the strip actually draws — a blank cell at each end plus a rule between every pair — by counting up while `2 + cols*need + (cols-1)*3` still fits, **not** by dividing `inner` by `need`: that division ignores the overhead, so a 40-cell panel was told it had three columns and then handed each of them 10 cells, cutting `MAINTENANCE` to `MAINTENANC` — the caption the floor exists to protect. At the 80-column baseline the card panel is 27 cells and even two columns need 29, so the grid stands on one; a value longer than any caption costs a column rather than its own legibility. Below `metricStripMinWidth` (`metricMinCol` plus the row's two blank edge cells — measuring against `metricMinCol` alone was two cells short, and a 12-cell panel drew a single 10-cell column) the strip stands down completely, which only a hand-built model reaches since the panel has a 30-cell minimum. **Every row is exactly `inner` cells** — a short row would break the fill into a ragged edge — and every segment carries the background itself for the reason the selected list row does. **Each cell is centered in its column** (odd slack to the right, so a caption and its value can differ by at most one cell in where they start): a caption and the value under it are one measurement, and flush-left hangs them off a rule that is nowhere near either. `TestMetricsStripLayout` pins the width, the caption-over-value reading (via `metricValue`, which identifies a column by the `│` rules around it rather than by the caption's start offset — centered caption and value deliberately do not begin at the same cell), the centering and the re-flow; `TestMetricsStripOmitsUnknowns` pins the omission. - **Panel titles**: all three panels inset a title into their top border (`┌─ ▸ [1] tools 27 3↑ ─…─┐`) via the shared `insetPanelTitle` — an ANSI-safe splice over the already-rendered frame (`ui`'s `truncateVisible` is unexported; the helper repaints the border runs from their `stripANSI` text and drops the title in **already styled**). That is what lets one title carry several colors, which the `[1]` counts are the point of: the tracked count in `Dim`, the update count in `Signal` — the tracker's whole reason to exist, so it is the one thing in a panel title that gets the signal color, and it is absent when nothing is behind. A `panelTitle` carries **both** a plain and a styled form: the plain one is what the border arithmetic measures, because escape sequences are not cells, and a title that does not fit is dropped whole (a chopped title reads worse than none). **Focus is marked twice** — by the accent color and by a `▸` prefix — so it survives a monochrome terminal and a reader who cannot separate the two panel colors; `TestPanelTitleFollowsFocus` pins both signals. The titles are lowercase (`[1] tools`, `[2] brief`, `[3] readme` / `[3] help` / `[3] man`, overridden by `[3] update` while a live log shows and by `[3] update finished` / `[3] update failed` once it ended) and double as the documentation for the digit focus hotkeys, so the status bar carries no digit hints. `[3]` additionally names **the two sources it is not showing** (`· h help · m man`) in the border color: those keys switch what the panel *is*, which is a property of the panel rather than an action on its content — and they are dropped while the update log owns it, since none of the three modes is what is on screen. All title characters are single-width and non-East-Asian-Ambiguous except `▸` (U+25B8, Ambiguous like the list markers, and measured with the plain form either way), keeping the border width math stable. diff --git a/internal/model/textutil.go b/internal/model/textutil.go index 59d4e97..4bc5f9e 100644 --- a/internal/model/textutil.go +++ b/internal/model/textutil.go @@ -544,7 +544,17 @@ func markdownToLines(s string, width int) []mdLine { } if m := mdHeadingRe.FindStringSubmatch(line); m != nil { + // A heading gets a blank row on both sides even when the source + // has none — GoReleaser bodies pack headings tight against their + // lists, and on the card nothing else separates a section from + // the one above. Both calls ride emitBlank's collapse, so a body + // that already carries the blanks gains no extra row, and a + // heading that converts to nothing still yields exactly one + // blank: mdEmitInline's own emitBlank fires first and the + // trailing call becomes a no-op. + emitBlank() mdEmitInline(emit, emitBlank, m[1], width, mdHeading) + emitBlank() continue } // Checked before the list patterns: "---" is the stock separator above diff --git a/internal/model/textutil_test.go b/internal/model/textutil_test.go index 30a0219..5591d25 100644 --- a/internal/model/textutil_test.go +++ b/internal/model/textutil_test.go @@ -87,10 +87,38 @@ func TestMarkdownToLines(t *testing.T) { }{ {"empty input", "", 80, nil}, { + // Consecutive headings get one blank between them (the trailing + // blank of one collapses with the leading blank of the next), no + // leading blank before the first, no trailing after the last. "headings h1 to h6", "# One\n## Two\n### Three\n#### Four\n##### Five\n###### Six", 80, - []string{"H|One", "H|Two", "H|Three", "H|Four", "H|Five", "H|Six"}, + []string{ + "H|One", "B|", "H|Two", "B|", "H|Three", "B|", + "H|Four", "B|", "H|Five", "B|", "H|Six", + }, + }, + { + // GoReleaser writes no blank lines at all; the converter supplies + // the air around each heading itself. A body whose author already + // wrote them must come out identical — the inserted blank and the + // source blank collapse into one. + "packed headings get air on both sides", + "## Changelog\n### New features\n* a\n### Bug fixes\n* b", + 80, + []string{ + "H|Changelog", "B|", "H|New features", "B|", "B|• a", + "B|", "H|Bug fixes", "B|", "B|• b", + }, + }, + { + "authored blanks around headings do not double", + "## Changelog\n\n### New features\n\n* a\n\n### Bug fixes\n\n* b", + 80, + []string{ + "H|Changelog", "B|", "H|New features", "B|", "B|• a", + "B|", "H|Bug fixes", "B|", "B|• b", + }, }, { // The space after the hashes is required, so a leading issue ref @@ -361,6 +389,7 @@ func TestMarkdownToLinesGitHubBody(t *testing.T) { got := mdDump(markdownToLines(body, 200)) want := []string{ "H|What's Changed", + "B|", "B|• fix: detect uv tools by @someone in https://github.com/o/r/pull/44", "B|• docs: update README", "B|",