cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Merge branch 'main' into worktree-agent-abec0b9c68b8ca7c4

Dami committed 2119992
commit
211999286af526b35f28bcf4f6a3c08c2cbfa826
parent
5fb1b9fba268cfc6e07760c6280db771f3f686a4
parent
e3098d818ca03c6a5ea2cf5023e050629cd02bd8
author
Dami <dami.etoile@gmail.com>
9 files changed, +152 -68
MROADMAP.md36++++++++++++--------
Mdocs/contributing/limits.md34+++++++++---------
Mdocs/terminal/ssh.md4+-
Minternal/ui/bars.go56++++++++++++++++++++----------
Minternal/ui/gridlines.go5++-
Minternal/ui/model.go4++
Minternal/ui/rowtext/rowtext.go54+++++++++++++++++++++++++----
Minternal/ui/rowtext/rowtext_test.go10+++++
Minternal/ui/view.go17+++++----
M ROADMAP.md +22 -14
@@ -38,20 +38,28 @@ as `$r1`, and refreshing a region re-runs what depends on it.
3838 | `012 nu` and the notebook sheet: a nushell prompt in the formula bar (history, Tab completion), results stacking as named live regions built on spills, `$r1` references with a dependency graph so refresh cascades, Freeze to values, other sheets reading `Shell!r1`, commands saved and never run on open, trust for files from elsewhere, timeouts and an output cap, off in `012 serve` unless configured | A reactive notebook of pipelines inside a spreadsheet | L |3939 | Nushell's own completions in the prompt; `nu_plugin_012` only if `--pipe` proves clumsy from inside nu | | S to M |4040 41-## Later: sharing a live sheet (shelved)42-43-Explored, not scheduled. `012 serve` gives each SSH session its own44-spreadsheet; saving over a file another session saved asks first, and45-that is all the sessions know of each other. One session host that runs46-a Bubble Tea program for any byte stream with window-size events, fed by47-SSH (`charm.land/wish/v2`), iroh tickets (the Go transport in48-`FelineStateMachine/allons` `local/transport/iroh`, which needs cgo and a49-prebuilt iroh-ffi archive, so it would sit behind a build tag), and the50-web (`NimbleMarkets/go-booba` serves Bubble Tea over51-WebSocket/WebTransport with ghostty-web; Bubble Tea v2 support52-unverified). Open questions: per-user sessions on one sheet versus53-mirroring one session, and whether the web page is served by the host or54-by a gateway dialing the iroh ticket.41+### 5. Toward multiplayer42+43+Sessions share one workbook through the server that already hosts them44+(`012 serve`), so every change is ordered in one place: the workbook's one45+mutation path (`Batch`/`Change`) becomes a stream of operations, the same46+log macros record. Each step is useful on its own.47+48+| Step | Result | Size |49+|---|---|---|50+| Streaming files: a sheet or linked range follows a file as it grows (CSV, TSV, NDJSON, NUON; `tail -f` style) or is rewritten, and a nushell region can follow a streaming pipeline, with rows arriving live and no full reload | Logs and feeds as live sheets; the change stream multiplayer needs | M |51+| Shared viewing over SSH: several `012 serve` sessions open the same workbook; one edits, the others follow live with their own cursor, scroll and theme; presence shows who is where | Watch-along and review, one writer | M |52+| Shared editing over SSH: every session edits, the server orders operations (no CRDT needed while one server holds the workbook), per-user undo, presence and edit ownership shown in the grid, saves by the server | Multiplayer 012 over SSH | L |53+54+## Later: other transports (shelved)55+56+Explored, not scheduled: carrying the same shared sessions over iroh57+tickets (the Go transport in `FelineStateMachine/allons`58+`local/transport/iroh`, which needs cgo and a prebuilt iroh-ffi archive, so59+it would sit behind a build tag) and the web (`NimbleMarkets/go-booba`60+serves Bubble Tea over WebSocket/WebTransport with ghostty-web; Bubble Tea61+v2 support unverified). They would reuse the server-ordered operation62+stream above rather than a design of their own.5563 5664 ## Shipped5765
M docs/contributing/limits.md +17 -17
@@ -28,7 +28,7 @@ it lags, and past a second it stalls.
2828 | Sheet size | A grid of 1,048,576 x 16,384 (A..XFD), with up to `max-cells` cells (ten million by default): navigation, drawing and every command cost what the cells cost, not the grid; ten million numbers take 205 MB, build in 1.8 s and save in 0.54 s | Opening a `.012` file of ten million cells: 1.2 s, 311 MB of heap at its peak | More than `max-cells` cells: imports keep whole rows up to it and say what they dropped; larger pastes and fills are refused | Parsing each cell's entry from the file's JSON; heap per cell (about 20 B for numbers, 20 to 60 B for real data, 750 B for formulas) |2929 | Incremental recalc | A change that makes formulas read under about 500,000 cells in total (fan-out, chains and volatiles of 8192 cells: 1.3 to 1.9 ms); 1000 SUMs over a full column: 0.53 ms; 8192 running totals: 2.7 ms | 60 criteria functions (SUMIF, COUNTIFS, AVERAGEIF) over whole columns of 8192 rows: 29 ms an edit | | About 3 ns per cell read: an index into the column's block |3030 | Full recalc | Any sheet: numbers and text hold their values and cost nothing; 1000 full-column SUMs 0.5 ms; 8192 running totals 1.8 ms | 60 whole-column criteria functions over 8192 rows: 44 ms | | Same as above |31-| Rendering | Any sheet at up to 200 x 60: 1 ms a frame; 400 x 120: 4.5 ms; a color scale on every cell shown adds 0.4 ms at 200 x 60, borders on every cell and wrapped text 0.4 ms | 20 charts at 400 x 120: 7 ms | | View building styled strings, then Bubble Tea parsing and diffing them |31+| Rendering | Any sheet at up to 200 x 60: 1.3 ms a frame; 400 x 120: 4.2 ms; a color scale on every cell shown adds 0.4 ms at 200 x 60, borders on every cell and wrapped text 0.4 ms | 20 charts at 400 x 120: 6 ms | | View building styled strings, then Bubble Tea parsing and diffing them |3232 | Selection statistics | Any selection: extending one over all 2.1 M cells of 8192 x 256 costs 0.3 ms a key | | | Per column with data, 1024 or 64 rows at a time from an index on the blocks of filled cells, plus the rows at the selection's ends |3333 | Imports | CSV, SQLite, Parquet: 2 to 4 M cells/s (a million cells in 0.25 to 0.4 s); XLSX numbers or text: 0.8 to 1.4 M cells/s | XLSX with formulas: 0.4 to 0.6 M cells/s | Data past `max-cells` or the grid (dropped, with a note); XLSX files past the reader's limits (refused) | Building cells one at a time; XML decoding; XLSX formula translation |3434 | Undo | One step of any size: undo costs what the edit cost; clearing all ten million cells of a full sheet holds 193 MB | History capped at 100 steps and 256 MB of before-images: 100 whole-column steps hold 15 MB | A step past 1 GB (millions of formulas or long distinct texts at once): asks, and runs without undo if told to | Before-images in the store's form: a slot (20 B) per plain cell, a whole `Cell` per formula |
@@ -59,7 +59,7 @@ as a stream, so opening costs about the memory the workbook then holds
5959 | Open `.012` (heap at the peak, the workbook included) | 24 ms (15 MB) | 0.24 s (60 MB) | 1.2 s (311 MB; 0.9 GB resident, the file's bytes and a sheet built beside it included) |6060 | Export CSV / TSV / XLSX / SQLite | 33 / 31 / 45 / 38 ms | 0.51 / 0.52 / 0.65 / 0.68 s | |6161 | Edit a number, undo and redo it | 0.6 us, 1.2 us | | 0.6 us, 1.2 us |62-| Frame at 200 x 60, whole | 0.88 ms | 0.87 ms | |62+| Frame at 200 x 60, whole | 0.80 ms | 0.80 ms | |6363 6464 XLSX exports are written by 012 with archive/zip: 1.9 MB allocated for6565 8192 x 26 and 17 MB for 8192 x 256, whose time is mostly compression.
@@ -204,13 +204,13 @@ terminal (`BenchmarkFrame`).
204204 205205 | Sheet | 80 x 24 | 200 x 60 | 400 x 120 |206206 |---|---|---|---|207-| Empty | 0.19 ms | 0.89 ms | 3.2 ms |208-| 8192 x 256 numbers | 0.18 ms | 0.87 ms | 3.4 ms |209-| 8192 rows of 500-character unicode text | 0.34 ms | 1.37 ms | 4.4 ms |210-| 1000 named ranges | 0.30 ms | 1.06 ms | 3.5 ms |211-| 20 charts (text) | 0.82 ms | 2.9 ms | 7.3 ms |207+| Empty | 0.14 ms | 0.63 ms | 2.3 ms |208+| 8192 x 256 numbers | 0.17 ms | 0.80 ms | 3.1 ms |209+| 8192 rows of 500-character unicode text | 0.30 ms | 1.26 ms | 4.2 ms |210+| 1000 named ranges | 0.24 ms | 0.73 ms | 2.4 ms |211+| 20 charts (text) | 0.78 ms | 2.7 ms | 6.0 ms |212212 213-About half of a frame at 400 x 120 is Bubble Tea's parse and diff of the213+More than half of a frame at 400 x 120 is Bubble Tea's parse and diff of the214214 view string.215215 216216 Conditional formats and validation cost what the screen shows. A cell's
@@ -224,8 +224,8 @@ every cell on screen a shade), at 200 x 60 (`BenchmarkFrame`,
224224 225225 | | Without rules | With the scale |226226 |---|---|---|227-| A frame, or an arrow key through to it | 0.89 ms | 1.28 ms |228-| Typing a number and Enter, through to the frame | 0.89 ms | 2.3 ms (the scale's percentile over 213 k numbers) |227+| A frame, or an arrow key through to it | 0.80 ms | 1.2 ms |228+| Typing a number and Enter, through to the frame | 0.81 ms | 2.2 ms (the scale's percentile over 213 k numbers) |229229 230230 Shades and rule colors keep their escape codes, so a plain cell on one231231 costs a string concatenation, not a style render. A custom formula is
@@ -241,8 +241,8 @@ more than a shade. `bars-8192x26` puts a data bar over
241241 242242 | | 80 x 24 | 200 x 60 | 400 x 120 |243243 |---|---|---|---|244-| 8192 x 26 numbers, a frame | 0.18 ms | 0.89 ms | 3.1 ms |245-| The same with bars and icons on every cell | 0.27 ms | 1.5 ms | 4.9 ms |244+| 8192 x 26 numbers, a frame | 0.17 ms | 0.80 ms | 2.8 ms |245+| The same with bars and icons on every cell | 0.26 ms | 1.4 ms | 4.6 ms |246246 247247 Wrapped text, borders, row heights and merged cells cost what the248248 screen shows too. A sheet with none of them is drawn a line per row
@@ -256,9 +256,9 @@ top (`BenchmarkFrame`, `BenchmarkKeystroke`):
256256 257257 | | 80 x 24 | 200 x 60 | 400 x 120 |258258 |---|---|---|---|259-| 8192 x 26 numbers, a frame | 0.18 ms | 0.90 ms | 3.1 ms |260-| The same laid out, a frame | 0.24 ms | 1.28 ms | 4.3 ms |261-| The same laid out, an arrow key through to its frame | 0.25 ms | 1.25 ms | |259+| 8192 x 26 numbers, a frame | 0.17 ms | 0.80 ms | 2.8 ms |260+| The same laid out, a frame | 0.24 ms | 1.22 ms | 4.1 ms |261+| The same laid out, an arrow key through to its frame | 0.25 ms | 1.23 ms | |262262 263263 A chart drawn as text costs 4 to 50 us at 24 x 10 to 120 x 40 cells,264264 whatever its data: only the categories that fit are drawn (a pie of 8192
@@ -269,8 +269,8 @@ when its data, size or theme changes: 25 to 260 us at 24 x 10 cells and
269269 in 0.07 to 0.3 ms at 24 x 10 cells and 3 to 7 ms at 120 x 40 (10 x 20270270 pixel cells), 0.6 to 67 KB to send (`BenchmarkSixel`); it is sent again,271271 without encoding, whenever the screen under it is redrawn. Key presses add little: an arrow key272-through to its frame is 0.19 ms at 80 x 24 and 0.9 ms at 200 x 60;273-Page Down 1.1 ms.272+through to its frame is 0.17 ms at 80 x 24 and 0.8 ms at 200 x 60;273+Page Down 1.0 ms.274274 275275 ## Selection statistics276276
M docs/terminal/ssh.md +2 -2
M internal/ui/bars.go +38 -18
@@ -81,25 +81,34 @@ func (m *Model) barText(sp rowtext.Span, w int, look *sheet.Look, base *lipgloss
8181 st := spanStyle(&m.th, *base, sp)8282 text = &st8383 }84+ // A run is drawn in the cell's role (penBase), its text's style85+ // (penText) or a role of the theme (penRole, role). The theme's roles86+ // are held apart from base, so base, often a shade on the caller's87+ // stack, never flows into the frame's cache of shades.8488 var b strings.Builder8589 var run strings.Builder86- var cur *lipgloss.Style90+ cur, curRole := penBase, (*lipgloss.Style)(nil)8791 flush := func() {8892 switch {8993 case run.Len() == 0:90- case cur == base && !colored:94+ case cur == penBase && !colored:9195 b.WriteString(run.String())92- case cur == text || cur == base: // either may be the cell's own, made for it93- b.WriteString(cur.Render(run.String()))96+ case cur == penBase:97+ b.WriteString(base.Render(run.String()))98+ case cur == penText:99+ b.WriteString(text.Render(run.String()))94100 default: // a theme's role, whose address stays put for the frame95- b.WriteString(m.shade(cur).Wrap(run.String()))101+ b.WriteString(m.shade(curRole).Wrap(run.String()))96102 }97103 run.Reset()98104 }99- put := func(role *lipgloss.Style, s string) {100- if role != cur {105+ put := func(p pen, role *lipgloss.Style, s string) {106+ if p == penText && text == base {107+ p = penBase108+ }109+ if p != cur || role != curRole {101110 flush()102- cur = role111+ cur, curRole = p, role103112 }104113 run.WriteString(s)105114 }
@@ -107,15 +116,17 @@ func (m *Model) barText(sp rowtext.Span, w int, look *sheet.Look, base *lipgloss
107116 switch n := coverAt(cover, x); {108117 case ch == "":109118 case icon && x == iconColumn(w):110- put(m.ruleRole(look.IconColor, base, colored), look.Icon)119+ p, role := m.ruleRole(look.IconColor, colored)120+ put(p, role, look.Icon)111121 case n > 0 && ch == " ":112- put(m.ruleRole(look.BarColor, base, colored), theme.Block(n))122+ p, role := m.ruleRole(look.BarColor, colored)123+ put(p, role, theme.Block(n))113124 case n >= 4 && !colored:114- put(&m.th.BarOn[look.BarColor], ch)125+ put(penRole, &m.th.BarOn[look.BarColor], ch)115126 case ch == " ":116- put(base, ch)127+ put(penBase, nil, ch)117128 default:118- put(text, ch)129+ put(penText, nil, ch)119130 }120131 }121132 flush()
@@ -142,13 +153,22 @@ func coverAt(cover []int, x int) int {
142153 return 0143154 }144155 145-// ruleRole is the role a rule color draws a glyph in: the color, or the146-// highlight's own role on a highlighted cell.147-func (m *Model) ruleRole(c sheet.Color, base *lipgloss.Style, colored bool) *lipgloss.Style {156+// pen is what barText draws a run of a cell's columns in.157+type pen uint8158+159+const (160+ penBase pen = iota // the cell's role161+ penText // its text's style162+ penRole // a role of the theme, kept for the frame163+)164+165+// ruleRole is the pen a rule color draws a glyph in: the color's role,166+// or the cell's own role on a highlighted cell.167+func (m *Model) ruleRole(c sheet.Color, colored bool) (pen, *lipgloss.Style) {148168 if colored || c == sheet.ColorNone || int(c) >= sheet.NumColors {149- return base169+ return penBase, nil150170 }151- return &m.th.RuleText[c]171+ return penRole, &m.th.RuleText[c]152172 }153173 154174 // chipText draws a dropdown's value as a chip in a column w wide: its
M internal/ui/gridlines.go +3 -2
M internal/ui/model.go +4 -0
M internal/ui/rowtext/rowtext.go +46 -8
@@ -56,13 +56,30 @@ type Line struct{ K, N int }
5656 // stays in its column. Text doesn't run on across a border or into5757 // merged cells, whose contents the caller draws (see Merged).5858 func LayoutLine(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int, ln Line) []Span {59- l := rowLayout{s: s, loc: s.Locale(), row: row, lo: lo, hi: lo + ncols - 1, out: make([]Span, ncols), ln: ln, shaped: s.Shaped()}59+ var sc Scratch60+ return sc.LayoutLine(s, row, lo, ncols, minCol, maxCol, ln)61+}62+63+// Scratch keeps the slices a layout works in, so the lines of a frame64+// reuse them rather than allocate their own. The spans a layout through65+// it returns hold until its next layout.66+type Scratch struct {67+ spans []Span68+ x []int69+}70+71+// LayoutLine is the package's LayoutLine, laid out in sc's slices.72+func (sc *Scratch) LayoutLine(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int, ln Line) []Span {73+ sc.spans = resize(sc.spans, ncols)74+ l := rowLayout{s: s, loc: s.Locale(), row: row, lo: lo, hi: lo + ncols - 1, out: sc.spans, ln: ln, shaped: s.Shaped()}6075 for i := range l.out {6176 l.out[i] = Span{Trail: s.ColWidth(lo + i)}6277 }6378 l.first, l.last = l.reach(minCol, maxCol)6479 // x[k] is where column first+k starts, relative to column first.65- l.x = make([]int, l.last-l.first+2)80+ sc.x = resize(sc.x, l.last-l.first+2)81+ l.x = sc.x82+ l.x[0] = 06683 for c := l.first; c <= l.last; c++ {6784 l.x[c-l.first+1] = l.x[c-l.first] + s.ColWidth(c)6885 }
@@ -75,6 +92,15 @@ func LayoutLine(s *sheet.Sheet, row, lo, ncols, minCol, maxCol int, ln Line) []S
7592 return l.out7693 }7794 95+// resize returns s n long, reusing its array when it has room; what it96+// holds is the caller's to set.97+func resize[T any](s []T, n int) []T {98+ if cap(s) < n {99+ return make([]T, n)100+ }101+ return s[:n]102+}103+78104 // rowLayout is Layout's work in progress on one row.79105 type rowLayout struct {80106 s *sheet.Sheet
@@ -146,8 +172,8 @@ func (l *rowLayout) free(k, side int) bool {
146172 // reaches.147173 func (l *rowLayout) place(c int, v sheet.Value) {148174 x0, x1 := l.col(c)149- text, align, pad := l.display(c, v, x1-x0)150175 style := l.s.CellStyle(sheet.Addr{Col: c, Row: l.row})176+ text, align, pad := l.display(c, v, style.Align, x1-x0)151177 own := style.Wrap != sheet.WrapOverflow // the text stays in its column152178 if l.shaped {153179 var show bool
@@ -155,7 +181,7 @@ func (l *rowLayout) place(c int, v sheet.Value) {
155181 return156182 }157183 }158- tw := ansi.StringWidth(text)184+ tw := textWidth(text)159185 start := x0 + pad160186 switch align {161187 case sheet.AlignFill:
@@ -184,9 +210,10 @@ func (l *rowLayout) place(c int, v sheet.Value) {
184210 }185211 }186212 187-// display formats the cell in column c for a column w wide, returning188-// the text, its alignment and the padding on its aligned side.189-func (l *rowLayout) display(c int, v sheet.Value, w int) (string, sheet.Align, int) {213+// display formats the cell in column c, which its style aligns as a214+// says, for a column w wide, returning the text, its alignment and the215+// padding on its aligned side.216+func (l *rowLayout) display(c int, v sheet.Value, a sheet.Align, w int) (string, sheet.Align, int) {190217 f := l.s.DisplayFormat(sheet.Addr{Col: c, Row: l.row})191218 text, align := sheet.DisplayIn(v, f, w, l.loc)192219 pad := 1
@@ -198,7 +225,7 @@ func (l *rowLayout) display(c int, v sheet.Value, w int) (string, sheet.Align, i
198225 text, pad = wider, 0199226 }200227 }201- if a := l.s.CellStyle(sheet.Addr{Col: c, Row: l.row}).Align; a != sheet.AlignAuto && align != sheet.AlignFill {228+ if a != sheet.AlignAuto && align != sheet.AlignFill {202229 align = a203230 }204231 return text, align, pad
@@ -299,3 +326,14 @@ func nextCluster(s string) (n, w int) {
299326 g, w := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth)300327 return len(g), w301328 }329+330+// textWidth is ansi.StringWidth of s, counted at once when s is331+// printable ASCII, as numbers and most text are: one column a byte.332+func textWidth(s string) int {333+ for i := 0; i < len(s); i++ {334+ if b := s[i]; b < 0x20 || b >= 0x7f {335+ return ansi.StringWidth(s)336+ }337+ }338+ return len(s)339+}
M internal/ui/rowtext/rowtext_test.go +10 -0
M internal/ui/view.go +10 -7
@@ -170,11 +170,13 @@ func (m *Model) cellsText(lc *lineCtx, first int, spans []rowtext.Span) string {
170170 if rules {171171 rgb = m.slotColor172172 }173+ // A shade is a whole role, so it is kept across the loop and read174+ // through a pointer rather than zeroed and copied for every cell.175+ var shade theme.Shade173176 for i, sp := range spans {174177 a := sheet.Addr{Col: first + i, Row: row}175178 base, colored := m.cellRole(lc, a, &sp, spills)176179 var look sheet.Look177- var shade theme.Shade178180 shaded := false179181 if piece, typing := m.entryPiece(lc, a); typing { // gridlines.go180182 sp = rowtext.Span{Text: piece}
@@ -195,7 +197,7 @@ func (m *Model) cellsText(lc *lineCtx, first int, spans []rowtext.Span) string {
195197 marked := theme.Drawable(base.Inherit(m.th.Copied))196198 base, colored, shaded = &marked, true, false197199 }198- text := m.cellText(lc, a, sp, &look, base, colored, shade, shaded)200+ text := m.cellText(lc, a, sp, &look, base, colored, &shade, shaded)199201 b.WriteString(m.cellMarks(lc, a, text, &look, base, colored))200202 }201203 return b.String()
@@ -204,7 +206,7 @@ func (m *Model) cellsText(lc *lineCtx, first int, spans []rowtext.Span) string {
204206 // cellText draws a cell's span on its role: as a rule's shade, with its205207 // data bar or icon, as a dropdown's chip (taking the look's ▾, which the206208 // chip holds), or plainly.207-func (m *Model) cellText(lc *lineCtx, a sheet.Addr, sp rowtext.Span, look *sheet.Look, base *lipgloss.Style, colored bool, shade theme.Shade, shaded bool) string {209+func (m *Model) cellText(lc *lineCtx, a sheet.Addr, sp rowtext.Span, look *sheet.Look, base *lipgloss.Style, colored bool, shade *theme.Shade, shaded bool) string {208210 switch {209211 case look.Bar || look.Icon != "" || look.ValueHidden:210212 return m.barText(sp, m.sheet.ColWidth(a.Col), look, base, colored, m.valueLine(lc, a))
@@ -222,7 +224,7 @@ func (m *Model) cellText(lc *lineCtx, a sheet.Addr, sp rowtext.Span, look *sheet
222224 case shaded && plainSpan(sp):223225 return shade.Wrap(strings.Repeat(" ", sp.Lead) + sp.Text + strings.Repeat(" ", sp.Trail))224226 }225- return renderSpan(&m.th, sp, *base, colored)227+ return renderSpan(&m.th, sp, base, colored)226228 }227229 228230 // cellRole is the role a cell is drawn in: the pointer, the selection,
@@ -293,7 +295,8 @@ func plainSpan(sp rowtext.Span) bool {
293295 294296 // renderSpan draws a span on base, one of the cell roles. Plain cells295297 // with no text style are written without escape codes.296-func renderSpan(th *theme.Theme, sp rowtext.Span, base lipgloss.Style, colored bool) string {298+// Roles are large, so base is read through a pointer.299+func renderSpan(th *theme.Theme, sp rowtext.Span, base *lipgloss.Style, colored bool) string {297300 lead, trail := strings.Repeat(" ", sp.Lead), strings.Repeat(" ", sp.Trail)298301 plain := plainSpan(sp)299302 switch {
@@ -302,9 +305,9 @@ func renderSpan(th *theme.Theme, sp rowtext.Span, base lipgloss.Style, colored b
302305 case plain:303306 return base.Render(lead + sp.Text + trail)304307 case !colored:305- return lead + spanStyle(th, base, sp).Render(sp.Text) + trail308+ return lead + spanStyle(th, *base, sp).Render(sp.Text) + trail306309 }307- return base.Render(lead) + spanStyle(th, base, sp).Render(sp.Text) + base.Render(trail)310+ return base.Render(lead) + spanStyle(th, *base, sp).Render(sp.Text) + base.Render(trail)308311 }309312 310313 // inCellText shows the entry being typed inside the cell, keeping the end