list.go

  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	// 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
 36// renderedItem holds the rendered content and height of an item.
 37type renderedItem struct {
 38	content string
 39	height  int
 40}
 41
 42// NewList creates a new lazy-loaded list.
 43func NewList(items ...Item) *List {
 44	l := new(List)
 45	l.items = items
 46	l.renderedItems = make(map[int]renderedItem)
 47	return l
 48}
 49
 50// SetSize sets the size of the list viewport.
 51func (l *List) SetSize(width, height int) {
 52	if width != l.width {
 53		l.renderedItems = make(map[int]renderedItem)
 54	}
 55	l.width = width
 56	l.height = height
 57	// l.normalizeOffsets()
 58}
 59
 60// SetGap sets the gap between items.
 61func (l *List) SetGap(gap int) {
 62	l.gap = gap
 63}
 64
 65// Width returns the width of the list viewport.
 66func (l *List) Width() int {
 67	return l.width
 68}
 69
 70// Height returns the height of the list viewport.
 71func (l *List) Height() int {
 72	return l.height
 73}
 74
 75// Len returns the number of items in the list.
 76func (l *List) Len() int {
 77	return len(l.items)
 78}
 79
 80// getItem renders (if needed) and returns the item at the given index.
 81func (l *List) getItem(idx int) renderedItem {
 82	if idx < 0 || idx >= len(l.items) {
 83		return renderedItem{}
 84	}
 85
 86	if item, ok := l.renderedItems[idx]; ok {
 87		return item
 88	}
 89
 90	item := l.items[idx]
 91	rendered := item.Render(l.width)
 92	height := countLines(rendered)
 93	// slog.Info("Rendered item", "idx", idx, "height", height)
 94
 95	ri := renderedItem{
 96		content: rendered,
 97		height:  height,
 98	}
 99
100	l.renderedItems[idx] = ri
101
102	return ri
103}
104
105// ScrollToIndex scrolls the list to the given item index.
106func (l *List) ScrollToIndex(index int) {
107	if index < 0 {
108		index = 0
109	}
110	if index >= len(l.items) {
111		index = len(l.items) - 1
112	}
113	l.offsetIdx = index
114	l.offsetLine = 0
115}
116
117// ScrollBy scrolls the list by the given number of lines.
118func (l *List) ScrollBy(lines int) {
119	if len(l.items) == 0 || lines == 0 {
120		return
121	}
122
123	if lines > 0 {
124		// Scroll down
125		// Calculate from the bottom how many lines needed to anchor the last
126		// item to the bottom
127		var totalLines int
128		var lastItemIdx int // the last item that can be partially visible
129		for i := len(l.items) - 1; i >= 0; i-- {
130			item := l.getItem(i)
131			totalLines += item.height
132			if l.gap > 0 && i < len(l.items)-1 {
133				totalLines += l.gap
134			}
135			if totalLines >= l.height {
136				lastItemIdx = i
137				break
138			}
139		}
140
141		// Now scroll down by lines
142		var item renderedItem
143		l.offsetLine += lines
144		for {
145			item = l.getItem(l.offsetIdx)
146			totalHeight := item.height
147			if l.gap > 0 {
148				totalHeight += l.gap
149			}
150
151			if l.offsetIdx >= lastItemIdx || l.offsetLine < totalHeight {
152				// Valid offset
153				break
154			}
155
156			// Move to next item
157			l.offsetLine -= totalHeight
158			l.offsetIdx++
159		}
160
161		if l.offsetLine >= item.height {
162			l.offsetLine = item.height - 1
163		}
164	} else if lines < 0 {
165		// Scroll up
166		// Calculate from offset how many items needed to fill the viewport
167		// This is needed to know when to stop scrolling up
168		var totalLines int
169		var firstItemIdx int
170		for i := l.offsetIdx; i >= 0; i-- {
171			item := l.getItem(i)
172			totalLines += item.height
173			if l.gap > 0 && i < l.offsetIdx {
174				totalLines += l.gap
175			}
176			if totalLines >= l.height {
177				firstItemIdx = i
178				break
179			}
180		}
181
182		// Now scroll up by lines
183		l.offsetLine += lines // lines is negative
184		for l.offsetIdx > firstItemIdx && l.offsetLine < 0 {
185			// Move to previous item
186			l.offsetIdx--
187			prevItem := l.getItem(l.offsetIdx)
188			totalHeight := prevItem.height
189			if l.gap > 0 {
190				totalHeight += l.gap
191			}
192			l.offsetLine += totalHeight
193		}
194
195		if l.offsetLine < 0 {
196			l.offsetLine = 0
197		}
198	}
199}
200
201// findVisibleItems finds the range of items that are visible in the viewport.
202// This is used for checking if selected item is in view.
203func (l *List) findVisibleItems() (startIdx, endIdx int) {
204	if len(l.items) == 0 {
205		return 0, 0
206	}
207
208	startIdx = l.offsetIdx
209	currentIdx := startIdx
210	visibleHeight := -l.offsetLine
211
212	for currentIdx < len(l.items) {
213		item := l.getItem(currentIdx)
214		visibleHeight += item.height
215		if l.gap > 0 {
216			visibleHeight += l.gap
217		}
218
219		if visibleHeight >= l.height {
220			break
221		}
222		currentIdx++
223	}
224
225	endIdx = currentIdx
226	if endIdx >= len(l.items) {
227		endIdx = len(l.items) - 1
228	}
229
230	return startIdx, endIdx
231}
232
233// Render renders the list and returns the visible lines.
234func (l *List) Render() string {
235	if len(l.items) == 0 {
236		return ""
237	}
238
239	slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height)
240
241	var lines []string
242	currentIdx := l.offsetIdx
243	currentOffset := l.offsetLine
244
245	linesNeeded := l.height
246
247	for linesNeeded > 0 && currentIdx < len(l.items) {
248		item := l.getItem(currentIdx)
249		itemLines := strings.Split(item.content, "\n")
250		itemHeight := len(itemLines)
251
252		if currentOffset < itemHeight {
253			// Add visible content lines
254			lines = append(lines, itemLines[currentOffset:]...)
255
256			// Add gap if this is not the absolute last visual element (conceptually gaps are between items)
257			// But in the loop we can just add it and trim later
258			if l.gap > 0 {
259				for i := 0; i < l.gap; i++ {
260					lines = append(lines, "")
261				}
262			}
263		} else {
264			// offsetLine starts in the gap
265			gapOffset := currentOffset - itemHeight
266			gapRemaining := l.gap - gapOffset
267			if gapRemaining > 0 {
268				for i := 0; i < gapRemaining; i++ {
269					lines = append(lines, "")
270				}
271			}
272		}
273
274		linesNeeded = l.height - len(lines)
275		currentIdx++
276		currentOffset = 0 // Reset offset for subsequent items
277	}
278
279	if len(lines) > l.height {
280		lines = lines[:l.height]
281	}
282
283	return strings.Join(lines, "\n")
284}
285
286// PrependItems prepends items to the list.
287func (l *List) PrependItems(items ...Item) {
288	l.items = append(items, l.items...)
289
290	// Shift cache
291	newCache := make(map[int]renderedItem)
292	for idx, val := range l.renderedItems {
293		newCache[idx+len(items)] = val
294	}
295	l.renderedItems = newCache
296
297	// Keep view position relative to the content that was visible
298	l.offsetIdx += len(items)
299
300	// Update selection index if valid
301	if l.selectedIdx != -1 {
302		l.selectedIdx += len(items)
303	}
304}
305
306// AppendItems appends items to the list.
307func (l *List) AppendItems(items ...Item) {
308	l.items = append(l.items, items...)
309}
310
311// Focus sets the focus state of the list.
312func (l *List) Focus() {
313	l.focused = true
314	if l.selectedIdx < 0 || l.selectedIdx > len(l.items)-1 {
315		return
316	}
317}
318
319// Blur removes the focus state from the list.
320func (l *List) Blur() {
321	l.focused = false
322}
323
324// ScrollToTop scrolls the list to the top.
325func (l *List) ScrollToTop() {
326	l.offsetIdx = 0
327	l.offsetLine = 0
328}
329
330// ScrollToBottom scrolls the list to the bottom.
331func (l *List) ScrollToBottom() {
332	if len(l.items) == 0 {
333		return
334	}
335
336	// Scroll to the last item
337	var totalHeight int
338	for i := len(l.items) - 1; i >= 0; i-- {
339		item := l.getItem(i)
340		totalHeight += item.height
341		if l.gap > 0 && i < len(l.items)-1 {
342			totalHeight += l.gap
343		}
344		if totalHeight >= l.height {
345			l.offsetIdx = i
346			l.offsetLine = totalHeight - l.height
347			break
348		}
349	}
350	if totalHeight < l.height {
351		// All items fit in the viewport
352		l.ScrollToTop()
353	}
354}
355
356// ScrollToSelected scrolls the list to the selected item.
357func (l *List) ScrollToSelected() {
358	// TODO: Implement me
359}
360
361// SelectedItemInView returns whether the selected item is currently in view.
362func (l *List) SelectedItemInView() bool {
363	if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
364		return false
365	}
366	startIdx, endIdx := l.findVisibleItems()
367	return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
368}
369
370// SetSelected sets the selected item index in the list.
371func (l *List) SetSelected(index int) {
372	if index < 0 || index >= len(l.items) {
373		l.selectedIdx = -1
374	} else {
375		l.selectedIdx = index
376	}
377}
378
379// SelectPrev selects the previous item in the list.
380func (l *List) SelectPrev() {
381	if l.selectedIdx > 0 {
382		l.selectedIdx--
383	}
384}
385
386// SelectNext selects the next item in the list.
387func (l *List) SelectNext() {
388	if l.selectedIdx < len(l.items)-1 {
389		l.selectedIdx++
390	}
391}
392
393// SelectFirst selects the first item in the list.
394func (l *List) SelectFirst() {
395	if len(l.items) > 0 {
396		l.selectedIdx = 0
397	}
398}
399
400// SelectLast selects the last item in the list.
401func (l *List) SelectLast() {
402	if len(l.items) > 0 {
403		l.selectedIdx = len(l.items) - 1
404	}
405}
406
407// SelectFirstInView selects the first item currently in view.
408func (l *List) SelectFirstInView() {
409	startIdx, _ := l.findVisibleItems()
410	l.selectedIdx = startIdx
411}
412
413// SelectLastInView selects the last item currently in view.
414func (l *List) SelectLastInView() {
415	_, endIdx := l.findVisibleItems()
416	l.selectedIdx = endIdx
417}
418
419// HandleMouseDown handles mouse down events at the given line in the viewport.
420func (l *List) HandleMouseDown(x, y int) {
421}
422
423// HandleMouseUp handles mouse up events at the given line in the viewport.
424func (l *List) HandleMouseUp(x, y int) {
425}
426
427// HandleMouseDrag handles mouse drag events at the given line in the viewport.
428func (l *List) HandleMouseDrag(x, y int) {
429}
430
431// countLines counts the number of lines in a string.
432func countLines(s string) int {
433	if s == "" {
434		return 0
435	}
436	return strings.Count(s, "\n") + 1
437}