cat / 012
clone

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

Add Format commands, Sheets shortcuts and styled cells

Dami committed b41ad94
Format > Number presets, decimal places, bold, italic, underline,
strikethrough, alignment and Clear formatting are registered commands
under the format.* IDs, bound to Sheets' keys (Ctrl+Shift+1 to 6,
Ctrl+B/I/U, Alt+Shift+5, Ctrl+Shift+L/E/R, Ctrl+\) with the shifted
forms some terminals send as aliases. They apply to the selection and
say what they did on the third panel line.

The grid shows each cell in its number format and text style: text
styles cover only the text, right-aligned and centered text runs into
blank neighbors, a number one column too wide uses the padding when
nothing is to its right, and text stops short of a neighbor instead
of touching it.
commit
b41ad9419a1cb2b825c0a87a22225588b947436b
parent
fee01d3c5d5fe12019ff2a6a1bdfea9308eba459
author
Dami <dami.etoile@gmail.com>
10 files changed, +661 -66
MREADME.md8+-
Me2e/app_test.go2+-
Ae2e/format_test.go47++++++
Me2e/testdata/screens/help-light.html20+-
Me2e/testdata/screens/help.html20+-
Ainternal/ui/format.go177++++++++++++++++++++++
Ainternal/ui/format_test.go242++++++++++++++++++++++++++++++
Minternal/ui/model.go4+-
Minternal/ui/theme.go23+++
Minternal/ui/view.go184+++++++++++++++++------
M README.md +6 -2
M e2e/app_test.go +1 -1
A e2e/format_test.go +47 -0
@@ -0,0 +1,47 @@
1+package e2e2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestTypeDateAndApplyCurrency(t *testing.T) {9+ s := start(t, "")10+ s.keys("9/26/2026", "<enter>", "2026-09-27", "<enter>", "14:30", "<enter>")11+ s.waitForLine(gridRow1, numRow(1, "9/26/2026"))12+ s.waitForLine(gridRow1+1, numRow(2, "2026-09-27"))13+ s.waitForLine(gridRow1+2, numRow(3, "14:30:00"))14+15+ // Dates are numbers: the day after is one more.16+ s.keys("=A1+1", "<enter>")17+ s.waitForLine(gridRow1+3, numRow(4, "9/27/2026"))18+19+ s.keys("1450", "<tab>", "612.4", "<enter>", "<up>", "<shift+right>", "<ctrl+shift+4>")20+ s.waitForLine(gridRow1+4, numRow(5, "$1,450.00", "$612.40"))21+ s.waitFor("Currency format for A5:B5")22+23+ // Too wide next to a filled cell: #s, as in Sheets.24+ s.keys("<ctrl+shift+5>", "<ctrl+b>")25+ s.waitForLine(gridRow1+4, numRow(5, "#########", "61240.00%"))26+ s.waitFor("Bold on for A5:B5")27+ if !strings.Contains(s.html(), "font-weight:bold") {28+ t.Errorf("no bold cells:\n%s", s.html())29+ }30+}31+32+func TestFormatsSurviveSaveAndReopen(t *testing.T) {33+ dir := t.TempDir()34+ s := start(t, dir)35+ s.keys("1450", "<enter>", "<up>", "<ctrl+shift+1>", "<ctrl+i>")36+ s.waitForLine(gridRow1, numRow(1, "1,450.00"))37+ s.keys("<ctrl+s>", "formats", "<enter>")38+ s.eventually("saved", func() bool { return s.title() == "one23 - formats.o23" })39+ s.keys("<ctrl+q>")40+ s.waitExit()41+42+ r := start(t, dir, "formats.o23")43+ r.waitForLine(gridRow1, numRow(1, "1,450.00"))44+ if !strings.Contains(r.html(), "font-style:italic") {45+ t.Errorf("italic lost after reopening:\n%s", r.html())46+ }47+}
M e2e/testdata/screens/help-light.html +12 -8
M e2e/testdata/screens/help.html +12 -8
A internal/ui/format.go +177 -0
@@ -0,0 +1,177 @@
1+package ui2+3+import (4+ "strings"5+6+ tea "charm.land/bubbletea/v2"7+8+ "one23/internal/sheet"9+)10+11+// Format commands follow Sheets' Format menu and shortcuts. They apply to12+// the whole selection through Sheet methods, each one undo step with a13+// label such as "bold B2:B5", and confirm what changed on the third panel14+// line.15+16+// numberFormats are Format > Number, with Sheets' examples as17+// descriptions.18+var numberFormats = []struct {19+ id, title, desc string20+ f sheet.Format21+}{22+ {"format.automatic", "Automatic", "Show numbers as typed, or as formulas infer", sheet.Format{}},23+ {"format.plain_text", "Plain text", "Keep entries exactly as typed, even numbers and formulas", sheet.Preset(sheet.FmtText)},24+ {"format.number", "Number", "Number with thousands separators: 1,000.12", sheet.Preset(sheet.FmtNumber)},25+ {"format.percent", "Percent", "Percent: 10.12%", sheet.Preset(sheet.FmtPercent)},26+ {"format.scientific", "Scientific", "Scientific notation: 1.01E+03", sheet.Preset(sheet.FmtScientific)},27+ {"format.accounting", "Accounting", "Accounting, $ at the left and negatives in parentheses: $ (1,000.12)", sheet.Preset(sheet.FmtAccounting)},28+ {"format.financial", "Financial", "Financial, negatives in parentheses: (1,000.12)", sheet.Preset(sheet.FmtFinancial)},29+ {"format.currency", "Currency", "Currency: $1,000.12", sheet.Preset(sheet.FmtCurrency)},30+ {"format.currency_rounded", "Currency rounded", "Currency without cents: $1,000", sheet.Format{Kind: sheet.FmtCurrency}},31+ {"format.date", "Date", "Date: 9/26/2026", sheet.Preset(sheet.FmtDate)},32+ {"format.time", "Time", "Time: 3:59:00 PM", sheet.Preset(sheet.FmtTime)},33+ {"format.datetime", "Date time", "Date and time: 9/26/2026 15:59:00", sheet.Preset(sheet.FmtDateTime)},34+ {"format.duration", "Duration", "Elapsed hours, minutes and seconds: 24:01:00", sheet.Preset(sheet.FmtDuration)},35+}36+37+// textStyles toggle like Sheets: on for the whole selection unless the38+// active cell already has it.39+var textStyles = []struct {40+ id, title, desc string41+ get func(*sheet.Style) *bool42+}{43+ {"format.bold", "Bold", "Make text bold, or plain again", func(s *sheet.Style) *bool { return &s.Bold }},44+ {"format.italic", "Italic", "Make text italic, or upright again", func(s *sheet.Style) *bool { return &s.Italic }},45+ {"format.underline", "Underline", "Underline text, or remove the underline", func(s *sheet.Style) *bool { return &s.Underline }},46+ {"format.strikethrough", "Strikethrough", "Strike through text, or remove the line", func(s *sheet.Style) *bool { return &s.Strikethrough }},47+}48+49+var alignments = []struct {50+ id, title, desc string51+ a sheet.Align52+}{53+ {"format.align_left", "Left", "Align to the left", sheet.AlignLeft},54+ {"format.align_center", "Center", "Center horizontally", sheet.AlignCenter},55+ {"format.align_right", "Right", "Align to the right", sheet.AlignRight},56+}57+58+func init() {59+ for _, nf := range numberFormats {60+ f, title := nf.f, nf.title61+ register(&command{id: nf.id, title: title, desc: nf.desc, run: func(m *Model) tea.Cmd {62+ r := m.selection()63+ m.sheet.SetFormat(r, f)64+ m.formatted(title + " format")65+ return nil66+ }})67+ }68+ for _, ts := range textStyles {69+ get, title := ts.get, ts.title70+ register(&command{id: ts.id, title: title, desc: ts.desc, run: func(m *Model) tea.Cmd {71+ var cur sheet.Style72+ if c := m.sheet.Cell(m.cur); c != nil {73+ cur = c.Style74+ }75+ on := !*get(&cur)76+ r := m.selection()77+ label := strings.ToLower(title) + " " + r.String()78+ if !on {79+ label = "remove " + strings.ToLower(title) + " from " + r.String()80+ }81+ m.sheet.Batch(sheet.Change{Label: label, Focus: r}, func() error {82+ m.sheet.SetStyle(r, func(s *sheet.Style) { *get(s) = on })83+ return nil84+ })85+ m.formatted(title + onOff(on))86+ return nil87+ }})88+ }89+ for _, al := range alignments {90+ a, title := al.a, al.title91+ register(&command{id: al.id, title: title, desc: al.desc, run: func(m *Model) tea.Cmd {92+ r := m.selection()93+ m.sheet.Batch(sheet.Change{Label: "align " + r.String() + " " + strings.ToLower(title), Focus: r}, func() error {94+ m.sheet.SetStyle(r, func(s *sheet.Style) { s.Align = a })95+ return nil96+ })97+ m.formatted("Aligned " + strings.ToLower(title))98+ return nil99+ }})100+ }101+ register(102+ &command{id: "format.decimals_more", title: "Increase decimal places", desc: "Show one more decimal place", run: func(m *Model) tea.Cmd {103+ m.sheet.AdjustDecimals(m.selection(), 1)104+ m.formatted("One more decimal place")105+ return nil106+ }},107+ &command{id: "format.decimals_less", title: "Decrease decimal places", desc: "Show one less decimal place", run: func(m *Model) tea.Cmd {108+ m.sheet.AdjustDecimals(m.selection(), -1)109+ m.formatted("One less decimal place")110+ return nil111+ }},112+ &command{id: "format.clear", title: "Clear formatting", desc: "Reset number formats and text styles, keeping contents", run: func(m *Model) tea.Cmd {113+ m.sheet.ClearFormatting(m.selection())114+ m.formatted("Formatting cleared")115+ return nil116+ }},117+ )118+119+ // Sheets' shortcuts. Ctrl+Shift+digit needs a terminal that reports120+ // it (the kitty keyboard protocol, as in Ghostty, kitty or WezTerm).121+ for key, id := range map[string]string{122+ "ctrl+shift+1": "format.number",123+ "ctrl+shift+2": "format.time",124+ "ctrl+shift+3": "format.date",125+ "ctrl+shift+4": "format.currency",126+ "ctrl+shift+5": "format.percent",127+ "ctrl+shift+6": "format.scientific",128+ "ctrl+b": "format.bold",129+ "ctrl+i": "format.italic",130+ "ctrl+u": "format.underline",131+ "alt+shift+5": "format.strikethrough",132+ "ctrl+shift+l": "format.align_left",133+ "ctrl+shift+e": "format.align_center",134+ "ctrl+shift+r": "format.align_right",135+ "ctrl+\\": "format.clear",136+ } {137+ keymap[key] = id138+ }139+}140+141+// keyAliases maps other ways terminals report a shortcut to the form in142+// keymap: without the kitty protocol, or with a shifted character,143+// Ctrl+Shift+1 arrives as ctrl+! and Alt+Shift+5 as alt+%.144+var keyAliases = func() map[string]string {145+ a := map[string]string{"alt+%": "alt+shift+5", "alt+shift+%": "alt+shift+5"}146+ for i, sym := range "!@#$%^" {147+ d := string(rune('1' + i))148+ a["ctrl+"+string(sym)] = "ctrl+shift+" + d149+ a["ctrl+shift+"+string(sym)] = "ctrl+shift+" + d150+ }151+ for _, l := range "ler" {152+ a["ctrl+"+strings.ToUpper(string(l))] = "ctrl+shift+" + string(l)153+ }154+ return a155+}()156+157+// canonicalKey resolves keyAliases.158+func canonicalKey(k string) string {159+ if c, ok := keyAliases[k]; ok {160+ return c161+ }162+ return k163+}164+165+// formatted marks the sheet changed and says what happened, e.g. "Bold166+// on for B2:B5", on the third panel line.167+func (m *Model) formatted(what string) {168+ m.changed = true169+ m.note = what + " for " + m.selection().String()170+}171+172+func onOff(on bool) string {173+ if on {174+ return " on"175+ }176+ return " off"177+}
A internal/ui/format_test.go +242 -0
@@ -0,0 +1,242 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+9+ "one23/internal/sheet"10+)11+12+// cells returns the text of the first n columns of grid row r (1-based),13+// each trimmed.14+func cells(m *Model, r, n int) []string {15+ l := line(m, gridTop+r-1)16+ out := make([]string, n)17+ x := rowHdrW18+ for i := range n {19+ w := m.sheet.ColWidth(m.left + i)20+ seg := ""21+ if x < len(l) {22+ seg = l[x:min(x+w, len(l))]23+ }24+ out[i] = strings.TrimSpace(seg)25+ x += w26+ }27+ return out28+}29+30+// raw returns the columns of grid row r untrimmed, joined by |.31+func raw(m *Model, r, n int) string {32+ l := line(m, gridTop+r-1)33+ l += strings.Repeat(" ", max(0, 400-len(l)))34+ var parts []string35+ x := rowHdrW36+ for i := range n {37+ w := m.sheet.ColWidth(m.left + i)38+ parts = append(parts, l[x:x+w])39+ x += w40+ }41+ return strings.Join(parts, "|")42+}43+44+func TestFormatShortcutsApplyToSelection(t *testing.T) {45+ tests := []struct {46+ keys []string47+ a, b string48+ }{49+ {[]string{"<ctrl+shift+1>"}, "1,234.50", "0.25"},50+ {[]string{"<ctrl+!>"}, "1,234.50", "0.25"}, // shifted form of Ctrl+Shift+151+ {[]string{"<ctrl+shift+4>"}, "$1,234.50", "$0.25"},52+ {[]string{"<ctrl+$>"}, "$1,234.50", "$0.25"},53+ {[]string{"<ctrl+shift+5>"}, "123450.00%", "25.00%"},54+ {[]string{"<ctrl+shift+6>"}, "1.23E+03", "2.50E-01"},55+ {[]string{"<ctrl+shift+3>"}, "5/18/1903", "12/30/1899"},56+ {[]string{"<ctrl+shift+2>"}, "12:00:00 PM", "6:00:00 AM"},57+ }58+ for _, tt := range tests {59+ m := newModel()60+ m.sheet.SetColWidth(0, 12)61+ m.sheet.SetColWidth(1, 12)62+ press(t, m, "1234.5", "<tab>", "0.25", "<enter>", "<up>", "<shift+right>")63+ press(t, m, tt.keys...)64+ if got := cells(m, 1, 2); got[0] != tt.a || got[1] != tt.b {65+ t.Errorf("%v: row 1 = %q, want %q %q", tt.keys, got, tt.a, tt.b)66+ }67+ }68+}69+70+func TestFormatCommandsCoverSheetsMenu(t *testing.T) {71+ m := newModel()72+ press(t, m, "-1450", "<enter>", "<up>")73+ m.sheet.SetColWidth(0, 12)74+ tests := []struct{ id, want string }{75+ {"format.accounting", " $(1,450.00)"},76+ {"format.financial", " (1,450.00) "},77+ {"format.currency_rounded", " -$1,450 "},78+ {"format.duration", "-34800:00:00"}, // uses the padding: nothing to its right79+ {"format.plain_text", " -1450 "},80+ {"format.automatic", " -1450 "},81+ }82+ for _, tt := range tests {83+ m.runCommand(tt.id)84+ if got := raw(m, 1, 1); got != tt.want {85+ t.Errorf("%s: A1 shows %q, want %q", tt.id, got, tt.want)86+ }87+ }88+}89+90+func TestDecimalPlaces(t *testing.T) {91+ m := newModel()92+ press(t, m, "1.5", "<enter>", "<up>")93+ m.runCommand("format.decimals_more")94+ m.runCommand("format.decimals_more")95+ if got := cells(m, 1, 1)[0]; got != "1.500" {96+ t.Errorf("after two more: %q", got)97+ }98+ m.runCommand("format.decimals_less")99+ m.runCommand("format.decimals_less")100+ m.runCommand("format.decimals_less")101+ if got := cells(m, 1, 1)[0]; got != "2" {102+ t.Errorf("after three less: %q", got)103+ }104+}105+106+func TestTextStylesToggle(t *testing.T) {107+ m := newModel()108+ press(t, m, "Rent", "<enter>", "Food", "<enter>", "<up>", "<up>", "<shift+down>")109+ style := func(a string) sheet.Style {110+ if c := m.sheet.Cell(addr(a)); c != nil {111+ return c.Style112+ }113+ return sheet.Style{}114+ }115+ press(t, m, "<ctrl+b>")116+ if !style("A1").Bold || !style("A2").Bold {117+ t.Fatal("Ctrl+B didn't bold the selection")118+ }119+ if got := line(m, 2); got != "Bold on for A1:A2" {120+ t.Errorf("line 3 = %q", got)121+ }122+ press(t, m, "<ctrl+b>")123+ if style("A1").Bold || style("A2").Bold {124+ t.Error("second Ctrl+B didn't turn bold off")125+ }126+ if line(m, 2) != "Bold off for A1:A2" {127+ t.Errorf("line 3 = %q", line(m, 2))128+ }129+ press(t, m, "<ctrl+i>", "<ctrl+u>", "<alt+shift+5>")130+ if st := style("A2"); !st.Italic || !st.Underline || !st.Strikethrough {131+ t.Errorf("A2 style %+v", st)132+ }133+ press(t, m, "<ctrl+\\>")134+ if m.sheet.Cell(addr("A1")).Style != (sheet.Style{}) || input(m, "A1") != "Rent" {135+ t.Error("Ctrl+\\ didn't clear formatting, or cleared contents")136+ }137+ // The note goes away on the next key.138+ press(t, m, "<down>")139+ if line(m, 2) != "" {140+ t.Errorf("line 3 after moving = %q", line(m, 2))141+ }142+}143+144+func TestStylesRender(t *testing.T) {145+ m := newModel()146+ press(t, m, "Total", "<enter>", "<up>", "<ctrl+b>", "<ctrl+u>", "<down>")147+ grid := strings.Split(m.View().Content, "\n")[gridTop]148+ // Bold and underline wrap just the text, not the padding.149+ if !strings.Contains(grid, "m \x1b[1;4") || !strings.Contains(grid, "l\x1b[m ") {150+ t.Errorf("row 1 = %q", grid)151+ }152+}153+154+func TestAlignment(t *testing.T) {155+ m := newModel()156+ press(t, m, "<right>", "abc", "<tab>", "42", "<enter>")157+ press(t, m, "<up>", "<ctrl+shift+r>", "<right>", "<ctrl+shift+l>")158+ if got := raw(m, 1, 3); got != " | abc | 42 " {159+ t.Errorf("right/left = %q", got)160+ }161+ press(t, m, "<left>", "<ctrl+shift+e>", "<right>", "<ctrl+shift+e>")162+ if got := raw(m, 1, 3); got != " | abc | 42 " {163+ t.Errorf("centered = %q", got)164+ }165+}166+167+func TestOverflowDirections(t *testing.T) {168+ m := newModel()169+ // Right-aligned text runs left into blank cells; centered text runs170+ // both ways; a formatted blank still lets text through.171+ press(t, m, "<right>", "<right>", "Right-aligned heading", "<enter>", "<up>", "<ctrl+shift+r>")172+ if got := raw(m, 1, 4); got != " Ri|ght-aligne|d heading | " {173+ t.Errorf("right overflow = %q", got)174+ }175+ m.sheet.SetFormat(sheet.Rect{From: addr("B1"), To: addr("B1")}, sheet.Preset(sheet.FmtCurrency))176+ press(t, m, "<ctrl+shift+e>")177+ if got := raw(m, 1, 4); got != " | Right|-aligned h|eading " {178+ t.Errorf("centered overflow = %q", got)179+ }180+ // A filled neighbor stops it.181+ press(t, m, "<left>", "x", "<enter>")182+ if got := raw(m, 1, 4); got != " | x |-aligned h|eading " {183+ t.Errorf("blocked overflow = %q", got)184+ }185+}186+187+func TestNumbersTooWideShowHashes(t *testing.T) {188+ m := newModel()189+ press(t, m, "1234567.891", "<enter>", "<up>", "<ctrl+shift+1>")190+ if got := raw(m, 1, 1); got != "######### " {191+ t.Errorf("A1 = %q", got)192+ }193+ m.sheet.SetColWidth(0, 14)194+ if got := raw(m, 1, 1); got != " 1,234,567.89 " {195+ t.Errorf("wider A1 = %q", got)196+ }197+}198+199+func TestLongDatesUseThePadding(t *testing.T) {200+ m := newModel()201+ press(t, m, "12/31/2026", "<enter>")202+ if got := raw(m, 1, 2); got != "12/31/2026| " {203+ t.Errorf("A1 = %q", got)204+ }205+ // With a neighbor there's no room, as in Sheets.206+ press(t, m, "<up>", "<right>", "x", "<enter>")207+ if got := raw(m, 1, 2); got != "######### | x " {208+ t.Errorf("A1 next to B1 = %q", got)209+ }210+}211+212+func TestTypedDatesAndCurrencyShowFormatted(t *testing.T) {213+ m := newModel()214+ press(t, m, "9/26/2026", "<enter>", "$1,200", "<enter>", "12.5%", "<enter>", "14:30", "<enter>")215+ for i, want := range []string{"9/26/2026", "$1,200", "12.50%", "14:30:00"} {216+ if got := cells(m, i+1, 1)[0]; got != want {217+ t.Errorf("row %d = %q, want %q", i+1, got, want)218+ }219+ }220+ // The formula bar keeps what was typed.221+ press(t, m, "<ctrl+home>")222+ if !strings.HasPrefix(line(m, 0), " A1 9/26/2026") {223+ t.Errorf("formula bar %q", line(m, 0))224+ }225+}226+227+func TestFormatHelpListsShortcuts(t *testing.T) {228+ m := newModel()229+ m.Update(teaSize(120, 60))230+ press(t, m, "<f1>")231+ s := screen(m)232+ for _, want := range []string{"Ctrl+Shift+4", "Ctrl+B", "Alt+Shift+5", "Ctrl+\\"} {233+ if !strings.Contains(s, want) {234+ t.Errorf("help lacks %s", want)235+ }236+ }237+ if strings.Contains(s, "Ctrl+$") {238+ t.Error("help lists key aliases")239+ }240+}241+242+func teaSize(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} }
M internal/ui/model.go +3 -1
M internal/ui/theme.go +23 -0
M internal/ui/view.go +138 -46
@@ -9,6 +9,7 @@ import (
99 "strings"1010 1111 tea "charm.land/bubbletea/v2"12+ "charm.land/lipgloss/v2"1213 "github.com/charmbracelet/x/ansi"1314 1415 "one23/internal/sheet"
@@ -107,6 +108,8 @@ func (m *Model) panelLine3() string {
107108 switch {108109 case m.hint != "":109110 return m.th.warning.Render(m.hint)111+ case m.note != "" && m.mode == modeReady:112+ return m.th.hint.Render(m.note)110113 case m.mode == modeMenu:111114 lvl := m.menu[len(m.menu)-1]112115 return lvl.items[lvl.sel].description(m)
@@ -204,25 +207,55 @@ func (m *Model) gridRow(row int) string {
204207 var b strings.Builder205208 b.WriteString(hdr.Render(padLeft(strconv.Itoa(row+1), rowHdrW-1) + " "))206209 207- for i, text := range m.rowText(row) {210+ for i, sp := range m.rowText(row) {208211 a := sheet.Addr{Col: m.left + i, Row: row}209- if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) {210- text = m.inCellText(m.sheet.ColWidth(a.Col))211- }212+ base, colored := m.th.cell, true212213 switch {213214 case a == focus:214- b.WriteString(m.th.pointer.Render(text))215+ base = m.th.pointer215216 case selecting && sel.Contains(a):216- b.WriteString(m.th.selection.Render(text))217+ base = m.th.selection217218 case m.sheet.Value(a).Kind == sheet.Error:218- b.WriteString(m.th.errorCell.Render(text))219+ base = m.th.errorCell219220 default:220- b.WriteString(text)221+ colored = false222+ }223+ if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) {224+ sp = span{text: m.inCellText(m.sheet.ColWidth(a.Col))}221225 }226+ b.WriteString(m.renderSpan(sp, base, colored))222227 }223228 return b.String()224229 }225230 231+// span is what one grid column shows in a row: blank columns, the text,232+// blank columns. Only the text takes the owning cell's text style, so an233+// underline doesn't run into the padding.234+type span struct {235+ lead int236+ text string237+ trail int238+ style sheet.Style239+ owner int // column of the cell the text belongs to240+}241+242+// renderSpan draws a span on base, one of the cell roles. Plain cells243+// with no text style are written without escape codes.244+func (m *Model) renderSpan(sp span, base lipgloss.Style, colored bool) string {245+ lead, trail := strings.Repeat(" ", sp.lead), strings.Repeat(" ", sp.trail)246+ st := sp.style247+ st.Align = sheet.AlignAuto248+ switch {249+ case st.IsZero() && !colored:250+ return lead + sp.text + trail251+ case st.IsZero():252+ return base.Render(lead + sp.text + trail)253+ case !colored:254+ return lead + m.th.text(base, st).Render(sp.text) + trail255+ }256+ return base.Render(lead) + m.th.text(base, st).Render(sp.text) + base.Render(trail)257+}258+226259 // inCellText shows the entry being typed inside the cell, keeping the end227260 // of long entries visible, as Sheets does.228261 func (m *Model) inCellText(w int) string {
@@ -233,47 +266,112 @@ func (m *Model) inCellText(w int) string {
233266 return padRight(text, w)234267 }235268 236-// rowText returns the visible text of each column in row, each exactly the237-// column's width. Text starts after one column of padding and overflows238-// into blank cells to the right, including text that starts left of the239-// viewport, as in Sheets.240-func (m *Model) rowText(row int) []string {269+// rowText lays out the visible columns of row, each span exactly its270+// column's width. Values are formatted with their cell's number format271+// and aligned as Sheets does: numbers right, text left, booleans and272+// errors centered, unless the cell sets an alignment. Text runs on into273+// blank neighbors: to the right when left-aligned, to the left when274+// right-aligned, both ways when centered. It can come from cells outside275+// the viewport, so the scan starts at the nearest filled cell on each276+// side.277+func (m *Model) rowText(row int) []span {241278 ncols := m.visibleCols(m.left)242- out := make([]string, ncols)279+ lo, hi := m.left, m.left+ncols-1280+ out := make([]span, ncols)281+ for i := range out {282+ out[i] = span{trail: m.sheet.ColWidth(lo + i)}283+ }284+ content := func(c int) *sheet.Cell {285+ if c := m.sheet.Cell(sheet.Addr{Col: c, Row: row}); !c.Blank() {286+ return c287+ }288+ return nil289+ }290+ first, last := lo, hi291+ for c := lo - 1; c >= 0; c-- {292+ if content(c) != nil {293+ first = c294+ break295+ }296+ }297+ for c := hi + 1; c < sheet.MaxCols; c++ {298+ if content(c) != nil {299+ last = c300+ break301+ }302+ }303+ // x[k] is where column first+k starts, relative to column first.304+ x := make([]int, last-first+2)305+ for c := first; c <= last; c++ {306+ x[c-first+1] = x[c-first] + m.sheet.ColWidth(c)307+ }308+ col := func(c int) (int, int) { return x[c-first], x[c-first+1] }243309 244- var spill string245- for c := m.left - 1; c >= 0; c-- {246- cell := m.sheet.Cell(sheet.Addr{Col: c, Row: row})310+ claimed := 0 // text of earlier cells reaches up to here311+ for c := first; c <= last; c++ {312+ cell := content(c)247313 if cell == nil {248314 continue249315 }316+ x0, x1 := col(c)317+ f := m.sheet.DisplayFormat(sheet.Addr{Col: c, Row: row})318+ text, align := sheet.Display(cell.Value, f, x1-x0)319+ pad := 1320+ // A number one character too wide (12/31/2026 in a default321+ // column) may use the padding when nothing is to its right,322+ // rather than turning into #s.323+ if cell.Value.Kind == sheet.Number && strings.Trim(text, "#") == "" && content(c+1) == nil {324+ if wider, _ := sheet.Display(cell.Value, f, x1-x0+1); strings.Trim(wider, "#") != "" {325+ text, pad = wider, 0326+ }327+ }328+ if a := cell.Style.Align; a != sheet.AlignAuto && align != sheet.AlignFill {329+ align = a330+ }331+ tw := ansi.StringWidth(text)332+ start := x0 + pad333+ switch align {334+ case sheet.AlignFill:335+ start = x0336+ case sheet.AlignRight:337+ start = x1 - pad - tw338+ case sheet.AlignCenter:339+ start = x0 + (x1-x0-tw)/2340+ }341+ from, to := max(x0, claimed), x1 // where this cell's text may go250342 if cell.Value.Kind == sheet.Text {251- spill = " " + cell.Value.Str252- for k := c; k < m.left && spill != ""; k++ {253- _, spill = cut(spill, m.sheet.ColWidth(k))343+ for k := c + 1; k <= last && start+tw > to && content(k) == nil; k++ {344+ _, to = col(k)345+ }346+ for k := c - 1; k >= first && start < from && content(k) == nil; k-- {347+ if kx0, _ := col(k); kx0 >= claimed {348+ from = kx0349+ } else {350+ from = claimed351+ break352+ }254353 }255354 }256- break355+ claimed = to356+ for k := max(first, lo); k <= min(last, hi); k++ {357+ kx0, kx1 := col(k)358+ if kx1 <= from || kx0 >= to {359+ continue360+ }361+ sp := span{trail: kx1 - kx0, style: cell.Style, owner: c}362+ if seg0, seg1 := max(start, kx0, from), min(start+tw, kx1, to); seg1 > seg0 {363+ sp.lead, sp.text, sp.trail = seg0-kx0, ansi.Cut(text, seg0-start, seg1-start), kx1-seg1364+ }365+ out[k-lo] = sp366+ }257367 }258-259- for i := range ncols {260- c := m.left + i261- w := m.sheet.ColWidth(c)262- cell := m.sheet.Cell(sheet.Addr{Col: c, Row: row})263- switch {264- case cell != nil && cell.Value.Kind == sheet.Text:265- var head string266- head, spill = cut(" "+cell.Value.Str, w)267- out[i] = padRight(head, w)268- case cell != nil:269- spill = ""270- out[i] = padRight(sheet.FormatValue(cell.Value, w), w)271- case spill != "":272- var head string273- head, spill = cut(spill, w)274- out[i] = padRight(head, w)275- default:276- out[i] = strings.Repeat(" ", w)368+ // Text that runs to the edge of its column would touch a neighbor369+ // that starts at its own edge ("Groceries9/28/2026"); keep a gap.370+ for i := 0; i+1 < len(out); i++ {371+ l, r := &out[i], &out[i+1]372+ if l.text != "" && l.trail == 0 && r.text != "" && r.lead == 0 && l.owner != r.owner {373+ l.text = ansi.Truncate(l.text, ansi.StringWidth(l.text)-1, "")374+ l.trail = 1277375 }278376 }279377 return out
@@ -399,12 +497,6 @@ func keyLabel(k string) string {
399497 return strings.Join(parts, "+")400498 }401499 402-// cut splits s after w display columns.403-func cut(s string, w int) (head, tail string) {404- head = ansi.Truncate(s, w, "")405- return head, s[len(head):]406-}407-408500 func padRight(s string, w int) string {409501 return s + strings.Repeat(" ", max(w-ansi.StringWidth(s), 0))410502 }