@@ -1,1371 +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− 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.
264−func (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.
271−func (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.
286−func (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.
296−func (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.
321−func (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.
363−func (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.
379−func (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.
391−func (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.
401−func (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.
410−func (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.
416−func (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.
426−func (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).
446−func (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.
468−func (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.
478−func (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.
497−type 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.
506−func (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.
525−func (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.
540−func (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.
546−type 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.
555−func (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.
574−func (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.
589−func (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.
596−func (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.
627−func (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.
655−func (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.
666−func (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.
676−func (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.
687−func (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.
704−func (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.
730−func (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.
754−func (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.
768−func (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.
781−func (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.
788−func (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.
805−func (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.
820−func (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.
843−func (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.
856−func (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.
868−func (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.
882−func (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.
900−func (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.
913−func (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.
926−func (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.
936−func (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.
953−func (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.
965−func (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.
979−func (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.
985−func (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.
1006−func (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.
1020−func (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.
1031−func (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.
1042−func (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.
1060−func (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.
1074−func (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.
1094−func (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.
1117−func (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.
1128−func (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.
1155−type section struct {
1156− header SectionHeader
1157− items []util.Model
1158−}
1159−
1160−// parseSections parses the flat item list into sections.
1161−func (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.
1201−func (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−
1214−func (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.
1268−func (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.
1324−func (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.
1333−func (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.
1351−func (m *model) Blur() tea.Cmd {
1352− m.isFocused = false
1353− cmd := m.blurSelected()
1354− return cmd
1355−}
1356−
1357−// Focus implements ListModel.
1358−func (m *model) Focus() tea.Cmd {
1359− m.isFocused = true
1360− cmd := m.focusSelected()
1361− return cmd
1362−}
1363−
1364−// IsFocused implements ListModel.
1365−func (m *model) IsFocused() bool {
1366− return m.isFocused
1367−}
1368−
1369−func (m *model) SetFilterPlaceholder(placeholder string) {
1370− m.input.Placeholder = placeholder
1371−}