cat / 012
clone

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

Move the find bar into internal/ui/findbar

Dami committed d6549fc
Find and replace acts on the workbook, the active cell and the edit line
through findbar.Host (ten methods); the model keeps its commands, opening
it over the selection and the grid's match highlight. The shared search
prompt and chip keys move to package overlay.
commit
d6549fc7eef9c4e6a84904260f5e404b4db2b401
parent
f3818a864cbb62d225a5e343c9a26607e27c13df
author
Dami <dami.etoile@gmail.com>
13 files changed, +708 -414
Minternal/ui/filter.go5+-
Minternal/ui/find.go332+------------------------
Minternal/ui/find_test.go41++--
Ainternal/ui/findbar/find.go399++++++++++++++++++++++++++++++
Ainternal/ui/findbar/find_test.go162++++++++++++
Rinternal/ui/findview.go → internal/ui/findbar/view.go112++++-----
Minternal/ui/hosts.go25++
Minternal/ui/model.go3+-
Minternal/ui/overlay/overlay.go19+
Minternal/ui/picker/picker.go12+-
Minternal/ui/picker/picker_test.go2+-
Minternal/ui/sort.go2+-
Minternal/ui/tabs_test.go8+-
M internal/ui/filter.go +2 -3
M internal/ui/find.go +17 -315
@@ -1,58 +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"12- "github.com/FelineStateMachine/012/internal/ui/overlay"9+ "github.com/FelineStateMachine/012/internal/ui/findbar"1310 )1411 15-// Find and replace is a bar on the context line rather than a dialog, in16-// the spirit of less and vim: matches highlight in the grid as you type17-// and the active cell follows the current one. Options are toggles shown18-// as chips and switched with Alt keys, as in VS Code's find widget. The19-// scope chip says where to search, as Sheets' "Search" choice: this20-// sheet, all sheets, or the range selected when the bar opened; Alt+S21-// goes through them.22-23-type findBar struct {24- m *Model // the model it acts on25- replace bool // Ctrl+H: a replacement field follows the query26- field int // 0 is the query, 1 the replacement27- fields [2]string28- opts sheet.FindOptions29- scope *sheet.Rect // the selection when the bar opened, if any30- home *sheet.Sheet // the sheet scope is on31- where findScope32-33- matches []cellOn34- index map[cellOn]int35- cur int // index of the current match, -1 when none36- err string // e.g. an invalid regular expression37- from cellOn // where the search goes on from38-}39-40-// findScope is where the find bar searches.41-type findScope int42-43-const (44- inSheet findScope = iota // the sheet shown45- inAll // every sheet, in tab order46- inRange // the range selected when the bar opened47-)48-49-// cellOn is a cell on a particular sheet.50-type cellOn struct {51- s *sheet.Sheet52- a sheet.Addr53-}54-55-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.5614 5715 func init() {5816 register(
@@ -83,301 +41,45 @@ func init() {
8341 // last search from the active cell, without opening the bar, as n and N8442 // do in vim and less.8543 func (m *Model) findAgain(d int) {86- f := m.find87- if f == nil || f.fields[0] == "" {44+ if m.find == nil || !m.find.Searched() {8845 m.note = "No search yet: " + m.shortcut("edit.find") + " finds"8946 return9047 }91- if f.where == inRange && !f.home.Live() {92- f.where = inSheet93- }94- here := cellOn{m.sheet, m.cur}95- f.from = here96- f.search(m) // lands on the first match at or after here97- switch {98- case f.err != "":99- m.note = f.err100- return101- case len(f.matches) == 0:102- m.note = "No matches for " + f.fields[0]103- return104- case d > 0 && f.matches[f.cur] == here, d < 0:105- f.step(m, d)106- }107- m.note = "Match " + strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + " for " + f.fields[0]48+ m.find.Again(d)10849 }10950 11051 // openFind opens the bar, keeping the last search. A multi-cell selection11152 // becomes the search scope, as Sheets' "specific range".11253 func (m *Model) openFind(replace bool) {113- f, ok := m.overlay.(*findBar)54+ f, ok := m.overlay.(*findbar.Bar)11455 if !ok {11556 f = m.find11657 if f == nil {117- f = &findBar{m: m, cur: -1}118- }119- f.scope, f.home = nil, m.sheet120- if f.where == inRange || f.where == inAll && len(m.book().Visible()) == 1 {121- f.where = inSheet58+ f = findbar.New(m.host())12259 }60+ var sel *sheet.Rect12361 if m.hasRange() {12462 r := m.selection()125- f.scope, f.where = &r, inRange63+ sel = &r12664 }127- f.from = cellOn{m.sheet, m.cur}65+ f.Reset(sel)66+ f.Vim = m.prefs.vim12867 m.clearSelection()12968 m.openOverlay(f)130- // Load the kept query so focus doesn't overwrite it.131- m.line.Buf = []rune(f.fields[f.field])132- }133- f.replace = f.replace || replace134- if replace && f.fields[0] != "" {135- f.focus(m, 1)136- } else {137- f.focus(m, 0)138- }139- f.search(m)140-}141-142-func (f *findBar) Indicator() string { return "FIND" }143-144-func (f *findBar) Layout() []overlay.Box { return nil }145-146-// focus moves editing to field i, keeping the other field's text.147-func (f *findBar) focus(m *Model, i int) {148- f.fields[f.field] = m.line.Text()149- f.field = i150- m.line.Set(f.fields[i])151-}152-153-// Changed re-runs the search as the query is typed.154-func (f *findBar) Changed() {155- m := f.m156- f.fields[f.field] = m.line.Text()157- if f.field == 0 {158- f.search(m)159- }160-}161-162-// options returns the search options, with the scope applied.163-func (f *findBar) options() sheet.FindOptions {164- o := f.opts165- o.Within = nil166- if f.where == inRange {167- o.Within = f.scope168- }169- return o170-}171-172-// sheets are the sheets searched, in order.173-func (f *findBar) sheets(m *Model) []*sheet.Sheet {174- switch f.where {175- case inAll:176- return m.book().Visible() // a match on a hidden sheet couldn't be shown177- case inRange:178- if f.home.Live() {179- return []*sheet.Sheet{f.home}180- }181- }182- return []*sheet.Sheet{m.sheet}183-}184-185-// search finds all matches and makes the first one at or after where the186-// search started the current one, so the view doesn't jump backwards187-// while typing.188-func (f *findBar) search(m *Model) {189- f.err, f.matches, f.index, f.cur = "", nil, nil, -1190- span := telemetry.Start("find")191- sheets := f.sheets(m)192- for _, s := range sheets {193- found, err := s.Find(f.fields[0], f.options())194- if err != nil {195- span.End(slog.Int("matches", 0))196- f.err = "Invalid regular expression"197- return198- }199- for _, a := range found {200- f.matches = append(f.matches, cellOn{s, a})201- }202- }203- span.End(slog.Int("matches", len(f.matches)), slog.Int("sheets", len(sheets)))204- f.index = make(map[cellOn]int, len(f.matches))205- for i, c := range f.matches {206- f.index[c] = i207- }208- if len(f.matches) == 0 {209- return210- }211- order := func(s *sheet.Sheet) int { return slices.Index(sheets, s) }212- f.cur = 0213- for i, c := range f.matches {214- 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) {215- f.cur = i216- break217- }21869 }219- f.show(m)70+ f.Open(replace)22071 }22172 222-// show makes the current match the active cell, on its sheet.223-func (f *findBar) show(m *Model) {224- c := f.matches[f.cur]225- m.showSheet(c.s)226- m.cur = c.a227-}228-229-// step moves to the next (+1) or previous (-1) match, wrapping around.230-func (f *findBar) step(m *Model, d int) {231- if len(f.matches) == 0 {232- return233- }234- f.cur = (f.cur + d + len(f.matches)) % len(f.matches)235- f.show(m)236- f.from = f.matches[f.cur]237-}238-239-// nextScope is where Alt+S goes from where: the range (if there is one),240-// the sheet, then all sheets (if there are several).241-func (f *findBar) nextScope(m *Model) findScope {242- order := []findScope{inSheet}243- if len(m.book().Visible()) > 1 {244- order = append(order, inAll)245- }246- if f.scope != nil {247- order = append([]findScope{inRange}, order...)248- }249- return order[(slices.Index(order, f.where)+1)%len(order)]250-}251-252-func (f *findBar) Key(k tea.KeyPressMsg) tea.Cmd {253- m := f.m254- switch k.String() {255- case "esc":256- m.find = f257- m.closeOverlay()258- case "enter":259- if m.prefs.vim && !f.replace {260- // As / in vim: Enter stays on the match; n and N go on.261- m.find = f262- m.closeOverlay()263- return nil264- }265- if f.replace && f.field == 1 {266- f.replaceOne(m)267- } else {268- f.step(m, 1)269- }270- case "shift+enter", "up":271- f.step(m, -1)272- case "down":273- f.step(m, 1)274- case "ctrl+enter", "alt+a":275- if f.replace {276- f.replaceAll(m)277- }278- case "tab", "shift+tab":279- if f.replace {280- f.focus(m, 1-f.field)281- }282- case "ctrl+f":283- f.focus(m, 0)284- case "ctrl+h":285- f.replace = true286- f.focus(m, 1)287- case "alt+c":288- f.opts.MatchCase = !f.opts.MatchCase289- f.search(m)290- case "alt+w":291- f.opts.WholeCell = !f.opts.WholeCell292- f.search(m)293- case "alt+r":294- f.opts.Regex = !f.opts.Regex295- f.search(m)296- case "alt+=":297- f.opts.InFormulas = !f.opts.InFormulas298- f.search(m)299- case "alt+s":300- if next := f.nextScope(m); next != f.where {301- f.where = next302- f.from = cellOn{m.sheet, m.cur}303- f.search(m)304- }305- default:306- before := m.line.Text()307- m.line.Key(k)308- if m.line.Text() != before {309- f.Changed()310- }311- }312- return nil313-}314-315-// replaceOne replaces the current match and moves to the next one.316-func (f *findBar) replaceOne(m *Model) {317- if f.cur < 0 {318- return319- }320- c := f.matches[f.cur]321- a := c.a322- changed, err := c.s.Replace(a, f.fields[0], f.fields[1], f.options())323- switch {324- case err != nil:325- m.note = "Can't replace in " + a.String() + ": " + err.Error()326- return327- case !changed:328- m.note = "Formulas in " + a.String() + " change only when searching within formulas"329- default:330- m.changed = true331- }332- f.from = c333- f.search(m)334- if changed && f.cur >= 0 && f.matches[f.cur] == c {335- f.step(m, 1) // the cell still matches; move past it336- }337-}338-339-func (f *findBar) replaceAll(m *Model) {340- // Across sheets, still one undo step.341- span := telemetry.Start("replace")342- n := 0343- err := m.book().Batch(sheet.Change{Label: "replace all", Sheet: m.sheet}, func() error {344- for _, s := range f.sheets(m) {345- k, err := s.ReplaceAll(f.fields[0], f.fields[1], f.options())346- n += k347- if err != nil {348- return err349- }350- }351- return nil352- })353- span.End(slog.Int("replaced", n))354- if n > 0 {355- m.changed = true356- }357- switch {358- case err != nil:359- m.note = "Replaced " + cellCount(n) + ", then stopped: " + err.Error()360- case n == 0:361- m.note = "Nothing to replace"362- default:363- m.note = "Replaced " + cellCount(n)364- }365- 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)36677 }36778 79+// cellCount is n cells in words: "1 cell", "3 cells".36880 func cellCount(n int) string {36981 if n == 1 {37082 return "1 cell"37183 }37284 return strconv.Itoa(n) + " cells"37385 }374-375-// found reports whether a is a match of the open find bar.376-func (m *Model) found(a sheet.Addr) bool {377- f, ok := m.overlay.(*findBar)378- if !ok {379- return false380- }381- _, hit := f.index[cellOn{m.sheet, a}]382- return hit383-}
M internal/ui/find_test.go +21 -20
@@ -5,6 +5,7 @@ import (
55 "testing"66 77 tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/012/internal/ui/findbar"89 "github.com/charmbracelet/x/ansi"910 )1011
@@ -24,9 +25,9 @@ func col(l, sub string) int {
2425 return ansi.StringWidth(l[:strings.Index(l, sub)])2526 }2627 27-func findBarOf(t *testing.T, m *Model) *findBar {28+func findBarOf(t *testing.T, m *Model) *findbar.Bar {2829 t.Helper()29- f, ok := m.overlay.(*findBar)30+ f, ok := m.overlay.(*findbar.Bar)3031 if !ok {3132 t.Fatalf("no find bar (overlay %T)", m.overlay)3233 }
@@ -41,8 +42,8 @@ func TestFindAsYouType(t *testing.T) {
4142 }4243 press(t, m, "ren")4344 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))45+ if len(f.Matches()) != 3 || m.cur != addr("A1") || !strings.Contains(line(m, contextLine), "1 of 3") {46+ t.Fatalf("matches %v cur %v line %q", f.Matches(), m.cur, line(m, contextLine))4647 }4748 // The terminal cursor sits after the query.4849 if x, y, ok := m.cursorPos(); !ok || y != contextLine || x != col(line(m, contextLine), "ren")+3 {
@@ -70,7 +71,7 @@ func TestFindAsYouType(t *testing.T) {
7071 }7172 // Ctrl+F again reopens the last search.7273 press(t, m, "<ctrl+f>")73- if f := findBarOf(t, m); m.line.Text() != "ren" || len(f.matches) != 3 {74+ if f := findBarOf(t, m); m.line.Text() != "ren" || len(f.Matches()) != 3 {7475 t.Errorf("reopened with %q", m.line.Text())7576 }7677 }
@@ -80,28 +81,28 @@ func TestFindOptions(t *testing.T) {
8081 press(t, m, "<ctrl+f>", "Rent")8182 f := findBarOf(t, m)8283 press(t, m, "<alt+c>")83- if len(f.matches) != 1 {84- t.Errorf("match case: %v", f.matches)84+ if len(f.Matches()) != 1 {85+ t.Errorf("match case: %v", f.Matches())8586 }8687 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)88+ if len(f.Matches()) != 1 || f.Matches()[0].At != addr("A1") {89+ t.Errorf("whole cell: %v", f.Matches())8990 }9091 press(t, m, "<alt+w>")9192 for range 4 {9293 press(t, m, "<backspace>")9394 }9495 press(t, m, "B1", "<alt+=>")95- if len(f.matches) != 2 {96- t.Errorf("in formulas: %v", f.matches)96+ if len(f.Matches()) != 2 {97+ t.Errorf("in formulas: %v", f.Matches())9798 }9899 press(t, m, "<alt+=>")99100 for range 2 {100101 press(t, m, "<backspace>")101102 }102103 press(t, m, "<alt+r>", "^r.n")103- if len(f.matches) != 2 {104- t.Errorf("regex: %v", f.matches)104+ if len(f.Matches()) != 2 {105+ t.Errorf("regex: %v", f.Matches())105106 }106107 press(t, m, "(")107108 if !strings.Contains(line(m, contextLine), "Invalid regular expression") {
@@ -118,12 +119,12 @@ func TestFindWithinSelection(t *testing.T) {
118119 m := findModel(t)119120 press(t, m, "<shift+down>", "<ctrl+f>", "ren")120121 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))122+ if len(f.Matches()) != 2 || !strings.Contains(line(m, contextLine), "in A1:A2") {123+ t.Fatalf("scoped: %v line %q", f.Matches(), line(m, contextLine))123124 }124125 press(t, m, "<alt+s>")125- if len(f.matches) != 3 {126- t.Errorf("unscoped: %v", f.matches)126+ if len(f.Matches()) != 3 {127+ t.Errorf("unscoped: %v", f.Matches())127128 }128129 }129130
@@ -131,7 +132,7 @@ func TestReplace(t *testing.T) {
131132 m := findModel(t)132133 press(t, m, "<ctrl+h>", "rent", "<tab>", "lease")133134 f := findBarOf(t, m)134- if !f.replace || f.field != 1 || !strings.Contains(line(m, contextLine), "Replace › lease") {135+ if !f.Replacing() || f.Field() != 1 || !strings.Contains(line(m, contextLine), "Replace › lease") {135136 t.Fatalf("replace bar %q", line(m, contextLine))136137 }137138 press(t, m, "<enter>") // replace A1, move on
@@ -157,8 +158,8 @@ func TestFindMouse(t *testing.T) {
157158 f := findBarOf(t, m)158159 l := line(m, contextLine)159160 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)161+ if !f.Options().MatchCase || len(f.Matches()) != 2 {162+ t.Errorf("clicking Aa: case %v matches %v", f.Options().MatchCase, f.Matches())162163 }163164 // Clicking the grid closes the bar and moves there.164165 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 +51 -61
@@ -1,43 +1,44 @@
1-package ui1+package findbar22 33 import (44 "strconv"55 "strings"66 77 tea "charm.land/bubbletea/v2"8- "github.com/FelineStateMachine/012/internal/ui/overlay"9- "github.com/FelineStateMachine/012/internal/ui/picker"108 "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/overlay"1111 )1212 1313 // The find bar as the context line draws it, and its mouse targets: the1414 // fields, then the option chips, with the match count on the right.1515 1616 // Pieces of the bar, left to right: each field, then the option chips.17-type findPart struct {17+type part struct {1818 text string1919 field int // -1 for chips2020 toggle string // the Alt key a chip toggles2121 }2222 23-func (f *findBar) parts(m *Model) []findPart {23+func (f *Bar) parts() []part {24+ th := f.h.Theme()2425 label := func(name string, i int) string {25- style := m.th.Muted26+ style := th.Muted2627 if f.field == i {27- style = m.th.Key28+ style = th.Key2829 }29- return style.Render(name) + m.th.Muted.Render(picker.SearchPrompt)30+ return style.Render(name) + th.Muted.Render(overlay.SearchPrompt)3031 }31- parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}32+ parts := []part{{text: label("Find", 0) + f.fieldText(0), field: 0}}3233 if f.replace {33- 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})3435 }35- chip := func(on bool, name, key string) findPart {36- style := m.th.Muted36+ chip := func(on bool, name, key string) part {37+ style := th.Muted3738 if on {38- style = m.th.MenuSelected39+ style = th.MenuSelected3940 }40- return findPart{text: style.Render(" " + name + " "), field: -1, toggle: key}41+ return part{text: style.Render(" " + name + " "), field: -1, toggle: key}4142 }4243 parts = append(parts,4344 chip(f.opts.MatchCase, "Aa", "alt+c"),
@@ -49,23 +50,24 @@ func (f *findBar) parts(m *Model) []findPart {
4950 parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))5051 case f.where == inAll:5152 parts = append(parts, chip(true, "in all sheets", "alt+s"))52- case f.scope != nil || len(m.book().Visible()) > 1:53- 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"))5456 }5557 return parts5658 }5759 5860 // fieldText is a field's text: the live edit buffer when focused.59-func (f *findBar) fieldText(m *Model, i int) string {61+func (f *Bar) fieldText(i int) string {6062 if f.field == i {61- return m.line.Text()63+ return f.h.Line().Text()6264 }6365 return f.fields[i]6466 }6567 6668 // gapAfter separates a part from the next: wider after a field, so the6769 // query and the chips read as separate groups.68-func gapAfter(p findPart) string {70+func gapAfter(p part) string {6971 if p.field >= 0 {7072 return " "7173 }
@@ -73,8 +75,8 @@ func gapAfter(p findPart) string {
7375 }7476 7577 // spans returns each part with the x where it starts on the context line.76-func (f *findBar) spans(m *Model) ([]findPart, []int) {77- parts := f.parts(m)78+func (f *Bar) spans() ([]part, []int) {79+ parts := f.parts()7880 xs := make([]int, len(parts))7981 x := 08082 for i, p := range parts {
@@ -84,12 +86,15 @@ func (f *findBar) spans(m *Model) ([]findPart, []int) {
8486 return parts, xs8587 }8688 89+// Layout is empty: the bar is drawn on the context line.90+func (f *Bar) Layout() []overlay.Box { return nil }91+8792 // ContextLine renders the bar for the context line, with the match count8893 // on the right.89-func (f *findBar) ContextLine() (left, right string) {90- m := f.m94+func (f *Bar) ContextLine() (left, right string) {95+ th := f.h.Theme()9196 var b strings.Builder92- parts := f.parts(m)97+ parts := f.parts()9398 for i, p := range parts {9499 b.WriteString(p.text)95100 if i < len(parts)-1 {
@@ -98,88 +103,73 @@ func (f *findBar) ContextLine() (left, right string) {
98103 }99104 switch {100105 case f.err != "":101- right = m.th.Warning.Render(f.err)106+ right = th.Warning.Render(f.err)102107 case f.fields[0] == "":103108 case len(f.matches) == 0:104- right = m.th.Warning.Render("No matches")109+ right = th.Warning.Render("No matches")105110 default:106111 where := ""107112 if f.where == inAll {108- where = " on " + f.matches[f.cur].s.Name()113+ where = " on " + f.matches[f.cur].Sheet.Name()109114 }110- 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)111116 }112117 return b.String(), right113118 }114119 115120 // Cursor puts the terminal cursor in the focused field, whose text ends116121 // its part.117-func (f *findBar) Cursor() (x, y int) {118- m := f.m119- parts, xs := f.spans(m)122+func (f *Bar) Cursor() (x, y int) {123+ parts, xs := f.spans()120124 for i, p := range parts {121125 if p.field == f.field {122- 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.ContextLine123127 }124128 }125- return 0, contextLine129+ return 0, overlay.ContextLine126130 }127131 128132 // Mouse focuses a field or flips a chip on the bar; a click anywhere129133 // else closes the bar and lands where it was clicked.130-func (f *findBar) Mouse(e overlay.MouseEvent) tea.Cmd {131- m := f.m134+func (f *Bar) Mouse(e overlay.MouseEvent) tea.Cmd {132135 if e.Kind != overlay.MousePress {133136 return nil134137 }135- if e.Y != contextLine {136- m.find = f137- m.closeOverlay()138- 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)139141 }140- parts, xs := f.spans(m)142+ parts, xs := f.spans()141143 for i, p := range parts {142144 if e.X >= xs[i] && e.X < xs[i]+ansi.StringWidth(p.text) {143145 if p.field >= 0 {144- f.focus(m, p.field)146+ f.focus(p.field)145147 return nil146148 }147- return f.Key(keyFor(p.toggle))149+ return f.Key(overlay.KeyFor(p.toggle))148150 }149151 }150152 return nil151153 }152154 153-// keyFor makes a key press from a keystroke like "alt+c".154-func keyFor(s string) tea.KeyPressMsg {155- k := tea.KeyPressMsg{}156- parts := strings.Split(s, "+")157- for _, p := range parts[:len(parts)-1] {158- if p == "alt" {159- k.Mod |= tea.ModAlt160- }161- }162- k.Code = rune(parts[len(parts)-1][0])163- return k164-}165-166155 // Status shows the keys for moving and replacing, and a reminder of the167156 // option keys when there's room. Narrow screens keep the most useful keys.168-func (f *findBar) Status() (string, string) {169- m := f.m157+func (f *Bar) Status() (string, string) {158+ th := f.h.Theme()159+ width, _ := f.h.Size()170160 pairs := []string{"Enter", "next", "Shift+Enter", "previous", "Esc", "close"}171161 if f.replace {172162 pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}173163 }174164 desc := "Alt+C/W/R/= options"175- if f.scope != nil || len(m.book().Visible()) > 1 {165+ if f.scope != nil || len(f.h.Book().Visible()) > 1 {176166 desc = "Alt+C/W/R/=/S options"177167 }178168 for {179- keys := m.th.KeyHints(pairs...)169+ keys := th.KeyHints(pairs...)180170 switch {181- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:182- return m.th.Muted.Render(desc), keys171+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= width:172+ return th.Muted.Render(desc), keys183173 case desc != "":184174 desc = ""185175 case len(pairs) > 2:
M internal/ui/hosts.go +25 -0
@@ -3,7 +3,9 @@ package ui
33 import (44 tea "charm.land/bubbletea/v2"55 6+ "github.com/FelineStateMachine/012/internal/sheet"67 "github.com/FelineStateMachine/012/internal/ui/cmdline"8+ "github.com/FelineStateMachine/012/internal/ui/findbar"79 "github.com/FelineStateMachine/012/internal/ui/lineedit"810 "github.com/FelineStateMachine/012/internal/ui/picker"911 "github.com/FelineStateMachine/012/internal/ui/theme"
@@ -20,6 +22,7 @@ func (m *Model) host() host { return host{m} }
2022 var (2123 _ picker.Host = host{}2224 _ cmdline.Host = host{}25+ _ findbar.Host = host{}2326 )2427 2528 // What every component with a text field needs.
@@ -57,3 +60,25 @@ func (h host) Commands() []cmdline.Item {
5760 5861 func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }5962 func (h host) Fail(msg string) { h.m.fail(msg) }63+64+// The find bar.65+66+func (h host) Book() *sheet.Workbook { return h.m.book() }67+func (h host) At() (*sheet.Sheet, sheet.Addr) { return h.m.sheet, h.m.cur }68+func (h host) Note(msg string) { h.m.note = msg }69+func (h host) Edited() { h.m.changed = true }70+func (h host) Show(s *sheet.Sheet, a sheet.Addr) { h.m.showSheet(s); h.m.cur = a }71+72+// Leave closes the find bar, keeping it for Ctrl+F and find next.73+func (h host) Leave() {74+ if f, ok := h.m.overlay.(*findbar.Bar); ok {75+ h.m.find = f76+ }77+ h.m.closeOverlay()78+}79+80+// Press hands a click outside an overlay to the grid, as if none were81+// open.82+func (h host) Press(x, y int, button tea.MouseButton) tea.Cmd {83+ return h.m.handlePress(tea.Mouse{X: x, Y: y, Button: button})84+}
M internal/ui/model.go +2 -1
M internal/ui/overlay/overlay.go +19 -0
M internal/ui/picker/picker.go +4 -8
M internal/ui/picker/picker_test.go +1 -1
M internal/ui/sort.go +1 -1
M internal/ui/tabs_test.go +4 -4