cat / 012
clone

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

Move the overlay contract into internal/ui/overlay

Dami committed fe630b6
Overlays no longer take the model on every call: the Overlay, Text and
Liner interfaces, Box, MouseEvent and List live in a leaf package, and
each overlay holds the host it acts on (for now the model itself), so a
component can move to a package of its own.
commit
fe630b6b7696d9e4122b8b215f6ab4e5b0f2734a
parent
100955bb936c9c26436c4dbec3c1d8487a8c1853
author
Dami <dami.etoile@gmail.com>
34 files changed, +590 -481
Minternal/ui/assist.go17+++--
Minternal/ui/assist_test.go2+-
Minternal/ui/charteditor.go36+++++-----
Minternal/ui/charts.go13++--
Minternal/ui/chartsel.go56+++++++++-------
Minternal/ui/cmdline.go91++++++++++++++------------
Minternal/ui/context_test.go10+-
Minternal/ui/dialog.go30+++++---
Minternal/ui/download.go3+-
Minternal/ui/entry.go5+-
Minternal/ui/file.go1+
Minternal/ui/filter.go94+++++++++++++++------------
Minternal/ui/find.go22++++---
Minternal/ui/findview.go33+++++----
Minternal/ui/help.go27+++++---
Minternal/ui/macro.go1+
Minternal/ui/macro_test.go2+-
Minternal/ui/macromanage.go22+++---
Minternal/ui/menuoverlay.go72+++++++++++---------
Minternal/ui/model.go33+++++-----
Minternal/ui/names.go20+++---
Minternal/ui/overlay.go105+++++-------------------------
Ainternal/ui/overlay/overlay.go97++++++++++++++++++++++++++++
Minternal/ui/palette.go84+++++++++++++-----------
Minternal/ui/palette_test.go18+++---
Minternal/ui/panel.go20++----
Minternal/ui/pivotactions.go13++-
Minternal/ui/pivoteditor.go64++++++++++--------
Minternal/ui/shell_test.go10+-
Minternal/ui/sort.go37++++++-----
Minternal/ui/tabs.go4+-
Minternal/ui/tabs_test.go4+-
Minternal/ui/themepicker.go24++++---
Minternal/ui/transfer.go1+
M internal/ui/assist.go +9 -8
@@ -10,6 +10,7 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/sheet"1212 "github.com/FelineStateMachine/012/internal/ui/formula"13+ "github.com/FelineStateMachine/012/internal/ui/overlay"1314 "github.com/FelineStateMachine/012/internal/ui/theme"1415 )1516
@@ -149,14 +150,14 @@ func (a *assist) accept(m *Model, s suggestion, start int) {
149150 150151 // box draws the list under the formula bar, its text aligned with151152 // the word being typed: the name, then the signature or range dimmed.152-func (a *assist) box(m *Model) (box, bool) {153+func (a *assist) box(m *Model) (overlay.Box, bool) {153154 list, start := a.shown(m)154155 if list == nil {155- return box{}, false156+ return overlay.Box{}, false156157 }157158 rows := min(len(list), assistRows, m.height-gridTop-2)158159 if rows < 1 {159- return box{}, false160+ return overlay.Box{}, false160161 }161162 a.top = clamp(a.top, a.sel-rows+1, a.sel)162163 a.top = clamp(a.top, 0, len(list)-rows)
@@ -187,7 +188,7 @@ func (a *assist) box(m *Model) (box, bool) {
187188 // The box's text starts one column in, under the word's first letter.188189 x := formulaBarTextX() + ansi.StringWidth(string(m.line.buf[:start])) - 2189190 x = clamp(x, 0, max(m.width-inner-2, 0))190- return box{id: assistID, x: x, y: contextLine + 1, lines: m.th.Frame(inner, "", footer, lines)}, true191+ return overlay.Box{ID: assistID, X: x, Y: contextLine + 1, Lines: m.th.Frame(inner, "", footer, lines)}, true191192 }192193 193194 // mouse lets the mouse hover and click suggestions and scroll the
@@ -198,12 +199,12 @@ func (a *assist) mouse(m *Model, msg tea.MouseMsg) bool {
198199 return false199200 }200201 mouse := msg.Mouse()201- h := compositor([]box{b}).Hit(mouse.X, mouse.Y)202+ h := compositor([]overlay.Box{b}).Hit(mouse.X, mouse.Y)202203 if h.Empty() {203204 return false204205 }205206 list, start := a.shown(m)206- i := a.top + mouse.Y - b.y - 1207+ i := a.top + mouse.Y - b.Y - 1207208 switch msg.(type) {208209 case tea.MouseWheelMsg:209210 d := 1
@@ -212,11 +213,11 @@ func (a *assist) mouse(m *Model, msg tea.MouseMsg) bool {
212213 }213214 a.sel = clamp(a.sel+d, 0, len(list)-1)214215 case tea.MouseMotionMsg:215- if i >= a.top && i < min(len(list), a.top+b.height()-2) {216+ if i >= a.top && i < min(len(list), a.top+b.Height()-2) {216217 a.sel = i217218 }218219 case tea.MouseClickMsg:219- if mouse.Button == tea.MouseLeft && i >= a.top && i < min(len(list), a.top+b.height()-2) {220+ if mouse.Button == tea.MouseLeft && i >= a.top && i < min(len(list), a.top+b.Height()-2) {220221 a.accept(m, list[i], start)221222 }222223 }
M internal/ui/assist_test.go +1 -1
M internal/ui/charteditor.go +18 -18
@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/x/ansi"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"1112 )1213 1314 // chartEditor is the chart editor: a bar on the context line, in the
@@ -15,6 +16,7 @@ import (
1516 // as toggles. Changes show at once; Enter keeps them and Esc undoes them,1617 // removing a chart that was just inserted.1718 type chartEditor struct {19+ m *Model // the model it acts on1820 i int1921 start int // the sheet's state before editing, to undo back to2022 isNew bool
@@ -27,12 +29,12 @@ func (m *Model) openChartEditor(i int, isNew bool, start int) {
2729 return2830 }2931 m.charts.last = i30- m.openOverlay(&chartEditor{i: i, start: start, isNew: isNew})32+ m.openOverlay(&chartEditor{m: m, i: i, start: start, isNew: isNew})3133 }3234 33-func (e *chartEditor) indicator() string { return "CHART" }35+func (e *chartEditor) Indicator() string { return "CHART" }3436 35-func (e *chartEditor) layout(*Model) []box { return nil }37+func (e *chartEditor) Layout() []overlay.Box { return nil }3638 3739 func (e *chartEditor) chart(m *Model) sheet.Chart { return m.sheet.Charts()[e.i] }3840
@@ -44,7 +46,8 @@ func (e *chartEditor) set(m *Model, fn func(c *sheet.Chart)) {
4446 m.changed = m.sheet.StateID() != m.saved4547 }4648 47-func (e *chartEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {49+func (e *chartEditor) Key(k tea.KeyPressMsg) tea.Cmd {50+ m := e.m4851 if e.i >= len(m.sheet.Charts()) {4952 m.closeOverlay()5053 return nil
@@ -176,7 +179,8 @@ func (e *chartEditor) spans(m *Model) ([]editorPart, []int) {
176179 }177180 }178181 179-func (e *chartEditor) contextLine(m *Model) (string, string) {182+func (e *chartEditor) ContextLine() (string, string) {183+ m := e.m180184 if e.i >= len(m.sheet.Charts()) {181185 return "", ""182186 }
@@ -189,25 +193,27 @@ func (e *chartEditor) contextLine(m *Model) (string, string) {
189193 return b.String(), ""190194 }191195 192-func (e *chartEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {193- if ev.kind != mousePress {196+func (e *chartEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {197+ m := e.m198+ if ev.Kind != overlay.MousePress {194199 return nil195200 }196- if ev.y != contextLine {201+ if ev.Y != contextLine {197202 // A click elsewhere keeps the changes and acts as usual.198203 m.closeOverlay()199- return m.handlePress(tea.Mouse{X: ev.x, Y: ev.y, Button: ev.button})204+ return m.handlePress(tea.Mouse{X: ev.X, Y: ev.Y, Button: ev.Button})200205 }201206 parts, xs := e.spans(m)202207 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})208+ if ev.X >= xs[i] && ev.X < xs[i]+ansi.StringWidth(p.text) {209+ return e.Key(tea.KeyPressMsg{Code: rune(p.key[0]), Text: p.key})205210 }206211 }207212 return nil208213 }209214 210-func (e *chartEditor) status(m *Model) (string, string) {215+func (e *chartEditor) Status() (string, string) {216+ m := e.m211217 pairs := []string{"←/→", "type", "S", "switch rows/columns", "H", "header", "L", "labels", "R", "range", "T", "title", "Enter", "done", "Esc", "cancel"}212218 for {213219 keys := m.th.KeyHints(pairs...)
@@ -218,12 +224,6 @@ func (e *chartEditor) status(m *Model) (string, string) {
218224 }219225 }220226 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-227227 // Editor keys, also the toggles' mouse targets.228228 const (229229 editorSwitch = "s"
M internal/ui/charts.go +7 -6
@@ -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/overlay"1314 "github.com/FelineStateMachine/012/internal/ui/theme"1415 )1516
@@ -190,8 +191,8 @@ func (m *Model) selectedChart() int {
190191 191192 // chartBoxes draws the charts on screen, clipped to the grid, bottom192193 // first.193-func (m *Model) chartBoxes() []box {194- var boxes []box194+func (m *Model) chartBoxes() []overlay.Box {195+ var boxes []overlay.Box195196 sel := m.selectedChart()196197 top, bottom := gridTop, gridTop+m.visibleRows()197198 for i, c := range m.displayCharts() {
@@ -202,13 +203,13 @@ func (m *Model) chartBoxes() []box {
202203 lines := m.drawChart(i, c, i == sel)203204 // Clip to the grid: the headers and panel stay on top.204205 from, to := max(m.hdrW()-x, 0), min(c.W, m.width-x)205- b := box{id: chartBoxID(i), x: x + from, y: max(y, top)}206+ b := overlay.Box{ID: chartBoxID(i), X: x + from, Y: max(y, top)}206207 for k, l := range lines {207208 if y+k >= top && y+k < bottom {208- b.lines = append(b.lines, ansi.Cut(l, from, to))209+ b.Lines = append(b.Lines, ansi.Cut(l, from, to))209210 }210211 }211- if len(b.lines) > 0 && to > from {212+ if len(b.Lines) > 0 && to > from {212213 boxes = append(boxes, b)213214 }214215 }
@@ -336,7 +337,7 @@ var chartMenu = []menuItem{{cmd: "chart.edit"}, {cmd: "chart.delete"}}
336337 func (m *Model) selectChart(i int) {337338 m.clearSelection()338339 m.charts.last = i339- m.openOverlay(&chartSel{i: i})340+ m.openOverlay(&chartSel{m: m, i: i})340341 }341342 342343 // setShape sets the terminal's pointer shape, if it changed.
M internal/ui/chartsel.go +31 -25
@@ -5,12 +5,14 @@ import (
55 "github.com/charmbracelet/x/ansi"66 77 "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/overlay"89 )910 1011 // chartSel is a selected chart. Arrows move it a cell at a time, Shift1112 // and arrows resize it, Enter edits it and Del deletes it; the mouse1213 // drags it or its corner. Other keys deselect it and act as usual.1314 type chartSel struct {15+ m *Model // the model it acts on1416 i int1517 drag int // chartDragNone, chartDragMove or chartDragResize1618 grabX int // where the chart was grabbed, relative to its corner
@@ -18,9 +20,9 @@ type chartSel struct {
1820 preview *sheet.Chart // the chart while being dragged1921 }2022 21-func (s *chartSel) indicator() string { return "CHART" }23+func (s *chartSel) Indicator() string { return "CHART" }2224 23-func (s *chartSel) layout(*Model) []box {25+func (s *chartSel) Layout() []overlay.Box {2426 return nil // drawn with the other charts, see chartBoxes2527 }2628
@@ -32,7 +34,8 @@ func (s *chartSel) chart(m *Model) (sheet.Chart, bool) {
3234 return charts[s.i], true3335 }3436 35-func (s *chartSel) key(m *Model, k tea.KeyPressMsg) tea.Cmd {37+func (s *chartSel) Key(k tea.KeyPressMsg) tea.Cmd {38+ m := s.m3639 c, ok := s.chart(m)3740 if !ok {3841 m.closeOverlay()
@@ -95,54 +98,55 @@ func (s *chartSel) press(m *Model, x, y int) {
9598 s.preview = &c9699 }97100 98-func (s *chartSel) mouse(m *Model, e mouseEvent) tea.Cmd {99- switch e.kind {100- case mousePress:101+func (s *chartSel) Mouse(e overlay.MouseEvent) tea.Cmd {102+ m := s.m103+ switch e.Kind {104+ case overlay.MousePress:101105 return s.pressAt(m, e)102- case mouseMotion:106+ case overlay.MouseMotion:103107 return s.motion(m, e)104- case mouseRelease:108+ case overlay.MouseRelease:105109 s.release(m)106- case mouseWheel:107- m.handleWheel(tea.Mouse{X: e.x, Y: e.y, Button: e.button})110+ case overlay.MouseWheel:111+ m.handleWheel(tea.Mouse{X: e.X, Y: e.Y, Button: e.Button})108112 }109113 return nil110114 }111115 112116 // pressAt selects the chart pressed and starts dragging it, or opens its113117 // 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)118+func (s *chartSel) pressAt(m *Model, e overlay.MouseEvent) tea.Cmd {119+ i := m.chartAt(e.X, e.Y)116120 switch {117121 case i < 0:118122 m.closeOverlay()119- return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})120- case e.button == tea.MouseRight:123+ return m.handlePress(tea.Mouse{X: e.X, Y: e.Y, Button: e.Button})124+ case e.Button == tea.MouseRight:121125 m.selectChart(i)122- m.showContextMenu(chartMenu, e.x, e.y+1)123- case e.button == tea.MouseLeft:126+ m.showContextMenu(chartMenu, e.X, e.Y+1)127+ case e.Button == tea.MouseLeft:124128 if i != s.i {125129 m.selectChart(i)126130 s = m.overlay.(*chartSel)127131 }128- s.press(m, e.x, e.y)132+ s.press(m, e.X, e.Y)129133 }130134 return nil131135 }132136 133137 // motion moves or resizes the chart being dragged, or else sets the134138 // pointer shape for what's under the mouse.135-func (s *chartSel) motion(m *Model, e mouseEvent) tea.Cmd {139+func (s *chartSel) motion(m *Model, e overlay.MouseEvent) tea.Cmd {136140 if s.drag == chartDragNone || s.preview == nil {137- return m.setShape(s.hoverShape(m, e.x, e.y))141+ return m.setShape(s.hoverShape(m, e.X, e.Y))138142 }139143 p := *s.preview140144 if s.drag == chartDragResize {141145 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)146+ p.W = clamp(e.X-cx+1, sheet.MinChartW, sheet.MaxChartW)147+ p.H = clamp(e.Y-cy+1, sheet.MinChartH, sheet.MaxChartH)144148 } else {145- p.At = m.chartCellAt(e.x-s.grabX, e.y-s.grabY)149+ p.At = m.chartCellAt(e.X-s.grabX, e.Y-s.grabY)146150 }147151 s.preview = &p148152 return nil
@@ -200,7 +204,8 @@ func (g *grid) chartCellAt(x, y int) sheet.Addr {
200204 return clampAddr(a)201205 }202206 203-func (s *chartSel) status(m *Model) (string, string) {207+func (s *chartSel) Status() (string, string) {208+ m := s.m204209 c, ok := s.chart(m)205210 if !ok {206211 return "", ""
@@ -222,8 +227,9 @@ func (s *chartSel) status(m *Model) (string, string) {
222227 }223228 }224229 225-// contextLine says what's selected and how to change it.226-func (s *chartSel) contextLine(m *Model) (string, string) {230+// ContextLine says what's selected and how to change it.231+func (s *chartSel) ContextLine() (string, string) {232+ m := s.m227233 c, ok := s.chart(m)228234 if !ok {229235 return "", ""
M internal/ui/cmdline.go +50 -41
@@ -11,6 +11,7 @@ import (
1111 1212 "github.com/FelineStateMachine/012/internal/fileio"1313 "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/overlay"1415 "github.com/FelineStateMachine/012/internal/ui/theme"1516 )1617
@@ -22,7 +23,8 @@ import (
2223 // there's no second list of commands to keep up.2324 2425 type cmdLine struct {25- list26+ m *Model // the model it acts on27+ overlay.List2628 shown []cmdItem2729 tabbed bool // the text is a completion Tab put there; Tab again moves on2830 }
@@ -52,21 +54,22 @@ func init() {
5254 register(&command{id: "vim.command", macro: macroNever, title: "Command line",5355 desc: "Type a command: a cell to go to (B12), w, q, wq, e file, or any command by name",5456 run: func(m *Model) tea.Cmd {55- c := &cmdLine{}57+ c := &cmdLine{m: m}5658 m.line.clear()5759 m.openOverlay(c)58- c.changed(m)60+ c.Changed()5961 return nil6062 }})6163 }6264 63-func (c *cmdLine) indicator() string { return "COMMAND" }65+func (c *cmdLine) Indicator() string { return "COMMAND" }6466 65-// changed completes what's typed: file commands, then commands whose ID67+// Changed completes what's typed: file commands, then commands whose ID6668 // or title starts with it, then those that contain it. Nothing is6769 // completed once a file command's argument follows.68-func (c *cmdLine) changed(m *Model) {69- c.sel, c.top, c.tabbed = 0, 0, false70+func (c *cmdLine) Changed() {71+ m := c.m72+ c.Sel, c.Top, c.tabbed = 0, 0, false7073 c.shown = c.shown[:0]7174 q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(m.line.text(), " "), ":"))7275 if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {
@@ -111,7 +114,8 @@ func commandIDs() []string {
111114 return ids112115 }113116 114-func (c *cmdLine) key(m *Model, k tea.KeyPressMsg) tea.Cmd {117+func (c *cmdLine) Key(k tea.KeyPressMsg) tea.Cmd {118+ m := c.m115119 switch key := k.String(); {116120 case key == "esc", key == "backspace" && len(m.line.buf) == 0:117121 m.closeOverlay()
@@ -122,14 +126,14 @@ func (c *cmdLine) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
122126 case key == "shift+tab":123127 c.complete(m, -1)124128 case key == "up", key == "ctrl+p":125- c.move(-1, len(c.shown))129+ c.Move(-1, len(c.shown))126130 case key == "down", key == "ctrl+n":127- c.move(1, len(c.shown))131+ c.Move(1, len(c.shown))128132 default:129133 before := m.line.text()130134 m.line.key(k)131135 if m.line.text() != before {132- c.changed(m)136+ c.Changed()133137 }134138 }135139 return nil
@@ -142,9 +146,9 @@ func (c *cmdLine) complete(m *Model, d int) {
142146 return143147 }144148 if c.tabbed {145- c.move(d, len(c.shown))149+ c.Move(d, len(c.shown))146150 }147- m.line.set(c.shown[c.sel].word)151+ m.line.set(c.shown[c.Sel].word)148152 c.tabbed = true149153 }150154
@@ -154,8 +158,8 @@ func (c *cmdLine) complete(m *Model, d int) {
154158 func (c *cmdLine) run(m *Model) tea.Cmd {155159 text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(m.line.text()), ":"))156160 fallback := ""157- if c.sel < len(c.shown) {158- fallback = c.shown[c.sel].word161+ if c.Sel < len(c.shown) {162+ fallback = c.shown[c.Sel].word159163 }160164 m.closeOverlay()161165 if text == "" {
@@ -264,20 +268,23 @@ func (m *Model) editFile(name string, force bool) tea.Cmd {
264268 return m.openFile(name)265269 }266270 267-func (c *cmdLine) contextLine(m *Model) (string, string) {271+func (c *cmdLine) ContextLine() (string, string) {272+ m := c.m268273 return m.th.Title.Render(":") + m.line.text(), ""269274 }270275 271-func (c *cmdLine) cursor(m *Model) (int, int) {276+func (c *cmdLine) Cursor() (int, int) {277+ m := c.m272278 return 1 + ansi.StringWidth(m.line.head()), contextLine273279 }274280 275-func (c *cmdLine) status(m *Model) (string, string) {281+func (c *cmdLine) Status() (string, string) {282+ m := c.m276283 keys := m.th.KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")277- if c.sel >= len(c.shown) {284+ if c.Sel >= len(c.shown) {278285 return "", keys279286 }280- it := c.shown[c.sel]287+ it := c.shown[c.Sel]281288 if it.off {282289 return it.desc + " (not available now)", keys283290 }
@@ -289,14 +296,15 @@ func (c *cmdLine) rows(m *Model) int {
289296 return max(min(len(c.shown), 8, m.height-contextLine-4), 0)290297 }291298 292-// layout draws the completions in a box under the context line: title,299+// Layout draws the completions in a box under the context line: title,293300 // what Tab types, and the shortcut.294-func (c *cmdLine) layout(m *Model) []box {301+func (c *cmdLine) Layout() []overlay.Box {302+ m := c.m295303 rows := c.rows(m)296304 if rows == 0 {297305 return nil298306 }299- c.show(rows)307+ c.Show(rows)300308 tw, ww, kw := 0, 0, 0301309 for _, it := range c.shown {302310 tw = max(tw, ansi.StringWidth(it.title))
@@ -311,11 +319,11 @@ func (c *cmdLine) layout(m *Model) []box {
311319 ww = max(min(ww, inner-1-tw-2-kw-2), 0)312320 lines := make([]string, rows)313321 for r := range rows {314- i := c.top + r322+ i := c.Top + r315323 it := c.shown[i]316324 base, dim := m.th.MenuBar, m.th.Muted317325 switch {318- case i == c.sel:326+ case i == c.Sel:319327 base, dim = m.th.MenuSelected, m.th.MenuSelected320328 case it.off:321329 base, dim = m.th.Disabled, m.th.Disabled
@@ -325,7 +333,7 @@ func (c *cmdLine) layout(m *Model) []box {
325333 k := ""326334 switch {327335 case it.key == "":328- case i == c.sel || it.off:336+ case i == c.Sel || it.off:329337 k = base.Render(" " + it.key + " ")330338 default:331339 k = m.th.Chip(it.key)
@@ -333,29 +341,30 @@ func (c *cmdLine) layout(m *Model) []box {
333341 row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")334342 lines[r] = ansi.Truncate(row, inner, "")335343 }336- footer := strconv.Itoa(c.sel+1) + " of " + strconv.Itoa(len(c.shown))337- return []box{{id: cmdLineID, x: 0, y: contextLine + 1, lines: m.th.Frame(inner, "Commands", footer, lines)}}344+ footer := strconv.Itoa(c.Sel+1) + " of " + strconv.Itoa(len(c.shown))345+ return []overlay.Box{{ID: cmdLineID, X: 0, Y: contextLine + 1, Lines: m.th.Frame(inner, "Commands", footer, lines)}}338346 }339347 340-func (c *cmdLine) mouse(m *Model, e mouseEvent) tea.Cmd {341- if e.box != cmdLineID {342- if e.kind == mousePress {348+func (c *cmdLine) Mouse(e overlay.MouseEvent) tea.Cmd {349+ m := c.m350+ if e.Box != cmdLineID {351+ if e.Kind == overlay.MousePress {343352 m.closeOverlay()344353 }345354 return nil346355 }347- i := c.top + e.row - 1 // under the top border356+ i := c.Top + e.Row - 1 // under the top border348357 switch {349- case e.kind == mouseWheel && e.button == tea.MouseWheelUp:350- c.move(-1, len(c.shown))351- case e.kind == mouseWheel && e.button == tea.MouseWheelDown:352- c.move(1, len(c.shown))353- case e.row < 1 || i >= len(c.shown) || i >= c.top+c.rows(m):354- case e.kind == mouseMotion:355- c.sel = i356- case e.kind == mousePress && e.button == tea.MouseLeft:358+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:359+ c.Move(-1, len(c.shown))360+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:361+ c.Move(1, len(c.shown))362+ case e.Row < 1 || i >= len(c.shown) || i >= c.Top+c.rows(m):363+ case e.Kind == overlay.MouseMotion:364+ c.Sel = i365+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:357366 m.line.set(c.shown[i].word)358- c.sel = i367+ c.Sel = i359368 return c.run(m)360369 }361370 return nil
M internal/ui/context_test.go +5 -5
M internal/ui/dialog.go +18 -12
@@ -6,6 +6,7 @@ import (
66 tea "charm.land/bubbletea/v2"77 "github.com/charmbracelet/x/ansi"88 9+ "github.com/FelineStateMachine/012/internal/ui/overlay"910 "github.com/FelineStateMachine/012/internal/ui/theme"1011 )1112
@@ -14,6 +15,7 @@ import (
1415 // "You have unsaved changes. Enter Save and quit D Discard Esc Cancel".1516 // It takes the keyboard like an overlay but draws no box.1617 type choiceBar struct {18+ m *Model // the model it acts on1719 msg string1820 warn bool // the message is a warning, e.g. about losing work1921 desc string // more on the status line, when the question needs it
@@ -26,11 +28,12 @@ type choice struct {
2628 run func(m *Model) tea.Cmd2729 }2830 29-func (c *choiceBar) indicator() string { return "MENU" }30-func (c *choiceBar) layout(*Model) []box { return nil }31-func (c *choiceBar) status(*Model) (string, string) { return c.desc, "" }31+func (c *choiceBar) Indicator() string { return "MENU" }32+func (c *choiceBar) Layout() []overlay.Box { return nil }33+func (c *choiceBar) Status() (string, string) { return c.desc, "" }3234 33-func (c *choiceBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {35+func (c *choiceBar) Key(k tea.KeyPressMsg) tea.Cmd {36+ m := c.m3437 key := strings.ToLower(k.String())3538 for _, ch := range c.choices {3639 if key == ch.key {
@@ -47,17 +50,18 @@ func (c *choiceBar) choose(m *Model, ch choice) tea.Cmd {
4750 return cmd4851 }4952 50-// mouse runs a choice when its key chip or label is clicked. A click53+// Mouse runs a choice when its key chip or label is clicked. A click5154 // anywhere else cancels, like Esc.52-func (c *choiceBar) mouse(m *Model, e mouseEvent) tea.Cmd {53- if e.kind != mousePress {55+func (c *choiceBar) Mouse(e overlay.MouseEvent) tea.Cmd {56+ m := c.m57+ if e.Kind != overlay.MousePress {5458 return nil5559 }56- if e.y == contextLine {60+ if e.Y == contextLine {5761 x := ansi.StringWidth(c.prefix(m))5862 for _, ch := range c.choices {5963 w := ansi.StringWidth(c.item(m, ch))60- if e.x >= x && e.x < x+w {64+ if e.X >= x && e.X < x+w {6165 return c.choose(m, ch)6266 }6367 x += w + len(choiceGap)
@@ -80,13 +84,14 @@ func (c *choiceBar) item(m *Model, ch choice) string {
8084 return m.th.Chip(theme.KeyLabel(ch.key)) + " " + ch.label8185 }8286 83-// line renders the bar for the context line.84-func (c *choiceBar) line(m *Model) string {87+// ContextLine renders the bar for the context line.88+func (c *choiceBar) ContextLine() (string, string) {89+ m := c.m8590 items := make([]string, len(c.choices))8691 for i, ch := range c.choices {8792 items[i] = c.item(m, ch)8893 }89- return c.prefix(m) + strings.Join(items, choiceGap)94+ return c.prefix(m) + strings.Join(items, choiceGap), ""9095 }9196 9297 // quit exits, asking first when there are unsaved changes. Enter saves
@@ -96,6 +101,7 @@ func (m *Model) quit() tea.Cmd {
96101 return m.exit()97102 }98103 m.openOverlay(&choiceBar{104+ m: m,99105 msg: "You have unsaved changes.",100106 warn: true,101107 choices: []choice{
M internal/ui/download.go +2 -1
M internal/ui/entry.go +3 -2
M internal/ui/file.go +1 -0
M internal/ui/filter.go +51 -43
@@ -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/overlay"1415 "github.com/FelineStateMachine/012/internal/ui/theme"1516 )1617
@@ -98,19 +99,20 @@ func (g *grid) filterButtonX(c int) int {
9899 // sheet's filter, or of a pivot table's (pivoteditor.go), which set what99100 // applying and cancelling do.100101 type filterPicker struct {102+ m *Model // the model it acts on101103 title string102104 x int // where the box goes, e.g. over its column103105 onApply func(m *Model, cr sheet.Criteria)104106 // onCancel, when set, runs after Esc or a click outside closes the105107 // picker.106- onCancel func(m *Model)107- values []sheet.FilterValue108- checked map[string]bool109- cond sheet.Condition110- field int // 0 is the search, 1 the condition's value111- fields [2]string // the text of each field112- shown []int // values matching the search, best first113- list // over the rows: "Select all", then shown108+ onCancel func(m *Model)109+ values []sheet.FilterValue110+ checked map[string]bool111+ cond sheet.Condition112+ field int // 0 is the search, 1 the condition's value113+ fields [2]string // the text of each field114+ shown []int // values matching the search, best first115+ overlay.List // over the rows: "Select all", then shown114116 }115117 116118 const filterID = "filter"
@@ -137,7 +139,7 @@ func (m *Model) openFilterPicker(col int) {
137139 // over values, with cond as the condition, calling apply with the138140 // criteria chosen.139141 func (m *Model) openValuesPicker(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(*Model, sheet.Criteria)) *filterPicker {140- p := &filterPicker{title: title, x: x, onApply: apply, values: values, checked: map[string]bool{}, cond: cond}142+ p := &filterPicker{m: m, title: title, x: x, onApply: apply, values: values, checked: map[string]bool{}, cond: cond}141143 for _, v := range p.values {142144 p.checked[v.Text] = v.Shown143145 }
@@ -156,7 +158,7 @@ func (p *filterPicker) close(m *Model) {
156158 }157159 }158160 159-func (p *filterPicker) indicator() string { return "FILTER" }161+func (p *filterPicker) Indicator() string { return "FILTER" }160162 161163 // focus moves editing to field i, keeping the other field's text.162164 func (p *filterPicker) focus(m *Model, i int) {
@@ -165,7 +167,8 @@ func (p *filterPicker) focus(m *Model, i int) {
165167 m.line.set(p.fields[i])166168 }167169 168-func (p *filterPicker) changed(m *Model) {170+func (p *filterPicker) Changed() {171+ m := p.m169172 p.fields[p.field] = m.line.text()170173 if p.field == 0 {171174 p.search()
@@ -182,7 +185,7 @@ func valueLabel(text string) string {
182185 183186 // search narrows the list to the values matching the search.184187 func (p *filterPicker) search() {185- p.sel, p.top = 0, 0188+ p.Sel, p.Top = 0, 0186189 p.shown = p.shown[:0]187190 q := strings.TrimSpace(p.fields[0])188191 if q == "" {
@@ -268,7 +271,8 @@ func rowCount(n int) string {
268271 return strconv.Itoa(n) + " rows"269272 }270273 271-func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {274+func (p *filterPicker) Key(k tea.KeyPressMsg) tea.Cmd {275+ m := p.m272276 n := len(p.shown) + 1273277 switch key := k.String(); {274278 case key == "esc":
@@ -284,63 +288,65 @@ func (p *filterPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
284288 p.cycle(1)285289 }286290 case key == "up" || key == "ctrl+p":287- p.move(-1, n)291+ p.Move(-1, n)288292 case key == "down" || key == "ctrl+n":289- p.move(1, n)293+ p.Move(1, n)290294 case key == "pgup":291- p.sel = max(p.sel-p.rows(m), 0)295+ p.Sel = max(p.Sel-p.rows(m), 0)292296 case key == "pgdown":293- p.sel = min(p.sel+p.rows(m), n-1)297+ p.Sel = min(p.Sel+p.rows(m), n-1)294298 case p.field == 0 && key == "space":295- p.toggle(p.sel)299+ p.toggle(p.Sel)296300 default:297301 before := m.line.text()298302 m.line.key(k)299303 if m.line.text() != before {300- p.changed(m)304+ p.Changed()301305 }302306 }303307 return nil304308 }305309 306-func (p *filterPicker) mouse(m *Model, e mouseEvent) tea.Cmd {307- if e.box != filterID {308- if e.kind == mousePress {310+func (p *filterPicker) Mouse(e overlay.MouseEvent) tea.Cmd {311+ m := p.m312+ if e.Box != filterID {313+ if e.Kind == overlay.MousePress {309314 p.close(m)310315 }311316 return nil312317 }313318 n := len(p.shown) + 1314- i := p.top + e.row - filterFirstRow319+ i := p.Top + e.Row - filterFirstRow315320 switch {316- case e.kind == mouseWheel && e.button == tea.MouseWheelUp:317- p.move(-1, n)318- case e.kind == mouseWheel && e.button == tea.MouseWheelDown:319- p.move(1, n)320- case e.kind != mousePress && e.kind != mouseMotion:321- case e.row == 1 && e.kind == mousePress:321+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:322+ p.Move(-1, n)323+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:324+ p.Move(1, n)325+ case e.Kind != overlay.MousePress && e.Kind != overlay.MouseMotion:326+ case e.Row == 1 && e.Kind == overlay.MousePress:322327 if p.field != 1 {323328 p.focus(m, 1)324329 }325- switch x := e.col - 1 - len(" If "); {330+ switch x := e.Col - 1 - len(" If "); {326331 case x == 0:327332 p.cycle(-1)328333 case x == ansi.StringWidth(p.condChip())-1:329334 p.cycle(1)330335 }331- case e.row == 3 && e.kind == mousePress:336+ case e.Row == 3 && e.Kind == overlay.MousePress:332337 p.focus(m, 0)333- case e.row < filterFirstRow || i >= n || i >= p.top+p.rows(m):334- case e.kind == mouseMotion:335- p.sel = i336- case e.button == tea.MouseLeft:337- p.sel = i338+ case e.Row < filterFirstRow || i >= n || i >= p.Top+p.rows(m):339+ case e.Kind == overlay.MouseMotion:340+ p.Sel = i341+ case e.Button == tea.MouseLeft:342+ p.Sel = i338343 p.toggle(i)339344 }340345 return nil341346 }342347 343-func (p *filterPicker) status(m *Model) (string, string) {348+func (p *filterPicker) Status() (string, string) {349+ m := p.m344350 if p.field == 1 {345351 return "Rows must also meet the condition", m.th.KeyHints("Up/Down", "condition", "Tab", "values", "Enter", "apply", "Esc", "cancel")346352 }
@@ -379,7 +385,8 @@ func (p *filterPicker) box(m *Model) (x, y, inner int) {
379385 return x, min(y, max(m.height-1-h, 0)), inner380386 }381387 382-func (p *filterPicker) cursor(m *Model) (int, int) {388+func (p *filterPicker) Cursor() (int, int) {389+ m := p.m383390 x, y, _ := p.box(m)384391 caret := ansi.StringWidth(m.line.head())385392 if p.field == 1 {
@@ -393,10 +400,11 @@ func (p *filterPicker) condChip() string {
393400 return "‹ " + p.cond.Op.Title() + " ›"394401 }395402 396-func (p *filterPicker) layout(m *Model) []box {403+func (p *filterPicker) Layout() []overlay.Box {404+ m := p.m397405 x, y, inner := p.box(m)398406 rows := p.rows(m)399- p.show(rows)407+ p.Show(rows)400408 401409 chipStyle := m.th.KeyChip402410 if p.field == 1 {
@@ -424,7 +432,7 @@ func (p *filterPicker) layout(m *Model) []box {
424432 lines := []string{theme.Cells(m.th.MenuBar, cond, inner), theme.SepRow, theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}425433 426434 for r := range rows {427- i := p.top + r435+ i := p.Top + r428436 if i > len(p.shown) {429437 lines = append(lines, theme.Cells(m.th.MenuBar, "", inner))430438 continue
@@ -440,7 +448,7 @@ func (p *filterPicker) layout(m *Model) []box {
440448 box = "[x] "441449 }442450 base, muted := m.th.MenuBar, m.th.Muted443- if i == p.sel {451+ if i == p.Sel {444452 base, muted = m.th.MenuSelected, m.th.MenuSelected445453 }446454 labelStyle := base
@@ -453,5 +461,5 @@ func (p *filterPicker) layout(m *Model) []box {
453461 }454462 title := p.title455463 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.values))456- return []box{{id: filterID, x: x, y: y, lines: m.th.Frame(inner, title, footer, lines)}}464+ return []overlay.Box{{ID: filterID, X: x, Y: y, Lines: m.th.Frame(inner, title, footer, lines)}}457465 }
M internal/ui/find.go +13 -9
@@ -9,6 +9,7 @@ import (
99 1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/telemetry"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"1213 )1314 1415 // Find and replace is a bar on the context line rather than a dialog, in
@@ -20,8 +21,9 @@ import (
2021 // goes through them.2122 2223 type findBar struct {23- replace bool // Ctrl+H: a replacement field follows the query24- field int // 0 is the query, 1 the replacement24+ m *Model // the model it acts on25+ replace bool // Ctrl+H: a replacement field follows the query26+ field int // 0 is the query, 1 the replacement2527 fields [2]string2628 opts sheet.FindOptions2729 scope *sheet.Rect // the selection when the bar opened, if any
@@ -112,7 +114,7 @@ func (m *Model) openFind(replace bool) {
112114 if !ok {113115 f = m.find114116 if f == nil {115- f = &findBar{cur: -1}117+ f = &findBar{m: m, cur: -1}116118 }117119 f.scope, f.home = nil, m.sheet118120 if f.where == inRange || f.where == inAll && len(m.book().Visible()) == 1 {
@@ -137,9 +139,9 @@ func (m *Model) openFind(replace bool) {
137139 f.search(m)138140 }139141 140-func (f *findBar) indicator() string { return "FIND" }142+func (f *findBar) Indicator() string { return "FIND" }141143 142-func (f *findBar) layout(*Model) []box { return nil }144+func (f *findBar) Layout() []overlay.Box { return nil }143145 144146 // focus moves editing to field i, keeping the other field's text.145147 func (f *findBar) focus(m *Model, i int) {
@@ -148,8 +150,9 @@ func (f *findBar) focus(m *Model, i int) {
148150 m.line.set(f.fields[i])149151 }150152 151-// changed re-runs the search as the query is typed.152-func (f *findBar) changed(m *Model) {153+// Changed re-runs the search as the query is typed.154+func (f *findBar) Changed() {155+ m := f.m153156 f.fields[f.field] = m.line.text()154157 if f.field == 0 {155158 f.search(m)
@@ -246,7 +249,8 @@ func (f *findBar) nextScope(m *Model) findScope {
246249 return order[(slices.Index(order, f.where)+1)%len(order)]247250 }248251 249-func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {252+func (f *findBar) Key(k tea.KeyPressMsg) tea.Cmd {253+ m := f.m250254 switch k.String() {251255 case "esc":252256 m.find = f
@@ -302,7 +306,7 @@ func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
302306 before := m.line.text()303307 m.line.key(k)304308 if m.line.text() != before {305- f.changed(m)309+ f.Changed()306310 }307311 }308312 return nil
M internal/ui/findview.go +19 -14
@@ -5,6 +5,7 @@ import (
55 "strings"66 77 tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/012/internal/ui/overlay"89 "github.com/charmbracelet/x/ansi"910 )1011
@@ -82,9 +83,10 @@ func (f *findBar) spans(m *Model) ([]findPart, []int) {
8283 return parts, xs8384 }8485 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) {86+// ContextLine renders the bar for the context line, with the match count87+// on the right.88+func (f *findBar) ContextLine() (left, right string) {89+ m := f.m8890 var b strings.Builder8991 parts := f.parts(m)9092 for i, p := range parts {
@@ -109,9 +111,10 @@ func (f *findBar) line(m *Model) (left, right string) {
109111 return b.String(), right110112 }111113 112-// cursor puts the terminal cursor in the focused field, whose text ends114+// Cursor puts the terminal cursor in the focused field, whose text ends113115 // its part.114-func (f *findBar) cursor(m *Model) (x, y int) {116+func (f *findBar) Cursor() (x, y int) {117+ m := f.m115118 parts, xs := f.spans(m)116119 for i, p := range parts {117120 if p.field == f.field {
@@ -121,25 +124,26 @@ func (f *findBar) cursor(m *Model) (x, y int) {
121124 return 0, contextLine122125 }123126 124-// mouse focuses a field or flips a chip on the bar; a click anywhere127+// Mouse focuses a field or flips a chip on the bar; a click anywhere125128 // 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 {129+func (f *findBar) Mouse(e overlay.MouseEvent) tea.Cmd {130+ m := f.m131+ if e.Kind != overlay.MousePress {128132 return nil129133 }130- if e.y != contextLine {134+ if e.Y != contextLine {131135 m.find = f132136 m.closeOverlay()133- return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})137+ return m.handlePress(tea.Mouse{X: e.X, Y: e.Y, Button: e.Button})134138 }135139 parts, xs := f.spans(m)136140 for i, p := range parts {137- if e.x >= xs[i] && e.x < xs[i]+ansi.StringWidth(p.text) {141+ if e.X >= xs[i] && e.X < xs[i]+ansi.StringWidth(p.text) {138142 if p.field >= 0 {139143 f.focus(m, p.field)140144 return nil141145 }142- return f.key(m, keyFor(p.toggle))146+ return f.Key(keyFor(p.toggle))143147 }144148 }145149 return nil
@@ -158,9 +162,10 @@ func keyFor(s string) tea.KeyPressMsg {
158162 return k159163 }160164 161-// status shows the keys for moving and replacing, and a reminder of the165+// Status shows the keys for moving and replacing, and a reminder of the162166 // option keys when there's room. Narrow screens keep the most useful keys.163-func (f *findBar) status(m *Model) (string, string) {167+func (f *findBar) Status() (string, string) {168+ m := f.m164169 pairs := []string{"Enter", "next", "Shift+Enter", "previous", "Esc", "close"}165170 if f.replace {166171 pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}
M internal/ui/help.go +17 -10
@@ -11,13 +11,14 @@ import (
1111 "github.com/charmbracelet/x/ansi"1212 1313 "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/overlay"1415 "github.com/FelineStateMachine/012/internal/ui/theme"1516 )1617 1718 func init() {1819 register(1920 &command{id: "help", macro: macroNever, title: "Keyboard shortcuts", desc: "Show every key, grouped by what it does", run: func(m *Model) tea.Cmd {20- m.openOverlay(&shortcuts{})21+ m.openOverlay(&shortcuts{m: m})2122 return nil2223 }},2324 &command{id: "help.functions", macro: macroNever, title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {
@@ -28,6 +29,7 @@ func init() {
2829 }},2930 &command{id: "help.about", macro: macroNever, title: "About 012", desc: "Show the version", run: func(m *Model) tea.Cmd {3031 m.openOverlay(&choiceBar{32+ m: m,3133 msg: "012 " + version() + ": Lotus 1-2-3 looks, Google Sheets keys.",3234 choices: []choice{3335 {key: "esc", label: "Close", run: func(*Model) tea.Cmd { return nil }},
@@ -172,12 +174,13 @@ func keyHelpRows(vim bool) []helpRow {
172174 // shortcuts is the keyboard shortcuts view: a scrollable box over the173175 // grid, in two columns when the screen is wide enough.174176 type shortcuts struct {177+ m *Model // the model it acts on175178 top int176179 }177180 178181 const shortcutsID = "shortcuts"179182 180-func (s *shortcuts) indicator() string { return "HELP" }183+func (s *shortcuts) Indicator() string { return "HELP" }181184 182185 // lines renders the rows, as one or two columns, and returns their width.183186 func (s *shortcuts) lines(m *Model) ([]string, int) {
@@ -239,7 +242,8 @@ func (s *shortcuts) visible(m *Model, total int) int {
239242 return max(min(total, m.height-2-2-1), 1)240243 }241244 242-func (s *shortcuts) layout(m *Model) []box {245+func (s *shortcuts) Layout() []overlay.Box {246+ m := s.m243247 lines, w := s.lines(m)244248 inner := w + 1245249 n := s.visible(m, len(lines)+1)
@@ -259,10 +263,11 @@ func (s *shortcuts) layout(m *Model) []box {
259263 }260264 b := m.th.Frame(inner, "Keyboard shortcuts", footer, rows)261265 w, h := ansi.StringWidth(b[0]), len(b)262- return []box{{id: shortcutsID, x: (m.width - w) / 2, y: max(menuLine+1, (m.height-1-h)/2), lines: b}}266+ return []overlay.Box{{ID: shortcutsID, X: (m.width - w) / 2, Y: max(menuLine+1, (m.height-1-h)/2), Lines: b}}263267 }264268 265-func (s *shortcuts) key(m *Model, k tea.KeyPressMsg) tea.Cmd {269+func (s *shortcuts) Key(k tea.KeyPressMsg) tea.Cmd {270+ m := s.m266271 lines, _ := s.lines(m)267272 page := s.visible(m, len(lines)+1)268273 switch k.String() {
@@ -285,18 +290,20 @@ func (s *shortcuts) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
285290 return nil286291 }287292 288-func (s *shortcuts) mouse(m *Model, e mouseEvent) tea.Cmd {293+func (s *shortcuts) Mouse(e overlay.MouseEvent) tea.Cmd {294+ m := s.m289295 switch {290- case e.kind == mouseWheel && e.button == tea.MouseWheelUp:296+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:291297 s.top = max(s.top-3, 0)292- case e.kind == mouseWheel && e.button == tea.MouseWheelDown:298+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:293299 s.top += 3 // layout clamps294- case e.kind == mousePress && e.box != shortcutsID:300+ case e.Kind == overlay.MousePress && e.Box != shortcutsID:295301 m.closeOverlay()296302 }297303 return nil298304 }299305 300-func (s *shortcuts) status(m *Model) (string, string) {306+func (s *shortcuts) Status() (string, string) {307+ m := s.m301308 return "", m.th.KeyHints("Up/Down", "scroll", "Esc", "close")302309 }
M internal/ui/macro.go +1 -0
M internal/ui/macro_test.go +1 -1
M internal/ui/macromanage.go +12 -10
@@ -35,7 +35,7 @@ func (m *Model) openMacros(sel string) {
3535 p.action = "run"3636 for i, pm := range p.shown {3737 if strings.EqualFold(pm.item.title, sel) {38- p.sel = i38+ p.Sel = i3939 }4040 }4141 m.openOverlay(&macrosPicker{picker: p})
@@ -69,13 +69,14 @@ func macroManageItems(m *Model) []pickItem {
6969 7070 // current is the macro highlighted, if any.7171 func (p *macrosPicker) current(m *Model) (sheet.Macro, bool) {72- if p.picker.sel >= len(p.shown) || p.shown[p.picker.sel].item.title == writeMacroTitle {72+ if p.picker.Sel >= len(p.shown) || p.shown[p.picker.Sel].item.title == writeMacroTitle {7373 return sheet.Macro{}, false7474 }75- return m.book().Macro(p.shown[p.picker.sel].item.title)75+ return m.book().Macro(p.shown[p.picker.Sel].item.title)7676 }7777 78-func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {78+func (p *macrosPicker) Key(k tea.KeyPressMsg) tea.Cmd {79+ m := p.m7980 p.msg = ""8081 mc, ok := p.current(m)8182 switch k.String() {
@@ -84,7 +85,7 @@ func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
8485 return nil8586 }8687 default:87- return p.picker.key(m, k)88+ return p.picker.Key(k)8889 }8990 switch k.String() {9091 case "f2":
@@ -104,14 +105,15 @@ func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
104105 m.book().DeleteMacro(mc.Name)105106 p.msg = "Deleted " + mc.Name + "; Ctrl+Z brings it back"106107 p.items = macroManageItems(m)107- sel := p.picker.sel108- p.changed(m)109- p.picker.sel = max(min(sel, len(p.shown)-1), 0)108+ sel := p.picker.Sel109+ p.Changed()110+ p.picker.Sel = max(min(sel, len(p.shown)-1), 0)110111 }111112 return nil112113 }113114 114-func (p *macrosPicker) status(m *Model) (string, string) {115+func (p *macrosPicker) Status() (string, string) {116+ m := p.m115117 pairs := []string{"Enter", "run", "F2", "rename", "F3", "shortcut", "F4", "edit", "Ctrl+D", "delete", "Esc", "close"}116118 if !m.macros.editor {117119 pairs = slices.Delete(pairs, 6, 8)
@@ -127,7 +129,7 @@ func (p *macrosPicker) status(m *Model) (string, string) {
127129 if p.msg != "" {128130 return p.msg, keys129131 }130- desc, _ := p.picker.status(m)132+ desc, _ := p.picker.Status()131133 return desc, keys132134 }133135
M internal/ui/menuoverlay.go +39 -33
@@ -7,14 +7,16 @@ import (
77 tea "charm.land/bubbletea/v2"88 "github.com/charmbracelet/x/ansi"99 10+ "github.com/FelineStateMachine/012/internal/ui/overlay"1011 "github.com/FelineStateMachine/012/internal/ui/theme"1112 )1213 1314 // menuOverlay is an open dropdown from the menu bar, or a context menu,1415 // with any submenus opened from it.1516 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 opened17+ m *Model // the model it acts on18+ bar int // index in barMenus of the open title; -1 for a context menu19+ x, y int // where a context menu opened1820 levels []*menuLevel1921 }2022
@@ -32,13 +34,13 @@ func (m *Model) showBarMenu(i int) {
3234 return3335 }3436 i = (i%len(menus) + len(menus)) % len(menus)35- m.openOverlay(&menuOverlay{bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})37+ m.openOverlay(&menuOverlay{m: m, bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})3638 }3739 3840 // showContextMenu opens a menu of items with its corner at x, y.3941 func (m *Model) showContextMenu(items []menuItem, x, y int) {4042 if items = visibleItems(items); len(items) > 0 {41- m.openOverlay(&menuOverlay{bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})43+ m.openOverlay(&menuOverlay{m: m, bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})4244 }4345 }4446
@@ -73,13 +75,14 @@ func (l *menuLevel) step(m *Model, d int) {
7375 7476 func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }7577 76-func (o *menuOverlay) indicator() string { return "MENU" }78+func (o *menuOverlay) Indicator() string { return "MENU" }7779 78-// key handles a key in an open menu: Up/Down highlight, Right opens a80+// Key handles a key in an open menu: Up/Down highlight, Right opens a7981 // submenu or the next menu, Left closes a submenu or opens the previous8082 // menu, Enter runs, Esc closes one level, and a letter jumps to the items8183 // starting with it, running the item if it's the only one.82-func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {84+func (o *menuOverlay) Key(k tea.KeyPressMsg) tea.Cmd {85+ m := o.m8386 l := o.top()8487 key := k.String()8588 if i := barMenuFor(key); i >= 0 {
@@ -179,24 +182,25 @@ func (o *menuOverlay) choose(m *Model) tea.Cmd {
179182 180183 func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }181184 182-// mouse handles hovering and clicking items, switching menus by hovering185+// Mouse handles hovering and clicking items, switching menus by hovering183186 // or clicking the menu bar, and closing the menu on a click elsewhere.184-func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {187+func (o *menuOverlay) Mouse(e overlay.MouseEvent) tea.Cmd {188+ m := o.m185189 for lv, l := range o.levels {186- if e.box == menuBoxID(lv) {190+ if e.Box == menuBoxID(lv) {187191 return o.levelMouse(m, e, lv, l)188192 }189193 }190- if e.y == menuLine && e.box == "" {191- if t := barMenuAt(e.x); t >= 0 {194+ if e.Y == menuLine && e.Box == "" {195+ if t := barMenuAt(e.X); t >= 0 {192196 o.barMouse(m, e, t)193197 return nil194198 }195199 }196- if e.kind == mousePress {200+ if e.Kind == overlay.MousePress {197201 m.closeOverlay()198- if e.button == tea.MouseRight {199- m.rightClick(e.x, e.y) // right-clicking elsewhere opens a menu there202+ if e.Button == tea.MouseRight {203+ m.rightClick(e.X, e.Y) // right-clicking elsewhere opens a menu there200204 }201205 }202206 return nil
@@ -204,25 +208,25 @@ func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {
204208 205209 // levelMouse handles the mouse over open box lv: hovering highlights an206210 // 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 {211+func (o *menuOverlay) levelMouse(m *Model, e overlay.MouseEvent, lv int, l *menuLevel) tea.Cmd {212+ i := l.top + e.Row - 1213+ if e.Kind == overlay.MouseWheel {210214 o.levels = o.levels[:lv+1]211- l.wheel(m, e.button)215+ l.wheel(m, e.Button)212216 return nil213217 }214- if e.row < 1 || i >= len(l.items) || !l.selectable(m, i) {218+ if e.Row < 1 || i >= len(l.items) || !l.selectable(m, i) {215219 return nil216220 }217- switch e.kind {218- case mouseMotion:221+ switch e.Kind {222+ case overlay.MouseMotion:219223 if i != l.sel || len(o.levels) == lv+1 {220224 o.levels, l.sel = o.levels[:lv+1], i221225 if sub := l.items[i].items; sub != nil {222226 o.levels = append(o.levels, m.newLevel(sub, false))223227 }224228 }225- case mousePress:229+ case overlay.MousePress:226230 o.levels, l.sel = o.levels[:lv+1], i227231 return o.choose(m)228232 }
@@ -231,11 +235,11 @@ func (o *menuOverlay) levelMouse(m *Model, e mouseEvent, lv int, l *menuLevel) t
231235 232236 // barMouse handles the mouse over menu bar title t: clicking the open233237 // title closes its menu, and clicking or hovering another opens that one.234-func (o *menuOverlay) barMouse(m *Model, e mouseEvent, t int) {238+func (o *menuOverlay) barMouse(m *Model, e overlay.MouseEvent, t int) {235239 switch {236- case e.kind == mousePress && t == o.bar:240+ case e.Kind == overlay.MousePress && t == o.bar:237241 m.closeOverlay()238- case e.kind == mousePress, e.kind == mouseMotion && o.bar >= 0 && t != o.bar:242+ case e.Kind == overlay.MousePress, e.Kind == overlay.MouseMotion && o.bar >= 0 && t != o.bar:239243 m.showBarMenu(t)240244 }241245 }
@@ -250,7 +254,8 @@ func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {
250254 }251255 }252256 253-func (o *menuOverlay) status(m *Model) (string, string) {257+func (o *menuOverlay) Status() (string, string) {258+ m := o.m254259 keys := m.th.KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")255260 if o.bar >= 0 {256261 keys = m.th.KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")
@@ -272,11 +277,12 @@ func (o *menuOverlay) status(m *Model) (string, string) {
272277 return commands[it.cmd].desc, keys273278 }274279 275-// layout places the dropdown under its title (or a context menu at the280+// Layout places the dropdown under its title (or a context menu at the276281 // mouse) and each submenu beside the item that opened it, flipping left or277282 // 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))283+func (o *menuOverlay) Layout() []overlay.Box {284+ m := o.m285+ boxes := make([]overlay.Box, 0, len(o.levels))280286 maxRows := max(m.height-4, 3)281287 for i, l := range o.levels {282288 rows := min(len(l.items), maxRows)
@@ -290,12 +296,12 @@ func (o *menuOverlay) layout(m *Model) []box {
290296 switch {291297 case i > 0:292298 p := boxes[i-1]293- x, y = p.x+p.width(), p.y+o.levels[i-1].sel-o.levels[i-1].top299+ x, y = p.X+p.Width(), p.Y+o.levels[i-1].sel-o.levels[i-1].top294300 // Open to the right; else to the left; and if neither fits,295301 // against the screen's right edge, where it covers the parent's296302 // shortcuts rather than its labels.297303 if x+w > m.width {298- x = p.x - w304+ x = p.X - w299305 if x < 0 {300306 x = m.width - w301307 }
@@ -310,7 +316,7 @@ func (o *menuOverlay) layout(m *Model) []box {
310316 }311317 x = clamp(x, 0, m.width-w)312318 y = clamp(y, 0, m.height-1-h)313- boxes = append(boxes, box{id: menuBoxID(i), x: x, y: y, lines: lines})319+ boxes = append(boxes, overlay.Box{ID: menuBoxID(i), X: x, Y: y, Lines: lines})314320 }315321 return boxes316322 }
M internal/ui/model.go +17 -16
@@ -12,6 +12,7 @@ import (
1212 "github.com/FelineStateMachine/012/internal/confine"1313 "github.com/FelineStateMachine/012/internal/sheet"1414 "github.com/FelineStateMachine/012/internal/telemetry"15+ "github.com/FelineStateMachine/012/internal/ui/overlay"1516 "github.com/FelineStateMachine/012/internal/ui/theme"1617 )1718
@@ -83,21 +84,21 @@ type Model struct {
8384 8485 // Components. Each owns its state and the handling of the input it8586 // takes; Model routes messages to them and composes what they draw.86- line lineEdit // the edit line of entries, prompts and search fields: line.go87- entry entry // typing into a cell: entry.go88- point pointer // the cell or range pointed at in POINT mode and range prompts89- prompt *prompt // a question on the context line: prompt.go90- overlay overlay // the open menu, picker or bar, if any (modeMenu): overlay.go91- mouse mouseState // drags, hover and double clicks: mouse.go92- tabs tabStrip // the sheet tabs and where each sheet was left: tabstrip.go93- find *findBar // the last search, reopened by Ctrl+F: find.go94- charts chartState // chart commands' target: charts.go95- copied clipboard // what Ctrl+V pastes: clipboard.go96- trace *trace // precedents or dependents being shown: trace.go97- xfer transfer // imports and downloads: transfer.go98- jev *jevRunner // answers JEV functions; nil without an API key: jev.go99- term terminal // what the terminal supports: graphics.go100- prefs prefs // the settings in effect and the theme chosen: prefs.go87+ line lineEdit // the edit line of entries, prompts and search fields: line.go88+ entry entry // typing into a cell: entry.go89+ point pointer // the cell or range pointed at in POINT mode and range prompts90+ prompt *prompt // a question on the context line: prompt.go91+ overlay overlay.Overlay // the open menu, picker or bar, if any (modeMenu): overlay.go92+ mouse mouseState // drags, hover and double clicks: mouse.go93+ tabs tabStrip // the sheet tabs and where each sheet was left: tabstrip.go94+ find *findBar // the last search, reopened by Ctrl+F: find.go95+ charts chartState // chart commands' target: charts.go96+ copied clipboard // what Ctrl+V pastes: clipboard.go97+ trace *trace // precedents or dependents being shown: trace.go98+ xfer transfer // imports and downloads: transfer.go99+ jev *jevRunner // answers JEV functions; nil without an API key: jev.go100+ term terminal // what the terminal supports: graphics.go101+ prefs prefs // the settings in effect and the theme chosen: prefs.go101102 102103 vim vimState // a vim key sequence in progress: vim.go103104 rec *recorder // a macro being recorded: macrorec.go
@@ -258,7 +259,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
258259 case modePoint:259260 return m.pointKey(k)260261 case modeMenu:261- return m.overlay.key(m, k)262+ return m.overlay.Key(k)262263 case modePrompt:263264 return m.prompt.key(m, k)264265 case modeError:
M internal/ui/names.go +11 -9
@@ -83,13 +83,14 @@ func namesItems(m *Model, sel sheet.Rect) []pickItem {
8383 8484 // current is the named range highlighted in the picker, if any.8585 func (p *namesPicker) current(m *Model) (sheet.Name, bool) {86- if p.picker.sel >= len(p.shown) {86+ if p.picker.Sel >= len(p.shown) {8787 return sheet.Name{}, false8888 }89- return m.sheet.LookupName(p.shown[p.picker.sel].item.title)89+ return m.sheet.LookupName(p.shown[p.picker.Sel].item.title)9090 }9191 92-func (p *namesPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {92+func (p *namesPicker) Key(k tea.KeyPressMsg) tea.Cmd {93+ m := p.m9394 p.msg = ""9495 switch k.String() {9596 case "f2":
@@ -104,16 +105,17 @@ func (p *namesPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
104105 m.changed = true105106 p.msg = "Deleted " + n.Name + "; Ctrl+Z brings it back"106107 p.items = namesItems(m, p.sel)107- sel := p.picker.sel108- p.changed(m)109- p.picker.sel = max(min(sel, len(p.shown)-1), 0)108+ sel := p.picker.Sel109+ p.Changed()110+ p.picker.Sel = max(min(sel, len(p.shown)-1), 0)110111 }111112 return nil112113 }113- return p.picker.key(m, k)114+ return p.picker.Key(k)114115 }115116 116-func (p *namesPicker) status(m *Model) (string, string) {117+func (p *namesPicker) Status() (string, string) {118+ m := p.m117119 keys := m.th.KeyHints("Enter", "go to", "F2", "edit", "Ctrl+D", "delete", "Esc", "close")118120 if _, ok := p.current(m); !ok {119121 keys = m.th.KeyHints("Enter", "add", "Esc", "close")
@@ -121,7 +123,7 @@ func (p *namesPicker) status(m *Model) (string, string) {
121123 if p.msg != "" {122124 return p.msg, keys123125 }124- desc, _ := p.picker.status(m)126+ desc, _ := p.picker.Status()125127 return desc, keys126128 }127129
M internal/ui/overlay.go +17 -88
@@ -5,64 +5,11 @@ import (
55 66 tea "charm.land/bubbletea/v2"77 "charm.land/lipgloss/v2"8- "github.com/charmbracelet/x/ansi"8+ "github.com/FelineStateMachine/012/internal/ui/overlay"99 )1010 11-// overlay is a floating box drawn over the grid: a dropdown or context12-// menu, the command palette or a dialog. While one is open it takes every13-// key and mouse event, and Esc closes one level. Overlays are composited14-// on top of the finished screen, so the grid underneath never moves.15-type overlay interface {16- // indicator is the mode shown at the top right, e.g. "MENU".17- indicator() string18- // layout places the overlay's boxes on the screen, bottom first.19- layout(m *Model) []box20- key(m *Model, k tea.KeyPressMsg) tea.Cmd21- mouse(m *Model, e mouseEvent) tea.Cmd22- // status is what the status line says while the overlay is open: what23- // the highlighted item does, and the keys that apply.24- status(m *Model) (desc, keys string)25-}26-27-// textOverlay is an overlay with a text input, which gets the terminal28-// cursor. The text is edited in Model.line, like any other entry.29-type textOverlay interface {30- overlay31- cursor(m *Model) (x, y int)32- changed(m *Model) // the text changed33-}34-35-// box is a rectangle of styled lines at a screen position. Every line has36-// the same display width.37-type box struct {38- id string // identifies the box in mouse hit tests39- x, y int40- lines []string41-}42-43-func (b box) width() int { return ansi.StringWidth(b.lines[0]) }44-func (b box) height() int { return len(b.lines) }45-46-type mouseKind int47-48-const (49- mousePress mouseKind = iota50- mouseMotion51- mouseRelease52- mouseWheel53-)54-55-// mouseEvent is a mouse event with what's under it already worked out.56-type mouseEvent struct {57- kind mouseKind58- button tea.MouseButton59- x, y int60- box string // id of the topmost box under the mouse, "" for none61- col, row int // position inside that box62-}63-6411 // openOverlay shows o, replacing any open overlay.65-func (m *Model) openOverlay(o overlay) {12+func (m *Model) openOverlay(o overlay.Overlay) {6613 m.overlay = o6714 m.mode = modeMenu6815 }
@@ -89,29 +36,29 @@ func (m *Model) runFromOverlay(id string) tea.Cmd {
8936 9037 // compositor stacks boxes over the screen in order, each on its own9138 // layer so hit tests find the topmost box.92-func compositor(boxes []box) *lipgloss.Compositor {39+func compositor(boxes []overlay.Box) *lipgloss.Compositor {9340 layers := make([]*lipgloss.Layer, len(boxes))9441 for i, b := range boxes {95- layers[i] = lipgloss.NewLayer(strings.Join(b.lines, "\n")).X(b.x).Y(b.y).Z(i + 1).ID(b.id)42+ layers[i] = lipgloss.NewLayer(strings.Join(b.Lines, "\n")).X(b.X).Y(b.Y).Z(i + 1).ID(b.ID)9643 }9744 return lipgloss.NewCompositor(layers...)9845 }9946 10047 // floating returns the boxes drawn over the screen: the open overlay's,10148 // or the formula suggestions while typing.102-func (m *Model) floating() []box {49+func (m *Model) floating() []overlay.Box {10350 if m.overlay != nil {104- return m.overlay.layout(m)51+ return m.overlay.Layout()10552 }10653 if b, ok := m.entry.assist.box(m); ok {107- return []box{b}54+ return []overlay.Box{b}10855 }10956 return nil11057 }11158 11259 // compose draws the charts floating over the grid, then boxes (the open11360 // overlay or formula suggestions), over the rendered screen.114-func (m *Model) compose(screen string, boxes []box) string {61+func (m *Model) compose(screen string, boxes []overlay.Box) string {11562 c := lipgloss.NewCanvas(m.width, m.height)11663 c.Compose(lipgloss.NewLayer(screen))11764 if charts := m.chartBoxes(); len(charts) > 0 {
@@ -157,38 +104,20 @@ func (m *Model) shellMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
157104 // position under the mouse.158105 func (m *Model) overlayMouse(msg tea.MouseMsg) tea.Cmd {159106 mouse := msg.Mouse()160- e := mouseEvent{button: mouse.Button, x: mouse.X, y: mouse.Y}107+ e := overlay.MouseEvent{Button: mouse.Button, X: mouse.X, Y: mouse.Y}161108 switch msg.(type) {162109 case tea.MouseClickMsg:163- e.kind = mousePress110+ e.Kind = overlay.MousePress164111 case tea.MouseMotionMsg:165- e.kind = mouseMotion112+ e.Kind = overlay.MouseMotion166113 case tea.MouseReleaseMsg:167- e.kind = mouseRelease114+ e.Kind = overlay.MouseRelease168115 case tea.MouseWheelMsg:169- e.kind = mouseWheel116+ e.Kind = overlay.MouseWheel170117 }171- if h := compositor(m.overlay.layout(m)).Hit(e.x, e.y); !h.Empty() {172- e.box = h.ID()173- e.col, e.row = e.x-h.Bounds().Min.X, e.y-h.Bounds().Min.Y118+ if h := compositor(m.overlay.Layout()).Hit(e.X, e.Y); !h.Empty() {119+ e.Box = h.ID()120+ e.Col, e.Row = e.X-h.Bounds().Min.X, e.Y-h.Bounds().Min.Y174121 }175- return m.overlay.mouse(m, e)176-}177-178-// list is the highlighted row and scroll position of a list in a box.179-type list struct {180- sel, top int181-}182-183-// move highlights the row d steps away, wrapping around the ends.184-func (l *list) move(d, n int) {185- if n > 0 {186- l.sel = ((l.sel+d)%n + n) % n187- }188-}189-190-// show scrolls so the highlighted row is among the rows visible ones.191-func (l *list) show(rows int) {192- l.top = clamp(l.top, l.sel-rows+1, l.sel)193- l.top = max(l.top, 0)122+ return m.overlay.Mouse(e)194123 }
A internal/ui/overlay/overlay.go +97 -0
@@ -0,0 +1,97 @@
1+// Package overlay is the contract between the UI's root model and the2+// components that take over input while open: menus, pickers, bars on3+// the context line and dialogs. It holds what they share (the boxes they4+// draw, the mouse events they get, a scrolling list) and nothing of the5+// model, so a component can live in a package of its own and be tested6+// alone. Each component is built with the small host interface it acts7+// on; the model implements those.8+package overlay9+10+import (11+ tea "charm.land/bubbletea/v2"12+ "github.com/charmbracelet/x/ansi"13+)14+15+// The control panel's rows that overlays anchor to.16+const (17+ MenuLine = 0 // the menu bar; dropdowns and pickers open under it18+ ContextLine = 2 // prompts and bars; completions open under it19+)20+21+// Overlay is a floating box drawn over the grid: a dropdown or context22+// menu, the command palette or a dialog. While one is open it takes every23+// key and mouse event, and Esc closes one level. Overlays are composited24+// on top of the finished screen, so the grid underneath never moves.25+type Overlay interface {26+ // Indicator is the mode shown at the top right, e.g. "MENU".27+ Indicator() string28+ // Layout places the overlay's boxes on the screen, bottom first.29+ Layout() []Box30+ Key(k tea.KeyPressMsg) tea.Cmd31+ Mouse(e MouseEvent) tea.Cmd32+ // Status is what the status line says while the overlay is open: what33+ // the highlighted item does, and the keys that apply.34+ Status() (desc, keys string)35+}36+37+// Text is an overlay with a text input, which gets the terminal cursor.38+// The text is edited in the model's shared edit line, like any other39+// entry.40+type Text interface {41+ Overlay42+ Cursor() (x, y int)43+ Changed() // the text changed44+}45+46+// Liner is an overlay drawn on the context line, such as the find bar.47+type Liner interface {48+ ContextLine() (left, right string)49+}50+51+// Box is a rectangle of styled lines at a screen position. Every line has52+// the same display width.53+type Box struct {54+ ID string // identifies the box in mouse hit tests55+ X, Y int56+ Lines []string57+}58+59+func (b Box) Width() int { return ansi.StringWidth(b.Lines[0]) }60+func (b Box) Height() int { return len(b.Lines) }61+62+// MouseKind is what a mouse event is.63+type MouseKind int64+65+const (66+ MousePress MouseKind = iota67+ MouseMotion68+ MouseRelease69+ MouseWheel70+)71+72+// MouseEvent is a mouse event with what's under it already worked out.73+type MouseEvent struct {74+ Kind MouseKind75+ Button tea.MouseButton76+ X, Y int77+ Box string // ID of the topmost box under the mouse, "" for none78+ Col, Row int // position inside that box79+}80+81+// List is the highlighted row and scroll position of a list in a box.82+type List struct {83+ Sel, Top int84+}85+86+// Move highlights the row d steps away, wrapping around the ends.87+func (l *List) Move(d, n int) {88+ if n > 0 {89+ l.Sel = ((l.Sel+d)%n + n) % n90+ }91+}92+93+// Show scrolls so the highlighted row is among the visible rows.94+func (l *List) Show(rows int) {95+ l.Top = max(l.Sel-rows+1, min(l.Top, l.Sel))96+ l.Top = max(l.Top, 0)97+}
M internal/ui/palette.go +46 -38
@@ -11,6 +11,7 @@ import (
1111 "github.com/charmbracelet/x/ansi"1212 "github.com/sahilm/fuzzy"1313 14+ "github.com/FelineStateMachine/012/internal/ui/overlay"1415 "github.com/FelineStateMachine/012/internal/ui/theme"1516 )1617
@@ -96,13 +97,14 @@ type pickMatch struct {
9697 // the highlighted result's description on the status line. The search is9798 // edited in Model.line with the usual line-editing keys.9899 type picker struct {100+ m *Model // the model it acts on99101 title string100102 placeholder string101103 maxW int102104 action string // what Enter does, for the key hints103105 items []pickItem104106 shown []pickMatch105- list107+ overlay.List106108 107109 // enter, when set, gets the first look at Enter with the search text,108110 // e.g. to take a typed path; it reports whether it handled it.
@@ -120,18 +122,19 @@ const (
120122 )121123 122124 func newPicker(m *Model, title, placeholder string, maxW int, items []pickItem) *picker {123- p := &picker{title: title, placeholder: placeholder, maxW: maxW, items: items}125+ p := &picker{m: m, title: title, placeholder: placeholder, maxW: maxW, items: items}124126 m.line.clear()125- p.changed(m)127+ p.Changed()126128 return p127129 }128130 129-func (p *picker) indicator() string { return "MENU" }131+func (p *picker) Indicator() string { return "MENU" }130132 131-// changed filters the items by the search. Best matches come first; ties133+// Changed filters the items by the search. Best matches come first; ties132134 // keep menu order.133-func (p *picker) changed(m *Model) {134- p.sel, p.top = 0, 0135+func (p *picker) Changed() {136+ m := p.m137+ p.Sel, p.Top = 0, 0135138 p.shown = p.shown[:0]136139 q := strings.TrimSpace(m.line.text())137140 if q == "" {
@@ -208,17 +211,18 @@ func wordPrefix(title, q string) bool {
208211 return false209212 }210213 211-func (p *picker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {214+func (p *picker) Key(k tea.KeyPressMsg) tea.Cmd {215+ m := p.m212216 rows := p.rows(m)213217 switch k.String() {214218 case "up", "ctrl+p", "shift+tab":215- p.move(-1, len(p.shown))219+ p.Move(-1, len(p.shown))216220 case "down", "ctrl+n", "tab":217- p.move(1, len(p.shown))221+ p.Move(1, len(p.shown))218222 case "pgup":219- p.sel = max(p.sel-rows, 0)223+ p.Sel = max(p.Sel-rows, 0)220224 case "pgdown":221- p.sel = max(min(p.sel+rows, len(p.shown)-1), 0)225+ p.Sel = max(min(p.Sel+rows, len(p.shown)-1), 0)222226 case "enter":223227 return p.pick(m)224228 case "esc":
@@ -227,7 +231,7 @@ func (p *picker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
227231 before := m.line.text()228232 m.line.key(k)229233 if m.line.text() != before {230- p.changed(m)234+ p.Changed()231235 }232236 }233237 return nil
@@ -239,10 +243,10 @@ func (p *picker) pick(m *Model) tea.Cmd {
239243 return cmd240244 }241245 }242- if p.sel >= len(p.shown) || p.shown[p.sel].item.off {246+ if p.Sel >= len(p.shown) || p.shown[p.Sel].item.off {243247 return nil244248 }245- it := p.shown[p.sel].item249+ it := p.shown[p.Sel].item246250 cmd := it.pick(m)247251 if p.answers {248252 m.recordAnswer(it.title, false)
@@ -274,35 +278,37 @@ func (p *picker) answer(m *Model, title string) (tea.Cmd, bool) {
274278 // the results.275279 const pickerFirstRow = 3276280 277-func (p *picker) mouse(m *Model, e mouseEvent) tea.Cmd {278- if e.box != pickerID {279- if e.kind == mousePress {281+func (p *picker) Mouse(e overlay.MouseEvent) tea.Cmd {282+ m := p.m283+ if e.Box != pickerID {284+ if e.Kind == overlay.MousePress {280285 p.close(m)281286 }282287 return nil283288 }284- i := p.top + e.row - pickerFirstRow289+ i := p.Top + e.Row - pickerFirstRow285290 switch {286- case e.kind == mouseWheel && e.button == tea.MouseWheelUp:287- p.move(-1, len(p.shown))288- case e.kind == mouseWheel && e.button == tea.MouseWheelDown:289- p.move(1, len(p.shown))290- case e.row < pickerFirstRow || i >= len(p.shown) || i >= p.top+p.rows(m):291- case e.kind == mouseMotion:292- p.sel = i293- case e.kind == mousePress && e.button == tea.MouseLeft:294- p.sel = i291+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:292+ p.Move(-1, len(p.shown))293+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:294+ p.Move(1, len(p.shown))295+ case e.Row < pickerFirstRow || i >= len(p.shown) || i >= p.Top+p.rows(m):296+ case e.Kind == overlay.MouseMotion:297+ p.Sel = i298+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:299+ p.Sel = i295300 return p.pick(m)296301 }297302 return nil298303 }299304 300-func (p *picker) status(m *Model) (string, string) {305+func (p *picker) Status() (string, string) {306+ m := p.m301307 keys := m.th.KeyHints("Up/Down", "move", "Enter", cmp.Or(p.action, "run"), "Esc", "close")302- if p.sel >= len(p.shown) {308+ if p.Sel >= len(p.shown) {303309 return "", keys304310 }305- it := p.shown[p.sel].item311+ it := p.shown[p.Sel].item306312 if it.off {307313 return it.desc + " (not available now)", keys308314 }
@@ -328,16 +334,18 @@ func (p *picker) box(m *Model) (x, y, inner int) {
328334 return (m.width - inner - 2) / 2, menuLine + 1, inner329335 }330336 331-func (p *picker) cursor(m *Model) (int, int) {337+func (p *picker) Cursor() (int, int) {338+ m := p.m332339 x, y, _ := p.box(m)333340 return x + 1 + ansi.StringWidth(searchPrompt) + ansi.StringWidth(m.line.head()), y + 1334341 }335342 336-func (p *picker) layout(m *Model) []box {343+func (p *picker) Layout() []overlay.Box {344+ m := p.m337345 x, y, inner := p.box(m)338346 rows := p.rows(m)339347 if len(p.shown) > 0 {340- p.show(rows)348+ p.Show(rows)341349 }342350 input := m.th.Title.Render(searchPrompt) + m.line.text()343351 if len(m.line.buf) == 0 {
@@ -346,7 +354,7 @@ func (p *picker) layout(m *Model) []box {
346354 lines := []string{theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}347355 lines = append(lines, p.resultRows(m, inner, rows)...)348356 footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.items))349- return []box{{id: pickerID, x: x, y: y, lines: m.th.Frame(inner, p.title, footer, lines)}}357+ return []overlay.Box{{ID: pickerID, X: x, Y: y, Lines: m.th.Frame(inner, p.title, footer, lines)}}350358 }351359 352360 // resultRows lays out the visible results in columns: title, detail and
@@ -364,7 +372,7 @@ func (p *picker) resultRows(m *Model, inner, rows int) []string {
364372 dw := inner - 1 - tw - 3 - kw - 2365373 out := make([]string, 0, rows)366374 for r := range rows {367- i := p.top + r375+ i := p.Top + r368376 if i >= len(p.shown) {369377 text := ""370378 if r == 0 {
@@ -376,7 +384,7 @@ func (p *picker) resultRows(m *Model, inner, rows int) []string {
376384 pm := p.shown[i]377385 base, dim, hl := m.th.MenuBar, m.th.Muted, m.th.Match378386 switch {379- case i == p.sel:387+ case i == p.Sel:380388 base, dim, hl = m.th.MenuSelected, m.th.MenuSelected, m.th.MatchSelected381389 case pm.item.off:382390 base, dim, hl = m.th.Disabled, m.th.Disabled, m.th.Disabled
@@ -388,7 +396,7 @@ func (p *picker) resultRows(m *Model, inner, rows int) []string {
388396 k := ""389397 switch {390398 case pm.item.key == "":391- case i == p.sel:399+ case i == p.Sel:392400 k = base.Render(" " + pm.item.key + " ")393401 case pm.item.off:394402 k = m.th.Disabled.Render(" " + pm.item.key + " ")
M internal/ui/palette_test.go +9 -9
@@ -20,10 +20,10 @@ func openPicker(t *testing.T, m *Model) *picker {
2020 func selected(t *testing.T, m *Model) string {2121 t.Helper()2222 p := openPicker(t, m)23- if p.sel >= len(p.shown) {23+ if p.Sel >= len(p.shown) {2424 return ""2525 }26- return p.shown[p.sel].item.title26+ return p.shown[p.Sel].item.title2727 }2828 2929 func TestPaletteKeys(t *testing.T) {
@@ -50,8 +50,8 @@ func TestPaletteSearchRunsCommand(t *testing.T) {
5050 t.Errorf("result should show its menu path:\n%s", screen(m))5151 }5252 x, y, _ := m.cursorPos()53- if b := p.layout(m)[0]; y != b.y+1 || x != b.x+1+3+len("colwid") {54- t.Errorf("cursor at %d,%d, box at %d,%d", x, y, b.x, b.y)53+ if b := p.Layout()[0]; y != b.Y+1 || x != b.X+1+3+len("colwid") {54+ t.Errorf("cursor at %d,%d, box at %d,%d", x, y, b.X, b.Y)5555 }5656 press(t, m, "<enter>")5757 if m.overlay != nil || m.mode != modePrompt || m.prompt.kind != promptWidth {
@@ -81,8 +81,8 @@ func TestPaletteMatchesMenuPath(t *testing.T) {
8181 }8282 // Up from the top wraps to the bottom.8383 press(t, m, "<up>", "<up>", "<up>", "<up>")84- if p := openPicker(t, m); p.sel != len(p.shown)-1 {85- t.Errorf("wrapped to %d of %d", p.sel, len(p.shown))84+ if p := openPicker(t, m); p.Sel != len(p.shown)-1 {85+ t.Errorf("wrapped to %d of %d", p.Sel, len(p.shown))8686 }8787 }8888
@@ -168,18 +168,18 @@ func TestPaletteMouse(t *testing.T) {
168168 m := newModel()169169 press(t, m, "<ctrl+k>", "go")170170 p := openPicker(t, m)171- b := p.layout(m)[0]171+ b := p.Layout()[0]172172 var row int173173 for i, pm := range p.shown {174174 if pm.item.title == "Go to" {175175 row = i176176 }177177 }178- mouseAt(m, tea.MouseMotionMsg{X: b.x + 4, Y: b.y + pickerFirstRow + row})178+ mouseAt(m, tea.MouseMotionMsg{X: b.X + 4, Y: b.Y + pickerFirstRow + row})179179 if selected(t, m) != "Go to" {180180 t.Fatalf("hover selected %q", selected(t, m))181181 }182- leftClick(m, b.x+4, b.y+pickerFirstRow+row)182+ leftClick(m, b.X+4, b.Y+pickerFirstRow+row)183183 if m.mode != modePrompt || m.prompt.label != "Go to:" {184184 t.Errorf("click ran: mode %v", m.mode)185185 }
M internal/ui/panel.go +7 -13
@@ -14,6 +14,7 @@ import (
1414 1515 "github.com/FelineStateMachine/012/internal/sheet"1616 "github.com/FelineStateMachine/012/internal/telemetry"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"1718 "github.com/FelineStateMachine/012/internal/ui/theme"1819 )1920
@@ -136,7 +137,7 @@ func (m *Model) indicator() string {
136137 case m.macros.run != nil:137138 return "CMD" // 1-2-3's indicator while a macro runs138139 case m.overlay != nil:139- return m.overlay.indicator()140+ return m.overlay.Indicator()140141 case m.mode == modePrompt:141142 return m.prompt.indicator142143 case m.vimActive() && m.visual() != visualNone:
@@ -220,15 +221,8 @@ func (m *Model) contextLineText() string {
220221 left = m.recordingLine()221222 }222223 case m.mode == modeMenu:223- switch o := m.overlay.(type) {224- case *choiceBar:225- left = o.line(m)226- case *findBar:227- left, right = o.line(m)228- case contextLiner:229- left, right = o.contextLine(m)230- case *sortBar:231- left = o.line(m)224+ if o, ok := m.overlay.(overlay.Liner); ok {225+ left, right = o.ContextLine()232226 }233227 case m.mode == modePrompt:234228 left, right = m.prompt.line(m)
@@ -264,8 +258,8 @@ func (m *Model) spread(left, right string) string {
264258 // while typing an entry, on the context line in a text prompt, or in an265259 // overlay's search field.266260 func (m *Model) cursorPos() (x, y int, ok bool) {267- if o, isText := m.overlay.(textOverlay); isText {268- x, y = o.cursor(m)261+ if o, isText := m.overlay.(overlay.Text); isText {262+ x, y = o.Cursor()269263 return x, y, true270264 }271265 switch {
@@ -305,7 +299,7 @@ func (m *Model) statusLayout() (string, []tabSpan) {
305299 func (m *Model) floatingStatus() (string, bool) {306300 desc, keys, floating := m.entry.assist.status(m)307301 if m.overlay != nil {308- desc, keys = m.overlay.status(m)302+ desc, keys = m.overlay.Status()309303 floating = true310304 }311305 if !floating || desc == "" && keys == "" {
M internal/ui/pivotactions.go +8 -5
M internal/ui/pivoteditor.go +35 -29
@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/x/ansi"99 1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"1112 "github.com/FelineStateMachine/012/internal/ui/theme"1213 )1314
@@ -21,10 +22,11 @@ import (
2122 // it. Enter keeps the changes; Esc undoes them, removing a pivot just2223 // created.2324 type pivotEditor struct {25+ m *Model // the model it acts on2426 start int // the workbook's state before editing, to undo back to2527 back *sheet.Sheet // for a new pivot, the sheet it summarizes, shown again on Esc2628 msg string // why the last change was refused, for the status line27- list29+ overlay.List2830 }2931 3032 // Sections of the editor, in order.
@@ -68,13 +70,13 @@ const pivotEditorID = "pivot"
6870 // openPivotEditor edits the shown sheet's pivot. start is the state Esc6971 // returns to; back, for a pivot just created, the sheet to show then.7072 func (m *Model) openPivotEditor(start int, back *sheet.Sheet) {71- e := &pivotEditor{start: start, back: back}72- e.sel = 2 // the Rows section73+ e := &pivotEditor{m: m, start: start, back: back}74+ e.Sel = 2 // the Rows section7375 m.clearSelection()7476 m.openOverlay(e)7577 }7678 77-func (e *pivotEditor) indicator() string { return "PIVOT" }79+func (e *pivotEditor) Indicator() string { return "PIVOT" }7880 7981 func (e *pivotEditor) pivot(m *Model) sheet.Pivot {8082 p, _ := m.sheet.Pivot()
@@ -106,9 +108,9 @@ func selectable(it pivotItem) bool { return it.kind != itemSep && it.kind != ite
106108 107109 // step moves the highlight d selectable lines, stopping at the ends.108110 func (e *pivotEditor) step(items []pivotItem, d int) {109- for i := e.sel + d; i >= 0 && i < len(items); i += d {111+ for i := e.Sel + d; i >= 0 && i < len(items); i += d {110112 if selectable(items[i]) {111- e.sel = i113+ e.Sel = i112114 return113115 }114116 }
@@ -117,18 +119,18 @@ func (e *pivotEditor) step(items []pivotItem, d int) {
117119 // current is the highlighted line, kept on a selectable one.118120 func (e *pivotEditor) current(m *Model) pivotItem {119121 items := e.items(m)120- e.sel = clamp(e.sel, 0, len(items)-1)121- if !selectable(items[e.sel]) {122+ e.Sel = clamp(e.Sel, 0, len(items)-1)123+ if !selectable(items[e.Sel]) {122124 e.step(items, -1)123125 }124- return items[e.sel]126+ return items[e.Sel]125127 }126128 127129 // selectItem highlights the line showing it, if any.128130 func (e *pivotEditor) selectItem(m *Model, it pivotItem) {129131 for i, x := range e.items(m) {130132 if x == it {131- e.sel = i133+ e.Sel = i132134 }133135 }134136 }
@@ -144,7 +146,8 @@ func (e *pivotEditor) set(m *Model, label string, fn func(p *sheet.Pivot)) {
144146 m.changed = m.sheet.StateID() != m.saved145147 }146148 147-func (e *pivotEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {149+func (e *pivotEditor) Key(k tea.KeyPressMsg) tea.Cmd {150+ m := e.m148151 if _, ok := m.sheet.Pivot(); !ok {149152 m.closeOverlay()150153 return nil
@@ -160,9 +163,9 @@ func (e *pivotEditor) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
160163 case "down", "ctrl+n", "tab":161164 e.step(e.items(m), 1)162165 case "home", "pgup":163- e.sel = 0166+ e.Sel = 0164167 case "end", "pgdown":165- e.sel = len(e.items(m)) - 1168+ e.Sel = len(e.items(m)) - 1166169 case "shift+up", "shift+down":167170 e.reorder(m, it, key == "shift+down")168171 case "left", "right":
@@ -195,26 +198,27 @@ func (e *pivotEditor) cancel(m *Model) {
195198 // reopen returns to the editor after a picker or prompt.196199 func (e *pivotEditor) reopen(m *Model) { m.openOverlay(e) }197200 198-func (e *pivotEditor) mouse(m *Model, ev mouseEvent) tea.Cmd {199- if ev.box != pivotEditorID {200- if ev.kind == mousePress {201+func (e *pivotEditor) Mouse(ev overlay.MouseEvent) tea.Cmd {202+ m := e.m203+ if ev.Box != pivotEditorID {204+ if ev.Kind == overlay.MousePress {201205 m.closeOverlay() // a click elsewhere keeps the changes202206 }203207 return nil204208 }205209 items := e.items(m)206- i := e.top + ev.row - 1210+ i := e.Top + ev.Row - 1207211 switch {208- case ev.kind == mouseWheel && ev.button == tea.MouseWheelUp:212+ case ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelUp:209213 e.step(items, -1)210- case ev.kind == mouseWheel && ev.button == tea.MouseWheelDown:214+ case ev.Kind == overlay.MouseWheel && ev.Button == tea.MouseWheelDown:211215 e.step(items, 1)212- case ev.kind != mousePress || ev.button != tea.MouseLeft || i < 0 || i >= len(items) || !selectable(items[i]):213- case i == e.sel || items[i].kind == itemToggle:214- e.sel = i216+ case ev.Kind != overlay.MousePress || ev.Button != tea.MouseLeft || i < 0 || i >= len(items) || !selectable(items[i]):217+ case i == e.Sel || items[i].kind == itemToggle:218+ e.Sel = i215219 return e.act(m, items[i])216220 default:217- e.sel = i221+ e.Sel = i218222 }219223 return nil220224 }
@@ -230,26 +234,27 @@ func (e *pivotEditor) box(m *Model) (x, y, inner int) {
230234 return max(m.width-inner-2, 0), gridTop, inner231235 }232236 233-func (e *pivotEditor) layout(m *Model) []box {237+func (e *pivotEditor) Layout() []overlay.Box {238+ m := e.m234239 x, y, inner := e.box(m)235240 items := e.items(m)236241 e.current(m)237242 rows := e.rows(m, len(items))238- e.show(rows)243+ e.Show(rows)239244 p := e.pivot(m)240245 var lines []string241246 for r := range rows {242- i := e.top + r247+ i := e.Top + r243248 if i >= len(items) {244249 break245250 }246- lines = append(lines, e.line(m, p, items[i], i == e.sel, inner))251+ lines = append(lines, e.line(m, p, items[i], i == e.Sel, inner))247252 }248253 footer := ""249254 if out, ok := m.sheet.PivotRange(); ok && m.sheet.PivotError() == "" {250255 footer = "results " + out.String()251256 }252- return []box{{id: pivotEditorID, x: x, y: y, lines: m.th.Frame(inner, "Pivot table", footer, lines)}}257+ return []overlay.Box{{ID: pivotEditorID, X: x, Y: y, Lines: m.th.Frame(inner, "Pivot table", footer, lines)}}253258 }254259 255260 // line draws one line of the editor, inner columns wide.
@@ -313,7 +318,8 @@ func (e *pivotEditor) sourceText(m *Model, p sheet.Pivot, sel bool) string {
313318 return m.th.Key.Render(text)314319 }315320 316-func (e *pivotEditor) status(m *Model) (string, string) {321+func (e *pivotEditor) Status() (string, string) {322+ m := e.m317323 it := e.current(m)318324 desc, pairs := e.help(m, it)319325 if e.msg != "" {
M internal/ui/shell_test.go +5 -5
M internal/ui/sort.go +21 -16
@@ -9,6 +9,7 @@ import (
99 1010 "github.com/FelineStateMachine/012/internal/sheet"1111 "github.com/FelineStateMachine/012/internal/telemetry"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"1213 )1314 1415 // Sorting follows Sheets' Data menu: sort the sheet or a range by the
@@ -127,6 +128,7 @@ func (g *grid) looksLikeHeader(r sheet.Rect) bool {
127128 128129 // sortBar picks the columns and order to sort a range by.129130 type sortBar struct {131+ m *Model // the model it acts on130132 rng sheet.Rect // the whole range, header rows included131133 headers int // header rows at the top of rng; 0 when switched off132134 guess int // header rows to use when switched on
@@ -136,7 +138,7 @@ type sortBar struct {
136138 137139 func (m *Model) openSortBar() {138140 r := m.dataRange()139- b := &sortBar{rng: r, headers: m.headerRows(r, m.hasRange())}141+ b := &sortBar{m: m, rng: r, headers: m.headerRows(r, m.hasRange())}140142 b.guess = max(b.headers, 1)141143 b.keys = []sheet.SortKey{{Col: clamp(m.cur.Col, r.From.Col, r.To.Col)}}142144 m.openOverlay(b)
@@ -166,8 +168,8 @@ func (m *Model) barActive() (sheet.Addr, bool) {
166168 return sheet.Addr{}, false167169 }168170 169-func (b *sortBar) indicator() string { return "SORT" }170-func (b *sortBar) layout(*Model) []box { return nil }171+func (b *sortBar) Indicator() string { return "SORT" }172+func (b *sortBar) Layout() []overlay.Box { return nil }171173 172174 // setCol points the focused key at column c, if it's in the range.173175 func (b *sortBar) setCol(c int) {
@@ -192,7 +194,8 @@ func (b *sortBar) add() {
192194 }193195 }194196 195-func (b *sortBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {197+func (b *sortBar) Key(k tea.KeyPressMsg) tea.Cmd {198+ m := b.m196199 key := k.String()197200 switch key {198201 case "esc":
@@ -269,32 +272,33 @@ func (b *sortBar) parts(m *Model) []sortPart {
269272 return parts270273 }271274 272-// line renders the bar for the context line.273-func (b *sortBar) line(m *Model) string {275+// ContextLine renders the bar for the context line.276+func (b *sortBar) ContextLine() (string, string) {274277 var s strings.Builder275- for _, p := range b.parts(m) {278+ for _, p := range b.parts(b.m) {276279 s.WriteString(p.text)277280 }278- return s.String()281+ return s.String(), ""279282 }280283 281-// mouse focuses a key's chip (a second click flips its order) or flips284+// Mouse focuses a key's chip (a second click flips its order) or flips282285 // the header row toggle; a click anywhere else cancels.283-func (b *sortBar) mouse(m *Model, e mouseEvent) tea.Cmd {284- if e.kind != mousePress {286+func (b *sortBar) Mouse(e overlay.MouseEvent) tea.Cmd {287+ m := b.m288+ if e.Kind != overlay.MousePress {285289 return nil286290 }287- if e.y != contextLine {291+ if e.Y != contextLine {288292 m.closeOverlay()289293 return nil290294 }291295 x := 0292296 for _, p := range b.parts(m) {293297 w := ansi.StringWidth(p.text)294- if e.x >= x && e.x < x+w {298+ if e.X >= x && e.X < x+w {295299 switch {296300 case p.toggle:297- return b.key(m, keyFor("alt+h"))301+ return b.Key(keyFor("alt+h"))298302 case p.key == b.cur:299303 b.keys[b.cur].Desc = !b.keys[b.cur].Desc300304 case p.key >= 0:
@@ -307,8 +311,9 @@ func (b *sortBar) mouse(m *Model, e mouseEvent) tea.Cmd {
307311 return nil308312 }309313 310-// status shows the keys, the most useful ones first on narrow screens.311-func (b *sortBar) status(m *Model) (string, string) {314+// Status shows the keys, the most useful ones first on narrow screens.315+func (b *sortBar) Status() (string, string) {316+ m := b.m312317 pairs := []string{"Left/Right", "column", "Space", "order", "Enter", "sort", "Esc", "cancel"}313318 desc := "Alt+A add Alt+H header Tab next"314319 for {
M internal/ui/tabs.go +2 -2
M internal/ui/tabs_test.go +2 -2
M internal/ui/themepicker.go +14 -10
@@ -6,6 +6,7 @@ import (
66 tea "charm.land/bubbletea/v2"77 88 "github.com/FelineStateMachine/012/internal/config"9+ "github.com/FelineStateMachine/012/internal/ui/overlay"910 "github.com/FelineStateMachine/012/internal/ui/theme"1011 )1112
@@ -44,44 +45,47 @@ func newThemePicker(m *Model) *themePicker {
4445 }4546 tp := &themePicker{picker: newPicker(m, "Theme", "Type a name, dark or light", 64, items)}4647 tp.action = "keep"47- tp.sel = sel48+ tp.Sel = sel4849 tp.preview(m)4950 return tp5051 }5152 52-func (tp *themePicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {53+func (tp *themePicker) Key(k tea.KeyPressMsg) tea.Cmd {54+ m := tp.m5355 if k.String() == "esc" {5456 tp.restore(m)5557 }56- cmd := tp.picker.key(m, k)58+ cmd := tp.picker.Key(k)5759 if m.overlay == tp {5860 tp.preview(m)5961 }6062 return cmd6163 }6264 63-func (tp *themePicker) mouse(m *Model, e mouseEvent) tea.Cmd {64- if e.box != pickerID && e.kind == mousePress {65+func (tp *themePicker) Mouse(e overlay.MouseEvent) tea.Cmd {66+ m := tp.m67+ if e.Box != pickerID && e.Kind == overlay.MousePress {6568 tp.restore(m)6669 }67- cmd := tp.picker.mouse(m, e)70+ cmd := tp.picker.Mouse(e)6871 if m.overlay == tp {6972 tp.preview(m)7073 }7174 return cmd7275 }7376 74-func (tp *themePicker) changed(m *Model) {75- tp.picker.changed(m)77+func (tp *themePicker) Changed() {78+ m := tp.m79+ tp.picker.Changed()7680 tp.preview(m)7781 }7882 7983 // preview draws the highlighted theme.8084 func (tp *themePicker) preview(m *Model) {81- if tp.sel >= len(tp.shown) {85+ if tp.Sel >= len(tp.shown) {8286 return8387 }84- name := tp.shown[tp.sel].item.title88+ name := tp.shown[tp.Sel].item.title8589 if name == tp.shownName {8690 return8791 }
M internal/ui/transfer.go +1 -0