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