Preserve cell colours in the overlay repaint #2

Open
opened 2026-09-19 16:27:44 +09:00 by natsukium · 1 comment
Owner

Summary

Carry each cell's SGR style through source::Row and repaint it in the picker, so the
overlay looks like the buffer the producer captured instead of a flat dim block.

Why

docs/research.md "Edge-case handling across competitors" records Color preservation as
❌ plain for every competitor (kitty, tmux-thumbs, tmux-fingers). They all repaint from
plain text, so they have nothing to repaint with.

spoor is the one tool that does not: it parses the raw bytes itself through felis-vt into
a felis-grid cell grid (docs/design.md "Alignment"), and felis_grid::Cell already carries
style: StyleId, resolvable via Grid::style. The information is sitting in the grid and is
being thrown away:

  • src/source.rs read_row builds source::Cell { text, col, width } and drops cell.style.
  • src/picker.rs draw collects a row into one String and prints it under a single
    Attribute::Dim.

Since the redraw-style renderer paints text and labels from the same grid, preserving the pen
costs no accuracy — it is the same cells, emitted with their attributes.

Sketch

  • source::Cell gains the resolved attributes (fg / bg / bold / italic / underline / reverse …),
    read from Grid::style(cell.style) in read_row.
  • picker::draw emits per style-run rather than one Print per row: set the pen, print the run,
    reset at the row end. Runs, not per cell, so a colourful row is not one escape per glyph.
  • Labels keep winning: they are drawn after the text with their own colours, unchanged.
  • The current flat dim becomes a flag (--dim?) rather than the only behaviour — dimming exists
    to make labels stand out, and with real colours it should be the user's choice.

Producer requirement

Colour survives only when the producer did not strip it — the same blind-pipe rule as
docs/research.md "Soft-wrapped matches": information the producer discarded cannot be
recovered, and spoor does not guess it back.

Producer What it takes
tmux capture-pane -p strips SGR; needs -e (e.g. tmux capture-pane -J -e -p)
kitty kitty @ get-text --ansi
plain pipe most tools disable colour off a tty: git log --color=always, rg --color=always, …
felis pipe action the daemon's region serialization branches on ansi (row_ansi vs row_text_trim); it has to be the ansi side

When the input is already colourless this degrades to exactly today's rendering, so there is no
regression for the plain producers. --help should say so, the way it already does for
soft-wrap needing a logical source.

