diff --git a/internal/ui/list/example_test.go b/internal/ui/list/example_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d88a616ddb7a58d547c55fb72e46f7fab31a9ec8 --- /dev/null +++ b/internal/ui/list/example_test.go @@ -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) +} diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go new file mode 100644 index 0000000000000000000000000000000000000000..8e8a5cf27a022bb1af9daefd4f162dee0acd9a48 --- /dev/null +++ b/internal/ui/list/item.go @@ -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) + } +} diff --git a/internal/ui/list/item_test.go b/internal/ui/list/item_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4ed2529441fcf2954436eacb0699224054ae4ee4 --- /dev/null +++ b/internal/ui/list/item_test.go @@ -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") + } +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..0b87aca1790776666a97cfb7cb76fe83933375e2 --- /dev/null +++ b/internal/ui/list/list.go @@ -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 + } +} diff --git a/internal/ui/list/list_test.go b/internal/ui/list/list_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5ec228e698fae6b52c6c44e981348d964fbd46ca --- /dev/null +++ b/internal/ui/list/list_test.go @@ -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) + } +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index d81c02ee0d8dc7ca233476d5a789c2b25f9f0305..7a3b2847fb3b8c659d69fe4645cd1228ee881c88 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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