loam

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 5 Imported by: 0

README

loam

A tiny Go library for coloring and highlighting rows in a bubbles/table view by post-processing its already-rendered text, rather than putting ANSI-styled strings into table.Row values directly.

This is the rendering substrate shared by canopy (agent-session dashboard) and understory (git-worktree dashboard): both need the same two things from their table view — recolor certain columns based on each cell's own word, and highlight whichever row is currently selected — so it lives here once instead of being duplicated (and drifting) in both trees, the same way mycelium already holds the open-or-focus-a-window logic both dashboards' Enter key needs.

Why post-process instead of styling table.Row directly

bubbles/table v1's cell truncation (runewidth.Truncate) is not ANSI-aware: escape codes get counted as extra visible width and sliced mid-sequence, corrupting the row (verified empirically against bubbles/table v1.0.0 — a styled "unmerged" in a 9-wide column gets truncated with a dangling escape code). Post-processing the table's already-rendered plain-text view instead sidesteps that entirely: the widths/padding/truncation the table computes are always over plain text, and only the final display string gets colored.

What it does

  • WordColumn + ColorizeRows — recolor one or more columns of an already-rendered view, each cell picking its style from its own (trimmed) word. A WordColumn.Style can vary by word (e.g. "dirty" vs "clean"), always return the same style regardless of content (e.g. a Since/Updated column), or do its own pre-processing first (e.g. strip a trailing blink-marker suffix before deciding the style) — it's just a func(string) lipgloss.Style, so any of that is a caller-side closure, not something loam needs to know about.
  • Sentinel + Tag — mark whichever row should get a full-line highlight by prepending a zero-width Unicode tag to any one of its cells (Since/Updated-style columns are a good choice: always populated, never blanked for grouping, never truncated in practice). ColorizeRows finds that row from the rendered text — no need to track bubbles/table's internal scroll offset, which v1 doesn't expose anyway — highlights it, then strips the tag back out before returning, so it never reaches the terminal.
  • HighlightRow + StyleSequences — the primitive the row highlight is built on: wraps an entire line in a style even when that line already contains other ANSI (from RecolorWord, applied first). A naive open + line + close wrap breaks the moment line contains its own reset code, since every lipgloss render ends with a full SGR reset regardless of which attributes were opened — so HighlightRow reapplies its own opening sequence right after every such inner reset it finds, keeping the outer style in effect up to the real, final close.
  • ColumnOffsets + RecolorWord + DisplayColumnToByteOffset — the lower-level pieces: computing each column's start/width within a rendered line (accounting for bubbles/table's fixed padding), and recoloring one column's span by display column rather than byte offset, so a multi-byte rune in an earlier column (a truncation ellipsis, or a genuinely unicode name) never misaligns a later column's recoloring.

Usage

cols := []table.Column{
	{Title: "Updated", Width: 8},
	{Title: "Worktree", Width: 8},
	{Title: "Merge", Width: 9},
}

// Row building: tag the selected row via loam.Tag.
row := table.Row{
	loam.Tag(humanizeSince(e.CommitTime), i == cursor),
	worktreeStatusLabel(e),
	mergeStatusLabel(e),
}

// View: recolor Worktree/Merge, and highlight whichever row is tagged.
view := loam.ColorizeRows(table.View(), table.Columns(), []loam.WordColumn{
	{Index: colWorktree, Style: worktreeStatusStyle},
	{Index: colMerge, Style: mergeStatusStyle},
}, rowHighlightStyle)

Development

go build ./...
go vet ./...
go test ./...
gofmt -l .   # should print nothing

Documentation

Overview

Package loam is the shared rendering substrate canopy and understory both grow their bubbles/table dashboards from: per-word column coloring and whole-row selection highlighting, applied by post-processing an already-rendered table view rather than putting ANSI-styled strings into table.Row values directly.

That indirection exists because bubbles/table v1's cell truncation (runewidth.Truncate) is not ANSI-aware: escape codes get counted as extra visible width and sliced mid-sequence, corrupting the row (verified empirically against bubbles/table v1.0.0 — a styled "unmerged" in a 9-wide column gets truncated with a dangling escape code). Post-processing the table's already-rendered plain-text view instead sidesteps that entirely: the widths/padding/truncation the table computes are always over plain text, and only the final display string gets colored.

This started as two independently-written, near-identical colorize.go files (one in each tree, one literally commenting that it was "the same technique... as [the other]'s own package") before being pulled out here, the same way mycelium already holds the open-or-focus-a-window logic both dashboards' Enter key needs.

Index

Constants

View Source
const Sentinel = "\u200b"

Sentinel tags whichever row a caller wants ColorizeRows to highlight, without needing a dedicated leading marker column/glyph that takes up space in every row. Prepend it (see Tag) to any one cell's text on the row you want highlighted — Since/Updated-style columns are a good choice: always populated, never blanked for grouping, and never long enough to risk truncation.