Rendering is producer-independent: the overlay writes to /dev/tty via crossterm, so the
colours show in whatever terminal spoor was launched from (truecolor subject to that
terminal's support).

Out of scope

OSC 8 hyperlinks (Cell.link + Grid::hyperlink) are a separate feature — a match source,
not a rendering concern — and need felis to pass a link table when re-encoding
(RowAnsiOptions.links: None emits no OSC 8). Filed separately if we do it.

## Summary Carry each cell's SGR style through `source::Row` and repaint it in the picker, so the overlay looks like the buffer the producer captured instead of a flat dim block. ## Why `docs/research.md` "Edge-case handling across competitors" records **Color preservation** as `❌ plain` for *every* competitor (kitty, tmux-thumbs, tmux-fingers). They all repaint from plain text, so they have nothing to repaint with. `spoor` is the one tool that does not: it parses the raw bytes itself through `felis-vt` into a `felis-grid` cell grid (`docs/design.md` "Alignment"), and `felis_grid::Cell` already carries `style: StyleId`, resolvable via `Grid::style`. The information is sitting in the grid and is being thrown away: - `src/source.rs` `read_row` builds `source::Cell { text, col, width }` and drops `cell.style`. - `src/picker.rs` `draw` collects a row into one `String` and prints it under a single `Attribute::Dim`. Since the redraw-style renderer paints text and labels from the same grid, preserving the pen costs no accuracy — it is the same cells, emitted with their attributes. ## Sketch - `source::Cell` gains the resolved attributes (fg / bg / bold / italic / underline / reverse …), read from `Grid::style(cell.style)` in `read_row`. - `picker::draw` emits per style-run rather than one `Print` per row: set the pen, print the run, reset at the row end. Runs, not per cell, so a colourful row is not one escape per glyph. - Labels keep winning: they are drawn after the text with their own colours, unchanged. - The current flat dim becomes a flag (`--dim`?) rather than the only behaviour — dimming exists to make labels stand out, and with real colours it should be the user's choice. ## Producer requirement Colour survives only when the producer did not strip it — the same blind-pipe rule as `docs/research.md` "Soft-wrapped matches": information the producer discarded cannot be recovered, and `spoor` does not guess it back. | Producer | What it takes | | --- | --- | | tmux | `capture-pane -p` strips SGR; needs `-e` (e.g. `tmux capture-pane -J -e -p`) | | kitty | `kitty @ get-text --ansi` | | plain pipe | most tools disable colour off a tty: `git log --color=always`, `rg --color=always`, … | | felis pipe action | the daemon's region serialization branches on `ansi` (`row_ansi` vs `row_text_trim`); it has to be the ansi side | When the input is already colourless this degrades to exactly today's rendering, so there is no regression for the plain producers. `--help` should say so, the way it already does for soft-wrap needing a logical source. Rendering is producer-independent: the overlay writes to `/dev/tty` via crossterm, so the colours show in whatever terminal `spoor` was launched from (truecolor subject to that terminal's support). ## Out of scope OSC 8 hyperlinks (`Cell.link` + `Grid::hyperlink`) are a separate feature — a match *source*, not a rendering concern — and need felis to pass a link table when re-encoding (`RowAnsiOptions.links: None` emits no OSC 8). Filed separately if we do it.
Author
Owner

Implementation plan

Goal

Repaint the overlay with each cell's SGR pen (fg / bg / underline colour,
bold / faint / italic / underline shape / blink / reverse / conceal /
strikethrough / overline) as felis-grid parsed it, including styled blanks
(BCE), so the picker shows the styles the producer captured. Labels keep
drawing on top, unchanged. The flat Dim becomes opt-in.

What "preserve" means, precisely: the per-cell SGR state. Indexed colours
stay indices and default colours stay defaults; the terminal spoor runs in
resolves them through its palette and theme. Producer-side palette
overrides (OSC 4), theme overrides (OSC 10/11) and DECSCNM reverse video are
grid-global state in felis, not per-cell, and are not carried. This is the
same delegation the producer's own output would get when replayed into that
terminal.

Where the information is and where it is lost today

  • felis_grid::Cell.style: StyleId is grid-local; Grid::style(id) -> &Attributes resolves it. Attributes is Copy and carries fg, bg,
    underline_color (Color::Default = follow fg), flags: AttrFlags,
    underline_style.
  • source::read_row drops cell.style. The Grid is dropped at the end of
    source::screen, so the resolution has to happen there — a StyleId is
    meaningless afterwards.
  • source::read_row also drops every Grapheme::Empty, which is where felis
    keeps a pen-coloured erased cell (BCE): a full-width diff-line background,
    a status bar's trailing colour.
  • picker::draw joins a row's cell texts into one String and prints it
    under Attribute::Dim.

Side finding: that join also loses column gaps. felis' HT moves the
cursor without writing spaces, so a tab-aligned row (ls -l, go test
output) repaints with the text pulled left of where the labels (which use
cell.col) land. Per-run MoveTo below fixes that for free.

Design

1. Data: source::Cell gains a resolved style, and blanks become cells

// src/source.rs
pub enum Content {
    /// One scalar, or a multi-codepoint cluster.
    Glyph(String),
    /// An erased cell that still carries a pen — painted, never matched.
    Blank,
}

pub struct Cell {
    pub content: Content,
    pub col: u16,
    pub width: u16,
    /// The cell's pen, resolved from the grid's style table at read time —
    /// a StyleId does not outlive the grid.
    pub style: Style,
}

Style is spoor's own type, defined in a new src/style.rs, expressed in
crossterm vocabulary so the picker consumes it with no translation:

// src/style.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Style {
    pub fg: Color,               // crossterm::style::Color, Reset = default
    pub bg: Color,
    pub underline: Color,        // Reset = follow fg (what SGR 58 absent means)
    pub attrs: Attributes,       // crossterm::style::Attributes bitset
}

