An open menu acts on the model through menuHost (nine unexported methods) and stays in package ui, since its items are the command registry and menu bar. The theme picker moves to internal/ui/themepicker: a picker whose host also previews and keeps themes, and which knows it closed without asking the model.
- commit
- 897a8144249f569631a4845ee0c2f04bdb59095b
- parent
- 5f3f9f54ca8800b7c219e397bfc37137904d2112
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 27 | ++++++- |
| M | internal/ | 81 | ++++++++++++-------- |
| M | internal/ | 3 | +- |
| M | internal/ | 99 | +----------------------- |
| A | internal/ | 125 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 116 | ++++++++++++++++++++++++++++ |
@@ -9,6 +9,7 @@ import (
99 "github.com/FelineStateMachine/012/internal/ui/lineedit"1010 "github.com/FelineStateMachine/012/internal/ui/picker"1111 "github.com/FelineStateMachine/012/internal/ui/theme"12+ "github.com/FelineStateMachine/012/internal/ui/themepicker"1213 )1314 1415 // host is how components in packages of their own reach the model. It@@ -20,11 +21,20 @@ type host struct{ m *Model }
2021 func (m *Model) host() host { return host{m} }2122 2223 var (23- _ picker.Host = host{}24- _ cmdline.Host = host{}25- _ findbar.Host = host{}24+ _ picker.Host = host{}25+ _ cmdline.Host = host{}26+ _ findbar.Host = host{}27+ _ themepicker.Host = host{}2628 )2729 30+// Components that stay in package ui are handed interfaces of their own31+// (menuHost and the like), which the model implements with unexported32+// methods, these among them.33+34+func (m *Model) styles() *theme.Theme { return &m.th }35+func (m *Model) size() (width, height int) { return m.width, m.height }36+func (m *Model) available(id string) bool { return commands[id].available(m) }37+2838 // What every component with a text field needs.2939 3040 func (h host) Theme() *theme.Theme { return &h.m.th }@@ -61,6 +71,17 @@ func (h host) Commands() []cmdline.Item {
6171 func (h host) Run(text string) (tea.Cmd, bool) { return h.m.runCmdLine(text) }6272 func (h host) Fail(msg string) { h.m.fail(msg) }6373 74+// The theme picker.75+76+func (h host) Current() string { return h.m.prefs.Config.Theme().Pick(!h.m.prefs.light) }77+func (h host) ThemesDir() string { return h.m.prefs.ThemesDir }78+func (h host) Keep(name string) { h.m.keepTheme(name) }79+80+func (h host) Preview(name string) {81+ h.m.prefs.preview = name82+ h.m.applyTheme()83+}84+6485 // The find bar.6586 6687 func (h host) Book() *sheet.Workbook { return h.m.book() }@@ -11,12 +11,28 @@ import (
1111 "github.com/FelineStateMachine/012/internal/ui/theme"1212 )1313 14+// menuHost is what an open menu acts on. The model implements it. Menus15+// stay in package ui because their items are the command registry and16+// the menu bar's definitions.17+type menuHost interface {18+ styles() *theme.Theme19+ size() (width, height int)20+ available(id string) bool21+ shortcut(id string) string22+ isChecked(id string) bool23+ showBarMenu(i int)24+ rightClick(x, y int)25+ closeOverlay()26+ // runFromOverlay closes the menu and runs a command through runCommand.27+ runFromOverlay(id string) tea.Cmd28+}29+1430 // menuOverlay is an open dropdown from the menu bar, or a context menu,1531 // with any submenus opened from it.1632 type menuOverlay struct {17- m *Model // the model it acts on18- bar int // index in barMenus of the open title; -1 for a context menu19- x, y int // where a context menu opened33+ m menuHost // the model, through what a menu needs of it34+ bar int // index in barMenus of the open title; -1 for a context menu35+ x, y int // where a context menu opened2036 levels []*menuLevel2137 }2238 @@ -34,17 +50,17 @@ func (m *Model) showBarMenu(i int) {
3450 return3551 }3652 i = (i%len(menus) + len(menus)) % len(menus)37- m.openOverlay(&menuOverlay{m: m, bar: i, levels: []*menuLevel{m.newLevel(visibleItems(menus[i].def.items), true)}})53+ m.openOverlay(&menuOverlay{m: m, bar: i, levels: []*menuLevel{newLevel(m, visibleItems(menus[i].def.items), true)}})3854 }3955 4056 // showContextMenu opens a menu of items with its corner at x, y.4157 func (m *Model) showContextMenu(items []menuItem, x, y int) {4258 if items = visibleItems(items); len(items) > 0 {43- m.openOverlay(&menuOverlay{m: m, bar: -1, x: x, y: y, levels: []*menuLevel{m.newLevel(items, true)}})59+ m.openOverlay(&menuOverlay{m: m, bar: -1, x: x, y: y, levels: []*menuLevel{newLevel(m, items, true)}})4460 }4561 }4662 47-func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {63+func newLevel(m menuHost, items []menuItem, highlight bool) *menuLevel {4864 l := &menuLevel{items: items, sel: -1}4965 if highlight {5066 l.step(m, 1)@@ -52,14 +68,14 @@ func (m *Model) newLevel(items []menuItem, highlight bool) *menuLevel {
5268 return l5369 }5470 55-func (l *menuLevel) selectable(m *Model, i int) bool {71+func (l *menuLevel) selectable(m menuHost, i int) bool {5672 it := l.items[i]57- return !it.sep && (it.items != nil || commands[it.cmd].available(m))73+ return !it.sep && (it.items != nil || m.available(it.cmd))5874 }5975 6076 // step moves the highlight to the next selectable item in direction d,6177 // wrapping around.62-func (l *menuLevel) step(m *Model, d int) {78+func (l *menuLevel) step(m menuHost, d int) {6379 n, i := len(l.items), l.sel6480 if i < 0 && d < 0 {6581 i = n@@ -137,7 +153,7 @@ func (o *menuOverlay) Key(k tea.KeyPressMsg) tea.Cmd {
137153 138154 // jump highlights the next item starting with letter, running it if it's139155 // the only one.140-func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {156+func (o *menuOverlay) jump(m menuHost, letter string) tea.Cmd {141157 l := o.top()142158 if letter == "" {143159 return nil@@ -167,14 +183,14 @@ func (o *menuOverlay) jump(m *Model, letter string) tea.Cmd {
167183 }168184 169185 // choose opens the highlighted submenu or runs the highlighted command.170-func (o *menuOverlay) choose(m *Model) tea.Cmd {186+func (o *menuOverlay) choose(m menuHost) tea.Cmd {171187 l := o.top()172188 if l.sel < 0 || !l.selectable(m, l.sel) {173189 return nil174190 }175191 it := l.items[l.sel]176192 if it.items != nil {177- o.levels = append(o.levels, m.newLevel(it.items, true))193+ o.levels = append(o.levels, newLevel(m, it.items, true))178194 return nil179195 }180196 return m.runFromOverlay(it.cmd)@@ -208,7 +224,7 @@ func (o *menuOverlay) Mouse(e overlay.MouseEvent) tea.Cmd {
208224 209225 // levelMouse handles the mouse over open box lv: hovering highlights an210226 // item and opens its submenu, clicking chooses it, the wheel moves.211-func (o *menuOverlay) levelMouse(m *Model, e overlay.MouseEvent, lv int, l *menuLevel) tea.Cmd {227+func (o *menuOverlay) levelMouse(m menuHost, e overlay.MouseEvent, lv int, l *menuLevel) tea.Cmd {212228 i := l.top + e.Row - 1213229 if e.Kind == overlay.MouseWheel {214230 o.levels = o.levels[:lv+1]@@ -223,7 +239,7 @@ func (o *menuOverlay) levelMouse(m *Model, e overlay.MouseEvent, lv int, l *menu
223239 if i != l.sel || len(o.levels) == lv+1 {224240 o.levels, l.sel = o.levels[:lv+1], i225241 if sub := l.items[i].items; sub != nil {226- o.levels = append(o.levels, m.newLevel(sub, false))242+ o.levels = append(o.levels, newLevel(m, sub, false))227243 }228244 }229245 case overlay.MousePress:@@ -235,7 +251,7 @@ func (o *menuOverlay) levelMouse(m *Model, e overlay.MouseEvent, lv int, l *menu
235251 236252 // barMouse handles the mouse over menu bar title t: clicking the open237253 // title closes its menu, and clicking or hovering another opens that one.238-func (o *menuOverlay) barMouse(m *Model, e overlay.MouseEvent, t int) {254+func (o *menuOverlay) barMouse(m menuHost, e overlay.MouseEvent, t int) {239255 switch {240256 case e.Kind == overlay.MousePress && t == o.bar:241257 m.closeOverlay()@@ -245,7 +261,7 @@ func (o *menuOverlay) barMouse(m *Model, e overlay.MouseEvent, t int) {
245261 }246262 247263 // wheel moves the highlight, which scrolls a menu too tall for the screen.248-func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {264+func (l *menuLevel) wheel(m menuHost, b tea.MouseButton) {249265 switch b {250266 case tea.MouseWheelUp:251267 l.step(m, -1)@@ -256,9 +272,9 @@ func (l *menuLevel) wheel(m *Model, b tea.MouseButton) {
256272 257273 func (o *menuOverlay) Status() (string, string) {258274 m := o.m259- keys := m.th.KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")275+ keys := m.styles().KeyHints("Up/Down", "move", "Enter", "choose", "Esc", "close")260276 if o.bar >= 0 {261- keys = m.th.KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")277+ keys = m.styles().KeyHints("Arrows", "move", "Enter", "choose", "Esc", "close")262278 }263279 l := o.top()264280 if l.sel < 0 {@@ -282,8 +298,9 @@ func (o *menuOverlay) Status() (string, string) {
282298 // up where the screen runs out. The status line stays visible.283299 func (o *menuOverlay) Layout() []overlay.Box {284300 m := o.m301+ width, height := m.size()285302 boxes := make([]overlay.Box, 0, len(o.levels))286- maxRows := max(m.height-4, 3)303+ maxRows := max(height-4, 3)287304 for i, l := range o.levels {288305 rows := min(len(l.items), maxRows)289306 if l.sel >= 0 {@@ -300,22 +317,22 @@ func (o *menuOverlay) Layout() []overlay.Box {
300317 // Open to the right; else to the left; and if neither fits,301318 // against the screen's right edge, where it covers the parent's302319 // shortcuts rather than its labels.303- if x+w > m.width {320+ if x+w > width {304321 x = p.X - w305322 if x < 0 {306- x = m.width - w323+ x = width - w307324 }308325 }309326 case o.bar >= 0:310327 x, y = barMenus()[o.bar].x, menuLine+1311328 default:312329 x, y = o.x, o.y313- if y+h > m.height-1 {330+ if y+h > height-1 {314331 y = o.y - h315332 }316333 }317- x = clamp(x, 0, m.width-w)318- y = clamp(y, 0, m.height-1-h)334+ x = clamp(x, 0, width-w)335+ y = clamp(y, 0, height-1-h)319336 boxes = append(boxes, overlay.Box{ID: menuBoxID(i), X: x, Y: y, Lines: lines})320337 }321338 return boxes@@ -324,7 +341,7 @@ func (o *menuOverlay) Layout() []overlay.Box {
324341 // lines draws the box: each item with its shortcut right-aligned, a325342 // › for submenus, separators between groups, unavailable items dimmed and326343 // arrows in the border when the items don't all fit.327-func (l *menuLevel) lines(m *Model, rows int) []string {344+func (l *menuLevel) lines(m menuHost, rows int) []string {328345 lw, kw := 0, 0329346 for _, it := range l.items {330347 lw = max(lw, ansi.StringWidth(it.label()))@@ -338,12 +355,12 @@ func (l *menuLevel) lines(m *Model, rows int) []string {
338355 out = append(out, theme.SepRow)339356 continue340357 }341- style := m.th.MenuBar358+ style := m.styles().MenuBar342359 switch {343360 case i == l.sel:344- style = m.th.MenuSelected361+ style = m.styles().MenuSelected345362 case !l.selectable(m, i):346- style = m.th.Disabled363+ style = m.styles().Disabled347364 }348365 k := it.key(m, !l.selectable(m, i))349366 if it.items != nil || i == l.sel {@@ -359,7 +376,7 @@ func (l *menuLevel) lines(m *Model, rows int) []string {
359376 if l.top+rows < len(l.items) {360377 down = "▼"361378 }362- return m.th.Frame(inner, up, down, out)379+ return m.styles().Frame(inner, up, down, out)363380 }364381 365382 // isChecked reports whether the command is a setting that is on.@@ -371,7 +388,7 @@ func (m *Model) isChecked(id string) bool {
371388 // key is what an item shows on the right: its shortcut as a key chip372389 // (plain when the item is unavailable), › for a submenu, or ✓ for a373390 // setting that is on.374-func (it menuItem) key(m *Model, disabled bool) string {391+func (it menuItem) key(m menuHost, disabled bool) string {375392 k := m.shortcut(it.cmd)376393 switch {377394 case it.items != nil:@@ -381,7 +398,7 @@ func (it menuItem) key(m *Model, disabled bool) string {
381398 case k == "":382399 return ""383400 case disabled:384- return m.th.Disabled.Render(" " + k + " ")401+ return m.styles().Disabled.Render(" " + k + " ")385402 }386- return m.th.Chip(k)403+ return m.styles().Chip(k)387404 }@@ -15,6 +15,7 @@ import (
1515 "github.com/FelineStateMachine/012/internal/keyring"1616 "github.com/FelineStateMachine/012/internal/sheet"1717 "github.com/FelineStateMachine/012/internal/ui/theme"18+ "github.com/FelineStateMachine/012/internal/ui/themepicker"1819 )1920 2021 // Settings are what the app takes from outside the workbook: the config@@ -83,7 +84,7 @@ func init() {
8384 register(8485 &command{id: "settings.theme", macro: macroNever, title: "Theme",8586 desc: "Pick a color theme, previewing each as you move; Esc keeps the one you had",86- run: func(m *Model) tea.Cmd { m.openOverlay(newThemePicker(m)); return nil }},87+ run: func(m *Model) tea.Cmd { m.openOverlay(themepicker.New(m.host())); return nil }},8788 &command{id: "settings.config_edit", macro: macroNever, title: "Open config file",8889 desc: "Edit the settings file in $VISUAL or $EDITOR; 012 reloads it when you close the editor",8990 enabled: func(m *Model) bool { return m.prefs.Config != nil && m.prefs.Config.Path != "" },@@ -1,106 +1,11 @@
11 package ui22 33 import (4- "strings"5-6- tea "charm.land/bubbletea/v2"7-84 "github.com/FelineStateMachine/012/internal/config"9- "github.com/FelineStateMachine/012/internal/ui/overlay"10- "github.com/FelineStateMachine/012/internal/ui/picker"11- "github.com/FelineStateMachine/012/internal/ui/theme"125 )136 14-// themePicker is File > Settings > Theme: every theme in a picker, fuzzy15-// searched by name, "dark" or "light". The highlighted theme is drawn16-// live; Enter keeps it and saves it to the config file, Esc goes back to17-// the theme there was.18-type themePicker struct {19- m *Model // the model it acts on20- *picker.Picker21- shownName string // the theme being previewed22-}23-24-func newThemePicker(m *Model) *themePicker {25- current := m.prefs.Config.Theme().Pick(!m.prefs.light)26- entries := theme.List(m.prefs.ThemesDir)27- items := make([]picker.Item, 0, len(entries))28- sel := 029- for _, e := range entries {30- kind := "light"31- if e.Dark {32- kind = "dark"33- }34- desc := "A " + kind + " color scheme, drawn in its own colors with solid bars"35- switch {36- case e.Name == theme.Terminal:37- kind, desc = "your terminal's colors", "The terminal's own 16-color palette, following its light or dark background"38- case e.User:39- kind += ", your file"40- }41- if strings.EqualFold(e.Name, current) {42- sel = len(items)43- }44- name := e.Name45- items = append(items, picker.Item{Title: name, Name: len(name), Detail: kind, Desc: desc,46- Pick: func() tea.Cmd { m.keepTheme(name); return nil }})47- }48- tp := &themePicker{m: m, Picker: newPicker(m, "Theme", "Type a name, dark or light", 64, items)}49- tp.Action = "keep"50- tp.Sel = sel51- tp.preview(m)52- return tp53-}54-55-func (tp *themePicker) Key(k tea.KeyPressMsg) tea.Cmd {56- m := tp.m57- if k.String() == "esc" {58- tp.restore(m)59- }60- cmd := tp.Picker.Key(k)61- if m.overlay == tp {62- tp.preview(m)63- }64- return cmd65-}66-67-func (tp *themePicker) Mouse(e overlay.MouseEvent) tea.Cmd {68- m := tp.m69- if e.Box != picker.ID && e.Kind == overlay.MousePress {70- tp.restore(m)71- }72- cmd := tp.Picker.Mouse(e)73- if m.overlay == tp {74- tp.preview(m)75- }76- return cmd77-}78-79-func (tp *themePicker) Changed() {80- m := tp.m81- tp.Picker.Changed()82- tp.preview(m)83-}84-85-// preview draws the highlighted theme.86-func (tp *themePicker) preview(m *Model) {87- if tp.Sel >= len(tp.Shown()) {88- return89- }90- name := tp.Shown()[tp.Sel].Item.Title91- if name == tp.shownName {92- return93- }94- tp.shownName = name95- m.prefs.preview = name96- m.applyTheme()97-}98-99-// restore goes back to the configured theme.100-func (tp *themePicker) restore(m *Model) {101- m.prefs.preview = ""102- m.applyTheme()103-}7+// The theme picker is package themepicker; the model draws, keeps and8+// saves the themes it picks.1049 10510 // keepTheme makes name the theme and saves it in the config file. When10611 // the config picks a theme by the terminal's background, only the one@@ -0,0 +1,125 @@
1+// Package themepicker is File > Settings > Theme: every theme in a2+// picker, fuzzy searched by name, "dark" or "light". The highlighted theme3+// is drawn live; Enter keeps it, Esc goes back to the theme there was. It4+// knows the UI only through Host, which draws and saves themes.5+package themepicker6+7+import (8+ "strings"9+10+ tea "charm.land/bubbletea/v2"11+12+ "github.com/FelineStateMachine/012/internal/ui/overlay"13+ "github.com/FelineStateMachine/012/internal/ui/picker"14+ "github.com/FelineStateMachine/012/internal/ui/theme"15+)16+17+// Host is what the theme picker needs of the UI: a picker's host, and18+// the themes.19+type Host interface {20+ picker.Host21+ // Current is the theme configured for the terminal's background.22+ Current() string23+ // ThemesDir is where the user's own themes are.24+ ThemesDir() string25+ // Preview draws the theme named name; "" goes back to the configured26+ // one.27+ Preview(name string)28+ // Keep closes the picker and makes name the theme, saving it.29+ Keep(name string)30+}31+32+// Picker is the open theme picker.33+type Picker struct {34+ *picker.Picker35+ h Host36+ open bool // not yet closed or kept37+ shown string // the theme being previewed38+}39+40+// New returns a picker of every theme with the current one highlighted41+// and drawn.42+func New(h Host) *Picker {43+ tp := &Picker{h: h, open: true}44+ current := h.Current()45+ entries := theme.List(h.ThemesDir())46+ items := make([]picker.Item, 0, len(entries))47+ sel := 048+ for _, e := range entries {49+ kind := "light"50+ if e.Dark {51+ kind = "dark"52+ }53+ desc := "A " + kind + " color scheme, drawn in its own colors with solid bars"54+ switch {55+ case e.Name == theme.Terminal:56+ kind, desc = "your terminal's colors", "The terminal's own 16-color palette, following its light or dark background"57+ case e.User:58+ kind += ", your file"59+ }60+ if strings.EqualFold(e.Name, current) {61+ sel = len(items)62+ }63+ name := e.Name64+ items = append(items, picker.Item{Title: name, Name: len(name), Detail: kind, Desc: desc,65+ Pick: func() tea.Cmd {66+ tp.open = false67+ h.Keep(name)68+ return nil69+ }})70+ }71+ tp.Picker = picker.New(closing{h, tp}, "Theme", "Type a name, dark or light", 64, items)72+ tp.Action = "keep"73+ tp.Sel = sel74+ tp.preview()75+ return tp76+}77+78+// closing is the host as the inner picker sees it: closing it closes the79+// theme picker.80+type closing struct {81+ Host82+ tp *Picker83+}84+85+func (c closing) Close() {86+ c.tp.open = false87+ c.Host.Close()88+}89+90+func (tp *Picker) Key(k tea.KeyPressMsg) tea.Cmd {91+ if k.String() == "esc" {92+ tp.h.Preview("")93+ }94+ cmd := tp.Picker.Key(k)95+ if tp.open {96+ tp.preview()97+ }98+ return cmd99+}100+101+func (tp *Picker) Mouse(e overlay.MouseEvent) tea.Cmd {102+ if e.Box != picker.ID && e.Kind == overlay.MousePress {103+ tp.h.Preview("")104+ }105+ cmd := tp.Picker.Mouse(e)106+ if tp.open {107+ tp.preview()108+ }109+ return cmd110+}111+112+func (tp *Picker) Changed() {113+ tp.Picker.Changed()114+ tp.preview()115+}116+117+// preview draws the highlighted theme.118+func (tp *Picker) preview() {119+ it := tp.Selected()120+ if it == nil || it.Title == tp.shown {121+ return122+ }123+ tp.shown = it.Title124+ tp.h.Preview(it.Title)125+}@@ -0,0 +1,116 @@
1+package themepicker2+3+import (4+ "testing"5+6+ tea "charm.land/bubbletea/v2"7+8+ "github.com/FelineStateMachine/012/internal/ui/lineedit"9+ "github.com/FelineStateMachine/012/internal/ui/overlay"10+ "github.com/FelineStateMachine/012/internal/ui/theme"11+)12+13+// fakeHost records what the picker drew and kept.14+type fakeHost struct {15+ th theme.Theme16+ line lineedit.Line17+ current string18+ previews []string19+ kept string20+ closed int21+}22+23+func (f *fakeHost) Theme() *theme.Theme { return &f.th }24+func (f *fakeHost) Size() (width, height int) { return 100, 30 }25+func (f *fakeHost) Line() *lineedit.Line { return &f.line }26+func (f *fakeHost) Close() { f.closed++ }27+func (f *fakeHost) RecordAnswer(string, bool) {}28+func (f *fakeHost) Current() string { return f.current }29+func (f *fakeHost) ThemesDir() string { return "" }30+func (f *fakeHost) Preview(name string) { f.previews = append(f.previews, name) }31+func (f *fakeHost) Keep(name string) { f.kept = name }32+func (f *fakeHost) last() string { return f.previews[len(f.previews)-1] }33+func newHost(current string) *fakeHost { return &fakeHost{th: theme.New(true), current: current} }34+func down() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyDown} }35+func key(code rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: code} }36+func typed(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: []rune(s)[0], Text: s} }37+func press(p *Picker, keys ...tea.KeyPressMsg) {38+ for _, k := range keys {39+ p.Key(k)40+ }41+}42+43+func selected(p *Picker) string { return p.Selected().Title }44+func outside() overlay.MouseEvent { return overlay.MouseEvent{Kind: overlay.MousePress} }45+func hover(row int) overlay.MouseEvent {46+ return overlay.MouseEvent{Kind: overlay.MouseMotion, Box: "picker", Row: row}47+}48+49+func entries() []theme.Entry { return theme.List("") }50+func name(i int) string { return entries()[i].Name }51+func index(n string) int {52+ for i, e := range entries() {53+ if e.Name == n {54+ return i55+ }56+ }57+ return -158+}59+60+func TestOpensOnTheCurrentTheme(t *testing.T) {61+ want := name(3)62+ h := newHost(want)63+ p := New(h)64+ if selected(p) != want || h.last() != want {65+ t.Errorf("opened on %q, drew %v", selected(p), h.previews)66+ }67+}68+69+func TestPreviewsAsItMovesAndRestoresOnEsc(t *testing.T) {70+ h := newHost(name(0))71+ p := New(h)72+ press(p, down())73+ if h.last() != name(1) {74+ t.Errorf("Down drew %v", h.previews)75+ }76+ press(p, key(tea.KeyEscape))77+ if h.last() != "" || h.closed != 1 {78+ t.Errorf("Esc: drew %v, closed %d", h.previews, h.closed)79+ }80+ n := len(h.previews)81+ p.Mouse(hover(4))82+ if len(h.previews) != n {83+ t.Error("a closed picker drew a theme")84+ }85+}86+87+func TestEnterKeeps(t *testing.T) {88+ h := newHost(name(0))89+ p := New(h)90+ press(p, down(), down(), key(tea.KeyEnter))91+ if h.kept != name(2) {92+ t.Errorf("kept %q", h.kept)93+ }94+}95+96+func TestClickOutsideRestores(t *testing.T) {97+ h := newHost(name(0))98+ p := New(h)99+ press(p, down())100+ p.Mouse(outside())101+ if h.last() != "" || h.closed != 1 {102+ t.Errorf("click outside: drew %v, closed %d", h.previews, h.closed)103+ }104+}105+106+func TestSearchPreviewsTheBestMatch(t *testing.T) {107+ h := newHost(name(0))108+ p := New(h)109+ target := name(len(entries()) - 1)110+ for _, r := range target {111+ press(p, typed(string(r)))112+ }113+ if index(selected(p)) < 0 || h.last() != selected(p) {114+ t.Errorf("searching %q selected %q, drew %v", target, selected(p), h.previews)115+ }116+}