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