// crossterm's Color has no Default impl, so this is written by hand:
// Reset / Reset / Reset / Attributes::default().
impl Default for Style { ... }

impl From<&felis_grid::Attributes> for Style { ... }

Mapping (feliscrossterm):

felis crossterm
Color::Default Color::Reset
Color::Indexed(n) Color::AnsiValue(n)
Color::Rgb(r,g,b) Color::Rgb{r,g,b}
BOLD Bold
FAINT Dim
ITALIC Italic
UNDERLINE + Single/Double/Curly/Dotted/Dashed Underlined / DoubleUnderlined / Undercurled / Underdotted / Underdashed
BLINK SlowBlink
REVERSE Reverse
CONCEAL Hidden
STRIKETHROUGH CrossedOut
OVERLINE OverLined
PROTECTED, ISO_PROTECTED dropped — not SGR, not visible

Why a spoor type rather than storing felis_grid::Attributes: source.rs
is already the one felis boundary (matcher and picker never see felis
types), and picker::Colors is already crossterm-typed. Putting the
translation at the boundary keeps that layering. Why not translate in the
picker: then source would export a felis type and every consumer would
need felis in scope. Cost accepted: source::Cell now names crossterm
types, tying the source model to the current renderer. Fine for an
unpublished crate; if a second renderer ever appears, introduce spoor-owned
colour/attribute types rather than switching to felis'.

Why no truecolor down-mapping: rendering is producer-independent and goes
through crossterm to /dev/tty; the terminal spoor runs in decides what
38;2;r;g;b means, exactly as it would for the producer's own output.
felis' AnsiCaps.truecolor down-mapping exists for hosts that cannot render
RGB; spoor has no such host knowledge and should not guess (same rule as
column facts). Revisit trigger: a real terminal that renders RGB as garbage.

read_row changes:

  • every kept cell gets style: Style::from(grid.style(cell.style)). A wide
    glyph's Spacer carries the owner's StyleId in felis (editing.rs
    copies owner.style), so skipping the spacer loses nothing.
  • a Grapheme::Empty whose resolved Style is not Style::default() is
    kept as Content::Blank, width 1. felis builds the erase blank with
    style: self.pen_style and extends the row watermark over a pen-coloured
    blank, so these are real content. A default-styled Empty is still
    skipped: it is what Clear already gives.

Consumers of Content::Blank:

  • matcher::layout appends only Glyph text and spans; a Blank
    contributes no bytes, so matching is unchanged from today.
  • picker paints a Blank as one space under its style.
  • main's trailing-row trim (cells.is_empty()) keeps a row that holds
    only styled blanks, which is right: it is visible content.

2. Rendering: runs, not rows, not cells

picker::draw repaints each visible row as style runs: maximal stretches
of cells that are column-contiguous (next.col == prev.col + prev.width)
and share one Style. Each run is emitted as:

MoveTo(run.col, screen_row)
SetAttribute(Reset)            // no carry-over from the previous run
SetForegroundColor(style.fg)
SetBackgroundColor(style.bg)
SetUnderlineColor(style.underline)
SetAttributes(style.attrs)     // + Dim when --dim; LAST, see NO_COLOR
Print(run.text)

and each row ends with one SetAttribute(Reset) — the invariant the label
pass already relies on (it sets fg/bg/Bold without a leading reset). Not
ResetColor as well: in crossterm 0.28 ResetColor writes CSI 0 m, the
same full reset, so pairing them is two resets.

The leading reset per run mirrors felis' row_ansi choice: re-specify the
pen on every change instead of minimal-diffing, so a stale attribute can
never leak and the byte stream is deterministic enough to snapshot in tests.
Cost is one reset per style change, not per glyph.

