diff --git a/CLAUDE.md b/CLAUDE.md index 42b5fbb..7759be2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,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. +- **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. A click opens the link **on `Release`, not `Press`**: the press is what anchors a mouse-drag selection (see **Mouse policy** below), and a drag that begins on a link must copy, not open — `handleSelectableMouse` defers the link lookup to a motion-less release. - **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`), one of the **two** palettes a theme switch must **not** repaint (`HeadingColors`/`ChromaColors` is the other — see the `internal/ui` row above): `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. @@ -120,7 +120,7 @@ The model is a three-panel layout with focus cycling via `→/←` between `focu - **Status-message lifecycle**: `m.statusMsg` (rendered by `renderStatusBar`, which outranks both the self-update banner and the hints bar whenever non-empty — the banner is not modal, and a transient status covering it for `statusMsgTTL` is intended) has two clears. (1) **Immediate**: the blanket `m.statusMsg = ""` on every `tea.KeyMsg` — any keypress wipes the current status at once. (2) **TTL auto-expiry**: every *transient* status is set via **`setStatus(s) tea.Cmd`** (model.go), which bumps a generation counter `m.statusSeq`, sets the message, and returns `tea.Tick(statusMsgTTL, …)` producing a `statusExpiredMsg{seq}` stamped with the current seq; the `Update` handler clears the message only when `msg.seq == m.statusSeq`, so a stale timer from a message already superseded by a newer one is a harmless no-op. `statusMsgTTL` is a **var** (`1 * time.Second`), shrunk by tests the same way as `launchTimeout` (the tick captures the value at construction, so the shrink must precede the `Update`). The returned `tea.Cmd` must be **batched** into whatever the caller already returns (`tea.Batch`); callers that only set a status return it directly. **In-flight** statuses (`launching in …`, `still launching `) are the deliberate exception and go through **`setStickyStatus(s)`** — no timer, but it **still bumps `statusSeq`**: they report work still in progress and are extinguished by `launchDoneMsg`, not the clock, so letting a timer expire one mid-flight would hide the only sign the adapter is busy for up to `launchTimeout`. The bump is the whole point of the helper rather than a plain assignment — a transient status set within the last `statusMsgTTL` (the group toggle, say) has a tick in flight whose seq would otherwise still match the sticky message and wipe it (`TestInFlightStatusSurvivesStaleExpiry`). `TestStatusExpired*`/`TestSetStatus*` pin the mechanism; `assertOnlyExpiryTick` is the test helper that asserts a site returns *only* the expiry tick (no fetch/exec rode along). - **API-status overlay (`a`)**: opens a read-only view of the GitHub rate limit and token (source, masked value, used/limit with threshold icon, reset time) with token entry/removal/refresh. When no token is configured it leads with an `add a github token…` nudge; a **rejected** one gets `replace the token to restore the 5000/h limit` instead — "add" reads as advice to someone who has already done it, and the thing to do is swap the credential, not create one. A rejected **env** token gets a third wording, `GITHUB_TOKEN was refused — replace it in your shell`, and offers **no key at all**: `[e]` runs `SetToken`, which writes the config file, and `effectiveToken` reads that only when `GITHUB_TOKEN` is empty — so the key cannot fix this one, and the variable belongs to the shell that launched keepkit. The env arm must stay **first** in the switch or the config wording swallows it; `[d]` two blocks down has gated on the source all along for the same reason. All three are hidden while entering a token, when the open input is already the answer. A rejection also rewrites the token line itself to `token () — rejected (HTTP 401)` in `Danger`, with `requests run unauthenticated` under it: this is the surface that **always** has the answer, since the gauge's `✕` is droppable under width pressure and invisible before the first rate snapshot. **The mask is load-bearing** — it is how the user recognises which credential to replace — and it survives only because `version.Token()` reads the raw `effectiveToken()` core rather than the suppressed `resolveToken()`. It is `modeAPIStatus` (token entry: `modeTokenInput`) with a matching `renderStatusBar()` branch; `a` fires only in `modeNormal`; `esc` **or `q`** closes it. See the GitHub API section for the data flow. - **Overlay compositing**: two overlays composite over the layout via `ui.PlaceOverlay` (a centered fg-over-bg compositor), gated by the shared `overlayVisible()` predicate in `View()` — the `[a]` API-status overlay (`renderAPIStatus`) and the `[?]` hotkeys overlay (`renderHotkeys`), picked by `m.mode`. `PlaceOverlay` dims the whole visible background — original styling is stripped and repainted with `OverlayDimStyle` (`ColorDim`) — so the modal is the only full-color element. Covered rows get the dim inside `overlayLine` *after* `truncateVisible`/`dropVisible`, because those helpers `StripANSI` the bg and would erase a pre-applied dim from the modal's side margins. -- **Mouse policy** (`handleMouse` in `render.go`, dispatched from `Update()` before the mode switch, gated inside the function): wheel scrolling works in every mode; while any overlay is visible (`overlayVisible()` — `[a]` API status or `[?]` hotkeys) all mouse input is a no-op, and before the first `WindowSizeMsg` (`!m.ready`) too. Clicks that change selection or focus fire only in `modeNormal` — otherwise a click would move `selectedMeta()` under an open note/tags/rename editor and retarget the commit. A click that changes the selected tool goes through the same `selectMeta` helper as the keyboard `j`/`k` path, including the auto-fetch; a click anywhere in the tools panel (row or empty area) focuses it via `setFocus`, matching brief/help — the same helper the keyboard uses, so a click cannot leave the list painted with stale focus styling. Both panels translate the click row the same way — **through `panelRow(msg.Y, vp.Height)`**, then `+ vp.YOffset`. X alone does not mean "inside a panel": the outer `Margin(1,0)` row, the two borders and the status/hints bars all share the panels' columns, and with a scrolled viewport an unbounded `msg.Y - 2` maps that chrome onto real content — a click on the blank top row would open a card link. `panelRow` returns `-1` outside the viewport's rows; inside, `[1]` goes through `toolAtLine()` (a group header maps to `-1` and selects nothing) and `[2]` through `buildCard()`'s link index — a click on the `repo:` line or the changelog release URL returns `openURLCmd(url)`, every other line only moves focus. +- **Mouse policy** (`handleMouse` in `render.go`, dispatched from `Update()` before the mode switch, gated inside the function): wheel scrolling works in every mode; while any overlay is visible (`overlayVisible()` — `[a]` API status or `[?]` hotkeys) all mouse input is a no-op, and before the first `WindowSizeMsg` (`!m.ready`) too. Clicks that change selection or focus fire only in `modeNormal` — otherwise a click would move `selectedMeta()` under an open note/tags/rename editor and retarget the commit. A click that changes the selected tool goes through the same `selectMeta` helper as the keyboard `j`/`k` path, including the auto-fetch; a click anywhere in the tools panel (row or empty area) focuses it via `setFocus`, matching brief/help — the same helper the keyboard uses, so a click cannot leave the list painted with stale focus styling. Both panels translate the click row the same way — **through `panelRow(msg.Y, vp.Height)`**, then `+ vp.YOffset`. X alone does not mean "inside a panel": the outer `Margin(1,0)` row, the two borders and the status/hints bars all share the panels' columns, and with a scrolled viewport an unbounded `msg.Y - 2` maps that chrome onto real content — a click on the blank top row would open a card link. `panelRow` returns `-1` outside the viewport's rows; inside, `[1]` goes through `toolAtLine()` (a group header maps to `-1` and selects nothing) and `[2]` through `buildCard()`'s link index — a click on the `repo:` line or the changelog release URL returns `openURLCmd(url)`, every other line only moves focus. **Drag selection** lives on top of this: a `Press` in `[2]`/`[3]` anchors a selection (snapshotting `renderCard()`/`renderHelpContent()` into `selStyled`/`selPlain`), `Motion` extends it (`mouseSelPos` maps X to a cell column via `panelContentStartX` and Y to a content line via `panelRow` + `YOffset`), and `Release` copies the plain text of a non-empty selection through `copyCmd`/`clipboard` — so the press no longer opens a link, only a motion-less release does (`handleSelectableMouse`). Coordinates are content coordinates (`selPos{line, col}`): the viewport is line-based, and the highlight is painted by `highlightSelection` (in `textutil.go`) with `ansi.Cut` + a reverse-video `selStyle`, the strip-then-repaint rule; `clearSelection` restores the clean content on release/cancel. ### File storage diff --git a/README.md b/README.md index e515486..a3fbae5 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,9 @@ Pure TUI, no subcommands; the only flags are `--version` and `--help`. samples are left exactly as written - **Clickable card** — the repository and release links on the tool card open in the browser by mouse click, or by hotkeys for the repo and changelog pages +- **Select and copy** — drag with the mouse in the brief card or the docs panel to + select text; releasing the button copies the plain text to the system clipboard, + with a confirmation in the status bar - **Language stack** — the card names a repository's languages with their shares and draws them as a proportional band in GitHub's own per-language colors - **Tags and grouping** — one tag per tool; `space` regroups the flat list under diff --git a/go.mod b/go.mod index 3afa3ee..ce6577f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/stanlyzoolo/keepkit go 1.25.0 require ( + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 @@ -17,7 +18,6 @@ require ( require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect diff --git a/internal/model/cardlinks_test.go b/internal/model/cardlinks_test.go index c2cbd5e..6b4b39a 100644 --- a/internal/model/cardlinks_test.go +++ b/internal/model/cardlinks_test.go @@ -249,7 +249,7 @@ func TestMouseBriefLinkClick(t *testing.T) { t.Fatalf("setup: links = %v, want the repo and changelog lines", links) } for line, url := range links { - _, cmd := m.Update(leftClick(briefX(m), line+2)) + _, cmd := clickUpdate(m, briefX(m), line+2) if cmd == nil { t.Errorf("click on line %d (%s) dispatched no command", line, url) } @@ -264,7 +264,7 @@ func TestMouseBriefLinkClick(t *testing.T) { if _, ok := links[2]; ok { t.Fatalf("setup: line 2 unexpectedly linked") } - updated, cmd := m.Update(leftClick(briefX(m), 2+2)) + updated, cmd := clickUpdate(m, briefX(m), 2+2) if cmd != nil { t.Errorf("click on an unlinked line dispatched a command") } @@ -291,10 +291,10 @@ func TestMouseBriefLinkClick(t *testing.T) { } // The same screen row now shows a different content line: clicking where // the changelog heading was before the scroll must no longer open it. - if _, cmd := m.Update(leftClick(briefX(m), logLine+2)); cmd != nil { + if _, cmd := clickUpdate(m, briefX(m), logLine+2); cmd != nil { t.Errorf("click ignored the scroll offset and still opened a link") } - if _, cmd := m.Update(leftClick(briefX(m), logLine-3+2)); cmd == nil { + if _, cmd := clickUpdate(m, briefX(m), logLine-3+2); cmd == nil { t.Errorf("click at the scrolled changelog row dispatched no command") } }) @@ -320,7 +320,7 @@ func TestMouseBriefLinkClick(t *testing.T) { {"below the terminal", m.height + 5}, } for _, tt := range outside { - if _, cmd := m.Update(leftClick(briefX(m), tt.y)); cmd != nil { + if _, cmd := clickUpdate(m, briefX(m), tt.y); cmd != nil { t.Errorf("click on the %s (y=%d) dispatched a command", tt.name, tt.y) } } @@ -331,7 +331,7 @@ func TestMouseBriefLinkClick(t *testing.T) { m.mode = modeEditNote _, links := m.buildCard() for line := range links { - if _, cmd := m.Update(leftClick(briefX(m), line+2)); cmd != nil { + if _, cmd := clickUpdate(m, briefX(m), line+2); cmd != nil { t.Errorf("click on line %d opened a link while the note editor was open", line) } } diff --git a/internal/model/commands.go b/internal/model/commands.go index f6d25cf..3860f1f 100644 --- a/internal/model/commands.go +++ b/internal/model/commands.go @@ -9,8 +9,10 @@ import ( "os/exec" "runtime" "time" + "unicode/utf8" tea "github.com/charmbracelet/bubbletea" + "github.com/atotto/clipboard" "github.com/stanlyzoolo/keepkit/internal/launcher" "github.com/stanlyzoolo/keepkit/internal/loader" @@ -688,3 +690,29 @@ func fetchHelpCmd(name string, mode int) tea.Cmd { return helpOutputMsg{toolName: name, mode: mode, output: cleanTerminalOutput(string(output))} }) } + +// copyDoneMsg carries the result of writing a mouse selection to the system +// clipboard. n is the number of runes copied (0 on failure); err is the +// clipboard error, nil on success. +type copyDoneMsg struct { + n int + err error +} + +// writeClipboard is the seam copyCmd writes through: clipboard.WriteAll in +// production, swapped in tests so the handler can be driven without touching a +// real clipboard. The shape mirrors updater's testHomeDir / version's +// testBrewPrefix — a package-level var a test overrides, not an injected dep. +var writeClipboard = clipboard.WriteAll + +// copyCmd writes text to the system clipboard off the Update thread and reports +// the outcome via copyDoneMsg. The clipboard write shells out (pbcopy / xclip / +// wl-clipboard / PowerShell) and can block, so it must not run inside Update. +func copyCmd(text string) tea.Cmd { + return safeCmd("copyCmd", func() tea.Msg { + if err := writeClipboard(text); err != nil { + return copyDoneMsg{err: err} + } + return copyDoneMsg{n: utf8.RuneCountInString(text)} + }) +} diff --git a/internal/model/commands_test.go b/internal/model/commands_test.go index ff7bc73..400dcc5 100644 --- a/internal/model/commands_test.go +++ b/internal/model/commands_test.go @@ -702,3 +702,32 @@ func TestTokenAcceptedRefetchesEveryRepo(t *testing.T) { } }) } + +func TestCopyDoneMsgHandler(t *testing.T) { + prev := writeClipboard + t.Cleanup(func() { writeClipboard = prev }) + + base := New(nil) + + t.Run("success sets the copied status", func(t *testing.T) { + writeClipboard = func(string) error { return nil } + msg := copyCmd("hello")() + done, ok := msg.(copyDoneMsg) + if !ok || done.n != 5 { + t.Fatalf("copyCmd = %#v, want copyDoneMsg{n:5}", msg) + } + updated, _ := base.Update(done) + if got := updated.(Model).statusMsg; got != "copied 5 characters" { + t.Errorf("statusMsg = %q, want %q", got, "copied 5 characters") + } + }) + + t.Run("failure sets the copy-failed status", func(t *testing.T) { + writeClipboard = func(string) error { return errors.New("no xclip") } + msg := copyCmd("x")() + updated, _ := base.Update(msg) + if got := updated.(Model).statusMsg; got != "copy failed: no xclip" { + t.Errorf("statusMsg = %q, want %q", got, "copy failed: no xclip") + } + }) +} diff --git a/internal/model/model.go b/internal/model/model.go index 6017e1f..e0158ae 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -361,6 +361,19 @@ type Model struct { updateLogFor string updateOutcome updateOutcome + // Mouse-drag selection state for panels [2]/[3]. selActive runs from the + // left-button press to the release; selPanel names the panel being selected + // in (focusBrief or focusHelp). selAnchor/selCursor are content coordinates + // (see selPos); selStyled/selPlain are that panel's content lines — styled + // and ANSI-stripped — snapshotted at press time, so a motion event paints + // without re-rendering the card/README. + selActive bool + selPanel int + selAnchor selPos + selCursor selPos + selStyled []string + selPlain []string + // appVersion is the version of the running binary (ldflag or buildinfo, // injected by WithAppVersion) and the gate for the whole self-update // feature: empty or "dev" means no self-check request and no banner. It is @@ -1214,6 +1227,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case copyDoneMsg: + if msg.err != nil { + return m, m.setStatus("copy failed: " + msg.err.Error()) + } + return m, m.setStatus("copied " + strconv.Itoa(msg.n) + " characters") + case launchDoneMsg: // Tab-open adapter finished. Clear the one-launch-at-a-time guard // first, so the fallback (or the user's retry) can dispatch again. @@ -1428,6 +1447,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: m.statusMsg = "" + // Any keystroke abandons an in-flight mouse drag: a release is not + // always delivered (the button can be let go over another window), and a + // key must never act on content still wearing the selection highlight. + // clearSelection is a no-op when no drag is active. + m.clearSelection() // Every modal return funnels through flushPendingLaunch: the keystroke // that brings the mode back to modeNormal is the single point where a diff --git a/internal/model/mouse_test.go b/internal/model/mouse_test.go index 9ea4f02..b999187 100644 --- a/internal/model/mouse_test.go +++ b/internal/model/mouse_test.go @@ -33,6 +33,17 @@ func leftClick(x, y int) tea.MouseMsg { return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} } +func leftRelease(x, y int) tea.MouseMsg { + return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease} +} + +// clickUpdate sends a press then a release at the same cell — the events a pure +// click (no drag) produces — and returns the model and command after the release. +func clickUpdate(m Model, x, y int) (tea.Model, tea.Cmd) { + nm, _ := m.Update(leftClick(x, y)) + return nm.Update(leftRelease(x, y)) +} + func wheelDown(x, y int) tea.MouseMsg { return tea.MouseMsg{X: x, Y: y, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress} } diff --git a/internal/model/render.go b/internal/model/render.go index 1569a1e..e915293 100644 --- a/internal/model/render.go +++ b/internal/model/render.go @@ -2200,6 +2200,194 @@ func (m Model) hasUpdate(toolName string) bool { return ok && version.IsNewer(vi.Installed, vi.Latest) } +// panelContentStartX returns the first screen column of the given panel's +// viewport content — the column right of its left border — and the content's +// width in columns. Panels sit flush with no outer horizontal margin, so the +// start is pure panel-width arithmetic; the content is one column narrower than +// the panel because withScrollbar keeps the last column for the thumb. +func (m Model) panelContentStartX(focus int) (start, width int, ok bool) { + toolsPanelEnd := m.toolsW + 2 + switch focus { + case focusBrief: + return toolsPanelEnd + 1, m.briefW - 1, true + case focusHelp: + return toolsPanelEnd + m.briefW + 2 + 1, m.helpW - 1, true + default: + return 0, 0, false + } +} + +// mouseSelPos maps a screen X/Y to panel content coordinates (see selPos). X +// maps to a cell column within the viewport; Y maps to a content line through +// panelRow plus the viewport's YOffset. Clicks past the text (the scrollbar +// column, a row beyond the last line) clamp to the widest column; cutCells +// clamps again to each line's real width. +func (m Model) mouseSelPos(focus, x, y int) (selPos, bool) { + startX, vpW, ok := m.panelContentStartX(focus) + if !ok { + return selPos{}, false + } + vp := m.briefViewport + if focus != focusBrief { + vp = m.helpViewport + } + row := panelRow(y, vp.Height) + if row < 0 { + return selPos{}, false + } + col := max(x-startX, 0) + if vpW > 0 { + col = min(col, vpW) + } + return selPos{line: row + vp.YOffset, col: col}, true +} + +// beginSelection snapshots the panel's current content (styled and plain) and +// anchors the selection at pos. The snapshot is what paintSelection repaints, so +// a motion event never re-renders the card or README. +func (m *Model) beginSelection(focus int, pos selPos) { + m.selActive = true + m.selPanel = focus + m.selAnchor = pos + m.selCursor = pos + switch focus { + case focusBrief: + m.selStyled = strings.Split(m.renderCard(), "\n") + case focusHelp: + m.selStyled = strings.Split(m.renderHelpContent(), "\n") + } + m.selPlain = make([]string, len(m.selStyled)) + for i, l := range m.selStyled { + m.selPlain[i] = stripANSI(l) + } +} + +// clearSelection drops the selection state and restores the panel's clean +// content. For [3] it repaints renderHelpContent directly rather than through +// setHelpContent — the latter resets the --help/man spotlight cursor, which a +// drag must not. +func (m *Model) clearSelection() { + if !m.selActive { + return + } + m.selActive = false + m.selAnchor, m.selCursor = selPos{}, selPos{} + m.selStyled, m.selPlain = nil, nil + switch m.selPanel { + case focusBrief: + m.briefViewport.SetContent(m.renderCard()) + case focusHelp: + m.helpViewport.SetContent(m.renderHelpContent()) + } + m.selPanel = 0 +} + +// paintSelection repaints the selected panel's viewport with the selection +// highlight and keeps the cursor visible (auto-scrolling at the edges so a drag +// can extend past the visible window). +func (m *Model) paintSelection() { + if !m.selActive { + return + } + start, end := selOrdered(m.selAnchor, m.selCursor) + content := highlightSelection(m.selStyled, start, end) + if m.selPanel == focusBrief { + m.briefViewport.SetContent(content) + } else { + m.helpViewport.SetContent(content) + } + + vp := &m.briefViewport + if m.selPanel != focusBrief { + vp = &m.helpViewport + } + line := m.selCursor.line + total := len(m.selStyled) + switch { + case line < vp.YOffset: + vp.SetYOffset(line) + case line > vp.YOffset+vp.Height-1: + vp.SetYOffset(line - vp.Height + 1) + case line == vp.YOffset && vp.YOffset > 0: + vp.SetYOffset(vp.YOffset - 1) + case line == vp.YOffset+vp.Height-1 && vp.YOffset+vp.Height < total: + vp.SetYOffset(vp.YOffset + 1) + } +} + +// handleSelectableMouse drives drag selection for a panel that supports it +// (brief or help). Press anchors, motion extends, release copies the plain text +// of a non-empty selection. openLink, when non-nil, is consulted on a pure click +// (press+release without motion) so the brief card's links still open in the +// browser. Returns the command to run, if any. +func (m Model) handleSelectableMouse(focus int, msg tea.MouseMsg, clickable bool, openLink func(line int) tea.Cmd) (tea.Model, tea.Cmd) { + switch msg.Button { + case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown: + var vp *viewport.Model + if focus == focusBrief { + vp = &m.briefViewport + } else { + vp = &m.helpViewport + } + var c tea.Cmd + *vp, c = vp.Update(msg) + return m, c + case tea.MouseButtonLeft: + if !clickable { + return m, nil + } + switch msg.Action { + case tea.MouseActionPress: + m.setFocus(focus) + if pos, ok := m.mouseSelPos(focus, msg.X, msg.Y); ok { + m.beginSelection(focus, pos) + m.paintSelection() + } + return m, nil + case tea.MouseActionMotion: + if !m.selActive || m.selPanel != focus { + return m, nil + } + if pos, ok := m.mouseSelPos(focus, msg.X, msg.Y); ok { + m.selCursor = pos + m.paintSelection() + } + return m, nil + case tea.MouseActionRelease: + if m.selActive && m.selPanel == focus { + start, end := selOrdered(m.selAnchor, m.selCursor) + text, n := selectedText(m.selPlain, start, end) + m.clearSelection() + if n > 0 { + return m, copyCmd(text) + } + } + // A pure click (no motion) is not a selection: open the link, if + // the panel has one under the cursor. + if openLink != nil { + if row := panelRow(msg.Y, m.briefViewport.Height); row >= 0 { + line := row + m.briefViewport.YOffset + if cmd := openLink(line); cmd != nil { + return m, cmd + } + } + } + return m, nil + } + } + return m, nil +} + +// briefLinkCmd returns the browser-open command for a clickable card line, or +// nil when the line is not a link. Extracted so the drag-continuation path in +// handleMouse and the normal brief branch share one definition. +func (m Model) briefLinkCmd(line int) tea.Cmd { + if _, links := m.buildCard(); links[line] != "" { + return openURLCmd(links[line]) + } + return nil +} + func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Mouse policy: wheel scrolls in every mode, clicks (selection/focus) only // in modeNormal — while an input mode owns the keyboard a click must not @@ -2210,21 +2398,27 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } clickable := m.mode == modeNormal + // A drag belongs to the panel where it was anchored, not where the cursor + // currently is: motion and release must finalize through the anchoring + // panel even if the cursor drifts over [1], the status bar or outside the + // viewport — otherwise a release outside [2]/[3] would strand selActive and + // leave the highlight frozen on screen. + if m.selActive && msg.Button == tea.MouseButtonLeft && + (msg.Action == tea.MouseActionMotion || msg.Action == tea.MouseActionRelease) { + if m.selPanel == focusBrief { + return m.handleSelectableMouse(focusBrief, msg, clickable, m.briefLinkCmd) + } + return m.handleSelectableMouse(focusHelp, msg, clickable, nil) + } + // Panels sit flush (each is panelW+2 wide incl. borders) with no outer // horizontal margin, so screen X maps directly to panel spans. toolsPanelEnd := m.toolsW + 2 briefPanelEnd := toolsPanelEnd + m.briefW + 2 - // X alone does not mean "inside a panel": the outer Margin(1,0) row, the - // panel borders and the status/hints bars share the panels' columns. Rows - // outside the viewport must not be mapped onto content — a stray Y there - // once resolved to a card link and opened the browser from a click on empty - // chrome. - - // Detect which panel the click is in var cmd tea.Cmd if msg.X < toolsPanelEnd { - // Left panel (Tools) + // Left panel (Tools) — no drag selection here. if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress && clickable { // Any click in the panel focuses it, matching brief/help. m.setFocus(focusTools) @@ -2244,36 +2438,12 @@ func (m Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { m.toolsViewport, cmd = m.toolsViewport.Update(msg) } } else if msg.X < briefPanelEnd { - // Middle panel (Brief) - switch msg.Button { - case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown: - m.briefViewport, cmd = m.briefViewport.Update(msg) - case tea.MouseButtonLeft: - if msg.Action == tea.MouseActionPress && clickable { - m.setFocus(focusBrief) - // Clickable links: the repo line and the changelog release URL - // open in the browser, like the [o] and [c] keys. The index is - // recomputed here rather than cached in the model — it is one - // card render per click and can never go stale against the - // content actually on screen. - if row := panelRow(msg.Y, m.briefViewport.Height); row >= 0 { - line := row + m.briefViewport.YOffset - if _, links := m.buildCard(); links[line] != "" { - return m, openURLCmd(links[line]) - } - } - } - } + // Middle panel (Brief): drag selects text; a pure click opens a + // clickable link (repo line, changelog heading), like [o]/[c]. + return m.handleSelectableMouse(focusBrief, msg, clickable, m.briefLinkCmd) } else { - // Right panel (Help) - switch msg.Button { - case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown: - m.helpViewport, cmd = m.helpViewport.Update(msg) - case tea.MouseButtonLeft: - if msg.Action == tea.MouseActionPress && clickable { - m.setFocus(focusHelp) - } - } + // Right panel (Help): drag selects text. + return m.handleSelectableMouse(focusHelp, msg, clickable, nil) } return m, cmd } diff --git a/internal/model/selection_test.go b/internal/model/selection_test.go new file mode 100644 index 0000000..36f259d --- /dev/null +++ b/internal/model/selection_test.go @@ -0,0 +1,215 @@ +package model + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// TestMouseXToColumn pins the screen-X → content-column geometry that drag +// selection rests on: the brief content starts one column right of the tools +// panel's right border, is briefW-1 columns wide (the last column is the +// scrollbar), and YOffset shifts the mapped line. +func TestMouseXToColumn(t *testing.T) { + m := newMouseTestModel(t, 120, 24, "alpha", "beta") + + start, width, ok := m.panelContentStartX(focusBrief) + if !ok { + t.Fatal("panelContentStartX(focusBrief) = not ok") + } + if start != m.toolsW+3 { + t.Errorf("brief content start = %d, want toolsW+3 = %d", start, m.toolsW+3) + } + if width != m.briefW-1 { + t.Errorf("brief content width = %d, want briefW-1 = %d", width, m.briefW-1) + } + + // Y=2 is the first content row (margin=0, border=1). + if pos, ok := m.mouseSelPos(focusBrief, start, 2); !ok || pos != (selPos{0, 0}) { + t.Errorf("mouseSelPos(briefStart, 2) = %+v/%v, want line 0 col 0", pos, ok) + } + if pos, ok := m.mouseSelPos(focusBrief, start+3, 2); !ok || pos.col != 3 { + t.Errorf("mouseSelPos(briefStart+3, 2) col = %d, want 3", pos.col) + } + // A click on the scrollbar column clamps to the content width. + if pos, _ := m.mouseSelPos(focusBrief, start+width+1, 2); pos.col != width { + t.Errorf("mouseSelPos(scrollbar) col = %d, want clamped to %d", pos.col, width) + } + + m.briefViewport.SetContent(strings.Repeat("line\n", 40)) + m.briefViewport.SetYOffset(2) + if pos, _ := m.mouseSelPos(focusBrief, start, 2); pos.line != 2 { + t.Errorf("mouseSelPos with YOffset=2 line = %d, want 2", pos.line) + } +} + +// TestDragSelectionCopiesOnRelease drives a full drag over the brief card's +// title line and asserts the clipboard seam receives the plain text of the +// selection, the copy command reports a positive count, and the selection state +// is cleared. +func TestDragSelectionCopiesOnRelease(t *testing.T) { + prev := writeClipboard + var copied string + writeClipboard = func(s string) error { copied = s; return nil } + t.Cleanup(func() { writeClipboard = prev }) + + m := newMouseTestModel(t, 120, 24, "gh") + lines := strings.Split(m.renderCard(), "\n") + if len(lines) < 2 { + t.Fatalf("setup: card has %d lines", len(lines)) + } + want := stripANSI(lines[1]) // the title line, line 1 (line 0 is the blank top row) + + start, _, _ := m.panelContentStartX(focusBrief) + press := tea.MouseMsg{X: start, Y: 1 + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + motion := tea.MouseMsg{X: start + 10, Y: 1 + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion} + release := tea.MouseMsg{X: start + 10, Y: 1 + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease} + + updated, _ := m.Update(press) + nm := updated.(Model) + if !nm.selActive { + t.Fatal("press did not begin a selection") + } + + updated, _ = nm.Update(motion) + nm = updated.(Model) + updated, cmd := nm.Update(release) + nm = updated.(Model) + if cmd == nil { + t.Fatal("release returned no copy command") + } + msg := cmd() + if _, ok := msg.(copyDoneMsg); !ok { + t.Fatalf("release command = %#v, want copyDoneMsg", msg) + } + if copied != want { + t.Errorf("copied = %q, want %q", copied, want) + } + if nm.selActive { + t.Errorf("selection still active after release") + } +} + +// TestDragStartingOnLinkDoesNotOpen: a drag that begins on a clickable link line +// must copy the selection, not open the browser — the link only opens on a pure +// click (press+release with no motion). +func TestDragStartingOnLinkDoesNotOpen(t *testing.T) { + prev := writeClipboard + writeClipboard = func(string) error { return nil } + t.Cleanup(func() { writeClipboard = prev }) + + m := linkedCardModel(t, linkRepo) + m.changelogData["gh"] = changelogMsg{toolName: "gh", htmlUrl: linkRelURL, body: "release notes"} + m.briefViewport.SetContent(m.renderCard()) + + _, links := m.buildCard() + var repoLine = -1 + for line, url := range links { + if url == "https://"+linkRepo { + repoLine = line + } + } + if repoLine < 0 { + t.Fatal("setup: no repo link line") + } + + start, _, _ := m.panelContentStartX(focusBrief) + press := tea.MouseMsg{X: start, Y: repoLine + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + motion := tea.MouseMsg{X: start + 6, Y: repoLine + 1 + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion} + release := tea.MouseMsg{X: start + 6, Y: repoLine + 1 + 2, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease} + + updated, _ := m.Update(press) + updated, _ = updated.(Model).Update(motion) + _, cmd := updated.(Model).Update(release) + if cmd == nil { + t.Fatal("drag release returned no command") + } + if _, ok := cmd().(copyDoneMsg); !ok { + t.Errorf("drag over a link dispatched a browser/copy-adjacent command, want copyDoneMsg") + } +} + +// TestEmptyClickNoCopy: a pure click on a non-link line starts and ends a +// selection without copying, and dispatches no command. +func TestEmptyClickNoCopy(t *testing.T) { + prev := writeClipboard + called := false + writeClipboard = func(string) error { called = true; return nil } + t.Cleanup(func() { writeClipboard = prev }) + + m := newMouseTestModel(t, 120, 24, "gh") + start, _, _ := m.panelContentStartX(focusBrief) + + updated, _ := m.Update(leftClick(start, 2+2)) + updated, cmd := updated.(Model).Update(leftRelease(start, 2+2)) + if cmd != nil { + t.Errorf("empty click returned a command %#v", cmd) + } + if called { + t.Errorf("empty click wrote to the clipboard") + } + if updated.(Model).selActive { + t.Errorf("selection still active after empty click") + } +} + +// TestDragReleaseOutsidePanelFinalizes: a drag that begins in the brief panel +// but is released with the cursor over the tools panel (or elsewhere outside +// [2]/[3]) must still copy and clear the selection — the drag belongs to the +// panel where it was anchored, not where the cursor ended up. +func TestDragReleaseOutsidePanelFinalizes(t *testing.T) { + prev := writeClipboard + var copied string + writeClipboard = func(s string) error { copied = s; return nil } + t.Cleanup(func() { writeClipboard = prev }) + + m := newMouseTestModel(t, 120, 24, "gh") + start, _, _ := m.panelContentStartX(focusBrief) + press := tea.MouseMsg{X: start, Y: 3, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + motion := tea.MouseMsg{X: start + 6, Y: 4, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion} + // Released over the tools panel (X=1), well outside the brief content. + release := tea.MouseMsg{X: 1, Y: 4, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease} + + updated, _ := m.Update(press) + updated, _ = updated.(Model).Update(motion) + updated, cmd := updated.(Model).Update(release) + if cmd == nil { + t.Fatal("release outside the panel returned no copy command") + } + if _, ok := cmd().(copyDoneMsg); !ok { + t.Errorf("release outside the panel dispatched %#v, want copyDoneMsg", cmd()) + } + if copied == "" { + t.Error("nothing was copied") + } + if updated.(Model).selActive { + t.Error("selection still active after releasing outside the panel") + } +} + +func TestSelectionDisabledUnderOverlayAndNonNormal(t *testing.T) { + prev := writeClipboard + called := false + writeClipboard = func(string) error { called = true; return nil } + t.Cleanup(func() { writeClipboard = prev }) + + for _, mode := range []inputMode{modeEditNote, modeAPIStatus} { + m := newMouseTestModel(t, 120, 24, "gh") + m.mode = mode + start, _, _ := m.panelContentStartX(focusBrief) + press := tea.MouseMsg{X: start, Y: 3, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress} + motion := tea.MouseMsg{X: start + 5, Y: 4, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion} + release := tea.MouseMsg{X: start + 5, Y: 4, Button: tea.MouseButtonLeft, Action: tea.MouseActionRelease} + + updated, _ := m.Update(press) + updated, _ = updated.(Model).Update(motion) + updated, cmd := updated.(Model).Update(release) + if updated.(Model).selActive { + t.Errorf("mode %d: selection began while the keyboard was owned", mode) + } + if cmd != nil || called { + t.Errorf("mode %d: selection copied while the keyboard was owned", mode) + } + } +} diff --git a/internal/model/textutil.go b/internal/model/textutil.go index 4bc5f9e..fe0291c 100644 --- a/internal/model/textutil.go +++ b/internal/model/textutil.go @@ -11,6 +11,8 @@ import ( "unicode/utf8" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" "github.com/stanlyzoolo/keepkit/internal/ui" "github.com/stanlyzoolo/keepkit/internal/version" @@ -675,6 +677,138 @@ func stripANSI(s string) string { return ui.StripANSI(s) } +// selPos is a position in panel content coordinates. line is a 0-based content +// line index (bubbles/viewport is line-based — no soft wrap — so a content line +// is a screen line); col is a 0-based cell column within that line's plain text, +// measured with runewidth, the same width lipgloss.Width uses. Living in content +// coordinates is what keeps the mouse-drag selection above scrolling: YOffset is +// added only when mapping a screen row back to a line, never stored. +type selPos struct { + line int + col int +} + +// selOrdered normalizes an anchor/cursor pair into [start, end), so a selection +// dragged upward or leftward is the same range as one dragged the other way. +func selOrdered(a, b selPos) (selPos, selPos) { + if b.line < a.line || (b.line == a.line && b.col < a.col) { + return b, a + } + return a, b +} + +// selStyle is the paint for a mouse selection: reverse video, not a theme role. +// Selection is a transient highlight laid over *other* content's own colors, so +// it is not a meaning Theme has a word for — the same reasoning that keeps +// HeadingColors/LanguageColor outside the theme switch. +var selStyle = lipgloss.NewStyle().Reverse(true) + +// highlightLine repaints the cell columns [from, to) of a styled content line +// with the selection style, preserving the original styling of the prefix and +// suffix. ansi.Cut is ANSI- and wide-char-aware, so the two cuts cannot split an +// escape sequence; the selected middle is stripped and repainted whole — the +// codebase's strip-then-repaint rule, which exists because styling must never be +// cut mid-sequence and re-emitted to a terminal verbatim. An empty range is a +// no-op that returns the line untouched. +func highlightLine(styled string, from, to int) string { + if from >= to { + return styled + } + // Width measured in ansi.Cut's own units (GraphemeWidth), not runewidth, so + // the three Cut calls below partition the line exactly — prefix [0,from) + + // middle [from,to) + suffix [to,w) == [0,w) by construction, with no cell + // dropped or duplicated. runewidth and displaywidth agree on every glyph + // keepkit renders (single BMP codepoints, no ZWJ emoji), so this matches the + // copy side's runewidth columns. + w := ansi.StringWidth(styled) + from = max(from, 0) + to = min(to, w) + if from >= to { + return styled + } + if from == 0 && to >= w { + return selStyle.Render(stripANSI(styled)) + } + return ansi.Cut(styled, 0, from) + + selStyle.Render(stripANSI(ansi.Cut(styled, from, to))) + + ansi.Cut(styled, to, w) +} + +// cutCells returns the substring of s spanning cell columns [from, to), measured +// with runewidth. A wide rune straddling a boundary is kept whole — a cell +// column cannot be half-copied. +func cutCells(s string, from, to int) string { + width := runewidth.StringWidth(s) + from = max(from, 0) + to = min(to, width) + if from >= to { + return "" + } + var b strings.Builder + col := 0 + for _, r := range s { + rw := runewidth.RuneWidth(r) + if col >= to { + break + } + if col+rw > from { + b.WriteRune(r) + } + col += rw + } + return b.String() +} + +// selectedText extracts the plain (ANSI-stripped) text of the selection from +// plainLines and returns it with the number of runes copied. The first and last +// lines are cut to their cell columns; intermediate lines are copied whole. +// Returns "" and 0 for an empty or out-of-range selection. +func selectedText(plainLines []string, start, end selPos) (string, int) { + if len(plainLines) == 0 || start.line < 0 || start.line >= len(plainLines) { + return "", 0 + } + end.line = min(end.line, len(plainLines)-1) + if start.line > end.line { + return "", 0 + } + if start.line == end.line { + cut := cutCells(plainLines[start.line], start.col, end.col) + return cut, utf8.RuneCountInString(cut) + } + var sb strings.Builder + sb.WriteString(cutCells(plainLines[start.line], start.col, runewidth.StringWidth(plainLines[start.line]))) + sb.WriteByte('\n') + for i := start.line + 1; i < end.line; i++ { + sb.WriteString(plainLines[i]) + sb.WriteByte('\n') + } + sb.WriteString(cutCells(plainLines[end.line], 0, end.col)) + return sb.String(), utf8.RuneCountInString(sb.String()) +} + +// highlightSelection repaints the cell range [start, end) over styled content +// lines. Lines before start and after end pass through untouched; the boundary +// lines are cut to their columns and intermediate lines are repainted whole. +// Pure, so it is table-testable without a terminal. +func highlightSelection(styled []string, start, end selPos) string { + out := make([]string, len(styled)) + copy(out, styled) + if len(out) == 0 { + return "" + } + for i := start.line; i <= end.line && i < len(out); i++ { + from, to := 0, ansi.StringWidth(styled[i]) + if i == start.line { + from = start.col + } + if i == end.line { + to = end.col + } + out[i] = highlightLine(out[i], from, to) + } + return strings.Join(out, "\n") +} + // isTUITakeover reports whether captured probe output shows the tool started // a full-screen TUI instead of printing help: entering or leaving the // alternate screen (ESC[?1049h/l) is the one sequence real help text never diff --git a/internal/model/textutil_test.go b/internal/model/textutil_test.go index 5591d25..e9818ce 100644 --- a/internal/model/textutil_test.go +++ b/internal/model/textutil_test.go @@ -2,8 +2,11 @@ package model import ( "slices" + "strings" "testing" "time" + + "github.com/charmbracelet/lipgloss" ) func TestMdInline(t *testing.T) { @@ -496,3 +499,93 @@ func TestMarkdownToLinesFenceKind(t *testing.T) { } } } + +func TestHighlightLine(t *testing.T) { + t.Run("empty or inverted range is a no-op", func(t *testing.T) { + if got := highlightLine("abcde", 2, 2); got != "abcde" { + t.Errorf("highlightLine(2,2) = %q, want the line untouched", got) + } + if got := highlightLine("abcde", 3, 1); got != "abcde" { + t.Errorf("highlightLine(3,1) = %q, want the line untouched", got) + } + }) + + t.Run("preserves the text of styled input", func(t *testing.T) { + styled := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")).Render("hello world") + if got := stripANSI(highlightLine(styled, 0, 5)); got != "hello world" { + t.Errorf("highlight changed the text to %q", got) + } + }) + + t.Run("full line repaints whole", func(t *testing.T) { + if got := highlightLine("abc", 0, 3); got != selStyle.Render("abc") { + t.Errorf("highlightLine(0,3) = %q, want %q", got, selStyle.Render("abc")) + } + }) + + t.Run("middle range repaints only the middle", func(t *testing.T) { + got := highlightLine("abcde", 1, 3) + want := "a" + selStyle.Render("bc") + "de" + if got != want { + t.Errorf("highlightLine(1,3) = %q, want %q", got, want) + } + }) + + t.Run("out-of-range clamps to the line", func(t *testing.T) { + if got := highlightLine("abc", -1, 99); got != selStyle.Render("abc") { + t.Errorf("highlightLine(-1,99) = %q, want full-line repaint", got) + } + }) +} + +func TestSelectedText(t *testing.T) { + t.Run("single line partial", func(t *testing.T) { + got, n := selectedText([]string{"abcdef"}, selPos{0, 1}, selPos{0, 4}) + if got != "bcd" || n != 3 { + t.Errorf("selectedText = %q/%d, want %q/3", got, n, "bcd") + } + }) + + t.Run("multi-line with partial edges", func(t *testing.T) { + got, n := selectedText([]string{"aa", "bb", "cc"}, selPos{0, 1}, selPos{2, 1}) + if got != "a\nbb\nc" || n != 6 { + t.Errorf("selectedText = %q/%d, want %q/6", got, n, "a\nbb\nc") + } + }) + + t.Run("empty range returns nothing", func(t *testing.T) { + got, n := selectedText([]string{"abc"}, selPos{0, 1}, selPos{0, 1}) + if got != "" || n != 0 { + t.Errorf("selectedText(empty) = %q/%d, want \"\"/0", got, n) + } + }) + + t.Run("out-of-range clamps to line end", func(t *testing.T) { + got, _ := selectedText([]string{"abcdef"}, selPos{0, 1}, selPos{0, 99}) + if got != "bcdef" { + t.Errorf("selectedText(clamped) = %q, want %q", got, "bcdef") + } + }) + + t.Run("wide glyph is kept whole", func(t *testing.T) { + // '界' is two cells wide; a cell range [1,3) that straddles it must not + // split the rune. + got, _ := selectedText([]string{"a界b"}, selPos{0, 1}, selPos{0, 3}) + if got != "界" { + t.Errorf("selectedText(wide) = %q, want %q", got, "界") + } + }) +} + +func TestHighlightSelection(t *testing.T) { + styled := []string{"aa", "bb", "cc"} + got := highlightSelection(styled, selPos{0, 1}, selPos{2, 1}) + want := []string{ + "a" + selStyle.Render("a"), + selStyle.Render("bb"), + selStyle.Render("c") + "c", + } + if got != strings.Join(want, "\n") { + t.Errorf("highlightSelection =\n%q\nwant\n%q", got, strings.Join(want, "\n")) + } +}