It's a zero-width Unicode space (U+200B), not a visible character: zero width means it never changes any column's padding/truncation math (bubbles/table and go-runewidth both measure display width, not byte length, so this is invisible to both — verified empirically against runewidth.Truncate and lipgloss.Style.Width), and it travels with the row's own data through bubbles/table's internal scrolling exactly like any other cell value would. That matters because ColorizeRows operates on the table's already-rendered, already- scrolled text: bubbles/table v1 doesn't expose its internal scroll offset, so there'd be no other way to know which rendered line corresponds to a given data row's cursor index once the view has scrolled. ColorizeRows strips Sentinel back out of the final output before returning it, so it never leaks into, say, a copy-pasted terminal selection.

Variables

This section is empty.

Functions

func ColorizeRows

func ColorizeRows(view string, cols []table.Column, wordCols []WordColumn, highlight lipgloss.Style) string

ColorizeRows recolors each of wordCols on an already-rendered bubbles/table view, then highlights the whole line of whichever row carries Sentinel (see Tag) in highlight, and finally strips Sentinel out of the result. cols must be the exact columns the view was rendered with.

The header line and any line that already contains an escape sequence coming in (from some outer style applied before ColorizeRows ever ran — e.g. bubbles/table's own default Selected style, if a caller hasn't overridden it to empty) is left untouched entirely: recoloring a sub-span of a line that already carries its own color would inject a reset code that cuts the outer style short for the rest of that line. That guard does not apply between the steps below for the *same* line, though: each WordColumn only inserts bytes into its own disjoint span (processed rightmost first, since inserting bytes into a column would otherwise shift the start offset of any column to its right), and the row highlight applied after them is specifically built (see HighlightRow) to survive wrapping a line that already contains their escape codes.

func DisplayColumnToByteOffset

func DisplayColumnToByteOffset(line string, col int) int

DisplayColumnToByteOffset returns the byte index in line at which display column col begins. Returns len(line) once col reaches or passes the line's own display width.

func HighlightRow

func HighlightRow(line string, style lipgloss.Style) string

HighlightRow wraps the whole of line in style, even when line already contains other ANSI escape codes (e.g. from RecolorWord, applied first by ColorizeRows). A naive open+line+close wrap would break the moment line contains its own reset code: every lipgloss render ends with a full SGR reset ("\x1b[0m", verified against lipgloss/termenv directly — true regardless of which attributes were opened), so an inner reset would end style's effect for the remainder of line, well before the intended closing tag. Reapplying style's own opening sequence immediately after every such inner reset keeps it in effect right up to the final, real close.

func RecolorWord

func RecolorWord(line string, off ColOffset, lookup func(string) lipgloss.Style) string

RecolorWord wraps the display-column span of line at off in whichever style lookup(word) returns for that span's trimmed text, preserving line's total length. An empty word (blank filler row below the real data, or the placeholder row) is left alone.

off's start/width are display columns, not byte offsets: naively slicing line as line[off.Start:off.Start+off.Width] silently corrupts this the moment any earlier column contains a multi-byte rune whose byte length doesn't match its display width — the truncation ellipsis "…" bubbles/table's own runewidth.Truncate appends to an over-long cell, or a genuinely unicode name. DisplayColumnToByteOffset walks the line rune-by-rune to find the real byte offsets first.

func StyleSequences

func StyleSequences(style lipgloss.Style) (open, closeSeq string)

StyleSequences extracts style's opening and closing escape sequences by rendering it around a NUL byte and splitting on that byte, rather than assuming any particular SGR codes: NUL can't otherwise appear in a rendered table view, and this stays correct regardless of which attributes style combines or whether color is even enabled (in which case lipgloss renders content unchanged and both return values are "").

func Tag

func Tag(text string, selected bool) string

Tag prepends Sentinel to text when selected is true, leaving text unchanged otherwise. Prepended rather than appended: bubbles/table truncates a too-long cell from the tail (runewidth.Truncate keeps the head plus an ellipsis), so a leading zero-width tag survives regardless of how long the cell's real content is, where a trailing one could get truncated away along with the tail.

Types

type ColOffset

type ColOffset struct {
	Start, Width int
}

ColOffset is a column's start position and width within a rendered row line, accounting for bubbles/table's fixed 1-space padding on both sides of every cell (table.DefaultStyles()'s Cell/Header Padding(0, 1)).

func ColumnOffsets

func ColumnOffsets(cols []table.Column) []ColOffset

ColumnOffsets computes each column's start/width within a rendered line, given cols in the same order the table was built with. Only correct for bubbles/table's default (no border) layout.

type WordColumn

type WordColumn struct {
	Index int
	Style func(word string) lipgloss.Style
}

WordColumn recolors one column of an already-rendered table view: the cell at Index gets Style(word) applied to it, where word is that cell's own trimmed text. An empty word (a blank filler row below the real data, or a placeholder row) is left alone.

Style controls the entire per-word lookup, so it covers both callers' needs: a map-backed lookup that varies by word (e.g. "dirty" vs "clean"), or a closure that ignores word and always returns the same fixed style (e.g. a Since/Updated column that's always styled the same regardless of its own content) — and anything in between, like a closure that strips its own suffix marker before deciding the style (e.g. a blinking "done*" indicator), same as any other function value.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL