1package lazylist
2
3import (
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.
11type 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.
40type renderedItem struct {
41 content string
42 height int
43}
44
45// itemPosition holds the start and end line of an item in the list.
46type itemPosition struct {
47 startLine int
48 endLine int
49}
50
51// Height returns the height of item based on its start and end lines.
52func (ip itemPosition) Height() int {
53 return ip.endLine - ip.startLine
54}
55
56// NewList creates a new lazy-loaded list.
57func 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.
67func (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.
80func (l *List) SetGap(gap int) {
81 l.gap = gap
82}
83
84// Width returns the width of the list viewport.
85func (l *List) Width() int {
86 return l.width
87}
88
89// Height returns the height of the list viewport.
90func (l *List) Height() int {
91 return l.height
92}
93
94// Len returns the number of items in the list.
95func (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.
101func (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.
139func (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.
148func (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.
196func (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.
226func (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.
246func (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.
275func (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.
294func (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.
302func (l *List) Focus() {
303 l.focused = true
304}
305
306// Blur removes the focus state from the list.
307func (l *List) Blur() {
308 l.focused = false
309}
310
311// ScrollToTop scrolls the list to the top.
312func (l *List) ScrollToTop() {
313 l.offsetIdx = 0
314 l.offsetLine = 0
315}
316
317// ScrollToBottom scrolls the list to the bottom.
318func (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.
329func (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.
338func (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.
347func (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.
356func (l *List) SelectPrev() {
357 if l.selectedIdx > 0 {
358 l.selectedIdx--
359 }
360}
361
362// SelectNext selects the next item in the list.
363func (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.
370func (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.
377func (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.
384func (l *List) SelectFirstInView() {
385 startIdx, _ := l.findVisibleItems()
386 l.selectedIdx = startIdx
387}
388
389// SelectLastInView selects the last item currently in view.
390func (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.
396func (l *List) HandleMouseDown(x, y int) {
397}
398
399// HandleMouseUp handles mouse up events at the given line in the viewport.
400func (l *List) HandleMouseUp(x, y int) {
401}
402
403// HandleMouseDrag handles mouse drag events at the given line in the viewport.
404func (l *List) HandleMouseDrag(x, y int) {
405}
406
407// countLines counts the number of lines in a string.
408func countLines(s string) int {
409 if s == "" {
410 return 0
411 }
412 return strings.Count(s, "\n") + 1
413}