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