From 2217341118762a4be27d7bc2069b2a0cb4f19486 Mon Sep 17 00:00:00 2001 From: felipe ospina Date: Fri, 24 Apr 2026 18:39:53 -0500 Subject: [PATCH] feat: add multi-select filter popover with text input support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add FilterModel to the popover package — a reusable overlay component with checkbox sections and text input fields. Navigation uses tab/ shift+tab between fields, j/k within sections, space to toggle. - FilterSection/FilterOption/FilterInput types in messages.go - Open/Close/Apply message types for consumer integration - Dynamic help line based on active field type - 2-column grid layout for checkbox sections - PrimaryBright for focused items, Primary for unfocused - Example app in popover/examples/filter/ --- messages.go | 39 +++++ popover/examples/filter/main.go | 114 +++++++++++++ popover/examples/filter/theme.go | 43 +++++ popover/filter.go | 265 +++++++++++++++++++++++++++++++ 4 files changed, 461 insertions(+) create mode 100644 popover/examples/filter/main.go create mode 100644 popover/examples/filter/theme.go create mode 100644 popover/filter.go diff --git a/messages.go b/messages.go index 9d1e139..32d71e1 100644 --- a/messages.go +++ b/messages.go @@ -119,6 +119,45 @@ type ConfirmPopoverYesMsg struct{} // ConfirmPopoverNoMsg is returned when the user cancels the popover. type ConfirmPopoverNoMsg struct{} +// --- Filter popover messages --- + +// FilterSection represents a group of checkable options in the filter popover. +type FilterSection struct { + Title string + Options []FilterOption +} + +// FilterOption is a single checkable item in a filter section. +type FilterOption struct { + Label string + Value string + Selected bool +} + +// FilterInput represents a text input field in the filter popover. +type FilterInput struct { + Title string + Placeholder string + Value string +} + +// OpenFilterPopoverMsg tells the shell to open the filter popover. +type OpenFilterPopoverMsg struct { + Sections []FilterSection + Inputs []FilterInput +} + +// CloseFilterPopoverMsg tells the shell to close the filter popover without applying. +type CloseFilterPopoverMsg struct{} + +// ApplyFilterPopoverMsg is returned when the user applies the filter selections. +// Selections maps section title to the list of selected option values. +// Inputs maps input title to the entered value. +type ApplyFilterPopoverMsg struct { + Selections map[string][]string + Inputs map[string]string +} + // SelectionProvider is implemented by panels that can provide a selected item label. type SelectionProvider interface { SelectedLabel() string diff --git a/popover/examples/filter/main.go b/popover/examples/filter/main.go new file mode 100644 index 0000000..766eac7 --- /dev/null +++ b/popover/examples/filter/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "fmt" + "os" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/felipeospina21/tuishell" + "github.com/felipeospina21/tuishell/popover" + "github.com/felipeospina21/tuishell/style" +) + +type model struct { + theme style.Theme + filter popover.FilterModel + open bool + result string + width int + height int +} + +func newModel() model { + t := defaultTheme() + return model{theme: t, filter: popover.NewFilter(t)} +} + +func (m model) Init() tea.Cmd { return nil } + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyPressMsg: + if !m.open { + switch msg.String() { + case "f": + m.open = true + m.filter.Open( + []tuishell.FilterSection{ + {Title: "Status", Options: []tuishell.FilterOption{ + {Label: "In Progress", Value: "in_progress", Selected: true}, + {Label: "To Do", Value: "todo"}, + {Label: "In Review", Value: "in_review"}, + }}, + {Title: "Priority", Options: []tuishell.FilterOption{ + {Label: "Critical", Value: "critical"}, + {Label: "High", Value: "high", Selected: true}, + {Label: "Medium", Value: "medium"}, + {Label: "Low", Value: "low"}, + }}, + {Title: "Type", Options: []tuishell.FilterOption{ + {Label: "Bug", Value: "bug", Selected: true}, + {Label: "Story", Value: "story"}, + {Label: "Task", Value: "task"}, + }}, + }, + []tuishell.FilterInput{ + {Title: "Sprint", Placeholder: "e.g. 42"}, + }, + ) + return m, nil + case "q", "ctrl+c": + return m, tea.Quit + } + return m, nil + } + case tuishell.CloseFilterPopoverMsg: + m.open = false + return m, nil + case tuishell.ApplyFilterPopoverMsg: + m.open = false + var parts []string + for title, vals := range msg.Selections { + parts = append(parts, fmt.Sprintf("%s: %s", title, strings.Join(vals, ", "))) + } + for title, val := range msg.Inputs { + if val != "" { + parts = append(parts, fmt.Sprintf("%s: %s", title, val)) + } + } + m.result = strings.Join(parts, " | ") + return m, nil + } + + if m.open { + var cmd tea.Cmd + m.filter, cmd = m.filter.Update(msg) + return m, cmd + } + return m, nil +} + +func (m model) View() tea.View { + bg := fmt.Sprintf("\n Press 'f' to open filter popover, 'q' to quit\n\n Applied: %s\n", m.result) + bg = lipgloss.NewStyle().Width(m.width).Height(m.height).Render(bg) + + screen := bg + if m.open { + screen = m.filter.View(bg, m.width, m.height) + } + + v := tea.NewView(screen) + v.AltScreen = true + return v +} + +func main() { + if _, err := tea.NewProgram(newModel()).Run(); err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } +} diff --git a/popover/examples/filter/theme.go b/popover/examples/filter/theme.go new file mode 100644 index 0000000..b1e1269 --- /dev/null +++ b/popover/examples/filter/theme.go @@ -0,0 +1,43 @@ +package main + +import ( + "charm.land/lipgloss/v2" + "github.com/felipeospina21/tuishell/style" +) + +func defaultTheme() style.Theme { + return style.Theme{ + Primary: lipgloss.Color("#b8a6ff"), + PrimaryBright: lipgloss.Color("#9673ff"), + PrimaryFg: lipgloss.Color("#f2f0ff"), + PrimaryDim: lipgloss.Color("#4c01d6"), + + Info: lipgloss.Color("#3ac4d9"), + InfoBright: lipgloss.Color("#1ca7be"), + Success: lipgloss.Color("#6beaaf"), + SuccessBright: lipgloss.Color("#3ad994"), + Danger: lipgloss.Color("#f9a8a8"), + DangerBright: lipgloss.Color("#f47575"), + Warning: lipgloss.Color("#ffe043"), + WarningBright: lipgloss.Color("#ffcc14"), + Caution: lipgloss.Color("#ff8237"), + + Text: lipgloss.Color("#C4C4C4"), + TextInverse: lipgloss.Color("#111"), + TextDimmed: lipgloss.Color("#777777"), + Muted: lipgloss.Color("#999999"), + Dim: lipgloss.Color("#444444"), + Border: lipgloss.Color("#3f4145"), + ModalBorder: lipgloss.Color("#666666"), + SurfaceDim: lipgloss.Color("#1e1e24"), + SelectionBorder: lipgloss.Color("#AD58B4"), + + StatusText: lipgloss.Color("#FFFDF5"), + StatusNormal: lipgloss.Color("#6914ff"), + StatusLoading: lipgloss.Color("#1A7A94"), + StatusError: lipgloss.Color("#CE3060"), + StatusDemo: lipgloss.Color("#4E8212"), + StatusAccent1: lipgloss.Color("#A550DF"), + StatusAccent2: lipgloss.Color("#6124DF"), + } +} diff --git a/popover/filter.go b/popover/filter.go new file mode 100644 index 0000000..6116752 --- /dev/null +++ b/popover/filter.go @@ -0,0 +1,265 @@ +package popover + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/felipeospina21/tuishell" + "github.com/felipeospina21/tuishell/style" +) + +// FilterModel is a compact multi-select filter popover overlay with sections and text inputs. +type FilterModel struct { + Sections []tuishell.FilterSection + Inputs []tuishell.FilterInput + inputModels []textinput.Model + activeField int // index across all fields (sections + inputs) + activeCursor int // cursor within active checkbox section + theme style.Theme + open bool +} + +// NewFilter creates a new filter popover. +func NewFilter(t style.Theme) FilterModel { + return FilterModel{theme: t} +} + +func (m FilterModel) totalFields() int { + return len(m.Sections) + len(m.Inputs) +} + +func (m FilterModel) isInputField() bool { + return m.activeField >= len(m.Sections) +} + +func (m FilterModel) inputIndex() int { + return m.activeField - len(m.Sections) +} + +// Open configures the filter popover with sections and input fields. +func (m *FilterModel) Open(sections []tuishell.FilterSection, inputs []tuishell.FilterInput) { + m.Sections = sections + m.Inputs = inputs + m.inputModels = make([]textinput.Model, len(inputs)) + for i, inp := range inputs { + ti := textinput.New() + ti.Placeholder = inp.Placeholder + ti.SetValue(inp.Value) + ti.CharLimit = 64 + m.inputModels[i] = ti + } + m.activeField = 0 + m.activeCursor = 0 + m.open = true +} + +// Close hides the filter popover. +func (m *FilterModel) Close() { m.open = false } + +// IsOpen reports whether the filter popover is visible. +func (m FilterModel) IsOpen() bool { return m.open } + +func (m FilterModel) helpText() string { + // Always use the longest variant so the popover size stays constant. + full := "enter apply · esc cancel · tab next · j/k navigate · space toggle" + if m.isInputField() { + short := "enter apply · esc cancel · tab next" + // Pad to match full length + return short + strings.Repeat(" ", max(0, len(full)-len(short))) + } + return full +} + +// Update handles key events for the filter popover. +func (m FilterModel) Update(msg tea.Msg) (FilterModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyPressMsg: + key := msg.String() + + // Always intercepted keys + switch key { + case "esc": + return m, func() tea.Msg { return tuishell.CloseFilterPopoverMsg{} } + case "enter": + sel := make(map[string][]string) + for _, s := range m.Sections { + for _, o := range s.Options { + if o.Selected { + sel[s.Title] = append(sel[s.Title], o.Value) + } + } + } + inputs := make(map[string]string) + for i, inp := range m.Inputs { + inputs[inp.Title] = m.inputModels[i].Value() + } + return m, func() tea.Msg { + return tuishell.ApplyFilterPopoverMsg{Selections: sel, Inputs: inputs} + } + case "tab": + if m.totalFields() > 0 { + if m.isInputField() { + m.inputModels[m.inputIndex()].Blur() + } + m.activeField = (m.activeField + 1) % m.totalFields() + if m.isInputField() { + m.inputModels[m.inputIndex()].Focus() + } else if m.activeField < len(m.Sections) { + m.activeCursor = min(m.activeCursor, len(m.Sections[m.activeField].Options)-1) + } + } + return m, nil + case "shift+tab": + if m.totalFields() > 0 { + if m.isInputField() { + m.inputModels[m.inputIndex()].Blur() + } + m.activeField = (m.activeField - 1 + m.totalFields()) % m.totalFields() + if m.isInputField() { + m.inputModels[m.inputIndex()].Focus() + } else if m.activeField < len(m.Sections) { + m.activeCursor = min(m.activeCursor, len(m.Sections[m.activeField].Options)-1) + } + } + return m, nil + } + + // Field-specific keys + if m.isInputField() { + // Forward to active textinput + idx := m.inputIndex() + var cmd tea.Cmd + m.inputModels[idx], cmd = m.inputModels[idx].Update(msg) + return m, cmd + } + + // Checkbox section keys + switch key { + case "space": + if m.activeField < len(m.Sections) { + opts := m.Sections[m.activeField].Options + if m.activeCursor < len(opts) { + opts[m.activeCursor].Selected = !opts[m.activeCursor].Selected + m.Sections[m.activeField].Options = opts + } + } + case "j", "down": + if m.activeField < len(m.Sections) { + if m.activeCursor < len(m.Sections[m.activeField].Options)-1 { + m.activeCursor++ + } + } + case "k", "up": + if m.activeCursor > 0 { + m.activeCursor-- + } + } + } + return m, nil +} + +// View renders the filter popover over the given background. +func (m FilterModel) View(bg string, screenW, screenH int) string { + t := m.theme + w := min(60, screenW-4) + + header := headerStyle(t).Render("Filters") + + titleStyle := lipgloss.NewStyle().Foreground(t.Primary).Bold(true) + activeTitleStyle := lipgloss.NewStyle().Foreground(t.PrimaryBright).Bold(true).Italic(true) + focused := lipgloss.NewStyle().Foreground(t.PrimaryBright).Italic(true) + normal := lipgloss.NewStyle().Foreground(t.Text) + + // Render checkbox sections + sectionBlocks := make([]string, len(m.Sections)) + colW := (w - 2) / 2 + for i, sec := range m.Sections { + var lines []string + if i == m.activeField { + lines = append(lines, activeTitleStyle.Render(sec.Title)) + } else { + lines = append(lines, titleStyle.Render(sec.Title)) + } + for j, opt := range sec.Options { + check := "[ ]" + if opt.Selected { + check = "[x]" + } + label := fmt.Sprintf("%s %s", check, opt.Label) + if i == m.activeField && j == m.activeCursor { + lines = append(lines, focused.Render(label)) + } else { + lines = append(lines, normal.Render(label)) + } + } + sectionBlocks[i] = strings.Join(lines, "\n") + } + + // Layout sections in 2-column grid + var rows []string + for i := 0; i < len(sectionBlocks); i += 2 { + if i+1 < len(sectionBlocks) { + left := lipgloss.NewStyle().Width(colW).Render(sectionBlocks[i]) + right := lipgloss.NewStyle().Width(colW).Render(sectionBlocks[i+1]) + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, left, right)) + } else { + rows = append(rows, sectionBlocks[i]) + } + } + + sectionsView := strings.Join(rows, "\n\n") + + // Render input fields below sections + var inputLines []string + dimStyle := lipgloss.NewStyle().Foreground(t.Dim) + for i, inp := range m.Inputs { + fieldIdx := len(m.Sections) + i + isActive := m.activeField == fieldIdx + + var label string + if isActive { + label = activeTitleStyle.Render(inp.Title + ": ") + } else { + label = titleStyle.Render(inp.Title + ": ") + } + + var value string + if isActive { + value = m.inputModels[i].View() + } else { + v := m.inputModels[i].Value() + if v == "" { + value = dimStyle.Render(inp.Placeholder) + } else { + value = normal.Render(v) + } + } + inputLines = append(inputLines, label+value) + } + + help := lipgloss.NewStyle().Foreground(t.Dim).MarginTop(1).Render(m.helpText()) + + var parts []string + parts = append(parts, header) + if len(sectionsView) > 0 { + parts = append(parts, sectionsView) + } + if len(inputLines) > 0 { + inputBlock := lipgloss.NewStyle().MarginTop(1).Render(strings.Join(inputLines, "\n")) + parts = append(parts, inputBlock) + } + parts = append(parts, help) + + body := lipgloss.JoinVertical(0, parts...) + box := boxStyle(t).Width(w).Render(body) + + lines := strings.Split(bg, "\n") + bgH := len(lines) + if bgH == 0 { + bgH = screenH + } + return overlay(t, box, bg, screenW, bgH) +}