@@ -1,1371 +0,0 @@
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"
20−// Constants for special index values and defaults
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
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 {
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
48−// HasAnim interface identifies items that support animation.
49−// Items implementing this interface will receive animation update messages.
50−type HasAnim interface {
52− Spinning() bool // Returns true if the item is currently animating
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
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
65−// SectionHeader interface identifies items that are section headers.
66−// Section headers are rendered differently and are skipped during navigation.
67−type SectionHeader interface {
69− IsSectionHeader() bool // Returns true if this item is a section header
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
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
89−// newRenderState creates a new render state with default values.
90−func newRenderState() *renderState {
92− items: make(map[int]renderedItem),
94− lastIndex: NotRendered,
95− finalHeight: NoFinalHeight,
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
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
117−// selectionState manages which item is currently selected.
118−type selectionState struct {
119− selectedIndex int // Index of the currently selected item, or NoSelection
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
127−// model is the main implementation of the ListModel interface.
128−// It coordinates between view state, render state, and selection state.
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
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
148− isFocused bool // Whether the list is currently focused
151−// listOptions is a function type for configuring list options.
152−type listOptions func(*model)
154−// WithKeyMap sets custom key bindings for the list.
155−func WithKeyMap(k KeyMap) listOptions {
156− return func(m *model) {
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)
168−// WithGapSize sets the number of empty lines to insert between list items.
169−func WithGapSize(gapSize int) listOptions {
170− return func(m *model) {
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) {
184−// WithItems sets the initial items for the list.
185−func WithItems(items []util.Model) listOptions {
186− return func(m *model) {
188− m.filteredItems = items // Initially, all items are visible
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
199−// WithHideFilterInput hides the filter input field.
200−func WithHideFilterInput(hide bool) listOptions {
201− return func(m *model) {
202− m.hideFilterInput = hide
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
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
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
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()
235− keyMap: DefaultKeyMap(),
236− allItems: []util.Model{},
237− filteredItems: []util.Model{},
238− renderState: newRenderState(),
239− gapSize: DefaultGapSize,
241− selectionState: selectionState{selectedIndex: NoSelection},
242− filterPlaceholder: "Type to filter...",
243− inputStyle: t.S().Base.Padding(0, 1, 1, 1),
246− for _, opt := range opts {
250− if m.filterable && !m.hideFilterInput {
251− t := styles.CurrentTheme()
252− ti := textinput.New()
253− ti.Placeholder = m.filterPlaceholder
254− ti.SetVirtualCursor(false)
256− ti.SetStyles(t.S().TextInput)
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)
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)
276− return m.handleAnimationMsg(msg)
278− if m.selectionState.isValidIndex(len(m.filteredItems)) {
279− return m.updateSelectedItem(msg)
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()
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
300− if m.renderState.needsRerender {
304− content := lipgloss.NewStyle().
305− Padding(m.padding...).
306− Height(m.viewState.height).
307− Render(m.viewState.content)
309− if m.filterable && !m.hideFilterInput {
310− content = lipgloss.JoinVertical(
312− m.inputStyle.Render(m.input.View()),
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) {
323− case key.Matches(msg, m.keyMap.Down):
325− case key.Matches(msg, m.keyMap.Up):
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()
344− if !m.filterable || m.hideFilterInput {
345− return m, nil // Ignore other keys if not filterable or input is hidden
348− u, cmd := m.input.Update(msg)
350− cmds = append(cmds, cmd)
351− if m.currentSearch != m.input.Value() {
352− cmd = m.Filter(m.input.Value())
353− cmds = append(cmds, cmd)
355− m.currentSearch = m.input.Value()
356− return m, tea.Batch(cmds...)
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) {
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)
374− return m, tea.Batch(cmds...)
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) {
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)
386− return m, tea.Batch(cmds...)
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)
395− m.increaseOffset(amount)
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)
405− m.decreaseOffset(amount)
409−// Items returns a copy of all items in the list.
410−func (m *model) Items() []util.Model {
411− return m.filteredItems
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()
420− m.renderVisibleForward()
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{
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,
435− if m.renderState.lastIndex > NotRendered {
436− renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
437− renderer.start = len(m.renderState.lines)
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{
450− cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2,
451− items: m.filteredItems,
452− realIdx: m.renderState.lastIndex,
455− if m.renderState.lastIndex > NotRendered {
456− renderer.items = m.filteredItems[:m.renderState.lastIndex]
457− renderer.start = len(m.renderState.lines)
459− m.renderState.lastIndex = len(m.filteredItems)
460− renderer.realIdx = len(m.filteredItems)
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)
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 = ""
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")
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")
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
505−// render processes items in forward order, building up the rendered content.
506−func (r *forwardRenderer) render() {
507− for _, item := range r.items {
509− if r.start > r.cutoff {
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())
518− r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
519− r.model.renderState.lastIndex = r.realIdx
520− r.start += len(itemLines)
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
530− itemLines := r.renderItemLines(item)
531− r.model.renderState.items[r.realIdx] = renderedItem{
534− height: len(itemLines),
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)
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
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-- {
558− if r.start > r.cutoff {
562− itemLines := r.getOrRenderItem(r.items[i])
564− r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
567− r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
568− r.model.renderState.lastIndex = r.realIdx
569− r.start += len(itemLines)
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
579− itemLines := r.renderItemLines(item)
580− r.model.renderState.items[r.realIdx] = renderedItem{
583− height: len(itemLines),
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)
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()
601− if m.selectionState.selectedIndex <= 0 {
605− cmds := []tea.Cmd{m.blurSelected()}
606− m.selectionState.selectedIndex--
608− // Skip section headers
609− for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
610− m.selectionState.selectedIndex--
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...)
619− cmds = append(cmds, m.focusSelected())
620− m.ensureSelectedItemVisible()
621− return tea.Batch(cmds...)
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
632− if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
636− cmds := []tea.Cmd{m.blurSelected()}
637− m.selectionState.selectedIndex++
639− // Skip section headers
640− for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
641− m.selectionState.selectedIndex++
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()
649− cmds = append(cmds, m.focusSelected())
650− m.ensureSelectedItemVisible()
651− return tea.Batch(cmds...)
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) {
659− if header, ok := m.filteredItems[index].(SectionHeader); ok {
660− return header.IsSectionHeader()
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) {
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) {
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]
690− m.renderState.needsRerender = true
694− if m.viewState.reverse {
695− m.ensureVisibleReverse(cachedItem)
697− m.ensureVisibleForward(cachedItem)
699− m.renderState.needsRerender = true
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)
710− changeNeeded := cachedItem.start - m.viewState.offset
711− m.increaseOffset(changeNeeded)
716− if cachedItem.start < m.viewState.offset {
717− changeNeeded := m.viewState.offset - cachedItem.start
718− m.decreaseOffset(changeNeeded)
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)
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)
736− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
737− m.increaseOffset(changeNeeded)
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)
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()
759− cmds = append(cmds, m.focusSelected())
762− return tea.Batch(cmds...)
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()
773− cmds = append(cmds, m.focusSelected())
776− return tea.Batch(cmds...)
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
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 {
790− return nil // No focus change if the list is not focused
792− if !m.selectionState.isValidIndex(len(m.filteredItems)) {
795− if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
797− m.rerenderItem(m.selectionState.selectedIndex)
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)) {
809− if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
811− m.rerenderItem(m.selectionState.selectedIndex)
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 {
825− cachedItem, ok := m.renderState.items[inx]
830− rerenderedLines := m.getItemLines(m.filteredItems[inx])
831− if slices.Equal(cachedItem.lines, rerenderedLines) {
835− m.updateRenderedLines(cachedItem, rerenderedLines)
836− m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
837− m.updateCachedItem(inx, cachedItem, rerenderedLines)
838− m.renderState.needsRerender = true
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
846− itemLines = strings.Split(item.View(), "\n")
849− gap := make([]string, m.gapSize)
850− itemLines = append(itemLines, gap...)
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)
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...)
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
872− if m.viewState.reverse {
873− totalLines := len(m.renderState.lines)
874− end = totalLines - cachedItem.start
875− start = end - cachedItem.height
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 {
887− if inx == len(m.filteredItems)-1 {
888− m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
891− currentStart := cachedItem.start + newHeight
892− if m.viewState.reverse {
893− m.updatePositionsReverse(inx, currentStart)
895− m.updatePositionsForward(inx, currentStart)
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
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
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{
929− start: cachedItem.start,
930− height: len(newLines),
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
943− m.renderState.needsRerender = true
946− m.viewState.offset += n
947− m.renderState.needsRerender = true
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
959− m.renderState.needsRerender = true
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) {
969− m.filteredItems[inx] = item
970− if m.selectionState.selectedIndex == inx {
975− m.renderState.needsRerender = true
978−// GetSize returns the current dimensions of the list.
979−func (m *model) GetSize() (int, int) {
980− return m.viewState.width, m.viewState.height
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
990− if m.viewState.width == width && m.viewState.height == height {
993− if m.viewState.height != height {
994− m.renderState.finalHeight = NoFinalHeight
995− m.viewState.height = height
997− m.viewState.width = width
999− if m.filterable && !m.hideFilterInput {
1000− m.input.SetWidth(m.getItemWidth() - 5)
1002− return m.setAllItemsSize()
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) {
1010− width -= m.padding[0] * 2
1012− width -= m.padding[1] * 2
1014− width -= m.padding[1] + m.padding[3]
1016− return max(0, width)
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) {
1024− if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
1025− return i.SetSize(m.getItemWidth(), 0)
1030−// setAllItemsSize updates the size of all items that support sizing.
1031−func (m *model) setAllItemsSize() tea.Cmd {
1033− for i := range m.filteredItems {
1034− if cmd := m.setItemSize(i); cmd != nil {
1035− cmds = append(cmds, cmd)
1038− return tea.Batch(cmds...)
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) {
1046− height -= m.padding[0] * 2
1048− height -= m.padding[0] * 2
1050− height -= m.padding[0] + m.padding[2]
1052− if m.filterable && !m.hideFilterInput {
1053− height -= lipgloss.Height(m.inputStyle.Render("dummy"))
1055− return max(0, height)
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 {
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...)
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) {
1078− m.allItems = slices.Delete(m.allItems, i, i+1)
1079− delete(m.renderState.items, i)
1080− m.filteredItems = m.allItems
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--
1089− m.renderState.needsRerender = true
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
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 {
1104− m.renderState.items = newItems
1106− if m.selectionState.selectedIndex >= 0 {
1107− m.selectionState.selectedIndex++
1110− cmds = append(cmds, m.goToTop())
1111− cmds = append(cmds, m.setItemSize(0))
1112− m.renderState.needsRerender = true
1113− return tea.Batch(cmds...)
1116−// setReverse switches between forward and reverse rendering modes.
1117−func (m *model) setReverse(reverse bool) {
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 {
1130− m.filteredItems = items
1131− cmds := []tea.Cmd{m.setAllItemsSize()}
1133− for _, item := range m.filteredItems {
1134− cmds = append(cmds, item.Init())
1137− if len(m.filteredItems) > 0 {
1138− if m.viewState.reverse {
1139− m.selectionState.selectedIndex = m.findLastSelectableItem()
1141− m.selectionState.selectedIndex = m.findFirstSelectableItem()
1143− if cmd := m.focusSelected(); cmd != nil {
1144− cmds = append(cmds, cmd)
1147− m.selectionState.selectedIndex = NoSelection
1151− return tea.Batch(cmds...)
1154−// section represents a group of items under a section header.
1155−type section struct {
1156− header SectionHeader
1160−// parseSections parses the flat item list into sections.
1161−func (m *model) parseSections() []section {
1162− var sections []section
1163− var currentSection *section
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)
1171− currentSection = §ion{
1173− items: []util.Model{},
1175− } else if currentSection != nil {
1176− // Add item to current section
1177− currentSection.items = append(currentSection.items, item)
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{
1183− items: []util.Model{item},
1186− // Add to the last implicit section
1187− sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
1192− // Don't forget the last section
1193− if currentSection != nil {
1194− sections = append(sections, *currentSection)
1200−// flattenSections converts sections back to a flat list.
1201−func (m *model) flattenSections(sections []section) []util.Model {
1202− var result []util.Model
1204− for _, sect := range sections {
1205− if sect.header != nil {
1206− result = append(result, sect.header)
1208− result = append(result, sect.items...)
1214−func (m *model) Filter(search string) tea.Cmd {
1216− search = strings.TrimSpace(search)
1217− search = strings.ToLower(search)
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())
1224− if i, ok := item.(HasMatchIndexes); ok {
1225− i.MatchIndexes(make([]int, 0))
1230− cmds = append(cmds, m.SetItems(m.allItems))
1231− return tea.Batch(cmds...)
1234− // Parse items into sections
1235− sections := m.parseSections()
1236− var filteredSections []section
1238− for _, sect := range sections {
1239− filteredSection := m.filterSection(sect, search)
1240− if filteredSection != nil {
1241− filteredSections = append(filteredSections, *filteredSection)
1245− // Rebuild flat list from filtered sections
1246− m.filteredItems = m.flattenSections(filteredSections)
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()
1254− m.selectionState.selectedIndex = m.findFirstSelectableItem()
1256− if cmd := m.focusSelected(); cmd != nil {
1257− cmds = append(cmds, cmd)
1260− m.selectionState.selectedIndex = NoSelection
1264− return tea.Batch(cmds...)
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
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
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())
1294− // Find matches within this section
1295− matches := fuzzy.Find(search, words)
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
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)
1308− matchedItems = append(matchedItems, item)
1312− // Return section only if it has matches
1313− if len(matchedItems) > 0 {
1315− header: sect.header,
1316− items: matchedItems,
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) {
1328− return m.selectionState.selectedIndex
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())
1341− } else if changeNeeded > 0 {
1342− for range changeNeeded {
1343− cmds = append(cmds, m.selectPreviousItem())
1347− return tea.Batch(cmds...)
1350−// Blur implements ListModel.
1351−func (m *model) Blur() tea.Cmd {
1352− m.isFocused = false
1353− cmd := m.blurSelected()
1357−// Focus implements ListModel.
1358−func (m *model) Focus() tea.Cmd {
1360− cmd := m.focusSelected()
1364−// IsFocused implements ListModel.
1365−func (m *model) IsFocused() bool {
1369−func (m *model) SetFilterPlaceholder(placeholder string) {
1370− m.input.Placeholder = placeholder