@@ -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)
}
@@ -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")
}
@@ -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)
}