diff --git a/internal/ui/list/items.go b/internal/ui/list/items.go index dd259eb71dc8c47121648869fff61d8eb8bca97e..d9d285d0d6607113d5173f07aa3c71cb86c538f6 100644 --- a/internal/ui/list/items.go +++ b/internal/ui/list/items.go @@ -1,101 +1,102 @@ package list -import "strings" +import ( + "io" -// RenderedItem represents a rendered item as a string. -type RenderedItem interface { - Item - // Height returns the height of the rendered item in lines. - Height() int -} + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/glamour/v2" + "github.com/charmbracelet/glamour/v2/ansi" +) -// Item represents a single item in the [List] component. +// Item represents a rendered 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 + // Content is the rendered content of the item. + Content() string + // Height returns the height of the item based on its content. + Height() int } -// StringItem is a simple implementation of the [Item] interface that holds a -// string. -type StringItem struct { - ItemID string - Content string -} +// Gap is [GapItem] to be used as a vertical gap in the list. +var Gap = GapItem{} -// NewStringItem creates a new StringItem with the given ID and content. -func NewStringItem(id, content string) StringItem { - return StringItem{ - ItemID: id, - Content: content, - } -} +// GapItem represents a vertical gap in the list. +type GapItem struct{} -// ID returns the unique identifier of the string item. -func (s StringItem) ID() string { - return s.ItemID +// Content returns the content of the gap item. +func (g GapItem) Content() string { + return "" } -// Render returns the rendered string representation of the string item. -func (s StringItem) Render() string { - return s.Content +// Height returns the height of the gap item. +func (g GapItem) Height() int { + return 1 } -// 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{} +// StringItem represents a simple string item in the list. +type StringItem struct { + content string +} -// ID returns the unique identifier of the gap. -func (g GapItem) ID() string { - return "gap" +// NewStringItem creates a new [StringItem] with the given id and content. +func NewStringItem(content string) StringItem { + return StringItem{ + content: content, + } } -// Render returns the rendered string representation of the gap. -func (g GapItem) Render() string { - return "\n" +// Content returns the content of the string item. +func (s StringItem) Content() string { + return s.content } -// Height returns the height of the rendered gap in lines. -func (g GapItem) Height() int { - return 1 +// Height returns the height of the string item based on its content. +func (s StringItem) Height() int { + return lipgloss.Height(s.content) } -// CachedItem wraps an Item and caches its rendered string representation and height. -type CachedItem struct { - item Item - rendered string - height int +// MarkdownItem represents a markdown item in the list. +type MarkdownItem struct { + StringItem } -// 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, +// NewMarkdownItem creates a new [MarkdownItem] with the given id and content. +func NewMarkdownItem(id, content string) MarkdownItem { + return MarkdownItem{ + StringItem: StringItem{ + content: content, + }, } } -// ID returns the unique identifier of the cached item. -func (c CachedItem) ID() string { - return c.item.ID() +// Content returns the content of the markdown item. +func (m MarkdownItem) Content() string { + return m.StringItem.Content() } -// Item returns the underlying Item. -func (c CachedItem) Item() Item { - return c.item +// Height returns the height of the markdown item based on its content. +func (m MarkdownItem) Height() int { + return m.StringItem.Height() } -// Render returns the cached rendered string representation of the item. -func (c CachedItem) Render() string { - return c.rendered +// MarkdownItemMaxWidth is the maximum width for rendering markdown items. +const MarkdownItemMaxWidth = 120 + +// MarkdownItemRenderer renders [MarkdownItem]s in a [List]. +type MarkdownItemRenderer struct { + Styles *ansi.StyleConfig } -// Height returns the cached height of the rendered item in lines. -func (c CachedItem) Height() int { - return c.height +// Render implements [ItemRenderer]. +func (m *MarkdownItemRenderer) Render(w io.Writer, list *List, index int, item Item) { + width := min(list.Width(), MarkdownItemMaxWidth) + var r *glamour.TermRenderer + if m.Styles != nil { + r = common.MarkdownRenderer(*m.Styles, width) + } else { + r = common.PlainMarkdownRenderer(width) + } + + rendered, _ := r.Render(item.Content()) + _, _ = io.WriteString(w, rendered) } diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 0bc7d320b9e91bea1a5f7f7a034e3cd61fea7c4c..367db35cd4d668c95c300697d874c3602d0dcb2f 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -2,97 +2,64 @@ package list import ( - "image" + "io" "slices" "strings" - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/exp/ordered" - lru "github.com/hashicorp/golang-lru/v2" + uv "github.com/charmbracelet/ultraviolet" ) -// 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 +// ItemRenderer is an interface for rendering items in the list. +type ItemRenderer interface { + // Render renders the item as a string. + Render(w io.Writer, l *List, index int, item Item) +} - // linesCount is the cached total number of rendered lines in the list. - linesCount int +// DefaultItemRenderer is the default implementation of [ItemRenderer]. +type DefaultItemRenderer struct{} - // rect is the bounding rectangle of the list. - rect image.Rectangle +// Render renders the item as a string using its content. +func (r *DefaultItemRenderer) Render(w io.Writer, list *List, index int, item Item) { + _, _ = io.WriteString(w, item.Content()) +} - // reverse indicates if the list is in reverse order. - reverse bool +// List represents a component that renders a list of [Item]s via +// [ItemRenderer]s. It supports focus management and styling. +type List struct { + // items is the master list of items. + items []Item - // hasFocus indicates if the list has focus. - hasFocus bool + // rend is the item renderer for the list. + rend ItemRenderer - styles Styles + // width is the width of the list. + width int - cache *lru.Cache[string, RenderedItem] + // yOffset is the vertical scroll offset. -1 means scrolled to bottom. + yOffset int } // New creates a new [List] component with the given items. -func New(items ...Item) *List { - cache, _ := lru.New[string, RenderedItem](256) +func New(rend ItemRenderer, items ...Item) *List { + if rend == nil { + rend = &DefaultItemRenderer{} + } l := &List{ - idx: -1, - items: items, - styles: DefaultStyles(), - cache: cache, + rend: rend, + yOffset: -1, } + l.Append(items...) 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 +// SetWidth sets the width of the list. +func (l *List) SetWidth(width int) { + l.width = width } // 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 + return l.width } // Len returns the number of items in the list. @@ -100,7 +67,7 @@ func (l *List) Len() int { return len(l.items) } -// Items returns the items in the list. +// Items returns a new slice of all items in the list. func (l *List) Items() []Item { return l.items } @@ -136,186 +103,139 @@ 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() - } +// GotoBottom scrolls the list to the bottom. +func (l *List) GotoBottom() { + l.yOffset = -1 } -// 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 +// GotoTop scrolls the list to the top. +func (l *List) GotoTop() { + l.yOffset = 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 +// TotalHeight returns the total height of all items in the list. +func (l *List) TotalHeight() int { + total := 0 + for _, item := range l.items { + total += item.Height() } - l.idx = len(l.items) - 1 + return total } -// 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 +// ScrollUp scrolls the list up by the given number of lines. +func (l *List) ScrollUp(lines int) { + if l.yOffset == -1 { + // Calculate total height + totalHeight := l.TotalHeight() + l.yOffset = totalHeight } - - 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 - } + l.yOffset -= lines + if l.yOffset < 0 { + l.yOffset = 0 } } -// FocusNext focuses the next item in the list. -func (l *List) FocusNext() { - if !l.hasFocus { - l.Focus() +// ScrollDown scrolls the list down by the given number of lines. +func (l *List) ScrollDown(lines int) { + if l.yOffset == -1 { + // Already at bottom + return } - l.focus(1) -} - -// FocusPrev focuses the previous item in the list. -func (l *List) FocusPrev() { - if !l.hasFocus { - l.Focus() + l.yOffset += lines + totalHeight := l.TotalHeight() + if l.yOffset >= totalHeight { + l.yOffset = -1 // Scroll to bottom } - 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 +// YOffset returns the current vertical scroll offset. +func (l *List) YOffset() int { + if l.yOffset == -1 { + return l.TotalHeight() + } + return l.yOffset } -// ScrollUp scrolls the list up by n lines. -func (l *List) ScrollUp(n int) { - l.scroll(-n) +// Render renders the whole list as a string. +func (l *List) Render() string { + return l.RenderRange(0, len(l.items)) } -// ScrollDown scrolls the list down by n lines. -func (l *List) ScrollDown(n int) { - l.scroll(n) +// Draw draws the list to the given [uv.Screen] in the specified area. +func (l *List) Draw(scr uv.Screen, area uv.Rectangle) { + yOffset := l.YOffset() + rendered := l.RenderLines(yOffset, yOffset+area.Dy()) + uv.NewStyledString(rendered).Draw(scr, area) } -// 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() +// RenderRange renders a range of items from start to end indices. +func (l *List) RenderRange(start, end int) string { + var b strings.Builder + for i := start; i < end && i < len(l.items); i++ { + item, ok := l.At(i) + if !ok { + continue } - } else if n < 0 { - l.yOffset += n - if l.yOffset < 0 { - l.yOffset = 0 + + l.rend.Render(&b, l, i, item) + if i < end-1 && i < len(l.items)-1 { + b.WriteString("\n") } } + + return b.String() } -// 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 - } +// RenderLines renders the list based on the start and end y offsets. +func (l *List) RenderLines(startY, endY int) string { + var b strings.Builder + currentY := 0 + for i := 0; i < len(l.items); i++ { + item, ok := l.At(i) + if !ok { + continue + } - // 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 + itemHeight := item.Height() + if currentY+itemHeight <= startY { + // Skip this item as it's above the startY + currentY += itemHeight + continue + } + if currentY >= endY { + // Stop rendering as we've reached endY + break } - listWidth := l.Width() - itemStyle.GetHorizontalFrameSize() + // Render the item to a temporary buffer if needed + if currentY < startY || currentY+itemHeight > endY { + var tempBuf strings.Builder + l.rend.Render(&tempBuf, l, i, item) + lines := strings.Split(tempBuf.String(), "\n") - 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) + // Calculate the visible lines + startLine := 0 + if currentY < startY { + startLine = startY - currentY + } + endLine := itemHeight + if currentY+itemHeight > endY { + endLine = endY - currentY } - renderedString := itemStyle.Render(cachedItem.Render()) - rendered = append(rendered, renderedString) - } - - if l.reverse { - i-- + // Write only the visible lines + for j := startLine; j < endLine && j < len(lines); j++ { + b.WriteString(lines[j]) + b.WriteString("\n") + } } else { - i++ + // Render the whole item directly + l.rend.Render(&b, l, i, item) + b.WriteString("\n") } - } - if l.reverse { - slices.Reverse(rendered) + currentY += itemHeight } - 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() + return strings.TrimRight(b.String(), "\n") } diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go index 289fc36430d26c499160d3667a5f232db73d36f5..4abd01a6e06886a930e139a1af0c6508d4ee5b6c 100644 --- a/internal/ui/list/list_test.go +++ b/internal/ui/list/list_test.go @@ -1,154 +1,90 @@ package list import ( - "image" "testing" ) func TestNewList(t *testing.T) { items := []Item{ - NewStringItem("1", "Item 1"), - NewStringItem("2", "Item 2"), - NewStringItem("3", "Item 3"), + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), } - bounds := image.Rect(0, 0, 10, 5) - list := New(bounds, items...) + var defaultRend DefaultItemRenderer + list := New(&defaultRend, items...) - if list.Count() != len(items) { - t.Errorf("expected list count %d, got %d", len(items), list.Count()) + if list.Len() != len(items) { + t.Errorf("expected list count %d, got %d", len(items), list.Len()) } - 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()) - } + rendered := list.Render() + expected := "Item 1\nItem 2\nItem 3" + if rendered != expected { + t.Errorf("expected rendered output:\n%s\ngot:\n%s", expected, rendered) } } 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"), - } + var defaultRend DefaultItemRenderer + list := New(&defaultRend, + NewStringItem("Item 1"), + ) - list.Append(newItems...) + list.Append( + NewStringItem("Item 2"), + NewStringItem("Item 3"), + ) - if list.Count() != len(newItems) { - t.Errorf("expected list count %d, got %d", len(newItems), list.Count()) + if list.Len() != 3 { + t.Errorf("expected list count 3, got %d", list.Len()) } - 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()) - } + rendered := list.Render() + expected := "Item 1\nItem 2\nItem 3" + if rendered != expected { + t.Errorf("expected rendered output:\n%s\ngot:\n%s", expected, rendered) } } 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 { + var defaultRend DefaultItemRenderer + list := New(&defaultRend, + NewStringItem("Item 1"), + NewStringItem("Item 2"), + ) + + updated := list.Update(1, NewStringItem("Updated Item 2")) + if !updated { 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" - + expected := "Item 1\nUpdated Item 2" 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"), +func TestListDelete(t *testing.T) { + var defaultRend DefaultItemRenderer + list := New(&defaultRend, + NewStringItem("Item 1"), + NewStringItem("Item 2"), + NewStringItem("Item 3"), + ) + + deleted := list.Delete(1) + if !deleted { + t.Errorf("expected delete to succeed") } - bounds := image.Rect(0, 0, 10, 4) - list := New(bounds, items...) - list.SetReverse(true) + if list.Len() != 2 { + t.Errorf("expected list count 2, got %d", list.Len()) + } rendered := list.Render() - expected := "Line 4\nLine 5\nLine 6\n" - + expected := "Item 1\nItem 3" 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 deleted file mode 100644 index 4c00240b0f2b5e4e5e585d06c06864c1ba31fac6..0000000000000000000000000000000000000000 --- a/internal/ui/list/styles.go +++ /dev/null @@ -1,25 +0,0 @@ -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 -}