cat / 012
clone

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

main
012 / internal / ui / findbar / find.go
find.go 10.7K
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.8package findbar910import (11 "log/slog"12 "slices"13 "strconv"1415 tea "charm.land/bubbletea/v2"1617 "src.rest/cat/012/internal/sheet"18 "src.rest/cat/012/internal/telemetry"19 "src.rest/cat/012/internal/ui/lineedit"20 "src.rest/cat/012/internal/ui/theme"21)2223// Host is what the find bar needs of the UI.24type 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 // Trace is where searches are timed, nested in what is open.31 Trace() *telemetry.Trace32 // At is the sheet shown and its active cell.33 At() (*sheet.Sheet, sheet.Addr)34 // Show shows sheet s with a as the active cell.35 Show(s *sheet.Sheet, a sheet.Addr)36 // Note says how the last action went, on the context line.37 Note(msg string)38 // Replace runs a replacement, do, which returns what it did as a39 // macro answers Find and replace (see Answer), or "" when it changed40 // nothing: the file is then modified, and a macro being recorded41 // records it after the selection it started from.42 Replace(do func() string)43 // Leave closes the bar, keeping its search for Ctrl+F and find next.44 Leave()45 // Press handles a mouse press outside the bar, as on the grid.46 Press(x, y int, button tea.MouseButton) tea.Cmd47}4849// Bar is the find bar: the search, its options and its matches. It50// outlives being open, so the last search can be reopened or repeated.51type Bar struct {52 h Host53 replace bool // Ctrl+H: a replacement field follows the query54 field int // 0 is the query, 1 the replacement55 fields [2]string56 opts sheet.FindOptions57 scope *sheet.Rect // the selection when the bar opened, if any58 home *sheet.Sheet // the sheet scope is on59 where scope6061 matches []Match62 index map[Match]int63 cur int // index of the current match, -1 when none64 err string // e.g. an invalid regular expression65 from Match // where the search goes on from6667 // Vim makes Enter close the bar on the match, as / does in vim.68 Vim bool69}7071// scope is where the find bar searches.72type scope int7374const (75 inSheet scope = iota // the sheet shown76 inAll // every sheet, in tab order77 inRange // the range selected when the bar opened78)7980// Match is a cell on a particular sheet.81type Match struct {82 Sheet *sheet.Sheet83 At sheet.Addr84}8586// New returns a bar with no search yet.87func New(h Host) *Bar { return &Bar{h: h, cur: -1} }8889// Matches are the matches of the search, in order.90func (f *Bar) Matches() []Match { return f.matches }9192// Options are the search's options.93func (f *Bar) Options() sheet.FindOptions { return f.opts }9495// Replacing reports whether the replacement field shows, and Field which96// field is focused: 0 the query, 1 the replacement.97func (f *Bar) Replacing() bool { return f.replace }98func (f *Bar) Field() int { return f.field }99100// Found reports whether a on s is a match.101func (f *Bar) Found(s *sheet.Sheet, a sheet.Addr) bool {102 _, hit := f.index[Match{s, a}]103 return hit104}105106// Again goes to the next (d = 1) or previous (d = -1) match of the last107// search from the active cell, without opening the bar, as n and N do in108// vim and less, and says where it went. It does nothing without a search109// to repeat (see Searched).110func (f *Bar) Again(d int) {111 if f.fields[0] == "" {112 return113 }114 if f.where == inRange && !f.home.Live() {115 f.where = inSheet116 }117 s, a := f.h.At()118 here := Match{s, a}119 f.from = here120 f.search() // lands on the first match at or after here121 switch {122 case f.err != "":123 f.h.Note(f.err)124 return125 case len(f.matches) == 0:126 f.h.Note("No matches for " + f.fields[0])127 return128 case d > 0 && f.matches[f.cur] == here, d < 0:129 f.step(d)130 }131 f.h.Note("Match " + strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + " for " + f.fields[0])132}133134// Searched reports whether there's a search to repeat.135func (f *Bar) Searched() bool { return f.fields[0] != "" }136137// Reset gets the bar ready to open over the sheet shown, keeping the last138// search. A multi-cell selection sel becomes the search scope, as Sheets'139// "specific range". The kept query is loaded into the edit line.140func (f *Bar) Reset(sel *sheet.Rect) {141 s, a := f.h.At()142 f.scope, f.home = nil, s143 if f.where == inRange || f.where == inAll && len(f.h.Book().Visible()) == 1 {144 f.where = inSheet145 }146 if sel != nil {147 f.scope, f.where = sel, inRange148 }149 f.from = Match{s, a}150 // Load the kept query so focus doesn't overwrite it.151 f.h.Line().Buf = []rune(f.fields[f.field])152}153154// Open focuses the query, or with replace the replacement once there's155// a query, and searches.156func (f *Bar) Open(replace bool) {157 f.replace = f.replace || replace158 if replace && f.fields[0] != "" {159 f.focus(1)160 } else {161 f.focus(0)162 }163 f.search()164}165166func (f *Bar) Indicator() string { return "FIND" }167168// focus moves editing to field i, keeping the other field's text.169func (f *Bar) focus(i int) {170 line := f.h.Line()171 f.fields[f.field] = line.Text()172 f.field = i173 line.Set(f.fields[i])174}175176// Changed re-runs the search as the query is typed.177func (f *Bar) Changed() {178 f.fields[f.field] = f.h.Line().Text()179 if f.field == 0 {180 f.search()181 }182}183184// options returns the search options, with the scope applied.185func (f *Bar) options() sheet.FindOptions {186 o := f.opts187 o.Within = nil188 if f.where == inRange {189 o.Within = f.scope190 }191 return o192}193194// sheets are the sheets searched, in order.195func (f *Bar) sheets() []*sheet.Sheet {196 switch f.where {197 case inAll:198 return f.h.Book().Visible() // a match on a hidden sheet couldn't be shown199 case inRange:200 if f.home.Live() {201 return []*sheet.Sheet{f.home}202 }203 }204 s, _ := f.h.At()205 return []*sheet.Sheet{s}206}207208// search finds all matches and makes the first one at or after where the209// search started the current one, so the view doesn't jump backwards210// while typing.211func (f *Bar) search() {212 f.err, f.matches, f.index, f.cur = "", nil, nil, -1213 span := f.h.Trace().Start("find")214 sheets := f.sheets()215 for _, s := range sheets {216 found, err := s.Find(f.fields[0], f.options())217 if err != nil {218 span.End(slog.Int("matches", 0))219 f.err = "Invalid regular expression"220 return221 }222 for _, a := range found {223 f.matches = append(f.matches, Match{s, a})224 }225 }226 span.End(slog.Int("matches", len(f.matches)), slog.Int("sheets", len(sheets)))227 f.index = make(map[Match]int, len(f.matches))228 for i, c := range f.matches {229 f.index[c] = i230 }231 if len(f.matches) == 0 {232 return233 }234 order := func(s *sheet.Sheet) int { return slices.Index(sheets, s) }235 f.cur = 0236 for i, c := range f.matches {237 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) {238 f.cur = i239 break240 }241 }242 f.show()243}244245// show makes the current match the active cell, on its sheet.246func (f *Bar) show() {247 c := f.matches[f.cur]248 f.h.Show(c.Sheet, c.At)249}250251// step moves to the next (+1) or previous (-1) match, wrapping around.252func (f *Bar) step(d int) {253 if len(f.matches) == 0 {254 return255 }256 f.cur = (f.cur + d + len(f.matches)) % len(f.matches)257 f.show()258 f.from = f.matches[f.cur]259}260261// nextScope is where Alt+S goes from where: the range (if there is one),262// the sheet, then all sheets (if there are several).263func (f *Bar) nextScope() scope {264 order := []scope{inSheet}265 if len(f.h.Book().Visible()) > 1 {266 order = append(order, inAll)267 }268 if f.scope != nil {269 order = append([]scope{inRange}, order...)270 }271 return order[(slices.Index(order, f.where)+1)%len(order)]272}273274func (f *Bar) Key(k tea.KeyPressMsg) tea.Cmd {275 switch k.String() {276 case "esc":277 f.h.Leave()278 case "enter":279 if f.Vim && !f.replace {280 // As / in vim: Enter stays on the match; n and N go on.281 f.h.Leave()282 return nil283 }284 if f.replace && f.field == 1 {285 f.replaceOne()286 } else {287 f.step(1)288 }289 case "shift+enter", "up":290 f.step(-1)291 case "down":292 f.step(1)293 case "ctrl+enter", "alt+a":294 if f.replace {295 f.replaceAll()296 }297 case "tab", "shift+tab":298 if f.replace {299 f.focus(1 - f.field)300 }301 case "ctrl+f":302 f.focus(0)303 case "ctrl+h":304 f.replace = true305 f.focus(1)306 default:307 f.optionKey(k)308 }309 return nil310}311312// optionKey flips an option or the scope, or else edits the focused313// field.314func (f *Bar) optionKey(k tea.KeyPressMsg) {315 switch k.String() {316 case "alt+c":317 f.opts.MatchCase = !f.opts.MatchCase318 case "alt+w":319 f.opts.WholeCell = !f.opts.WholeCell320 case "alt+r":321 f.opts.Regex = !f.opts.Regex322 case "alt+=":323 f.opts.InFormulas = !f.opts.InFormulas324 case "alt+s":325 next := f.nextScope()326 if next == f.where {327 return328 }329 f.where = next330 s, a := f.h.At()331 f.from = Match{s, a}332 default:333 line := f.h.Line()334 before := line.Text()335 line.Key(k)336 if line.Text() != before {337 f.Changed()338 }339 return340 }341 f.search()342}343344// replaceOne replaces the current match and moves to the next one.345func (f *Bar) replaceOne() {346 if f.cur < 0 {347 return348 }349 f.h.Replace(f.doReplaceOne)350}351352func (f *Bar) doReplaceOne() string {353 c := f.matches[f.cur]354 a := c.At355 changed, err := c.Sheet.Replace(a, f.fields[0], f.fields[1], f.options())356 switch {357 case err != nil:358 f.h.Note("Can't replace in " + a.String() + ": " + err.Error())359 return ""360 case !changed:361 f.h.Note("Formulas in " + a.String() + " change only when searching within formulas")362 }363 f.from = c364 f.search()365 if changed && f.cur >= 0 && f.matches[f.cur] == c {366 f.step(1) // the cell still matches; move past it367 }368 if !changed {369 return ""370 }371 return f.answer(a.String())372}373374func (f *Bar) replaceAll() { f.h.Replace(f.doReplaceAll) }375376func (f *Bar) doReplaceAll() string {377 // Across sheets, still one undo step.378 span := f.h.Trace().Start("replace")379 n := 0380 s, _ := f.h.At()381 err := f.h.Book().Batch(sheet.Change{Label: "replace all", Sheet: s}, func() error {382 for _, s := range f.sheets() {383 k, err := s.ReplaceAll(f.fields[0], f.fields[1], f.options())384 n += k385 if err != nil {386 return err387 }388 }389 return nil390 })391 span.End(slog.Int("replaced", n))392 switch {393 case err != nil:394 f.h.Note("Replaced " + cellCount(n) + ", then stopped: " + err.Error())395 case n == 0:396 f.h.Note("Nothing to replace")397 default:398 f.h.Note("Replaced " + cellCount(n))399 }400 f.search()401 if n == 0 {402 return ""403 }404 return f.answer("")405}406407// cellCount is n cells in words: "1 cell", "3 cells".408func cellCount(n int) string {409 if n == 1 {410 return "1 cell"411 }412 return strconv.Itoa(n) + " cells"413}