diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index 5ca3fcb..d97f9f3 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -63,3 +63,4 @@ keybindings: input: select: "enter" reset: "r" + filter: "/" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 093b4c1..39e1f88 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -62,6 +62,7 @@ type PowerKeys struct { type InputKeys struct { Select string `mapstructure:"select"` Reset string `mapstructure:"reset"` + Filter string `mapstructure:"filter"` } type Keybindings struct { diff --git a/internal/keys/input.go b/internal/keys/input.go index 4bda9a1..8653c85 100644 --- a/internal/keys/input.go +++ b/internal/keys/input.go @@ -8,15 +8,17 @@ import ( type InputKeyMap struct { Select key.Binding Reset key.Binding + Filter key.Binding } func newInputKeyMap(cfg config.InputKeys) InputKeyMap { return InputKeyMap{ Select: binding(cfg.Select, "apply layout"), Reset: binding(cfg.Reset, "reset to config"), + Filter: binding(cfg.Filter, "filter"), } } func (i InputKeyMap) Bindings() []key.Binding { - return []key.Binding{i.Select, i.Reset} + return []key.Binding{i.Select, i.Reset, i.Filter} } diff --git a/internal/ui/input/keyboard.go b/internal/ui/input/keyboard.go index 2fefaf4..33651d6 100644 --- a/internal/ui/input/keyboard.go +++ b/internal/ui/input/keyboard.go @@ -2,6 +2,7 @@ package input import ( "fmt" + "strings" "charm.land/bubbles/v2/key" tea "charm.land/bubbletea/v2" @@ -39,11 +40,41 @@ func (m *Model) handleSetLayout(msg setLayoutMsg) tea.Cmd { return fetchLayoutCmd() } +// visibleLayouts returns the layouts matching the current filter query (by +// code or name, case-insensitive), or every layout when there's no query. +func (m Model) visibleLayouts() []inputpkg.Layout { + q := strings.ToLower(strings.TrimSpace(m.filterInput.Value())) + if q == "" { + return inputpkg.Layouts + } + matches := make([]inputpkg.Layout, 0, len(inputpkg.Layouts)) + for _, l := range inputpkg.Layouts { + if strings.Contains(strings.ToLower(l.Name), q) || strings.Contains(strings.ToLower(l.Code), q) { + matches = append(matches, l) + } + } + return matches +} + +// clampCursor keeps the cursor within the currently visible (filtered) +// list, called whenever the filter query — and so the list length — +// changes. +func (m *Model) clampCursor() { + n := len(m.visibleLayouts()) + switch { + case m.cursor >= n: + m.cursor = n - 1 + case m.cursor < 0: + m.cursor = 0 + } +} + func (m Model) selectedLayout() *inputpkg.Layout { - if m.cursor < 0 || m.cursor >= len(inputpkg.Layouts) { + list := m.visibleLayouts() + if m.cursor < 0 || m.cursor >= len(list) { return nil } - return &inputpkg.Layouts[m.cursor] + return &list[m.cursor] } func (m *Model) updateLayout(msg tea.Msg) tea.Cmd { @@ -54,13 +85,36 @@ func (m *Model) updateLayout(msg tea.Msg) tea.Cmd { g := keys.Keys.Global in := keys.Keys.Input + if m.filtering { + switch { + case key.Matches(keyMsg, g.Escape): + m.filtering = false + m.filterInput.Blur() + m.filterInput.SetValue("") + m.cursor = 0 + case key.Matches(keyMsg, in.Select): + m.filtering = false + m.filterInput.Blur() + m.clampCursor() + default: + var cmd tea.Cmd + m.filterInput, cmd = m.filterInput.Update(keyMsg) + m.clampCursor() + return cmd + } + return nil + } + switch { + case key.Matches(keyMsg, in.Filter): + m.filtering = true + m.filterInput.Focus() case key.Matches(keyMsg, g.Up): if m.cursor > 0 { m.cursor-- } case key.Matches(keyMsg, g.Down): - if m.cursor < len(inputpkg.Layouts)-1 { + if m.cursor < len(m.visibleLayouts())-1 { m.cursor++ } case key.Matches(keyMsg, in.Select): @@ -90,16 +144,32 @@ func (m Model) renderLayout(width, height int) string { } current := subtleStyle().Render("Current: ") + inputpkg.LayoutName(m.currentLayout) + subtleStyle().Render(" ("+m.currentLayout+")") - lines := []string{title, "", current, ""} + lines := []string{title, "", current} + + switch { + case m.filtering: + lines = append(lines, m.filterInput.View()) + case m.filterInput.Value() != "": + lines = append(lines, subtleStyle().Render(fmt.Sprintf("Filter: %q — press / to change", m.filterInput.Value()))) + } + lines = append(lines, "") + + list := m.visibleLayouts() available := height - len(lines) - 2 if available < 1 { available = 1 } - p, start, end := paginate(m.cursor, len(inputpkg.Layouts), available) + + if len(list) == 0 { + lines = append(lines, ilovetui.S.Faint.Render("No layout matches the filter.")) + return lipgloss.JoinVertical(lipgloss.Left, lines...) + } + + p, start, end := paginate(m.cursor, len(list), available) for i := start; i < end; i++ { - lines = append(lines, m.renderLayoutRow(inputpkg.Layouts[i], i == m.cursor)) + lines = append(lines, m.renderLayoutRow(list[i], i == m.cursor)) } footer := renderPaginatorDots(p) diff --git a/internal/ui/input/keymap.go b/internal/ui/input/keymap.go index 4ee3659..491f69e 100644 --- a/internal/ui/input/keymap.go +++ b/internal/ui/input/keymap.go @@ -24,12 +24,17 @@ func (k inputKeyMap) ShortHelp() []key.Binding { if k.m.checkErr != nil { return []key.Binding{g.Help} } + if k.m.filtering { + confirm := relabel(in.Select, "confirm") + cancel := relabel(g.Escape, "cancel") + return []key.Binding{confirm, cancel} + } switch k.m.focus { case focusMouse: speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up") return []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus, g.Help} default: - return []key.Binding{g.Up, g.Down, in.Select, in.Reset, g.CycleFocus, g.Help} + return []key.Binding{g.Up, g.Down, in.Select, in.Filter, in.Reset, g.CycleFocus, g.Help} } } @@ -37,6 +42,12 @@ func (k inputKeyMap) FullHelp() [][]key.Binding { g := keys.Keys.Global in := keys.Keys.Input + if k.m.filtering { + confirm := relabel(in.Select, "confirm filter") + cancel := relabel(g.Escape, "cancel filter") + return keys.ChunkByWidth([]key.Binding{confirm, cancel}, k.width) + } + var bindings []key.Binding switch { case k.m.checkErr != nil: @@ -45,7 +56,7 @@ func (k inputKeyMap) FullHelp() [][]key.Binding { speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up") bindings = []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus} default: - bindings = []key.Binding{g.Up, g.Down, in.Select, in.Reset, g.CycleFocus} + bindings = []key.Binding{g.Up, g.Down, in.Select, in.Filter, in.Reset, g.CycleFocus} } bindings = append(bindings, g.CommonBindings()...) return keys.ChunkByWidth(bindings, k.width) diff --git a/internal/ui/input/model.go b/internal/ui/input/model.go index b949d62..ed4930e 100644 --- a/internal/ui/input/model.go +++ b/internal/ui/input/model.go @@ -5,7 +5,9 @@ package input import ( "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ilovetui "github.com/anotherhadi/ilovetui" ) @@ -33,6 +35,8 @@ type Model struct { cursorInit bool // whether cursor has been snapped to the current layout yet layoutPending bool layoutFeedback string + filterInput textinput.Model + filtering bool sensitivityErr error sensitivity float64 @@ -42,7 +46,16 @@ type Model struct { } func New() Model { - return Model{help: ilovetui.NewHelp()} + ti := textinput.New() + ti.Prompt = "/" + s := ti.Styles() + s.Focused.Prompt = lipgloss.NewStyle().Foreground(ilovetui.S.Primary) + ti.SetStyles(s) + + return Model{ + help: ilovetui.NewHelp(), + filterInput: ti, + } } func (m Model) Init() tea.Cmd { @@ -57,11 +70,12 @@ func (m *Model) Activate() tea.Cmd { } func (m Model) IsEditing() bool { - return false + return m.filtering } func (m *Model) SetSize(w, h int) { m.width = w m.height = h m.help.SetWidth(w - 2) + m.filterInput.SetWidth(w - 4) } diff --git a/internal/ui/input/update.go b/internal/ui/input/update.go index 3ae262b..c26044d 100644 --- a/internal/ui/input/update.go +++ b/internal/ui/input/update.go @@ -43,6 +43,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.handleReset(msg) case tea.KeyPressMsg: + if m.filtering { + // Every key goes straight to the filter box while typing (a + // query like "tab" or "r" must not also trigger CycleFocus or + // Reset), same precedence the About page's search box uses. + return m, m.updateLayout(msg) + } + if key.Matches(msg, keys.Keys.Global.Help) { m.help.ShowAll = !m.help.ShowAll return m, nil