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