Colours before attributes, because of NO_COLOR. crossterm honours
NO_COLOR: with it set, every colour command still writes CSI + m around
an empty colour, i.e. ESC[m, a full reset. Emitting attributes after the
colour commands means that under NO_COLOR the repaint loses colours (as the
convention asks) but keeps bold / underline / reverse and --dim, instead of
being silently flattened. The alternative, force_color_output(true) on the
grounds that the repaint is the user's own content, was considered and
rejected for now: spoor's labels are its own decoration and the existing
label pass already goes colourless under NO_COLOR, so the overlay should
follow one rule. Revisit if a NO_COLOR user reports labels being lost.
The same ordering applies to the label pass.

Reset before Clear. draw starts with Clear(ClearType::All).
Entering the alternate screen does not reset the pen, and on a BCE terminal
a clear under an inherited background paints every untouched cell with it.
Queue SetAttribute(Reset) before the clear on every draw.

A MoveTo per run (rather than only on style change) is what fixes the
tab-gap bug above: a gap is a run boundary, and the next run positions
itself by its own column.

Extract the grouping as a pure function so it is testable without a tty:

struct Run { col: u16, style: Style, text: String }   // Style is Copy
fn runs(row: &Row) -> Vec<Run>

A Blank cell contributes " " to its run's text.

Make draw generic over W: Write (crossterm's queue! already is), so a
test can render into a Vec<u8> and assert on the bytes. pick keeps
passing the /dev/tty File.

Labels: drawn after the text, with Reset → label colours → Bold
PrintReset (same colours-then-attributes order, one trailing reset).
They still win because they are painted last at the match column, over
whatever the source had there — including Hidden, Reverse or a coloured
background, since the leading reset discards it.

Scroll indicator: unchanged (it already resets around itself).

3. Flag: --dim

/// Dim the repainted text so labels stand out more. Off by default: the
/// repaint carries the source's own colours, and the labels already have
/// their own foreground/background. Composes with the source style (a bold
/// red cell stays bold red, dimmed).
#[arg(long)]
dim: bool,

Implementation: when set, Dim is OR-ed into every text run's attributes
before emission. picker::Colors is renamed Theme and gains dim: bool;
it is the overlay look bundle, and extending it keeps pick's arity flat.

Why opt-in rather than --no-dim: the point of the feature is a faithful
repaint; dimming everything was a substitute for having colours. The one
visible change for existing plain-input users is "no longer dim"; they get
it back with --dim. Called out in the commit body and README. The
single-match fast path in main bypasses the picker, so --dim has no
visible effect there, like every other look flag.

4. Producer requirement (documentation only)

Colour survives only if the producer kept it. --help long_about gets a
paragraph beside the soft-wrap one:

The repaint carries the source's colours when the producer kept them:
tmux capture-pane -e, kitty @ get-text --ansi, --color=always on a
plain pipe, felis' pipe action with ansi. A producer that strips SGR
yields a plain repaint — nothing is guessed back.

Also:

  • README producer examples become tmux capture-pane -p -e -J (colour
    and logical lines — the existing examples use bare -p, which the
    soft-wrap help already says loses wraps) and kitty @ get-text --ansi.
  • docs/design.md "Extension surface" table: --dim row. "Core → renderer"
    bullet: "repaints the captured cells with their SGR pen".
  • docs/research.md feature matrix "Color preservation": add a spoor ✅
    entry, and amend finding 5 ("plain background is universal") with why
    spoor is the exception: it parses the bytes itself, so the styles are
    already in hand.

5. What is deliberately not done

  • Matcher gaps. The matcher still joins glyphs across empty columns
    (foo<tab>bar matches foobar). Independent of painting; a Blank
    cell is invisible to layout by design here. Follow-up issue.
  • Palette / theme / DECSCNM. Grid-global state, see "Goal".
  • OSC 8 hyperlinks. Out of scope per the issue.
  • Truecolor down-mapping. See above.
  • Kitty text-sizing (Cell.sizing). Not rendered today either; a sized
    run repaints at normal size. Unchanged.

Test plan

src/style.rs:

  • Style::default() is exactly Reset / Reset / Reset / no attributes
  • each felis flag maps to its crossterm attribute; PROTECTED maps to none
  • Indexed/Rgb/Default colours map as tabled
  • underline shape maps only when UNDERLINE is set

src/source.rs:

  • \x1b[31mb → cell b has fg: AnsiValue(1), neighbours default
  • \x1b[1;4:3mx → bold + undercurled
  • \x1b[0m after a colour restores Style::default()
  • a wide glyph carries its style (assert on the cell)
  • \x1b[41m\x1b[K yields Blank cells with bg: AnsiValue(1) to the row
    end; a default-pen \x1b[K yields none
  • existing tests: add style: Style::default() (or ..Default::default())

src/matcher.rs:

  • a row of Blank cells around a URL matches the URL at the same column
    and the value has no spaces (blanks contribute no bytes)

src/picker.rs:

  • runs: equal adjacent styles merge; a style change splits; a column gap
    splits; a wide glyph followed by a same-style glyph merges (col + width)
  • draw into Vec<u8>: the first bytes are a reset then Clear; a red
    cell's run contains 38;5;1; each row ends with exactly one CSI 0 m; a
    default run after a bold/reverse run starts with a reset; underline colour
    emits 59 for default and 58;5;n / 58;2;… otherwise; with --dim
    every run carries 2, also when the source already has bold or faint; a
    gap row places the second run with a MoveTo at the gap's far column; a
    styled Blank paints a space with its background
  • under NO_COLOR (Colored::set_ansi_color_disabled(true) in the test)
    a bold red run still emits 1 and the label still emits bold
  • round trip: feed the emitted bytes back through source::screen and
    assert the cells' styles equal the input row's — stronger than substring
    checks
  • a plain row (all default) renders as exactly one run per contiguous
    stretch — the "no regression for plain producers" claim
  • a row holding only styled blanks survives main's trailing-row trim

tests/cli_input.rs:

  • --dim is accepted (a single match resolves headless, so only parsing is
    checkable)
  • --help contains the producer colour-retention paragraph

Rollout

One PR: style module + source change (incl. styled blanks) + matcher skip
of blanks + picker runs + --dim + docs. Commit body records the
default-look change (dim off) and the tab-gap fix.

Review log

pi (2026-09-19) reviewed the first draft; every finding was verified against
crossterm 0.28.1 and felis-grid at the pinned rev and folded in:

  • blocker: crossterm::Color has no Default, so Style needs a manual impl
  • high: NO_COLOR turns colour commands into ESC[m; attributes must be
    emitted after colours (design keeps honouring NO_COLOR rather than
    forcing colour, pi's other option)
  • high: styled blanks were deferred on a false premise (they need not enter
    matcher text); now in scope via Content::Blank
  • medium: "as felis resolved it" overclaimed; narrowed to the per-cell SGR pen
  • medium: reset before Clear(All)
  • low: ResetColor is already a full reset; one trailing reset per row/label
  • low: README tmux example should be -p -e -J; Run.style by value;
    ColorsTheme
## Implementation plan ### Goal Repaint the overlay with each cell's **SGR pen** (fg / bg / underline colour, bold / faint / italic / underline shape / blink / reverse / conceal / strikethrough / overline) as `felis-grid` parsed it, including styled blanks (BCE), so the picker shows the styles the producer captured. Labels keep drawing on top, unchanged. The flat `Dim` becomes opt-in. What "preserve" means, precisely: the per-cell SGR state. Indexed colours stay indices and default colours stay defaults; the terminal spoor runs in resolves them through *its* palette and theme. Producer-side palette overrides (OSC 4), theme overrides (OSC 10/11) and DECSCNM reverse video are grid-global state in felis, not per-cell, and are not carried. This is the same delegation the producer's own output would get when replayed into that terminal. ### Where the information is and where it is lost today - `felis_grid::Cell.style: StyleId` is grid-local; `Grid::style(id) -> &Attributes` resolves it. `Attributes` is `Copy` and carries `fg`, `bg`, `underline_color` (`Color::Default` = follow fg), `flags: AttrFlags`, `underline_style`. - `source::read_row` drops `cell.style`. The `Grid` is dropped at the end of `source::screen`, so the resolution has to happen there — a `StyleId` is meaningless afterwards. - `source::read_row` also drops every `Grapheme::Empty`, which is where felis keeps a pen-coloured erased cell (BCE): a full-width diff-line background, a status bar's trailing colour. - `picker::draw` joins a row's cell texts into one `String` and prints it under `Attribute::Dim`. Side finding: that join also loses **column gaps**. felis' HT moves the cursor without writing spaces, so a tab-aligned row (`ls -l`, `go test` output) repaints with the text pulled left of where the labels (which use `cell.col`) land. Per-run `MoveTo` below fixes that for free. ### Design #### 1. Data: `source::Cell` gains a resolved `style`, and blanks become cells ```rust // src/source.rs pub enum Content { /// One scalar, or a multi-codepoint cluster. Glyph(String), /// An erased cell that still carries a pen — painted, never matched. Blank, } pub struct Cell { pub content: Content, pub col: u16, pub width: u16, /// The cell's pen, resolved from the grid's style table at read time — /// a StyleId does not outlive the grid. pub style: Style, } ``` `Style` is spoor's own type, defined in a new `src/style.rs`, expressed in **crossterm** vocabulary so the picker consumes it with no translation: ```rust // src/style.rs #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Style { pub fg: Color, // crossterm::style::Color, Reset = default pub bg: Color, pub underline: Color, // Reset = follow fg (what SGR 58 absent means) pub attrs: Attributes, // crossterm::style::Attributes bitset } // crossterm's Color has no Default impl, so this is written by hand: // Reset / Reset / Reset / Attributes::default(). impl Default for Style { ... } impl From<&felis_grid::Attributes> for Style { ... } ``` Mapping (`felis` → `crossterm`): | felis | crossterm | | --- | --- | | `Color::Default` | `Color::Reset` | | `Color::Indexed(n)` | `Color::AnsiValue(n)` | | `Color::Rgb(r,g,b)` | `Color::Rgb{r,g,b}` | | `BOLD` | `Bold` | | `FAINT` | `Dim` | | `ITALIC` | `Italic` | | `UNDERLINE` + `Single/Double/Curly/Dotted/Dashed` | `Underlined / DoubleUnderlined / Undercurled / Underdotted / Underdashed` | | `BLINK` | `SlowBlink` | | `REVERSE` | `Reverse` | | `CONCEAL` | `Hidden` | | `STRIKETHROUGH` | `CrossedOut` | | `OVERLINE` | `OverLined` | | `PROTECTED`, `ISO_PROTECTED` | dropped — not SGR, not visible | Why a spoor type rather than storing `felis_grid::Attributes`: `source.rs` is already the one felis boundary (matcher and picker never see felis types), and `picker::Colors` is already crossterm-typed. Putting the translation at the boundary keeps that layering. Why not translate in the picker: then `source` would export a felis type and every consumer would need felis in scope. Cost accepted: `source::Cell` now names crossterm types, tying the source model to the current renderer. Fine for an unpublished crate; if a second renderer ever appears, introduce spoor-owned colour/attribute types rather than switching to felis'. Why no truecolor down-mapping: rendering is producer-independent and goes through crossterm to `/dev/tty`; the terminal spoor runs in decides what `38;2;r;g;b` means, exactly as it would for the producer's own output. felis' `AnsiCaps.truecolor` down-mapping exists for hosts that cannot render RGB; spoor has no such host knowledge and should not guess (same rule as column facts). Revisit trigger: a real terminal that renders RGB as garbage. `read_row` changes: - every kept cell gets `style: Style::from(grid.style(cell.style))`. A wide glyph's `Spacer` carries the owner's `StyleId` in felis (`editing.rs` copies `owner.style`), so skipping the spacer loses nothing. - a `Grapheme::Empty` whose resolved `Style` is not `Style::default()` is kept as `Content::Blank`, width 1. felis builds the erase blank with `style: self.pen_style` and extends the row watermark over a pen-coloured blank, so these are real content. A default-styled `Empty` is still skipped: it is what `Clear` already gives. Consumers of `Content::Blank`: - `matcher::layout` appends only `Glyph` text and spans; a `Blank` contributes no bytes, so matching is unchanged from today. - `picker` paints a `Blank` as one space under its style. - `main`'s trailing-row trim (`cells.is_empty()`) keeps a row that holds only styled blanks, which is right: it is visible content. #### 2. Rendering: runs, not rows, not cells `picker::draw` repaints each visible row as **style runs**: maximal stretches of cells that are column-contiguous (`next.col == prev.col + prev.width`) and share one `Style`. Each run is emitted as: ``` MoveTo(run.col, screen_row) SetAttribute(Reset) // no carry-over from the previous run SetForegroundColor(style.fg) SetBackgroundColor(style.bg) SetUnderlineColor(style.underline) SetAttributes(style.attrs) // + Dim when --dim; LAST, see NO_COLOR Print(run.text) ``` and each row ends with one `SetAttribute(Reset)` — the invariant the label pass already relies on (it sets fg/bg/Bold without a leading reset). Not `ResetColor` as well: in crossterm 0.28 `ResetColor` writes `CSI 0 m`, the same full reset, so pairing them is two resets. The leading reset per run mirrors felis' `row_ansi` choice: re-specify the pen on every change instead of minimal-diffing, so a stale attribute can never leak and the byte stream is deterministic enough to snapshot in tests. Cost is one reset per style change, not per glyph. **Colours before attributes, because of `NO_COLOR`.** crossterm honours `NO_COLOR`: with it set, every colour command still writes `CSI` + `m` around an *empty* colour, i.e. `ESC[m`, a full reset. Emitting attributes after the colour commands means that under `NO_COLOR` the repaint loses colours (as the convention asks) but keeps bold / underline / reverse and `--dim`, instead of being silently flattened. The alternative, `force_color_output(true)` on the grounds that the repaint is the user's own content, was considered and rejected for now: spoor's labels are its own decoration and the existing label pass already goes colourless under `NO_COLOR`, so the overlay should follow one rule. Revisit if a `NO_COLOR` user reports labels being lost. The same ordering applies to the label pass. **Reset before `Clear`.** `draw` starts with `Clear(ClearType::All)`. Entering the alternate screen does not reset the pen, and on a BCE terminal a clear under an inherited background paints every untouched cell with it. Queue `SetAttribute(Reset)` before the clear on every draw. A `MoveTo` per run (rather than only on style change) is what fixes the tab-gap bug above: a gap is a run boundary, and the next run positions itself by its own column. Extract the grouping as a pure function so it is testable without a tty: ```rust struct Run { col: u16, style: Style, text: String } // Style is Copy fn runs(row: &Row) -> Vec<Run> ``` A `Blank` cell contributes `" "` to its run's text. Make `draw` generic over `W: Write` (crossterm's `queue!` already is), so a test can render into a `Vec<u8>` and assert on the bytes. `pick` keeps passing the `/dev/tty` `File`. Labels: drawn after the text, with `Reset` → label colours → `Bold` → `Print` → `Reset` (same colours-then-attributes order, one trailing reset). They still win because they are painted last at the match column, over whatever the source had there — including `Hidden`, `Reverse` or a coloured background, since the leading reset discards it. Scroll indicator: unchanged (it already resets around itself). #### 3. Flag: `--dim` ``` /// Dim the repainted text so labels stand out more. Off by default: the /// repaint carries the source's own colours, and the labels already have /// their own foreground/background. Composes with the source style (a bold /// red cell stays bold red, dimmed). #[arg(long)] dim: bool, ``` Implementation: when set, `Dim` is OR-ed into every text run's attributes before emission. `picker::Colors` is renamed `Theme` and gains `dim: bool`; it is the overlay look bundle, and extending it keeps `pick`'s arity flat. Why opt-in rather than `--no-dim`: the point of the feature is a faithful repaint; dimming everything was a substitute for having colours. The one visible change for existing plain-input users is "no longer dim"; they get it back with `--dim`. Called out in the commit body and README. The single-match fast path in `main` bypasses the picker, so `--dim` has no visible effect there, like every other look flag. #### 4. Producer requirement (documentation only) Colour survives only if the producer kept it. `--help` `long_about` gets a paragraph beside the soft-wrap one: > The repaint carries the source's colours when the producer kept them: > `tmux capture-pane -e`, `kitty @ get-text --ansi`, `--color=always` on a > plain pipe, felis' pipe action with `ansi`. A producer that strips SGR > yields a plain repaint — nothing is guessed back. Also: - README producer examples become `tmux capture-pane -p -e -J` (colour *and* logical lines — the existing examples use bare `-p`, which the soft-wrap help already says loses wraps) and `kitty @ get-text --ansi`. - `docs/design.md` "Extension surface" table: `--dim` row. "Core → renderer" bullet: "repaints the captured cells *with their SGR pen*". - `docs/research.md` feature matrix "Color preservation": add a `spoor ✅` entry, and amend finding 5 ("plain background is universal") with why spoor is the exception: it parses the bytes itself, so the styles are already in hand. #### 5. What is deliberately not done - **Matcher gaps.** The matcher still joins glyphs across empty columns (`foo<tab>bar` matches `foobar`). Independent of painting; a `Blank` cell is invisible to `layout` by design here. Follow-up issue. - **Palette / theme / DECSCNM.** Grid-global state, see "Goal". - **OSC 8 hyperlinks.** Out of scope per the issue. - **Truecolor down-mapping.** See above. - **Kitty text-sizing (`Cell.sizing`).** Not rendered today either; a sized run repaints at normal size. Unchanged. ### Test plan `src/style.rs`: - `Style::default()` is exactly Reset / Reset / Reset / no attributes - each felis flag maps to its crossterm attribute; `PROTECTED` maps to none - `Indexed`/`Rgb`/`Default` colours map as tabled - underline shape maps only when `UNDERLINE` is set `src/source.rs`: - `\x1b[31mb` → cell `b` has `fg: AnsiValue(1)`, neighbours default - `\x1b[1;4:3mx` → bold + undercurled - `\x1b[0m` after a colour restores `Style::default()` - a wide glyph carries its style (assert on the `世` cell) - `\x1b[41m\x1b[K` yields `Blank` cells with `bg: AnsiValue(1)` to the row end; a default-pen `\x1b[K` yields none - existing tests: add `style: Style::default()` (or `..Default::default()`) `src/matcher.rs`: - a row of `Blank` cells around a URL matches the URL at the same column and the value has no spaces (blanks contribute no bytes) `src/picker.rs`: - `runs`: equal adjacent styles merge; a style change splits; a column gap splits; a wide glyph followed by a same-style glyph merges (`col + width`) - `draw` into `Vec<u8>`: the first bytes are a reset then `Clear`; a red cell's run contains `38;5;1`; each row ends with exactly one `CSI 0 m`; a default run after a bold/reverse run starts with a reset; underline colour emits `59` for default and `58;5;n` / `58;2;…` otherwise; with `--dim` every run carries `2`, also when the source already has bold or faint; a gap row places the second run with a `MoveTo` at the gap's far column; a styled `Blank` paints a space with its background - under `NO_COLOR` (`Colored::set_ansi_color_disabled(true)` in the test) a bold red run still emits `1` and the label still emits bold - round trip: feed the emitted bytes back through `source::screen` and assert the cells' styles equal the input row's — stronger than substring checks - a plain row (all default) renders as exactly one run per contiguous stretch — the "no regression for plain producers" claim - a row holding only styled blanks survives `main`'s trailing-row trim `tests/cli_input.rs`: - `--dim` is accepted (a single match resolves headless, so only parsing is checkable) - `--help` contains the producer colour-retention paragraph ### Rollout One PR: style module + source change (incl. styled blanks) + matcher skip of blanks + picker runs + `--dim` + docs. Commit body records the default-look change (dim off) and the tab-gap fix. ### Review log pi (2026-09-19) reviewed the first draft; every finding was verified against crossterm 0.28.1 and felis-grid at the pinned rev and folded in: - blocker: `crossterm::Color` has no `Default`, so `Style` needs a manual impl - high: `NO_COLOR` turns colour commands into `ESC[m`; attributes must be emitted after colours (design keeps honouring `NO_COLOR` rather than forcing colour, pi's other option) - high: styled blanks were deferred on a false premise (they need not enter matcher text); now in scope via `Content::Blank` - medium: "as felis resolved it" overclaimed; narrowed to the per-cell SGR pen - medium: reset before `Clear(All)` - low: `ResetColor` is already a full reset; one trailing reset per row/label - low: README tmux example should be `-p -e -J`; `Run.style` by value; `Colors` → `Theme`
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
natsukium/spoor#2
No description provided.