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