add filter

Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 18:22:44 +02:00
parent fc35732dbf
commit 0f7738be68
7 changed files with 117 additions and 11 deletions
+1
View File
@@ -63,3 +63,4 @@ keybindings:
input: input:
select: "enter" select: "enter"
reset: "r" reset: "r"
filter: "/"
+1
View File
@@ -62,6 +62,7 @@ type PowerKeys struct {
type InputKeys struct { type InputKeys struct {
Select string `mapstructure:"select"` Select string `mapstructure:"select"`
Reset string `mapstructure:"reset"` Reset string `mapstructure:"reset"`
Filter string `mapstructure:"filter"`
} }
type Keybindings struct { type Keybindings struct {
+3 -1
View File
@@ -8,15 +8,17 @@ import (
type InputKeyMap struct { type InputKeyMap struct {
Select key.Binding Select key.Binding
Reset key.Binding Reset key.Binding
Filter key.Binding
} }
func newInputKeyMap(cfg config.InputKeys) InputKeyMap { func newInputKeyMap(cfg config.InputKeys) InputKeyMap {
return InputKeyMap{ return InputKeyMap{
Select: binding(cfg.Select, "apply layout"), Select: binding(cfg.Select, "apply layout"),
Reset: binding(cfg.Reset, "reset to config"), Reset: binding(cfg.Reset, "reset to config"),
Filter: binding(cfg.Filter, "filter"),
} }
} }
func (i InputKeyMap) Bindings() []key.Binding { func (i InputKeyMap) Bindings() []key.Binding {
return []key.Binding{i.Select, i.Reset} return []key.Binding{i.Select, i.Reset, i.Filter}
} }
+76 -6
View File
@@ -2,6 +2,7 @@ package input
import ( import (
"fmt" "fmt"
"strings"
"charm.land/bubbles/v2/key" "charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
@@ -39,11 +40,41 @@ func (m *Model) handleSetLayout(msg setLayoutMsg) tea.Cmd {
return fetchLayoutCmd() 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 { 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 nil
} }
return &inputpkg.Layouts[m.cursor] return &list[m.cursor]
} }
func (m *Model) updateLayout(msg tea.Msg) tea.Cmd { 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 g := keys.Keys.Global
in := keys.Keys.Input 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 { switch {
case key.Matches(keyMsg, in.Filter):
m.filtering = true
m.filterInput.Focus()
case key.Matches(keyMsg, g.Up): case key.Matches(keyMsg, g.Up):
if m.cursor > 0 { if m.cursor > 0 {
m.cursor-- m.cursor--
} }
case key.Matches(keyMsg, g.Down): case key.Matches(keyMsg, g.Down):
if m.cursor < len(inputpkg.Layouts)-1 { if m.cursor < len(m.visibleLayouts())-1 {
m.cursor++ m.cursor++
} }
case key.Matches(keyMsg, in.Select): 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+")") 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 available := height - len(lines) - 2
if available < 1 { if available < 1 {
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++ { 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) footer := renderPaginatorDots(p)
+13 -2
View File
@@ -24,12 +24,17 @@ func (k inputKeyMap) ShortHelp() []key.Binding {
if k.m.checkErr != nil { if k.m.checkErr != nil {
return []key.Binding{g.Help} 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 { switch k.m.focus {
case focusMouse: case focusMouse:
speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up") speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up")
return []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus, g.Help} return []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus, g.Help}
default: 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 g := keys.Keys.Global
in := keys.Keys.Input 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 var bindings []key.Binding
switch { switch {
case k.m.checkErr != nil: 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") speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up")
bindings = []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus} bindings = []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus}
default: 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()...) bindings = append(bindings, g.CommonBindings()...)
return keys.ChunkByWidth(bindings, k.width) return keys.ChunkByWidth(bindings, k.width)
+16 -2
View File
@@ -5,7 +5,9 @@ package input
import ( import (
"charm.land/bubbles/v2/help" "charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui" ilovetui "github.com/anotherhadi/ilovetui"
) )
@@ -33,6 +35,8 @@ type Model struct {
cursorInit bool // whether cursor has been snapped to the current layout yet cursorInit bool // whether cursor has been snapped to the current layout yet
layoutPending bool layoutPending bool
layoutFeedback string layoutFeedback string
filterInput textinput.Model
filtering bool
sensitivityErr error sensitivityErr error
sensitivity float64 sensitivity float64
@@ -42,7 +46,16 @@ type Model struct {
} }
func New() Model { 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 { func (m Model) Init() tea.Cmd {
@@ -57,11 +70,12 @@ func (m *Model) Activate() tea.Cmd {
} }
func (m Model) IsEditing() bool { func (m Model) IsEditing() bool {
return false return m.filtering
} }
func (m *Model) SetSize(w, h int) { func (m *Model) SetSize(w, h int) {
m.width = w m.width = w
m.height = h m.height = h
m.help.SetWidth(w - 2) m.help.SetWidth(w - 2)
m.filterInput.SetWidth(w - 4)
} }
+7
View File
@@ -43,6 +43,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.handleReset(msg) return m, m.handleReset(msg)
case tea.KeyPressMsg: 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) { if key.Matches(msg, keys.Keys.Global.Help) {
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
return m, nil return m, nil