Detailed changes
@@ -3,14 +3,5 @@
"Go": {
"command": "gopls"
}
- },
- "mcp": {
- "linear": {
- "type": "stdio",
- "command": "mcp-remote",
- "args": [
- "https://mcp.linear.app/sse"
- ]
- }
}
}
@@ -14,6 +14,7 @@ require (
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250716191546-1e2ffbbcf5c5
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac
github.com/charmbracelet/catwalk v0.3.1
+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69
github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674
github.com/charmbracelet/glamour/v2 v2.0.0-20250516160903-6f1e2c8f9ebe
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250716211347-10c048e36112
@@ -74,6 +74,8 @@ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6a
github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250717140350-bb75e8f6b6ac/go.mod h1:m240IQxo1/eDQ7klblSzOCAUyc3LddHcV3Rc/YEGAgw=
github.com/charmbracelet/catwalk v0.3.1 h1:MkGWspcMyE659zDkqS+9wsaCMTKRFEDBFY2A2sap6+U=
github.com/charmbracelet/catwalk v0.3.1/go.mod h1:gUUCqqZ8bk4D7ZzGTu3I77k7cC2x4exRuJBN1H2u2pc=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69 h1:nXLMl4ows2qogDXhuEtDNgFNXQiU+PJer+UEBsQZuns=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250724172607-5ba56e2bec69/go.mod h1:XIQ1qQfRph6Z5o2EikCydjumo0oDInQySRHuPATzbZc=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/fang v0.3.1-0.20250711140230-d5ebb8c1d674 h1:+Cz+VfxD5DO+JT1LlswXWhre0HYLj6l2HW8HVGfMuC0=
@@ -5,7 +5,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core/list"
+ "github.com/charmbracelet/crush/internal/tui/exp/list"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
"github.com/charmbracelet/lipgloss/v2"
@@ -50,6 +50,8 @@ type Completions interface {
Height() int
}
+type listModel = list.FilterableList[list.CompletionItem[any]]
+
type completionsCmp struct {
width int
height int // Height of the completions component`
@@ -58,7 +60,7 @@ type completionsCmp struct {
open bool // Indicates if the completions are open
keyMap KeyMap
- list list.ListModel
+ list listModel
query string // The current filter query
}
@@ -76,10 +78,13 @@ func New() Completions {
keyMap.UpOneItem = completionsKeyMap.Up
keyMap.DownOneItem = completionsKeyMap.Down
- l := list.New(
- list.WithReverse(true),
- list.WithKeyMap(keyMap),
- list.WithHideFilterInput(true),
+ l := list.NewFilterableList(
+ []list.CompletionItem[any]{},
+ list.WithFilterInputHidden(),
+ list.WithFilterListOptions(
+ list.WithDirectionBackward(),
+ list.WithKeyMap(keyMap),
+ ),
)
return &completionsCmp{
width: 0,
@@ -109,12 +114,12 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, c.keyMap.Up):
u, cmd := c.list.Update(msg)
- c.list = u.(list.ListModel)
+ c.list = u.(listModel)
return c, cmd
case key.Matches(msg, c.keyMap.Down):
d, cmd := c.list.Update(msg)
- c.list = d.(list.ListModel)
+ c.list = d.(listModel)
return c, cmd
case key.Matches(msg, c.keyMap.UpInsert):
selectedItemInx := c.list.SelectedIndex() - 1
@@ -141,12 +146,11 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
Insert: true,
})
case key.Matches(msg, c.keyMap.Select):
- selectedItemInx := c.list.SelectedIndex()
- if selectedItemInx == list.NoSelection {
- return c, nil // No item selected, do nothing
+ s := c.list.SelectedItem()
+ if s == nil {
+ return c, nil
}
- items := c.list.Items()
- selectedItem := items[selectedItemInx].(CompletionItem).Value()
+ selectedItem := *s
c.open = false // Close completions after selection
return c, util.CmdHandler(SelectCompletionMsg{
Value: selectedItem,
@@ -162,10 +166,14 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c.query = ""
c.x = msg.X
c.y = msg.Y
- items := []util.Model{}
+ items := []list.CompletionItem[any]{}
t := styles.CurrentTheme()
for _, completion := range msg.Completions {
- item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
+ item := list.NewCompletionItem(
+ completion.Title,
+ completion.Value,
+ list.WithCompletionBackgroundColor(t.BgSubtle),
+ )
items = append(items, item)
}
c.height = max(min(c.height, len(items)), 1) // Ensure at least 1 item height
@@ -1,282 +0,0 @@
-package completions
-
-import (
- "image/color"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/core/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/x/ansi"
- "github.com/rivo/uniseg"
-)
-
-type CompletionItem interface {
- util.Model
- layout.Focusable
- layout.Sizeable
- list.HasMatchIndexes
- list.HasFilterValue
- Value() any
-}
-
-type completionItemCmp struct {
- width int
- text string
- value any
- focus bool
- matchIndexes []int
- bgColor color.Color
- shortcut string
-}
-
-type CompletionOption func(*completionItemCmp)
-
-func WithBackgroundColor(c color.Color) CompletionOption {
- return func(cmp *completionItemCmp) {
- cmp.bgColor = c
- }
-}
-
-func WithMatchIndexes(indexes ...int) CompletionOption {
- return func(cmp *completionItemCmp) {
- cmp.matchIndexes = indexes
- }
-}
-
-func WithShortcut(shortcut string) CompletionOption {
- return func(cmp *completionItemCmp) {
- cmp.shortcut = shortcut
- }
-}
-
-func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
- c := &completionItemCmp{
- text: text,
- value: value,
- }
-
- for _, opt := range opts {
- opt(c)
- }
- return c
-}
-
-// Init implements CommandItem.
-func (c *completionItemCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update implements CommandItem.
-func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
- return c, nil
-}
-
-// View implements CommandItem.
-func (c *completionItemCmp) View() string {
- t := styles.CurrentTheme()
-
- itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
- innerWidth := c.width - 2 // Account for padding
-
- if c.shortcut != "" {
- innerWidth -= lipgloss.Width(c.shortcut)
- }
-
- titleStyle := t.S().Text.Width(innerWidth)
- titleMatchStyle := t.S().Text.Underline(true)
- if c.bgColor != nil {
- titleStyle = titleStyle.Background(c.bgColor)
- titleMatchStyle = titleMatchStyle.Background(c.bgColor)
- itemStyle = itemStyle.Background(c.bgColor)
- }
-
- if c.focus {
- titleStyle = t.S().TextSelected.Width(innerWidth)
- titleMatchStyle = t.S().TextSelected.Underline(true)
- itemStyle = itemStyle.Background(t.Primary)
- }
-
- var truncatedTitle string
-
- if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
- // Smart truncation: ensure the last matching part is visible
- truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
- } else {
- // No matches, use regular truncation
- truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
- }
-
- text := titleStyle.Render(truncatedTitle)
- if len(c.matchIndexes) > 0 {
- var ranges []lipgloss.Range
- for _, rng := range matchedRanges(c.matchIndexes) {
- // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
- // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
- // so we need to adjust it here:
- start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
- ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
- }
- text = lipgloss.StyleRanges(text, ranges...)
- }
- parts := []string{text}
- if c.shortcut != "" {
- // Add the shortcut at the end
- shortcutStyle := t.S().Muted
- if c.focus {
- shortcutStyle = t.S().TextSelected
- }
- parts = append(parts, shortcutStyle.Render(c.shortcut))
- }
- item := itemStyle.Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- parts...,
- ),
- )
- return item
-}
-
-// Blur implements CommandItem.
-func (c *completionItemCmp) Blur() tea.Cmd {
- c.focus = false
- return nil
-}
-
-// Focus implements CommandItem.
-func (c *completionItemCmp) Focus() tea.Cmd {
- c.focus = true
- return nil
-}
-
-// GetSize implements CommandItem.
-func (c *completionItemCmp) GetSize() (int, int) {
- return c.width, 1
-}
-
-// IsFocused implements CommandItem.
-func (c *completionItemCmp) IsFocused() bool {
- return c.focus
-}
-
-// SetSize implements CommandItem.
-func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
- c.width = width
- return nil
-}
-
-func (c *completionItemCmp) MatchIndexes(indexes []int) {
- c.matchIndexes = indexes
-}
-
-func (c *completionItemCmp) FilterValue() string {
- return c.text
-}
-
-func (c *completionItemCmp) Value() any {
- return c.value
-}
-
-// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
-func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
- if width <= 0 {
- return ""
- }
-
- textLen := ansi.StringWidth(text)
- if textLen <= width {
- return text
- }
-
- if len(matchIndexes) == 0 {
- return ansi.Truncate(text, width, "…")
- }
-
- // Find the last match position
- lastMatchPos := matchIndexes[len(matchIndexes)-1]
-
- // Convert byte position to visual width position
- lastMatchVisualPos := 0
- bytePos := 0
- gr := uniseg.NewGraphemes(text)
- for bytePos < lastMatchPos && gr.Next() {
- bytePos += len(gr.Str())
- lastMatchVisualPos += max(1, gr.Width())
- }
-
- // Calculate how much space we need for the ellipsis
- ellipsisWidth := 1 // "…" character width
- availableWidth := width - ellipsisWidth
-
- // If the last match is within the available width, truncate from the end
- if lastMatchVisualPos < availableWidth {
- return ansi.Truncate(text, width, "…")
- }
-
- // Calculate the start position to ensure the last match is visible
- // We want to show some context before the last match if possible
- startVisualPos := max(0, lastMatchVisualPos-availableWidth+1)
-
- // Convert visual position back to byte position
- startBytePos := 0
- currentVisualPos := 0
- gr = uniseg.NewGraphemes(text)
- for currentVisualPos < startVisualPos && gr.Next() {
- startBytePos += len(gr.Str())
- currentVisualPos += max(1, gr.Width())
- }
-
- // Extract the substring starting from startBytePos
- truncatedText := text[startBytePos:]
-
- // Truncate to fit width with ellipsis
- truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
- truncatedText = "…" + truncatedText
- return truncatedText
-}
-
-func matchedRanges(in []int) [][2]int {
- if len(in) == 0 {
- return [][2]int{}
- }
- current := [2]int{in[0], in[0]}
- if len(in) == 1 {
- return [][2]int{current}
- }
- var out [][2]int
- for i := 1; i < len(in); i++ {
- if in[i] == current[1]+1 {
- current[1] = in[i]
- } else {
- out = append(out, current)
- current = [2]int{in[i], in[i]}
- }
- }
- out = append(out, current)
- return out
-}
-
-func bytePosToVisibleCharPos(str string, rng [2]int) (int, int) {
- bytePos, byteStart, byteStop := 0, rng[0], rng[1]
- pos, start, stop := 0, 0, 0
- gr := uniseg.NewGraphemes(str)
- for byteStart > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
- }
- start = pos
- for byteStop > bytePos {
- if !gr.Next() {
- break
- }
- bytePos += len(gr.Str())
- pos += max(1, gr.Width())
- }
- stop = pos
- return start, stop
-}
@@ -1,76 +0,0 @@
-package list
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
-)
-
-type KeyMap struct {
- Down,
- Up,
- DownOneItem,
- UpOneItem,
- PageDown,
- PageUp,
- HalfPageDown,
- HalfPageUp,
- Home,
- End key.Binding
-}
-
-func DefaultKeyMap() KeyMap {
- return KeyMap{
- Down: key.NewBinding(
- key.WithKeys("down", "ctrl+j", "ctrl+n", "j"),
- key.WithHelp("↓", "down"),
- ),
- Up: key.NewBinding(
- key.WithKeys("up", "ctrl+k", "ctrl+p", "k"),
- key.WithHelp("↑", "up"),
- ),
- UpOneItem: key.NewBinding(
- key.WithKeys("shift+up", "K"),
- key.WithHelp("shift+↑", "up one item"),
- ),
- DownOneItem: key.NewBinding(
- key.WithKeys("shift+down", "J"),
- key.WithHelp("shift+↓", "down one item"),
- ),
- HalfPageDown: key.NewBinding(
- key.WithKeys("d"),
- key.WithHelp("d", "half page down"),
- ),
- PageDown: key.NewBinding(
- key.WithKeys("pgdown", " ", "f"),
- key.WithHelp("f/pgdn", "page down"),
- ),
- PageUp: key.NewBinding(
- key.WithKeys("pgup", "b"),
- key.WithHelp("b/pgup", "page up"),
- ), HalfPageUp: key.NewBinding(
- key.WithKeys("u"),
- key.WithHelp("u", "half page up"),
- ),
- Home: key.NewBinding(
- key.WithKeys("g", "home"),
- key.WithHelp("g", "home"),
- ),
- End: key.NewBinding(
- key.WithKeys("G", "end"),
- key.WithHelp("G", "end"),
- ),
- }
-}
-
-// KeyBindings implements layout.KeyMapProvider
-func (k KeyMap) KeyBindings() []key.Binding {
- return []key.Binding{
- k.Down,
- k.Up,
- k.DownOneItem,
- k.UpOneItem,
- k.HalfPageDown,
- k.HalfPageUp,
- k.Home,
- k.End,
- }
-}
@@ -1,1370 +0,0 @@
-package list
-
-import (
- "slices"
- "sort"
- "strings"
-
- "github.com/charmbracelet/bubbles/v2/help"
- "github.com/charmbracelet/bubbles/v2/key"
- "github.com/charmbracelet/bubbles/v2/textinput"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/anim"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/sahilm/fuzzy"
-)
-
-// Constants for special index values and defaults
-const (
- NoSelection = -1 // Indicates no item is currently selected
- NotRendered = -1 // Indicates an item hasn't been rendered yet
- NoFinalHeight = -1 // Indicates final height hasn't been calculated
- DefaultGapSize = 0 // Default spacing between list items
-)
-
-// ListModel defines the interface for a scrollable, selectable list component.
-// It combines the basic Model interface with sizing capabilities and list-specific operations.
-type ListModel interface {
- util.Model
- layout.Sizeable
- layout.Focusable
- SetItems([]util.Model) tea.Cmd // Replace all items in the list
- AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
- PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
- DeleteItem(int) // Remove an item at the specified index
- UpdateItem(int, util.Model) // Replace an item at the specified index
- ResetView() // Clear rendering cache and reset scroll position
- Items() []util.Model // Get all items in the list
- SelectedIndex() int // Get the index of the currently selected item
- SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
- Filter(string) tea.Cmd // Filter items based on a search term
- SetFilterPlaceholder(string) // Set the placeholder text for the filter input
- Cursor() *tea.Cursor // Get the current cursor position in the filter input
-}
-
-// HasAnim interface identifies items that support animation.
-// Items implementing this interface will receive animation update messages.
-type HasAnim interface {
- util.Model
- Spinning() bool // Returns true if the item is currently animating
-}
-
-// HasFilterValue interface allows items to provide a filter value for searching.
-type HasFilterValue interface {
- FilterValue() string // Returns a string value used for filtering/searching
-}
-
-// HasMatchIndexes interface allows items to set matched character indexes.
-type HasMatchIndexes interface {
- MatchIndexes([]int) // Sets the indexes of matched characters in the item's content
-}
-
-// SectionHeader interface identifies items that are section headers.
-// Section headers are rendered differently and are skipped during navigation.
-type SectionHeader interface {
- util.Model
- IsSectionHeader() bool // Returns true if this item is a section header
-}
-
-// renderedItem represents a cached rendered item with its position and content.
-type renderedItem struct {
- lines []string // The rendered lines of text for this item
- start int // Starting line position in the overall rendered content
- height int // Number of lines this item occupies
-}
-
-// renderState manages the rendering cache and state for the list.
-// It tracks which items have been rendered and their positions.
-type renderState struct {
- items map[int]renderedItem // Cache of rendered items by index
- lines []string // All rendered lines concatenated
- lastIndex int // Index of the last rendered item
- finalHeight int // Total height when all items are rendered
- needsRerender bool // Flag indicating if re-rendering is needed
-}
-
-// newRenderState creates a new render state with default values.
-func newRenderState() *renderState {
- return &renderState{
- items: make(map[int]renderedItem),
- lines: []string{},
- lastIndex: NotRendered,
- finalHeight: NoFinalHeight,
- needsRerender: true,
- }
-}
-
-// reset clears all cached rendering data and resets state to initial values.
-func (rs *renderState) reset() {
- rs.items = make(map[int]renderedItem)
- rs.lines = []string{}
- rs.lastIndex = NotRendered
- rs.finalHeight = NoFinalHeight
- rs.needsRerender = true
-}
-
-// viewState manages the visual display properties of the list.
-type viewState struct {
- width, height int // Dimensions of the list viewport
- offset int // Current scroll offset in lines
- reverse bool // Whether to render in reverse order (bottom-up)
- content string // The final rendered content to display
-}
-
-// selectionState manages which item is currently selected.
-type selectionState struct {
- selectedIndex int // Index of the currently selected item, or NoSelection
-}
-
-// isValidIndex checks if the selected index is within the valid range of items.
-func (ss *selectionState) isValidIndex(itemCount int) bool {
- return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
-}
-
-// model is the main implementation of the ListModel interface.
-// It coordinates between view state, render state, and selection state.
-type model struct {
- viewState viewState // Display and scrolling state
- renderState *renderState // Rendering cache and state
- selectionState selectionState // Item selection state
- help help.Model // Help system for keyboard shortcuts
- keyMap KeyMap // Key bindings for navigation
- allItems []util.Model // The actual list items
- gapSize int // Number of empty lines between items
- padding []int // Padding around the list content
- wrapNavigation bool // Whether to wrap navigation at the ends
-
- filterable bool // Whether items can be filtered
- filterPlaceholder string // Placeholder text for filter input
- filteredItems []util.Model // Filtered items based on current search
- input textinput.Model // Input field for filtering items
- inputStyle lipgloss.Style // Style for the input field
- hideFilterInput bool // Whether to hide the filter input field
- currentSearch string // Current search term for filtering
-
- isFocused bool // Whether the list is currently focused
-}
-
-// listOptions is a function type for configuring list options.
-type listOptions func(*model)
-
-// WithKeyMap sets custom key bindings for the list.
-func WithKeyMap(k KeyMap) listOptions {
- return func(m *model) {
- m.keyMap = k
- }
-}
-
-// WithReverse sets whether the list should render in reverse order (newest items at bottom).
-func WithReverse(reverse bool) listOptions {
- return func(m *model) {
- m.setReverse(reverse)
- }
-}
-
-// WithGapSize sets the number of empty lines to insert between list items.
-func WithGapSize(gapSize int) listOptions {
- return func(m *model) {
- m.gapSize = gapSize
- }
-}
-
-// WithPadding sets the padding around the list content.
-// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
-// 4 values = top/right/bottom/left.
-func WithPadding(padding ...int) listOptions {
- return func(m *model) {
- m.padding = padding
- }
-}
-
-// WithItems sets the initial items for the list.
-func WithItems(items []util.Model) listOptions {
- return func(m *model) {
- m.allItems = items
- m.filteredItems = items // Initially, all items are visible
- }
-}
-
-// WithFilterable enables filtering of items based on their FilterValue.
-func WithFilterable(filterable bool) listOptions {
- return func(m *model) {
- m.filterable = filterable
- }
-}
-
-// WithHideFilterInput hides the filter input field.
-func WithHideFilterInput(hide bool) listOptions {
- return func(m *model) {
- m.hideFilterInput = hide
- }
-}
-
-// WithFilterPlaceholder sets the placeholder text for the filter input field.
-func WithFilterPlaceholder(placeholder string) listOptions {
- return func(m *model) {
- m.filterPlaceholder = placeholder
- }
-}
-
-// WithInputStyle sets the style for the filter input field.
-func WithInputStyle(style lipgloss.Style) listOptions {
- return func(m *model) {
- m.inputStyle = style
- }
-}
-
-// WithWrapNavigation enables wrapping navigation at the ends of the list.
-func WithWrapNavigation(wrap bool) listOptions {
- return func(m *model) {
- m.wrapNavigation = wrap
- }
-}
-
-// New creates a new list model with the specified options.
-// The list starts with no items selected and requires SetItems to be called
-// or items to be provided via WithItems option.
-func New(opts ...listOptions) ListModel {
- t := styles.CurrentTheme()
-
- m := &model{
- help: help.New(),
- keyMap: DefaultKeyMap(),
- allItems: []util.Model{},
- filteredItems: []util.Model{},
- renderState: newRenderState(),
- gapSize: DefaultGapSize,
- padding: []int{},
- selectionState: selectionState{selectedIndex: NoSelection},
- filterPlaceholder: "Type to filter...",
- inputStyle: t.S().Base.Padding(0, 1, 1, 1),
- isFocused: true,
- }
- for _, opt := range opts {
- opt(m)
- }
-
- if m.filterable && !m.hideFilterInput {
- ti := textinput.New()
- ti.Placeholder = m.filterPlaceholder
- ti.SetVirtualCursor(false)
- ti.Focus()
- ti.SetStyles(t.S().TextInput)
- m.input = ti
- }
- return m
-}
-
-// Init initializes the list component and sets up the initial items.
-// This is called automatically by the Bubble Tea framework.
-func (m *model) Init() tea.Cmd {
- return m.SetItems(m.filteredItems)
-}
-
-// Update handles incoming messages and updates the list state accordingly.
-// It processes keyboard input, animation messages, and forwards other messages
-// to the currently selected item.
-func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- return m.handleKeyPress(msg)
- case anim.StepMsg:
- return m.handleAnimationMsg(msg)
- }
- if m.selectionState.isValidIndex(len(m.filteredItems)) {
- return m.updateSelectedItem(msg)
- }
-
- return m, nil
-}
-
-// Cursor returns the current cursor position in the input field.
-func (m *model) Cursor() *tea.Cursor {
- if m.filterable && !m.hideFilterInput {
- return m.input.Cursor()
- }
- return nil
-}
-
-// View renders the list to a string for display.
-// Returns empty string if the list has no dimensions.
-// Triggers re-rendering if needed before returning content.
-func (m *model) View() string {
- if m.viewState.height == 0 || m.viewState.width == 0 {
- return "" // No content to display
- }
- if m.renderState.needsRerender {
- m.renderVisible()
- }
-
- content := lipgloss.NewStyle().
- Padding(m.padding...).
- Height(m.viewState.height).
- Render(m.viewState.content)
-
- if m.filterable && !m.hideFilterInput {
- content = lipgloss.JoinVertical(
- lipgloss.Left,
- m.inputStyle.Render(m.input.View()),
- content,
- )
- }
- return content
-}
-
-// handleKeyPress processes keyboard input for list navigation.
-// Supports scrolling, item selection, and navigation to top/bottom.
-func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
- switch {
- case key.Matches(msg, m.keyMap.Down):
- m.scrollDown(1)
- case key.Matches(msg, m.keyMap.Up):
- m.scrollUp(1)
- case key.Matches(msg, m.keyMap.DownOneItem):
- return m, m.selectNextItem()
- case key.Matches(msg, m.keyMap.UpOneItem):
- return m, m.selectPreviousItem()
- case key.Matches(msg, m.keyMap.HalfPageDown):
- m.scrollDown(m.listHeight() / 2)
- case key.Matches(msg, m.keyMap.HalfPageUp):
- m.scrollUp(m.listHeight() / 2)
- case key.Matches(msg, m.keyMap.PageDown):
- m.scrollDown(m.listHeight())
- case key.Matches(msg, m.keyMap.PageUp):
- m.scrollUp(m.listHeight())
- case key.Matches(msg, m.keyMap.Home):
- return m, m.goToTop()
- case key.Matches(msg, m.keyMap.End):
- return m, m.goToBottom()
- default:
- if !m.filterable || m.hideFilterInput {
- return m, nil // Ignore other keys if not filterable or input is hidden
- }
- var cmds []tea.Cmd
- u, cmd := m.input.Update(msg)
- m.input = u
- cmds = append(cmds, cmd)
- if m.currentSearch != m.input.Value() {
- cmd = m.Filter(m.input.Value())
- cmds = append(cmds, cmd)
- }
- m.currentSearch = m.input.Value()
- return m, tea.Batch(cmds...)
- }
- return m, nil
-}
-
-// handleAnimationMsg forwards animation messages to items that support animation.
-// Only items implementing HasAnim and currently spinning receive these messages.
-func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- for inx, item := range m.filteredItems {
- if i, ok := item.(HasAnim); ok && i.Spinning() {
- updated, cmd := i.Update(msg)
- cmds = append(cmds, cmd)
- if u, ok := updated.(util.Model); ok {
- m.UpdateItem(inx, u)
- }
- }
- }
- return m, tea.Batch(cmds...)
-}
-
-// updateSelectedItem forwards messages to the currently selected item.
-// This allows the selected item to handle its own input and state changes.
-func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- u, cmd := m.filteredItems[m.selectionState.selectedIndex].Update(msg)
- cmds = append(cmds, cmd)
- if updated, ok := u.(util.Model); ok {
- m.UpdateItem(m.selectionState.selectedIndex, updated)
- }
- return m, tea.Batch(cmds...)
-}
-
-// scrollDown scrolls the list down by the specified amount.
-// Direction is automatically adjusted based on reverse mode.
-func (m *model) scrollDown(amount int) {
- if m.viewState.reverse {
- m.decreaseOffset(amount)
- } else {
- m.increaseOffset(amount)
- }
-}
-
-// scrollUp scrolls the list up by the specified amount.
-// Direction is automatically adjusted based on reverse mode.
-func (m *model) scrollUp(amount int) {
- if m.viewState.reverse {
- m.increaseOffset(amount)
- } else {
- m.decreaseOffset(amount)
- }
-}
-
-// Items returns a copy of all items in the list.
-func (m *model) Items() []util.Model {
- return m.filteredItems
-}
-
-// renderVisible determines which rendering strategy to use and triggers rendering.
-// Uses forward rendering for normal mode and reverse rendering for reverse mode.
-func (m *model) renderVisible() {
- if m.viewState.reverse {
- m.renderVisibleReverse()
- } else {
- m.renderVisibleForward()
- }
-}
-
-// renderVisibleForward renders items from top to bottom (normal mode).
-// Only renders items that are currently visible or near the viewport.
-func (m *model) renderVisibleForward() {
- renderer := &forwardRenderer{
- model: m,
- start: 0,
- cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd
- items: m.filteredItems,
- realIdx: m.renderState.lastIndex,
- }
-
- if m.renderState.lastIndex > NotRendered {
- renderer.items = m.filteredItems[m.renderState.lastIndex+1:]
- renderer.start = len(m.renderState.lines)
- }
-
- renderer.render()
- m.finalizeRender()
-}
-
-// renderVisibleReverse renders items from bottom to top (reverse mode).
-// Used when new items should appear at the bottom (like chat messages).
-func (m *model) renderVisibleReverse() {
- renderer := &reverseRenderer{
- model: m,
- start: 0,
- cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2,
- items: m.filteredItems,
- realIdx: m.renderState.lastIndex,
- }
-
- if m.renderState.lastIndex > NotRendered {
- renderer.items = m.filteredItems[:m.renderState.lastIndex]
- renderer.start = len(m.renderState.lines)
- } else {
- m.renderState.lastIndex = len(m.filteredItems)
- renderer.realIdx = len(m.filteredItems)
- }
-
- renderer.render()
- m.finalizeRender()
-}
-
-// finalizeRender completes the rendering process by updating scroll bounds and content.
-func (m *model) finalizeRender() {
- m.renderState.needsRerender = false
- if m.renderState.finalHeight > NoFinalHeight {
- m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
- }
- m.updateContent()
-}
-
-// updateContent extracts the visible portion of rendered content for display.
-// Handles both normal and reverse rendering modes.
-func (m *model) updateContent() {
- maxHeight := min(m.listHeight(), len(m.renderState.lines))
- if m.viewState.offset >= len(m.renderState.lines) {
- m.viewState.content = ""
- return
- }
-
- if m.viewState.reverse {
- end := len(m.renderState.lines) - m.viewState.offset
- start := max(0, end-maxHeight)
- m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
- } else {
- endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
- m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
- }
-}
-
-// forwardRenderer handles rendering items from top to bottom.
-// It builds up the rendered content incrementally, caching results for performance.
-type forwardRenderer struct {
- model *model // Reference to the parent list model
- start int // Current line position in the overall content
- cutoff int // Line position where we can stop rendering
- items []util.Model // Items to render (may be a subset)
- realIdx int // Real index in the full item list
-}
-
-// render processes items in forward order, building up the rendered content.
-func (r *forwardRenderer) render() {
- for _, item := range r.items {
- r.realIdx++
- if r.start > r.cutoff {
- break
- }
-
- itemLines := r.getOrRenderItem(item)
- if r.realIdx == len(r.model.filteredItems)-1 {
- r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
- }
-
- r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
- r.model.renderState.lastIndex = r.realIdx
- r.start += len(itemLines)
- }
-}
-
-// getOrRenderItem retrieves cached content or renders the item if not cached.
-func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
- if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
- return cachedContent.lines
- }
-
- itemLines := r.renderItemLines(item)
- r.model.renderState.items[r.realIdx] = renderedItem{
- lines: itemLines,
- start: r.start,
- height: len(itemLines),
- }
- return itemLines
-}
-
-// renderItemLines converts an item to its string representation with gaps.
-func (r *forwardRenderer) renderItemLines(item util.Model) []string {
- return r.model.getItemLines(item)
-}
-
-// reverseRenderer handles rendering items from bottom to top.
-// Used in reverse mode where new items appear at the bottom.
-type reverseRenderer struct {
- model *model // Reference to the parent list model
- start int // Current line position in the overall content
- cutoff int // Line position where we can stop rendering
- items []util.Model // Items to render (may be a subset)
- realIdx int // Real index in the full item list
-}
-
-// render processes items in reverse order, prepending to the rendered content.
-func (r *reverseRenderer) render() {
- for i := len(r.items) - 1; i >= 0; i-- {
- r.realIdx--
- if r.start > r.cutoff {
- break
- }
-
- itemLines := r.getOrRenderItem(r.items[i])
- if r.realIdx == 0 {
- r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
- }
-
- r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
- r.model.renderState.lastIndex = r.realIdx
- r.start += len(itemLines)
- }
-}
-
-// getOrRenderItem retrieves cached content or renders the item if not cached.
-func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
- if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
- return cachedContent.lines
- }
-
- itemLines := r.renderItemLines(item)
- r.model.renderState.items[r.realIdx] = renderedItem{
- lines: itemLines,
- start: r.start,
- height: len(itemLines),
- }
- return itemLines
-}
-
-// renderItemLines converts an item to its string representation with gaps.
-func (r *reverseRenderer) renderItemLines(item util.Model) []string {
- return r.model.getItemLines(item)
-}
-
-// selectPreviousItem moves selection to the previous item in the list.
-// Handles focus management and ensures the selected item remains visible.
-// Skips section headers during navigation.
-func (m *model) selectPreviousItem() tea.Cmd {
- if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation {
- // If at the beginning and wrapping is enabled, go to the last item
- return m.goToBottom()
- }
- if m.selectionState.selectedIndex <= 0 {
- return nil
- }
-
- cmds := []tea.Cmd{m.blurSelected()}
- m.selectionState.selectedIndex--
-
- // Skip section headers
- for m.selectionState.selectedIndex >= 0 && m.isSectionHeader(m.selectionState.selectedIndex) {
- m.selectionState.selectedIndex--
- }
-
- // If we went past the beginning, stay at the first non-header item
- if m.selectionState.selectedIndex <= 0 {
- cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed
- return tea.Batch(cmds...)
- }
-
- cmds = append(cmds, m.focusSelected())
- m.ensureSelectedItemVisible()
- return tea.Batch(cmds...)
-}
-
-// selectNextItem moves selection to the next item in the list.
-// Handles focus management and ensures the selected item remains visible.
-// Skips section headers during navigation.
-func (m *model) selectNextItem() tea.Cmd {
- if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation {
- // If at the end and wrapping is enabled, go to the first item
- return m.goToTop()
- }
- if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
- return nil
- }
-
- cmds := []tea.Cmd{m.blurSelected()}
- m.selectionState.selectedIndex++
-
- // Skip section headers
- for m.selectionState.selectedIndex < len(m.filteredItems) && m.isSectionHeader(m.selectionState.selectedIndex) {
- m.selectionState.selectedIndex++
- }
-
- // If we went past the end, stay at the last non-header item
- if m.selectionState.selectedIndex >= len(m.filteredItems) {
- m.selectionState.selectedIndex = m.findLastSelectableItem()
- }
-
- cmds = append(cmds, m.focusSelected())
- m.ensureSelectedItemVisible()
- return tea.Batch(cmds...)
-}
-
-// isSectionHeader checks if the item at the given index is a section header.
-func (m *model) isSectionHeader(index int) bool {
- if index < 0 || index >= len(m.filteredItems) {
- return false
- }
- if header, ok := m.filteredItems[index].(SectionHeader); ok {
- return header.IsSectionHeader()
- }
- return false
-}
-
-// findFirstSelectableItem finds the first item that is not a section header.
-func (m *model) findFirstSelectableItem() int {
- for i := range m.filteredItems {
- if !m.isSectionHeader(i) {
- return i
- }
- }
- return NoSelection
-}
-
-// findLastSelectableItem finds the last item that is not a section header.
-func (m *model) findLastSelectableItem() int {
- for i := len(m.filteredItems) - 1; i >= 0; i-- {
- if !m.isSectionHeader(i) {
- return i
- }
- }
- return NoSelection
-}
-
-// ensureSelectedItemVisible scrolls the list to make the selected item visible.
-// Uses different strategies for forward and reverse rendering modes.
-func (m *model) ensureSelectedItemVisible() {
- cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
- if !ok {
- m.renderState.needsRerender = true
- return
- }
-
- if m.viewState.reverse {
- m.ensureVisibleReverse(cachedItem)
- } else {
- m.ensureVisibleForward(cachedItem)
- }
- m.renderState.needsRerender = true
-}
-
-// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
-// Handles both large items (taller than viewport) and normal items.
-func (m *model) ensureVisibleForward(cachedItem renderedItem) {
- if cachedItem.height >= m.listHeight() {
- if m.selectionState.selectedIndex > 0 {
- changeNeeded := m.viewState.offset - cachedItem.start
- m.decreaseOffset(changeNeeded)
- } else {
- changeNeeded := cachedItem.start - m.viewState.offset
- m.increaseOffset(changeNeeded)
- }
- return
- }
-
- if cachedItem.start < m.viewState.offset {
- changeNeeded := m.viewState.offset - cachedItem.start
- m.decreaseOffset(changeNeeded)
- } else {
- end := cachedItem.start + cachedItem.height
- if end > m.viewState.offset+m.listHeight() {
- changeNeeded := end - (m.viewState.offset + m.listHeight())
- m.increaseOffset(changeNeeded)
- }
- }
-}
-
-// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
-// Handles both large items (taller than viewport) and normal items.
-func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
- if cachedItem.height >= m.listHeight() {
- if m.selectionState.selectedIndex < len(m.filteredItems)-1 {
- changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
- m.decreaseOffset(changeNeeded)
- } else {
- changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
- m.increaseOffset(changeNeeded)
- }
- return
- }
-
- if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
- changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
- m.increaseOffset(changeNeeded)
- } else if cachedItem.start < m.viewState.offset {
- changeNeeded := m.viewState.offset - cachedItem.start
- m.decreaseOffset(changeNeeded)
- }
-}
-
-// goToBottom switches to reverse mode and selects the last selectable item.
-// Commonly used for chat-like interfaces where new content appears at the bottom.
-// Skips section headers when selecting the last item.
-func (m *model) goToBottom() tea.Cmd {
- cmds := []tea.Cmd{m.blurSelected()}
- m.viewState.reverse = true
- m.selectionState.selectedIndex = m.findLastSelectableItem()
- if m.isFocused {
- cmds = append(cmds, m.focusSelected())
- }
- m.ResetView()
- return tea.Batch(cmds...)
-}
-
-// goToTop switches to forward mode and selects the first selectable item.
-// Standard behavior for most list interfaces.
-// Skips section headers when selecting the first item.
-func (m *model) goToTop() tea.Cmd {
- cmds := []tea.Cmd{m.blurSelected()}
- m.viewState.reverse = false
- m.selectionState.selectedIndex = m.findFirstSelectableItem()
- if m.isFocused {
- cmds = append(cmds, m.focusSelected())
- }
- m.ResetView()
- return tea.Batch(cmds...)
-}
-
-// ResetView clears all cached rendering data and resets scroll position.
-// Forces a complete re-render on the next View() call.
-func (m *model) ResetView() {
- m.renderState.reset()
- m.viewState.offset = 0
-}
-
-// focusSelected gives focus to the currently selected item if it supports focus.
-// Triggers a re-render of the item to show its focused state.
-func (m *model) focusSelected() tea.Cmd {
- if !m.isFocused {
- return nil // No focus change if the list is not focused
- }
- if !m.selectionState.isValidIndex(len(m.filteredItems)) {
- return nil
- }
- if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
- cmd := i.Focus()
- m.rerenderItem(m.selectionState.selectedIndex)
- return cmd
- }
- return nil
-}
-
-// blurSelected removes focus from the currently selected item if it supports focus.
-// Triggers a re-render of the item to show its unfocused state.
-func (m *model) blurSelected() tea.Cmd {
- if !m.selectionState.isValidIndex(len(m.filteredItems)) {
- return nil
- }
- if i, ok := m.filteredItems[m.selectionState.selectedIndex].(layout.Focusable); ok {
- cmd := i.Blur()
- m.rerenderItem(m.selectionState.selectedIndex)
- return cmd
- }
- return nil
-}
-
-// rerenderItem updates the cached rendering of a specific item.
-// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
-// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
-func (m *model) rerenderItem(inx int) {
- if inx < 0 || inx >= len(m.filteredItems) || len(m.renderState.lines) == 0 {
- return
- }
-
- cachedItem, ok := m.renderState.items[inx]
- if !ok {
- return
- }
-
- rerenderedLines := m.getItemLines(m.filteredItems[inx])
- if slices.Equal(cachedItem.lines, rerenderedLines) {
- return
- }
-
- m.updateRenderedLines(cachedItem, rerenderedLines)
- m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
- m.updateCachedItem(inx, cachedItem, rerenderedLines)
- m.renderState.needsRerender = true
-}
-
-// getItemLines converts an item to its rendered lines, including any gap spacing.
-// Handles section headers with special styling.
-func (m *model) getItemLines(item util.Model) []string {
- var itemLines []string
-
- itemLines = strings.Split(item.View(), "\n")
-
- if m.gapSize > 0 {
- gap := make([]string, m.gapSize)
- itemLines = append(itemLines, gap...)
- }
- return itemLines
-}
-
-// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
-func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
- start, end := m.getItemBounds(cachedItem)
- totalLines := len(m.renderState.lines)
-
- if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
- m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
- m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
- }
-}
-
-// getItemBounds calculates the start and end line positions for an item.
-// Handles both forward and reverse rendering modes.
-func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
- start = cachedItem.start
- end = start + cachedItem.height
-
- if m.viewState.reverse {
- totalLines := len(m.renderState.lines)
- end = totalLines - cachedItem.start
- start = end - cachedItem.height
- }
- return start, end
-}
-
-// updateItemPositions recalculates positions for items after the changed item.
-// This is necessary when an item's height changes, affecting subsequent items.
-func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
- if cachedItem.height == newHeight {
- return
- }
-
- if inx == len(m.filteredItems)-1 {
- m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
- }
-
- currentStart := cachedItem.start + newHeight
- if m.viewState.reverse {
- m.updatePositionsReverse(inx, currentStart)
- } else {
- m.updatePositionsForward(inx, currentStart)
- }
-}
-
-// updatePositionsForward updates positions for items after the changed item in forward mode.
-func (m *model) updatePositionsForward(inx int, currentStart int) {
- for i := inx + 1; i < len(m.filteredItems); i++ {
- if existing, ok := m.renderState.items[i]; ok {
- existing.start = currentStart
- currentStart += existing.height
- m.renderState.items[i] = existing
- } else {
- break
- }
- }
-}
-
-// updatePositionsReverse updates positions for items before the changed item in reverse mode.
-func (m *model) updatePositionsReverse(inx int, currentStart int) {
- for i := inx - 1; i >= 0; i-- {
- if existing, ok := m.renderState.items[i]; ok {
- existing.start = currentStart
- currentStart += existing.height
- m.renderState.items[i] = existing
- } else {
- break
- }
- }
-}
-
-// updateCachedItem updates the cached rendering information for a specific item.
-func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
- m.renderState.items[inx] = renderedItem{
- lines: newLines,
- start: cachedItem.start,
- height: len(newLines),
- }
-}
-
-// increaseOffset scrolls the list down by increasing the offset.
-// Respects the final height limit to prevent scrolling past the end.
-func (m *model) increaseOffset(n int) {
- if m.renderState.finalHeight > NoFinalHeight {
- if m.viewState.offset < m.renderState.finalHeight {
- m.viewState.offset += n
- if m.viewState.offset > m.renderState.finalHeight {
- m.viewState.offset = m.renderState.finalHeight
- }
- m.renderState.needsRerender = true
- }
- } else {
- m.viewState.offset += n
- m.renderState.needsRerender = true
- }
-}
-
-// decreaseOffset scrolls the list up by decreasing the offset.
-// Prevents scrolling above the beginning of the list.
-func (m *model) decreaseOffset(n int) {
- if m.viewState.offset > 0 {
- m.viewState.offset -= n
- if m.viewState.offset < 0 {
- m.viewState.offset = 0
- }
- m.renderState.needsRerender = true
- }
-}
-
-// UpdateItem replaces an item at the specified index with a new item.
-// Handles focus management and triggers re-rendering as needed.
-func (m *model) UpdateItem(inx int, item util.Model) {
- if inx < 0 || inx >= len(m.filteredItems) {
- return
- }
- m.filteredItems[inx] = item
- if m.selectionState.selectedIndex == inx {
- m.focusSelected()
- }
- m.setItemSize(inx)
- m.rerenderItem(inx)
- m.renderState.needsRerender = true
-}
-
-// GetSize returns the current dimensions of the list.
-func (m *model) GetSize() (int, int) {
- return m.viewState.width, m.viewState.height
-}
-
-// SetSize updates the list dimensions and triggers a complete re-render.
-// Also updates the size of all items that support sizing.
-func (m *model) SetSize(width int, height int) tea.Cmd {
- if m.filterable && !m.hideFilterInput {
- height -= 2 // adjust for input field height and border
- }
-
- if m.viewState.width == width && m.viewState.height == height {
- return nil
- }
- if m.viewState.height != height {
- m.renderState.finalHeight = NoFinalHeight
- m.viewState.height = height
- }
- m.viewState.width = width
- m.ResetView()
- if m.filterable && !m.hideFilterInput {
- m.input.SetWidth(m.getItemWidth() - 5)
- }
- return m.setAllItemsSize()
-}
-
-// getItemWidth calculates the available width for items, accounting for padding.
-func (m *model) getItemWidth() int {
- width := m.viewState.width
- switch len(m.padding) {
- case 1:
- width -= m.padding[0] * 2
- case 2, 3:
- width -= m.padding[1] * 2
- case 4:
- width -= m.padding[1] + m.padding[3]
- }
- return max(0, width)
-}
-
-// setItemSize updates the size of a specific item if it supports sizing.
-func (m *model) setItemSize(inx int) tea.Cmd {
- if inx < 0 || inx >= len(m.filteredItems) {
- return nil
- }
- if i, ok := m.filteredItems[inx].(layout.Sizeable); ok {
- return i.SetSize(m.getItemWidth(), 0)
- }
- return nil
-}
-
-// setAllItemsSize updates the size of all items that support sizing.
-func (m *model) setAllItemsSize() tea.Cmd {
- var cmds []tea.Cmd
- for i := range m.filteredItems {
- if cmd := m.setItemSize(i); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- return tea.Batch(cmds...)
-}
-
-// listHeight calculates the available height for list content, accounting for padding.
-func (m *model) listHeight() int {
- height := m.viewState.height
- switch len(m.padding) {
- case 1:
- height -= m.padding[0] * 2
- case 2:
- height -= m.padding[0] * 2
- case 3, 4:
- height -= m.padding[0] + m.padding[2]
- }
- if m.filterable && !m.hideFilterInput {
- height -= lipgloss.Height(m.inputStyle.Render("dummy"))
- }
- return max(0, height)
-}
-
-// AppendItem adds a new item to the end of the list.
-// Automatically switches to reverse mode and scrolls to show the new item.
-func (m *model) AppendItem(item util.Model) tea.Cmd {
- cmds := []tea.Cmd{
- item.Init(),
- }
- m.allItems = append(m.allItems, item)
- m.filteredItems = m.allItems
- cmds = append(cmds, m.setItemSize(len(m.filteredItems)-1))
- cmds = append(cmds, m.goToBottom())
- m.renderState.needsRerender = true
- return tea.Batch(cmds...)
-}
-
-// DeleteItem removes an item at the specified index.
-// Adjusts selection if necessary and triggers a complete re-render.
-func (m *model) DeleteItem(i int) {
- if i < 0 || i >= len(m.filteredItems) {
- return
- }
- m.allItems = slices.Delete(m.allItems, i, i+1)
- delete(m.renderState.items, i)
- m.filteredItems = m.allItems
-
- if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
- m.selectionState.selectedIndex--
- } else if m.selectionState.selectedIndex > i {
- m.selectionState.selectedIndex--
- }
-
- m.ResetView()
- m.renderState.needsRerender = true
-}
-
-// PrependItem adds a new item to the beginning of the list.
-// Adjusts cached positions and selection index, then switches to forward mode.
-func (m *model) PrependItem(item util.Model) tea.Cmd {
- cmds := []tea.Cmd{item.Init()}
- m.allItems = append([]util.Model{item}, m.allItems...)
- m.filteredItems = m.allItems
-
- // Shift all cached item indices by 1
- newItems := make(map[int]renderedItem, len(m.renderState.items))
- for k, v := range m.renderState.items {
- newItems[k+1] = v
- }
- m.renderState.items = newItems
-
- if m.selectionState.selectedIndex >= 0 {
- m.selectionState.selectedIndex++
- }
-
- cmds = append(cmds, m.goToTop())
- cmds = append(cmds, m.setItemSize(0))
- m.renderState.needsRerender = true
- return tea.Batch(cmds...)
-}
-
-// setReverse switches between forward and reverse rendering modes.
-func (m *model) setReverse(reverse bool) {
- if reverse {
- m.goToBottom()
- } else {
- m.goToTop()
- }
-}
-
-// SetItems replaces all items in the list with a new set.
-// Initializes all items, sets their sizes, and establishes initial selection.
-// Ensures the initial selection skips section headers.
-func (m *model) SetItems(items []util.Model) tea.Cmd {
- m.allItems = items
- m.filteredItems = items
- cmds := []tea.Cmd{m.setAllItemsSize()}
-
- for _, item := range m.filteredItems {
- cmds = append(cmds, item.Init())
- }
-
- if len(m.filteredItems) > 0 {
- if m.viewState.reverse {
- m.selectionState.selectedIndex = m.findLastSelectableItem()
- } else {
- m.selectionState.selectedIndex = m.findFirstSelectableItem()
- }
- if cmd := m.focusSelected(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- } else {
- m.selectionState.selectedIndex = NoSelection
- }
-
- m.ResetView()
- return tea.Batch(cmds...)
-}
-
-// section represents a group of items under a section header.
-type section struct {
- header SectionHeader
- items []util.Model
-}
-
-// parseSections parses the flat item list into sections.
-func (m *model) parseSections() []section {
- var sections []section
- var currentSection *section
-
- for _, item := range m.allItems {
- if header, ok := item.(SectionHeader); ok && header.IsSectionHeader() {
- // Start a new section
- if currentSection != nil {
- sections = append(sections, *currentSection)
- }
- currentSection = §ion{
- header: header,
- items: []util.Model{},
- }
- } else if currentSection != nil {
- // Add item to current section
- currentSection.items = append(currentSection.items, item)
- } else {
- // Item without a section header - create an implicit section
- if len(sections) == 0 || sections[len(sections)-1].header != nil {
- sections = append(sections, section{
- header: nil,
- items: []util.Model{item},
- })
- } else {
- // Add to the last implicit section
- sections[len(sections)-1].items = append(sections[len(sections)-1].items, item)
- }
- }
- }
-
- // Don't forget the last section
- if currentSection != nil {
- sections = append(sections, *currentSection)
- }
-
- return sections
-}
-
-// flattenSections converts sections back to a flat list.
-func (m *model) flattenSections(sections []section) []util.Model {
- var result []util.Model
-
- for _, sect := range sections {
- if sect.header != nil {
- result = append(result, sect.header)
- }
- result = append(result, sect.items...)
- }
-
- return result
-}
-
-func (m *model) Filter(search string) tea.Cmd {
- var cmds []tea.Cmd
- search = strings.TrimSpace(search)
- search = strings.ToLower(search)
-
- // Clear focus and match indexes from all items
- for _, item := range m.allItems {
- if i, ok := item.(layout.Focusable); ok {
- cmds = append(cmds, i.Blur())
- }
- if i, ok := item.(HasMatchIndexes); ok {
- i.MatchIndexes(make([]int, 0))
- }
- }
-
- if search == "" {
- cmds = append(cmds, m.SetItems(m.allItems))
- return tea.Batch(cmds...)
- }
-
- // Parse items into sections
- sections := m.parseSections()
- var filteredSections []section
-
- for _, sect := range sections {
- filteredSection := m.filterSection(sect, search)
- if filteredSection != nil {
- filteredSections = append(filteredSections, *filteredSection)
- }
- }
-
- // Rebuild flat list from filtered sections
- m.filteredItems = m.flattenSections(filteredSections)
-
- // Set initial selection
- if len(m.filteredItems) > 0 {
- if m.viewState.reverse {
- slices.Reverse(m.filteredItems)
- m.selectionState.selectedIndex = m.findLastSelectableItem()
- } else {
- m.selectionState.selectedIndex = m.findFirstSelectableItem()
- }
- if cmd := m.focusSelected(); cmd != nil {
- cmds = append(cmds, cmd)
- }
- } else {
- m.selectionState.selectedIndex = NoSelection
- }
-
- m.ResetView()
- return tea.Batch(cmds...)
-}
-
-// filterSection filters items within a section and returns the section if it has matches.
-func (m *model) filterSection(sect section, search string) *section {
- var matchedItems []util.Model
- var hasHeaderMatch bool
-
- // Check if section header itself matches
- if sect.header != nil {
- headerText := strings.ToLower(sect.header.View())
- if strings.Contains(headerText, search) {
- hasHeaderMatch = true
- // If header matches, include all items in the section
- matchedItems = sect.items
- }
- }
-
- // If header didn't match, filter items within the section
- if !hasHeaderMatch && len(sect.items) > 0 {
- // Create words array for items in this section
- words := make([]string, len(sect.items))
- for i, item := range sect.items {
- if f, ok := item.(HasFilterValue); ok {
- words[i] = strings.ToLower(f.FilterValue())
- } else {
- words[i] = ""
- }
- }
-
- // Find matches within this section
- matches := fuzzy.Find(search, words)
-
- // Sort matches by score but preserve relative order for equal scores
- sort.SliceStable(matches, func(i, j int) bool {
- return matches[i].Score > matches[j].Score
- })
-
- // Build matched items list
- for _, match := range matches {
- item := sect.items[match.Index]
- if i, ok := item.(HasMatchIndexes); ok {
- i.MatchIndexes(match.MatchedIndexes)
- }
- matchedItems = append(matchedItems, item)
- }
- }
-
- // Return section only if it has matches
- if len(matchedItems) > 0 {
- return §ion{
- header: sect.header,
- items: matchedItems,
- }
- }
-
- return nil
-}
-
-// SelectedIndex returns the index of the currently selected item.
-func (m *model) SelectedIndex() int {
- if m.selectionState.selectedIndex < 0 || m.selectionState.selectedIndex >= len(m.filteredItems) {
- return NoSelection
- }
- return m.selectionState.selectedIndex
-}
-
-// SetSelected sets the selected item by index and automatically scrolls to make it visible.
-// If the index is invalid or points to a section header, it finds the nearest selectable item.
-func (m *model) SetSelected(index int) tea.Cmd {
- changeNeeded := m.selectionState.selectedIndex - index
- cmds := []tea.Cmd{}
- if changeNeeded < 0 {
- for range -changeNeeded {
- cmds = append(cmds, m.selectNextItem())
- m.renderVisible()
- }
- } else if changeNeeded > 0 {
- for range changeNeeded {
- cmds = append(cmds, m.selectPreviousItem())
- m.renderVisible()
- }
- }
- return tea.Batch(cmds...)
-}
-
-// Blur implements ListModel.
-func (m *model) Blur() tea.Cmd {
- m.isFocused = false
- cmd := m.blurSelected()
- return cmd
-}
-
-// Focus implements ListModel.
-func (m *model) Focus() tea.Cmd {
- m.isFocused = true
- cmd := m.focusSelected()
- return cmd
-}
-
-// IsFocused implements ListModel.
-func (m *model) IsFocused() bool {
- return m.isFocused
-}
-
-func (m *model) SetFilterPlaceholder(placeholder string) {
- m.input.Placeholder = placeholder
-}
@@ -1,69 +0,0 @@
-package commands
-
-import (
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/core/layout"
- "github.com/charmbracelet/crush/internal/tui/components/core/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- "github.com/charmbracelet/x/ansi"
-)
-
-type ItemSection interface {
- util.Model
- layout.Sizeable
- list.SectionHeader
- SetInfo(info string)
-}
-type itemSectionModel struct {
- width int
- title string
- info string
-}
-
-func NewItemSection(title string) ItemSection {
- return &itemSectionModel{
- title: title,
- }
-}
-
-func (m *itemSectionModel) Init() tea.Cmd {
- return nil
-}
-
-func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
- return m, nil
-}
-
-func (m *itemSectionModel) View() string {
- t := styles.CurrentTheme()
- title := ansi.Truncate(m.title, m.width-2, "…")
- style := t.S().Base.Padding(1, 1, 0, 1)
- title = t.S().Muted.Render(title)
- section := ""
- if m.info != "" {
- section = core.SectionWithInfo(title, m.width-2, m.info)
- } else {
- section = core.Section(title, m.width-2)
- }
-
- return style.Render(section)
-}
-
-func (m *itemSectionModel) GetSize() (int, int) {
- return m.width, 1
-}
-
-func (m *itemSectionModel) SetSize(width int, height int) tea.Cmd {
- m.width = width
- return nil
-}
-
-func (m *itemSectionModel) IsSectionHeader() bool {
- return true
-}
-
-func (m *itemSectionModel) SetInfo(info string) {
- m.info = info
-}
@@ -2,6 +2,7 @@ package list
import (
"regexp"
+ "slices"
"sort"
"strings"
@@ -24,6 +25,7 @@ type FilterableList[T FilterableItem] interface {
Cursor() *tea.Cursor
SetInputWidth(int)
SetInputPlaceholder(string)
+ Filter(q string) tea.Cmd
}
type HasMatchIndexes interface {
@@ -263,6 +265,10 @@ func (f *filterableList[T]) Filter(query string) tea.Cmd {
matchedItems = append(matchedItems, item)
}
+ if f.list.direction == DirectionBackward {
+ slices.Reverse(matchedItems)
+ }
+
cmds = append(cmds, f.list.SetItems(matchedItems))
return tea.Batch(cmds...)
}