cat / 012
clone

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

Merge the UI restructure: components own their state, leaf packages for theme, row layout and formula text

Dami committed a9320d2
commit
a9320d2b322f54527639c62ebc583b3d782a060b
parent
cb8e90663c5062d8cbcb6bf68792983c9964f325
parent
f68a7c7001ecee13edc472022402322250208c0a
author
Dami <dami.etoile@gmail.com>
67 files changed, +4273 -3717
Mdocs/UX.md8+-
Mdocs/architecture.md67+++-
Mdocs/extending.md8+-
Minternal/ui/assist.go250++++-----------
Minternal/ui/assist_test.go90+----
Ainternal/ui/charteditor.go234++++++++++++++
Minternal/ui/charts.go505++----------------------------
Minternal/ui/charts_test.go8+-
Ainternal/ui/chartsel.go239++++++++++++++
Minternal/ui/clipboard.go40+-
Ainternal/ui/columns.go80+++++
Minternal/ui/commands.go20+-
Minternal/ui/context.go8-
Minternal/ui/data_test.go51++-
Minternal/ui/dialog.go6+-
Ainternal/ui/download.go170++++++++++
Minternal/ui/edit.go144+--------
Minternal/ui/edit_test.go43+--
Minternal/ui/entry.go128+++-----
Ainternal/ui/file.go155+++++++++
Minternal/ui/fill.go34+-
Minternal/ui/filter.go82++--
Minternal/ui/find.go194+-----------
Minternal/ui/find_test.go4+-
Ainternal/ui/findview.go185+++++++++++
Minternal/ui/format_test.go2+-
Ainternal/ui/formula/formula.go278+++++++++++++++++
Ainternal/ui/formula/formula_test.go87+++++
Minternal/ui/graphics.go85+++--
Ainternal/ui/grid.go25+
Minternal/ui/help.go28+-
Minternal/ui/help_test.go10+-
Minternal/ui/jev.go47++-
Ainternal/ui/line.go105++++++
Minternal/ui/links.go32+-
Minternal/ui/links_test.go10+-
Dinternal/ui/menu.go358---------------------
Minternal/ui/menubar.go365+----------------------
Ainternal/ui/menuoverlay.go381+++++++++++++++++++++++
Minternal/ui/model.go226+++----------
Minternal/ui/model_test.go14+-
Minternal/ui/mouse.go258++++-----------
Minternal/ui/mouse_test.go26+-
Minternal/ui/names.go12+-
Minternal/ui/overlay.go55+---
Minternal/ui/palette.go74+---
Minternal/ui/palette_test.go4+-
Minternal/ui/panel.go204++++++------
Minternal/ui/panes.go233+++++++++++---
Minternal/ui/panes_test.go6+-
Ainternal/ui/prompt.go190+++++++++++
Ainternal/ui/rowtext/rowtext.go228++++++++++++++
Rinternal/ui/view_test.go → internal/ui/rowtext/rowtext_test.go2+-
Minternal/ui/selection.go172++++++++--
Minternal/ui/sort.go30+-
Minternal/ui/stress_test.go2+-
Minternal/ui/tabs.go284++---------------
Ainternal/ui/tabstrip.go248+++++++++++++++
Dinternal/ui/theme.go191-----------
Ainternal/ui/theme/theme.go196++++++++++++
Ainternal/ui/theme/widgets.go140++++++++
Ainternal/ui/theme/widgets_test.go44+++
Minternal/ui/trace.go37+-
Minternal/ui/trace_test.go4+-
Minternal/ui/transfer.go227+++-----------
Minternal/ui/transfer_test.go10+-
Minternal/ui/view.go307+++---------------
M docs/UX.md +4 -4
M docs/architecture.md +56 -11
@@ -12,6 +12,9 @@ internal/jev JEV configuration, answer cache and TypeSafe client
1212 internal/telemetry opt-in JSON log and OTLP export of spans, events and frame stats1313 internal/stress synthetic worst-case sheets for the -tags stress benchmarks1414 internal/ui the Bubble Tea model: modes, menus, overlays, rendering15+ theme style roles and the widgets drawn with them (frames, key chips)16+ rowtext laying out a row of cell text across the columns on screen17+ formula reading the formula being typed (F4, the word and call at the caret)1518 e2e/ end-to-end tests through libghostty (separate module, cgo)1619 oracle/ differential tests against excelize (separate module)1720 ```
@@ -51,17 +54,59 @@ its computed value, and its format and style.
5154 5255 ## The UI5356 54-`internal/ui` is one Bubble Tea model. Inside the grid it follows Google55-Sheets; around it, the control panel keeps a 1-2-3 look. Every action is a56-registered command with a title and description, reached from key bindings,57-the menu bar, context menus, the command palette and the help overlay, so58-they can't disagree. Menus, the palette and pickers are overlays composited59-with Lip Gloss layers over the grid, which never shifts under them. Only the60-visible cells are rendered. Styles come from a small set of theme roles61-with dark and light variants built on the terminal's 16 ANSI colors, so the62-user's palette applies.63-64-See [UX.md](UX.md) for the rules every change follows.57+`internal/ui` is a Bubble Tea program. Inside the grid it follows Google58+Sheets; around it, the control panel keeps a 1-2-3 look. See59+[UX.md](UX.md) for the rules every change follows.60+61+**Commands.** Every action is a registered command with a title and62+description (`commands.go`), reached from key bindings, the menu bar,63+context menus, the command palette, the help overlay and mouse gestures64+that stand for one (double-clicking a tab renames it through65+`sheet.rename`), so they can't disagree. `runCommand` is the one place a66+command runs: telemetry times it there, and a command log or macro67+recorder would attach there.68+69+**Model and components.** `Model` (`model.go`) is the root: it holds the70+file, the mode and the note on the context line, owns one component for71+each thing that takes input or draws part of the screen, routes each72+message to the component it's for, and composes the screen from what they73+draw. The components:74+75+| Component | Type | Owns |76+|---|---|---|77+| grid (embedded) | `grid` | the sheet shown, active cell, scroll, window size, selection; mapping rows and columns to the screen, frozen panes, moving and selecting (`grid.go`, `panes.go`, `selection.go`) |78+| edit line | `lineEdit` | the one-line editor shared by cell entries, prompts and search fields (`line.go`) |79+| cell entry | `entry`, `assist` | typing into a cell, pointing at references, other sheets while pointing, formula suggestions and signatures (`entry.go`, `assist.go`) |80+| prompt | `prompt` | a question on the context line, typed or pointed at (`prompt.go`) |81+| overlays | `overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (`palette.go`, `names.go`), the filter picker, the find, sort and choice bars, the chart editor and selection, the shortcuts |82+| sheet tabs | `tabStrip` | where each sheet was left, the tab strip's scroll, layout and clicks (`tabstrip.go`) |83+| mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |84+| import | `transfer` | the import in progress, its progress display and cancelling (`transfer.go`) |85+| others | `clipboard`, `trace`, `chartState`, `jevRunner`, `terminal` | what Ctrl+V pastes, a trace being shown, chart commands' target, JEV questions in flight, what the terminal supports and the chart images sent to it |86+87+Overlays implement the `overlay` interface (`overlay.go`): an indicator88+for the mode, `key` and `mouse` handlers, a `layout` of boxes to draw, and89+the status line while open; bars on the context line add a90+`contextLine`, and those with a text field a `cursor`. Like `assist` and91+`prompt`, they are handed the model when they handle input, since acting92+on it is their job. A new overlay is a new type; `Model` needs no new93+fields, only a way to open it (usually a command).94+95+**Drawing.** `View` (`panel.go`) stacks the control panel, the header,96+the grid rows and the status line, then composites the floating layers97+with Lip Gloss: charts over the grid, then the open overlay's boxes or the98+formula suggestions, so the grid never shifts under them. Only the visible99+cells are rendered; `rowtext` lays out each row's text in one pass over100+the cells that can reach the screen. Styles come from `theme`, a small set101+of roles with dark and light variants on the terminal's 16 ANSI colors,102+so the user's palette applies; its widgets (framed boxes, key chips, key103+hints) are what every overlay is drawn with.104+105+**Packages.** `theme`, `rowtext` and `formula` depend on nothing in106+`ui`, so they can be tested and measured alone. The components stay in107+package `ui` because they act on the model; moving them out would mean108+exporting most of it. New leaf packages are split off the same way when109+a part needs only the sheet or the theme.65110 66111 ## Files67112
M docs/extending.md +7 -1
M internal/ui/assist.go +68 -182
@@ -4,12 +4,13 @@ import (
44 "slices"55 "strconv"66 "strings"7- "unicode"87 98 tea "charm.land/bubbletea/v2"109 "github.com/charmbracelet/x/ansi"1110 1211 "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/formula"13+ "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415 1516 // Formula assistance, as in Sheets: while a function or range name is
@@ -20,7 +21,9 @@ import (
2021 // (Up/Down, Tab, Enter, Esc) and only while it shows, so arrows after an2122 // operator still point at cells.2223 23-// assist is the state of the suggestion list.24+// assist is the suggestion list: its state, and the keys, mouse, box25+// and status line it takes over while it shows. Like an overlay, it is26+// handed the model, for the entry and the sheet's names.2427 type assist struct {2528 active bool // the last key typed or deleted text; moving the caret hides the list2629 sel, top int // highlighted suggestion and first one shown; reset as the word changes
@@ -40,90 +43,12 @@ type suggestion struct {
4043 fn bool // a function: accepting it adds "("4144 }4245 43-// caret describes the formula text before the caret: the word being44-// typed, if it may be a function or name, and the innermost function call45-// the caret is in.46-type caret struct {47- word string48- wordStart int49- fn string // e.g. "SUM", or "" outside any function's parentheses50- arg int // index of the argument the caret is in51-}52-53-func isWordRune(r rune) bool {54- return r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) || r == '_' || r == '.' || r == '$' || r == '@'55-}56-57-// scanCaret reads a formula up to pos the way the lexer does: strings are58-// skipped, parentheses nest, and commas or semicolons at the innermost59-// level separate arguments.60-func scanCaret(buf []rune, pos int) caret {61- type frame struct {62- fn string63- arg int64- }65- var stack []frame66- var c caret67- inStr := false68- start := -1 // start of the word being read69- prev := "" // the word just before a space or "(" (SUM ( is allowed)70- for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"71- r := buf[i]72- if inStr {73- inStr = r != '"'74- continue75- }76- if isWordRune(r) {77- if start < 0 {78- start = i79- }80- continue81- }82- if start >= 0 {83- prev, start = string(buf[start:i]), -184- }85- switch r {86- case ' ':87- continue // keep prev for "SUM ("88- case '"':89- inStr = true90- case '(':91- stack = append(stack, frame{fn: strings.ToUpper(strings.TrimPrefix(prev, "@"))})92- case ')':93- if len(stack) > 0 {94- stack = stack[:len(stack)-1]95- }96- case ',', ';':97- if len(stack) > 0 {98- stack[len(stack)-1].arg++99- }100- }101- prev = ""102- }103- if inStr {104- return caret{}105- }106- for i := len(stack) - 1; i >= 0; i-- {107- if stack[i].fn != "" {108- c.fn, c.arg = stack[i].fn, stack[i].arg109- break110- }111- }112- if start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {113- w := []rune(strings.TrimPrefix(string(buf[start:pos]), "@"))114- if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {115- c.word, c.wordStart = string(w), pos-len(w)116- }117- }118- return c119-}120-12146 // suggestions lists the functions and named ranges for word: those12247 // starting with it first (names before functions), then, from two12348 // letters on, those containing it, such as COUNTIFS for "ifs". A word12449 // that is already a cell reference only gets names and functions that12550 // start with it.126-func (m *Model) suggestions(word string) []suggestion {51+func suggestions(sh *sheet.Sheet, word string) []suggestion {12752 w := strings.ToUpper(word)12853 var prefix, inner []suggestion12954 add := func(s suggestion, name string) {
@@ -134,7 +59,7 @@ func (m *Model) suggestions(word string) []suggestion {
13459 inner = append(inner, s)13560 }13661 }137- for _, n := range m.sheet.Names() {62+ for _, n := range sh.Names() {13863 add(suggestion{name: n.Name, detail: n.Ref(), desc: "Named range " + n.Name + ": " + n.Ref()}, strings.ToUpper(n.Name))13964 }14065 for _, f := range sheet.Funcs() {
@@ -146,60 +71,60 @@ func (m *Model) suggestions(word string) []suggestion {
14671 return append(prefix, inner...)14772 }14873 149-// shownSuggestions returns the list for the word at the caret, and where74+// shown returns the list for the word at the caret, and where15075 // the word starts, or nothing when the list is hidden: outside ENTER and15176 // EDIT, in plain text, after the caret moved, or when the word already15277 // names a range exactly and nothing else matches.153-func (m *Model) shownSuggestions() ([]suggestion, int) {154- if (m.mode != modeEnter && m.mode != modeEdit) || !m.isFormula() || !m.assist.active || m.overlay != nil {78+func (a *assist) shown(m *Model) ([]suggestion, int) {79+ if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.isFormula() || !a.active || m.overlay != nil {15580 return nil, 015681 }157- c := scanCaret(m.buf, m.bufPos)158- if c.word == "" {82+ c := formula.ScanCaret(m.line.buf, m.line.pos)83+ if c.Word == "" {15984 return nil, 016085 }161- list := m.suggestions(c.word)162- if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.word) {86+ list := suggestions(m.sheet, c.Word)87+ if len(list) == 0 || len(list) == 1 && !list[0].fn && strings.EqualFold(list[0].name, c.Word) {16388 return nil, 016489 }165- m.assist.sel = min(m.assist.sel, len(list)-1)166- return list, c.wordStart90+ a.sel = min(a.sel, len(list)-1)91+ return list, c.WordStart16792 }16893 16994 // typeKey applies a line-editing key to the entry. Typing or deleting17095 // shows suggestions; moving the caret hides them, as in Sheets.17196 func (m *Model) typeKey(k tea.KeyPressMsg) {172- before := string(m.buf)173- m.lineKey(k)174- m.assist = assist{active: string(m.buf) != before}97+ before := m.line.text()98+ m.line.key(k)99+ m.entry.assist = assist{active: m.line.text() != before}175100 }176101 177-// assistKey handles the keys the suggestion list takes while it shows.178-func (m *Model) assistKey(key string) bool {179- list, start := m.shownSuggestions()102+// key handles the keys the suggestion list takes while it shows.103+func (a *assist) key(m *Model, key string) bool {104+ list, start := a.shown(m)180105 if list == nil {181106 return false182107 }183108 switch key {184109 case "up", "ctrl+p":185- m.assist.sel = (m.assist.sel - 1 + len(list)) % len(list)110+ a.sel = (a.sel - 1 + len(list)) % len(list)186111 case "down", "ctrl+n":187- m.assist.sel = (m.assist.sel + 1) % len(list)112+ a.sel = (a.sel + 1) % len(list)188113 case "tab", "enter":189- m.acceptSuggestion(list[m.assist.sel], start)114+ a.accept(m, list[a.sel], start)190115 case "esc":191- m.assist.active = false116+ a.active = false192117 default:193118 return false194119 }195120 return true196121 }197122 198-// acceptSuggestion replaces the word at the caret with s, adding "(" after123+// accept replaces the word at the caret with s, adding "(" after199124 // a function unless one is already there.200-func (m *Model) acceptSuggestion(s suggestion, start int) {125+func (a *assist) accept(m *Model, s suggestion, start int) {201126 text := []rune(s.name)202- rest := m.buf[m.bufPos:]127+ rest := m.line.buf[m.line.pos:]203128 if s.fn && (len(rest) == 0 || rest[0] != '(') {204129 text = append(text, '(')205130 }
@@ -207,15 +132,15 @@ func (m *Model) acceptSuggestion(s suggestion, start int) {
207132 if s.fn && len(rest) > 0 && rest[0] == '(' {208133 pos++ // into the existing parentheses209134 }210- m.buf = slices.Concat(m.buf[:start], text, rest)211- m.bufPos = pos212- m.assist.active = false135+ m.line.buf = slices.Concat(m.line.buf[:start], text, rest)136+ m.line.pos = pos137+ a.active = false213138 }214139 215-// assistBox draws the list under the formula bar, its text aligned with140+// box draws the list under the formula bar, its text aligned with216141 // the word being typed: the name, then the signature or range dimmed.217-func (m *Model) assistBox() (box, bool) {218- list, start := m.shownSuggestions()142+func (a *assist) box(m *Model) (box, bool) {143+ list, start := a.shown(m)219144 if list == nil {220145 return box{}, false221146 }
@@ -223,7 +148,6 @@ func (m *Model) assistBox() (box, bool) {
223148 if rows < 1 {224149 return box{}, false225150 }226- a := &m.assist227151 a.top = clamp(a.top, a.sel-rows+1, a.sel)228152 a.top = clamp(a.top, 0, len(list)-rows)229153 nw, dw := 0, 0
@@ -235,15 +159,15 @@ func (m *Model) assistBox() (box, bool) {
235159 lines := make([]string, 0, rows)236160 for i := a.top; i < a.top+rows; i++ {237161 s := list[i]238- base, dim := m.th.menuBar, m.th.muted162+ base, dim := m.th.MenuBar, m.th.Muted239163 if i == a.sel {240- base, dim = m.th.menuSelected, m.th.menuSelected164+ base, dim = m.th.MenuSelected, m.th.MenuSelected241165 }242166 detail := s.detail243167 if s.fn {244168 detail = s.detail[len(s.name):] // just the arguments: (value1, ...)245169 }246- row := base.Render(" "+padRight(s.name, nw)+" ") + dim.Render(detail)170+ row := base.Render(" "+theme.PadRight(s.name, nw)+" ") + dim.Render(detail)247171 lines = append(lines, ansi.Truncate(row, inner, "…")+base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row), 0))))248172 }249173 footer := ""
@@ -251,15 +175,15 @@ func (m *Model) assistBox() (box, bool) {
251175 footer = strconv.Itoa(a.sel+1) + " of " + strconv.Itoa(len(list))252176 }253177 // The box's text starts one column in, under the word's first letter.254- x := formulaBarTextX() + ansi.StringWidth(string(m.buf[:start])) - 2178+ x := formulaBarTextX() + ansi.StringWidth(string(m.line.buf[:start])) - 2255179 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)}, true180+ return box{id: assistID, x: x, y: contextLine + 1, lines: m.th.Frame(inner, "", footer, lines)}, true257181 }258182 259-// assistMouse lets the mouse hover and click suggestions and scroll the183+// mouse lets the mouse hover and click suggestions and scroll the260184 // list. It reports false for events outside the list.261-func (m *Model) assistMouse(msg tea.MouseMsg) bool {262- b, ok := m.assistBox()185+func (a *assist) mouse(m *Model, msg tea.MouseMsg) bool {186+ b, ok := a.box(m)263187 if !ok {264188 return false265189 }
@@ -268,55 +192,55 @@ func (m *Model) assistMouse(msg tea.MouseMsg) bool {
268192 if h.Empty() {269193 return false270194 }271- list, start := m.shownSuggestions()272- i := m.assist.top + mouse.Y - b.y - 1195+ list, start := a.shown(m)196+ i := a.top + mouse.Y - b.y - 1273197 switch msg.(type) {274198 case tea.MouseWheelMsg:275199 d := 1276200 if mouse.Button == tea.MouseWheelUp {277201 d = -1278202 }279- m.assist.sel = clamp(m.assist.sel+d, 0, len(list)-1)203+ a.sel = clamp(a.sel+d, 0, len(list)-1)280204 case tea.MouseMotionMsg:281- if i >= m.assist.top && i < min(len(list), m.assist.top+b.height()-2) {282- m.assist.sel = i205+ if i >= a.top && i < min(len(list), a.top+b.height()-2) {206+ a.sel = i283207 }284208 case tea.MouseClickMsg:285- if mouse.Button == tea.MouseLeft && i >= m.assist.top && i < min(len(list), m.assist.top+b.height()-2) {286- m.acceptSuggestion(list[i], start)209+ if mouse.Button == tea.MouseLeft && i >= a.top && i < min(len(list), a.top+b.height()-2) {210+ a.accept(m, list[i], start)287211 }288212 }289213 return true290214 }291215 292-// assistStatus is the status line while the list shows: what the216+// status is the status line while the list shows: what the293217 // highlighted suggestion is, and the keys that apply.294-func (m *Model) assistStatus() (desc, keys string, ok bool) {295- list, _ := m.shownSuggestions()218+func (a *assist) status(m *Model) (desc, keys string, ok bool) {219+ list, _ := a.shown(m)296220 if list == nil {297221 return "", "", false298222 }299- return list[m.assist.sel].desc, m.keyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true223+ return list[a.sel].desc, m.th.KeyHints("Up/Down", "move", "Tab", "insert", "Esc", "hide"), true300224 }301225 302226 // inFunction reports whether the caret is inside a known function's303227 // parentheses.304-func (m *Model) inFunction() bool {305- _, ok := sheet.LookupFunc(scanCaret(m.buf, m.bufPos).fn)228+func (l *lineEdit) inFunction() bool {229+ _, ok := sheet.LookupFunc(formula.ScanCaret(l.buf, l.pos).Fn)306230 return ok307231 }308232 309233 // signatureLine puts the signature of the function around the caret on310234 // the context line, with what the function does and the keys that apply311235 // (hints) as room allows. It reports false outside a function.312-func (m *Model) signatureLine(buf []rune, pos int, hints string) (left, right string, ok bool) {313- sig, desc := m.signature(buf, pos)236+func signatureLine(th *theme.Theme, width int, buf []rune, pos int, hints string) (left, right string, ok bool) {237+ sig, desc := signature(th, buf, pos)314238 if sig == "" {315239 return "", "", false316240 }317- full := sig + " " + m.th.muted.Render(desc)241+ full := sig + " " + th.Muted.Render(desc)318242 for _, try := range [][2]string{{full, hints}, {sig, hints}, {full, ""}} {319- if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= m.width {243+ if ansi.StringWidth(try[0])+3+ansi.StringWidth(try[1]) <= width {320244 return try[0], try[1], true321245 }322246 }
@@ -326,22 +250,22 @@ func (m *Model) signatureLine(buf []rune, pos int, hints string) (left, right st
326250 // signature renders the signature of the function around the caret, e.g.327251 // SUM(value1, [value2, ...]), with the current argument marked, and what328252 // the function does. It is "" outside a function.329-func (m *Model) signature(buf []rune, pos int) (sig, desc string) {330- c := scanCaret(buf, pos)331- f, ok := sheet.LookupFunc(c.fn)332- if c.fn == "" || !ok {253+func signature(th *theme.Theme, buf []rune, pos int) (sig, desc string) {254+ c := formula.ScanCaret(buf, pos)255+ f, ok := sheet.LookupFunc(c.Fn)256+ if c.Fn == "" || !ok {333257 return "", ""334258 }335- parts := splitArgs(f.Args)336- cur := argPart(parts, c.arg, f.Max < 0)259+ parts := formula.SplitArgs(f.Args)260+ cur := formula.ArgPart(parts, c.Arg, f.Max < 0)337261 var b strings.Builder338- b.WriteString(m.th.key.Render(f.Name) + "(")262+ b.WriteString(th.Key.Render(f.Name) + "(")339263 for i, p := range parts {340264 if i > 0 {341265 b.WriteString(", ")342266 }343267 if i == cur {344- b.WriteString(m.th.argument.Render(p))268+ b.WriteString(th.Argument.Render(p))345269 } else {346270 b.WriteString(p)347271 }
@@ -349,41 +273,3 @@ func (m *Model) signature(buf []rune, pos int) (sig, desc string) {
349273 b.WriteString(")")350274 return b.String(), f.Desc351275 }352-353-// splitArgs splits a signature's arguments at the top-level commas, so354-// "[value2, ...]" stays one part.355-func splitArgs(args string) []string {356- var out []string357- depth, start := 0, 0358- for i, r := range args {359- switch r {360- case '[':361- depth++362- case ']':363- depth--364- case ',':365- if depth == 0 {366- out = append(out, strings.TrimSpace(args[start:i]))367- start = i + 1368- }369- }370- }371- if s := strings.TrimSpace(args[start:]); s != "" {372- out = append(out, s)373- }374- return out375-}376-377-// argPart maps an argument index to the part of the signature that378-// describes it: a repeating part like "[value2, ...]" covers every379-// argument from its position on. It returns -1 past the last argument.380-func argPart(parts []string, arg int, variadic bool) int {381- rep := slices.IndexFunc(parts, func(p string) bool { return strings.Contains(p, "...") })382- switch {383- case rep >= 0 && variadic && arg >= rep:384- return rep385- case arg < len(parts):386- return arg387- }388- return -1389-}
M internal/ui/assist_test.go +18 -72
@@ -7,64 +7,10 @@ import (
77 tea "charm.land/bubbletea/v2"88 )99 10-func TestScanCaret(t *testing.T) {11- for _, tc := range []struct {12- text string // | marks the caret13- word string14- fn string15- arg int16- }{17- {"=SU|", "SU", "", 0},18- {"=@su|", "su", "", 0},19- {"=SUM(|", "", "SUM", 0},20- {"=SUM(A1, B|", "B", "SUM", 1},21- {"=IF(A1>2, SUM(B1; B2), |", "", "IF", 2},22- {"=IF(A1, (1+|", "", "IF", 1},23- {`=IF(A1="a,b(", |`, "", "IF", 1},24- {`=CONCAT("SU|`, "", "", 0},25- {"=SUM (1, |", "", "SUM", 1},26- {"=jev.te|", "jev.te", "", 0},27- {"=A1+1|", "", "", 0},28- {"=SU|M(1)", "", "", 0},29- {"=Sales|", "Sales", "", 0},30- } {31- i := strings.Index(tc.text, "|")32- buf := []rune(strings.Replace(tc.text, "|", "", 1))33- c := scanCaret(buf, len([]rune(tc.text[:i])))34- if c.word != tc.word || c.fn != tc.fn || c.arg != tc.arg {35- t.Errorf("%s: word %q fn %q arg %d", tc.text, c.word, c.fn, c.arg)36- }37- }38-}39-40-func TestArgPart(t *testing.T) {41- for _, tc := range []struct {42- args string43- arg int44- variadic bool45- want string46- }{47- {"value1, [value2, ...]", 0, true, "value1"},48- {"value1, [value2, ...]", 4, true, "[value2, ...]"},49- {"condition, value_if_true, [value_if_false]", 2, false, "[value_if_false]"},50- {"condition, value_if_true, [value_if_false]", 3, false, ""},51- {"sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", 5, true, "[criteria_range2, criterion2, ...]"},52- } {53- parts := splitArgs(tc.args)54- got := ""55- if i := argPart(parts, tc.arg, tc.variadic); i >= 0 {56- got = parts[i]57- }58- if got != tc.want {59- t.Errorf("%s arg %d: %q, want %q", tc.args, tc.arg, got, tc.want)60- }61- }62-}63-6410 func TestAutocompleteFunctions(t *testing.T) {6511 m := newModel()6612 press(t, m, "=su")67- list, _ := m.shownSuggestions()13+ list, _ := m.entry.assist.shown(m)6814 if len(list) == 0 || list[0].name != "SUBSTITUTE" && list[0].name != "SUM" {6915 t.Fatalf("suggestions %v", list)7016 }
@@ -81,19 +27,19 @@ func TestAutocompleteFunctions(t *testing.T) {
8127 t.Errorf("status %q", st)8228 }8329 // Down moves the highlight rather than committing; Tab inserts.84- for list[m.assist.sel].name != "SUM" {30+ for list[m.entry.assist.sel].name != "SUM" {8531 press(t, m, "<down>")8632 }8733 press(t, m, "<tab>")88- if m.mode != modeEnter || string(m.buf) != "=SUM(" {89- t.Fatalf("mode %v buf %q", m.mode, string(m.buf))34+ if m.mode != modeEnter || m.line.text() != "=SUM(" {35+ t.Fatalf("mode %v buf %q", m.mode, m.line.text())9036 }9137 if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "SUM(value1, [value2, ...])") || !strings.Contains(ctx, "Sum of numbers") {9238 t.Errorf("signature %q", ctx)9339 }9440 press(t, m, "1,")95- sig, _ := m.signature(m.buf, m.bufPos)96- if !strings.Contains(sig, m.th.argument.Render("[value2, ...]")) {41+ sig, _ := signature(&m.th, m.line.buf, m.line.pos)42+ if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {9743 t.Errorf("second argument not marked: %q", sig)9844 }9945 press(t, m, "2)", "<enter>")
@@ -106,8 +52,8 @@ func TestAutocompleteKeys(t *testing.T) {
10652 m := newModel()10753 // Enter inserts while the list shows, then commits when it doesn't.10854 press(t, m, "=ab", "<enter>")109- if string(m.buf) != "=ABS(" {110- t.Fatalf("enter: %q", string(m.buf))55+ if m.line.text() != "=ABS(" {56+ t.Fatalf("enter: %q", m.line.text())11157 }11258 press(t, m, "-4)", "<enter>")11359 if m.sheet.Value(addr("A1")).Num != 4 {
@@ -116,7 +62,7 @@ func TestAutocompleteKeys(t *testing.T) {
11662 // Esc hides the list, a second Esc cancels the entry.11763 press(t, m, "=ro")11864 press(t, m, "<esc>")119- if list, _ := m.shownSuggestions(); list != nil || m.mode != modeEnter {65+ if list, _ := m.entry.assist.shown(m); list != nil || m.mode != modeEnter {12066 t.Fatalf("esc: %v %v", list, m.mode)12167 }12268 press(t, m, "<esc>")
@@ -131,16 +77,16 @@ func TestAutocompleteKeys(t *testing.T) {
13177 press(t, m, "<esc>", "<esc>")13278 // Moving the caret hides the list; a cell reference gets no list.13379 press(t, m, "=co", "<left>")134- if list, _ := m.shownSuggestions(); list != nil {80+ if list, _ := m.entry.assist.shown(m); list != nil {13581 t.Error("list after moving the caret")13682 }13783 press(t, m, "<esc>", "=B2")138- if list, _ := m.shownSuggestions(); list != nil {84+ if list, _ := m.entry.assist.shown(m); list != nil {13985 t.Errorf("list for a reference: %v", list)14086 }14187 // Plain text gets no list.14288 press(t, m, "<esc>", "su")143- if list, _ := m.shownSuggestions(); list != nil {89+ if list, _ := m.entry.assist.shown(m); list != nil {14490 t.Error("list for text")14591 }14692 }
@@ -149,22 +95,22 @@ func TestAutocompleteNamesAndMouse(t *testing.T) {
14995 m := tallModel()15096 m.sheet.DefineName("Sales", rectOf("B1:B3"))15197 press(t, m, "=SUM(sa")152- list, start := m.shownSuggestions()98+ list, start := m.entry.assist.shown(m)15399 if list[0].name != "Sales" || list[0].fn {154100 t.Fatalf("names first: %v", list)155101 }156102 // Clicking a suggestion inserts it.157- b, _ := m.assistBox()103+ b, _ := m.entry.assist.box(m)158104 send(m, tea.MouseClickMsg{X: b.x + 3, Y: b.y + 1, Button: tea.MouseLeft})159- if string(m.buf) != "=SUM(Sales" || m.bufPos != start+5 {160- t.Fatalf("click: %q", string(m.buf))105+ if m.line.text() != "=SUM(Sales" || m.line.pos != start+5 {106+ t.Fatalf("click: %q", m.line.text())161107 }162108 // Editing inside existing parentheses doesn't double them.163109 m = newModel()164110 press(t, m, "=AB(1)", "<f2>", "<left>", "<left>", "<left>", "<backspace>", "B")165111 press(t, m, "<tab>")166- if string(m.buf) != "=ABS(1)" || m.bufPos != 5 {167- t.Errorf("into parens: %q at %d", string(m.buf), m.bufPos)112+ if m.line.text() != "=ABS(1)" || m.line.pos != 5 {113+ t.Errorf("into parens: %q at %d", m.line.text(), m.line.pos)168114 }169115 }170116
A internal/ui/charteditor.go +234 -0
@@ -0,0 +1,234 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+)12+13+// chartEditor is the chart editor: a bar on the context line, in the14+// spirit of the find bar, with the type as a row of chips and the options15+// as toggles. Changes show at once; Enter keeps them and Esc undoes them,16+// removing a chart that was just inserted.17+type chartEditor struct {18+ i int19+ start int // the sheet's state before editing, to undo back to20+ isNew bool21+}22+23+// openChartEditor edits chart i. start is the sheet's state to return to24+// on Esc.25+func (m *Model) openChartEditor(i int, isNew bool, start int) {26+ if i < 0 || i >= len(m.sheet.Charts()) {27+ return28+ }29+ m.charts.last = i30+ m.openOverlay(&chartEditor{i: i, start: start, isNew: isNew})31+}32+33+func (e *chartEditor) indicator() string { return "CHART" }34+35+func (e *chartEditor) layout(*Model) []box { return nil }36+37+func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }38+39+// set applies a change to the chart as its own undo step.40+func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {41+ c := e.chart(m)42+ fn(&c)43+ m.sheet.SetChart(e.i, c, "edit chart")44+ m.changed = m.sheet.StateID() != m.saved45+}46+47+func (e *chartEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {48+ if e.i >= len(m.sheet.Charts()) {49+ m.closeOverlay()50+ return nil51+ }52+ switch key := strings.ToLower(k.String()); key {53+ case "enter":54+ m.selectChart(e.i)55+ if e.isNew {56+ m.note = "Inserted a chart of " + e.chart(m).Data.String()57+ }58+ case "esc":59+ for m.sheet.StateID() != e.start && m.sheet.CanUndo() {60+ m.sheet.Undo()61+ }62+ m.changed = m.sheet.StateID() != m.saved63+ m.closeOverlay()64+ m.charts.last = -165+ case "left", "right", "shift+tab", "tab":66+ d := 167+ if key == "left" || key == "shift+tab" {68+ d = -169+ }70+ e.set(m, func(c *sheet.Chart) {71+ n := len(sheet.ChartTypes)72+ c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]73+ })74+ case "1", "2", "3", "4":75+ e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[key[0]-'1'] })76+ case editorSwitch:77+ e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })78+ case editorHeader:79+ e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })80+ case editorLabels:81+ e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })82+ case editorRange:83+ e.editRange(m)84+ case editorTitle:85+ e.editTitle(m)86+ }87+ return nil88+}89+90+// reopen returns to the editor after a prompt.91+func (e *chartEditor) reopen(m *Model) {92+ m.openOverlay(e)93+}94+95+// editRange asks for the data range by pointing, starting from the96+// current one.97+func (e *chartEditor) editRange(m *Model) {98+ d := e.chart(m).Data99+ m.closeOverlay()100+ m.cur, m.ext, m.selecting, m.whole = d.From, d.To, true, wholeNone101+ m.openRange("Chart data:", func(m *Model, r sheet.Rect) tea.Cmd {102+ m.clearSelection()103+ e.set(m, func(c *sheet.Chart) { c.Data = r })104+ e.reopen(m)105+ return nil106+ })107+ m.prompt.onCancel = func(m *Model) {108+ m.clearSelection()109+ e.reopen(m)110+ }111+}112+113+func (e *chartEditor) editTitle(m *Model) {114+ title := e.chart(m).Title115+ m.closeOverlay()116+ m.openText("Chart title:", title, func(m *Model, text string) tea.Cmd {117+ e.set(m, func(c *sheet.Chart) { c.Title = text })118+ e.reopen(m)119+ return nil120+ })121+ m.prompt.onCancel = e.reopen122+}123+124+// editorPart is a piece of the editor bar: a type chip or a toggle.125+type editorPart struct {126+ text string127+ key string // the key it stands for128+}129+130+func (e *chartEditor) parts(m *Model) []editorPart {131+ c := e.chart(m)132+ chip := func(on bool, name, key string) editorPart {133+ style := m.th.Muted134+ if on {135+ style = m.th.MenuSelected136+ }137+ return editorPart{text: style.Render(" " + name + " "), key: key}138+ }139+ var parts []editorPart140+ for i, t := range sheet.ChartTypes {141+ parts = append(parts, chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))142+ }143+ series := "Series in columns"144+ if c.ByRow {145+ series = "Series in rows"146+ }147+ header, labels := "Header row", "Labels in column"148+ if c.ByRow {149+ header, labels = "Header column", "Labels in row"150+ }151+ return append(parts,152+ editorPart{text: m.th.Key.Render(c.Data.String()), key: editorRange},153+ chip(true, series, editorSwitch),154+ chip(c.Header, header, editorHeader),155+ chip(c.Labels, labels, editorLabels))156+}157+158+// spans lays the parts out on the context line, dropping options from the159+// right on narrow screens, and returns each part's x.160+func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {161+ parts := e.parts(m)162+ for {163+ xs := make([]int, len(parts))164+ x := 0165+ for i, p := range parts {166+ if i == len(sheet.ChartTypes) {167+ x += 2 // a wider gap between the types and the options168+ }169+ xs[i] = x170+ x += ansi.StringWidth(p.text) + 1171+ }172+ if x-1 <= m.width || len(parts) <= len(sheet.ChartTypes) {173+ return parts, xs174+ }175+ parts = parts[:len(parts)-1]176+ }177+}178+179+func (e *chartEditor) contextLine(m *Model) (string, string) {180+ if e.i >= len(m.sheet.Charts()) {181+ return "", ""182+ }183+ parts, xs := e.spans(m)184+ var b strings.Builder185+ for i, p := range parts {186+ b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))187+ b.WriteString(p.text)188+ }189+ return b.String(), ""190+}191+192+func (e *chartEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {193+ if ev.kind != mousePress {194+ return nil195+ }196+ if ev.y != contextLine {197+ // A click elsewhere keeps the changes and acts as usual.198+ m.closeOverlay()199+ return m.handlePress(tea.Mouse{X: ev.x, Y: ev.y, Button: ev.button})200+ }201+ parts, xs := e.spans(m)202+ for i, p := range parts {203+ if ev.x >= xs[i] && ev.x < xs[i]+ansi.StringWidth(p.text) {204+ return e.key(m, tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})205+ }206+ }207+ return nil208+}209+210+func (e *chartEditor) status(m *Model) (string, string) {211+ pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}212+ for {213+ keys := m.th.KeyHints(pairs...)214+ if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {215+ return "", keys216+ }217+ pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...) // keep Enter and Esc218+ }219+}220+221+// contextLiner is an overlay drawn on the context line, like the chart222+// editor.223+type contextLiner interface {224+ contextLine(m *Model) (left, right string)225+}226+227+// Editor keys, also the toggles' mouse targets.228+const (229+ editorSwitch = "s"230+ editorHeader = "h"231+ editorLabels = "l"232+ editorRange = "r"233+ editorTitle = "t"234+)
M internal/ui/charts.go +41 -464
@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/chart"1212 "github.com/FelineStateMachine/012/internal/sheet"13+ "github.com/FelineStateMachine/012/internal/ui/theme"1314 )1415 1516 // Charts follow Sheets: Insert > Chart charts the selection (or the block
@@ -53,6 +54,11 @@ func init() {
5354 )5455 }5556 57+// chartState is what chart commands remember between selections.58+type chartState struct {59+ last int // the chart last selected, for chart commands; -1 for none60+}61+5662 func hasCharts(m *Model) bool { return len(m.sheet.Charts()) > 0 }5763 5864 // targetChart is the chart a chart command acts on: the selected one, the
@@ -65,8 +71,8 @@ func (m *Model) targetChart() int {
6571 case *chartEditor:6672 return o.i6773 }68- if m.lastChart >= 0 && m.lastChart < len(charts) {69- return m.lastChart74+ if m.charts.last >= 0 && m.charts.last < len(charts) {75+ return m.charts.last7076 }7177 for i, c := range charts {7278 if c.Data.Contains(m.cur) {
@@ -105,20 +111,20 @@ func (m *Model) insertChart() tea.Cmd {
105111 }106112 107113 // showChart scrolls so chart i is on screen, as far as it fits.108-func (m *Model) showChart(i int) {109- charts := m.sheet.Charts()114+func (g *grid) showChart(i int) {115+ charts := g.sheet.Charts()110116 if i < 0 || i >= len(charts) {111117 return112118 }113119 c := charts[i]114- rows := m.visibleRows()115- if c.At.Row < m.top || c.At.Row+c.H > m.top+rows {116- m.top = clamp(c.At.Row+c.H-rows, 0, c.At.Row)117- }118- if c.At.Col < m.left || m.colStart(c.At.Col)+c.W > m.width {119- m.left = c.At.Col120- for m.left > 0 && m.colStart(c.At.Col)+c.W+m.sheet.ColWidth(m.left-1) <= m.width {121- m.left--120+ rows := g.visibleRows()121+ if c.At.Row < g.top || c.At.Row+c.H > g.top+rows {122+ g.top = clamp(c.At.Row+c.H-rows, 0, c.At.Row)123+ }124+ if c.At.Col < g.left || g.colStart(c.At.Col)+c.W > g.width {125+ g.left = c.At.Col126+ for g.left > 0 && g.colStart(c.At.Col)+c.W+g.sheet.ColWidth(g.left-1) <= g.width {127+ g.left--122128 }123129 }124130 }
@@ -129,11 +135,11 @@ func (m *Model) deleteChart(i int) {
129135 }130136 m.sheet.DeleteChart(i)131137 m.changed = true132- m.lastChart = -1138+ m.charts.last = -1133139 if _, ok := m.overlay.(*chartSel); ok {134140 m.closeOverlay()135141 }136- m.note = "Deleted the chart " + m.keyHints(shortcut("edit.undo"), "undo")142+ m.note = "Deleted the chart " + m.th.KeyHints(shortcut("edit.undo"), "undo")137143 }138144 139145 // displayCharts are the charts as drawn: with the one being dragged at
@@ -152,8 +158,8 @@ func chartInner(c sheet.Chart) (w, h int) { return c.W - 4, c.H - 2 }
152158 153159 // chartScreen returns where chart c's top-left corner is on screen; it154160 // may be off screen.155-func (m *Model) chartScreen(c sheet.Chart) (x, y int) {156- return m.colStart(c.At.Col), gridTop + c.At.Row - m.top161+func (g *grid) chartScreen(c sheet.Chart) (x, y int) {162+ return g.colStart(c.At.Col), gridTop + c.At.Row - g.top157163 }158164 159165 // chartAt returns the topmost chart drawn under x, y, or -1.
@@ -214,14 +220,14 @@ func (m *Model) chartBoxes() []box {
214220 // border with a resize handle in the corner.215221 func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {216222 w, h := chartInner(c)217- o := m.chartOptions()223+ o := m.term.chartOptions()218224 if i >= maxImages {219225 o.Image = false220226 }221227 g := chart.Draw(c.Type, m.sheet.ChartData(c), w, h, o)222- border := m.th.chartFrame228+ border := m.th.ChartFrame223229 if selected {224- border = m.th.chartSelected230+ border = m.th.ChartSelected225231 }226232 title := c.Title227233 if title == "" {
@@ -230,10 +236,10 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
230236 title = ansi.Truncate(" "+title+" ", c.W-4, "…")231237 footer := " " + c.Data.String() + " "232238 lines := make([]string, 0, c.H)233- lines = append(lines, border.Render("┌─")+m.th.title.Render(title)+239+ lines = append(lines, border.Render("┌─")+m.th.Title.Render(title)+234240 border.Render(strings.Repeat("─", max(c.W-3-ansi.StringWidth(title), 0))+"┐"))235241 for y := range h {236- lines = append(lines, border.Render("│")+" "+m.chartRow(g, y, firstImageID+i, o.Image)+" "+border.Render("│"))242+ lines = append(lines, border.Render("│")+" "+chartRow(&m.th, g, y, firstImageID+i, o.Image)+" "+border.Render("│"))237243 }238244 corner := "┘"239245 if selected {
@@ -243,14 +249,14 @@ func (m *Model) drawChart(i int, c sheet.Chart, selected bool) []string {
243249 if fill < 1 {244250 lines = append(lines, border.Render("└"+strings.Repeat("─", c.W-2)+corner))245251 } else {246- lines = append(lines, border.Render("└"+strings.Repeat("─", fill))+m.th.muted.Render(footer)+border.Render("─"+corner))252+ lines = append(lines, border.Render("└"+strings.Repeat("─", fill))+m.th.Muted.Render(footer)+border.Render("─"+corner))247253 }248254 return lines249255 }250256 251257 // chartRow renders row y of a drawn chart with the theme; with images,252258 // the plot area is the image's placeholders.253-func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {259+func chartRow(th *theme.Theme, g *chart.Grid, y, imageID int, image bool) string {254260 var b, run strings.Builder255261 var style lipgloss.Style256262 styled := false
@@ -272,15 +278,15 @@ func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {
272278 text := c.Text273279 switch {274280 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)281+ key, st = 1, th.ImageID(imageID)276282 text = chart.Placeholder(y-g.Plot.Min.Y, x-g.Plot.Min.X)277283 case c.Fg == chart.None && c.Bg == chart.None:278284 key, isStyled = 2, false279285 default:280286 key = 3 + int(c.Fg)*32 + int(c.Bg)281- st = m.chartRole(c.Fg)287+ st = chartRole(th, c.Fg)282288 if c.Bg >= chart.Series {283- st = st.Inherit(m.th.seriesBg[int(c.Bg-chart.Series)%chart.Colors])289+ st = st.Inherit(th.SeriesBg[int(c.Bg-chart.Series)%chart.Colors])284290 }285291 }286292 if key != prev {
@@ -293,18 +299,18 @@ func (m *Model) chartRow(g *chart.Grid, y, imageID int, image bool) string {
293299 return b.String()294300 }295301 296-func (m *Model) chartRole(r chart.Role) lipgloss.Style {302+func chartRole(th *theme.Theme, r chart.Role) lipgloss.Style {297303 switch {298304 case r == chart.Axis:299- return m.th.chartAxis305+ return th.ChartAxis300306 case r == chart.Label:301- return m.th.chartLabel307+ return th.ChartLabel302308 case r == chart.Muted:303- return m.th.muted309+ return th.Muted304310 case r >= chart.Series:305- return m.th.series[int(r-chart.Series)%chart.Colors]311+ return th.Series[int(r-chart.Series)%chart.Colors]306312 }307- return m.th.cell313+ return th.Cell308314 }309315 310316 // chartClick handles a press on the grid in READY: pressing a chart
@@ -329,444 +335,15 @@ var chartMenu = []menuItem{{cmd: "chart.edit"}, {cmd: "chart.delete"}}
329335 // selectChart selects chart i.330336 func (m *Model) selectChart(i int) {331337 m.clearSelection()332- m.lastChart = i338+ m.charts.last = i333339 m.openOverlay(&chartSel{i: i})334340 }335341 336342 // setShape sets the terminal's pointer shape, if it changed.337343 func (m *Model) setShape(shape string) tea.Cmd {338- if shape == m.shape {344+ if shape == m.mouse.shape {339345 return nil340346 }341- m.shape = shape347+ m.mouse.shape = shape342348 return tea.Raw(ansi.SetPointerShape(shape))343349 }344-345-// chartSel is a selected chart. Arrows move it a cell at a time, Shift346-// and arrows resize it, Enter edits it and Del deletes it; the mouse347-// drags it or its corner. Other keys deselect it and act as usual.348-type chartSel struct {349- i int350- drag int // chartDragNone, chartDragMove or chartDragResize351- grabX int // where the chart was grabbed, relative to its corner352- grabY int353- preview *sheet.Chart // the chart while being dragged354-}355-356-const (357- chartDragNone = iota358- chartDragMove359- chartDragResize360-)361-362-func (s *chartSel) indicator() string { return "CHART" }363-func (s *chartSel) layout(*Model) []box {364- return nil // drawn with the other charts, see chartBoxes365-}366-367-func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {368- charts := m.sheet.Charts()369- if s.i < 0 || s.i >= len(charts) {370- return sheet.Chart{}, false371- }372- return charts[s.i], true373-}374-375-func (s *chartSel) key(m *Model, k tea.KeyPressMsg) tea.Cmd {376- c, ok := s.chart(m)377- if !ok {378- m.closeOverlay()379- return nil380- }381- moved := c382- switch k.String() {383- case "esc":384- m.closeOverlay()385- return nil386- case "enter":387- m.openChartEditor(s.i, false, m.sheet.StateID())388- return nil389- case "delete", "backspace":390- m.deleteChart(s.i)391- return nil392- case "tab":393- m.selectChart((s.i + 1) % len(m.sheet.Charts()))394- return nil395- case "up":396- moved.At.Row--397- case "down":398- moved.At.Row++399- case "left":400- moved.At.Col--401- case "right":402- moved.At.Col++403- case "shift+up":404- moved.H--405- case "shift+down":406- moved.H++407- case "shift+left":408- moved.W -= 2409- case "shift+right":410- moved.W += 2411- default:412- m.closeOverlay()413- return m.handleKey(k)414- }415- label := "move chart"416- if moved.W != c.W || moved.H != c.H {417- label = "resize chart"418- }419- m.sheet.SetChart(s.i, moved, label)420- m.changed = true421- m.showChart(s.i)422- return nil423-}424-425-// press starts a drag at x, y: the corner resizes, anywhere else moves.426-func (s *chartSel) press(m *Model, x, y int) {427- c, ok := s.chart(m)428- if !ok {429- return430- }431- cx, cy := m.chartScreen(c)432- s.grabX, s.grabY = x-cx, y-cy433- s.drag = chartDragMove434- if x >= cx+c.W-2 && y == cy+c.H-1 {435- s.drag = chartDragResize436- }437- s.preview = &c438-}439-440-func (s *chartSel) mouse(m *Model, e mouseEvent) tea.Cmd {441- switch e.kind {442- case mousePress:443- i := m.chartAt(e.x, e.y)444- switch {445- case i < 0:446- m.closeOverlay()447- return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})448- case e.button == tea.MouseRight:449- m.selectChart(i)450- m.showContextMenu(chartMenu, e.x, e.y+1)451- case e.button == tea.MouseLeft:452- if i != s.i {453- m.selectChart(i)454- s = m.overlay.(*chartSel)455- }456- s.press(m, e.x, e.y)457- }458- case mouseMotion:459- if s.drag == chartDragNone || s.preview == nil {460- shape := "default"461- if i := m.chartAt(e.x, e.y); i >= 0 {462- shape = "move"463- if c := m.displayCharts()[i]; i == s.i {464- cx, cy := m.chartScreen(c)465- if e.x >= cx+c.W-2 && e.y == cy+c.H-1 {466- shape = "nwse-resize"467- }468- }469- }470- return m.setShape(shape)471- }472- p := *s.preview473- if s.drag == chartDragResize {474- cx, cy := m.chartScreen(p)475- p.W = clamp(e.x-cx+1, sheet.MinChartW, sheet.MaxChartW)476- p.H = clamp(e.y-cy+1, sheet.MinChartH, sheet.MaxChartH)477- } else {478- p.At = m.chartCellAt(e.x-s.grabX, e.y-s.grabY)479- }480- s.preview = &p481- case mouseRelease:482- if s.preview != nil && s.drag != chartDragNone {483- label := "move chart"484- if s.drag == chartDragResize {485- label = "resize chart"486- }487- before := m.sheet.StateID()488- m.sheet.SetChart(s.i, *s.preview, label)489- if m.sheet.StateID() != before {490- m.changed = true491- }492- }493- s.drag, s.preview = chartDragNone, nil494- case mouseWheel:495- m.handleWheel(tea.Mouse{X: e.x, Y: e.y, Button: e.button})496- }497- return nil498-}499-500-// chartCellAt returns the cell a chart's corner snaps to when dragged to501-// screen position x, y, which may be past the grid's edges.502-func (m *Model) chartCellAt(x, y int) sheet.Addr {503- a := sheet.Addr{Row: max(m.top+y-gridTop, 0), Col: m.left}504- switch {505- case x >= rowHdrW:506- if col, _, ok := m.colSpan(x); ok {507- a.Col = col508- } else {509- a.Col = m.left + m.visibleCols(m.left) - 1510- }511- default:512- for cx := rowHdrW; cx > x && a.Col > 0; {513- a.Col--514- cx -= m.sheet.ColWidth(a.Col)515- }516- }517- return clampAddr(a)518-}519-520-func (s *chartSel) status(m *Model) (string, string) {521- c, ok := s.chart(m)522- if !ok {523- return "", ""524- }525- desc := m.th.muted.Render("Chart of " + c.Data.String())526- pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}527- for {528- keys := m.keyHints(pairs...)529- switch {530- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:531- return desc, keys532- case desc != "":533- desc = ""534- case len(pairs) > 4:535- pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc536- default:537- return "", keys538- }539- }540-}541-542-// contextLine says what's selected and how to change it.543-func (s *chartSel) contextLine(m *Model) (string, string) {544- c, ok := s.chart(m)545- if !ok {546- return "", ""547- }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"), ""550-}551-552-// chartEditor is the chart editor: a bar on the context line, in the553-// spirit of the find bar, with the type as a row of chips and the options554-// as toggles. Changes show at once; Enter keeps them and Esc undoes them,555-// removing a chart that was just inserted.556-type chartEditor struct {557- i int558- start int // the sheet's state before editing, to undo back to559- isNew bool560-}561-562-// openChartEditor edits chart i. start is the sheet's state to return to563-// on Esc.564-func (m *Model) openChartEditor(i int, isNew bool, start int) {565- if i < 0 || i >= len(m.sheet.Charts()) {566- return567- }568- m.lastChart = i569- m.openOverlay(&chartEditor{i: i, start: start, isNew: isNew})570-}571-572-func (e *chartEditor) indicator() string { return "CHART" }573-func (e *chartEditor) layout(*Model) []box { return nil }574-575-func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }576-577-// set applies a change to the chart as its own undo step.578-func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {579- c := e.chart(m)580- fn(&c)581- m.sheet.SetChart(e.i, c, "edit chart")582- m.changed = m.sheet.StateID() != m.saved583-}584-585-// Editor keys, also the toggles' mouse targets.586-const (587- editorSwitch = "s"588- editorHeader = "h"589- editorLabels = "l"590- editorRange = "r"591- editorTitle = "t"592-)593-594-func (e *chartEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {595- if e.i >= len(m.sheet.Charts()) {596- m.closeOverlay()597- return nil598- }599- switch key := strings.ToLower(k.String()); key {600- case "enter":601- m.selectChart(e.i)602- if e.isNew {603- m.note = "Inserted a chart of " + e.chart(m).Data.String()604- }605- case "esc":606- for m.sheet.StateID() != e.start && m.sheet.CanUndo() {607- m.sheet.Undo()608- }609- m.changed = m.sheet.StateID() != m.saved610- m.closeOverlay()611- m.lastChart = -1612- case "left", "right", "shift+tab", "tab":613- d := 1614- if key == "left" || key == "shift+tab" {615- d = -1616- }617- e.set(m, func(c *sheet.Chart) {618- n := len(sheet.ChartTypes)619- c.Type = sheet.ChartTypes[((int(c.Type)+d)%n+n)%n]620- })621- case "1", "2", "3", "4":622- e.set(m, func(c *sheet.Chart) { c.Type = sheet.ChartTypes[key[0]-'1'] })623- case editorSwitch:624- e.set(m, func(c *sheet.Chart) { c.ByRow = !c.ByRow })625- case editorHeader:626- e.set(m, func(c *sheet.Chart) { c.Header = !c.Header })627- case editorLabels:628- e.set(m, func(c *sheet.Chart) { c.Labels = !c.Labels })629- case editorRange:630- e.editRange(m)631- case editorTitle:632- e.editTitle(m)633- }634- return nil635-}636-637-// reopen returns to the editor after a prompt.638-func (e *chartEditor) reopen(m *Model) {639- m.openOverlay(e)640-}641-642-// editRange asks for the data range by pointing, starting from the643-// current one.644-func (e *chartEditor) editRange(m *Model) {645- d := e.chart(m).Data646- m.closeOverlay()647- m.cur, m.ext, m.selecting, m.whole = d.From, d.To, true, wholeNone648- m.openRange("Chart data:", func(m *Model, r sheet.Rect) tea.Cmd {649- m.clearSelection()650- e.set(m, func(c *sheet.Chart) { c.Data = r })651- e.reopen(m)652- return nil653- })654- m.prompt.onCancel = func(m *Model) {655- m.clearSelection()656- e.reopen(m)657- }658-}659-660-func (e *chartEditor) editTitle(m *Model) {661- title := e.chart(m).Title662- m.closeOverlay()663- m.openText("Chart title:", title, func(m *Model, text string) tea.Cmd {664- e.set(m, func(c *sheet.Chart) { c.Title = text })665- e.reopen(m)666- return nil667- })668- m.prompt.onCancel = e.reopen669-}670-671-// editorPart is a piece of the editor bar: a type chip or a toggle.672-type editorPart struct {673- text string674- key string // the key it stands for675-}676-677-func (e *chartEditor) parts(m *Model) []editorPart {678- c := e.chart(m)679- chip := func(on bool, name, key string) editorPart {680- style := m.th.muted681- if on {682- style = m.th.menuSelected683- }684- return editorPart{text: style.Render(" " + name + " "), key: key}685- }686- var parts []editorPart687- for i, t := range sheet.ChartTypes {688- parts = append(parts, chip(t == c.Type, t.Title(), strconv.Itoa(i+1)))689- }690- series := "Series in columns"691- if c.ByRow {692- series = "Series in rows"693- }694- header, labels := "Header row", "Labels in column"695- if c.ByRow {696- header, labels = "Header column", "Labels in row"697- }698- return append(parts,699- editorPart{text: m.th.key.Render(c.Data.String()), key: editorRange},700- chip(true, series, editorSwitch),701- chip(c.Header, header, editorHeader),702- chip(c.Labels, labels, editorLabels))703-}704-705-// spans lays the parts out on the context line, dropping options from the706-// right on narrow screens, and returns each part's x.707-func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {708- parts := e.parts(m)709- for {710- xs := make([]int, len(parts))711- x := 0712- for i, p := range parts {713- if i == len(sheet.ChartTypes) {714- x += 2 // a wider gap between the types and the options715- }716- xs[i] = x717- x += ansi.StringWidth(p.text) + 1718- }719- if x-1 <= m.width || len(parts) <= len(sheet.ChartTypes) {720- return parts, xs721- }722- parts = parts[:len(parts)-1]723- }724-}725-726-func (e *chartEditor) contextLine(m *Model) (string, string) {727- if e.i >= len(m.sheet.Charts()) {728- return "", ""729- }730- parts, xs := e.spans(m)731- var b strings.Builder732- for i, p := range parts {733- b.WriteString(strings.Repeat(" ", xs[i]-ansi.StringWidth(b.String())))734- b.WriteString(p.text)735- }736- return b.String(), ""737-}738-739-func (e *chartEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {740- if ev.kind != mousePress {741- return nil742- }743- if ev.y != contextLine {744- // A click elsewhere keeps the changes and acts as usual.745- m.closeOverlay()746- return m.handlePress(tea.Mouse{X: ev.x, Y: ev.y, Button: ev.button})747- }748- parts, xs := e.spans(m)749- for i, p := range parts {750- if ev.x >= xs[i] && ev.x < xs[i]+ansi.StringWidth(p.text) {751- return e.key(m, tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})752- }753- }754- return nil755-}756-757-func (e *chartEditor) status(m *Model) (string, string) {758- pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}759- for {760- keys := m.keyHints(pairs...)761- if ansi.StringWidth(keys) <= m.width || len(pairs) <= 4 {762- return "", keys763- }764- pairs = append(pairs[:len(pairs)-6], pairs[len(pairs)-4:]...) // keep Enter and Esc765- }766-}767-768-// contextLiner is an overlay drawn on the context line, like the chart769-// editor.770-type contextLiner interface {771- contextLine(m *Model) (left, right string)772-}
M internal/ui/charts_test.go +4 -4
@@ -89,7 +89,7 @@ func TestChartEditorPrompts(t *testing.T) {
8989 if m.mode != modePrompt {9090 t.Fatalf("title prompt not open, mode %v", m.mode)9191 }92- m.buf, m.bufPos = []rune("Spend"), 592+ m.line.buf, m.line.pos = []rune("Spend"), 59393 press(t, m, "<enter>")9494 if c := m.sheet.Charts()[0]; c.Title != "Spend" || m.indicator() != "CHART" {9595 t.Errorf("title %q, indicator %q", c.Title, m.indicator())
@@ -190,7 +190,7 @@ func TestChartImages(t *testing.T) {
190190 // No kitty reply: text, and nothing sent.191191 m.runCommand("insert.chart")192192 press(t, m, "<enter>", "<esc>")193- if cmd := m.syncImages(); cmd != nil {193+ if cmd := m.term.syncImages(m.sheet, m.displayCharts, &m.th); cmd != nil {194194 t.Error("images sent without kitty graphics")195195 }196196 // The terminal answers the query: images, and placeholders in the view.
@@ -213,7 +213,7 @@ func TestChartImages(t *testing.T) {
213213 _, cmd = m.Update(nil)214214 raw = nil215215 m.term.sent[firstImageID] = "stale"216- collect(m.syncImages())216+ collect(m.term.syncImages(m.sheet, m.displayCharts, &m.th))217217 if len(raw) != 1 {218218 t.Errorf("changed chart not sent")219219 }
@@ -226,7 +226,7 @@ func TestChartImages(t *testing.T) {
226226 // Deleting the chart frees its image.227227 raw = nil228228 m.sheet.DeleteChart(0)229- collect(m.syncImages())229+ collect(m.term.syncImages(m.sheet, m.displayCharts, &m.th))230230 if len(raw) != 1 || !strings.Contains(raw[0], "a=d,d=I,i=16") {231231 t.Errorf("image not freed: %q", raw)232232 }
A internal/ui/chartsel.go +239 -0
@@ -0,0 +1,239 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+ "github.com/charmbracelet/x/ansi"6+7+ "github.com/FelineStateMachine/012/internal/sheet"8+)9+10+// chartSel is a selected chart. Arrows move it a cell at a time, Shift11+// and arrows resize it, Enter edits it and Del deletes it; the mouse12+// drags it or its corner. Other keys deselect it and act as usual.13+type chartSel struct {14+ i int15+ drag int // chartDragNone, chartDragMove or chartDragResize16+ grabX int // where the chart was grabbed, relative to its corner17+ grabY int18+ preview *sheet.Chart // the chart while being dragged19+}20+21+func (s *chartSel) indicator() string { return "CHART" }22+23+func (s *chartSel) layout(*Model) []box {24+ return nil // drawn with the other charts, see chartBoxes25+}26+27+func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {28+ charts := m.sheet.Charts()29+ if s.i < 0 || s.i >= len(charts) {30+ return sheet.Chart{}, false31+ }32+ return charts[s.i], true33+}34+35+func (s *chartSel) key(m *Model, k tea.KeyPressMsg) tea.Cmd {36+ c, ok := s.chart(m)37+ if !ok {38+ m.closeOverlay()39+ return nil40+ }41+ moved := c42+ switch k.String() {43+ case "esc":44+ m.closeOverlay()45+ return nil46+ case "enter":47+ return m.runCommand("chart.edit")48+ case "delete", "backspace":49+ return m.runCommand("chart.delete")50+ case "tab":51+ m.selectChart((s.i + 1) % len(m.sheet.Charts()))52+ return nil53+ case "up":54+ moved.At.Row--55+ case "down":56+ moved.At.Row++57+ case "left":58+ moved.At.Col--59+ case "right":60+ moved.At.Col++61+ case "shift+up":62+ moved.H--63+ case "shift+down":64+ moved.H++65+ case "shift+left":66+ moved.W -= 267+ case "shift+right":68+ moved.W += 269+ default:70+ m.closeOverlay()71+ return m.handleKey(k)72+ }73+ label := "move chart"74+ if moved.W != c.W || moved.H != c.H {75+ label = "resize chart"76+ }77+ m.sheet.SetChart(s.i, moved, label)78+ m.changed = true79+ m.showChart(s.i)80+ return nil81+}82+83+// press starts a drag at x, y: the corner resizes, anywhere else moves.84+func (s *chartSel) press(m *Model, x, y int) {85+ c, ok := s.chart(m)86+ if !ok {87+ return88+ }89+ cx, cy := m.chartScreen(c)90+ s.grabX, s.grabY = x-cx, y-cy91+ s.drag = chartDragMove92+ if x >= cx+c.W-2 && y == cy+c.H-1 {93+ s.drag = chartDragResize94+ }95+ s.preview = &c96+}97+98+func (s *chartSel) mouse(m *Model, e mouseEvent) tea.Cmd {99+ switch e.kind {100+ case mousePress:101+ return s.pressAt(m, e)102+ case mouseMotion:103+ return s.motion(m, e)104+ case mouseRelease:105+ s.release(m)106+ case mouseWheel:107+ m.handleWheel(tea.Mouse{X: e.x, Y: e.y, Button: e.button})108+ }109+ return nil110+}111+112+// pressAt selects the chart pressed and starts dragging it, or opens its113+// menu; pressing off the charts deselects and acts as usual.114+func (s *chartSel) pressAt(m *Model, e mouseEvent) tea.Cmd {115+ i := m.chartAt(e.x, e.y)116+ switch {117+ case i < 0:118+ m.closeOverlay()119+ return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})120+ case e.button == tea.MouseRight:121+ m.selectChart(i)122+ m.showContextMenu(chartMenu, e.x, e.y+1)123+ case e.button == tea.MouseLeft:124+ if i != s.i {125+ m.selectChart(i)126+ s = m.overlay.(*chartSel)127+ }128+ s.press(m, e.x, e.y)129+ }130+ return nil131+}132+133+// motion moves or resizes the chart being dragged, or else sets the134+// pointer shape for what's under the mouse.135+func (s *chartSel) motion(m *Model, e mouseEvent) tea.Cmd {136+ if s.drag == chartDragNone || s.preview == nil {137+ return m.setShape(s.hoverShape(m, e.x, e.y))138+ }139+ p := *s.preview140+ if s.drag == chartDragResize {141+ cx, cy := m.chartScreen(p)142+ p.W = clamp(e.x-cx+1, sheet.MinChartW, sheet.MaxChartW)143+ p.H = clamp(e.y-cy+1, sheet.MinChartH, sheet.MaxChartH)144+ } else {145+ p.At = m.chartCellAt(e.x-s.grabX, e.y-s.grabY)146+ }147+ s.preview = &p148+ return nil149+}150+151+// hoverShape is the pointer shape at x, y: a move cursor over charts,152+// and a resize one over the selected chart's corner.153+func (s *chartSel) hoverShape(m *Model, x, y int) string {154+ i := m.chartAt(x, y)155+ if i < 0 {156+ return "default"157+ }158+ if c := m.displayCharts()[i]; i == s.i {159+ cx, cy := m.chartScreen(c)160+ if x >= cx+c.W-2 && y == cy+c.H-1 {161+ return "nwse-resize"162+ }163+ }164+ return "move"165+}166+167+// release drops the chart being dragged where it is, as one undo step.168+func (s *chartSel) release(m *Model) {169+ if s.preview != nil && s.drag != chartDragNone {170+ label := "move chart"171+ if s.drag == chartDragResize {172+ label = "resize chart"173+ }174+ before := m.sheet.StateID()175+ m.sheet.SetChart(s.i, *s.preview, label)176+ if m.sheet.StateID() != before {177+ m.changed = true178+ }179+ }180+ s.drag, s.preview = chartDragNone, nil181+}182+183+// chartCellAt returns the cell a chart's corner snaps to when dragged to184+// screen position x, y, which may be past the grid's edges.185+func (g *grid) chartCellAt(x, y int) sheet.Addr {186+ a := sheet.Addr{Row: max(g.top+y-gridTop, 0), Col: g.left}187+ switch {188+ case x >= rowHdrW:189+ if col, _, ok := g.colSpan(x); ok {190+ a.Col = col191+ } else {192+ a.Col = g.left + g.visibleCols(g.left) - 1193+ }194+ default:195+ for cx := rowHdrW; cx > x && a.Col > 0; {196+ a.Col--197+ cx -= g.sheet.ColWidth(a.Col)198+ }199+ }200+ return clampAddr(a)201+}202+203+func (s *chartSel) status(m *Model) (string, string) {204+ c, ok := s.chart(m)205+ if !ok {206+ return "", ""207+ }208+ desc := m.th.Muted.Render("Chart of " + c.Data.String())209+ pairs := []string{"Enter", "edit", "Del", "delete", "Arrows", "move", "Shift+arrows", "resize", "Esc", "done"}210+ for {211+ keys := m.th.KeyHints(pairs...)212+ switch {213+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:214+ return desc, keys215+ case desc != "":216+ desc = ""217+ case len(pairs) > 4:218+ pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc219+ default:220+ return "", keys221+ }222+ }223+}224+225+// contextLine says what's selected and how to change it.226+func (s *chartSel) contextLine(m *Model) (string, string) {227+ c, ok := s.chart(m)228+ if !ok {229+ return "", ""230+ }231+ return m.th.Key.Render(c.Type.Title()+" chart") + m.th.Muted.Render(" of ") + c.Data.String() +232+ m.th.Muted.Render(" drag to move, drag the corner to resize"), ""233+}234+235+const (236+ chartDragNone = iota237+ chartDragMove238+ chartDragResize239+)
M internal/ui/clipboard.go +20 -20
@@ -46,12 +46,12 @@ func (m *Model) copy(cut bool) tea.Cmd {
4646 4747 // copyRange is the selection, trimmed to the data when whole rows or4848 // columns are selected, as there's no point copying thousands of blanks.49-func (m *Model) copyRange() sheet.Rect {50- r := m.selection()51- if m.whole == wholeNone {49+func (g *grid) copyRange() sheet.Rect {50+ r := g.selection()51+ if g.whole == wholeNone {5252 return r5353 }54- used, ok := m.sheet.UsedRange()54+ used, ok := g.sheet.UsedRange()5555 r.To = sheet.Addr{Col: min(r.To.Col, used.To.Col), Row: min(r.To.Row, used.To.Row)}5656 if !ok || r.To.Col < r.From.Col || r.To.Row < r.From.Row {5757 r.To = r.From
@@ -88,7 +88,7 @@ func (m *Model) paste(values bool) tea.Cmd {
8888 m.selectRect(r)8989 switch {9090 case c.cut && !values:91- m.note = "Moved " + m.clipLabel(c) + " to " + r.String()91+ m.note = "Moved " + c.label(m.sheet) + " to " + r.String()9292 case values:9393 m.note = "Pasted values into " + countCells(r) + " at " + r.String()9494 default:
@@ -97,27 +97,27 @@ func (m *Model) paste(values bool) tea.Cmd {
9797 return nil9898 }9999 100-// copyMarked reports whether a shows the copy marker.101-func (m *Model) copyMarked(a sheet.Addr) bool {102- return m.copied.marked && m.copied.sheet == m.sheet && m.copied.clip.Src.Contains(a)100+// marks reports whether a, on the sheet shown, shows the copy marker.101+func (c *clipboard) marks(shown *sheet.Sheet, a sheet.Addr) bool {102+ return c.marked && c.sheet == shown && c.clip.Src.Contains(a)103103 }104104 105-// clipLabel names the copied range, with its sheet when that isn't the105+// label names the copied range, with its sheet when that isn't the106106 // one shown: A1:B3, or Sheet1!A1:B3.107-func (m *Model) clipLabel(c clipboard) string {108- if c.sheet != m.sheet {107+func (c *clipboard) label(shown *sheet.Sheet) string {108+ if c.sheet != shown {109109 return sheet.Qualified(c.sheet.Name(), c.clip.Src)110110 }111111 return c.clip.Src.String()112112 }113113 114-// clearCopyMark hides the copy marker; a pending cut is cancelled, as in114+// clearMark hides the copy marker; a pending cut is cancelled, as in115115 // Sheets, since the cells it would move may have changed.116-func (m *Model) clearCopyMark() {117- if m.copied.cut {118- m.copied = clipboard{}116+func (c *clipboard) clearMark() {117+ if c.cut {118+ *c = clipboard{}119119 }120- m.copied.marked = false120+ c.marked = false121121 }122122 123123 // pasteText fills a block of cells from pasted text with tabs or line
@@ -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.copied.label(m.sheet) + " " + m.th.KeyHints("Ctrl+V", "move here", "Esc", "cancel")229229 case m.copied.marked:230- text := "Copied " + m.clipLabel(m.copied) + " "231- full := text + m.keyHints("Ctrl+V", "paste", "Ctrl+Shift+V", "paste values", "Esc", "clear")230+ text := "Copied " + m.copied.label(m.sheet) + " "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 }
A internal/ui/columns.go +80 -0
@@ -0,0 +1,80 @@
1+package ui2+3+import (4+ "strconv"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/charmbracelet/x/ansi"8+9+ "github.com/FelineStateMachine/012/internal/sheet"10+)11+12+// Column widths: typed on the context line with a live preview, dragged13+// by the header border (mouse.go), or fitted to the contents by14+// double-clicking it, as in Sheets.15+16+// openWidth asks for the width of the selected columns, previewing it17+// live as the arrows change it. Esc restores the original widths.18+func (m *Model) openWidth() tea.Cmd {19+ r := m.selection()20+ orig := map[int]int{}21+ for c := r.From.Col; c <= r.To.Col; c++ {22+ orig[c] = m.sheet.ColWidth(c)23+ }24+ restore := func(m *Model) {25+ for c, w := range orig {26+ m.sheet.SetColWidth(c, w)27+ }28+ }29+ m.openPrompt(&prompt{30+ kind: promptWidth,31+ label: "Column width (1-240):",32+ indicator: "WIDTH",33+ fresh: true,34+ onText: func(m *Model, text string) tea.Cmd {35+ w, err := strconv.Atoi(text)36+ if err != nil || w < 1 || w > 240 {37+ restore(m)38+ m.fail("Column width must be between 1 and 240")39+ return nil40+ }41+ m.setWidths(w)42+ m.changed = true43+ return nil44+ },45+ onCancel: restore,46+ }, strconv.Itoa(m.sheet.ColWidth(m.cur.Col)))47+ return nil48+}49+50+// setWidths sets the width of every selected column.51+func (m *Model) setWidths(w int) {52+ r := m.selection()53+ for c := r.From.Col; c <= r.To.Col; c++ {54+ m.sheet.SetColWidth(c, w)55+ }56+}57+58+// autofit sizes column c to its widest content, as double-clicking a59+// header border does in Sheets. Selected columns fit together.60+func (m *Model) autofit(c int) {61+ cols := []int{c}62+ if r := m.selection(); m.whole == wholeCols && c >= r.From.Col && c <= r.To.Col {63+ cols = cols[:0]64+ for k := r.From.Col; k <= r.To.Col; k++ {65+ cols = append(cols, k)66+ }67+ }68+ widest := map[int]int{}69+ for _, a := range m.sheet.Addrs() {70+ if cell := m.sheet.Cell(a); cell != nil {71+ widest[a.Col] = max(widest[a.Col], ansi.StringWidth(cell.Value.String()))72+ }73+ }74+ for _, k := range cols {75+ // One column of padding each side, and never narrower than the76+ // header letters.77+ m.sheet.SetColWidth(k, clamp(widest[k]+2, len(sheet.ColName(k))+2, 240))78+ }79+ m.changed = true80+}
M internal/ui/commands.go +6 -14
@@ -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,12 +101,14 @@ 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 }107108 108-// runCommand runs a registered command by ID.109+// runCommand runs a registered command by ID. It is the one place a110+// command runs, whatever reached it (a key, a menu, the palette, a click111+// on a tab or a chart), so a command log or macro recorder attaches here.109112 func (m *Model) runCommand(id string) tea.Cmd {110113 c, ok := commands[id]111114 if !ok {
@@ -132,7 +135,7 @@ func init() {
132135 }},133136 &command{id: "select.none", title: "Deselect", desc: "Collapse the selection and clear the copy marker", run: func(m *Model) tea.Cmd {134137 m.clearSelection()135- m.clearCopyMark()138+ m.copied.clearMark()136139 return nil137140 }},138141 &command{id: "select.all", title: "Select all", desc: "Select the data, then the whole sheet", run: (*Model).selectAll},
@@ -157,14 +160,3 @@ func init() {
157160 &command{id: "quit", title: "Quit", desc: "Close 012", run: (*Model).quit},158161 )159162 }160-161-// save writes to the current file, asking for a name the first time.162-func (m *Model) save() tea.Cmd {163- if m.filename == "" && m.xfer.source != "" {164- return m.saveImported()165- }166- if m.filename == "" {167- return m.openSave()168- }169- return saveCmd(m.sheet, m.filename)170-}
M internal/ui/context.go +0 -8
M internal/ui/data_test.go +32 -19
@@ -27,28 +27,16 @@ func TestFillHandleDrag(t *testing.T) {
2727 }2828 for _, tt := range tests {2929 t.Run(tt.name, func(t *testing.T) {30- m := newModel()31- for i, in := range tt.cells {32- m.sheet.Set(sheet.Addr{Row: i}, in)33- }34- m.sheet.ClearHistory()35- m.cur, m.ext = addr("A1"), sheet.Addr{Row: len(tt.cells) - 1}36- m.selecting = len(tt.cells) > 130+ m := fillModel(tt.cells)3731 y := gridTop + len(tt.cells) - 138- // Hovering the corner cell shows the handle.39- send(m, tea.MouseMotionMsg{X: cellX(0), Y: y})40- if !strings.Contains(line(m, y), "▟") {41- t.Fatalf("no handle: %q", line(m, y))42- }43- if h := m.hitTest(handleX(0), y); h.kind != hitFillHandle {44- t.Fatalf("hit %+v", h)45- }32+ hoverFillHandle(t, m, y)4633 send(m, tea.MouseClickMsg(leftAt(handleX(0), y)))47- send(m, tea.MouseMotionMsg(leftAt(cellX(tt.to.Col), gridTop+tt.to.Row)))34+ to := leftAt(cellX(tt.to.Col), gridTop+tt.to.Row)35+ send(m, tea.MouseMotionMsg(to))4836 if tt.sel != "A1" && !strings.Contains(line(m, contextLine), "Fill "+tt.sel) {4937 t.Errorf("context line %q", line(m, contextLine))5038 }51- send(m, tea.MouseReleaseMsg(leftAt(cellX(tt.to.Col), gridTop+tt.to.Row)))39+ send(m, tea.MouseReleaseMsg(to))5240 for a, in := range tt.want {5341 if got := input(m, a); got != in {5442 t.Errorf("%s = %q, want %q", a, got, in)
@@ -67,6 +55,31 @@ func TestFillHandleDrag(t *testing.T) {
6755 }6856 }6957 58+// fillModel has cells entered from A1 down and selected.59+func fillModel(cells []string) *Model {60+ m := newModel()61+ for i, in := range cells {62+ m.sheet.Set(sheet.Addr{Row: i}, in)63+ }64+ m.sheet.ClearHistory()65+ m.cur, m.ext = addr("A1"), sheet.Addr{Row: len(cells) - 1}66+ m.selecting = len(cells) > 167+ return m68+}69+70+// hoverFillHandle hovers the corner cell on line y and checks that the71+// fill handle shows there.72+func hoverFillHandle(t *testing.T, m *Model, y int) {73+ t.Helper()74+ send(m, tea.MouseMotionMsg{X: cellX(0), Y: y})75+ if !strings.Contains(line(m, y), "▟") {76+ t.Fatalf("no handle: %q", line(m, y))77+ }78+ if h := m.hitTest(handleX(0), y); h.kind != hitFillHandle {79+ t.Fatalf("hit %+v", h)80+ }81+}82+7083 func TestFillHandleEscCancels(t *testing.T) {7184 m := newModel()7285 press(t, m, "1", "<enter>", "<up>")
@@ -74,8 +87,8 @@ func TestFillHandleEscCancels(t *testing.T) {
7487 send(m, tea.MouseMotionMsg(leftAt(cellX(0), gridTop+4)))7588 press(t, m, "<esc>")7689 send(m, tea.MouseReleaseMsg(leftAt(cellX(0), gridTop+4)))77- if input(m, "A2") != "" || m.drag != dragNone {78- t.Errorf("A2 %q drag %v", input(m, "A2"), m.drag)90+ if input(m, "A2") != "" || m.mouse.drag != dragNone {91+ t.Errorf("A2 %q drag %v", input(m, "A2"), m.mouse.drag)7992 }8093 }8194
M internal/ui/dialog.go +4 -2
A internal/ui/download.go +170 -0
@@ -0,0 +1,170 @@
1+package ui2+3+import (4+ "context"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/fileio"13+ "github.com/FelineStateMachine/012/internal/sheet"14+)15+16+// File > Download: the workbook, or for formats with tables the selection,17+// written in the background in any format fileio exports, asking before18+// replacing a file or table.19+20+func init() {21+ for _, k := range fileio.Kinds() {22+ if !k.CanExport() {23+ continue24+ }25+ register(&command{26+ id: downloadID(k),27+ title: "Download as " + k.String(),28+ desc: k.About(),29+ run: func(m *Model) tea.Cmd { return m.openDownload(k) },30+ })31+ }32+}33+34+// downloadID is the command downloading in format k.35+func downloadID(k fileio.Kind) string { return "file.download." + strings.ToLower(k.String()) }36+37+// downloadItems is the File > Download menu: every format 012 exports.38+func downloadItems() []menuItem {39+ var items []menuItem40+ for _, k := range fileio.Kinds() {41+ if k.CanExport() {42+ items = append(items, menuItem{cmd: downloadID(k), title: k.MenuTitle()})43+ }44+ }45+ return items46+}47+48+type exportedMsg struct {49+ name string50+ kind fileio.Kind51+ res *fileio.ExportResult52+ err error53+}54+55+// displayBase is the name downloads and saves start from: the sheet's56+// file or the imported file, without its extension.57+func (m *Model) displayBase() string {58+ name := m.filename59+ if name == "" {60+ name = m.xfer.source61+ }62+ if name == "" {63+ return "SHEET1"64+ }65+ return strings.TrimSuffix(name, filepath.Ext(name))66+}67+68+// openDownload asks where to download the sheet as k.69+func (m *Model) openDownload(k fileio.Kind) tea.Cmd {70+ if _, ok := m.sheet.UsedRange(); !ok {71+ m.note = "Nothing to download: the sheet is empty"72+ return nil73+ }74+ r := sheet.Rect{}75+ label := "Download as " + k.String() + ":"76+ if k.HasTables() && m.hasRange() {77+ r = m.selection()78+ label = "Download " + r.String() + " as " + k.String() + ":"79+ }80+ m.openText(label, m.displayBase()+k.Ext(), func(m *Model, text string) tea.Cmd {81+ if text == "" {82+ return nil83+ }84+ name := text85+ if filepath.Ext(name) == "" {86+ name += k.Ext()87+ }88+ if k.HasTables() {89+ m.openTableName(name, k, r)90+ return nil91+ }92+ return m.confirmReplace(filepath.Base(name)+" exists.", func(m *Model) tea.Cmd {93+ return m.download(name, k, r, "")94+ }, exists(name))95+ })96+ return nil97+}98+99+func exists(name string) bool {100+ _, err := os.Stat(name)101+ return err == nil102+}103+104+// openTableName asks for the table to write in a database of kind k.105+func (m *Model) openTableName(name string, k fileio.Kind, r sheet.Rect) {106+ m.openText("Table in "+filepath.Base(name)+":", fileio.TableName(filepath.Base(m.displayBase())), func(m *Model, table string) tea.Cmd {107+ if table == "" {108+ return nil109+ }110+ has := false111+ if exists(name) {112+ ts, err := fileio.Tables(context.Background(), name)113+ if err != nil {114+ m.fail(fmt.Sprintf("Couldn't open %s: %v", filepath.Base(name), err))115+ return nil116+ }117+ for _, t := range ts {118+ has = has || strings.EqualFold(t.Name, table)119+ }120+ }121+ return m.confirmReplace("Table "+table+" exists in "+filepath.Base(name)+".", func(m *Model) tea.Cmd {122+ return m.download(name, k, r, table)123+ }, has)124+ })125+}126+127+// confirmReplace runs do, first asking on the context line when it128+// would replace something.129+func (m *Model) confirmReplace(msg string, do func(*Model) tea.Cmd, replaces bool) tea.Cmd {130+ if !replaces {131+ return do(m)132+ }133+ m.openOverlay(&choiceBar{134+ msg: msg,135+ warn: true,136+ choices: []choice{137+ {key: "enter", label: "Replace", run: do},138+ {key: "esc", label: "Cancel", run: func(*Model) tea.Cmd { return nil }},139+ },140+ })141+ return nil142+}143+144+// download snapshots the sheet and writes it in the background.145+func (m *Model) download(name string, k fileio.Kind, r sheet.Rect, table string) tea.Cmd {146+ snap := fileio.Snap(m.sheet, r, filepath.Base(m.displayBase()))147+ if k.HoldsSheets() && r == (sheet.Rect{}) {148+ snap = fileio.SnapBook(m.sheet) // every sheet, as Sheets' .xlsx download149+ }150+ return func() tea.Msg {151+ res, err := fileio.Export(context.Background(), name, k, snap, fileio.ExportOptions{Table: table})152+ return exportedMsg{name: name, kind: k, res: res, err: err}153+ }154+}155+156+// saveImported is Save for a sheet imported from another format: save it157+// as a .012 file (keeping formulas and formatting), or export it back.158+func (m *Model) saveImported() tea.Cmd {159+ k := m.xfer.kind160+ choices := []choice{{key: "enter", label: "Save as " + filepath.Base(m.displayBase()) + sheet.FileExt, run: (*Model).openSave}}161+ if k.CanExport() {162+ choices = append(choices, choice{key: "e", label: "Download as " + k.String(), run: func(m *Model) tea.Cmd { return m.openDownload(k) }})163+ }164+ choices = append(choices, choice{key: "esc", label: "Cancel", run: func(m *Model) tea.Cmd {165+ m.quitAfterSave = false166+ return nil167+ }})168+ m.openOverlay(&choiceBar{msg: filepath.Base(m.xfer.source) + " was imported.", choices: choices})169+ return nil170+}
M internal/ui/edit.go +16 -128
@@ -3,13 +3,12 @@ package ui
33 import (44 "errors"55 "fmt"6- "slices"7- "strings"86 "unicode/utf8"97 108 tea "charm.land/bubbletea/v2"119 1210 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/formula"1312 )1413 1514 // Undo and redo, fill, inserting and deleting rows and columns, and F4 in
@@ -114,23 +113,23 @@ func (m *Model) afterHistory(verb, none string, c sheet.Change, ok bool) {
114113 }115114 116115 // selectRect selects r, as whole columns or rows when it spans the sheet.117-func (m *Model) selectRect(r sheet.Rect) {116+func (g *grid) selectRect(r sheet.Rect) {118117 allRows := r.From.Row == 0 && r.To.Row == sheet.MaxRows-1119118 allCols := r.From.Col == 0 && r.To.Col == sheet.MaxCols-1120119 switch {121120 case allRows && allCols:122- m.selecting, m.whole, m.ext = true, wholeAll, m.cur121+ g.selecting, g.whole, g.ext = true, wholeAll, g.cur123122 case allRows:124- m.cur.Col = r.From.Col125- m.ext = sheet.Addr{Col: r.To.Col, Row: m.cur.Row}126- m.selecting, m.whole = true, wholeCols123+ g.cur.Col = r.From.Col124+ g.ext = sheet.Addr{Col: r.To.Col, Row: g.cur.Row}125+ g.selecting, g.whole = true, wholeCols127126 case allCols:128- m.cur.Row = r.From.Row129- m.ext = sheet.Addr{Col: m.cur.Col, Row: r.To.Row}130- m.selecting, m.whole = true, wholeRows127+ g.cur.Row = r.From.Row128+ g.ext = sheet.Addr{Col: g.cur.Col, Row: r.To.Row}129+ g.selecting, g.whole = true, wholeRows131130 default:132- m.cur, m.ext = r.From, r.To133- m.selecting, m.whole = r.From != r.To, wholeNone131+ g.cur, g.ext = r.From, r.To132+ g.selecting, g.whole = r.From != r.To, wholeNone134133 }135134 }136135
@@ -152,7 +151,7 @@ func (m *Model) structural(err error) tea.Cmd {
152151 // fillEntry stores the entry being typed in every selected cell, adjusting153152 // references as if it were copied from the active cell (Ctrl+Enter).154153 func (m *Model) fillEntry() bool {155- input := string(m.buf)154+ input := m.line.text()156155 if err := m.entrySheet().FillEntry(m.selection(), m.cur, input); err != nil {157156 m.entryError(err, input)158157 return false
@@ -166,131 +165,20 @@ func (m *Model) fillEntry() bool {
166165 func (m *Model) entryError(err error, input string) {167166 var pe *sheet.ParseError168167 m.mode = modeEdit169- m.hint = err.Error()168+ m.entry.hint = err.Error()170169 if errors.As(err, &pe) {171- m.bufPos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])170+ m.line.pos = utf8.RuneCountInString(input[:min(pe.Pos, len(input))])172171 }173172 }174173 175174 // toggleAbsolute cycles the reference at the caret through A1, $A$1, A$1176175 // and $A1, as F4 does in Sheets. A range cycles both corners.177176 func (m *Model) toggleAbsolute() {178- if buf, pos, ok := cycleRef(m.buf, m.bufPos); ok {179- m.buf, m.bufPos = buf, pos177+ if buf, pos, ok := formula.CycleRef(m.line.buf, m.line.pos); ok {178+ m.line.buf, m.line.pos = buf, pos180179 }181180 }182181 183-// cycleRef finds the reference (or range) at or just before pos in a184-// formula and returns the text with its absolute markers cycled, and the185-// caret moved to the reference's end.186-func cycleRef(buf []rune, pos int) ([]rune, int, bool) {187- quoted := make([]bool, len(buf))188- in := false189- for i, r := range buf {190- if r == '"' {191- in = !in192- }193- quoted[i] = in || r == '"'194- }195- isRefRune := func(i int) bool {196- r := buf[i]197- return !quoted[i] && (r == '$' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z')198- }199- // word returns the bounds of the run of reference characters around i.200- word := func(i int) (int, int) {201- start, end := i, i202- for start > 0 && isRefRune(start-1) {203- start--204- }205- for end < len(buf) && isRefRune(end) {206- end++207- }208- return start, end209- }210- isRef := func(start, end int) bool {211- if start == end || start > 0 && buf[start-1] == '@' || end < len(buf) && buf[end] == '(' {212- return false213- }214- _, ok := sheet.ParseAddr(string(buf[start:end]))215- return ok216- }217- start, end := word(pos)218- if !isRef(start, end) {219- return nil, 0, false220- }221- // Widen to a range written A1:B2 or A1..B2.222- sep := func(i int) int {223- switch {224- case i < len(buf) && buf[i] == ':':225- return 1226- case i+1 < len(buf) && buf[i] == '.' && buf[i+1] == '.':227- return 2228- }229- return 0230- }231- corners := [][2]int{{start, end}}232- if n := sep(end); n > 0 {233- if s2, e2 := word(end + n); s2 == end+n && isRef(s2, e2) {234- corners = append(corners, [2]int{s2, e2})235- }236- } else {237- for n := 1; n <= 2 && start-n > 0; n++ {238- if sep(start-n) == n {239- if s0, e0 := word(start - n - 1); e0 == start-n && isRef(s0, e0) {240- corners = [][2]int{{s0, e0}, {start, end}}241- }242- break243- }244- }245- }246- next := nextMarkers(string(buf[corners[0][0]:corners[0][1]]))247- out := slices.Clone(buf[:corners[0][0]])248- for i, c := range corners {249- if i > 0 {250- out = append(out, buf[corners[i-1][1]:c[0]]...)251- }252- out = append(out, []rune(withMarkers(string(buf[c[0]:c[1]]), next))...)253- }254- caret := len(out)255- out = append(out, buf[corners[len(corners)-1][1]:]...)256- return out, caret, true257-}258-259-// nextMarkers returns the absolute markers that follow ref's in F4's260-// cycle: A1 -> $A$1 -> A$1 -> $A1 -> A1.261-func nextMarkers(ref string) markers {262- col := strings.HasPrefix(ref, "$")263- row := strings.Contains(ref[1:], "$")264- switch {265- case !col && !row:266- return markers{true, true}267- case col && row:268- return markers{false, true}269- case row:270- return markers{true, false}271- }272- return markers{}273-}274-275-// markers says which parts of a reference are absolute.276-type markers struct{ col, row bool }277-278-// withMarkers rewrites ref with the given absolute markers.279-func withMarkers(ref string, marks markers) string {280- ref = strings.ToUpper(strings.ReplaceAll(ref, "$", ""))281- i := strings.IndexAny(ref, "0123456789")282- var b strings.Builder283- if marks.col {284- b.WriteByte('$')285- }286- b.WriteString(ref[:i])287- if marks.row {288- b.WriteByte('$')289- }290- b.WriteString(ref[i:])291- return b.String()292-}293-294182 // countCells describes a paste for the context line.295183 func countCells(r sheet.Rect) string {296184 n := (r.To.Col - r.From.Col + 1) * (r.To.Row - r.From.Row + 1)
M internal/ui/edit_test.go +8 -35
@@ -85,8 +85,8 @@ func TestCopyPaste(t *testing.T) {
8585 if got := clipboardText(t, m, "edit.copy"); got != "1\t2" {8686 t.Errorf("system clipboard %q", got)8787 }88- if l := line(m, 2); !m.copyMarked(addr("B1")) || !strings.HasPrefix(l, "Copied A1:B1") || !strings.Contains(l, "Ctrl+V") {89- t.Fatalf("marker %v, line %q", m.copyMarked(addr("B1")), line(m, 2))88+ if l := line(m, 2); !m.copied.marks(m.sheet, addr("B1")) || !strings.HasPrefix(l, "Copied A1:B1") || !strings.Contains(l, "Ctrl+V") {89+ t.Fatalf("marker %v, line %q", m.copied.marks(m.sheet, addr("B1")), line(m, 2))9090 }9191 press(t, m, "<down>", "<ctrl+v>")9292 if input(m, "B2") != "=A2*2" || m.sheet.Value(addr("B2")).Num != 2 || m.selection().String() != "A2:B2" {
@@ -171,8 +171,8 @@ func TestPasteTSVFromTerminal(t *testing.T) {
171171 }172172 // A single line still starts an entry.173173 send(m, tea.PasteMsg{Content: "hello\n"})174- if m.mode != modeEnter || string(m.buf) != "hello" {175- t.Errorf("mode %v buf %q", m.mode, string(m.buf))174+ if m.mode != modeEnter || m.line.text() != "hello" {175+ t.Errorf("mode %v buf %q", m.mode, m.line.text())176176 }177177 }178178
@@ -254,41 +254,14 @@ func TestF4CyclesReferences(t *testing.T) {
254254 press(t, m, "=A1+B2")255255 for _, want := range []string{"=A1+$B$2", "=A1+B$2", "=A1+$B2", "=A1+B2"} {256256 press(t, m, "<f4>")257- if string(m.buf) != want {258- t.Errorf("F4 gave %q, want %q", string(m.buf), want)257+ if m.line.text() != want {258+ t.Errorf("F4 gave %q, want %q", m.line.text(), want)259259 }260260 }261261 // F4 while pointing puts the reference in first.262262 press(t, m, "*", "<up>", "<f4>")263- if string(m.buf) != "=A1+B2*$A$1" || m.mode != modeEnter {264- t.Errorf("F4 in POINT: %q mode %v", string(m.buf), m.mode)265- }266-}267-268-func TestCycleRef(t *testing.T) {269- tests := []struct {270- in string271- caret int272- want string273- ok bool274- }{275- {"=a1", 3, "=$A$1", true},276- {"=A1+1", 1, "=$A$1+1", true},277- {"=SUM(A1:B2)", 10, "=SUM($A$1:$B$2)", true},278- {"=SUM(A1:B2)", 7, "=SUM($A$1:$B$2)", true},279- {"=SUM($A$1..B2)", 12, "=SUM(A$1..B$2)", true},280- {`="A1"`, 3, "", false},281- {"=LOG10(2)", 6, "", false},282- {"=1+2", 2, "", false},283- }284- for _, tt := range tests {285- out, pos, ok := cycleRef([]rune(tt.in), tt.caret)286- if ok != tt.ok || string(out) != tt.want {287- t.Errorf("cycleRef(%q, %d) = %q, %v; want %q", tt.in, tt.caret, string(out), ok, tt.want)288- }289- if ok && pos > len(out) {290- t.Errorf("caret %d past end of %q", pos, string(out))291- }263+ if m.line.text() != "=A1+B2*$A$1" || m.mode != modeEnter {264+ t.Errorf("F4 in POINT: %q mode %v", m.line.text(), m.mode)292265 }293266 }294267
M internal/ui/entry.go +48 -80
@@ -1,9 +1,7 @@
11 package ui22 33 import (4- "slices"54 "strings"6- "unicode"75 "unicode/utf8"86 97 tea "charm.land/bubbletea/v2"
@@ -17,33 +15,49 @@ import (
1715 // operator point at cells (POINT) and insert their references. The1816 // selection stays while typing, so Ctrl+Enter can fill it.1917 18+// entry is the state of typing into a cell, beyond the text itself (in19+// Model.line) and the cell or range pointed at (Model.point).20+type entry struct {21+ hint string // shown on the context line, e.g. a formula error22+23+ // While pointing, the entry's text before and after the reference24+ // being pointed at.25+ prefix, suffix string26+27+ // home is the entry's sheet while the pointer is on another one; see28+ // tabs.go.29+ home *sheet.Sheet30+31+ assist assist // function and name suggestions: assist.go32+33+ // tabStart remembers where a run of Tab-committed entries began, so34+ // Enter returns to that column on the next row, as in Sheets.35+ tabStart int36+ tabbing bool37+}38+2039 func (m *Model) startEntry(md mode, text string) {2140 m.mode = md22- m.home = nil23- m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}24- m.insert(text)41+ m.entry.home = nil42+ m.line.clear()43+ m.entry.hint, m.entry.assist = "", assist{}44+ m.line.insert(text)2545 }2646 2747 // startEdit edits the active cell's contents with the caret at the end.2848 func (m *Model) startEdit() tea.Cmd {2949 m.startEntry(modeEdit, "")3050 if c := m.sheet.Cell(m.cur); c != nil {31- m.buf = []rune(c.Input)32- m.bufPos = len(m.buf)51+ m.line.set(c.Input)3352 }3453 return nil3554 }3655 37-// isFormula reports whether the edit line holds a formula.38-func (m *Model) isFormula() bool {39- return len(m.buf) > 0 && (m.buf[0] == '=' || m.buf[0] == '+' || m.buf[0] == '-')40-}41-4256 // enterKey handles ENTER mode. Arrow keys accept the entry and move, except4357 // in a formula where a reference may follow the caret: then they point.4458 func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {4559 key := k.String()46- if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {60+ if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {4761 return nil4862 }4963 if key == "f2" {
@@ -54,10 +68,10 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
5468 m.toggleAbsolute()5569 return nil5670 }57- if m.isFormula() && m.canPoint() && m.startPoint(key) {71+ if m.line.isFormula() && m.line.canPoint() && m.startPoint(key) {5872 return nil5973 }60- if m.isFormula() && (key == "left" || key == "right") {74+ if m.line.isFormula() && (key == "left" || key == "right") {6175 m.typeKey(k) // move the caret within a formula6276 return nil6377 }
@@ -74,10 +88,10 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
7488 // editKey handles EDIT mode, where left and right move the caret.7589 func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {7690 key := k.String()77- if m.assistKey(key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {91+ if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {7892 return nil7993 }80- if m.isFormula() && m.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {94+ if m.line.isFormula() && m.line.canPoint() && strings.HasPrefix(key, "shift+") && m.startPoint(key) {8195 return nil8296 }8397 switch key {
@@ -110,16 +124,16 @@ func (m *Model) commitKey(key string) bool {
110124 }111125 switch key {112126 case "enter":113- if m.tabbing {114- m.cur.Col = m.tabStart115- m.tabbing = false127+ if m.entry.tabbing {128+ m.cur.Col = m.entry.tabStart129+ m.entry.tabbing = false116130 }117131 m.cur.Row++118132 case "shift+enter":119133 m.cur.Row--120134 case "tab":121- if !m.tabbing {122- m.tabStart, m.tabbing = m.cur.Col, true135+ if !m.entry.tabbing {136+ m.entry.tabStart, m.entry.tabbing = m.cur.Col, true123137 }124138 m.cur.Col++125139 case "shift+tab":
@@ -137,40 +151,6 @@ func (m *Model) cancelKey(key string) bool {
137151 return true138152 }139153 140-// lineKey applies line-editing keys to buf.141-func (m *Model) lineKey(k tea.KeyPressMsg) {142- switch k.String() {143- case "left":144- m.bufPos = max(m.bufPos-1, 0)145- case "right":146- m.bufPos = min(m.bufPos+1, len(m.buf))147- case "home", "ctrl+a":148- m.bufPos = 0149- case "end", "ctrl+e":150- m.bufPos = len(m.buf)151- case "backspace":152- if m.bufPos > 0 {153- m.buf = slices.Delete(m.buf, m.bufPos-1, m.bufPos)154- m.bufPos--155- }156- case "delete":157- if m.bufPos < len(m.buf) {158- m.buf = slices.Delete(m.buf, m.bufPos, m.bufPos+1)159- }160- default:161- m.insert(typed(k))162- }163-}164-165-// canPoint reports whether the text before the caret ends where a cell166-// reference may follow.167-func (m *Model) canPoint() bool {168- if m.bufPos == 0 {169- return false170- }171- return strings.ContainsRune("=+-*/^(,;<>&:", m.buf[m.bufPos-1])172-}173-174154 // startPoint enters POINT mode if key moves (Shift extends). The pointer175155 // starts at the active cell, or on another sheet where it last pointed.176156 func (m *Model) startPoint(key string) bool {
@@ -186,8 +166,8 @@ func (m *Model) startPoint(key string) bool {
186166 } else {187167 return false188168 }189- m.pointPrefix = string(m.buf[:m.bufPos])190- m.pointSuffix = string(m.buf[m.bufPos:])169+ m.entry.prefix = m.line.head()170+ m.entry.suffix = m.line.tail()191171 m.mode = modePoint192172 m.navigate(key, &m.point.at)193173 return true
@@ -221,8 +201,8 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
221201 }222202 if text := typed(k); text != "" {223203 m.resumeEntry(m.pointRef())224- m.insert(text)225- m.assist = assist{active: true}204+ m.line.insert(text)205+ m.entry.assist = assist{active: true}226206 }227207 return nil228208 }
@@ -230,14 +210,14 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
230210 // resumeEntry leaves POINT mode, inserting ref at the caret.231211 func (m *Model) resumeEntry(ref string) {232212 m.mode = modeEnter233- m.buf = []rune(m.pointPrefix + ref + m.pointSuffix)234- m.bufPos = utf8.RuneCountInString(m.pointPrefix + ref)213+ m.line.buf = []rune(m.entry.prefix + ref + m.entry.suffix)214+ m.line.pos = utf8.RuneCountInString(m.entry.prefix + ref)235215 }236216 237217 // commit stores the edit line in the active cell. An invalid formula stays238218 // in EDIT mode with the caret at the problem and the reason on line 3.239219 func (m *Model) commit() bool {240- input := string(m.buf)220+ input := m.line.text()241221 if err := m.set(m.cur, input); err != nil {242222 m.entryError(err, input)243223 return false
@@ -260,20 +240,8 @@ func (m *Model) set(a sheet.Addr, input string) error {
260240 func (m *Model) cancelEntry() {261241 m.returnHome()262242 m.mode = modeReady263- m.buf, m.bufPos, m.hint, m.assist = nil, 0, "", assist{}264-}265-266-func (m *Model) insert(text string) {267- for _, r := range text {268- if r == '\n' || r == '\r' || r == '\t' {269- r = ' '270- }271- if !unicode.IsPrint(r) {272- continue273- }274- m.buf = slices.Insert(m.buf, m.bufPos, r)275- m.bufPos++276- }243+ m.line.clear()244+ m.entry.hint, m.entry.assist = "", assist{}277245 }278246 279247 func (m *Model) handlePaste(content string) {
@@ -286,14 +254,14 @@ func (m *Model) handlePaste(content string) {
286254 m.startEntry(modeEnter, content)287255 }288256 case modeEnter, modeEdit:289- m.insert(content)257+ m.line.insert(content)290258 case modePrompt:291259 if m.prompt.kind != promptRange {292- m.promptType(content)260+ m.prompt.paste(m, content)293261 }294262 case modeMenu:295263 if o, ok := m.overlay.(textOverlay); ok {296- m.insert(content)264+ m.line.insert(content)297265 o.changed(m)298266 }299267 }
A internal/ui/file.go +155 -0
@@ -0,0 +1,155 @@
1+package ui2+3+import (4+ "fmt"5+ "log/slog"6+ "os"7+ "path/filepath"8+ "slices"9+ "strings"10+11+ tea "charm.land/bubbletea/v2"12+13+ "github.com/FelineStateMachine/012/internal/fileio"14+ "github.com/FelineStateMachine/012/internal/sheet"15+ "github.com/FelineStateMachine/012/internal/telemetry"16+)17+18+// Saving and opening .012 files. Files are written and read in the19+// background; other formats go through transfer.go.20+21+func (m *Model) openSave() tea.Cmd {22+ name := m.filename23+ if name == "" {24+ name = m.displayBase() + sheet.FileExt25+ }26+ m.openText("Save as:", name, func(m *Model, text string) tea.Cmd {27+ return saveCmd(m.sheet, withExt(text))28+ })29+ return nil30+}31+32+func (m *Model) openRetrieve() tea.Cmd {33+ m.openText("Open file:", "", func(m *Model, text string) tea.Cmd {34+ if _, ok := fileio.KindOf(text); ok {35+ return m.confirmImport(text, fileio.Options{})36+ }37+ return loadCmd(withExt(text))38+ })39+ return listFilesCmd40+}41+42+// reset starts over on s, as File > New and Open do. What belongs to the43+// session rather than the sheet carries over: the window, theme, terminal44+// state and the JEV connection.45+func (m *Model) reset(s *sheet.Sheet, filename string) {46+ *m = Model{grid: grid{sheet: s, width: m.width, height: m.height}, filename: filename, th: m.th, term: m.term, jev: m.jev, charts: chartState{last: -1}}47+}48+49+func withExt(name string) string {50+ if filepath.Ext(name) == "" {51+ return name + sheet.FileExt52+ }53+ return name54+}55+56+type savedMsg struct {57+ name string58+ err error59+}60+61+type loadedMsg struct {62+ name string63+ sheet *sheet.Sheet64+ err error65+}66+67+type filesMsg []string68+69+// saveCmd writes the worksheet atomically: to a temporary file first, then70+// renamed over the target.71+func saveCmd(s *sheet.Sheet, name string) tea.Cmd {72+ span := telemetry.Start("save")73+ var buf strings.Builder74+ err := s.Write(&buf)75+ if telemetry.Enabled() {76+ span.End(slog.Int("cells", s.Len()), slog.Int("bytes", buf.Len()))77+ }78+ return func() tea.Msg {79+ if err != nil {80+ return savedMsg{name, err}81+ }82+ tmp := name + ".tmp"83+ if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {84+ return savedMsg{name, err}85+ }86+ return savedMsg{name, os.Rename(tmp, name)}87+ }88+}89+90+func loadCmd(name string) tea.Cmd {91+ return func() tea.Msg {92+ f, err := os.Open(name)93+ if err != nil {94+ return loadedMsg{name: name, err: err}95+ }96+ defer f.Close()97+ span := telemetry.Start("open")98+ s, err := sheet.Read(f)99+ if err != nil {100+ span.Fail(err)101+ } else if telemetry.Enabled() {102+ span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", openSize(f)))103+ }104+ return loadedMsg{name, s, err}105+ }106+}107+108+// openSize is the size of an open file, for telemetry; -1 if unknown.109+func openSize(f *os.File) int64 {110+ st, err := f.Stat()111+ if err != nil {112+ return -1113+ }114+ return st.Size()115+}116+117+func listFilesCmd() tea.Msg {118+ files, _ := filepath.Glob("*" + sheet.FileExt)119+ slices.Sort(files)120+ return filesMsg(files)121+}122+123+func (m *Model) handleSaved(msg savedMsg) tea.Cmd {124+ quit := m.quitAfterSave125+ m.quitAfterSave = false126+ if msg.err != nil {127+ m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))128+ return nil129+ }130+ m.filename = msg.name131+ m.changed = false132+ if quit {133+ return m.exit()134+ }135+ return nil136+}137+138+func (m *Model) handleLoaded(msg loadedMsg) {139+ if msg.err != nil {140+ m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))141+ return142+ }143+ m.reset(msg.sheet, msg.name)144+}145+146+// save writes to the current file, asking for a name the first time.147+func (m *Model) save() tea.Cmd {148+ if m.filename == "" && m.xfer.source != "" {149+ return m.saveImported()150+ }151+ if m.filename == "" {152+ return m.openSave()153+ }154+ return saveCmd(m.sheet, m.filename)155+}
M internal/ui/fill.go +17 -17
@@ -16,11 +16,11 @@ import (
1616 1717 // fillCorner is the cell with the fill handle: the bottom-right corner of1818 // the selection. Whole rows and columns have none.19-func (m *Model) fillCorner() sheet.Addr {20- if m.whole != wholeNone {19+func (g *grid) fillCorner() sheet.Addr {20+ if g.whole != wholeNone {2121 return sheet.Addr{Col: -1, Row: -1}2222 }23- return m.selection().To23+ return g.selection().To2424 }2525 2626 // showFillHandle reports whether the fill handle is drawn in cell a:
@@ -30,24 +30,24 @@ func (m *Model) showFillHandle(a sheet.Addr) bool {
3030 return false3131 }3232 switch {33- case m.drag == dragFill:33+ case m.mouse.drag == dragFill:3434 return true35- case m.drag != dragNone:35+ case m.mouse.drag != dragNone:3636 return false3737 }38- return (m.hover.kind == hitCell || m.hover.kind == hitFillHandle) && m.hover.addr == a38+ return (m.mouse.hover.kind == hitCell || m.mouse.hover.kind == hitFillHandle) && m.mouse.hover.addr == a3939 }4040 4141 // startFill starts dragging the fill handle.4242 func (m *Model) startFill() {43- m.drag = dragFill44- m.fillAt, m.fillTo = m.fillCorner(), m.selection()43+ m.mouse.drag = dragFill44+ m.mouse.fillAt, m.mouse.fillTo = m.fillCorner(), m.selection()4545 }4646 4747 // dragFillTo points the fill at cell a: the selection grows along the4848 // axis a is further out on, as in Sheets.4949 func (m *Model) dragFillTo(a sheet.Addr) {50- m.fillAt = a50+ m.mouse.fillAt = a5151 src := m.selection()5252 down, up := a.Row-src.To.Row, src.From.Row-a.Row5353 right, left := a.Col-src.To.Col, src.From.Col-a.Col
@@ -65,13 +65,13 @@ func (m *Model) dragFillTo(a sheet.Addr) {
6565 default:6666 r.From.Col = a.Col6767 }68- m.fillTo = r68+ m.mouse.fillTo = r6969 }7070 7171 // finishFill fills the range the handle was dragged over and selects it.7272 func (m *Model) finishFill() {73- m.drag = dragNone74- src, dst := m.selection(), m.fillTo73+ m.mouse.drag = dragNone74+ src, dst := m.selection(), m.mouse.fillTo7575 if dst == src {7676 return7777 }
@@ -88,14 +88,14 @@ func (m *Model) finishFill() {
8888 8989 // cancelFill stops a fill handle drag without filling.9090 func (m *Model) cancelFill() {91- m.drag, m.autoscrolling = dragNone, false92- m.fillTo = m.selection()91+ m.mouse.drag, m.mouse.autoscrolling = dragNone, false92+ m.mouse.fillTo = m.selection()9393 }9494 9595 // fillLine is the context line while dragging the fill handle.9696 func (m *Model) fillLine() string {97- 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")97+ if m.mouse.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.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.mouse.fillTo.String()) + m.th.Muted.Render(" release to fill ") + m.th.KeyHints("Esc", "cancel")101101 }
M internal/ui/filter.go +41 -41
@@ -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,19 +51,19 @@ 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 5758 // dataRange is the range data commands act on: the selection when there5859 // is one, cut to the data in it, or else the block of data around the5960 // active cell.60-func (m *Model) dataRange() sheet.Rect {61- if !m.hasRange() {62- return m.sheet.Region(m.cur)61+func (g *grid) dataRange() sheet.Rect {62+ if !g.hasRange() {63+ return g.sheet.Region(g.cur)6364 }64- r := m.selection()65- if used, ok := m.sheet.UsedRange(); ok {65+ r := g.selection()66+ if used, ok := g.sheet.UsedRange(); ok {6667 r.To.Row = max(min(r.To.Row, used.To.Row), r.From.Row)6768 r.To.Col = max(min(r.To.Col, used.To.Col), r.From.Col)6869 }
@@ -71,26 +72,26 @@ func (m *Model) dataRange() sheet.Rect {
7172 7273 // filterMark is the filter button in column c's header, if c is in the7374 // filter's range, and whether the column's filter hides anything.74-func (m *Model) filterMark(c int) (string, bool) {75- r, on := m.sheet.FilterRange()75+func (g *grid) filterMark(c int) (string, bool) {76+ r, on := g.sheet.FilterRange()7677 switch {7778 case !on || c < r.From.Col || c > r.To.Col:7879 return "", false79- case m.sheet.ColumnFiltered(c):80+ case g.sheet.ColumnFiltered(c):8081 return "▼", true8182 }8283 return "▾", false8384 }8485 8586 // filterButtonX is the screen x of column c's filter button, or -1.86-func (m *Model) filterButtonX(c int) int {87+func (g *grid) filterButtonX(c int) int {8788 name := sheet.ColName(c)88- w := m.sheet.ColWidth(c)89- if mark, _ := m.filterMark(c); mark == "" || w < len(name)+3 {89+ w := g.sheet.ColWidth(c)90+ if mark, _ := g.filterMark(c); mark == "" || w < len(name)+3 {9091 return -19192 }9293 lw := len(name) + 293- return m.colStart(c) + (w-lw)/2 + lw - 194+ return g.colStart(c) + (w-lw)/2 + lw - 19495 }9596 9697 // filterPicker is the values list and condition for one column.
@@ -124,7 +125,7 @@ func (m *Model) openFilterPicker(col int) {
124125 }125126 p.fields[1] = p.cond.Arg126127 m.openOverlay(p)127- m.buf, m.bufPos = nil, 0128+ m.line.clear()128129 p.search()129130 }130131
@@ -132,14 +133,13 @@ func (p *filterPicker) indicator() string { return "FILTER" }
132133 133134 // focus moves editing to field i, keeping the other field's text.134135 func (p *filterPicker) focus(m *Model, i int) {135- p.fields[p.field] = string(m.buf)136+ p.fields[p.field] = m.line.text()136137 p.field = i137- m.buf = []rune(p.fields[i])138- m.bufPos = len(m.buf)138+ m.line.set(p.fields[i])139139 }140140 141141 func (p *filterPicker) changed(m *Model) {142- p.fields[p.field] = string(m.buf)142+ p.fields[p.field] = m.line.text()143143 if p.field == 0 {144144 p.search()145145 }
@@ -204,7 +204,7 @@ func (p *filterPicker) cycle(d int) {
204204 205205 // apply sets the column's criteria and closes the picker.206206 func (p *filterPicker) apply(m *Model) {207- p.fields[p.field] = string(m.buf)207+ p.fields[p.field] = m.line.text()208208 var cr sheet.Criteria209209 for _, v := range p.values {210210 if !p.checked[v.Text] {
@@ -262,9 +262,9 @@ func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
262262 case p.field == 0 && key == "space":263263 p.toggle(p.sel)264264 default:265- before := string(m.buf)266- m.lineKey(k)267- if string(m.buf) != before {265+ before := m.line.text()266+ m.line.key(k)267+ if m.line.text() != before {268268 p.changed(m)269269 }270270 }
@@ -310,15 +310,15 @@ func (p *filterPicker) mouse(m *Model, e mouseEvent) tea.Cmd {
310310 311311 func (p *filterPicker) status(m *Model) (string, string) {312312 if p.field == 1 {313- return "Rows must also meet the condition", m.keyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")313+ return "Rows must also meet the condition", m.th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")314314 }315315 pairs := []string{"Space", "check", "Tab", "condition", "Enter", "apply", "Esc", "cancel"}316316 desc := "Type to search the values"317317 for {318- keys := m.keyHints(pairs...)318+ keys := m.th.KeyHints(pairs...)319319 switch {320320 case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:321- return m.th.muted.Render(desc), keys321+ return m.th.Muted.Render(desc), keys322322 case desc != "":323323 desc = ""324324 case len(pairs) > 4:
@@ -349,7 +349,7 @@ func (p *filterPicker) box(m *Model) (x, y, inner int) {
349349 350350 func (p *filterPicker) cursor(m *Model) (int, int) {351351 x, y, _ := p.box(m)352- caret := ansi.StringWidth(string(m.buf[:m.bufPos]))352+ caret := ansi.StringWidth(m.line.head())353353 if p.field == 1 {354354 return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1355355 }
@@ -366,35 +366,35 @@ func (p *filterPicker) layout(m *Model) []box {
366366 rows := p.rows(m)367367 p.show(rows)368368 369- chipStyle := m.th.keyChip369+ chipStyle := m.th.KeyChip370370 if p.field == 1 {371- chipStyle = m.th.menuSelected371+ chipStyle = m.th.MenuSelected372372 }373- cond := m.th.muted.Render(" If ") + chipStyle.Render(p.condChip())373+ cond := m.th.Muted.Render(" If ") + chipStyle.Render(p.condChip())374374 if p.cond.Op.TakesArg() {375375 arg := p.fields[1]376376 if p.field == 1 {377- arg = string(m.buf)377+ arg = m.line.text()378378 }379379 if arg == "" && p.field != 1 {380- arg = m.th.muted.Render("value")380+ arg = m.th.Muted.Render("value")381381 }382382 cond += " " + arg383383 }384384 search := p.fields[0]385385 if p.field == 0 {386- search = string(m.buf)386+ search = m.line.text()387387 }388- input := m.th.title.Render(searchPrompt) + search388+ input := m.th.Title.Render(searchPrompt) + search389389 if search == "" {390- input += m.th.muted.Render("Search values")390+ input += m.th.Muted.Render("Search values")391391 }392- lines := []string{cells(m.th.menuBar, cond, inner), sepRow, cells(m.th.menuBar, input, inner), sepRow}392+ lines := []string{theme.Cells(m.th.MenuBar, cond, inner), theme.SepRow, theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}393393 394394 for r := range rows {395395 i := p.top + r396396 if i > len(p.shown) {397- lines = append(lines, cells(m.th.menuBar, "", inner))397+ lines = append(lines, theme.Cells(m.th.MenuBar, "", inner))398398 continue399399 }400400 label, count, checked := "Select all", "", p.allChecked()
@@ -407,16 +407,16 @@ func (p *filterPicker) layout(m *Model) []box {
407407 if checked {408408 box = "[x] "409409 }410- base, muted := m.th.menuBar, m.th.muted410+ base, muted := m.th.MenuBar, m.th.Muted411411 if i == p.sel {412- base, muted = m.th.menuSelected, m.th.menuSelected412+ base, muted = m.th.MenuSelected, m.th.MenuSelected413413 }414414 labelStyle := base415415 if dim {416416 labelStyle = muted417417 }418- text := base.Render(" "+box) + cells(labelStyle, label, inner-6-len(count)-1)419- text += muted.Render(padLeft(count, len(count)+1)) + base.Render(" ")418+ text := base.Render(" "+box) + theme.Cells(labelStyle, label, inner-6-len(count)-1)419+ text += muted.Render(theme.PadLeft(count, len(count)+1)) + base.Render(" ")420420 lines = append(lines, ansi.Truncate(text, inner, ""))421421 }422422 title := "Filter " + sheet.ColName(p.col)
@@ -426,5 +426,5 @@ func (p *filterPicker) layout(m *Model) []box {
426426 }427427 }428428 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)}}429+ return []box{{id: filterID, x: x, y: y, lines: m.th.Frame(inner, title, footer, lines)}}430430 }
M internal/ui/find.go +9 -185
@@ -4,10 +4,8 @@ import (
44 "log/slog"55 "slices"66 "strconv"7- "strings"87 98 tea "charm.land/bubbletea/v2"10- "github.com/charmbracelet/x/ansi"119 1210 "github.com/FelineStateMachine/012/internal/sheet"1311 "github.com/FelineStateMachine/012/internal/telemetry"
@@ -74,7 +72,7 @@ func init() {
7472 func (m *Model) openFind(replace bool) {7573 f, ok := m.overlay.(*findBar)7674 if !ok {77- f = m.lastFind75+ f = m.find7876 if f == nil {7977 f = &findBar{cur: -1}8078 }
@@ -90,7 +88,7 @@ func (m *Model) openFind(replace bool) {
9088 m.clearSelection()9189 m.openOverlay(f)9290 // Load the kept query so focus doesn't overwrite it.93- m.buf = []rune(f.fields[f.field])91+ m.line.buf = []rune(f.fields[f.field])9492 }9593 f.replace = f.replace || replace9694 if replace && f.fields[0] != "" {
@@ -107,15 +105,14 @@ func (f *findBar) layout(*Model) []box { return nil }
107105 108106 // focus moves editing to field i, keeping the other field's text.109107 func (f *findBar) focus(m *Model, i int) {110- f.fields[f.field] = string(m.buf)108+ f.fields[f.field] = m.line.text()111109 f.field = i112- m.buf = []rune(f.fields[i])113- m.bufPos = len(m.buf)110+ m.line.set(f.fields[i])114111 }115112 116113 // changed re-runs the search as the query is typed.117114 func (f *findBar) changed(m *Model) {118- f.fields[f.field] = string(m.buf)115+ f.fields[f.field] = m.line.text()119116 if f.field == 0 {120117 f.search(m)121118 }
@@ -214,7 +211,7 @@ func (f *findBar) nextScope(m *Model) findScope {
214211 func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {215212 switch k.String() {216213 case "esc":217- m.lastFind = f214+ m.find = f218215 m.closeOverlay()219216 case "enter":220217 if f.replace && f.field == 1 {
@@ -258,9 +255,9 @@ func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
258255 f.search(m)259256 }260257 default:261- before := string(m.buf)262- m.lineKey(k)263- if string(m.buf) != before {258+ before := m.line.text()259+ m.line.key(k)260+ if m.line.text() != before {264261 f.changed(m)265262 }266263 }
@@ -327,179 +324,6 @@ func cellCount(n int) string {
327324 return strconv.Itoa(n) + " cells"328325 }329326 330-// Pieces of the bar, left to right: each field, then the option chips.331-type findPart struct {332- text string333- field int // -1 for chips334- toggle string // the Alt key a chip toggles335-}336-337-func (f *findBar) parts(m *Model) []findPart {338- label := func(name string, i int) string {339- style := m.th.muted340- if f.field == i {341- style = m.th.key342- }343- return style.Render(name) + m.th.muted.Render(searchPrompt)344- }345- parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}346- if f.replace {347- parts = append(parts, findPart{text: label("Replace", 1) + f.fieldText(m, 1), field: 1})348- }349- chip := func(on bool, name, key string) findPart {350- style := m.th.muted351- if on {352- style = m.th.menuSelected353- }354- return findPart{text: style.Render(" " + name + " "), field: -1, toggle: key}355- }356- parts = append(parts,357- chip(f.opts.MatchCase, "Aa", "alt+c"),358- chip(f.opts.WholeCell, "Whole", "alt+w"),359- chip(f.opts.Regex, ".*", "alt+r"),360- chip(f.opts.InFormulas, "=", "alt+="))361- switch {362- case f.where == inRange:363- parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))364- case f.where == inAll:365- parts = append(parts, chip(true, "in all sheets", "alt+s"))366- case f.scope != nil || m.book().Len() > 1:367- parts = append(parts, chip(false, "in "+m.sheet.Name(), "alt+s"))368- }369- return parts370-}371-372-// fieldText is a field's text: the live edit buffer when focused.373-func (f *findBar) fieldText(m *Model, i int) string {374- if f.field == i {375- return string(m.buf)376- }377- return f.fields[i]378-}379-380-// gapAfter separates a part from the next: wider after a field, so the381-// query and the chips read as separate groups.382-func gapAfter(p findPart) string {383- if p.field >= 0 {384- return " "385- }386- return " "387-}388-389-// spans returns each part with the x where it starts on the context line.390-func (f *findBar) spans(m *Model) ([]findPart, []int) {391- parts := f.parts(m)392- xs := make([]int, len(parts))393- x := 0394- for i, p := range parts {395- xs[i] = x396- x += ansi.StringWidth(p.text) + len(gapAfter(p))397- }398- return parts, xs399-}400-401-// line renders the bar for the context line, with the match count on the402-// right.403-func (f *findBar) line(m *Model) (left, right string) {404- var b strings.Builder405- parts := f.parts(m)406- for i, p := range parts {407- b.WriteString(p.text)408- if i < len(parts)-1 {409- b.WriteString(gapAfter(p))410- }411- }412- switch {413- case f.err != "":414- right = m.th.warning.Render(f.err)415- case f.fields[0] == "":416- case len(f.matches) == 0:417- right = m.th.warning.Render("No matches")418- default:419- where := ""420- if f.where == inAll {421- where = " on " + f.matches[f.cur].s.Name()422- }423- right = m.th.muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)424- }425- return b.String(), right426-}427-428-// cursor puts the terminal cursor in the focused field, whose text ends429-// its part.430-func (f *findBar) cursor(m *Model) (x, y int) {431- parts, xs := f.spans(m)432- for i, p := range parts {433- if p.field == f.field {434- return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(string(m.buf[m.bufPos:])), contextLine435- }436- }437- return 0, contextLine438-}439-440-// mouse focuses a field or flips a chip on the bar; a click anywhere441-// else closes the bar and lands where it was clicked.442-func (f *findBar) mouse(m *Model, e mouseEvent) tea.Cmd {443- if e.kind != mousePress {444- return nil445- }446- if e.y != contextLine {447- m.lastFind = f448- m.closeOverlay()449- return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})450- }451- parts, xs := f.spans(m)452- for i, p := range parts {453- if e.x >= xs[i] && e.x < xs[i]+ansi.StringWidth(p.text) {454- if p.field >= 0 {455- f.focus(m, p.field)456- return nil457- }458- return f.key(m, keyFor(p.toggle))459- }460- }461- return nil462-}463-464-// keyFor makes a key press from a keystroke like "alt+c".465-func keyFor(s string) tea.KeyPressMsg {466- k := tea.KeyPressMsg{}467- parts := strings.Split(s, "+")468- for _, p := range parts[:len(parts)-1] {469- if p == "alt" {470- k.Mod |= tea.ModAlt471- }472- }473- k.Code = rune(parts[len(parts)-1][0])474- return k475-}476-477-// status shows the keys for moving and replacing, and a reminder of the478-// option keys when there's room. Narrow screens keep the most useful keys.479-func (f *findBar) status(m *Model) (string, string) {480- pairs := []string{"Enter", "next", "Shift+Enter", "previous", "Esc", "close"}481- if f.replace {482- pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}483- }484- desc := "Alt+C/W/R/= options"485- if f.scope != nil || m.book().Len() > 1 {486- desc = "Alt+C/W/R/=/S options"487- }488- for {489- keys := m.keyHints(pairs...)490- switch {491- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:492- return m.th.muted.Render(desc), keys493- case desc != "":494- desc = ""495- case len(pairs) > 2:496- pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc497- default:498- return "", keys499- }500- }501-}502-503327 // found reports whether a is a match of the open find bar.504328 func (m *Model) found(a sheet.Addr) bool {505329 f, ok := m.overlay.(*findBar)
M internal/ui/find_test.go +2 -2
A internal/ui/findview.go +185 -0
@@ -0,0 +1,185 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// The find bar as the context line draws it, and its mouse targets: the12+// fields, then the option chips, with the match count on the right.13+14+// Pieces of the bar, left to right: each field, then the option chips.15+type findPart struct {16+ text string17+ field int // -1 for chips18+ toggle string // the Alt key a chip toggles19+}20+21+func (f *findBar) parts(m *Model) []findPart {22+ label := func(name string, i int) string {23+ style := m.th.Muted24+ if f.field == i {25+ style = m.th.Key26+ }27+ return style.Render(name) + m.th.Muted.Render(searchPrompt)28+ }29+ parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}30+ if f.replace {31+ parts = append(parts, findPart{text: label("Replace", 1) + f.fieldText(m, 1), field: 1})32+ }33+ chip := func(on bool, name, key string) findPart {34+ style := m.th.Muted35+ if on {36+ style = m.th.MenuSelected37+ }38+ return findPart{text: style.Render(" " + name + " "), field: -1, toggle: key}39+ }40+ parts = append(parts,41+ chip(f.opts.MatchCase, "Aa", "alt+c"),42+ chip(f.opts.WholeCell, "Whole", "alt+w"),43+ chip(f.opts.Regex, ".*", "alt+r"),44+ chip(f.opts.InFormulas, "=", "alt+="))45+ switch {46+ case f.where == inRange:47+ parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))48+ case f.where == inAll:49+ parts = append(parts, chip(true, "in all sheets", "alt+s"))50+ case f.scope != nil || m.book().Len() > 1:51+ parts = append(parts, chip(false, "in "+m.sheet.Name(), "alt+s"))52+ }53+ return parts54+}55+56+// fieldText is a field's text: the live edit buffer when focused.57+func (f *findBar) fieldText(m *Model, i int) string {58+ if f.field == i {59+ return m.line.text()60+ }61+ return f.fields[i]62+}63+64+// gapAfter separates a part from the next: wider after a field, so the65+// query and the chips read as separate groups.66+func gapAfter(p findPart) string {67+ if p.field >= 0 {68+ return " "69+ }70+ return " "71+}72+73+// spans returns each part with the x where it starts on the context line.74+func (f *findBar) spans(m *Model) ([]findPart, []int) {75+ parts := f.parts(m)76+ xs := make([]int, len(parts))77+ x := 078+ for i, p := range parts {79+ xs[i] = x80+ x += ansi.StringWidth(p.text) + len(gapAfter(p))81+ }82+ return parts, xs83+}84+85+// line renders the bar for the context line, with the match count on the86+// right.87+func (f *findBar) line(m *Model) (left, right string) {88+ var b strings.Builder89+ parts := f.parts(m)90+ for i, p := range parts {91+ b.WriteString(p.text)92+ if i < len(parts)-1 {93+ b.WriteString(gapAfter(p))94+ }95+ }96+ switch {97+ case f.err != "":98+ right = m.th.Warning.Render(f.err)99+ case f.fields[0] == "":100+ case len(f.matches) == 0:101+ right = m.th.Warning.Render("No matches")102+ default:103+ where := ""104+ if f.where == inAll {105+ where = " on " + f.matches[f.cur].s.Name()106+ }107+ right = m.th.Muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)108+ }109+ return b.String(), right110+}111+112+// cursor puts the terminal cursor in the focused field, whose text ends113+// its part.114+func (f *findBar) cursor(m *Model) (x, y int) {115+ parts, xs := f.spans(m)116+ for i, p := range parts {117+ if p.field == f.field {118+ return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(m.line.tail()), contextLine119+ }120+ }121+ return 0, contextLine122+}123+124+// mouse focuses a field or flips a chip on the bar; a click anywhere125+// else closes the bar and lands where it was clicked.126+func (f *findBar) mouse(m *Model, e mouseEvent) tea.Cmd {127+ if e.kind != mousePress {128+ return nil129+ }130+ if e.y != contextLine {131+ m.find = f132+ m.closeOverlay()133+ return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})134+ }135+ parts, xs := f.spans(m)136+ for i, p := range parts {137+ if e.x >= xs[i] && e.x < xs[i]+ansi.StringWidth(p.text) {138+ if p.field >= 0 {139+ f.focus(m, p.field)140+ return nil141+ }142+ return f.key(m, keyFor(p.toggle))143+ }144+ }145+ return nil146+}147+148+// keyFor makes a key press from a keystroke like "alt+c".149+func keyFor(s string) tea.KeyPressMsg {150+ k := tea.KeyPressMsg{}151+ parts := strings.Split(s, "+")152+ for _, p := range parts[:len(parts)-1] {153+ if p == "alt" {154+ k.Mod |= tea.ModAlt155+ }156+ }157+ k.Code = rune(parts[len(parts)-1][0])158+ return k159+}160+161+// status shows the keys for moving and replacing, and a reminder of the162+// option keys when there's room. Narrow screens keep the most useful keys.163+func (f *findBar) status(m *Model) (string, string) {164+ pairs := []string{"Enter", "next", "Shift+Enter", "previous", "Esc", "close"}165+ if f.replace {166+ pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}167+ }168+ desc := "Alt+C/W/R/= options"169+ if f.scope != nil || m.book().Len() > 1 {170+ desc = "Alt+C/W/R/=/S options"171+ }172+ for {173+ keys := m.th.KeyHints(pairs...)174+ switch {175+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:176+ return m.th.Muted.Render(desc), keys177+ case desc != "":178+ desc = ""179+ case len(pairs) > 2:180+ pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc181+ default:182+ return "", keys183+ }184+ }185+}
M internal/ui/format_test.go +1 -1
A internal/ui/formula/formula.go +278 -0
@@ -0,0 +1,278 @@
1+// Package formula reads the formula being typed in the UI: the2+// reference F4 cycles through absolute markers, and the word and function3+// call around the caret that formula assistance follows. It works on the4+// entry's runes alone.5+package formula6+7+import (8+ "slices"9+ "strings"10+ "unicode"11+12+ "github.com/FelineStateMachine/012/internal/sheet"13+)14+15+// CycleRef finds the reference (or range) at or just before pos in a16+// formula and returns the text with its absolute markers cycled, and the17+// caret moved to the reference's end.18+func CycleRef(buf []rune, pos int) ([]rune, int, bool) {19+ corners, ok := newRefScan(buf).corners(pos)20+ if !ok {21+ return nil, 0, false22+ }23+ next := nextMarkers(string(buf[corners[0][0]:corners[0][1]]))24+ out := slices.Clone(buf[:corners[0][0]])25+ for i, c := range corners {26+ if i > 0 {27+ out = append(out, buf[corners[i-1][1]:c[0]]...)28+ }29+ out = append(out, []rune(withMarkers(string(buf[c[0]:c[1]]), next))...)30+ }31+ caret := len(out)32+ out = append(out, buf[corners[len(corners)-1][1]:]...)33+ return out, caret, true34+}35+36+// refScan finds cell references in a formula's text, outside strings.37+type refScan struct {38+ buf []rune39+ quoted []bool // inside a string, quotes included40+}41+42+func newRefScan(buf []rune) refScan {43+ quoted := make([]bool, len(buf))44+ in := false45+ for i, r := range buf {46+ if r == '"' {47+ in = !in48+ }49+ quoted[i] = in || r == '"'50+ }51+ return refScan{buf: buf, quoted: quoted}52+}53+54+func (s refScan) isRefRune(i int) bool {55+ r := s.buf[i]56+ return !s.quoted[i] && (r == '$' || r >= '0' && r <= '9' || r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z')57+}58+59+// word returns the bounds of the run of reference characters around i.60+func (s refScan) word(i int) (int, int) {61+ start, end := i, i62+ for start > 0 && s.isRefRune(start-1) {63+ start--64+ }65+ for end < len(s.buf) && s.isRefRune(end) {66+ end++67+ }68+ return start, end69+}70+71+// isRef reports whether buf[start:end] is a cell reference, not a name72+// (@x) or a function (x().73+func (s refScan) isRef(start, end int) bool {74+ buf := s.buf75+ if start == end || start > 0 && buf[start-1] == '@' || end < len(buf) && buf[end] == '(' {76+ return false77+ }78+ _, ok := sheet.ParseAddr(string(buf[start:end]))79+ return ok80+}81+82+// sep returns the length of a range separator at i, ":" or "..", or 0.83+func (s refScan) sep(i int) int {84+ switch {85+ case i < len(s.buf) && s.buf[i] == ':':86+ return 187+ case i+1 < len(s.buf) && s.buf[i] == '.' && s.buf[i+1] == '.':88+ return 289+ }90+ return 091+}92+93+// corners returns the bounds of the reference at pos, widened to both94+// corners of a range written A1:B2 or A1..B2.95+func (s refScan) corners(pos int) ([][2]int, bool) {96+ start, end := s.word(pos)97+ if !s.isRef(start, end) {98+ return nil, false99+ }100+ if n := s.sep(end); n > 0 {101+ if s2, e2 := s.word(end + n); s2 == end+n && s.isRef(s2, e2) {102+ return [][2]int{{start, end}, {s2, e2}}, true103+ }104+ return [][2]int{{start, end}}, true105+ }106+ for n := 1; n <= 2 && start-n > 0; n++ {107+ if s.sep(start-n) == n {108+ if s0, e0 := s.word(start - n - 1); e0 == start-n && s.isRef(s0, e0) {109+ return [][2]int{{s0, e0}, {start, end}}, true110+ }111+ break112+ }113+ }114+ return [][2]int{{start, end}}, true115+}116+117+// Caret describes the formula text before the caret: the word being118+// typed, if it may be a function or name, and the innermost function call119+// the caret is in.120+type Caret struct {121+ Word string122+ WordStart int123+ Fn string // e.g. "SUM", or "" outside any function's parentheses124+ Arg int // index of the argument the caret is in125+}126+127+func isWordRune(r rune) bool {128+ return r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) || r == '_' || r == '.' || r == '$' || r == '@'129+}130+131+// ScanCaret reads a formula up to pos the way the lexer does: strings are132+// skipped, parentheses nest, and commas or semicolons at the innermost133+// level separate arguments.134+func ScanCaret(buf []rune, pos int) Caret {135+ s := caretScan{start: -1}136+ for i := 1; i < pos; i++ { // buf[0] is "=", "+" or "-"137+ s.step(buf, i)138+ }139+ if s.inStr {140+ return Caret{}141+ }142+ var c Caret143+ for i := len(s.stack) - 1; i >= 0; i-- {144+ if s.stack[i].fn != "" {145+ c.Fn, c.Arg = s.stack[i].fn, s.stack[i].arg146+ break147+ }148+ }149+ if s.start >= 0 && (pos == len(buf) || !isWordRune(buf[pos])) {150+ w := []rune(strings.TrimPrefix(string(buf[s.start:pos]), "@"))151+ if len(w) > 0 && (unicode.IsLetter(w[0]) || w[0] == '_') {152+ c.Word, c.WordStart = string(w), pos-len(w)153+ }154+ }155+ return c156+}157+158+// caretScan is ScanCaret's state as it reads.159+type caretScan struct {160+ stack []callFrame // the calls open at this point, innermost last161+ inStr bool162+ start int // start of the word being read, or -1163+ prev string // the word just before a space or "(" (SUM ( is allowed)164+}165+166+// callFrame is a function call the caret may be in.167+type callFrame struct {168+ fn string169+ arg int170+}171+172+// step reads buf[i].173+func (s *caretScan) step(buf []rune, i int) {174+ r := buf[i]175+ if s.inStr {176+ s.inStr = r != '"'177+ return178+ }179+ if isWordRune(r) {180+ if s.start < 0 {181+ s.start = i182+ }183+ return184+ }185+ if s.start >= 0 {186+ s.prev, s.start = string(buf[s.start:i]), -1187+ }188+ switch r {189+ case ' ':190+ return // keep prev for "SUM ("191+ case '"':192+ s.inStr = true193+ case '(':194+ s.stack = append(s.stack, callFrame{fn: strings.ToUpper(strings.TrimPrefix(s.prev, "@"))})195+ case ')':196+ if len(s.stack) > 0 {197+ s.stack = s.stack[:len(s.stack)-1]198+ }199+ case ',', ';':200+ if len(s.stack) > 0 {201+ s.stack[len(s.stack)-1].arg++202+ }203+ }204+ s.prev = ""205+}206+207+// SplitArgs splits a signature's arguments at the top-level commas, so208+// "[value2, ...]" stays one part.209+func SplitArgs(args string) []string {210+ var out []string211+ depth, start := 0, 0212+ for i, r := range args {213+ switch r {214+ case '[':215+ depth++216+ case ']':217+ depth--218+ case ',':219+ if depth == 0 {220+ out = append(out, strings.TrimSpace(args[start:i]))221+ start = i + 1222+ }223+ }224+ }225+ if s := strings.TrimSpace(args[start:]); s != "" {226+ out = append(out, s)227+ }228+ return out229+}230+231+// ArgPart maps an argument index to the part of the signature that232+// describes it: a repeating part like "[value2, ...]" covers every233+// argument from its position on. It returns -1 past the last argument.234+func ArgPart(parts []string, arg int, variadic bool) int {235+ rep := slices.IndexFunc(parts, func(p string) bool { return strings.Contains(p, "...") })236+ switch {237+ case rep >= 0 && variadic && arg >= rep:238+ return rep239+ case arg < len(parts):240+ return arg241+ }242+ return -1243+}244+245+// nextMarkers returns the absolute markers that follow ref's in F4's246+// cycle: A1 -> $A$1 -> A$1 -> $A1 -> A1.247+func nextMarkers(ref string) markers {248+ col := strings.HasPrefix(ref, "$")249+ row := strings.Contains(ref[1:], "$")250+ switch {251+ case !col && !row:252+ return markers{true, true}253+ case col && row:254+ return markers{false, true}255+ case row:256+ return markers{true, false}257+ }258+ return markers{}259+}260+261+// markers says which parts of a reference are absolute.262+type markers struct{ col, row bool }263+264+// withMarkers rewrites ref with the given absolute markers.265+func withMarkers(ref string, marks markers) string {266+ ref = strings.ToUpper(strings.ReplaceAll(ref, "$", ""))267+ i := strings.IndexAny(ref, "0123456789")268+ var b strings.Builder269+ if marks.col {270+ b.WriteByte('$')271+ }272+ b.WriteString(ref[:i])273+ if marks.row {274+ b.WriteByte('$')275+ }276+ b.WriteString(ref[i:])277+ return b.String()278+}
A internal/ui/formula/formula_test.go +87 -0
@@ -0,0 +1,87 @@
1+package formula2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestScanCaret(t *testing.T) {9+ for _, tc := range []struct {10+ text string // | marks the caret11+ word string12+ fn string13+ arg int14+ }{15+ {"=SU|", "SU", "", 0},16+ {"=@su|", "su", "", 0},17+ {"=SUM(|", "", "SUM", 0},18+ {"=SUM(A1, B|", "B", "SUM", 1},19+ {"=IF(A1>2, SUM(B1; B2), |", "", "IF", 2},20+ {"=IF(A1, (1+|", "", "IF", 1},21+ {`=IF(A1="a,b(", |`, "", "IF", 1},22+ {`=CONCAT("SU|`, "", "", 0},23+ {"=SUM (1, |", "", "SUM", 1},24+ {"=jev.te|", "jev.te", "", 0},25+ {"=A1+1|", "", "", 0},26+ {"=SU|M(1)", "", "", 0},27+ {"=Sales|", "Sales", "", 0},28+ } {29+ i := strings.Index(tc.text, "|")30+ buf := []rune(strings.Replace(tc.text, "|", "", 1))31+ c := ScanCaret(buf, len([]rune(tc.text[:i])))32+ if c.Word != tc.word || c.Fn != tc.fn || c.Arg != tc.arg {33+ t.Errorf("%s: word %q fn %q arg %d", tc.text, c.Word, c.Fn, c.Arg)34+ }35+ }36+}37+38+func TestArgPart(t *testing.T) {39+ for _, tc := range []struct {40+ args string41+ arg int42+ variadic bool43+ want string44+ }{45+ {"value1, [value2, ...]", 0, true, "value1"},46+ {"value1, [value2, ...]", 4, true, "[value2, ...]"},47+ {"condition, value_if_true, [value_if_false]", 2, false, "[value_if_false]"},48+ {"condition, value_if_true, [value_if_false]", 3, false, ""},49+ {"sum_range, criteria_range1, criterion1, [criteria_range2, criterion2, ...]", 5, true, "[criteria_range2, criterion2, ...]"},50+ } {51+ parts := SplitArgs(tc.args)52+ got := ""53+ if i := ArgPart(parts, tc.arg, tc.variadic); i >= 0 {54+ got = parts[i]55+ }56+ if got != tc.want {57+ t.Errorf("%s arg %d: %q, want %q", tc.args, tc.arg, got, tc.want)58+ }59+ }60+}61+62+func TestCycleRef(t *testing.T) {63+ tests := []struct {64+ in string65+ caret int66+ want string67+ ok bool68+ }{69+ {"=a1", 3, "=$A$1", true},70+ {"=A1+1", 1, "=$A$1+1", true},71+ {"=SUM(A1:B2)", 10, "=SUM($A$1:$B$2)", true},72+ {"=SUM(A1:B2)", 7, "=SUM($A$1:$B$2)", true},73+ {"=SUM($A$1..B2)", 12, "=SUM(A$1..B$2)", true},74+ {`="A1"`, 3, "", false},75+ {"=LOG10(2)", 6, "", false},76+ {"=1+2", 2, "", false},77+ }78+ for _, tt := range tests {79+ out, pos, ok := CycleRef([]rune(tt.in), tt.caret)80+ if ok != tt.ok || string(out) != tt.want {81+ t.Errorf("CycleRef(%q, %d) = %q, %v; want %q", tt.in, tt.caret, string(out), ok, tt.want)82+ }83+ if ok && pos > len(out) {84+ t.Errorf("caret %d past end of %q", pos, string(out))85+ }86+ }87+}
M internal/ui/graphics.go +44 -41
@@ -13,7 +13,9 @@ import (
1313 "github.com/charmbracelet/x/ansi"1414 1515 "github.com/FelineStateMachine/012/internal/chart"16+ "github.com/FelineStateMachine/012/internal/sheet"1617 "github.com/FelineStateMachine/012/internal/telemetry"18+ "github.com/FelineStateMachine/012/internal/ui/theme"1719 )1820 1921 // terminal is what 012 knows about the terminal it runs in, learned from
@@ -46,8 +48,8 @@ func (t *terminal) wrap(seq string) string {
4648 4749 // probes are the startup queries: kitty graphics support, and live4850 // light and dark changes (mode 2031).49-func (m *Model) probes() tea.Cmd {50- return tea.Raw(m.term.wrap(chart.Query()) + ansi.SetModeLightDark)51+func (t *terminal) probes() tea.Cmd {52+ return tea.Raw(t.wrap(chart.Query()) + ansi.SetModeLightDark)5153 }5254 5355 // firstImageID is the image id of the first chart. Placeholders name
@@ -58,12 +60,12 @@ const firstImageID = 16
5860 // maxImages caps how many charts become images; the rest stay text.5961 const maxImages = 256 - firstImageID6062 61-// handleTerminal handles replies to queries and terminal events.62-func (m *Model) handleTerminal(msg tea.Msg) tea.Cmd {63+// handle handles replies to queries and terminal events.64+func (t *terminal) handle(msg tea.Msg) tea.Cmd {6365 switch msg := msg.(type) {6466 case uv.KittyGraphicsEvent:65- if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !m.term.kitty {66- m.term.kitty = true67+ if msg.Options.ID == chart.QueryID && string(msg.Payload) == "OK" && !t.kitty {68+ t.kitty = true6769 // Ask for the cell size (for image proportions) and the6870 // palette colors charts draw with.6971 var q strings.Builder
@@ -75,20 +77,20 @@ func (m *Model) handleTerminal(msg tea.Msg) tea.Cmd {
7577 }7678 case uv.CellSizeEvent:7779 if msg.Width > 0 && msg.Height > 0 {78- m.term.cellW, m.term.cellH = msg.Width, msg.Height80+ t.cellW, t.cellH = msg.Width, msg.Height7981 }8082 case uv.UnknownOscEvent:8183 if i, c, ok := parsePaletteReply(string(msg)); ok {82- m.term.palette[i] = c84+ t.palette[i] = c8385 }8486 case uv.DarkColorSchemeEvent, uv.LightColorSchemeEvent:8587 // The system switched between light and dark. The terminal's8688 // background decides the theme, so ask for it again.8789 return tea.RequestBackgroundColor8890 case tea.FocusMsg:89- m.term.blurred = false91+ t.blurred = false9092 case tea.BlurMsg:91- m.term.blurred = true93+ t.blurred = true9294 }9395 return nil9496 }
@@ -127,69 +129,70 @@ func (t *terminal) color(i int) color.RGBA {
127129 }128130 129131 // chartPalette is the theme's series colors as the terminal draws them.130-func (m *Model) chartPalette() chart.Palette {132+func (t *terminal) chartPalette(th *theme.Theme) chart.Palette {131133 var p chart.Palette132- for i, idx := range m.th.seriesANSI {133- p.Series[i] = m.term.color(idx)134+ for i, idx := range th.SeriesANSI {135+ p.Series[i] = t.color(idx)134136 }135- p.Grid = m.term.color(8) // bright black, like the text axes137+ p.Grid = t.color(8) // bright black, like the text axes136138 p.Grid.A = 90137139 return p138140 }139141 140142 // chartOptions are the drawing options for charts on this terminal.141-func (m *Model) chartOptions() chart.Options {142- return chart.Options{Image: m.term.kitty, CellW: m.term.cellW, CellH: m.term.cellH}143+func (t *terminal) chartOptions() chart.Options {144+ return chart.Options{Image: t.kitty, CellW: t.cellW, CellH: t.cellH}143145 }144146 145147 // syncImages sends the images of charts that changed since they were last146-// sent, and frees those of deleted charts. Update calls it after every147-// message, so images follow edits, recalculation, resizing and the theme.148-func (m *Model) syncImages() tea.Cmd {149- if !m.term.kitty {148+// sent, and frees those of deleted charts; shown gives the charts as149+// drawn, asked for only on terminals that show images. Update calls it150+// after every message, so images follow edits, recalculation, resizing151+// and the theme.152+func (t *terminal) syncImages(s *sheet.Sheet, shown func() []sheet.Chart, th *theme.Theme) tea.Cmd {153+ if !t.kitty {150154 return nil151155 }152156 var out strings.Builder153- charts := m.displayCharts()154- o := m.chartOptions()155- pal := m.chartPalette()157+ o := t.chartOptions()158+ pal := t.chartPalette(th)156159 live := map[int]bool{}157- for i, c := range charts {160+ for i, c := range shown() {158161 if i >= maxImages {159162 break160163 }161164 id := firstImageID + i162165 live[id] = true163166 w, h := chartInner(c)164- d := m.sheet.ChartData(c)167+ d := s.ChartData(c)165168 key := fmt.Sprintf("%v %v %d %d %v %v", c.Type, d, w, h, o, pal)166- prev, resent := m.term.sent[id]169+ prev, resent := t.sent[id]167170 if prev == key {168171 continue169172 }170- m.term.sent[id] = key173+ t.sent[id] = key171174 if resent {172175 // Free the old image and its placement first: Ghostty keeps173176 // every virtual placement of an id and sizes the image by the174177 // oldest, so a chart that grew would draw at its old size.175- out.WriteString(chart.Delete(id, m.term.wrap))178+ out.WriteString(chart.Delete(id, t.wrap))176179 }177180 span := telemetry.Start("chart.image", slog.String("type", c.Type.String()), slog.Int("w", w), slog.Int("h", h))178181 g := chart.Draw(c.Type, d, w, h, o)179182 img := chart.Image(c.Type, d, w, h, o, pal)180183 if img == nil || g.Plot.Empty() {181184 span.End()182- out.WriteString(chart.Delete(id, m.term.wrap))185+ out.WriteString(chart.Delete(id, t.wrap))183186 continue184187 }185- sent := chart.Transmit(id, img, g.Plot.Dx(), g.Plot.Dy(), m.term.wrap)188+ sent := chart.Transmit(id, img, g.Plot.Dx(), g.Plot.Dy(), t.wrap)186189 span.End(slog.Int("bytes", len(sent)))187190 out.WriteString(sent)188191 }189- for id := range m.term.sent {192+ for id := range t.sent {190193 if !live[id] {191- delete(m.term.sent, id)192- out.WriteString(chart.Delete(id, m.term.wrap))194+ delete(t.sent, id)195+ out.WriteString(chart.Delete(id, t.wrap))193196 }194197 }195198 if out.Len() == 0 {
@@ -198,21 +201,21 @@ func (m *Model) syncImages() tea.Cmd {
198201 return tea.Raw(out.String())199202 }200203 201-// releaseTerminal undoes the startup modes and frees the chart images.202-func (m *Model) releaseTerminal() string {204+// release undoes the startup modes and frees the chart images.205+func (t *terminal) release() string {203206 var b strings.Builder204207 b.WriteString(ansi.ResetModeLightDark)205- for id := range m.term.sent {206- b.WriteString(chart.Delete(id, m.term.wrap))208+ for id := range t.sent {209+ b.WriteString(chart.Delete(id, t.wrap))207210 }208211 return b.String()209212 }210213 211-// notifyDone tells the user a long job finished, with a desktop214+// notify tells the user a long job finished, with a desktop212215 // notification (OSC 9) when the terminal window isn't focused. When it is,213216 // the screen already shows the result.214-func (m *Model) notifyDone(msg string) tea.Cmd {215- if !m.term.blurred {217+func (t *terminal) notify(msg string) tea.Cmd {218+ if !t.blurred {216219 return nil217220 }218221 msg = strings.Map(func(r rune) rune {
@@ -221,5 +224,5 @@ func (m *Model) notifyDone(msg string) tea.Cmd {
221224 }222225 return r223226 }, msg)224- return tea.Raw(m.term.wrap(ansi.Notify("012: " + msg)))227+ return tea.Raw(t.wrap(ansi.Notify("012: " + msg)))225228 }
A internal/ui/grid.go +25 -0
M internal/ui/help.go +10 -18
@@ -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 }
M internal/ui/help_test.go +6 -4
M internal/ui/jev.go +27 -20
@@ -11,6 +11,7 @@ import (
1111 "github.com/FelineStateMachine/012/internal/jev"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 // JEV functions are answered in the background: the engine queues
@@ -68,19 +69,19 @@ func plural(n int, one, many string) string {
6869 return many6970 }7071 71-// sendJEV starts queued questions, keeping at most jevParallel in flight72+// send starts queued questions, keeping at most jevParallel in flight7273 // so a filled-down column doesn't open hundreds of connections at once.73-func (m *Model) sendJEV() tea.Cmd {74- if m.jev == nil {74+func (j *jevRunner) send() tea.Cmd {75+ if j == nil {7576 return nil7677 }77- inFlight, queued := m.jev.cache.Busy()78+ inFlight, queued := j.cache.Busy()7879 telemetry.Set("jev_in_flight", int64(inFlight))7980 telemetry.Set("jev_queued", int64(queued))80- calls := m.jev.cache.Take(jevParallel - inFlight)81+ calls := j.cache.Take(jevParallel - inFlight)8182 cmds := make([]tea.Cmd, len(calls))8283 for i, call := range calls {83- client := m.jev.client84+ client := j.client8485 cmds[i] = func() tea.Msg {8586 ctx, cancel := context.WithTimeout(context.Background(), jevTimeout)8687 defer cancel()
@@ -97,21 +98,27 @@ func (m *Model) sendJEV() tea.Cmd {
9798 return tea.Batch(cmds...)9899 }99100 100-// handleJEVAnswer stores an answer and recalculates the JEV cells.101-func (m *Model) handleJEVAnswer(msg jevAnswerMsg) {101+// answerJEV stores an answer and recalculates the JEV cells, telling102+// the user when the last question is answered.103+func (m *Model) answerJEV(msg jevAnswerMsg) tea.Cmd {102104 if m.jev == nil {103- return105+ return nil104106 }107+ busy := m.jev.busy() != ""105108 m.jev.cache.Store(msg.call, msg.answer)106109 m.sheet.RecalcVolatile()110+ if busy && m.jev.busy() == "" {111+ return m.term.notify("JEV finished answering in " + m.displayName())112+ }113+ return nil107114 }108115 109-// jevBusy describes questions still being answered, for the status line.110-func (m *Model) jevBusy() string {111- if m.jev == nil {116+// busy describes questions still being answered, for the status line.117+func (j *jevRunner) busy() string {118+ if j == nil {112119 return ""113120 }114- inFlight, queued := m.jev.cache.Busy()121+ inFlight, queued := j.cache.Busy()115122 switch {116123 case inFlight+queued == 0:117124 return ""
@@ -121,18 +128,18 @@ func (m *Model) jevBusy() string {
121128 return fmt.Sprintf("JEV answering %d, %d waiting", inFlight, queued)122129 }123130 124-// jevLine explains the JEV answer in the active cell, for the context line.125-func (m *Model) jevLine() string {126- calls := m.sheet.RemoteCalls(m.cur)131+// line explains the JEV answers of calls, the active cell's, for the132+// context line.133+func (j *jevRunner) line(th *theme.Theme, calls []sheet.RemoteCall) string {127134 if len(calls) == 0 {128135 return ""129136 }130- if m.jev == nil {131- return m.th.warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")137+ if j == nil {138+ return th.Warning.Render("JEV functions need TYPESAFE_API_KEY, in the environment or a .env file")132139 }133140 var parts []string134141 for _, c := range calls {135- a, ok := m.jev.cache.Answer(c)142+ a, ok := j.cache.Answer(c)136143 if !ok {137144 parts = append(parts, "JEV: asking…")138145 continue
@@ -143,5 +150,5 @@ func (m *Model) jevLine() string {
143150 for _, p := range parts[1:] {144151 line += " " + p145152 }146- return m.th.muted.Render(line)153+ return th.Muted.Render(line)147154 }
A internal/ui/line.go +105 -0
@@ -0,0 +1,105 @@
1+package ui2+3+import (4+ "slices"5+ "strings"6+ "unicode"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/charmbracelet/x/ansi"10+)11+12+// lineEdit is the one-line text editor behind every text field: the13+// entry typed into a cell (ENTER, EDIT), prompts on the context line, and14+// the search fields of the find bar and the pickers. Only one of them is15+// edited at a time, so they share it; a component with several fields16+// keeps the others' text itself and loads a field here to edit it.17+type lineEdit struct {18+ buf []rune19+ pos int // the caret, an index into buf20+}21+22+// set replaces the text, with the caret at its end.23+func (l *lineEdit) set(text string) {24+ l.buf = []rune(text)25+ l.pos = len(l.buf)26+}27+28+// clear empties the line.29+func (l *lineEdit) clear() {30+ l.buf, l.pos = nil, 031+}32+33+// text is the whole line.34+func (l *lineEdit) text() string { return string(l.buf) }35+36+// head and tail are the text before and after the caret.37+func (l *lineEdit) head() string { return string(l.buf[:l.pos]) }38+func (l *lineEdit) tail() string { return string(l.buf[l.pos:]) }39+40+// insert types text at the caret. Line breaks and tabs become spaces, and41+// other control characters are dropped.42+func (l *lineEdit) insert(text string) {43+ for _, r := range text {44+ if r == '\n' || r == '\r' || r == '\t' {45+ r = ' '46+ }47+ if !unicode.IsPrint(r) {48+ continue49+ }50+ l.buf = slices.Insert(l.buf, l.pos, r)51+ l.pos++52+ }53+}54+55+// key applies a line-editing key.56+func (l *lineEdit) key(k tea.KeyPressMsg) {57+ switch k.String() {58+ case "left":59+ l.pos = max(l.pos-1, 0)60+ case "right":61+ l.pos = min(l.pos+1, len(l.buf))62+ case "home", "ctrl+a":63+ l.pos = 064+ case "end", "ctrl+e":65+ l.pos = len(l.buf)66+ case "backspace":67+ if l.pos > 0 {68+ l.buf = slices.Delete(l.buf, l.pos-1, l.pos)69+ l.pos--70+ }71+ case "delete":72+ if l.pos < len(l.buf) {73+ l.buf = slices.Delete(l.buf, l.pos, l.pos+1)74+ }75+ default:76+ l.insert(typed(k))77+ }78+}79+80+// setCaret puts the edit caret at display column x of the entry.81+func (l *lineEdit) setCaret(x int) {82+ w := 083+ for i, r := range l.buf {84+ if w >= x {85+ l.pos = i86+ return87+ }88+ w += ansi.StringWidth(string(r))89+ }90+ l.pos = len(l.buf)91+}92+93+// isFormula reports whether the edit line holds a formula.94+func (l *lineEdit) isFormula() bool {95+ return len(l.buf) > 0 && (l.buf[0] == '=' || l.buf[0] == '+' || l.buf[0] == '-')96+}97+98+// canPoint reports whether the text before the caret ends where a cell99+// reference may follow.100+func (l *lineEdit) canPoint() bool {101+ if l.pos == 0 {102+ return false103+ }104+ return strings.ContainsRune("=+-*/^(,;<>&:", l.buf[l.pos-1])105+}
M internal/ui/links.go +17 -15
@@ -4,6 +4,8 @@ import (
44 "charm.land/lipgloss/v2"55 66 "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/rowtext"8+ "github.com/FelineStateMachine/012/internal/ui/theme"79 )810 911 // Cells whose text is a URL, and HYPERLINK formulas, are drawn as OSC 8
@@ -13,31 +15,31 @@ import (
1315 1416 // decorate marks a span's text as a link or an error, from the cell the1517 // text belongs to.16-func (m *Model) decorate(sp *span, row int) {17- if sp.text == "" {18+func (g *grid) decorate(sp *rowtext.Span, row int) {19+ if sp.Text == "" {1820 return1921 }20- a := sheet.Addr{Col: sp.owner, Row: row}21- switch v := m.sheet.Value(a); {22+ a := sheet.Addr{Col: sp.Owner, Row: row}23+ switch v := g.sheet.Value(a); {2224 case v.Kind == sheet.Error && !sheet.IsPending(v):23- sp.error = true25+ sp.Error = true2426 case v.Kind == sheet.Text:25- sp.link = m.sheet.Link(a)27+ sp.Link = g.sheet.Link(a)2628 }2729 }2830 29-// textStyle is the style of a span's text on base: the cell's text style,31+// spanStyle is the style of a span's text on base: the cell's text style,3032 // then the link or error mark. On a colored role (the pointer, the3133 // selection) a link keeps the role's colors and adds its underline.32-func (m *Model) textStyle(base lipgloss.Style, sp span) lipgloss.Style {33- st := sp.style34+func spanStyle(th *theme.Theme, base lipgloss.Style, sp rowtext.Span) lipgloss.Style {35+ st := sp.Style3436 st.Align = sheet.AlignAuto35- s := m.th.text(base, st)37+ s := th.Text(base, st)3638 switch {37- case sp.link != "":38- s = s.Inherit(m.th.link).Hyperlink(sp.link)39- case sp.error:40- s = s.Inherit(m.th.errorMark)39+ case sp.Link != "":40+ s = s.Inherit(th.Link).Hyperlink(sp.Link)41+ case sp.Error:42+ s = s.Inherit(th.ErrorMark)4143 }4244 return s4345 }
@@ -50,5 +52,5 @@ func (m *Model) errorLine() string {
5052 if v.Kind != sheet.Error || why == "" {5153 return ""5254 }53- return m.th.errorCell.Render(v.Str) + " " + m.th.muted.Render(why)55+ return m.th.ErrorCell.Render(v.Str) + " " + m.th.Muted.Render(why)5456 }
M internal/ui/links_test.go +6 -4
D internal/ui/menu.go +0 -358
@@ -1,358 +0,0 @@
1-package ui2-3-import (4- "fmt"5- "log/slog"6- "os"7- "path/filepath"8- "slices"9- "strconv"10- "strings"11- "unicode"12-13- tea "charm.land/bubbletea/v2"14-15- "github.com/FelineStateMachine/012/internal/fileio"16- "github.com/FelineStateMachine/012/internal/sheet"17- "github.com/FelineStateMachine/012/internal/telemetry"18-)19-20-type promptKind int21-22-const (23- promptText promptKind = iota24- promptRange25- promptWidth26-)27-28-// prompt is a question on the context line, such as a file name or a29-// range to act on.30-type prompt struct {31- kind promptKind32- label string33- indicator string34- fresh bool // the default is still showing; typing replaces it35- typing bool // range prompts: the user is typing instead of pointing36- onText func(m *Model, text string) tea.Cmd37- onRange func(m *Model, r sheet.Rect) tea.Cmd38- onCancel func(m *Model)39-}40-41-func (m *Model) pointing() bool {42- return m.mode == modePrompt && m.prompt.kind == promptRange && !m.prompt.typing43-}44-45-func (m *Model) openPrompt(p *prompt, initial string) {46- m.mode = modePrompt47- m.prompt = p48- m.buf, m.bufPos = nil, 049- m.hint = ""50- m.insert(initial)51-}52-53-func (m *Model) openText(label, initial string, onText func(*Model, string) tea.Cmd) {54- m.openPrompt(&prompt{kind: promptText, label: label, indicator: "EDIT", fresh: true, onText: onText}, initial)55-}56-57-// openRange asks for a range, starting from the current selection.58-func (m *Model) openRange(label string, onRange func(*Model, sheet.Rect) tea.Cmd) {59- r := m.selection()60- m.openPrompt(&prompt{kind: promptRange, label: label, indicator: "POINT", onRange: onRange}, "")61- m.point = pointer{anchor: r.From, at: r.To, anchored: r.From != r.To}62-}63-64-func (m *Model) openGoto() {65- m.openText("Go to:", m.cur.String(), func(m *Model, text string) tea.Cmd {66- text = strings.TrimSpace(text)67- // A sheet may lead: Sheet2!A1, 'Q3 plan'!B2:C9, or just Sheet2!.68- target := m.sheet69- if name, rest := sheet.SplitSheet(text); name != "" {70- if target = m.book().Lookup(name); target == nil {71- m.fail("There's no sheet named " + name)72- return nil73- }74- if text = rest; text == "" {75- m.showSheet(target)76- return nil77- }78- }79- r, ok := sheet.ParseRange(text)80- if n, named := m.sheet.LookupName(text); named && !n.Gone() && target == m.sheet {81- r, ok, target = n.Range, true, n.Sheet82- }83- if !ok {84- m.fail("Not a cell, range or named range: " + text)85- return nil86- }87- m.showSheet(target)88- if r.From == r.To {89- m.clearSelection()90- m.cur = r.From91- return nil92- }93- m.selectRect(r)94- return nil95- })96- m.prompt.indicator = "POINT"97-}98-99-// openWidth asks for the width of the selected columns, previewing it100-// live as the arrows change it. Esc restores the original widths.101-func (m *Model) openWidth() tea.Cmd {102- r := m.selection()103- orig := map[int]int{}104- for c := r.From.Col; c <= r.To.Col; c++ {105- orig[c] = m.sheet.ColWidth(c)106- }107- restore := func(m *Model) {108- for c, w := range orig {109- m.sheet.SetColWidth(c, w)110- }111- }112- m.openPrompt(&prompt{113- kind: promptWidth,114- label: "Column width (1-240):",115- indicator: "WIDTH",116- fresh: true,117- onText: func(m *Model, text string) tea.Cmd {118- w, err := strconv.Atoi(text)119- if err != nil || w < 1 || w > 240 {120- restore(m)121- m.fail("Column width must be between 1 and 240")122- return nil123- }124- m.setWidths(w)125- m.changed = true126- return nil127- },128- onCancel: restore,129- }, strconv.Itoa(m.sheet.ColWidth(m.cur.Col)))130- return nil131-}132-133-// setWidths sets the width of every selected column.134-func (m *Model) setWidths(w int) {135- r := m.selection()136- for c := r.From.Col; c <= r.To.Col; c++ {137- m.sheet.SetColWidth(c, w)138- }139-}140-141-func (m *Model) openSave() tea.Cmd {142- name := m.filename143- if name == "" {144- name = m.displayBase() + sheet.FileExt145- }146- m.openText("Save as:", name, func(m *Model, text string) tea.Cmd {147- return saveCmd(m.sheet, withExt(text))148- })149- return nil150-}151-152-func (m *Model) openRetrieve() tea.Cmd {153- m.files = nil154- m.openText("Open file:", "", func(m *Model, text string) tea.Cmd {155- if _, ok := fileio.KindOf(text); ok {156- return m.confirmImport(text, fileio.Options{})157- }158- return loadCmd(withExt(text))159- })160- return listFilesCmd161-}162-163-// promptKey handles keys while a prompt is open.164-func (m *Model) promptKey(k tea.KeyPressMsg) tea.Cmd {165- p := m.prompt166- key := k.String()167- switch key {168- case "esc":169- if m.pointing() && m.point.anchored {170- m.point.anchored = false171- return nil172- }173- if p.onCancel != nil {174- p.onCancel(m)175- }176- m.quitAfterSave = false // cancelling Save as cancels Save and quit177- m.closePrompt()178- return nil179- case "enter":180- text := strings.TrimSpace(string(m.buf))181- m.closePrompt()182- if p.kind != promptRange {183- return p.onText(m, text)184- }185- r := m.point.rect()186- if p.typing {187- var ok bool188- if r, ok = sheet.ParseRange(text); !ok {189- m.fail("Invalid range: " + text)190- return nil191- }192- }193- return p.onRange(m, r)194- }195-196- switch {197- case m.pointing():198- if m.pointMoveKey(key) {199- return nil200- }201- if key == ":" {202- m.point.anchor, m.point.anchored = m.point.at, true203- return nil204- }205- if text := typed(k); text != "" {206- p.typing = true207- m.insert(text)208- }209- case p.kind == promptWidth && (key == "left" || key == "right"):210- w, _ := strconv.Atoi(string(m.buf))211- if key == "left" {212- w--213- } else {214- w++215- }216- w = clamp(w, 1, 240)217- m.buf, m.bufPos, p.fresh = nil, 0, false218- m.insert(strconv.Itoa(w))219- m.setWidths(w) // live preview220- default:221- if p.fresh && typed(k) != "" {222- m.buf, m.bufPos = nil, 0223- }224- p.fresh = false225- if p.kind == promptWidth && !isDigits(typed(k)) {226- return nil227- }228- m.lineKey(k)229- }230- return nil231-}232-233-func (m *Model) promptType(text string) {234- if m.prompt.fresh {235- m.buf, m.bufPos = nil, 0236- m.prompt.fresh = false237- }238- m.insert(text)239-}240-241-func (m *Model) closePrompt() {242- m.prompt = nil243- m.files = nil244- m.cancelEntry()245-}246-247-func (m *Model) fail(msg string) {248- m.mode = modeError249- m.errMsg = msg250-}251-252-// reset starts over on s, as File > New and Open do. What belongs to the253-// session rather than the sheet carries over: the window, theme, terminal254-// state and the JEV connection.255-func (m *Model) reset(s *sheet.Sheet, filename string) {256- *m = Model{sheet: s, filename: filename, width: m.width, height: m.height, th: m.th, term: m.term, jev: m.jev, lastChart: -1}257-}258-259-func isDigits(s string) bool {260- return !strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsDigit(r) })261-}262-263-func withExt(name string) string {264- if filepath.Ext(name) == "" {265- return name + sheet.FileExt266- }267- return name268-}269-270-type savedMsg struct {271- name string272- err error273-}274-275-type loadedMsg struct {276- name string277- sheet *sheet.Sheet278- err error279-}280-281-type filesMsg []string282-283-// saveCmd writes the worksheet atomically: to a temporary file first, then284-// renamed over the target.285-func saveCmd(s *sheet.Sheet, name string) tea.Cmd {286- span := telemetry.Start("save")287- var buf strings.Builder288- err := s.Write(&buf)289- if telemetry.Enabled() {290- span.End(slog.Int("cells", s.Len()), slog.Int("bytes", buf.Len()))291- }292- return func() tea.Msg {293- if err != nil {294- return savedMsg{name, err}295- }296- tmp := name + ".tmp"297- if err := os.WriteFile(tmp, []byte(buf.String()), 0o644); err != nil {298- return savedMsg{name, err}299- }300- return savedMsg{name, os.Rename(tmp, name)}301- }302-}303-304-func loadCmd(name string) tea.Cmd {305- return func() tea.Msg {306- f, err := os.Open(name)307- if err != nil {308- return loadedMsg{name: name, err: err}309- }310- defer f.Close()311- span := telemetry.Start("open")312- s, err := sheet.Read(f)313- if err != nil {314- span.Fail(err)315- } else if telemetry.Enabled() {316- span.End(slog.Int("cells", s.Len()), slog.Int64("bytes", openSize(f)))317- }318- return loadedMsg{name, s, err}319- }320-}321-322-// openSize is the size of an open file, for telemetry; -1 if unknown.323-func openSize(f *os.File) int64 {324- st, err := f.Stat()325- if err != nil {326- return -1327- }328- return st.Size()329-}330-331-func listFilesCmd() tea.Msg {332- files, _ := filepath.Glob("*" + sheet.FileExt)333- slices.Sort(files)334- return filesMsg(files)335-}336-337-func (m *Model) handleSaved(msg savedMsg) tea.Cmd {338- quit := m.quitAfterSave339- m.quitAfterSave = false340- if msg.err != nil {341- m.fail(fmt.Sprintf("Couldn't save %s: %v", msg.name, msg.err))342- return nil343- }344- m.filename = msg.name345- m.changed = false346- if quit {347- return m.exit()348- }349- return nil350-}351-352-func (m *Model) handleLoaded(msg loadedMsg) {353- if msg.err != nil {354- m.fail(fmt.Sprintf("Couldn't open %s: %v", msg.name, msg.err))355- return356- }357- m.reset(msg.sheet, msg.name)358-}
M internal/ui/menubar.go +2 -363
@@ -1,11 +1,9 @@
11 package ui22 33 import (4- "strconv"54 "strings"65 76 tea "charm.land/bubbletea/v2"8- "github.com/charmbracelet/x/ansi"97 )108 119 // The menu bar follows Google Sheets: File Edit View Insert Format Data
@@ -165,9 +163,9 @@ func barMenus() []barMenu {
165163 func (m *Model) menuBarTitles() string {166164 var b strings.Builder167165 for i, bm := range barMenus() {168- style, accel := m.th.menuBar, m.th.menuAccel166+ style, accel := m.th.MenuBar, m.th.MenuAccel169167 if i == m.openBarMenu() {170- style, accel = m.th.menuSelected, m.th.menuAccelSelected168+ style, accel = m.th.MenuSelected, m.th.MenuAccelSelected171169 }172170 t := bm.def.title173171 k := strings.IndexByte(strings.ToLower(t), bm.def.accel)
@@ -216,362 +214,3 @@ func init() {
216214 }})217215 keymap["f10"] = "menu"218216 }219-220-// menuOverlay is an open dropdown from the menu bar, or a context menu,221-// with any submenus opened from it.222-type menuOverlay struct {223- bar int // index in barMenus of the open title; -1 for a context menu224- x, y int // where a context menu opened225- levels []*menuLevel226-}227-228-// menuLevel is one open box: the dropdown or a submenu.229-type menuLevel struct {230- items []menuItem231- sel int // -1 while nothing is highlighted, e.g. a submenu opened by hovering232- top int // first item shown when the box is too tall for the screen233-}234-235-// showBarMenu opens dropdown i of barMenus.236-func (m *Model) showBarMenu(i int) {237- menus := barMenus()238- if len(menus) == 0 {239- return240- }241- i = (i%len(menus) + len(menus)) % len(menus)242- m.openOverlay(&menuOverlay{bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})243-}244-245-// showContextMenu opens a menu of items with its corner at x, y.246-func (m *Model) showContextMenu(items []menuItem, x, y int) {247- if items = visibleItems(items); len(items) > 0 {248- m.openOverlay(&menuOverlay{bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})249- }250-}251-252-func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {253- l := &menuLevel{items: items, sel: -1}254- if highlight {255- l.step(m, 1)256- }257- return l258-}259-260-func (l *menuLevel) selectable(m *Model, i int) bool {261- it := l.items[i]262- return !it.sep && (it.items != nil || commands[it.cmd].available(m))263-}264-265-// step moves the highlight to the next selectable item in direction d,266-// wrapping around.267-func (l *menuLevel) step(m *Model, d int) {268- n, i := len(l.items), l.sel269- if i < 0 && d < 0 {270- i = n271- }272- for range n {273- i = ((i+d)%n + n) % n274- if l.selectable(m, i) {275- l.sel = i276- return277- }278- }279-}280-281-func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }282-283-func (o *menuOverlay) indicator() string { return "MENU" }284-285-// key handles a key in an open menu: Up/Down highlight, Right opens a286-// submenu or the next menu, Left closes a submenu or opens the previous287-// menu, Enter runs, Esc closes one level, and a letter jumps to the items288-// starting with it, running the item if it's the only one.289-func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {290- l := o.top()291- key := k.String()292- if i := barMenuFor(key); i >= 0 {293- m.showBarMenu(i)294- return nil295- }296- switch key {297- case "up", "shift+tab":298- l.step(m, -1)299- case "down", "tab":300- l.step(m, 1)301- case "home":302- l.sel = -1303- l.step(m, 1)304- case "end":305- l.sel = -1306- l.step(m, -1)307- case "right":308- if l.sel >= 0 && l.items[l.sel].items != nil {309- return o.choose(m)310- }311- if l.sel < 0 {312- l.step(m, 1)313- } else if o.bar >= 0 {314- m.showBarMenu(o.bar + 1)315- }316- case "left":317- if len(o.levels) > 1 {318- o.levels = o.levels[:len(o.levels)-1]319- } else if o.bar >= 0 {320- m.showBarMenu(o.bar - 1)321- }322- case "enter", "space":323- if l.sel < 0 {324- l.step(m, 1)325- return nil326- }327- return o.choose(m)328- case "esc":329- if len(o.levels) > 1 {330- o.levels = o.levels[:len(o.levels)-1]331- } else {332- m.closeOverlay()333- }334- case "f10":335- m.closeOverlay()336- default:337- return o.jump(m, typed(k))338- }339- return nil340-}341-342-// jump highlights the next item starting with letter, running it if it's343-// the only one.344-func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {345- l := o.top()346- if letter == "" {347- return nil348- }349- var matches []int350- for i, it := range l.items {351- if l.selectable(m, i) && strings.HasPrefix(strings.ToLower(it.label()), strings.ToLower(letter)) {352- matches = append(matches, i)353- }354- }355- switch len(matches) {356- case 0:357- return nil358- case 1:359- l.sel = matches[0]360- return o.choose(m)361- }362- next := matches[0]363- for _, i := range matches {364- if i > l.sel {365- next = i366- break367- }368- }369- l.sel = next370- return nil371-}372-373-// choose opens the highlighted submenu or runs the highlighted command.374-func (o *menuOverlay) choose(m *Model) tea.Cmd {375- l := o.top()376- if l.sel < 0 || !l.selectable(m, l.sel) {377- return nil378- }379- it := l.items[l.sel]380- if it.items != nil {381- o.levels = append(o.levels, m.newLevel(it.items, true))382- return nil383- }384- return m.runFromOverlay(it.cmd)385-}386-387-func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }388-389-// mouse handles hovering and clicking items, switching menus by hovering390-// or clicking the menu bar, and closing the menu on a click elsewhere.391-func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {392- for lv, l := range o.levels {393- if e.box != menuBoxID(lv) {394- continue395- }396- i := l.top + e.row - 1397- if e.kind == mouseWheel {398- o.levels = o.levels[:lv+1]399- l.wheel(m, e.button)400- return nil401- }402- if e.row < 1 || i >= len(l.items) || !l.selectable(m, i) {403- return nil404- }405- switch e.kind {406- case mouseMotion:407- if i != l.sel || len(o.levels) == lv+1 {408- o.levels, l.sel = o.levels[:lv+1], i409- if sub := l.items[i].items; sub != nil {410- o.levels = append(o.levels, m.newLevel(sub, false))411- }412- }413- case mousePress:414- o.levels, l.sel = o.levels[:lv+1], i415- return o.choose(m)416- }417- return nil418- }419- if e.y == menuLine && e.box == "" {420- if t := barMenuAt(e.x); t >= 0 {421- switch {422- case e.kind == mousePress && t == o.bar:423- m.closeOverlay()424- case e.kind == mousePress, e.kind == mouseMotion && o.bar >= 0 && t != o.bar:425- m.showBarMenu(t)426- }427- return nil428- }429- }430- if e.kind == mousePress {431- m.closeOverlay()432- if e.button == tea.MouseRight {433- m.rightClick(e.x, e.y) // right-clicking elsewhere opens a menu there434- }435- }436- return nil437-}438-439-// wheel moves the highlight, which scrolls a menu too tall for the screen.440-func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {441- switch b {442- case tea.MouseWheelUp:443- l.step(m, -1)444- case tea.MouseWheelDown:445- l.step(m, 1)446- }447-}448-449-func (o *menuOverlay) status(m *Model) (string, string) {450- keys := m.keyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")451- if o.bar >= 0 {452- keys = m.keyHints("Arrows", "move", "Enter", "choose", "Esc", "close")453- }454- l := o.top()455- if l.sel < 0 {456- return "", keys457- }458- it := l.items[l.sel]459- if it.items != nil {460- names := make([]string, 0, len(it.items))461- for _, sub := range it.items {462- if !sub.sep {463- names = append(names, sub.label())464- }465- }466- return strings.Join(names, ", "), keys467- }468- return commands[it.cmd].desc, keys469-}470-471-// layout places the dropdown under its title (or a context menu at the472-// mouse) and each submenu beside the item that opened it, flipping left or473-// up where the screen runs out. The status line stays visible.474-func (o *menuOverlay) layout(m *Model) []box {475- boxes := make([]box, 0, len(o.levels))476- maxRows := max(m.height-4, 3)477- for i, l := range o.levels {478- rows := min(len(l.items), maxRows)479- if l.sel >= 0 {480- l.top = clamp(l.top, l.sel-rows+1, l.sel)481- }482- l.top = clamp(l.top, 0, len(l.items)-rows)483- lines := m.dropdown(l, rows)484- w, h := ansi.StringWidth(lines[0]), len(lines)485- var x, y int486- switch {487- case i > 0:488- p := boxes[i-1]489- x, y = p.x+p.width(), p.y+o.levels[i-1].sel-o.levels[i-1].top490- // Open to the right; else to the left; and if neither fits,491- // against the screen's right edge, where it covers the parent's492- // shortcuts rather than its labels.493- if x+w > m.width {494- x = p.x - w495- if x < 0 {496- x = m.width - w497- }498- }499- case o.bar >= 0:500- x, y = barMenus()[o.bar].x, menuLine+1501- default:502- x, y = o.x, o.y503- if y+h > m.height-1 {504- y = o.y - h505- }506- }507- x = clamp(x, 0, m.width-w)508- y = clamp(y, 0, m.height-1-h)509- boxes = append(boxes, box{id: menuBoxID(i), x: x, y: y, lines: lines})510- }511- return boxes512-}513-514-// dropdown draws a menu box: each item with its shortcut right-aligned, a515-// › for submenus, separators between groups, unavailable items dimmed and516-// arrows in the border when the items don't all fit.517-func (m *Model) dropdown(l *menuLevel, rows int) []string {518- lw, kw := 0, 0519- for _, it := range l.items {520- lw = max(lw, ansi.StringWidth(it.label()))521- kw = max(kw, ansi.StringWidth(m.itemKey(it, false)))522- }523- inner := max(lw+kw+5, 18)524- out := make([]string, 0, rows)525- for i := l.top; i < l.top+rows; i++ {526- it := l.items[i]527- if it.sep {528- out = append(out, sepRow)529- continue530- }531- style := m.th.menuBar532- switch {533- case i == l.sel:534- style = m.th.menuSelected535- case !l.selectable(m, i):536- style = m.th.disabled537- }538- k := m.itemKey(it, !l.selectable(m, i))539- if it.items != nil || i == l.sel {540- // The highlight runs unbroken across the row.541- k = style.Render(ansi.Strip(k))542- }543- out = append(out, cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))544- }545- var up, down string546- if l.top > 0 {547- up = "▲"548- }549- if l.top+rows < len(l.items) {550- down = "▼"551- }552- return m.frame(inner, up, down, out)553-}554-555-// isChecked reports whether the command is a setting that is on.556-func (m *Model) isChecked(id string) bool {557- c := commands[id]558- return c != nil && c.checked != nil && c.checked(m)559-}560-561-// itemKey is what an item shows on the right: its shortcut as a key chip562-// (plain when the item is unavailable), › for a submenu, or ✓ for a563-// setting that is on.564-func (m *Model) itemKey(it menuItem, disabled bool) string {565- k := shortcut(it.cmd)566- switch {567- case it.items != nil:568- return "›"569- case k == "" && m.isChecked(it.cmd):570- return "✓"571- case k == "":572- return ""573- case disabled:574- return m.th.disabled.Render(" " + k + " ")575- }576- return m.chip(k)577-}
A internal/ui/menuoverlay.go +381 -0
@@ -0,0 +1,381 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// menuOverlay is an open dropdown from the menu bar, or a context menu,14+// with any submenus opened from it.15+type menuOverlay struct {16+ bar int // index in barMenus of the open title; -1 for a context menu17+ x, y int // where a context menu opened18+ levels []*menuLevel19+}20+21+// menuLevel is one open box: the dropdown or a submenu.22+type menuLevel struct {23+ items []menuItem24+ sel int // -1 while nothing is highlighted, e.g. a submenu opened by hovering25+ top int // first item shown when the box is too tall for the screen26+}27+28+// showBarMenu opens dropdown i of barMenus.29+func (m *Model) showBarMenu(i int) {30+ menus := barMenus()31+ if len(menus) == 0 {32+ return33+ }34+ i = (i%len(menus) + len(menus)) % len(menus)35+ m.openOverlay(&menuOverlay{bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})36+}37+38+// showContextMenu opens a menu of items with its corner at x, y.39+func (m *Model) showContextMenu(items []menuItem, x, y int) {40+ if items = visibleItems(items); len(items) > 0 {41+ m.openOverlay(&menuOverlay{bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})42+ }43+}44+45+func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {46+ l := &menuLevel{items: items, sel: -1}47+ if highlight {48+ l.step(m, 1)49+ }50+ return l51+}52+53+func (l *menuLevel) selectable(m *Model, i int) bool {54+ it := l.items[i]55+ return !it.sep && (it.items != nil || commands[it.cmd].available(m))56+}57+58+// step moves the highlight to the next selectable item in direction d,59+// wrapping around.60+func (l *menuLevel) step(m *Model, d int) {61+ n, i := len(l.items), l.sel62+ if i < 0 && d < 0 {63+ i = n64+ }65+ for range n {66+ i = ((i+d)%n + n) % n67+ if l.selectable(m, i) {68+ l.sel = i69+ return70+ }71+ }72+}73+74+func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }75+76+func (o *menuOverlay) indicator() string { return "MENU" }77+78+// key handles a key in an open menu: Up/Down highlight, Right opens a79+// submenu or the next menu, Left closes a submenu or opens the previous80+// menu, Enter runs, Esc closes one level, and a letter jumps to the items81+// starting with it, running the item if it's the only one.82+func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {83+ l := o.top()84+ key := k.String()85+ if i := barMenuFor(key); i >= 0 {86+ m.showBarMenu(i)87+ return nil88+ }89+ switch key {90+ case "up", "shift+tab":91+ l.step(m, -1)92+ case "down", "tab":93+ l.step(m, 1)94+ case "home":95+ l.sel = -196+ l.step(m, 1)97+ case "end":98+ l.sel = -199+ l.step(m, -1)100+ case "right":101+ if l.sel >= 0 && l.items[l.sel].items != nil {102+ return o.choose(m)103+ }104+ if l.sel < 0 {105+ l.step(m, 1)106+ } else if o.bar >= 0 {107+ m.showBarMenu(o.bar + 1)108+ }109+ case "left":110+ if len(o.levels) > 1 {111+ o.levels = o.levels[:len(o.levels)-1]112+ } else if o.bar >= 0 {113+ m.showBarMenu(o.bar - 1)114+ }115+ case "enter", "space":116+ if l.sel < 0 {117+ l.step(m, 1)118+ return nil119+ }120+ return o.choose(m)121+ case "esc":122+ if len(o.levels) > 1 {123+ o.levels = o.levels[:len(o.levels)-1]124+ } else {125+ m.closeOverlay()126+ }127+ case "f10":128+ m.closeOverlay()129+ default:130+ return o.jump(m, typed(k))131+ }132+ return nil133+}134+135+// jump highlights the next item starting with letter, running it if it's136+// the only one.137+func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {138+ l := o.top()139+ if letter == "" {140+ return nil141+ }142+ var matches []int143+ for i, it := range l.items {144+ if l.selectable(m, i) && strings.HasPrefix(strings.ToLower(it.label()), strings.ToLower(letter)) {145+ matches = append(matches, i)146+ }147+ }148+ switch len(matches) {149+ case 0:150+ return nil151+ case 1:152+ l.sel = matches[0]153+ return o.choose(m)154+ }155+ next := matches[0]156+ for _, i := range matches {157+ if i > l.sel {158+ next = i159+ break160+ }161+ }162+ l.sel = next163+ return nil164+}165+166+// choose opens the highlighted submenu or runs the highlighted command.167+func (o *menuOverlay) choose(m *Model) tea.Cmd {168+ l := o.top()169+ if l.sel < 0 || !l.selectable(m, l.sel) {170+ return nil171+ }172+ it := l.items[l.sel]173+ if it.items != nil {174+ o.levels = append(o.levels, m.newLevel(it.items, true))175+ return nil176+ }177+ return m.runFromOverlay(it.cmd)178+}179+180+func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }181+182+// mouse handles hovering and clicking items, switching menus by hovering183+// or clicking the menu bar, and closing the menu on a click elsewhere.184+func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {185+ for lv, l := range o.levels {186+ if e.box == menuBoxID(lv) {187+ return o.levelMouse(m, e, lv, l)188+ }189+ }190+ if e.y == menuLine && e.box == "" {191+ if t := barMenuAt(e.x); t >= 0 {192+ o.barMouse(m, e, t)193+ return nil194+ }195+ }196+ if e.kind == mousePress {197+ m.closeOverlay()198+ if e.button == tea.MouseRight {199+ m.rightClick(e.x, e.y) // right-clicking elsewhere opens a menu there200+ }201+ }202+ return nil203+}204+205+// levelMouse handles the mouse over open box lv: hovering highlights an206+// item and opens its submenu, clicking chooses it, the wheel moves.207+func (o *menuOverlay) levelMouse(m *Model, e mouseEvent, lv int, l *menuLevel) tea.Cmd {208+ i := l.top + e.row - 1209+ if e.kind == mouseWheel {210+ o.levels = o.levels[:lv+1]211+ l.wheel(m, e.button)212+ return nil213+ }214+ if e.row < 1 || i >= len(l.items) || !l.selectable(m, i) {215+ return nil216+ }217+ switch e.kind {218+ case mouseMotion:219+ if i != l.sel || len(o.levels) == lv+1 {220+ o.levels, l.sel = o.levels[:lv+1], i221+ if sub := l.items[i].items; sub != nil {222+ o.levels = append(o.levels, m.newLevel(sub, false))223+ }224+ }225+ case mousePress:226+ o.levels, l.sel = o.levels[:lv+1], i227+ return o.choose(m)228+ }229+ return nil230+}231+232+// barMouse handles the mouse over menu bar title t: clicking the open233+// title closes its menu, and clicking or hovering another opens that one.234+func (o *menuOverlay) barMouse(m *Model, e mouseEvent, t int) {235+ switch {236+ case e.kind == mousePress && t == o.bar:237+ m.closeOverlay()238+ case e.kind == mousePress, e.kind == mouseMotion && o.bar >= 0 && t != o.bar:239+ m.showBarMenu(t)240+ }241+}242+243+// wheel moves the highlight, which scrolls a menu too tall for the screen.244+func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {245+ switch b {246+ case tea.MouseWheelUp:247+ l.step(m, -1)248+ case tea.MouseWheelDown:249+ l.step(m, 1)250+ }251+}252+253+func (o *menuOverlay) status(m *Model) (string, string) {254+ keys := m.th.KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")255+ if o.bar >= 0 {256+ keys = m.th.KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")257+ }258+ l := o.top()259+ if l.sel < 0 {260+ return "", keys261+ }262+ it := l.items[l.sel]263+ if it.items != nil {264+ names := make([]string, 0, len(it.items))265+ for _, sub := range it.items {266+ if !sub.sep {267+ names = append(names, sub.label())268+ }269+ }270+ return strings.Join(names, ", "), keys271+ }272+ return commands[it.cmd].desc, keys273+}274+275+// layout places the dropdown under its title (or a context menu at the276+// mouse) and each submenu beside the item that opened it, flipping left or277+// up where the screen runs out. The status line stays visible.278+func (o *menuOverlay) layout(m *Model) []box {279+ boxes := make([]box, 0, len(o.levels))280+ maxRows := max(m.height-4, 3)281+ for i, l := range o.levels {282+ rows := min(len(l.items), maxRows)283+ if l.sel >= 0 {284+ l.top = clamp(l.top, l.sel-rows+1, l.sel)285+ }286+ l.top = clamp(l.top, 0, len(l.items)-rows)287+ lines := l.lines(m, rows)288+ w, h := ansi.StringWidth(lines[0]), len(lines)289+ var x, y int290+ switch {291+ case i > 0:292+ p := boxes[i-1]293+ x, y = p.x+p.width(), p.y+o.levels[i-1].sel-o.levels[i-1].top294+ // Open to the right; else to the left; and if neither fits,295+ // against the screen's right edge, where it covers the parent's296+ // shortcuts rather than its labels.297+ if x+w > m.width {298+ x = p.x - w299+ if x < 0 {300+ x = m.width - w301+ }302+ }303+ case o.bar >= 0:304+ x, y = barMenus()[o.bar].x, menuLine+1305+ default:306+ x, y = o.x, o.y307+ if y+h > m.height-1 {308+ y = o.y - h309+ }310+ }311+ x = clamp(x, 0, m.width-w)312+ y = clamp(y, 0, m.height-1-h)313+ boxes = append(boxes, box{id: menuBoxID(i), x: x, y: y, lines: lines})314+ }315+ return boxes316+}317+318+// lines draws the box: each item with its shortcut right-aligned, a319+// › for submenus, separators between groups, unavailable items dimmed and320+// arrows in the border when the items don't all fit.321+func (l *menuLevel) lines(m *Model, rows int) []string {322+ lw, kw := 0, 0323+ for _, it := range l.items {324+ lw = max(lw, ansi.StringWidth(it.label()))325+ kw = max(kw, ansi.StringWidth(it.key(m, false)))326+ }327+ inner := max(lw+kw+5, 18)328+ out := make([]string, 0, rows)329+ for i := l.top; i < l.top+rows; i++ {330+ it := l.items[i]331+ if it.sep {332+ out = append(out, theme.SepRow)333+ continue334+ }335+ style := m.th.MenuBar336+ switch {337+ case i == l.sel:338+ style = m.th.MenuSelected339+ case !l.selectable(m, i):340+ style = m.th.Disabled341+ }342+ k := it.key(m, !l.selectable(m, i))343+ if it.items != nil || i == l.sel {344+ // The highlight runs unbroken across the row.345+ k = style.Render(ansi.Strip(k))346+ }347+ out = append(out, theme.Cells(style, " "+it.label(), inner-ansi.StringWidth(k)-1)+k+style.Render(" "))348+ }349+ var up, down string350+ if l.top > 0 {351+ up = "▲"352+ }353+ if l.top+rows < len(l.items) {354+ down = "▼"355+ }356+ return m.th.Frame(inner, up, down, out)357+}358+359+// isChecked reports whether the command is a setting that is on.360+func (m *Model) isChecked(id string) bool {361+ c := commands[id]362+ return c != nil && c.checked != nil && c.checked(m)363+}364+365+// key is what an item shows on the right: its shortcut as a key chip366+// (plain when the item is unavailable), › for a submenu, or ✓ for a367+// setting that is on.368+func (it menuItem) key(m *Model, disabled bool) string {369+ k := shortcut(it.cmd)370+ switch {371+ case it.items != nil:372+ return "›"373+ case k == "" && m.isChecked(it.cmd):374+ return "✓"375+ case k == "":376+ return ""377+ case disabled:378+ return m.th.Disabled.Render(" " + k + " ")379+ }380+ return m.th.Chip(k)381+}
M internal/ui/model.go +56 -170
@@ -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.
@@ -45,89 +46,58 @@ const (
4546 // doubleClick is the longest gap between two clicks that edits a cell.4647 const doubleClick = 400 * time.Millisecond4748 48-// Model is the whole application state.49+// Model is the root of the UI, the tea.Model Bubble Tea runs. It holds50+// the file, the mode and the grid, owns a component for everything that51+// takes input or draws a part of the screen, routes each message to the52+// one it's for, and composes the screen from what they draw (panel.go,53+// view.go, overlay.go).4954 type Model struct {50- sheet *sheet.Sheet // the sheet shown; its workbook is the file51- filename string52- changed bool53- saved int // the sheet's StateID when last saved or loaded54-55- copied clipboard // see clipboard.go56- note string // feedback on the last action, e.g. "Undid: clear B3"57-55+ // grid is the sheet shown, the active cell, the scroll position, the56+ // window size and the selection; see grid.go.57+ grid58+59+ // The file.60+ filename string61+ changed bool62+ saved int // the sheet's StateID when last saved or loaded5863 quitAfterSave bool // "Save and quit" is waiting for the save to finish5964 60- cur sheet.Addr // the active cell61- top, left int // first visible row and column62- width, height int63-64- // Selection: see selection.go.65- selecting bool66- ext sheet.Addr // the moving corner of the selection67- whole wholeKind68-69- // Mouse: see mouse.go.70- drag dragKind71- lastClick time.Time72- lastHit hit73- hover hit // what's under the mouse, for hover styling74- mouseX, mouseY int // last mouse position, for autoscroll75- autoscrolling bool // an autoscroll tick is pending76- resizeCol int // column being resized by its header border77- fillAt sheet.Addr // where a fill handle drag points, see fill.go78- fillTo sheet.Rect // the range that drag would fill79- shape string // pointer shape last sent to the terminal80-81- // Sheets: see tabs.go.82- home *sheet.Sheet // while pointing into another sheet, the entry's sheet83- places map[*sheet.Sheet]place // where each sheet's cursor and scroll were left84- tabLeft int // the first tab shown when they don't all fit85-86- // tabStart remembers where a run of Tab-committed entries began, so87- // Enter returns to that column on the next row, as in Sheets.88- tabStart int89- tabbing bool90-91- mode mode92-93- // buf is the edit line used by ENTER, EDIT and text prompts.94- buf []rune95- bufPos int96- hint string // shown on the third panel line, e.g. a formula error97-98- point pointer // POINT mode and range prompts99- pointPrefix string // entry text before the reference being pointed at100- pointSuffix string // entry text after the caret while pointing101- assist assist // function and name suggestions while typing, see assist.go102-103- trace *trace // precedents or dependents being shown, see trace.go104-105- overlay overlay // open menu, palette or dialog, if any (modeMenu)106- lastFind *findBar // the last search, reopened by Ctrl+F107- jev *jevRunner // answers JEV functions; nil without an API key108- xfer transfer // imports and downloads, see transfer.go109- prompt *prompt110- files []string // file list shown by File Open111- errMsg string112-113- term terminal // what the terminal supports, see graphics.go114- lastChart int // the chart last selected, for chart commands; -1 for none65+ mode mode66+ note string // feedback on the last action, e.g. "Undid: clear B3"67+ errMsg string // the message ERROR mode shows68+69+ // Components. Each owns its state and the handling of the input it70+ // takes; Model routes messages to them and composes what they draw.71+ line lineEdit // the edit line of entries, prompts and search fields: line.go72+ entry entry // typing into a cell: entry.go73+ point pointer // the cell or range pointed at in POINT mode and range prompts74+ prompt *prompt // a question on the context line: prompt.go75+ overlay overlay // the open menu, picker or bar, if any (modeMenu): overlay.go76+ mouse mouseState // drags, hover and double clicks: mouse.go77+ tabs tabStrip // the sheet tabs and where each sheet was left: tabstrip.go78+ find *findBar // the last search, reopened by Ctrl+F: find.go79+ charts chartState // chart commands' target: charts.go80+ copied clipboard // what Ctrl+V pastes: clipboard.go81+ trace *trace // precedents or dependents being shown: trace.go82+ xfer transfer // imports and downloads: transfer.go83+ jev *jevRunner // answers JEV functions; nil without an API key: jev.go84+ term terminal // what the terminal supports: graphics.go11585 11686 keyAt time.Time // when the key the next frame answers was pressed, for telemetry11787 118- th theme88+ th theme.Theme11989 }12090 12191 // New returns a model editing s. filename may be empty.12292 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}93+ return &Model{grid: grid{sheet: s, width: 80, height: 24}, filename: filename, th: theme.New(true), term: newTerminal(), charts: chartState{last: -1}}12494 }12595 12696 // Init implements tea.Model. It asks the terminal for its background color12797 // so the theme can adapt to light terminals.12898 func (m *Model) Init() tea.Cmd {129- // sendJEV starts any questions queued while loading the file.130- return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.probes(), m.sendJEV(), m.startupCmd())99+ // jev.send starts any questions queued while loading the file.100+ return tea.Batch(tea.RequestBackgroundColor, tea.Raw(shiftEscapeOn), m.term.probes(), m.jev.send(), m.startupCmd())131101 }132102 133103 // Update implements tea.Model.
@@ -152,7 +122,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
152122 m.width, m.height = msg.Width, msg.Height153123 beforeMode = -1 // keep the focus visible after a resize154124 case tea.BackgroundColorMsg:155- m.th = newTheme(msg.IsDark())125+ m.th = theme.New(msg.IsDark())156126 case tea.KeyPressMsg:157127 cmd = m.handleKey(msg)158128 case tea.PasteMsg:
@@ -175,17 +145,15 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
175145 case loadedMsg:176146 m.handleLoaded(msg)177147 case filesMsg:178- m.files = msg179- case jevAnswerMsg:180- busy := m.jevBusy() != ""181- m.handleJEVAnswer(msg)182- if busy && m.jevBusy() == "" {183- cmd = m.notifyDone("JEV finished answering in " + m.displayName())148+ if m.prompt != nil {149+ m.prompt.files = msg184150 }151+ case jevAnswerMsg:152+ cmd = m.answerJEV(msg)185153 case importedMsg, importTickMsg, exportedMsg:186154 cmd = m.handleTransfer(msg)187155 default:188- cmd = m.handleTerminal(msg)156+ cmd = m.term.handle(msg)189157 }190158 // Scroll only when the focus moves, so the mouse wheel can look around191159 // without the view snapping back, as in Sheets.
@@ -196,7 +164,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
196164 m.clampView()197165 // Any edit may have queued JEV questions.198166 // Chart images follow any change, see graphics.go.199- return m, tea.Batch(cmd, m.sendJEV(), m.syncImages())167+ return m, tea.Batch(cmd, m.jev.send(), m.term.syncImages(m.sheet, m.displayCharts, &m.th))200168 }201169 202170 // beginUpdate prepares for an input event and returns the sheet's state
@@ -225,14 +193,14 @@ func (m *Model) endUpdate(state int) {
225193 // An edit, a sort or a filter may hide the active cell's row.226194 m.cur.Row = m.visibleRow(m.cur.Row)227195 if !m.copied.keep {228- m.clearCopyMark()196+ m.copied.clearMark()229197 }230198 }231199 m.copied.keep = false232200 }233201 234202 func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {235- if m.drag == dragFill {203+ if m.mouse.drag == dragFill {236204 if k.String() == "esc" {237205 m.cancelFill()238206 }
@@ -253,7 +221,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
253221 case modeMenu:254222 return m.overlay.key(m, k)255223 case modePrompt:256- return m.promptKey(k)224+ return m.prompt.key(m, k)257225 case modeError:258226 m.errMsg = ""259227 m.mode = modeReady
@@ -264,7 +232,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
264232 func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {265233 key := k.String()266234 if m.moveKey(key) {267- m.tabbing = false235+ m.entry.tabbing = false268236 return nil269237 }270238 if i := barMenuFor(key); i >= 0 {
@@ -280,75 +248,6 @@ func (m *Model) readyKey(k tea.KeyPressMsg) tea.Cmd {
280248 return nil281249 }282250 283-// navigate applies a movement key to a, returning false if it isn't one.284-// Keys follow Google Sheets.285-func (m *Model) navigate(key string, a *sheet.Addr) bool {286- rows, cols := m.scrollRows(), m.visibleCols(m.left)287- switch key {288- case "up":289- a.Row = m.stepRow(a.Row, -1)290- case "down":291- a.Row = m.stepRow(a.Row, 1)292- case "left", "shift+tab":293- a.Col--294- case "right", "tab":295- a.Col++296- case "ctrl+up":297- *a = m.sheet.Edge(*a, 0, -1)298- case "ctrl+down":299- *a = m.sheet.Edge(*a, 0, 1)300- case "ctrl+left":301- *a = m.sheet.Edge(*a, -1, 0)302- case "ctrl+right", "end":303- *a = m.sheet.Edge(*a, 1, 0)304- case "pgup":305- a.Row = m.stepRow(a.Row, -rows)306- m.top = m.stepRow(m.top, -rows)307- case "pgdown":308- a.Row = m.stepRow(a.Row, rows)309- m.top = m.stepRow(m.top, rows)310- case "alt+pgup":311- a.Col -= cols312- m.left -= cols313- case "alt+pgdown":314- a.Col += cols315- m.left += cols316- case "home":317- a.Col = 0318- case "ctrl+home":319- *a = sheet.Addr{}320- case "ctrl+end":321- used, _ := m.sheet.UsedRange()322- *a = used.To323- default:324- return false325- }326- *a = clampAddr(*a)327- a.Row = m.visibleRow(a.Row)328- // Moving into the frozen panes by keyboard scrolls the rest back to329- // the start, as in Sheets (Ctrl+Home shows A1 with row 2 under it).330- if fr, fc := m.frozen(); a.Row < fr || a.Col < fc {331- if a.Row < fr {332- m.top = 0333- }334- if a.Col < fc {335- m.left = 0336- }337- }338- m.clampView()339- return true340-}341-342-func isMoveKey(key string) bool {343- switch key {344- case "up", "down", "left", "right", "tab", "shift+tab", "pgup", "pgdown",345- "alt+pgup", "alt+pgdown", "home", "end", "ctrl+home", "ctrl+end",346- "ctrl+up", "ctrl+down", "ctrl+left", "ctrl+right":347- return true348- }349- return false350-}351-352251 func (m *Model) handleWheel(mouse tea.Mouse) {353252 const step = 3354253 button := mouse.Button
@@ -379,32 +278,14 @@ func (m *Model) focus() *sheet.Addr {
379278 switch {380279 case m.mode == modePoint || m.pointing() || m.away():381280 return &m.point.at382- case m.drag == dragFill:383- return &m.fillAt281+ case m.mouse.drag == dragFill:282+ return &m.mouse.fillAt384283 case m.selecting && m.whole == wholeNone:385284 return &m.ext386285 }387286 return &m.cur388287 }389288 390-func (m *Model) visibleRows() int {391- return max(m.height-gridTop-1, 1)392-}393-394-// visibleCols returns how many whole scrolling columns fit starting at395-// left.396-func (m *Model) visibleCols(left int) int {397- n, w := 0, m.scrollX()398- for c := left; c < sheet.MaxCols; c++ {399- w += m.sheet.ColWidth(c)400- if w > m.width {401- break402- }403- n++404- }405- return max(n, 1)406-}407-408289 // typed returns the printable text of a key press, if any.409290 func typed(k tea.KeyPressMsg) string {410291 if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {
@@ -423,3 +304,8 @@ func clampAddr(a sheet.Addr) sheet.Addr {
423304 Row: clamp(a.Row, 0, sheet.MaxRows-1),424305 }425306 }307+308+func (m *Model) fail(msg string) {309+ m.mode = modeError310+ m.errMsg = msg311+}
M internal/ui/model_test.go +7 -7

Not shown: this page already shows 6000 lines of diff.

M internal/ui/mouse.go +68 -190

Not shown: this page already shows 6000 lines of diff.

M internal/ui/mouse_test.go +13 -13

Not shown: this page already shows 6000 lines of diff.

M internal/ui/names.go +6 -6

Not shown: this page already shows 6000 lines of diff.

M internal/ui/overlay.go +4 -51

Not shown: this page already shows 6000 lines of diff.

M internal/ui/palette.go +25 -49

Not shown: this page already shows 6000 lines of diff.

M internal/ui/palette_test.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/panel.go +97 -107

Not shown: this page already shows 6000 lines of diff.

M internal/ui/panes.go +183 -50

Not shown: this page already shows 6000 lines of diff.

M internal/ui/panes_test.go +3 -3

Not shown: this page already shows 6000 lines of diff.

A internal/ui/prompt.go +190 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/rowtext/rowtext.go +228 -0

Not shown: this page already shows 6000 lines of diff.

R internal/ui/view_test.go → internal/ui/rowtext/rowtext_test.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/selection.go +138 -34

Not shown: this page already shows 6000 lines of diff.

M internal/ui/sort.go +15 -15

Not shown: this page already shows 6000 lines of diff.

M internal/ui/stress_test.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M internal/ui/tabs.go +35 -249

Not shown: this page already shows 6000 lines of diff.

A internal/ui/tabstrip.go +248 -0

Not shown: this page already shows 6000 lines of diff.

D internal/ui/theme.go +0 -191

Not shown: this page already shows 6000 lines of diff.

A internal/ui/theme/theme.go +196 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/theme/widgets.go +140 -0

Not shown: this page already shows 6000 lines of diff.

A internal/ui/theme/widgets_test.go +44 -0

Not shown: this page already shows 6000 lines of diff.

M internal/ui/trace.go +19 -18

Not shown: this page already shows 6000 lines of diff.

M internal/ui/trace_test.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M internal/ui/transfer.go +43 -184

Not shown: this page already shows 6000 lines of diff.

M internal/ui/transfer_test.go +5 -5

Not shown: this page already shows 6000 lines of diff.

M internal/ui/view.go +51 -256

Not shown: this page already shows 6000 lines of diff.