on main help sign in
chore: use new list in completions
commit20d16fd2acf04c10c9eca972be1eafe44b25c6c2 authorKujtim Hoxha <kujtimii.h@gmail.com> dateThu 24 Jul 2025, 20:52 (1 year ago) parentdcf069e
9 files changed+32−1821
crush.json−9 go.mod+1 go.sum+2 internal/tui/components/completions/completions.go+23−15 internal/tui/components/completions/item.go−282 internal/tui/components/core/list/keys.go−76 internal/tui/components/core/list/list.go−1370 internal/tui/components/dialogs/commands/item.go−69 internal/tui/exp/list/filterable.go+6
crush.json−9
@@ -3,14 +3,5 @@
33 "Go": {
44 "command": "gopls"
55 }
6− },
7− "mcp": {
8− "linear": {
9− "type": "stdio",
10− "command": "mcp-remote",
11− "args": [
12− "https://mcp.linear.app/sse"
13− ]
14− }
156 }
167}
go.mod+1
@@ -14,6 +14,7 @@ require (
1414 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
1515 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
1616 github.com/charmbracelet/catwalk v0.3.1
17+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
1718 github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
1819 github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
1920 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
go.sum+2
@@ -74,6 +74,8 @@ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6a
7474github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
7575github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
7676github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
77+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
78+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
7779github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
7880github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
7981github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
internal/tui/components/completions/completions.go+23−15
@@ -5,7 +5,7 @@ import (
55
66 "github.com/charmbracelet/bubbles/v2/key"
77 tea "github.com/charmbracelet/bubbletea/v2"
8− "github.com/charmbracelet/crush/internal/tui/components/core/list"
8+ "github.com/charmbracelet/crush/internal/tui/exp/list"
99 "github.com/charmbracelet/crush/internal/tui/styles"
1010 "github.com/charmbracelet/crush/internal/tui/util"
1111 "github.com/charmbracelet/lipgloss/v2"
@@ -50,6 +50,8 @@ type Completions interface {
5050 Height() int
5151}
5252
53+type listModel = list.FilterableList[list.CompletionItem[any]]
54+
5355type completionsCmp struct {
5456 width int
5557 height int // Height of the completions component`
@@ -58,7 +60,7 @@ type completionsCmp struct {
5860 open bool // Indicates if the completions are open
5961 keyMap KeyMap
6062
61− list list.ListModel
63+ list listModel
6264 query string // The current filter query
6365}
6466
@@ -76,10 +78,13 @@ func New() Completions {
7678 keyMap.UpOneItem = completionsKeyMap.Up
7779 keyMap.DownOneItem = completionsKeyMap.Down
7880
79− l := list.New(
80− list.WithReverse(true),
81− list.WithKeyMap(keyMap),
82− list.WithHideFilterInput(true),
81+ l := list.NewFilterableList(
82+ []list.CompletionItem[any]{},
83+ list.WithFilterInputHidden(),
84+ list.WithFilterListOptions(
85+ list.WithDirectionBackward(),
86+ list.WithKeyMap(keyMap),
87+ ),
8388 )
8489 return &completionsCmp{
8590 width: 0,
@@ -109,12 +114,12 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
109114 switch {
110115 case key.Matches(msg, c.keyMap.Up):
111116 u, cmd := c.list.Update(msg)
112− c.list = u.(list.ListModel)
117+ c.list = u.(listModel)
113118 return c, cmd
114119
115120 case key.Matches(msg, c.keyMap.Down):
116121 d, cmd := c.list.Update(msg)
117− c.list = d.(list.ListModel)
122+ c.list = d.(listModel)
118123 return c, cmd
119124 case key.Matches(msg, c.keyMap.UpInsert):
120125 selectedItemInx := c.list.SelectedIndex() - 1
@@ -141,12 +146,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141146 Insert: true,
142147 })
143148 case key.Matches(msg, c.keyMap.Select):
144− selectedItemInx := c.list.SelectedIndex()
145− if selectedItemInx == list.NoSelection {
146− return c, nil // No item selected, do nothing
149+ s := c.list.SelectedItem()
150+ if s == nil {
151+ return c, nil
147152 }
148− items := c.list.Items()
149− selectedItem := items[selectedItemInx].(CompletionItem).Value()
153+ selectedItem := *s
150154 c.open = false // Close completions after selection
151155 return c, util.CmdHandler(SelectCompletionMsg{
152156 Value: selectedItem,
@@ -162,10 +166,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
162166 c.query = ""
163167 c.x = msg.X
164168 c.y = msg.Y
165− items := []util.Model{}
169+ items := []list.CompletionItem[any]{}
166170 t := styles.CurrentTheme()
167171 for _, completion := range msg.Completions {
168− item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
172+ item := list.NewCompletionItem(
173+ completion.Title,
174+ completion.Value,
175+ list.WithCompletionBackgroundColor(t.BgSubtle),
176+ )
169177 items = append(items, item)
170178 }
171179 c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
internal/tui/components/completions/item.godeleted−282
@@ -1,282 +0,0 @@
1−package completions
2−
3−import (
4− "image/color"
5−
6− tea "github.com/charmbracelet/bubbletea/v2"
7− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8− "github.com/charmbracelet/crush/internal/tui/components/core/list"
9− "github.com/charmbracelet/crush/internal/tui/styles"
10− "github.com/charmbracelet/crush/internal/tui/util"
11− "github.com/charmbracelet/lipgloss/v2"
12− "github.com/charmbracelet/x/ansi"
13− "github.com/rivo/uniseg"
14−)
15−
16−type CompletionItem interface {
17− util.Model
18− layout.Focusable
19− layout.Sizeable
20− list.HasMatchIndexes
21− list.HasFilterValue
22− Value() any
23−}
24−
25−type completionItemCmp struct {
26− width int
27− text string
28− value any
29− focus bool
30− matchIndexes []int
31− bgColor color.Color
32− shortcut string
33−}
34−
35−type CompletionOption func(*completionItemCmp)
36−
37−func WithBackgroundColor(c color.Color) CompletionOption {
38− return func(cmp *completionItemCmp) {
39− cmp.bgColor = c
40− }
41−}
42−
43−func WithMatchIndexes(indexes ...int) CompletionOption {
44− return func(cmp *completionItemCmp) {
45− cmp.matchIndexes = indexes
46− }
47−}
48−
49−func WithShortcut(shortcut string) CompletionOption {
50− return func(cmp *completionItemCmp) {
51− cmp.shortcut = shortcut
52− }
53−}
54−
55−func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
56− c := &completionItemCmp{
57− text: text,
58− value: value,
59− }
60−
61− for _, opt := range opts {
62− opt(c)
63− }
64− return c
65−}
66−
67−// Init implements CommandItem.
68−func (c *completionItemCmp) Init() tea.Cmd {
69− return nil
70−}
71−
72−// Update implements CommandItem.
73−func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
74− return c, nil
75−}
76−
77−// View implements CommandItem.
78−func (c *completionItemCmp) View() string {
79− t := styles.CurrentTheme()
80−
81− itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
82− innerWidth := c.width - 2 // Account for padding
83−
84− if c.shortcut != "" {
85− innerWidth -= lipgloss.Width(c.shortcut)
86− }
87−
88− titleStyle := t.S().Text.Width(innerWidth)
89− titleMatchStyle := t.S().Text.Underline(true)
90− if c.bgColor != nil {
91− titleStyle = titleStyle.Background(c.bgColor)
92− titleMatchStyle = titleMatchStyle.Background(c.bgColor)
93− itemStyle = itemStyle.Background(c.bgColor)
94− }
95−
96− if c.focus {
97− titleStyle = t.S().TextSelected.Width(innerWidth)
98− titleMatchStyle = t.S().TextSelected.Underline(true)
99− itemStyle = itemStyle.Background(t.Primary)
100− }
101−
102− var truncatedTitle string
103−
104− if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
105− // Smart truncation: ensure the last matching part is visible
106− truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
107− } else {
108− // No matches, use regular truncation
109− truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
110− }
111−
112− text := titleStyle.Render(truncatedTitle)
113− if len(c.matchIndexes) > 0 {
114− var ranges []lipgloss.Range
115− for _, rng := range matchedRanges(c.matchIndexes) {
116− // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
117− // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
118− // so we need to adjust it here:
119− start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
120− ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
121− }
122− text = lipgloss.StyleRanges(text, ranges...)
123− }
124− parts := []string{text}
125− if c.shortcut != "" {
126− // Add the shortcut at the end
127− shortcutStyle := t.S().Muted
128− if c.focus {
129− shortcutStyle = t.S().TextSelected
130− }
131− parts = append(parts, shortcutStyle.Render(c.shortcut))
132− }
133− item := itemStyle.Render(
134− lipgloss.JoinHorizontal(
135− lipgloss.Left,
136− parts...,
137− ),
138− )
139− return item
140−}
141−
142−// Blur implements CommandItem.
143−func (c *completionItemCmp) Blur() tea.Cmd {
144− c.focus = false
145− return nil
146−}
147−
148−// Focus implements CommandItem.
149−func (c *completionItemCmp) Focus() tea.Cmd {
150− c.focus = true
151− return nil
152−}
153−
154−// GetSize implements CommandItem.
155−func (c *completionItemCmp) GetSize() (int, int) {
156− return c.width, 1
157−}
158−
159−// IsFocused implements CommandItem.
160−func (c *completionItemCmp) IsFocused() bool {
161− return c.focus
162−}
163−
164−// SetSize implements CommandItem.
165−func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
166− c.width = width
167− return nil
168−}
169−
170−func (c *completionItemCmp) MatchIndexes(indexes []int) {
171− c.matchIndexes = indexes
172−}
173−
174−func (c *completionItemCmp) FilterValue() string {
175− return c.text
176−}
177−
178−func (c *completionItemCmp) Value() any {
179− return c.value
180−}
181−
182−// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
183−func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
184− if width <= 0 {
185− return ""
186− }
187−
188− textLen := ansi.StringWidth(text)
189− if textLen <= width {
190− return text
191− }
192−
193− if len(matchIndexes) == 0 {
194− return ansi.Truncate(text, width, "…")
195− }
196−
197− // Find the last match position
198− lastMatchPos := matchIndexes[len(matchIndexes)-1]
199−
200− // Convert byte position to visual width position
201− lastMatchVisualPos := 0
202− bytePos := 0
203− gr := uniseg.NewGraphemes(text)
204− for bytePos < lastMatchPos && gr.Next() {
205− bytePos += len(gr.Str())
206− lastMatchVisualPos += max(1, gr.Width())
207− }
208−
209− // Calculate how much space we need for the ellipsis
210− ellipsisWidth := 1 // "…" character width
211− availableWidth := width - ellipsisWidth
212−
213− // If the last match is within the available width, truncate from the end
214− if lastMatchVisualPos < availableWidth {
215− return ansi.Truncate(text, width, "…")
216− }
217−
218− // Calculate the start position to ensure the last match is visible
219− // We want to show some context before the last match if possible
220− startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
221−
222− // Convert visual position back to byte position
223− startBytePos := 0
224− currentVisualPos := 0
225− gr = uniseg.NewGraphemes(text)
226− for currentVisualPos < startVisualPos && gr.Next() {
227− startBytePos += len(gr.Str())
228− currentVisualPos += max(1, gr.Width())
229− }
230−
231− // Extract the substring starting from startBytePos
232− truncatedText := text[startBytePos:]
233−
234− // Truncate to fit width with ellipsis
235− truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
236− truncatedText = "…" + truncatedText
237− return truncatedText
238−}
239−
240−func matchedRanges(in []int) [][2]int {
241− if len(in) == 0 {
242− return [][2]int{}
243− }
244− current := [2]int{in[0], in[0]}
245− if len(in) == 1 {
246− return [][2]int{current}
247− }
248− var out [][2]int
249− for i := 1; i < len(in); i++ {
250− if in[i] == current[1]+1 {
251− current[1] = in[i]
252− } else {
253− out = append(out, current)
254− current = [2]int{in[i], in[i]}
255− }
256− }
257− out = append(out, current)
258− return out
259−}
260−
261−func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
262− bytePos, byteStart, byteStop := 0, rng[0], rng[1]
263− pos, start, stop := 0, 0, 0
264− gr := uniseg.NewGraphemes(str)
265− for byteStart > bytePos {
266− if !gr.Next() {
267− break
268− }
269− bytePos += len(gr.Str())
270− pos += max(1, gr.Width())
271− }
272− start = pos
273− for byteStop > bytePos {
274− if !gr.Next() {
275− break
276− }
277− bytePos += len(gr.Str())
278− pos += max(1, gr.Width())
279− }
280− stop = pos
281− return start, stop
282−}
internal/tui/components/core/list/keys.godeleted−76
@@ -1,76 +0,0 @@
1−package list
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− Down,
9− Up,
10− DownOneItem,
11− UpOneItem,
12− PageDown,
13− PageUp,
14− HalfPageDown,
15− HalfPageUp,
16− Home,
17− End key.Binding
18−}
19−
20−func DefaultKeyMap() KeyMap {
21− return KeyMap{
22− Down: key.NewBinding(
23− key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
24− key.WithHelp("↓", "down"),
25− ),
26− Up: key.NewBinding(
27− key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
28− key.WithHelp("↑", "up"),
29− ),
30− UpOneItem: key.NewBinding(
31− key.WithKeys("shift+up", "K"),
32− key.WithHelp("shift+↑", "up one item"),
33− ),
34− DownOneItem: key.NewBinding(
35− key.WithKeys("shift+down", "J"),
36− key.WithHelp("shift+↓", "down one item"),
37− ),
38− HalfPageDown: key.NewBinding(
39− key.WithKeys("d"),
40− key.WithHelp("d", "half page down"),
41− ),
42− PageDown: key.NewBinding(
43− key.WithKeys("pgdown", " ", "f"),
44− key.WithHelp("f/pgdn", "page down"),
45− ),
46− PageUp: key.NewBinding(
47− key.WithKeys("pgup", "b"),
48− key.WithHelp("b/pgup", "page up"),
49− ), HalfPageUp: key.NewBinding(
50− key.WithKeys("u"),
51− key.WithHelp("u", "half page up"),
52− ),
53− Home: key.NewBinding(
54− key.WithKeys("g", "home"),
55− key.WithHelp("g", "home"),
56− ),
57− End: key.NewBinding(
58− key.WithKeys("G", "end"),
59− key.WithHelp("G", "end"),
60− ),
61− }
62−}
63−
64−// KeyBindings implements layout.KeyMapProvider
65−func (k KeyMap) KeyBindings() []key.Binding {
66− return []key.Binding{
67− k.Down,
68− k.Up,
69− k.DownOneItem,
70− k.UpOneItem,
71− k.HalfPageDown,
72− k.HalfPageUp,
73− k.Home,
74− k.End,
75− }
76−}
internal/tui/components/core/list/list.godeleted−1370
@@ -1,1370 +0,0 @@
1−package list
2−
3−import (
4− "slices"
5− "sort"
6− "strings"
7−
8− "github.com/charmbracelet/bubbles/v2/help"
9− "github.com/charmbracelet/bubbles/v2/key"
10− "github.com/charmbracelet/bubbles/v2/textinput"
11− tea "github.com/charmbracelet/bubbletea/v2"
12− "github.com/charmbracelet/crush/internal/tui/components/anim"
13− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
14− "github.com/charmbracelet/crush/internal/tui/styles"
15− "github.com/charmbracelet/crush/internal/tui/util"
16− "github.com/charmbracelet/lipgloss/v2"
17− "github.com/sahilm/fuzzy"
18−)
19−
20−// Constants for special index values and defaults
21−const (
22− NoSelection = -1 // Indicates no item is currently selected
23− NotRendered = -1 // Indicates an item hasn't been rendered yet
24− NoFinalHeight = -1 // Indicates final height hasn't been calculated
25− DefaultGapSize = 0 // Default spacing between list items
26−)
27−
28−// ListModel defines the interface for a scrollable, selectable list component.
29−// It combines the basic Model interface with sizing capabilities and list-specific operations.
30−type ListModel interface {
31− util.Model
32− layout.Sizeable
33− layout.Focusable
34− SetItems([]util.Model) tea.Cmd // Replace all items in the list
35− AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
36− PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
37− DeleteItem(int) // Remove an item at the specified index
38− UpdateItem(int, util.Model) // Replace an item at the specified index
39− ResetView() // Clear rendering cache and reset scroll position
40− Items() []util.Model // Get all items in the list
41− SelectedIndex() int // Get the index of the currently selected item
42− SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
43− Filter(string) tea.Cmd // Filter items based on a search term
44− SetFilterPlaceholder(string) // Set the placeholder text for the filter input
45− Cursor() *tea.Cursor // Get the current cursor position in the filter input
46−}
47−
48−// HasAnim interface identifies items that support animation.
49−// Items implementing this interface will receive animation update messages.
50−type HasAnim interface {
51− util.Model
52− Spinning() bool // Returns true if the item is currently animating
53−}
54−
55−// HasFilterValue interface allows items to provide a filter value for searching.
56−type HasFilterValue interface {
57− FilterValue() string // Returns a string value used for filtering/searching
58−}
59−
60−// HasMatchIndexes interface allows items to set matched character indexes.
61−type HasMatchIndexes interface {
62− MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
63−}
64−
65−// SectionHeader interface identifies items that are section headers.
66−// Section headers are rendered differently and are skipped during navigation.
67−type SectionHeader interface {
68− util.Model
69− IsSectionHeader() bool // Returns true if this item is a section header
70−}
71−
72−// renderedItem represents a cached rendered item with its position and content.
73−type renderedItem struct {
74− lines []string // The rendered lines of text for this item
75− start int // Starting line position in the overall rendered content
76− height int // Number of lines this item occupies
77−}
78−
79−// renderState manages the rendering cache and state for the list.
80−// It tracks which items have been rendered and their positions.
81−type renderState struct {
82− items map[int]renderedItem // Cache of rendered items by index
83− lines []string // All rendered lines concatenated
84− lastIndex int // Index of the last rendered item
85− finalHeight int // Total height when all items are rendered
86− needsRerender bool // Flag indicating if re-rendering is needed
87−}
88−
89−// newRenderState creates a new render state with default values.
90−func newRenderState() *renderState {
91− return &renderState{
92− items: make(map[int]renderedItem),
93− lines: []string{},
94− lastIndex: NotRendered,
95− finalHeight: NoFinalHeight,
96− needsRerender: true,
97− }
98−}
99−
100−// reset clears all cached rendering data and resets state to initial values.
101−func (rs *renderState) reset() {
102− rs.items = make(map[int]renderedItem)
103− rs.lines = []string{}
104− rs.lastIndex = NotRendered
105− rs.finalHeight = NoFinalHeight
106− rs.needsRerender = true
107−}
108−
109−// viewState manages the visual display properties of the list.
110−type viewState struct {
111− width, height int // Dimensions of the list viewport
112− offset int // Current scroll offset in lines
113− reverse bool // Whether to render in reverse order (bottom-up)
114− content string // The final rendered content to display
115−}
116−
117−// selectionState manages which item is currently selected.
118−type selectionState struct {
119− selectedIndex int // Index of the currently selected item, or NoSelection
120−}
121−
122−// isValidIndex checks if the selected index is within the valid range of items.
123−func (ss *selectionState) isValidIndex(itemCount int) bool {
124− return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
125−}
126−
127−// model is the main implementation of the ListModel interface.
128−// It coordinates between view state, render state, and selection state.
129−type model struct {
130− viewState viewState // Display and scrolling state
131− renderState *renderState // Rendering cache and state
132− selectionState selectionState // Item selection state
133− help help.Model // Help system for keyboard shortcuts
134− keyMap KeyMap // Key bindings for navigation
135− allItems []util.Model // The actual list items
136− gapSize int // Number of empty lines between items
137− padding []int // Padding around the list content
138− wrapNavigation bool // Whether to wrap navigation at the ends
139−
140− filterable bool // Whether items can be filtered
141− filterPlaceholder string // Placeholder text for filter input
142− filteredItems []util.Model // Filtered items based on current search
143− input textinput.Model // Input field for filtering items
144− inputStyle lipgloss.Style // Style for the input field
145− hideFilterInput bool // Whether to hide the filter input field
146− currentSearch string // Current search term for filtering
147−
148− isFocused bool // Whether the list is currently focused
149−}
150−
151−// listOptions is a function type for configuring list options.
152−type listOptions func(*model)
153−
154−// WithKeyMap sets custom key bindings for the list.
155−func WithKeyMap(k KeyMap) listOptions {
156− return func(m *model) {
157− m.keyMap = k
158− }
159−}
160−
161−// WithReverse sets whether the list should render in reverse order (newest items at bottom).
162−func WithReverse(reverse bool) listOptions {
163− return func(m *model) {
164− m.setReverse(reverse)
165− }
166−}
167−
168−// WithGapSize sets the number of empty lines to insert between list items.
169−func WithGapSize(gapSize int) listOptions {
170− return func(m *model) {
171− m.gapSize = gapSize
172− }
173−}
174−
175−// WithPadding sets the padding around the list content.
176−// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
177−// 4 values = top/right/bottom/left.
178−func WithPadding(padding ...int) listOptions {
179− return func(m *model) {
180− m.padding = padding
181− }
182−}
183−
184−// WithItems sets the initial items for the list.
185−func WithItems(items []util.Model) listOptions {
186− return func(m *model) {
187− m.allItems = items
188− m.filteredItems = items // Initially, all items are visible
189− }
190−}
191−
192−// WithFilterable enables filtering of items based on their FilterValue.
193−func WithFilterable(filterable bool) listOptions {
194− return func(m *model) {
195− m.filterable = filterable
196− }
197−}
198−
199−// WithHideFilterInput hides the filter input field.
200−func WithHideFilterInput(hide bool) listOptions {
201− return func(m *model) {
202− m.hideFilterInput = hide
203− }
204−}
205−
206−// WithFilterPlaceholder sets the placeholder text for the filter input field.
207−func WithFilterPlaceholder(placeholder string) listOptions {
208− return func(m *model) {
209− m.filterPlaceholder = placeholder
210− }
211−}
212−
213−// WithInputStyle sets the style for the filter input field.
214−func WithInputStyle(style lipgloss.Style) listOptions {
215− return func(m *model) {
216− m.inputStyle = style
217− }
218−}
219−
220−// WithWrapNavigation enables wrapping navigation at the ends of the list.
221−func WithWrapNavigation(wrap bool) listOptions {
222− return func(m *model) {
223− m.wrapNavigation = wrap
224− }
225−}
226−
227−// New creates a new list model with the specified options.
228−// The list starts with no items selected and requires SetItems to be called
229−// or items to be provided via WithItems option.
230−func New(opts ...listOptions) ListModel {
231− t := styles.CurrentTheme()
232−
233− m := &model{
234− help: help.New(),
235− keyMap: DefaultKeyMap(),
236− allItems: []util.Model{},
237− filteredItems: []util.Model{},
238− renderState: newRenderState(),
239− gapSize: DefaultGapSize,
240− padding: []int{},
241− selectionState: selectionState{selectedIndex: NoSelection},
242− filterPlaceholder: "Type to filter...",
243− inputStyle: t.S().Base.Padding(0, 1, 1, 1),
244− isFocused: true,
245− }
246− for _, opt := range opts {
247− opt(m)
248− }
249−
250− if m.filterable && !m.hideFilterInput {
251− ti := textinput.New()
252− ti.Placeholder = m.filterPlaceholder
253− ti.SetVirtualCursor(false)
254− ti.Focus()
255− ti.SetStyles(t.S().TextInput)
256− m.input = ti
257− }
258− return m
259−}
260−
261−// Init initializes the list component and sets up the initial items.
262−// This is called automatically by the Bubble Tea framework.
263−func (m *model) Init() tea.Cmd {
264− return m.SetItems(m.filteredItems)
265−}
266−
267−// Update handles incoming messages and updates the list state accordingly.
268−// It processes keyboard input, animation messages, and forwards other messages
269−// to the currently selected item.
270−func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
271− switch msg := msg.(type) {
272− case tea.KeyPressMsg:
273− return m.handleKeyPress(msg)
274− case anim.StepMsg:
275− return m.handleAnimationMsg(msg)
276− }
277− if m.selectionState.isValidIndex(len(m.filteredItems)) {
278− return m.updateSelectedItem(msg)
279− }
280−
281− return m, nil
282−}
283−
284−// Cursor returns the current cursor position in the input field.
285−func (m *model) Cursor() *tea.Cursor {
286− if m.filterable && !m.hideFilterInput {
287− return m.input.Cursor()
288− }
289− return nil
290−}
291−
292−// View renders the list to a string for display.
293−// Returns empty string if the list has no dimensions.
294−// Triggers re-rendering if needed before returning content.
295−func (m *model) View() string {
296− if m.viewState.height == 0 || m.viewState.width == 0 {
297− return "" // No content to display
298− }
299− if m.renderState.needsRerender {
300− m.renderVisible()
301− }
302−
303− content := lipgloss.NewStyle().
304− Padding(m.padding...).
305− Height(m.viewState.height).
306− Render(m.viewState.content)
307−
308− if m.filterable && !m.hideFilterInput {
309− content = lipgloss.JoinVertical(
310− lipgloss.Left,
311− m.inputStyle.Render(m.input.View()),
312− content,
313− )
314− }
315− return content
316−}
317−
318−// handleKeyPress processes keyboard input for list navigation.
319−// Supports scrolling, item selection, and navigation to top/bottom.
320−func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
321− switch {
322− case key.Matches(msg, m.keyMap.Down):
323− m.scrollDown(1)
324− case key.Matches(msg, m.keyMap.Up):
325− m.scrollUp(1)
326− case key.Matches(msg, m.keyMap.DownOneItem):
327− return m, m.selectNextItem()
328− case key.Matches(msg, m.keyMap.UpOneItem):
329− return m, m.selectPreviousItem()
330− case key.Matches(msg, m.keyMap.HalfPageDown):
331− m.scrollDown(m.listHeight() / 2)
332− case key.Matches(msg, m.keyMap.HalfPageUp):
333− m.scrollUp(m.listHeight() / 2)
334− case key.Matches(msg, m.keyMap.PageDown):
335− m.scrollDown(m.listHeight())
336− case key.Matches(msg, m.keyMap.PageUp):
337− m.scrollUp(m.listHeight())
338− case key.Matches(msg, m.keyMap.Home):
339− return m, m.goToTop()
340− case key.Matches(msg, m.keyMap.End):
341− return m, m.goToBottom()
342− default:
343− if !m.filterable || m.hideFilterInput {
344− return m, nil // Ignore other keys if not filterable or input is hidden
345− }
346− var cmds []tea.Cmd
347− u, cmd := m.input.Update(msg)
348− m.input = u
349− cmds = append(cmds, cmd)
350− if m.currentSearch != m.input.Value() {
351− cmd = m.Filter(m.input.Value())
352− cmds = append(cmds, cmd)
353− }
354− m.currentSearch = m.input.Value()
355− return m, tea.Batch(cmds...)
356− }
357− return m, nil
358−}
359−
360−// handleAnimationMsg forwards animation messages to items that support animation.
361−// Only items implementing HasAnim and currently spinning receive these messages.
362−func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
363− var cmds []tea.Cmd
364− for inx, item := range m.filteredItems {
365− if i, ok := item.(HasAnim); ok && i.Spinning() {
366− updated, cmd := i.Update(msg)
367− cmds = append(cmds, cmd)
368− if u, ok := updated.(util.Model); ok {
369− m.UpdateItem(inx, u)
370− }
371− }
372− }
373− return m, tea.Batch(cmds...)
374−}
375−
376−// updateSelectedItem forwards messages to the currently selected item.
377−// This allows the selected item to handle its own input and state changes.
378−func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
379− var cmds []tea.Cmd
380− u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg)
381− cmds = append(cmds, cmd)
382− if updated, ok := u.(util.Model); ok {
383− m.UpdateItem(m.selectionState.selectedIndex, updated)
384− }
385− return m, tea.Batch(cmds...)
386−}
387−
388−// scrollDown scrolls the list down by the specified amount.
389−// Direction is automatically adjusted based on reverse mode.
390−func (m *model) scrollDown(amount int) {
391− if m.viewState.reverse {
392− m.decreaseOffset(amount)
393− } else {
394− m.increaseOffset(amount)
395− }
396−}
397−
398−// scrollUp scrolls the list up by the specified amount.
399−// Direction is automatically adjusted based on reverse mode.
400−func (m *model) scrollUp(amount int) {
401− if m.viewState.reverse {
402− m.increaseOffset(amount)
403− } else {
404− m.decreaseOffset(amount)
405− }
406−}
407−
408−// Items returns a copy of all items in the list.
409−func (m *model) Items() []util.Model {
410− return m.filteredItems
411−}
412−
413−// renderVisible determines which rendering strategy to use and triggers rendering.
414−// Uses forward rendering for normal mode and reverse rendering for reverse mode.
415−func (m *model) renderVisible() {
416− if m.viewState.reverse {
417− m.renderVisibleReverse()
418− } else {
419− m.renderVisibleForward()
420− }
421−}
422−
423−// renderVisibleForward renders items from top to bottom (normal mode).
424−// Only renders items that are currently visible or near the viewport.
425−func (m *model) renderVisibleForward() {
426− renderer := &forwardRenderer{
427− model: m,
428− start: 0,
429− cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd
430− items: m.filteredItems,
431− realIdx: m.renderState.lastIndex,
432− }
433−
434− if m.renderState.lastIndex > NotRendered {
435− renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
436− renderer.start = len(m.renderState.lines)
437− }
438−
439− renderer.render()
440− m.finalizeRender()
441−}
442−
443−// renderVisibleReverse renders items from bottom to top (reverse mode).
444−// Used when new items should appear at the bottom (like chat messages).
445−func (m *model) renderVisibleReverse() {
446− renderer := &reverseRenderer{
447− model: m,
448− start: 0,
449− cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2,
450− items: m.filteredItems,
451− realIdx: m.renderState.lastIndex,
452− }
453−
454− if m.renderState.lastIndex > NotRendered {
455− renderer.items = m.filteredItems[:m.renderState.lastIndex]
456− renderer.start = len(m.renderState.lines)
457− } else {
458− m.renderState.lastIndex = len(m.filteredItems)
459− renderer.realIdx = len(m.filteredItems)
460− }
461−
462− renderer.render()
463− m.finalizeRender()
464−}
465−
466−// finalizeRender completes the rendering process by updating scroll bounds and content.
467−func (m *model) finalizeRender() {
468− m.renderState.needsRerender = false
469− if m.renderState.finalHeight > NoFinalHeight {
470− m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
471− }
472− m.updateContent()
473−}
474−
475−// updateContent extracts the visible portion of rendered content for display.
476−// Handles both normal and reverse rendering modes.
477−func (m *model) updateContent() {
478− maxHeight := min(m.listHeight(), len(m.renderState.lines))
479− if m.viewState.offset >= len(m.renderState.lines) {
480− m.viewState.content = ""
481− return
482− }
483−
484− if m.viewState.reverse {
485− end := len(m.renderState.lines) - m.viewState.offset
486− start := max(0, end-maxHeight)
487− m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
488− } else {
489− endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
490− m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
491− }
492−}
493−
494−// forwardRenderer handles rendering items from top to bottom.
495−// It builds up the rendered content incrementally, caching results for performance.
496−type forwardRenderer struct {
497− model *model // Reference to the parent list model
498− start int // Current line position in the overall content
499− cutoff int // Line position where we can stop rendering
500− items []util.Model // Items to render (may be a subset)
501− realIdx int // Real index in the full item list
502−}
503−
504−// render processes items in forward order, building up the rendered content.
505−func (r *forwardRenderer) render() {
506− for _, item := range r.items {
507− r.realIdx++
508− if r.start > r.cutoff {
509− break
510− }
511−
512− itemLines := r.getOrRenderItem(item)
513− if r.realIdx == len(r.model.filteredItems)-1 {
514− r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
515− }
516−
517− r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
518− r.model.renderState.lastIndex = r.realIdx
519− r.start += len(itemLines)
520− }
521−}
522−
523−// getOrRenderItem retrieves cached content or renders the item if not cached.
524−func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
525− if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
526− return cachedContent.lines
527− }
528−
529− itemLines := r.renderItemLines(item)
530− r.model.renderState.items[r.realIdx] = renderedItem{
531− lines: itemLines,
532− start: r.start,
533− height: len(itemLines),
534− }
535− return itemLines
536−}
537−
538−// renderItemLines converts an item to its string representation with gaps.
539−func (r *forwardRenderer) renderItemLines(item util.Model) []string {
540− return r.model.getItemLines(item)
541−}
542−
543−// reverseRenderer handles rendering items from bottom to top.
544−// Used in reverse mode where new items appear at the bottom.
545−type reverseRenderer struct {
546− model *model // Reference to the parent list model
547− start int // Current line position in the overall content
548− cutoff int // Line position where we can stop rendering
549− items []util.Model // Items to render (may be a subset)
550− realIdx int // Real index in the full item list
551−}
552−
553−// render processes items in reverse order, prepending to the rendered content.
554−func (r *reverseRenderer) render() {
555− for i := len(r.items) - 1; i >= 0; i-- {
556− r.realIdx--
557− if r.start > r.cutoff {
558− break
559− }
560−
561− itemLines := r.getOrRenderItem(r.items[i])
562− if r.realIdx == 0 {
563− r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
564− }
565−
566− r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
567− r.model.renderState.lastIndex = r.realIdx
568− r.start += len(itemLines)
569− }
570−}
571−
572−// getOrRenderItem retrieves cached content or renders the item if not cached.
573−func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
574− if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
575− return cachedContent.lines
576− }
577−
578− itemLines := r.renderItemLines(item)
579− r.model.renderState.items[r.realIdx] = renderedItem{
580− lines: itemLines,
581− start: r.start,
582− height: len(itemLines),
583− }
584− return itemLines
585−}
586−
587−// renderItemLines converts an item to its string representation with gaps.
588−func (r *reverseRenderer) renderItemLines(item util.Model) []string {
589− return r.model.getItemLines(item)
590−}
591−
592−// selectPreviousItem moves selection to the previous item in the list.
593−// Handles focus management and ensures the selected item remains visible.
594−// Skips section headers during navigation.
595−func (m *model) selectPreviousItem() tea.Cmd {
596− if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation {
597− // If at the beginning and wrapping is enabled, go to the last item
598− return m.goToBottom()
599− }
600− if m.selectionState.selectedIndex <= 0 {
601− return nil
602− }
603−
604− cmds := []tea.Cmd{m.blurSelected()}
605− m.selectionState.selectedIndex--
606−
607− // Skip section headers
608− for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
609− m.selectionState.selectedIndex--
610− }
611−
612− // If we went past the beginning, stay at the first non-header item
613− if m.selectionState.selectedIndex <= 0 {
614− cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed
615− return tea.Batch(cmds...)
616− }
617−
618− cmds = append(cmds, m.focusSelected())
619− m.ensureSelectedItemVisible()
620− return tea.Batch(cmds...)
621−}
622−
623−// selectNextItem moves selection to the next item in the list.
624−// Handles focus management and ensures the selected item remains visible.
625−// Skips section headers during navigation.
626−func (m *model) selectNextItem() tea.Cmd {
627− if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation {
628− // If at the end and wrapping is enabled, go to the first item
629− return m.goToTop()
630− }
631− if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
632− return nil
633− }
634−
635− cmds := []tea.Cmd{m.blurSelected()}
636− m.selectionState.selectedIndex++
637−
638− // Skip section headers
639− for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
640− m.selectionState.selectedIndex++
641− }
642−
643− // If we went past the end, stay at the last non-header item
644− if m.selectionState.selectedIndex >= len(m.filteredItems) {
645− m.selectionState.selectedIndex = m.findLastSelectableItem()
646− }
647−
648− cmds = append(cmds, m.focusSelected())
649− m.ensureSelectedItemVisible()
650− return tea.Batch(cmds...)
651−}
652−
653−// isSectionHeader checks if the item at the given index is a section header.
654−func (m *model) isSectionHeader(index int) bool {
655− if index < 0 || index >= len(m.filteredItems) {
656− return false
657− }
658− if header, ok := m.filteredItems[index].(SectionHeader); ok {
659− return header.IsSectionHeader()
660− }
661− return false
662−}
663−
664−// findFirstSelectableItem finds the first item that is not a section header.
665−func (m *model) findFirstSelectableItem() int {
666− for i := range m.filteredItems {
667− if !m.isSectionHeader(i) {
668− return i
669− }
670− }
671− return NoSelection
672−}
673−
674−// findLastSelectableItem finds the last item that is not a section header.
675−func (m *model) findLastSelectableItem() int {
676− for i := len(m.filteredItems) - 1; i >= 0; i-- {
677− if !m.isSectionHeader(i) {
678− return i
679− }
680− }
681− return NoSelection
682−}
683−
684−// ensureSelectedItemVisible scrolls the list to make the selected item visible.
685−// Uses different strategies for forward and reverse rendering modes.
686−func (m *model) ensureSelectedItemVisible() {
687− cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
688− if !ok {
689− m.renderState.needsRerender = true
690− return
691− }
692−
693− if m.viewState.reverse {
694− m.ensureVisibleReverse(cachedItem)
695− } else {
696− m.ensureVisibleForward(cachedItem)
697− }
698− m.renderState.needsRerender = true
699−}
700−
701−// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
702−// Handles both large items (taller than viewport) and normal items.
703−func (m *model) ensureVisibleForward(cachedItem renderedItem) {
704− if cachedItem.height >= m.listHeight() {
705− if m.selectionState.selectedIndex > 0 {
706− changeNeeded := m.viewState.offset - cachedItem.start
707− m.decreaseOffset(changeNeeded)
708− } else {
709− changeNeeded := cachedItem.start - m.viewState.offset
710− m.increaseOffset(changeNeeded)
711− }
712− return
713− }
714−
715− if cachedItem.start < m.viewState.offset {
716− changeNeeded := m.viewState.offset - cachedItem.start
717− m.decreaseOffset(changeNeeded)
718− } else {
719− end := cachedItem.start + cachedItem.height
720− if end > m.viewState.offset+m.listHeight() {
721− changeNeeded := end - (m.viewState.offset + m.listHeight())
722− m.increaseOffset(changeNeeded)
723− }
724− }
725−}
726−
727−// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
728−// Handles both large items (taller than viewport) and normal items.
729−func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
730− if cachedItem.height >= m.listHeight() {
731− if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
732− changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
733− m.decreaseOffset(changeNeeded)
734− } else {
735− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
736− m.increaseOffset(changeNeeded)
737− }
738− return
739− }
740−
741− if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
742− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
743− m.increaseOffset(changeNeeded)
744− } else if cachedItem.start < m.viewState.offset {
745− changeNeeded := m.viewState.offset - cachedItem.start
746− m.decreaseOffset(changeNeeded)
747− }
748−}
749−
750−// goToBottom switches to reverse mode and selects the last selectable item.
751−// Commonly used for chat-like interfaces where new content appears at the bottom.
752−// Skips section headers when selecting the last item.
753−func (m *model) goToBottom() tea.Cmd {
754− cmds := []tea.Cmd{m.blurSelected()}
755− m.viewState.reverse = true
756− m.selectionState.selectedIndex = m.findLastSelectableItem()
757− if m.isFocused {
758− cmds = append(cmds, m.focusSelected())
759− }
760− m.ResetView()
761− return tea.Batch(cmds...)
762−}
763−
764−// goToTop switches to forward mode and selects the first selectable item.
765−// Standard behavior for most list interfaces.
766−// Skips section headers when selecting the first item.
767−func (m *model) goToTop() tea.Cmd {
768− cmds := []tea.Cmd{m.blurSelected()}
769− m.viewState.reverse = false
770− m.selectionState.selectedIndex = m.findFirstSelectableItem()
771− if m.isFocused {
772− cmds = append(cmds, m.focusSelected())
773− }
774− m.ResetView()
775− return tea.Batch(cmds...)
776−}
777−
778−// ResetView clears all cached rendering data and resets scroll position.
779−// Forces a complete re-render on the next View() call.
780−func (m *model) ResetView() {
781− m.renderState.reset()
782− m.viewState.offset = 0
783−}
784−
785−// focusSelected gives focus to the currently selected item if it supports focus.
786−// Triggers a re-render of the item to show its focused state.
787−func (m *model) focusSelected() tea.Cmd {
788− if !m.isFocused {
789− return nil // No focus change if the list is not focused
790− }
791− if !m.selectionState.isValidIndex(len(m.filteredItems)) {
792− return nil
793− }
794− if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
795− cmd := i.Focus()
796− m.rerenderItem(m.selectionState.selectedIndex)
797− return cmd
798− }
799− return nil
800−}
801−
802−// blurSelected removes focus from the currently selected item if it supports focus.
803−// Triggers a re-render of the item to show its unfocused state.
804−func (m *model) blurSelected() tea.Cmd {
805− if !m.selectionState.isValidIndex(len(m.filteredItems)) {
806− return nil
807− }
808− if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
809− cmd := i.Blur()
810− m.rerenderItem(m.selectionState.selectedIndex)
811− return cmd
812− }
813− return nil
814−}
815−
816−// rerenderItem updates the cached rendering of a specific item.
817−// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
818−// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
819−func (m *model) rerenderItem(inx int) {
820− if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
821− return
822− }
823−
824− cachedItem, ok := m.renderState.items[inx]
825− if !ok {
826− return
827− }
828−
829− rerenderedLines := m.getItemLines(m.filteredItems[inx])
830− if slices.Equal(cachedItem.lines, rerenderedLines) {
831− return
832− }
833−
834− m.updateRenderedLines(cachedItem, rerenderedLines)
835− m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
836− m.updateCachedItem(inx, cachedItem, rerenderedLines)
837− m.renderState.needsRerender = true
838−}
839−
840−// getItemLines converts an item to its rendered lines, including any gap spacing.
841−// Handles section headers with special styling.
842−func (m *model) getItemLines(item util.Model) []string {
843− var itemLines []string
844−
845− itemLines = strings.Split(item.View(), "\n")
846−
847− if m.gapSize > 0 {
848− gap := make([]string, m.gapSize)
849− itemLines = append(itemLines, gap...)
850− }
851− return itemLines
852−}
853−
854−// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
855−func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
856− start, end := m.getItemBounds(cachedItem)
857− totalLines := len(m.renderState.lines)
858−
859− if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
860− m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
861− m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
862− }
863−}
864−
865−// getItemBounds calculates the start and end line positions for an item.
866−// Handles both forward and reverse rendering modes.
867−func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
868− start = cachedItem.start
869− end = start + cachedItem.height
870−
871− if m.viewState.reverse {
872− totalLines := len(m.renderState.lines)
873− end = totalLines - cachedItem.start
874− start = end - cachedItem.height
875− }
876− return start, end
877−}
878−
879−// updateItemPositions recalculates positions for items after the changed item.
880−// This is necessary when an item's height changes, affecting subsequent items.
881−func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
882− if cachedItem.height == newHeight {
883− return
884− }
885−
886− if inx == len(m.filteredItems)-1 {
887− m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
888− }
889−
890− currentStart := cachedItem.start + newHeight
891− if m.viewState.reverse {
892− m.updatePositionsReverse(inx, currentStart)
893− } else {
894− m.updatePositionsForward(inx, currentStart)
895− }
896−}
897−
898−// updatePositionsForward updates positions for items after the changed item in forward mode.
899−func (m *model) updatePositionsForward(inx int, currentStart int) {
900− for i := inx + 1; i < len(m.filteredItems); i++ {
901− if existing, ok := m.renderState.items[i]; ok {
902− existing.start = currentStart
903− currentStart += existing.height
904− m.renderState.items[i] = existing
905− } else {
906− break
907− }
908− }
909−}
910−
911−// updatePositionsReverse updates positions for items before the changed item in reverse mode.
912−func (m *model) updatePositionsReverse(inx int, currentStart int) {
913− for i := inx - 1; i >= 0; i-- {
914− if existing, ok := m.renderState.items[i]; ok {
915− existing.start = currentStart
916− currentStart += existing.height
917− m.renderState.items[i] = existing
918− } else {
919− break
920− }
921− }
922−}
923−
924−// updateCachedItem updates the cached rendering information for a specific item.
925−func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
926− m.renderState.items[inx] = renderedItem{
927− lines: newLines,
928− start: cachedItem.start,
929− height: len(newLines),
930− }
931−}
932−
933−// increaseOffset scrolls the list down by increasing the offset.
934−// Respects the final height limit to prevent scrolling past the end.
935−func (m *model) increaseOffset(n int) {
936− if m.renderState.finalHeight > NoFinalHeight {
937− if m.viewState.offset < m.renderState.finalHeight {
938− m.viewState.offset += n
939− if m.viewState.offset > m.renderState.finalHeight {
940− m.viewState.offset = m.renderState.finalHeight
941− }
942− m.renderState.needsRerender = true
943− }
944− } else {
945− m.viewState.offset += n
946− m.renderState.needsRerender = true
947− }
948−}
949−
950−// decreaseOffset scrolls the list up by decreasing the offset.
951−// Prevents scrolling above the beginning of the list.
952−func (m *model) decreaseOffset(n int) {
953− if m.viewState.offset > 0 {
954− m.viewState.offset -= n
955− if m.viewState.offset < 0 {
956− m.viewState.offset = 0
957− }
958− m.renderState.needsRerender = true
959− }
960−}
961−
962−// UpdateItem replaces an item at the specified index with a new item.
963−// Handles focus management and triggers re-rendering as needed.
964−func (m *model) UpdateItem(inx int, item util.Model) {
965− if inx < 0 || inx >= len(m.filteredItems) {
966− return
967− }
968− m.filteredItems[inx] = item
969− if m.selectionState.selectedIndex == inx {
970− m.focusSelected()
971− }
972− m.setItemSize(inx)
973− m.rerenderItem(inx)
974− m.renderState.needsRerender = true
975−}
976−
977−// GetSize returns the current dimensions of the list.
978−func (m *model) GetSize() (int, int) {
979− return m.viewState.width, m.viewState.height
980−}
981−
982−// SetSize updates the list dimensions and triggers a complete re-render.
983−// Also updates the size of all items that support sizing.
984−func (m *model) SetSize(width int, height int) tea.Cmd {
985− if m.filterable && !m.hideFilterInput {
986− height -= 2 // adjust for input field height and border
987− }
988−
989− if m.viewState.width == width && m.viewState.height == height {
990− return nil
991− }
992− if m.viewState.height != height {
993− m.renderState.finalHeight = NoFinalHeight
994− m.viewState.height = height
995− }
996− m.viewState.width = width
997− m.ResetView()
998− if m.filterable && !m.hideFilterInput {
999− m.input.SetWidth(m.getItemWidth() - 5)
1000− }
1001− return m.setAllItemsSize()
1002−}
1003−
1004−// getItemWidth calculates the available width for items, accounting for padding.
1005−func (m *model) getItemWidth() int {
1006− width := m.viewState.width
1007− switch len(m.padding) {
1008− case 1:
1009− width -= m.padding[0] * 2
1010− case 2, 3:
1011− width -= m.padding[1] * 2
1012− case 4:
1013− width -= m.padding[1] + m.padding[3]
1014− }
1015− return max(0, width)
1016−}
1017−
1018−// setItemSize updates the size of a specific item if it supports sizing.
1019−func (m *model) setItemSize(inx int) tea.Cmd {
1020− if inx < 0 || inx >= len(m.filteredItems) {
1021− return nil
1022− }
1023− if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
1024− return i.SetSize(m.getItemWidth(), 0)
1025− }
1026− return nil
1027−}
1028−
1029−// setAllItemsSize updates the size of all items that support sizing.
1030−func (m *model) setAllItemsSize() tea.Cmd {
1031− var cmds []tea.Cmd
1032− for i := range m.filteredItems {
1033− if cmd := m.setItemSize(i); cmd != nil {
1034− cmds = append(cmds, cmd)
1035− }
1036− }
1037− return tea.Batch(cmds...)
1038−}
1039−
1040−// listHeight calculates the available height for list content, accounting for padding.
1041−func (m *model) listHeight() int {
1042− height := m.viewState.height
1043− switch len(m.padding) {
1044− case 1:
1045− height -= m.padding[0] * 2
1046− case 2:
1047− height -= m.padding[0] * 2
1048− case 3, 4:
1049− height -= m.padding[0] + m.padding[2]
1050− }
1051− if m.filterable && !m.hideFilterInput {
1052− height -= lipgloss.Height(m.inputStyle.Render("dummy"))
1053− }
1054− return max(0, height)
1055−}
1056−
1057−// AppendItem adds a new item to the end of the list.
1058−// Automatically switches to reverse mode and scrolls to show the new item.
1059−func (m *model) AppendItem(item util.Model) tea.Cmd {
1060− cmds := []tea.Cmd{
1061− item.Init(),
1062− }
1063− m.allItems = append(m.allItems, item)
1064− m.filteredItems = m.allItems
1065− cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1))
1066− cmds = append(cmds, m.goToBottom())
1067− m.renderState.needsRerender = true
1068− return tea.Batch(cmds...)
1069−}
1070−
1071−// DeleteItem removes an item at the specified index.
1072−// Adjusts selection if necessary and triggers a complete re-render.
1073−func (m *model) DeleteItem(i int) {
1074− if i < 0 || i >= len(m.filteredItems) {
1075− return
1076− }
1077− m.allItems = slices.Delete(m.allItems, i, i+1)
1078− delete(m.renderState.items, i)
1079− m.filteredItems = m.allItems
1080−
1081− if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
1082− m.selectionState.selectedIndex--
1083− } else if m.selectionState.selectedIndex > i {
1084− m.selectionState.selectedIndex--
1085− }
1086−
1087− m.ResetView()
1088− m.renderState.needsRerender = true
1089−}
1090−
1091−// PrependItem adds a new item to the beginning of the list.
1092−// Adjusts cached positions and selection index, then switches to forward mode.
1093−func (m *model) PrependItem(item util.Model) tea.Cmd {
1094− cmds := []tea.Cmd{item.Init()}
1095− m.allItems = append([]util.Model{item}, m.allItems...)
1096− m.filteredItems = m.allItems
1097−
1098− // Shift all cached item indices by 1
1099− newItems := make(map[int]renderedItem, len(m.renderState.items))
1100− for k, v := range m.renderState.items {
1101− newItems[k+1] = v
1102− }
1103− m.renderState.items = newItems
1104−
1105− if m.selectionState.selectedIndex >= 0 {
1106− m.selectionState.selectedIndex++
1107− }
1108−
1109− cmds = append(cmds, m.goToTop())
1110− cmds = append(cmds, m.setItemSize(0))
1111− m.renderState.needsRerender = true
1112− return tea.Batch(cmds...)
1113−}
1114−
1115−// setReverse switches between forward and reverse rendering modes.
1116−func (m *model) setReverse(reverse bool) {
1117− if reverse {
1118− m.goToBottom()
1119− } else {
1120− m.goToTop()
1121− }
1122−}
1123−
1124−// SetItems replaces all items in the list with a new set.
1125−// Initializes all items, sets their sizes, and establishes initial selection.
1126−// Ensures the initial selection skips section headers.
1127−func (m *model) SetItems(items []util.Model) tea.Cmd {
1128− m.allItems = items
1129− m.filteredItems = items
1130− cmds := []tea.Cmd{m.setAllItemsSize()}
1131−
1132− for _, item := range m.filteredItems {
1133− cmds = append(cmds, item.Init())
1134− }
1135−
1136− if len(m.filteredItems) > 0 {
1137− if m.viewState.reverse {
1138− m.selectionState.selectedIndex = m.findLastSelectableItem()
1139− } else {
1140− m.selectionState.selectedIndex = m.findFirstSelectableItem()
1141− }
1142− if cmd := m.focusSelected(); cmd != nil {
1143− cmds = append(cmds, cmd)
1144− }
1145− } else {
1146− m.selectionState.selectedIndex = NoSelection
1147− }
1148−
1149− m.ResetView()
1150− return tea.Batch(cmds...)
1151−}
1152−
1153−// section represents a group of items under a section header.
1154−type section struct {
1155− header SectionHeader
1156− items []util.Model
1157−}
1158−
1159−// parseSections parses the flat item list into sections.
1160−func (m *model) parseSections() []section {
1161− var sections []section
1162− var currentSection *section
1163−
1164− for _, item := range m.allItems {
1165− if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() {
1166− // Start a new section
1167− if currentSection != nil {
1168− sections = append(sections, *currentSection)
1169− }
1170− currentSection = &section{
1171− header: header,
1172− items: []util.Model{},
1173− }
1174− } else if currentSection != nil {
1175− // Add item to current section
1176− currentSection.items = append(currentSection.items, item)
1177− } else {
1178− // Item without a section header - create an implicit section
1179− if len(sections) == 0 || sections[len(sections)-1].header != nil {
1180− sections = append(sections, section{
1181− header: nil,
1182− items: []util.Model{item},
1183− })
1184− } else {
1185− // Add to the last implicit section
1186− sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
1187− }
1188− }
1189− }
1190−
1191− // Don't forget the last section
1192− if currentSection != nil {
1193− sections = append(sections, *currentSection)
1194− }
1195−
1196− return sections
1197−}
1198−
1199−// flattenSections converts sections back to a flat list.
1200−func (m *model) flattenSections(sections []section) []util.Model {
1201− var result []util.Model
1202−
1203− for _, sect := range sections {
1204− if sect.header != nil {
1205− result = append(result, sect.header)
1206− }
1207− result = append(result, sect.items...)
1208− }
1209−
1210− return result
1211−}
1212−
1213−func (m *model) Filter(search string) tea.Cmd {
1214− var cmds []tea.Cmd
1215− search = strings.TrimSpace(search)
1216− search = strings.ToLower(search)
1217−
1218− // Clear focus and match indexes from all items
1219− for _, item := range m.allItems {
1220− if i, ok := item.(layout.Focusable); ok {
1221− cmds = append(cmds, i.Blur())
1222− }
1223− if i, ok := item.(HasMatchIndexes); ok {
1224− i.MatchIndexes(make([]int, 0))
1225− }
1226− }
1227−
1228− if search == "" {
1229− cmds = append(cmds, m.SetItems(m.allItems))
1230− return tea.Batch(cmds...)
1231− }
1232−
1233− // Parse items into sections
1234− sections := m.parseSections()
1235− var filteredSections []section
1236−
1237− for _, sect := range sections {
1238− filteredSection := m.filterSection(sect, search)
1239− if filteredSection != nil {
1240− filteredSections = append(filteredSections, *filteredSection)
1241− }
1242− }
1243−
1244− // Rebuild flat list from filtered sections
1245− m.filteredItems = m.flattenSections(filteredSections)
1246−
1247− // Set initial selection
1248− if len(m.filteredItems) > 0 {
1249− if m.viewState.reverse {
1250− slices.Reverse(m.filteredItems)
1251− m.selectionState.selectedIndex = m.findLastSelectableItem()
1252− } else {
1253− m.selectionState.selectedIndex = m.findFirstSelectableItem()
1254− }
1255− if cmd := m.focusSelected(); cmd != nil {
1256− cmds = append(cmds, cmd)
1257− }
1258− } else {
1259− m.selectionState.selectedIndex = NoSelection
1260− }
1261−
1262− m.ResetView()
1263− return tea.Batch(cmds...)
1264−}
1265−
1266−// filterSection filters items within a section and returns the section if it has matches.
1267−func (m *model) filterSection(sect section, search string) *section {
1268− var matchedItems []util.Model
1269− var hasHeaderMatch bool
1270−
1271− // Check if section header itself matches
1272− if sect.header != nil {
1273− headerText := strings.ToLower(sect.header.View())
1274− if strings.Contains(headerText, search) {
1275− hasHeaderMatch = true
1276− // If header matches, include all items in the section
1277− matchedItems = sect.items
1278− }
1279− }
1280−
1281− // If header didn't match, filter items within the section
1282− if !hasHeaderMatch && len(sect.items) > 0 {
1283− // Create words array for items in this section
1284− words := make([]string, len(sect.items))
1285− for i, item := range sect.items {
1286− if f, ok := item.(HasFilterValue); ok {
1287− words[i] = strings.ToLower(f.FilterValue())
1288− } else {
1289− words[i] = ""
1290− }
1291− }
1292−
1293− // Find matches within this section
1294− matches := fuzzy.Find(search, words)
1295−
1296− // Sort matches by score but preserve relative order for equal scores
1297− sort.SliceStable(matches, func(i, j int) bool {
1298− return matches[i].Score > matches[j].Score
1299− })
1300−
1301− // Build matched items list
1302− for _, match := range matches {
1303− item := sect.items[match.Index]
1304− if i, ok := item.(HasMatchIndexes); ok {
1305− i.MatchIndexes(match.MatchedIndexes)
1306− }
1307− matchedItems = append(matchedItems, item)
1308− }
1309− }
1310−
1311− // Return section only if it has matches
1312− if len(matchedItems) > 0 {
1313− return &section{
1314− header: sect.header,
1315− items: matchedItems,
1316− }
1317− }
1318−
1319− return nil
1320−}
1321−
1322−// SelectedIndex returns the index of the currently selected item.
1323−func (m *model) SelectedIndex() int {
1324− if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
1325− return NoSelection
1326− }
1327− return m.selectionState.selectedIndex
1328−}
1329−
1330−// SetSelected sets the selected item by index and automatically scrolls to make it visible.
1331−// If the index is invalid or points to a section header, it finds the nearest selectable item.
1332−func (m *model) SetSelected(index int) tea.Cmd {
1333− changeNeeded := m.selectionState.selectedIndex - index
1334− cmds := []tea.Cmd{}
1335− if changeNeeded < 0 {
1336− for range -changeNeeded {
1337− cmds = append(cmds, m.selectNextItem())
1338− m.renderVisible()
1339− }
1340− } else if changeNeeded > 0 {
1341− for range changeNeeded {
1342− cmds = append(cmds, m.selectPreviousItem())
1343− m.renderVisible()
1344− }
1345− }
1346− return tea.Batch(cmds...)
1347−}
1348−
1349−// Blur implements ListModel.
1350−func (m *model) Blur() tea.Cmd {
1351− m.isFocused = false
1352− cmd := m.blurSelected()
1353− return cmd
1354−}
1355−
1356−// Focus implements ListModel.
1357−func (m *model) Focus() tea.Cmd {
1358− m.isFocused = true
1359− cmd := m.focusSelected()
1360− return cmd
1361−}
1362−
1363−// IsFocused implements ListModel.
1364−func (m *model) IsFocused() bool {
1365− return m.isFocused
1366−}
1367−
1368−func (m *model) SetFilterPlaceholder(placeholder string) {
1369− m.input.Placeholder = placeholder
1370−}
internal/tui/components/dialogs/commands/item.godeleted−69
@@ -1,69 +0,0 @@
1−package commands
2−
3−import (
4− tea "github.com/charmbracelet/bubbletea/v2"
5− "github.com/charmbracelet/crush/internal/tui/components/core"
6− "github.com/charmbracelet/crush/internal/tui/components/core/layout"
7− "github.com/charmbracelet/crush/internal/tui/components/core/list"
8− "github.com/charmbracelet/crush/internal/tui/styles"
9− "github.com/charmbracelet/crush/internal/tui/util"
10− "github.com/charmbracelet/x/ansi"
11−)
12−
13−type ItemSection interface {
14− util.Model
15− layout.Sizeable
16− list.SectionHeader
17− SetInfo(info string)
18−}
19−type itemSectionModel struct {
20− width int
21− title string
22− info string
23−}
24−
25−func NewItemSection(title string) ItemSection {
26− return &itemSectionModel{
27− title: title,
28− }
29−}
30−
31−func (m *itemSectionModel) Init() tea.Cmd {
32− return nil
33−}
34−
35−func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
36− return m, nil
37−}
38−
39−func (m *itemSectionModel) View() string {
40− t := styles.CurrentTheme()
41− title := ansi.Truncate(m.title, m.width-2, "…")
42− style := t.S().Base.Padding(1, 1, 0, 1)
43− title = t.S().Muted.Render(title)
44− section := ""
45− if m.info != "" {
46− section = core.SectionWithInfo(title, m.width-2, m.info)
47− } else {
48− section = core.Section(title, m.width-2)
49− }
50−
51− return style.Render(section)
52−}
53−
54−func (m *itemSectionModel) GetSize() (int, int) {
55− return m.width, 1
56−}
57−
58−func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
59− m.width = width
60− return nil
61−}
62−
63−func (m *itemSectionModel) IsSectionHeader() bool {
64− return true
65−}
66−
67−func (m *itemSectionModel) SetInfo(info string) {
68− m.info = info
69−}
internal/tui/exp/list/filterable.go+6
@@ -2,6 +2,7 @@ package list
22
33import (
44 "regexp"
5+ "slices"
56 "sort"
67 "strings"
78
@@ -24,6 +25,7 @@ type FilterableList[T FilterableItem] interface {
2425 Cursor() *tea.Cursor
2526 SetInputWidth(int)
2627 SetInputPlaceholder(string)
28+ Filter(q string) tea.Cmd
2729}
2830
2931type HasMatchIndexes interface {
@@ -263,6 +265,10 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
263265 matchedItems = append(matchedItems, item)
264266 }
265267
268+ if f.list.direction == DirectionBackward {
269+ slices.Reverse(matchedItems)
270+ }
271+
266272 cmds = append(cmds, f.list.SetItems(matchedItems))
267273 return tea.Batch(cmds...)
268274}