cat / 012
clone

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

Add semantic theme with light variant and fix contrast issues

Dami committed 071e0a6
All views style through theme roles. The app asks the terminal for its
background color and switches to a light variant, which fixes unreadable
white-on-gray headers on light terminals. Range selection text is now
dark on blue in dark mode, the highlighted menu item uses an explicit
accent instead of reverse video (which some terminals render nearly
invisible), row numbers are right-aligned, error cells are red and help
shows keys as F2 and Del instead of upper-cased names.
commit
071e0a65d908eec63cbeec5cff9dcbd8ce27c554
parent
48ae55d409f7747e9bb49cccb1ded8a0ce70690d
author
Dami <dami.etoile@gmail.com>
2 files changed, +117 -45
Ainternal/ui/theme.go51++++++++++++++
Minternal/ui/view.go111++++++++++++++++++------------
A internal/ui/theme.go +51 -0
@@ -0,0 +1,51 @@
1+package ui2+3+import (4+ "charm.land/lipgloss/v2"5+)6+7+// theme holds every style the UI draws with. Views must use these roles8+// instead of creating styles inline, so the look stays consistent and a9+// new theme only has to be defined here.10+//11+// Colors are the 16 ANSI colors, so the sheet follows the user's terminal12+// palette; dark and light variants only differ where ANSI colors would13+// lose contrast.14+type theme struct {15+ indicator lipgloss.Style // mode indicator, top right16+ header lipgloss.Style // column letters and row numbers17+ headerActive lipgloss.Style // header of the focused row and column18+ pointer lipgloss.Style // the cell pointer19+ selection lipgloss.Style // a range being pointed at20+ menuSelected lipgloss.Style // highlighted menu item21+ hint lipgloss.Style // guidance on the third panel line22+ warning lipgloss.Style // recoverable problems, e.g. a formula error23+ error lipgloss.Style // ERROR mode message24+ muted lipgloss.Style // secondary text: key hints, file lists25+ errorCell lipgloss.Style // cells whose value is ERR or NA26+}27+28+func newTheme(dark bool) theme {29+ // Contrast was checked against the reference palettes in e2e: text on30+ // colored backgrounds stays at or above roughly 4.5:1.31+ headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite32+ selFg, muted := lipgloss.Black, lipgloss.BrightBlack33+ if !dark {34+ headerBg, headerFg = lipgloss.White, lipgloss.Black35+ selFg, muted = lipgloss.BrightWhite, lipgloss.Black36+ }37+ accent := lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)38+ return theme{39+ indicator: accent.Bold(true),40+ header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),41+ headerActive: accent.Bold(true),42+ pointer: accent,43+ selection: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),44+ menuSelected: accent.Bold(true),45+ hint: lipgloss.NewStyle().Foreground(muted),46+ warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),47+ error: lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true),48+ muted: lipgloss.NewStyle().Foreground(muted),49+ errorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),50+ }51+}
M internal/ui/view.go +66 -45
@@ -3,30 +3,16 @@ package ui
33 import (44 "fmt"55 "path/filepath"6+ "slices"67 "strconv"78 "strings"89 910 tea "charm.land/bubbletea/v2"10- "charm.land/lipgloss/v2"1111 "github.com/charmbracelet/x/ansi"1212 1313 "one23/internal/sheet"1414 )1515 16-// Styles use the 16 ANSI colors so the sheet follows the user's terminal17-// theme.18-var (19- indicatorStyle = lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black).Bold(true)20- headerStyle = lipgloss.NewStyle().Background(lipgloss.BrightBlack).Foreground(lipgloss.BrightWhite)21- headerCurStyle = lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black).Bold(true)22- pointerStyle = lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)23- rangeStyle = lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(lipgloss.BrightWhite)24- menuSelStyle = lipgloss.NewStyle().Reverse(true)25- hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Yellow)26- errorStyle = lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true)27- dimStyle = lipgloss.NewStyle().Foreground(lipgloss.BrightBlack)28-)29-3016 // View implements tea.Model.3117 func (m *Model) View() tea.View {3218 var lines []string
@@ -74,7 +60,7 @@ func (m *Model) panelLine1() string {
7460 if m.mode == modePrompt {7561 ind = m.prompt.indicator7662 }77- ind = indicatorStyle.Render(" " + ind + " ")63+ ind = m.th.indicator.Render(" " + ind + " ")7864 gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(ind)7965 if gap < 1 {8066 left = ansi.Truncate(left, m.width-ansi.StringWidth(ind)-1, "")
@@ -96,7 +82,7 @@ func (m *Model) panelLine2() string {
9682 for i, it := range lvl.items {9783 parts[i] = it.name9884 if i == lvl.sel {99- parts[i] = menuSelStyle.Render(it.name)85+ parts[i] = m.th.menuSelected.Render(it.name)10086 }10187 }10288 return strings.Join(parts, " ")
@@ -121,13 +107,13 @@ func (m *Model) panelLine3() string {
121107 lvl := m.menu[len(m.menu)-1]122108 return lvl.items[lvl.sel].description(m)123109 case m.mode == modePrompt && len(m.files) > 0:124- return dimStyle.Render(strings.Join(m.files, " "))110+ return m.th.muted.Render(strings.Join(m.files, " "))125111 case m.mode == modePrompt && m.prompt.kind == promptWidth:126- return dimStyle.Render("Type a width or use the left and right arrows")112+ return m.th.muted.Render("Type a width or use the left and right arrows")127113 case m.pointing() || m.mode == modePoint:128- return dimStyle.Render("Arrows move, . anchors a range, Esc unanchors, Enter accepts")114+ return m.th.muted.Render("Arrows move, . anchors a range, Esc unanchors, Enter accepts")129115 case m.hint != "":130- return hintStyle.Render(m.hint)116+ return m.th.warning.Render(m.hint)131117 }132118 return ""133119 }
@@ -145,13 +131,13 @@ func (m *Model) cursorX() (int, bool) {
145131 146132 func (m *Model) headerRow() string {147133 var b strings.Builder148- b.WriteString(headerStyle.Render(strings.Repeat(" ", rowHdrW)))134+ b.WriteString(m.th.header.Render(strings.Repeat(" ", rowHdrW)))149135 focus := *m.focus()150136 for i := range m.visibleCols(m.left) {151137 c := m.left + i152- style := headerStyle138+ style := m.th.header153139 if c == focus.Col {154- style = headerCurStyle140+ style = m.th.headerActive155141 }156142 b.WriteString(style.Render(center(sheet.ColName(c), m.sheet.ColWidth(c))))157143 }
@@ -163,12 +149,12 @@ func (m *Model) gridRow(row int) string {
163149 return ""164150 }165151 focus := *m.focus()166- hdr := headerStyle152+ hdr := m.th.header167153 if row == focus.Row {168- hdr = headerCurStyle154+ hdr = m.th.headerActive169155 }170156 var b strings.Builder171- b.WriteString(hdr.Render(padRight(strconv.Itoa(row+1), rowHdrW)))157+ b.WriteString(hdr.Render(padLeft(strconv.Itoa(row+1), rowHdrW-1) + " "))172158 173159 var sel sheet.Rect174160 selecting := m.mode == modePoint || m.pointing()
@@ -179,9 +165,11 @@ func (m *Model) gridRow(row int) string {
179165 a := sheet.Addr{Col: m.left + i, Row: row}180166 switch {181167 case a == focus:182- b.WriteString(pointerStyle.Render(text))168+ b.WriteString(m.th.pointer.Render(text))183169 case selecting && sel.Contains(a):184- b.WriteString(rangeStyle.Render(text))170+ b.WriteString(m.th.selection.Render(text))171+ case m.sheet.Value(a).Kind == sheet.Error:172+ b.WriteString(m.th.errorCell.Render(text))185173 default:186174 b.WriteString(text)187175 }
@@ -252,7 +240,7 @@ func cellText(c *sheet.Cell, w int, spill *string) string {
252240 253241 func (m *Model) statusLine() string {254242 if m.mode == modeError {255- return errorStyle.Render(m.errMsg) + dimStyle.Render(" (press any key)")243+ return m.th.error.Render(m.errMsg) + m.th.muted.Render(" (press any key)")256244 }257245 left := m.displayName()258246 if m.changed {
@@ -263,34 +251,67 @@ func (m *Model) statusLine() string {
263251 ind = append(ind, "CIRC")264252 }265253 ind = append(ind, "F1 Help / Menu")266- right := dimStyle.Render(strings.Join(ind, " "))254+ right := m.th.muted.Render(strings.Join(ind, " "))267255 gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)268256 return left + strings.Repeat(" ", gap) + right269257 }270258 271259 func (m *Model) helpLines() []string {272- return []string{273- indicatorStyle.Render(" HELP ") + " one23 keys (press any key to return)",260+ lines := []string{261+ m.th.indicator.Render(" HELP ") + " one23 (press any key to return)",262+ "",263+ " Arrows Move the cell pointer",264+ " PgUp / PgDn Move one screen up or down",265+ " Tab / Shift+Tab Move one screen right or left",266+ " Home Go to A1",267+ " Ctrl+C Quit immediately",274268 "",275- " Arrows Move the cell pointer",276- " PgUp / PgDn Move one screen up or down",277- " Tab / Shift+Tab Move one screen right or left",278- " Home Go to A1",279- " F5 Go to a cell address",280- " F2 Edit the current cell",281- " Del Erase the current cell",282- " / or < Open the menu (type an item's first letter)",283- " Ctrl+C Quit immediately",269+ }270+ // Shortcuts come from the keymap so help can't drift from behavior.271+ var ids []string272+ for id := range commands {273+ if len(keysFor(id)) > 0 {274+ ids = append(ids, id)275+ }276+ }277+ slices.SortFunc(ids, func(a, b string) int { return strings.Compare(commands[a].title, commands[b].title) })278+ for _, id := range ids {279+ keys := keysFor(id)280+ for i, k := range keys {281+ keys[i] = keyLabel(k)282+ }283+ lines = append(lines, fmt.Sprintf(" %-20s%s", strings.Join(keys, " or "), commands[id].desc))284+ }285+ return append(lines,284286 "",285287 " Entries",286- " Text Label (prefix ' left, \" right, ^ center)",287- " 0-9 + - . ( @ # Value or formula, e.g. +A1*2, @SUM(A1..A5)",288- " Arrows after an operator enter POINT mode; . anchors a range",288+ " Text Label (prefix ' left, \" right, ^ center)",289+ " 0-9 + - . ( @ # $ Value or formula, e.g. +A1*2, @SUM(A1..A5)",290+ " Arrow after + ( , POINT mode: arrow to a cell, . anchors a range",289291 "",290292 " Functions: @SUM @AVG @COUNT @MIN @MAX @ABS @INT @SQRT @ROUND @MOD",291293 " @IF @PI @TRUE @FALSE @ERR @NA",292294 " Operators: + - * / ^ & = <> < > <= >= #AND# #OR# #NOT#",295+ )296+}297+298+// keyLabel formats a key binding for display, e.g. "delete" -> "Del".299+func keyLabel(k string) string {300+ switch k {301+ case "delete":302+ return "Del"303+ case "enter":304+ return "Enter"305+ case "esc":306+ return "Esc"307+ }308+ parts := strings.Split(k, "+")309+ for i, p := range parts {310+ if len(p) > 1 {311+ parts[i] = strings.ToUpper(p[:1]) + p[1:]312+ }293313 }314+ return strings.Join(parts, "+")294315 }295316 296317 // cut splits s after w display columns.