@@ -3,7 +3,6 @@ package list
33import (
44 "slices"
55 "strings"
6− "sync"
76
87 "github.com/charmbracelet/bubbles/v2/help"
98 "github.com/charmbracelet/bubbles/v2/key"
@@ -14,89 +13,155 @@ import (
1413 "github.com/opencode-ai/opencode/internal/tui/util"
1514)
1615
16+// Constants for special index values and defaults
17+const (
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
22+)
23+
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.
1726type ListModel interface {
1827 util.Model
1928 layout.Sizeable
20− SetItems([]util.Model) tea.Cmd
21− AppendItem(util.Model) tea.Cmd
22− PrependItem(util.Model) tea.Cmd
23− DeleteItem(int)
24− UpdateItem(int, util.Model)
25− ResetView()
26− Items() []util.Model
29+ SetItems([]util.Model) tea.Cmd // Replace all items in the list
30+ AppendItem(util.Model) tea.Cmd // Add an item to the end of the list
31+ PrependItem(util.Model) tea.Cmd // Add an item to the beginning of the list
32+ DeleteItem(int) // Remove an item at the specified index
33+ UpdateItem(int, util.Model) // Replace an item at the specified index
34+ ResetView() // Clear rendering cache and reset scroll position
35+ Items() []util.Model // Get all items in the list
2736}
2837
38+// HasAnim interface identifies items that support animation.
39+// Items implementing this interface will receive animation update messages.
2940type HasAnim interface {
3041 util.Model
31− Spinning() bool
42+ Spinning() bool // Returns true if the item is currently animating
3243}
3344
45+// renderedItem represents a cached rendered item with its position and content.
3446type renderedItem struct {
35− lines []string
36− start int
37− height int
47+ lines []string // The rendered lines of text for this item
48+ start int // Starting line position in the overall rendered content
49+ height int // Number of lines this item occupies
50+}
51+
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
60+}
61+
62+// newRenderState creates a new render state with default values.
63+func newRenderState() *renderState {
64+ return &renderState{
65+ items: make(map[int]renderedItem),
66+ lines: []string{},
67+ lastIndex: NotRendered,
68+ finalHeight: NoFinalHeight,
69+ needsRerender: true,
70+ }
71+}
72+
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
80+}
81+
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
88+}
89+
90+// selectionState manages which item is currently selected.
91+type selectionState struct {
92+ selectedIndex int // Index of the currently selected item, or NoSelection
93+}
94+
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
3898}
99+
100+// model is the main implementation of the ListModel interface.
101+// It coordinates between view state, render state, and selection state.
39102type model struct {
40− width, height, offset int
41− finalHeight int // this gets set when the last item is rendered to mark the max offset
42− reverse bool
43− help help.Model
44− keymap KeyMap
45− items []util.Model
46− renderedItems *sync.Map // item index to rendered string
47− needsRerender bool
48− renderedLines []string
49− selectedItemInx int
50− lastRenderedInx int
51− content string
52− gapSize int
53− padding []int
103+ viewState viewState // Display and scrolling state
104+ renderState *renderState // Rendering cache and state
105+ selectionState selectionState // Item selection state
106+ help help.Model // Help system for keyboard shortcuts
107+ keymap KeyMap // Key bindings for navigation
108+ items []util.Model // The actual list items
109+ gapSize int // Number of empty lines between items
110+ padding []int // Padding around the list content
54111}
55112
113+// listOptions is a function type for configuring list options.
56114type listOptions func(*model)
57115
116+// WithKeyMap sets custom key bindings for the list.
58117func WithKeyMap(k KeyMap) listOptions {
59118 return func(m *model) {
60119 m.keymap = k
61120 }
62121}
63122
123+// WithReverse sets whether the list should render in reverse order (newest items at bottom).
64124func WithReverse(reverse bool) listOptions {
65125 return func(m *model) {
66126 m.setReverse(reverse)
67127 }
68128}
69129
130+// WithGapSize sets the number of empty lines to insert between list items.
70131func WithGapSize(gapSize int) listOptions {
71132 return func(m *model) {
72133 m.gapSize = gapSize
73134 }
74135}
75136
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.
76140func WithPadding(padding ...int) listOptions {
77141 return func(m *model) {
78142 m.padding = padding
79143 }
80144}
81145
146+// WithItems sets the initial items for the list.
82147func WithItems(items []util.Model) listOptions {
83148 return func(m *model) {
84149 m.items = items
85150 }
86151}
87152
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.
88156func New(opts ...listOptions) ListModel {
89157 m := &model{
90− help: help.New(),
91− keymap: defaultKeymap(),
92− items: []util.Model{},
93− needsRerender: true,
94− gapSize: 0,
95− padding: []int{},
96− selectedItemInx: -1,
97− finalHeight: -1,
98− lastRenderedInx: -1,
99− renderedItems: new(sync.Map),
158+ help: help.New(),
159+ keymap: DefaultKeymap(),
160+ items: []util.Model{},
161+ renderState: newRenderState(),
162+ gapSize: DefaultGapSize,
163+ padding: []int{},
164+ selectionState: selectionState{selectedIndex: NoSelection},
100165 }
101166 for _, opt := range opts {
102167 opt(m)
@@ -104,591 +169,737 @@ func New(opts ...listOptions) ListModel {
104169 return m
105170}
106171
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.
108174func (m *model) Init() tea.Cmd {
109− cmds := []tea.Cmd{
110− m.SetItems(m.items),
111− }
112− return tea.Batch(cmds...)
175+ return m.SetItems(m.items)
113176}
114177
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.
116181func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
117− var cmds []tea.Cmd
118182 switch msg := msg.(type) {
119183 case tea.KeyPressMsg:
120− switch {
121− case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
122− if m.reverse {
123− m.decreaseOffset(1)
124− } else {
125− m.increaseOffset(1)
126− }
127− return m, nil
128− case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
129− if m.reverse {
130− m.increaseOffset(1)
131− } else {
132− m.decreaseOffset(1)
133− }
134− return m, nil
135− case key.Matches(msg, m.keymap.DownOneItem):
136− m.downOneItem()
137− return m, nil
138− case key.Matches(msg, m.keymap.UpOneItem):
139− m.upOneItem()
140− return m, nil
141− case key.Matches(msg, m.keymap.HalfPageDown):
142− if m.reverse {
143− m.decreaseOffset(m.listHeight() / 2)
144− } else {
145− m.increaseOffset(m.listHeight() / 2)
146− }
147− return m, nil
148− case key.Matches(msg, m.keymap.HalfPageUp):
149− if m.reverse {
150− m.increaseOffset(m.listHeight() / 2)
151− } else {
152− m.decreaseOffset(m.listHeight() / 2)
153− }
154− return m, nil
155− case key.Matches(msg, m.keymap.Home):
156− m.goToTop()
157− return m, nil
158− case key.Matches(msg, m.keymap.End):
159− m.goToBottom()
160− return m, nil
161− }
184+ return m.handleKeyPress(msg)
162185 case anim.ColorCycleMsg, anim.StepCharsMsg:
163− for inx, item := range m.items {
164− if i, ok := item.(HasAnim); ok {
165− if i.Spinning() {
166− updated, cmd := i.Update(msg)
167− cmds = append(cmds, cmd)
168− m.UpdateItem(inx, updated.(util.Model))
169− }
186+ return m.handleAnimationMsg(msg)
187+ }
188+
189+ if m.selectionState.isValidIndex(len(m.items)) {
190+ return m.updateSelectedItem(msg)
191+ }
192+
193+ return m, nil
194+}
195+
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) {
199+ switch {
200+ case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
201+ m.scrollDown(1)
202+ case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
203+ m.scrollUp(1)
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()
216+ }
217+ return m, nil
218+}
219+
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) {
223+ var cmds []tea.Cmd
224+ for inx, item := range m.items {
225+ if i, ok := item.(HasAnim); ok && i.Spinning() {
226+ updated, cmd := i.Update(msg)
227+ cmds = append(cmds, cmd)
228+ if u, ok := updated.(util.Model); ok {
229+ m.UpdateItem(inx, u)
170230 }
171231 }
172− return m, tea.Batch(cmds...)
173232 }
174− if m.selectedItemInx > -1 {
175− u, cmd := m.items[m.selectedItemInx].Update(msg)
176− cmds = append(cmds, cmd)
177− m.UpdateItem(m.selectedItemInx, u.(util.Model))
178− return m, tea.Batch(cmds...)
233+ return m, tea.Batch(cmds...)
234+}
235+
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) {
239+ var cmds []tea.Cmd
240+ u, cmd := m.items[m.selectionState.selectedIndex].Update(msg)
241+ cmds = append(cmds, cmd)
242+ if updated, ok := u.(util.Model); ok {
243+ m.UpdateItem(m.selectionState.selectedIndex, updated)
244+ }
245+ return m, tea.Batch(cmds...)
246+}
247+
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)
253+ } else {
254+ m.increaseOffset(amount)
179255 }
256+}
180257
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)
263+ } else {
264+ m.decreaseOffset(amount)
265+ }
182266}
183267
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.
185271func (m *model) View() string {
186− if m.height == 0 || m.width == 0 {
272+ if m.viewState.height == 0 || m.viewState.width == 0 {
187273 return ""
188274 }
189− if m.needsRerender {
275+ if m.renderState.needsRerender {
190276 m.renderVisible()
191277 }
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)
193279}
194280
195−// Items implements ListModel.
281+// Items returns a copy of all items in the list.
196282func (m *model) Items() []util.Model {
197283 return m.items
198284}
199285
200−func (m *model) renderVisibleReverse() {
201− start := 0
202− cutoff := m.offset + m.listHeight()
203− items := m.items
204− if m.lastRenderedInx > -1 {
205− items = m.items[:m.lastRenderedInx]
206− start = len(m.renderedLines)
286+// renderVisible determines which rendering strategy to use and triggers rendering.
287+// Uses forward rendering for normal mode and reverse rendering for reverse mode.
288+func (m *model) renderVisible() {
289+ if m.viewState.reverse {
290+ m.renderVisibleReverse()
207291 } else {
208− // reveresed so that it starts at the end
209− m.lastRenderedInx = len(m.items)
210− }
211− realIndex := m.lastRenderedInx
212− for i := len(items) - 1; i >= 0; i-- {
213− realIndex--
214− var itemLines []string
215− cachedContent, ok := m.renderedItems.Load(realIndex)
216− if ok {
217− itemLines = cachedContent.(renderedItem).lines
218− } else {
219− itemLines = strings.Split(items[i].View(), "\n")
220− if m.gapSize > 0 {
221− for range m.gapSize {
222− itemLines = append(itemLines, "")
223− }
224− }
225− m.renderedItems.Store(realIndex, renderedItem{
226− lines: itemLines,
227− start: start,
228− height: len(itemLines),
229− })
230− }
292+ m.renderVisibleForward()
293+ }
294+}
231295
232− if realIndex == 0 {
233− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
234− }
235− m.renderedLines = append(itemLines, m.renderedLines...)
236− m.lastRenderedInx = realIndex
237− // always render the next item
238− if start > cutoff {
239− break
240− }
241− start += len(itemLines)
296+// renderVisibleForward renders items from top to bottom (normal mode).
297+// Only renders items that are currently visible or near the viewport.
298+func (m *model) renderVisibleForward() {
299+ renderer := &forwardRenderer{
300+ model: m,
301+ start: 0,
302+ cutoff: m.viewState.offset + m.listHeight(),
303+ items: m.items,
304+ realIdx: m.renderState.lastIndex,
242305 }
243− m.needsRerender = false
244− if m.finalHeight > -1 {
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
246− m.offset = min(m.offset, m.finalHeight)
306+
307+ if m.renderState.lastIndex > NotRendered {
308+ renderer.items = m.items[m.renderState.lastIndex+1:]
309+ renderer.start = len(m.renderState.lines)
247310 }
248− maxHeight := min(m.listHeight(), len(m.renderedLines))
249− if m.offset < len(m.renderedLines) {
250− end := len(m.renderedLines) - m.offset
251− start := max(0, end-maxHeight)
252− m.content = strings.Join(m.renderedLines[start:end], "\n")
311+
312+ renderer.render()
313+ m.finalizeRender()
314+}
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{
320+ model: m,
321+ start: 0,
322+ cutoff: m.viewState.offset + m.listHeight(),
323+ items: m.items,
324+ realIdx: m.renderState.lastIndex,
325+ }
326+
327+ if m.renderState.lastIndex > NotRendered {
328+ renderer.items = m.items[:m.renderState.lastIndex]
329+ renderer.start = len(m.renderState.lines)
253330 } else {
254− m.content = ""
331+ m.renderState.lastIndex = len(m.items)
332+ renderer.realIdx = len(m.items)
255333 }
334+
335+ renderer.render()
336+ m.finalizeRender()
256337}
257338
258−func (m *model) renderVisible() {
259− if m.reverse {
260− m.renderVisibleReverse()
339+// finalizeRender completes the rendering process by updating scroll bounds and content.
340+func (m *model) finalizeRender() {
341+ m.renderState.needsRerender = false
342+ if m.renderState.finalHeight > NoFinalHeight {
343+ m.viewState.offset = min(m.viewState.offset, m.renderState.finalHeight)
344+ }
345+ m.updateContent()
346+}
347+
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 = ""
261354 return
262355 }
263− start := 0
264− cutoff := m.offset + m.listHeight()
265− items := m.items
266− if m.lastRenderedInx > -1 {
267− items = m.items[m.lastRenderedInx+1:]
268− start = len(m.renderedLines)
356+
357+ if m.viewState.reverse {
358+ end := len(m.renderState.lines) - m.viewState.offset
359+ start := max(0, end-maxHeight)
360+ m.viewState.content = strings.Join(m.renderState.lines[start:end], "\n")
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")
269364 }
365+}
270366
271− realIndex := m.lastRenderedInx
272− for _, item := range items {
273− realIndex++
367+// forwardRenderer handles rendering items from top to bottom.
368+// It builds up the rendered content incrementally, caching results for performance.
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
375+}
274376
275− var itemLines []string
276− cachedContent, ok := m.renderedItems.Load(realIndex)
277− if ok {
278− itemLines = cachedContent.(renderedItem).lines
279− } else {
280− itemLines = strings.Split(item.View(), "\n")
281− if m.gapSize > 0 {
282− for range m.gapSize {
283− itemLines = append(itemLines, "")
284− }
285− }
286− m.renderedItems.Store(realIndex, renderedItem{
287− lines: itemLines,
288− start: start,
289− height: len(itemLines),
290− })
377+// render processes items in forward order, building up the rendered content.
378+func (r *forwardRenderer) render() {
379+ for _, item := range r.items {
380+ r.realIdx++
381+ if r.start > r.cutoff {
382+ break
383+ }
384+
385+ itemLines := r.getOrRenderItem(item)
386+ if r.realIdx == len(r.model.items)-1 {
387+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
291388 }
292− // always render the next item
293− if start > cutoff {
389+
390+ r.model.renderState.lines = append(r.model.renderState.lines, itemLines...)
391+ r.model.renderState.lastIndex = r.realIdx
392+ r.start += len(itemLines)
393+ }
394+}
395+
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
400+ }
401+
402+ itemLines := r.renderItemLines(item)
403+ r.model.renderState.items[r.realIdx] = renderedItem{
404+ lines: itemLines,
405+ start: r.start,
406+ height: len(itemLines),
407+ }
408+ return itemLines
409+}
410+
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)
414+}
415+
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
424+}
425+
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-- {
429+ r.realIdx--
430+ if r.start > r.cutoff {
294431 break
295432 }
296433
297− if realIndex == len(m.items)-1 {
298− m.finalHeight = max(0, start+len(itemLines)-m.listHeight())
434+ itemLines := r.getOrRenderItem(r.items[i])
435+ if r.realIdx == 0 {
436+ r.model.renderState.finalHeight = max(0, r.start+len(itemLines)-r.model.listHeight())
299437 }
300438
301− m.renderedLines = append(m.renderedLines, itemLines...)
302− m.lastRenderedInx = realIndex
303− start += len(itemLines)
439+ r.model.renderState.lines = append(itemLines, r.model.renderState.lines...)
440+ r.model.renderState.lastIndex = r.realIdx
441+ r.start += len(itemLines)
442+ }
443+}
444+
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
449+ }
450+
451+ itemLines := r.renderItemLines(item)
452+ r.model.renderState.items[r.realIdx] = renderedItem{
453+ lines: itemLines,
454+ start: r.start,
455+ height: len(itemLines),
456+ }
457+ return itemLines
458+}
459+
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)
463+}
464+
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 {
469+ return nil
470+ }
471+
472+ cmds := []tea.Cmd{m.blurSelected()}
473+ m.selectionState.selectedIndex--
474+ cmds = append(cmds, m.focusSelected())
475+ m.ensureSelectedItemVisible()
476+ return tea.Batch(cmds...)
477+}
478+
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 {
483+ return nil
304484 }
305− m.needsRerender = false
306− maxHeight := min(m.listHeight(), len(m.renderedLines))
307− if m.finalHeight > -1 {
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
309− m.offset = min(m.offset, m.finalHeight)
485+
486+ cmds := []tea.Cmd{m.blurSelected()}
487+ m.selectionState.selectedIndex++
488+ cmds = append(cmds, m.focusSelected())
489+ m.ensureSelectedItemVisible()
490+ return tea.Batch(cmds...)
491+}
492+
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]
497+ if !ok {
498+ m.renderState.needsRerender = true
499+ return
310500 }
311− if m.offset < len(m.renderedLines) {
312− m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
501+
502+ if m.viewState.reverse {
503+ m.ensureVisibleReverse(cachedItem)
313504 } else {
314− m.content = ""
505+ m.ensureVisibleForward(cachedItem)
315506 }
507+ m.renderState.needsRerender = true
316508}
317509
318−func (m *model) upOneItem() tea.Cmd {
319− var cmds []tea.Cmd
320− if m.selectedItemInx > 0 {
321− cmd := m.blurSelected()
322− cmds = append(cmds, cmd)
323− m.selectedItemInx--
324− cmd = m.focusSelected()
325− cmds = append(cmds, cmd)
326− }
327−
328− cached, ok := m.renderedItems.Load(m.selectedItemInx)
329− if ok {
330− // already rendered
331− if !m.reverse {
332− cachedItem, _ := cached.(renderedItem)
333− // might not fit on the screen move the offset to the start of the item
334− if cachedItem.height >= m.listHeight() {
335− changeNeeded := m.offset - cachedItem.start
336− m.decreaseOffset(changeNeeded)
337− }
338− if cachedItem.start < m.offset {
339− changeNeeded := m.offset - cachedItem.start
340− m.decreaseOffset(changeNeeded)
341− }
510+// ensureVisibleForward ensures the selected item is visible in forward rendering mode.
511+// Handles both large items (taller than viewport) and normal items.
512+func (m *model) ensureVisibleForward(cachedItem renderedItem) {
513+ if cachedItem.height >= m.listHeight() {
514+ if m.selectionState.selectedIndex > 0 {
515+ changeNeeded := m.viewState.offset - cachedItem.start
516+ m.decreaseOffset(changeNeeded)
342517 } else {
343− cachedItem, _ := cached.(renderedItem)
344− // might not fit on the screen move the offset to the start of the item
345− if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
346− changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
347− m.increaseOffset(changeNeeded)
348− }
518+ changeNeeded := cachedItem.start - m.viewState.offset
519+ m.increaseOffset(changeNeeded)
520+ }
521+ return
522+ }
523+
524+ if cachedItem.start < m.viewState.offset {
525+ changeNeeded := m.viewState.offset - cachedItem.start
526+ m.decreaseOffset(changeNeeded)
527+ } else {
528+ end := cachedItem.start + cachedItem.height
529+ if end > m.viewState.offset+m.listHeight() {
530+ changeNeeded := end - (m.viewState.offset + m.listHeight())
531+ m.increaseOffset(changeNeeded)
349532 }
350533 }
351− m.needsRerender = true
352− return tea.Batch(cmds...)
353534}
354535
355−func (m *model) downOneItem() tea.Cmd {
356− var cmds []tea.Cmd
357− if m.selectedItemInx < len(m.items)-1 {
358− cmd := m.blurSelected()
359− cmds = append(cmds, cmd)
360− m.selectedItemInx++
361− cmd = m.focusSelected()
362− cmds = append(cmds, cmd)
363− }
364− cached, ok := m.renderedItems.Load(m.selectedItemInx)
365− if ok {
366− // already rendered
367− if !m.reverse {
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)
373− } else {
374− end := cachedItem.start + cachedItem.height
375− if end > m.offset+m.listHeight() {
376− changeNeeded := end - (m.offset + m.listHeight())
377− m.increaseOffset(changeNeeded)
378− }
379− }
536+// ensureVisibleReverse ensures the selected item is visible in reverse rendering mode.
537+// Handles both large items (taller than viewport) and normal items.
538+func (m *model) ensureVisibleReverse(cachedItem renderedItem) {
539+ if cachedItem.height >= m.listHeight() {
540+ if m.selectionState.selectedIndex < len(m.items)-1 {
541+ changeNeeded := m.viewState.offset - (cachedItem.start + cachedItem.height - m.listHeight())
542+ m.decreaseOffset(changeNeeded)
380543 } else {
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())
385− m.decreaseOffset(changeNeeded)
386− } else {
387− if cachedItem.start < m.offset {
388− changeNeeded := m.offset - cachedItem.start
389− m.decreaseOffset(changeNeeded)
390− }
391− }
544+ changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.viewState.offset
545+ m.increaseOffset(changeNeeded)
392546 }
547+ return
393548 }
394549
395− m.needsRerender = true
396− return tea.Batch(cmds...)
550+ if cachedItem.start+cachedItem.height > m.viewState.offset+m.listHeight() {
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)
556+ }
397557}
398558
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.
399561func (m *model) goToBottom() tea.Cmd {
400− if len(m.items) == 0 {
401− return nil
402− }
403− var cmds []tea.Cmd
404− m.reverse = true
405− cmd := m.blurSelected()
406− cmds = append(cmds, cmd)
407− m.selectedItemInx = len(m.items) - 1
408− cmd = m.focusSelected()
409− cmds = append(cmds, cmd)
562+ cmds := []tea.Cmd{m.blurSelected()}
563+ m.viewState.reverse = true
564+ m.selectionState.selectedIndex = len(m.items) - 1
565+ cmds = append(cmds, m.focusSelected())
410566 m.ResetView()
411567 return tea.Batch(cmds...)
412568}
413569
414−func (m *model) ResetView() {
415− m.renderedItems.Clear()
416− m.renderedLines = []string{}
417− m.offset = 0
418− m.lastRenderedInx = -1
419− m.finalHeight = -1
420− m.needsRerender = true
421−}
422−
570+// goToTop switches to forward mode and selects the first item.
571+// Standard behavior for most list interfaces.
423572func (m *model) goToTop() tea.Cmd {
424− if len(m.items) == 0 {
425− return nil
573+ cmds := []tea.Cmd{m.blurSelected()}
574+ m.viewState.reverse = false
575+ if len(m.items) > 0 {
576+ m.selectionState.selectedIndex = 0
426577 }
427− var cmds []tea.Cmd
428− m.reverse = false
429− cmd := m.blurSelected()
430− cmds = append(cmds, cmd)
431− m.selectedItemInx = 0
432− cmd = m.focusSelected()
433− cmds = append(cmds, cmd)
578+ cmds = append(cmds, m.focusSelected())
434579 m.ResetView()
435580 return tea.Batch(cmds...)
436581}
437582
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
588+}
589+
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.
438592func (m *model) focusSelected() tea.Cmd {
439− if m.selectedItemInx == -1 {
593+ if !m.selectionState.isValidIndex(len(m.items)) {
440594 return nil
441595 }
442− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
596+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
443597 cmd := i.Focus()
444− m.rerenderItem(m.selectedItemInx)
598+ m.rerenderItem(m.selectionState.selectedIndex)
445599 return cmd
446600 }
447601 return nil
448602}
449603
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.
450606func (m *model) blurSelected() tea.Cmd {
451− if m.selectedItemInx == -1 {
607+ if !m.selectionState.isValidIndex(len(m.items)) {
452608 return nil
453609 }
454− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
610+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
455611 cmd := i.Blur()
456− m.rerenderItem(m.selectedItemInx)
612+ m.rerenderItem(m.selectionState.selectedIndex)
457613 return cmd
458614 }
459615 return nil
460616}
461617
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.
462621func (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 {
464623 return
465624 }
466− cached, ok := m.renderedItems.Load(inx)
467− cachedItem, _ := cached.(renderedItem)
625+
626+ cachedItem, ok := m.renderState.items[inx]
468627 if !ok {
469− // No need to rerender
470628 return
471629 }
472− rerenderedItem := m.items[inx].View()
473− rerenderedLines := strings.Split(rerenderedItem, "\n")
474− if m.gapSize > 0 {
475− for range m.gapSize {
476− rerenderedLines = append(rerenderedLines, "")
477− }
478− }
479− // check if lines are the same
630+
631+ rerenderedLines := m.getItemLines(m.items[inx])
480632 if slices.Equal(cachedItem.lines, rerenderedLines) {
481− // No changes
482633 return
483634 }
484− // check if the item is in the content
485− start := cachedItem.start
486− end := start + cachedItem.height
487− totalLines := len(m.renderedLines)
488− if m.reverse {
635+
636+ m.updateRenderedLines(cachedItem, rerenderedLines)
637+ m.updateItemPositions(inx, cachedItem, len(rerenderedLines))
638+ m.updateCachedItem(inx, cachedItem, rerenderedLines)
639+ m.renderState.needsRerender = true
640+}
641+
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")
645+ if m.gapSize > 0 {
646+ gap := make([]string, m.gapSize)
647+ itemLines = append(itemLines, gap...)
648+ }
649+ return itemLines
650+}
651+
652+// updateRenderedLines replaces the lines for a specific item in the overall rendered content.
653+func (m *model) updateRenderedLines(cachedItem renderedItem, newLines []string) {
654+ start, end := m.getItemBounds(cachedItem)
655+ totalLines := len(m.renderState.lines)
656+
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...)
660+ }
661+}
662+
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
667+ end = start + cachedItem.height
668+
669+ if m.viewState.reverse {
670+ totalLines := len(m.renderState.lines)
489671 end = totalLines - cachedItem.start
490672 start = end - cachedItem.height
491673 }
492− if start <= totalLines && end <= totalLines {
493− m.renderedLines = slices.Delete(m.renderedLines, start, end)
494− m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
674+ return start, end
675+}
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 {
681+ return
682+ }
683+
684+ if inx == len(m.items)-1 {
685+ m.renderState.finalHeight = max(0, cachedItem.start+newHeight-m.listHeight())
686+ }
687+
688+ currentStart := cachedItem.start + newHeight
689+ if m.viewState.reverse {
690+ m.updatePositionsReverse(inx, currentStart)
691+ } else {
692+ m.updatePositionsForward(inx, currentStart)
495693 }
496− // TODO: if hight changed do something
497− if cachedItem.height != len(rerenderedLines) {
498− if inx == len(m.items)-1 {
499− m.finalHeight = max(0, start+len(rerenderedLines)-m.listHeight())
694+}
695+
696+// updatePositionsForward updates positions for items after the changed item in forward mode.
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
703+ } else {
704+ break
500705 }
706+ }
707+}
501708
502− // update the start of the other cached items
503− currentStart := cachedItem.start + len(rerenderedLines)
504− if m.reverse {
505− for i := inx - 1; i < len(m.items); i-- {
506− if existing, ok := m.renderedItems.Load(i); ok {
507− cached := existing.(renderedItem)
508− cached.start = currentStart
509− currentStart += cached.height
510− m.renderedItems.Store(i, cached)
511− } else {
512− break
513− }
514− }
709+// updatePositionsReverse updates positions for items before the changed item in reverse mode.
710+func (m *model) updatePositionsReverse(inx int, currentStart int) {
711+ for i := inx - 1; i >= 0; i-- {
712+ if existing, ok := m.renderState.items[i]; ok {
713+ existing.start = currentStart
714+ currentStart += existing.height
715+ m.renderState.items[i] = existing
515716 } else {
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)
522− } else {
523− break
524− }
525− }
717+ break
526718 }
527719 }
528− m.renderedItems.Store(inx, renderedItem{
529− lines: rerenderedLines,
720+}
721+
722+// updateCachedItem updates the cached rendering information for a specific item.
723+func (m *model) updateCachedItem(inx int, cachedItem renderedItem, newLines []string) {
724+ m.renderState.items[inx] = renderedItem{
725+ lines: newLines,
530726 start: cachedItem.start,
531− height: len(rerenderedLines),
532− })
533− m.needsRerender = true
727+ height: len(newLines),
728+ }
534729}
535730
731+// increaseOffset scrolls the list down by increasing the offset.
732+// Respects the final height limit to prevent scrolling past the end.
536733func (m *model) increaseOffset(n int) {
537− if m.finalHeight > -1 {
538− if m.offset < m.finalHeight {
539− m.offset += n
540− if m.offset > m.finalHeight {
541− m.offset = m.finalHeight
734+ if m.renderState.finalHeight > NoFinalHeight {
735+ if m.viewState.offset < m.renderState.finalHeight {
736+ m.viewState.offset += n
737+ if m.viewState.offset > m.renderState.finalHeight {
738+ m.viewState.offset = m.renderState.finalHeight
542739 }
543− m.needsRerender = true
740+ m.renderState.needsRerender = true
544741 }
545742 } else {
546− m.offset += n
547− m.needsRerender = true
743+ m.viewState.offset += n
744+ m.renderState.needsRerender = true
548745 }
549746}
550747
748+// decreaseOffset scrolls the list up by decreasing the offset.
749+// Prevents scrolling above the beginning of the list.
551750func (m *model) decreaseOffset(n int) {
552− if m.offset > 0 {
553− m.offset -= n
554− if m.offset < 0 {
555− m.offset = 0
751+ if m.viewState.offset > 0 {
752+ m.viewState.offset -= n
753+ if m.viewState.offset < 0 {
754+ m.viewState.offset = 0
556755 }
557− m.needsRerender = true
756+ m.renderState.needsRerender = true
558757 }
559758}
560759
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.
562762func (m *model) UpdateItem(inx int, item util.Model) {
763+ if inx < 0 || inx >= len(m.items) {
764+ return
765+ }
563766 m.items[inx] = item
564− if m.selectedItemInx == inx {
565− if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
767+ if m.selectionState.selectedIndex == inx {
768+ if i, ok := m.items[m.selectionState.selectedIndex].(layout.Focusable); ok {
566769 i.Focus()
567770 }
568771 }
569772 m.setItemSize(inx)
570773 m.rerenderItem(inx)
571− m.needsRerender = true
774+ m.renderState.needsRerender = true
572775}
573776
574−// GetSize implements List.
777+// GetSize returns the current dimensions of the list.
575778func (m *model) GetSize() (int, int) {
576− return m.width, m.height
779+ return m.viewState.width, m.viewState.height
577780}
578781
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.
580784func (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 {
582786 return nil
583787 }
584− if m.height != height {
585− m.finalHeight = -1
586− m.height = height
788+ if m.viewState.height != height {
789+ m.renderState.finalHeight = NoFinalHeight
790+ m.viewState.height = height
587791 }
588− m.width = width
792+ m.viewState.width = width
589793 m.ResetView()
590794 return m.setAllItemsSize()
591795}
592796
797+// getItemSize calculates the available width for items, accounting for padding.
593798func (m *model) getItemSize() int {
594− width := m.width
595− if m.padding != nil {
596− if len(m.padding) == 1 {
597− width -= m.padding[0] * 2
598− } else if len(m.padding) == 2 || len(m.padding) == 3 {
599− width -= m.padding[1] * 2
600− } else if len(m.padding) == 4 {
601− width -= m.padding[1] + m.padding[3]
602− }
799+ width := m.viewState.width
800+ switch len(m.padding) {
801+ case 1:
802+ width -= m.padding[0] * 2
803+ case 2, 3:
804+ width -= m.padding[1] * 2
805+ case 4:
806+ width -= m.padding[1] + m.padding[3]
603807 }
604− return width
808+ return max(0, width)
605809}
606810
811+// setItemSize updates the size of a specific item if it supports sizing.
607812func (m *model) setItemSize(inx int) tea.Cmd {
813+ if inx < 0 || inx >= len(m.items) {
814+ return nil
815+ }
608816 if i, ok := m.items[inx].(layout.Sizeable); ok {
609− cmd := i.SetSize(m.getItemSize(), 0) // height is not limited
610− return cmd
817+ return i.SetSize(m.getItemSize(), 0)
611818 }
612819 return nil
613820}
614821
822+// setAllItemsSize updates the size of all items that support sizing.
615823func (m *model) setAllItemsSize() tea.Cmd {
616824 var cmds []tea.Cmd
617825 for i := range m.items {
618− cmd := m.setItemSize(i)
619− cmds = append(cmds, cmd)
826+ if cmd := m.setItemSize(i); cmd != nil {
827+ cmds = append(cmds, cmd)
828+ }
620829 }
621830 return tea.Batch(cmds...)
622831}
623832
833+// listHeight calculates the available height for list content, accounting for padding.
624834func (m *model) listHeight() int {
625− height := m.height
626− if m.padding != nil {
627− if len(m.padding) == 1 {
628− height -= m.padding[0] * 2
629− } else if len(m.padding) == 2 {
630− height -= m.padding[1] * 2
631− } else if len(m.padding) == 3 {
632− height -= m.padding[0] + m.padding[2]
633− } else if len(m.padding) == 4 {
634− height -= m.padding[0] + m.padding[2]
635− }
835+ height := m.viewState.height
836+ switch len(m.padding) {
837+ case 1:
838+ height -= m.padding[0] * 2
839+ case 2:
840+ height -= m.padding[0] * 2
841+ case 3, 4:
842+ height -= m.padding[0] + m.padding[2]
636843 }
637− return height
844+ return max(0, height)
638845}
639846
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.
641849func (m *model) AppendItem(item util.Model) tea.Cmd {
642− var cmds []tea.Cmd
643− cmd := item.Init()
644− cmds = append(cmds, cmd)
850+ cmds := []tea.Cmd{
851+ item.Init(),
852+ }
645853 m.items = append(m.items, item)
646− cmd = m.setItemSize(len(m.items) - 1)
647− cmds = append(cmds, cmd)
648− cmd = m.goToBottom()
649− cmds = append(cmds, cmd)
650− m.needsRerender = true
854+ cmds = append(cmds, m.setItemSize(len(m.items)-1))
855+ cmds = append(cmds, m.goToBottom())
856+ m.renderState.needsRerender = true
651857 return tea.Batch(cmds...)
652858}
653859
654−// DeleteItem implements List.
860+// DeleteItem removes an item at the specified index.
861+// Adjusts selection if necessary and triggers a complete re-render.
655862func (m *model) DeleteItem(i int) {
863+ if i < 0 || i >= len(m.items) {
864+ return
865+ }
656866 m.items = slices.Delete(m.items, i, i+1)
657− m.renderedItems.Delete(i)
658− if m.selectedItemInx == i {
659− m.selectedItemInx--
867+ delete(m.renderState.items, i)
868+
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--
660873 }
874+
661875 m.ResetView()
662− m.needsRerender = true
876+ m.renderState.needsRerender = true
663877}
664878
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.
666881func (m *model) PrependItem(item util.Model) tea.Cmd {
667− var cmds []tea.Cmd
668− cmd := item.Init()
669− cmds = append(cmds, cmd)
882+ cmds := []tea.Cmd{item.Init()}
670883 m.items = append([]util.Model{item}, m.items...)
671− // update the indices of the rendered items
672− newRenderedItems := make(map[int]renderedItem)
673− m.renderedItems.Range(func(key any, value any) bool {
674− keyInt := key.(int)
675− renderedItem := value.(renderedItem)
676− newKey := keyInt + 1
677− newRenderedItems[newKey] = renderedItem
678− return false
679− })
680− m.renderedItems.Clear()
681− for k, v := range newRenderedItems {
682− m.renderedItems.Store(k, v)
683− }
684− cmd = m.goToTop()
685− cmds = append(cmds, cmd)
686− cmd = m.setItemSize(0)
687− cmds = append(cmds, cmd)
688− m.needsRerender = true
884+
885+ // Shift all cached item indices by 1
886+ newItems := make(map[int]renderedItem, len(m.renderState.items))
887+ for k, v := range m.renderState.items {
888+ newItems[k+1] = v
889+ }
890+ m.renderState.items = newItems
891+
892+ if m.selectionState.selectedIndex >= 0 {
893+ m.selectionState.selectedIndex++
894+ }
895+
896+ cmds = append(cmds, m.goToTop())
897+ cmds = append(cmds, m.setItemSize(0))
898+ m.renderState.needsRerender = true
689899 return tea.Batch(cmds...)
690900}
691901
902+// setReverse switches between forward and reverse rendering modes.
692903func (m *model) setReverse(reverse bool) {
693904 if reverse {
694905 m.goToBottom()
@@ -697,24 +908,29 @@ func (m *model) setReverse(reverse bool) {
697908 }
698909}
699910
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.
701913func (m *model) SetItems(items []util.Model) tea.Cmd {
702914 m.items = items
703− var cmds []tea.Cmd
704− cmd := m.setAllItemsSize()
705− cmds = append(cmds, cmd)
915+ cmds := []tea.Cmd{m.setAllItemsSize()}
916+
706917 for _, item := range m.items {
707918 cmds = append(cmds, item.Init())
708919 }
709− if m.reverse {
710− m.selectedItemInx = len(m.items) - 1
711− cmd := m.focusSelected()
712− cmds = append(cmds, cmd)
920+
921+ if len(m.items) > 0 {
922+ if m.viewState.reverse {
923+ m.selectionState.selectedIndex = len(m.items) - 1
924+ } else {
925+ m.selectionState.selectedIndex = 0
926+ }
927+ if cmd := m.focusSelected(); cmd != nil {
928+ cmds = append(cmds, cmd)
929+ }
713930 } else {
714− m.selectedItemInx = 0
715− cmd := m.focusSelected()
716− cmds = append(cmds, cmd)
931+ m.selectionState.selectedIndex = NoSelection
717932 }
933+
718934 m.ResetView()
719935 return tea.Batch(cmds...)
720936}