The palette and every other picker are one component that needs only a theme, the screen size, the edit line, closing and recording an answer (picker.Host). The model implements it through host, an adapter that keeps these methods off ui.Model's exported API. Items pick through closures over the model instead of being handed it.
- commit
- 4e8f94e5360d1b780ae63f3f4488ab7fc8162b1a
- parent
- c0462c2be7ed95450a11a8e8354c19169fc6a393
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 5 | +- |
| M | internal/ | 3 | +- |
| M | internal/ | 15 | +- |
| M | internal/ | 13 | +- |
| M | internal/ | 6 | +- |
| A | internal/ | 29 | ++ |
| M | internal/ | 9 | +- |
| M | internal/ | 5 | +- |
| M | internal/ | 13 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 11 | +- |
| M | internal/ | 44 | ++-- |
| M | internal/ | 5 | +- |
| M | internal/ | 36 | +- |
| M | internal/ | 357 | +--------------------------- |
| M | internal/ | 65 | +++-- |
| A | internal/ | 391 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 167 | +++++++++++++ |
| M | internal/ | 24 | +- |
| M | internal/ | 11 | +- |
| M | internal/ | 4 | +- |
| M | internal/ | 26 | +- |
| M | internal/ | 29 | +- |
| M | internal/ | 4 | +- |
@@ -12,6 +12,7 @@ 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"1516 "github.com/FelineStateMachine/012/internal/ui/theme"1617 )1718 @@ -392,7 +393,7 @@ func (p *filterPicker) Cursor() (int, int) {
392393 if p.field == 1 {393394 return x + 1 + len(" If ") + ansi.StringWidth(p.condChip()) + 2 + caret, y + 1394395 }395- return x + 1 + ansi.StringWidth(searchPrompt) + caret, y + 3396+ return x + 1 + ansi.StringWidth(picker.SearchPrompt) + caret, y + 3396397 }397398 398399 // condChip is the condition's name between arrows that change it.@@ -425,7 +426,7 @@ func (p *filterPicker) Layout() []overlay.Box {
425426 if p.field == 0 {426427 search = m.line.Text()427428 }428- input := m.th.Title.Render(searchPrompt) + search429+ input := m.th.Title.Render(picker.SearchPrompt) + search429430 if search == "" {430431 input += m.th.Muted.Render("Search values")431432 }@@ -6,6 +6,7 @@ import (
66 77 tea "charm.land/bubbletea/v2"88 "github.com/FelineStateMachine/012/internal/ui/overlay"9+ "github.com/FelineStateMachine/012/internal/ui/picker"910 "github.com/charmbracelet/x/ansi"1011 )1112 @@ -25,7 +26,7 @@ func (f *findBar) parts(m *Model) []findPart {
2526 if f.field == i {2627 style = m.th.Key2728 }28- return style.Render(name) + m.th.Muted.Render(searchPrompt)29+ return style.Render(name) + m.th.Muted.Render(picker.SearchPrompt)2930 }3031 parts := []findPart{{text: label("Find", 0) + f.fieldText(m, 0), field: 0}}3132 if f.replace {@@ -12,6 +12,7 @@ import (
1212 1313 "github.com/FelineStateMachine/012/internal/sheet"1414 "github.com/FelineStateMachine/012/internal/ui/overlay"15+ "github.com/FelineStateMachine/012/internal/ui/picker"1516 "github.com/FelineStateMachine/012/internal/ui/theme"1617 )1718 @@ -22,8 +23,8 @@ func init() {
2223 return nil2324 }},2425 &command{id: "help.functions", macro: macroNever, title: "Function list", desc: "Search the functions formulas can use", run: func(m *Model) tea.Cmd {25- p := newPicker(m, "Functions", "Type a function name, e.g. sum or if", 100, functionItems())26- p.action = "insert"26+ p := newPicker(m, "Functions", "Type a function name, e.g. sum or if", 100, functionItems(m))27+ p.Action = "insert"2728 m.openOverlay(p)2829 return nil2930 }},@@ -53,13 +54,13 @@ func version() string {
5354 5455 // functionItems lists the spreadsheet functions for the function list;5556 // picking one starts a formula with it.56-func functionItems() []pickItem {57+func functionItems(m *Model) []picker.Item {5758 fns := sheet.Funcs()58- items := make([]pickItem, len(fns))59+ items := make([]picker.Item, len(fns))5960 for i, f := range fns {60- items[i] = pickItem{61- title: f.Name + "(" + f.Args + ")", name: len(f.Name), detail: f.Desc, desc: f.Desc,62- pick: func(m *Model) tea.Cmd {61+ items[i] = picker.Item{62+ Title: f.Name + "(" + f.Args + ")", Name: len(f.Name), Detail: f.Desc, Desc: f.Desc,63+ Pick: func() tea.Cmd {6364 m.closeOverlay()6465 m.startEntry(modeEnter, "="+f.Name+"(")6566 return nil@@ -4,6 +4,7 @@ import (
44 tea "charm.land/bubbletea/v2"55 66 "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/picker"78 )89 910 // Hidden sheets, as in Sheets: the tab menu's Hide sheet takes a sheet@@ -40,15 +41,15 @@ func (m *Model) hideSheet() tea.Cmd {
4041 // openHiddenPicker lists the hidden sheets; Enter shows the one picked4142 // again. A macro records the pick as the command's answer.4243 func (m *Model) openHiddenPicker() {43- var items []pickItem44+ var items []picker.Item4445 for _, s := range m.book().HiddenSheets() {4546 detail := "empty"4647 if used, ok := s.UsedRange(); ok {4748 detail = used.String() + ", " + cellCount(s.Len())4849 }49- items = append(items, pickItem{50- title: s.Name(), name: len(s.Name()), detail: detail, desc: "Show " + s.Name() + " again",51- pick: func(m *Model) tea.Cmd {50+ items = append(items, picker.Item{51+ Title: s.Name(), Name: len(s.Name()), Detail: detail, Desc: "Show " + s.Name() + " again",52+ Pick: func() tea.Cmd {5253 m.closeOverlay()5354 if err := m.book().UnhideSheet(s); err != nil {5455 m.fail(err.Error())@@ -61,8 +62,8 @@ func (m *Model) openHiddenPicker() {
6162 })6263 }6364 p := newPicker(m, "Hidden sheets", "Type a sheet name", 60, items)64- p.action = "unhide"65- p.answers = true65+ p.Action = "unhide"66+ p.Answers = true6667 m.openOverlay(p)6768 }6869 @@ -3,6 +3,8 @@ package ui
33 import (44 "strings"55 "testing"6+7+ "github.com/FelineStateMachine/012/internal/ui/picker"68 )79 810 // hiddenBook is a model of three sheets, Sheet1 reading Data, showing@@ -84,8 +86,8 @@ func TestHiddenSheetsPicker(t *testing.T) {
8486 }8587 run(m, m.runCommand("sheet.hide"))8688 run(m, m.runCommand("sheet.unhide"))87- p, ok := m.overlay.(*picker)88- if !ok || p.title != "Hidden sheets" || len(p.items) != 1 || p.items[0].title != "Data" {89+ p, ok := m.overlay.(*picker.Picker)90+ if !ok || p.Title() != "Hidden sheets" || len(p.Items) != 1 || p.Items[0].Title != "Data" {8991 t.Fatalf("overlay %#v", m.overlay)9092 }9193 if !strings.Contains(screen(m), "Hidden sheets") || !strings.Contains(screen(m), "A1, 1 cell") {@@ -0,0 +1,29 @@
1+package ui2+3+import (4+ "github.com/FelineStateMachine/012/internal/ui/lineedit"5+ "github.com/FelineStateMachine/012/internal/ui/picker"6+ "github.com/FelineStateMachine/012/internal/ui/theme"7+)8+9+// host is how components in packages of their own reach the model. It10+// implements each one's Host interface, the little it acts on, so the11+// model's exported methods stay what cmd/012 and serve use, and a12+// component sees only its own interface.13+type host struct{ m *Model }14+15+func (m *Model) host() host { return host{m} }16+17+var _ picker.Host = host{}18+19+func (h host) Theme() *theme.Theme { return &h.m.th }20+func (h host) Size() (width, height int) { return h.m.width, h.m.height }21+func (h host) Line() *lineedit.Line { return &h.m.line }22+func (h host) Close() { h.m.closeOverlay() }23+24+func (h host) RecordAnswer(answer string, cancelled bool) { h.m.recordAnswer(answer, cancelled) }25+26+// newPicker returns a picker of items over the model; the caller opens it.27+func newPicker(m *Model, title, placeholder string, maxW int, items []picker.Item) *picker.Picker {28+ return picker.New(m.host(), title, placeholder, maxW, items)29+}@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/fileio"1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/picker"1112 )1213 1314 // Where File > Import puts what it reads, as Sheets' Import location:@@ -44,13 +45,13 @@ func (m *Model) askImportPlace(name string) tea.Cmd {
4445 if k.HoldsSheets() {4546 insert, as = "Insert new sheets", " as new sheets"4647 }47- item := func(title, detail, desc string, run func(m *Model) tea.Cmd) pickItem {48- return pickItem{title: title, name: len(title), detail: detail, desc: desc, pick: func(m *Model) tea.Cmd {48+ item := func(title, detail, desc string, run func(m *Model) tea.Cmd) picker.Item {49+ return picker.Item{Title: title, Name: len(title), Detail: detail, Desc: desc, Pick: func() tea.Cmd {4950 m.closeOverlay()5051 return run(m)5152 }}5253 }53- items := []pickItem{item(insert, "after the others", "Add "+base+as,54+ items := []picker.Item{item(insert, "after the others", "Add "+base+as,5455 func(m *Model) tea.Cmd { return m.startImport(name, fileio.Options{}, placeNewSheets) })}5556 if !k.HoldsSheets() {5657 items = append(items, item("Replace current sheet", m.sheet.Name(), "Put "+base+" in place of "+m.sheet.Name()+", keeping its name",@@ -63,7 +64,7 @@ func (m *Model) askImportPlace(name string) tea.Cmd {
6364 items = append(items, item("Replace spreadsheet", detail, "Open "+base+" instead, as File > Open does",6465 func(m *Model) tea.Cmd { return m.confirmImport(name, fileio.Options{}) }))6566 p := newPicker(m, "Import "+base, "Import location", 60, items)66- p.action = "import"67+ p.Action = "import"6768 m.openOverlay(p)6869 return nil6970 }@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/fileio"1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/picker"1112 )1213 1314 // importInto runs File > Import on name and picks place in the location@@ -16,8 +17,8 @@ func importInto(t *testing.T, m *Model, name, place string) {
1617 t.Helper()1718 m.runCommand("file.import")1819 press(t, m, name, "<enter>")19- p, ok := m.overlay.(*picker)20- if !ok || !strings.HasPrefix(p.title, "Import ") {20+ p, ok := m.overlay.(*picker.Picker)21+ if !ok || !strings.HasPrefix(p.Title(), "Import ") {2122 t.Fatalf("no location picker: %T", m.overlay)2223 }2324 press(t, m, place, "<enter>")@@ -9,6 +9,7 @@ import (
99 1010 "github.com/FelineStateMachine/012/internal/macro"1111 "github.com/FelineStateMachine/012/internal/sheet"12+ "github.com/FelineStateMachine/012/internal/ui/picker"1213 )1314 1415 // Macros, as Google Sheets' Extensions > Macros, under Data > Macros:@@ -102,13 +103,13 @@ func (m *Model) runShortcut(key string) (tea.Cmd, bool) {
102103 103104 // macroRunItems lists the saved macros for the run picker and the104105 // palette; picking one runs it.105-func macroRunItems(m *Model) []pickItem {106- var items []pickItem106+func macroRunItems(m *Model) []picker.Item {107+ var items []picker.Item107108 for _, mc := range m.book().Macros() {108- items = append(items, pickItem{109- title: mc.Name, name: len(mc.Name), detail: "Data › Macros", key: shortcutLabel(mc.Key),110- desc: "Run the macro " + mc.Name, off: m.rec != nil,111- pick: func(m *Model) tea.Cmd {109+ items = append(items, picker.Item{110+ Title: mc.Name, Name: len(mc.Name), Detail: "Data › Macros", Key: shortcutLabel(mc.Key),111+ Desc: "Run the macro " + mc.Name, Off: m.rec != nil,112+ Pick: func() tea.Cmd {112113 m.closeOverlay()113114 return m.runMacro(mc)114115 },@@ -307,7 +307,7 @@ func TestPaletteAndPickerRunMacros(t *testing.T) {
307307 run(m, m.runCommand("macro.run"))308308 press(t, m, "<enter>")309309 if m.note != "Ran Stamp" || m.overlay != nil {310- t.Errorf("picker: note %q", m.note)310+ t.Errorf("Picker: note %q", m.note)311311 }312312 }313313 @@ -7,6 +7,7 @@ import (
77 "strings"88 99 "github.com/FelineStateMachine/012/internal/sheet"10+ "github.com/FelineStateMachine/012/internal/ui/picker"1011 )1112 1213 // What scripts do to the selection, the sheets and through commands; the@@ -295,7 +296,7 @@ func (h scriptHost) answer(c *command, answer *string) error {
295296 // overlays (dialogs, pickers) can't be answered by a script.296297 func (h scriptHost) choose(c *command, answer *string) error {297298 m := h.m298- if p, ok := m.overlay.(*picker); ok && p.answers {299+ if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {299300 return h.pickAnswer(c, p, answer)300301 }301302 bar, ok := m.overlay.(*choiceBar)@@ -321,10 +322,10 @@ func (h scriptHost) choose(c *command, answer *string) error {
321322 322323 // pickAnswer answers a picker that asks a command's question with the323324 // title of one of its items.324-func (h scriptHost) pickAnswer(c *command, p *picker, answer *string) error {325+func (h scriptHost) pickAnswer(c *command, p *picker.Picker, answer *string) error {325326 m := h.m326327 if answer != nil {327- if cmd, ok := p.answer(m, *answer); ok {328+ if cmd, ok := p.Answer(*answer); ok {328329 m.macros.cmds = append(m.macros.cmds, cmd)329330 if m.mode == modeError {330331 return h.failure()@@ -334,8 +335,8 @@ func (h scriptHost) pickAnswer(c *command, p *picker, answer *string) error {
334335 }335336 m.closeOverlay()336337 var titles []string337- for _, it := range p.items {338- titles = append(titles, it.title)338+ for _, it := range p.Items {339+ titles = append(titles, it.Title)339340 }340341 return fmt.Errorf("%s asks for one of %s: give run(%q, answer=...)", c.title, strings.Join(titles, ", "), c.id)341342 }@@ -13,6 +13,7 @@ import (
1313 1414 "github.com/FelineStateMachine/012/internal/macro"1515 "github.com/FelineStateMachine/012/internal/sheet"16+ "github.com/FelineStateMachine/012/internal/ui/picker"1617 )1718 1819 // Managing macros, as Sheets' Extensions > Macros > Manage macros: a@@ -23,7 +24,8 @@ import (
2324 2425 // macrosPicker is the picker of saved macros with its extra keys.2526 type macrosPicker struct {26- *picker27+ m *Model // the model it acts on28+ *picker.Picker2729 msg string // feedback on the last action, e.g. a deletion2830 }2931 @@ -32,22 +34,22 @@ const writeMacroTitle = "+ Write a macro"
3234 // openMacros opens the manager with the macro named sel highlighted.3335 func (m *Model) openMacros(sel string) {3436 p := newPicker(m, "Macros", "Type a macro's name", 72, macroManageItems(m))35- p.action = "run"36- for i, pm := range p.shown {37- if strings.EqualFold(pm.item.title, sel) {37+ p.Action = "run"38+ for i, pm := range p.Shown() {39+ if strings.EqualFold(pm.Item.Title, sel) {3840 p.Sel = i3941 }4042 }41- m.openOverlay(¯osPicker{picker: p})43+ m.openOverlay(¯osPicker{m: m, Picker: p})4244 }4345 4446 // macroManageItems lists "Write a macro" and then every macro.45-func macroManageItems(m *Model) []pickItem {46- var items []pickItem47+func macroManageItems(m *Model) []picker.Item {48+ var items []picker.Item4749 if m.macros.editor {48- items = append(items, pickItem{49- title: writeMacroTitle, name: len(writeMacroTitle), desc: "Write a new macro script in your editor",50- pick: func(m *Model) tea.Cmd {50+ items = append(items, picker.Item{51+ Title: writeMacroTitle, Name: len(writeMacroTitle), Desc: "Write a new macro script in your editor",52+ Pick: func() tea.Cmd {5153 m.closeOverlay()5254 return m.newMacro()5355 },@@ -55,10 +57,10 @@ func macroManageItems(m *Model) []pickItem {
5557 }5658 for _, mc := range m.book().Macros() {5759 lines := strings.Count(strings.TrimRight(mc.Source, "\n"), "\n") + 158- items = append(items, pickItem{59- title: mc.Name, name: len(mc.Name), detail: strconv.Itoa(lines) + " lines", key: shortcutLabel(mc.Key),60- desc: "Run " + mc.Name,61- pick: func(m *Model) tea.Cmd {60+ items = append(items, picker.Item{61+ Title: mc.Name, Name: len(mc.Name), Detail: strconv.Itoa(lines) + " lines", Key: shortcutLabel(mc.Key),62+ Desc: "Run " + mc.Name,63+ Pick: func() tea.Cmd {6264 m.closeOverlay()6365 return m.runMacro(mc)6466 },@@ -69,10 +71,10 @@ func macroManageItems(m *Model) []pickItem {
6971 7072 // current is the macro highlighted, if any.7173 func (p *macrosPicker) current(m *Model) (sheet.Macro, bool) {72- if p.picker.Sel >= len(p.shown) || p.shown[p.picker.Sel].item.title == writeMacroTitle {74+ if p.Picker.Sel >= len(p.Shown()) || p.Shown()[p.Picker.Sel].Item.Title == writeMacroTitle {7375 return sheet.Macro{}, false7476 }75- return m.book().Macro(p.shown[p.picker.Sel].item.title)77+ return m.book().Macro(p.Shown()[p.Picker.Sel].Item.Title)7678 }7779 7880 func (p *macrosPicker) Key(k tea.KeyPressMsg) tea.Cmd {@@ -85,7 +87,7 @@ func (p *macrosPicker) Key(k tea.KeyPressMsg) tea.Cmd {
8587 return nil8688 }8789 default:88- return p.picker.Key(k)90+ return p.Picker.Key(k)8991 }9092 switch k.String() {9193 case "f2":@@ -104,10 +106,10 @@ func (p *macrosPicker) Key(k tea.KeyPressMsg) tea.Cmd {
104106 case "ctrl+d":105107 m.book().DeleteMacro(mc.Name)106108 p.msg = "Deleted " + mc.Name + "; Ctrl+Z brings it back"107- p.items = macroManageItems(m)108- sel := p.picker.Sel109+ p.Items = macroManageItems(m)110+ sel := p.Picker.Sel109111 p.Changed()110- p.picker.Sel = max(min(sel, len(p.shown)-1), 0)112+ p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)111113 }112114 return nil113115 }@@ -129,7 +131,7 @@ func (p *macrosPicker) Status() (string, string) {
129131 if p.msg != "" {130132 return p.msg, keys131133 }132- desc, _ := p.picker.Status()134+ desc, _ := p.Picker.Status()133135 return desc, keys134136 }135137 @@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/macro"1010 "github.com/FelineStateMachine/012/internal/sheet"11+ "github.com/FelineStateMachine/012/internal/ui/picker"1112 )1213 1314 // Recording a macro, as Sheets' Extensions > Macros > Record macro: while@@ -131,14 +132,14 @@ func (m *Model) recordingCommand(c *command) tea.Cmd {
131132 cmd := c.run(m)132133 r.depth--133134 _, choosing := m.overlay.(*choiceBar)134- if p, ok := m.overlay.(*picker); ok && p.answers {135+ if p, ok := m.overlay.(*picker.Picker); ok && p.Answers {135136 choosing = true136137 }137138 switch {138139 case m.mode == modePrompt || choosing:139140 r.pending = c.id // recorded once answered140141 case m.overlay != nil:141- // A dialog or picker: what it does isn't recorded (see observe).142+ // A dialog or Picker: what it does isn't recorded (see observe).142143 case m.mode != modeError:143144 r.add(m, macro.Call("run", c.id))144145 }@@ -6,6 +6,7 @@ import (
66 tea "charm.land/bubbletea/v2"77 88 "github.com/FelineStateMachine/012/internal/sheet"9+ "github.com/FelineStateMachine/012/internal/ui/picker"910 )1011 1112 // Named ranges, as Sheets' Data > Named ranges: a picker lists them with@@ -29,7 +30,8 @@ func init() {
2930 3031 // namesPicker is the picker of named ranges with its extra keys.3132 type namesPicker struct {32- *picker33+ m *Model // the model it acts on34+ *picker.Picker3335 sel sheet.Rect // the selection when the picker opened, for "Add a range"3436 msg string // feedback on the last action, e.g. a deletion3537 }@@ -37,17 +39,17 @@ type namesPicker struct {
3739 // openNames opens the picker; sel is what "Add a range" names.3840 func (m *Model) openNames(sel sheet.Rect) {3941 p := newPicker(m, "Named ranges", "Type a name", 60, namesItems(m, sel))40- p.action = "go to"42+ p.Action = "go to"4143 m.clearSelection()42- m.openOverlay(&namesPicker{picker: p, sel: sel})44+ m.openOverlay(&namesPicker{m: m, Picker: p, sel: sel})4345 }4446 4547 // namesItems lists "Add a range" and then every named range.46-func namesItems(m *Model, sel sheet.Rect) []pickItem {48+func namesItems(m *Model, sel sheet.Rect) []picker.Item {4749 add := "+ Add a range"48- items := []pickItem{{49- title: add, name: len(add), detail: sel.String(), desc: "Name " + sel.String() + ", to use the name in formulas",50- pick: func(m *Model) tea.Cmd {50+ items := []picker.Item{{51+ Title: add, Name: len(add), Detail: sel.String(), Desc: "Name " + sel.String() + ", to use the name in formulas",52+ Pick: func() tea.Cmd {5153 m.closeOverlay()5254 m.defineName(sel, true)5355 return nil@@ -65,9 +67,9 @@ func namesItems(m *Model, sel sheet.Rect) []pickItem {
6567 case users > 1:6668 desc += ", used in " + strconv.Itoa(users) + " formulas"6769 }68- items = append(items, pickItem{69- title: n.Name, name: len(n.Name), detail: n.Ref(), desc: desc, off: n.Gone(),70- pick: func(m *Model) tea.Cmd {70+ items = append(items, picker.Item{71+ Title: n.Name, Name: len(n.Name), Detail: n.Ref(), Desc: desc, Off: n.Gone(),72+ Pick: func() tea.Cmd {7173 m.closeOverlay()7274 if m.refuseHidden(n.Sheet) {7375 return nil@@ -83,10 +85,10 @@ func namesItems(m *Model, sel sheet.Rect) []pickItem {
8385 8486 // current is the named range highlighted in the picker, if any.8587 func (p *namesPicker) current(m *Model) (sheet.Name, bool) {86- if p.picker.Sel >= len(p.shown) {88+ if p.Picker.Sel >= len(p.Shown()) {8789 return sheet.Name{}, false8890 }89- return m.sheet.LookupName(p.shown[p.picker.Sel].item.title)91+ return m.sheet.LookupName(p.Shown()[p.Picker.Sel].Item.Title)9092 }9193 9294 func (p *namesPicker) Key(k tea.KeyPressMsg) tea.Cmd {@@ -104,14 +106,14 @@ func (p *namesPicker) Key(k tea.KeyPressMsg) tea.Cmd {
104106 m.sheet.DeleteName(n.Name)105107 m.changed = true106108 p.msg = "Deleted " + n.Name + "; Ctrl+Z brings it back"107- p.items = namesItems(m, p.sel)108- sel := p.picker.Sel109+ p.Items = namesItems(m, p.sel)110+ sel := p.Picker.Sel109111 p.Changed()110- p.picker.Sel = max(min(sel, len(p.shown)-1), 0)112+ p.Picker.Sel = max(min(sel, len(p.Shown())-1), 0)111113 }112114 return nil113115 }114- return p.picker.Key(k)116+ return p.Picker.Key(k)115117 }116118 117119 func (p *namesPicker) Status() (string, string) {@@ -123,7 +125,7 @@ func (p *namesPicker) Status() (string, string) {
123125 if p.msg != "" {124126 return p.msg, keys125127 }126- desc, _ := p.picker.Status()128+ desc, _ := p.Picker.Status()127129 return desc, keys128130 }129131 @@ -2,17 +2,10 @@ package ui
22 33 import (44 "cmp"5- "maps"65 "slices"7- "strconv"8- "strings"96 107 tea "charm.land/bubbletea/v2"11- "github.com/charmbracelet/x/ansi"12- "github.com/sahilm/fuzzy"13-14- "github.com/FelineStateMachine/012/internal/ui/overlay"15- "github.com/FelineStateMachine/012/internal/ui/theme"8+ "github.com/FelineStateMachine/012/internal/ui/picker"169 )1710 1811 // The command palette ("Search the menus" in Sheets, Alt+/) finds any@@ -31,8 +24,8 @@ func init() {
3124 3225 // paletteItems lists every command: those in menus first, in menu order3326 // with their menu path, then the rest by title.34-func paletteItems(m *Model) []pickItem {35- var items []pickItem27+func paletteItems(m *Model) []picker.Item {28+ var items []picker.Item3629 seen := map[string]bool{"palette": true}3730 add := func(id, path string) {3831 if seen[id] {@@ -40,9 +33,9 @@ func paletteItems(m *Model) []pickItem {
4033 }4134 seen[id] = true4235 c := commands[id]43- items = append(items, pickItem{44- title: c.title, name: len(c.title), detail: path, key: m.shortcut(id), desc: c.desc,45- off: !c.available(m), pick: func(m *Model) tea.Cmd { return m.runFromOverlay(id) },36+ items = append(items, picker.Item{37+ Title: c.title, Name: len(c.title), Detail: path, Key: m.shortcut(id), Desc: c.desc,38+ Off: !c.available(m), Pick: func() tea.Cmd { return m.runFromOverlay(id) },4639 })4740 }4841 var walk func(items []menuItem, path string)@@ -70,341 +63,3 @@ func paletteItems(m *Model) []pickItem {
7063 }7164 return items7265 }73-74-// pickItem is one row of a picker.75-type pickItem struct {76- title string // e.g. "Save as"77- name int // bytes of title that are searched, e.g. just "SUM" of "SUM(value, ...)"78- detail string // a dimmed second column, also searched, e.g. the menu path79- key string // shortcut, shown as a key chip80- desc string // what it does, for the status line81- off bool // unavailable right now82- pick func(m *Model) tea.Cmd83-}84-85-// haystack is what a search matches against.86-func (it pickItem) haystack() string { return it.title[:it.name] + " " + it.detail }87-88-// pickMatch is an item that matches the search, with the matched byte89-// offsets in its title and detail.90-type pickMatch struct {91- item *pickItem92- inTitle, inDesc []int93-}94-95-// picker is a searchable list in a box, in the manner of fzf: a search96-// field on top, results below with matched characters highlighted, and97-// the highlighted result's description on the status line. The search is98-// edited in Model.line with the usual line-editing keys.99-type picker struct {100- m *Model // the model it acts on101- title string102- placeholder string103- maxW int104- action string // what Enter does, for the key hints105- items []pickItem106- shown []pickMatch107- overlay.List108-109- // enter, when set, gets the first look at Enter with the search text,110- // e.g. to take a typed path; it reports whether it handled it.111- enter func(m *Model, query string) (tea.Cmd, bool)112-113- // answers marks a picker that is the question of a command that114- // changes the workbook: a recording keeps the title picked as the115- // command's answer, and scripts answer it with a title.116- answers bool117-}118-119-const (120- pickerID = "picker"121- searchPrompt = " › "122-)123-124-func newPicker(m *Model, title, placeholder string, maxW int, items []pickItem) *picker {125- p := &picker{m: m, title: title, placeholder: placeholder, maxW: maxW, items: items}126- m.line.Clear()127- p.Changed()128- return p129-}130-131-func (p *picker) Indicator() string { return "MENU" }132-133-// Changed filters the items by the search. Best matches come first; ties134-// keep menu order.135-func (p *picker) Changed() {136- m := p.m137- p.Sel, p.Top = 0, 0138- p.shown = p.shown[:0]139- q := strings.TrimSpace(m.line.Text())140- if q == "" {141- for i := range p.items {142- p.shown = append(p.shown, pickMatch{item: &p.items[i]})143- }144- return145- }146- // Rank title and path matches together by fuzzy score, so a tight147- // match like "file" on the File menu beats letters scattered across a148- // title. Title matches get a small bonus and win ties. Titles with a149- // word starting with the query ("col" in Column width) come before150- // the rest, such as "Command line", which only spells it across words.151- names, hay := make([]string, len(p.items)), make([]string, len(p.items))152- for i, it := range p.items {153- names[i], hay[i] = it.title[:it.name], it.haystack()154- }155- const titleBonus = 10156- type ranked struct {157- pm pickMatch158- score int159- order int160- prefix bool // a word of the title starts with the query161- }162- best := map[int]*ranked{}163- for _, mt := range fuzzy.FindNoSort(q, names) {164- best[mt.Index] = &ranked{pickMatch{item: &p.items[mt.Index], inTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index, false}165- }166- for _, mt := range fuzzy.FindNoSort(q, hay) {167- if r, ok := best[mt.Index]; ok && r.score >= mt.Score {168- continue169- }170- it := &p.items[mt.Index]171- pm := pickMatch{item: it}172- for _, i := range mt.MatchedIndexes {173- switch {174- case i < it.name:175- pm.inTitle = append(pm.inTitle, i)176- case i >= it.name+2:177- pm.inDesc = append(pm.inDesc, i-it.name-2)178- }179- }180- best[mt.Index] = &ranked{pm, mt.Score, mt.Index, false}181- }182- for i, r := range best {183- r.prefix = wordPrefix(names[i], q)184- }185- all := slices.Collect(maps.Values(best))186- slices.SortFunc(all, func(a, b *ranked) int {187- if a.prefix != b.prefix {188- if a.prefix {189- return -1190- }191- return 1192- }193- if c := cmp.Compare(b.score, a.score); c != 0 {194- return c195- }196- return cmp.Compare(a.order, b.order)197- })198- for _, r := range all {199- p.shown = append(p.shown, r.pm)200- }201-}202-203-// wordPrefix reports whether a word of title starts with q, ignoring case.204-func wordPrefix(title, q string) bool {205- q = strings.ToLower(q)206- for w := range strings.FieldsSeq(strings.ToLower(title)) {207- if strings.HasPrefix(w, q) {208- return true209- }210- }211- return false212-}213-214-func (p *picker) Key(k tea.KeyPressMsg) tea.Cmd {215- m := p.m216- rows := p.rows(m)217- switch k.String() {218- case "up", "ctrl+p", "shift+tab":219- p.Move(-1, len(p.shown))220- case "down", "ctrl+n", "tab":221- p.Move(1, len(p.shown))222- case "pgup":223- p.Sel = max(p.Sel-rows, 0)224- case "pgdown":225- p.Sel = max(min(p.Sel+rows, len(p.shown)-1), 0)226- case "enter":227- return p.pick(m)228- case "esc":229- p.close(m)230- default:231- before := m.line.Text()232- m.line.Key(k)233- if m.line.Text() != before {234- p.Changed()235- }236- }237- return nil238-}239-240-func (p *picker) pick(m *Model) tea.Cmd {241- if p.enter != nil {242- if cmd, ok := p.enter(m, strings.TrimSpace(m.line.Text())); ok {243- return cmd244- }245- }246- if p.Sel >= len(p.shown) || p.shown[p.Sel].item.off {247- return nil248- }249- it := p.shown[p.Sel].item250- cmd := it.pick(m)251- if p.answers {252- m.recordAnswer(it.title, false)253- }254- return cmd255-}256-257-// close closes the picker without picking, which cancels the question258-// it asks.259-func (p *picker) close(m *Model) {260- m.closeOverlay()261- if p.answers {262- m.recordAnswer("", true)263- }264-}265-266-// answer picks the item titled title (in any case), as a script answers267-// the picker's question, and reports whether there was one.268-func (p *picker) answer(m *Model, title string) (tea.Cmd, bool) {269- for i := range p.items {270- if it := &p.items[i]; strings.EqualFold(it.title, title) && !it.off {271- return it.pick(m), true272- }273- }274- return nil, false275-}276-277-// Rows of the box: the top border, the search field, a separator, then278-// the results.279-const pickerFirstRow = 3280-281-func (p *picker) Mouse(e overlay.MouseEvent) tea.Cmd {282- m := p.m283- if e.Box != pickerID {284- if e.Kind == overlay.MousePress {285- p.close(m)286- }287- return nil288- }289- i := p.Top + e.Row - pickerFirstRow290- switch {291- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:292- p.Move(-1, len(p.shown))293- case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:294- p.Move(1, len(p.shown))295- case e.Row < pickerFirstRow || i >= len(p.shown) || i >= p.Top+p.rows(m):296- case e.Kind == overlay.MouseMotion:297- p.Sel = i298- case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:299- p.Sel = i300- return p.pick(m)301- }302- return nil303-}304-305-func (p *picker) Status() (string, string) {306- m := p.m307- keys := m.th.KeyHints("Up/Down", "move", "Enter", cmp.Or(p.action, "run"), "Esc", "close")308- if p.Sel >= len(p.shown) {309- return "", keys310- }311- it := p.shown[p.Sel].item312- if it.off {313- return it.desc + " (not available now)", keys314- }315- return it.desc, keys316-}317-318-// rows is how many results show. The box shrinks from the bottom as the319-// search narrows, so the search field never moves, and it leaves the320-// status line visible.321-func (p *picker) rows(m *Model) int {322- return max(min(len(p.shown), 12, m.height-1-menuLine-1-4), 1)323-}324-325-func (p *picker) box(m *Model) (x, y, inner int) {326- tw, dw, kw := 0, 0, 0327- for _, it := range p.items {328- tw = max(tw, ansi.StringWidth(it.title))329- dw = max(dw, ansi.StringWidth(it.detail))330- kw = max(kw, ansi.StringWidth(it.key)+2)331- }332- inner = min(1+tw+3+dw+3+kw+1, p.maxW, m.width-2)333- inner = max(inner, min(40, m.width-2))334- return (m.width - inner - 2) / 2, menuLine + 1, inner335-}336-337-func (p *picker) Cursor() (int, int) {338- m := p.m339- x, y, _ := p.box(m)340- return x + 1 + ansi.StringWidth(searchPrompt) + ansi.StringWidth(m.line.Head()), y + 1341-}342-343-func (p *picker) Layout() []overlay.Box {344- m := p.m345- x, y, inner := p.box(m)346- rows := p.rows(m)347- if len(p.shown) > 0 {348- p.Show(rows)349- }350- input := m.th.Title.Render(searchPrompt) + m.line.Text()351- if len(m.line.Buf) == 0 {352- input += m.th.Muted.Render(p.placeholder)353- }354- lines := []string{theme.Cells(m.th.MenuBar, input, inner), theme.SepRow}355- lines = append(lines, p.resultRows(m, inner, rows)...)356- footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.items))357- return []overlay.Box{{ID: pickerID, X: x, Y: y, Lines: m.th.Frame(inner, p.title, footer, lines)}}358-}359-360-// resultRows lays out the visible results in columns: title, detail and361-// the shortcut chip at the right. The detail column is dropped when the362-// box is too narrow for it.363-func (p *picker) resultRows(m *Model, inner, rows int) []string {364- tw, kw := 0, 0365- for _, it := range p.items {366- tw = max(tw, ansi.StringWidth(it.title))367- if it.key != "" {368- kw = max(kw, ansi.StringWidth(it.key)+2)369- }370- }371- tw = min(tw, inner*3/5)372- dw := inner - 1 - tw - 3 - kw - 2373- out := make([]string, 0, rows)374- for r := range rows {375- i := p.Top + r376- if i >= len(p.shown) {377- text := ""378- if r == 0 {379- text = m.th.Muted.Render(" No matches")380- }381- out = append(out, theme.Cells(m.th.MenuBar, text, inner))382- continue383- }384- pm := p.shown[i]385- base, dim, hl := m.th.MenuBar, m.th.Muted, m.th.Match386- switch {387- case i == p.Sel:388- base, dim, hl = m.th.MenuSelected, m.th.MenuSelected, m.th.MatchSelected389- case pm.item.off:390- base, dim, hl = m.th.Disabled, m.th.Disabled, m.th.Disabled391- }392- row := base.Render(" ") + theme.HighlightMatches(pm.item.title, pm.inTitle, tw, base, hl)393- if dw >= 8 {394- row += base.Render(" ") + theme.HighlightMatches(pm.item.detail, pm.inDesc, dw, dim, hl)395- }396- k := ""397- switch {398- case pm.item.key == "":399- case i == p.Sel:400- k = base.Render(" " + pm.item.key + " ")401- case pm.item.off:402- k = m.th.Disabled.Render(" " + pm.item.key + " ")403- default:404- k = m.th.Chip(pm.item.key)405- }406- row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")407- out = append(out, ansi.Truncate(row, inner, ""))408- }409- return out410-}@@ -6,11 +6,12 @@ import (
66 "testing"77 88 tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/012/internal/ui/picker"910 )1011 11-func openPicker(t *testing.T, m *Model) *picker {12+func openPicker(t *testing.T, m *Model) *picker.Picker {1213 t.Helper()13- p, ok := m.overlay.(*picker)14+ p, ok := m.overlay.(*picker.Picker)1415 if !ok {1516 t.Fatalf("no picker open (mode %v, overlay %T)", m.mode, m.overlay)1617 }@@ -20,18 +21,18 @@ func openPicker(t *testing.T, m *Model) *picker {
2021 func selected(t *testing.T, m *Model) string {2122 t.Helper()2223 p := openPicker(t, m)23- if p.Sel >= len(p.shown) {24+ if p.Sel >= len(p.Shown()) {2425 return ""2526 }26- return p.shown[p.Sel].item.title27+ return p.Shown()[p.Sel].Item.Title2728 }2829 2930 func TestPaletteKeys(t *testing.T) {3031 for _, key := range []string{"<ctrl+k>", "<alt+/>", "<ctrl+shift+p>"} {3132 m := newModel()3233 press(t, m, key)33- if p := openPicker(t, m); len(p.shown) != len(p.items) || !strings.HasSuffix(line(m, menuLine), "MENU") {34- t.Errorf("%s: %d of %d shown", key, len(p.shown), len(p.items))34+ if p := openPicker(t, m); len(p.Shown()) != len(p.Items) || !strings.HasSuffix(line(m, menuLine), "MENU") {35+ t.Errorf("%s: %d of %d shown", key, len(p.Shown()), len(p.Items))3536 }3637 }3738 }@@ -43,8 +44,8 @@ func TestPaletteSearchRunsCommand(t *testing.T) {
4344 if got := selected(t, m); got != "Column width" {4445 t.Fatalf("best match %q", got)4546 }46- if len(p.shown[0].inTitle) != len("colwid") {47- t.Errorf("matched %v", p.shown[0].inTitle)47+ if len(p.Shown()[0].InTitle) != len("colwid") {48+ t.Errorf("matched %v", p.Shown()[0].InTitle)4849 }4950 if !strings.Contains(screen(m), "Column width") || !strings.Contains(screen(m), "Format") {5051 t.Errorf("result should show its menu path:\n%s", screen(m))@@ -65,8 +66,8 @@ func TestPaletteMatchesMenuPath(t *testing.T) {
6566 // File menu command through its path.6667 press(t, m, "<ctrl+k>", "file")6768 var got []string68- for _, pm := range openPicker(t, m).shown {69- got = append(got, pm.item.title)69+ for _, pm := range openPicker(t, m).Shown() {70+ got = append(got, pm.Item.Title)7071 }7172 if got[0] != "Open config file" {7273 t.Errorf("first match for file: %q", got[0])@@ -81,8 +82,8 @@ func TestPaletteMatchesMenuPath(t *testing.T) {
8182 }8283 // Up from the top wraps to the bottom.8384 press(t, m, "<up>", "<up>", "<up>", "<up>")84- if p := openPicker(t, m); p.Sel != len(p.shown)-1 {85- t.Errorf("wrapped to %d of %d", p.Sel, len(p.shown))85+ if p := openPicker(t, m); p.Sel != len(p.Shown())-1 {86+ t.Errorf("wrapped to %d of %d", p.Sel, len(p.Shown()))8687 }8788 }8889 @@ -91,38 +92,48 @@ func TestPaletteTitleMatchesFirst(t *testing.T) {
9192 // "sel" also matches "Save" through the l of its File path, but9293 // titles that match on their own rank first.9394 press(t, m, "<ctrl+k>", "sel")94- shown := openPicker(t, m).shown95+ shown := openPicker(t, m).Shown()9596 seenPathOnly := false9697 for _, pm := range shown {97- inTitle := strings.Contains(strings.ToLower(pm.item.title), "sel")98+ inTitle := strings.Contains(strings.ToLower(pm.Item.Title), "sel")9899 // Titles matching letter by letter ("Move sheet left") are99100 // title matches too, just not literal ones.100- pathOnly := len(pm.inTitle) < len("sel")101- if pathOnly && pm.item.title != "Select all" {101+ pathOnly := len(pm.InTitle) < len("sel")102+ if pathOnly && pm.Item.Title != "Select all" {102103 seenPathOnly = true103104 } else if seenPathOnly && inTitle {104- t.Errorf("%q ranked after a path-only match", pm.item.title)105+ t.Errorf("%q ranked after a path-only match", pm.Item.Title)105106 }106107 }107108 }108109 110+// wordPrefix reports whether a word of title starts with q, ignoring case.111+func wordPrefix(title, q string) bool {112+ for w := range strings.FieldsSeq(strings.ToLower(title)) {113+ if strings.HasPrefix(w, strings.ToLower(q)) {114+ return true115+ }116+ }117+ return false118+}119+109120 func TestPaletteWordPrefixFirst(t *testing.T) {110121 m := newModel()111122 // "col" starts a word in the column commands; "Command line" only112123 // spells it across two words, so it ranks after them.113124 press(t, m, "<ctrl+k>", "col")114- shown := openPicker(t, m).shown125+ shown := openPicker(t, m).Shown()115126 seenOther := false116127 for _, pm := range shown {117- prefix := wordPrefix(pm.item.title[:pm.item.name], "col")128+ prefix := wordPrefix(pm.Item.Title[:pm.Item.Name], "col")118129 if !prefix {119130 seenOther = true120131 } else if seenOther {121- t.Errorf("%q, a word-prefix match, ranked after other matches", pm.item.title)132+ t.Errorf("%q, a word-prefix match, ranked after other matches", pm.Item.Title)122133 }123134 }124- if len(shown) == 0 || !wordPrefix(shown[0].item.title, "col") {125- t.Errorf("first match for col: %+v", shown[0].item.title)135+ if len(shown) == 0 || !wordPrefix(shown[0].Item.Title, "col") {136+ t.Errorf("first match for col: %+v", shown[0].Item.Title)126137 }127138 }128139 @@ -135,7 +146,7 @@ func TestPaletteNoMatchAndEsc(t *testing.T) {
135146 press(t, m, "<enter>")136147 openPicker(t, m)137148 press(t, m, "<backspace>", "<backspace>", "<backspace>", "<backspace>")138- if p := openPicker(t, m); len(p.shown) != len(p.items) {149+ if p := openPicker(t, m); len(p.Shown()) != len(p.Items) {139150 t.Error("clearing the search did not show everything")140151 }141152 send(m, tea.PasteMsg{Content: "goto"})@@ -170,16 +181,16 @@ func TestPaletteMouse(t *testing.T) {
170181 p := openPicker(t, m)171182 b := p.Layout()[0]172183 var row int173- for i, pm := range p.shown {174- if pm.item.title == "Go to" {184+ for i, pm := range p.Shown() {185+ if pm.Item.Title == "Go to" {175186 row = i176187 }177188 }178- mouseAt(m, tea.MouseMotionMsg{X: b.X + 4, Y: b.Y + pickerFirstRow + row})189+ mouseAt(m, tea.MouseMotionMsg{X: b.X + 4, Y: b.Y + picker.FirstRow + row})179190 if selected(t, m) != "Go to" {180191 t.Fatalf("hover selected %q", selected(t, m))181192 }182- leftClick(m, b.X+4, b.Y+pickerFirstRow+row)193+ leftClick(m, b.X+4, b.Y+picker.FirstRow+row)183194 if m.mode != modePrompt || m.prompt.label != "Go to:" {184195 t.Errorf("click ran: mode %v", m.mode)185196 }@@ -0,0 +1,391 @@
1+// Package picker is the searchable list behind the command palette and2+// every other picker of the UI (functions, sheets, named ranges, macros,3+// themes, files to import): a search field on top, results below with4+// matched characters highlighted, in the manner of fzf. It knows the UI5+// only through Host.6+package picker7+8+import (9+ "cmp"10+ "maps"11+ "slices"12+ "strconv"13+ "strings"14+15+ tea "charm.land/bubbletea/v2"16+ "github.com/charmbracelet/x/ansi"17+ "github.com/sahilm/fuzzy"18+19+ "github.com/FelineStateMachine/012/internal/ui/lineedit"20+ "github.com/FelineStateMachine/012/internal/ui/overlay"21+ "github.com/FelineStateMachine/012/internal/ui/theme"22+)23+24+// Host is what a picker needs of the UI.25+type Host interface {26+ Theme() *theme.Theme27+ Size() (width, height int)28+ // Line is the shared edit line the search is typed in.29+ Line() *lineedit.Line30+ // Close closes the open overlay.31+ Close()32+ // RecordAnswer tells a macro recording the answer to the question of33+ // the command that opened the picker, or that it was cancelled.34+ RecordAnswer(answer string, cancelled bool)35+}36+37+// Item is one row of a picker.38+type Item struct {39+ Title string // e.g. "Save as"40+ Name int // bytes of Title that are searched, e.g. just "SUM" of "SUM(value, ...)"41+ Detail string // a dimmed second column, also searched, e.g. the menu path42+ Key string // shortcut, shown as a key chip43+ Desc string // what it does, for the status line44+ Off bool // unavailable right now45+ Pick func() tea.Cmd46+}47+48+// haystack is what a search matches against.49+func (it Item) haystack() string { return it.Title[:it.Name] + " " + it.Detail }50+51+// Match is an item that matches the search, with the matched byte52+// offsets in its title and detail.53+type Match struct {54+ Item *Item55+ InTitle, InDetail []int56+}57+58+// Picker is a searchable list in a box: a search field on top, results59+// below with matched characters highlighted, and the highlighted result's60+// description on the status line. The search is edited in the host's61+// edit line with the usual line-editing keys.62+type Picker struct {63+ h Host64+ title string65+ placeholder string66+ maxW int67+ shown []Match68+ overlay.List69+70+ // Action is what Enter does, for the key hints ("run" by default).71+ Action string72+ Items []Item73+74+ // Enter, when set, gets the first look at Enter with the search text,75+ // e.g. to take a typed path; it reports whether it handled it.76+ Enter func(query string) (tea.Cmd, bool)77+78+ // Answers marks a picker that is the question of a command that79+ // changes the workbook: a recording keeps the title picked as the80+ // command's answer, and scripts answer it with a title.81+ Answers bool82+}83+84+const (85+ // ID identifies the picker's box in mouse events.86+ ID = "picker"87+ // SearchPrompt starts the search field.88+ SearchPrompt = " › "89+)90+91+// New returns a picker of items titled title, at most maxW columns wide92+// inside its frame, with the edit line cleared for the search.93+func New(h Host, title, placeholder string, maxW int, items []Item) *Picker {94+ p := &Picker{h: h, title: title, placeholder: placeholder, maxW: maxW, Items: items}95+ h.Line().Clear()96+ p.Changed()97+ return p98+}99+100+// Title is the picker's title, on its frame.101+func (p *Picker) Title() string { return p.title }102+103+// Shown are the items that match the search, best first.104+func (p *Picker) Shown() []Match { return p.shown }105+106+// Selected is the highlighted item, or nil when nothing matches.107+func (p *Picker) Selected() *Item {108+ if p.Sel >= len(p.shown) {109+ return nil110+ }111+ return p.shown[p.Sel].Item112+}113+114+func (p *Picker) Indicator() string { return "MENU" }115+116+// Changed filters the items by the search. Best matches come first; ties117+// keep menu order.118+func (p *Picker) Changed() {119+ p.Sel, p.Top = 0, 0120+ p.shown = p.shown[:0]121+ q := strings.TrimSpace(p.h.Line().Text())122+ if q == "" {123+ for i := range p.Items {124+ p.shown = append(p.shown, Match{Item: &p.Items[i]})125+ }126+ return127+ }128+ // Rank title and path matches together by fuzzy score, so a tight129+ // match like "file" on the File menu beats letters scattered across a130+ // title. Title matches get a small bonus and win ties. Titles with a131+ // word starting with the query ("col" in Column width) come before132+ // the rest, such as "Command line", which only spells it across words.133+ names, hay := make([]string, len(p.Items)), make([]string, len(p.Items))134+ for i, it := range p.Items {135+ names[i], hay[i] = it.Title[:it.Name], it.haystack()136+ }137+ const titleBonus = 10138+ type ranked struct {139+ pm Match140+ score int141+ order int142+ prefix bool // a word of the title starts with the query143+ }144+ best := map[int]*ranked{}145+ for _, mt := range fuzzy.FindNoSort(q, names) {146+ best[mt.Index] = &ranked{Match{Item: &p.Items[mt.Index], InTitle: mt.MatchedIndexes}, mt.Score + titleBonus, mt.Index, false}147+ }148+ for _, mt := range fuzzy.FindNoSort(q, hay) {149+ if r, ok := best[mt.Index]; ok && r.score >= mt.Score {150+ continue151+ }152+ it := &p.Items[mt.Index]153+ pm := Match{Item: it}154+ for _, i := range mt.MatchedIndexes {155+ switch {156+ case i < it.Name:157+ pm.InTitle = append(pm.InTitle, i)158+ case i >= it.Name+2:159+ pm.InDetail = append(pm.InDetail, i-it.Name-2)160+ }161+ }162+ best[mt.Index] = &ranked{pm, mt.Score, mt.Index, false}163+ }164+ for i, r := range best {165+ r.prefix = wordPrefix(names[i], q)166+ }167+ all := slices.Collect(maps.Values(best))168+ slices.SortFunc(all, func(a, b *ranked) int {169+ if a.prefix != b.prefix {170+ if a.prefix {171+ return -1172+ }173+ return 1174+ }175+ if c := cmp.Compare(b.score, a.score); c != 0 {176+ return c177+ }178+ return cmp.Compare(a.order, b.order)179+ })180+ for _, r := range all {181+ p.shown = append(p.shown, r.pm)182+ }183+}184+185+// wordPrefix reports whether a word of title starts with q, ignoring case.186+func wordPrefix(title, q string) bool {187+ q = strings.ToLower(q)188+ for w := range strings.FieldsSeq(strings.ToLower(title)) {189+ if strings.HasPrefix(w, q) {190+ return true191+ }192+ }193+ return false194+}195+196+func (p *Picker) Key(k tea.KeyPressMsg) tea.Cmd {197+ rows := p.rows()198+ switch k.String() {199+ case "up", "ctrl+p", "shift+tab":200+ p.Move(-1, len(p.shown))201+ case "down", "ctrl+n", "tab":202+ p.Move(1, len(p.shown))203+ case "pgup":204+ p.Sel = max(p.Sel-rows, 0)205+ case "pgdown":206+ p.Sel = max(min(p.Sel+rows, len(p.shown)-1), 0)207+ case "enter":208+ return p.pick()209+ case "esc":210+ p.Close()211+ default:212+ line := p.h.Line()213+ before := line.Text()214+ line.Key(k)215+ if line.Text() != before {216+ p.Changed()217+ }218+ }219+ return nil220+}221+222+func (p *Picker) pick() tea.Cmd {223+ if p.Enter != nil {224+ if cmd, ok := p.Enter(strings.TrimSpace(p.h.Line().Text())); ok {225+ return cmd226+ }227+ }228+ if p.Sel >= len(p.shown) || p.shown[p.Sel].Item.Off {229+ return nil230+ }231+ it := p.shown[p.Sel].Item232+ cmd := it.Pick()233+ if p.Answers {234+ p.h.RecordAnswer(it.Title, false)235+ }236+ return cmd237+}238+239+// Close closes the picker without picking, which cancels the question240+// it asks.241+func (p *Picker) Close() {242+ p.h.Close()243+ if p.Answers {244+ p.h.RecordAnswer("", true)245+ }246+}247+248+// Answer picks the item titled title (in any case), as a script answers249+// the picker's question, and reports whether there was one.250+func (p *Picker) Answer(title string) (tea.Cmd, bool) {251+ for i := range p.Items {252+ if it := &p.Items[i]; strings.EqualFold(it.Title, title) && !it.Off {253+ return it.Pick(), true254+ }255+ }256+ return nil, false257+}258+259+// FirstRow is the box's first result row, under the top border, the260+// search field and a separator.261+const FirstRow = 3262+263+func (p *Picker) Mouse(e overlay.MouseEvent) tea.Cmd {264+ if e.Box != ID {265+ if e.Kind == overlay.MousePress {266+ p.Close()267+ }268+ return nil269+ }270+ i := p.Top + e.Row - FirstRow271+ switch {272+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelUp:273+ p.Move(-1, len(p.shown))274+ case e.Kind == overlay.MouseWheel && e.Button == tea.MouseWheelDown:275+ p.Move(1, len(p.shown))276+ case e.Row < FirstRow || i >= len(p.shown) || i >= p.Top+p.rows():277+ case e.Kind == overlay.MouseMotion:278+ p.Sel = i279+ case e.Kind == overlay.MousePress && e.Button == tea.MouseLeft:280+ p.Sel = i281+ return p.pick()282+ }283+ return nil284+}285+286+func (p *Picker) Status() (string, string) {287+ keys := p.h.Theme().KeyHints("Up/Down", "move", "Enter", cmp.Or(p.Action, "run"), "Esc", "close")288+ if p.Sel >= len(p.shown) {289+ return "", keys290+ }291+ it := p.shown[p.Sel].Item292+ if it.Off {293+ return it.Desc + " (not available now)", keys294+ }295+ return it.Desc, keys296+}297+298+// rows is how many results show. The box shrinks from the bottom as the299+// search narrows, so the search field never moves, and it leaves the300+// status line visible.301+func (p *Picker) rows() int {302+ _, height := p.h.Size()303+ return max(min(len(p.shown), 12, height-1-overlay.MenuLine-1-4), 1)304+}305+306+func (p *Picker) box() (x, y, inner int) {307+ width, _ := p.h.Size()308+ tw, dw, kw := 0, 0, 0309+ for _, it := range p.Items {310+ tw = max(tw, ansi.StringWidth(it.Title))311+ dw = max(dw, ansi.StringWidth(it.Detail))312+ kw = max(kw, ansi.StringWidth(it.Key)+2)313+ }314+ inner = min(1+tw+3+dw+3+kw+1, p.maxW, width-2)315+ inner = max(inner, min(40, width-2))316+ return (width - inner - 2) / 2, overlay.MenuLine + 1, inner317+}318+319+func (p *Picker) Cursor() (int, int) {320+ x, y, _ := p.box()321+ return x + 1 + ansi.StringWidth(SearchPrompt) + ansi.StringWidth(p.h.Line().Head()), y + 1322+}323+324+func (p *Picker) Layout() []overlay.Box {325+ th, line := p.h.Theme(), p.h.Line()326+ x, y, inner := p.box()327+ rows := p.rows()328+ if len(p.shown) > 0 {329+ p.Show(rows)330+ }331+ input := th.Title.Render(SearchPrompt) + line.Text()332+ if len(line.Buf) == 0 {333+ input += th.Muted.Render(p.placeholder)334+ }335+ lines := []string{theme.Cells(th.MenuBar, input, inner), theme.SepRow}336+ lines = append(lines, p.resultRows(th, inner, rows)...)337+ footer := strconv.Itoa(len(p.shown)) + " of " + strconv.Itoa(len(p.Items))338+ return []overlay.Box{{ID: ID, X: x, Y: y, Lines: th.Frame(inner, p.title, footer, lines)}}339+}340+341+// resultRows lays out the visible results in columns: title, detail and342+// the shortcut chip at the right. The detail column is dropped when the343+// box is too narrow for it.344+func (p *Picker) resultRows(th *theme.Theme, inner, rows int) []string {345+ tw, kw := 0, 0346+ for _, it := range p.Items {347+ tw = max(tw, ansi.StringWidth(it.Title))348+ if it.Key != "" {349+ kw = max(kw, ansi.StringWidth(it.Key)+2)350+ }351+ }352+ tw = min(tw, inner*3/5)353+ dw := inner - 1 - tw - 3 - kw - 2354+ out := make([]string, 0, rows)355+ for r := range rows {356+ i := p.Top + r357+ if i >= len(p.shown) {358+ text := ""359+ if r == 0 {360+ text = th.Muted.Render(" No matches")361+ }362+ out = append(out, theme.Cells(th.MenuBar, text, inner))363+ continue364+ }365+ pm := p.shown[i]366+ base, dim, hl := th.MenuBar, th.Muted, th.Match367+ switch {368+ case i == p.Sel:369+ base, dim, hl = th.MenuSelected, th.MenuSelected, th.MatchSelected370+ case pm.Item.Off:371+ base, dim, hl = th.Disabled, th.Disabled, th.Disabled372+ }373+ row := base.Render(" ") + theme.HighlightMatches(pm.Item.Title, pm.InTitle, tw, base, hl)374+ if dw >= 8 {375+ row += base.Render(" ") + theme.HighlightMatches(pm.Item.Detail, pm.InDetail, dw, dim, hl)376+ }377+ k := ""378+ switch {379+ case pm.Item.Key == "":380+ case i == p.Sel:381+ k = base.Render(" " + pm.Item.Key + " ")382+ case pm.Item.Off:383+ k = th.Disabled.Render(" " + pm.Item.Key + " ")384+ default:385+ k = th.Chip(pm.Item.Key)386+ }387+ row += base.Render(strings.Repeat(" ", max(inner-ansi.StringWidth(row)-ansi.StringWidth(k)-1, 0))) + k + base.Render(" ")388+ out = append(out, ansi.Truncate(row, inner, ""))389+ }390+ return out391+}@@ -0,0 +1,167 @@
1+package picker2+3+import (4+ "strings"5+ "testing"6+7+ tea "charm.land/bubbletea/v2"8+ "github.com/charmbracelet/x/ansi"9+10+ "github.com/FelineStateMachine/012/internal/ui/lineedit"11+ "github.com/FelineStateMachine/012/internal/ui/overlay"12+ "github.com/FelineStateMachine/012/internal/ui/theme"13+)14+15+// fakeHost is a screen of its own with a record of what the picker did.16+type fakeHost struct {17+ th theme.Theme18+ line lineedit.Line19+ w, h int20+ closed int21+ answers []string22+}23+24+func newHost() *fakeHost { return &fakeHost{th: theme.New(true), w: 80, h: 24} }25+26+func (f *fakeHost) Theme() *theme.Theme { return &f.th }27+func (f *fakeHost) Size() (width, height int) { return f.w, f.h }28+func (f *fakeHost) Line() *lineedit.Line { return &f.line }29+func (f *fakeHost) Close() { f.closed++ }30+31+func (f *fakeHost) RecordAnswer(answer string, cancelled bool) {32+ if cancelled {33+ answer = "(cancelled)"34+ }35+ f.answers = append(f.answers, answer)36+}37+38+func items(picked *string, titles ...string) []Item {39+ out := make([]Item, len(titles))40+ for i, t := range titles {41+ out[i] = Item{Title: t, Name: len(t), Desc: "Does " + t, Pick: func() tea.Cmd { *picked = t; return nil }}42+ }43+ return out44+}45+46+func typeText(p *Picker, s string) {47+ for _, r := range s {48+ p.Key(tea.KeyPressMsg{Code: r, Text: string(r)})49+ }50+}51+52+func titles(p *Picker) []string {53+ var out []string54+ for _, m := range p.Shown() {55+ out = append(out, m.Item.Title)56+ }57+ return out58+}59+60+func TestSearchRanksWordPrefixesFirst(t *testing.T) {61+ h, picked := newHost(), ""62+ p := New(h, "Test", "Type", 60, items(&picked, "Command line", "Column width", "Save"))63+ if got := len(p.Shown()); got != 3 {64+ t.Fatalf("empty search shows %d", got)65+ }66+ typeText(p, "col")67+ if got := strings.Join(titles(p), ","); got != "Column width,Command line" {68+ t.Errorf("col matched %s", got)69+ }70+ if h.line.Text() != "col" {71+ t.Errorf("search typed %q", h.line.Text())72+ }73+}74+75+func TestEnterPicksAndRecords(t *testing.T) {76+ h, picked := newHost(), ""77+ p := New(h, "Test", "Type", 60, items(&picked, "Alpha", "Beta"))78+ p.Answers = true79+ p.Key(tea.KeyPressMsg{Code: tea.KeyDown})80+ p.Key(tea.KeyPressMsg{Code: tea.KeyEnter})81+ if picked != "Beta" || strings.Join(h.answers, ",") != "Beta" {82+ t.Errorf("picked %q, answers %v", picked, h.answers)83+ }84+ p.Key(tea.KeyPressMsg{Code: tea.KeyEscape})85+ if h.closed != 1 || h.answers[len(h.answers)-1] != "(cancelled)" {86+ t.Errorf("Esc: closed %d, answers %v", h.closed, h.answers)87+ }88+}89+90+func TestUnavailableItemsDontPick(t *testing.T) {91+ h, picked := newHost(), ""92+ its := items(&picked, "Undo")93+ its[0].Off = true94+ p := New(h, "Test", "Type", 60, its)95+ p.Key(tea.KeyPressMsg{Code: tea.KeyEnter})96+ if picked != "" {97+ t.Error("an unavailable item was picked")98+ }99+ if desc, _ := p.Status(); desc != "Does Undo (not available now)" {100+ t.Errorf("status %q", desc)101+ }102+ if _, ok := p.Answer("undo"); ok {103+ t.Error("a script answered with an unavailable item")104+ }105+}106+107+func TestEnterHookAndAnswer(t *testing.T) {108+ h, picked := newHost(), ""109+ p := New(h, "Test", "Type", 60, items(&picked, "Alpha"))110+ var query string111+ p.Enter = func(q string) (tea.Cmd, bool) { query = q; return nil, q == "/tmp" }112+ typeText(p, "/tmp")113+ p.Key(tea.KeyPressMsg{Code: tea.KeyEnter})114+ if query != "/tmp" || picked != "" {115+ t.Errorf("Enter hook got %q, picked %q", query, picked)116+ }117+ if _, ok := p.Answer("ALPHA"); !ok || picked != "Alpha" {118+ t.Errorf("answer picked %q", picked)119+ }120+}121+122+func TestLayoutAndMouse(t *testing.T) {123+ h, picked := newHost(), ""124+ p := New(h, "Test", "Type a name", 60, items(&picked, "Alpha", "Beta", "Gamma"))125+ boxes := p.Layout()126+ if len(boxes) != 1 || boxes[0].ID != ID {127+ t.Fatalf("layout %+v", boxes)128+ }129+ b := boxes[0]130+ if b.Y != overlay.MenuLine+1 || b.X+b.Width() > h.w || b.Height() != 3+3+1 {131+ t.Errorf("box at %d,%d, %dx%d", b.X, b.Y, b.Width(), b.Height())132+ }133+ out := ansi.Strip(strings.Join(b.Lines, "\n"))134+ for _, want := range []string{"Test", "Type a name", "Gamma", "3 of 3"} {135+ if !strings.Contains(out, want) {136+ t.Errorf("box lacks %q:\n%s", want, out)137+ }138+ }139+ x, y := p.Cursor()140+ if y != b.Y+1 || x != b.X+1+ansi.StringWidth(SearchPrompt) {141+ t.Errorf("cursor at %d,%d", x, y)142+ }143+ p.Mouse(overlay.MouseEvent{Kind: overlay.MouseMotion, Box: ID, Row: FirstRow + 2})144+ if p.Selected().Title != "Gamma" {145+ t.Errorf("hover selected %q", p.Selected().Title)146+ }147+ p.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft, Box: ID, Row: FirstRow + 1})148+ if picked != "Beta" {149+ t.Errorf("click picked %q", picked)150+ }151+ p.Mouse(overlay.MouseEvent{Kind: overlay.MousePress, Button: tea.MouseLeft})152+ if h.closed != 1 {153+ t.Error("a click outside didn't close the picker")154+ }155+}156+157+func TestNoMatches(t *testing.T) {158+ h, picked := newHost(), ""159+ p := New(h, "Test", "Type", 60, items(&picked, "Alpha"))160+ typeText(p, "zzq")161+ if p.Selected() != nil {162+ t.Error("something is selected")163+ }164+ if out := ansi.Strip(strings.Join(p.Layout()[0].Lines, "\n")); !strings.Contains(out, "No matches") || !strings.Contains(out, "0 of 1") {165+ t.Errorf("no-match box:\n%s", out)166+ }167+}@@ -8,6 +8,7 @@ import (
88 99 "github.com/FelineStateMachine/012/internal/sheet"1010 "github.com/FelineStateMachine/012/internal/ui/overlay"11+ "github.com/FelineStateMachine/012/internal/ui/picker"1112 )1213 1314 // What the pivot editor's keys do to the pivot, and how its fields read.@@ -224,13 +225,13 @@ func (e *pivotEditor) addField(m *Model, sect int) {
224225 used[f.Col] = true225226 }226227 }227- var items []pickItem228+ var items []picker.Item228229 for col := p.Range.From.Col; col <= p.Range.To.Col && !p.Lost; col++ {229230 name := m.book().FieldName(p, col)230- items = append(items, pickItem{231- title: name, name: len(name), detail: "column " + sheet.ColName(col), off: used[col],232- desc: "Add " + name + " to " + sectNames[sect],233- pick: func(m *Model) tea.Cmd {231+ items = append(items, picker.Item{232+ Title: name, Name: len(name), Detail: "column " + sheet.ColName(col), Off: used[col],233+ Desc: "Add " + name + " to " + sectNames[sect],234+ Pick: func() tea.Cmd {234235 m.closeOverlay()235236 e.add(m, sect, col)236237 e.reopen(m)@@ -239,8 +240,8 @@ func (e *pivotEditor) addField(m *Model, sect int) {
239240 })240241 }241242 fp := newPicker(m, "Add to "+sectNames[sect], "Type a field name", 50, items)242- fp.action = "add"243- m.openOverlay(&fieldPicker{picker: fp, back: e})243+ fp.Action = "add"244+ m.openOverlay(&fieldPicker{m: m, Picker: fp, back: e})244245 }245246 246247 // add puts the field in column col at the end of a section.@@ -262,7 +263,8 @@ func (e *pivotEditor) add(m *Model, sect, col int) {
262263 263264 // fieldPicker is the picker of fields to add; Esc returns to the editor.264265 type fieldPicker struct {265- *picker266+ m *Model // the model it acts on267+ *picker.Picker266268 back *pivotEditor267269 }268270 @@ -273,17 +275,17 @@ func (f *fieldPicker) Key(k tea.KeyPressMsg) tea.Cmd {
273275 f.back.reopen(m)274276 return nil275277 }276- return f.picker.Key(k)278+ return f.Picker.Key(k)277279 }278280 279281 func (f *fieldPicker) Mouse(e overlay.MouseEvent) tea.Cmd {280282 m := f.m281- if e.Box != pickerID && e.Kind == overlay.MousePress {283+ if e.Box != picker.ID && e.Kind == overlay.MousePress {282284 m.closeOverlay()283285 f.back.reopen(m)284286 return nil285287 }286- return f.picker.Mouse(e)288+ return f.Picker.Mouse(e)287289 }288290 289291 // editFilter opens the values list of filter i, in the manner of a@@ -4,6 +4,7 @@ import (
44 tea "charm.land/bubbletea/v2"55 66 "github.com/FelineStateMachine/012/internal/sheet"7+ "github.com/FelineStateMachine/012/internal/ui/picker"78 )89 910 // Sheets work as in Google Sheets: Ctrl+PgDn and Ctrl+PgUp (or Alt+Right@@ -286,7 +287,7 @@ func (m *Model) openRename() {
286287 // openSheetPicker lists the sheets, fzf style, with the one shown287288 // highlighted.288289 func (m *Model) openSheetPicker() {289- var items []pickItem290+ var items []picker.Item290291 sel := 0291292 for i, s := range m.visibleSheets() {292293 if s == m.sheet {@@ -296,9 +297,9 @@ func (m *Model) openSheetPicker() {
296297 if used, ok := s.UsedRange(); ok {297298 detail = used.String() + ", " + cellCount(s.Len())298299 }299- items = append(items, pickItem{300- title: s.Name(), name: len(s.Name()), detail: detail, desc: "Show " + s.Name(),301- pick: func(m *Model) tea.Cmd {300+ items = append(items, picker.Item{301+ Title: s.Name(), Name: len(s.Name()), Detail: detail, Desc: "Show " + s.Name(),302+ Pick: func() tea.Cmd {302303 m.closeOverlay()303304 m.showSheet(s)304305 return nil@@ -306,7 +307,7 @@ func (m *Model) openSheetPicker() {
306307 })307308 }308309 p := newPicker(m, "Go to sheet", "Type a sheet name", 60, items)309- p.action = "show"310+ p.Action = "show"310311 p.Sel = sel311312 m.openOverlay(p)312313 }@@ -243,8 +243,8 @@ func TestSheetPicker(t *testing.T) {
243243 }244244 press(t, m, "<alt+shift+k>")245245 p := openPicker(t, m)246- if len(p.shown) != 4 || p.Sel != 3 {247- t.Fatalf("picker: %d shown, %d selected", len(p.shown), p.Sel)246+ if len(p.Shown()) != 4 || p.Sel != 3 {247+ t.Fatalf("picker: %d shown, %d selected", len(p.Shown()), p.Sel)248248 }249249 press(t, m, "sheet2", "<enter>")250250 if m.overlay != nil || m.sheet.Name() != "Sheet2" {@@ -7,6 +7,7 @@ import (
77 88 "github.com/FelineStateMachine/012/internal/config"99 "github.com/FelineStateMachine/012/internal/ui/overlay"10+ "github.com/FelineStateMachine/012/internal/ui/picker"1011 "github.com/FelineStateMachine/012/internal/ui/theme"1112 )1213 @@ -15,14 +16,15 @@ import (
1516 // live; Enter keeps it and saves it to the config file, Esc goes back to1617 // the theme there was.1718 type themePicker struct {18- *picker19+ m *Model // the model it acts on20+ *picker.Picker1921 shownName string // the theme being previewed2022 }2123 2224 func newThemePicker(m *Model) *themePicker {2325 current := m.prefs.Config.Theme().Pick(!m.prefs.light)2426 entries := theme.List(m.prefs.ThemesDir)25- items := make([]pickItem, 0, len(entries))27+ items := make([]picker.Item, 0, len(entries))2628 sel := 02729 for _, e := range entries {2830 kind := "light"@@ -40,11 +42,11 @@ func newThemePicker(m *Model) *themePicker {
4042 sel = len(items)4143 }4244 name := e.Name43- items = append(items, pickItem{title: name, name: len(name), detail: kind, desc: desc,44- pick: func(m *Model) tea.Cmd { m.keepTheme(name); return nil }})45+ items = append(items, picker.Item{Title: name, Name: len(name), Detail: kind, Desc: desc,46+ Pick: func() tea.Cmd { m.keepTheme(name); return nil }})4547 }46- tp := &themePicker{picker: newPicker(m, "Theme", "Type a name, dark or light", 64, items)}47- tp.action = "keep"48+ tp := &themePicker{m: m, Picker: newPicker(m, "Theme", "Type a name, dark or light", 64, items)}49+ tp.Action = "keep"4850 tp.Sel = sel4951 tp.preview(m)5052 return tp@@ -55,7 +57,7 @@ func (tp *themePicker) Key(k tea.KeyPressMsg) tea.Cmd {
5557 if k.String() == "esc" {5658 tp.restore(m)5759 }58- cmd := tp.picker.Key(k)60+ cmd := tp.Picker.Key(k)5961 if m.overlay == tp {6062 tp.preview(m)6163 }@@ -64,10 +66,10 @@ func (tp *themePicker) Key(k tea.KeyPressMsg) tea.Cmd {
6466 6567 func (tp *themePicker) Mouse(e overlay.MouseEvent) tea.Cmd {6668 m := tp.m67- if e.Box != pickerID && e.Kind == overlay.MousePress {69+ if e.Box != picker.ID && e.Kind == overlay.MousePress {6870 tp.restore(m)6971 }70- cmd := tp.picker.Mouse(e)72+ cmd := tp.Picker.Mouse(e)7173 if m.overlay == tp {7274 tp.preview(m)7375 }@@ -76,16 +78,16 @@ func (tp *themePicker) Mouse(e overlay.MouseEvent) tea.Cmd {
7678 7779 func (tp *themePicker) Changed() {7880 m := tp.m79- tp.picker.Changed()81+ tp.Picker.Changed()8082 tp.preview(m)8183 }8284 8385 // preview draws the highlighted theme.8486 func (tp *themePicker) preview(m *Model) {85- if tp.Sel >= len(tp.shown) {87+ if tp.Sel >= len(tp.Shown()) {8688 return8789 }88- name := tp.shown[tp.Sel].item.title90+ name := tp.Shown()[tp.Sel].Item.Title8991 if name == tp.shownName {9092 return9193 }@@ -15,6 +15,7 @@ import (
1515 1616 "github.com/FelineStateMachine/012/internal/confine"1717 "github.com/FelineStateMachine/012/internal/fileio"18+ "github.com/FelineStateMachine/012/internal/ui/picker"1819 "github.com/FelineStateMachine/012/internal/ui/theme"1920 )2021 @@ -111,11 +112,11 @@ func importable(root confine.Root) ([]string, map[string]int64) {
111112 return out, sizes112113 }113114 114-// openImport opens the import picker: the importable files here, with115+// openImport opens the import Picker: the importable files here, with115116 // their type and size. A typed path that isn't listed can be imported116117 // too.117118 func (m *Model) openImport() tea.Cmd {118- var items []pickItem119+ var items []picker.Item119120 names, sizes := importable(m.root)120121 lw := 0 // types line up, and sizes after them121122 for _, name := range names {@@ -129,17 +130,17 @@ func (m *Model) openImport() tea.Cmd {
129130 detail = fmt.Sprintf("%-*s %6s", lw, k.Label(), fileSize(size))130131 desc += ", " + fileSize(size)131132 }132- items = append(items, pickItem{title: name, name: len(name), detail: detail, desc: desc,133- pick: func(m *Model) tea.Cmd {133+ items = append(items, picker.Item{Title: name, Name: len(name), Detail: detail, Desc: desc,134+ Pick: func() tea.Cmd {134135 m.closeOverlay()135136 return m.askImportPlace(name)136137 }})137138 }138139 p := newPicker(m, "Import", "Type to filter, or a path", 72, items)139- p.action = "import"140- p.enter = func(m *Model, query string) (tea.Cmd, bool) {140+ p.Action = "import"141+ p.Enter = func(query string) (tea.Cmd, bool) {141142 // A path, or a name that matched nothing: import it by name.142- if query == "" || !strings.ContainsRune(query, filepath.Separator) && len(p.shown) > 0 {143+ if query == "" || !strings.ContainsRune(query, filepath.Separator) && len(p.Shown()) > 0 {143144 return nil, false144145 }145146 m.closeOverlay()@@ -342,15 +343,15 @@ func (m *Model) handleImported(msg importedMsg) tea.Cmd {
342343 // openTablePicker asks which table of a SQLite database to import, or343344 // for a query.344345 func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place importPlace) {345- var items []pickItem346+ var items []picker.Item346347 for _, t := range tables {347348 detail := countRows(t.Rows) + ", " + plural(len(t.Cols), "1 column", fmt.Sprintf("%d columns", len(t.Cols)))348349 if t.View {349350 detail = "view, " + detail350351 }351352 desc := "Import " + t.Name + ": " + strings.Join(t.Cols, ", ")352- items = append(items, pickItem{title: t.Name, name: len(t.Name), detail: detail, desc: desc,353- pick: func(m *Model) tea.Cmd {353+ items = append(items, picker.Item{Title: t.Name, Name: len(t.Name), Detail: detail, Desc: desc,354+ Pick: func() tea.Cmd {354355 m.closeOverlay()355356 return m.startImport(name, fileio.Options{Table: t.Name}, place)356357 }})@@ -359,9 +360,9 @@ func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place im
359360 if len(tables) > 0 {360361 first = tables[0].Name361362 }362- items = append(items, pickItem{title: "Run a query", name: len("Run a query"), detail: "SELECT …",363- desc: "Type a SQL query and import its results",364- pick: func(m *Model) tea.Cmd {363+ items = append(items, picker.Item{Title: "Run a query", Name: len("Run a query"), Detail: "SELECT …",364+ Desc: "Type a SQL query and import its results",365+ Pick: func() tea.Cmd {365366 m.closeOverlay()366367 m.openText("SQL query:", "SELECT * FROM "+quoteSQL(first), func(m *Model, text string) tea.Cmd {367368 if text == "" {@@ -373,7 +374,7 @@ func (m *Model) openTablePicker(name string, tables []fileio.TableInfo, place im
373374 return nil374375 }})375376 p := newPicker(m, "Import from "+filepath.Base(name), "Type to filter tables", 72, items)376- p.action = "import"377+ p.Action = "import"377378 m.openOverlay(p)378379 }379380 @@ -25,8 +25,8 @@ func writeFile(t *testing.T, name, text string) {
2525 2626 func pickerTitles(t *testing.T, m *Model) []string {2727 var out []string28- for _, pm := range openPicker(t, m).shown {29- out = append(out, pm.item.title+" | "+pm.item.detail)28+ for _, pm := range openPicker(t, m).Shown() {29+ out = append(out, pm.Item.Title+" | "+pm.Item.Detail)3030 }3131 return out3232 }