6 tea "charm.land/bubbletea/v2"
7 "charm.land/lipgloss/v2"
8 uv "github.com/charmbracelet/ultraviolet"
9 "github.com/charmbracelet/x/exp/ordered"
14var newlineBuffer = strings.Repeat("\n", maxGapSize)
16// SimpleList is a string-based list with virtual scrolling behavior.
17// Based on exp/list but simplified for our needs.
18type SimpleList struct {
19 // Viewport dimensions.
22 // Scroll offset (in lines from top).
27 itemIDs map[string]int // ID -> index mapping
29 // Rendered content (all items stacked).
31 renderedHeight int // Total height of rendered content in lines
32 lineOffsets []int // Byte offsets for each line (for fast slicing)
34 // Rendered item metadata.
35 renderedItems map[string]renderedItem
44 // Mouse/highlight state.
48 mouseDownY int // viewport-relative Y
51 mouseDragY int // viewport-relative Y
52 selectionStartLine int
58 gap int // Gap between items in lines
61type renderedItem struct {
64 start int // Start line in rendered content
65 end int // End line in rendered content
68// NewSimpleList creates a new simple list.
69func NewSimpleList(items ...Item) *SimpleList {
72 itemIDs: make(map[string]int, len(items)),
73 renderedItems: make(map[string]renderedItem),
77 selectionStartLine: -1,
78 selectionStartCol: -1,
84 for i, item := range items {
85 if idItem, ok := item.(interface{ ID() string }); ok {
86 l.itemIDs[idItem.ID()] = i
93// Init initializes the list (Bubbletea lifecycle).
94func (l *SimpleList) Init() tea.Cmd {
98// Update handles messages (Bubbletea lifecycle).
99func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) {
103// View returns the visible viewport (Bubbletea lifecycle).
104func (l *SimpleList) View() string {
105 if l.height <= 0 || l.width <= 0 {
109 start, end := l.viewPosition()
110 viewStart := max(0, start)
113 if viewStart > viewEnd {
117 view := l.getLines(viewStart, viewEnd)
119 // Apply width/height constraints.
120 view = lipgloss.NewStyle().
125 // Apply highlighting if active.
126 if l.hasSelection() {
127 return l.renderSelection(view)
133// viewPosition returns the start and end line indices for the viewport.
134func (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)
141// getLines returns lines [start, end] from rendered content.
142func (l *SimpleList) getLines(start, end int) string {
143 if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
147 if end >= len(l.lineOffsets) {
148 end = len(l.lineOffsets) - 1
154 startOffset := l.lineOffsets[start]
156 if end+1 < len(l.lineOffsets) {
157 endOffset = l.lineOffsets[end+1] - 1 // Exclude newline
159 endOffset = len(l.rendered)
162 if startOffset >= len(l.rendered) {
165 endOffset = min(endOffset, len(l.rendered))
167 return l.rendered[startOffset:endOffset]
170// render rebuilds the rendered content from all items.
171func (l *SimpleList) render() tea.Cmd {
172 if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
176 // Set default selection if none.
177 if l.selectedIdx < 0 && len(l.items) > 0 {
181 // Handle focus changes.
184 focusCmd = l.focusSelectedItem()
186 focusCmd = l.blurSelectedItem()
190 var b strings.Builder
193 for i, item := range l.items {
195 view := l.renderItem(item)
196 height := lipgloss.Height(view)
199 rItem := renderedItem{
203 end: currentLine + height - 1,
206 if idItem, ok := item.(interface{ ID() string }); ok {
207 l.renderedItems[idItem.ID()] = rItem
210 // Append to rendered content.
213 // Add gap after item (except last).
215 if i == len(l.items)-1 {
220 if gap <= maxGapSize {
221 b.WriteString(newlineBuffer[:gap])
223 b.WriteString(strings.Repeat("\n", gap))
227 currentLine += height + gap
230 l.setRendered(b.String())
232 // Scroll to selected item.
233 if l.focused && l.selectedIdx >= 0 {
234 l.scrollToSelection()
240// renderItem renders a single item.
241func (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)
247 // Find actual height.
248 height := l.measureBufferHeight(&buf)
257// measureBufferHeight finds the actual content height in a buffer.
258func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int {
259 height := buf.Height()
261 // Scan from bottom up to find last non-empty line.
262 for y := height - 1; y >= 0; y-- {
264 if l.lineHasContent(line) {
272// lineHasContent checks if a line has any non-empty cells.
273func (l *SimpleList) lineHasContent(line uv.Line) bool {
274 for x := 0; x < len(line); x++ {
276 if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " {
283// setRendered updates the rendered content and caches line offsets.
284func (l *SimpleList) setRendered(rendered string) {
285 l.rendered = rendered
286 l.renderedHeight = lipgloss.Height(rendered)
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)
295 idx := strings.IndexByte(rendered[offset:], '\n')
300 l.lineOffsets = append(l.lineOffsets, offset)
307// scrollToSelection scrolls to make the selected item visible.
308func (l *SimpleList) scrollToSelection() {
309 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
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 {
325 start, end := l.viewPosition()
328 if rItem.start >= start && rItem.end <= end {
332 // Item is above viewport - scroll up.
333 if rItem.start < start {
334 l.offset = rItem.start
338 // Item is below viewport - scroll down.
340 l.offset = max(0, rItem.end-l.height+1)
344// Focus/blur management.
346func (l *SimpleList) focusSelectedItem() tea.Cmd {
347 if l.selectedIdx < 0 || !l.focused {
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() {
361 if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
362 if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() {
367 l.prevSelectedIdx = l.selectedIdx
368 return tea.Batch(cmds...)
371func (l *SimpleList) blurSelectedItem() tea.Cmd {
372 if l.selectedIdx < 0 || l.focused {
376 if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
377 if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() {
387// SetSize sets the viewport dimensions.
388func (l *SimpleList) SetSize(width, height int) tea.Cmd {
393 if oldWidth != width {
394 // Width changed - need to re-render.
401// Width returns the viewport width.
402func (l *SimpleList) Width() int {
406// Height returns the viewport height.
407func (l *SimpleList) Height() int {
411// GetSize returns the viewport dimensions.
412func (l *SimpleList) GetSize() (int, int) {
413 return l.width, l.height
416// Items returns all items.
417func (l *SimpleList) Items() []Item {
421// Len returns the number of items.
422func (l *SimpleList) Len() int {
426// SetItems replaces all items.
427func (l *SimpleList) SetItems(items []Item) tea.Cmd {
429 l.itemIDs = make(map[string]int, len(items))
430 l.renderedItems = make(map[string]renderedItem)
432 l.prevSelectedIdx = -1
436 for i, item := range items {
437 if idItem, ok := item.(interface{ ID() string }); ok {
438 l.itemIDs[idItem.ID()] = i
445// AppendItem adds an item to the end.
446func (l *SimpleList) AppendItem(item Item) tea.Cmd {
447 l.items = append(l.items, item)
449 if idItem, ok := item.(interface{ ID() string }); ok {
450 l.itemIDs[idItem.ID()] = len(l.items) - 1
456// PrependItem adds an item to the beginning.
457func (l *SimpleList) PrependItem(item Item) tea.Cmd {
458 l.items = append([]Item{item}, l.items...)
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
469 if l.selectedIdx >= 0 {
472 if l.prevSelectedIdx >= 0 {
479// UpdateItem replaces an item at the given index.
480func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd {
481 if idx < 0 || idx >= len(l.items) {
488 if idItem, ok := item.(interface{ ID() string }); ok {
489 l.itemIDs[idItem.ID()] = idx
495// DeleteItem removes an item at the given index.
496func (l *SimpleList) DeleteItem(idx int) tea.Cmd {
497 if idx < 0 || idx >= len(l.items) {
501 l.items = append(l.items[:idx], l.items[idx+1:]...)
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
512 if l.selectedIdx == idx {
514 l.selectedIdx = idx - 1
515 } else if len(l.items) > 0 {
520 } else if l.selectedIdx > idx {
524 if l.prevSelectedIdx == idx {
525 l.prevSelectedIdx = -1
526 } else if l.prevSelectedIdx > idx {
533// Focus sets the list as focused.
534func (l *SimpleList) Focus() tea.Cmd {
539// Blur removes focus from the list.
540func (l *SimpleList) Blur() tea.Cmd {
545// Focused returns whether the list is focused.
546func (l *SimpleList) Focused() bool {
552// Selected returns the currently selected item index.
553func (l *SimpleList) Selected() int {
557// SelectedIndex returns the currently selected item index.
558func (l *SimpleList) SelectedIndex() int {
562// SelectedItem returns the currently selected item.
563func (l *SimpleList) SelectedItem() Item {
564 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
567 return l.items[l.selectedIdx]
570// SetSelected sets the selected item by index.
571func (l *SimpleList) SetSelected(idx int) tea.Cmd {
572 if idx < -1 || idx >= len(l.items) {
576 if l.selectedIdx == idx {
580 l.prevSelectedIdx = l.selectedIdx
586// SelectFirst selects the first item.
587func (l *SimpleList) SelectFirst() tea.Cmd {
588 return l.SetSelected(0)
591// SelectLast selects the last item.
592func (l *SimpleList) SelectLast() tea.Cmd {
593 if len(l.items) > 0 {
594 return l.SetSelected(len(l.items) - 1)
599// SelectNext selects the next item.
600func (l *SimpleList) SelectNext() tea.Cmd {
601 if l.selectedIdx < len(l.items)-1 {
602 return l.SetSelected(l.selectedIdx + 1)
607// SelectPrev selects the previous item.
608func (l *SimpleList) SelectPrev() tea.Cmd {
609 if l.selectedIdx > 0 {
610 return l.SetSelected(l.selectedIdx - 1)
615// SelectNextWrap selects the next item (wraps to beginning).
616func (l *SimpleList) SelectNextWrap() tea.Cmd {
617 if len(l.items) == 0 {
620 nextIdx := (l.selectedIdx + 1) % len(l.items)
621 return l.SetSelected(nextIdx)
624// SelectPrevWrap selects the previous item (wraps to end).
625func (l *SimpleList) SelectPrevWrap() tea.Cmd {
626 if len(l.items) == 0 {
629 prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items)
630 return l.SetSelected(prevIdx)
633// SelectFirstInView selects the first fully visible item.
634func (l *SimpleList) SelectFirstInView() tea.Cmd {
635 if len(l.items) == 0 {
639 start, end := l.viewPosition()
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)
655// SelectLastInView selects the last fully visible item.
656func (l *SimpleList) SelectLastInView() tea.Cmd {
657 if len(l.items) == 0 {
661 start, end := l.viewPosition()
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)
677// SelectedItemInView returns true if the selected item is visible.
678func (l *SimpleList) SelectedItemInView() bool {
679 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
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 {
694 start, end := l.viewPosition()
695 return rItem.start < end && rItem.end > start
700// Offset returns the current scroll offset.
701func (l *SimpleList) Offset() int {
705// TotalHeight returns the total height of all items.
706func (l *SimpleList) TotalHeight() int {
707 return l.renderedHeight
710// ScrollBy scrolls by the given number of lines.
711func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd {
712 l.offset += deltaLines
717// ScrollToTop scrolls to the top.
718func (l *SimpleList) ScrollToTop() tea.Cmd {
723// ScrollToBottom scrolls to the bottom.
724func (l *SimpleList) ScrollToBottom() tea.Cmd {
725 l.offset = l.renderedHeight - l.height
730// AtTop returns true if scrolled to the top.
731func (l *SimpleList) AtTop() bool {
735// AtBottom returns true if scrolled to the bottom.
736func (l *SimpleList) AtBottom() bool {
737 return l.offset >= l.renderedHeight-l.height
740// ScrollToItem scrolls to make an item visible.
741func (l *SimpleList) ScrollToItem(idx int) tea.Cmd {
742 if idx < 0 || idx >= len(l.items) {
746 var rItem *renderedItem
747 if idItem, ok := l.items[idx].(interface{ ID() string }); ok {
748 if ri, ok := l.renderedItems[idItem.ID()]; ok {
757 start, end := l.viewPosition()
760 if rItem.start >= start && rItem.end <= end {
765 if rItem.start < start {
766 l.offset = rItem.start
772 l.offset = rItem.end - l.height + 1
779// ScrollToSelected scrolls to the selected item.
780func (l *SimpleList) ScrollToSelected() tea.Cmd {
781 if l.selectedIdx >= 0 {
782 return l.ScrollToItem(l.selectedIdx)
787func (l *SimpleList) clampOffset() {
788 maxOffset := l.renderedHeight - l.height
792 l.offset = ordered.Clamp(l.offset, 0, maxOffset)
795// Mouse and highlighting.
797// HandleMouseDown handles mouse press.
798func (l *SimpleList) HandleMouseDown(x, y int) bool {
799 if x < 0 || y < 0 || x >= l.width || y >= l.height {
803 // Find item at viewport y.
804 contentY := l.offset + y
805 itemIdx := l.findItemAtLine(contentY)
812 l.mouseDownItem = itemIdx
815 l.mouseDragItem = itemIdx
820 l.selectionStartLine = y
821 l.selectionStartCol = x
822 l.selectionEndLine = y
823 l.selectionEndCol = x
826 l.SetSelected(itemIdx)
831// HandleMouseDrag handles mouse drag.
832func (l *SimpleList) HandleMouseDrag(x, y int) bool {
837 // Clamp coordinates to viewport bounds.
838 clampedX := max(0, min(x, l.width-1))
839 clampedY := max(0, min(y, l.height-1))
841 if clampedY >= 0 && clampedY < l.height {
842 contentY := l.offset + clampedY
843 itemIdx := l.findItemAtLine(contentY)
845 l.mouseDragItem = itemIdx
846 l.mouseDragX = clampedX
847 l.mouseDragY = clampedY
851 // Update selection end (clamped to viewport).
852 l.selectionEndLine = clampedY
853 l.selectionEndCol = clampedX
858// HandleMouseUp handles mouse release.
859func (l *SimpleList) HandleMouseUp(x, y int) bool {
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
875// ClearHighlight clears the selection.
876func (l *SimpleList) ClearHighlight() {
877 l.selectionStartLine = -1
878 l.selectionStartCol = -1
879 l.selectionEndLine = -1
880 l.selectionEndCol = -1
886// GetHighlightedText returns the selected text.
887func (l *SimpleList) GetHighlightedText() string {
888 if !l.hasSelection() {
892 return l.renderSelection(l.View())
895func (l *SimpleList) hasSelection() bool {
896 return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
899// renderSelection applies highlighting to the view and extracts text.
900func (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)
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
912 if l.selectionEndLine < l.selectionStartLine {
913 startCol = l.selectionEndCol
914 endCol = l.selectionStartCol
917 // Apply highlighting.
918 for y := startLine; y <= endLine && y < l.height; y++ {
919 if y >= buf.Height() {
925 // Determine column range for this line.
933 colEnd = min(endCol, len(line))
936 // Apply highlight style.
937 for x := colStart; x < colEnd && x < len(line); x++ {
939 if cell != nil && !cell.IsZero() {
941 // Toggle reverse for highlight.
942 if cell.Style.Attrs&uv.AttrReverse != 0 {
943 cell.Style.Attrs &^= uv.AttrReverse
945 cell.Style.Attrs |= uv.AttrReverse
947 buf.SetCell(x, y, cell)
955// findItemAtLine finds the item index at the given content line.
956func (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 {
969// Render returns the view (for compatibility).
970func (l *SimpleList) Render() string {
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list