cat / 012
clone

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

Merge branch 'main' into worktree-agent-aea7749106333daaf

Dami committed d6aac3a
commit
d6aac3aea17765c50c8e668a7f1ec68bc99e19bc
parent
c247b57c1e3eaddceb7b70305a53222155be30f9
parent
c02df3ccd66c34aff2d884f19e164c15e1954e72
author
Dami <dami.etoile@gmail.com>
78 files changed, +3953 -2318
Mdocs/architecture.md71++++-
Mdocs/extending.md22+-
Minternal/ui/assist.go40++-
Minternal/ui/assist_test.go20+-
Minternal/ui/assistsheets.go11+-
Minternal/ui/assistsheets_test.go16+-
Minternal/ui/charteditor.go36+-
Minternal/ui/charts.go13+-
Minternal/ui/charts_test.go2+-
Minternal/ui/chartsel.go56++--
Minternal/ui/cmdline.go233+-----------------
Ainternal/ui/cmdline/cmdline.go275+++++++++++++++++++++
Ainternal/ui/cmdline/cmdline_test.go158++++++++++++
Minternal/ui/context_test.go10+-
Minternal/ui/dialog.go30+-
Minternal/ui/download.go7+-
Minternal/ui/edit.go8+-
Minternal/ui/edit_test.go12+-
Minternal/ui/entry.go35+-
Minternal/ui/file.go3+-
Minternal/ui/filter.go120+++++----
Minternal/ui/find.go328+-----------------------
Minternal/ui/find_test.go44++--
Ainternal/ui/findbar/find.go399++++++++++++++++++++++++++++++
Ainternal/ui/findbar/find_test.go162++++++++++++
Rinternal/ui/findview.go → internal/ui/findbar/view.go120++++-----
Minternal/ui/help.go42++-
Minternal/ui/help_test.go4+-
Minternal/ui/hidden.go15+-
Minternal/ui/hidden_test.go14+-
Ainternal/ui/hosts.go134++++++++++
Minternal/ui/importplace.go49++--
Minternal/ui/importplace_test.go9+-
Dinternal/ui/line.go105--------
Ainternal/ui/lineedit/lineedit.go116+++++++++
Ainternal/ui/lineedit/lineedit_test.go45+++
Minternal/ui/macro.go16+-
Minternal/ui/macro_test.go14+-
Minternal/ui/macrohostnav.go15+-
Minternal/ui/macromanage.go86++++--
Minternal/ui/macrorec.go5+-
Minternal/ui/menuoverlay.go139++++++----
Ainternal/ui/menuoverlay_test.go82++++++
Minternal/ui/model.go46++--
Minternal/ui/model_test.go8+-
Minternal/ui/mouse.go16+-
Minternal/ui/mouse_test.go8+-
Minternal/ui/names.go46++--
Minternal/ui/overlay.go106+-------
Ainternal/ui/overlay/overlay.go116+++++++++
Minternal/ui/palette.go350+--------------------------
Minternal/ui/palette_test.go76+++--
Minternal/ui/panel.go57++--
Ainternal/ui/picker/picker.go387+++++++++++++++++++++++++++++
Ainternal/ui/picker/picker_test.go167+++++++++++++
Minternal/ui/pivotactions.go90+++---
Minternal/ui/pivoteditor.go159+++++++-----
Minternal/ui/prefs.go3+-
Minternal/ui/prefs_test.go2+-
Minternal/ui/prompt.go34+-
Minternal/ui/serve_test.go7+-
Minternal/ui/shell_test.go10+-
Minternal/ui/sort.go37++-
Minternal/ui/tabs.go52++--
Minternal/ui/tabs_test.go17+-
Minternal/ui/tabstrip.go192+-------------
Ainternal/ui/tabstrip/tabstrip.go193+++++++++++++++
Ainternal/ui/tabstrip/tabstrip_test.go91+++++++
Minternal/ui/themepicker.go93+-------
Ainternal/ui/themepicker/themepicker.go125+++++++++
Ainternal/ui/themepicker/themepicker_test.go116+++++++++
Minternal/ui/transfer.go229++++-------------
Ainternal/ui/transfer/transfer.go181++++++++++++++
Ainternal/ui/transfer/transfer_test.go89+++++++
Minternal/ui/transfer_test.go35+-
Minternal/ui/view.go2+-
Minternal/ui/vim_test.go8+-
Minternal/ui/vimkeys.go2+-
M docs/architecture.md +55 -16
@@ -24,6 +24,14 @@ internal/ui the Bubble Tea model: modes, menus, overlays, rendering
2424 theme style roles, color schemes and the widgets drawn with them (frames, key chips)2525 rowtext laying out a row of cell text across the columns on screen2626 formula reading the formula being typed (F4, the word and call at the caret)27+ overlay the contract of what takes over input: Overlay, boxes, mouse events, lists28+ lineedit the one-line editor every text field shares29+ picker the searchable list behind the palette and every picker30+ cmdline the : command line and its completions31+ findbar find and replace, a bar on the context line32+ themepicker File > Settings > Theme, previewing as it moves33+ tabstrip the sheet tabs' layout and where each sheet was left34+ transfer imports running in the background and their progress2735 e2e/ end-to-end tests through libghostty (separate module, cgo)2836 oracle/ differential tests against excelize's calculation (separate module)2937 ```
@@ -247,23 +255,50 @@ draw. The components:
247255 | Component | Type | Owns |248256 |---|---|---|249257 | 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`) |250-| edit line | `lineEdit` | the one-line editor shared by cell entries, prompts and search fields (`line.go`) |258+| edit line | `lineedit.Line` | the one-line editor shared by cell entries, prompts and search fields (package `lineedit`) |251259 | cell entry | `entry`, `assist` | typing into a cell, pointing at references, other sheets while pointing, formula suggestions and signatures (`entry.go`, `assist.go`) |252260 | prompt | `prompt` | a question on the context line, typed or pointed at (`prompt.go`) |253-| 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 pivot editor (`pivoteditor.go`, `pivotactions.go`), the shortcuts |254-| sheet tabs | `tabStrip` | where each sheet was left, the tab strip's scroll, layout and clicks (`tabstrip.go`) |261+| overlays | `overlay.Overlay` | whatever has taken over input: menus (`menuoverlay.go`), the palette and pickers (package `picker`, with `palette.go`, `names.go`), the command line (package `cmdline`), the theme picker (package `themepicker`), the find bar (package `findbar`), the filter picker, the sort and choice bars, the chart editor and selection, the pivot editor (`pivoteditor.go`, `pivotactions.go`), the shortcuts |262+| sheet tabs | `tabstrip.Strip` | where each sheet was left, the tab strip's scroll and layout (package `tabstrip`); what clicks on it do (`tabstrip.go`) |255263 | mouse | `mouseState` | drags, hover, double clicks, the fill handle (`mouse.go`, `fill.go`) |256-| import | `transfer` | the import in progress, its progress display and cancelling (`transfer.go`) |264+| import | `transfer.Transfer` | the import in progress, its progress display and cancelling (package `transfer`); choosing and placing imports (`transfer.go`, `importplace.go`) |257265 | macros | `recorder`, `macroState` | a recording in progress (`macrorec.go`); a macro running, trust in the file's macros (`macrorun.go`); what scripts act on (`macrohost.go`, `macrohostnav.go`); Data > Macros and the manager (`macro.go`, `macromanage.go`) |258266 | 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 |259267 260-Overlays implement the `overlay` interface (`overlay.go`): an indicator261-for the mode, `key` and `mouse` handlers, a `layout` of boxes to draw, and262-the status line while open; bars on the context line add a263-`contextLine`, and those with a text field a `cursor`. Like `assist` and264-`prompt`, they are handed the model when they handle input, since acting265-on it is their job. A new overlay is a new type; `Model` needs no new266-fields, only a way to open it (usually a command).268+Overlays implement `overlay.Overlay` (package `overlay`): an indicator for269+the mode, `Key` and `Mouse` handlers, a `Layout` of boxes to draw, and270+the status line while open; bars on the context line add `ContextLine`271+(`overlay.Liner`), and those with a text field `Cursor` and `Changed`272+(`overlay.Text`). The model routes to them without handing itself over:273+each overlay is built with the host it acts on and keeps it. A new274+overlay is a new type; `Model` needs no new fields, only a way to open it275+(usually a command).276+277+**Hosts.** A component names what it needs of the model in a small278+interface, its host, and is tested against a fake of it. Components in279+packages of their own declare an exported `Host`, which the model280+implements through `host` (`hosts.go`), an adapter that keeps those281+methods off `ui.Model`'s exported API:282+283+| Package | Host | Methods |284+|---|---|---|285+| `picker` | `picker.Host` | theme, size, the edit line, close, record the answer to a command's question (5) |286+| `cmdline` | `cmdline.Host` | theme, size, the edit line, close, the commands to complete, run a line, fail (7) |287+| `themepicker` | `themepicker.Host` | a picker's host, and the current theme, the themes directory, preview, keep (9) |288+| `findbar` | `findbar.Host` | theme, size, the edit line, the workbook, the sheet and cell shown, show a cell, note, mark modified, leave keeping the search, pass a click to the grid (10) |289+| `tabstrip`, `transfer` | none | they're handed a view or messages and draw what they're given |290+291+Components that stay in package `ui` declare an unexported host the model292+implements itself: `menuHost` for menus (9 methods; their items are the293+command registry and the menu bar's definitions), `macrosHost` for the294+macro manager (9; it's a few keys over a picker, and what they do is the295+macro machinery), and `pivotHost` for the pivot editor and its field296+picker (14; it opens the model's pickers, filter values and range prompt297+and comes back from them). The rest (the filter picker, the sort and298+choice bars, the chart editor and selection, the shortcuts, the named299+ranges picker) still keep the model itself. Commands still run in one300+place: a host's way to run one (`runFromOverlay`, `cmdline.Host.Run`)301+goes through `runCommand`, so macros record them and pivots guard them.267302 268303 **Drawing.** `View` (`panel.go`) stacks the control panel, the header,269304 the grid rows and the status line, then composites the floating layers
@@ -275,11 +310,15 @@ of roles with dark and light variants on the terminal's 16 ANSI colors,
275310 so the user's palette applies; its widgets (framed boxes, key chips, key276311 hints) are what every overlay is drawn with.277312 278-**Packages.** `theme`, `rowtext` and `formula` depend on nothing in279-`ui`, so they can be tested and measured alone. The components stay in280-package `ui` because they act on the model; moving them out would mean281-exporting most of it. New leaf packages are split off the same way when282-a part needs only the sheet or the theme.313+**Packages.** `theme`, `rowtext`, `formula`, `overlay` and `lineedit`314+depend on nothing in `ui`, so they can be tested and measured alone. The315+components in `picker`, `cmdline`, `themepicker`, `findbar`, `tabstrip`316+and `transfer` build on them and reach the model only through their317+hosts, with unit tests of their own. A component moves out of package318+`ui` when its host stays small (about ten methods or fewer); one that319+needs more keeps a narrow interface inside `ui` instead, since moving it320+would mean exporting half of the model. New leaf packages are split off321+the same way when a part needs only the sheet or the theme.283322 284323 ## Files285324
M docs/extending.md +19 -3
@@ -40,10 +40,23 @@ be kept in sync by hand, turn it into a registry.
4040 UI features that take over input (menus, palette, find bar, pickers, chart4141 editor, sheet tabs, import flow) are components: a type with its own state,4242 key and mouse handling, layout and status line, owned by `Model` and driven43-through the `overlay` interface or its siblings. `Model` routes messages and43+through `overlay.Overlay` or its siblings. `Model` routes messages and4444 composes the screen; it doesn't hold a component's fields. Overlays are4545 composited over the finished grid, so a new one never shifts the layout.4646 47+A component is built with a host: the small interface of what it needs48+of the model (reading the sheet or the selection, running a command,49+opening or closing an overlay, a note, the theme), which the model50+implements, so it can be tested against a fake. When the host stays51+small, about ten methods or fewer, the component lives in a package of52+its own under `internal/ui` (`picker`, `cmdline`, `findbar`,53+`themepicker`), declares `Host`, and the model implements it through the54+`host` adapter in `hosts.go`; parts that need no host at all take plain55+values (`tabstrip`, `transfer`). A component that needs more keeps an56+unexported host inside package `ui` (`menuHost`, `pivotHost`,57+`macrosHost`) rather than exporting half of `Model`. Either way, running a58+command goes through `runCommand` (pattern 3).59+4760 ### 3. Actions go through commands4861 4962 Every user action is a registered command, whatever reaches it (key, menu,
@@ -178,8 +191,11 @@ a golden screen reviewed in the gallery for anything visible (see
178191 179192 Where the code doesn't follow the patterns yet:180193 181-- Components in `internal/ui` are handed the whole `*Model` rather than a182- narrower interface, so they stay in package `ui` (pattern 2).194+- Some overlays still keep the whole `*Model` as their host rather than a195+ narrower interface: the filter picker, the sort and choice bars, the196+ chart editor and selection, the shortcuts and the named ranges picker197+ (pattern 2). The cell entry, its suggestions and prompts are handed the198+ model too.183199 - Movement keys, typing, F4 in formulas, Alt+letter menus and direct mouse184200 manipulation act without a registered command (pattern 3). The macro185201 recorder covers what matters for replay without them (entries, the
M internal/ui/assist.go +21 -19
@@ -10,6 +10,8 @@ import (
1010 1111 "github.com/FelineStateMachine/012/internal/sheet"1212 "github.com/FelineStateMachine/012/internal/ui/formula"13+ "github.com/FelineStateMachine/012/internal/ui/lineedit"14+ "github.com/FelineStateMachine/012/internal/ui/overlay"1315 "github.com/FelineStateMachine/012/internal/ui/theme"1416 )1517
@@ -83,10 +85,10 @@ func suggestions(sh *sheet.Sheet, word string) []suggestion {
8385 // EDIT, in plain text, after the caret moved, or when the word already8486 // names a range exactly and nothing else matches.8587 func (a *assist) shown(m *Model) ([]suggestion, int) {86- if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.isFormula() || !a.active || m.overlay != nil {88+ if (m.mode != modeEnter && m.mode != modeEdit) || !m.line.IsFormula() || !a.active || m.overlay != nil {8789 return nil, 08890 }89- c := formula.ScanCaret(m.line.buf, m.line.pos)91+ c := formula.ScanCaret(m.line.Buf, m.line.Pos)9092 if c.Word == "" {9193 return nil, 09294 }
@@ -101,9 +103,9 @@ func (a *assist) shown(m *Model) ([]suggestion, int) {
101103 // typeKey applies a line-editing key to the entry. Typing or deleting102104 // shows suggestions; moving the caret hides them, as in Sheets.103105 func (m *Model) typeKey(k tea.KeyPressMsg) {104- before := m.line.text()105- m.line.key(k)106- m.entry.assist = assist{active: m.line.text() != before}106+ before := m.line.Text()107+ m.line.Key(k)108+ m.entry.assist = assist{active: m.line.Text() != before}107109 }108110 109111 // key handles the keys the suggestion list takes while it shows.
@@ -131,7 +133,7 @@ func (a *assist) key(m *Model, key string) bool {
131133 // a function unless one is already there.132134 func (a *assist) accept(m *Model, s suggestion, start int) {133135 text := []rune(s.name)134- rest := m.line.buf[m.line.pos:]136+ rest := m.line.Buf[m.line.Pos:]135137 if s.fn && (len(rest) == 0 || rest[0] != '(') {136138 text = append(text, '(')137139 }
@@ -142,21 +144,21 @@ func (a *assist) accept(m *Model, s suggestion, start int) {
142144 if s.fn && len(rest) > 0 && rest[0] == '(' {143145 pos++ // into the existing parentheses144146 }145- m.line.buf = slices.Concat(m.line.buf[:start], text, rest)146- m.line.pos = pos147+ m.line.Buf = slices.Concat(m.line.Buf[:start], text, rest)148+ m.line.Pos = pos147149 a.active = false148150 }149151 150152 // box draws the list under the formula bar, its text aligned with151153 // the word being typed: the name, then the signature or range dimmed.152-func (a *assist) box(m *Model) (box, bool) {154+func (a *assist) box(m *Model) (overlay.Box, bool) {153155 list, start := a.shown(m)154156 if list == nil {155- return box{}, false157+ return overlay.Box{}, false156158 }157159 rows := min(len(list), assistRows, m.height-gridTop-2)158160 if rows < 1 {159- return box{}, false161+ return overlay.Box{}, false160162 }161163 a.top = clamp(a.top, a.sel-rows+1, a.sel)162164 a.top = clamp(a.top, 0, len(list)-rows)
@@ -185,9 +187,9 @@ func (a *assist) box(m *Model) (box, bool) {
185187 footer = strconv.Itoa(a.sel+1) + " of " + strconv.Itoa(len(list))186188 }187189 // The box's text starts one column in, under the word's first letter.188- x := formulaBarTextX() + ansi.StringWidth(string(m.line.buf[:start])) - 2190+ x := formulaBarTextX() + ansi.StringWidth(string(m.line.Buf[:start])) - 2189191 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)}, true192+ return overlay.Box{ID: assistID, X: x, Y: contextLine + 1, Lines: m.th.Frame(inner, "", footer, lines)}, true191193 }192194 193195 // mouse lets the mouse hover and click suggestions and scroll the
@@ -198,12 +200,12 @@ func (a *assist) mouse(m *Model, msg tea.MouseMsg) bool {
198200 return false199201 }200202 mouse := msg.Mouse()201- h := compositor([]box{b}).Hit(mouse.X, mouse.Y)203+ h := compositor([]overlay.Box{b}).Hit(mouse.X, mouse.Y)202204 if h.Empty() {203205 return false204206 }205207 list, start := a.shown(m)206- i := a.top + mouse.Y - b.y - 1208+ i := a.top + mouse.Y - b.Y - 1207209 switch msg.(type) {208210 case tea.MouseWheelMsg:209211 d := 1
@@ -212,11 +214,11 @@ func (a *assist) mouse(m *Model, msg tea.MouseMsg) bool {
212214 }213215 a.sel = clamp(a.sel+d, 0, len(list)-1)214216 case tea.MouseMotionMsg:215- if i >= a.top && i < min(len(list), a.top+b.height()-2) {217+ if i >= a.top && i < min(len(list), a.top+b.Height()-2) {216218 a.sel = i217219 }218220 case tea.MouseClickMsg:219- if mouse.Button == tea.MouseLeft && i >= a.top && i < min(len(list), a.top+b.height()-2) {221+ if mouse.Button == tea.MouseLeft && i >= a.top && i < min(len(list), a.top+b.Height()-2) {220222 a.accept(m, list[i], start)221223 }222224 }
@@ -235,8 +237,8 @@ func (a *assist) status(m *Model) (desc, keys string, ok bool) {
235237 236238 // inFunction reports whether the caret is inside a known function's237239 // parentheses.238-func (l *lineEdit) inFunction() bool {239- _, ok := sheet.LookupFunc(formula.ScanCaret(l.buf, l.pos).Fn)240+func inFunction(l *lineedit.Line) bool {241+ _, ok := sheet.LookupFunc(formula.ScanCaret(l.Buf, l.Pos).Fn)240242 return ok241243 }242244
M internal/ui/assist_test.go +10 -10
@@ -31,14 +31,14 @@ func TestAutocompleteFunctions(t *testing.T) {
3131 press(t, m, "<down>")3232 }3333 press(t, m, "<tab>")34- if m.mode != modeEnter || m.line.text() != "=SUM(" {35- t.Fatalf("mode %v buf %q", m.mode, m.line.text())34+ if m.mode != modeEnter || m.line.Text() != "=SUM(" {35+ t.Fatalf("mode %v buf %q", m.mode, m.line.Text())3636 }3737 if ctx := line(m, contextLine); !strings.HasPrefix(ctx, "SUM(value1, [value2, ...])") || !strings.Contains(ctx, "Sum of numbers") {3838 t.Errorf("signature %q", ctx)3939 }4040 press(t, m, "1,")41- sig, _ := signature(&m.th, m.line.buf, m.line.pos)41+ sig, _ := signature(&m.th, m.line.Buf, m.line.Pos)4242 if !strings.Contains(sig, m.th.Argument.Render("[value2, ...]")) {4343 t.Errorf("second argument not marked: %q", sig)4444 }
@@ -52,8 +52,8 @@ func TestAutocompleteKeys(t *testing.T) {
5252 m := newModel()5353 // Enter inserts while the list shows, then commits when it doesn't.5454 press(t, m, "=ab", "<enter>")55- if m.line.text() != "=ABS(" {56- t.Fatalf("enter: %q", m.line.text())55+ if m.line.Text() != "=ABS(" {56+ t.Fatalf("enter: %q", m.line.Text())5757 }5858 press(t, m, "-4)", "<enter>")5959 if m.sheet.Value(addr("A1")).Num != 4 {
@@ -101,16 +101,16 @@ func TestAutocompleteNamesAndMouse(t *testing.T) {
101101 }102102 // Clicking a suggestion inserts it.103103 b, _ := m.entry.assist.box(m)104- send(m, tea.MouseClickMsg{X: b.x + 3, Y: b.y + 1, Button: tea.MouseLeft})105- if m.line.text() != "=SUM(Sales" || m.line.pos != start+5 {106- t.Fatalf("click: %q", m.line.text())104+ send(m, tea.MouseClickMsg{X: b.X + 3, Y: b.Y + 1, Button: tea.MouseLeft})105+ if m.line.Text() != "=SUM(Sales" || m.line.Pos != start+5 {106+ t.Fatalf("click: %q", m.line.Text())107107 }108108 // Editing inside existing parentheses doesn't double them.109109 m = newModel()110110 press(t, m, "=AB(1)", "<f2>", "<left>", "<left>", "<left>", "<backspace>", "B")111111 press(t, m, "<tab>")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)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)114114 }115115 }116116
M internal/ui/assistsheets.go +6 -5
@@ -5,6 +5,7 @@ import (
55 "strings"66 77 "github.com/FelineStateMachine/012/internal/sheet"8+ "github.com/FelineStateMachine/012/internal/ui/lineedit"89 )910 1011 // Sheet names in formula suggestions: typing =Su offers Summary! with the
@@ -61,8 +62,8 @@ func trimSheetEnd(rest []rune) []rune {
6162 6263 // sheetBeforeCaret returns the sheet named just before the caret, as in6364 // "=SUM(Summary!" or "='Q3 plan'!", and where its name starts.64-func (l *lineEdit) sheetBeforeCaret(book *sheet.Workbook) (*sheet.Sheet, int, bool) {65- head := l.buf[:l.pos]65+func sheetBeforeCaret(l *lineedit.Line, book *sheet.Workbook) (*sheet.Sheet, int, bool) {66+ head := l.Buf[:l.Pos]6667 end := len(head) - 1 // the "!"6768 if end < 2 || head[end] != '!' {6869 return nil, 0, false
@@ -106,15 +107,15 @@ func (m *Model) pointAfterSheet(key string) bool {
106107 if _, extend := extendKey(key); !extend && (!isMoveKey(key) || key == "tab" || key == "shift+tab") {107108 return false108109 }109- s, start, ok := m.line.sheetBeforeCaret(m.book())110+ s, start, ok := sheetBeforeCaret(&m.line, m.book())110111 if !ok || s.Hidden() {111112 return false112113 }113114 if s == m.sheet && !m.away() {114115 return m.startPoint(key) // its own sheet: the name stays as typed115116 }116- m.line.buf = append(m.line.buf[:start:start], m.line.buf[m.line.pos:]...)117- m.line.pos = start117+ m.line.Buf = append(m.line.Buf[:start:start], m.line.Buf[m.line.Pos:]...)118+ m.line.Pos = start118119 if s == m.sheet {119120 return m.startPoint(key)120121 }
M internal/ui/assistsheets_test.go +8 -8
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/charts_test.go +1 -1
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 +2 -231
@@ -7,11 +7,10 @@ import (
77 "strings"88 99 tea "charm.land/bubbletea/v2"10- "github.com/charmbracelet/x/ansi"1110 1211 "github.com/FelineStateMachine/012/internal/fileio"1312 "github.com/FelineStateMachine/012/internal/sheet"14- "github.com/FelineStateMachine/012/internal/ui/theme"13+ "github.com/FelineStateMachine/012/internal/ui/cmdline"1514 )1615 1716 // The command line (: with vim keys, or from the palette) takes vim's
@@ -21,84 +20,15 @@ import (
2120 // the command registry as you type, in a box under the context line, so2221 // there's no second list of commands to keep up.2322 24-type cmdLine struct {25- list26- shown []cmdItem27- tabbed bool // the text is a completion Tab put there; Tab again moves on28-}29-30-// cmdItem is a completion: a file command or a registered command.31-type cmdItem struct {32- word string // what Tab puts on the line: "w", or a command's ID33- title string34- desc string35- key string // the command's shortcut36- off bool // unavailable right now37-}38-39-// fileWords are vim's file commands.40-var fileWords = []cmdItem{41- {word: "w", title: "Write", desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},42- {word: "q", title: "Quit", desc: "Close 012, asking about unsaved changes"},43- {word: "q!", title: "Quit without saving", desc: "Close 012, discarding unsaved changes"},44- {word: "wq", title: "Write and quit", desc: "Save the sheet, then close 012"},45- {word: "x", title: "Write if changed and quit", desc: "Save the sheet if it changed, then close 012"},46- {word: "e", title: "Edit a file", desc: "Open a sheet or import a file: :e name"},47-}48-49-const cmdLineID = "cmdline"50-5123 func init() {5224 register(&command{id: "vim.command", macro: macroNever, title: "Command line",5325 desc: "Type a command: a cell to go to (B12), w, q, wq, e file, or any command by name",5426 run: func(m *Model) tea.Cmd {55- c := &cmdLine{}56- m.line.clear()57- m.openOverlay(c)58- c.changed(m)27+ m.openOverlay(cmdline.New(m.host()))5928 return nil6029 }})6130 }6231 63-func (c *cmdLine) indicator() string { return "COMMAND" }64-65-// changed completes what's typed: file commands, then commands whose ID66-// or title starts with it, then those that contain it. Nothing is67-// 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- c.shown = c.shown[:0]71- q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(m.line.text(), " "), ":"))72- if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {73- return74- }75- for _, w := range fileWords {76- if strings.HasPrefix(w.word, q) {77- c.shown = append(c.shown, w)78- }79- }80- var starts, contains []cmdItem81- for _, id := range commandIDs() {82- cm := commands[id]83- title := strings.ToLower(cm.title)84- it := cmdItem{word: id, title: cm.title, desc: cm.desc, key: m.shortcut(id), off: !cm.available(m)}85- switch {86- case id == "vim.command":87- case strings.HasPrefix(id, q) || strings.HasPrefix(title, q):88- starts = append(starts, it)89- case strings.Contains(id, q) || strings.Contains(title, q):90- contains = append(contains, it)91- }92- }93- c.shown = append(append(c.shown, starts...), contains...)94-}95-96-// isFileWord reports whether word is one of vim's file commands, which97-// take an argument.98-func isFileWord(word string) bool {99- return slices.ContainsFunc(fileWords, func(it cmdItem) bool { return it.word == strings.TrimSuffix(word, "!") })100-}101-10232 // commandIDs are the registered commands' IDs, by title.10333 func commandIDs() []string {10434 ids := make([]string, 0, len(commands))
@@ -111,68 +41,6 @@ func commandIDs() []string {
11141 return ids11242 }11343 114-func (c *cmdLine) key(m *Model, k tea.KeyPressMsg) tea.Cmd {115- switch key := k.String(); {116- case key == "esc", key == "backspace" && len(m.line.buf) == 0:117- m.closeOverlay()118- case key == "enter":119- return c.run(m)120- case key == "tab":121- c.complete(m, 1)122- case key == "shift+tab":123- c.complete(m, -1)124- case key == "up", key == "ctrl+p":125- c.move(-1, len(c.shown))126- case key == "down", key == "ctrl+n":127- c.move(1, len(c.shown))128- default:129- before := m.line.text()130- m.line.key(k)131- if m.line.text() != before {132- c.changed(m)133- }134- }135- return nil136-}137-138-// complete puts the highlighted completion on the line; pressed again,139-// it moves on to the next one (d = 1) or the previous one (d = -1).140-func (c *cmdLine) complete(m *Model, d int) {141- if len(c.shown) == 0 {142- return143- }144- if c.tabbed {145- c.move(d, len(c.shown))146- }147- m.line.set(c.shown[c.sel].word)148- c.tabbed = true149-}150-151-// run closes the line and carries it out. Text that isn't a command,152-// cell or range runs the highlighted completion, so ":fill d" Enter153-// fills down.154-func (c *cmdLine) run(m *Model) tea.Cmd {155- text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(m.line.text()), ":"))156- fallback := ""157- if c.sel < len(c.shown) {158- fallback = c.shown[c.sel].word159- }160- m.closeOverlay()161- if text == "" {162- return nil163- }164- if cmd, ok := m.runCmdLine(text); ok {165- return cmd166- }167- if fallback != "" {168- if cmd, ok := m.runCmdLine(fallback); ok {169- return cmd170- }171- }172- m.fail("Not a command, cell or range: " + text)173- return nil174-}175-17644 // runCmdLine carries out a command line and reports whether it was one.17745 func (m *Model) runCmdLine(text string) (tea.Cmd, bool) {17846 word, arg, _ := strings.Cut(text, " ")
@@ -263,100 +131,3 @@ func (m *Model) editFile(name string, force bool) tea.Cmd {
263131 }264132 return m.openFile(name)265133 }266-267-func (c *cmdLine) contextLine(m *Model) (string, string) {268- return m.th.Title.Render(":") + m.line.text(), ""269-}270-271-func (c *cmdLine) cursor(m *Model) (int, int) {272- return 1 + ansi.StringWidth(m.line.head()), contextLine273-}274-275-func (c *cmdLine) status(m *Model) (string, string) {276- keys := m.th.KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")277- if c.sel >= len(c.shown) {278- return "", keys279- }280- it := c.shown[c.sel]281- if it.off {282- return it.desc + " (not available now)", keys283- }284- return it.desc, keys285-}286-287-// rows is how many completions show.288-func (c *cmdLine) rows(m *Model) int {289- return max(min(len(c.shown), 8, m.height-contextLine-4), 0)290-}291-292-// layout draws the completions in a box under the context line: title,293-// what Tab types, and the shortcut.294-func (c *cmdLine) layout(m *Model) []box {295- rows := c.rows(m)296- if rows == 0 {297- return nil298- }299- c.show(rows)300- tw, ww, kw := 0, 0, 0301- for _, it := range c.shown {302- tw = max(tw, ansi.StringWidth(it.title))303- ww = max(ww, ansi.StringWidth(it.word))304- if it.key != "" {305- kw = max(kw, ansi.StringWidth(it.key)+2)306- }307- }308- // As wide as the completions, within the screen and a line's reach.309- inner := min(max(1+tw+2+ww+2+kw+1, 40), 72, m.width-2)310- tw = min(tw, inner/2)311- ww = max(min(ww, inner-1-tw-2-kw-2), 0)312- lines := make([]string, rows)313- for r := range rows {314- i := c.top + r315- it := c.shown[i]316- base, dim := m.th.MenuBar, m.th.Muted317- switch {318- case i == c.sel:319- base, dim = m.th.MenuSelected, m.th.MenuSelected320- case it.off:321- base, dim = m.th.Disabled, m.th.Disabled322- }323- row := base.Render(" "+theme.PadRight(ansi.Truncate(it.title, tw, "…"), tw)+" ") +324- dim.Render(theme.PadRight(ansi.Truncate(it.word, ww, "…"), ww))325- k := ""326- switch {327- case it.key == "":328- case i == c.sel || it.off:329- k = base.Render(" " + it.key + " ")330- default:331- k = m.th.Chip(it.key)332- }333- row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")334- lines[r] = ansi.Truncate(row, inner, "")335- }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)}}338-}339-340-func (c *cmdLine) mouse(m *Model, e mouseEvent) tea.Cmd {341- if e.box != cmdLineID {342- if e.kind == mousePress {343- m.closeOverlay()344- }345- return nil346- }347- i := c.top + e.row - 1 // under the top border348- 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:357- m.line.set(c.shown[i].word)358- c.sel = i359- return c.run(m)360- }361- return nil362-}
A internal/ui/cmdline/cmdline.go +275 -0
@@ -0,0 +1,275 @@
1+// Package cmdline is the command line (: with vim keys, or from the2+// palette): what's typed on the context line, with completions from the3+// command registry in a box under it. It knows the UI only through Host;4+// what a line means (vim's file commands, a cell to go to, a command by5+// name) is the host's to carry out.6+package cmdline7+8+import (9+ "slices"10+ "strconv"11+ "strings"12+13+ tea "charm.land/bubbletea/v2"14+ "github.com/charmbracelet/x/ansi"15+16+ "github.com/FelineStateMachine/012/internal/ui/lineedit"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/theme"19+)20+21+// Host is what the command line needs of the UI.22+type Host interface {23+ Theme() *theme.Theme24+ Size() (width, height int)25+ // Line is the shared edit line the command is typed in.26+ Line() *lineedit.Line27+ // Close closes the open overlay.28+ Close()29+ // Commands are the registered commands to complete, by title.30+ Commands() []Item31+ // Run carries out a command line and reports whether it was one.32+ Run(text string) (tea.Cmd, bool)33+ // Fail shows an error.34+ Fail(msg string)35+}36+37+// Item is a completion: a file command or a registered command.38+type Item struct {39+ Word string // what Tab puts on the line: "w", or a command's ID40+ Title string41+ Desc string42+ Key string // the command's shortcut43+ Off bool // unavailable right now44+}45+46+// FileWords are vim's file commands.47+var FileWords = []Item{48+ {Word: "w", Title: "Write", Desc: "Save the sheet; :w name saves it as name, :w name.csv downloads it"},49+ {Word: "q", Title: "Quit", Desc: "Close 012, asking about unsaved changes"},50+ {Word: "q!", Title: "Quit without saving", Desc: "Close 012, discarding unsaved changes"},51+ {Word: "wq", Title: "Write and quit", Desc: "Save the sheet, then close 012"},52+ {Word: "x", Title: "Write if changed and quit", Desc: "Save the sheet if it changed, then close 012"},53+ {Word: "e", Title: "Edit a file", Desc: "Open a sheet or import a file: :e name"},54+}55+56+// ID identifies the completions' box in mouse events.57+const ID = "cmdline"58+59+// Line is the open command line.60+type Line struct {61+ h Host62+ overlay.List63+ shown []Item64+ tabbed bool // the text is a completion Tab put there; Tab again moves on65+}66+67+// New returns a command line with the edit line cleared.68+func New(h Host) *Line {69+ c := &Line{h: h}70+ h.Line().Clear()71+ c.Changed()72+ return c73+}74+75+// Shown are the completions of what's typed.76+func (c *Line) Shown() []Item { return c.shown }77+78+func (c *Line) Indicator() string { return "COMMAND" }79+80+// Changed completes what's typed: file commands, then commands whose ID81+// or title starts with it, then those that contain it. Nothing is82+// completed once a file command's argument follows.83+func (c *Line) Changed() {84+ c.Sel, c.Top, c.tabbed = 0, 0, false85+ c.shown = c.shown[:0]86+ q := strings.ToLower(strings.TrimPrefix(strings.TrimLeft(c.h.Line().Text(), " "), ":"))87+ if word, _, arg := strings.Cut(q, " "); arg && isFileWord(word) {88+ return89+ }90+ for _, w := range FileWords {91+ if strings.HasPrefix(w.Word, q) {92+ c.shown = append(c.shown, w)93+ }94+ }95+ var starts, contains []Item96+ for _, it := range c.h.Commands() {97+ title := strings.ToLower(it.Title)98+ switch {99+ case strings.HasPrefix(it.Word, q) || strings.HasPrefix(title, q):100+ starts = append(starts, it)101+ case strings.Contains(it.Word, q) || strings.Contains(title, q):102+ contains = append(contains, it)103+ }104+ }105+ c.shown = append(append(c.shown, starts...), contains...)106+}107+108+// isFileWord reports whether word is one of vim's file commands, which109+// take an argument.110+func isFileWord(word string) bool {111+ return slices.ContainsFunc(FileWords, func(it Item) bool { return it.Word == strings.TrimSuffix(word, "!") })112+}113+114+func (c *Line) Key(k tea.KeyPressMsg) tea.Cmd {115+ line := c.h.Line()116+ switch key := k.String(); {117+ case key == "esc", key == "backspace" && len(line.Buf) == 0:118+ c.h.Close()119+ case key == "enter":120+ return c.run()121+ case key == "tab":122+ c.complete(1)123+ case key == "shift+tab":124+ c.complete(-1)125+ case key == "up", key == "ctrl+p":126+ c.Move(-1, len(c.shown))127+ case key == "down", key == "ctrl+n":128+ c.Move(1, len(c.shown))129+ default:130+ before := line.Text()131+ line.Key(k)132+ if line.Text() != before {133+ c.Changed()134+ }135+ }136+ return nil137+}138+139+// complete puts the highlighted completion on the line; pressed again,140+// it moves on to the next one (d = 1) or the previous one (d = -1).141+func (c *Line) complete(d int) {142+ if len(c.shown) == 0 {143+ return144+ }145+ if c.tabbed {146+ c.Move(d, len(c.shown))147+ }148+ c.h.Line().Set(c.shown[c.Sel].Word)149+ c.tabbed = true150+}151+152+// run closes the line and carries it out. Text that isn't a command,153+// cell or range runs the highlighted completion, so ":fill d" Enter154+// fills down.155+func (c *Line) run() tea.Cmd {156+ text := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(c.h.Line().Text()), ":"))157+ fallback := ""158+ if c.Sel < len(c.shown) {159+ fallback = c.shown[c.Sel].Word160+ }161+ c.h.Close()162+ if text == "" {163+ return nil164+ }165+ if cmd, ok := c.h.Run(text); ok {166+ return cmd167+ }168+ if fallback != "" {169+ if cmd, ok := c.h.Run(fallback); ok {170+ return cmd171+ }172+ }173+ c.h.Fail("Not a command, cell or range: " + text)174+ return nil175+}176+177+func (c *Line) ContextLine() (string, string) {178+ return c.h.Theme().Title.Render(":") + c.h.Line().Text(), ""179+}180+181+func (c *Line) Cursor() (int, int) {182+ return 1 + ansi.StringWidth(c.h.Line().Head()), overlay.ContextLine183+}184+185+func (c *Line) Status() (string, string) {186+ keys := c.h.Theme().KeyHints("Tab", "complete", "Enter", "run", "Esc", "cancel")187+ if c.Sel >= len(c.shown) {188+ return "", keys189+ }190+ it := c.shown[c.Sel]191+ if it.Off {192+ return it.Desc + " (not available now)", keys193+ }194+ return it.Desc, keys195+}196+197+// rows is how many completions show.198+func (c *Line) rows() int {199+ _, height := c.h.Size()200+ return max(min(len(c.shown), 8, height-overlay.ContextLine-4), 0)201+}202+203+// Layout draws the completions in a box under the context line: title,204+// what Tab types, and the shortcut.205+func (c *Line) Layout() []overlay.Box {206+ rows := c.rows()207+ if rows == 0 {208+ return nil209+ }210+ c.Show(rows)211+ th := c.h.Theme()212+ width, _ := c.h.Size()213+ tw, ww, kw := 0, 0, 0214+ for _, it := range c.shown {215+ tw = max(tw, ansi.StringWidth(it.Title))216+ ww = max(ww, ansi.StringWidth(it.Word))217+ if it.Key != "" {218+ kw = max(kw, ansi.StringWidth(it.Key)+2)219+ }220+ }221+ // As wide as the completions, within the screen and a line's reach.222+ inner := min(max(1+tw+2+ww+2+kw+1, 40), 72, width-2)223+ tw = min(tw, inner/2)224+ ww = max(min(ww, inner-1-tw-2-kw-2), 0)225+ lines := make([]string, rows)226+ for r := range rows {227+ i := c.Top + r228+ it := c.shown[i]229+ base, dim := th.MenuBar, th.Muted230+ switch {231+ case i == c.Sel:232+ base, dim = th.MenuSelected, th.MenuSelected233+ case it.Off:234+ base, dim = th.Disabled, th.Disabled235+ }236+ row := base.Render(" "+theme.PadRight(ansi.Truncate(it.Title, tw, "…"), tw)+" ") +237+ dim.Render(theme.PadRight(ansi.Truncate(it.Word, ww, "…"), ww))238+ k := ""239+ switch {240+ case it.Key == "":241+ case i == c.Sel || it.Off:242+ k = base.Render(" " + it.Key + " ")243+ default:244+ k = th.Chip(it.Key)245+ }246+ row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")247+ lines[r] = ansi.Truncate(row, inner, "")248+ }249+ footer := strconv.Itoa(c.Sel+1) + " of " + strconv.Itoa(len(c.shown))250+ return []overlay.Box{{ID: ID, X: 0, Y: overlay.ContextLine + 1, Lines: th.Frame(inner, "Commands", footer, lines)}}251+}252+253+func (c *Line) Mouse(e overlay.MouseEvent) tea.Cmd {254+ if e.Box != ID {255+ if e.Kind == overlay.MousePress {256+ c.h.Close()257+ }258+ return nil259+ }260+ i := c.Top + e.Row - 1 // under the top border261+ switch {262+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:263+ c.Move(-1, len(c.shown))264+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:265+ c.Move(1, len(c.shown))266+ case e.Row < 1 || i >= len(c.shown) || i >= c.Top+c.rows():267+ case e.Kind == overlay.MouseMotion:268+ c.Sel = i269+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:270+ c.h.Line().Set(c.shown[i].Word)271+ c.Sel = i272+ return c.run()273+ }274+ return nil275+}
A internal/ui/cmdline/cmdline_test.go +158 -0
@@ -0,0 +1,158 @@
1+package cmdline2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/lineedit"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// fakeHost knows a few commands and runs lines it recognises.16+type fakeHost struct {17+ th theme.Theme18+ line lineedit.Line19+ closed int20+ ran []string21+ failed string22+}23+24+func newHost() *fakeHost { return &fakeHost{th: theme.New(true)} }25+26+func (f *fakeHost) Theme() *theme.Theme { return &f.th }27+func (f *fakeHost) Size() (width, height int) { return 80, 24 }28+func (f *fakeHost) Line() *lineedit.Line { return &f.line }29+func (f *fakeHost) Close() { f.closed++ }30+func (f *fakeHost) Fail(msg string) { f.failed = msg }31+32+func (f *fakeHost) Commands() []Item {33+ return []Item{34+ {Word: "edit.fill_down", Title: "Fill down", Desc: "Copy the top cell down", Key: "Ctrl+D"},35+ {Word: "format.bold", Title: "Bold", Desc: "Bold the selection", Key: "Ctrl+B"},36+ {Word: "edit.undo", Title: "Undo", Desc: "Undo", Off: true},37+ }38+}39+40+func (f *fakeHost) Run(text string) (tea.Cmd, bool) {41+ if text == "edit.fill_down" || text == "w" || text == "B12" {42+ f.ran = append(f.ran, text)43+ return nil, true44+ }45+ return nil, false46+}47+48+func typeText(c *Line, s string) {49+ for _, r := range s {50+ c.Key(tea.KeyPressMsg{Code: r, Text: string(r)})51+ }52+}53+54+func words(c *Line) string {55+ var out []string56+ for _, it := range c.Shown() {57+ out = append(out, it.Word)58+ }59+ return strings.Join(out, ",")60+}61+62+func TestCompletes(t *testing.T) {63+ h := newHost()64+ c := New(h)65+ if n := len(c.Shown()); n != len(FileWords)+3 {66+ t.Fatalf("empty line completes %d", n)67+ }68+ typeText(c, "fill")69+ if got := words(c); got != "edit.fill_down" {70+ t.Errorf("fill completes %s", got)71+ }72+ typeText(c, " d")73+ if got := words(c); got != "edit.fill_down" {74+ t.Errorf("fill d completes %s", got)75+ }76+ h.line.Clear()77+ typeText(c, "w")78+ if got := words(c); !strings.HasPrefix(got, "w,wq,") {79+ t.Errorf("w completes %s", got)80+ }81+ typeText(c, " out.csv")82+ if got := words(c); got != "" {83+ t.Errorf("a file command's argument completes %s", got)84+ }85+}86+87+func TestTabCompletesThenCycles(t *testing.T) {88+ h := newHost()89+ c := New(h)90+ typeText(c, "o")91+ c.Key(tea.KeyPressMsg{Code: tea.KeyTab})92+ first := h.line.Text()93+ c.Key(tea.KeyPressMsg{Code: tea.KeyTab})94+ if first != c.Shown()[0].Word || h.line.Text() != c.Shown()[1].Word {95+ t.Errorf("Tab put %q then %q", first, h.line.Text())96+ }97+}98+99+func TestEnterRunsOrFallsBack(t *testing.T) {100+ h := newHost()101+ c := New(h)102+ typeText(c, ":B12")103+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})104+ if h.closed != 1 || strings.Join(h.ran, ",") != "B12" {105+ t.Errorf("closed %d, ran %v", h.closed, h.ran)106+ }107+ // Text that isn't a command runs the highlighted completion.108+ c = New(h)109+ typeText(c, "fill d")110+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})111+ if h.failed != "" || strings.Join(h.ran, ",") != "B12,edit.fill_down" {112+ t.Errorf("ran %v, failed %q", h.ran, h.failed)113+ }114+ c = New(h)115+ typeText(c, "nonsense")116+ c.Key(tea.KeyPressMsg{Code: tea.KeyEnter})117+ if h.failed != "Not a command, cell or range: nonsense" {118+ t.Errorf("failed %q", h.failed)119+ }120+}121+122+func TestBackspaceOnEmptyCloses(t *testing.T) {123+ h := newHost()124+ c := New(h)125+ c.Key(tea.KeyPressMsg{Code: tea.KeyBackspace})126+ if h.closed != 1 {127+ t.Error("Backspace on an empty line didn't close it")128+ }129+}130+131+func TestLayoutStatusAndMouse(t *testing.T) {132+ h := newHost()133+ c := New(h)134+ typeText(c, "o")135+ left, _ := c.ContextLine()136+ if ansi.Strip(left) != ":o" {137+ t.Errorf("context line %q", left)138+ }139+ if x, y := c.Cursor(); x != 2 || y != overlay.ContextLine {140+ t.Errorf("cursor %d,%d", x, y)141+ }142+ b := c.Layout()[0]143+ if b.ID != ID || b.Y != overlay.ContextLine+1 || !strings.Contains(ansi.Strip(strings.Join(b.Lines, "\n")), "Commands") {144+ t.Fatalf("box %+v", b)145+ }146+ for i, it := range c.Shown() {147+ if it.Word == "edit.undo" {148+ c.Mouse(overlay.MouseEvent{Kind: overlay.MouseMotion, Box: ID, Row: 1 + i})149+ }150+ }151+ if desc, _ := c.Status(); desc != "Undo (not available now)" {152+ t.Errorf("status %q", desc)153+ }154+ c.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft})155+ if h.closed != 1 {156+ t.Error("a click outside didn't close the line")157+ }158+}
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 +4 -3
M internal/ui/edit.go +4 -4
M internal/ui/edit_test.go +6 -6
M internal/ui/entry.go +18 -17
@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/macro"1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"1112 )1213 1314 // Entry follows Google Sheets: typing replaces the cell (ENTER), Enter or
@@ -43,16 +44,16 @@ func (m *Model) startEntry(md mode, text string) {
4344 }4445 m.mode = md4546 m.entry.home = nil46- m.line.clear()47+ m.line.Clear()4748 m.entry.hint, m.entry.assist = "", assist{}48- m.line.insert(text)49+ m.line.Insert(text)4950 }5051 5152 // startEdit edits the active cell's contents with the caret at the end.5253 func (m *Model) startEdit() tea.Cmd {5354 m.startEntry(modeEdit, "")5455 if c := m.sheet.Cell(m.cur); c != nil && m.mode == modeEdit {55- m.line.set(c.Input)56+ m.line.Set(c.Input)5657 }5758 return nil5859 }
@@ -72,10 +73,10 @@ func (m *Model) enterKey(k tea.KeyPressMsg) tea.Cmd {
7273 m.toggleAbsolute()7374 return nil7475 }75- if m.line.isFormula() && (m.pointAfterSheet(key) || m.line.canPoint() && m.startPoint(key)) {76+ if m.line.IsFormula() && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {7677 return nil7778 }78- if m.line.isFormula() && (key == "left" || key == "right") {79+ if m.line.IsFormula() && (key == "left" || key == "right") {7980 m.typeKey(k) // move the caret within a formula8081 return nil8182 }
@@ -95,7 +96,7 @@ func (m *Model) editKey(k tea.KeyPressMsg) tea.Cmd {
9596 if m.entry.assist.key(m, key) || m.commitKey(key) || m.cancelKey(key) || m.sheetKey(key) {9697 return nil9798 }98- if m.line.isFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.line.canPoint() && m.startPoint(key)) {99+ if m.line.IsFormula() && strings.HasPrefix(key, "shift+") && (m.pointAfterSheet(key) || m.line.CanPoint() && m.startPoint(key)) {99100 return nil100101 }101102 switch key {
@@ -170,8 +171,8 @@ func (m *Model) startPoint(key string) bool {
170171 } else {171172 return false172173 }173- m.entry.prefix = m.line.head()174- m.entry.suffix = m.line.tail()174+ m.entry.prefix = m.line.Head()175+ m.entry.suffix = m.line.Tail()175176 m.mode = modePoint176177 m.navigate(key, &m.point.at)177178 return true
@@ -205,7 +206,7 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
205206 }206207 if text := typed(k); text != "" {207208 m.resumeEntry(m.pointRef())208- m.line.insert(text)209+ m.line.Insert(text)209210 m.entry.assist = assist{active: true}210211 }211212 return nil
@@ -214,14 +215,14 @@ func (m *Model) pointKey(k tea.KeyPressMsg) tea.Cmd {
214215 // resumeEntry leaves POINT mode, inserting ref at the caret.215216 func (m *Model) resumeEntry(ref string) {216217 m.mode = modeEnter217- m.line.buf = []rune(m.entry.prefix + ref + m.entry.suffix)218- m.line.pos = utf8.RuneCountInString(m.entry.prefix + ref)218+ m.line.Buf = []rune(m.entry.prefix + ref + m.entry.suffix)219+ m.line.Pos = utf8.RuneCountInString(m.entry.prefix + ref)219220 }220221 221222 // commit stores the edit line in the active cell. An invalid formula stays222223 // in EDIT mode with the caret at the problem and the reason on line 3.223224 func (m *Model) commit() bool {224- input := m.line.text()225+ input := m.line.Text()225226 if err := m.set(m.cur, input); err != nil {226227 m.entryError(err, input)227228 return false
@@ -245,7 +246,7 @@ func (m *Model) set(a sheet.Addr, input string) error {
245246 func (m *Model) cancelEntry() {246247 m.returnHome()247248 m.mode = modeReady248- m.line.clear()249+ m.line.Clear()249250 m.entry.hint, m.entry.assist = "", assist{}250251 }251252
@@ -261,15 +262,15 @@ func (m *Model) handlePaste(content string) {
261262 m.startEntry(modeEnter, content)262263 }263264 case modeEnter, modeEdit:264- m.line.insert(content)265+ m.line.Insert(content)265266 case modePrompt:266267 if m.prompt.kind != promptRange {267268 m.prompt.paste(m, content)268269 }269270 case modeMenu:270- if o, ok := m.overlay.(textOverlay); ok {271- m.line.insert(content)272- o.changed(m)271+ if o, ok := m.overlay.(overlay.Text); ok {272+ m.line.Insert(content)273+ o.Changed()273274 }274275 }275276 }
M internal/ui/file.go +2 -1
M internal/ui/filter.go +64 -56
@@ -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,13 +139,13 @@ 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 }144146 p.fields[1] = p.cond.Arg145147 m.openOverlay(p)146- m.line.clear()148+ m.line.Clear()147149 p.search()148150 return p149151 }
@@ -156,17 +158,18 @@ 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) {163- p.fields[p.field] = m.line.text()165+ p.fields[p.field] = m.line.Text()164166 p.field = i165- m.line.set(p.fields[i])167+ m.line.Set(p.fields[i])166168 }167169 168-func (p *filterPicker) changed(m *Model) {169- p.fields[p.field] = m.line.text()170+func (p *filterPicker) Changed() {171+ m := p.m172+ p.fields[p.field] = m.line.Text()170173 if p.field == 0 {171174 p.search()172175 }
@@ -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 == "" {
@@ -231,7 +234,7 @@ func (p *filterPicker) cycle(d int) {
231234 232235 // apply sets the column's criteria and closes the picker.233236 func (p *filterPicker) apply(m *Model) {234- p.fields[p.field] = m.line.text()237+ p.fields[p.field] = m.line.Text()235238 var cr sheet.Criteria236239 for _, v := range p.values {237240 if !p.checked[v.Text] {
@@ -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:297- before := m.line.text()298- m.line.key(k)299- if m.line.text() != before {300- p.changed(m)301+ before := m.line.Text()302+ m.line.Key(k)303+ if m.line.Text() != before {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,13 +385,14 @@ 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)384- caret := ansi.StringWidth(m.line.head())391+ caret := ansi.StringWidth(m.line.Head())385392 if p.field == 1 {386393 return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1387394 }388- return x + 1 + ansi.StringWidth(searchPrompt) + caret, y + 3395+ return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + caret, y + 3389396 }390397 391398 // condChip is the condition's name between arrows that change it.
@@ -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 {
@@ -406,7 +414,7 @@ func (p *filterPicker) layout(m *Model) []box {
406414 if p.cond.Op.TakesArg() {407415 arg := p.fields[1]408416 if p.field == 1 {409- arg = m.line.text()417+ arg = m.line.Text()410418 }411419 if arg == "" && p.field != 1 {412420 arg = m.th.Muted.Render("value")
@@ -415,16 +423,16 @@ func (p *filterPicker) layout(m *Model) []box {
415423 }416424 search := p.fields[0]417425 if p.field == 0 {418- search = m.line.text()426+ search = m.line.Text()419427 }420- input := m.th.Title.Render(searchPrompt) + search428+ input := m.th.Title.Render(overlay.SearchPrompt) + search421429 if search == "" {422430 input += m.th.Muted.Render("Search values")423431 }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 +17 -311
@@ -1,56 +1,16 @@
11 package ui22 33 import (4- "log/slog"5- "slices"64 "strconv"75 86 tea "charm.land/bubbletea/v2"97 108 "github.com/FelineStateMachine/012/internal/sheet"11- "github.com/FelineStateMachine/012/internal/telemetry"9+ "github.com/FelineStateMachine/012/internal/ui/findbar"1210 )1311 14-// Find and replace is a bar on the context line rather than a dialog, in15-// the spirit of less and vim: matches highlight in the grid as you type16-// and the active cell follows the current one. Options are toggles shown17-// as chips and switched with Alt keys, as in VS Code's find widget. The18-// scope chip says where to search, as Sheets' "Search" choice: this19-// sheet, all sheets, or the range selected when the bar opened; Alt+S20-// goes through them.21-22-type findBar struct {23- replace bool // Ctrl+H: a replacement field follows the query24- field int // 0 is the query, 1 the replacement25- fields [2]string26- opts sheet.FindOptions27- scope *sheet.Rect // the selection when the bar opened, if any28- home *sheet.Sheet // the sheet scope is on29- where findScope30-31- matches []cellOn32- index map[cellOn]int33- cur int // index of the current match, -1 when none34- err string // e.g. an invalid regular expression35- from cellOn // where the search goes on from36-}37-38-// findScope is where the find bar searches.39-type findScope int40-41-const (42- inSheet findScope = iota // the sheet shown43- inAll // every sheet, in tab order44- inRange // the range selected when the bar opened45-)46-47-// cellOn is a cell on a particular sheet.48-type cellOn struct {49- s *sheet.Sheet50- a sheet.Addr51-}52-53-const findID = "find"12+// Find and replace is a bar on the context line (package findbar); here13+// are its commands and what it asks of the model.5414 5515 func init() {5616 register(
@@ -81,299 +41,45 @@ func init() {
8141 // last search from the active cell, without opening the bar, as n and N8242 // do in vim and less.8343 func (m *Model) findAgain(d int) {84- f := m.find85- if f == nil || f.fields[0] == "" {44+ if m.find == nil || !m.find.Searched() {8645 m.note = "No search yet: " + m.shortcut("edit.find") + " finds"8746 return8847 }89- if f.where == inRange && !f.home.Live() {90- f.where = inSheet91- }92- here := cellOn{m.sheet, m.cur}93- f.from = here94- f.search(m) // lands on the first match at or after here95- switch {96- case f.err != "":97- m.note = f.err98- return99- case len(f.matches) == 0:100- m.note = "No matches for " + f.fields[0]101- return102- case d > 0 && f.matches[f.cur] == here, d < 0:103- f.step(m, d)104- }105- m.note = "Match " + strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + " for " + f.fields[0]48+ m.find.Again(d)10649 }10750 10851 // openFind opens the bar, keeping the last search. A multi-cell selection10952 // becomes the search scope, as Sheets' "specific range".11053 func (m *Model) openFind(replace bool) {111- f, ok := m.overlay.(*findBar)54+ f, ok := m.overlay.(*findbar.Bar)11255 if !ok {11356 f = m.find11457 if f == nil {115- f = &findBar{cur: -1}116- }117- f.scope, f.home = nil, m.sheet118- if f.where == inRange || f.where == inAll && len(m.book().Visible()) == 1 {119- f.where = inSheet58+ f = findbar.New(m.host())12059 }60+ var sel *sheet.Rect12161 if m.hasRange() {12262 r := m.selection()123- f.scope, f.where = &r, inRange63+ sel = &r12464 }125- f.from = cellOn{m.sheet, m.cur}65+ f.Reset(sel)66+ f.Vim = m.prefs.vim12667 m.clearSelection()12768 m.openOverlay(f)128- // Load the kept query so focus doesn't overwrite it.129- m.line.buf = []rune(f.fields[f.field])130- }131- f.replace = f.replace || replace132- if replace && f.fields[0] != "" {133- f.focus(m, 1)134- } else {135- f.focus(m, 0)136- }137- f.search(m)138-}139-140-func (f *findBar) indicator() string { return "FIND" }141-142-func (f *findBar) layout(*Model) []box { return nil }143-144-// focus moves editing to field i, keeping the other field's text.145-func (f *findBar) focus(m *Model, i int) {146- f.fields[f.field] = m.line.text()147- f.field = i148- m.line.set(f.fields[i])149-}150-151-// changed re-runs the search as the query is typed.152-func (f *findBar) changed(m *Model) {153- f.fields[f.field] = m.line.text()154- if f.field == 0 {155- f.search(m)156- }157-}158-159-// options returns the search options, with the scope applied.160-func (f *findBar) options() sheet.FindOptions {161- o := f.opts162- o.Within = nil163- if f.where == inRange {164- o.Within = f.scope165- }166- return o167-}168-169-// sheets are the sheets searched, in order.170-func (f *findBar) sheets(m *Model) []*sheet.Sheet {171- switch f.where {172- case inAll:173- return m.book().Visible() // a match on a hidden sheet couldn't be shown174- case inRange:175- if f.home.Live() {176- return []*sheet.Sheet{f.home}177- }178- }179- return []*sheet.Sheet{m.sheet}180-}181-182-// search finds all matches and makes the first one at or after where the183-// search started the current one, so the view doesn't jump backwards184-// while typing.185-func (f *findBar) search(m *Model) {186- f.err, f.matches, f.index, f.cur = "", nil, nil, -1187- span := telemetry.Start("find")188- sheets := f.sheets(m)189- for _, s := range sheets {190- found, err := s.Find(f.fields[0], f.options())191- if err != nil {192- span.End(slog.Int("matches", 0))193- f.err = "Invalid regular expression"194- return195- }196- for _, a := range found {197- f.matches = append(f.matches, cellOn{s, a})198- }199- }200- span.End(slog.Int("matches", len(f.matches)), slog.Int("sheets", len(sheets)))201- f.index = make(map[cellOn]int, len(f.matches))202- for i, c := range f.matches {203- f.index[c] = i204- }205- if len(f.matches) == 0 {206- return207- }208- order := func(s *sheet.Sheet) int { return slices.Index(sheets, s) }209- f.cur = 0210- for i, c := range f.matches {211- if d := order(c.s) - order(f.from.s); d > 0 || d == 0 && (c.a.Row > f.from.a.Row || c.a.Row == f.from.a.Row && c.a.Col >= f.from.a.Col) {212- f.cur = i213- break214- }21569 }216- f.show(m)70+ f.Open(replace)21771 }21872 219-// show makes the current match the active cell, on its sheet.220-func (f *findBar) show(m *Model) {221- c := f.matches[f.cur]222- m.showSheet(c.s)223- m.cur = c.a224-}225-226-// step moves to the next (+1) or previous (-1) match, wrapping around.227-func (f *findBar) step(m *Model, d int) {228- if len(f.matches) == 0 {229- return230- }231- f.cur = (f.cur + d + len(f.matches)) % len(f.matches)232- f.show(m)233- f.from = f.matches[f.cur]234-}235-236-// nextScope is where Alt+S goes from where: the range (if there is one),237-// the sheet, then all sheets (if there are several).238-func (f *findBar) nextScope(m *Model) findScope {239- order := []findScope{inSheet}240- if len(m.book().Visible()) > 1 {241- order = append(order, inAll)242- }243- if f.scope != nil {244- order = append([]findScope{inRange}, order...)245- }246- return order[(slices.Index(order, f.where)+1)%len(order)]247-}248-249-func (f *findBar) key(m *Model, k tea.KeyPressMsg) tea.Cmd {250- switch k.String() {251- case "esc":252- m.find = f253- m.closeOverlay()254- case "enter":255- if m.prefs.vim && !f.replace {256- // As / in vim: Enter stays on the match; n and N go on.257- m.find = f258- m.closeOverlay()259- return nil260- }261- if f.replace && f.field == 1 {262- f.replaceOne(m)263- } else {264- f.step(m, 1)265- }266- case "shift+enter", "up":267- f.step(m, -1)268- case "down":269- f.step(m, 1)270- case "ctrl+enter", "alt+a":271- if f.replace {272- f.replaceAll(m)273- }274- case "tab", "shift+tab":275- if f.replace {276- f.focus(m, 1-f.field)277- }278- case "ctrl+f":279- f.focus(m, 0)280- case "ctrl+h":281- f.replace = true282- f.focus(m, 1)283- case "alt+c":284- f.opts.MatchCase = !f.opts.MatchCase285- f.search(m)286- case "alt+w":287- f.opts.WholeCell = !f.opts.WholeCell288- f.search(m)289- case "alt+r":290- f.opts.Regex = !f.opts.Regex291- f.search(m)292- case "alt+=":293- f.opts.InFormulas = !f.opts.InFormulas294- f.search(m)295- case "alt+s":296- if next := f.nextScope(m); next != f.where {297- f.where = next298- f.from = cellOn{m.sheet, m.cur}299- f.search(m)300- }301- default:302- before := m.line.text()303- m.line.key(k)304- if m.line.text() != before {305- f.changed(m)306- }307- }308- return nil309-}310-311-// replaceOne replaces the current match and moves to the next one.312-func (f *findBar) replaceOne(m *Model) {313- if f.cur < 0 {314- return315- }316- c := f.matches[f.cur]317- a := c.a318- changed, err := c.s.Replace(a, f.fields[0], f.fields[1], f.options())319- switch {320- case err != nil:321- m.note = "Can't replace in " + a.String() + ": " + err.Error()322- return323- case !changed:324- m.note = "Formulas in " + a.String() + " change only when searching within formulas"325- default:326- m.changed = true327- }328- f.from = c329- f.search(m)330- if changed && f.cur >= 0 && f.matches[f.cur] == c {331- f.step(m, 1) // the cell still matches; move past it332- }333-}334-335-func (f *findBar) replaceAll(m *Model) {336- // Across sheets, still one undo step.337- span := telemetry.Start("replace")338- n := 0339- err := m.book().Batch(sheet.Change{Label: "replace all", Sheet: m.sheet}, func() error {340- for _, s := range f.sheets(m) {341- k, err := s.ReplaceAll(f.fields[0], f.fields[1], f.options())342- n += k343- if err != nil {344- return err345- }346- }347- return nil348- })349- span.End(slog.Int("replaced", n))350- if n > 0 {351- m.changed = true352- }353- switch {354- case err != nil:355- m.note = "Replaced " + cellCount(n) + ", then stopped: " + err.Error()356- case n == 0:357- m.note = "Nothing to replace"358- default:359- m.note = "Replaced " + cellCount(n)360- }361- f.search(m)73+// found reports whether a is a match of the open find bar.74+func (m *Model) found(a sheet.Addr) bool {75+ f, ok := m.overlay.(*findbar.Bar)76+ return ok && f.Found(m.sheet, a)36277 }36378 79+// cellCount is n cells in words: "1 cell", "3 cells".36480 func cellCount(n int) string {36581 if n == 1 {36682 return "1 cell"36783 }36884 return strconv.Itoa(n) + " cells"36985 }370-371-// found reports whether a is a match of the open find bar.372-func (m *Model) found(a sheet.Addr) bool {373- f, ok := m.overlay.(*findBar)374- if !ok {375- return false376- }377- _, hit := f.index[cellOn{m.sheet, a}]378- return hit379-}
M internal/ui/find_test.go +23 -21
@@ -6,6 +6,8 @@ import (
66 77 tea "charm.land/bubbletea/v2"88 "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/findbar"911 )1012 1113 // findModel has a small sheet to search.
@@ -24,9 +26,9 @@ func col(l, sub string) int {
2426 return ansi.StringWidth(l[:strings.Index(l, sub)])2527 }2628 27-func findBarOf(t *testing.T, m *Model) *findBar {29+func findBarOf(t *testing.T, m *Model) *findbar.Bar {2830 t.Helper()29- f, ok := m.overlay.(*findBar)31+ f, ok := m.overlay.(*findbar.Bar)3032 if !ok {3133 t.Fatalf("no find bar (overlay %T)", m.overlay)3234 }
@@ -41,8 +43,8 @@ func TestFindAsYouType(t *testing.T) {
4143 }4244 press(t, m, "ren")4345 f := findBarOf(t, m)44- if len(f.matches) != 3 || m.cur != addr("A1") || !strings.Contains(line(m, contextLine), "1 of 3") {45- t.Fatalf("matches %v cur %v line %q", f.matches, m.cur, line(m, contextLine))46+ if len(f.Matches()) != 3 || m.cur != addr("A1") || !strings.Contains(line(m, contextLine), "1 of 3") {47+ t.Fatalf("matches %v cur %v line %q", f.Matches(), m.cur, line(m, contextLine))4648 }4749 // The terminal cursor sits after the query.4850 if x, y, ok := m.cursorPos(); !ok || y != contextLine || x != col(line(m, contextLine), "ren")+3 {
@@ -70,8 +72,8 @@ func TestFindAsYouType(t *testing.T) {
7072 }7173 // Ctrl+F again reopens the last search.7274 press(t, m, "<ctrl+f>")73- if f := findBarOf(t, m); m.line.text() != "ren" || len(f.matches) != 3 {74- t.Errorf("reopened with %q", m.line.text())75+ if f := findBarOf(t, m); m.line.Text() != "ren" || len(f.Matches()) != 3 {76+ t.Errorf("reopened with %q", m.line.Text())7577 }7678 }7779
@@ -80,28 +82,28 @@ func TestFindOptions(t *testing.T) {
8082 press(t, m, "<ctrl+f>", "Rent")8183 f := findBarOf(t, m)8284 press(t, m, "<alt+c>")83- if len(f.matches) != 1 {84- t.Errorf("match case: %v", f.matches)85+ if len(f.Matches()) != 1 {86+ t.Errorf("match case: %v", f.Matches())8587 }8688 press(t, m, "<alt+c>", "<alt+w>")87- if len(f.matches) != 1 || f.matches[0].a != addr("A1") {88- t.Errorf("whole cell: %v", f.matches)89+ if len(f.Matches()) != 1 || f.Matches()[0].At != addr("A1") {90+ t.Errorf("whole cell: %v", f.Matches())8991 }9092 press(t, m, "<alt+w>")9193 for range 4 {9294 press(t, m, "<backspace>")9395 }9496 press(t, m, "B1", "<alt+=>")95- if len(f.matches) != 2 {96- t.Errorf("in formulas: %v", f.matches)97+ if len(f.Matches()) != 2 {98+ t.Errorf("in formulas: %v", f.Matches())9799 }98100 press(t, m, "<alt+=>")99101 for range 2 {100102 press(t, m, "<backspace>")101103 }102104 press(t, m, "<alt+r>", "^r.n")103- if len(f.matches) != 2 {104- t.Errorf("regex: %v", f.matches)105+ if len(f.Matches()) != 2 {106+ t.Errorf("regex: %v", f.Matches())105107 }106108 press(t, m, "(")107109 if !strings.Contains(line(m, contextLine), "Invalid regular expression") {
@@ -118,12 +120,12 @@ func TestFindWithinSelection(t *testing.T) {
118120 m := findModel(t)119121 press(t, m, "<shift+down>", "<ctrl+f>", "ren")120122 f := findBarOf(t, m)121- if len(f.matches) != 2 || !strings.Contains(line(m, contextLine), "in A1:A2") {122- t.Fatalf("scoped: %v line %q", f.matches, line(m, contextLine))123+ if len(f.Matches()) != 2 || !strings.Contains(line(m, contextLine), "in A1:A2") {124+ t.Fatalf("scoped: %v line %q", f.Matches(), line(m, contextLine))123125 }124126 press(t, m, "<alt+s>")125- if len(f.matches) != 3 {126- t.Errorf("unscoped: %v", f.matches)127+ if len(f.Matches()) != 3 {128+ t.Errorf("unscoped: %v", f.Matches())127129 }128130 }129131
@@ -131,7 +133,7 @@ func TestReplace(t *testing.T) {
131133 m := findModel(t)132134 press(t, m, "<ctrl+h>", "rent", "<tab>", "lease")133135 f := findBarOf(t, m)134- if !f.replace || f.field != 1 || !strings.Contains(line(m, contextLine), "Replace › lease") {136+ if !f.Replacing() || f.Field() != 1 || !strings.Contains(line(m, contextLine), "Replace › lease") {135137 t.Fatalf("replace bar %q", line(m, contextLine))136138 }137139 press(t, m, "<enter>") // replace A1, move on
@@ -157,8 +159,8 @@ func TestFindMouse(t *testing.T) {
157159 f := findBarOf(t, m)158160 l := line(m, contextLine)159161 send(m, tea.MouseClickMsg{X: col(l, "Aa"), Y: contextLine, Button: tea.MouseLeft})160- if !f.opts.MatchCase || len(f.matches) != 2 {161- t.Errorf("clicking Aa: case %v matches %v", f.opts.MatchCase, f.matches)162+ if !f.Options().MatchCase || len(f.Matches()) != 2 {163+ t.Errorf("clicking Aa: case %v matches %v", f.Options().MatchCase, f.Matches())162164 }163165 // Clicking the grid closes the bar and moves there.164166 click(m, cellX(1), gridTop+1, 0)
A internal/ui/findbar/find.go +399 -0
@@ -0,0 +1,399 @@
1+// Package findbar is find and replace: a bar on the context line rather2+// than a dialog, in the spirit of less and vim. Matches highlight in the3+// grid as you type and the active cell follows the current one. Options4+// are toggles shown as chips and switched with Alt keys, as in VS Code's5+// find widget. The scope chip says where to search, as Sheets' "Search"6+// choice: this sheet, all sheets, or the range selected when the bar7+// opened; Alt+S goes through them. It knows the UI only through Host.8+package findbar9+10+import (11+ "log/slog"12+ "slices"13+ "strconv"14+15+ tea "charm.land/bubbletea/v2"16+17+ "github.com/FelineStateMachine/012/internal/sheet"18+ "github.com/FelineStateMachine/012/internal/telemetry"19+ "github.com/FelineStateMachine/012/internal/ui/lineedit"20+ "github.com/FelineStateMachine/012/internal/ui/theme"21+)22+23+// Host is what the find bar needs of the UI.24+type Host interface {25+ Theme() *theme.Theme26+ Size() (width, height int)27+ // Line is the shared edit line the focused field is typed in.28+ Line() *lineedit.Line29+ Book() *sheet.Workbook30+ // At is the sheet shown and its active cell.31+ At() (*sheet.Sheet, sheet.Addr)32+ // Show shows sheet s with a as the active cell.33+ Show(s *sheet.Sheet, a sheet.Addr)34+ // Note says how the last action went, on the context line.35+ Note(msg string)36+ // Edited marks the file modified.37+ Edited()38+ // Leave closes the bar, keeping its search for Ctrl+F and find next.39+ Leave()40+ // Press handles a mouse press outside the bar, as on the grid.41+ Press(x, y int, button tea.MouseButton) tea.Cmd42+}43+44+// Bar is the find bar: the search, its options and its matches. It45+// outlives being open, so the last search can be reopened or repeated.46+type Bar struct {47+ h Host48+ replace bool // Ctrl+H: a replacement field follows the query49+ field int // 0 is the query, 1 the replacement50+ fields [2]string51+ opts sheet.FindOptions52+ scope *sheet.Rect // the selection when the bar opened, if any53+ home *sheet.Sheet // the sheet scope is on54+ where scope55+56+ matches []Match57+ index map[Match]int58+ cur int // index of the current match, -1 when none59+ err string // e.g. an invalid regular expression60+ from Match // where the search goes on from61+62+ // Vim makes Enter close the bar on the match, as / does in vim.63+ Vim bool64+}65+66+// scope is where the find bar searches.67+type scope int68+69+const (70+ inSheet scope = iota // the sheet shown71+ inAll // every sheet, in tab order72+ inRange // the range selected when the bar opened73+)74+75+// Match is a cell on a particular sheet.76+type Match struct {77+ Sheet *sheet.Sheet78+ At sheet.Addr79+}80+81+// New returns a bar with no search yet.82+func New(h Host) *Bar { return &Bar{h: h, cur: -1} }83+84+// Matches are the matches of the search, in order.85+func (f *Bar) Matches() []Match { return f.matches }86+87+// Options are the search's options.88+func (f *Bar) Options() sheet.FindOptions { return f.opts }89+90+// Replacing reports whether the replacement field shows, and Field which91+// field is focused: 0 the query, 1 the replacement.92+func (f *Bar) Replacing() bool { return f.replace }93+func (f *Bar) Field() int { return f.field }94+95+// Found reports whether a on s is a match.96+func (f *Bar) Found(s *sheet.Sheet, a sheet.Addr) bool {97+ _, hit := f.index[Match{s, a}]98+ return hit99+}100+101+// Again goes to the next (d = 1) or previous (d = -1) match of the last102+// search from the active cell, without opening the bar, as n and N do in103+// vim and less, and says where it went. It does nothing without a search104+// to repeat (see Searched).105+func (f *Bar) Again(d int) {106+ if f.fields[0] == "" {107+ return108+ }109+ if f.where == inRange && !f.home.Live() {110+ f.where = inSheet111+ }112+ s, a := f.h.At()113+ here := Match{s, a}114+ f.from = here115+ f.search() // lands on the first match at or after here116+ switch {117+ case f.err != "":118+ f.h.Note(f.err)119+ return120+ case len(f.matches) == 0:121+ f.h.Note("No matches for " + f.fields[0])122+ return123+ case d > 0 && f.matches[f.cur] == here, d < 0:124+ f.step(d)125+ }126+ f.h.Note("Match " + strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + " for " + f.fields[0])127+}128+129+// Searched reports whether there's a search to repeat.130+func (f *Bar) Searched() bool { return f.fields[0] != "" }131+132+// Reset gets the bar ready to open over the sheet shown, keeping the last133+// search. A multi-cell selection sel becomes the search scope, as Sheets'134+// "specific range". The kept query is loaded into the edit line.135+func (f *Bar) Reset(sel *sheet.Rect) {136+ s, a := f.h.At()137+ f.scope, f.home = nil, s138+ if f.where == inRange || f.where == inAll && len(f.h.Book().Visible()) == 1 {139+ f.where = inSheet140+ }141+ if sel != nil {142+ f.scope, f.where = sel, inRange143+ }144+ f.from = Match{s, a}145+ // Load the kept query so focus doesn't overwrite it.146+ f.h.Line().Buf = []rune(f.fields[f.field])147+}148+149+// Open focuses the query, or with replace the replacement once there's150+// a query, and searches.151+func (f *Bar) Open(replace bool) {152+ f.replace = f.replace || replace153+ if replace && f.fields[0] != "" {154+ f.focus(1)155+ } else {156+ f.focus(0)157+ }158+ f.search()159+}160+161+func (f *Bar) Indicator() string { return "FIND" }162+163+// focus moves editing to field i, keeping the other field's text.164+func (f *Bar) focus(i int) {165+ line := f.h.Line()166+ f.fields[f.field] = line.Text()167+ f.field = i168+ line.Set(f.fields[i])169+}170+171+// Changed re-runs the search as the query is typed.172+func (f *Bar) Changed() {173+ f.fields[f.field] = f.h.Line().Text()174+ if f.field == 0 {175+ f.search()176+ }177+}178+179+// options returns the search options, with the scope applied.180+func (f *Bar) options() sheet.FindOptions {181+ o := f.opts182+ o.Within = nil183+ if f.where == inRange {184+ o.Within = f.scope185+ }186+ return o187+}188+189+// sheets are the sheets searched, in order.190+func (f *Bar) sheets() []*sheet.Sheet {191+ switch f.where {192+ case inAll:193+ return f.h.Book().Visible() // a match on a hidden sheet couldn't be shown194+ case inRange:195+ if f.home.Live() {196+ return []*sheet.Sheet{f.home}197+ }198+ }199+ s, _ := f.h.At()200+ return []*sheet.Sheet{s}201+}202+203+// search finds all matches and makes the first one at or after where the204+// search started the current one, so the view doesn't jump backwards205+// while typing.206+func (f *Bar) search() {207+ f.err, f.matches, f.index, f.cur = "", nil, nil, -1208+ span := telemetry.Start("find")209+ sheets := f.sheets()210+ for _, s := range sheets {211+ found, err := s.Find(f.fields[0], f.options())212+ if err != nil {213+ span.End(slog.Int("matches", 0))214+ f.err = "Invalid regular expression"215+ return216+ }217+ for _, a := range found {218+ f.matches = append(f.matches, Match{s, a})219+ }220+ }221+ span.End(slog.Int("matches", len(f.matches)), slog.Int("sheets", len(sheets)))222+ f.index = make(map[Match]int, len(f.matches))223+ for i, c := range f.matches {224+ f.index[c] = i225+ }226+ if len(f.matches) == 0 {227+ return228+ }229+ order := func(s *sheet.Sheet) int { return slices.Index(sheets, s) }230+ f.cur = 0231+ for i, c := range f.matches {232+ if d := order(c.Sheet) - order(f.from.Sheet); d > 0 || d == 0 && (c.At.Row > f.from.At.Row || c.At.Row == f.from.At.Row && c.At.Col >= f.from.At.Col) {233+ f.cur = i234+ break235+ }236+ }237+ f.show()238+}239+240+// show makes the current match the active cell, on its sheet.241+func (f *Bar) show() {242+ c := f.matches[f.cur]243+ f.h.Show(c.Sheet, c.At)244+}245+246+// step moves to the next (+1) or previous (-1) match, wrapping around.247+func (f *Bar) step(d int) {248+ if len(f.matches) == 0 {249+ return250+ }251+ f.cur = (f.cur + d + len(f.matches)) % len(f.matches)252+ f.show()253+ f.from = f.matches[f.cur]254+}255+256+// nextScope is where Alt+S goes from where: the range (if there is one),257+// the sheet, then all sheets (if there are several).258+func (f *Bar) nextScope() scope {259+ order := []scope{inSheet}260+ if len(f.h.Book().Visible()) > 1 {261+ order = append(order, inAll)262+ }263+ if f.scope != nil {264+ order = append([]scope{inRange}, order...)265+ }266+ return order[(slices.Index(order, f.where)+1)%len(order)]267+}268+269+func (f *Bar) Key(k tea.KeyPressMsg) tea.Cmd {270+ switch k.String() {271+ case "esc":272+ f.h.Leave()273+ case "enter":274+ if f.Vim && !f.replace {275+ // As / in vim: Enter stays on the match; n and N go on.276+ f.h.Leave()277+ return nil278+ }279+ if f.replace && f.field == 1 {280+ f.replaceOne()281+ } else {282+ f.step(1)283+ }284+ case "shift+enter", "up":285+ f.step(-1)286+ case "down":287+ f.step(1)288+ case "ctrl+enter", "alt+a":289+ if f.replace {290+ f.replaceAll()291+ }292+ case "tab", "shift+tab":293+ if f.replace {294+ f.focus(1 - f.field)295+ }296+ case "ctrl+f":297+ f.focus(0)298+ case "ctrl+h":299+ f.replace = true300+ f.focus(1)301+ default:302+ f.optionKey(k)303+ }304+ return nil305+}306+307+// optionKey flips an option or the scope, or else edits the focused308+// field.309+func (f *Bar) optionKey(k tea.KeyPressMsg) {310+ switch k.String() {311+ case "alt+c":312+ f.opts.MatchCase = !f.opts.MatchCase313+ case "alt+w":314+ f.opts.WholeCell = !f.opts.WholeCell315+ case "alt+r":316+ f.opts.Regex = !f.opts.Regex317+ case "alt+=":318+ f.opts.InFormulas = !f.opts.InFormulas319+ case "alt+s":320+ next := f.nextScope()321+ if next == f.where {322+ return323+ }324+ f.where = next325+ s, a := f.h.At()326+ f.from = Match{s, a}327+ default:328+ line := f.h.Line()329+ before := line.Text()330+ line.Key(k)331+ if line.Text() != before {332+ f.Changed()333+ }334+ return335+ }336+ f.search()337+}338+339+// replaceOne replaces the current match and moves to the next one.340+func (f *Bar) replaceOne() {341+ if f.cur < 0 {342+ return343+ }344+ c := f.matches[f.cur]345+ a := c.At346+ changed, err := c.Sheet.Replace(a, f.fields[0], f.fields[1], f.options())347+ switch {348+ case err != nil:349+ f.h.Note("Can't replace in " + a.String() + ": " + err.Error())350+ return351+ case !changed:352+ f.h.Note("Formulas in " + a.String() + " change only when searching within formulas")353+ default:354+ f.h.Edited()355+ }356+ f.from = c357+ f.search()358+ if changed && f.cur >= 0 && f.matches[f.cur] == c {359+ f.step(1) // the cell still matches; move past it360+ }361+}362+363+func (f *Bar) replaceAll() {364+ // Across sheets, still one undo step.365+ span := telemetry.Start("replace")366+ n := 0367+ s, _ := f.h.At()368+ err := f.h.Book().Batch(sheet.Change{Label: "replace all", Sheet: s}, func() error {369+ for _, s := range f.sheets() {370+ k, err := s.ReplaceAll(f.fields[0], f.fields[1], f.options())371+ n += k372+ if err != nil {373+ return err374+ }375+ }376+ return nil377+ })378+ span.End(slog.Int("replaced", n))379+ if n > 0 {380+ f.h.Edited()381+ }382+ switch {383+ case err != nil:384+ f.h.Note("Replaced " + cellCount(n) + ", then stopped: " + err.Error())385+ case n == 0:386+ f.h.Note("Nothing to replace")387+ default:388+ f.h.Note("Replaced " + cellCount(n))389+ }390+ f.search()391+}392+393+// cellCount is n cells in words: "1 cell", "3 cells".394+func cellCount(n int) string {395+ if n == 1 {396+ return "1 cell"397+ }398+ return strconv.Itoa(n) + " cells"399+}
A internal/ui/findbar/find_test.go +162 -0
@@ -0,0 +1,162 @@
1+package findbar2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/lineedit"12+ "github.com/FelineStateMachine/012/internal/ui/overlay"13+ "github.com/FelineStateMachine/012/internal/ui/theme"14+)15+16+// fakeHost is a workbook with an active cell and a record of what the17+// bar did.18+type fakeHost struct {19+ th theme.Theme20+ line lineedit.Line21+ s *sheet.Sheet22+ cur sheet.Addr23+ note string24+ edited bool25+ left int26+ pressed []int27+}28+29+func newHost(t *testing.T, cells ...string) *fakeHost {30+ t.Helper()31+ s := sheet.New()32+ for i := 0; i < len(cells); i += 2 {33+ if err := s.Set(addr(cells[i]), cells[i+1]); err != nil {34+ t.Fatal(err)35+ }36+ }37+ return &fakeHost{th: theme.New(true), s: s}38+}39+40+func addr(s string) sheet.Addr {41+ a, _ := sheet.ParseAddr(s)42+ return a43+}44+45+func (f *fakeHost) Theme() *theme.Theme { return &f.th }46+func (f *fakeHost) Size() (width, height int) { return 120, 24 }47+func (f *fakeHost) Line() *lineedit.Line { return &f.line }48+func (f *fakeHost) Book() *sheet.Workbook { return f.s.Book() }49+func (f *fakeHost) At() (*sheet.Sheet, sheet.Addr) { return f.s, f.cur }50+func (f *fakeHost) Show(s *sheet.Sheet, a sheet.Addr) { f.s, f.cur = s, a }51+func (f *fakeHost) Note(msg string) { f.note = msg }52+func (f *fakeHost) Edited() { f.edited = true }53+func (f *fakeHost) Leave() { f.left++ }54+55+func (f *fakeHost) Press(x, y int, _ tea.MouseButton) tea.Cmd {56+ f.pressed = []int{x, y}57+ return nil58+}59+60+func typeText(b *Bar, s string) {61+ for _, r := range s {62+ b.Key(tea.KeyPressMsg{Code: r, Text: string(r)})63+ }64+}65+66+func open(h *fakeHost, replace bool) *Bar {67+ b := New(h)68+ b.Reset(nil)69+ b.Open(replace)70+ return b71+}72+73+func TestFindAsYouType(t *testing.T) {74+ h := newHost(t, "A1", "Rent", "A2", "rental car", "A3", "Total rent", "B1", "1450")75+ b := open(h, false)76+ typeText(b, "ren")77+ if len(b.Matches()) != 3 || h.cur != addr("A1") {78+ t.Fatalf("matches %v, at %v", b.Matches(), h.cur)79+ }80+ if left, right := b.ContextLine(); !strings.Contains(ansi.Strip(left), "Find › ren") || ansi.Strip(right) != "1 of 3" {81+ t.Errorf("context line %q %q", left, right)82+ }83+ b.Key(tea.KeyPressMsg{Code: tea.KeyEnter})84+ b.Key(tea.KeyPressMsg{Code: tea.KeyEnter})85+ b.Key(tea.KeyPressMsg{Code: tea.KeyEnter})86+ if h.cur != addr("A1") {87+ t.Errorf("didn't wrap: at %v", h.cur)88+ }89+ if !b.Found(h.s, addr("A2")) || b.Found(h.s, addr("B1")) {90+ t.Error("wrong cells found")91+ }92+ b.Key(tea.KeyPressMsg{Code: tea.KeyEscape})93+ if h.left != 1 {94+ t.Error("Esc didn't leave")95+ }96+}97+98+func TestAgainFromTheActiveCell(t *testing.T) {99+ h := newHost(t, "A1", "x", "A5", "x", "A9", "x")100+ b := open(h, false)101+ typeText(b, "x")102+ h.cur = addr("A5")103+ b.Again(1)104+ if h.cur != addr("A9") || h.note != "Match 3 of 3 for x" {105+ t.Errorf("next: at %v, %q", h.cur, h.note)106+ }107+ b.Again(-1)108+ if h.cur != addr("A5") {109+ t.Errorf("previous: at %v", h.cur)110+ }111+}112+113+func TestOptionsAndBadRegex(t *testing.T) {114+ h := newHost(t, "A1", "Rent", "A2", "rent")115+ b := open(h, false)116+ typeText(b, "Rent")117+ b.Key(tea.KeyPressMsg{Code: 'c', Mod: tea.ModAlt})118+ if !b.Options().MatchCase || len(b.Matches()) != 1 {119+ t.Errorf("match case: %v", b.Matches())120+ }121+ b.Key(tea.KeyPressMsg{Code: 'r', Mod: tea.ModAlt})122+ typeText(b, "(")123+ if _, right := b.ContextLine(); ansi.Strip(right) != "Invalid regular expression" {124+ t.Errorf("bad regex: %q", right)125+ }126+}127+128+func TestReplace(t *testing.T) {129+ h := newHost(t, "A1", "rent", "A2", "rental car", "A3", "total rent")130+ b := open(h, true)131+ typeText(b, "rent")132+ b.Key(tea.KeyPressMsg{Code: tea.KeyTab})133+ typeText(b, "lease")134+ if !b.Replacing() || b.Field() != 1 {135+ t.Fatalf("replace field %d", b.Field())136+ }137+ b.Key(tea.KeyPressMsg{Code: tea.KeyEnter})138+ if got := h.s.Cell(addr("A1")).Input; got != "lease" || !h.edited || h.cur != addr("A2") {139+ t.Errorf("replace one: A1 %q, at %v", got, h.cur)140+ }141+ b.Key(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModCtrl})142+ if got := h.s.Cell(addr("A3")).Input; got != "total lease" || h.note != "Replaced 2 cells" {143+ t.Errorf("replace all: A3 %q, note %q", got, h.note)144+ }145+}146+147+func TestMouse(t *testing.T) {148+ h := newHost(t, "A1", "rent", "A2", "Rent")149+ b := open(h, false)150+ typeText(b, "rent")151+ left, _ := b.ContextLine()152+ plain := ansi.Strip(left)153+ x := ansi.StringWidth(plain[:strings.Index(plain, "Aa")])154+ b.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: x, Y: overlay.ContextLine})155+ if !b.Options().MatchCase || len(b.Matches()) != 1 {156+ t.Errorf("clicking Aa: %v", b.Matches())157+ }158+ b.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, X: 3, Y: 10})159+ if h.left != 1 || len(h.pressed) != 2 || h.pressed[1] != 10 {160+ t.Errorf("a click on the grid: left %d, pressed %v", h.left, h.pressed)161+ }162+}
R internal/ui/findview.go → internal/ui/findbar/view.go +58 -62
@@ -1,4 +1,4 @@
1-package ui1+package findbar22 33 import (44 "strconv"
@@ -6,36 +6,39 @@ import (
66 77 tea "charm.land/bubbletea/v2"88 "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/overlay"911 )1012 1113 // The find bar as the context line draws it, and its mouse targets: the1214 // fields, then the option chips, with the match count on the right.1315 1416 // Pieces of the bar, left to right: each field, then the option chips.15-type findPart struct {17+type part struct {1618 text string1719 field int // -1 for chips1820 toggle string // the Alt key a chip toggles1921 }2022 21-func (f *findBar) parts(m *Model) []findPart {23+func (f *Bar) parts() []part {24+ th := f.h.Theme()2225 label := func(name string, i int) string {23- style := m.th.Muted26+ style := th.Muted2427 if f.field == i {25- style = m.th.Key28+ style = th.Key2629 }27- return style.Render(name) + m.th.Muted.Render(searchPrompt)30+ return style.Render(name) + th.Muted.Render(overlay.SearchPrompt)2831 }29- parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}32+ parts := []part{{text: label("Find", 0) + f.fieldText(0), field: 0}}3033 if f.replace {31- parts = append(parts, findPart{text: label("Replace", 1) + f.fieldText(m, 1), field: 1})34+ parts = append(parts, part{text: label("Replace", 1) + f.fieldText(1), field: 1})3235 }33- chip := func(on bool, name, key string) findPart {34- style := m.th.Muted36+ chip := func(on bool, name, key string) part {37+ style := th.Muted3538 if on {36- style = m.th.MenuSelected39+ style = th.MenuSelected3740 }38- return findPart{text: style.Render(" " + name + " "), field: -1, toggle: key}41+ return part{text: style.Render(" " + name + " "), field: -1, toggle: key}3942 }4043 parts = append(parts,4144 chip(f.opts.MatchCase, "Aa", "alt+c"),
@@ -47,23 +50,24 @@ func (f *findBar) parts(m *Model) []findPart {
4750 parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))4851 case f.where == inAll:4952 parts = append(parts, chip(true, "in all sheets", "alt+s"))50- case f.scope != nil || len(m.book().Visible()) > 1:51- parts = append(parts, chip(false, "in "+m.sheet.Name(), "alt+s"))53+ case f.scope != nil || len(f.h.Book().Visible()) > 1:54+ s, _ := f.h.At()55+ parts = append(parts, chip(false, "in "+s.Name(), "alt+s"))5256 }5357 return parts5458 }5559 5660 // fieldText is a field's text: the live edit buffer when focused.57-func (f *findBar) fieldText(m *Model, i int) string {61+func (f *Bar) fieldText(i int) string {5862 if f.field == i {59- return m.line.text()63+ return f.h.Line().Text()6064 }6165 return f.fields[i]6266 }6367 6468 // gapAfter separates a part from the next: wider after a field, so the6569 // query and the chips read as separate groups.66-func gapAfter(p findPart) string {70+func gapAfter(p part) string {6771 if p.field >= 0 {6872 return " "6973 }
@@ -71,8 +75,8 @@ func gapAfter(p findPart) string {
7175 }7276 7377 // 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)78+func (f *Bar) spans() ([]part, []int) {79+ parts := f.parts()7680 xs := make([]int, len(parts))7781 x := 07882 for i, p := range parts {
@@ -82,11 +86,15 @@ func (f *findBar) spans(m *Model) ([]findPart, []int) {
8286 return parts, xs8387 }8488 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) {89+// Layout is empty: the bar is drawn on the context line.90+func (f *Bar) Layout() []overlay.Box { return nil }91+92+// ContextLine renders the bar for the context line, with the match count93+// on the right.94+func (f *Bar) ContextLine() (left, right string) {95+ th := f.h.Theme()8896 var b strings.Builder89- parts := f.parts(m)97+ parts := f.parts()9098 for i, p := range parts {9199 b.WriteString(p.text)92100 if i < len(parts)-1 {
@@ -95,85 +103,73 @@ func (f *findBar) line(m *Model) (left, right string) {
95103 }96104 switch {97105 case f.err != "":98- right = m.th.Warning.Render(f.err)106+ right = th.Warning.Render(f.err)99107 case f.fields[0] == "":100108 case len(f.matches) == 0:101- right = m.th.Warning.Render("No matches")109+ right = th.Warning.Render("No matches")102110 default:103111 where := ""104112 if f.where == inAll {105- where = " on " + f.matches[f.cur].s.Name()113+ where = " on " + f.matches[f.cur].Sheet.Name()106114 }107- right = m.th.Muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)115+ right = th.Muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)108116 }109117 return b.String(), right110118 }111119 112-// cursor puts the terminal cursor in the focused field, whose text ends120+// Cursor puts the terminal cursor in the focused field, whose text ends113121 // its part.114-func (f *findBar) cursor(m *Model) (x, y int) {115- parts, xs := f.spans(m)122+func (f *Bar) Cursor() (x, y int) {123+ parts, xs := f.spans()116124 for i, p := range parts {117125 if p.field == f.field {118- return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(m.line.tail()), contextLine126+ return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(f.h.Line().Tail()), overlay.ContextLine119127 }120128 }121- return 0, contextLine129+ return 0, overlay.ContextLine122130 }123131 124-// mouse focuses a field or flips a chip on the bar; a click anywhere132+// Mouse focuses a field or flips a chip on the bar; a click anywhere125133 // 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 {134+func (f *Bar) Mouse(e overlay.MouseEvent) tea.Cmd {135+ if e.Kind != overlay.MousePress {128136 return nil129137 }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})138+ if e.Y != overlay.ContextLine {139+ f.h.Leave()140+ return f.h.Press(e.X, e.Y, e.Button)134141 }135- parts, xs := f.spans(m)142+ parts, xs := f.spans()136143 for i, p := range parts {137- if e.x >= xs[i] && e.x < xs[i]+ansi.StringWidth(p.text) {144+ if e.X >= xs[i] && e.X < xs[i]+ansi.StringWidth(p.text) {138145 if p.field >= 0 {139- f.focus(m, p.field)146+ f.focus(p.field)140147 return nil141148 }142- return f.key(m, keyFor(p.toggle))149+ return f.Key(overlay.KeyFor(p.toggle))143150 }144151 }145152 return nil146153 }147154 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 the155+// Status shows the keys for moving and replacing, and a reminder of the162156 // option keys when there's room. Narrow screens keep the most useful keys.163-func (f *findBar) status(m *Model) (string, string) {157+func (f *Bar) Status() (string, string) {158+ th := f.h.Theme()159+ width, _ := f.h.Size()164160 pairs := []string{"Enter", "next", "Shift+Enter", "previous", "Esc", "close"}165161 if f.replace {166162 pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}167163 }168164 desc := "Alt+C/W/R/= options"169- if f.scope != nil || len(m.book().Visible()) > 1 {165+ if f.scope != nil || len(f.h.Book().Visible()) > 1 {170166 desc = "Alt+C/W/R/=/S options"171167 }172168 for {173- keys := m.th.KeyHints(pairs...)169+ keys := th.KeyHints(pairs...)174170 switch {175- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:176- return m.th.Muted.Render(desc), keys171+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:172+ return th.Muted.Render(desc), keys177173 case desc != "":178174 desc = ""179175 case len(pairs) > 2:
M internal/ui/help.go +25 -17
@@ -11,23 +11,26 @@ import (
1111 "github.com/charmbracelet/x/ansi"1212 1313 "github.com/FelineStateMachine/012/internal/sheet"14+ "github.com/FelineStateMachine/012/internal/ui/overlay"15+ "github.com/FelineStateMachine/012/internal/ui/picker"1416 "github.com/FelineStateMachine/012/internal/ui/theme"1517 )1618 1719 func init() {1820 register(1921 &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{})22+ m.openOverlay(&shortcuts{m: m})2123 return nil2224 }},2325 &command{id: "help.functions", macro: macroNever, title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {24- p := newPicker(m, "Functions", "Type a function name, e.g. sum or if", 100, functionItems())25- p.action = "insert"26+ p := m.newPicker("Functions", "Type a function name, e.g. sum or if", 100, functionItems(m))27+ p.Action = "insert"2628 m.openOverlay(p)2729 return nil2830 }},2931 &command{id: "help.about", macro: macroNever, title: "About 012", desc: "Show the version", run: func(m *Model) tea.Cmd {3032 m.openOverlay(&choiceBar{33+ m: m,3134 msg: "012 " + version() + ": Lotus 1-2-3 looks, Google Sheets keys.",3235 choices: []choice{3336 {key: "esc", label: "Close", run: func(*Model) tea.Cmd { return nil }},
@@ -51,13 +54,13 @@ func version() string {
5154 5255 // functionItems lists the spreadsheet functions for the function list;5356 // picking one starts a formula with it.54-func functionItems() []pickItem {57+func functionItems(m *Model) []picker.Item {5558 fns := sheet.Funcs()56- items := make([]pickItem, len(fns))59+ items := make([]picker.Item, len(fns))5760 for i, f := range fns {58- items[i] = pickItem{59- title: f.Name + "(" + f.Args + ")", name: len(f.Name), detail: f.Desc, desc: f.Desc,60- pick: func(m *Model) tea.Cmd {61+ items[i] = picker.Item{62+ Title: f.Name + "(" + f.Args + ")", Name: len(f.Name), Detail: f.Desc, Desc: f.Desc,63+ Pick: func() tea.Cmd {6164 m.closeOverlay()6265 m.startEntry(modeEnter, "="+f.Name+"(")6366 return nil
@@ -172,12 +175,13 @@ func keyHelpRows(vim bool) []helpRow {
172175 // shortcuts is the keyboard shortcuts view: a scrollable box over the173176 // grid, in two columns when the screen is wide enough.174177 type shortcuts struct {178+ m *Model // the model it acts on175179 top int176180 }177181 178182 const shortcutsID = "shortcuts"179183 180-func (s *shortcuts) indicator() string { return "HELP" }184+func (s *shortcuts) Indicator() string { return "HELP" }181185 182186 // lines renders the rows, as one or two columns, and returns their width.183187 func (s *shortcuts) lines(m *Model) ([]string, int) {
@@ -239,7 +243,8 @@ func (s *shortcuts) visible(m *Model, total int) int {
239243 return max(min(total, m.height-2-2-1), 1)240244 }241245 242-func (s *shortcuts) layout(m *Model) []box {246+func (s *shortcuts) Layout() []overlay.Box {247+ m := s.m243248 lines, w := s.lines(m)244249 inner := w + 1245250 n := s.visible(m, len(lines)+1)
@@ -259,10 +264,11 @@ func (s *shortcuts) layout(m *Model) []box {
259264 }260265 b := m.th.Frame(inner, "Keyboard shortcuts", footer, rows)261266 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}}267+ return []overlay.Box{{ID: shortcutsID, X: (m.width - w) / 2, Y: max(menuLine+1, (m.height-1-h)/2), Lines: b}}263268 }264269 265-func (s *shortcuts) key(m *Model, k tea.KeyPressMsg) tea.Cmd {270+func (s *shortcuts) Key(k tea.KeyPressMsg) tea.Cmd {271+ m := s.m266272 lines, _ := s.lines(m)267273 page := s.visible(m, len(lines)+1)268274 switch k.String() {
@@ -285,18 +291,20 @@ func (s *shortcuts) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
285291 return nil286292 }287293 288-func (s *shortcuts) mouse(m *Model, e mouseEvent) tea.Cmd {294+func (s *shortcuts) Mouse(e overlay.MouseEvent) tea.Cmd {295+ m := s.m289296 switch {290- case e.kind == mouseWheel && e.button == tea.MouseWheelUp:297+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:291298 s.top = max(s.top-3, 0)292- case e.kind == mouseWheel && e.button == tea.MouseWheelDown:299+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:293300 s.top += 3 // layout clamps294- case e.kind == mousePress && e.box != shortcutsID:301+ case e.Kind == overlay.MousePress && e.Box != shortcutsID:295302 m.closeOverlay()296303 }297304 return nil298305 }299306 300-func (s *shortcuts) status(m *Model) (string, string) {307+func (s *shortcuts) Status() (string, string) {308+ m := s.m301309 return "", m.th.KeyHints("Up/Down", "scroll", "Esc", "close")302310 }
M internal/ui/help_test.go +2 -2
M internal/ui/hidden.go +8 -7
M internal/ui/hidden_test.go +8 -6
@@ -3,6 +3,8 @@ package ui
33 import (44 "strings"55 "testing"6+7+ "github.com/FelineStateMachine/012/internal/ui/picker"68 )79 810 // hiddenBook is a model of three sheets, Sheet1 reading Data, showing
@@ -13,7 +15,7 @@ func hiddenBook(t *testing.T) *Model {
1315 press(t, m, "=", "D", "a", "t", "a", "!", "A", "1", "*", "2", "<enter>")1416 press(t, m, "<shift+f11>")1517 run(m, m.runCommand("sheet.rename"))16- m.line.set("Data")18+ m.line.Set("Data")1719 press(t, m, "<enter>", "2", "1", "<enter>", "<shift+f11>", "<ctrl+pgup>")1820 if sheetNames(m) != "Sheet1,Data,Sheet3" || m.sheet.Name() != "Data" {1921 t.Fatalf("sheets %s, on %s", sheetNames(m), m.sheet.Name())
@@ -59,7 +61,7 @@ func TestHideSheetCommand(t *testing.T) {
5961 6062 // Going to a cell on a hidden sheet says it's hidden.6163 run(m, m.runCommand("goto"))62- m.line.set("Data!A1")64+ m.line.Set("Data!A1")6365 press(t, m, "<enter>")6466 if m.sheet.Name() != "Sheet1" || m.mode != modeError || m.errMsg != "Data is hidden; View > Hidden sheets shows it again" {6567 t.Errorf("goto: on %s, %q", m.sheet.Name(), m.errMsg)
@@ -84,8 +86,8 @@ func TestHiddenSheetsPicker(t *testing.T) {
8486 }8587 run(m, m.runCommand("sheet.hide"))8688 run(m, m.runCommand("sheet.unhide"))87- p, ok := m.overlay.(*picker)88- if !ok || p.title != "Hidden sheets" || len(p.items) != 1 || p.items[0].title != "Data" {89+ p, ok := m.overlay.(*picker.Picker)90+ if !ok || p.Title() != "Hidden sheets" || len(p.Items) != 1 || p.Items[0].Title != "Data" {8991 t.Fatalf("overlay %#v", m.overlay)9092 }9193 if !strings.Contains(screen(m), "Hidden sheets") || !strings.Contains(screen(m), "A1, 1 cell") {
@@ -114,9 +116,9 @@ func TestRecordHideAndUnhide(t *testing.T) {
114116 run(m, m.runCommand("sheet.unhide"))115117 press(t, m, "<enter>")116118 run(m, m.runCommand("macro.stop"))117- m.line.set("Toggle")119+ m.line.Set("Toggle")118120 press(t, m, "<enter>")119- m.line.set("")121+ m.line.Set("")120122 press(t, m, "<enter>")121123 mc, ok := m.book().Macro("Toggle")122124 if !ok {
A internal/ui/hosts.go +134 -0
@@ -0,0 +1,134 @@
1+package ui2+3+import (4+ tea "charm.land/bubbletea/v2"5+6+ "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/cmdline"8+ "github.com/FelineStateMachine/012/internal/ui/findbar"9+ "github.com/FelineStateMachine/012/internal/ui/lineedit"10+ "github.com/FelineStateMachine/012/internal/ui/picker"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+ "github.com/FelineStateMachine/012/internal/ui/themepicker"13+)14+15+// host is how components in packages of their own reach the model. It16+// implements each one's Host interface, the little it acts on, so the17+// model's exported methods stay what cmd/012 and serve use, and a18+// component sees only its own interface.19+type host struct{ m *Model }20+21+func (m *Model) host() host { return host{m} }22+23+var (24+ _ picker.Host = host{}25+ _ cmdline.Host = host{}26+ _ findbar.Host = host{}27+ _ themepicker.Host = host{}28+)29+30+// The in-package hosts the model implements itself.31+var (32+ _ menuHost = (*Model)(nil)33+ _ pivotHost = (*Model)(nil)34+ _ macrosHost = (*Model)(nil)35+)36+37+// Components that stay in package ui are handed interfaces of their own38+// (menuHost and the like), which the model implements with unexported39+// methods, these among them.40+41+func (m *Model) styles() *theme.Theme { return &m.th }42+func (m *Model) size() (width, height int) { return m.width, m.height }43+func (m *Model) available(id string) bool { return commands[id].available(m) }44+func (m *Model) sheetShown() *sheet.Sheet { return m.sheet }45+func (m *Model) canEditScripts() bool { return m.macros.editor }46+47+// syncChanged makes the modified flag follow the undo history.48+func (m *Model) syncChanged() { m.changed = m.sheet.StateID() != m.saved }49+50+// pickValues opens a filter's values list titled title at screen column51+// x; apply gets the criteria chosen, and cancel runs after Esc.52+func (m *Model) pickValues(title string, x int, values []sheet.FilterValue, cond sheet.Condition, apply func(sheet.Criteria), cancel func()) {53+ fp := m.openValuesPicker(title, x, values, cond, func(_ *Model, cr sheet.Criteria) { apply(cr) })54+ fp.onCancel = func(*Model) { cancel() }55+}56+57+// pointRange asks for a range on the context line; done gets it, and58+// cancel runs after Esc.59+func (m *Model) pointRange(label string, done func(sheet.Rect), cancel func()) {60+ m.openRange(label, func(_ *Model, r sheet.Rect) tea.Cmd {61+ done(r)62+ return nil63+ })64+ m.prompt.onCancel = func(*Model) { cancel() }65+}66+67+// What every component with a text field needs.68+69+func (h host) Theme() *theme.Theme { return &h.m.th }70+func (h host) Size() (width, height int) { return h.m.width, h.m.height }71+func (h host) Line() *lineedit.Line { return &h.m.line }72+func (h host) Close() { h.m.closeOverlay() }73+74+// The picker.75+76+func (h host) RecordAnswer(answer string, cancelled bool) { h.m.recordAnswer(answer, cancelled) }77+78+// newPicker returns a picker of items over the model; the caller opens it.79+func (m *Model) newPicker(title, placeholder string, maxW int, items []picker.Item) *picker.Picker {80+ return picker.New(m.host(), title, placeholder, maxW, items)81+}82+83+// The command line.84+85+// Commands are the command line's completions: every registered command86+// but the command line itself, by title.87+func (h host) Commands() []cmdline.Item {88+ ids := commandIDs()89+ items := make([]cmdline.Item, 0, len(ids))90+ for _, id := range ids {91+ if id == "vim.command" {92+ continue93+ }94+ c := commands[id]95+ items = append(items, cmdline.Item{Word: id, Title: c.title, Desc: c.desc, Key: h.m.shortcut(id), Off: !c.available(h.m)})96+ }97+ return items98+}99+100+func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }101+func (h host) Fail(msg string) { h.m.fail(msg) }102+103+// The theme picker.104+105+func (h host) Current() string { return h.m.prefs.Config.Theme().Pick(!h.m.prefs.light) }106+func (h host) ThemesDir() string { return h.m.prefs.ThemesDir }107+func (h host) Keep(name string) { h.m.keepTheme(name) }108+109+func (h host) Preview(name string) {110+ h.m.prefs.preview = name111+ h.m.applyTheme()112+}113+114+// The find bar.115+116+func (h host) Book() *sheet.Workbook { return h.m.book() }117+func (h host) At() (*sheet.Sheet, sheet.Addr) { return h.m.sheet, h.m.cur }118+func (h host) Note(msg string) { h.m.note = msg }119+func (h host) Edited() { h.m.changed = true }120+func (h host) Show(s *sheet.Sheet, a sheet.Addr) { h.m.showSheet(s); h.m.cur = a }121+122+// Leave closes the find bar, keeping it for Ctrl+F and find next.123+func (h host) Leave() {124+ if f, ok := h.m.overlay.(*findbar.Bar); ok {125+ h.m.find = f126+ }127+ h.m.closeOverlay()128+}129+130+// Press hands a click outside an overlay to the grid, as if none were131+// open.132+func (h host) Press(x, y int, button tea.MouseButton) tea.Cmd {133+ return h.m.handlePress(tea.Mouse{X: x, Y: y, Button: button})134+}
M internal/ui/importplace.go +22 -27
@@ -8,6 +8,8 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/fileio"1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/picker"12+ "github.com/FelineStateMachine/012/internal/ui/transfer"1113 )1214 1315 // Where File > Import puts what it reads, as Sheets' Import location:
@@ -16,19 +18,12 @@ import (
1618 // do). A new, empty spreadsheet is just replaced. Inserting or replacing1719 // a sheet is one undo step, and the file stays the one being edited.1820 19-// importPlace is where an import goes.20-type importPlace int21-22-const (23- placeBook importPlace = iota // replace the spreadsheet24- placeNewSheets // insert new sheets25- placeSheet // replace the sheet shown26-)21+// The places are transfer.Book, transfer.NewSheets and transfer.Sheet.2722 2823 // pristine reports whether the spreadsheet is new and untouched, so an2924 // import may simply replace it.3025 func (m *Model) pristine() bool {31- return !m.changed && m.filename == "" && m.xfer.source == "" && m.book().Len() == 1 &&26+ return !m.changed && m.filename == "" && m.xfer.Source == "" && m.book().Len() == 1 &&3227 m.sheet.Len() == 0 && len(m.sheet.Charts()) == 03328 }3429
@@ -36,7 +31,7 @@ func (m *Model) pristine() bool {
3631 // spreadsheet is new and empty.3732 func (m *Model) askImportPlace(name string) tea.Cmd {3833 if m.pristine() {39- return m.startImport(name, fileio.Options{}, placeBook)34+ return m.startImport(name, fileio.Options{}, transfer.Book)4035 }4136 k, _ := fileio.KindOf(name)4237 base := filepath.Base(name)
@@ -44,17 +39,17 @@ func (m *Model) askImportPlace(name string) tea.Cmd {
4439 if k.HoldsSheets() {4540 insert, as = "Insert new sheets", " as new sheets"4641 }47- item := func(title, detail, desc string, run func(m *Model) tea.Cmd) pickItem {48- return pickItem{title: title, name: len(title), detail: detail, desc: desc, pick: func(m *Model) tea.Cmd {42+ item := func(title, detail, desc string, run func(m *Model) tea.Cmd) picker.Item {43+ return picker.Item{Title: title, Name: len(title), Detail: detail, Desc: desc, Pick: func() tea.Cmd {4944 m.closeOverlay()5045 return run(m)5146 }}5247 }53- items := []pickItem{item(insert, "after the others", "Add "+base+as,54- func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeNewSheets) })}48+ items := []picker.Item{item(insert, "after the others", "Add "+base+as,49+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, transfer.NewSheets) })}5550 if !k.HoldsSheets() {5651 items = append(items, item("Replace current sheet", m.sheet.Name(), "Put "+base+" in place of "+m.sheet.Name()+", keeping its name",57- func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeSheet) }))52+ func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, transfer.Sheet) }))5853 }5954 detail := "open it instead"6055 if m.changed {
@@ -62,19 +57,19 @@ func (m *Model) askImportPlace(name string) tea.Cmd {
6257 }6358 items = append(items, item("Replace spreadsheet", detail, "Open "+base+" instead, as File > Open does",6459 func(m *Model) tea.Cmd { return m.confirmImport(name, fileio.Options{}) }))65- p := newPicker(m, "Import "+base, "Import location", 60, items)66- p.action = "import"60+ p := m.newPicker("Import "+base, "Import location", 60, items)61+ p.Action = "import"6762 m.openOverlay(p)6863 return nil6964 }7065 7166 // placeImport puts an imported workbook where the import was asked to go7267 // and says what it did, or reports false to replace the spreadsheet.73-func (m *Model) placeImport(msg importedMsg, what string) bool {74- res := msg.res75- label := "import " + filepath.Base(msg.name)76- switch msg.place {77- case placeNewSheets:68+func (m *Model) placeImport(msg transfer.ImportedMsg, what string) bool {69+ res := msg.Res70+ label := "import " + filepath.Base(msg.Name)71+ switch msg.Place {72+ case transfer.NewSheets:7873 ins, err := m.book().InsertBook(res.Sheet.Book(), label)7974 if err != nil {8075 m.fail(err.Error())
@@ -85,18 +80,18 @@ func (m *Model) placeImport(msg importedMsg, what string) bool {
8580 show = ins.Sheets[0]8681 }8782 m.afterSheetsChange(show, m.book().Index(m.sheet))88- m.note = "Imported " + what + " as " + sheetList(ins.Sheets) + " (" + countRows(res.Rows) + ")"83+ m.note = "Imported " + what + " as " + sheetList(ins.Sheets) + " (" + transfer.Rows(res.Rows) + ")"8984 if ins.Names > 0 {90- m.note += "; " + plural(ins.Names, "1 named range", thousands(ins.Names)+" named ranges") + " left out, their names taken"85+ m.note += "; " + plural(ins.Names, "1 named range", transfer.Thousands(ins.Names)+" named ranges") + " left out, their names taken"9186 }92- case placeSheet:87+ case transfer.Sheet:9388 i := m.book().Index(m.sheet)9489 if err := m.book().ReplaceSheet(m.sheet, res.Sheet, label); err != nil {9590 m.fail(err.Error())9691 return true9792 }9893 m.afterSheetsChange(res.Sheet, i)99- m.note = "Imported " + what + " into " + res.Sheet.Name() + " (" + countRows(res.Rows) + ")"94+ m.note = "Imported " + what + " into " + res.Sheet.Name() + " (" + transfer.Rows(res.Rows) + ")"10095 default:10196 return false10297 }
@@ -116,6 +111,6 @@ func sheetList(sheets []*sheet.Sheet) string {
116111 case 2:117112 return sheets[0].Name() + " and " + sheets[1].Name()118113 default:119- return thousands(n) + " sheets, " + sheets[0].Name() + " to " + sheets[n-1].Name()114+ return transfer.Thousands(n) + " sheets, " + sheets[0].Name() + " to " + sheets[n-1].Name()120115 }121116 }
M internal/ui/importplace_test.go +5 -4
D internal/ui/line.go +0 -105
@@ -1,105 +0,0 @@
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-}
A internal/ui/lineedit/lineedit.go +116 -0
@@ -0,0 +1,116 @@
1+// Package lineedit is the one-line text editor behind every text field of2+// the UI. It depends on nothing in package ui, so components in their own3+// packages can edit the shared line.4+package lineedit5+6+import (7+ "slices"8+ "strings"9+ "unicode"10+11+ tea "charm.land/bubbletea/v2"12+ "github.com/charmbracelet/x/ansi"13+)14+15+// Line is the one-line text editor behind every text field: the16+// entry typed into a cell (ENTER, EDIT), prompts on the context line, and17+// the search fields of the find bar and the pickers. Only one of them is18+// edited at a time, so they share it; a component with several fields19+// keeps the others' text itself and loads a field here to edit it.20+type Line struct {21+ Buf []rune22+ Pos int // the caret, an index into Buf23+}24+25+// Set replaces the text, with the caret at its end.26+func (l *Line) Set(text string) {27+ l.Buf = []rune(text)28+ l.Pos = len(l.Buf)29+}30+31+// Clear empties the line.32+func (l *Line) Clear() {33+ l.Buf, l.Pos = nil, 034+}35+36+// Text is the whole line.37+func (l *Line) Text() string { return string(l.Buf) }38+39+// Head and tail are the text before and after the caret.40+func (l *Line) Head() string { return string(l.Buf[:l.Pos]) }41+func (l *Line) Tail() string { return string(l.Buf[l.Pos:]) }42+43+// Insert types text at the caret. Line breaks and tabs become spaces, and44+// other control characters are dropped.45+func (l *Line) Insert(text string) {46+ for _, r := range text {47+ if r == '\n' || r == '\r' || r == '\t' {48+ r = ' '49+ }50+ if !unicode.IsPrint(r) {51+ continue52+ }53+ l.Buf = slices.Insert(l.Buf, l.Pos, r)54+ l.Pos++55+ }56+}57+58+// Key applies a line-editing Key.59+func (l *Line) Key(k tea.KeyPressMsg) {60+ switch k.String() {61+ case "left":62+ l.Pos = max(l.Pos-1, 0)63+ case "right":64+ l.Pos = min(l.Pos+1, len(l.Buf))65+ case "home", "ctrl+a":66+ l.Pos = 067+ case "end", "ctrl+e":68+ l.Pos = len(l.Buf)69+ case "backspace":70+ if l.Pos > 0 {71+ l.Buf = slices.Delete(l.Buf, l.Pos-1, l.Pos)72+ l.Pos--73+ }74+ case "delete":75+ if l.Pos < len(l.Buf) {76+ l.Buf = slices.Delete(l.Buf, l.Pos, l.Pos+1)77+ }78+ default:79+ l.Insert(Typed(k))80+ }81+}82+83+// SetCaret puts the edit caret at display column x of the entry.84+func (l *Line) SetCaret(x int) {85+ w := 086+ for i, r := range l.Buf {87+ if w >= x {88+ l.Pos = i89+ return90+ }91+ w += ansi.StringWidth(string(r))92+ }93+ l.Pos = len(l.Buf)94+}95+96+// IsFormula reports whether the edit line holds a formula.97+func (l *Line) IsFormula() bool {98+ return len(l.Buf) > 0 && (l.Buf[0] == '=' || l.Buf[0] == '+' || l.Buf[0] == '-')99+}100+101+// CanPoint reports whether the text before the caret ends where a cell102+// reference may follow.103+func (l *Line) CanPoint() bool {104+ if l.Pos == 0 {105+ return false106+ }107+ return strings.ContainsRune("=+-*/^(,;<>&:", l.Buf[l.Pos-1])108+}109+110+// Typed returns the printable text of a key press, if any.111+func Typed(k tea.KeyPressMsg) string {112+ if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {113+ return ""114+ }115+ return k.Text116+}
A internal/ui/lineedit/lineedit_test.go +45 -0
M internal/ui/macro.go +9 -7
@@ -9,6 +9,7 @@ import (
99 1010 "github.com/FelineStateMachine/012/internal/macro"1111 "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/picker"1213 )1314 1415 // Macros, as Google Sheets' Extensions > Macros, under Data > Macros:
@@ -50,7 +51,7 @@ func init() {
5051 enabled: func(m *Model) bool { return idle(m) && len(m.book().Macros()) > 0 },5152 desc: "Pick a saved macro and run it",5253 run: func(m *Model) tea.Cmd {53- p := newPicker(m, "Run macro", "Type a macro's name", 60, macroRunItems(m))54+ p := m.newPicker("Run macro", "Type a macro's name", 60, macroRunItems(m))5455 m.openOverlay(p)5556 return nil5657 }},
@@ -102,13 +103,13 @@ func (m *Model) runShortcut(key string) (tea.Cmd, bool) {
102103 103104 // macroRunItems lists the saved macros for the run picker and the104105 // palette; picking one runs it.105-func macroRunItems(m *Model) []pickItem {106- var items []pickItem106+func macroRunItems(m *Model) []picker.Item {107+ var items []picker.Item107108 for _, mc := range m.book().Macros() {108- items = append(items, pickItem{109- title: mc.Name, name: len(mc.Name), detail: "Data › Macros", key: shortcutLabel(mc.Key),110- desc: "Run the macro " + mc.Name, off: m.rec != nil,111- pick: func(m *Model) tea.Cmd {109+ items = append(items, picker.Item{110+ Title: mc.Name, Name: len(mc.Name), Detail: "Data › Macros", Key: shortcutLabel(mc.Key),111+ Desc: "Run the macro " + mc.Name, Off: m.rec != nil,112+ Pick: func() tea.Cmd {112113 m.closeOverlay()113114 return m.runMacro(mc)114115 },
@@ -130,6 +131,7 @@ func (m *Model) runMacro(mc sheet.Macro) tea.Cmd {
130131 return m.startMacro(mc)131132 }132133 m.openOverlay(&choiceBar{134+ m: m,133135 msg: "Trust this file's macros?",134136 desc: "They were made on another computer and can change the file. Trusting covers all of them from now on.",135137 warn: true,
M internal/ui/macro_test.go +7 -7
@@ -33,9 +33,9 @@ func record(t *testing.T, m *Model, relative bool, name, key string, keys ...str
3333 if m.mode != modePrompt || !strings.HasPrefix(m.prompt.label, "Save macro as") {3434 t.Fatalf("no name prompt: mode %v", m.mode)3535 }36- m.line.set(name)36+ m.line.Set(name)3737 press(t, m, "<enter>")38- m.line.set(key)38+ m.line.Set(key)3939 press(t, m, "<enter>")4040 if m.rec != nil {4141 t.Fatalf("still recording: %q %q", m.note, m.errMsg)
@@ -201,7 +201,7 @@ func TestRecordingAnswersAndSkips(t *testing.T) {
201201 m = newModel()202202 run(m, m.runCommand("macro.record"))203203 run(m, m.runCommand("column.width"))204- m.line.set("15")204+ m.line.Set("15")205205 press(t, m, "<enter>")206206 run(m, m.runCommand("macro.stop"))207207 press(t, m, "<enter>", "<enter>")
@@ -307,7 +307,7 @@ func TestPaletteAndPickerRunMacros(t *testing.T) {
307307 run(m, m.runCommand("macro.run"))308308 press(t, m, "<enter>")309309 if m.note != "Ran Stamp" || m.overlay != nil {310- t.Errorf("picker: note %q", m.note)310+ t.Errorf("Picker: note %q", m.note)311311 }312312 }313313
@@ -320,10 +320,10 @@ func TestManageMacros(t *testing.T) {
320320 t.Fatalf("manager:\n%s", screen(m))321321 }322322 press(t, m, "<down>", "<f2>")323- m.line.set("Uno")323+ m.line.Set("Uno")324324 press(t, m, "<enter>")325325 press(t, m, "<f3>")326- m.line.set("7")326+ m.line.Set("7")327327 press(t, m, "<enter>")328328 mc := mustMacro(t, m, "Uno")329329 if mc.Key != "7" {
@@ -349,7 +349,7 @@ func TestWithoutAnEditorScriptsAreReadOnly(t *testing.T) {
349349 if strings.Contains(screen(m), "+ Write a macro") {350350 t.Error("the manager offers to write a macro")351351 }352- if cmd := m.overlay.key(m, tea.KeyPressMsg{Code: tea.KeyF4}); cmd != nil || !strings.Contains(line(m, m.height-1), "No editor in this session") {352+ if cmd := m.overlay.Key(tea.KeyPressMsg{Code: tea.KeyF4}); cmd != nil || !strings.Contains(line(m, m.height-1), "No editor in this session") {353353 t.Errorf("F4: %v %q", cmd, line(m, m.height-1))354354 }355355 }
M internal/ui/macrohostnav.go +8 -7
@@ -7,6 +7,7 @@ import (
77 "strings"88 99 "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/picker"1011 )1112 1213 // What scripts do to the selection, the sheets and through commands; the
@@ -270,8 +271,8 @@ func (h scriptHost) answer(c *command, answer *string) error {
270271 return h.failure()271272 case m.mode == modePrompt && answer != nil:272273 p := m.prompt273- m.line.clear()274- m.line.insert(*answer)274+ m.line.Clear()275+ m.line.Insert(*answer)275276 p.fresh, p.typing = false, p.kind == promptRange276277 m.macros.cmds = append(m.macros.cmds, p.accept(m))277278 case m.mode == modePrompt:
@@ -295,7 +296,7 @@ func (h scriptHost) answer(c *command, answer *string) error {
295296 // overlays (dialogs, pickers) can't be answered by a script.296297 func (h scriptHost) choose(c *command, answer *string) error {297298 m := h.m298- if p, ok := m.overlay.(*picker); ok && p.answers {299+ if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {299300 return h.pickAnswer(c, p, answer)300301 }301302 bar, ok := m.overlay.(*choiceBar)
@@ -321,10 +322,10 @@ func (h scriptHost) choose(c *command, answer *string) error {
321322 322323 // pickAnswer answers a picker that asks a command's question with the323324 // title of one of its items.324-func (h scriptHost) pickAnswer(c *command, p *picker, answer *string) error {325+func (h scriptHost) pickAnswer(c *command, p *picker.Picker, answer *string) error {325326 m := h.m326327 if answer != nil {327- if cmd, ok := p.answer(m, *answer); ok {328+ if cmd, ok := p.Answer(*answer); ok {328329 m.macros.cmds = append(m.macros.cmds, cmd)329330 if m.mode == modeError {330331 return h.failure()
@@ -334,8 +335,8 @@ func (h scriptHost) pickAnswer(c *command, p *picker, answer *string) error {
334335 }335336 m.closeOverlay()336337 var titles []string337- for _, it := range p.items {338- titles = append(titles, it.title)338+ for _, it := range p.Items {339+ titles = append(titles, it.Title)339340 }340341 return fmt.Errorf("%s asks for one of %s: give run(%q, answer=...)", c.title, strings.Join(titles, ", "), c.id)341342 }
M internal/ui/macromanage.go +54 -32
@@ -13,6 +13,8 @@ import (
1313 1414 "github.com/FelineStateMachine/012/internal/macro"1515 "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui/picker"17+ "github.com/FelineStateMachine/012/internal/ui/theme"1618 )1719 1820 // Managing macros, as Sheets' Extensions > Macros > Manage macros: a
@@ -21,9 +23,27 @@ import (
2123 // Ctrl+D deletes it. The first row writes a new one. Questions go on the2224 // context line; the picker opens again after each.2325 26+// macrosHost is what the macro manager acts on. The model implements it.27+// The manager stays in package ui: it is a few keys over a picker, and28+// what they do (run, rename, edit a script) is the model's macro29+// machinery.30+type macrosHost interface {31+ styles() *theme.Theme32+ size() (width, height int)33+ book() *sheet.Workbook34+ closeOverlay()35+ // canEditScripts reports whether this session may open an editor.36+ canEditScripts() bool37+ renameMacro(mc sheet.Macro)38+ setMacroShortcut(mc sheet.Macro)39+ editScript(name, src string, isNew bool) tea.Cmd40+ macroManageItems() []picker.Item41+}42+2443 // macrosPicker is the picker of saved macros with its extra keys.2544 type macrosPicker struct {26- *picker45+ m macrosHost // the model, through what the manager needs of it46+ *picker.Picker2747 msg string // feedback on the last action, e.g. a deletion2848 }2949
@@ -31,23 +51,23 @@ const writeMacroTitle = "+ Write a macro"
3151 3252 // openMacros opens the manager with the macro named sel highlighted.3353 func (m *Model) openMacros(sel string) {34- p := newPicker(m, "Macros", "Type a macro's name", 72, macroManageItems(m))35- p.action = "run"36- for i, pm := range p.shown {37- if strings.EqualFold(pm.item.title, sel) {38- p.sel = i54+ p := m.newPicker("Macros", "Type a macro's name", 72, m.macroManageItems())55+ p.Action = "run"56+ for i, pm := range p.Shown() {57+ if strings.EqualFold(pm.Item.Title, sel) {58+ p.Sel = i3959 }4060 }41- m.openOverlay(&macrosPicker{picker: p})61+ m.openOverlay(&macrosPicker{m: m, Picker: p})4262 }4363 4464 // macroManageItems lists "Write a macro" and then every macro.45-func macroManageItems(m *Model) []pickItem {46- var items []pickItem65+func (m *Model) macroManageItems() []picker.Item {66+ var items []picker.Item4767 if m.macros.editor {48- items = append(items, pickItem{49- title: writeMacroTitle, name: len(writeMacroTitle), desc: "Write a new macro script in your editor",50- pick: func(m *Model) tea.Cmd {68+ items = append(items, picker.Item{69+ Title: writeMacroTitle, Name: len(writeMacroTitle), Desc: "Write a new macro script in your editor",70+ Pick: func() tea.Cmd {5171 m.closeOverlay()5272 return m.newMacro()5373 },
@@ -55,10 +75,10 @@ func macroManageItems(m *Model) []pickItem {
5575 }5676 for _, mc := range m.book().Macros() {5777 lines := strings.Count(strings.TrimRight(mc.Source, "\n"), "\n") + 158- items = append(items, pickItem{59- title: mc.Name, name: len(mc.Name), detail: strconv.Itoa(lines) + " lines", key: shortcutLabel(mc.Key),60- desc: "Run " + mc.Name,61- pick: func(m *Model) tea.Cmd {78+ items = append(items, picker.Item{79+ Title: mc.Name, Name: len(mc.Name), Detail: strconv.Itoa(lines) + " lines", Key: shortcutLabel(mc.Key),80+ Desc: "Run " + mc.Name,81+ Pick: func() tea.Cmd {6282 m.closeOverlay()6383 return m.runMacro(mc)6484 },
@@ -68,14 +88,15 @@ func macroManageItems(m *Model) []pickItem {
6888 }6989 7090 // current is the macro highlighted, if any.71-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 {91+func (p *macrosPicker) current(m macrosHost) (sheet.Macro, bool) {92+ if p.Picker.Sel >= len(p.Shown()) || p.Shown()[p.Picker.Sel].Item.Title == writeMacroTitle {7393 return sheet.Macro{}, false7494 }75- return m.book().Macro(p.shown[p.picker.sel].item.title)95+ return m.book().Macro(p.Shown()[p.Picker.Sel].Item.Title)7696 }7797 78-func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {98+func (p *macrosPicker) Key(k tea.KeyPressMsg) tea.Cmd {99+ m := p.m79100 p.msg = ""80101 mc, ok := p.current(m)81102 switch k.String() {
@@ -84,7 +105,7 @@ func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
84105 return nil85106 }86107 default:87- return p.picker.key(m, k)108+ return p.Picker.Key(k)88109 }89110 switch k.String() {90111 case "f2":
@@ -94,7 +115,7 @@ func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
94115 m.closeOverlay()95116 m.setMacroShortcut(mc)96117 case "f4":97- if !m.macros.editor {118+ if !m.canEditScripts() {98119 p.msg = "No editor in this session"99120 return nil100121 }
@@ -103,31 +124,32 @@ func (p *macrosPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
103124 case "ctrl+d":104125 m.book().DeleteMacro(mc.Name)105126 p.msg = "Deleted " + mc.Name + "; Ctrl+Z brings it back"106- p.items = macroManageItems(m)107- sel := p.picker.sel108- p.changed(m)109- p.picker.sel = max(min(sel, len(p.shown)-1), 0)127+ p.Items = m.macroManageItems()128+ sel := p.Picker.Sel129+ p.Changed()130+ p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)110131 }111132 return nil112133 }113134 114-func (p *macrosPicker) status(m *Model) (string, string) {135+func (p *macrosPicker) Status() (string, string) {136+ m := p.m115137 pairs := []string{"Enter", "run", "F2", "rename", "F3", "shortcut", "F4", "edit", "Ctrl+D", "delete", "Esc", "close"}116- if !m.macros.editor {138+ if !m.canEditScripts() {117139 pairs = slices.Delete(pairs, 6, 8)118140 }119- if m.width < 100 {141+ if width, _ := m.size(); width < 100 {120142 pairs = slices.DeleteFunc(pairs[:len(pairs)-2], func(s string) bool { return s == "shortcut" })121143 pairs = slices.Insert(pairs, slices.Index(pairs, "F3")+1, "key")122144 }123- keys := m.th.KeyHints(pairs...)145+ keys := m.styles().KeyHints(pairs...)124146 if _, ok := p.current(m); !ok {125- keys = m.th.KeyHints("Enter", "write", "Esc", "close")147+ keys = m.styles().KeyHints("Enter", "write", "Esc", "close")126148 }127149 if p.msg != "" {128150 return p.msg, keys129151 }130- desc, _ := p.picker.status(m)152+ desc, _ := p.Picker.Status()131153 return desc, keys132154 }133155
M internal/ui/macrorec.go +3 -2
M internal/ui/menuoverlay.go +81 -58
@@ -7,14 +7,32 @@ 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 14+// menuHost is what an open menu acts on. The model implements it. Menus15+// stay in package ui because their items are the command registry and16+// the menu bar's definitions.17+type menuHost interface {18+ styles() *theme.Theme19+ size() (width, height int)20+ available(id string) bool21+ shortcut(id string) string22+ isChecked(id string) bool23+ showBarMenu(i int)24+ rightClick(x, y int)25+ closeOverlay()26+ // runFromOverlay closes the menu and runs a command through runCommand.27+ runFromOverlay(id string) tea.Cmd28+}29+1330 // menuOverlay is an open dropdown from the menu bar, or a context menu,1431 // with any submenus opened from it.1532 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 opened33+ m menuHost // the model, through what a menu needs of it34+ bar int // index in barMenus of the open title; -1 for a context menu35+ x, y int // where a context menu opened1836 levels []*menuLevel1937 }2038
@@ -32,17 +50,17 @@ func (m *Model) showBarMenu(i int) {
3250 return3351 }3452 i = (i%len(menus) + len(menus)) % len(menus)35- m.openOverlay(&menuOverlay{bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})53+ m.openOverlay(&menuOverlay{m: m, bar: i, levels: []*menuLevel{newLevel(m, visibleItems(menus[i].def.items), true)}})3654 }3755 3856 // showContextMenu opens a menu of items with its corner at x, y.3957 func (m *Model) showContextMenu(items []menuItem, x, y int) {4058 if items = visibleItems(items); len(items) > 0 {41- m.openOverlay(&menuOverlay{bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})59+ m.openOverlay(&menuOverlay{m: m, bar: -1, x: x, y: y, levels: []*menuLevel{newLevel(m, items, true)}})4260 }4361 }4462 45-func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {63+func newLevel(m menuHost, items []menuItem, highlight bool) *menuLevel {4664 l := &menuLevel{items: items, sel: -1}4765 if highlight {4866 l.step(m, 1)
@@ -50,14 +68,14 @@ func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {
5068 return l5169 }5270 53-func (l *menuLevel) selectable(m *Model, i int) bool {71+func (l *menuLevel) selectable(m menuHost, i int) bool {5472 it := l.items[i]55- return !it.sep && (it.items != nil || commands[it.cmd].available(m))73+ return !it.sep && (it.items != nil || m.available(it.cmd))5674 }5775 5876 // step moves the highlight to the next selectable item in direction d,5977 // wrapping around.60-func (l *menuLevel) step(m *Model, d int) {78+func (l *menuLevel) step(m menuHost, d int) {6179 n, i := len(l.items), l.sel6280 if i < 0 && d < 0 {6381 i = n
@@ -73,13 +91,14 @@ func (l *menuLevel) step(m *Model, d int) {
7391 7492 func (o *menuOverlay) top() *menuLevel { return o.levels[len(o.levels)-1] }7593 76-func (o *menuOverlay) indicator() string { return "MENU" }94+func (o *menuOverlay) Indicator() string { return "MENU" }7795 78-// key handles a key in an open menu: Up/Down highlight, Right opens a96+// Key handles a key in an open menu: Up/Down highlight, Right opens a7997 // submenu or the next menu, Left closes a submenu or opens the previous8098 // menu, Enter runs, Esc closes one level, and a letter jumps to the items8199 // starting with it, running the item if it's the only one.82-func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {100+func (o *menuOverlay) Key(k tea.KeyPressMsg) tea.Cmd {101+ m := o.m83102 l := o.top()84103 key := k.String()85104 if i := barMenuFor(key); i >= 0 {
@@ -134,7 +153,7 @@ func (o *menuOverlay) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
134153 135154 // jump highlights the next item starting with letter, running it if it's136155 // the only one.137-func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {156+func (o *menuOverlay) jump(m menuHost, letter string) tea.Cmd {138157 l := o.top()139158 if letter == "" {140159 return nil
@@ -164,14 +183,14 @@ func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {
164183 }165184 166185 // choose opens the highlighted submenu or runs the highlighted command.167-func (o *menuOverlay) choose(m *Model) tea.Cmd {186+func (o *menuOverlay) choose(m menuHost) tea.Cmd {168187 l := o.top()169188 if l.sel < 0 || !l.selectable(m, l.sel) {170189 return nil171190 }172191 it := l.items[l.sel]173192 if it.items != nil {174- o.levels = append(o.levels, m.newLevel(it.items, true))193+ o.levels = append(o.levels, newLevel(m, it.items, true))175194 return nil176195 }177196 return m.runFromOverlay(it.cmd)
@@ -179,24 +198,25 @@ func (o *menuOverlay) choose(m *Model) tea.Cmd {
179198 180199 func menuBoxID(level int) string { return "menu" + strconv.Itoa(level) }181200 182-// mouse handles hovering and clicking items, switching menus by hovering201+// Mouse handles hovering and clicking items, switching menus by hovering183202 // or clicking the menu bar, and closing the menu on a click elsewhere.184-func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {203+func (o *menuOverlay) Mouse(e overlay.MouseEvent) tea.Cmd {204+ m := o.m185205 for lv, l := range o.levels {186- if e.box == menuBoxID(lv) {206+ if e.Box == menuBoxID(lv) {187207 return o.levelMouse(m, e, lv, l)188208 }189209 }190- if e.y == menuLine && e.box == "" {191- if t := barMenuAt(e.x); t >= 0 {210+ if e.Y == menuLine && e.Box == "" {211+ if t := barMenuAt(e.X); t >= 0 {192212 o.barMouse(m, e, t)193213 return nil194214 }195215 }196- if e.kind == mousePress {216+ if e.Kind == overlay.MousePress {197217 m.closeOverlay()198- if e.button == tea.MouseRight {199- m.rightClick(e.x, e.y) // right-clicking elsewhere opens a menu there218+ if e.Button == tea.MouseRight {219+ m.rightClick(e.X, e.Y) // right-clicking elsewhere opens a menu there200220 }201221 }202222 return nil
@@ -204,25 +224,25 @@ func (o *menuOverlay) mouse(m *Model, e mouseEvent) tea.Cmd {
204224 205225 // levelMouse handles the mouse over open box lv: hovering highlights an206226 // 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 {227+func (o *menuOverlay) levelMouse(m menuHost, e overlay.MouseEvent, lv int, l *menuLevel) tea.Cmd {228+ i := l.top + e.Row - 1229+ if e.Kind == overlay.MouseWheel {210230 o.levels = o.levels[:lv+1]211- l.wheel(m, e.button)231+ l.wheel(m, e.Button)212232 return nil213233 }214- if e.row < 1 || i >= len(l.items) || !l.selectable(m, i) {234+ if e.Row < 1 || i >= len(l.items) || !l.selectable(m, i) {215235 return nil216236 }217- switch e.kind {218- case mouseMotion:237+ switch e.Kind {238+ case overlay.MouseMotion:219239 if i != l.sel || len(o.levels) == lv+1 {220240 o.levels, l.sel = o.levels[:lv+1], i221241 if sub := l.items[i].items; sub != nil {222- o.levels = append(o.levels, m.newLevel(sub, false))242+ o.levels = append(o.levels, newLevel(m, sub, false))223243 }224244 }225- case mousePress:245+ case overlay.MousePress:226246 o.levels, l.sel = o.levels[:lv+1], i227247 return o.choose(m)228248 }
@@ -231,17 +251,17 @@ func (o *menuOverlay) levelMouse(m *Model, e mouseEvent, lv int, l *menuLevel) t
231251 232252 // barMouse handles the mouse over menu bar title t: clicking the open233253 // title closes its menu, and clicking or hovering another opens that one.234-func (o *menuOverlay) barMouse(m *Model, e mouseEvent, t int) {254+func (o *menuOverlay) barMouse(m menuHost, e overlay.MouseEvent, t int) {235255 switch {236- case e.kind == mousePress && t == o.bar:256+ case e.Kind == overlay.MousePress && t == o.bar:237257 m.closeOverlay()238- case e.kind == mousePress, e.kind == mouseMotion && o.bar >= 0 && t != o.bar:258+ case e.Kind == overlay.MousePress, e.Kind == overlay.MouseMotion && o.bar >= 0 && t != o.bar:239259 m.showBarMenu(t)240260 }241261 }242262 243263 // wheel moves the highlight, which scrolls a menu too tall for the screen.244-func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {264+func (l *menuLevel) wheel(m menuHost, b tea.MouseButton) {245265 switch b {246266 case tea.MouseWheelUp:247267 l.step(m, -1)
@@ -250,10 +270,11 @@ func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {
250270 }251271 }252272 253-func (o *menuOverlay) status(m *Model) (string, string) {254- keys := m.th.KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")273+func (o *menuOverlay) Status() (string, string) {274+ m := o.m275+ keys := m.styles().KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")255276 if o.bar >= 0 {256- keys = m.th.KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")277+ keys = m.styles().KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")257278 }258279 l := o.top()259280 if l.sel < 0 {
@@ -272,12 +293,14 @@ func (o *menuOverlay) status(m *Model) (string, string) {
272293 return commands[it.cmd].desc, keys273294 }274295 275-// layout places the dropdown under its title (or a context menu at the296+// Layout places the dropdown under its title (or a context menu at the276297 // mouse) and each submenu beside the item that opened it, flipping left or277298 // 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)299+func (o *menuOverlay) Layout() []overlay.Box {300+ m := o.m301+ width, height := m.size()302+ boxes := make([]overlay.Box, 0, len(o.levels))303+ maxRows := max(height-4, 3)281304 for i, l := range o.levels {282305 rows := min(len(l.items), maxRows)283306 if l.sel >= 0 {
@@ -290,27 +313,27 @@ func (o *menuOverlay) layout(m *Model) []box {
290313 switch {291314 case i > 0:292315 p := boxes[i-1]293- x, y = p.x+p.width(), p.y+o.levels[i-1].sel-o.levels[i-1].top316+ x, y = p.X+p.Width(), p.Y+o.levels[i-1].sel-o.levels[i-1].top294317 // Open to the right; else to the left; and if neither fits,295318 // against the screen's right edge, where it covers the parent's296319 // shortcuts rather than its labels.297- if x+w > m.width {298- x = p.x - w320+ if x+w > width {321+ x = p.X - w299322 if x < 0 {300- x = m.width - w323+ x = width - w301324 }302325 }303326 case o.bar >= 0:304327 x, y = barMenus()[o.bar].x, menuLine+1305328 default:306329 x, y = o.x, o.y307- if y+h > m.height-1 {330+ if y+h > height-1 {308331 y = o.y - h309332 }310333 }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})334+ x = clamp(x, 0, width-w)335+ y = clamp(y, 0, height-1-h)336+ boxes = append(boxes, overlay.Box{ID: menuBoxID(i), X: x, Y: y, Lines: lines})314337 }315338 return boxes316339 }
@@ -318,7 +341,7 @@ func (o *menuOverlay) layout(m *Model) []box {
318341 // lines draws the box: each item with its shortcut right-aligned, a319342 // › for submenus, separators between groups, unavailable items dimmed and320343 // arrows in the border when the items don't all fit.321-func (l *menuLevel) lines(m *Model, rows int) []string {344+func (l *menuLevel) lines(m menuHost, rows int) []string {322345 lw, kw := 0, 0323346 for _, it := range l.items {324347 lw = max(lw, ansi.StringWidth(it.label()))
@@ -332,12 +355,12 @@ func (l *menuLevel) lines(m *Model, rows int) []string {
332355 out = append(out, theme.SepRow)333356 continue334357 }335- style := m.th.MenuBar358+ style := m.styles().MenuBar336359 switch {337360 case i == l.sel:338- style = m.th.MenuSelected361+ style = m.styles().MenuSelected339362 case !l.selectable(m, i):340- style = m.th.Disabled363+ style = m.styles().Disabled341364 }342365 k := it.key(m, !l.selectable(m, i))343366 if it.items != nil || i == l.sel {
@@ -353,7 +376,7 @@ func (l *menuLevel) lines(m *Model, rows int) []string {
353376 if l.top+rows < len(l.items) {354377 down = "▼"355378 }356- return m.th.Frame(inner, up, down, out)379+ return m.styles().Frame(inner, up, down, out)357380 }358381 359382 // isChecked reports whether the command is a setting that is on.
@@ -365,7 +388,7 @@ func (m *Model) isChecked(id string) bool {
365388 // key is what an item shows on the right: its shortcut as a key chip366389 // (plain when the item is unavailable), › for a submenu, or ✓ for a367390 // setting that is on.368-func (it menuItem) key(m *Model, disabled bool) string {391+func (it menuItem) key(m menuHost, disabled bool) string {369392 k := m.shortcut(it.cmd)370393 switch {371394 case it.items != nil:
@@ -375,7 +398,7 @@ func (it menuItem) key(m *Model, disabled bool) string {
375398 case k == "":376399 return ""377400 case disabled:378- return m.th.Disabled.Render(" " + k + " ")401+ return m.styles().Disabled.Render(" " + k + " ")379402 }380- return m.th.Chip(k)403+ return m.styles().Chip(k)381404 }
A internal/ui/menuoverlay_test.go +82 -0
@@ -0,0 +1,82 @@
1+package ui2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/overlay"11+ "github.com/FelineStateMachine/012/internal/ui/theme"12+)13+14+// fakeMenuHost is a menu's host without a model: every command is15+// available but "off", and what the menu asks for is recorded.16+type fakeMenuHost struct {17+ th theme.Theme18+ ran []string19+ closed int20+ bar int21+ right [2]int22+}23+24+func (f *fakeMenuHost) styles() *theme.Theme { return &f.th }25+func (f *fakeMenuHost) size() (width, height int) { return 80, 24 }26+func (f *fakeMenuHost) available(id string) bool { return id != "off" }27+func (f *fakeMenuHost) shortcut(id string) string { return map[string]string{"a": "Ctrl+A"}[id] }28+func (f *fakeMenuHost) isChecked(id string) bool { return id == "b" }29+func (f *fakeMenuHost) showBarMenu(i int) { f.bar = i }30+func (f *fakeMenuHost) rightClick(x, y int) { f.right = [2]int{x, y} }31+func (f *fakeMenuHost) closeOverlay() { f.closed++ }32+33+func (f *fakeMenuHost) runFromOverlay(id string) tea.Cmd {34+ f.ran = append(f.ran, id)35+ return nil36+}37+38+func fakeMenu(h menuHost) *menuOverlay {39+ items := []menuItem{40+ {title: "Alpha", cmd: "a"}, {title: "Beta", cmd: "b"}, sep,41+ {title: "Off", cmd: "off"},42+ {title: "More", items: []menuItem{{title: "Gamma", cmd: "c"}}},43+ }44+ return &menuOverlay{m: h, bar: -1, x: 10, y: 5, levels: []*menuLevel{newLevel(h, items, true)}}45+}46+47+func TestMenuAlone(t *testing.T) {48+ h := &fakeMenuHost{th: theme.New(true), bar: -1}49+ o := fakeMenu(h)50+ b := o.Layout()[0]51+ out := ansi.Strip(strings.Join(b.Lines, "\n"))52+ for _, want := range []string{"Alpha", "Ctrl+A", "Beta", "✓", "Off", "›"} {53+ if !strings.Contains(out, want) {54+ t.Errorf("menu lacks %q:\n%s", want, out)55+ }56+ }57+ if b.X != 10 || b.Y != 5 {58+ t.Errorf("menu at %d,%d", b.X, b.Y)59+ }60+ // Down skips the separator and the unavailable item.61+ o.Key(tea.KeyPressMsg{Code: tea.KeyDown})62+ o.Key(tea.KeyPressMsg{Code: tea.KeyDown})63+ if it := o.top().items[o.top().sel]; it.title != "More" {64+ t.Fatalf("highlighted %q", it.title)65+ }66+ o.Key(tea.KeyPressMsg{Code: tea.KeyRight})67+ o.Key(tea.KeyPressMsg{Code: tea.KeyEnter})68+ if len(o.levels) != 2 || strings.Join(h.ran, ",") != "c" {69+ t.Errorf("levels %d, ran %v", len(o.levels), h.ran)70+ }71+ // A letter runs the only item starting with it.72+ o.Key(tea.KeyPressMsg{Code: tea.KeyEscape})73+ o.Key(tea.KeyPressMsg{Code: 'b', Text: "b"})74+ if strings.Join(h.ran, ",") != "c,b" {75+ t.Errorf("ran %v", h.ran)76+ }77+ // Right-clicking outside closes the menu and opens one there.78+ o.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseRight, X: 50, Y: 20})79+ if h.closed != 1 || h.right != [2]int{50, 20} {80+ t.Errorf("closed %d, right-click at %v", h.closed, h.right)81+ }82+}
M internal/ui/model.go +23 -23
@@ -12,7 +12,12 @@ 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/findbar"16+ "github.com/FelineStateMachine/012/internal/ui/lineedit"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"1519 "github.com/FelineStateMachine/012/internal/ui/theme"20+ "github.com/FelineStateMachine/012/internal/ui/transfer"1621 )1722 1823 // mode is the state shown in the mode indicator.
@@ -83,21 +88,21 @@ type Model struct {
8388 8489 // Components. Each owns its state and the handling of the input it8590 // 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.go91+ line lineedit.Line // the edit line of entries, prompts and search fields: package lineedit92+ entry entry // typing into a cell: entry.go93+ point pointer // the cell or range pointed at in POINT mode and range prompts94+ prompt *prompt // a question on the context line: prompt.go95+ overlay overlay.Overlay // the open menu, picker or bar, if any (modeMenu): overlay.go96+ mouse mouseState // drags, hover and double clicks: mouse.go97+ tabs tabstrip.Strip // the sheet tabs and where each sheet was left: package tabstrip98+ find *findbar.Bar // the last search, reopened by Ctrl+F: package findbar99+ charts chartState // chart commands' target: charts.go100+ copied clipboard // what Ctrl+V pastes: clipboard.go101+ trace *trace // precedents or dependents being shown: trace.go102+ xfer transfer.Transfer // the import running and the file imported: package transfer103+ jev *jevRunner // answers JEV functions; nil without an API key: jev.go104+ term terminal // what the terminal supports: graphics.go105+ prefs prefs // the settings in effect and the theme chosen: prefs.go101106 102107 vim vimState // a vim key sequence in progress: vim.go103108 rec *recorder // a macro being recorded: macrorec.go
@@ -180,7 +185,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
180185 cmd = m.answerJEV(msg)181186 case jevRecalcMsg:182187 cmd = m.recalcAnswered()183- case importedMsg, importTickMsg, exportedMsg:188+ case transfer.ImportedMsg, transfer.TickMsg, exportedMsg:184189 cmd = m.handleTransfer(msg)185190 case macroCallMsg:186191 cmd = m.serveMacro(msg)
@@ -258,7 +263,7 @@ func (m *Model) handleKey(k tea.KeyPressMsg) tea.Cmd {
258263 case modePoint:259264 return m.pointKey(k)260265 case modeMenu:261- return m.overlay.key(m, k)266+ return m.overlay.Key(k)262267 case modePrompt:263268 return m.prompt.key(m, k)264269 case modeError:
@@ -334,12 +339,7 @@ func (m *Model) focus() *sheet.Addr {
334339 }335340 336341 // typed returns the printable text of a key press, if any.337-func typed(k tea.KeyPressMsg) string {338- if k.Mod.Contains(tea.ModCtrl) || k.Mod.Contains(tea.ModAlt) {339- return ""340- }341- return k.Text342-}342+func typed(k tea.KeyPressMsg) string { return lineedit.Typed(k) }343343 344344 func clamp(v, lo, hi int) int {345345 return max(lo, min(v, hi))
M internal/ui/model_test.go +4 -4
M internal/ui/mouse.go +8 -8
M internal/ui/mouse_test.go +4 -4
M internal/ui/names.go +25 -21
@@ -6,6 +6,7 @@ import (
66 tea "charm.land/bubbletea/v2"77 88 "github.com/FelineStateMachine/012/internal/sheet"9+ "github.com/FelineStateMachine/012/internal/ui/picker"910 )1011 1112 // Named ranges, as Sheets' Data > Named ranges: a picker lists them with
@@ -29,25 +30,26 @@ func init() {
2930 3031 // namesPicker is the picker of named ranges with its extra keys.3132 type namesPicker struct {32- *picker33+ m *Model // the model it acts on34+ *picker.Picker3335 sel sheet.Rect // the selection when the picker opened, for "Add a range"3436 msg string // feedback on the last action, e.g. a deletion3537 }3638 3739 // openNames opens the picker; sel is what "Add a range" names.3840 func (m *Model) openNames(sel sheet.Rect) {39- p := newPicker(m, "Named ranges", "Type a name", 60, namesItems(m, sel))40- p.action = "go to"41+ p := m.newPicker("Named ranges", "Type a name", 60, namesItems(m, sel))42+ p.Action = "go to"4143 m.clearSelection()42- m.openOverlay(&namesPicker{picker: p, sel: sel})44+ m.openOverlay(&namesPicker{m: m, Picker: p, sel: sel})4345 }4446 4547 // namesItems lists "Add a range" and then every named range.46-func namesItems(m *Model, sel sheet.Rect) []pickItem {48+func namesItems(m *Model, sel sheet.Rect) []picker.Item {4749 add := "+ Add a range"48- items := []pickItem{{49- title: add, name: len(add), detail: sel.String(), desc: "Name " + sel.String() + ", to use the name in formulas",50- pick: func(m *Model) tea.Cmd {50+ items := []picker.Item{{51+ Title: add, Name: len(add), Detail: sel.String(), Desc: "Name " + sel.String() + ", to use the name in formulas",52+ Pick: func() tea.Cmd {5153 m.closeOverlay()5254 m.defineName(sel, true)5355 return nil
@@ -65,9 +67,9 @@ func namesItems(m *Model, sel sheet.Rect) []pickItem {
6567 case users > 1:6668 desc += ", used in " + strconv.Itoa(users) + " formulas"6769 }68- items = append(items, pickItem{69- title: n.Name, name: len(n.Name), detail: n.Ref(), desc: desc, off: n.Gone(),70- pick: func(m *Model) tea.Cmd {70+ items = append(items, picker.Item{71+ Title: n.Name, Name: len(n.Name), Detail: n.Ref(), Desc: desc, Off: n.Gone(),72+ Pick: func() tea.Cmd {7173 m.closeOverlay()7274 if m.refuseHidden(n.Sheet) {7375 return nil
@@ -83,13 +85,14 @@ func namesItems(m *Model, sel sheet.Rect) []pickItem {
8385 8486 // current is the named range highlighted in the picker, if any.8587 func (p *namesPicker) current(m *Model) (sheet.Name, bool) {86- if p.picker.sel >= len(p.shown) {88+ if p.Picker.Sel >= len(p.Shown()) {8789 return sheet.Name{}, false8890 }89- return m.sheet.LookupName(p.shown[p.picker.sel].item.title)91+ return m.sheet.LookupName(p.Shown()[p.Picker.Sel].Item.Title)9092 }9193 92-func (p *namesPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {94+func (p *namesPicker) Key(k tea.KeyPressMsg) tea.Cmd {95+ m := p.m9396 p.msg = ""9497 switch k.String() {9598 case "f2":
@@ -103,17 +106,18 @@ func (p *namesPicker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {
103106 m.sheet.DeleteName(n.Name)104107 m.changed = true105108 p.msg = "Deleted " + n.Name + "; Ctrl+Z brings it back"106- 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)109+ p.Items = namesItems(m, p.sel)110+ sel := p.Picker.Sel111+ p.Changed()112+ p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)110113 }111114 return nil112115 }113- return p.picker.key(m, k)116+ return p.Picker.Key(k)114117 }115118 116-func (p *namesPicker) status(m *Model) (string, string) {119+func (p *namesPicker) Status() (string, string) {120+ m := p.m117121 keys := m.th.KeyHints("Enter", "go to", "F2", "edit", "Ctrl+D", "delete", "Esc", "close")118122 if _, ok := p.current(m); !ok {119123 keys = m.th.KeyHints("Enter", "add", "Esc", "close")
@@ -121,7 +125,7 @@ func (p *namesPicker) status(m *Model) (string, string) {
121125 if p.msg != "" {122126 return p.msg, keys123127 }124- desc, _ := p.picker.status(m)128+ desc, _ := p.Picker.Status()125129 return desc, keys126130 }127131
M internal/ui/overlay.go +18 -88
@@ -5,64 +5,12 @@ import (
55 66 tea "charm.land/bubbletea/v2"77 "charm.land/lipgloss/v2"8- "github.com/charmbracelet/x/ansi"9-)10-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 int478 48-const (49- mousePress mouseKind = iota50- mouseMotion51- mouseRelease52- mouseWheel9+ "github.com/FelineStateMachine/012/internal/ui/overlay"5310 )5411 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-6412 // openOverlay shows o, replacing any open overlay.65-func (m *Model) openOverlay(o overlay) {13+func (m *Model) openOverlay(o overlay.Overlay) {6614 m.overlay = o6715 m.mode = modeMenu6816 }
@@ -70,7 +18,7 @@ func (m *Model) openOverlay(o overlay) {
7018 // closeOverlay closes the open overlay and returns to READY.7119 func (m *Model) closeOverlay() {7220 m.overlay = nil73- m.line.clear()21+ m.line.Clear()7422 if m.mode == modeMenu {7523 m.mode = modeReady7624 }
@@ -89,29 +37,29 @@ func (m *Model) runFromOverlay(id string) tea.Cmd {
8937 9038 // compositor stacks boxes over the screen in order, each on its own9139 // layer so hit tests find the topmost box.92-func compositor(boxes []box) *lipgloss.Compositor {40+func compositor(boxes []overlay.Box) *lipgloss.Compositor {9341 layers := make([]*lipgloss.Layer, len(boxes))9442 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)43+ layers[i] = lipgloss.NewLayer(strings.Join(b.Lines, "\n")).X(b.X).Y(b.Y).Z(i + 1).ID(b.ID)9644 }9745 return lipgloss.NewCompositor(layers...)9846 }9947 10048 // floating returns the boxes drawn over the screen: the open overlay's,10149 // or the formula suggestions while typing.102-func (m *Model) floating() []box {50+func (m *Model) floating() []overlay.Box {10351 if m.overlay != nil {104- return m.overlay.layout(m)52+ return m.overlay.Layout()10553 }10654 if b, ok := m.entry.assist.box(m); ok {107- return []box{b}55+ return []overlay.Box{b}10856 }10957 return nil11058 }11159 11260 // compose draws the charts floating over the grid, then boxes (the open11361 // overlay or formula suggestions), over the rendered screen.114-func (m *Model) compose(screen string, boxes []box) string {62+func (m *Model) compose(screen string, boxes []overlay.Box) string {11563 c := lipgloss.NewCanvas(m.width, m.height)11664 c.Compose(lipgloss.NewLayer(screen))11765 if charts := m.chartBoxes(); len(charts) > 0 {
@@ -157,38 +105,20 @@ func (m *Model) shellMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
157105 // position under the mouse.158106 func (m *Model) overlayMouse(msg tea.MouseMsg) tea.Cmd {159107 mouse := msg.Mouse()160- e := mouseEvent{button: mouse.Button, x: mouse.X, y: mouse.Y}108+ e := overlay.MouseEvent{Button: mouse.Button, X: mouse.X, Y: mouse.Y}161109 switch msg.(type) {162110 case tea.MouseClickMsg:163- e.kind = mousePress111+ e.Kind = overlay.MousePress164112 case tea.MouseMotionMsg:165- e.kind = mouseMotion113+ e.Kind = overlay.MouseMotion166114 case tea.MouseReleaseMsg:167- e.kind = mouseRelease115+ e.Kind = overlay.MouseRelease168116 case tea.MouseWheelMsg:169- e.kind = mouseWheel117+ e.Kind = overlay.MouseWheel170118 }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.Y119+ if h := compositor(m.overlay.Layout()).Hit(e.X, e.Y); !h.Empty() {120+ e.Box = h.ID()121+ e.Col, e.Row = e.X-h.Bounds().Min.X, e.Y-h.Bounds().Min.Y174122 }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)123+ return m.overlay.Mouse(e)194124 }
A internal/ui/overlay/overlay.go +116 -0
@@ -0,0 +1,116 @@
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+ "strings"12+13+ tea "charm.land/bubbletea/v2"14+ "github.com/charmbracelet/x/ansi"15+)16+17+// The control panel's rows that overlays anchor to.18+const (19+ MenuLine = 0 // the menu bar; dropdowns and pickers open under it20+ ContextLine = 2 // prompts and bars; completions open under it21+)22+23+// SearchPrompt starts a search field, in pickers and bars.24+const SearchPrompt = " › "25+26+// KeyFor makes a key press from a keystroke like "alt+c", for chips that27+// stand for a key when clicked.28+func KeyFor(s string) tea.KeyPressMsg {29+ k := tea.KeyPressMsg{}30+ parts := strings.Split(s, "+")31+ for _, p := range parts[:len(parts)-1] {32+ if p == "alt" {33+ k.Mod |= tea.ModAlt34+ }35+ }36+ k.Code = rune(parts[len(parts)-1][0])37+ return k38+}39+40+// Overlay is a floating box drawn over the grid: a dropdown or context41+// menu, the command palette or a dialog. While one is open it takes every42+// key and mouse event, and Esc closes one level. Overlays are composited43+// on top of the finished screen, so the grid underneath never moves.44+type Overlay interface {45+ // Indicator is the mode shown at the top right, e.g. "MENU".46+ Indicator() string47+ // Layout places the overlay's boxes on the screen, bottom first.48+ Layout() []Box49+ Key(k tea.KeyPressMsg) tea.Cmd50+ Mouse(e MouseEvent) tea.Cmd51+ // Status is what the status line says while the overlay is open: what52+ // the highlighted item does, and the keys that apply.53+ Status() (desc, keys string)54+}55+56+// Text is an overlay with a text input, which gets the terminal cursor.57+// The text is edited in the model's shared edit line, like any other58+// entry.59+type Text interface {60+ Overlay61+ Cursor() (x, y int)62+ Changed() // the text changed63+}64+65+// Liner is an overlay drawn on the context line, such as the find bar.66+type Liner interface {67+ ContextLine() (left, right string)68+}69+70+// Box is a rectangle of styled lines at a screen position. Every line has71+// the same display width.72+type Box struct {73+ ID string // identifies the box in mouse hit tests74+ X, Y int75+ Lines []string76+}77+78+func (b Box) Width() int { return ansi.StringWidth(b.Lines[0]) }79+func (b Box) Height() int { return len(b.Lines) }80+81+// MouseKind is what a mouse event is.82+type MouseKind int83+84+const (85+ MousePress MouseKind = iota86+ MouseMotion87+ MouseRelease88+ MouseWheel89+)90+91+// MouseEvent is a mouse event with what's under it already worked out.92+type MouseEvent struct {93+ Kind MouseKind94+ Button tea.MouseButton95+ X, Y int96+ Box string // ID of the topmost box under the mouse, "" for none97+ Col, Row int // position inside that box98+}99+100+// List is the highlighted row and scroll position of a list in a box.101+type List struct {102+ Sel, Top int103+}104+105+// Move highlights the row d steps away, wrapping around the ends.106+func (l *List) Move(d, n int) {107+ if n > 0 {108+ l.Sel = ((l.Sel+d)%n + n) % n109+ }110+}111+112+// Show scrolls so the highlighted row is among the visible rows.113+func (l *List) Show(rows int) {114+ l.Top = max(l.Sel-rows+1, min(l.Top, l.Sel))115+ l.Top = max(l.Top, 0)116+}
M internal/ui/palette.go +7 -343
@@ -2,16 +2,11 @@ package ui
22 33 import (44 "cmp"5- "maps"65 "slices"7- "strconv"8- "strings"96 107 tea "charm.land/bubbletea/v2"11- "github.com/charmbracelet/x/ansi"12- "github.com/sahilm/fuzzy"138 14- "github.com/FelineStateMachine/012/internal/ui/theme"9+ "github.com/FelineStateMachine/012/internal/ui/picker"1510 )1611 1712 // The command palette ("Search the menus" in Sheets, Alt+/) finds any
@@ -20,7 +15,7 @@ import (
2015 2116 func init() {2217 register(&command{id: "palette", macro: macroNever, title: "Search the menus", desc: "Find and run any command by name", run: func(m *Model) tea.Cmd {23- m.openOverlay(newPicker(m, "Search the menus", "Type a command, e.g. save or width", 76, paletteItems(m)))18+ m.openOverlay(m.newPicker("Search the menus", "Type a command, e.g. save or width", 76, paletteItems(m)))2419 return nil2520 }})2621 for _, k := range []string{"alt+/", "ctrl+k", "ctrl+shift+p"} {
@@ -30,8 +25,8 @@ func init() {
3025 3126 // paletteItems lists every command: those in menus first, in menu order3227 // with their menu path, then the rest by title.33-func paletteItems(m *Model) []pickItem {34- var items []pickItem28+func paletteItems(m *Model) []picker.Item {29+ var items []picker.Item3530 seen := map[string]bool{"palette": true}3631 add := func(id, path string) {3732 if seen[id] {
@@ -39,9 +34,9 @@ func paletteItems(m *Model) []pickItem {
3934 }4035 seen[id] = true4136 c := commands[id]42- items = append(items, pickItem{43- title: c.title, name: len(c.title), detail: path, key: m.shortcut(id), desc: c.desc,44- off: !c.available(m), pick: func(m *Model) tea.Cmd { return m.runFromOverlay(id) },37+ items = append(items, picker.Item{38+ Title: c.title, Name: len(c.title), Detail: path, Key: m.shortcut(id), Desc: c.desc,39+ Off: !c.available(m), Pick: func() tea.Cmd { return m.runFromOverlay(id) },4540 })4641 }4742 var walk func(items []menuItem, path string)
@@ -69,334 +64,3 @@ func paletteItems(m *Model) []pickItem {
6964 }7065 return items7166 }72-73-// pickItem is one row of a picker.74-type pickItem struct {75- title string // e.g. "Save as"76- name int // bytes of title that are searched, e.g. just "SUM" of "SUM(value, ...)"77- detail string // a dimmed second column, also searched, e.g. the menu path78- key string // shortcut, shown as a key chip79- desc string // what it does, for the status line80- off bool // unavailable right now81- pick func(m *Model) tea.Cmd82-}83-84-// haystack is what a search matches against.85-func (it pickItem) haystack() string { return it.title[:it.name] + " " + it.detail }86-87-// pickMatch is an item that matches the search, with the matched byte88-// offsets in its title and detail.89-type pickMatch struct {90- item *pickItem91- inTitle, inDesc []int92-}93-94-// picker is a searchable list in a box, in the manner of fzf: a search95-// field on top, results below with matched characters highlighted, and96-// the highlighted result's description on the status line. The search is97-// edited in Model.line with the usual line-editing keys.98-type picker struct {99- title string100- placeholder string101- maxW int102- action string // what Enter does, for the key hints103- items []pickItem104- shown []pickMatch105- list106-107- // enter, when set, gets the first look at Enter with the search text,108- // e.g. to take a typed path; it reports whether it handled it.109- enter func(m *Model, query string) (tea.Cmd, bool)110-111- // answers marks a picker that is the question of a command that112- // changes the workbook: a recording keeps the title picked as the113- // command's answer, and scripts answer it with a title.114- answers bool115-}116-117-const (118- pickerID = "picker"119- searchPrompt = " › "120-)121-122-func newPicker(m *Model, title, placeholder string, maxW int, items []pickItem) *picker {123- p := &picker{title: title, placeholder: placeholder, maxW: maxW, items: items}124- m.line.clear()125- p.changed(m)126- return p127-}128-129-func (p *picker) indicator() string { return "MENU" }130-131-// changed filters the items by the search. Best matches come first; ties132-// keep menu order.133-func (p *picker) changed(m *Model) {134- p.sel, p.top = 0, 0135- p.shown = p.shown[:0]136- q := strings.TrimSpace(m.line.text())137- if q == "" {138- for i := range p.items {139- p.shown = append(p.shown, pickMatch{item: &p.items[i]})140- }141- return142- }143- // Rank title and path matches together by fuzzy score, so a tight144- // match like "file" on the File menu beats letters scattered across a145- // title. Title matches get a small bonus and win ties. Titles with a146- // word starting with the query ("col" in Column width) come before147- // the rest, such as "Command line", which only spells it across words.148- names, hay := make([]string, len(p.items)), make([]string, len(p.items))149- for i, it := range p.items {150- names[i], hay[i] = it.title[:it.name], it.haystack()151- }152- const titleBonus = 10153- type ranked struct {154- pm pickMatch155- score int156- order int157- prefix bool // a word of the title starts with the query158- }159- best := map[int]*ranked{}160- for _, mt := range fuzzy.FindNoSort(q, names) {161- best[mt.Index] = &ranked{pickMatch{item: &p.items[mt.Index], inTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index, false}162- }163- for _, mt := range fuzzy.FindNoSort(q, hay) {164- if r, ok := best[mt.Index]; ok && r.score >= mt.Score {165- continue166- }167- it := &p.items[mt.Index]168- pm := pickMatch{item: it}169- for _, i := range mt.MatchedIndexes {170- switch {171- case i < it.name:172- pm.inTitle = append(pm.inTitle, i)173- case i >= it.name+2:174- pm.inDesc = append(pm.inDesc, i-it.name-2)175- }176- }177- best[mt.Index] = &ranked{pm, mt.Score, mt.Index, false}178- }179- for i, r := range best {180- r.prefix = wordPrefix(names[i], q)181- }182- all := slices.Collect(maps.Values(best))183- slices.SortFunc(all, func(a, b *ranked) int {184- if a.prefix != b.prefix {185- if a.prefix {186- return -1187- }188- return 1189- }190- if c := cmp.Compare(b.score, a.score); c != 0 {191- return c192- }193- return cmp.Compare(a.order, b.order)194- })195- for _, r := range all {196- p.shown = append(p.shown, r.pm)197- }198-}199-200-// wordPrefix reports whether a word of title starts with q, ignoring case.201-func wordPrefix(title, q string) bool {202- q = strings.ToLower(q)203- for w := range strings.FieldsSeq(strings.ToLower(title)) {204- if strings.HasPrefix(w, q) {205- return true206- }207- }208- return false209-}210-211-func (p *picker) key(m *Model, k tea.KeyPressMsg) tea.Cmd {212- rows := p.rows(m)213- switch k.String() {214- case "up", "ctrl+p", "shift+tab":215- p.move(-1, len(p.shown))216- case "down", "ctrl+n", "tab":217- p.move(1, len(p.shown))218- case "pgup":219- p.sel = max(p.sel-rows, 0)220- case "pgdown":221- p.sel = max(min(p.sel+rows, len(p.shown)-1), 0)222- case "enter":223- return p.pick(m)224- case "esc":225- p.close(m)226- default:227- before := m.line.text()228- m.line.key(k)229- if m.line.text() != before {230- p.changed(m)231- }232- }233- return nil234-}235-236-func (p *picker) pick(m *Model) tea.Cmd {237- if p.enter != nil {238- if cmd, ok := p.enter(m, strings.TrimSpace(m.line.text())); ok {239- return cmd240- }241- }242- if p.sel >= len(p.shown) || p.shown[p.sel].item.off {243- return nil244- }245- it := p.shown[p.sel].item246- cmd := it.pick(m)247- if p.answers {248- m.recordAnswer(it.title, false)249- }250- return cmd251-}252-253-// close closes the picker without picking, which cancels the question254-// it asks.255-func (p *picker) close(m *Model) {256- m.closeOverlay()257- if p.answers {258- m.recordAnswer("", true)259- }260-}261-262-// answer picks the item titled title (in any case), as a script answers263-// the picker's question, and reports whether there was one.264-func (p *picker) answer(m *Model, title string) (tea.Cmd, bool) {265- for i := range p.items {266- if it := &p.items[i]; strings.EqualFold(it.title, title) && !it.off {267- return it.pick(m), true268- }269- }270- return nil, false271-}272-273-// Rows of the box: the top border, the search field, a separator, then274-// the results.275-const pickerFirstRow = 3276-277-func (p *picker) mouse(m *Model, e mouseEvent) tea.Cmd {278- if e.box != pickerID {279- if e.kind == mousePress {280- p.close(m)281- }282- return nil283- }284- i := p.top + e.row - pickerFirstRow285- 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 = i295- return p.pick(m)296- }297- return nil298-}299-300-func (p *picker) status(m *Model) (string, string) {301- keys := m.th.KeyHints("Up/Down", "move", "Enter", cmp.Or(p.action, "run"), "Esc", "close")302- if p.sel >= len(p.shown) {303- return "", keys304- }305- it := p.shown[p.sel].item306- if it.off {307- return it.desc + " (not available now)", keys308- }309- return it.desc, keys310-}311-312-// rows is how many results show. The box shrinks from the bottom as the313-// search narrows, so the search field never moves, and it leaves the314-// status line visible.315-func (p *picker) rows(m *Model) int {316- return max(min(len(p.shown), 12, m.height-1-menuLine-1-4), 1)317-}318-319-func (p *picker) box(m *Model) (x, y, inner int) {320- tw, dw, kw := 0, 0, 0321- for _, it := range p.items {322- tw = max(tw, ansi.StringWidth(it.title))323- dw = max(dw, ansi.StringWidth(it.detail))324- kw = max(kw, ansi.StringWidth(it.key)+2)325- }326- inner = min(1+tw+3+dw+3+kw+1, p.maxW, m.width-2)327- inner = max(inner, min(40, m.width-2))328- return (m.width - inner - 2) / 2, menuLine + 1, inner329-}330-331-func (p *picker) cursor(m *Model) (int, int) {332- x, y, _ := p.box(m)333- return x + 1 + ansi.StringWidth(searchPrompt) + ansi.StringWidth(m.line.head()), y + 1334-}335-336-func (p *picker) layout(m *Model) []box {337- x, y, inner := p.box(m)338- rows := p.rows(m)339- if len(p.shown) > 0 {340- p.show(rows)341- }342- input := m.th.Title.Render(searchPrompt) + m.line.text()343- if len(m.line.buf) == 0 {344- input += m.th.Muted.Render(p.placeholder)345- }346- lines := []string{theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}347- lines = append(lines, p.resultRows(m, inner, rows)...)348- 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)}}350-}351-352-// resultRows lays out the visible results in columns: title, detail and353-// the shortcut chip at the right. The detail column is dropped when the354-// box is too narrow for it.355-func (p *picker) resultRows(m *Model, inner, rows int) []string {356- tw, kw := 0, 0357- for _, it := range p.items {358- tw = max(tw, ansi.StringWidth(it.title))359- if it.key != "" {360- kw = max(kw, ansi.StringWidth(it.key)+2)361- }362- }363- tw = min(tw, inner*3/5)364- dw := inner - 1 - tw - 3 - kw - 2365- out := make([]string, 0, rows)366- for r := range rows {367- i := p.top + r368- if i >= len(p.shown) {369- text := ""370- if r == 0 {371- text = m.th.Muted.Render(" No matches")372- }373- out = append(out, theme.Cells(m.th.MenuBar, text, inner))374- continue375- }376- pm := p.shown[i]377- base, dim, hl := m.th.MenuBar, m.th.Muted, m.th.Match378- switch {379- case i == p.sel:380- base, dim, hl = m.th.MenuSelected, m.th.MenuSelected, m.th.MatchSelected381- case pm.item.off:382- base, dim, hl = m.th.Disabled, m.th.Disabled, m.th.Disabled383- }384- row := base.Render(" ") + theme.HighlightMatches(pm.item.title, pm.inTitle, tw, base, hl)385- if dw >= 8 {386- row += base.Render(" ") + theme.HighlightMatches(pm.item.detail, pm.inDesc, dw, dim, hl)387- }388- k := ""389- switch {390- case pm.item.key == "":391- case i == p.sel:392- k = base.Render(" " + pm.item.key + " ")393- case pm.item.off:394- k = m.th.Disabled.Render(" " + pm.item.key + " ")395- default:396- k = m.th.Chip(pm.item.key)397- }398- row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")399- out = append(out, ansi.Truncate(row, inner, ""))400- }401- return out402-}
M internal/ui/palette_test.go +44 -32
@@ -6,11 +6,13 @@ import (
66 "testing"77 88 tea "charm.land/bubbletea/v2"9+10+ "github.com/FelineStateMachine/012/internal/ui/picker"911 )1012 11-func openPicker(t *testing.T, m *Model) *picker {13+func openPicker(t *testing.T, m *Model) *picker.Picker {1214 t.Helper()13- p, ok := m.overlay.(*picker)15+ p, ok := m.overlay.(*picker.Picker)1416 if !ok {1517 t.Fatalf("no picker open (mode %v, overlay %T)", m.mode, m.overlay)1618 }
@@ -20,18 +22,18 @@ func openPicker(t *testing.T, m *Model) *picker {
2022 func selected(t *testing.T, m *Model) string {2123 t.Helper()2224 p := openPicker(t, m)23- if p.sel >= len(p.shown) {25+ if p.Sel >= len(p.Shown()) {2426 return ""2527 }26- return p.shown[p.sel].item.title28+ return p.Shown()[p.Sel].Item.Title2729 }2830 2931 func TestPaletteKeys(t *testing.T) {3032 for _, key := range []string{"<ctrl+k>", "<alt+/>", "<ctrl+shift+p>"} {3133 m := newModel()3234 press(t, m, key)33- if p := openPicker(t, m); len(p.shown) != len(p.items) || !strings.HasSuffix(line(m, menuLine), "MENU") {34- t.Errorf("%s: %d of %d shown", key, len(p.shown), len(p.items))35+ if p := openPicker(t, m); len(p.Shown()) != len(p.Items) || !strings.HasSuffix(line(m, menuLine), "MENU") {36+ t.Errorf("%s: %d of %d shown", key, len(p.Shown()), len(p.Items))3537 }3638 }3739 }
@@ -43,15 +45,15 @@ func TestPaletteSearchRunsCommand(t *testing.T) {
4345 if got := selected(t, m); got != "Column width" {4446 t.Fatalf("best match %q", got)4547 }46- if len(p.shown[0].inTitle) != len("colwid") {47- t.Errorf("matched %v", p.shown[0].inTitle)48+ if len(p.Shown()[0].InTitle) != len("colwid") {49+ t.Errorf("matched %v", p.Shown()[0].InTitle)4850 }4951 if !strings.Contains(screen(m), "Column width") || !strings.Contains(screen(m), "Format") {5052 t.Errorf("result should show its menu path:\n%s", screen(m))5153 }5254 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)55+ if b := p.Layout()[0]; y != b.Y+1 || x != b.X+1+3+len("colwid") {56+ t.Errorf("cursor at %d,%d, box at %d,%d", x, y, b.X, b.Y)5557 }5658 press(t, m, "<enter>")5759 if m.overlay != nil || m.mode != modePrompt || m.prompt.kind != promptWidth {
@@ -65,8 +67,8 @@ func TestPaletteMatchesMenuPath(t *testing.T) {
6567 // File menu command through its path.6668 press(t, m, "<ctrl+k>", "file")6769 var got []string68- for _, pm := range openPicker(t, m).shown {69- got = append(got, pm.item.title)70+ for _, pm := range openPicker(t, m).Shown() {71+ got = append(got, pm.Item.Title)7072 }7173 if got[0] != "Open config file" {7274 t.Errorf("first match for file: %q", got[0])
@@ -81,8 +83,8 @@ func TestPaletteMatchesMenuPath(t *testing.T) {
8183 }8284 // Up from the top wraps to the bottom.8385 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))86+ if p := openPicker(t, m); p.Sel != len(p.Shown())-1 {87+ t.Errorf("wrapped to %d of %d", p.Sel, len(p.Shown()))8688 }8789 }8890
@@ -91,19 +93,29 @@ func TestPaletteTitleMatchesFirst(t *testing.T) {
9193 // "sel" also matches "Save" through the l of its File path, but9294 // titles that match on their own rank first.9395 press(t, m, "<ctrl+k>", "sel")94- shown := openPicker(t, m).shown96+ shown := openPicker(t, m).Shown()9597 seenPathOnly := false9698 for _, pm := range shown {97- inTitle := strings.Contains(strings.ToLower(pm.item.title), "sel")99+ inTitle := strings.Contains(strings.ToLower(pm.Item.Title), "sel")98100 // Titles matching letter by letter ("Move sheet left") are99101 // title matches too, just not literal ones.100- pathOnly := len(pm.inTitle) < len("sel")101- if pathOnly && pm.item.title != "Select all" {102+ pathOnly := len(pm.InTitle) < len("sel")103+ if pathOnly && pm.Item.Title != "Select all" {102104 seenPathOnly = true103105 } else if seenPathOnly && inTitle {104- t.Errorf("%q ranked after a path-only match", pm.item.title)106+ t.Errorf("%q ranked after a path-only match", pm.Item.Title)107+ }108+ }109+}110+111+// wordPrefix reports whether a word of title starts with q, ignoring case.112+func wordPrefix(title, q string) bool {113+ for w := range strings.FieldsSeq(strings.ToLower(title)) {114+ if strings.HasPrefix(w, strings.ToLower(q)) {115+ return true105116 }106117 }118+ return false107119 }108120 109121 func TestPaletteWordPrefixFirst(t *testing.T) {
@@ -111,18 +123,18 @@ func TestPaletteWordPrefixFirst(t *testing.T) {
111123 // "col" starts a word in the column commands; "Command line" only112124 // spells it across two words, so it ranks after them.113125 press(t, m, "<ctrl+k>", "col")114- shown := openPicker(t, m).shown126+ shown := openPicker(t, m).Shown()115127 seenOther := false116128 for _, pm := range shown {117- prefix := wordPrefix(pm.item.title[:pm.item.name], "col")129+ prefix := wordPrefix(pm.Item.Title[:pm.Item.Name], "col")118130 if !prefix {119131 seenOther = true120132 } else if seenOther {121- t.Errorf("%q, a word-prefix match, ranked after other matches", pm.item.title)133+ t.Errorf("%q, a word-prefix match, ranked after other matches", pm.Item.Title)122134 }123135 }124- if len(shown) == 0 || !wordPrefix(shown[0].item.title, "col") {125- t.Errorf("first match for col: %+v", shown[0].item.title)136+ if len(shown) == 0 || !wordPrefix(shown[0].Item.Title, "col") {137+ t.Errorf("first match for col: %+v", shown[0].Item.Title)126138 }127139 }128140
@@ -135,7 +147,7 @@ func TestPaletteNoMatchAndEsc(t *testing.T) {
135147 press(t, m, "<enter>")136148 openPicker(t, m)137149 press(t, m, "<backspace>", "<backspace>", "<backspace>", "<backspace>")138- if p := openPicker(t, m); len(p.shown) != len(p.items) {150+ if p := openPicker(t, m); len(p.Shown()) != len(p.Items) {139151 t.Error("clearing the search did not show everything")140152 }141153 send(m, tea.PasteMsg{Content: "goto"})
@@ -143,8 +155,8 @@ func TestPaletteNoMatchAndEsc(t *testing.T) {
143155 t.Errorf("paste searched for %q", selected(t, m))144156 }145157 press(t, m, "<esc>")146- if m.overlay != nil || m.mode != modeReady || len(m.line.buf) != 0 {147- t.Errorf("Esc: mode %v buf %q", m.mode, m.line.text())158+ if m.overlay != nil || m.mode != modeReady || len(m.line.Buf) != 0 {159+ t.Errorf("Esc: mode %v buf %q", m.mode, m.line.Text())148160 }149161 }150162
@@ -168,18 +180,18 @@ func TestPaletteMouse(t *testing.T) {
168180 m := newModel()169181 press(t, m, "<ctrl+k>", "go")170182 p := openPicker(t, m)171- b := p.layout(m)[0]183+ b := p.Layout()[0]172184 var row int173- for i, pm := range p.shown {174- if pm.item.title == "Go to" {185+ for i, pm := range p.Shown() {186+ if pm.Item.Title == "Go to" {175187 row = i176188 }177189 }178- mouseAt(m, tea.MouseMotionMsg{X: b.x + 4, Y: b.y + pickerFirstRow + row})190+ mouseAt(m, tea.MouseMotionMsg{X: b.X + 4, Y: b.Y + picker.FirstRow + row})179191 if selected(t, m) != "Go to" {180192 t.Fatalf("hover selected %q", selected(t, m))181193 }182- leftClick(m, b.x+4, b.y+pickerFirstRow+row)194+ leftClick(m, b.X+4, b.Y+picker.FirstRow+row)183195 if m.mode != modePrompt || m.prompt.label != "Go to:" {184196 t.Errorf("click ran: mode %v", m.mode)185197 }
M internal/ui/panel.go +26 -31
@@ -14,6 +14,8 @@ import (
1414 1515 "github.com/FelineStateMachine/012/internal/sheet"1616 "github.com/FelineStateMachine/012/internal/telemetry"17+ "github.com/FelineStateMachine/012/internal/ui/overlay"18+ "github.com/FelineStateMachine/012/internal/ui/tabstrip"1719 "github.com/FelineStateMachine/012/internal/ui/theme"1820 )1921
@@ -55,7 +57,7 @@ func (m *Model) View() tea.View {
5557 // Terminals that support it (OSC 9;4) show activity in the tab.5658 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, 0)5759 }58- if bar := m.xfer.progressBar(); bar != nil {60+ if bar := m.xfer.ProgressBar(); bar != nil {5961 v.ProgressBar = bar6062 }6163 if m.changed {
@@ -119,8 +121,8 @@ func (m *Model) timeFrame(start time.Time) {
119121 }120122 121123 func (m *Model) displayName() string {122- if m.filename == "" && m.xfer.source != "" {123- return filepath.Base(m.xfer.source)124+ if m.filename == "" && m.xfer.Source != "" {125+ return filepath.Base(m.xfer.Source)124126 }125127 if m.filename == "" {126128 return "untitled"
@@ -131,12 +133,12 @@ func (m *Model) displayName() string {
131133 // indicator is the mode shown at the top right.132134 func (m *Model) indicator() string {133135 switch {134- case m.xfer.job != nil:136+ case m.xfer.Busy():135137 return "WAIT"136138 case m.macros.run != nil:137139 return "CMD" // 1-2-3's indicator while a macro runs138140 case m.overlay != nil:139- return m.overlay.indicator()141+ return m.overlay.Indicator()140142 case m.mode == modePrompt:141143 return m.prompt.indicator142144 case m.vimActive() && m.visual() != visualNone:
@@ -179,7 +181,7 @@ func (m *Model) formulaBar() string {
179181 box := m.th.Header.Render(theme.PadRight(" "+ansi.Truncate(name, nameBoxW-1, "…"), nameBoxW)) + " "180182 switch m.mode {181183 case modeEnter, modeEdit:182- return box + m.line.text()184+ return box + m.line.Text()183185 case modePoint:184186 return box + m.entry.prefix + m.th.Selection.Render(m.pointRef()) + m.entry.suffix185187 }
@@ -194,8 +196,8 @@ func (m *Model) formulaBar() string {
194196 func (m *Model) contextLineText() string {195197 var left, right string196198 switch {197- case m.xfer.job != nil:198- left = m.xfer.line(&m.th)199+ case m.xfer.Busy():200+ left = m.xfer.Line(&m.th)199201 case m.mouse.drag == dragResize:200202 left = m.th.Key.Render("Column "+sheet.ColName(m.mouse.resizeCol)) + m.th.Muted.Render(" width ") +201203 strconv.Itoa(m.sheet.ColWidth(m.mouse.resizeCol)) + m.th.Muted.Render(" double-click the border to fit")
@@ -220,15 +222,8 @@ func (m *Model) contextLineText() string {
220222 left = m.recordingLine()221223 }222224 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)225+ if o, ok := m.overlay.(overlay.Liner); ok {226+ left, right = o.ContextLine()232227 }233228 case m.mode == modePrompt:234229 left, right = m.prompt.line(m)
@@ -238,9 +233,9 @@ func (m *Model) contextLineText() string {
238233 if left, right, ok = signatureLine(&m.th, m.width, prefix, len(prefix), m.th.KeyHints("Shift+arrows", "range", "Esc", "back")); !ok {239234 left = m.th.KeyHints("Arrows", "pick a cell", "Shift+arrows", "pick a range", "Enter", "accept", "Esc", "back")240235 }241- case (m.mode == modeEnter || m.mode == modeEdit) && m.line.isFormula() && m.line.inFunction():242- left, right, _ = signatureLine(&m.th, m.width, m.line.buf, m.line.pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))243- case m.mode == modeEnter && m.line.isFormula():236+ case (m.mode == modeEnter || m.mode == modeEdit) && m.line.IsFormula() && inFunction(&m.line):237+ left, right, _ = signatureLine(&m.th, m.width, m.line.Buf, m.line.Pos, m.th.KeyHints("Enter", "accept", "Esc", "cancel"))238+ case m.mode == modeEnter && m.line.IsFormula():244239 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "pick cells after an operator", "Esc", "cancel")245240 case m.mode == modeEnter:246241 left = m.th.KeyHints("Enter", "accept", "Tab", "accept and go right", "Arrows", "accept and move", "Esc", "cancel")
@@ -264,13 +259,13 @@ func (m *Model) spread(left, right string) string {
264259 // while typing an entry, on the context line in a text prompt, or in an265260 // overlay's search field.266261 func (m *Model) cursorPos() (x, y int, ok bool) {267- if o, isText := m.overlay.(textOverlay); isText {268- x, y = o.cursor(m)262+ if o, isText := m.overlay.(overlay.Text); isText {263+ x, y = o.Cursor()269264 return x, y, true270265 }271266 switch {272267 case m.mode == modeEnter, m.mode == modeEdit:273- return formulaBarTextX() + ansi.StringWidth(m.line.head()), formulaLine, true268+ return formulaBarTextX() + ansi.StringWidth(m.line.Head()), formulaLine, true274269 case m.mode == modePrompt && !m.pointing():275270 return ansi.StringWidth(m.prompt.prefix() + m.prompt.head(m)), contextLine, true276271 }
@@ -287,12 +282,12 @@ func (m *Model) statusLine() string {
287282 // statistics or the ways in to everything else on the right. While a288283 // menu, picker or suggestion list is open, it says what the highlighted289284 // item does instead.290-func (m *Model) statusLayout() (string, []tabSpan) {285+func (m *Model) statusLayout() (string, []tabstrip.Span) {291286 if m.mode == modeError {292287 return m.th.Error.Render(m.errMsg) + m.th.Muted.Render(" press any key"), nil293288 }294- if m.xfer.job != nil {295- return m.spread(m.xfer.status(&m.th, m.width)), nil289+ if m.xfer.Busy() {290+ return m.spread(m.xfer.Status(&m.th, m.width)), nil296291 }297292 if line, ok := m.floatingStatus(); ok {298293 return line, nil
@@ -305,7 +300,7 @@ func (m *Model) statusLayout() (string, []tabSpan) {
305300 func (m *Model) floatingStatus() (string, bool) {306301 desc, keys, floating := m.entry.assist.status(m)307302 if m.overlay != nil {308- desc, keys = m.overlay.status(m)303+ desc, keys = m.overlay.Status()309304 floating = true310305 }311306 if !floating || desc == "" && keys == "" {
@@ -321,12 +316,12 @@ func (m *Model) floatingStatus() (string, bool) {
321316 // out, the right side gives up detail first, then the file name, then322317 // tabs scroll: first every tab is tried, then half the line of them, then323318 // just the one shown.324-func (m *Model) fileStatus() (string, []tabSpan) {319+func (m *Model) fileStatus() (string, []tabstrip.Span) {325320 v := m.tabView()326321 state := m.statusState()327322 infos := []string{m.displayName() + state, strings.TrimPrefix(state, " ")}328323 rights := m.statusRights()329- for _, need := range []int{v.fullWidth(), min(v.fullWidth(), m.width/2), v.minWidth()} {324+ for _, need := range []int{v.FullWidth(), min(v.FullWidth(), m.width/2), v.MinWidth()} {330325 for _, info := range infos {331326 if info != "" {332327 info = m.th.FrozenLine.Render(" │ ") + info // like a tmux pane border
@@ -341,7 +336,7 @@ func (m *Model) fileStatus() (string, []tabSpan) {
341336 342337 // statusFits lays out the status line with info after the tabs and the343338 // most detailed of rights that leaves the tabs need columns.344-func (m *Model) statusFits(v tabView, need int, info string, rights []string) (string, []tabSpan, bool) {339+func (m *Model) statusFits(v tabstrip.View, need int, info string, rights []string) (string, []tabstrip.Span, bool) {345340 for _, right := range rights {346341 room := m.width - ansi.StringWidth(info)347342 if right != "" {
@@ -350,7 +345,7 @@ func (m *Model) statusFits(v tabView, need int, info string, rights []string) (s
350345 if room < need && (right != "" || info != "") {351346 continue352347 }353- tabs, spans := m.tabs.layout(&m.th, v, room)348+ tabs, spans := m.tabs.Layout(&m.th, v, room)354349 left := tabs + info355350 gap := max(m.width-ansi.StringWidth(left)-ansi.StringWidth(right), 1)356351 return left + strings.Repeat(" ", gap) + right, spans, true
A internal/ui/picker/picker.go +387 -0
@@ -0,0 +1,387 @@
1+// Package picker is the searchable list behind the command palette and2+// every other picker of the UI (functions, sheets, named ranges, macros,3+// themes, files to import): a search field on top, results below with4+// matched characters highlighted, in the manner of fzf. It knows the UI5+// only through Host.6+package picker7+8+import (9+ "cmp"10+ "maps"11+ "slices"12+ "strconv"13+ "strings"14+15+ tea "charm.land/bubbletea/v2"16+ "github.com/charmbracelet/x/ansi"17+ "github.com/sahilm/fuzzy"18+19+ "github.com/FelineStateMachine/012/internal/ui/lineedit"20+ "github.com/FelineStateMachine/012/internal/ui/overlay"21+ "github.com/FelineStateMachine/012/internal/ui/theme"22+)23+24+// Host is what a picker needs of the UI.25+type Host interface {26+ Theme() *theme.Theme27+ Size() (width, height int)28+ // Line is the shared edit line the search is typed in.29+ Line() *lineedit.Line30+ // Close closes the open overlay.31+ Close()32+ // RecordAnswer tells a macro recording the answer to the question of33+ // the command that opened the picker, or that it was cancelled.34+ RecordAnswer(answer string, cancelled bool)35+}36+37+// Item is one row of a picker.38+type Item struct {39+ Title string // e.g. "Save as"40+ Name int // bytes of Title that are searched, e.g. just "SUM" of "SUM(value, ...)"41+ Detail string // a dimmed second column, also searched, e.g. the menu path42+ Key string // shortcut, shown as a key chip43+ Desc string // what it does, for the status line44+ Off bool // unavailable right now45+ Pick func() tea.Cmd46+}47+48+// haystack is what a search matches against.49+func (it Item) haystack() string { return it.Title[:it.Name] + " " + it.Detail }50+51+// Match is an item that matches the search, with the matched byte52+// offsets in its title and detail.53+type Match struct {54+ Item *Item55+ InTitle, InDetail []int56+}57+58+// Picker is a searchable list in a box: a search field on top, results59+// below with matched characters highlighted, and the highlighted result's60+// description on the status line. The search is edited in the host's61+// edit line with the usual line-editing keys.62+type Picker struct {63+ h Host64+ title string65+ placeholder string66+ maxW int67+ shown []Match68+ overlay.List69+70+ // Action is what Enter does, for the key hints ("run" by default).71+ Action string72+ Items []Item73+74+ // Enter, when set, gets the first look at Enter with the search text,75+ // e.g. to take a typed path; it reports whether it handled it.76+ Enter func(query string) (tea.Cmd, bool)77+78+ // Answers marks a picker that is the question of a command that79+ // changes the workbook: a recording keeps the title picked as the80+ // command's answer, and scripts answer it with a title.81+ Answers bool82+}83+84+// ID identifies the picker's box in mouse events.85+const ID = "picker"86+87+// New returns a picker of items titled title, at most maxW columns wide88+// inside its frame, with the edit line cleared for the search.89+func New(h Host, title, placeholder string, maxW int, items []Item) *Picker {90+ p := &Picker{h: h, title: title, placeholder: placeholder, maxW: maxW, Items: items}91+ h.Line().Clear()92+ p.Changed()93+ return p94+}95+96+// Title is the picker's title, on its frame.97+func (p *Picker) Title() string { return p.title }98+99+// Shown are the items that match the search, best first.100+func (p *Picker) Shown() []Match { return p.shown }101+102+// Selected is the highlighted item, or nil when nothing matches.103+func (p *Picker) Selected() *Item {104+ if p.Sel >= len(p.shown) {105+ return nil106+ }107+ return p.shown[p.Sel].Item108+}109+110+func (p *Picker) Indicator() string { return "MENU" }111+112+// Changed filters the items by the search. Best matches come first; ties113+// keep menu order.114+func (p *Picker) Changed() {115+ p.Sel, p.Top = 0, 0116+ p.shown = p.shown[:0]117+ q := strings.TrimSpace(p.h.Line().Text())118+ if q == "" {119+ for i := range p.Items {120+ p.shown = append(p.shown, Match{Item: &p.Items[i]})121+ }122+ return123+ }124+ // Rank title and path matches together by fuzzy score, so a tight125+ // match like "file" on the File menu beats letters scattered across a126+ // title. Title matches get a small bonus and win ties. Titles with a127+ // word starting with the query ("col" in Column width) come before128+ // the rest, such as "Command line", which only spells it across words.129+ names, hay := make([]string, len(p.Items)), make([]string, len(p.Items))130+ for i, it := range p.Items {131+ names[i], hay[i] = it.Title[:it.Name], it.haystack()132+ }133+ const titleBonus = 10134+ type ranked struct {135+ pm Match136+ score int137+ order int138+ prefix bool // a word of the title starts with the query139+ }140+ best := map[int]*ranked{}141+ for _, mt := range fuzzy.FindNoSort(q, names) {142+ best[mt.Index] = &ranked{Match{Item: &p.Items[mt.Index], InTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index, false}143+ }144+ for _, mt := range fuzzy.FindNoSort(q, hay) {145+ if r, ok := best[mt.Index]; ok && r.score >= mt.Score {146+ continue147+ }148+ it := &p.Items[mt.Index]149+ pm := Match{Item: it}150+ for _, i := range mt.MatchedIndexes {151+ switch {152+ case i < it.Name:153+ pm.InTitle = append(pm.InTitle, i)154+ case i >= it.Name+2:155+ pm.InDetail = append(pm.InDetail, i-it.Name-2)156+ }157+ }158+ best[mt.Index] = &ranked{pm, mt.Score, mt.Index, false}159+ }160+ for i, r := range best {161+ r.prefix = wordPrefix(names[i], q)162+ }163+ all := slices.Collect(maps.Values(best))164+ slices.SortFunc(all, func(a, b *ranked) int {165+ if a.prefix != b.prefix {166+ if a.prefix {167+ return -1168+ }169+ return 1170+ }171+ if c := cmp.Compare(b.score, a.score); c != 0 {172+ return c173+ }174+ return cmp.Compare(a.order, b.order)175+ })176+ for _, r := range all {177+ p.shown = append(p.shown, r.pm)178+ }179+}180+181+// wordPrefix reports whether a word of title starts with q, ignoring case.182+func wordPrefix(title, q string) bool {183+ q = strings.ToLower(q)184+ for w := range strings.FieldsSeq(strings.ToLower(title)) {185+ if strings.HasPrefix(w, q) {186+ return true187+ }188+ }189+ return false190+}191+192+func (p *Picker) Key(k tea.KeyPressMsg) tea.Cmd {193+ rows := p.rows()194+ switch k.String() {195+ case "up", "ctrl+p", "shift+tab":196+ p.Move(-1, len(p.shown))197+ case "down", "ctrl+n", "tab":198+ p.Move(1, len(p.shown))199+ case "pgup":200+ p.Sel = max(p.Sel-rows, 0)201+ case "pgdown":202+ p.Sel = max(min(p.Sel+rows, len(p.shown)-1), 0)203+ case "enter":204+ return p.pick()205+ case "esc":206+ p.Close()207+ default:208+ line := p.h.Line()209+ before := line.Text()210+ line.Key(k)211+ if line.Text() != before {212+ p.Changed()213+ }214+ }215+ return nil216+}217+218+func (p *Picker) pick() tea.Cmd {219+ if p.Enter != nil {220+ if cmd, ok := p.Enter(strings.TrimSpace(p.h.Line().Text())); ok {221+ return cmd222+ }223+ }224+ if p.Sel >= len(p.shown) || p.shown[p.Sel].Item.Off {225+ return nil226+ }227+ it := p.shown[p.Sel].Item228+ cmd := it.Pick()229+ if p.Answers {230+ p.h.RecordAnswer(it.Title, false)231+ }232+ return cmd233+}234+235+// Close closes the picker without picking, which cancels the question236+// it asks.237+func (p *Picker) Close() {238+ p.h.Close()239+ if p.Answers {240+ p.h.RecordAnswer("", true)241+ }242+}243+244+// Answer picks the item titled title (in any case), as a script answers245+// the picker's question, and reports whether there was one.246+func (p *Picker) Answer(title string) (tea.Cmd, bool) {247+ for i := range p.Items {248+ if it := &p.Items[i]; strings.EqualFold(it.Title, title) && !it.Off {249+ return it.Pick(), true250+ }251+ }252+ return nil, false253+}254+255+// FirstRow is the box's first result row, under the top border, the256+// search field and a separator.257+const FirstRow = 3258+259+func (p *Picker) Mouse(e overlay.MouseEvent) tea.Cmd {260+ if e.Box != ID {261+ if e.Kind == overlay.MousePress {262+ p.Close()263+ }264+ return nil265+ }266+ i := p.Top + e.Row - FirstRow267+ switch {268+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:269+ p.Move(-1, len(p.shown))270+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:271+ p.Move(1, len(p.shown))272+ case e.Row < FirstRow || i >= len(p.shown) || i >= p.Top+p.rows():273+ case e.Kind == overlay.MouseMotion:274+ p.Sel = i275+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:276+ p.Sel = i277+ return p.pick()278+ }279+ return nil280+}281+282+func (p *Picker) Status() (string, string) {283+ keys := p.h.Theme().KeyHints("Up/Down", "move", "Enter", cmp.Or(p.Action, "run"), "Esc", "close")284+ if p.Sel >= len(p.shown) {285+ return "", keys286+ }287+ it := p.shown[p.Sel].Item288+ if it.Off {289+ return it.Desc + " (not available now)", keys290+ }291+ return it.Desc, keys292+}293+294+// rows is how many results show. The box shrinks from the bottom as the295+// search narrows, so the search field never moves, and it leaves the296+// status line visible.297+func (p *Picker) rows() int {298+ _, height := p.h.Size()299+ return max(min(len(p.shown), 12, height-1-overlay.MenuLine-1-4), 1)300+}301+302+func (p *Picker) box() (x, y, inner int) {303+ width, _ := p.h.Size()304+ tw, dw, kw := 0, 0, 0305+ for _, it := range p.Items {306+ tw = max(tw, ansi.StringWidth(it.Title))307+ dw = max(dw, ansi.StringWidth(it.Detail))308+ kw = max(kw, ansi.StringWidth(it.Key)+2)309+ }310+ inner = min(1+tw+3+dw+3+kw+1, p.maxW, width-2)311+ inner = max(inner, min(40, width-2))312+ return (width - inner - 2) / 2, overlay.MenuLine + 1, inner313+}314+315+func (p *Picker) Cursor() (int, int) {316+ x, y, _ := p.box()317+ return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + ansi.StringWidth(p.h.Line().Head()), y + 1318+}319+320+func (p *Picker) Layout() []overlay.Box {321+ th, line := p.h.Theme(), p.h.Line()322+ x, y, inner := p.box()323+ rows := p.rows()324+ if len(p.shown) > 0 {325+ p.Show(rows)326+ }327+ input := th.Title.Render(overlay.SearchPrompt) + line.Text()328+ if len(line.Buf) == 0 {329+ input += th.Muted.Render(p.placeholder)330+ }331+ lines := []string{theme.Cells(th.MenuBar, input, inner), theme.SepRow}332+ lines = append(lines, p.resultRows(th, inner, rows)...)333+ footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.Items))334+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, p.title, footer, lines)}}335+}336+337+// resultRows lays out the visible results in columns: title, detail and338+// the shortcut chip at the right. The detail column is dropped when the339+// box is too narrow for it.340+func (p *Picker) resultRows(th *theme.Theme, inner, rows int) []string {341+ tw, kw := 0, 0342+ for _, it := range p.Items {343+ tw = max(tw, ansi.StringWidth(it.Title))344+ if it.Key != "" {345+ kw = max(kw, ansi.StringWidth(it.Key)+2)346+ }347+ }348+ tw = min(tw, inner*3/5)349+ dw := inner - 1 - tw - 3 - kw - 2350+ out := make([]string, 0, rows)351+ for r := range rows {352+ i := p.Top + r353+ if i >= len(p.shown) {354+ text := ""355+ if r == 0 {356+ text = th.Muted.Render(" No matches")357+ }358+ out = append(out, theme.Cells(th.MenuBar, text, inner))359+ continue360+ }361+ pm := p.shown[i]362+ base, dim, hl := th.MenuBar, th.Muted, th.Match363+ switch {364+ case i == p.Sel:365+ base, dim, hl = th.MenuSelected, th.MenuSelected, th.MatchSelected366+ case pm.Item.Off:367+ base, dim, hl = th.Disabled, th.Disabled, th.Disabled368+ }369+ row := base.Render(" ") + theme.HighlightMatches(pm.Item.Title, pm.InTitle, tw, base, hl)370+ if dw >= 8 {371+ row += base.Render(" ") + theme.HighlightMatches(pm.Item.Detail, pm.InDetail, dw, dim, hl)372+ }373+ k := ""374+ switch {375+ case pm.Item.Key == "":376+ case i == p.Sel:377+ k = base.Render(" " + pm.Item.Key + " ")378+ case pm.Item.Off:379+ k = th.Disabled.Render(" " + pm.Item.Key + " ")380+ default:381+ k = th.Chip(pm.Item.Key)382+ }383+ row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")384+ out = append(out, ansi.Truncate(row, inner, ""))385+ }386+ return out387+}
A internal/ui/picker/picker_test.go +167 -0

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

M internal/ui/pivotactions.go +46 -44

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

M internal/ui/pivoteditor.go +97 -62

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

M internal/ui/prefs.go +2 -1

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

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

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

M internal/ui/prompt.go +17 -17

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

M internal/ui/serve_test.go +4 -3

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

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

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

M internal/ui/sort.go +21 -16

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

M internal/ui/tabs.go +27 -25

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

M internal/ui/tabs_test.go +9 -8

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

M internal/ui/tabstrip.go +19 -173

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

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

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

A internal/ui/tabstrip/tabstrip_test.go +91 -0

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

M internal/ui/themepicker.go +2 -91

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

A internal/ui/themepicker/themepicker.go +125 -0

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

A internal/ui/themepicker/themepicker_test.go +116 -0

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

M internal/ui/transfer.go +55 -174

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

A internal/ui/transfer/transfer.go +181 -0

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

A internal/ui/transfer/transfer_test.go +89 -0

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

M internal/ui/transfer_test.go +18 -17

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

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

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

M internal/ui/vim_test.go +4 -4

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

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

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