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