feat(ui): list: initial implementation of the new List component

Ayman Bagabas created

Change summary

internal/ui/list/items.go     | 101 +++++++++++
internal/ui/list/list.go      | 321 +++++++++++++++++++++++++++++++++++++
internal/ui/list/list_test.go | 155 +++++++++++++++++
internal/ui/list/styles.go    |  25 ++
4 files changed, 602 insertions(+)

Detailed changes

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
+}

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()
+}

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)
+	}
+}

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
+}