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>
| M | internal/ | 5 | +- |
| M | internal/ | 332 | +------------------------ |
| M | internal/ | 41 | ++-- |
| A | internal/ | 399 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 162 | ++++++++++++ |
| R | internal/ | 112 | ++++----- |
| M | internal/ | 25 | ++ |
| M | internal/ | 3 | +- |
| M | internal/ | 19 | + |
| M | internal/ | 12 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 8 | +- |
@@ -12,7 +12,6 @@ import (
1212 "github.com/FelineStateMachine/012/internal/sheet"1313 "github.com/FelineStateMachine/012/internal/telemetry"1414 "github.com/FelineStateMachine/012/internal/ui/overlay"15- "github.com/FelineStateMachine/012/internal/ui/picker"1615 "github.com/FelineStateMachine/012/internal/ui/theme"1716 )1817 @@ -393,7 +392,7 @@ func (p *filterPicker) Cursor() (int, int) {
393392 if p.field == 1 {394393 return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1395394 }396- return x + 1 + ansi.StringWidth(picker.SearchPrompt) + caret, y + 3395+ return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + caret, y + 3397396 }398397 399398 // condChip is the condition's name between arrows that change it.@@ -426,7 +425,7 @@ func (p *filterPicker) Layout() []overlay.Box {
426425 if p.field == 0 {427426 search = m.line.Text()428427 }429- input := m.th.Title.Render(picker.SearchPrompt) + search428+ input := m.th.Title.Render(overlay.SearchPrompt) + search430429 if search == "" {431430 input += m.th.Muted.Render("Search values")432431 }@@ -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-}@@ -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)@@ -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+}@@ -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+}@@ -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:@@ -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+}@@ -12,6 +12,7 @@ import (
1212 "github.com/FelineStateMachine/012/internal/confine"1313 "github.com/FelineStateMachine/012/internal/sheet"1414 "github.com/FelineStateMachine/012/internal/telemetry"15+ "github.com/FelineStateMachine/012/internal/ui/findbar"1516 "github.com/FelineStateMachine/012/internal/ui/lineedit"1617 "github.com/FelineStateMachine/012/internal/ui/overlay"1718 "github.com/FelineStateMachine/012/internal/ui/theme"@@ -92,7 +93,7 @@ type Model struct {
9293 overlay overlay.Overlay // the open menu, picker or bar, if any (modeMenu): overlay.go9394 mouse mouseState // drags, hover and double clicks: mouse.go9495 tabs tabStrip // the sheet tabs and where each sheet was left: tabstrip.go95- find *findBar // the last search, reopened by Ctrl+F: find.go96+ find *findbar.Bar // the last search, reopened by Ctrl+F: find.go9697 charts chartState // chart commands' target: charts.go9798 copied clipboard // what Ctrl+V pastes: clipboard.go9899 trace *trace // precedents or dependents being shown: trace.go@@ -8,6 +8,8 @@
88 package overlay99 1010 import (11+ "strings"12+1113 tea "charm.land/bubbletea/v2"1214 "github.com/charmbracelet/x/ansi"1315 )@@ -18,6 +20,23 @@ const (
1820 ContextLine = 2 // prompts and bars; completions open under it1921 )2022 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+2140 // Overlay is a floating box drawn over the grid: a dropdown or context2241 // menu, the command palette or a dialog. While one is open it takes every2342 // key and mouse event, and Esc closes one level. Overlays are composited@@ -81,12 +81,8 @@ type Picker struct {
8181 Answers bool8282 }8383 84-const (85- // ID identifies the picker's box in mouse events.86- ID = "picker"87- // SearchPrompt starts the search field.88- SearchPrompt = " › "89-)84+// ID identifies the picker's box in mouse events.85+const ID = "picker"9086 9187 // New returns a picker of items titled title, at most maxW columns wide9288 // inside its frame, with the edit line cleared for the search.@@ -318,7 +314,7 @@ func (p *Picker) box() (x, y, inner int) {
318314 319315 func (p *Picker) Cursor() (int, int) {320316 x, y, _ := p.box()321- return x + 1 + ansi.StringWidth(SearchPrompt) + ansi.StringWidth(p.h.Line().Head()), y + 1317+ return x + 1 + ansi.StringWidth(overlay.SearchPrompt) + ansi.StringWidth(p.h.Line().Head()), y + 1322318 }323319 324320 func (p *Picker) Layout() []overlay.Box {@@ -328,7 +324,7 @@ func (p *Picker) Layout() []overlay.Box {
328324 if len(p.shown) > 0 {329325 p.Show(rows)330326 }331- input := th.Title.Render(SearchPrompt) + line.Text()327+ input := th.Title.Render(overlay.SearchPrompt) + line.Text()332328 if len(line.Buf) == 0 {333329 input += th.Muted.Render(p.placeholder)334330 }@@ -137,7 +137,7 @@ func TestLayoutAndMouse(t *testing.T) {
137137 }138138 }139139 x, y := p.Cursor()140- if y != b.Y+1 || x != b.X+1+ansi.StringWidth(SearchPrompt) {140+ if y != b.Y+1 || x != b.X+1+ansi.StringWidth(overlay.SearchPrompt) {141141 t.Errorf("cursor at %d,%d", x, y)142142 }143143 p.Mouse(overlay.MouseEvent{Kind: overlay.MouseMotion, Box: ID, Row: FirstRow + 2})@@ -298,7 +298,7 @@ func (b *sortBar) Mouse(e overlay.MouseEvent) tea.Cmd {
298298 if e.X >= x && e.X < x+w {299299 switch {300300 case p.toggle:301- return b.Key(keyFor("alt+h"))301+ return b.Key(overlay.KeyFor("alt+h"))302302 case p.key == b.cur:303303 b.keys[b.cur].Desc = !b.keys[b.cur].Desc304304 case p.key >= 0:@@ -299,12 +299,12 @@ func TestFindAllSheets(t *testing.T) {
299299 press(t, m, "rent", "<enter>", "<shift+f11>", "<down>", "rental", "<enter>", "<ctrl+pgup>")300300 press(t, m, "<ctrl+f>", "rent")301301 f := findBarOf(t, m)302- if len(f.matches) != 1 || !strings.Contains(line(m, contextLine), "in Sheet1") {303- t.Fatalf("this sheet: %d matches, %q", len(f.matches), line(m, contextLine))302+ if len(f.Matches()) != 1 || !strings.Contains(line(m, contextLine), "in Sheet1") {303+ t.Fatalf("this sheet: %d matches, %q", len(f.Matches()), line(m, contextLine))304304 }305305 press(t, m, "<alt+s>")306- if len(f.matches) != 2 || !strings.Contains(line(m, contextLine), "in all sheets") {307- t.Fatalf("all sheets: %d matches, %q", len(f.matches), line(m, contextLine))306+ if len(f.Matches()) != 2 || !strings.Contains(line(m, contextLine), "in all sheets") {307+ t.Fatalf("all sheets: %d matches, %q", len(f.Matches()), line(m, contextLine))308308 }309309 press(t, m, "<enter>")310310 if m.sheet.Name() != "Sheet2" || m.cur != addr("A2") || !strings.Contains(line(m, contextLine), "2 of 2 on Sheet2") {