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