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's drawing and mouse handling to findview.go

Dami committed 9ee0349
find.go was 511 lines; searching and replacing stay, and how the bar is
drawn on the context line, clicked and described on the status line
move to their own file.
commit
9ee03491c3e32598b19f41e356ee0fcf83216d40
parent
139f5c2acbdd1568ba826575e0129790a26a94fb
author
Dami <dami.etoile@gmail.com>
2 files changed, +185 -175
Minternal/ui/find.go175----------------------------
Ainternal/ui/findview.go185++++++++++++++++++++++++++++++
M internal/ui/find.go +0 -175
@@ -4,10 +4,8 @@ import (
44 "log/slog"55 "slices"66 "strconv"7- "strings"87 98 tea "charm.land/bubbletea/v2"10- "github.com/charmbracelet/x/ansi"119 1210 "github.com/FelineStateMachine/012/internal/sheet"1311 "github.com/FelineStateMachine/012/internal/telemetry"
@@ -326,179 +324,6 @@ func cellCount(n int) string {
326324 return strconv.Itoa(n) + " cells"327325 }328326 329-// Pieces of the bar, left to right: each field, then the option chips.330-type findPart struct {331- text string332- field int // -1 for chips333- toggle string // the Alt key a chip toggles334-}335-336-func (f *findBar) parts(m *Model) []findPart {337- label := func(name string, i int) string {338- style := m.th.Muted339- if f.field == i {340- style = m.th.Key341- }342- return style.Render(name) + m.th.Muted.Render(searchPrompt)343- }344- parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}345- if f.replace {346- parts = append(parts, findPart{text: label("Replace", 1) + f.fieldText(m, 1), field: 1})347- }348- chip := func(on bool, name, key string) findPart {349- style := m.th.Muted350- if on {351- style = m.th.MenuSelected352- }353- return findPart{text: style.Render(" " + name + " "), field: -1, toggle: key}354- }355- parts = append(parts,356- chip(f.opts.MatchCase, "Aa", "alt+c"),357- chip(f.opts.WholeCell, "Whole", "alt+w"),358- chip(f.opts.Regex, ".*", "alt+r"),359- chip(f.opts.InFormulas, "=", "alt+="))360- switch {361- case f.where == inRange:362- parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))363- case f.where == inAll:364- parts = append(parts, chip(true, "in all sheets", "alt+s"))365- case f.scope != nil || m.book().Len() > 1:366- parts = append(parts, chip(false, "in "+m.sheet.Name(), "alt+s"))367- }368- return parts369-}370-371-// fieldText is a field's text: the live edit buffer when focused.372-func (f *findBar) fieldText(m *Model, i int) string {373- if f.field == i {374- return m.line.text()375- }376- return f.fields[i]377-}378-379-// gapAfter separates a part from the next: wider after a field, so the380-// query and the chips read as separate groups.381-func gapAfter(p findPart) string {382- if p.field >= 0 {383- return " "384- }385- return " "386-}387-388-// spans returns each part with the x where it starts on the context line.389-func (f *findBar) spans(m *Model) ([]findPart, []int) {390- parts := f.parts(m)391- xs := make([]int, len(parts))392- x := 0393- for i, p := range parts {394- xs[i] = x395- x += ansi.StringWidth(p.text) + len(gapAfter(p))396- }397- return parts, xs398-}399-400-// line renders the bar for the context line, with the match count on the401-// right.402-func (f *findBar) line(m *Model) (left, right string) {403- var b strings.Builder404- parts := f.parts(m)405- for i, p := range parts {406- b.WriteString(p.text)407- if i < len(parts)-1 {408- b.WriteString(gapAfter(p))409- }410- }411- switch {412- case f.err != "":413- right = m.th.Warning.Render(f.err)414- case f.fields[0] == "":415- case len(f.matches) == 0:416- right = m.th.Warning.Render("No matches")417- default:418- where := ""419- if f.where == inAll {420- where = " on " + f.matches[f.cur].s.Name()421- }422- right = m.th.Muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)423- }424- return b.String(), right425-}426-427-// cursor puts the terminal cursor in the focused field, whose text ends428-// its part.429-func (f *findBar) cursor(m *Model) (x, y int) {430- parts, xs := f.spans(m)431- for i, p := range parts {432- if p.field == f.field {433- return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(m.line.tail()), contextLine434- }435- }436- return 0, contextLine437-}438-439-// mouse focuses a field or flips a chip on the bar; a click anywhere440-// else closes the bar and lands where it was clicked.441-func (f *findBar) mouse(m *Model, e mouseEvent) tea.Cmd {442- if e.kind != mousePress {443- return nil444- }445- if e.y != contextLine {446- m.find = f447- m.closeOverlay()448- return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})449- }450- parts, xs := f.spans(m)451- for i, p := range parts {452- if e.x >= xs[i] && e.x < xs[i]+ansi.StringWidth(p.text) {453- if p.field >= 0 {454- f.focus(m, p.field)455- return nil456- }457- return f.key(m, keyFor(p.toggle))458- }459- }460- return nil461-}462-463-// keyFor makes a key press from a keystroke like "alt+c".464-func keyFor(s string) tea.KeyPressMsg {465- k := tea.KeyPressMsg{}466- parts := strings.Split(s, "+")467- for _, p := range parts[:len(parts)-1] {468- if p == "alt" {469- k.Mod |= tea.ModAlt470- }471- }472- k.Code = rune(parts[len(parts)-1][0])473- return k474-}475-476-// status shows the keys for moving and replacing, and a reminder of the477-// option keys when there's room. Narrow screens keep the most useful keys.478-func (f *findBar) status(m *Model) (string, string) {479- pairs := []string{"Enter", "next", "Shift+Enter", "previous", "Esc", "close"}480- if f.replace {481- pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}482- }483- desc := "Alt+C/W/R/= options"484- if f.scope != nil || m.book().Len() > 1 {485- desc = "Alt+C/W/R/=/S options"486- }487- for {488- keys := m.th.KeyHints(pairs...)489- switch {490- case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:491- return m.th.Muted.Render(desc), keys492- case desc != "":493- desc = ""494- case len(pairs) > 2:495- pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc496- default:497- return "", keys498- }499- }500-}501-502327 // found reports whether a is a match of the open find bar.503328 func (m *Model) found(a sheet.Addr) bool {504329 f, ok := m.overlay.(*findBar)
A internal/ui/findview.go +185 -0
@@ -0,0 +1,185 @@
1+package ui2+3+import (4+ "strconv"5+ "strings"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+)10+11+// The find bar as the context line draws it, and its mouse targets: the12+// fields, then the option chips, with the match count on the right.13+14+// Pieces of the bar, left to right: each field, then the option chips.15+type findPart struct {16+ text string17+ field int // -1 for chips18+ toggle string // the Alt key a chip toggles19+}20+21+func (f *findBar) parts(m *Model) []findPart {22+ label := func(name string, i int) string {23+ style := m.th.Muted24+ if f.field == i {25+ style = m.th.Key26+ }27+ return style.Render(name) + m.th.Muted.Render(searchPrompt)28+ }29+ parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}30+ if f.replace {31+ parts = append(parts, findPart{text: label("Replace", 1) + f.fieldText(m, 1), field: 1})32+ }33+ chip := func(on bool, name, key string) findPart {34+ style := m.th.Muted35+ if on {36+ style = m.th.MenuSelected37+ }38+ return findPart{text: style.Render(" " + name + " "), field: -1, toggle: key}39+ }40+ parts = append(parts,41+ chip(f.opts.MatchCase, "Aa", "alt+c"),42+ chip(f.opts.WholeCell, "Whole", "alt+w"),43+ chip(f.opts.Regex, ".*", "alt+r"),44+ chip(f.opts.InFormulas, "=", "alt+="))45+ switch {46+ case f.where == inRange:47+ parts = append(parts, chip(true, "in "+f.scope.String(), "alt+s"))48+ case f.where == inAll:49+ parts = append(parts, chip(true, "in all sheets", "alt+s"))50+ case f.scope != nil || m.book().Len() > 1:51+ parts = append(parts, chip(false, "in "+m.sheet.Name(), "alt+s"))52+ }53+ return parts54+}55+56+// fieldText is a field's text: the live edit buffer when focused.57+func (f *findBar) fieldText(m *Model, i int) string {58+ if f.field == i {59+ return m.line.text()60+ }61+ return f.fields[i]62+}63+64+// gapAfter separates a part from the next: wider after a field, so the65+// query and the chips read as separate groups.66+func gapAfter(p findPart) string {67+ if p.field >= 0 {68+ return " "69+ }70+ return " "71+}72+73+// spans returns each part with the x where it starts on the context line.74+func (f *findBar) spans(m *Model) ([]findPart, []int) {75+ parts := f.parts(m)76+ xs := make([]int, len(parts))77+ x := 078+ for i, p := range parts {79+ xs[i] = x80+ x += ansi.StringWidth(p.text) + len(gapAfter(p))81+ }82+ return parts, xs83+}84+85+// line renders the bar for the context line, with the match count on the86+// right.87+func (f *findBar) line(m *Model) (left, right string) {88+ var b strings.Builder89+ parts := f.parts(m)90+ for i, p := range parts {91+ b.WriteString(p.text)92+ if i < len(parts)-1 {93+ b.WriteString(gapAfter(p))94+ }95+ }96+ switch {97+ case f.err != "":98+ right = m.th.Warning.Render(f.err)99+ case f.fields[0] == "":100+ case len(f.matches) == 0:101+ right = m.th.Warning.Render("No matches")102+ default:103+ where := ""104+ if f.where == inAll {105+ where = " on " + f.matches[f.cur].s.Name()106+ }107+ right = m.th.Muted.Render(strconv.Itoa(f.cur+1) + " of " + strconv.Itoa(len(f.matches)) + where)108+ }109+ return b.String(), right110+}111+112+// cursor puts the terminal cursor in the focused field, whose text ends113+// its part.114+func (f *findBar) cursor(m *Model) (x, y int) {115+ parts, xs := f.spans(m)116+ for i, p := range parts {117+ if p.field == f.field {118+ return xs[i] + ansi.StringWidth(p.text) - ansi.StringWidth(m.line.tail()), contextLine119+ }120+ }121+ return 0, contextLine122+}123+124+// mouse focuses a field or flips a chip on the bar; a click anywhere125+// else closes the bar and lands where it was clicked.126+func (f *findBar) mouse(m *Model, e mouseEvent) tea.Cmd {127+ if e.kind != mousePress {128+ return nil129+ }130+ if e.y != contextLine {131+ m.find = f132+ m.closeOverlay()133+ return m.handlePress(tea.Mouse{X: e.x, Y: e.y, Button: e.button})134+ }135+ parts, xs := f.spans(m)136+ for i, p := range parts {137+ if e.x >= xs[i] && e.x < xs[i]+ansi.StringWidth(p.text) {138+ if p.field >= 0 {139+ f.focus(m, p.field)140+ return nil141+ }142+ return f.key(m, keyFor(p.toggle))143+ }144+ }145+ return nil146+}147+148+// keyFor makes a key press from a keystroke like "alt+c".149+func keyFor(s string) tea.KeyPressMsg {150+ k := tea.KeyPressMsg{}151+ parts := strings.Split(s, "+")152+ for _, p := range parts[:len(parts)-1] {153+ if p == "alt" {154+ k.Mod |= tea.ModAlt155+ }156+ }157+ k.Code = rune(parts[len(parts)-1][0])158+ return k159+}160+161+// status shows the keys for moving and replacing, and a reminder of the162+// option keys when there's room. Narrow screens keep the most useful keys.163+func (f *findBar) status(m *Model) (string, string) {164+ pairs := []string{"Enter", "next", "Shift+Enter", "previous", "Esc", "close"}165+ if f.replace {166+ pairs = []string{"Enter", "replace", "Ctrl+Enter", "all", "Tab", "field", "Esc", "close"}167+ }168+ desc := "Alt+C/W/R/= options"169+ if f.scope != nil || m.book().Len() > 1 {170+ desc = "Alt+C/W/R/=/S options"171+ }172+ for {173+ keys := m.th.KeyHints(pairs...)174+ switch {175+ case ansi.StringWidth(desc)+3+ansi.StringWidth(keys) <= m.width:176+ return m.th.Muted.Render(desc), keys177+ case desc != "":178+ desc = ""179+ case len(pairs) > 2:180+ pairs = append(pairs[:len(pairs)-4], pairs[len(pairs)-2:]...) // keep Esc181+ default:182+ return "", keys183+ }184+ }185+}