1package list
2
3import (
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
21const (
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.
30type 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.
50type 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.
56type 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.
61type 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.
67type 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.
73type 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.
81type 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.
90func 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.
101func (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.
110type 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.
118type 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.
123func (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.
129type 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.
152type listOptions func(*model)
153
154// WithKeyMap sets custom key bindings for the list.
155func 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).
162func 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.
169func 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.
178func 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.
185func 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.
193func WithFilterable(filterable bool) listOptions {
194 return func(m *model) {
195 m.filterable = filterable
196 }
197}
198
199// WithHideFilterInput hides the filter input field.
200func 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.
207func 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.
214func 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.
221func 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.
230func 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.
263func (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.
270func (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.
285func (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.
295func (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.
320func (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.
362func (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.
378func (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.
390func (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.
400func (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.
409func (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.
415func (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.
425func (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).
445func (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.
467func (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.
477func (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.
496type 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.
505func (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.
524func (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.
539func (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.
545type 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.
554func (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.
573func (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.
588func (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.
595func (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.
626func (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.
654func (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.
665func (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.
675func (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.
686func (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.
703func (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.
729func (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.
753func (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.
767func (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.
780func (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.
787func (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.
804func (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.
819func (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.
842func (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.
855func (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.
867func (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.
881func (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.
899func (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.
912func (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.
925func (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.
935func (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.
952func (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.
964func (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.
978func (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.
984func (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.
1005func (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.
1019func (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.
1030func (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.
1041func (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.
1059func (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.
1073func (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.
1093func (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.
1116func (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.
1127func (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.
1154type section struct {
1155 header SectionHeader
1156 items []util.Model
1157}
1158
1159// parseSections parses the flat item list into sections.
1160func (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 = §ion{
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.
1200func (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
1213func (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.
1267func (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 §ion{
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.
1323func (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.
1332func (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.
1350func (m *model) Blur() tea.Cmd {
1351 m.isFocused = false
1352 cmd := m.blurSelected()
1353 return cmd
1354}
1355
1356// Focus implements ListModel.
1357func (m *model) Focus() tea.Cmd {
1358 m.isFocused = true
1359 cmd := m.focusSelected()
1360 return cmd
1361}
1362
1363// IsFocused implements ListModel.
1364func (m *model) IsFocused() bool {
1365 return m.isFocused
1366}
1367
1368func (m *model) SetFilterPlaceholder(placeholder string) {
1369 m.input.Placeholder = placeholder
1370}