on main help sign in
feat(ui): initial lazy-loaded list implementation
commitcd88520924a23f2258ec25302a7916760335c12d authorAyman Bagabas <ayman.bagabas@gmail.com> dateMon 8 Dec 2025, 18:04 (1 year ago) parent82bc601
8 files changed+2894−17
internal/ui/lazylist/item.go+8 internal/ui/lazylist/list.go+440 internal/ui/lazylist/list.go.bak+413 internal/ui/list/lazylist.go+1007 internal/ui/list/simplelist.go+972 internal/ui/model/chat.go+14−14 internal/ui/model/items.go+37 internal/ui/model/ui.go+3−3
internal/ui/lazylist/item.gonew file+8
@@ -0,0 +1,8 @@
1+package lazylist
2+
3+// Item represents a single item in the lazy-loaded list.
4+type Item interface {
5+ // Render returns the string representation of the item for the given
6+ // width.
7+ Render(width int) string
8+}
internal/ui/lazylist/list.gonew file+440
@@ -0,0 +1,440 @@
1+package lazylist
2+
3+import (
4+ "log/slog"
5+ "strings"
6+)
7+
8+// List represents a list of items that can be lazily rendered. A list is
9+// always rendered like a chat conversation where items are stacked vertically
10+// from top to bottom.
11+type List struct {
12+ // Viewport size
13+ width, height int
14+
15+ // Items in the list
16+ items []Item
17+
18+ // Gap between items (0 or less means no gap)
19+ gap int
20+
21+ // Focus and selection state
22+ focused bool
23+ selectedIdx int // The current selected index -1 means no selection
24+
25+ // Rendered content and cache
26+ renderedItems map[int]renderedItem
27+
28+ // offsetIdx is the index of the first visible item in the viewport.
29+ offsetIdx int
30+ // offsetLine is the number of lines of the item at offsetIdx that are
31+ // scrolled out of view (above the viewport).
32+ // It must always be >= 0.
33+ offsetLine int
34+
35+ // Dirty tracking
36+ dirtyItems map[int]struct{}
37+}
38+
39+// renderedItem holds the rendered content and height of an item.
40+type renderedItem struct {
41+ content string
42+ height int
43+}
44+
45+// NewList creates a new lazy-loaded list.
46+func NewList(items ...Item) *List {
47+ l := new(List)
48+ l.items = items
49+ l.renderedItems = make(map[int]renderedItem)
50+ l.dirtyItems = make(map[int]struct{})
51+ return l
52+}
53+
54+// SetSize sets the size of the list viewport.
55+func (l *List) SetSize(width, height int) {
56+ if width != l.width {
57+ l.renderedItems = make(map[int]renderedItem)
58+ }
59+ l.width = width
60+ l.height = height
61+ // l.normalizeOffsets()
62+}
63+
64+// SetGap sets the gap between items.
65+func (l *List) SetGap(gap int) {
66+ l.gap = gap
67+}
68+
69+// Width returns the width of the list viewport.
70+func (l *List) Width() int {
71+ return l.width
72+}
73+
74+// Height returns the height of the list viewport.
75+func (l *List) Height() int {
76+ return l.height
77+}
78+
79+// Len returns the number of items in the list.
80+func (l *List) Len() int {
81+ return len(l.items)
82+}
83+
84+// getItem renders (if needed) and returns the item at the given index.
85+func (l *List) getItem(idx int) renderedItem {
86+ if idx < 0 || idx >= len(l.items) {
87+ return renderedItem{}
88+ }
89+
90+ if item, ok := l.renderedItems[idx]; ok {
91+ if _, dirty := l.dirtyItems[idx]; !dirty {
92+ return item
93+ }
94+ }
95+
96+ item := l.items[idx]
97+ rendered := item.Render(l.width)
98+ height := countLines(rendered)
99+ // slog.Info("Rendered item", "idx", idx, "height", height)
100+
101+ ri := renderedItem{
102+ content: rendered,
103+ height: height,
104+ }
105+
106+ l.renderedItems[idx] = ri
107+ delete(l.dirtyItems, idx)
108+
109+ return ri
110+}
111+
112+// ScrollToIndex scrolls the list to the given item index.
113+func (l *List) ScrollToIndex(index int) {
114+ if index < 0 {
115+ index = 0
116+ }
117+ if index >= len(l.items) {
118+ index = len(l.items) - 1
119+ }
120+ l.offsetIdx = index
121+ l.offsetLine = 0
122+}
123+
124+// ScrollBy scrolls the list by the given number of lines.
125+func (l *List) ScrollBy(lines int) {
126+ if len(l.items) == 0 || lines == 0 {
127+ return
128+ }
129+
130+ if lines > 0 {
131+ // Scroll down
132+ // Calculate from the bottom how many lines needed to anchor the last
133+ // item to the bottom
134+ var totalLines int
135+ var lastItemIdx int // the last item that can be partially visible
136+ for i := len(l.items) - 1; i >= 0; i-- {
137+ item := l.getItem(i)
138+ totalLines += item.height
139+ if l.gap > 0 && i < len(l.items)-1 {
140+ totalLines += l.gap
141+ }
142+ if totalLines >= l.height {
143+ lastItemIdx = i
144+ break
145+ }
146+ }
147+
148+ // Now scroll down by lines
149+ var item renderedItem
150+ l.offsetLine += lines
151+ for {
152+ item = l.getItem(l.offsetIdx)
153+ totalHeight := item.height
154+ if l.gap > 0 {
155+ totalHeight += l.gap
156+ }
157+
158+ if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
159+ // Valid offset
160+ break
161+ }
162+
163+ // Move to next item
164+ l.offsetLine -= totalHeight
165+ l.offsetIdx++
166+ }
167+
168+ if l.offsetLine >= item.height {
169+ l.offsetLine = item.height - 1
170+ }
171+ } else if lines < 0 {
172+ // Scroll up
173+ l.offsetLine += lines
174+ for l.offsetLine < 0 {
175+ if l.offsetIdx == 0 {
176+ // Reached the top of the list
177+ l.offsetLine = 0
178+ break
179+ }
180+
181+ // Move to previous item
182+ l.offsetIdx--
183+ item := l.getItem(l.offsetIdx)
184+ totalHeight := item.height
185+ if l.gap > 0 {
186+ totalHeight += l.gap
187+ }
188+ l.offsetLine += totalHeight
189+ }
190+
191+ item := l.getItem(l.offsetIdx)
192+ if l.offsetLine >= item.height {
193+ l.offsetLine = item.height - 1
194+ }
195+ }
196+}
197+
198+// findVisibleItems finds the range of items that are visible in the viewport.
199+// This is used for checking if selected item is in view.
200+func (l *List) findVisibleItems() (startIdx, endIdx int) {
201+ if len(l.items) == 0 {
202+ return 0, 0
203+ }
204+
205+ startIdx = l.offsetIdx
206+ currentIdx := startIdx
207+ visibleHeight := -l.offsetLine
208+
209+ for currentIdx < len(l.items) {
210+ item := l.getItem(currentIdx)
211+ visibleHeight += item.height
212+ if l.gap > 0 {
213+ visibleHeight += l.gap
214+ }
215+
216+ if visibleHeight >= l.height {
217+ break
218+ }
219+ currentIdx++
220+ }
221+
222+ endIdx = currentIdx
223+ if endIdx >= len(l.items) {
224+ endIdx = len(l.items) - 1
225+ }
226+
227+ return startIdx, endIdx
228+}
229+
230+// Render renders the list and returns the visible lines.
231+func (l *List) Render() string {
232+ if len(l.items) == 0 {
233+ return ""
234+ }
235+
236+ slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height)
237+
238+ var lines []string
239+ currentIdx := l.offsetIdx
240+ currentOffset := l.offsetLine
241+
242+ linesNeeded := l.height
243+
244+ for linesNeeded > 0 && currentIdx < len(l.items) {
245+ item := l.getItem(currentIdx)
246+ itemLines := strings.Split(item.content, "\n")
247+ itemHeight := len(itemLines)
248+
249+ if currentOffset < itemHeight {
250+ // Add visible content lines
251+ lines = append(lines, itemLines[currentOffset:]...)
252+
253+ // Add gap if this is not the absolute last visual element (conceptually gaps are between items)
254+ // But in the loop we can just add it and trim later
255+ if l.gap > 0 {
256+ for i := 0; i < l.gap; i++ {
257+ lines = append(lines, "")
258+ }
259+ }
260+ } else {
261+ // offsetLine starts in the gap
262+ gapOffset := currentOffset - itemHeight
263+ gapRemaining := l.gap - gapOffset
264+ if gapRemaining > 0 {
265+ for i := 0; i < gapRemaining; i++ {
266+ lines = append(lines, "")
267+ }
268+ }
269+ }
270+
271+ linesNeeded = l.height - len(lines)
272+ currentIdx++
273+ currentOffset = 0 // Reset offset for subsequent items
274+ }
275+
276+ if len(lines) > l.height {
277+ lines = lines[:l.height]
278+ }
279+
280+ return strings.Join(lines, "\n")
281+}
282+
283+// PrependItems prepends items to the list.
284+func (l *List) PrependItems(items ...Item) {
285+ l.items = append(items, l.items...)
286+
287+ // Shift cache
288+ newCache := make(map[int]renderedItem)
289+ for idx, val := range l.renderedItems {
290+ newCache[idx+len(items)] = val
291+ }
292+ l.renderedItems = newCache
293+
294+ // Shift dirty items
295+ newDirty := make(map[int]struct{})
296+ for idx := range l.dirtyItems {
297+ newDirty[idx+len(items)] = struct{}{}
298+ }
299+ l.dirtyItems = newDirty
300+
301+ // Keep view position relative to the content that was visible
302+ l.offsetIdx += len(items)
303+
304+ // Update selection index if valid
305+ if l.selectedIdx != -1 {
306+ l.selectedIdx += len(items)
307+ }
308+}
309+
310+// AppendItems appends items to the list.
311+func (l *List) AppendItems(items ...Item) {
312+ l.items = append(l.items, items...)
313+}
314+
315+// Focus sets the focus state of the list.
316+func (l *List) Focus() {
317+ l.focused = true
318+}
319+
320+// Blur removes the focus state from the list.
321+func (l *List) Blur() {
322+ l.focused = false
323+}
324+
325+// ScrollToTop scrolls the list to the top.
326+func (l *List) ScrollToTop() {
327+ l.offsetIdx = 0
328+ l.offsetLine = 0
329+}
330+
331+// ScrollToBottom scrolls the list to the bottom.
332+func (l *List) ScrollToBottom() {
333+ if len(l.items) == 0 {
334+ return
335+ }
336+
337+ // Scroll to the last item
338+ var totalHeight int
339+ var i int
340+ for i = len(l.items) - 1; i >= 0; i-- {
341+ item := l.getItem(i)
342+ totalHeight += item.height
343+ if l.gap > 0 && i < len(l.items)-1 {
344+ totalHeight += l.gap
345+ }
346+ if totalHeight >= l.height {
347+ l.offsetIdx = i
348+ l.offsetLine = totalHeight - l.height
349+ break
350+ }
351+ }
352+ if i < 0 {
353+ // All items fit in the viewport
354+ l.offsetIdx = 0
355+ l.offsetLine = 0
356+ }
357+}
358+
359+// ScrollToSelected scrolls the list to the selected item.
360+func (l *List) ScrollToSelected() {
361+ // TODO: Implement me
362+}
363+
364+// SelectedItemInView returns whether the selected item is currently in view.
365+func (l *List) SelectedItemInView() bool {
366+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
367+ return false
368+ }
369+ startIdx, endIdx := l.findVisibleItems()
370+ return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
371+}
372+
373+// SetSelected sets the selected item index in the list.
374+func (l *List) SetSelected(index int) {
375+ if index < 0 || index >= len(l.items) {
376+ l.selectedIdx = -1
377+ } else {
378+ l.selectedIdx = index
379+ }
380+}
381+
382+// SelectPrev selects the previous item in the list.
383+func (l *List) SelectPrev() {
384+ if l.selectedIdx > 0 {
385+ l.selectedIdx--
386+ }
387+}
388+
389+// SelectNext selects the next item in the list.
390+func (l *List) SelectNext() {
391+ if l.selectedIdx < len(l.items)-1 {
392+ l.selectedIdx++
393+ }
394+}
395+
396+// SelectFirst selects the first item in the list.
397+func (l *List) SelectFirst() {
398+ if len(l.items) > 0 {
399+ l.selectedIdx = 0
400+ }
401+}
402+
403+// SelectLast selects the last item in the list.
404+func (l *List) SelectLast() {
405+ if len(l.items) > 0 {
406+ l.selectedIdx = len(l.items) - 1
407+ }
408+}
409+
410+// SelectFirstInView selects the first item currently in view.
411+func (l *List) SelectFirstInView() {
412+ startIdx, _ := l.findVisibleItems()
413+ l.selectedIdx = startIdx
414+}
415+
416+// SelectLastInView selects the last item currently in view.
417+func (l *List) SelectLastInView() {
418+ _, endIdx := l.findVisibleItems()
419+ l.selectedIdx = endIdx
420+}
421+
422+// HandleMouseDown handles mouse down events at the given line in the viewport.
423+func (l *List) HandleMouseDown(x, y int) {
424+}
425+
426+// HandleMouseUp handles mouse up events at the given line in the viewport.
427+func (l *List) HandleMouseUp(x, y int) {
428+}
429+
430+// HandleMouseDrag handles mouse drag events at the given line in the viewport.
431+func (l *List) HandleMouseDrag(x, y int) {
432+}
433+
434+// countLines counts the number of lines in a string.
435+func countLines(s string) int {
436+ if s == "" {
437+ return 0
438+ }
439+ return strings.Count(s, "\n") + 1
440+}
internal/ui/lazylist/list.go.baknew file+413
@@ -0,0 +1,413 @@
1+package lazylist
2+
3+import (
4+ "log/slog"
5+ "strings"
6+)
7+
8+// List represents a list of items that can be lazily rendered. A list is
9+// always rendered like a chat conversation where items are stacked vertically
10+// from top to bottom.
11+type List struct {
12+ // Viewport size
13+ width, height int
14+
15+ // Items in the list
16+ items []Item
17+
18+ // Gap between items (0 or less means no gap)
19+ gap int
20+
21+ // Focus and selection state
22+ focused bool
23+ selectedIdx int // The current selected index -1 means no selection
24+
25+ // Item positioning. If a position exists in the map, it means the item has
26+ // been rendered and measured.
27+ itemPositions map[int]itemPosition
28+
29+ // Rendered content and cache
30+ lines []string
31+ renderedItems map[int]renderedItem
32+ offsetIdx int // Index of the first visible item in the viewport
33+ offsetLine int // The offset line from the start of the offsetIdx item (can be negative)
34+
35+ // Dirty tracking
36+ dirtyItems map[int]struct{}
37+}
38+
39+// renderedItem holds the rendered content and height of an item.
40+type renderedItem struct {
41+ content string
42+ height int
43+}
44+
45+// itemPosition holds the start and end line of an item in the list.
46+type itemPosition struct {
47+ startLine int
48+ endLine int
49+}
50+
51+// Height returns the height of item based on its start and end lines.
52+func (ip itemPosition) Height() int {
53+ return ip.endLine - ip.startLine
54+}
55+
56+// NewList creates a new lazy-loaded list.
57+func NewList(items ...Item) *List {
58+ l := new(List)
59+ l.items = items
60+ l.itemPositions = make(map[int]itemPosition)
61+ l.renderedItems = make(map[int]renderedItem)
62+ l.dirtyItems = make(map[int]struct{})
63+ return l
64+}
65+
66+// SetSize sets the size of the list viewport.
67+func (l *List) SetSize(width, height int) {
68+ if width != l.width {
69+ // Mark all rendered items as dirty if width changes because their
70+ // layout may change.
71+ for idx := range l.itemPositions {
72+ l.dirtyItems[idx] = struct{}{}
73+ }
74+ }
75+ l.width = width
76+ l.height = height
77+}
78+
79+// SetGap sets the gap between items.
80+func (l *List) SetGap(gap int) {
81+ l.gap = gap
82+}
83+
84+// Width returns the width of the list viewport.
85+func (l *List) Width() int {
86+ return l.width
87+}
88+
89+// Height returns the height of the list viewport.
90+func (l *List) Height() int {
91+ return l.height
92+}
93+
94+// Len returns the number of items in the list.
95+func (l *List) Len() int {
96+ return len(l.items)
97+}
98+
99+// renderItem renders the item at the given index and updates its cache and
100+// position.
101+func (l *List) renderItem(idx int) {
102+ if idx < 0 || idx >= len(l.items) {
103+ return
104+ }
105+
106+ item := l.items[idx]
107+ rendered := item.Render(l.width)
108+ height := countLines(rendered)
109+
110+ l.renderedItems[idx] = renderedItem{
111+ content: rendered,
112+ height: height,
113+ }
114+
115+ // Calculate item position
116+ var startLine int
117+ if idx == 0 {
118+ startLine = 0
119+ } else {
120+ prevPos, ok := l.itemPositions[idx-1]
121+ if !ok {
122+ l.renderItem(idx - 1)
123+ prevPos = l.itemPositions[idx-1]
124+ }
125+ startLine = prevPos.endLine
126+ if l.gap > 0 {
127+ startLine += l.gap
128+ }
129+ }
130+ endLine := startLine + height
131+
132+ l.itemPositions[idx] = itemPosition{
133+ startLine: startLine,
134+ endLine: endLine,
135+ }
136+}
137+
138+// ScrollToIndex scrolls the list to the given item index.
139+func (l *List) ScrollToIndex(index int) {
140+ if index < 0 || index >= len(l.items) {
141+ return
142+ }
143+ l.offsetIdx = index
144+ l.offsetLine = 0
145+}
146+
147+// ScrollBy scrolls the list by the given number of lines.
148+func (l *List) ScrollBy(lines int) {
149+ l.offsetLine += lines
150+ if l.offsetIdx <= 0 && l.offsetLine < 0 {
151+ l.offsetIdx = 0
152+ l.offsetLine = 0
153+ return
154+ }
155+
156+ // Adjust offset index and line if needed
157+ for l.offsetLine < 0 && l.offsetIdx > 0 {
158+ // Move up to previous item
159+ l.offsetIdx--
160+ prevPos, ok := l.itemPositions[l.offsetIdx]
161+ if !ok {
162+ l.renderItem(l.offsetIdx)
163+ prevPos = l.itemPositions[l.offsetIdx]
164+ }
165+ l.offsetLine += prevPos.Height()
166+ if l.gap > 0 {
167+ l.offsetLine += l.gap
168+ }
169+ }
170+
171+ for {
172+ currentPos, ok := l.itemPositions[l.offsetIdx]
173+ if !ok {
174+ l.renderItem(l.offsetIdx)
175+ currentPos = l.itemPositions[l.offsetIdx]
176+ }
177+ if l.offsetLine >= currentPos.Height() {
178+ // Move down to next item
179+ l.offsetLine -= currentPos.Height()
180+ if l.gap > 0 {
181+ l.offsetLine -= l.gap
182+ }
183+ l.offsetIdx++
184+ if l.offsetIdx >= len(l.items) {
185+ l.offsetIdx = len(l.items) - 1
186+ l.offsetLine = currentPos.Height() - 1
187+ break
188+ }
189+ } else {
190+ break
191+ }
192+ }
193+}
194+
195+// findVisibleItems finds the range of items that are visible in the viewport.
196+func (l *List) findVisibleItems() (startIdx, endIdx int) {
197+ startIdx = l.offsetIdx
198+ endIdx = startIdx + 1
199+
200+ // Render items until we fill the viewport
201+ visibleHeight := -l.offsetLine
202+ for endIdx < len(l.items) {
203+ pos, ok := l.itemPositions[endIdx-1]
204+ if !ok {
205+ l.renderItem(endIdx - 1)
206+ pos = l.itemPositions[endIdx-1]
207+ }
208+ visibleHeight += pos.Height()
209+ if endIdx-1 < len(l.items)-1 && l.gap > 0 {
210+ visibleHeight += l.gap
211+ }
212+ if visibleHeight >= l.height {
213+ break
214+ }
215+ endIdx++
216+ }
217+
218+ if endIdx > len(l.items)-1 {
219+ endIdx = len(l.items) - 1
220+ }
221+
222+ return startIdx, endIdx
223+}
224+
225+// renderLines renders the items between startIdx and endIdx into lines.
226+func (l *List) renderLines(startIdx, endIdx int) []string {
227+ var lines []string
228+ for idx := startIdx; idx < endIdx+1; idx++ {
229+ rendered, ok := l.renderedItems[idx]
230+ if !ok {
231+ l.renderItem(idx)
232+ rendered = l.renderedItems[idx]
233+ }
234+ itemLines := strings.Split(rendered.content, "\n")
235+ lines = append(lines, itemLines...)
236+ if l.gap > 0 && idx < endIdx {
237+ for i := 0; i < l.gap; i++ {
238+ lines = append(lines, "")
239+ }
240+ }
241+ }
242+ return lines
243+}
244+
245+// Render renders the list and returns the visible lines.
246+func (l *List) Render() string {
247+ viewStartIdx, viewEndIdx := l.findVisibleItems()
248+ slog.Info("Render", "viewStartIdx", viewStartIdx, "viewEndIdx", viewEndIdx, "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine)
249+
250+ for idx := range l.dirtyItems {
251+ if idx >= viewStartIdx && idx <= viewEndIdx {
252+ l.renderItem(idx)
253+ delete(l.dirtyItems, idx)
254+ }
255+ }
256+
257+ lines := l.renderLines(viewStartIdx, viewEndIdx)
258+ for len(lines) < l.height {
259+ viewStartIdx--
260+ if viewStartIdx <= 0 {
261+ break
262+ }
263+
264+ lines = l.renderLines(viewStartIdx, viewEndIdx)
265+ }
266+
267+ if len(lines) > l.height {
268+ lines = lines[:l.height]
269+ }
270+
271+ return strings.Join(lines, "\n")
272+}
273+
274+// PrependItems prepends items to the list.
275+func (l *List) PrependItems(items ...Item) {
276+ l.items = append(items, l.items...)
277+ // Shift existing item positions
278+ newItemPositions := make(map[int]itemPosition)
279+ for idx, pos := range l.itemPositions {
280+ newItemPositions[idx+len(items)] = pos
281+ }
282+ l.itemPositions = newItemPositions
283+
284+ // Mark all items as dirty
285+ for idx := range l.items {
286+ l.dirtyItems[idx] = struct{}{}
287+ }
288+
289+ // Adjust offset index
290+ l.offsetIdx += len(items)
291+}
292+
293+// AppendItems appends items to the list.
294+func (l *List) AppendItems(items ...Item) {
295+ l.items = append(l.items, items...)
296+ for idx := len(l.items) - len(items); idx < len(l.items); idx++ {
297+ l.dirtyItems[idx] = struct{}{}
298+ }
299+}
300+
301+// Focus sets the focus state of the list.
302+func (l *List) Focus() {
303+ l.focused = true
304+}
305+
306+// Blur removes the focus state from the list.
307+func (l *List) Blur() {
308+ l.focused = false
309+}
310+
311+// ScrollToTop scrolls the list to the top.
312+func (l *List) ScrollToTop() {
313+ l.offsetIdx = 0
314+ l.offsetLine = 0
315+}
316+
317+// ScrollToBottom scrolls the list to the bottom.
318+func (l *List) ScrollToBottom() {
319+ l.offsetIdx = len(l.items) - 1
320+ pos, ok := l.itemPositions[l.offsetIdx]
321+ if !ok {
322+ l.renderItem(l.offsetIdx)
323+ pos = l.itemPositions[l.offsetIdx]
324+ }
325+ l.offsetLine = l.height - pos.Height()
326+}
327+
328+// ScrollToSelected scrolls the list to the selected item.
329+func (l *List) ScrollToSelected() {
330+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
331+ return
332+ }
333+ l.offsetIdx = l.selectedIdx
334+ l.offsetLine = 0
335+}
336+
337+// SelectedItemInView returns whether the selected item is currently in view.
338+func (l *List) SelectedItemInView() bool {
339+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
340+ return false
341+ }
342+ startIdx, endIdx := l.findVisibleItems()
343+ return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
344+}
345+
346+// SetSelected sets the selected item index in the list.
347+func (l *List) SetSelected(index int) {
348+ if index < 0 || index >= len(l.items) {
349+ l.selectedIdx = -1
350+ } else {
351+ l.selectedIdx = index
352+ }
353+}
354+
355+// SelectPrev selects the previous item in the list.
356+func (l *List) SelectPrev() {
357+ if l.selectedIdx > 0 {
358+ l.selectedIdx--
359+ }
360+}
361+
362+// SelectNext selects the next item in the list.
363+func (l *List) SelectNext() {
364+ if l.selectedIdx < len(l.items)-1 {
365+ l.selectedIdx++
366+ }
367+}
368+
369+// SelectFirst selects the first item in the list.
370+func (l *List) SelectFirst() {
371+ if len(l.items) > 0 {
372+ l.selectedIdx = 0
373+ }
374+}
375+
376+// SelectLast selects the last item in the list.
377+func (l *List) SelectLast() {
378+ if len(l.items) > 0 {
379+ l.selectedIdx = len(l.items) - 1
380+ }
381+}
382+
383+// SelectFirstInView selects the first item currently in view.
384+func (l *List) SelectFirstInView() {
385+ startIdx, _ := l.findVisibleItems()
386+ l.selectedIdx = startIdx
387+}
388+
389+// SelectLastInView selects the last item currently in view.
390+func (l *List) SelectLastInView() {
391+ _, endIdx := l.findVisibleItems()
392+ l.selectedIdx = endIdx
393+}
394+
395+// HandleMouseDown handles mouse down events at the given line in the viewport.
396+func (l *List) HandleMouseDown(x, y int) {
397+}
398+
399+// HandleMouseUp handles mouse up events at the given line in the viewport.
400+func (l *List) HandleMouseUp(x, y int) {
401+}
402+
403+// HandleMouseDrag handles mouse drag events at the given line in the viewport.
404+func (l *List) HandleMouseDrag(x, y int) {
405+}
406+
407+// countLines counts the number of lines in a string.
408+func countLines(s string) int {
409+ if s == "" {
410+ return 0
411+ }
412+ return strings.Count(s, "\n") + 1
413+}
internal/ui/list/lazylist.gonew file+1007
@@ -0,0 +1,1007 @@
1+package list
2+
3+import (
4+ "strings"
5+
6+ uv "github.com/charmbracelet/ultraviolet"
7+ "github.com/charmbracelet/ultraviolet/screen"
8+)
9+
10+// LazyList is a virtual scrolling list that only renders visible items.
11+// It uses height estimates to avoid expensive renders during initial layout.
12+type LazyList struct {
13+ // Configuration
14+ width, height int
15+
16+ // Data
17+ items []Item
18+
19+ // Focus & Selection
20+ focused bool
21+ selectedIdx int // Currently selected item index (-1 if none)
22+
23+ // Item positioning - tracks measured and estimated positions
24+ itemHeights []itemHeight
25+ totalHeight int // Sum of all item heights (measured or estimated)
26+
27+ // Viewport state
28+ offset int // Scroll offset in lines from top
29+
30+ // Rendered items cache - only visible items are rendered
31+ renderedCache map[int]*renderedItemCache
32+
33+ // Virtual scrolling configuration
34+ defaultEstimate int // Default height estimate for unmeasured items
35+ overscan int // Number of items to render outside viewport for smooth scrolling
36+
37+ // Dirty tracking
38+ needsLayout bool
39+ dirtyItems map[int]bool
40+ dirtyViewport bool // True if we need to re-render viewport
41+
42+ // Mouse state
43+ mouseDown bool
44+ mouseDownItem int
45+ mouseDownX int
46+ mouseDownY int
47+ mouseDragItem int
48+ mouseDragX int
49+ mouseDragY int
50+}
51+
52+// itemHeight tracks the height of an item - either measured or estimated.
53+type itemHeight struct {
54+ height int
55+ measured bool // true if height is actual measurement, false if estimate
56+}
57+
58+// renderedItemCache stores a rendered item's buffer.
59+type renderedItemCache struct {
60+ buffer *uv.ScreenBuffer
61+ height int // Actual measured height after rendering
62+}
63+
64+// NewLazyList creates a new lazy-rendering list.
65+func NewLazyList(items ...Item) *LazyList {
66+ l := &LazyList{
67+ items: items,
68+ itemHeights: make([]itemHeight, len(items)),
69+ renderedCache: make(map[int]*renderedItemCache),
70+ dirtyItems: make(map[int]bool),
71+ selectedIdx: -1,
72+ mouseDownItem: -1,
73+ mouseDragItem: -1,
74+ defaultEstimate: 10, // Conservative estimate: 5 lines per item
75+ overscan: 5, // Render 3 items above/below viewport
76+ needsLayout: true,
77+ dirtyViewport: true,
78+ }
79+
80+ // Initialize all items with estimated heights
81+ for i := range l.items {
82+ l.itemHeights[i] = itemHeight{
83+ height: l.defaultEstimate,
84+ measured: false,
85+ }
86+ }
87+ l.calculateTotalHeight()
88+
89+ return l
90+}
91+
92+// calculateTotalHeight sums all item heights (measured or estimated).
93+func (l *LazyList) calculateTotalHeight() {
94+ l.totalHeight = 0
95+ for _, h := range l.itemHeights {
96+ l.totalHeight += h.height
97+ }
98+}
99+
100+// getItemPosition returns the Y position where an item starts.
101+func (l *LazyList) getItemPosition(idx int) int {
102+ pos := 0
103+ for i := 0; i < idx && i < len(l.itemHeights); i++ {
104+ pos += l.itemHeights[i].height
105+ }
106+ return pos
107+}
108+
109+// findVisibleItems returns the range of items that are visible or near the viewport.
110+func (l *LazyList) findVisibleItems() (firstIdx, lastIdx int) {
111+ if len(l.items) == 0 {
112+ return 0, 0
113+ }
114+
115+ viewportStart := l.offset
116+ viewportEnd := l.offset + l.height
117+
118+ // Find first visible item
119+ firstIdx = -1
120+ pos := 0
121+ for i := 0; i < len(l.items); i++ {
122+ itemEnd := pos + l.itemHeights[i].height
123+ if itemEnd > viewportStart {
124+ firstIdx = i
125+ break
126+ }
127+ pos = itemEnd
128+ }
129+
130+ // Apply overscan above
131+ firstIdx = max(0, firstIdx-l.overscan)
132+
133+ // Find last visible item
134+ lastIdx = firstIdx
135+ pos = l.getItemPosition(firstIdx)
136+ for i := firstIdx; i < len(l.items); i++ {
137+ if pos >= viewportEnd {
138+ break
139+ }
140+ pos += l.itemHeights[i].height
141+ lastIdx = i
142+ }
143+
144+ // Apply overscan below
145+ lastIdx = min(len(l.items)-1, lastIdx+l.overscan)
146+
147+ return firstIdx, lastIdx
148+}
149+
150+// renderItem renders a single item and caches it.
151+// Returns the actual measured height.
152+func (l *LazyList) renderItem(idx int) int {
153+ if idx < 0 || idx >= len(l.items) {
154+ return 0
155+ }
156+
157+ item := l.items[idx]
158+
159+ // Measure actual height
160+ actualHeight := item.Height(l.width)
161+
162+ // Create buffer and render
163+ buf := uv.NewScreenBuffer(l.width, actualHeight)
164+ area := uv.Rect(0, 0, l.width, actualHeight)
165+ item.Draw(&buf, area)
166+
167+ // Cache rendered item
168+ l.renderedCache[idx] = &renderedItemCache{
169+ buffer: &buf,
170+ height: actualHeight,
171+ }
172+
173+ // Update height if it was estimated or changed
174+ if !l.itemHeights[idx].measured || l.itemHeights[idx].height != actualHeight {
175+ oldHeight := l.itemHeights[idx].height
176+ l.itemHeights[idx] = itemHeight{
177+ height: actualHeight,
178+ measured: true,
179+ }
180+
181+ // Adjust total height
182+ l.totalHeight += actualHeight - oldHeight
183+ }
184+
185+ return actualHeight
186+}
187+
188+// Draw implements uv.Drawable.
189+func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) {
190+ if area.Dx() <= 0 || area.Dy() <= 0 {
191+ return
192+ }
193+
194+ widthChanged := l.width != area.Dx()
195+ heightChanged := l.height != area.Dy()
196+
197+ l.width = area.Dx()
198+ l.height = area.Dy()
199+
200+ // Width changes invalidate all cached renders
201+ if widthChanged {
202+ l.renderedCache = make(map[int]*renderedItemCache)
203+ // Mark all heights as needing remeasurement
204+ for i := range l.itemHeights {
205+ l.itemHeights[i].measured = false
206+ l.itemHeights[i].height = l.defaultEstimate
207+ }
208+ l.calculateTotalHeight()
209+ l.needsLayout = true
210+ l.dirtyViewport = true
211+ }
212+
213+ if heightChanged {
214+ l.clampOffset()
215+ l.dirtyViewport = true
216+ }
217+
218+ if len(l.items) == 0 {
219+ screen.ClearArea(scr, area)
220+ return
221+ }
222+
223+ // Find visible items based on current estimates
224+ firstIdx, lastIdx := l.findVisibleItems()
225+
226+ // Track the first visible item's position to maintain stability
227+ // Only stabilize if we're not at the top boundary
228+ stabilizeIdx := -1
229+ stabilizeY := 0
230+ if l.offset > 0 {
231+ for i := firstIdx; i <= lastIdx; i++ {
232+ itemPos := l.getItemPosition(i)
233+ if itemPos >= l.offset {
234+ stabilizeIdx = i
235+ stabilizeY = itemPos
236+ break
237+ }
238+ }
239+ }
240+
241+ // Track if any heights changed during rendering
242+ heightsChanged := false
243+
244+ // Render visible items that aren't cached (measurement pass)
245+ for i := firstIdx; i <= lastIdx; i++ {
246+ if _, cached := l.renderedCache[i]; !cached {
247+ oldHeight := l.itemHeights[i].height
248+ l.renderItem(i)
249+ if l.itemHeights[i].height != oldHeight {
250+ heightsChanged = true
251+ }
252+ } else if l.dirtyItems[i] {
253+ // Re-render dirty items
254+ oldHeight := l.itemHeights[i].height
255+ l.renderItem(i)
256+ delete(l.dirtyItems, i)
257+ if l.itemHeights[i].height != oldHeight {
258+ heightsChanged = true
259+ }
260+ }
261+ }
262+
263+ // If heights changed, adjust offset to keep stabilization point stable
264+ if heightsChanged && stabilizeIdx >= 0 {
265+ newStabilizeY := l.getItemPosition(stabilizeIdx)
266+ offsetDelta := newStabilizeY - stabilizeY
267+
268+ // Adjust offset to maintain visual stability
269+ l.offset += offsetDelta
270+ l.clampOffset()
271+
272+ // Re-find visible items with adjusted positions
273+ firstIdx, lastIdx = l.findVisibleItems()
274+
275+ // Render any newly visible items after position adjustments
276+ for i := firstIdx; i <= lastIdx; i++ {
277+ if _, cached := l.renderedCache[i]; !cached {
278+ l.renderItem(i)
279+ }
280+ }
281+ }
282+
283+ // Clear old cache entries outside visible range
284+ if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 {
285+ l.pruneCache(firstIdx, lastIdx)
286+ }
287+
288+ // Composite visible items into viewport with stable positions
289+ l.drawViewport(scr, area, firstIdx, lastIdx)
290+
291+ l.dirtyViewport = false
292+ l.needsLayout = false
293+}
294+
295+// drawViewport composites visible items into the screen.
296+func (l *LazyList) drawViewport(scr uv.Screen, area uv.Rectangle, firstIdx, lastIdx int) {
297+ screen.ClearArea(scr, area)
298+
299+ itemStartY := l.getItemPosition(firstIdx)
300+
301+ for i := firstIdx; i <= lastIdx; i++ {
302+ cached, ok := l.renderedCache[i]
303+ if !ok {
304+ continue
305+ }
306+
307+ // Calculate where this item appears in viewport
308+ itemY := itemStartY - l.offset
309+ itemHeight := cached.height
310+
311+ // Skip if entirely above viewport
312+ if itemY+itemHeight < 0 {
313+ itemStartY += itemHeight
314+ continue
315+ }
316+
317+ // Stop if entirely below viewport
318+ if itemY >= l.height {
319+ break
320+ }
321+
322+ // Calculate visible portion of item
323+ srcStartY := 0
324+ dstStartY := itemY
325+
326+ if itemY < 0 {
327+ // Item starts above viewport
328+ srcStartY = -itemY
329+ dstStartY = 0
330+ }
331+
332+ srcEndY := srcStartY + (l.height - dstStartY)
333+ if srcEndY > itemHeight {
334+ srcEndY = itemHeight
335+ }
336+
337+ // Copy visible lines from item buffer to screen
338+ buf := cached.buffer.Buffer
339+ destY := area.Min.Y + dstStartY
340+
341+ for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
342+ if srcY >= buf.Height() {
343+ break
344+ }
345+
346+ line := buf.Line(srcY)
347+ destX := area.Min.X
348+
349+ for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
350+ cell := line.At(x)
351+ scr.SetCell(destX, destY, cell)
352+ destX++
353+ }
354+ destY++
355+ }
356+
357+ itemStartY += itemHeight
358+ }
359+}
360+
361+// pruneCache removes cached items outside the visible range.
362+func (l *LazyList) pruneCache(firstIdx, lastIdx int) {
363+ keepStart := max(0, firstIdx-l.overscan*2)
364+ keepEnd := min(len(l.items)-1, lastIdx+l.overscan*2)
365+
366+ for idx := range l.renderedCache {
367+ if idx < keepStart || idx > keepEnd {
368+ delete(l.renderedCache, idx)
369+ }
370+ }
371+}
372+
373+// clampOffset ensures scroll offset stays within valid bounds.
374+func (l *LazyList) clampOffset() {
375+ maxOffset := l.totalHeight - l.height
376+ if maxOffset < 0 {
377+ maxOffset = 0
378+ }
379+
380+ if l.offset > maxOffset {
381+ l.offset = maxOffset
382+ }
383+ if l.offset < 0 {
384+ l.offset = 0
385+ }
386+}
387+
388+// SetItems replaces all items in the list.
389+func (l *LazyList) SetItems(items []Item) {
390+ l.items = items
391+ l.itemHeights = make([]itemHeight, len(items))
392+ l.renderedCache = make(map[int]*renderedItemCache)
393+ l.dirtyItems = make(map[int]bool)
394+
395+ // Initialize with estimates
396+ for i := range l.items {
397+ l.itemHeights[i] = itemHeight{
398+ height: l.defaultEstimate,
399+ measured: false,
400+ }
401+ }
402+ l.calculateTotalHeight()
403+ l.needsLayout = true
404+ l.dirtyViewport = true
405+}
406+
407+// AppendItem adds an item to the end of the list.
408+func (l *LazyList) AppendItem(item Item) {
409+ l.items = append(l.items, item)
410+ l.itemHeights = append(l.itemHeights, itemHeight{
411+ height: l.defaultEstimate,
412+ measured: false,
413+ })
414+ l.totalHeight += l.defaultEstimate
415+ l.dirtyViewport = true
416+}
417+
418+// PrependItem adds an item to the beginning of the list.
419+func (l *LazyList) PrependItem(item Item) {
420+ l.items = append([]Item{item}, l.items...)
421+ l.itemHeights = append([]itemHeight{{
422+ height: l.defaultEstimate,
423+ measured: false,
424+ }}, l.itemHeights...)
425+
426+ // Shift cache indices
427+ newCache := make(map[int]*renderedItemCache)
428+ for idx, cached := range l.renderedCache {
429+ newCache[idx+1] = cached
430+ }
431+ l.renderedCache = newCache
432+
433+ l.totalHeight += l.defaultEstimate
434+ l.offset += l.defaultEstimate // Maintain scroll position
435+ l.dirtyViewport = true
436+}
437+
438+// UpdateItem replaces an item at the given index.
439+func (l *LazyList) UpdateItem(idx int, item Item) {
440+ if idx < 0 || idx >= len(l.items) {
441+ return
442+ }
443+
444+ l.items[idx] = item
445+ delete(l.renderedCache, idx)
446+ l.dirtyItems[idx] = true
447+ // Keep height estimate - will remeasure on next render
448+ l.dirtyViewport = true
449+}
450+
451+// ScrollBy scrolls by the given number of lines.
452+func (l *LazyList) ScrollBy(delta int) {
453+ l.offset += delta
454+ l.clampOffset()
455+ l.dirtyViewport = true
456+}
457+
458+// ScrollToBottom scrolls to the end of the list.
459+func (l *LazyList) ScrollToBottom() {
460+ l.offset = l.totalHeight - l.height
461+ l.clampOffset()
462+ l.dirtyViewport = true
463+}
464+
465+// ScrollToTop scrolls to the beginning of the list.
466+func (l *LazyList) ScrollToTop() {
467+ l.offset = 0
468+ l.dirtyViewport = true
469+}
470+
471+// Len returns the number of items in the list.
472+func (l *LazyList) Len() int {
473+ return len(l.items)
474+}
475+
476+// Focus sets the list as focused.
477+func (l *LazyList) Focus() {
478+ l.focused = true
479+ l.focusSelectedItem()
480+ l.dirtyViewport = true
481+}
482+
483+// Blur removes focus from the list.
484+func (l *LazyList) Blur() {
485+ l.focused = false
486+ l.blurSelectedItem()
487+ l.dirtyViewport = true
488+}
489+
490+// focusSelectedItem focuses the currently selected item if it's focusable.
491+func (l *LazyList) focusSelectedItem() {
492+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
493+ return
494+ }
495+
496+ item := l.items[l.selectedIdx]
497+ if f, ok := item.(Focusable); ok {
498+ f.Focus()
499+ delete(l.renderedCache, l.selectedIdx)
500+ l.dirtyItems[l.selectedIdx] = true
501+ }
502+}
503+
504+// blurSelectedItem blurs the currently selected item if it's focusable.
505+func (l *LazyList) blurSelectedItem() {
506+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
507+ return
508+ }
509+
510+ item := l.items[l.selectedIdx]
511+ if f, ok := item.(Focusable); ok {
512+ f.Blur()
513+ delete(l.renderedCache, l.selectedIdx)
514+ l.dirtyItems[l.selectedIdx] = true
515+ }
516+}
517+
518+// IsFocused returns whether the list is focused.
519+func (l *LazyList) IsFocused() bool {
520+ return l.focused
521+}
522+
523+// Width returns the current viewport width.
524+func (l *LazyList) Width() int {
525+ return l.width
526+}
527+
528+// Height returns the current viewport height.
529+func (l *LazyList) Height() int {
530+ return l.height
531+}
532+
533+// SetSize sets the viewport size explicitly.
534+// This is useful when you want to pre-configure the list size before drawing.
535+func (l *LazyList) SetSize(width, height int) {
536+ widthChanged := l.width != width
537+ heightChanged := l.height != height
538+
539+ l.width = width
540+ l.height = height
541+
542+ // Width changes invalidate all cached renders
543+ if widthChanged && width > 0 {
544+ l.renderedCache = make(map[int]*renderedItemCache)
545+ // Mark all heights as needing remeasurement
546+ for i := range l.itemHeights {
547+ l.itemHeights[i].measured = false
548+ l.itemHeights[i].height = l.defaultEstimate
549+ }
550+ l.calculateTotalHeight()
551+ l.needsLayout = true
552+ l.dirtyViewport = true
553+ }
554+
555+ if heightChanged && height > 0 {
556+ l.clampOffset()
557+ l.dirtyViewport = true
558+ }
559+
560+ // After cache invalidation, scroll to selected item or bottom
561+ if widthChanged || heightChanged {
562+ if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
563+ // Scroll to selected item
564+ l.ScrollToSelected()
565+ } else if len(l.items) > 0 {
566+ // No selection - scroll to bottom
567+ l.ScrollToBottom()
568+ }
569+ }
570+}
571+
572+// Selection methods
573+
574+// Selected returns the currently selected item index (-1 if none).
575+func (l *LazyList) Selected() int {
576+ return l.selectedIdx
577+}
578+
579+// SetSelected sets the selected item by index.
580+func (l *LazyList) SetSelected(idx int) {
581+ if idx < -1 || idx >= len(l.items) {
582+ return
583+ }
584+
585+ if l.selectedIdx != idx {
586+ prevIdx := l.selectedIdx
587+ l.selectedIdx = idx
588+ l.dirtyViewport = true
589+
590+ // Update focus states if list is focused.
591+ if l.focused {
592+ // Blur previously selected item.
593+ if prevIdx >= 0 && prevIdx < len(l.items) {
594+ if f, ok := l.items[prevIdx].(Focusable); ok {
595+ f.Blur()
596+ delete(l.renderedCache, prevIdx)
597+ l.dirtyItems[prevIdx] = true
598+ }
599+ }
600+
601+ // Focus newly selected item.
602+ if idx >= 0 && idx < len(l.items) {
603+ if f, ok := l.items[idx].(Focusable); ok {
604+ f.Focus()
605+ delete(l.renderedCache, idx)
606+ l.dirtyItems[idx] = true
607+ }
608+ }
609+ }
610+ }
611+}
612+
613+// SelectPrev selects the previous item.
614+func (l *LazyList) SelectPrev() {
615+ if len(l.items) == 0 {
616+ return
617+ }
618+
619+ if l.selectedIdx <= 0 {
620+ l.selectedIdx = 0
621+ } else {
622+ l.selectedIdx--
623+ }
624+
625+ l.dirtyViewport = true
626+}
627+
628+// SelectNext selects the next item.
629+func (l *LazyList) SelectNext() {
630+ if len(l.items) == 0 {
631+ return
632+ }
633+
634+ if l.selectedIdx < 0 {
635+ l.selectedIdx = 0
636+ } else if l.selectedIdx < len(l.items)-1 {
637+ l.selectedIdx++
638+ }
639+
640+ l.dirtyViewport = true
641+}
642+
643+// SelectFirst selects the first item.
644+func (l *LazyList) SelectFirst() {
645+ if len(l.items) > 0 {
646+ l.selectedIdx = 0
647+ l.dirtyViewport = true
648+ }
649+}
650+
651+// SelectLast selects the last item.
652+func (l *LazyList) SelectLast() {
653+ if len(l.items) > 0 {
654+ l.selectedIdx = len(l.items) - 1
655+ l.dirtyViewport = true
656+ }
657+}
658+
659+// SelectFirstInView selects the first visible item in the viewport.
660+func (l *LazyList) SelectFirstInView() {
661+ if len(l.items) == 0 {
662+ return
663+ }
664+
665+ firstIdx, _ := l.findVisibleItems()
666+ l.selectedIdx = firstIdx
667+ l.dirtyViewport = true
668+}
669+
670+// SelectLastInView selects the last visible item in the viewport.
671+func (l *LazyList) SelectLastInView() {
672+ if len(l.items) == 0 {
673+ return
674+ }
675+
676+ _, lastIdx := l.findVisibleItems()
677+ l.selectedIdx = lastIdx
678+ l.dirtyViewport = true
679+}
680+
681+// SelectedItemInView returns whether the selected item is visible in the viewport.
682+func (l *LazyList) SelectedItemInView() bool {
683+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
684+ return false
685+ }
686+
687+ firstIdx, lastIdx := l.findVisibleItems()
688+ return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx
689+}
690+
691+// ScrollToSelected scrolls the viewport to ensure the selected item is visible.
692+func (l *LazyList) ScrollToSelected() {
693+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
694+ return
695+ }
696+
697+ // Get selected item position
698+ itemY := l.getItemPosition(l.selectedIdx)
699+ itemHeight := l.itemHeights[l.selectedIdx].height
700+
701+ // Check if item is above viewport
702+ if itemY < l.offset {
703+ l.offset = itemY
704+ l.dirtyViewport = true
705+ return
706+ }
707+
708+ // Check if item is below viewport
709+ itemBottom := itemY + itemHeight
710+ viewportBottom := l.offset + l.height
711+
712+ if itemBottom > viewportBottom {
713+ // Scroll so item bottom is at viewport bottom
714+ l.offset = itemBottom - l.height
715+ l.clampOffset()
716+ l.dirtyViewport = true
717+ }
718+}
719+
720+// Mouse interaction methods
721+
722+// HandleMouseDown handles mouse button down events.
723+// Returns true if the event was handled.
724+func (l *LazyList) HandleMouseDown(x, y int) bool {
725+ if x < 0 || y < 0 || x >= l.width || y >= l.height {
726+ return false
727+ }
728+
729+ // Find which item was clicked
730+ clickY := l.offset + y
731+ itemIdx := l.findItemAtY(clickY)
732+
733+ if itemIdx < 0 {
734+ return false
735+ }
736+
737+ // Calculate item-relative Y position.
738+ itemY := clickY - l.getItemPosition(itemIdx)
739+
740+ l.mouseDown = true
741+ l.mouseDownItem = itemIdx
742+ l.mouseDownX = x
743+ l.mouseDownY = itemY
744+ l.mouseDragItem = itemIdx
745+ l.mouseDragX = x
746+ l.mouseDragY = itemY
747+
748+ // Select the clicked item
749+ l.SetSelected(itemIdx)
750+
751+ return true
752+}
753+
754+// HandleMouseDrag handles mouse drag events.
755+func (l *LazyList) HandleMouseDrag(x, y int) {
756+ if !l.mouseDown {
757+ return
758+ }
759+
760+ // Find item under cursor
761+ if y >= 0 && y < l.height {
762+ dragY := l.offset + y
763+ itemIdx := l.findItemAtY(dragY)
764+ if itemIdx >= 0 {
765+ l.mouseDragItem = itemIdx
766+ // Calculate item-relative Y position.
767+ l.mouseDragY = dragY - l.getItemPosition(itemIdx)
768+ l.mouseDragX = x
769+ }
770+ }
771+
772+ // Update highlight if item supports it.
773+ l.updateHighlight()
774+}
775+
776+// HandleMouseUp handles mouse button up events.
777+func (l *LazyList) HandleMouseUp(x, y int) {
778+ if !l.mouseDown {
779+ return
780+ }
781+
782+ l.mouseDown = false
783+
784+ // Final highlight update.
785+ l.updateHighlight()
786+}
787+
788+// findItemAtY finds the item index at the given Y coordinate (in content space, not viewport).
789+func (l *LazyList) findItemAtY(y int) int {
790+ if y < 0 || len(l.items) == 0 {
791+ return -1
792+ }
793+
794+ pos := 0
795+ for i := 0; i < len(l.items); i++ {
796+ itemHeight := l.itemHeights[i].height
797+ if y >= pos && y < pos+itemHeight {
798+ return i
799+ }
800+ pos += itemHeight
801+ }
802+
803+ return -1
804+}
805+
806+// updateHighlight updates the highlight range for highlightable items.
807+// Supports highlighting within a single item and respects drag direction.
808+func (l *LazyList) updateHighlight() {
809+ if l.mouseDownItem < 0 {
810+ return
811+ }
812+
813+ // Get start and end item indices.
814+ downItemIdx := l.mouseDownItem
815+ dragItemIdx := l.mouseDragItem
816+
817+ // Determine selection direction.
818+ draggingDown := dragItemIdx > downItemIdx ||
819+ (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
820+ (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
821+
822+ // Determine actual start and end based on direction.
823+ var startItemIdx, endItemIdx int
824+ var startLine, startCol, endLine, endCol int
825+
826+ if draggingDown {
827+ // Normal forward selection.
828+ startItemIdx = downItemIdx
829+ endItemIdx = dragItemIdx
830+ startLine = l.mouseDownY
831+ startCol = l.mouseDownX
832+ endLine = l.mouseDragY
833+ endCol = l.mouseDragX
834+ } else {
835+ // Backward selection (dragging up).
836+ startItemIdx = dragItemIdx
837+ endItemIdx = downItemIdx
838+ startLine = l.mouseDragY
839+ startCol = l.mouseDragX
840+ endLine = l.mouseDownY
841+ endCol = l.mouseDownX
842+ }
843+
844+ // Clear all highlights first.
845+ for i, item := range l.items {
846+ if h, ok := item.(Highlightable); ok {
847+ h.SetHighlight(-1, -1, -1, -1)
848+ delete(l.renderedCache, i)
849+ l.dirtyItems[i] = true
850+ }
851+ }
852+
853+ // Highlight all items in range.
854+ for idx := startItemIdx; idx <= endItemIdx; idx++ {
855+ item, ok := l.items[idx].(Highlightable)
856+ if !ok {
857+ continue
858+ }
859+
860+ if idx == startItemIdx && idx == endItemIdx {
861+ // Single item selection.
862+ item.SetHighlight(startLine, startCol, endLine, endCol)
863+ } else if idx == startItemIdx {
864+ // First item - from start position to end of item.
865+ itemHeight := l.itemHeights[idx].height
866+ item.SetHighlight(startLine, startCol, itemHeight-1, 9999) // 9999 = end of line
867+ } else if idx == endItemIdx {
868+ // Last item - from start of item to end position.
869+ item.SetHighlight(0, 0, endLine, endCol)
870+ } else {
871+ // Middle item - fully highlighted.
872+ itemHeight := l.itemHeights[idx].height
873+ item.SetHighlight(0, 0, itemHeight-1, 9999)
874+ }
875+
876+ delete(l.renderedCache, idx)
877+ l.dirtyItems[idx] = true
878+ }
879+}
880+
881+// ClearHighlight clears any active text highlighting.
882+func (l *LazyList) ClearHighlight() {
883+ for i, item := range l.items {
884+ if h, ok := item.(Highlightable); ok {
885+ h.SetHighlight(-1, -1, -1, -1)
886+ delete(l.renderedCache, i)
887+ l.dirtyItems[i] = true
888+ }
889+ }
890+ l.mouseDownItem = -1
891+ l.mouseDragItem = -1
892+}
893+
894+// GetHighlightedText returns the plain text content of all highlighted regions
895+// across items, without any styling. Returns empty string if no highlights exist.
896+func (l *LazyList) GetHighlightedText() string {
897+ var result strings.Builder
898+
899+ // Iterate through items to find highlighted ones.
900+ for i, item := range l.items {
901+ h, ok := item.(Highlightable)
902+ if !ok {
903+ continue
904+ }
905+
906+ startLine, startCol, endLine, endCol := h.GetHighlight()
907+ if startLine < 0 {
908+ continue
909+ }
910+
911+ // Ensure item is rendered so we can access its buffer.
912+ if _, ok := l.renderedCache[i]; !ok {
913+ l.renderItem(i)
914+ }
915+
916+ cached := l.renderedCache[i]
917+ if cached == nil || cached.buffer == nil {
918+ continue
919+ }
920+
921+ buf := cached.buffer
922+ itemHeight := cached.height
923+
924+ // Extract text from highlighted region in item buffer.
925+ for y := startLine; y <= endLine && y < itemHeight; y++ {
926+ if y >= buf.Height() {
927+ break
928+ }
929+
930+ line := buf.Line(y)
931+
932+ // Determine column range for this line.
933+ colStart := 0
934+ if y == startLine {
935+ colStart = startCol
936+ }
937+
938+ colEnd := len(line)
939+ if y == endLine {
940+ colEnd = min(endCol, len(line))
941+ }
942+
943+ // Track last non-empty position to trim trailing spaces.
944+ lastContentX := -1
945+ for x := colStart; x < colEnd && x < len(line); x++ {
946+ cell := line.At(x)
947+ if cell == nil || cell.IsZero() {
948+ continue
949+ }
950+ if cell.Content != "" && cell.Content != " " {
951+ lastContentX = x
952+ }
953+ }
954+
955+ // Extract text from cells, up to last content.
956+ endX := colEnd
957+ if lastContentX >= 0 {
958+ endX = lastContentX + 1
959+ }
960+
961+ for x := colStart; x < endX && x < len(line); x++ {
962+ cell := line.At(x)
963+ if cell != nil && !cell.IsZero() {
964+ result.WriteString(cell.Content)
965+ }
966+ }
967+
968+ // Add newline if not the last line.
969+ if y < endLine {
970+ result.WriteString("\n")
971+ }
972+ }
973+
974+ // Add newline between items if this isn't the last highlighted item.
975+ if i < len(l.items)-1 {
976+ nextHasHighlight := false
977+ for j := i + 1; j < len(l.items); j++ {
978+ if h, ok := l.items[j].(Highlightable); ok {
979+ s, _, _, _ := h.GetHighlight()
980+ if s >= 0 {
981+ nextHasHighlight = true
982+ break
983+ }
984+ }
985+ }
986+ if nextHasHighlight {
987+ result.WriteString("\n")
988+ }
989+ }
990+ }
991+
992+ return result.String()
993+}
994+
995+func min(a, b int) int {
996+ if a < b {
997+ return a
998+ }
999+ return b
1000+}
1001+
1002+func max(a, b int) int {
1003+ if a > b {
1004+ return a
1005+ }
1006+ return b
1007+}
internal/ui/list/simplelist.gonew file+972
@@ -0,0 +1,972 @@
1+package list
2+
3+import (
4+ "strings"
5+
6+ tea "charm.land/bubbletea/v2"
7+ "charm.land/lipgloss/v2"
8+ uv "github.com/charmbracelet/ultraviolet"
9+ "github.com/charmbracelet/x/exp/ordered"
10+)
11+
12+const maxGapSize = 100
13+
14+var newlineBuffer = strings.Repeat("\n", maxGapSize)
15+
16+// SimpleList is a string-based list with virtual scrolling behavior.
17+// Based on exp/list but simplified for our needs.
18+type SimpleList struct {
19+ // Viewport dimensions.
20+ width, height int
21+
22+ // Scroll offset (in lines from top).
23+ offset int
24+
25+ // Items.
26+ items []Item
27+ itemIDs map[string]int // ID -> index mapping
28+
29+ // Rendered content (all items stacked).
30+ rendered string
31+ renderedHeight int // Total height of rendered content in lines
32+ lineOffsets []int // Byte offsets for each line (for fast slicing)
33+
34+ // Rendered item metadata.
35+ renderedItems map[string]renderedItem
36+
37+ // Selection.
38+ selectedIdx int
39+ focused bool
40+
41+ // Focus tracking.
42+ prevSelectedIdx int
43+
44+ // Mouse/highlight state.
45+ mouseDown bool
46+ mouseDownItem int
47+ mouseDownX int
48+ mouseDownY int // viewport-relative Y
49+ mouseDragItem int
50+ mouseDragX int
51+ mouseDragY int // viewport-relative Y
52+ selectionStartLine int
53+ selectionStartCol int
54+ selectionEndLine int
55+ selectionEndCol int
56+
57+ // Configuration.
58+ gap int // Gap between items in lines
59+}
60+
61+type renderedItem struct {
62+ view string
63+ height int
64+ start int // Start line in rendered content
65+ end int // End line in rendered content
66+}
67+
68+// NewSimpleList creates a new simple list.
69+func NewSimpleList(items ...Item) *SimpleList {
70+ l := &SimpleList{
71+ items: items,
72+ itemIDs: make(map[string]int, len(items)),
73+ renderedItems: make(map[string]renderedItem),
74+ selectedIdx: -1,
75+ prevSelectedIdx: -1,
76+ gap: 0,
77+ selectionStartLine: -1,
78+ selectionStartCol: -1,
79+ selectionEndLine: -1,
80+ selectionEndCol: -1,
81+ }
82+
83+ // Build ID map.
84+ for i, item := range items {
85+ if idItem, ok := item.(interface{ ID() string }); ok {
86+ l.itemIDs[idItem.ID()] = i
87+ }
88+ }
89+
90+ return l
91+}
92+
93+// Init initializes the list (Bubbletea lifecycle).
94+func (l *SimpleList) Init() tea.Cmd {
95+ return l.render()
96+}
97+
98+// Update handles messages (Bubbletea lifecycle).
99+func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) {
100+ return l, nil
101+}
102+
103+// View returns the visible viewport (Bubbletea lifecycle).
104+func (l *SimpleList) View() string {
105+ if l.height <= 0 || l.width <= 0 {
106+ return ""
107+ }
108+
109+ start, end := l.viewPosition()
110+ viewStart := max(0, start)
111+ viewEnd := end
112+
113+ if viewStart > viewEnd {
114+ return ""
115+ }
116+
117+ view := l.getLines(viewStart, viewEnd)
118+
119+ // Apply width/height constraints.
120+ view = lipgloss.NewStyle().
121+ Height(l.height).
122+ Width(l.width).
123+ Render(view)
124+
125+ // Apply highlighting if active.
126+ if l.hasSelection() {
127+ return l.renderSelection(view)
128+ }
129+
130+ return view
131+}
132+
133+// viewPosition returns the start and end line indices for the viewport.
134+func (l *SimpleList) viewPosition() (int, int) {
135+ start := max(0, l.offset)
136+ end := min(l.offset+l.height-1, l.renderedHeight-1)
137+ start = min(start, end)
138+ return start, end
139+}
140+
141+// getLines returns lines [start, end] from rendered content.
142+func (l *SimpleList) getLines(start, end int) string {
143+ if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
144+ return ""
145+ }
146+
147+ if end >= len(l.lineOffsets) {
148+ end = len(l.lineOffsets) - 1
149+ }
150+ if start > end {
151+ return ""
152+ }
153+
154+ startOffset := l.lineOffsets[start]
155+ var endOffset int
156+ if end+1 < len(l.lineOffsets) {
157+ endOffset = l.lineOffsets[end+1] - 1 // Exclude newline
158+ } else {
159+ endOffset = len(l.rendered)
160+ }
161+
162+ if startOffset >= len(l.rendered) {
163+ return ""
164+ }
165+ endOffset = min(endOffset, len(l.rendered))
166+
167+ return l.rendered[startOffset:endOffset]
168+}
169+
170+// render rebuilds the rendered content from all items.
171+func (l *SimpleList) render() tea.Cmd {
172+ if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
173+ return nil
174+ }
175+
176+ // Set default selection if none.
177+ if l.selectedIdx < 0 && len(l.items) > 0 {
178+ l.selectedIdx = 0
179+ }
180+
181+ // Handle focus changes.
182+ var focusCmd tea.Cmd
183+ if l.focused {
184+ focusCmd = l.focusSelectedItem()
185+ } else {
186+ focusCmd = l.blurSelectedItem()
187+ }
188+
189+ // Render all items.
190+ var b strings.Builder
191+ currentLine := 0
192+
193+ for i, item := range l.items {
194+ // Render item.
195+ view := l.renderItem(item)
196+ height := lipgloss.Height(view)
197+
198+ // Store metadata.
199+ rItem := renderedItem{
200+ view: view,
201+ height: height,
202+ start: currentLine,
203+ end: currentLine + height - 1,
204+ }
205+
206+ if idItem, ok := item.(interface{ ID() string }); ok {
207+ l.renderedItems[idItem.ID()] = rItem
208+ }
209+
210+ // Append to rendered content.
211+ b.WriteString(view)
212+
213+ // Add gap after item (except last).
214+ gap := l.gap
215+ if i == len(l.items)-1 {
216+ gap = 0
217+ }
218+
219+ if gap > 0 {
220+ if gap <= maxGapSize {
221+ b.WriteString(newlineBuffer[:gap])
222+ } else {
223+ b.WriteString(strings.Repeat("\n", gap))
224+ }
225+ }
226+
227+ currentLine += height + gap
228+ }
229+
230+ l.setRendered(b.String())
231+
232+ // Scroll to selected item.
233+ if l.focused && l.selectedIdx >= 0 {
234+ l.scrollToSelection()
235+ }
236+
237+ return focusCmd
238+}
239+
240+// renderItem renders a single item.
241+func (l *SimpleList) renderItem(item Item) string {
242+ // Create a buffer for the item.
243+ buf := uv.NewScreenBuffer(l.width, 1000) // Max height
244+ area := uv.Rect(0, 0, l.width, 1000)
245+ item.Draw(&buf, area)
246+
247+ // Find actual height.
248+ height := l.measureBufferHeight(&buf)
249+ if height == 0 {
250+ height = 1
251+ }
252+
253+ // Render to string.
254+ return buf.Render()
255+}
256+
257+// measureBufferHeight finds the actual content height in a buffer.
258+func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int {
259+ height := buf.Height()
260+
261+ // Scan from bottom up to find last non-empty line.
262+ for y := height - 1; y >= 0; y-- {
263+ line := buf.Line(y)
264+ if l.lineHasContent(line) {
265+ return y + 1
266+ }
267+ }
268+
269+ return 0
270+}
271+
272+// lineHasContent checks if a line has any non-empty cells.
273+func (l *SimpleList) lineHasContent(line uv.Line) bool {
274+ for x := 0; x < len(line); x++ {
275+ cell := line.At(x)
276+ if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " {
277+ return true
278+ }
279+ }
280+ return false
281+}
282+
283+// setRendered updates the rendered content and caches line offsets.
284+func (l *SimpleList) setRendered(rendered string) {
285+ l.rendered = rendered
286+ l.renderedHeight = lipgloss.Height(rendered)
287+
288+ // Build line offset cache.
289+ if len(rendered) > 0 {
290+ l.lineOffsets = make([]int, 0, l.renderedHeight)
291+ l.lineOffsets = append(l.lineOffsets, 0)
292+
293+ offset := 0
294+ for {
295+ idx := strings.IndexByte(rendered[offset:], '\n')
296+ if idx == -1 {
297+ break
298+ }
299+ offset += idx + 1
300+ l.lineOffsets = append(l.lineOffsets, offset)
301+ }
302+ } else {
303+ l.lineOffsets = nil
304+ }
305+}
306+
307+// scrollToSelection scrolls to make the selected item visible.
308+func (l *SimpleList) scrollToSelection() {
309+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
310+ return
311+ }
312+
313+ // Get selected item metadata.
314+ var rItem *renderedItem
315+ if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
316+ if ri, ok := l.renderedItems[idItem.ID()]; ok {
317+ rItem = &ri
318+ }
319+ }
320+
321+ if rItem == nil {
322+ return
323+ }
324+
325+ start, end := l.viewPosition()
326+
327+ // Already visible.
328+ if rItem.start >= start && rItem.end <= end {
329+ return
330+ }
331+
332+ // Item is above viewport - scroll up.
333+ if rItem.start < start {
334+ l.offset = rItem.start
335+ return
336+ }
337+
338+ // Item is below viewport - scroll down.
339+ if rItem.end > end {
340+ l.offset = max(0, rItem.end-l.height+1)
341+ }
342+}
343+
344+// Focus/blur management.
345+
346+func (l *SimpleList) focusSelectedItem() tea.Cmd {
347+ if l.selectedIdx < 0 || !l.focused {
348+ return nil
349+ }
350+
351+ var cmds []tea.Cmd
352+
353+ // Blur previous.
354+ if l.prevSelectedIdx >= 0 && l.prevSelectedIdx != l.selectedIdx && l.prevSelectedIdx < len(l.items) {
355+ if f, ok := l.items[l.prevSelectedIdx].(Focusable); ok && f.IsFocused() {
356+ f.Blur()
357+ }
358+ }
359+
360+ // Focus current.
361+ if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
362+ if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() {
363+ f.Focus()
364+ }
365+ }
366+
367+ l.prevSelectedIdx = l.selectedIdx
368+ return tea.Batch(cmds...)
369+}
370+
371+func (l *SimpleList) blurSelectedItem() tea.Cmd {
372+ if l.selectedIdx < 0 || l.focused {
373+ return nil
374+ }
375+
376+ if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
377+ if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() {
378+ f.Blur()
379+ }
380+ }
381+
382+ return nil
383+}
384+
385+// Public API.
386+
387+// SetSize sets the viewport dimensions.
388+func (l *SimpleList) SetSize(width, height int) tea.Cmd {
389+ oldWidth := l.width
390+ l.width = width
391+ l.height = height
392+
393+ if oldWidth != width {
394+ // Width changed - need to re-render.
395+ return l.render()
396+ }
397+
398+ return nil
399+}
400+
401+// Width returns the viewport width.
402+func (l *SimpleList) Width() int {
403+ return l.width
404+}
405+
406+// Height returns the viewport height.
407+func (l *SimpleList) Height() int {
408+ return l.height
409+}
410+
411+// GetSize returns the viewport dimensions.
412+func (l *SimpleList) GetSize() (int, int) {
413+ return l.width, l.height
414+}
415+
416+// Items returns all items.
417+func (l *SimpleList) Items() []Item {
418+ return l.items
419+}
420+
421+// Len returns the number of items.
422+func (l *SimpleList) Len() int {
423+ return len(l.items)
424+}
425+
426+// SetItems replaces all items.
427+func (l *SimpleList) SetItems(items []Item) tea.Cmd {
428+ l.items = items
429+ l.itemIDs = make(map[string]int, len(items))
430+ l.renderedItems = make(map[string]renderedItem)
431+ l.selectedIdx = -1
432+ l.prevSelectedIdx = -1
433+ l.offset = 0
434+
435+ // Build ID map.
436+ for i, item := range items {
437+ if idItem, ok := item.(interface{ ID() string }); ok {
438+ l.itemIDs[idItem.ID()] = i
439+ }
440+ }
441+
442+ return l.render()
443+}
444+
445+// AppendItem adds an item to the end.
446+func (l *SimpleList) AppendItem(item Item) tea.Cmd {
447+ l.items = append(l.items, item)
448+
449+ if idItem, ok := item.(interface{ ID() string }); ok {
450+ l.itemIDs[idItem.ID()] = len(l.items) - 1
451+ }
452+
453+ return l.render()
454+}
455+
456+// PrependItem adds an item to the beginning.
457+func (l *SimpleList) PrependItem(item Item) tea.Cmd {
458+ l.items = append([]Item{item}, l.items...)
459+
460+ // Rebuild ID map (indices shifted).
461+ l.itemIDs = make(map[string]int, len(l.items))
462+ for i, it := range l.items {
463+ if idItem, ok := it.(interface{ ID() string }); ok {
464+ l.itemIDs[idItem.ID()] = i
465+ }
466+ }
467+
468+ // Adjust selection.
469+ if l.selectedIdx >= 0 {
470+ l.selectedIdx++
471+ }
472+ if l.prevSelectedIdx >= 0 {
473+ l.prevSelectedIdx++
474+ }
475+
476+ return l.render()
477+}
478+
479+// UpdateItem replaces an item at the given index.
480+func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd {
481+ if idx < 0 || idx >= len(l.items) {
482+ return nil
483+ }
484+
485+ l.items[idx] = item
486+
487+ // Update ID map.
488+ if idItem, ok := item.(interface{ ID() string }); ok {
489+ l.itemIDs[idItem.ID()] = idx
490+ }
491+
492+ return l.render()
493+}
494+
495+// DeleteItem removes an item at the given index.
496+func (l *SimpleList) DeleteItem(idx int) tea.Cmd {
497+ if idx < 0 || idx >= len(l.items) {
498+ return nil
499+ }
500+
501+ l.items = append(l.items[:idx], l.items[idx+1:]...)
502+
503+ // Rebuild ID map (indices shifted).
504+ l.itemIDs = make(map[string]int, len(l.items))
505+ for i, it := range l.items {
506+ if idItem, ok := it.(interface{ ID() string }); ok {
507+ l.itemIDs[idItem.ID()] = i
508+ }
509+ }
510+
511+ // Adjust selection.
512+ if l.selectedIdx == idx {
513+ if idx > 0 {
514+ l.selectedIdx = idx - 1
515+ } else if len(l.items) > 0 {
516+ l.selectedIdx = 0
517+ } else {
518+ l.selectedIdx = -1
519+ }
520+ } else if l.selectedIdx > idx {
521+ l.selectedIdx--
522+ }
523+
524+ if l.prevSelectedIdx == idx {
525+ l.prevSelectedIdx = -1
526+ } else if l.prevSelectedIdx > idx {
527+ l.prevSelectedIdx--
528+ }
529+
530+ return l.render()
531+}
532+
533+// Focus sets the list as focused.
534+func (l *SimpleList) Focus() tea.Cmd {
535+ l.focused = true
536+ return l.render()
537+}
538+
539+// Blur removes focus from the list.
540+func (l *SimpleList) Blur() tea.Cmd {
541+ l.focused = false
542+ return l.render()
543+}
544+
545+// Focused returns whether the list is focused.
546+func (l *SimpleList) Focused() bool {
547+ return l.focused
548+}
549+
550+// Selection.
551+
552+// Selected returns the currently selected item index.
553+func (l *SimpleList) Selected() int {
554+ return l.selectedIdx
555+}
556+
557+// SelectedIndex returns the currently selected item index.
558+func (l *SimpleList) SelectedIndex() int {
559+ return l.selectedIdx
560+}
561+
562+// SelectedItem returns the currently selected item.
563+func (l *SimpleList) SelectedItem() Item {
564+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
565+ return nil
566+ }
567+ return l.items[l.selectedIdx]
568+}
569+
570+// SetSelected sets the selected item by index.
571+func (l *SimpleList) SetSelected(idx int) tea.Cmd {
572+ if idx < -1 || idx >= len(l.items) {
573+ return nil
574+ }
575+
576+ if l.selectedIdx == idx {
577+ return nil
578+ }
579+
580+ l.prevSelectedIdx = l.selectedIdx
581+ l.selectedIdx = idx
582+
583+ return l.render()
584+}
585+
586+// SelectFirst selects the first item.
587+func (l *SimpleList) SelectFirst() tea.Cmd {
588+ return l.SetSelected(0)
589+}
590+
591+// SelectLast selects the last item.
592+func (l *SimpleList) SelectLast() tea.Cmd {
593+ if len(l.items) > 0 {
594+ return l.SetSelected(len(l.items) - 1)
595+ }
596+ return nil
597+}
598+
599+// SelectNext selects the next item.
600+func (l *SimpleList) SelectNext() tea.Cmd {
601+ if l.selectedIdx < len(l.items)-1 {
602+ return l.SetSelected(l.selectedIdx + 1)
603+ }
604+ return nil
605+}
606+
607+// SelectPrev selects the previous item.
608+func (l *SimpleList) SelectPrev() tea.Cmd {
609+ if l.selectedIdx > 0 {
610+ return l.SetSelected(l.selectedIdx - 1)
611+ }
612+ return nil
613+}
614+
615+// SelectNextWrap selects the next item (wraps to beginning).
616+func (l *SimpleList) SelectNextWrap() tea.Cmd {
617+ if len(l.items) == 0 {
618+ return nil
619+ }
620+ nextIdx := (l.selectedIdx + 1) % len(l.items)
621+ return l.SetSelected(nextIdx)
622+}
623+
624+// SelectPrevWrap selects the previous item (wraps to end).
625+func (l *SimpleList) SelectPrevWrap() tea.Cmd {
626+ if len(l.items) == 0 {
627+ return nil
628+ }
629+ prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items)
630+ return l.SetSelected(prevIdx)
631+}
632+
633+// SelectFirstInView selects the first fully visible item.
634+func (l *SimpleList) SelectFirstInView() tea.Cmd {
635+ if len(l.items) == 0 {
636+ return nil
637+ }
638+
639+ start, end := l.viewPosition()
640+
641+ for i := 0; i < len(l.items); i++ {
642+ if idItem, ok := l.items[i].(interface{ ID() string }); ok {
643+ if rItem, ok := l.renderedItems[idItem.ID()]; ok {
644+ // Check if fully visible.
645+ if rItem.start >= start && rItem.end <= end {
646+ return l.SetSelected(i)
647+ }
648+ }
649+ }
650+ }
651+
652+ return nil
653+}
654+
655+// SelectLastInView selects the last fully visible item.
656+func (l *SimpleList) SelectLastInView() tea.Cmd {
657+ if len(l.items) == 0 {
658+ return nil
659+ }
660+
661+ start, end := l.viewPosition()
662+
663+ for i := len(l.items) - 1; i >= 0; i-- {
664+ if idItem, ok := l.items[i].(interface{ ID() string }); ok {
665+ if rItem, ok := l.renderedItems[idItem.ID()]; ok {
666+ // Check if fully visible.
667+ if rItem.start >= start && rItem.end <= end {
668+ return l.SetSelected(i)
669+ }
670+ }
671+ }
672+ }
673+
674+ return nil
675+}
676+
677+// SelectedItemInView returns true if the selected item is visible.
678+func (l *SimpleList) SelectedItemInView() bool {
679+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
680+ return false
681+ }
682+
683+ var rItem *renderedItem
684+ if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
685+ if ri, ok := l.renderedItems[idItem.ID()]; ok {
686+ rItem = &ri
687+ }
688+ }
689+
690+ if rItem == nil {
691+ return false
692+ }
693+
694+ start, end := l.viewPosition()
695+ return rItem.start < end && rItem.end > start
696+}
697+
698+// Scrolling.
699+
700+// Offset returns the current scroll offset.
701+func (l *SimpleList) Offset() int {
702+ return l.offset
703+}
704+
705+// TotalHeight returns the total height of all items.
706+func (l *SimpleList) TotalHeight() int {
707+ return l.renderedHeight
708+}
709+
710+// ScrollBy scrolls by the given number of lines.
711+func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd {
712+ l.offset += deltaLines
713+ l.clampOffset()
714+ return nil
715+}
716+
717+// ScrollToTop scrolls to the top.
718+func (l *SimpleList) ScrollToTop() tea.Cmd {
719+ l.offset = 0
720+ return nil
721+}
722+
723+// ScrollToBottom scrolls to the bottom.
724+func (l *SimpleList) ScrollToBottom() tea.Cmd {
725+ l.offset = l.renderedHeight - l.height
726+ l.clampOffset()
727+ return nil
728+}
729+
730+// AtTop returns true if scrolled to the top.
731+func (l *SimpleList) AtTop() bool {
732+ return l.offset <= 0
733+}
734+
735+// AtBottom returns true if scrolled to the bottom.
736+func (l *SimpleList) AtBottom() bool {
737+ return l.offset >= l.renderedHeight-l.height
738+}
739+
740+// ScrollToItem scrolls to make an item visible.
741+func (l *SimpleList) ScrollToItem(idx int) tea.Cmd {
742+ if idx < 0 || idx >= len(l.items) {
743+ return nil
744+ }
745+
746+ var rItem *renderedItem
747+ if idItem, ok := l.items[idx].(interface{ ID() string }); ok {
748+ if ri, ok := l.renderedItems[idItem.ID()]; ok {
749+ rItem = &ri
750+ }
751+ }
752+
753+ if rItem == nil {
754+ return nil
755+ }
756+
757+ start, end := l.viewPosition()
758+
759+ // Already visible.
760+ if rItem.start >= start && rItem.end <= end {
761+ return nil
762+ }
763+
764+ // Above viewport.
765+ if rItem.start < start {
766+ l.offset = rItem.start
767+ return nil
768+ }
769+
770+ // Below viewport.
771+ if rItem.end > end {
772+ l.offset = rItem.end - l.height + 1
773+ l.clampOffset()
774+ }
775+
776+ return nil
777+}
778+
779+// ScrollToSelected scrolls to the selected item.
780+func (l *SimpleList) ScrollToSelected() tea.Cmd {
781+ if l.selectedIdx >= 0 {
782+ return l.ScrollToItem(l.selectedIdx)
783+ }
784+ return nil
785+}
786+
787+func (l *SimpleList) clampOffset() {
788+ maxOffset := l.renderedHeight - l.height
789+ if maxOffset < 0 {
790+ maxOffset = 0
791+ }
792+ l.offset = ordered.Clamp(l.offset, 0, maxOffset)
793+}
794+
795+// Mouse and highlighting.
796+
797+// HandleMouseDown handles mouse press.
798+func (l *SimpleList) HandleMouseDown(x, y int) bool {
799+ if x < 0 || y < 0 || x >= l.width || y >= l.height {
800+ return false
801+ }
802+
803+ // Find item at viewport y.
804+ contentY := l.offset + y
805+ itemIdx := l.findItemAtLine(contentY)
806+
807+ if itemIdx < 0 {
808+ return false
809+ }
810+
811+ l.mouseDown = true
812+ l.mouseDownItem = itemIdx
813+ l.mouseDownX = x
814+ l.mouseDownY = y
815+ l.mouseDragItem = itemIdx
816+ l.mouseDragX = x
817+ l.mouseDragY = y
818+
819+ // Start selection.
820+ l.selectionStartLine = y
821+ l.selectionStartCol = x
822+ l.selectionEndLine = y
823+ l.selectionEndCol = x
824+
825+ // Select item.
826+ l.SetSelected(itemIdx)
827+
828+ return true
829+}
830+
831+// HandleMouseDrag handles mouse drag.
832+func (l *SimpleList) HandleMouseDrag(x, y int) bool {
833+ if !l.mouseDown {
834+ return false
835+ }
836+
837+ // Clamp coordinates to viewport bounds.
838+ clampedX := max(0, min(x, l.width-1))
839+ clampedY := max(0, min(y, l.height-1))
840+
841+ if clampedY >= 0 && clampedY < l.height {
842+ contentY := l.offset + clampedY
843+ itemIdx := l.findItemAtLine(contentY)
844+ if itemIdx >= 0 {
845+ l.mouseDragItem = itemIdx
846+ l.mouseDragX = clampedX
847+ l.mouseDragY = clampedY
848+ }
849+ }
850+
851+ // Update selection end (clamped to viewport).
852+ l.selectionEndLine = clampedY
853+ l.selectionEndCol = clampedX
854+
855+ return true
856+}
857+
858+// HandleMouseUp handles mouse release.
859+func (l *SimpleList) HandleMouseUp(x, y int) bool {
860+ if !l.mouseDown {
861+ return false
862+ }
863+
864+ l.mouseDown = false
865+
866+ // Final selection update (clamped to viewport).
867+ clampedX := max(0, min(x, l.width-1))
868+ clampedY := max(0, min(y, l.height-1))
869+ l.selectionEndLine = clampedY
870+ l.selectionEndCol = clampedX
871+
872+ return true
873+}
874+
875+// ClearHighlight clears the selection.
876+func (l *SimpleList) ClearHighlight() {
877+ l.selectionStartLine = -1
878+ l.selectionStartCol = -1
879+ l.selectionEndLine = -1
880+ l.selectionEndCol = -1
881+ l.mouseDown = false
882+ l.mouseDownItem = -1
883+ l.mouseDragItem = -1
884+}
885+
886+// GetHighlightedText returns the selected text.
887+func (l *SimpleList) GetHighlightedText() string {
888+ if !l.hasSelection() {
889+ return ""
890+ }
891+
892+ return l.renderSelection(l.View())
893+}
894+
895+func (l *SimpleList) hasSelection() bool {
896+ return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
897+}
898+
899+// renderSelection applies highlighting to the view and extracts text.
900+func (l *SimpleList) renderSelection(view string) string {
901+ // Create a screen buffer spanning the viewport.
902+ buf := uv.NewScreenBuffer(l.width, l.height)
903+ area := uv.Rect(0, 0, l.width, l.height)
904+ uv.NewStyledString(view).Draw(&buf, area)
905+
906+ // Calculate selection bounds.
907+ startLine := min(l.selectionStartLine, l.selectionEndLine)
908+ endLine := max(l.selectionStartLine, l.selectionEndLine)
909+ startCol := l.selectionStartCol
910+ endCol := l.selectionEndCol
911+
912+ if l.selectionEndLine < l.selectionStartLine {
913+ startCol = l.selectionEndCol
914+ endCol = l.selectionStartCol
915+ }
916+
917+ // Apply highlighting.
918+ for y := startLine; y <= endLine && y < l.height; y++ {
919+ if y >= buf.Height() {
920+ break
921+ }
922+
923+ line := buf.Line(y)
924+
925+ // Determine column range for this line.
926+ colStart := 0
927+ if y == startLine {
928+ colStart = startCol
929+ }
930+
931+ colEnd := len(line)
932+ if y == endLine {
933+ colEnd = min(endCol, len(line))
934+ }
935+
936+ // Apply highlight style.
937+ for x := colStart; x < colEnd && x < len(line); x++ {
938+ cell := line.At(x)
939+ if cell != nil && !cell.IsZero() {
940+ cell = cell.Clone()
941+ // Toggle reverse for highlight.
942+ if cell.Style.Attrs&uv.AttrReverse != 0 {
943+ cell.Style.Attrs &^= uv.AttrReverse
944+ } else {
945+ cell.Style.Attrs |= uv.AttrReverse
946+ }
947+ buf.SetCell(x, y, cell)
948+ }
949+ }
950+ }
951+
952+ return buf.Render()
953+}
954+
955+// findItemAtLine finds the item index at the given content line.
956+func (l *SimpleList) findItemAtLine(line int) int {
957+ for i := 0; i < len(l.items); i++ {
958+ if idItem, ok := l.items[i].(interface{ ID() string }); ok {
959+ if rItem, ok := l.renderedItems[idItem.ID()]; ok {
960+ if line >= rItem.start && line <= rItem.end {
961+ return i
962+ }
963+ }
964+ }
965+ }
966+ return -1
967+}
968+
969+// Render returns the view (for compatibility).
970+func (l *SimpleList) Render() string {
971+ return l.View()
972+}
internal/ui/model/chat.go+14−14
@@ -8,6 +8,7 @@ import (
88 "github.com/charmbracelet/crush/internal/message"
99 "github.com/charmbracelet/crush/internal/tui/components/anim"
1010 "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/lazylist"
1112 "github.com/charmbracelet/crush/internal/ui/list"
1213 "github.com/charmbracelet/crush/internal/ui/styles"
1314 uv "github.com/charmbracelet/ultraviolet"
@@ -196,13 +197,14 @@ func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int,
196197// messages.
197198type Chat struct {
198199 com *common.Common
199− list *list.List
200+ list *lazylist.List
200201}
201202
202203// NewChat creates a new instance of [Chat] that handles chat interactions and
203204// messages.
204205func NewChat(com *common.Common) *Chat {
205− l := list.New()
206+ l := lazylist.NewList()
207+ l.SetGap(1)
206208 return &Chat{
207209 com: com,
208210 list: l,
@@ -216,7 +218,7 @@ func (m *Chat) Height() int {
216218
217219// Draw renders the chat UI component to the screen and the given area.
218220func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
219− m.list.Draw(scr, area)
221+ uv.NewStyledString(m.list.Render()).Draw(scr, area)
220222}
221223
222224// SetSize sets the size of the chat view port.
@@ -229,25 +231,23 @@ func (m *Chat) Len() int {
229231 return m.list.Len()
230232}
231233
232−// PrependItem prepends a new item to the chat list.
233−func (m *Chat) PrependItem(item list.Item) {
234− m.list.PrependItem(item)
234+// PrependItems prepends new items to the chat list.
235+func (m *Chat) PrependItems(items ...lazylist.Item) {
236+ m.list.PrependItems(items...)
237+ m.list.ScrollToIndex(0)
235238}
236239
237240// AppendMessages appends a new message item to the chat list.
238241func (m *Chat) AppendMessages(msgs ...MessageItem) {
239242 for _, msg := range msgs {
240− m.AppendItem(msg)
243+ m.AppendItems(msg)
241244 }
242245}
243246
244−// AppendItem appends a new item to the chat list.
245−func (m *Chat) AppendItem(item list.Item) {
246− if m.Len() > 0 {
247− // Always add a spacer between messages
248− m.list.AppendItem(list.NewSpacerItem(1))
249− }
250− m.list.AppendItem(item)
247+// AppendItems appends new items to the chat list.
248+func (m *Chat) AppendItems(items ...lazylist.Item) {
249+ m.list.AppendItems(items...)
250+ m.list.ScrollToIndex(m.list.Len() - 1)
251251}
252252
253253// Focus sets the focus state of the chat component.
internal/ui/model/items.go+37
@@ -13,6 +13,7 @@ import (
1313 "github.com/charmbracelet/crush/internal/config"
1414 "github.com/charmbracelet/crush/internal/message"
1515 "github.com/charmbracelet/crush/internal/ui/common"
16+ "github.com/charmbracelet/crush/internal/ui/lazylist"
1617 "github.com/charmbracelet/crush/internal/ui/list"
1718 "github.com/charmbracelet/crush/internal/ui/styles"
1819 "github.com/charmbracelet/crush/internal/ui/toolrender"
@@ -29,6 +30,7 @@ type MessageItem interface {
2930 list.Item
3031 list.Focusable
3132 list.Highlightable
33+ lazylist.Item
3234 Identifiable
3335}
3436
@@ -114,6 +116,11 @@ func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
114116 tempBuf.Draw(scr, area)
115117}
116118
119+// Render implements lazylist.Item.
120+func (m *MessageContentItem) Render(width int) string {
121+ return m.render(width)
122+}
123+
117124// render renders the content at the given width, using cache if available.
118125func (m *MessageContentItem) render(width int) string {
119126 // Cap width to maxWidth for markdown
@@ -228,6 +235,12 @@ func (t *ToolCallItem) Height(width int) int {
228235 return height
229236}
230237
238+// Render implements lazylist.Item.
239+func (t *ToolCallItem) Render(width int) string {
240+ cached := t.renderCached(width)
241+ return cached.content
242+}
243+
231244// Draw implements list.Item.
232245func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) {
233246 width := area.Dx()
@@ -334,6 +347,16 @@ func (a *AttachmentItem) Height(width int) int {
334347 return 1
335348}
336349
350+// Render implements lazylist.Item.
351+func (a *AttachmentItem) Render(width int) string {
352+ const maxFilenameWidth = 10
353+ return a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
354+ " %s %s ",
355+ styles.DocumentIcon,
356+ ansi.Truncate(a.filename, maxFilenameWidth, "..."),
357+ ))
358+}
359+
337360// Draw implements list.Item.
338361func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
339362 width := area.Dx()
@@ -410,6 +433,11 @@ func (t *ThinkingItem) Height(width int) int {
410433 return strings.Count(rendered, "\n") + 1
411434}
412435
436+// Render implements lazylist.Item.
437+func (t *ThinkingItem) Render(width int) string {
438+ return t.render(width)
439+}
440+
413441// Draw implements list.Item.
414442func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
415443 width := area.Dx()
@@ -522,6 +550,15 @@ func (s *SectionHeaderItem) Height(width int) int {
522550 return 1
523551}
524552
553+// Render implements lazylist.Item.
554+func (s *SectionHeaderItem) Render(width int) string {
555+ return s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
556+ s.sty.Subtle.Render(styles.ModelIcon),
557+ s.sty.Muted.Render(s.modelName),
558+ s.sty.Subtle.Render(s.duration.String()),
559+ ))
560+}
561+
525562// Draw implements list.Item.
526563func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
527564 width := area.Dx()
internal/ui/model/ui.go+3−3
@@ -172,7 +172,7 @@ func (m *UI) Init() tea.Cmd {
172172 if len(allSessions) > 0 {
173173 cmds = append(cmds, func() tea.Msg {
174174 time.Sleep(2 * time.Second)
175− return m.loadSession(allSessions[0].ID)()
175+ return m.loadSession(allSessions[1].ID)()
176176 })
177177 }
178178 return tea.Batch(cmds...)
@@ -441,12 +441,12 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
441441 editor.Draw(scr, layout.editor)
442442
443443 case uiChat:
444+ m.chat.Draw(scr, layout.main)
445+
444446 header := uv.NewStyledString(m.header)
445447 header.Draw(scr, layout.header)
446448 m.drawSidebar(scr, layout.sidebar)
447449
448− m.chat.Draw(scr, layout.main)
449−
450450 editor := uv.NewStyledString(m.textarea.View())
451451 editor.Draw(scr, layout.editor)
452452