diff --git a/internal/ui/list/items.go b/internal/ui/list/items.go new file mode 100644 index 0000000000000000000000000000000000000000..dd259eb71dc8c47121648869fff61d8eb8bca97e --- /dev/null +++ b/internal/ui/list/items.go @@ -0,0 +1,101 @@ +package list + +import "strings" + +// RenderedItem represents a rendered item as a string. +type RenderedItem interface { + Item + // Height returns the height of the rendered item in lines. + Height() int +} + +// Item represents a single item in the [List] component. +type Item interface { + // ID returns the unique identifier of the item. + ID() string + // Render returns the rendered string representation of the item. + Render() string +} + +// StringItem is a simple implementation of the [Item] interface that holds a +// string. +type StringItem struct { + ItemID string + Content string +} + +// NewStringItem creates a new StringItem with the given ID and content. +func NewStringItem(id, content string) StringItem { + return StringItem{ + ItemID: id, + Content: content, + } +} + +// ID returns the unique identifier of the string item. +func (s StringItem) ID() string { + return s.ItemID +} + +// Render returns the rendered string representation of the string item. +func (s StringItem) Render() string { + return s.Content +} + +// Gap is [GapItem] to be used as a vertical gap in the list. +var Gap = GapItem{} + +// GapItem is a one-line vertical gap in the list. +type GapItem struct{} + +// ID returns the unique identifier of the gap. +func (g GapItem) ID() string { + return "gap" +} + +// Render returns the rendered string representation of the gap. +func (g GapItem) Render() string { + return "\n" +} + +// Height returns the height of the rendered gap in lines. +func (g GapItem) Height() int { + return 1 +} + +// CachedItem wraps an Item and caches its rendered string representation and height. +type CachedItem struct { + item Item + rendered string + height int +} + +// NewCachedItem creates a new CachedItem from the given Item. +func NewCachedItem(item Item, rendered string) CachedItem { + height := 1 + strings.Count(rendered, "\n") + return CachedItem{ + item: item, + rendered: rendered, + height: height, + } +} + +// ID returns the unique identifier of the cached item. +func (c CachedItem) ID() string { + return c.item.ID() +} + +// Item returns the underlying Item. +func (c CachedItem) Item() Item { + return c.item +} + +// Render returns the cached rendered string representation of the item. +func (c CachedItem) Render() string { + return c.rendered +} + +// Height returns the cached height of the rendered item in lines. +func (c CachedItem) Height() int { + return c.height +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..0bc7d320b9e91bea1a5f7f7a034e3cd61fea7c4c --- /dev/null +++ b/internal/ui/list/list.go @@ -0,0 +1,321 @@ +// Package list implements a UI component for displaying a list of items. +package list + +import ( + "image" + "slices" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/ordered" + lru "github.com/hashicorp/golang-lru/v2" +) + +// List represents a component that display a list of [Item]s. +type List struct { + // idx is the current focused index in the list. -1 means no item is focused. + idx int + + items []Item + + // yOffset is the current vertical offset for scrolling. + yOffset int + + // linesCount is the cached total number of rendered lines in the list. + linesCount int + + // rect is the bounding rectangle of the list. + rect image.Rectangle + + // reverse indicates if the list is in reverse order. + reverse bool + + // hasFocus indicates if the list has focus. + hasFocus bool + + styles Styles + + cache *lru.Cache[string, RenderedItem] +} + +// New creates a new [List] component with the given items. +func New(items ...Item) *List { + cache, _ := lru.New[string, RenderedItem](256) + l := &List{ + idx: -1, + items: items, + styles: DefaultStyles(), + cache: cache, + } + return l +} + +// SetStyles sets the styles for the list. +func (l *List) SetStyles(s Styles) { + l.styles = s +} + +// SetReverse sets the reverse order of the list. +func (l *List) SetReverse(reverse bool) { + l.reverse = reverse +} + +// IsReverse returns true if the list is in reverse order. +func (l *List) IsReverse() bool { + return l.reverse +} + +// SetBounds sets the bounding rectangle of the list. +func (l *List) SetBounds(rect image.Rectangle) { + if l.rect.Dx() != rect.Dx() { + // Clear the cache if the width has changed. This is necessary because + // the rendered items are wrapped to the width of the list. + l.cache.Purge() + } + l.rect = rect +} + +// Width returns the width of the list. +func (l *List) Width() int { + return l.rect.Dx() +} + +// Height returns the height of the list. +func (l *List) Height() int { + return l.rect.Dy() +} + +// X returns the X position of the list. +func (l *List) X() int { + return l.rect.Min.X +} + +// Y returns the Y position of the list. +func (l *List) Y() int { + return l.rect.Min.Y +} + +// Len returns the number of items in the list. +func (l *List) Len() int { + return len(l.items) +} + +// Items returns the items in the list. +func (l *List) Items() []Item { + return l.items +} + +// Update updates an item at the given index. +func (l *List) Update(index int, item Item) bool { + if index < 0 || index >= len(l.items) { + return false + } + l.items[index] = item + return true +} + +// At returns the item at the given index. +func (l *List) At(index int) (Item, bool) { + if index < 0 || index >= len(l.items) { + return nil, false + } + return l.items[index], true +} + +// Delete removes the item at the given index. +func (l *List) Delete(index int) bool { + if index < 0 || index >= len(l.items) { + return false + } + l.items = slices.Delete(l.items, index, index+1) + return true +} + +// Append adds new items to the end of the list. +func (l *List) Append(items ...Item) { + l.items = append(l.items, items...) +} + +// Focus focuses the list +func (l *List) Focus() { + l.hasFocus = true + if l.idx < 0 && len(l.items) > 0 { + l.FocusFirst() + } +} + +// FocusFirst focuses the first item in the list. +func (l *List) FocusFirst() { + if !l.hasFocus { + l.Focus() + } + if l.reverse { + l.idx = len(l.items) - 1 + return + } + l.idx = 0 +} + +// FocusLast focuses the last item in the list. +func (l *List) FocusLast() { + if !l.hasFocus { + l.Focus() + } + if l.reverse { + l.idx = 0 + return + } + l.idx = len(l.items) - 1 +} + +// focus moves the focus by n offset. Positive n moves down, negative n moves up. +func (l *List) focus(n int) { + if l.reverse { + n = -n + } + + if n < 0 { + if l.idx+n < 0 { + l.idx = 0 + } else { + l.idx += n + } + } else if n > 0 { + if l.idx+n >= len(l.items) { + l.idx = len(l.items) - 1 + } else { + l.idx += n + } + } +} + +// FocusNext focuses the next item in the list. +func (l *List) FocusNext() { + if !l.hasFocus { + l.Focus() + } + l.focus(1) +} + +// FocusPrev focuses the previous item in the list. +func (l *List) FocusPrev() { + if !l.hasFocus { + l.Focus() + } + l.focus(-1) +} + +// FocusedItem returns the currently focused item. +func (l *List) FocusedItem() (Item, bool) { + return l.At(l.idx) +} + +// Blur removes focus from the list. +func (l *List) Blur() { + l.hasFocus = false +} + +// ScrollUp scrolls the list up by n lines. +func (l *List) ScrollUp(n int) { + l.scroll(-n) +} + +// ScrollDown scrolls the list down by n lines. +func (l *List) ScrollDown(n int) { + l.scroll(n) +} + +// scroll scrolls the list by n lines. Positive n scrolls down, negative n scrolls up. +func (l *List) scroll(n int) { + if l.reverse { + n = -n + } + + if n > 0 { + l.yOffset += n + if l.linesCount > l.Height() && l.yOffset > l.linesCount-l.Height() { + l.yOffset = l.linesCount - l.Height() + } + } else if n < 0 { + l.yOffset += n + if l.yOffset < 0 { + l.yOffset = 0 + } + } +} + +// Render renders the first n items that fit within the list's height and +// returns the rendered string. +func (l *List) Render() string { + var rendered []string + availableHeight := l.Height() + i := 0 + if l.reverse { + i = len(l.items) - 1 + } + + // Render items until we run out of space + for i >= 0 && i < len(l.items) { + itemStyle := l.styles.NormalItem + if l.hasFocus && l.idx == i { + itemStyle = l.styles.FocusedItem + } + + listWidth := l.Width() - itemStyle.GetHorizontalFrameSize() + + item, ok := l.At(i) + if ok { + cachedItem, ok := l.cache.Get(item.ID()) + if !ok { + renderedItem := lipgloss.Wrap(item.Render(), listWidth, "") + cachedItem = NewCachedItem(item, renderedItem) + l.cache.Add(item.ID(), cachedItem) + } + + renderedString := itemStyle.Render(cachedItem.Render()) + rendered = append(rendered, renderedString) + } + + if l.reverse { + i-- + } else { + i++ + } + } + + if l.reverse { + slices.Reverse(rendered) + } + + var sb strings.Builder + for i, item := range rendered { + sb.WriteString(item) + if i < len(rendered)-1 { + sb.WriteString("\n") + } + } + + linesCount := strings.Count(sb.String(), "\n") + 1 + l.linesCount = linesCount + + if linesCount <= availableHeight { + return sb.String() + } + + lines := strings.Split(sb.String(), "\n") + yOffset := ordered.Clamp(l.yOffset, 0, linesCount-availableHeight) + if l.reverse { + start := len(lines) - availableHeight - yOffset + end := max(availableHeight, len(lines)-l.yOffset) + return strings.Join(lines[start:end], "\n") + } + + start := 0 + yOffset + end := min(len(lines), availableHeight+yOffset) + return strings.Join(lines[start:end], "\n") +} + +// View returns the rendered view of the list. +func (l *List) View() string { + return l.Render() +} diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..289fc36430d26c499160d3667a5f232db73d36f5 --- /dev/null +++ b/internal/ui/list/list_test.go @@ -0,0 +1,155 @@ +package list + +import ( + "image" + "testing" +) + +func TestNewList(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + } + + bounds := image.Rect(0, 0, 10, 5) + list := New(bounds, items...) + + if list.Count() != len(items) { + t.Errorf("expected list count %d, got %d", len(items), list.Count()) + } + + for i, item := range items { + gotItem, ok := list.At(i) + if !ok { + t.Errorf("expected item at index %d to exist", i) + continue + } + if gotItem.ID() != item.ID() { + t.Errorf("expected item ID %s, got %s", item.ID(), gotItem.ID()) + } + } +} + +func TestListAppend(t *testing.T) { + bounds := image.Rect(0, 0, 10, 5) + list := New(bounds) + + newItems := []Item{ + NewStringItem("1", "Item A"), + NewStringItem("2", "Item B"), + } + + list.Append(newItems...) + + if list.Count() != len(newItems) { + t.Errorf("expected list count %d, got %d", len(newItems), list.Count()) + } + + for i, item := range newItems { + gotItem, ok := list.At(i) + if !ok { + t.Errorf("expected item at index %d to exist", i) + continue + } + if gotItem.ID() != item.ID() { + t.Errorf("expected item ID %s, got %s", item.ID(), gotItem.ID()) + } + } +} + +func TestListUpdate(t *testing.T) { + items := []Item{ + NewStringItem("1", "Old Item 1"), + NewStringItem("2", "Old Item 2"), + } + + bounds := image.Rect(0, 0, 10, 5) + list := New(bounds, items...) + + updatedItem := NewStringItem("1", "New Item 1") + success := list.Update(0, updatedItem) + if !success { + t.Errorf("expected update to succeed") + } + + gotItem, ok := list.At(0) + if !ok { + t.Errorf("expected item at index 0 to exist") + } else if gotItem.ID() != updatedItem.ID() { + t.Errorf("expected item ID %s, got %s", updatedItem.ID(), gotItem.ID()) + } +} + +func TestListDelete(t *testing.T) { + items := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("2", "Item 2"), + NewStringItem("3", "Item 3"), + } + + bounds := image.Rect(0, 0, 10, 5) + list := New(bounds, items...) + + success := list.Delete(1) + if !success { + t.Errorf("expected delete to succeed") + } + + if list.Count() != 2 { + t.Errorf("expected list count 2, got %d", list.Count()) + } + + expectedItems := []Item{ + NewStringItem("1", "Item 1"), + NewStringItem("3", "Item 3"), + } + + for i, item := range expectedItems { + gotItem, ok := list.At(i) + if !ok { + t.Errorf("expected item at index %d to exist", i) + continue + } + if gotItem.ID() != item.ID() { + t.Errorf("expected item ID %s, got %s", item.ID(), gotItem.ID()) + } + } +} + +func TestListRender(t *testing.T) { + items := []Item{ + NewStringItem("1", "Line 1\nLine 2"), + NewStringItem("2", "Line 3"), + NewStringItem("3", "Line 4\nLine 5\nLine 6"), + } + + bounds := image.Rect(0, 0, 10, 4) + list := New(bounds, items...) + + rendered := list.Render() + expected := "Line 1\nLine 2\nLine 3\n" + + if rendered != expected { + t.Errorf("expected rendered output:\n%s\ngot:\n%s", expected, rendered) + } +} + +func TestListRenderReverse(t *testing.T) { + items := []Item{ + NewStringItem("1", "Line 1\nLine 2"), + NewStringItem("2", "Line 3"), + NewStringItem("3", "Line 4\nLine 5\nLine 6"), + } + + bounds := image.Rect(0, 0, 10, 4) + list := New(bounds, items...) + list.SetReverse(true) + + rendered := list.Render() + expected := "Line 4\nLine 5\nLine 6\n" + + if rendered != expected { + t.Errorf("expected rendered output:\n%s\ngot:\n%s", expected, rendered) + } +} diff --git a/internal/ui/list/styles.go b/internal/ui/list/styles.go new file mode 100644 index 0000000000000000000000000000000000000000..4c00240b0f2b5e4e5e585d06c06864c1ba31fac6 --- /dev/null +++ b/internal/ui/list/styles.go @@ -0,0 +1,25 @@ +package list + +import ( + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" +) + +// Styles holds the styles for the List component. +type Styles struct { + NormalItem lipgloss.Style + FocusedItem lipgloss.Style +} + +// DefaultStyles returns the default styles for the List component. +func DefaultStyles() (s Styles) { + s.NormalItem = lipgloss.NewStyle(). + MarginLeft(1). + PaddingLeft(1) + s.FocusedItem = lipgloss.NewStyle(). + Border(lipgloss.ThickBorder(), false, false, false, true). + PaddingLeft(1). + BorderForeground(charmtone.Guac) + + return s +}