list.go

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