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>
| M | README.md | 8 | +- |
| M | e2e/ | 2 | +- |
| A | e2e/ | 47 | ++++++ |
| M | e2e/ | 20 | +- |
| M | e2e/ | 20 | +- |
| A | internal/ | 177 | ++++++++++++++++++++++ |
| A | internal/ | 242 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 4 | +- |
| M | internal/ | 23 | +++ |
| M | internal/ | 184 | +++++++++++++++++------ |
@@ -14,7 +14,7 @@ Inside the grid, one23 works like Google Sheets.
1414 1515 | Key | Action |1616 |---|---|17-| Type | Replace the cell. `=` starts a formula, `'` forces text; `$1,200` and `12%` are numbers |17+| Type | Replace the cell. `=` starts a formula, `'` forces text; `$1,200`, `12%`, `9/26/2026` and `14:30` are numbers that keep their format |1818 | Enter / Tab | Accept and move down / right (Enter returns to where a run of Tabs began) |1919 | Enter or F2, double-click | Edit the cell |2020 | Arrows while typing a formula | After an operator, pick a cell; Shift+arrows pick a range |@@ -22,7 +22,11 @@ Inside the grid, one23 works like Google Sheets.
2222 | Ctrl+arrows | Jump to the edge of the data (add Shift to select) |2323 | Click a header, Ctrl+Space, Shift+Space | Select whole columns or rows |2424 | Ctrl+A | Select the data, then everything |25-| Del / Backspace | Clear the selection |25+| Del / Backspace | Clear the selection (formatting stays) |26+| Ctrl+Shift+1 ... 6 | Number, time, date, currency, percent, scientific format |27+| Ctrl+B, Ctrl+I, Ctrl+U, Alt+Shift+5 | Bold, italic, underline, strikethrough |28+| Ctrl+Shift+L / E / R | Align left, center, right |29+| Ctrl+\ | Clear formatting |2630 | Ctrl+S, Ctrl+O, Ctrl+Q | Save, open, quit |2731 | Ctrl+G or F5 | Go to a cell |2832 | F10 | Menu |@@ -64,7 +64,7 @@ func TestTextOverflow(t *testing.T) {
6464 s := start(t, "")6565 s.keys("Quarterly revenue", "<enter>", "$1,200", "<enter>", "TRUE", "<enter>")6666 s.waitForLine(gridRow1, " 1 Quarterly revenue")67- s.waitForLine(gridRow1+1, numRow(2, "1200"))67+ s.waitForLine(gridRow1+1, numRow(2, "$1,200"))6868 s.waitForLine(gridRow1+2, " 3 TRUE")6969 }7070 @@ -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+}@@ -7,20 +7,24 @@
77 Mouse Click, drag or Shift+click to select; click headers88 for whole columns or rows; double-click to edit99 10+ Ctrl+B Make text bold, or plain again11+ Ctrl+Shift+E Center horizontally1012 Backspace / Del Clear the contents of the selected cells13+ Ctrl+\ Reset number formats and text styles, keeping contents14+ Ctrl+Shift+4 Currency: $1,000.1215+ Ctrl+Shift+3 Date: 9/26/20261116 Esc Collapse the selection to the active cell1217 Enter / F2 Edit the active cell's contents1318 Ctrl+G / F5 Move to a cell address19+ Ctrl+I Make text italic, or upright again20+ Ctrl+Shift+L Align to the left1421 F10 Open the menu22+ Ctrl+Shift+1 Number with thousands separators: 1,000.121523 Ctrl+O Open a sheet, replacing this one24+ Ctrl+Shift+5 Percent: 10.12%1625 Ctrl+Q Close one2326+ Ctrl+Shift+R Align to the right1727 Ctrl+S Save the sheet28+ Ctrl+Shift+6 Scientific notation: 1.01E+031829 Ctrl+A Select the data, then the whole sheet19- Ctrl+Space Select the whole columns of the selection20- Shift+Space Select the whole rows of the selection21-22- Typing replaces the cell. Start with = for a formula, ' to force text.23- While typing a formula, arrows after an operator pick cells.24-25- Functions: ABS AND AVERAGE COUNT COUNTA FALSE IF IFERROR INT MAX MIN MOD NA NOT OR PI ROUND SQRT26- SUM TRUE</pre>\ No newline at end of file30+ Ctrl+Space Select the whole columns of the selection</pre>\ No newline at end of file@@ -7,20 +7,24 @@
77 Mouse Click, drag or Shift+click to select; click headers88 for whole columns or rows; double-click to edit99 10+ Ctrl+B Make text bold, or plain again11+ Ctrl+Shift+E Center horizontally1012 Backspace / Del Clear the contents of the selected cells13+ Ctrl+\ Reset number formats and text styles, keeping contents14+ Ctrl+Shift+4 Currency: $1,000.1215+ Ctrl+Shift+3 Date: 9/26/20261116 Esc Collapse the selection to the active cell1217 Enter / F2 Edit the active cell's contents1318 Ctrl+G / F5 Move to a cell address19+ Ctrl+I Make text italic, or upright again20+ Ctrl+Shift+L Align to the left1421 F10 Open the menu22+ Ctrl+Shift+1 Number with thousands separators: 1,000.121523 Ctrl+O Open a sheet, replacing this one24+ Ctrl+Shift+5 Percent: 10.12%1625 Ctrl+Q Close one2326+ Ctrl+Shift+R Align to the right1727 Ctrl+S Save the sheet28+ Ctrl+Shift+6 Scientific notation: 1.01E+031829 Ctrl+A Select the data, then the whole sheet19- Ctrl+Space Select the whole columns of the selection20- Shift+Space Select the whole rows of the selection21-22- Typing replaces the cell. Start with = for a formula, ' to force text.23- While typing a formula, arrows after an operator pick cells.24-25- Functions: ABS AND AVERAGE COUNT COUNTA FALSE IF IFERROR INT MAX MIN MOD NA NOT OR PI ROUND SQRT26- SUM TRUE</pre>\ No newline at end of file30+ Ctrl+Space Select the whole columns of the selection</pre>\ No newline at end of file@@ -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+}@@ -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} }@@ -70,6 +70,7 @@ type Model struct {
7070 buf []rune7171 bufPos int7272 hint string // shown on the third panel line, e.g. a formula error73+ note string // what the last command did, until the next key7374 7475 point pointer // POINT mode and range prompts7576 pointPrefix string // entry text before the reference being pointed at@@ -103,6 +104,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
103104 case tea.BackgroundColorMsg:104105 m.th = newTheme(msg.IsDark())105106 case tea.KeyPressMsg:107+ m.note = ""106108 cmd = m.handleKey(msg)107109 case tea.PasteMsg:108110 m.handlePaste(msg.Content)@@ -156,7 +158,7 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
156158 m.tabbing = false157159 return nil158160 }159- if id, ok := keymap[key]; ok {161+ if id, ok := keymap[canonicalKey(key)]; ok {160162 return m.runCommand(id)161163 }162164 if text := typed(k); text != "" {@@ -2,6 +2,8 @@ package ui
22 33 import (44 "charm.land/lipgloss/v2"5+6+ "one23/internal/sheet"57 )68 79 // theme holds every style the UI draws with. Views must use these roles@@ -25,6 +27,26 @@ type theme struct {
2527 muted lipgloss.Style // secondary text: key hints, file lists2628 key lipgloss.Style // a key name inside a hint, e.g. "Enter"2729 errorCell lipgloss.Style // cells whose value is ERR or NA30+ cell lipgloss.Style // an ordinary cell: the base for bold, italic and underline31+}32+33+// text adds a cell's bold, italic, underline and strikethrough to base,34+// one of the cell roles (cell, pointer, selection, errorCell), so text35+// styles show through the pointer and selection colors.36+func (t theme) text(base lipgloss.Style, st sheet.Style) lipgloss.Style {37+ if st.Bold {38+ base = base.Bold(true)39+ }40+ if st.Italic {41+ base = base.Italic(true)42+ }43+ if st.Underline {44+ base = base.Underline(true)45+ }46+ if st.Strikethrough {47+ base = base.Strikethrough(true)48+ }49+ return base2850 }2951 3052 func newTheme(dark bool) theme {@@ -51,5 +73,6 @@ func newTheme(dark bool) theme {
5173 muted: lipgloss.NewStyle().Foreground(muted),5274 key: lipgloss.NewStyle().Bold(true),5375 errorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),76+ cell: lipgloss.NewStyle(),5477 }5578 }@@ -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 }