The style roles, framed boxes, key chips, key hints and text padding depend on nothing in the model, so they live in their own package that any part of the UI can use. Model drops the methods that only wrapped the theme (frame, chip, chips, keyHints, highlightMatches).
- commit
- c1ff31a7cb4e6900790f1927e11e4e836a46f1c0
- parent
- 6fef7ff87932957efb9097a34cc96f84227b5674
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 17 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 40 | +++--- |
| M | internal/ | 6 | +- |
| M | internal/ | 3 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 35 | +++--- |
| M | internal/ | 20 | ++-- |
| M | internal/ | 3 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 28 | ++--- |
| M | internal/ | 5 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 8 | +- |
| M | internal/ | 3 | +- |
| M | internal/ | 25 | ++-- |
| M | internal/ | 7 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 41 | ------ |
| M | internal/ | 55 | ++------ |
| M | internal/ | 74 | +++++------- |
| M | internal/ | 16 | +- |
| M | internal/ | 14 | +- |
| D | internal/ | 191 | ----------------------------- |
| A | internal/ | 196 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 140 | +++++++++++++++++++++ |
| M | internal/ | 6 | +- |
| M | internal/ | 8 | +- |
| M | internal/ | 91 | ++++---------- |
@@ -10,6 +10,7 @@ import (
1010 "github.com/charmbracelet/x/ansi"1111 1212 "github.com/FelineStateMachine/012/internal/sheet"13+ "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415 1516 // Formula assistance, as in Sheets: while a function or range name is@@ -235,15 +236,15 @@ func (m *Model) assistBox() (box, bool) {
235236 lines := make([]string, 0, rows)236237 for i := a.top; i < a.top+rows; i++ {237238 s := list[i]238- base, dim := m.th.menuBar, m.th.muted239+ base, dim := m.th.MenuBar, m.th.Muted239240 if i == a.sel {240- base, dim = m.th.menuSelected, m.th.menuSelected241+ base, dim = m.th.MenuSelected, m.th.MenuSelected241242 }242243 detail := s.detail243244 if s.fn {244245 detail = s.detail[len(s.name):] // just the arguments: (value1, ...)245246 }246- row := base.Render(" "+padRight(s.name, nw)+" ") + dim.Render(detail)247+ row := base.Render(" "+theme.PadRight(s.name, nw)+" ") + dim.Render(detail)247248 lines = append(lines, ansi.Truncate(row, inner, "…")+base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row), 0))))248249 }249250 footer := ""@@ -253,7 +254,7 @@ func (m *Model) assistBox() (box, bool) {
253254 // The box's text starts one column in, under the word's first letter.254255 x := formulaBarTextX() + ansi.StringWidth(string(m.buf[:start])) - 2255256 x = clamp(x, 0, max(m.width-inner-2, 0))256- return box{id: assistID, x: x, y: contextLine + 1, lines: m.frame(inner, "", footer, lines)}, true257+ return box{id: assistID, x: x, y: contextLine + 1, lines: m.th.Frame(inner, "", footer, lines)}, true257258 }258259 259260 // assistMouse lets the mouse hover and click suggestions and scroll the@@ -296,7 +297,7 @@ func (m *Model) assistStatus() (desc, keys string, ok bool) {
296297 if list == nil {297298 return "", "", false298299 }299- return list[m.assist.sel].desc, m.keyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true300+ return list[m.assist.sel].desc, m.th.KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true300301 }301302 302303 // inFunction reports whether the caret is inside a known function's@@ -314,7 +315,7 @@ func (m *Model) signatureLine(buf []rune, pos int, hints string) (left, right st
314315 if sig == "" {315316 return "", "", false316317 }317- full := sig + " " + m.th.muted.Render(desc)318+ full := sig + " " + m.th.Muted.Render(desc)318319 for _, try := range [][2]string{{full, hints}, {sig, hints}, {full, ""}} {319320 if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= m.width {320321 return try[0], try[1], true@@ -335,13 +336,13 @@ func (m *Model) signature(buf []rune, pos int) (sig, desc string) {
335336 parts := splitArgs(f.Args)336337 cur := argPart(parts, c.arg, f.Max < 0)337338 var b strings.Builder338- b.WriteString(m.th.key.Render(f.Name) + "(")339+ b.WriteString(m.th.Key.Render(f.Name) + "(")339340 for i, p := range parts {340341 if i > 0 {341342 b.WriteString(", ")342343 }343344 if i == cur {344- b.WriteString(m.th.argument.Render(p))345+ b.WriteString(m.th.Argument.Render(p))345346 } else {346347 b.WriteString(p)347348 }@@ -93,7 +93,7 @@ func TestAutocompleteFunctions(t *testing.T) {
9393 }9494 press(t, m, "1,")9595 sig, _ := m.signature(m.buf, m.bufPos)96- if !strings.Contains(sig, m.th.argument.Render("[value2, ...]")) {96+ if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {9797 t.Errorf("second argument not marked: %q", sig)9898 }9999 press(t, m, "2)", "<enter>")@@ -133,7 +133,7 @@ func (m *Model) deleteChart(i int) {
133133 if _, ok := m.overlay.(*chartSel); ok {134134 m.closeOverlay()135135 }136- m.note = "Deleted the chart " + m.keyHints(shortcut("edit.undo"), "undo")136+ m.note = "Deleted the chart " + m.th.KeyHints(shortcut("edit.undo"), "undo")137137 }138138 139139 // displayCharts are the charts as drawn: with the one being dragged at@@ -219,9 +219,9 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
219219 o.Image = false220220 }221221 g := chart.Draw(c.Type, m.sheet.ChartData(c), w, h, o)222- border := m.th.chartFrame222+ border := m.th.ChartFrame223223 if selected {224- border = m.th.chartSelected224+ border = m.th.ChartSelected225225 }226226 title := c.Title227227 if title == "" {@@ -230,7 +230,7 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
230230 title = ansi.Truncate(" "+title+" ", c.W-4, "…")231231 footer := " " + c.Data.String() + " "232232 lines := make([]string, 0, c.H)233- lines = append(lines, border.Render("┌─")+m.th.title.Render(title)+233+ lines = append(lines, border.Render("┌─")+m.th.Title.Render(title)+234234 border.Render(strings.Repeat("─", max(c.W-3-ansi.StringWidth(title), 0))+"┐"))235235 for y := range h {236236 lines = append(lines, border.Render("│")+" "+m.chartRow(g, y, firstImageID+i, o.Image)+" "+border.Render("│"))@@ -243,7 +243,7 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
243243 if fill < 1 {244244 lines = append(lines, border.Render("└"+strings.Repeat("─", c.W-2)+corner))245245 } else {246- lines = append(lines, border.Render("└"+strings.Repeat("─", fill))+m.th.muted.Render(footer)+border.Render("─"+corner))246+ lines = append(lines, border.Render("└"+strings.Repeat("─", fill))+m.th.Muted.Render(footer)+border.Render("─"+corner))247247 }248248 return lines249249 }@@ -272,7 +272,7 @@ func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {
272272 text := c.Text273273 switch {274274 case image && y >= g.Plot.Min.Y && y < g.Plot.Max.Y && x >= g.Plot.Min.X && x < g.Plot.Max.X:275- key, st = 1, m.th.imageID(imageID)275+ key, st = 1, m.th.ImageID(imageID)276276 text = chart.Placeholder(y-g.Plot.Min.Y, x-g.Plot.Min.X)277277 case c.Fg == chart.None && c.Bg == chart.None:278278 key, isStyled = 2, false@@ -280,7 +280,7 @@ func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {
280280 key = 3 + int(c.Fg)*32 + int(c.Bg)281281 st = m.chartRole(c.Fg)282282 if c.Bg >= chart.Series {283- st = st.Inherit(m.th.seriesBg[int(c.Bg-chart.Series)%chart.Colors])283+ st = st.Inherit(m.th.SeriesBg[int(c.Bg-chart.Series)%chart.Colors])284284 }285285 }286286 if key != prev {@@ -296,15 +296,15 @@ func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {
296296 func (m *Model) chartRole(r chart.Role) lipgloss.Style {297297 switch {298298 case r == chart.Axis:299- return m.th.chartAxis299+ return m.th.ChartAxis300300 case r == chart.Label:301- return m.th.chartLabel301+ return m.th.ChartLabel302302 case r == chart.Muted:303- return m.th.muted303+ return m.th.Muted304304 case r >= chart.Series:305- return m.th.series[int(r-chart.Series)%chart.Colors]305+ return m.th.Series[int(r-chart.Series)%chart.Colors]306306 }307- return m.th.cell307+ return m.th.Cell308308 }309309 310310 // chartClick handles a press on the grid in READY: pressing a chart@@ -522,10 +522,10 @@ func (s *chartSel) status(m *Model) (string, string) {
522522 if !ok {523523 return "", ""524524 }525- desc := m.th.muted.Render("Chart of " + c.Data.String())525+ desc := m.th.Muted.Render("Chart of " + c.Data.String())526526 pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}527527 for {528- keys := m.keyHints(pairs...)528+ keys := m.th.KeyHints(pairs...)529529 switch {530530 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:531531 return desc, keys@@ -545,8 +545,8 @@ func (s *chartSel) contextLine(m *Model) (string, string) {
545545 if !ok {546546 return "", ""547547 }548- return m.th.key.Render(c.Type.Title()+" chart") + m.th.muted.Render(" of ") + c.Data.String() +549- m.th.muted.Render(" drag to move, drag the corner to resize"), ""548+ return m.th.Key.Render(c.Type.Title()+" chart") + m.th.Muted.Render(" of ") + c.Data.String() +549+ m.th.Muted.Render(" drag to move, drag the corner to resize"), ""550550 }551551 552552 // chartEditor is the chart editor: a bar on the context line, in the@@ -677,9 +677,9 @@ type editorPart struct {
677677 func (e *chartEditor) parts(m *Model) []editorPart {678678 c := e.chart(m)679679 chip := func(on bool, name, key string) editorPart {680- style := m.th.muted680+ style := m.th.Muted681681 if on {682- style = m.th.menuSelected682+ style = m.th.MenuSelected683683 }684684 return editorPart{text: style.Render(" " + name + " "), key: key}685685 }@@ -696,7 +696,7 @@ func (e *chartEditor) parts(m *Model) []editorPart {
696696 header, labels = "Header column", "Labels in row"697697 }698698 return append(parts,699- editorPart{text: m.th.key.Render(c.Data.String()), key: editorRange},699+ editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange},700700 chip(true, series, editorSwitch),701701 chip(c.Header, header, editorHeader),702702 chip(c.Labels, labels, editorLabels))@@ -757,7 +757,7 @@ func (e *chartEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {
757757 func (e *chartEditor) status(m *Model) (string, string) {758758 pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}759759 for {760- keys := m.keyHints(pairs...)760+ keys := m.th.KeyHints(pairs...)761761 if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {762762 return "", keys763763 }@@ -225,14 +225,14 @@ func (m *Model) readyLine() string {
225225 case m.note != "":226226 return m.note227227 case m.copied.marked && m.copied.cut:228- return "Cut " + m.clipLabel(m.copied) + " " + m.keyHints("Ctrl+V", "move here", "Esc", "cancel")228+ return "Cut " + m.clipLabel(m.copied) + " " + m.th.KeyHints("Ctrl+V", "move here", "Esc", "cancel")229229 case m.copied.marked:230230 text := "Copied " + m.clipLabel(m.copied) + " "231- full := text + m.keyHints("Ctrl+V", "paste", "Ctrl+Shift+V", "paste values", "Esc", "clear")231+ full := text + m.th.KeyHints("Ctrl+V", "paste", "Ctrl+Shift+V", "paste values", "Esc", "clear")232232 if ansi.StringWidth(full) <= m.width {233233 return full234234 }235- return text + m.keyHints("Ctrl+V", "paste", "Esc", "clear")235+ return text + m.th.KeyHints("Ctrl+V", "paste", "Esc", "clear")236236 }237237 return ""238238 }@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/sheet"1212 "github.com/FelineStateMachine/012/internal/telemetry"13+ "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415 1516 // command is a user-facing action. Every action is registered once here@@ -100,7 +101,7 @@ func keyRank(k string) int {
100101 // e.g. "Ctrl+S", or "" if it has none.101102 func shortcut(id string) string {102103 if keys := keysFor(id); len(keys) > 0 {103- return keyLabel(keys[0])104+ return theme.KeyLabel(keys[0])104105 }105106 return ""106107 }@@ -4,6 +4,7 @@ import (
44 "strings"55 66 tea "charm.land/bubbletea/v2"7+ "github.com/FelineStateMachine/012/internal/ui/theme"78 "github.com/charmbracelet/x/ansi"89 )910 @@ -66,13 +67,13 @@ const choiceGap = " "
6667 6768 func (c *choiceBar) prefix(m *Model) string {6869 if c.warn {69- return m.th.warning.Render(c.msg) + choiceGap70+ return m.th.Warning.Render(c.msg) + choiceGap7071 }7172 return c.msg + choiceGap7273 }7374 7475 func (c *choiceBar) item(m *Model, ch choice) string {75- return m.chip(keyLabel(ch.key)) + " " + ch.label76+ return m.th.Chip(theme.KeyLabel(ch.key)) + " " + ch.label7677 }7778 7879 // line renders the bar for the context line.@@ -95,7 +95,7 @@ func (m *Model) cancelFill() {
9595 // fillLine is the context line while dragging the fill handle.9696 func (m *Model) fillLine() string {9797 if m.fillTo == m.selection() {98- return m.th.key.Render("Fill") + m.th.muted.Render(" drag down, up, right or left to fill a series or copy ") + m.keyHints("Esc", "cancel")98+ return m.th.Key.Render("Fill") + m.th.Muted.Render(" drag down, up, right or left to fill a series or copy ") + m.th.KeyHints("Esc", "cancel")9999 }100- return m.th.key.Render("Fill "+m.fillTo.String()) + m.th.muted.Render(" release to fill ") + m.keyHints("Esc", "cancel")100+ return m.th.Key.Render("Fill "+m.fillTo.String()) + m.th.Muted.Render(" release to fill ") + m.th.KeyHints("Esc", "cancel")101101 }@@ -11,6 +11,7 @@ import (
1111 1212 "github.com/FelineStateMachine/012/internal/sheet"1313 "github.com/FelineStateMachine/012/internal/telemetry"14+ "github.com/FelineStateMachine/012/internal/ui/theme"1415 )1516 1617 // Filters follow Sheets' Data > Create a filter: the headers of the@@ -50,7 +51,7 @@ func (m *Model) createFilter() tea.Cmd {
5051 r := m.dataRange()5152 m.sheet.CreateFilter(r)5253 m.changed = true53- m.note = "Created a filter on " + r.String() + " " + m.keyHints(shortcut("data.filter_column"), "filter the active column")54+ m.note = "Created a filter on " + r.String() + " " + m.th.KeyHints(shortcut("data.filter_column"), "filter the active column")5455 return nil5556 }5657 @@ -310,15 +311,15 @@ func (p *filterPicker) mouse(m *Model, e mouseEvent) tea.Cmd {
310311 311312 func (p *filterPicker) status(m *Model) (string, string) {312313 if p.field == 1 {313- return "Rows must also meet the condition", m.keyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")314+ return "Rows must also meet the condition", m.th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")314315 }315316 pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}316317 desc := "Type to search the values"317318 for {318- keys := m.keyHints(pairs...)319+ keys := m.th.KeyHints(pairs...)319320 switch {320321 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:321- return m.th.muted.Render(desc), keys322+ return m.th.Muted.Render(desc), keys322323 case desc != "":323324 desc = ""324325 case len(pairs) > 4:@@ -366,18 +367,18 @@ func (p *filterPicker) layout(m *Model) []box {
366367 rows := p.rows(m)367368 p.show(rows)368369 369- chipStyle := m.th.keyChip370+ chipStyle := m.th.KeyChip370371 if p.field == 1 {371- chipStyle = m.th.menuSelected372+ chipStyle = m.th.MenuSelected372373 }373- cond := m.th.muted.Render(" If ") + chipStyle.Render(p.condChip())374+ cond := m.th.Muted.Render(" If ") + chipStyle.Render(p.condChip())374375 if p.cond.Op.TakesArg() {375376 arg := p.fields[1]376377 if p.field == 1 {377378 arg = string(m.buf)378379 }379380 if arg == "" && p.field != 1 {380- arg = m.th.muted.Render("value")381+ arg = m.th.Muted.Render("value")381382 }382383 cond += " " + arg383384 }@@ -385,16 +386,16 @@ func (p *filterPicker) layout(m *Model) []box {
385386 if p.field == 0 {386387 search = string(m.buf)387388 }388- input := m.th.title.Render(searchPrompt) + search389+ input := m.th.Title.Render(searchPrompt) + search389390 if search == "" {390- input += m.th.muted.Render("Search values")391+ input += m.th.Muted.Render("Search values")391392 }392- lines := []string{cells(m.th.menuBar, cond, inner), sepRow, cells(m.th.menuBar, input, inner), sepRow}393+ lines := []string{theme.Cells(m.th.MenuBar, cond, inner), theme.SepRow, theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}393394 394395 for r := range rows {395396 i := p.top + r396397 if i > len(p.shown) {397- lines = append(lines, cells(m.th.menuBar, "", inner))398+ lines = append(lines, theme.Cells(m.th.MenuBar, "", inner))398399 continue399400 }400401 label, count, checked := "Select all", "", p.allChecked()@@ -407,16 +408,16 @@ func (p *filterPicker) layout(m *Model) []box {
407408 if checked {408409 box = "[x] "409410 }410- base, muted := m.th.menuBar, m.th.muted411+ base, muted := m.th.MenuBar, m.th.Muted411412 if i == p.sel {412- base, muted = m.th.menuSelected, m.th.menuSelected413+ base, muted = m.th.MenuSelected, m.th.MenuSelected413414 }414415 labelStyle := base415416 if dim {416417 labelStyle = muted417418 }418- text := base.Render(" "+box) + cells(labelStyle, label, inner-6-len(count)-1)419- text += muted.Render(padLeft(count, len(count)+1)) + base.Render(" ")419+ text := base.Render(" "+box) + theme.Cells(labelStyle, label, inner-6-len(count)-1)420+ text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")420421 lines = append(lines, ansi.Truncate(text, inner, ""))421422 }422423 title := "Filter " + sheet.ColName(p.col)@@ -426,5 +427,5 @@ func (p *filterPicker) layout(m *Model) []box {
426427 }427428 }428429 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))429- return []box{{id: filterID, x: x, y: y, lines: m.frame(inner, title, footer, lines)}}430+ return []box{{id: filterID, x: x, y: y, lines: m.th.Frame(inner, title, footer, lines)}}430431 }@@ -336,20 +336,20 @@ type findPart struct {
336336 337337 func (f *findBar) parts(m *Model) []findPart {338338 label := func(name string, i int) string {339- style := m.th.muted339+ style := m.th.Muted340340 if f.field == i {341- style = m.th.key341+ style = m.th.Key342342 }343- return style.Render(name) + m.th.muted.Render(searchPrompt)343+ return style.Render(name) + m.th.Muted.Render(searchPrompt)344344 }345345 parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}346346 if f.replace {347347 parts = append(parts, findPart{text: label("Replace", 1) + f.fieldText(m, 1), field: 1})348348 }349349 chip := func(on bool, name, key string) findPart {350- style := m.th.muted350+ style := m.th.Muted351351 if on {352- style = m.th.menuSelected352+ style = m.th.MenuSelected353353 }354354 return findPart{text: style.Render(" " + name + " "), field: -1, toggle: key}355355 }@@ -411,16 +411,16 @@ func (f *findBar) line(m *Model) (left, right string) {
411411 }412412 switch {413413 case f.err != "":414- right = m.th.warning.Render(f.err)414+ right = m.th.Warning.Render(f.err)415415 case f.fields[0] == "":416416 case len(f.matches) == 0:417- right = m.th.warning.Render("No matches")417+ right = m.th.Warning.Render("No matches")418418 default:419419 where := ""420420 if f.where == inAll {421421 where = " on " + f.matches[f.cur].s.Name()422422 }423- right = m.th.muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)423+ right = m.th.Muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)424424 }425425 return b.String(), right426426 }@@ -486,10 +486,10 @@ func (f *findBar) status(m *Model) (string, string) {
486486 desc = "Alt+C/W/R/=/S options"487487 }488488 for {489- keys := m.keyHints(pairs...)489+ keys := m.th.KeyHints(pairs...)490490 switch {491491 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:492- return m.th.muted.Render(desc), keys492+ return m.th.Muted.Render(desc), keys493493 case desc != "":494494 desc = ""495495 case len(pairs) > 2:@@ -129,7 +129,7 @@ func (t *terminal) color(i int) color.RGBA {
129129 // chartPalette is the theme's series colors as the terminal draws them.130130 func (m *Model) chartPalette() chart.Palette {131131 var p chart.Palette132- for i, idx := range m.th.seriesANSI {132+ for i, idx := range m.th.SeriesANSI {133133 p.Series[i] = m.term.color(idx)134134 }135135 p.Grid = m.term.color(8) // bright black, like the text axes@@ -11,6 +11,7 @@ import (
1111 "github.com/charmbracelet/x/ansi"1212 1313 "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/theme"1415 )1516 1617 func init() {@@ -90,7 +91,7 @@ func helpRows() []helpRow {
9091 }9192 listed[id] = true9293 for i, k := range keys {93- keys[i] = keyLabel(k)94+ keys[i] = theme.KeyLabel(k)9495 }9596 cmds = append(cmds, helpRow{keys: keys, action: commands[id].title})9697 }@@ -171,7 +172,7 @@ func (s *shortcuts) lines(m *Model) ([]string, int) {
171172 rows := helpRows()172173 keyW, actW := 0, 0173174 for _, r := range rows {174- keyW = max(keyW, ansi.StringWidth(m.chips(r.keys)))175+ keyW = max(keyW, ansi.StringWidth(m.th.Chips(r.keys)))175176 actW = max(actW, ansi.StringWidth(r.action))176177 }177178 // On narrow screens actions give way (truncated) so the keys fit.@@ -185,11 +186,11 @@ func (s *shortcuts) lines(m *Model) ([]string, int) {
185186 for i, r := range rows {186187 switch {187188 case r.heading != "" && i > 0:188- out = append(out, "", m.th.title.Render(" "+r.heading))189+ out = append(out, "", m.th.Title.Render(" "+r.heading))189190 case r.heading != "":190- out = append(out, m.th.title.Render(" "+r.heading))191+ out = append(out, m.th.Title.Render(" "+r.heading))191192 default:192- out = append(out, " "+padRight(ansi.Truncate(r.action, actW, "…"), actW)+" "+m.chips(r.keys))193+ out = append(out, " "+theme.PadRight(ansi.Truncate(r.action, actW, "…"), actW)+" "+m.th.Chips(r.keys))193194 }194195 }195196 return out@@ -214,22 +215,13 @@ func (s *shortcuts) lines(m *Model) ([]string, int) {
214215 if i < len(right) {215216 r = right[i]216217 }217- out[i] = padRight(l, colW) + " " + r218+ out[i] = theme.PadRight(l, colW) + " " + r218219 }219220 return out, 2*colW + 3220221 }221222 222223 func abs(x int) int { return max(x, -x) }223224 224-// chips renders keys as key chips, e.g. [Ctrl+/] [F1].225-func (m *Model) chips(keys []string) string {226- parts := make([]string, len(keys))227- for i, k := range keys {228- parts[i] = m.chip(k)229- }230- return strings.Join(parts, " ")231-}232-233225 // visible is how many lines fit between the menu bar and the status line.234226 func (s *shortcuts) visible(m *Model, total int) int {235227 return max(min(total, m.height-2-2-1), 1)@@ -244,7 +236,7 @@ func (s *shortcuts) layout(m *Model) []box {
244236 rows := make([]string, n)245237 for i := range rows {246238 if j := s.top + i; j < len(lines) {247- rows[i] = cells(m.th.menuBar, lines[j], inner)239+ rows[i] = theme.Cells(m.th.MenuBar, lines[j], inner)248240 } else {249241 rows[i] = strings.Repeat(" ", inner)250242 }@@ -253,7 +245,7 @@ func (s *shortcuts) layout(m *Model) []box {
253245 if n < len(lines) {254246 footer = strconv.Itoa(s.top+1) + "-" + strconv.Itoa(s.top+n) + " of " + strconv.Itoa(len(lines))255247 }256- b := m.frame(inner, "Keyboard shortcuts", footer, rows)248+ b := m.th.Frame(inner, "Keyboard shortcuts", footer, rows)257249 w, h := ansi.StringWidth(b[0]), len(b)258250 return []box{{id: shortcutsID, x: (m.width - w) / 2, y: max(menuLine+1, (m.height-1-h)/2), lines: b}}259251 }@@ -294,5 +286,5 @@ func (s *shortcuts) mouse(m *Model, e mouseEvent) tea.Cmd {
294286 }295287 296288 func (s *shortcuts) status(m *Model) (string, string) {297- return "", m.keyHints("Up/Down", "scroll", "Esc", "close")289+ return "", m.th.KeyHints("Up/Down", "scroll", "Esc", "close")298290 }@@ -5,6 +5,7 @@ import (
55 "testing"66 77 tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/012/internal/ui/theme"89 )910 1011 func TestShortcutsListEveryBoundCommand(t *testing.T) {@@ -16,12 +17,12 @@ func TestShortcutsListEveryBoundCommand(t *testing.T) {
1617 }1718 found := false1819 for _, r := range rows {19- if r.action == c.title && strings.Contains(strings.Join(r.keys, " "), keyLabel(key)) {20+ if r.action == c.title && strings.Contains(strings.Join(r.keys, " "), theme.KeyLabel(key)) {2021 found = true2122 }2223 }2324 if !found {24- t.Errorf("%s (%s) missing from the shortcuts", c.title, keyLabel(key))25+ t.Errorf("%s (%s) missing from the shortcuts", c.title, theme.KeyLabel(key))2526 }2627 }2728 }@@ -128,7 +128,7 @@ func (m *Model) jevLine() string {
128128 return ""129129 }130130 if m.jev == nil {131- return m.th.warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")131+ return m.th.Warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")132132 }133133 var parts []string134134 for _, c := range calls {@@ -143,5 +143,5 @@ func (m *Model) jevLine() string {
143143 for _, p := range parts[1:] {144144 line += " " + p145145 }146- return m.th.muted.Render(line)146+ return m.th.Muted.Render(line)147147 }@@ -32,12 +32,12 @@ func (m *Model) decorate(sp *span, row int) {
3232 func (m *Model) textStyle(base lipgloss.Style, sp span) lipgloss.Style {3333 st := sp.style3434 st.Align = sheet.AlignAuto35- s := m.th.text(base, st)35+ s := m.th.Text(base, st)3636 switch {3737 case sp.link != "":38- s = s.Inherit(m.th.link).Hyperlink(sp.link)38+ s = s.Inherit(m.th.Link).Hyperlink(sp.link)3939 case sp.error:40- s = s.Inherit(m.th.errorMark)40+ s = s.Inherit(m.th.ErrorMark)4141 }4242 return s4343 }@@ -50,5 +50,5 @@ func (m *Model) errorLine() string {
5050 if v.Kind != sheet.Error || why == "" {5151 return ""5252 }53- return m.th.errorCell.Render(v.Str) + " " + m.th.muted.Render(why)53+ return m.th.ErrorCell.Render(v.Str) + " " + m.th.Muted.Render(why)5454 }@@ -7,6 +7,7 @@ import (
77 "testing"88 99 tea "charm.land/bubbletea/v2"10+ "github.com/FelineStateMachine/012/internal/ui/theme"1011 uv "github.com/charmbracelet/ultraviolet"1112 "github.com/charmbracelet/x/ansi"1213 )@@ -93,7 +94,7 @@ func TestLiveLightDark(t *testing.T) {
9394 }9495 }9596 send(m, tea.BackgroundColorMsg{Color: color.White})96- if m.th.link.GetForeground() != newTheme(false).link.GetForeground() {97+ if m.th.Link.GetForeground() != theme.New(false).Link.GetForeground() {9798 t.Error("theme didn't follow a light background")9899 }99100 }@@ -5,6 +5,7 @@ import (
55 "strings"66 77 tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/012/internal/ui/theme"89 "github.com/charmbracelet/x/ansi"910 )1011 @@ -170,9 +171,9 @@ func barMenus() []barMenu {
170171 func (m *Model) menuBarTitles() string {171172 var b strings.Builder172173 for i, bm := range barMenus() {173- style, accel := m.th.menuBar, m.th.menuAccel174+ style, accel := m.th.MenuBar, m.th.MenuAccel174175 if i == m.openBarMenu() {175- style, accel = m.th.menuSelected, m.th.menuAccelSelected176+ style, accel = m.th.MenuSelected, m.th.MenuAccelSelected176177 }177178 t := bm.def.title178179 k := strings.IndexByte(strings.ToLower(t), bm.def.accel)@@ -452,9 +453,9 @@ func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {
452453 }453454 454455 func (o *menuOverlay) status(m *Model) (string, string) {455- keys := m.keyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")456+ keys := m.th.KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")456457 if o.bar >= 0 {457- keys = m.keyHints("Arrows", "move", "Enter", "choose", "Esc", "close")458+ keys = m.th.KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")458459 }459460 l := o.top()460461 if l.sel < 0 {@@ -530,22 +531,22 @@ func (m *Model) dropdown(l *menuLevel, rows int) []string {
530531 for i := l.top; i < l.top+rows; i++ {531532 it := l.items[i]532533 if it.sep {533- out = append(out, sepRow)534+ out = append(out, theme.SepRow)534535 continue535536 }536- style := m.th.menuBar537+ style := m.th.MenuBar537538 switch {538539 case i == l.sel:539- style = m.th.menuSelected540+ style = m.th.MenuSelected540541 case !l.selectable(m, i):541- style = m.th.disabled542+ style = m.th.Disabled542543 }543544 k := m.itemKey(it, !l.selectable(m, i))544545 if it.items != nil || i == l.sel {545546 // The highlight runs unbroken across the row.546547 k = style.Render(ansi.Strip(k))547548 }548- out = append(out, cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))549+ out = append(out, theme.Cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))549550 }550551 var up, down string551552 if l.top > 0 {@@ -554,7 +555,7 @@ func (m *Model) dropdown(l *menuLevel, rows int) []string {
554555 if l.top+rows < len(l.items) {555556 down = "▼"556557 }557- return m.frame(inner, up, down, out)558+ return m.th.Frame(inner, up, down, out)558559 }559560 560561 // isChecked reports whether the command is a setting that is on.@@ -576,7 +577,7 @@ func (m *Model) itemKey(it menuItem, disabled bool) string {
576577 case k == "":577578 return ""578579 case disabled:579- return m.th.disabled.Render(" " + k + " ")580+ return m.th.Disabled.Render(" " + k + " ")580581 }581- return m.chip(k)582+ return m.th.Chip(k)582583 }@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/sheet"1212 "github.com/FelineStateMachine/012/internal/telemetry"13+ "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415 1516 // mode is the state shown in the mode indicator.@@ -115,12 +116,12 @@ type Model struct {
115116 116117 keyAt time.Time // when the key the next frame answers was pressed, for telemetry117118 118- th theme119+ th theme.Theme119120 }120121 121122 // New returns a model editing s. filename may be empty.122123 func New(s *sheet.Sheet, filename string) *Model {123- return &Model{sheet: s, filename: filename, width: 80, height: 24, th: newTheme(true), term: newTerminal(), lastChart: -1}124+ return &Model{sheet: s, filename: filename, width: 80, height: 24, th: theme.New(true), term: newTerminal(), lastChart: -1}124125 }125126 126127 // Init implements tea.Model. It asks the terminal for its background color@@ -152,7 +153,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152153 m.width, m.height = msg.Width, msg.Height153154 beforeMode = -1 // keep the focus visible after a resize154155 case tea.BackgroundColorMsg:155- m.th = newTheme(msg.IsDark())156+ m.th = theme.New(msg.IsDark())156157 case tea.KeyPressMsg:157158 cmd = m.handleKey(msg)158159 case tea.PasteMsg:@@ -111,9 +111,9 @@ func (p *namesPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
111111 }112112 113113 func (p *namesPicker) status(m *Model) (string, string) {114- keys := m.keyHints("Enter", "go to", "F2", "edit", "Ctrl+D", "delete", "Esc", "close")114+ keys := m.th.KeyHints("Enter", "go to", "F2", "edit", "Ctrl+D", "delete", "Esc", "close")115115 if _, ok := p.current(m); !ok {116- keys = m.keyHints("Enter", "add", "Esc", "close")116+ keys = m.th.KeyHints("Enter", "add", "Esc", "close")117117 }118118 if p.msg != "" {119119 return p.msg, keys@@ -175,47 +175,6 @@ func (m *Model) overlayMouse(msg tea.MouseMsg) tea.Cmd {
175175 return m.overlay.mouse(m, e)176176 }177177 178-// A box is framed with light box-drawing lines, which keep the crisp179-// character-grid look. Rows are exactly the inner width; separator rows180-// become a line joined to the frame.181-182-// sepRow marks a separator in the rows passed to frame.183-const sepRow = "\x00"184-185-// frame draws a border around rows. A title sits in the top border and a186-// footer at the right of the bottom border.187-func (m *Model) frame(inner int, title, footer string, rows []string) []string {188- inner = max(inner, 2) // screens smaller than the box get a clipped box189- b := m.th.border190- top := "┌" + strings.Repeat("─", inner) + "┐"191- if title != "" {192- t := ansi.Truncate(" "+title+" ", inner-1, "…")193- top = b.Render("┌─") + m.th.title.Render(t) + b.Render(strings.Repeat("─", inner-1-ansi.StringWidth(t))+"┐")194- } else {195- top = b.Render(top)196- }197- bottom := b.Render("└" + strings.Repeat("─", inner) + "┘")198- if footer != "" && ansi.StringWidth(footer)+4 <= inner {199- f := " " + footer + " "200- bottom = b.Render("└"+strings.Repeat("─", inner-1-ansi.StringWidth(f))) + m.th.muted.Render(f) + b.Render("─┘")201- }202- lines := make([]string, 0, len(rows)+2)203- lines = append(lines, top)204- for _, r := range rows {205- if r == sepRow {206- lines = append(lines, b.Render("├"+strings.Repeat("─", inner)+"┤"))207- continue208- }209- lines = append(lines, b.Render("│")+r+b.Render("│"))210- }211- return append(lines, bottom)212-}213-214-// cells pads or truncates s to exactly w columns, then styles it.215-func cells(style lipgloss.Style, s string, w int) string {216- return style.Render(padRight(ansi.Truncate(s, w, "…"), w))217-}218-219178 // clampBox keeps a w by h box at x, y on screen, shifting it left and up220179 // as needed.221180 func (m *Model) clampBox(x, y, w, h int) (int, int) {@@ -8,7 +8,7 @@ import (
88 "strings"99 1010 tea "charm.land/bubbletea/v2"11- "charm.land/lipgloss/v2"11+ "github.com/FelineStateMachine/012/internal/ui/theme"1212 "github.com/charmbracelet/x/ansi"1313 "github.com/sahilm/fuzzy"1414 )@@ -243,7 +243,7 @@ func (p *picker) mouse(m *Model, e mouseEvent) tea.Cmd {
243243 }244244 245245 func (p *picker) status(m *Model) (string, string) {246- keys := m.keyHints("Up/Down", "move", "Enter", cmp.Or(p.action, "run"), "Esc", "close")246+ keys := m.th.KeyHints("Up/Down", "move", "Enter", cmp.Or(p.action, "run"), "Esc", "close")247247 if p.sel >= len(p.shown) {248248 return "", keys249249 }@@ -284,14 +284,14 @@ func (p *picker) layout(m *Model) []box {
284284 if len(p.shown) > 0 {285285 p.show(rows)286286 }287- input := m.th.title.Render(searchPrompt) + string(m.buf)287+ input := m.th.Title.Render(searchPrompt) + string(m.buf)288288 if len(m.buf) == 0 {289- input += m.th.muted.Render(p.placeholder)289+ input += m.th.Muted.Render(p.placeholder)290290 }291- lines := []string{cells(m.th.menuBar, input, inner), sepRow}291+ lines := []string{theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}292292 lines = append(lines, p.resultRows(m, inner, rows)...)293293 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.items))294- return []box{{id: pickerID, x: x, y: y, lines: m.frame(inner, p.title, footer, lines)}}294+ return []box{{id: pickerID, x: x, y: y, lines: m.th.Frame(inner, p.title, footer, lines)}}295295 }296296 297297 // resultRows lays out the visible results in columns: title, detail and@@ -313,22 +313,22 @@ func (p *picker) resultRows(m *Model, inner, rows int) []string {
313313 if i >= len(p.shown) {314314 text := ""315315 if r == 0 {316- text = m.th.muted.Render(" No matches")316+ text = m.th.Muted.Render(" No matches")317317 }318- out = append(out, cells(m.th.menuBar, text, inner))318+ out = append(out, theme.Cells(m.th.MenuBar, text, inner))319319 continue320320 }321321 pm := p.shown[i]322- base, dim, hl := m.th.menuBar, m.th.muted, m.th.match322+ base, dim, hl := m.th.MenuBar, m.th.Muted, m.th.Match323323 switch {324324 case i == p.sel:325- base, dim, hl = m.th.menuSelected, m.th.menuSelected, m.th.matchSelected325+ base, dim, hl = m.th.MenuSelected, m.th.MenuSelected, m.th.MatchSelected326326 case pm.item.off:327- base, dim, hl = m.th.disabled, m.th.disabled, m.th.disabled327+ base, dim, hl = m.th.Disabled, m.th.Disabled, m.th.Disabled328328 }329- row := base.Render(" ") + m.highlightMatches(pm.item.title, pm.inTitle, tw, base, hl)329+ row := base.Render(" ") + theme.HighlightMatches(pm.item.title, pm.inTitle, tw, base, hl)330330 if dw >= 8 {331- row += base.Render(" ") + m.highlightMatches(pm.item.detail, pm.inDesc, dw, dim, hl)331+ row += base.Render(" ") + theme.HighlightMatches(pm.item.detail, pm.inDesc, dw, dim, hl)332332 }333333 k := ""334334 switch {@@ -336,37 +336,12 @@ func (p *picker) resultRows(m *Model, inner, rows int) []string {
336336 case i == p.sel:337337 k = base.Render(" " + pm.item.key + " ")338338 case pm.item.off:339- k = m.th.disabled.Render(" " + pm.item.key + " ")339+ k = m.th.Disabled.Render(" " + pm.item.key + " ")340340 default:341- k = m.chip(pm.item.key)341+ k = m.th.Chip(pm.item.key)342342 }343343 row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")344344 out = append(out, ansi.Truncate(row, inner, ""))345345 }346346 return out347347 }348-349-// highlightMatches renders s in w columns with the bytes at idx in the hl style,350-// truncating with an ellipsis.351-func (m *Model) highlightMatches(s string, idx []int, w int, base, hl lipgloss.Style) string {352- s = ansi.Truncate(s, w, "…")353- var b strings.Builder354- run, lit := "", false355- flush := func() {356- if lit {357- b.WriteString(hl.Render(run))358- } else {359- b.WriteString(base.Render(run))360- }361- run = ""362- }363- for i, r := range s {364- if on := slices.Contains(idx, i); on != lit {365- flush()366- lit = on367- }368- run += string(r)369- }370- flush()371- return b.String() + base.Render(strings.Repeat(" ", max(w-ansi.StringWidth(s), 0)))372-}@@ -13,6 +13,7 @@ import (
1313 1414 "github.com/FelineStateMachine/012/internal/sheet"1515 "github.com/FelineStateMachine/012/internal/telemetry"16+ "github.com/FelineStateMachine/012/internal/ui/theme"1617 )1718 1819 // The control panel is three lines: the menu bar with the mode indicator,@@ -100,7 +101,7 @@ func (m *Model) indicator() string {
100101 // menuBarLine is the menu bar with the mode indicator on the right.101102 func (m *Model) menuBarLine() string {102103 left := m.menuBarTitles()103- ind := m.th.indicator.Render(" " + m.indicator() + " ")104+ ind := m.th.Indicator.Render(" " + m.indicator() + " ")104105 gap := m.width - ansi.StringWidth(left) - ansi.StringWidth(ind)105106 return left + strings.Repeat(" ", max(gap, 1)) + ind106107 }@@ -123,12 +124,12 @@ func (m *Model) formulaBar() string {
123124 if n, ok := m.namedSelection(); ok && (m.mode == modeReady || m.mode == modeMenu) {124125 name = ansi.Truncate(n, nameBoxW-1, "…")125126 }126- box := m.th.header.Render(padRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "127+ box := m.th.Header.Render(theme.PadRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "127128 switch m.mode {128129 case modeEnter, modeEdit:129130 return box + string(m.buf)130131 case modePoint:131- return box + m.pointPrefix + m.th.selection.Render(m.pointRef()) + m.pointSuffix132+ return box + m.pointPrefix + m.th.Selection.Render(m.pointRef()) + m.pointSuffix132133 }133134 if c := m.sheet.Cell(m.cur); c != nil {134135 return box + c.Input@@ -144,12 +145,12 @@ func (m *Model) contextLineText() string {
144145 case m.xfer.job != nil:145146 left = m.importLine()146147 case m.drag == dragResize:147- left = m.th.key.Render("Column "+sheet.ColName(m.resizeCol)) + m.th.muted.Render(" width ") +148- strconv.Itoa(m.sheet.ColWidth(m.resizeCol)) + m.th.muted.Render(" double-click the border to fit")148+ left = m.th.Key.Render("Column "+sheet.ColName(m.resizeCol)) + m.th.Muted.Render(" width ") +149+ strconv.Itoa(m.sheet.ColWidth(m.resizeCol)) + m.th.Muted.Render(" double-click the border to fit")149150 case m.drag == dragFill:150151 left = m.fillLine()151152 case m.hint != "":152- left = m.th.warning.Render(m.hint)153+ left = m.th.Warning.Render(m.hint)153154 case m.mode == modeReady && m.trace != nil:154155 left, right = m.traceLine()155156 case m.mode == modeReady:@@ -175,17 +176,17 @@ func (m *Model) contextLineText() string {
175176 case m.mode == modePoint:176177 prefix := []rune(m.pointPrefix)177178 var ok bool178- if left, right, ok = m.signatureLine(prefix, len(prefix), m.keyHints("Shift+arrows", "range", "Esc", "back")); !ok {179- left = m.keyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")179+ if left, right, ok = m.signatureLine(prefix, len(prefix), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {180+ left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")180181 }181182 case (m.mode == modeEnter || m.mode == modeEdit) && m.isFormula() && m.inFunction():182- left, right, _ = m.signatureLine(m.buf, m.bufPos, m.keyHints("Enter", "accept", "Esc", "cancel"))183+ left, right, _ = m.signatureLine(m.buf, m.bufPos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))183184 case m.mode == modeEnter && m.isFormula():184- left = m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")185+ left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")185186 case m.mode == modeEnter:186- left = m.keyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")187+ left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")187188 case m.mode == modeEdit:188- left = m.keyHints("Enter", "accept", "Left/Right", "move the caret", "Esc", "cancel")189+ left = m.th.KeyHints("Enter", "accept", "Left/Right", "move the caret", "Esc", "cancel")189190 }190191 return m.spread(left, right)191192 }@@ -205,35 +206,20 @@ func (m *Model) spread(left, right string) string {
205206 func (m *Model) promptLine() (left, right string) {206207 switch {207208 case m.pointing():208- return m.promptPrefix() + m.th.selection.Render(m.point.text()),209- m.keyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")209+ return m.promptPrefix() + m.th.Selection.Render(m.point.text()),210+ m.th.KeyHints("Arrows", "move", "Shift+arrows", "extend", "Enter", "apply", "Esc", "cancel")210211 case len(m.files) > 0:211- return m.promptPrefix() + string(m.buf), m.th.muted.Render(strings.Join(m.files, " "))212+ return m.promptPrefix() + string(m.buf), m.th.Muted.Render(strings.Join(m.files, " "))212213 case m.prompt.kind == promptWidth:213- return m.promptPrefix() + string(m.buf), m.keyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")214+ return m.promptPrefix() + string(m.buf), m.th.KeyHints("Left/Right", "adjust", "Enter", "apply", "Esc", "cancel")214215 }215- return m.promptPrefix() + string(m.buf), m.keyHints("Enter", "apply", "Esc", "cancel")216+ return m.promptPrefix() + string(m.buf), m.th.KeyHints("Enter", "apply", "Esc", "cancel")216217 }217218 218219 func (m *Model) promptPrefix() string {219220 return m.prompt.label + " "220221 }221222 222-// keyHints renders key and description pairs, e.g. "Enter accept", each223-// key as a chip.224-func (m *Model) keyHints(pairs ...string) string {225- parts := make([]string, 0, len(pairs)/2)226- for i := 0; i+1 < len(pairs); i += 2 {227- parts = append(parts, m.chip(pairs[i])+" "+m.th.muted.Render(pairs[i+1]))228- }229- return strings.Join(parts, " ")230-}231-232-// chip draws a key name as a key cap, e.g. "Ctrl+S".233-func (m *Model) chip(label string) string {234- return m.th.keyChip.Render(" " + label + " ")235-}236-237223 // cursorPos returns where the terminal cursor goes: in the formula bar238224 // while typing an entry, on the context line in a text prompt, or in an239225 // overlay's search field.@@ -263,7 +249,7 @@ func (m *Model) statusLine() string {
263249 // scroll.264250 func (m *Model) statusLayout() (string, []tabSpan) {265251 if m.mode == modeError {266- return m.th.error.Render(m.errMsg) + m.th.muted.Render(" press any key"), nil252+ return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil267253 }268254 if m.xfer.job != nil {269255 return m.importStatus(), nil@@ -289,7 +275,7 @@ func (m *Model) statusLayout() (string, []tabSpan) {
289275 for _, need := range []int{m.allTabs(), min(m.allTabs(), m.width/2), m.minTabs()} {290276 for _, info := range infos {291277 if info != "" {292- info = m.th.frozenLine.Render(" │ ") + info // like a tmux pane border278+ info = m.th.FrozenLine.Render(" │ ") + info // like a tmux pane border293279 }294280 for _, right := range m.statusRights() {295281 room := m.width - ansi.StringWidth(info)@@ -315,19 +301,19 @@ func (m *Model) statusLayout() (string, []tabSpan) {
315301 func (m *Model) statusState() string {316302 var b strings.Builder317303 if m.changed {318- b.WriteString(m.th.muted.Render(" modified"))304+ b.WriteString(m.th.Muted.Render(" modified"))319305 }320306 if m.book().Decimal() {321- b.WriteString(m.th.muted.Render(" decimal"))307+ b.WriteString(m.th.Muted.Render(" decimal"))322308 }323309 if m.book().Circular {324- b.WriteString(" " + m.th.warning.Render("Circular reference"))310+ b.WriteString(" " + m.th.Warning.Render("Circular reference"))325311 }326312 if n := m.sheet.HiddenRows(); n > 0 {327- b.WriteString(" " + m.th.hint.Render("Filter hides "+rowCount(n)))313+ b.WriteString(" " + m.th.Hint.Render("Filter hides "+rowCount(n)))328314 }329315 if busy := m.jevBusy(); busy != "" {330- b.WriteString(" " + m.th.hint.Render(busy))316+ b.WriteString(" " + m.th.Hint.Render(busy))331317 }332318 return b.String()333319 }@@ -340,13 +326,13 @@ func (m *Model) statusRights() []string {
340326 if m.hasRange() && m.mode == modeReady {341327 r := m.selection()342328 st := m.sheet.RangeStats(r)343- rng := m.th.key.Render(r.String())329+ rng := m.th.Key.Render(r.String())344330 sum, avg := "", ""345331 if st.Nums > 0 {346- sum = m.th.muted.Render("Sum ") + fmtStat(st.Sum)347- avg = m.th.muted.Render("Avg ") + fmtStat(st.Sum/float64(st.Nums))332+ sum = m.th.Muted.Render("Sum ") + fmtStat(st.Sum)333+ avg = m.th.Muted.Render("Avg ") + fmtStat(st.Sum/float64(st.Nums))348334 }349- count := m.th.muted.Render("Count ") + strconv.Itoa(st.Count)335+ count := m.th.Muted.Render("Count ") + strconv.Itoa(st.Count)350336 // As many stats as fit: Avg goes first, then Sum, then Count.351337 for _, parts := range [][]string{{rng, sum, avg, count}, {rng, sum, count}, {rng, count}, {rng}} {352338 out = append(out, strings.Join(slices.DeleteFunc(parts, func(p string) bool { return p == "" }), " "))@@ -355,7 +341,7 @@ func (m *Model) statusRights() []string {
355341 }356342 pairs := []string{shortcut("palette"), "search", shortcut("help"), "shortcuts", shortcut("menu"), "menu"}357343 for ; len(pairs) > 0; pairs = pairs[:len(pairs)-2] {358- out = append(out, m.keyHints(pairs...))344+ out = append(out, m.th.KeyHints(pairs...))359345 }360346 return append(out, "")361347 }@@ -241,10 +241,10 @@ type sortPart struct {
241241 }242242 243243 func (b *sortBar) parts(m *Model) []sortPart {244- parts := []sortPart{{text: m.th.key.Render("Sort "+b.data().String()) + m.th.muted.Render(" by "), key: -1}}244+ parts := []sortPart{{text: m.th.Key.Render("Sort "+b.data().String()) + m.th.Muted.Render(" by "), key: -1}}245245 for i, k := range b.keys {246246 if i > 0 {247- parts = append(parts, sortPart{text: m.th.muted.Render(" then "), key: -1})247+ parts = append(parts, sortPart{text: m.th.Muted.Render(" then "), key: -1})248248 }249249 label := sheet.ColName(k.Col)250250 if b.headers > 0 {@@ -252,15 +252,15 @@ func (b *sortBar) parts(m *Model) []sortPart {
252252 label += " " + ansi.Truncate(h, 16, "…")253253 }254254 }255- style := m.th.keyChip255+ style := m.th.KeyChip256256 if i == b.cur {257- style = m.th.menuSelected257+ style = m.th.MenuSelected258258 }259259 parts = append(parts, sortPart{text: style.Render(" " + label + " " + orderName(k.Desc) + " "), key: i})260260 }261- style := m.th.muted261+ style := m.th.Muted262262 if b.headers > 0 {263- style = m.th.menuSelected263+ style = m.th.MenuSelected264264 }265265 parts = append(parts, sortPart{text: " ", key: -1}, sortPart{text: style.Render(" Header row "), key: -1, toggle: true})266266 return parts@@ -309,10 +309,10 @@ func (b *sortBar) status(m *Model) (string, string) {
309309 pairs := []string{"Left/Right", "column", "Space", "order", "Enter", "sort", "Esc", "cancel"}310310 desc := "Alt+A add Alt+H header Tab next"311311 for {312- keys := m.keyHints(pairs...)312+ keys := m.th.KeyHints(pairs...)313313 switch {314314 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:315- return m.th.muted.Render(desc), keys315+ return m.th.Muted.Render(desc), keys316316 case desc != "":317317 desc = ""318318 case len(pairs) > 4:@@ -425,15 +425,15 @@ func (m *Model) tabStrip(room int) (string, []tabSpan) {
425425 x += ansi.StringWidth(text) + 1426426 }427427 if first > 0 {428- part(hitTabPrev, 0, m.th.muted.Render("‹"))428+ part(hitTabPrev, 0, m.th.Muted.Render("‹"))429429 }430430 for i := first; i <= last; i++ {431- style := m.th.tab431+ style := m.th.Tab432432 switch {433433 case i == active:434- style = m.th.tabActive434+ style = m.th.TabActive435435 case m.hover.kind == hitTab && m.hover.addr.Col == i && (m.drag == dragNone || m.drag == dragTab):436- style = m.th.tabHover436+ style = m.th.TabHover437437 }438438 label := tabLabel(sheets[i])439439 if i == active && x+ansi.StringWidth(label)+add > room {@@ -442,11 +442,11 @@ func (m *Model) tabStrip(room int) (string, []tabSpan) {
442442 part(hitTab, i, style.Render(label))443443 }444444 if last < len(sheets)-1 {445- part(hitTabNext, 0, m.th.muted.Render("›"))445+ part(hitTabNext, 0, m.th.Muted.Render("›"))446446 }447- addStyle := m.th.muted447+ addStyle := m.th.Muted448448 if m.hover.kind == hitTabAdd {449- addStyle = m.th.tabHover449+ addStyle = m.th.TabHover450450 }451451 part(hitTabAdd, 0, addStyle.Render(" + "))452452 return strings.TrimSuffix(b.String(), " "), spans@@ -1,191 +0,0 @@
1-package ui2-3-import (4- "charm.land/lipgloss/v2"5- "github.com/charmbracelet/x/ansi"6-7- "github.com/FelineStateMachine/012/internal/chart"8- "github.com/FelineStateMachine/012/internal/sheet"9-)10-11-// theme holds every style the UI draws with. Views must use these roles12-// instead of creating styles inline, so the look stays consistent and a13-// new theme only has to be defined here.14-//15-// Colors are the 16 ANSI colors, so the sheet follows the user's terminal16-// palette; dark and light variants only differ where ANSI colors would17-// lose contrast.18-type theme struct {19- indicator lipgloss.Style // mode indicator, top right20- header lipgloss.Style // column letters, row numbers and the name box21- headerActive lipgloss.Style // header of the focused row and column22- headerSel lipgloss.Style // headers of selected rows and columns23- headerHover lipgloss.Style // a header under the mouse24- handle lipgloss.Style // a column resize handle being hovered or dragged25- pointer lipgloss.Style // the cell pointer26- selection lipgloss.Style // a range being pointed at27- hint lipgloss.Style // guidance on the context line28- warning lipgloss.Style // recoverable problems, e.g. a formula error29- error lipgloss.Style // ERROR mode message30- muted lipgloss.Style // secondary text: key hints, file lists31- key lipgloss.Style // emphasized text in the status line, e.g. a range32- keyChip lipgloss.Style // a key cap in hints, menus and the palette, e.g. " Enter "33- errorCell lipgloss.Style // cells whose value is ERR or NA34- // errorMark is layered on an error's text, so errors show beyond35- // color: a curly underline, in the error color where the terminal36- // supports colored underlines.37- errorMark lipgloss.Style38- link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role39- found lipgloss.Style // cells matching an open search40- traced lipgloss.Style // precedents or dependents being traced41- argument lipgloss.Style // the argument at the caret in a function's signature42- progress lipgloss.Style // the done part of an import's progress bar43- progressTodo lipgloss.Style // the rest of the progress bar44- // copied marks the range on the clipboard, like Sheets' dashed border:45- // a dashed underline across every cell, layered on the cell's own46- // style, with its own text color where the cell has none.47- copied lipgloss.Style48- // frozenLine divides frozen rows and columns from the scrolling ones,49- // like a tmux pane border.50- frozenLine lipgloss.Style51- // Sheet tabs on the status line, like lazygit's panel tabs: plain52- // names (so they don't read as key chips), the sheet shown in the53- // accent and bold, and a tab under the mouse, or where a dragged tab54- // would go, bold and underlined.55- tab lipgloss.Style56- tabActive lipgloss.Style57- tabHover lipgloss.Style58- // filterOn is the filter mark in the header of a column whose filter59- // hides something (the mark itself also changes, from ▾ to ▼).60- filterOn lipgloss.Style61-62- // Chrome: the menu bar, dropdowns, the palette and dialogs.63- menuBar lipgloss.Style // menu bar titles64- menuAccel lipgloss.Style // a title's accelerator letter65- menuSelected lipgloss.Style // open title, highlighted item66- menuAccelSelected lipgloss.Style // accelerator letter of the open title67- border lipgloss.Style // box borders and separators68- title lipgloss.Style // box titles and group headings69- disabled lipgloss.Style // items that can't run right now70- match lipgloss.Style // characters matched by a search71- matchSelected lipgloss.Style // matched characters in the highlighted row72- cell lipgloss.Style // an ordinary cell: the base for bold, italic and underline73-74- // Charts: see charts.go.75- chartFrame lipgloss.Style // a chart's border76- chartSelected lipgloss.Style // the border of the selected chart and its resize handle77- chartAxis lipgloss.Style // axis lines and tick marks78- chartLabel lipgloss.Style // tick values, category labels and legend text79- // series colors bars, lines, slices and legend swatches, in order;80- // seriesBg is the same colors as backgrounds, for the lower half of a81- // pie's half blocks. seriesANSI is the ANSI index of each, so images82- // can use the terminal's own colors.83- series [chart.Colors]lipgloss.Style84- seriesBg [chart.Colors]lipgloss.Style85- seriesANSI [chart.Colors]int86-}87-88-// imageID is the style of an image's Unicode placeholders: the89-// foreground color is not a color but the image's id, in the 256-color90-// palette, which is how the terminal knows which image to draw there.91-func (t theme) imageID(id int) lipgloss.Style {92- return lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(id))93-}94-95-// text adds a cell's bold, italic, underline and strikethrough to base,96-// one of the cell roles (cell, pointer, selection, errorCell), so text97-// styles show through the pointer and selection colors.98-func (t theme) text(base lipgloss.Style, st sheet.Style) lipgloss.Style {99- if st.Bold {100- base = base.Bold(true)101- }102- if st.Italic {103- base = base.Italic(true)104- }105- if st.Underline {106- base = base.Underline(true)107- }108- if st.Strikethrough {109- base = base.Strikethrough(true)110- }111- return base112-}113-114-func newTheme(dark bool) theme {115- // Contrast was checked against the reference palettes in e2e: text on116- // colored backgrounds stays at or above roughly 4.5:1.117- headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite118- selFg, muted, match := lipgloss.Black, lipgloss.BrightBlack, lipgloss.Yellow119- bar := lipgloss.Cyan120- filterFg := lipgloss.Yellow121- if !dark {122- headerBg, headerFg = lipgloss.White, lipgloss.Black123- selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue124- filterFg = lipgloss.Blue125- bar = lipgloss.Blue126- }127- // Links are blue, as in Sheets; plain blue is too dark on a dark128- // background.129- link := lipgloss.BrightBlue130- // Series colors, most distinct first. On light backgrounds yellow and131- // cyan fade, so they come last or not at all.132- series := [chart.Colors]ansi.BasicColor{lipgloss.Cyan, lipgloss.Magenta, lipgloss.Yellow, lipgloss.Green, lipgloss.BrightBlue, lipgloss.BrightRed}133- if !dark {134- link = lipgloss.Blue135- series = [chart.Colors]ansi.BasicColor{lipgloss.Blue, lipgloss.Magenta, lipgloss.Green, lipgloss.Red, lipgloss.Cyan, lipgloss.BrightBlack}136- }137- accent := lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)138- t := theme{139- indicator: accent.Bold(true),140- header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),141- headerActive: accent.Bold(true),142- headerSel: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),143- headerHover: lipgloss.NewStyle().Background(headerBg).Foreground(lipgloss.Cyan).Bold(true),144- handle: lipgloss.NewStyle().Background(headerBg).Foreground(lipgloss.Cyan).Bold(true),145- pointer: accent,146- selection: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),147- hint: lipgloss.NewStyle().Foreground(muted),148- warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),149- error: lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true),150- muted: lipgloss.NewStyle().Foreground(muted),151- key: lipgloss.NewStyle().Bold(true),152- keyChip: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),153- errorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),154- errorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),155- link: lipgloss.NewStyle().Foreground(link).Underline(true),156- found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),157- traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),158- argument: lipgloss.NewStyle().Bold(true).Underline(true),159- progress: lipgloss.NewStyle().Foreground(bar),160- progressTodo: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),161- copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).162- UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),163- frozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),164- tab: lipgloss.NewStyle(),165- tabActive: accent.Bold(true),166- tabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),167- filterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),168-169- menuBar: lipgloss.NewStyle(),170- menuAccel: lipgloss.NewStyle().Underline(true),171- menuSelected: accent,172- menuAccelSelected: accent.Underline(true),173- border: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),174- title: lipgloss.NewStyle().Bold(true),175- disabled: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack), // exempt from contrast, like Sheets176- match: lipgloss.NewStyle().Foreground(match).Bold(true),177- matchSelected: accent.Bold(true).Underline(true),178- cell: lipgloss.NewStyle(),179-180- chartFrame: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),181- chartSelected: lipgloss.NewStyle().Foreground(lipgloss.Cyan).Bold(true),182- chartAxis: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),183- chartLabel: lipgloss.NewStyle().Foreground(muted),184- }185- for i, c := range series {186- t.series[i] = lipgloss.NewStyle().Foreground(c)187- t.seriesBg[i] = lipgloss.NewStyle().Background(c)188- t.seriesANSI[i] = int(c)189- }190- return t191-}@@ -0,0 +1,196 @@
1+// Package theme is the look of the UI: the style roles every view draws2+// with, and the small widgets built from them (framed boxes, key chips,3+// key hints). It knows nothing of the model, so any part of the UI can4+// use it.5+package theme6+7+import (8+ "charm.land/lipgloss/v2"9+ "github.com/charmbracelet/x/ansi"10+11+ "github.com/FelineStateMachine/012/internal/chart"12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// Theme holds every style the UI draws with. Views must use these roles16+// instead of creating styles inline, so the look stays consistent and a17+// new theme only has to be defined here.18+//19+// Colors are the 16 ANSI colors, so the sheet follows the user's terminal20+// palette; dark and light variants only differ where ANSI colors would21+// lose contrast.22+type Theme struct {23+ Indicator lipgloss.Style // mode indicator, top right24+ Header lipgloss.Style // column letters, row numbers and the name box25+ HeaderActive lipgloss.Style // header of the focused row and column26+ HeaderSel lipgloss.Style // headers of selected rows and columns27+ HeaderHover lipgloss.Style // a header under the mouse28+ Handle lipgloss.Style // a column resize handle being hovered or dragged29+ Pointer lipgloss.Style // the cell pointer30+ Selection lipgloss.Style // a range being pointed at31+ Hint lipgloss.Style // guidance on the context line32+ Warning lipgloss.Style // recoverable problems, e.g. a formula error33+ Error lipgloss.Style // ERROR mode message34+ Muted lipgloss.Style // secondary text: key hints, file lists35+ Key lipgloss.Style // emphasized text in the status line, e.g. a range36+ KeyChip lipgloss.Style // a key cap in hints, menus and the palette, e.g. " Enter "37+ ErrorCell lipgloss.Style // cells whose value is ERR or NA38+ // ErrorMark is layered on an error's text, so errors show beyond39+ // color: a curly underline, in the error color where the terminal40+ // supports colored underlines.41+ ErrorMark lipgloss.Style42+ Link lipgloss.Style // a cell's URL or HYPERLINK label, layered on the cell's role43+ Found lipgloss.Style // cells matching an open search44+ Traced lipgloss.Style // precedents or dependents being traced45+ Argument lipgloss.Style // the argument at the caret in a function's signature46+ Progress lipgloss.Style // the done part of an import's progress bar47+ ProgressTodo lipgloss.Style // the rest of the progress bar48+ // Copied marks the range on the clipboard, like Sheets' dashed border:49+ // a dashed underline across every cell, layered on the cell's own50+ // style, with its own text color where the cell has none.51+ Copied lipgloss.Style52+ // FrozenLine divides frozen rows and columns from the scrolling ones,53+ // like a tmux pane border.54+ FrozenLine lipgloss.Style55+ // Sheet tabs on the status line, like lazygit's panel tabs: plain56+ // names (so they don't read as key chips), the sheet shown in the57+ // accent and bold, and a tab under the mouse, or where a dragged tab58+ // would go, bold and underlined.59+ Tab lipgloss.Style60+ TabActive lipgloss.Style61+ TabHover lipgloss.Style62+ // FilterOn is the filter mark in the header of a column whose filter63+ // hides something (the mark itself also changes, from ▾ to ▼).64+ FilterOn lipgloss.Style65+66+ // Chrome: the menu bar, dropdowns, the palette and dialogs.67+ MenuBar lipgloss.Style // menu bar titles68+ MenuAccel lipgloss.Style // a title's accelerator letter69+ MenuSelected lipgloss.Style // open title, highlighted item70+ MenuAccelSelected lipgloss.Style // accelerator letter of the open title71+ Border lipgloss.Style // box borders and separators72+ Title lipgloss.Style // box titles and group headings73+ Disabled lipgloss.Style // items that can't run right now74+ Match lipgloss.Style // characters matched by a search75+ MatchSelected lipgloss.Style // matched characters in the highlighted row76+ Cell lipgloss.Style // an ordinary cell: the base for bold, italic and underline77+78+ // Charts: see charts.go in package ui.79+ ChartFrame lipgloss.Style // a chart's border80+ ChartSelected lipgloss.Style // the border of the selected chart and its resize handle81+ ChartAxis lipgloss.Style // axis lines and tick marks82+ ChartLabel lipgloss.Style // tick values, category labels and legend text83+ // Series colors bars, lines, slices and legend swatches, in order;84+ // SeriesBg is the same colors as backgrounds, for the lower half of a85+ // pie's half blocks. SeriesANSI is the ANSI index of each, so images86+ // can use the terminal's own colors.87+ Series [chart.Colors]lipgloss.Style88+ SeriesBg [chart.Colors]lipgloss.Style89+ SeriesANSI [chart.Colors]int90+}91+92+// ImageID is the style of an image's Unicode placeholders: the93+// foreground color is not a color but the image's id, in the 256-color94+// palette, which is how the terminal knows which image to draw there.95+func (t Theme) ImageID(id int) lipgloss.Style {96+ return lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(id))97+}98+99+// Text adds a cell's bold, italic, underline and strikethrough to base,100+// one of the cell roles (cell, pointer, selection, errorCell), so text101+// styles show through the pointer and selection colors.102+func (t Theme) Text(base lipgloss.Style, st sheet.Style) lipgloss.Style {103+ if st.Bold {104+ base = base.Bold(true)105+ }106+ if st.Italic {107+ base = base.Italic(true)108+ }109+ if st.Underline {110+ base = base.Underline(true)111+ }112+ if st.Strikethrough {113+ base = base.Strikethrough(true)114+ }115+ return base116+}117+118+// New returns the dark or light variant.119+func New(dark bool) Theme {120+ // Contrast was checked against the reference palettes in e2e: text on121+ // colored backgrounds stays at or above roughly 4.5:1.122+ headerBg, headerFg := lipgloss.BrightBlack, lipgloss.BrightWhite123+ selFg, muted, match := lipgloss.Black, lipgloss.BrightBlack, lipgloss.Yellow124+ bar := lipgloss.Cyan125+ filterFg := lipgloss.Yellow126+ if !dark {127+ headerBg, headerFg = lipgloss.White, lipgloss.Black128+ selFg, muted, match = lipgloss.BrightWhite, lipgloss.Black, lipgloss.Blue129+ filterFg = lipgloss.Blue130+ bar = lipgloss.Blue131+ }132+ // Links are blue, as in Sheets; plain blue is too dark on a dark133+ // background.134+ link := lipgloss.BrightBlue135+ // Series colors, most distinct first. On light backgrounds yellow and136+ // cyan fade, so they come last or not at all.137+ series := [chart.Colors]ansi.BasicColor{lipgloss.Cyan, lipgloss.Magenta, lipgloss.Yellow, lipgloss.Green, lipgloss.BrightBlue, lipgloss.BrightRed}138+ if !dark {139+ link = lipgloss.Blue140+ series = [chart.Colors]ansi.BasicColor{lipgloss.Blue, lipgloss.Magenta, lipgloss.Green, lipgloss.Red, lipgloss.Cyan, lipgloss.BrightBlack}141+ }142+ accent := lipgloss.NewStyle().Background(lipgloss.Cyan).Foreground(lipgloss.Black)143+ t := Theme{144+ Indicator: accent.Bold(true),145+ Header: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),146+ HeaderActive: accent.Bold(true),147+ HeaderSel: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),148+ HeaderHover: lipgloss.NewStyle().Background(headerBg).Foreground(lipgloss.Cyan).Bold(true),149+ Handle: lipgloss.NewStyle().Background(headerBg).Foreground(lipgloss.Cyan).Bold(true),150+ Pointer: accent,151+ Selection: lipgloss.NewStyle().Background(lipgloss.Blue).Foreground(selFg),152+ Hint: lipgloss.NewStyle().Foreground(muted),153+ Warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),154+ Error: lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true),155+ Muted: lipgloss.NewStyle().Foreground(muted),156+ Key: lipgloss.NewStyle().Bold(true),157+ KeyChip: lipgloss.NewStyle().Background(headerBg).Foreground(headerFg),158+ ErrorCell: lipgloss.NewStyle().Foreground(lipgloss.Red),159+ ErrorMark: lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly).UnderlineColor(lipgloss.Red),160+ Link: lipgloss.NewStyle().Foreground(link).Underline(true),161+ Found: lipgloss.NewStyle().Background(lipgloss.Yellow).Foreground(lipgloss.Black),162+ Traced: lipgloss.NewStyle().Background(lipgloss.Green).Foreground(lipgloss.Black),163+ Argument: lipgloss.NewStyle().Bold(true).Underline(true),164+ Progress: lipgloss.NewStyle().Foreground(bar),165+ ProgressTodo: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),166+ Copied: lipgloss.NewStyle().Foreground(lipgloss.Magenta).167+ UnderlineStyle(lipgloss.UnderlineDashed).UnderlineSpaces(true),168+ FrozenLine: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),169+ Tab: lipgloss.NewStyle(),170+ TabActive: accent.Bold(true),171+ TabHover: lipgloss.NewStyle().Foreground(bar).Bold(true).Underline(true),172+ FilterOn: lipgloss.NewStyle().Background(headerBg).Foreground(filterFg).Bold(true),173+174+ MenuBar: lipgloss.NewStyle(),175+ MenuAccel: lipgloss.NewStyle().Underline(true),176+ MenuSelected: accent,177+ MenuAccelSelected: accent.Underline(true),178+ Border: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),179+ Title: lipgloss.NewStyle().Bold(true),180+ Disabled: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack), // exempt from contrast, like Sheets181+ Match: lipgloss.NewStyle().Foreground(match).Bold(true),182+ MatchSelected: accent.Bold(true).Underline(true),183+ Cell: lipgloss.NewStyle(),184+185+ ChartFrame: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),186+ ChartSelected: lipgloss.NewStyle().Foreground(lipgloss.Cyan).Bold(true),187+ ChartAxis: lipgloss.NewStyle().Foreground(lipgloss.BrightBlack),188+ ChartLabel: lipgloss.NewStyle().Foreground(muted),189+ }190+ for i, c := range series {191+ t.Series[i] = lipgloss.NewStyle().Foreground(c)192+ t.SeriesBg[i] = lipgloss.NewStyle().Background(c)193+ t.SeriesANSI[i] = int(c)194+ }195+ return t196+}@@ -0,0 +1,140 @@
1+package theme2+3+import (4+ "slices"5+ "strings"6+7+ "charm.land/lipgloss/v2"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// A box is framed with light box-drawing lines, which keep the crisp12+// character-grid look. Rows are exactly the inner width; separator rows13+// become a line joined to the frame.14+15+// SepRow marks a separator in the rows passed to Frame.16+const SepRow = "\x00"17+18+// Frame draws a border around rows. A title sits in the top border and a19+// footer at the right of the bottom border.20+func (t Theme) Frame(inner int, title, footer string, rows []string) []string {21+ inner = max(inner, 2) // screens smaller than the box get a clipped box22+ b := t.Border23+ top := "┌" + strings.Repeat("─", inner) + "┐"24+ if title != "" {25+ tt := ansi.Truncate(" "+title+" ", inner-1, "…")26+ top = b.Render("┌─") + t.Title.Render(tt) + b.Render(strings.Repeat("─", inner-1-ansi.StringWidth(tt))+"┐")27+ } else {28+ top = b.Render(top)29+ }30+ bottom := b.Render("└" + strings.Repeat("─", inner) + "┘")31+ if footer != "" && ansi.StringWidth(footer)+4 <= inner {32+ f := " " + footer + " "33+ bottom = b.Render("└"+strings.Repeat("─", inner-1-ansi.StringWidth(f))) + t.Muted.Render(f) + b.Render("─┘")34+ }35+ lines := make([]string, 0, len(rows)+2)36+ lines = append(lines, top)37+ for _, r := range rows {38+ if r == SepRow {39+ lines = append(lines, b.Render("├"+strings.Repeat("─", inner)+"┤"))40+ continue41+ }42+ lines = append(lines, b.Render("│")+r+b.Render("│"))43+ }44+ return append(lines, bottom)45+}46+47+// Chip draws a key name as a key cap, e.g. "Ctrl+S".48+func (t Theme) Chip(label string) string {49+ return t.KeyChip.Render(" " + label + " ")50+}51+52+// Chips renders keys as key chips, e.g. [Ctrl+/] [F1].53+func (t Theme) Chips(keys []string) string {54+ parts := make([]string, len(keys))55+ for i, k := range keys {56+ parts[i] = t.Chip(k)57+ }58+ return strings.Join(parts, " ")59+}60+61+// KeyHints renders key and description pairs, e.g. "Enter accept", each62+// key as a chip.63+func (t Theme) KeyHints(pairs ...string) string {64+ parts := make([]string, 0, len(pairs)/2)65+ for i := 0; i+1 < len(pairs); i += 2 {66+ parts = append(parts, t.Chip(pairs[i])+" "+t.Muted.Render(pairs[i+1]))67+ }68+ return strings.Join(parts, " ")69+}70+71+// Cells pads or truncates s to exactly w columns, then styles it.72+func Cells(style lipgloss.Style, s string, w int) string {73+ return style.Render(PadRight(ansi.Truncate(s, w, "…"), w))74+}75+76+// HighlightMatches renders s in w columns with the bytes at idx in the hl77+// style, truncating with an ellipsis.78+func HighlightMatches(s string, idx []int, w int, base, hl lipgloss.Style) string {79+ s = ansi.Truncate(s, w, "…")80+ var b strings.Builder81+ run, lit := "", false82+ flush := func() {83+ if lit {84+ b.WriteString(hl.Render(run))85+ } else {86+ b.WriteString(base.Render(run))87+ }88+ run = ""89+ }90+ for i, r := range s {91+ if on := slices.Contains(idx, i); on != lit {92+ flush()93+ lit = on94+ }95+ run += string(r)96+ }97+ flush()98+ return b.String() + base.Render(strings.Repeat(" ", max(w-ansi.StringWidth(s), 0)))99+}100+101+// KeyLabel formats a key binding for display, e.g. "ctrl+s" -> "Ctrl+S".102+func KeyLabel(k string) string {103+ switch k {104+ case "delete":105+ return "Del"106+ case "backspace":107+ return "Backspace"108+ case "esc":109+ return "Esc"110+ }111+ k = strings.NewReplacer("pgdown", "PgDn", "pgup", "PgUp").Replace(k)112+ parts := strings.Split(k, "+")113+ for i, p := range parts {114+ switch {115+ case len(p) == 1:116+ parts[i] = strings.ToUpper(p)117+ case p[0] == 'f' && len(p) <= 3 && p[1] >= '0' && p[1] <= '9':118+ parts[i] = strings.ToUpper(p)119+ default:120+ parts[i] = strings.ToUpper(p[:1]) + p[1:]121+ }122+ }123+ return strings.Join(parts, "+")124+}125+126+// PadRight pads s with spaces to w columns.127+func PadRight(s string, w int) string {128+ return s + strings.Repeat(" ", max(w-ansi.StringWidth(s), 0))129+}130+131+// PadLeft right-aligns s in w columns.132+func PadLeft(s string, w int) string {133+ return strings.Repeat(" ", max(w-ansi.StringWidth(s), 0)) + s134+}135+136+// Center centers s in w columns, any odd space going right.137+func Center(s string, w int) string {138+ pad := max(w-ansi.StringWidth(s), 0)139+ return strings.Repeat(" ", pad/2) + s + strings.Repeat(" ", pad-pad/2)140+}@@ -112,7 +112,7 @@ func (m *Model) traceLine() (left, right string) {
112112 if len(t.targets) != 1 {113113 noun += "s"114114 }115- right = m.keyHints(shortcut(id), "next", "Esc", "back")115+ right = m.th.KeyHints(shortcut(id), "next", "Esc", "back")116116 origin := t.origin.String()117117 if t.home != m.sheet {118118 origin = sheet.Qualified(t.home.Name(), sheet.Rect{From: t.origin, To: t.origin})@@ -127,14 +127,14 @@ func (m *Model) traceLine() (left, right string) {
127127 for i, tg := range t.targets {128128 part := m.rangeLabel(tg, t.home)129129 if i == t.at {130- part = m.th.key.Render(part)130+ part = m.th.Key.Render(part)131131 }132132 if i > 0 {133133 part = ", " + part134134 }135135 more := " +" + strconv.Itoa(len(t.targets)-i) + " more"136136 if ansi.StringWidth(b.String()+part)+len(more) > room && i < len(t.targets)-1 {137- b.WriteString(m.th.muted.Render(more))137+ b.WriteString(m.th.Muted.Render(more))138138 break139139 }140140 b.WriteString(part)@@ -377,7 +377,7 @@ func quoteSQL(s string) string {
377377 378378 // importLine is the context line during an import.379379 func (m *Model) importLine() string {380- return "Importing " + filepath.Base(m.xfer.job.name) + "… " + m.keyHints("Esc", "cancel")380+ return "Importing " + filepath.Base(m.xfer.job.name) + "… " + m.th.KeyHints("Esc", "cancel")381381 }382382 383383 // importStatus is the status line during an import: the file, the rows@@ -385,15 +385,15 @@ func (m *Model) importLine() string {
385385 func (m *Model) importStatus() string {386386 job := m.xfer.job387387 rows, frac := job.prog.Get()388- left := m.th.key.Render("Importing " + filepath.Base(job.name))388+ left := m.th.Key.Render("Importing " + filepath.Base(job.name))389389 right := countRows(rows) + " read"390390 if frac >= 0 {391391 pct := fmt.Sprintf(" %3d%%", int(frac*100))392392 w := clamp(m.width-ansi.StringWidth(left)-ansi.StringWidth(right)-len(pct)-6, 0, 30)393393 if w >= 8 {394394 done := int(frac * float64(w))395- right += " " + m.th.progress.Render(strings.Repeat("━", done)) +396- m.th.progressTodo.Render(strings.Repeat("─", w-done)) + pct395+ right += " " + m.th.Progress.Render(strings.Repeat("━", done)) +396+ m.th.ProgressTodo.Render(strings.Repeat("─", w-done)) + pct397397 } else {398398 right += pct399399 }@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/x/ansi"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/theme"1112 )1213 1314 // active is the cell drawn as the cell pointer: the pointer while@@ -59,41 +60,41 @@ func (m *Model) screenCols() []int {
5960 6061 func (m *Model) headerRow() string {6162 var b strings.Builder62- b.WriteString(m.th.header.Render(strings.Repeat(" ", rowHdrW)))63+ b.WriteString(m.th.Header.Render(strings.Repeat(" ", rowHdrW)))6364 focus := m.active()6465 sel, selecting := m.highlight()6566 for _, c := range m.screenCols() {6667 if c == divider {67- b.WriteString(m.th.frozenLine.Render("│"))68+ b.WriteString(m.th.FrozenLine.Render("│"))6869 continue6970 }7071 w := m.sheet.ColWidth(c)71- style, plain := m.th.header, false72+ style, plain := m.th.Header, false7273 switch {7374 case c == focus.Col:74- style = m.th.headerActive75+ style = m.th.HeaderActive7576 case selecting && c >= sel.From.Col && c <= sel.To.Col:76- style = m.th.headerSel77+ style = m.th.HeaderSel7778 case m.hover.addr.Col == c && (m.hover.kind == hitColHeader || m.hover.kind == hitColBorder || m.hover.kind == hitFilterButton):78- style = m.th.headerHover79+ style = m.th.HeaderHover7980 default:8081 plain = true8182 }8283 name := sheet.ColName(c)83- label := style.Render(center(name, w))84+ label := style.Render(theme.Center(name, w))8485 if mark, on := m.filterMark(c); mark != "" && w >= len(name)+3 {8586 // The filter's button follows the letter, e.g. "B ▾".86- text := center(name+" "+mark, w)87+ text := theme.Center(name+" "+mark, w)8788 k := strings.Index(text, mark)8889 markStyle := style8990 if on && plain {90- markStyle = m.th.filterOn91+ markStyle = m.th.FilterOn9192 }9293 label = style.Render(text[:k]) + markStyle.Render(mark) + style.Render(text[k+len(mark):])9394 }9495 if m.showHandle(c) && w > 1 {9596 // Draw the resize handle in the header's last cell.96- b.WriteString(ansi.Truncate(label, w-1, "") + m.th.handle.Render("▐"))97+ b.WriteString(ansi.Truncate(label, w-1, "") + m.th.Handle.Render("▐"))9798 continue9899 }99100 b.WriteString(label)@@ -119,22 +120,22 @@ func (m *Model) gridRow(row int) string {
119120 }120121 focus := m.active()121122 sel, selecting := m.highlight()122- hdr := m.th.header123+ hdr := m.th.Header123124 switch {124125 case row == focus.Row:125- hdr = m.th.headerActive126+ hdr = m.th.HeaderActive126127 case selecting && row >= sel.From.Row && row <= sel.To.Row:127- hdr = m.th.headerSel128+ hdr = m.th.HeaderSel128129 case m.hover.kind == hitRowHeader && m.hover.addr.Row == row:129- hdr = m.th.headerHover130+ hdr = m.th.HeaderHover130131 }131132 var b strings.Builder132- b.WriteString(hdr.Render(padLeft(strconv.Itoa(row+1), rowHdrW-1) + " "))133+ b.WriteString(hdr.Render(theme.PadLeft(strconv.Itoa(row+1), rowHdrW-1) + " "))133134 134135 _, fc := m.frozen()135136 if fc > 0 {136137 b.WriteString(m.cellsText(row, 0, m.rowText(row, 0, fc, 0, fc-1), focus, sel, selecting))137- b.WriteString(m.th.frozenLine.Render("│"))138+ b.WriteString(m.th.FrozenLine.Render("│"))138139 }139140 ncols := m.visibleCols(m.left)140141 b.WriteString(m.cellsText(row, m.left, m.rowText(row, m.left, ncols, fc, sheet.MaxCols-1), focus, sel, selecting))@@ -147,26 +148,26 @@ func (m *Model) cellsText(row, first int, spans []span, focus sheet.Addr, sel sh
147148 var b strings.Builder148149 for i, sp := range spans {149150 a := sheet.Addr{Col: first + i, Row: row}150- base, colored := m.th.cell, true151+ base, colored := m.th.Cell, true151152 switch {152153 case a == focus:153- base = m.th.pointer154+ base = m.th.Pointer154155 case selecting && sel.Contains(a):155- base = m.th.selection156+ base = m.th.Selection156157 case m.found(a):157- base = m.th.found158+ base = m.th.Found158159 case m.traced(a):159- base = m.th.traced160+ base = m.th.Traced160161 case sheet.IsPending(m.sheet.Value(a)):161- base = m.th.muted162+ base = m.th.Muted162163 case m.sheet.Value(a).Kind == sheet.Error:163- base = m.th.errorCell164+ base = m.th.ErrorCell164165 default:165166 colored = false166167 }167168 // The copy marker is layered on the cell's own colors.168169 if m.copyMarked(a) {169- base, colored = base.Inherit(m.th.copied), true170+ base, colored = base.Inherit(m.th.Copied), true170171 }171172 if a == m.cur && (m.mode == modeEnter || m.mode == modeEdit) && !m.away() {172173 sp = span{text: m.inCellText(m.sheet.ColWidth(a.Col))}@@ -195,7 +196,7 @@ func (m *Model) dividerRow() string {
195196 }196197 b.WriteString(strings.Repeat("─", m.sheet.ColWidth(c)))197198 }198- return m.th.frozenLine.Render(ansi.Truncate(b.String(), m.width, ""))199+ return m.th.FrozenLine.Render(ansi.Truncate(b.String(), m.width, ""))199200 }200201 201202 // span is what one grid column shows in a row: blank columns, the text,@@ -237,7 +238,7 @@ func (m *Model) inCellText(w int) string {
237238 if over := ansi.StringWidth(text) - w; over > 0 {238239 text = ansi.TruncateLeft(text, over+1, "…")239240 }240- return padRight(text, w)241+ return theme.PadRight(text, w)241242 }242243 243244 // rowText lays out ncols columns of row from lo, each span exactly its@@ -394,41 +395,3 @@ func nextCluster(s string) (n, w int) {
394395 g, w := ansi.FirstGraphemeCluster(s, ansi.GraphemeWidth)395396 return len(g), w396397 }397-398-// keyLabel formats a key binding for display, e.g. "ctrl+s" -> "Ctrl+S".399-func keyLabel(k string) string {400- switch k {401- case "delete":402- return "Del"403- case "backspace":404- return "Backspace"405- case "esc":406- return "Esc"407- }408- k = strings.NewReplacer("pgdown", "PgDn", "pgup", "PgUp").Replace(k)409- parts := strings.Split(k, "+")410- for i, p := range parts {411- switch {412- case len(p) == 1:413- parts[i] = strings.ToUpper(p)414- case p[0] == 'f' && len(p) <= 3 && p[1] >= '0' && p[1] <= '9':415- parts[i] = strings.ToUpper(p)416- default:417- parts[i] = strings.ToUpper(p[:1]) + p[1:]418- }419- }420- return strings.Join(parts, "+")421-}422-423-func padRight(s string, w int) string {424- return s + strings.Repeat(" ", max(w-ansi.StringWidth(s), 0))425-}426-427-func padLeft(s string, w int) string {428- return strings.Repeat(" ", max(w-ansi.StringWidth(s), 0)) + s429-}430-431-func center(s string, w int) string {432- pad := max(w-ansi.StringWidth(s), 0)433- return strings.Repeat(" ", pad/2) + s + strings.Repeat(" ", pad-pad/2)434-}