@@ -3,7 +3,6 @@ package list
8 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/help"
9 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
@@ -14,89 +13,155 @@ import (
14 "github.com/opencode-ai/opencode/internal/tui/util"
13 "github.com/opencode-ai/opencode/internal/tui/util"
16+// Constants for special index values and defaults
18+ NoSelection = -1 // Indicates no item is currently selected
19+ NotRendered = -1 // Indicates an item hasn't been rendered yet
20+ NoFinalHeight = -1 // Indicates final height hasn't been calculated
21+ DefaultGapSize = 0 // Default spacing between list items
24+// ListModel defines the interface for a scrollable, selectable list component.
25+// It combines the basic Model interface with sizing capabilities and list-specific operations.
17type ListModel interface {
26type ListModel interface {
18 util.Model
27 util.Model
19 layout.Sizeable
28 layout.Sizeable
20− SetItems([]util.Model) tea.Cmd
29+ SetItems([]util.Model) tea.Cmd // Replace all items in the list
21− AppendItem(util.Model) tea.Cmd
30+ AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
22− PrependItem(util.Model) tea.Cmd
31+ PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
23− DeleteItem(int)
32+ DeleteItem(int) // Remove an item at the specified index
24− UpdateItem(int, util.Model)
33+ UpdateItem(int, util.Model) // Replace an item at the specified index
25− ResetView()
34+ ResetView() // Clear rendering cache and reset scroll position
26− Items() []util.Model
35+ Items() []util.Model // Get all items in the list
38+// HasAnim interface identifies items that support animation.
39+// Items implementing this interface will receive animation update messages.
29type HasAnim interface {
40type HasAnim interface {
30 util.Model
41 util.Model
31− Spinning() bool
42+ Spinning() bool // Returns true if the item is currently animating
45+// renderedItem represents a cached rendered item with its position and content.
34type renderedItem struct {
46type renderedItem struct {
35− lines []string
47+ lines []string // The rendered lines of text for this item
36− start int
48+ start int // Starting line position in the overall rendered content
37− height int
49+ height int // Number of lines this item occupies
52+// renderState manages the rendering cache and state for the list.
53+// It tracks which items have been rendered and their positions.
54+type renderState struct {
55+ items map[int]renderedItem // Cache of rendered items by index
56+ lines []string // All rendered lines concatenated
57+ lastIndex int // Index of the last rendered item
58+ finalHeight int // Total height when all items are rendered
59+ needsRerender bool // Flag indicating if re-rendering is needed
62+// newRenderState creates a new render state with default values.
63+func newRenderState() *renderState {
65+ items: make(map[int]renderedItem),
67+ lastIndex: NotRendered,
68+ finalHeight: NoFinalHeight,
73+// reset clears all cached rendering data and resets state to initial values.
74+func (rs *renderState) reset() {
75+ rs.items = make(map[int]renderedItem)
76+ rs.lines = []string{}
77+ rs.lastIndex = NotRendered
78+ rs.finalHeight = NoFinalHeight
79+ rs.needsRerender = true
82+// viewState manages the visual display properties of the list.
83+type viewState struct {
84+ width, height int // Dimensions of the list viewport
85+ offset int // Current scroll offset in lines
86+ reverse bool // Whether to render in reverse order (bottom-up)
87+ content string // The final rendered content to display
90+// selectionState manages which item is currently selected.
91+type selectionState struct {
92+ selectedIndex int // Index of the currently selected item, or NoSelection
95+// isValidIndex checks if the selected index is within the valid range of items.
96+func (ss *selectionState) isValidIndex(itemCount int) bool {
97+ return ss.selectedIndex >= 0 && ss.selectedIndex < itemCount
100+// model is the main implementation of the ListModel interface.
101+// It coordinates between view state, render state, and selection state.
39type model struct {
102type model struct {
40− width, height, offset int
103+ viewState viewState // Display and scrolling state
41− finalHeight int // this gets set when the last item is rendered to mark the max offset
104+ renderState *renderState // Rendering cache and state
42− reverse bool
105+ selectionState selectionState // Item selection state
43− help help.Model
106+ help help.Model // Help system for keyboard shortcuts
44− keymap KeyMap
107+ keymap KeyMap // Key bindings for navigation
45− items []util.Model
108+ items []util.Model // The actual list items
46− renderedItems *sync.Map // item index to rendered string
48− renderedLines []string
52− gapSize int
109+ gapSize int // Number of empty lines between items
53− padding []int
110+ padding []int // Padding around the list content
113+// listOptions is a function type for configuring list options.
56type listOptions func(*model)
114type listOptions func(*model)
116+// WithKeyMap sets custom key bindings for the list.
58func WithKeyMap(k KeyMap) listOptions {
117func WithKeyMap(k KeyMap) listOptions {
59 return func(m *model) {
118 return func(m *model) {
60 m.keymap = k
119 m.keymap = k
123+// WithReverse sets whether the list should render in reverse order (newest items at bottom).
64func WithReverse(reverse bool) listOptions {
124func WithReverse(reverse bool) listOptions {
65 return func(m *model) {
125 return func(m *model) {
66 m.setReverse(reverse)
126 m.setReverse(reverse)
130+// WithGapSize sets the number of empty lines to insert between list items.
70func WithGapSize(gapSize int) listOptions {
131func WithGapSize(gapSize int) listOptions {
71 return func(m *model) {
132 return func(m *model) {
72 m.gapSize = gapSize
133 m.gapSize = gapSize
137+// WithPadding sets the padding around the list content.
138+// Follows CSS padding convention: 1 value = all sides, 2 values = vertical/horizontal,
139+// 4 values = top/right/bottom/left.
76func WithPadding(padding ...int) listOptions {
140func WithPadding(padding ...int) listOptions {
77 return func(m *model) {
141 return func(m *model) {
78 m.padding = padding
142 m.padding = padding
146+// WithItems sets the initial items for the list.
82func WithItems(items []util.Model) listOptions {
147func WithItems(items []util.Model) listOptions {
83 return func(m *model) {
148 return func(m *model) {
84 m.items = items
149 m.items = items
153+// New creates a new list model with the specified options.
154+// The list starts with no items selected and requires SetItems to be called
155+// or items to be provided via WithItems option.
88func New(opts ...listOptions) ListModel {
156func New(opts ...listOptions) ListModel {
89 m := &model{
157 m := &model{
90− help: help.New(),
158+ help: help.New(),
91− keymap: defaultKeymap(),
159+ keymap: DefaultKeymap(),
92− items: []util.Model{},
160+ items: []util.Model{},
93− needsRerender: true,
161+ renderState: newRenderState(),
94− gapSize: 0,
162+ gapSize: DefaultGapSize,
95− padding: []int{},
163+ padding: []int{},
96− selectedItemInx: -1,
164+ selectionState: selectionState{selectedIndex: NoSelection},
99− renderedItems: new(sync.Map),
101 for _, opt := range opts {
166 for _, opt := range opts {
@@ -104,591 +169,737 @@ func New(opts ...listOptions) ListModel {
107−// Init implements List.
172+// Init initializes the list component and sets up the initial items.
173+// This is called automatically by the Bubble Tea framework.
108func (m *model) Init() tea.Cmd {
174func (m *model) Init() tea.Cmd {
110− m.SetItems(m.items),
175+ return m.SetItems(m.items)
112− return tea.Batch(cmds...)
115−// Update implements List.
178+// Update handles incoming messages and updates the list state accordingly.
179+// It processes keyboard input, animation messages, and forwards other messages
180+// to the currently selected item.
116func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
181func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
118 switch msg := msg.(type) {
182 switch msg := msg.(type) {
119 case tea.KeyPressMsg:
183 case tea.KeyPressMsg:
121− case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
127− return m, nil
184+ return m.handleKeyPress(msg)
128− case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
135− case key.Matches(msg, m.keymap.DownOneItem):
138− case key.Matches(msg, m.keymap.UpOneItem):
141− case key.Matches(msg, m.keymap.HalfPageDown):
143− m.decreaseOffset(m.listHeight() / 2)
145− m.increaseOffset(m.listHeight() / 2)
148− case key.Matches(msg, m.keymap.HalfPageUp):
150− m.increaseOffset(m.listHeight() / 2)
152− m.decreaseOffset(m.listHeight() / 2)
155− case key.Matches(msg, m.keymap.Home):
158− case key.Matches(msg, m.keymap.End):
162 case anim.ColorCycleMsg, anim.StepCharsMsg:
185 case anim.ColorCycleMsg, anim.StepCharsMsg:
186+ return m.handleAnimationMsg(msg)
189+ if m.selectionState.isValidIndex(len(m.items)) {
190+ return m.updateSelectedItem(msg)
196+// handleKeyPress processes keyboard input for list navigation.
197+// Supports scrolling, item selection, and navigation to top/bottom.
198+func (m *model) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
200+ case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
202+ case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
204+ case key.Matches(msg, m.keymap.DownOneItem):
205+ return m, m.selectNextItem()
206+ case key.Matches(msg, m.keymap.UpOneItem):
207+ return m, m.selectPreviousItem()
208+ case key.Matches(msg, m.keymap.HalfPageDown):
209+ m.scrollDown(m.listHeight() / 2)
210+ case key.Matches(msg, m.keymap.HalfPageUp):
211+ m.scrollUp(m.listHeight() / 2)
212+ case key.Matches(msg, m.keymap.Home):
213+ return m, m.goToTop()
214+ case key.Matches(msg, m.keymap.End):
215+ return m, m.goToBottom()
220+// handleAnimationMsg forwards animation messages to items that support animation.
221+// Only items implementing HasAnim and currently spinning receive these messages.
222+func (m *model) handleAnimationMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
163− for inx, item := range m.items {
224+ for inx, item := range m.items {
164− if i, ok := item.(HasAnim); ok {
225+ if i, ok := item.(HasAnim); ok && i.Spinning() {
166− updated, cmd := i.Update(msg)
226+ updated, cmd := i.Update(msg)
167− cmds = append(cmds, cmd)
227+ cmds = append(cmds, cmd)
228+ if u, ok := updated.(util.Model); ok {
168− m.UpdateItem(inx, updated.(util.Model))
229+ m.UpdateItem(inx, u)
172− return m, tea.Batch(cmds...)
174− if m.selectedItemInx > -1 {
233+ return m, tea.Batch(cmds...)
236+// updateSelectedItem forwards messages to the currently selected item.
237+// This allows the selected item to handle its own input and state changes.
238+func (m *model) updateSelectedItem(msg tea.Msg) (tea.Model, tea.Cmd) {
175− u, cmd := m.items[m.selectedItemInx].Update(msg)
240+ u, cmd := m.items[m.selectionState.selectedIndex].Update(msg)
176− cmds = append(cmds, cmd)
241+ cmds = append(cmds, cmd)
177− m.UpdateItem(m.selectedItemInx, u.(util.Model))
242+ if updated, ok := u.(util.Model); ok {
243+ m.UpdateItem(m.selectionState.selectedIndex, updated)
178− return m, tea.Batch(cmds...)
245+ return m, tea.Batch(cmds...)
248+// scrollDown scrolls the list down by the specified amount.
249+// Direction is automatically adjusted based on reverse mode.
250+func (m *model) scrollDown(amount int) {
251+ if m.viewState.reverse {
252+ m.decreaseOffset(amount)
254+ m.increaseOffset(amount)
181− return m, nil
258+// scrollUp scrolls the list up by the specified amount.
259+// Direction is automatically adjusted based on reverse mode.
260+func (m *model) scrollUp(amount int) {
261+ if m.viewState.reverse {
262+ m.increaseOffset(amount)
264+ m.decreaseOffset(amount)
184−// View implements List.
268+// View renders the list to a string for display.
269+// Returns empty string if the list has no dimensions.
270+// Triggers re-rendering if needed before returning content.
185func (m *model) View() string {
271func (m *model) View() string {
186− if m.height == 0 || m.width == 0 {
272+ if m.viewState.height == 0 || m.viewState.width == 0 {
187 return ""
273 return ""
189− if m.needsRerender {
275+ if m.renderState.needsRerender {
190 m.renderVisible()
276 m.renderVisible()
192− return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
278+ return lipgloss.NewStyle().Padding(m.padding...).Height(m.viewState.height).Render(m.viewState.content)
195−// Items implements ListModel.
281+// Items returns a copy of all items in the list.
196func (m *model) Items() []util.Model {
282func (m *model) Items() []util.Model {
197 return m.items
283 return m.items
200−func (m *model) renderVisibleReverse() {
286+// renderVisible determines which rendering strategy to use and triggers rendering.
201− start := 0
287+// Uses forward rendering for normal mode and reverse rendering for reverse mode.
202− cutoff := m.offset + m.listHeight()
288+func (m *model) renderVisible() {
203− items := m.items
289+ if m.viewState.reverse {
204− if m.lastRenderedInx > -1 {
290+ m.renderVisibleReverse()
205− items = m.items[:m.lastRenderedInx]
206− start = len(m.renderedLines)
208− // reveresed so that it starts at the end
292+ m.renderVisibleForward()
209− m.lastRenderedInx = len(m.items)
211− realIndex := m.lastRenderedInx
212− for i := len(items) - 1; i >= 0; i-- {
214− var itemLines []string
215− cachedContent, ok := m.renderedItems.Load(realIndex)
217− itemLines = cachedContent.(renderedItem).lines
219− itemLines = strings.Split(items[i].View(), "\n")
221− for range m.gapSize {
222− itemLines = append(itemLines, "")
225− m.renderedItems.Store(realIndex, renderedItem{
228− height: len(itemLines),
232− if realIndex == 0 {
296+// renderVisibleForward renders items from top to bottom (normal mode).
233− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
297+// Only renders items that are currently visible or near the viewport.
234− }
298+func (m *model) renderVisibleForward() {
235− m.renderedLines = append(itemLines, m.renderedLines...)
299+ renderer := &forwardRenderer{
236− m.lastRenderedInx = realIndex
300+ model: m,
237− // always render the next item
301+ start: 0,
238− if start > cutoff {
302+ cutoff: m.viewState.offset + m.listHeight(),
239− break
303+ items: m.items,
240− }
304+ realIdx: m.renderState.lastIndex,
241− start += len(itemLines)
243− m.needsRerender = false
306+
244− if m.finalHeight > -1 {
307+ if m.renderState.lastIndex > NotRendered {
245− // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
308+ renderer.items = m.items[m.renderState.lastIndex+1:]
246− m.offset = min(m.offset, m.finalHeight)
309+ renderer.start = len(m.renderState.lines)
248− maxHeight := min(m.listHeight(), len(m.renderedLines))
311+
249− if m.offset < len(m.renderedLines) {
312+ renderer.render()
250− end := len(m.renderedLines) - m.offset
313+ m.finalizeRender()
251− start := max(0, end-maxHeight)
314+}
252− m.content = strings.Join(m.renderedLines[start:end], "\n")
315+
316+// renderVisibleReverse renders items from bottom to top (reverse mode).
317+// Used when new items should appear at the bottom (like chat messages).
318+func (m *model) renderVisibleReverse() {
319+ renderer := &reverseRenderer{
322+ cutoff: m.viewState.offset + m.listHeight(),
324+ realIdx: m.renderState.lastIndex,
327+ if m.renderState.lastIndex > NotRendered {
328+ renderer.items = m.items[:m.renderState.lastIndex]
329+ renderer.start = len(m.renderState.lines)
254− m.content = ""
331+ m.renderState.lastIndex = len(m.items)
332+ renderer.realIdx = len(m.items)
339+// finalizeRender completes the rendering process by updating scroll bounds and content.
258−func (m *model) renderVisible() {
340+func (m *model) finalizeRender() {
259− if m.reverse {
341+ m.renderState.needsRerender = false
260− m.renderVisibleReverse()
342+ if m.renderState.finalHeight > NoFinalHeight {
343+ m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
348+// updateContent extracts the visible portion of rendered content for display.
349+// Handles both normal and reverse rendering modes.
350+func (m *model) updateContent() {
351+ maxHeight := min(m.listHeight(), len(m.renderState.lines))
352+ if m.viewState.offset >= len(m.renderState.lines) {
353+ m.viewState.content = ""
264− cutoff := m.offset + m.listHeight()
357+ if m.viewState.reverse {
265− items := m.items
358+ end := len(m.renderState.lines) - m.viewState.offset
266− if m.lastRenderedInx > -1 {
359+ start := max(0, end-maxHeight)
267− items = m.items[m.lastRenderedInx+1:]
360+ m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
268− start = len(m.renderedLines)
361+ } else {
362+ endIdx := min(maxHeight+m.viewState.offset, len(m.renderState.lines))
363+ m.viewState.content = strings.Join(m.renderState.lines[m.viewState.offset:endIdx], "\n")
271− realIndex := m.lastRenderedInx
367+// forwardRenderer handles rendering items from top to bottom.
272− for _, item := range items {
368+// It builds up the rendered content incrementally, caching results for performance.
273− realIndex++
369+type forwardRenderer struct {
370+ model *model // Reference to the parent list model
371+ start int // Current line position in the overall content
372+ cutoff int // Line position where we can stop rendering
373+ items []util.Model // Items to render (may be a subset)
374+ realIdx int // Real index in the full item list
275− var itemLines []string
377+// render processes items in forward order, building up the rendered content.
276− cachedContent, ok := m.renderedItems.Load(realIndex)
378+func (r *forwardRenderer) render() {
277− if ok {
379+ for _, item := range r.items {
278− itemLines = cachedContent.(renderedItem).lines
380+ r.realIdx++
279− } else {
381+ if r.start > r.cutoff {
280− itemLines = strings.Split(item.View(), "\n")
382+ break
281− if m.gapSize > 0 {
383+ }
282− for range m.gapSize {
384+
283− itemLines = append(itemLines, "")
385+ itemLines := r.getOrRenderItem(item)
284− }
386+ if r.realIdx == len(r.model.items)-1 {
285− }
387+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
286− m.renderedItems.Store(realIndex, renderedItem{
289− height: len(itemLines),
292− // always render the next item
389+
390+ r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
391+ r.model.renderState.lastIndex = r.realIdx
392+ r.start += len(itemLines)
396+// getOrRenderItem retrieves cached content or renders the item if not cached.
397+func (r *forwardRenderer) getOrRenderItem(item util.Model) []string {
398+ if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
399+ return cachedContent.lines
402+ itemLines := r.renderItemLines(item)
403+ r.model.renderState.items[r.realIdx] = renderedItem{
406+ height: len(itemLines),
411+// renderItemLines converts an item to its string representation with gaps.
412+func (r *forwardRenderer) renderItemLines(item util.Model) []string {
413+ return r.model.getItemLines(item)
416+// reverseRenderer handles rendering items from bottom to top.
417+// Used in reverse mode where new items appear at the bottom.
418+type reverseRenderer struct {
419+ model *model // Reference to the parent list model
420+ start int // Current line position in the overall content
421+ cutoff int // Line position where we can stop rendering
422+ items []util.Model // Items to render (may be a subset)
423+ realIdx int // Real index in the full item list
426+// render processes items in reverse order, prepending to the rendered content.
427+func (r *reverseRenderer) render() {
428+ for i := len(r.items) - 1; i >= 0; i-- {
293− if start > cutoff {
430+ if r.start > r.cutoff {
297− if realIndex == len(m.items)-1 {
434+ itemLines := r.getOrRenderItem(r.items[i])
298− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
436+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
301− m.renderedLines = append(m.renderedLines, itemLines...)
439+ r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
302− m.lastRenderedInx = realIndex
440+ r.model.renderState.lastIndex = r.realIdx
303− start += len(itemLines)
441+ r.start += len(itemLines)
445+// getOrRenderItem retrieves cached content or renders the item if not cached.
446+func (r *reverseRenderer) getOrRenderItem(item util.Model) []string {
447+ if cachedContent, ok := r.model.renderState.items[r.realIdx]; ok {
448+ return cachedContent.lines
451+ itemLines := r.renderItemLines(item)
452+ r.model.renderState.items[r.realIdx] = renderedItem{
455+ height: len(itemLines),
460+// renderItemLines converts an item to its string representation with gaps.
461+func (r *reverseRenderer) renderItemLines(item util.Model) []string {
462+ return r.model.getItemLines(item)
465+// selectPreviousItem moves selection to the previous item in the list.
466+// Handles focus management and ensures the selected item remains visible.
467+func (m *model) selectPreviousItem() tea.Cmd {
468+ if m.selectionState.selectedIndex <= 0 {
472+ cmds := []tea.Cmd{m.blurSelected()}
473+ m.selectionState.selectedIndex--
474+ cmds = append(cmds, m.focusSelected())
475+ m.ensureSelectedItemVisible()
476+ return tea.Batch(cmds...)
479+// selectNextItem moves selection to the next item in the list.
480+// Handles focus management and ensures the selected item remains visible.
481+func (m *model) selectNextItem() tea.Cmd {
482+ if m.selectionState.selectedIndex >= len(m.items)-1 || m.selectionState.selectedIndex < 0 {
305− m.needsRerender = false
485+
306− maxHeight := min(m.listHeight(), len(m.renderedLines))
486+ cmds := []tea.Cmd{m.blurSelected()}
307− if m.finalHeight > -1 {
487+ m.selectionState.selectedIndex++
308− // make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
488+ cmds = append(cmds, m.focusSelected())
309− m.offset = min(m.offset, m.finalHeight)
489+ m.ensureSelectedItemVisible()
490+ return tea.Batch(cmds...)
493+// ensureSelectedItemVisible scrolls the list to make the selected item visible.
494+// Uses different strategies for forward and reverse rendering modes.
495+func (m *model) ensureSelectedItemVisible() {
496+ cachedItem, ok := m.renderState.items[m.selectionState.selectedIndex]
498+ m.renderState.needsRerender = true
311− if m.offset < len(m.renderedLines) {
501+
312− m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
502+ if m.viewState.reverse {
503+ m.ensureVisibleReverse(cachedItem)
314− m.content = ""
505+ m.ensureVisibleForward(cachedItem)
507+ m.renderState.needsRerender = true
318−func (m *model) upOneItem() tea.Cmd {
510+// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
319− var cmds []tea.Cmd
511+// Handles both large items (taller than viewport) and normal items.
320− if m.selectedItemInx > 0 {
321− cmd := m.blurSelected()
322− cmds = append(cmds, cmd)
324− cmd = m.focusSelected()
325− cmds = append(cmds, cmd)
328− cached, ok := m.renderedItems.Load(m.selectedItemInx)
332− cachedItem, _ := cached.(renderedItem)
512+func (m *model) ensureVisibleForward(cachedItem renderedItem) {
333− // might not fit on the screen move the offset to the start of the item
334− if cachedItem.height >= m.listHeight() {
513+ if cachedItem.height >= m.listHeight() {
335− changeNeeded := m.offset - cachedItem.start
514+ if m.selectionState.selectedIndex > 0 {
336− m.decreaseOffset(changeNeeded)
338− if cachedItem.start < m.offset {
339− changeNeeded := m.offset - cachedItem.start
515+ changeNeeded := m.viewState.offset - cachedItem.start
340− m.decreaseOffset(changeNeeded)
516+ m.decreaseOffset(changeNeeded)
343− cachedItem, _ := cached.(renderedItem)
518+ changeNeeded := cachedItem.start - m.viewState.offset
344− // might not fit on the screen move the offset to the start of the item
519+ m.increaseOffset(changeNeeded)
524+ if cachedItem.start < m.viewState.offset {
525+ changeNeeded := m.viewState.offset - cachedItem.start
526+ m.decreaseOffset(changeNeeded)
345− if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
528+ end := cachedItem.start + cachedItem.height
529+ if end > m.viewState.offset+m.listHeight() {
346− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
530+ changeNeeded := end - (m.viewState.offset + m.listHeight())
347− m.increaseOffset(changeNeeded)
531+ m.increaseOffset(changeNeeded)
351− m.needsRerender = true
352− return tea.Batch(cmds...)
355−func (m *model) downOneItem() tea.Cmd {
536+// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
356− var cmds []tea.Cmd
537+// Handles both large items (taller than viewport) and normal items.
357− if m.selectedItemInx < len(m.items)-1 {
538+func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
358− cmd := m.blurSelected()
539+ if cachedItem.height >= m.listHeight() {
359− cmds = append(cmds, cmd)
540+ if m.selectionState.selectedIndex < len(m.items)-1 {
361− cmd = m.focusSelected()
362− cmds = append(cmds, cmd)
364− cached, ok := m.renderedItems.Load(m.selectedItemInx)
368− cachedItem, _ := cached.(renderedItem)
369− // might not fit on the screen move the offset to the start of the item
370− if cachedItem.height >= m.listHeight() {
371− changeNeeded := cachedItem.start - m.offset
372− m.increaseOffset(changeNeeded)
374− end := cachedItem.start + cachedItem.height
541+ changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
375− if end > m.offset+m.listHeight() {
376− changeNeeded := end - (m.offset + m.listHeight())
377− m.increaseOffset(changeNeeded)
542+ m.decreaseOffset(changeNeeded)
381− cachedItem, _ := cached.(renderedItem)
382− // might not fit on the screen move the offset to the start of the item
383− if cachedItem.height >= m.listHeight() {
384− changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
544+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
385− m.decreaseOffset(changeNeeded)
387− if cachedItem.start < m.offset {
388− changeNeeded := m.offset - cachedItem.start
389− m.decreaseOffset(changeNeeded)
545+ m.increaseOffset(changeNeeded)
395− m.needsRerender = true
550+ if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
396− return tea.Batch(cmds...)
551+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
552+ m.increaseOffset(changeNeeded)
553+ } else if cachedItem.start < m.viewState.offset {
554+ changeNeeded := m.viewState.offset - cachedItem.start
555+ m.decreaseOffset(changeNeeded)
559+// goToBottom switches to reverse mode and selects the last item.
560+// Commonly used for chat-like interfaces where new content appears at the bottom.
399func (m *model) goToBottom() tea.Cmd {
561func (m *model) goToBottom() tea.Cmd {
400− if len(m.items) == 0 {
562+ cmds := []tea.Cmd{m.blurSelected()}
404− m.reverse = true
563+ m.viewState.reverse = true
405− cmd := m.blurSelected()
564+ m.selectionState.selectedIndex = len(m.items) - 1
406− cmds = append(cmds, cmd)
407− m.selectedItemInx = len(m.items) - 1
408− cmd = m.focusSelected()
565+ cmds = append(cmds, m.focusSelected())
409− cmds = append(cmds, cmd)
410 m.ResetView()
566 m.ResetView()
411 return tea.Batch(cmds...)
567 return tea.Batch(cmds...)
414−func (m *model) ResetView() {
570+// goToTop switches to forward mode and selects the first item.
415− m.renderedItems.Clear()
571+// Standard behavior for most list interfaces.
416− m.renderedLines = []string{}
418− m.lastRenderedInx = -1
420− m.needsRerender = true
423func (m *model) goToTop() tea.Cmd {
572func (m *model) goToTop() tea.Cmd {
573+ cmds := []tea.Cmd{m.blurSelected()}
574+ m.viewState.reverse = false
424− if len(m.items) == 0 {
575+ if len(m.items) > 0 {
425− return nil
576+ m.selectionState.selectedIndex = 0
429− cmd := m.blurSelected()
430− cmds = append(cmds, cmd)
578+ cmds = append(cmds, m.focusSelected())
431− m.selectedItemInx = 0
432− cmd = m.focusSelected()
433− cmds = append(cmds, cmd)
434 m.ResetView()
579 m.ResetView()
435 return tea.Batch(cmds...)
580 return tea.Batch(cmds...)
583+// ResetView clears all cached rendering data and resets scroll position.
584+// Forces a complete re-render on the next View() call.
585+func (m *model) ResetView() {
586+ m.renderState.reset()
587+ m.viewState.offset = 0
590+// focusSelected gives focus to the currently selected item if it supports focus.
591+// Triggers a re-render of the item to show its focused state.
438func (m *model) focusSelected() tea.Cmd {
592func (m *model) focusSelected() tea.Cmd {
439− if m.selectedItemInx == -1 {
593+ if !m.selectionState.isValidIndex(len(m.items)) {
440 return nil
594 return nil
442− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
596+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
443 cmd := i.Focus()
597 cmd := i.Focus()
444− m.rerenderItem(m.selectedItemInx)
598+ m.rerenderItem(m.selectionState.selectedIndex)
445 return cmd
599 return cmd
447 return nil
601 return nil
604+// blurSelected removes focus from the currently selected item if it supports focus.
605+// Triggers a re-render of the item to show its unfocused state.
450func (m *model) blurSelected() tea.Cmd {
606func (m *model) blurSelected() tea.Cmd {
451− if m.selectedItemInx == -1 {
607+ if !m.selectionState.isValidIndex(len(m.items)) {
452 return nil
608 return nil
454− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
610+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
455 cmd := i.Blur()
611 cmd := i.Blur()
456− m.rerenderItem(m.selectedItemInx)
612+ m.rerenderItem(m.selectionState.selectedIndex)
457 return cmd
613 return cmd
459 return nil
615 return nil
618+// rerenderItem updates the cached rendering of a specific item.
619+// This is called when an item's state changes (e.g., focus/blur) and needs to be re-displayed.
620+// It efficiently updates only the changed item and adjusts positions of subsequent items if needed.
462func (m *model) rerenderItem(inx int) {
621func (m *model) rerenderItem(inx int) {
463− if inx < 0 || len(m.renderedLines) == 0 {
622+ if inx < 0 || inx >= len(m.items) || len(m.renderState.lines) == 0 {
466− cached, ok := m.renderedItems.Load(inx)
625+
467− cachedItem, _ := cached.(renderedItem)
626+ cachedItem, ok := m.renderState.items[inx]
469− // No need to rerender
472− rerenderedItem := m.items[inx].View()
630+
473− rerenderedLines := strings.Split(rerenderedItem, "\n")
631+ rerenderedLines := m.getItemLines(m.items[inx])
475− for range m.gapSize {
476− rerenderedLines = append(rerenderedLines, "")
479− // check if lines are the same
480 if slices.Equal(cachedItem.lines, rerenderedLines) {
632 if slices.Equal(cachedItem.lines, rerenderedLines) {
484− // check if the item is in the content
635+
636+ m.updateRenderedLines(cachedItem, rerenderedLines)
637+ m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
638+ m.updateCachedItem(inx, cachedItem, rerenderedLines)
639+ m.renderState.needsRerender = true
642+// getItemLines converts an item to its rendered lines, including any gap spacing.
643+func (m *model) getItemLines(item util.Model) []string {
644+ itemLines := strings.Split(item.View(), "\n")
646+ gap := make([]string, m.gapSize)
647+ itemLines = append(itemLines, gap...)
652+// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
653+func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
485− start := cachedItem.start
654+ start, end := m.getItemBounds(cachedItem)
655+ totalLines := len(m.renderState.lines)
657+ if start >= 0 && start <= totalLines && end >= 0 && end <= totalLines {
658+ m.renderState.lines = slices.Delete(m.renderState.lines, start, end)
659+ m.renderState.lines = slices.Insert(m.renderState.lines, start, newLines...)
663+// getItemBounds calculates the start and end line positions for an item.
664+// Handles both forward and reverse rendering modes.
665+func (m *model) getItemBounds(cachedItem renderedItem) (start, end int) {
666+ start = cachedItem.start
486− end := start + cachedItem.height
667+ end = start + cachedItem.height
669+ if m.viewState.reverse {
487− totalLines := len(m.renderedLines)
670+ totalLines := len(m.renderState.lines)
489 end = totalLines - cachedItem.start
671 end = totalLines - cachedItem.start
490 start = end - cachedItem.height
672 start = end - cachedItem.height
492− if start <= totalLines && end <= totalLines {
674+ return start, end
493− m.renderedLines = slices.Delete(m.renderedLines, start, end)
675+}
494− m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
676+
677+// updateItemPositions recalculates positions for items after the changed item.
678+// This is necessary when an item's height changes, affecting subsequent items.
679+func (m *model) updateItemPositions(inx int, cachedItem renderedItem, newHeight int) {
680+ if cachedItem.height == newHeight {
684+ if inx == len(m.items)-1 {
685+ m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
688+ currentStart := cachedItem.start + newHeight
689+ if m.viewState.reverse {
690+ m.updatePositionsReverse(inx, currentStart)
692+ m.updatePositionsForward(inx, currentStart)
496− // TODO: if hight changed do something
694+}
497− if cachedItem.height != len(rerenderedLines) {
695+
498− if inx == len(m.items)-1 {
696+// updatePositionsForward updates positions for items after the changed item in forward mode.
499− m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
697+func (m *model) updatePositionsForward(inx int, currentStart int) {
698+ for i := inx + 1; i < len(m.items); i++ {
699+ if existing, ok := m.renderState.items[i]; ok {
700+ existing.start = currentStart
701+ currentStart += existing.height
702+ m.renderState.items[i] = existing
502− // update the start of the other cached items
709+// updatePositionsReverse updates positions for items before the changed item in reverse mode.
503− currentStart := cachedItem.start + len(rerenderedLines)
710+func (m *model) updatePositionsReverse(inx int, currentStart int) {
505− for i := inx - 1; i < len(m.items); i-- {
711+ for i := inx - 1; i >= 0; i-- {
506− if existing, ok := m.renderedItems.Load(i); ok {
712+ if existing, ok := m.renderState.items[i]; ok {
507− cached := existing.(renderedItem)
508− cached.start = currentStart
713+ existing.start = currentStart
509− currentStart += cached.height
714+ currentStart += existing.height
510− m.renderedItems.Store(i, cached)
715+ m.renderState.items[i] = existing
516− for i := inx + 1; i < len(m.items); i++ {
517− if existing, ok := m.renderedItems.Load(i); ok {
518− cached := existing.(renderedItem)
519− cached.start = currentStart
520− currentStart += cached.height
521− m.renderedItems.Store(i, cached)
722+// updateCachedItem updates the cached rendering information for a specific item.
723+func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
528− m.renderedItems.Store(inx, renderedItem{
724+ m.renderState.items[inx] = renderedItem{
529− lines: rerenderedLines,
725+ lines: newLines,
530 start: cachedItem.start,
726 start: cachedItem.start,
531− height: len(rerenderedLines),
727+ height: len(newLines),
533− m.needsRerender = true
731+// increaseOffset scrolls the list down by increasing the offset.
732+// Respects the final height limit to prevent scrolling past the end.
536func (m *model) increaseOffset(n int) {
733func (m *model) increaseOffset(n int) {
537− if m.finalHeight > -1 {
734+ if m.renderState.finalHeight > NoFinalHeight {
538− if m.offset < m.finalHeight {
735+ if m.viewState.offset < m.renderState.finalHeight {
539− m.offset += n
736+ m.viewState.offset += n
540− if m.offset > m.finalHeight {
737+ if m.viewState.offset > m.renderState.finalHeight {
541− m.offset = m.finalHeight
738+ m.viewState.offset = m.renderState.finalHeight
543− m.needsRerender = true
740+ m.renderState.needsRerender = true
546− m.offset += n
743+ m.viewState.offset += n
547− m.needsRerender = true
744+ m.renderState.needsRerender = true
748+// decreaseOffset scrolls the list up by decreasing the offset.
749+// Prevents scrolling above the beginning of the list.
551func (m *model) decreaseOffset(n int) {
750func (m *model) decreaseOffset(n int) {
552− if m.offset > 0 {
751+ if m.viewState.offset > 0 {
553− m.offset -= n
752+ m.viewState.offset -= n
554− if m.offset < 0 {
753+ if m.viewState.offset < 0 {
555− m.offset = 0
754+ m.viewState.offset = 0
557− m.needsRerender = true
756+ m.renderState.needsRerender = true
561−// UpdateItem implements List.
760+// UpdateItem replaces an item at the specified index with a new item.
761+// Handles focus management and triggers re-rendering as needed.
562func (m *model) UpdateItem(inx int, item util.Model) {
762func (m *model) UpdateItem(inx int, item util.Model) {
763+ if inx < 0 || inx >= len(m.items) {
563 m.items[inx] = item
766 m.items[inx] = item
564− if m.selectedItemInx == inx {
767+ if m.selectionState.selectedIndex == inx {
565− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
768+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
566 i.Focus()
769 i.Focus()
569 m.setItemSize(inx)
772 m.setItemSize(inx)
570 m.rerenderItem(inx)
773 m.rerenderItem(inx)
571− m.needsRerender = true
774+ m.renderState.needsRerender = true
574−// GetSize implements List.
777+// GetSize returns the current dimensions of the list.
575func (m *model) GetSize() (int, int) {
778func (m *model) GetSize() (int, int) {
576− return m.width, m.height
779+ return m.viewState.width, m.viewState.height
579−// SetSize implements List.
782+// SetSize updates the list dimensions and triggers a complete re-render.
783+// Also updates the size of all items that support sizing.
580func (m *model) SetSize(width int, height int) tea.Cmd {
784func (m *model) SetSize(width int, height int) tea.Cmd {
581− if m.width == width && m.height == height {
785+ if m.viewState.width == width && m.viewState.height == height {
582 return nil
786 return nil
584− if m.height != height {
788+ if m.viewState.height != height {
585− m.finalHeight = -1
789+ m.renderState.finalHeight = NoFinalHeight
586− m.height = height
790+ m.viewState.height = height
588− m.width = width
792+ m.viewState.width = width
589 m.ResetView()
793 m.ResetView()
590 return m.setAllItemsSize()
794 return m.setAllItemsSize()
797+// getItemSize calculates the available width for items, accounting for padding.
593func (m *model) getItemSize() int {
798func (m *model) getItemSize() int {
594− width := m.width
799+ width := m.viewState.width
595− if m.padding != nil {
596− if len(m.padding) == 1 {
800+ switch len(m.padding) {
597− width -= m.padding[0] * 2
802+ width -= m.padding[0] * 2
598− } else if len(m.padding) == 2 || len(m.padding) == 3 {
803+ case 2, 3:
599− width -= m.padding[1] * 2
804+ width -= m.padding[1] * 2
600− } else if len(m.padding) == 4 {
805+ case 4:
601− width -= m.padding[1] + m.padding[3]
806+ width -= m.padding[1] + m.padding[3]
604− return width
808+ return max(0, width)
811+// setItemSize updates the size of a specific item if it supports sizing.
607func (m *model) setItemSize(inx int) tea.Cmd {
812func (m *model) setItemSize(inx int) tea.Cmd {
813+ if inx < 0 || inx >= len(m.items) {
608 if i, ok := m.items[inx].(layout.Sizeable); ok {
816 if i, ok := m.items[inx].(layout.Sizeable); ok {
609− cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
817+ return i.SetSize(m.getItemSize(), 0)
612 return nil
819 return nil
822+// setAllItemsSize updates the size of all items that support sizing.
615func (m *model) setAllItemsSize() tea.Cmd {
823func (m *model) setAllItemsSize() tea.Cmd {
616 var cmds []tea.Cmd
824 var cmds []tea.Cmd
617 for i := range m.items {
825 for i := range m.items {
618− cmd := m.setItemSize(i)
826+ if cmd := m.setItemSize(i); cmd != nil {
619− cmds = append(cmds, cmd)
827+ cmds = append(cmds, cmd)
621 return tea.Batch(cmds...)
830 return tea.Batch(cmds...)
833+// listHeight calculates the available height for list content, accounting for padding.
624func (m *model) listHeight() int {
834func (m *model) listHeight() int {
625− height := m.height
835+ height := m.viewState.height
626− if m.padding != nil {
627− if len(m.padding) == 1 {
836+ switch len(m.padding) {
628− height -= m.padding[0] * 2
838+ height -= m.padding[0] * 2
629− } else if len(m.padding) == 2 {
839+ case 2:
630− height -= m.padding[1] * 2
840+ height -= m.padding[0] * 2
631− } else if len(m.padding) == 3 {
841+ case 3, 4:
632− height -= m.padding[0] + m.padding[2]
633− } else if len(m.padding) == 4 {
634− height -= m.padding[0] + m.padding[2]
842+ height -= m.padding[0] + m.padding[2]
637− return height
844+ return max(0, height)
640−// AppendItem implements List.
847+// AppendItem adds a new item to the end of the list.
848+// Automatically switches to reverse mode and scrolls to show the new item.
641func (m *model) AppendItem(item util.Model) tea.Cmd {
849func (m *model) AppendItem(item util.Model) tea.Cmd {
642− var cmds []tea.Cmd
850+ cmds := []tea.Cmd{
643− cmd := item.Init()
851+ item.Init(),
644− cmds = append(cmds, cmd)
852+ }
645 m.items = append(m.items, item)
853 m.items = append(m.items, item)
646− cmd = m.setItemSize(len(m.items) - 1)
854+ cmds = append(cmds, m.setItemSize(len(m.items)-1))
647− cmds = append(cmds, cmd)
855+ cmds = append(cmds, m.goToBottom())
648− cmd = m.goToBottom()
649− cmds = append(cmds, cmd)
650− m.needsRerender = true
856+ m.renderState.needsRerender = true
651 return tea.Batch(cmds...)
857 return tea.Batch(cmds...)
654−// DeleteItem implements List.
860+// DeleteItem removes an item at the specified index.
861+// Adjusts selection if necessary and triggers a complete re-render.
655func (m *model) DeleteItem(i int) {
862func (m *model) DeleteItem(i int) {
863+ if i < 0 || i >= len(m.items) {
656 m.items = slices.Delete(m.items, i, i+1)
866 m.items = slices.Delete(m.items, i, i+1)
657− m.renderedItems.Delete(i)
867+ delete(m.renderState.items, i)
658− if m.selectedItemInx == i {
868+
659− m.selectedItemInx--
869+ if m.selectionState.selectedIndex == i && m.selectionState.selectedIndex > 0 {
870+ m.selectionState.selectedIndex--
871+ } else if m.selectionState.selectedIndex > i {
872+ m.selectionState.selectedIndex--
661 m.ResetView()
875 m.ResetView()
662− m.needsRerender = true
876+ m.renderState.needsRerender = true
665−// PrependItem implements List.
879+// PrependItem adds a new item to the beginning of the list.
880+// Adjusts cached positions and selection index, then switches to forward mode.
666func (m *model) PrependItem(item util.Model) tea.Cmd {
881func (m *model) PrependItem(item util.Model) tea.Cmd {
668− cmd := item.Init()
882+ cmds := []tea.Cmd{item.Init()}
669− cmds = append(cmds, cmd)
670 m.items = append([]util.Model{item}, m.items...)
883 m.items = append([]util.Model{item}, m.items...)
671− // update the indices of the rendered items
884+
885+ // Shift all cached item indices by 1
672− newRenderedItems := make(map[int]renderedItem)
886+ newItems := make(map[int]renderedItem, len(m.renderState.items))
673− m.renderedItems.Range(func(key any, value any) bool {
887+ for k, v := range m.renderState.items {
674− keyInt := key.(int)
888+ newItems[k+1] = v
675− renderedItem := value.(renderedItem)
889+ }
676− newKey := keyInt + 1
890+ m.renderState.items = newItems
677− newRenderedItems[newKey] = renderedItem
891+
678− return false
892+ if m.selectionState.selectedIndex >= 0 {
679− })
893+ m.selectionState.selectedIndex++
680− m.renderedItems.Clear()
681− for k, v := range newRenderedItems {
682− m.renderedItems.Store(k, v)
684− cmd = m.goToTop()
896+ cmds = append(cmds, m.goToTop())
685− cmds = append(cmds, cmd)
897+ cmds = append(cmds, m.setItemSize(0))
686− cmd = m.setItemSize(0)
687− cmds = append(cmds, cmd)
688− m.needsRerender = true
898+ m.renderState.needsRerender = true
689 return tea.Batch(cmds...)
899 return tea.Batch(cmds...)
902+// setReverse switches between forward and reverse rendering modes.
692func (m *model) setReverse(reverse bool) {
903func (m *model) setReverse(reverse bool) {
693 if reverse {
904 if reverse {
694 m.goToBottom()
905 m.goToBottom()
@@ -697,24 +908,29 @@ func (m *model) setReverse(reverse bool) {
700−// SetItems implements List.
911+// SetItems replaces all items in the list with a new set.
912+// Initializes all items, sets their sizes, and establishes initial selection.
701func (m *model) SetItems(items []util.Model) tea.Cmd {
913func (m *model) SetItems(items []util.Model) tea.Cmd {
702 m.items = items
914 m.items = items
704− cmd := m.setAllItemsSize()
915+ cmds := []tea.Cmd{m.setAllItemsSize()}
705− cmds = append(cmds, cmd)
916+
706 for _, item := range m.items {
917 for _, item := range m.items {
707 cmds = append(cmds, item.Init())
918 cmds = append(cmds, item.Init())
921+ if len(m.items) > 0 {
709− if m.reverse {
922+ if m.viewState.reverse {
710− m.selectedItemInx = len(m.items) - 1
923+ m.selectionState.selectedIndex = len(m.items) - 1
925+ m.selectionState.selectedIndex = 0
711− cmd := m.focusSelected()
927+ if cmd := m.focusSelected(); cmd != nil {
712− cmds = append(cmds, cmd)
928+ cmds = append(cmds, cmd)
714− m.selectedItemInx = 0
931+ m.selectionState.selectedIndex = NoSelection
715− cmd := m.focusSelected()
716− cmds = append(cmds, cmd)
718 m.ResetView()
934 m.ResetView()
719 return tea.Batch(cmds...)
935 return tea.Batch(cmds...)