package list import ( "fmt" "strings" "git.mlow.ca/mlow/lmcli/pkg/tui/shared" tuiutil "git.mlow.ca/mlow/lmcli/pkg/tui/util" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type Option struct { Label string Value interface{} } type OptionGroup struct { Name string Options []Option } type Model struct { ID int HeaderStyle lipgloss.Style ItemStyle lipgloss.Style SelectedStyle lipgloss.Style ItemRender func(Option, bool) string Width int Height int optionGroups []OptionGroup selected int filterInput textinput.Model filteredIndices []filteredIndex content viewport.Model itemYOffsets []int } type filteredIndex struct { groupIndex int optionIndex int } type MsgOptionSelected struct { ID int Option Option } func New(opts []Option) Model { return NewWithGroups([]OptionGroup{{Options: opts}}) } func NewWithGroups(groups []OptionGroup) Model { ti := textinput.New() ti.Prompt = "/" ti.PromptStyle = lipgloss.NewStyle().Faint(true) m := Model{ HeaderStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Padding(1, 0, 1, 1), ItemStyle: lipgloss.NewStyle(), SelectedStyle: lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("6")), optionGroups: groups, selected: 0, filterInput: ti, filteredIndices: make([]filteredIndex, 0), content: viewport.New(0, 0), itemYOffsets: make([]int, 0), } m.filterItems() m.content.SetContent(m.renderList()) return m } func (m *Model) Focused() { m.filterInput.Focused() } func (m *Model) Focus() { m.filterInput.Focus() } func (m *Model) Blur() { m.filterInput.Blur() } func (m *Model) filterItems() { filterText := strings.ToLower(m.filterInput.Value()) var prevSelection *filteredIndex if m.selected <= len(m.filteredIndices)-1 { prevSelection = &m.filteredIndices[m.selected] } m.filteredIndices = make([]filteredIndex, 0) for groupIndex, group := range m.optionGroups { for optionIndex, option := range group.Options { if filterText == "" || strings.Contains(strings.ToLower(option.Label), filterText) || (group.Name != "" && strings.Contains(strings.ToLower(group.Name), filterText)) { m.filteredIndices = append(m.filteredIndices, filteredIndex{groupIndex, optionIndex}) } } } found := false if len(m.filteredIndices) > 0 && prevSelection != nil { // Preserve previous selection if possible for i, filterIdx := range m.filteredIndices { if prevSelection.groupIndex == filterIdx.groupIndex { if prevSelection.optionIndex == filterIdx.optionIndex { m.selected = i found = true break } } } } if !found { m.selected = 0 } } func (m *Model) Update(msg tea.Msg) (Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: if m.filterInput.Focused() { switch msg.String() { case "esc": m.filterInput.Blur() m.filterInput.SetValue("") m.filterItems() m.refreshContent() return *m, shared.KeyHandled(msg) case "enter": m.filterInput.Blur() m.refreshContent() break case "up", "down": break default: m.filterInput, cmd = m.filterInput.Update(msg) m.filterItems() m.refreshContent() return *m, cmd } } switch msg.String() { case "up", "k": m.moveSelection(-1) return *m, shared.KeyHandled(msg) case "down", "j": m.moveSelection(1) return *m, shared.KeyHandled(msg) case "enter": return *m, func() tea.Msg { idx := m.filteredIndices[m.selected] return MsgOptionSelected{ ID: m.ID, Option: m.optionGroups[idx.groupIndex].Options[idx.optionIndex], } } case "/": m.filterInput.Focus() return *m, textinput.Blink } } m.content, cmd = m.content.Update(msg) return *m, cmd } func (m *Model) refreshContent() { m.content.SetContent(m.renderList()) m.ensureSelectedVisible() } func (m *Model) ensureSelectedVisible() { if m.selected == 0 { m.content.GotoTop() } else if m.selected == len(m.filteredIndices)-1 { m.content.GotoBottom() } else { tuiutil.ScrollIntoView(&m.content, m.itemYOffsets[m.selected], 0) } } func (m *Model) moveSelection(delta int) { prev := m.selected m.selected = min(len(m.filteredIndices)-1, max(0, m.selected+delta)) if prev != m.selected { m.refreshContent() } } func (m *Model) View() string { filter := "" if m.filterInput.Focused() { m.filterInput.Width = m.Width filter = m.filterInput.View() } contentHeight := m.Height - tuiutil.Height(filter) m.content.Width, m.content.Height = m.Width, contentHeight parts := []string{m.content.View()} if filter != "" { parts = append(parts, filter) } return lipgloss.JoinVertical(lipgloss.Left, parts...) } func (m *Model) renderList() string { var sb strings.Builder yOffset := 0 lastGroupIndex := -1 m.itemYOffsets = make([]int, len(m.filteredIndices)) for i, idx := range m.filteredIndices { if idx.groupIndex != lastGroupIndex { group := m.optionGroups[idx.groupIndex].Name if group != "" { headingStr := m.HeaderStyle.Render(group) yOffset += tuiutil.Height(headingStr) sb.WriteString(headingStr) sb.WriteRune('\n') } lastGroupIndex = idx.groupIndex } m.itemYOffsets[i] = yOffset option := m.optionGroups[idx.groupIndex].Options[idx.optionIndex] var item string if m.ItemRender != nil { item = m.ItemRender(option, i == m.selected) } else { prefix := " " if i == m.selected { prefix = "> " item = m.SelectedStyle.Render(option.Label) } else { item = m.ItemStyle.Render(option.Label) } item = fmt.Sprintf("%s%s", prefix, item) } sb.WriteString(item) yOffset += tuiutil.Height(item) if i < len(m.filteredIndices)-1 { sb.WriteRune('\n') } } return sb.String() }