Preserve cell colours in the overlay repaint #2
Labels
No labels
bug
design
docs
enhancement
good-first-issue
packaging
rendering
No milestone
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
natsukium/spoor#2
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Carry each cell's SGR style through
source::Rowand repaint it in the picker, so theoverlay 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❌ plainfor every competitor (kitty, tmux-thumbs, tmux-fingers). They all repaint fromplain text, so they have nothing to repaint with.
spooris the one tool that does not: it parses the raw bytes itself throughfelis-vtintoa
felis-gridcell grid (docs/design.md"Alignment"), andfelis_grid::Cellalready carriesstyle: StyleId, resolvable viaGrid::style. The information is sitting in the grid and isbeing thrown away:
src/source.rsread_rowbuildssource::Cell { text, col, width }and dropscell.style.src/picker.rsdrawcollects a row into oneStringand prints it under a singleAttribute::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::Cellgains the resolved attributes (fg / bg / bold / italic / underline / reverse …),read from
Grid::style(cell.style)inread_row.picker::drawemits per style-run rather than onePrintper 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.
--dim?) rather than the only behaviour — dimming existsto 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 berecovered, and
spoordoes not guess it back.capture-pane -pstrips SGR; needs-e(e.g.tmux capture-pane -J -e -p)kitty @ get-text --ansigit log --color=always,rg --color=always, …ansi(row_ansivsrow_text_trim); it has to be the ansi sideWhen the input is already colourless this degrades to exactly today's rendering, so there is no
regression for the plain producers.
--helpshould say so, the way it already does forsoft-wrap needing a logical source.
Rendering is producer-independent: the overlay writes to
/dev/ttyvia crossterm, so thecolours show in whatever terminal
spoorwas launched from (truecolor subject to thatterminal'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: Noneemits no OSC 8). Filed separately if we do it.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-gridparsed it, including styled blanks(BCE), so the picker shows the styles the producer captured. Labels keep
drawing on top, unchanged. The flat
Dimbecomes 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: StyleIdis grid-local;Grid::style(id) -> &Attributesresolves it.AttributesisCopyand carriesfg,bg,underline_color(Color::Default= follow fg),flags: AttrFlags,underline_style.source::read_rowdropscell.style. TheGridis dropped at the end ofsource::screen, so the resolution has to happen there — aStyleIdismeaningless afterwards.
source::read_rowalso drops everyGrapheme::Empty, which is where feliskeeps a pen-coloured erased cell (BCE): a full-width diff-line background,
a status bar's trailing colour.
picker::drawjoins a row's cell texts into oneStringand prints itunder
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 testoutput) repaints with the text pulled left of where the labels (which use
cell.col) land. Per-runMoveTobelow fixes that for free.Design
1. Data:
source::Cellgains a resolvedstyle, and blanks become cellsStyleis spoor's own type, defined in a newsrc/style.rs, expressed incrossterm vocabulary so the picker consumes it with no translation:
Mapping (
felis→crossterm):Color::DefaultColor::ResetColor::Indexed(n)Color::AnsiValue(n)Color::Rgb(r,g,b)Color::Rgb{r,g,b}BOLDBoldFAINTDimITALICItalicUNDERLINE+Single/Double/Curly/Dotted/DashedUnderlined / DoubleUnderlined / Undercurled / Underdotted / UnderdashedBLINKSlowBlinkREVERSEReverseCONCEALHiddenSTRIKETHROUGHCrossedOutOVERLINEOverLinedPROTECTED,ISO_PROTECTEDWhy a spoor type rather than storing
felis_grid::Attributes:source.rsis already the one felis boundary (matcher and picker never see felis
types), and
picker::Colorsis already crossterm-typed. Putting thetranslation at the boundary keeps that layering. Why not translate in the
picker: then
sourcewould export a felis type and every consumer wouldneed felis in scope. Cost accepted:
source::Cellnow names crosstermtypes, 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 what38;2;r;g;bmeans, exactly as it would for the producer's own output.felis'
AnsiCaps.truecolordown-mapping exists for hosts that cannot renderRGB; 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_rowchanges:style: Style::from(grid.style(cell.style)). A wideglyph's
Spacercarries the owner'sStyleIdin felis (editing.rscopies
owner.style), so skipping the spacer loses nothing.Grapheme::Emptywhose resolvedStyleis notStyle::default()iskept as
Content::Blank, width 1. felis builds the erase blank withstyle: self.pen_styleand extends the row watermark over a pen-colouredblank, so these are real content. A default-styled
Emptyis stillskipped: it is what
Clearalready gives.Consumers of
Content::Blank:matcher::layoutappends onlyGlyphtext and spans; aBlankcontributes no bytes, so matching is unchanged from today.
pickerpaints aBlankas one space under its style.main's trailing-row trim (cells.is_empty()) keeps a row that holdsonly styled blanks, which is right: it is visible content.
2. Rendering: runs, not rows, not cells
picker::drawrepaints each visible row as style runs: maximal stretchesof cells that are column-contiguous (
next.col == prev.col + prev.width)and share one
Style. Each run is emitted as:and each row ends with one
SetAttribute(Reset)— the invariant the labelpass already relies on (it sets fg/bg/Bold without a leading reset). Not
ResetColoras well: in crossterm 0.28ResetColorwritesCSI 0 m, thesame full reset, so pairing them is two resets.
The leading reset per run mirrors felis'
row_ansichoice: re-specify thepen 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 honoursNO_COLOR: with it set, every colour command still writesCSI+maroundan empty colour, i.e.
ESC[m, a full reset. Emitting attributes after thecolour commands means that under
NO_COLORthe repaint loses colours (as theconvention asks) but keeps bold / underline / reverse and
--dim, instead ofbeing silently flattened. The alternative,
force_color_output(true)on thegrounds 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 shouldfollow one rule. Revisit if a
NO_COLORuser reports labels being lost.The same ordering applies to the label pass.
Reset before
Clear.drawstarts withClear(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
MoveToper run (rather than only on style change) is what fixes thetab-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:
A
Blankcell contributes" "to its run's text.Make
drawgeneric overW: Write(crossterm'squeue!already is), so atest can render into a
Vec<u8>and assert on the bytes.pickkeepspassing the
/dev/ttyFile.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,Reverseor a colouredbackground, since the leading reset discards it.
Scroll indicator: unchanged (it already resets around itself).
3. Flag:
--dimImplementation: when set,
Dimis OR-ed into every text run's attributesbefore emission.
picker::Colorsis renamedThemeand gainsdim: 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 faithfulrepaint; 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. Thesingle-match fast path in
mainbypasses the picker, so--dimhas novisible effect there, like every other look flag.
4. Producer requirement (documentation only)
Colour survives only if the producer kept it.
--helplong_aboutgets aparagraph beside the soft-wrap one:
Also:
tmux capture-pane -p -e -J(colourand logical lines — the existing examples use bare
-p, which thesoft-wrap help already says loses wraps) and
kitty @ get-text --ansi.docs/design.md"Extension surface" table:--dimrow. "Core → renderer"bullet: "repaints the captured cells with their SGR pen".
docs/research.mdfeature matrix "Color preservation": add aspoor ✅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
(
foo<tab>barmatchesfoobar). Independent of painting; aBlankcell is invisible to
layoutby design here. Follow-up issue.Cell.sizing). Not rendered today either; a sizedrun repaints at normal size. Unchanged.
Test plan
src/style.rs:Style::default()is exactly Reset / Reset / Reset / no attributesPROTECTEDmaps to noneIndexed/Rgb/Defaultcolours map as tabledUNDERLINEis setsrc/source.rs:\x1b[31mb→ cellbhasfg: AnsiValue(1), neighbours default\x1b[1;4:3mx→ bold + undercurled\x1b[0mafter a colour restoresStyle::default()世cell)\x1b[41m\x1b[KyieldsBlankcells withbg: AnsiValue(1)to the rowend; a default-pen
\x1b[Kyields nonestyle: Style::default()(or..Default::default())src/matcher.rs:Blankcells around a URL matches the URL at the same columnand the value has no spaces (blanks contribute no bytes)
src/picker.rs:runs: equal adjacent styles merge; a style change splits; a column gapsplits; a wide glyph followed by a same-style glyph merges (
col + width)drawintoVec<u8>: the first bytes are a reset thenClear; a redcell's run contains
38;5;1; each row ends with exactly oneCSI 0 m; adefault run after a bold/reverse run starts with a reset; underline colour
emits
59for default and58;5;n/58;2;…otherwise; with--dimevery run carries
2, also when the source already has bold or faint; agap row places the second run with a
MoveToat the gap's far column; astyled
Blankpaints a space with its backgroundNO_COLOR(Colored::set_ansi_color_disabled(true)in the test)a bold red run still emits
1and the label still emits boldsource::screenandassert the cells' styles equal the input row's — stronger than substring
checks
stretch — the "no regression for plain producers" claim
main's trailing-row trimtests/cli_input.rs:--dimis accepted (a single match resolves headless, so only parsing ischeckable)
--helpcontains the producer colour-retention paragraphRollout
One PR: style module + source change (incl. styled blanks) + matcher skip
of blanks + picker runs +
--dim+ docs. Commit body records thedefault-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:
crossterm::Colorhas noDefault, soStyleneeds a manual implNO_COLORturns colour commands intoESC[m; attributes must beemitted after colours (design keeps honouring
NO_COLORrather thanforcing colour, pi's other option)
matcher text); now in scope via
Content::BlankClear(All)ResetColoris already a full reset; one trailing reset per row/label-p -e -J;Run.styleby value;Colors→Theme