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