Detailed changes
@@ -0,0 +1,276 @@
+package list_test
+
+import (
+ "fmt"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/ui/list"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+// Example demonstrates basic list usage with string items.
+func Example_basic() {
+ // Create some items
+ items := []list.Item{
+ list.NewStringItem("1", "First item"),
+ list.NewStringItem("2", "Second item"),
+ list.NewStringItem("3", "Third item"),
+ }
+
+ // Create a list with options
+ l := list.New(items...)
+ l.SetSize(80, 10)
+ l.SetSelectedIndex(0)
+ if true {
+ l.Focus()
+ }
+
+ // Draw to a screen buffer
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ l.Draw(&screen, area)
+
+ // Render to string
+ output := screen.Render()
+ fmt.Println(output)
+}
+
+// BorderedItem demonstrates a focusable item with borders.
+type BorderedItem struct {
+ id string
+ content string
+ focused bool
+ width int
+}
+
+func NewBorderedItem(id, content string) *BorderedItem {
+ return &BorderedItem{
+ id: id,
+ content: content,
+ width: 80,
+ }
+}
+
+func (b *BorderedItem) ID() string {
+ return b.id
+}
+
+func (b *BorderedItem) Height(width int) int {
+ // Account for border (2 lines for top and bottom)
+ b.width = width // Update width for rendering
+ return lipgloss.Height(b.render())
+}
+
+func (b *BorderedItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ rendered := b.render()
+ styled := uv.NewStyledString(rendered)
+ styled.Draw(scr, area)
+}
+
+func (b *BorderedItem) render() string {
+ style := lipgloss.NewStyle().
+ Width(b.width-4).
+ Padding(0, 1)
+
+ if b.focused {
+ style = style.
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("205"))
+ } else {
+ style = style.
+ Border(lipgloss.NormalBorder()).
+ BorderForeground(lipgloss.Color("240"))
+ }
+
+ return style.Render(b.content)
+}
+
+func (b *BorderedItem) Focus() {
+ b.focused = true
+}
+
+func (b *BorderedItem) Blur() {
+ b.focused = false
+}
+
+func (b *BorderedItem) IsFocused() bool {
+ return b.focused
+}
+
+// Example demonstrates focusable items with borders.
+func Example_focusable() {
+ // Create focusable items
+ items := []list.Item{
+ NewBorderedItem("1", "Focusable Item 1"),
+ NewBorderedItem("2", "Focusable Item 2"),
+ NewBorderedItem("3", "Focusable Item 3"),
+ }
+
+ // Create list with first item selected and focused
+ l := list.New(items...)
+ l.SetSize(80, 20)
+ l.SetSelectedIndex(0)
+ if true {
+ l.Focus()
+ }
+
+ // Draw to screen
+ screen := uv.NewScreenBuffer(80, 20)
+ area := uv.Rect(0, 0, 80, 20)
+ l.Draw(&screen, area)
+
+ // The first item will have a colored border since it's focused
+ output := screen.Render()
+ fmt.Println(output)
+}
+
+// Example demonstrates dynamic item updates.
+func Example_dynamicUpdates() {
+ items := []list.Item{
+ list.NewStringItem("1", "Item 1"),
+ list.NewStringItem("2", "Item 2"),
+ }
+
+ l := list.New(items...)
+ l.SetSize(80, 10)
+
+ // Draw initial state
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ l.Draw(&screen, area)
+
+ // Update an item
+ l.UpdateItem("2", list.NewStringItem("2", "Updated Item 2"))
+
+ // Draw again - only changed item is re-rendered
+ l.Draw(&screen, area)
+
+ // Append a new item
+ l.AppendItem(list.NewStringItem("3", "New Item 3"))
+
+ // Draw again - master buffer grows efficiently
+ l.Draw(&screen, area)
+
+ output := screen.Render()
+ fmt.Println(output)
+}
+
+// Example demonstrates scrolling with a large list.
+func Example_scrolling() {
+ // Create many items
+ items := make([]list.Item, 100)
+ for i := range items {
+ items[i] = list.NewStringItem(
+ fmt.Sprintf("%d", i),
+ fmt.Sprintf("Item %d", i),
+ )
+ }
+
+ // Create list with small viewport
+ l := list.New(items...)
+ l.SetSize(80, 10)
+ l.SetSelectedIndex(0)
+
+ // Draw initial view (shows items 0-9)
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ l.Draw(&screen, area)
+
+ // Scroll down
+ l.ScrollBy(5)
+ l.Draw(&screen, area) // Now shows items 5-14
+
+ // Jump to specific item
+ l.ScrollToItem("50")
+ l.Draw(&screen, area) // Now shows item 50 and neighbors
+
+ // Scroll to bottom
+ l.ScrollToBottom()
+ l.Draw(&screen, area) // Now shows last 10 items
+
+ output := screen.Render()
+ fmt.Println(output)
+}
+
+// VariableHeightItem demonstrates items with different heights.
+type VariableHeightItem struct {
+ id string
+ lines []string
+ width int
+}
+
+func NewVariableHeightItem(id string, lines []string) *VariableHeightItem {
+ return &VariableHeightItem{
+ id: id,
+ lines: lines,
+ width: 80,
+ }
+}
+
+func (v *VariableHeightItem) ID() string {
+ return v.id
+}
+
+func (v *VariableHeightItem) Height(width int) int {
+ return len(v.lines)
+}
+
+func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ content := ""
+ for i, line := range v.lines {
+ if i > 0 {
+ content += "\n"
+ }
+ content += line
+ }
+ styled := uv.NewStyledString(content)
+ styled.Draw(scr, area)
+}
+
+// Example demonstrates variable height items.
+func Example_variableHeights() {
+ items := []list.Item{
+ NewVariableHeightItem("1", []string{"Short item"}),
+ NewVariableHeightItem("2", []string{
+ "This is a taller item",
+ "that spans multiple lines",
+ "to demonstrate variable heights",
+ }),
+ NewVariableHeightItem("3", []string{"Another short item"}),
+ NewVariableHeightItem("4", []string{
+ "A medium height item",
+ "with two lines",
+ }),
+ }
+
+ l := list.New(items...)
+ l.SetSize(80, 15)
+
+ screen := uv.NewScreenBuffer(80, 15)
+ area := uv.Rect(0, 0, 80, 15)
+ l.Draw(&screen, area)
+
+ output := screen.Render()
+ fmt.Println(output)
+}
+
+// Example demonstrates markdown items in a list.
+func Example_markdown() {
+ // Create markdown items
+ items := []list.Item{
+ list.NewMarkdownItem("1", "# Welcome\n\nThis is a **markdown** item."),
+ list.NewMarkdownItem("2", "## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
+ list.NewMarkdownItem("3", "### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"),
+ }
+
+ // Create list
+ l := list.New(items...)
+ l.SetSize(80, 20)
+
+ screen := uv.NewScreenBuffer(80, 20)
+ area := uv.Rect(0, 0, 80, 20)
+ l.Draw(&screen, area)
+
+ output := screen.Render()
+ fmt.Println(output)
+}
@@ -0,0 +1,343 @@
+package list
+
+import (
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/glamour/v2"
+ "github.com/charmbracelet/glamour/v2/ansi"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/ultraviolet/screen"
+)
+
+// Item represents a list item that can draw itself to a UV buffer.
+// Items implement the uv.Drawable interface.
+type Item interface {
+ uv.Drawable
+
+ // ID returns unique identifier for this item.
+ ID() string
+
+ // Height returns the item's height in lines for the given width.
+ // This allows items to calculate height based on text wrapping and available space.
+ Height(width int) int
+}
+
+// Focusable is an optional interface for items that support focus.
+// When implemented, items can change appearance when focused (borders, colors, etc).
+type Focusable interface {
+ Focus()
+ Blur()
+ IsFocused() bool
+}
+
+// BaseFocusable provides common focus state and styling for items.
+// Embed this type to add focus behavior to any item.
+type BaseFocusable struct {
+ focused bool
+ focusStyle *lipgloss.Style
+ blurStyle *lipgloss.Style
+}
+
+// Focus implements Focusable interface.
+func (b *BaseFocusable) Focus() {
+ b.focused = true
+}
+
+// Blur implements Focusable interface.
+func (b *BaseFocusable) Blur() {
+ b.focused = false
+}
+
+// IsFocused implements Focusable interface.
+func (b *BaseFocusable) IsFocused() bool {
+ return b.focused
+}
+
+// HasFocusStyles returns true if both focus and blur styles are configured.
+func (b *BaseFocusable) HasFocusStyles() bool {
+ return b.focusStyle != nil && b.blurStyle != nil
+}
+
+// CurrentStyle returns the current style based on focus state.
+// Returns nil if no styles are configured, or if the current state's style is nil.
+func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
+ if b.focused {
+ return b.focusStyle
+ }
+ return b.blurStyle
+}
+
+// SetFocusStyles sets the focus and blur styles.
+func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
+ b.focusStyle = focusStyle
+ b.blurStyle = blurStyle
+}
+
+// StringItem is a simple string-based item with optional text wrapping.
+// It caches rendered content by width for efficient repeated rendering.
+// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
+type StringItem struct {
+ BaseFocusable
+ id string
+ content string // Raw content string (may contain ANSI styles)
+ wrap bool // Whether to wrap text
+
+ // Cache for rendered content at specific widths
+ // Key: width, Value: string
+ cache map[int]string
+}
+
+// NewStringItem creates a new string item with the given ID and content.
+func NewStringItem(id, content string) *StringItem {
+ return &StringItem{
+ id: id,
+ content: content,
+ wrap: false,
+ cache: make(map[int]string),
+ }
+}
+
+// NewWrappingStringItem creates a new string item that wraps text to fit width.
+func NewWrappingStringItem(id, content string) *StringItem {
+ return &StringItem{
+ id: id,
+ content: content,
+ wrap: true,
+ cache: make(map[int]string),
+ }
+}
+
+// WithFocusStyles sets the focus and blur styles for the string item.
+// If both styles are non-nil, the item will implement Focusable.
+func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
+ s.SetFocusStyles(focusStyle, blurStyle)
+ return s
+}
+
+// ID implements Item.
+func (s *StringItem) ID() string {
+ return s.id
+}
+
+// Height implements Item.
+func (s *StringItem) Height(width int) int {
+ // Calculate content width if we have styles
+ contentWidth := width
+ if style := s.CurrentStyle(); style != nil {
+ hFrameSize := style.GetHorizontalFrameSize()
+ if hFrameSize > 0 {
+ contentWidth -= hFrameSize
+ }
+ }
+
+ var lines int
+ if !s.wrap {
+ // No wrapping - height is just the number of newlines + 1
+ lines = strings.Count(s.content, "\n") + 1
+ } else {
+ // Use lipgloss.Wrap to wrap the content and count lines
+ // This preserves ANSI styles and is much faster than rendering to a buffer
+ wrapped := lipgloss.Wrap(s.content, contentWidth, "")
+ lines = strings.Count(wrapped, "\n") + 1
+ }
+
+ // Add vertical frame size if we have styles
+ if style := s.CurrentStyle(); style != nil {
+ lines += style.GetVerticalFrameSize()
+ }
+
+ return lines
+}
+
+// Draw implements Item and uv.Drawable.
+func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ width := area.Dx()
+
+ // Check cache first
+ content, ok := s.cache[width]
+ if !ok {
+ // Not cached - create and cache
+ content = s.content
+ if s.wrap {
+ // Wrap content using lipgloss
+ content = lipgloss.Wrap(s.content, width, "")
+ }
+ s.cache[width] = content
+ }
+
+ // Apply focus/blur styling if configured
+ if style := s.CurrentStyle(); style != nil {
+ content = style.Width(width).Render(content)
+ }
+
+ // Draw the styled string
+ styled := uv.NewStyledString(content)
+ styled.Draw(scr, area)
+}
+
+// MarkdownItem renders markdown content using Glamour.
+// It caches all rendered content by width for efficient repeated rendering.
+// The wrap width is capped at 120 cells by default to ensure readable line lengths.
+// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
+type MarkdownItem struct {
+ BaseFocusable
+ id string
+ markdown string // Raw markdown content
+ styleConfig *ansi.StyleConfig // Optional style configuration
+ maxWidth int // Maximum wrap width (default 120)
+
+ // Cache for rendered content at specific widths
+ // Key: width (capped to maxWidth), Value: rendered markdown string
+ cache map[int]string
+}
+
+// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering.
+const DefaultMarkdownMaxWidth = 120
+
+// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
+// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
+func NewMarkdownItem(id, markdown string) *MarkdownItem {
+ m := &MarkdownItem{
+ id: id,
+ markdown: markdown,
+ maxWidth: DefaultMarkdownMaxWidth,
+ cache: make(map[int]string),
+ }
+
+ return m
+}
+
+// WithStyleConfig sets a custom Glamour style configuration for the markdown item.
+func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem {
+ m.styleConfig = &styleConfig
+ return m
+}
+
+// WithMaxWidth sets the maximum wrap width for markdown rendering.
+func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem {
+ m.maxWidth = maxWidth
+ return m
+}
+
+// WithFocusStyles sets the focus and blur styles for the markdown item.
+// If both styles are non-nil, the item will implement Focusable.
+func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
+ m.SetFocusStyles(focusStyle, blurStyle)
+ return m
+}
+
+// ID implements Item.
+func (m *MarkdownItem) ID() string {
+ return m.id
+}
+
+// Height implements Item.
+func (m *MarkdownItem) Height(width int) int {
+ // Render the markdown to get its height
+ rendered := m.renderMarkdown(width)
+
+ // Apply focus/blur styling if configured to get accurate height
+ if style := m.CurrentStyle(); style != nil {
+ rendered = style.Render(rendered)
+ }
+
+ return strings.Count(rendered, "\n") + 1
+}
+
+// Draw implements Item and uv.Drawable.
+func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ width := area.Dx()
+ rendered := m.renderMarkdown(width)
+
+ // Apply focus/blur styling if configured
+ if style := m.CurrentStyle(); style != nil {
+ rendered = style.Render(rendered)
+ }
+
+ // Draw the rendered markdown
+ styled := uv.NewStyledString(rendered)
+ styled.Draw(scr, area)
+}
+
+// renderMarkdown renders the markdown content at the given width, using cache if available.
+// Width is always capped to maxWidth to ensure readable line lengths.
+func (m *MarkdownItem) renderMarkdown(width int) string {
+ // Cap width to maxWidth
+ cappedWidth := min(width, m.maxWidth)
+
+ // Check cache first (always cache all rendered markdown)
+ if cached, ok := m.cache[cappedWidth]; ok {
+ return cached
+ }
+
+ // Not cached - render now
+ opts := []glamour.TermRendererOption{
+ glamour.WithWordWrap(cappedWidth),
+ }
+
+ // Add style config if provided
+ if m.styleConfig != nil {
+ opts = append(opts, glamour.WithStyles(*m.styleConfig))
+ }
+
+ renderer, err := glamour.NewTermRenderer(opts...)
+ if err != nil {
+ // Fallback to plain text on error
+ return m.markdown
+ }
+
+ rendered, err := renderer.Render(m.markdown)
+ if err != nil {
+ // Fallback to plain text on error
+ return m.markdown
+ }
+
+ // Trim trailing whitespace
+ rendered = strings.TrimRight(rendered, "\n\r ")
+
+ // Always cache
+ m.cache[cappedWidth] = rendered
+
+ return rendered
+}
+
+// Gap is a 1-line spacer item used to add gaps between items.
+var Gap = NewSpacerItem("spacer-gap", 1)
+
+// SpacerItem is an empty item that takes up vertical space.
+// Useful for adding gaps between items in a list.
+type SpacerItem struct {
+ id string
+ height int
+}
+
+var _ Item = (*SpacerItem)(nil)
+
+// NewSpacerItem creates a new spacer item with the given ID and height in lines.
+func NewSpacerItem(id string, height int) *SpacerItem {
+ return &SpacerItem{
+ id: id,
+ height: height,
+ }
+}
+
+// ID implements Item.
+func (s *SpacerItem) ID() string {
+ return s.id
+}
+
+// Height implements Item.
+func (s *SpacerItem) Height(width int) int {
+ return s.height
+}
+
+// Draw implements Item.
+// Spacer items don't draw anything, they just take up space.
+func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ // Ensure the area is filled with spaces to clear any existing content
+ spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height))
+ if spacerArea.Overlaps(area) {
+ screen.ClearArea(scr, spacerArea)
+ }
+}
@@ -0,0 +1,602 @@
+package list
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/charmbracelet/glamour/v2/ansi"
+ uv "github.com/charmbracelet/ultraviolet"
+)
+
+func TestRenderHelper(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 10)
+
+ // Render to string
+ output := l.Render()
+
+ if len(output) == 0 {
+ t.Error("expected non-empty output from Render()")
+ }
+
+ // Check that output contains the items
+ if !strings.Contains(output, "Item 1") {
+ t.Error("expected output to contain 'Item 1'")
+ }
+ if !strings.Contains(output, "Item 2") {
+ t.Error("expected output to contain 'Item 2'")
+ }
+ if !strings.Contains(output, "Item 3") {
+ t.Error("expected output to contain 'Item 3'")
+ }
+}
+
+func TestRenderWithScrolling(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ NewStringItem("4", "Item 4"),
+ NewStringItem("5", "Item 5"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 2) // Small viewport
+
+ // Initial render should show first 2 items
+ output := l.Render()
+ if !strings.Contains(output, "Item 1") {
+ t.Error("expected output to contain 'Item 1'")
+ }
+ if !strings.Contains(output, "Item 2") {
+ t.Error("expected output to contain 'Item 2'")
+ }
+ if strings.Contains(output, "Item 3") {
+ t.Error("expected output to NOT contain 'Item 3' in initial view")
+ }
+
+ // Scroll down and render
+ l.ScrollBy(2)
+ output = l.Render()
+
+ // Now should show items 3 and 4
+ if strings.Contains(output, "Item 1") {
+ t.Error("expected output to NOT contain 'Item 1' after scrolling")
+ }
+ if !strings.Contains(output, "Item 3") {
+ t.Error("expected output to contain 'Item 3' after scrolling")
+ }
+ if !strings.Contains(output, "Item 4") {
+ t.Error("expected output to contain 'Item 4' after scrolling")
+ }
+}
+
+func TestRenderEmptyList(t *testing.T) {
+ l := New()
+ l.SetSize(80, 10)
+
+ output := l.Render()
+ if output != "" {
+ t.Errorf("expected empty output for empty list, got: %q", output)
+ }
+}
+
+func TestRenderVsDrawConsistency(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 10)
+
+ // Render using Render() method
+ renderOutput := l.Render()
+
+ // Render using Draw() method
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ l.Draw(&screen, area)
+ drawOutput := screen.Render()
+
+ // Trim any trailing whitespace for comparison
+ renderOutput = strings.TrimRight(renderOutput, "\n")
+ drawOutput = strings.TrimRight(drawOutput, "\n")
+
+ // Both methods should produce the same output
+ if renderOutput != drawOutput {
+ t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q",
+ renderOutput, drawOutput)
+ }
+}
+
+func BenchmarkRender(b *testing.B) {
+ items := make([]Item, 100)
+ for i := range items {
+ items[i] = NewStringItem(string(rune(i)), "Item content here")
+ }
+
+ l := New(items...)
+ l.SetSize(80, 24)
+ l.Render() // Prime the buffer
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = l.Render()
+ }
+}
+
+func BenchmarkRenderWithScrolling(b *testing.B) {
+ items := make([]Item, 1000)
+ for i := range items {
+ items[i] = NewStringItem(string(rune(i)), "Item content here")
+ }
+
+ l := New(items...)
+ l.SetSize(80, 24)
+ l.Render() // Prime the buffer
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ l.ScrollBy(1)
+ _ = l.Render()
+ }
+}
+
+func TestStringItemCache(t *testing.T) {
+ item := NewStringItem("1", "Test content")
+
+ // First draw at width 80 should populate cache
+ screen1 := uv.NewScreenBuffer(80, 5)
+ area1 := uv.Rect(0, 0, 80, 5)
+ item.Draw(&screen1, area1)
+
+ if len(item.cache) != 1 {
+ t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache))
+ }
+ if _, ok := item.cache[80]; !ok {
+ t.Error("expected cache to have entry for width 80")
+ }
+
+ // Second draw at same width should reuse cache
+ screen2 := uv.NewScreenBuffer(80, 5)
+ area2 := uv.Rect(0, 0, 80, 5)
+ item.Draw(&screen2, area2)
+
+ if len(item.cache) != 1 {
+ t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache))
+ }
+
+ // Draw at different width should add to cache
+ screen3 := uv.NewScreenBuffer(40, 5)
+ area3 := uv.Rect(0, 0, 40, 5)
+ item.Draw(&screen3, area3)
+
+ if len(item.cache) != 2 {
+ t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache))
+ }
+ if _, ok := item.cache[40]; !ok {
+ t.Error("expected cache to have entry for width 40")
+ }
+}
+
+func TestWrappingItemHeight(t *testing.T) {
+ // Short text that fits in one line
+ item1 := NewWrappingStringItem("1", "Short")
+ if h := item1.Height(80); h != 1 {
+ t.Errorf("expected height 1 for short text, got %d", h)
+ }
+
+ // Long text that wraps
+ longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
+ item2 := NewWrappingStringItem("2", longText)
+
+ // At width 80, should be fewer lines than width 20
+ height80 := item2.Height(80)
+ height20 := item2.Height(20)
+
+ if height20 <= height80 {
+ t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)",
+ height20, height80)
+ }
+
+ // Non-wrapping version should always be 1 line
+ item3 := NewStringItem("3", longText)
+ if h := item3.Height(20); h != 1 {
+ t.Errorf("expected height 1 for non-wrapping item, got %d", h)
+ }
+}
+
+func TestMarkdownItemBasic(t *testing.T) {
+ markdown := "# Hello\n\nThis is a **test**."
+ item := NewMarkdownItem("1", markdown)
+
+ if item.ID() != "1" {
+ t.Errorf("expected ID '1', got '%s'", item.ID())
+ }
+
+ // Test that height is calculated
+ height := item.Height(80)
+ if height < 1 {
+ t.Errorf("expected height >= 1, got %d", height)
+ }
+
+ // Test drawing
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ item.Draw(&screen, area)
+
+ // Should not panic and should render something
+ rendered := screen.Render()
+ if len(rendered) == 0 {
+ t.Error("expected non-empty rendered output")
+ }
+}
+
+func TestMarkdownItemCache(t *testing.T) {
+ markdown := "# Test\n\nSome content."
+ item := NewMarkdownItem("1", markdown)
+
+ // First render at width 80 should populate cache
+ height1 := item.Height(80)
+ if len(item.cache) != 1 {
+ t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
+ }
+
+ // Second render at same width should reuse cache
+ height2 := item.Height(80)
+ if height1 != height2 {
+ t.Errorf("expected consistent height, got %d then %d", height1, height2)
+ }
+ if len(item.cache) != 1 {
+ t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
+ }
+
+ // Render at different width should add to cache
+ _ = item.Height(40)
+ if len(item.cache) != 2 {
+ t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
+ }
+}
+
+func TestMarkdownItemMaxCacheWidth(t *testing.T) {
+ markdown := "# Test\n\nSome content."
+ item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
+
+ // Render at width 40 (below limit) - should cache at width 40
+ _ = item.Height(40)
+ if len(item.cache) != 1 {
+ t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
+ }
+
+ // Render at width 80 (above limit) - should cap to 50 and cache
+ _ = item.Height(80)
+ // Cache should have width 50 entry (capped from 80)
+ if len(item.cache) != 2 {
+ t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
+ }
+ if _, ok := item.cache[50]; !ok {
+ t.Error("expected cache to have entry for width 50 (capped from 80)")
+ }
+
+ // Render at width 100 (also above limit) - should reuse cached width 50
+ _ = item.Height(100)
+ if len(item.cache) != 2 {
+ t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
+ }
+}
+
+func TestMarkdownItemWithStyleConfig(t *testing.T) {
+ markdown := "# Styled\n\nContent with **bold** text."
+
+ // Create a custom style config
+ styleConfig := ansi.StyleConfig{
+ Document: ansi.StyleBlock{
+ Margin: uintPtr(0),
+ },
+ }
+
+ item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig)
+
+ // Render should use the custom style
+ height := item.Height(80)
+ if height < 1 {
+ t.Errorf("expected height >= 1, got %d", height)
+ }
+
+ // Draw should work without panic
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ item.Draw(&screen, area)
+
+ rendered := screen.Render()
+ if len(rendered) == 0 {
+ t.Error("expected non-empty rendered output with custom style")
+ }
+}
+
+func TestMarkdownItemInList(t *testing.T) {
+ items := []Item{
+ NewMarkdownItem("1", "# First\n\nMarkdown item."),
+ NewMarkdownItem("2", "# Second\n\nAnother item."),
+ NewStringItem("3", "Regular string item"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 20)
+
+ // Should render without error
+ output := l.Render()
+ if len(output) == 0 {
+ t.Error("expected non-empty output from list with markdown items")
+ }
+
+ // Should contain content from markdown items
+ if !strings.Contains(output, "First") {
+ t.Error("expected output to contain 'First'")
+ }
+ if !strings.Contains(output, "Second") {
+ t.Error("expected output to contain 'Second'")
+ }
+ if !strings.Contains(output, "Regular string item") {
+ t.Error("expected output to contain 'Regular string item'")
+ }
+}
+
+func TestMarkdownItemHeightWithWidth(t *testing.T) {
+ // Test that widths are capped to maxWidth
+ markdown := "This is a paragraph with some text."
+
+ item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
+
+ // At width 30 (below limit), should cache and render at width 30
+ height30 := item.Height(30)
+ if height30 < 1 {
+ t.Errorf("expected height >= 1, got %d", height30)
+ }
+
+ // At width 100 (above maxWidth), should cap to 50 and cache
+ height100 := item.Height(100)
+ if height100 < 1 {
+ t.Errorf("expected height >= 1, got %d", height100)
+ }
+
+ // Both should be cached (width 30 and capped width 50)
+ if len(item.cache) != 2 {
+ t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
+ }
+ if _, ok := item.cache[30]; !ok {
+ t.Error("expected cache to have entry for width 30")
+ }
+ if _, ok := item.cache[50]; !ok {
+ t.Error("expected cache to have entry for width 50 (capped from 100)")
+ }
+}
+
+func BenchmarkMarkdownItemRender(b *testing.B) {
+ markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
+ item := NewMarkdownItem("1", markdown)
+
+ // Prime the cache
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ item.Draw(&screen, area)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ item.Draw(&screen, area)
+ }
+}
+
+func BenchmarkMarkdownItemUncached(b *testing.B) {
+ markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ item := NewMarkdownItem("1", markdown)
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+ item.Draw(&screen, area)
+ }
+}
+
+func TestSpacerItem(t *testing.T) {
+ spacer := NewSpacerItem("spacer1", 3)
+
+ // Check ID
+ if spacer.ID() != "spacer1" {
+ t.Errorf("expected ID 'spacer1', got %q", spacer.ID())
+ }
+
+ // Check height
+ if h := spacer.Height(80); h != 3 {
+ t.Errorf("expected height 3, got %d", h)
+ }
+
+ // Height should be constant regardless of width
+ if h := spacer.Height(20); h != 3 {
+ t.Errorf("expected height 3 for width 20, got %d", h)
+ }
+
+ // Draw should not produce any visible content
+ screen := uv.NewScreenBuffer(20, 3)
+ area := uv.Rect(0, 0, 20, 3)
+ spacer.Draw(&screen, area)
+
+ output := screen.Render()
+ // Should be empty (just spaces)
+ for _, line := range strings.Split(output, "\n") {
+ trimmed := strings.TrimSpace(line)
+ if trimmed != "" {
+ t.Errorf("expected empty spacer output, got: %q", line)
+ }
+ }
+}
+
+func TestSpacerItemInList(t *testing.T) {
+ // Create a list with items separated by spacers
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewSpacerItem("spacer1", 1),
+ NewStringItem("2", "Item 2"),
+ NewSpacerItem("spacer2", 2),
+ NewStringItem("3", "Item 3"),
+ }
+
+ l := New(items...)
+ l.SetSize(20, 10)
+
+ output := l.Render()
+
+ // Should contain all three items
+ if !strings.Contains(output, "Item 1") {
+ t.Error("expected output to contain 'Item 1'")
+ }
+ if !strings.Contains(output, "Item 2") {
+ t.Error("expected output to contain 'Item 2'")
+ }
+ if !strings.Contains(output, "Item 3") {
+ t.Error("expected output to contain 'Item 3'")
+ }
+
+ // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
+ expectedHeight := 6
+ if l.TotalHeight() != expectedHeight {
+ t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
+ }
+}
+
+func TestSpacerItemNavigation(t *testing.T) {
+ // Spacers should not be selectable (they're not focusable)
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewSpacerItem("spacer1", 1),
+ NewStringItem("2", "Item 2"),
+ }
+
+ l := New(items...)
+ l.SetSize(20, 10)
+
+ // Select first item
+ l.SetSelectedIndex(0)
+ if l.SelectedIndex() != 0 {
+ t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
+ }
+
+ // Can select the spacer (it's a valid item, just not focusable)
+ l.SetSelectedIndex(1)
+ if l.SelectedIndex() != 1 {
+ t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
+ }
+
+ // Can select item after spacer
+ l.SetSelectedIndex(2)
+ if l.SelectedIndex() != 2 {
+ t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
+ }
+}
+
+// Helper function to create a pointer to uint
+func uintPtr(v uint) *uint {
+ return &v
+}
+
+func TestListDoesNotEatLastLine(t *testing.T) {
+ // Create items that exactly fill the viewport
+ items := []Item{
+ NewStringItem("1", "Line 1"),
+ NewStringItem("2", "Line 2"),
+ NewStringItem("3", "Line 3"),
+ NewStringItem("4", "Line 4"),
+ NewStringItem("5", "Line 5"),
+ }
+
+ // Create list with height exactly matching content (5 lines, no gaps)
+ l := New(items...)
+ l.SetSize(20, 5)
+
+ // Render the list
+ output := l.Render()
+
+ // Count actual lines in output
+ lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n")
+ actualLineCount := 0
+ for _, line := range lines {
+ if strings.TrimSpace(line) != "" {
+ actualLineCount++
+ }
+ }
+
+ // All 5 items should be visible
+ if !strings.Contains(output, "Line 1") {
+ t.Error("expected output to contain 'Line 1'")
+ }
+ if !strings.Contains(output, "Line 2") {
+ t.Error("expected output to contain 'Line 2'")
+ }
+ if !strings.Contains(output, "Line 3") {
+ t.Error("expected output to contain 'Line 3'")
+ }
+ if !strings.Contains(output, "Line 4") {
+ t.Error("expected output to contain 'Line 4'")
+ }
+ if !strings.Contains(output, "Line 5") {
+ t.Error("expected output to contain 'Line 5'")
+ }
+
+ if actualLineCount != 5 {
+ t.Errorf("expected 5 lines with content, got %d", actualLineCount)
+ }
+}
+
+func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
+ // Create more items than viewport height
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ NewStringItem("4", "Item 4"),
+ NewStringItem("5", "Item 5"),
+ NewStringItem("6", "Item 6"),
+ NewStringItem("7", "Item 7"),
+ }
+
+ // Viewport shows 3 items at a time
+ l := New(items...)
+ l.SetSize(20, 3)
+
+ // Need to render first to build the buffer and calculate total height
+ _ = l.Render()
+
+ // Now scroll to bottom
+ l.ScrollToBottom()
+
+ output := l.Render()
+
+ t.Logf("Output:\n%s", output)
+ t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
+
+ // Should show last 3 items: 5, 6, 7
+ if !strings.Contains(output, "Item 5") {
+ t.Error("expected output to contain 'Item 5'")
+ }
+ if !strings.Contains(output, "Item 6") {
+ t.Error("expected output to contain 'Item 6'")
+ }
+ if !strings.Contains(output, "Item 7") {
+ t.Error("expected output to contain 'Item 7'")
+ }
+
+ // Should not show earlier items
+ if strings.Contains(output, "Item 1") {
+ t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
+ }
+}
@@ -0,0 +1,795 @@
+package list
+
+import (
+ "strings"
+
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/ultraviolet/screen"
+)
+
+// List is a scrollable list component that implements uv.Drawable.
+// It efficiently manages a large number of items by caching rendered content
+// in a master buffer and extracting only the visible viewport when drawn.
+type List struct {
+ // Configuration
+ width, height int
+
+ // Data
+ items []Item
+ indexMap map[string]int // ID -> index for fast lookup
+
+ // Focus & Selection
+ focused bool
+ selectedIdx int // Currently selected item index (-1 if none)
+
+ // Master buffer containing ALL rendered items
+ masterBuffer *uv.ScreenBuffer
+ totalHeight int
+
+ // Item positioning in master buffer
+ itemPositions map[string]itemPosition
+
+ // Viewport state
+ offset int // Scroll offset in lines from top
+
+ // Dirty tracking
+ dirty bool
+ dirtyItems map[string]bool
+}
+
+type itemPosition struct {
+ startLine int
+ height int
+}
+
+// New creates a new list with the given items.
+func New(items ...Item) *List {
+ l := &List{
+ items: items,
+ indexMap: make(map[string]int, len(items)),
+ itemPositions: make(map[string]itemPosition, len(items)),
+ dirtyItems: make(map[string]bool),
+ selectedIdx: -1,
+ }
+
+ // Build index map
+ for i, item := range items {
+ l.indexMap[item.ID()] = i
+ }
+
+ l.dirty = true
+ return l
+}
+
+// ensureBuilt ensures the master buffer is built.
+// This is called by methods that need itemPositions or totalHeight.
+func (l *List) ensureBuilt() {
+ if l.width <= 0 || l.height <= 0 {
+ return
+ }
+
+ if l.dirty {
+ l.rebuildMasterBuffer()
+ } else if len(l.dirtyItems) > 0 {
+ l.updateDirtyItems()
+ }
+}
+
+// Draw implements uv.Drawable.
+// Draws the visible viewport of the list to the given screen buffer.
+func (l *List) Draw(scr uv.Screen, area uv.Rectangle) {
+ if area.Dx() <= 0 || area.Dy() <= 0 {
+ return
+ }
+
+ // Update internal dimensions if area size changed
+ widthChanged := l.width != area.Dx()
+ heightChanged := l.height != area.Dy()
+
+ l.width = area.Dx()
+ l.height = area.Dy()
+
+ // Only width changes require rebuilding master buffer
+ // Height changes only affect viewport clipping, not item rendering
+ if widthChanged {
+ l.dirty = true
+ }
+
+ // Height changes require clamping offset to new bounds
+ if heightChanged {
+ l.clampOffset()
+ }
+
+ if len(l.items) == 0 {
+ screen.ClearArea(scr, area)
+ return
+ }
+
+ // Ensure buffer is built
+ l.ensureBuilt()
+
+ // Draw visible portion to the target screen
+ l.drawViewport(scr, area)
+}
+
+// Render renders the visible viewport to a string.
+// This is a convenience method that creates a temporary screen buffer,
+// draws to it, and returns the rendered string.
+func (l *List) Render() string {
+ if l.width <= 0 || l.height <= 0 {
+ return ""
+ }
+
+ if len(l.items) == 0 {
+ return ""
+ }
+
+ // Ensure buffer is built
+ l.ensureBuilt()
+
+ // Extract visible lines directly from master buffer
+ return l.renderViewport()
+}
+
+// renderViewport renders the visible portion of the master buffer to a string.
+func (l *List) renderViewport() string {
+ if l.masterBuffer == nil {
+ return ""
+ }
+
+ buf := l.masterBuffer.Buffer
+
+ // Calculate visible region in master buffer
+ srcStartY := l.offset
+ srcEndY := l.offset + l.height
+
+ // Clamp to actual buffer bounds
+ if srcStartY >= len(buf.Lines) {
+ // Beyond end of content, return empty lines
+ emptyLine := strings.Repeat(" ", l.width)
+ lines := make([]string, l.height)
+ for i := range lines {
+ lines[i] = emptyLine
+ }
+ return strings.Join(lines, "\r\n")
+ }
+ if srcEndY > len(buf.Lines) {
+ srcEndY = len(buf.Lines)
+ }
+
+ // Build result with proper line handling
+ lines := make([]string, l.height)
+ lineIdx := 0
+
+ // Render visible lines from buffer
+ for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ {
+ lines[lineIdx] = buf.Lines[y].Render()
+ lineIdx++
+ }
+
+ // Pad remaining lines with spaces to maintain viewport height
+ emptyLine := strings.Repeat(" ", l.width)
+ for ; lineIdx < l.height; lineIdx++ {
+ lines[lineIdx] = emptyLine
+ }
+
+ return strings.Join(lines, "\r\n")
+}
+
+// drawViewport draws the visible portion from master buffer to target screen.
+func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
+ if l.masterBuffer == nil {
+ screen.ClearArea(scr, area)
+ return
+ }
+
+ buf := l.masterBuffer.Buffer
+
+ // Calculate visible region in master buffer
+ srcStartY := l.offset
+ srcEndY := l.offset + area.Dy()
+
+ // Clamp to actual buffer bounds
+ if srcStartY >= len(buf.Lines) {
+ screen.ClearArea(scr, area)
+ return
+ }
+ if srcEndY > len(buf.Lines) {
+ srcEndY = len(buf.Lines)
+ }
+
+ // Copy visible lines to target screen
+ destY := area.Min.Y
+ for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
+ line := buf.Lines[srcY]
+ destX := area.Min.X
+
+ for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
+ cell := line.At(x)
+ scr.SetCell(destX, destY, cell)
+ destX++
+ }
+ destY++
+ }
+
+ // Clear any remaining area if content is shorter than viewport
+ if destY < area.Max.Y {
+ clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY)
+ screen.ClearArea(scr, clearArea)
+ }
+}
+
+// rebuildMasterBuffer composes all items into the master buffer.
+func (l *List) rebuildMasterBuffer() {
+ if len(l.items) == 0 {
+ l.totalHeight = 0
+ l.dirty = false
+ return
+ }
+
+ // Calculate total height
+ l.totalHeight = l.calculateTotalHeight()
+
+ // Create or resize master buffer
+ if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight {
+ buf := uv.NewScreenBuffer(l.width, l.totalHeight)
+ l.masterBuffer = &buf
+ }
+
+ // Clear buffer
+ screen.Clear(l.masterBuffer)
+
+ // Draw each item
+ currentY := 0
+ for _, item := range l.items {
+ itemHeight := item.Height(l.width)
+
+ // Draw item to master buffer
+ area := uv.Rect(0, currentY, l.width, itemHeight)
+ item.Draw(l.masterBuffer, area)
+
+ // Store position
+ l.itemPositions[item.ID()] = itemPosition{
+ startLine: currentY,
+ height: itemHeight,
+ }
+
+ // Advance position
+ currentY += itemHeight
+ }
+
+ l.dirty = false
+ l.dirtyItems = make(map[string]bool)
+}
+
+// updateDirtyItems efficiently updates only changed items using slice operations.
+func (l *List) updateDirtyItems() {
+ if len(l.dirtyItems) == 0 {
+ return
+ }
+
+ // Check if all dirty items have unchanged heights
+ allSameHeight := true
+ for id := range l.dirtyItems {
+ idx, ok := l.indexMap[id]
+ if !ok {
+ continue
+ }
+
+ item := l.items[idx]
+ pos, ok := l.itemPositions[id]
+ if !ok {
+ l.dirty = true
+ l.dirtyItems = make(map[string]bool)
+ l.rebuildMasterBuffer()
+ return
+ }
+
+ newHeight := item.Height(l.width)
+ if newHeight != pos.height {
+ allSameHeight = false
+ break
+ }
+ }
+
+ // Optimization: If all dirty items have unchanged heights, re-render in place
+ if allSameHeight {
+ buf := l.masterBuffer.Buffer
+ for id := range l.dirtyItems {
+ idx := l.indexMap[id]
+ item := l.items[idx]
+ pos := l.itemPositions[id]
+
+ // Clear the item's area
+ for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
+ buf.Lines[y] = uv.NewLine(l.width)
+ }
+
+ // Re-render item
+ area := uv.Rect(0, pos.startLine, l.width, pos.height)
+ item.Draw(l.masterBuffer, area)
+ }
+
+ l.dirtyItems = make(map[string]bool)
+ return
+ }
+
+ // Height changed - full rebuild
+ l.dirty = true
+ l.dirtyItems = make(map[string]bool)
+ l.rebuildMasterBuffer()
+}
+
+// updatePositionsBelow updates the startLine for all items below the given index.
+func (l *List) updatePositionsBelow(fromIdx int, delta int) {
+ for i := fromIdx + 1; i < len(l.items); i++ {
+ item := l.items[i]
+ pos := l.itemPositions[item.ID()]
+ pos.startLine += delta
+ l.itemPositions[item.ID()] = pos
+ }
+}
+
+// calculateTotalHeight calculates the total height of all items plus gaps.
+func (l *List) calculateTotalHeight() int {
+ if len(l.items) == 0 {
+ return 0
+ }
+
+ total := 0
+ for _, item := range l.items {
+ total += item.Height(l.width)
+ }
+ return total
+}
+
+// SetSize updates the viewport size.
+func (l *List) SetSize(width, height int) {
+ widthChanged := l.width != width
+ heightChanged := l.height != height
+
+ l.width = width
+ l.height = height
+
+ // Width changes require full rebuild (items may reflow)
+ if widthChanged {
+ l.dirty = true
+ }
+
+ // Height changes require clamping offset to new bounds
+ if heightChanged {
+ l.clampOffset()
+ }
+}
+
+// GetSize returns the current viewport size.
+func (l *List) GetSize() (int, int) {
+ return l.width, l.height
+}
+
+// Len returns the number of items in the list.
+func (l *List) Len() int {
+ return len(l.items)
+}
+
+// SetItems replaces all items in the list.
+func (l *List) SetItems(items []Item) {
+ l.items = items
+ l.indexMap = make(map[string]int, len(items))
+ l.itemPositions = make(map[string]itemPosition, len(items))
+
+ for i, item := range items {
+ l.indexMap[item.ID()] = i
+ }
+
+ l.dirty = true
+}
+
+// Items returns all items in the list.
+func (l *List) Items() []Item {
+ return l.items
+}
+
+// AppendItem adds an item to the end of the list.
+func (l *List) AppendItem(item Item) {
+ l.items = append(l.items, item)
+ l.indexMap[item.ID()] = len(l.items) - 1
+
+ // If buffer not built yet, mark dirty for full rebuild
+ if l.masterBuffer == nil || l.width <= 0 {
+ l.dirty = true
+ return
+ }
+
+ // Process any pending dirty items before modifying buffer structure
+ if len(l.dirtyItems) > 0 {
+ l.updateDirtyItems()
+ }
+
+ // Efficient append: insert lines at end of buffer
+ itemHeight := item.Height(l.width)
+ startLine := l.totalHeight
+
+ // Expand buffer
+ newLines := make([]uv.Line, itemHeight)
+ for i := range newLines {
+ newLines[i] = uv.NewLine(l.width)
+ }
+ l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...)
+
+ // Draw new item
+ area := uv.Rect(0, startLine, l.width, itemHeight)
+ item.Draw(l.masterBuffer, area)
+
+ // Update tracking
+ l.itemPositions[item.ID()] = itemPosition{
+ startLine: startLine,
+ height: itemHeight,
+ }
+ l.totalHeight += itemHeight
+}
+
+// PrependItem adds an item to the beginning of the list.
+func (l *List) PrependItem(item Item) {
+ l.items = append([]Item{item}, l.items...)
+
+ // Rebuild index map (all indices shifted)
+ l.indexMap = make(map[string]int, len(l.items))
+ for i, itm := range l.items {
+ l.indexMap[itm.ID()] = i
+ }
+
+ if l.selectedIdx >= 0 {
+ l.selectedIdx++
+ }
+
+ // If buffer not built yet, mark dirty for full rebuild
+ if l.masterBuffer == nil || l.width <= 0 {
+ l.dirty = true
+ return
+ }
+
+ // Process any pending dirty items before modifying buffer structure
+ if len(l.dirtyItems) > 0 {
+ l.updateDirtyItems()
+ }
+
+ // Efficient prepend: insert lines at start of buffer
+ itemHeight := item.Height(l.width)
+
+ // Create new lines
+ newLines := make([]uv.Line, itemHeight)
+ for i := range newLines {
+ newLines[i] = uv.NewLine(l.width)
+ }
+
+ // Insert at beginning
+ buf := l.masterBuffer.Buffer
+ buf.Lines = append(newLines, buf.Lines...)
+
+ // Draw new item
+ area := uv.Rect(0, 0, l.width, itemHeight)
+ item.Draw(l.masterBuffer, area)
+
+ // Update all positions (shift everything down)
+ for i := 1; i < len(l.items); i++ {
+ itm := l.items[i]
+ if pos, ok := l.itemPositions[itm.ID()]; ok {
+ pos.startLine += itemHeight
+ l.itemPositions[itm.ID()] = pos
+ }
+ }
+
+ // Add position for new item
+ l.itemPositions[item.ID()] = itemPosition{
+ startLine: 0,
+ height: itemHeight,
+ }
+ l.totalHeight += itemHeight
+}
+
+// UpdateItem replaces an item with the same ID.
+func (l *List) UpdateItem(id string, item Item) {
+ idx, ok := l.indexMap[id]
+ if !ok {
+ return
+ }
+
+ l.items[idx] = item
+ l.dirtyItems[id] = true
+}
+
+// DeleteItem removes an item by ID.
+func (l *List) DeleteItem(id string) {
+ idx, ok := l.indexMap[id]
+ if !ok {
+ return
+ }
+
+ // Get position before deleting
+ pos, hasPos := l.itemPositions[id]
+
+ // Process any pending dirty items before modifying buffer structure
+ if len(l.dirtyItems) > 0 {
+ l.updateDirtyItems()
+ }
+
+ l.items = append(l.items[:idx], l.items[idx+1:]...)
+ delete(l.indexMap, id)
+ delete(l.itemPositions, id)
+
+ // Rebuild index map for items after deleted one
+ for i := idx; i < len(l.items); i++ {
+ l.indexMap[l.items[i].ID()] = i
+ }
+
+ // Adjust selection
+ if l.selectedIdx == idx {
+ if idx > 0 {
+ l.selectedIdx = idx - 1
+ } else if len(l.items) > 0 {
+ l.selectedIdx = 0
+ } else {
+ l.selectedIdx = -1
+ }
+ } else if l.selectedIdx > idx {
+ l.selectedIdx--
+ }
+
+ // If buffer not built yet, mark dirty for full rebuild
+ if l.masterBuffer == nil || !hasPos {
+ l.dirty = true
+ return
+ }
+
+ // Efficient delete: remove lines from buffer
+ deleteStart := pos.startLine
+ deleteEnd := pos.startLine + pos.height
+ buf := l.masterBuffer.Buffer
+
+ if deleteEnd <= len(buf.Lines) {
+ buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...)
+ l.totalHeight -= pos.height
+ l.updatePositionsBelow(idx-1, -pos.height)
+ } else {
+ // Position data corrupt, rebuild
+ l.dirty = true
+ }
+}
+
+// Focus focuses the list and the selected item (if focusable).
+func (l *List) Focus() {
+ l.focused = true
+ l.focusSelectedItem()
+}
+
+// Blur blurs the list and the selected item (if focusable).
+func (l *List) Blur() {
+ l.focused = false
+ l.blurSelectedItem()
+}
+
+// IsFocused returns whether the list is focused.
+func (l *List) IsFocused() bool {
+ return l.focused
+}
+
+// SetSelected sets the selected item by ID.
+func (l *List) SetSelected(id string) {
+ idx, ok := l.indexMap[id]
+ if !ok {
+ return
+ }
+
+ if l.selectedIdx == idx {
+ return
+ }
+
+ prevIdx := l.selectedIdx
+ l.selectedIdx = idx
+
+ // Update focus states if list is focused
+ if l.focused {
+ if prevIdx >= 0 && prevIdx < len(l.items) {
+ if f, ok := l.items[prevIdx].(Focusable); ok {
+ f.Blur()
+ l.dirtyItems[l.items[prevIdx].ID()] = true
+ }
+ }
+
+ if f, ok := l.items[idx].(Focusable); ok {
+ f.Focus()
+ l.dirtyItems[l.items[idx].ID()] = true
+ }
+ }
+}
+
+// SetSelectedIndex sets the selected item by index.
+func (l *List) SetSelectedIndex(idx int) {
+ if idx < 0 || idx >= len(l.items) {
+ return
+ }
+ l.SetSelected(l.items[idx].ID())
+}
+
+// SelectNext selects the next item in the list (wraps to beginning).
+// When the list is focused, skips non-focusable items.
+func (l *List) SelectNext() {
+ if len(l.items) == 0 {
+ return
+ }
+
+ startIdx := l.selectedIdx
+ for i := 0; i < len(l.items); i++ {
+ nextIdx := (startIdx + 1 + i) % len(l.items)
+
+ // If list is focused and item is not focusable, skip it
+ if l.focused {
+ if _, ok := l.items[nextIdx].(Focusable); !ok {
+ continue
+ }
+ }
+
+ // Select and scroll to this item
+ l.SetSelected(l.items[nextIdx].ID())
+ l.ScrollToSelected()
+ return
+ }
+}
+
+// SelectPrev selects the previous item in the list (wraps to end).
+// When the list is focused, skips non-focusable items.
+func (l *List) SelectPrev() {
+ if len(l.items) == 0 {
+ return
+ }
+
+ startIdx := l.selectedIdx
+ for i := 0; i < len(l.items); i++ {
+ prevIdx := (startIdx - 1 - i + len(l.items)) % len(l.items)
+
+ // If list is focused and item is not focusable, skip it
+ if l.focused {
+ if _, ok := l.items[prevIdx].(Focusable); !ok {
+ continue
+ }
+ }
+
+ // Select and scroll to this item
+ l.SetSelected(l.items[prevIdx].ID())
+ l.ScrollToSelected()
+ return
+ }
+}
+
+// SelectedItem returns the currently selected item, or nil if none.
+func (l *List) SelectedItem() Item {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+ return nil
+ }
+ return l.items[l.selectedIdx]
+}
+
+// SelectedIndex returns the index of the currently selected item, or -1 if none.
+func (l *List) SelectedIndex() int {
+ return l.selectedIdx
+}
+
+// AtBottom returns whether the viewport is scrolled to the bottom.
+func (l *List) AtBottom() bool {
+ l.ensureBuilt()
+ return l.offset >= l.totalHeight-l.height
+}
+
+// AtTop returns whether the viewport is scrolled to the top.
+func (l *List) AtTop() bool {
+ return l.offset <= 0
+}
+
+// ScrollBy scrolls the viewport by the given number of lines.
+// Positive values scroll down, negative scroll up.
+func (l *List) ScrollBy(deltaLines int) {
+ l.offset += deltaLines
+ l.clampOffset()
+}
+
+// ScrollToTop scrolls to the top of the list.
+func (l *List) ScrollToTop() {
+ l.offset = 0
+}
+
+// ScrollToBottom scrolls to the bottom of the list.
+func (l *List) ScrollToBottom() {
+ l.ensureBuilt()
+ if l.totalHeight > l.height {
+ l.offset = l.totalHeight - l.height
+ } else {
+ l.offset = 0
+ }
+}
+
+// ScrollToItem scrolls to make the item with the given ID visible.
+func (l *List) ScrollToItem(id string) {
+ l.ensureBuilt()
+ pos, ok := l.itemPositions[id]
+ if !ok {
+ return
+ }
+
+ itemStart := pos.startLine
+ itemEnd := pos.startLine + pos.height
+ viewStart := l.offset
+ viewEnd := l.offset + l.height
+
+ // Check if item is already fully visible
+ if itemStart >= viewStart && itemEnd <= viewEnd {
+ return
+ }
+
+ // Scroll to show item
+ if itemStart < viewStart {
+ l.offset = itemStart
+ } else if itemEnd > viewEnd {
+ l.offset = itemEnd - l.height
+ }
+
+ l.clampOffset()
+}
+
+// ScrollToSelected scrolls to make the selected item visible.
+func (l *List) ScrollToSelected() {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+ return
+ }
+ l.ScrollToItem(l.items[l.selectedIdx].ID())
+}
+
+// Offset returns the current scroll offset.
+func (l *List) Offset() int {
+ return l.offset
+}
+
+// TotalHeight returns the total height of all items including gaps.
+func (l *List) TotalHeight() int {
+ return l.totalHeight
+}
+
+// clampOffset ensures offset is within valid bounds.
+func (l *List) clampOffset() {
+ maxOffset := l.totalHeight - l.height
+ if maxOffset < 0 {
+ maxOffset = 0
+ }
+
+ if l.offset < 0 {
+ l.offset = 0
+ } else if l.offset > maxOffset {
+ l.offset = maxOffset
+ }
+}
+
+// focusSelectedItem focuses the currently selected item if it's focusable.
+func (l *List) focusSelectedItem() {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+ return
+ }
+
+ item := l.items[l.selectedIdx]
+ if f, ok := item.(Focusable); ok {
+ f.Focus()
+ l.dirtyItems[item.ID()] = true
+ }
+}
+
+// blurSelectedItem blurs the currently selected item if it's focusable.
+func (l *List) blurSelectedItem() {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+ return
+ }
+
+ item := l.items[l.selectedIdx]
+ if f, ok := item.(Focusable); ok {
+ f.Blur()
+ l.dirtyItems[item.ID()] = true
+ }
+}
@@ -0,0 +1,586 @@
+package list
+
+import (
+ "strings"
+ "testing"
+
+ "charm.land/lipgloss/v2"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewList(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 24)
+
+ if len(l.items) != 3 {
+ t.Errorf("expected 3 items, got %d", len(l.items))
+ }
+
+ if l.width != 80 || l.height != 24 {
+ t.Errorf("expected size 80x24, got %dx%d", l.width, l.height)
+ }
+}
+
+func TestListDraw(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 10)
+
+ // Create a screen buffer to draw into
+ screen := uv.NewScreenBuffer(80, 10)
+ area := uv.Rect(0, 0, 80, 10)
+
+ // Draw the list
+ l.Draw(&screen, area)
+
+ // Verify the buffer has content
+ output := screen.Render()
+ if len(output) == 0 {
+ t.Error("expected non-empty output")
+ }
+}
+
+func TestListAppendItem(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ }
+
+ l := New(items...)
+ l.AppendItem(NewStringItem("2", "Item 2"))
+
+ if len(l.items) != 2 {
+ t.Errorf("expected 2 items after append, got %d", len(l.items))
+ }
+
+ if l.items[1].ID() != "2" {
+ t.Errorf("expected item ID '2', got '%s'", l.items[1].ID())
+ }
+}
+
+func TestListDeleteItem(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ }
+
+ l := New(items...)
+ l.DeleteItem("2")
+
+ if len(l.items) != 2 {
+ t.Errorf("expected 2 items after delete, got %d", len(l.items))
+ }
+
+ if l.items[1].ID() != "3" {
+ t.Errorf("expected item ID '3', got '%s'", l.items[1].ID())
+ }
+}
+
+func TestListUpdateItem(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 10)
+
+ // Update item
+ newItem := NewStringItem("2", "Updated Item 2")
+ l.UpdateItem("2", newItem)
+
+ if l.items[1].(*StringItem).content != "Updated Item 2" {
+ t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
+ }
+}
+
+func TestListSelection(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ }
+
+ l := New(items...)
+ l.SetSelectedIndex(0)
+
+ if l.SelectedIndex() != 0 {
+ t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
+ }
+
+ l.SelectNext()
+ if l.SelectedIndex() != 1 {
+ t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex())
+ }
+
+ l.SelectPrev()
+ if l.SelectedIndex() != 0 {
+ t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex())
+ }
+}
+
+func TestListScrolling(t *testing.T) {
+ items := []Item{
+ NewStringItem("1", "Item 1"),
+ NewStringItem("2", "Item 2"),
+ NewStringItem("3", "Item 3"),
+ NewStringItem("4", "Item 4"),
+ NewStringItem("5", "Item 5"),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 2) // Small viewport
+
+ // Draw to initialize the master buffer
+ screen := uv.NewScreenBuffer(80, 2)
+ area := uv.Rect(0, 0, 80, 2)
+ l.Draw(&screen, area)
+
+ if l.Offset() != 0 {
+ t.Errorf("expected initial offset 0, got %d", l.Offset())
+ }
+
+ l.ScrollBy(2)
+ if l.Offset() != 2 {
+ t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset())
+ }
+
+ l.ScrollToTop()
+ if l.Offset() != 0 {
+ t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset())
+ }
+}
+
+// FocusableTestItem is a test item that implements Focusable.
+type FocusableTestItem struct {
+ id string
+ content string
+ focused bool
+}
+
+func (f *FocusableTestItem) ID() string {
+ return f.id
+}
+
+func (f *FocusableTestItem) Height(width int) int {
+ return 1
+}
+
+func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ prefix := "[ ]"
+ if f.focused {
+ prefix = "[X]"
+ }
+ content := prefix + " " + f.content
+ styled := uv.NewStyledString(content)
+ styled.Draw(scr, area)
+}
+
+func (f *FocusableTestItem) Focus() {
+ f.focused = true
+}
+
+func (f *FocusableTestItem) Blur() {
+ f.focused = false
+}
+
+func (f *FocusableTestItem) IsFocused() bool {
+ return f.focused
+}
+
+func TestListFocus(t *testing.T) {
+ items := []Item{
+ &FocusableTestItem{id: "1", content: "Item 1"},
+ &FocusableTestItem{id: "2", content: "Item 2"},
+ }
+
+ l := New(items...)
+ l.SetSize(80, 10)
+ l.SetSelectedIndex(0)
+
+ // Focus the list
+ l.Focus()
+
+ if !l.IsFocused() {
+ t.Error("expected list to be focused")
+ }
+
+ // Check if selected item is focused
+ selectedItem := l.SelectedItem().(*FocusableTestItem)
+ if !selectedItem.IsFocused() {
+ t.Error("expected selected item to be focused")
+ }
+
+ // Select next and check focus changes
+ l.SelectNext()
+ if selectedItem.IsFocused() {
+ t.Error("expected previous item to be blurred")
+ }
+
+ newSelectedItem := l.SelectedItem().(*FocusableTestItem)
+ if !newSelectedItem.IsFocused() {
+ t.Error("expected new selected item to be focused")
+ }
+
+ // Blur the list
+ l.Blur()
+ if l.IsFocused() {
+ t.Error("expected list to be blurred")
+ }
+}
+
+// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug:
+// Append items until viewport is full, select last, then navigate backwards.
+func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
+ t.Parallel()
+
+ focusStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("86"))
+
+ blurStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("240"))
+
+ // Start with one item
+ items := []Item{
+ NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+ }
+
+ l := New(items...)
+ l.SetSize(20, 15) // 15 lines viewport height
+ l.SetSelectedIndex(0)
+ l.Focus()
+
+ // Initial draw to build buffer
+ screen := uv.NewScreenBuffer(20, 15)
+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+
+ // Append items until we exceed viewport height
+ // Each focusable item with border is 5 lines tall
+ for i := 2; i <= 4; i++ {
+ item := NewStringItem(string(rune('0'+i)), "Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
+ l.AppendItem(item)
+ }
+
+ // Select the last item
+ l.SetSelectedIndex(3)
+
+ // Draw
+ screen = uv.NewScreenBuffer(20, 15)
+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+ output := screen.Render()
+
+ t.Logf("After selecting last item:\n%s", output)
+ require.Contains(t, output, "38;5;86", "expected focus color on last item")
+
+ // Now navigate backwards
+ l.SelectPrev()
+
+ screen = uv.NewScreenBuffer(20, 15)
+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+ output = screen.Render()
+
+ t.Logf("After SelectPrev:\n%s", output)
+ require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev")
+
+ // Navigate backwards again
+ l.SelectPrev()
+
+ screen = uv.NewScreenBuffer(20, 15)
+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
+ output = screen.Render()
+
+ t.Logf("After second SelectPrev:\n%s", output)
+ require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev")
+}
+
+func TestFocusableItemUpdate(t *testing.T) {
+ // Create styles with borders
+ focusStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("86"))
+
+ blurStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("240"))
+
+ // Create a focusable item
+ item := NewStringItem("1", "Test Item").WithFocusStyles(&focusStyle, &blurStyle)
+
+ // Initially not focused - render with blur style
+ screen1 := uv.NewScreenBuffer(20, 5)
+ area := uv.Rect(0, 0, 20, 5)
+ item.Draw(&screen1, area)
+ output1 := screen1.Render()
+
+ // Focus the item
+ item.Focus()
+
+ // Render again - should show focus style
+ screen2 := uv.NewScreenBuffer(20, 5)
+ item.Draw(&screen2, area)
+ output2 := screen2.Render()
+
+ // Outputs should be different (different border colors)
+ if output1 == output2 {
+ t.Error("expected different output after focusing, but got same output")
+ }
+
+ // Verify focus state
+ if !item.IsFocused() {
+ t.Error("expected item to be focused")
+ }
+
+ // Blur the item
+ item.Blur()
+
+ // Render again - should show blur style again
+ screen3 := uv.NewScreenBuffer(20, 5)
+ item.Draw(&screen3, area)
+ output3 := screen3.Render()
+
+ // Output should match original blur output
+ if output1 != output3 {
+ t.Error("expected same output after blurring as initial state")
+ }
+
+ // Verify blur state
+ if item.IsFocused() {
+ t.Error("expected item to be blurred")
+ }
+}
+
+func TestFocusableItemHeightWithBorder(t *testing.T) {
+ // Create a style with a border (adds 2 to vertical height)
+ borderStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder())
+
+ // Item without styles has height 1
+ plainItem := NewStringItem("1", "Test")
+ plainHeight := plainItem.Height(20)
+ if plainHeight != 1 {
+ t.Errorf("expected plain height 1, got %d", plainHeight)
+ }
+
+ // Item with border should add border height (2 lines)
+ item := NewStringItem("2", "Test").WithFocusStyles(&borderStyle, &borderStyle)
+ itemHeight := item.Height(20)
+ expectedHeight := 1 + 2 // content + border
+ if itemHeight != expectedHeight {
+ t.Errorf("expected height %d (content 1 + border 2), got %d",
+ expectedHeight, itemHeight)
+ }
+}
+
+func TestFocusableItemInList(t *testing.T) {
+ focusStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("86"))
+
+ blurStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("240"))
+
+ // Create list with focusable items
+ items := []Item{
+ NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+ NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
+ NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
+ }
+
+ l := New(items...)
+ l.SetSize(80, 20)
+ l.SetSelectedIndex(0)
+
+ // Focus the list
+ l.Focus()
+
+ // First item should be focused
+ firstItem := items[0].(*StringItem)
+ if !firstItem.IsFocused() {
+ t.Error("expected first item to be focused after focusing list")
+ }
+
+ // Render to ensure changes are visible
+ output1 := l.Render()
+ if !strings.Contains(output1, "Item 1") {
+ t.Error("expected output to contain first item")
+ }
+
+ // Select second item
+ l.SetSelectedIndex(1)
+
+ // First item should be blurred, second focused
+ if firstItem.IsFocused() {
+ t.Error("expected first item to be blurred after changing selection")
+ }
+
+ secondItem := items[1].(*StringItem)
+ if !secondItem.IsFocused() {
+ t.Error("expected second item to be focused after selection")
+ }
+
+ // Render again - should show updated focus
+ output2 := l.Render()
+ if !strings.Contains(output2, "Item 2") {
+ t.Error("expected output to contain second item")
+ }
+
+ // Outputs should be different
+ if output1 == output2 {
+ t.Error("expected different output after selection change")
+ }
+}
+
+func TestFocusableItemWithNilStyles(t *testing.T) {
+ // Test with nil styles - should render inner item directly
+ item := NewStringItem("1", "Plain Item").WithFocusStyles(nil, nil)
+
+ // Height should be based on content (no border since styles are nil)
+ itemHeight := item.Height(20)
+ if itemHeight != 1 {
+ t.Errorf("expected height 1 (no border), got %d", itemHeight)
+ }
+
+ // Draw should work without styles
+ screen := uv.NewScreenBuffer(20, 5)
+ area := uv.Rect(0, 0, 20, 5)
+ item.Draw(&screen, area)
+ output := screen.Render()
+
+ // Should contain the inner content
+ if !strings.Contains(output, "Plain Item") {
+ t.Error("expected output to contain inner item content")
+ }
+
+ // Focus/blur should still work but not change appearance
+ item.Focus()
+ screen2 := uv.NewScreenBuffer(20, 5)
+ item.Draw(&screen2, area)
+ output2 := screen2.Render()
+
+ // Output should be identical since no styles
+ if output != output2 {
+ t.Error("expected same output with nil styles whether focused or not")
+ }
+
+ if !item.IsFocused() {
+ t.Error("expected item to be focused")
+ }
+}
+
+func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
+ // Test with only focus style (blur is nil)
+ focusStyle := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("86"))
+
+ item := NewStringItem("1", "Test").WithFocusStyles(&focusStyle, nil)
+
+ // When not focused, should use nil blur style (no border)
+ screen1 := uv.NewScreenBuffer(20, 5)
+ area := uv.Rect(0, 0, 20, 5)
+ item.Draw(&screen1, area)
+ output1 := screen1.Render()
+
+ // Focus the item
+ item.Focus()
+ screen2 := uv.NewScreenBuffer(20, 5)
+ item.Draw(&screen2, area)
+ output2 := screen2.Render()
+
+ // Outputs should be different (focused has border, blurred doesn't)
+ if output1 == output2 {
+ t.Error("expected different output when only focus style is set")
+ }
+}
+
+func TestFocusableItemLastLineNotEaten(t *testing.T) {
+ // Create focusable items with borders
+ focusStyle := lipgloss.NewStyle().
+ Padding(1).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(lipgloss.Color("86"))
+
+ blurStyle := lipgloss.NewStyle().
+ BorderForeground(lipgloss.Color("240"))
+
+ items := []Item{
+ NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
+ Gap,
+ NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
+ Gap,
+ NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
+ Gap,
+ NewStringItem("4", "Item 4").WithFocusStyles(&focusStyle, &blurStyle),
+ Gap,
+ NewStringItem("5", "Item 5").WithFocusStyles(&focusStyle, &blurStyle),
+ }
+
+ // Items with padding(1) and border are 5 lines each
+ // Viewport of 10 lines fits exactly 2 items
+ l := New()
+ l.SetSize(20, 10)
+
+ for _, item := range items {
+ l.AppendItem(item)
+ }
+
+ // Focus the list
+ l.Focus()
+
+ // Select last item
+ l.SetSelectedIndex(len(items) - 1)
+
+ // Scroll to bottom
+ l.ScrollToBottom()
+
+ output := l.Render()
+
+ t.Logf("Output:\n%s", output)
+ t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
+
+ // Select previous - will skip gaps and go to Item 4
+ l.SelectPrev()
+
+ output = l.Render()
+
+ t.Logf("Output:\n%s", output)
+ t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
+
+ // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused)
+ if !strings.Contains(output, "Item 3") {
+ t.Error("expected output to contain 'Item 3'")
+ }
+ if !strings.Contains(output, "Item 4") {
+ t.Error("expected output to contain 'Item 4'")
+ }
+ if !strings.Contains(output, "Item 5") {
+ t.Error("expected output to contain 'Item 5'")
+ }
+
+ // Count bottom borders - should have 1 (focused item 4)
+ bottomBorderCount := 0
+ for _, line := range strings.Split(output, "\r\n") {
+ if strings.Contains(line, "╰") || strings.Contains(line, "└") {
+ bottomBorderCount++
+ }
+ }
+
+ if bottomBorderCount != 1 {
+ t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount)
+ }
+}
@@ -2,6 +2,7 @@ package model
import (
"context"
+ "fmt"
"image"
"math/rand"
"os"
@@ -21,6 +22,7 @@ import (
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/dialog"
+ "github.com/charmbracelet/crush/internal/ui/list"
"github.com/charmbracelet/crush/internal/ui/logo"
"github.com/charmbracelet/crush/internal/ui/styles"
"github.com/charmbracelet/crush/internal/version"
@@ -75,7 +77,7 @@ type UI struct {
keyMap KeyMap
keyenh tea.KeyboardEnhancementsMsg
- chat *ChatModel
+ chat *list.List
dialog *dialog.Overlay
help help.Model
@@ -123,6 +125,8 @@ func New(com *common.Common) *UI {
ta.SetVirtualCursor(false)
ta.Focus()
+ l := list.New()
+
ui := &UI{
com: com,
dialog: dialog.NewOverlay(),
@@ -131,6 +135,7 @@ func New(com *common.Common) *UI {
focus: uiFocusNone,
state: uiConfigure,
textarea: ta,
+ chat: l,
}
// set onboarding state defaults
@@ -194,6 +199,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.updateLayoutAndSize()
+ m.chat.ScrollToBottom()
case tea.KeyboardEnhancementsMsg:
m.keyenh = msg
if msg.SupportsKeyDisambiguation() {
@@ -236,6 +242,23 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
}
switch {
+ case msg.String() == "ctrl+shift+t":
+ m.chat.SelectPrev()
+ case msg.String() == "ctrl+t":
+ m.focus = uiFocusMain
+ m.state = uiChat
+ if m.chat.Len() > 0 {
+ m.chat.AppendItem(list.Gap)
+ }
+ m.chat.AppendItem(
+ list.NewStringItem(
+ fmt.Sprintf("%d", m.chat.Len()),
+ fmt.Sprintf("Welcome to Crush Chat! %d", rand.Intn(1000)),
+ ).WithFocusStyles(&m.com.Styles.BorderFocus, &m.com.Styles.BorderBlur),
+ )
+ m.chat.SetSelectedIndex(m.chat.Len() - 1)
+ m.chat.Focus()
+ m.chat.ScrollToBottom()
case key.Matches(msg, m.keyMap.Tab):
switch m.state {
case uiChat:
@@ -317,11 +340,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
header := uv.NewStyledString(m.header)
header.Draw(scr, layout.header)
m.drawSidebar(scr, layout.sidebar)
- mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
- Height(layout.main.Dy()).
- Render(" Chat Messages ")
- main := uv.NewStyledString(mainView)
- main.Draw(scr, layout.main)
+
+ m.chat.Draw(scr, layout.main)
editor := uv.NewStyledString(m.textarea.View())
editor.Draw(scr, layout.editor)
@@ -517,7 +537,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
case uiChat, uiLanding, uiChatCompact:
switch m.focus {
case uiFocusMain:
- cmds = append(cmds, m.updateChat(msg)...)
case uiFocusEditor:
switch {
case key.Matches(msg, m.keyMap.Editor.Newline):
@@ -533,15 +552,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
return cmds
}
-// updateChat updates the chat model with the given message and appends any
-// resulting commands to the cmds slice.
-func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
- updatedChat, cmd := m.chat.Update(msg)
- m.chat = updatedChat
- cmds = append(cmds, cmd)
- return cmds
-}
-
// updateLayoutAndSize updates the layout and sizes of UI components.
func (m *UI) updateLayoutAndSize() {
m.layout = generateLayout(m, m.width, m.height)
@@ -567,6 +577,7 @@ func (m *UI) updateSize() {
m.renderSidebarLogo(m.layout.sidebar.Dx())
m.textarea.SetWidth(m.layout.editor.Dx())
m.textarea.SetHeight(m.layout.editor.Dy())
+ m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
case uiChatCompact:
// TODO: set the width and heigh of the chat component
@@ -667,6 +678,8 @@ func generateLayout(m *UI, w, h int) layout {
// Add padding left
sideRect.Min.X += 1
mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
+ // Add bottom margin to main
+ mainRect.Max.Y -= 1
layout.sidebar = sideRect
layout.main = mainRect
layout.editor = editorRect