on main help sign in
feat(ui): add optimized list component with focus navigation
b71baeeAyman Bagabas
internal/ui/list/example_test.gonew file+276
@@ -0,0 +1,276 @@
1+package list_test
2+
3+import (
4+ "fmt"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/charmbracelet/crush/internal/ui/list"
8+ uv "github.com/charmbracelet/ultraviolet"
9+)
10+
11+// Example demonstrates basic list usage with string items.
12+func Example_basic() {
13+ // Create some items
14+ items := []list.Item{
15+ list.NewStringItem("1", "First item"),
16+ list.NewStringItem("2", "Second item"),
17+ list.NewStringItem("3", "Third item"),
18+ }
19+
20+ // Create a list with options
21+ l := list.New(items...)
22+ l.SetSize(80, 10)
23+ l.SetSelectedIndex(0)
24+ if true {
25+ l.Focus()
26+ }
27+
28+ // Draw to a screen buffer
29+ screen := uv.NewScreenBuffer(80, 10)
30+ area := uv.Rect(0, 0, 80, 10)
31+ l.Draw(&screen, area)
32+
33+ // Render to string
34+ output := screen.Render()
35+ fmt.Println(output)
36+}
37+
38+// BorderedItem demonstrates a focusable item with borders.
39+type BorderedItem struct {
40+ id string
41+ content string
42+ focused bool
43+ width int
44+}
45+
46+func NewBorderedItem(id, content string) *BorderedItem {
47+ return &BorderedItem{
48+ id: id,
49+ content: content,
50+ width: 80,
51+ }
52+}
53+
54+func (b *BorderedItem) ID() string {
55+ return b.id
56+}
57+
58+func (b *BorderedItem) Height(width int) int {
59+ // Account for border (2 lines for top and bottom)
60+ b.width = width // Update width for rendering
61+ return lipgloss.Height(b.render())
62+}
63+
64+func (b *BorderedItem) Draw(scr uv.Screen, area uv.Rectangle) {
65+ rendered := b.render()
66+ styled := uv.NewStyledString(rendered)
67+ styled.Draw(scr, area)
68+}
69+
70+func (b *BorderedItem) render() string {
71+ style := lipgloss.NewStyle().
72+ Width(b.width-4).
73+ Padding(0, 1)
74+
75+ if b.focused {
76+ style = style.
77+ Border(lipgloss.RoundedBorder()).
78+ BorderForeground(lipgloss.Color("205"))
79+ } else {
80+ style = style.
81+ Border(lipgloss.NormalBorder()).
82+ BorderForeground(lipgloss.Color("240"))
83+ }
84+
85+ return style.Render(b.content)
86+}
87+
88+func (b *BorderedItem) Focus() {
89+ b.focused = true
90+}
91+
92+func (b *BorderedItem) Blur() {
93+ b.focused = false
94+}
95+
96+func (b *BorderedItem) IsFocused() bool {
97+ return b.focused
98+}
99+
100+// Example demonstrates focusable items with borders.
101+func Example_focusable() {
102+ // Create focusable items
103+ items := []list.Item{
104+ NewBorderedItem("1", "Focusable Item 1"),
105+ NewBorderedItem("2", "Focusable Item 2"),
106+ NewBorderedItem("3", "Focusable Item 3"),
107+ }
108+
109+ // Create list with first item selected and focused
110+ l := list.New(items...)
111+ l.SetSize(80, 20)
112+ l.SetSelectedIndex(0)
113+ if true {
114+ l.Focus()
115+ }
116+
117+ // Draw to screen
118+ screen := uv.NewScreenBuffer(80, 20)
119+ area := uv.Rect(0, 0, 80, 20)
120+ l.Draw(&screen, area)
121+
122+ // The first item will have a colored border since it's focused
123+ output := screen.Render()
124+ fmt.Println(output)
125+}
126+
127+// Example demonstrates dynamic item updates.
128+func Example_dynamicUpdates() {
129+ items := []list.Item{
130+ list.NewStringItem("1", "Item 1"),
131+ list.NewStringItem("2", "Item 2"),
132+ }
133+
134+ l := list.New(items...)
135+ l.SetSize(80, 10)
136+
137+ // Draw initial state
138+ screen := uv.NewScreenBuffer(80, 10)
139+ area := uv.Rect(0, 0, 80, 10)
140+ l.Draw(&screen, area)
141+
142+ // Update an item
143+ l.UpdateItem("2", list.NewStringItem("2", "Updated Item 2"))
144+
145+ // Draw again - only changed item is re-rendered
146+ l.Draw(&screen, area)
147+
148+ // Append a new item
149+ l.AppendItem(list.NewStringItem("3", "New Item 3"))
150+
151+ // Draw again - master buffer grows efficiently
152+ l.Draw(&screen, area)
153+
154+ output := screen.Render()
155+ fmt.Println(output)
156+}
157+
158+// Example demonstrates scrolling with a large list.
159+func Example_scrolling() {
160+ // Create many items
161+ items := make([]list.Item, 100)
162+ for i := range items {
163+ items[i] = list.NewStringItem(
164+ fmt.Sprintf("%d", i),
165+ fmt.Sprintf("Item %d", i),
166+ )
167+ }
168+
169+ // Create list with small viewport
170+ l := list.New(items...)
171+ l.SetSize(80, 10)
172+ l.SetSelectedIndex(0)
173+
174+ // Draw initial view (shows items 0-9)
175+ screen := uv.NewScreenBuffer(80, 10)
176+ area := uv.Rect(0, 0, 80, 10)
177+ l.Draw(&screen, area)
178+
179+ // Scroll down
180+ l.ScrollBy(5)
181+ l.Draw(&screen, area) // Now shows items 5-14
182+
183+ // Jump to specific item
184+ l.ScrollToItem("50")
185+ l.Draw(&screen, area) // Now shows item 50 and neighbors
186+
187+ // Scroll to bottom
188+ l.ScrollToBottom()
189+ l.Draw(&screen, area) // Now shows last 10 items
190+
191+ output := screen.Render()
192+ fmt.Println(output)
193+}
194+
195+// VariableHeightItem demonstrates items with different heights.
196+type VariableHeightItem struct {
197+ id string
198+ lines []string
199+ width int
200+}
201+
202+func NewVariableHeightItem(id string, lines []string) *VariableHeightItem {
203+ return &VariableHeightItem{
204+ id: id,
205+ lines: lines,
206+ width: 80,
207+ }
208+}
209+
210+func (v *VariableHeightItem) ID() string {
211+ return v.id
212+}
213+
214+func (v *VariableHeightItem) Height(width int) int {
215+ return len(v.lines)
216+}
217+
218+func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) {
219+ content := ""
220+ for i, line := range v.lines {
221+ if i > 0 {
222+ content += "\n"
223+ }
224+ content += line
225+ }
226+ styled := uv.NewStyledString(content)
227+ styled.Draw(scr, area)
228+}
229+
230+// Example demonstrates variable height items.
231+func Example_variableHeights() {
232+ items := []list.Item{
233+ NewVariableHeightItem("1", []string{"Short item"}),
234+ NewVariableHeightItem("2", []string{
235+ "This is a taller item",
236+ "that spans multiple lines",
237+ "to demonstrate variable heights",
238+ }),
239+ NewVariableHeightItem("3", []string{"Another short item"}),
240+ NewVariableHeightItem("4", []string{
241+ "A medium height item",
242+ "with two lines",
243+ }),
244+ }
245+
246+ l := list.New(items...)
247+ l.SetSize(80, 15)
248+
249+ screen := uv.NewScreenBuffer(80, 15)
250+ area := uv.Rect(0, 0, 80, 15)
251+ l.Draw(&screen, area)
252+
253+ output := screen.Render()
254+ fmt.Println(output)
255+}
256+
257+// Example demonstrates markdown items in a list.
258+func Example_markdown() {
259+ // Create markdown items
260+ items := []list.Item{
261+ list.NewMarkdownItem("1", "# Welcome\n\nThis is a **markdown** item."),
262+ list.NewMarkdownItem("2", "## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
263+ list.NewMarkdownItem("3", "### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"),
264+ }
265+
266+ // Create list
267+ l := list.New(items...)
268+ l.SetSize(80, 20)
269+
270+ screen := uv.NewScreenBuffer(80, 20)
271+ area := uv.Rect(0, 0, 80, 20)
272+ l.Draw(&screen, area)
273+
274+ output := screen.Render()
275+ fmt.Println(output)
276+}
internal/ui/list/item.gonew file+343
@@ -0,0 +1,343 @@
1+package list
2+
3+import (
4+ "strings"
5+
6+ "charm.land/lipgloss/v2"
7+ "github.com/charmbracelet/glamour/v2"
8+ "github.com/charmbracelet/glamour/v2/ansi"
9+ uv "github.com/charmbracelet/ultraviolet"
10+ "github.com/charmbracelet/ultraviolet/screen"
11+)
12+
13+// Item represents a list item that can draw itself to a UV buffer.
14+// Items implement the uv.Drawable interface.
15+type Item interface {
16+ uv.Drawable
17+
18+ // ID returns unique identifier for this item.
19+ ID() string
20+
21+ // Height returns the item's height in lines for the given width.
22+ // This allows items to calculate height based on text wrapping and available space.
23+ Height(width int) int
24+}
25+
26+// Focusable is an optional interface for items that support focus.
27+// When implemented, items can change appearance when focused (borders, colors, etc).
28+type Focusable interface {
29+ Focus()
30+ Blur()
31+ IsFocused() bool
32+}
33+
34+// BaseFocusable provides common focus state and styling for items.
35+// Embed this type to add focus behavior to any item.
36+type BaseFocusable struct {
37+ focused bool
38+ focusStyle *lipgloss.Style
39+ blurStyle *lipgloss.Style
40+}
41+
42+// Focus implements Focusable interface.
43+func (b *BaseFocusable) Focus() {
44+ b.focused = true
45+}
46+
47+// Blur implements Focusable interface.
48+func (b *BaseFocusable) Blur() {
49+ b.focused = false
50+}
51+
52+// IsFocused implements Focusable interface.
53+func (b *BaseFocusable) IsFocused() bool {
54+ return b.focused
55+}
56+
57+// HasFocusStyles returns true if both focus and blur styles are configured.
58+func (b *BaseFocusable) HasFocusStyles() bool {
59+ return b.focusStyle != nil && b.blurStyle != nil
60+}
61+
62+// CurrentStyle returns the current style based on focus state.
63+// Returns nil if no styles are configured, or if the current state's style is nil.
64+func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
65+ if b.focused {
66+ return b.focusStyle
67+ }
68+ return b.blurStyle
69+}
70+
71+// SetFocusStyles sets the focus and blur styles.
72+func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
73+ b.focusStyle = focusStyle
74+ b.blurStyle = blurStyle
75+}
76+
77+// StringItem is a simple string-based item with optional text wrapping.
78+// It caches rendered content by width for efficient repeated rendering.
79+// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
80+type StringItem struct {
81+ BaseFocusable
82+ id string
83+ content string // Raw content string (may contain ANSI styles)
84+ wrap bool // Whether to wrap text
85+
86+ // Cache for rendered content at specific widths
87+ // Key: width, Value: string
88+ cache map[int]string
89+}
90+
91+// NewStringItem creates a new string item with the given ID and content.
92+func NewStringItem(id, content string) *StringItem {
93+ return &StringItem{
94+ id: id,
95+ content: content,
96+ wrap: false,
97+ cache: make(map[int]string),
98+ }
99+}
100+
101+// NewWrappingStringItem creates a new string item that wraps text to fit width.
102+func NewWrappingStringItem(id, content string) *StringItem {
103+ return &StringItem{
104+ id: id,
105+ content: content,
106+ wrap: true,
107+ cache: make(map[int]string),
108+ }
109+}
110+
111+// WithFocusStyles sets the focus and blur styles for the string item.
112+// If both styles are non-nil, the item will implement Focusable.
113+func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
114+ s.SetFocusStyles(focusStyle, blurStyle)
115+ return s
116+}
117+
118+// ID implements Item.
119+func (s *StringItem) ID() string {
120+ return s.id
121+}
122+
123+// Height implements Item.
124+func (s *StringItem) Height(width int) int {
125+ // Calculate content width if we have styles
126+ contentWidth := width
127+ if style := s.CurrentStyle(); style != nil {
128+ hFrameSize := style.GetHorizontalFrameSize()
129+ if hFrameSize > 0 {
130+ contentWidth -= hFrameSize
131+ }
132+ }
133+
134+ var lines int
135+ if !s.wrap {
136+ // No wrapping - height is just the number of newlines + 1
137+ lines = strings.Count(s.content, "\n") + 1
138+ } else {
139+ // Use lipgloss.Wrap to wrap the content and count lines
140+ // This preserves ANSI styles and is much faster than rendering to a buffer
141+ wrapped := lipgloss.Wrap(s.content, contentWidth, "")
142+ lines = strings.Count(wrapped, "\n") + 1
143+ }
144+
145+ // Add vertical frame size if we have styles
146+ if style := s.CurrentStyle(); style != nil {
147+ lines += style.GetVerticalFrameSize()
148+ }
149+
150+ return lines
151+}
152+
153+// Draw implements Item and uv.Drawable.
154+func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
155+ width := area.Dx()
156+
157+ // Check cache first
158+ content, ok := s.cache[width]
159+ if !ok {
160+ // Not cached - create and cache
161+ content = s.content
162+ if s.wrap {
163+ // Wrap content using lipgloss
164+ content = lipgloss.Wrap(s.content, width, "")
165+ }
166+ s.cache[width] = content
167+ }
168+
169+ // Apply focus/blur styling if configured
170+ if style := s.CurrentStyle(); style != nil {
171+ content = style.Width(width).Render(content)
172+ }
173+
174+ // Draw the styled string
175+ styled := uv.NewStyledString(content)
176+ styled.Draw(scr, area)
177+}
178+
179+// MarkdownItem renders markdown content using Glamour.
180+// It caches all rendered content by width for efficient repeated rendering.
181+// The wrap width is capped at 120 cells by default to ensure readable line lengths.
182+// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
183+type MarkdownItem struct {
184+ BaseFocusable
185+ id string
186+ markdown string // Raw markdown content
187+ styleConfig *ansi.StyleConfig // Optional style configuration
188+ maxWidth int // Maximum wrap width (default 120)
189+
190+ // Cache for rendered content at specific widths
191+ // Key: width (capped to maxWidth), Value: rendered markdown string
192+ cache map[int]string
193+}
194+
195+// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering.
196+const DefaultMarkdownMaxWidth = 120
197+
198+// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
199+// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
200+func NewMarkdownItem(id, markdown string) *MarkdownItem {
201+ m := &MarkdownItem{
202+ id: id,
203+ markdown: markdown,
204+ maxWidth: DefaultMarkdownMaxWidth,
205+ cache: make(map[int]string),
206+ }
207+
208+ return m
209+}
210+
211+// WithStyleConfig sets a custom Glamour style configuration for the markdown item.
212+func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem {
213+ m.styleConfig = &styleConfig
214+ return m
215+}
216+
217+// WithMaxWidth sets the maximum wrap width for markdown rendering.
218+func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem {
219+ m.maxWidth = maxWidth
220+ return m
221+}
222+
223+// WithFocusStyles sets the focus and blur styles for the markdown item.
224+// If both styles are non-nil, the item will implement Focusable.
225+func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
226+ m.SetFocusStyles(focusStyle, blurStyle)
227+ return m
228+}
229+
230+// ID implements Item.
231+func (m *MarkdownItem) ID() string {
232+ return m.id
233+}
234+
235+// Height implements Item.
236+func (m *MarkdownItem) Height(width int) int {
237+ // Render the markdown to get its height
238+ rendered := m.renderMarkdown(width)
239+
240+ // Apply focus/blur styling if configured to get accurate height
241+ if style := m.CurrentStyle(); style != nil {
242+ rendered = style.Render(rendered)
243+ }
244+
245+ return strings.Count(rendered, "\n") + 1
246+}
247+
248+// Draw implements Item and uv.Drawable.
249+func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
250+ width := area.Dx()
251+ rendered := m.renderMarkdown(width)
252+
253+ // Apply focus/blur styling if configured
254+ if style := m.CurrentStyle(); style != nil {
255+ rendered = style.Render(rendered)
256+ }
257+
258+ // Draw the rendered markdown
259+ styled := uv.NewStyledString(rendered)
260+ styled.Draw(scr, area)
261+}
262+
263+// renderMarkdown renders the markdown content at the given width, using cache if available.
264+// Width is always capped to maxWidth to ensure readable line lengths.
265+func (m *MarkdownItem) renderMarkdown(width int) string {
266+ // Cap width to maxWidth
267+ cappedWidth := min(width, m.maxWidth)
268+
269+ // Check cache first (always cache all rendered markdown)
270+ if cached, ok := m.cache[cappedWidth]; ok {
271+ return cached
272+ }
273+
274+ // Not cached - render now
275+ opts := []glamour.TermRendererOption{
276+ glamour.WithWordWrap(cappedWidth),
277+ }
278+
279+ // Add style config if provided
280+ if m.styleConfig != nil {
281+ opts = append(opts, glamour.WithStyles(*m.styleConfig))
282+ }
283+
284+ renderer, err := glamour.NewTermRenderer(opts...)
285+ if err != nil {
286+ // Fallback to plain text on error
287+ return m.markdown
288+ }
289+
290+ rendered, err := renderer.Render(m.markdown)
291+ if err != nil {
292+ // Fallback to plain text on error
293+ return m.markdown
294+ }
295+
296+ // Trim trailing whitespace
297+ rendered = strings.TrimRight(rendered, "\n\r ")
298+
299+ // Always cache
300+ m.cache[cappedWidth] = rendered
301+
302+ return rendered
303+}
304+
305+// Gap is a 1-line spacer item used to add gaps between items.
306+var Gap = NewSpacerItem("spacer-gap", 1)
307+
308+// SpacerItem is an empty item that takes up vertical space.
309+// Useful for adding gaps between items in a list.
310+type SpacerItem struct {
311+ id string
312+ height int
313+}
314+
315+var _ Item = (*SpacerItem)(nil)
316+
317+// NewSpacerItem creates a new spacer item with the given ID and height in lines.
318+func NewSpacerItem(id string, height int) *SpacerItem {
319+ return &SpacerItem{
320+ id: id,
321+ height: height,
322+ }
323+}
324+
325+// ID implements Item.
326+func (s *SpacerItem) ID() string {
327+ return s.id
328+}
329+
330+// Height implements Item.
331+func (s *SpacerItem) Height(width int) int {
332+ return s.height
333+}
334+
335+// Draw implements Item.
336+// Spacer items don't draw anything, they just take up space.
337+func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) {
338+ // Ensure the area is filled with spaces to clear any existing content
339+ spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height))
340+ if spacerArea.Overlaps(area) {
341+ screen.ClearArea(scr, spacerArea)
342+ }
343+}
internal/ui/list/item_test.gonew file+602
@@ -0,0 +1,602 @@
1+package list
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/charmbracelet/glamour/v2/ansi"
8+ uv "github.com/charmbracelet/ultraviolet"
9+)
10+
11+func TestRenderHelper(t *testing.T) {
12+ items := []Item{
13+ NewStringItem("1", "Item 1"),
14+ NewStringItem("2", "Item 2"),
15+ NewStringItem("3", "Item 3"),
16+ }
17+
18+ l := New(items...)
19+ l.SetSize(80, 10)
20+
21+ // Render to string
22+ output := l.Render()
23+
24+ if len(output) == 0 {
25+ t.Error("expected non-empty output from Render()")
26+ }
27+
28+ // Check that output contains the items
29+ if !strings.Contains(output, "Item 1") {
30+ t.Error("expected output to contain 'Item 1'")
31+ }
32+ if !strings.Contains(output, "Item 2") {
33+ t.Error("expected output to contain 'Item 2'")
34+ }
35+ if !strings.Contains(output, "Item 3") {
36+ t.Error("expected output to contain 'Item 3'")
37+ }
38+}
39+
40+func TestRenderWithScrolling(t *testing.T) {
41+ items := []Item{
42+ NewStringItem("1", "Item 1"),
43+ NewStringItem("2", "Item 2"),
44+ NewStringItem("3", "Item 3"),
45+ NewStringItem("4", "Item 4"),
46+ NewStringItem("5", "Item 5"),
47+ }
48+
49+ l := New(items...)
50+ l.SetSize(80, 2) // Small viewport
51+
52+ // Initial render should show first 2 items
53+ output := l.Render()
54+ if !strings.Contains(output, "Item 1") {
55+ t.Error("expected output to contain 'Item 1'")
56+ }
57+ if !strings.Contains(output, "Item 2") {
58+ t.Error("expected output to contain 'Item 2'")
59+ }
60+ if strings.Contains(output, "Item 3") {
61+ t.Error("expected output to NOT contain 'Item 3' in initial view")
62+ }
63+
64+ // Scroll down and render
65+ l.ScrollBy(2)
66+ output = l.Render()
67+
68+ // Now should show items 3 and 4
69+ if strings.Contains(output, "Item 1") {
70+ t.Error("expected output to NOT contain 'Item 1' after scrolling")
71+ }
72+ if !strings.Contains(output, "Item 3") {
73+ t.Error("expected output to contain 'Item 3' after scrolling")
74+ }
75+ if !strings.Contains(output, "Item 4") {
76+ t.Error("expected output to contain 'Item 4' after scrolling")
77+ }
78+}
79+
80+func TestRenderEmptyList(t *testing.T) {
81+ l := New()
82+ l.SetSize(80, 10)
83+
84+ output := l.Render()
85+ if output != "" {
86+ t.Errorf("expected empty output for empty list, got: %q", output)
87+ }
88+}
89+
90+func TestRenderVsDrawConsistency(t *testing.T) {
91+ items := []Item{
92+ NewStringItem("1", "Item 1"),
93+ NewStringItem("2", "Item 2"),
94+ }
95+
96+ l := New(items...)
97+ l.SetSize(80, 10)
98+
99+ // Render using Render() method
100+ renderOutput := l.Render()
101+
102+ // Render using Draw() method
103+ screen := uv.NewScreenBuffer(80, 10)
104+ area := uv.Rect(0, 0, 80, 10)
105+ l.Draw(&screen, area)
106+ drawOutput := screen.Render()
107+
108+ // Trim any trailing whitespace for comparison
109+ renderOutput = strings.TrimRight(renderOutput, "\n")
110+ drawOutput = strings.TrimRight(drawOutput, "\n")
111+
112+ // Both methods should produce the same output
113+ if renderOutput != drawOutput {
114+ t.Errorf("Render() and Draw() produced different outputs:\nRender():\n%q\n\nDraw():\n%q",
115+ renderOutput, drawOutput)
116+ }
117+}
118+
119+func BenchmarkRender(b *testing.B) {
120+ items := make([]Item, 100)
121+ for i := range items {
122+ items[i] = NewStringItem(string(rune(i)), "Item content here")
123+ }
124+
125+ l := New(items...)
126+ l.SetSize(80, 24)
127+ l.Render() // Prime the buffer
128+
129+ b.ResetTimer()
130+ for i := 0; i < b.N; i++ {
131+ _ = l.Render()
132+ }
133+}
134+
135+func BenchmarkRenderWithScrolling(b *testing.B) {
136+ items := make([]Item, 1000)
137+ for i := range items {
138+ items[i] = NewStringItem(string(rune(i)), "Item content here")
139+ }
140+
141+ l := New(items...)
142+ l.SetSize(80, 24)
143+ l.Render() // Prime the buffer
144+
145+ b.ResetTimer()
146+ for i := 0; i < b.N; i++ {
147+ l.ScrollBy(1)
148+ _ = l.Render()
149+ }
150+}
151+
152+func TestStringItemCache(t *testing.T) {
153+ item := NewStringItem("1", "Test content")
154+
155+ // First draw at width 80 should populate cache
156+ screen1 := uv.NewScreenBuffer(80, 5)
157+ area1 := uv.Rect(0, 0, 80, 5)
158+ item.Draw(&screen1, area1)
159+
160+ if len(item.cache) != 1 {
161+ t.Errorf("expected cache to have 1 entry after first draw, got %d", len(item.cache))
162+ }
163+ if _, ok := item.cache[80]; !ok {
164+ t.Error("expected cache to have entry for width 80")
165+ }
166+
167+ // Second draw at same width should reuse cache
168+ screen2 := uv.NewScreenBuffer(80, 5)
169+ area2 := uv.Rect(0, 0, 80, 5)
170+ item.Draw(&screen2, area2)
171+
172+ if len(item.cache) != 1 {
173+ t.Errorf("expected cache to still have 1 entry after second draw, got %d", len(item.cache))
174+ }
175+
176+ // Draw at different width should add to cache
177+ screen3 := uv.NewScreenBuffer(40, 5)
178+ area3 := uv.Rect(0, 0, 40, 5)
179+ item.Draw(&screen3, area3)
180+
181+ if len(item.cache) != 2 {
182+ t.Errorf("expected cache to have 2 entries after draw at different width, got %d", len(item.cache))
183+ }
184+ if _, ok := item.cache[40]; !ok {
185+ t.Error("expected cache to have entry for width 40")
186+ }
187+}
188+
189+func TestWrappingItemHeight(t *testing.T) {
190+ // Short text that fits in one line
191+ item1 := NewWrappingStringItem("1", "Short")
192+ if h := item1.Height(80); h != 1 {
193+ t.Errorf("expected height 1 for short text, got %d", h)
194+ }
195+
196+ // Long text that wraps
197+ longText := "This is a very long line that will definitely wrap when constrained to a narrow width"
198+ item2 := NewWrappingStringItem("2", longText)
199+
200+ // At width 80, should be fewer lines than width 20
201+ height80 := item2.Height(80)
202+ height20 := item2.Height(20)
203+
204+ if height20 <= height80 {
205+ t.Errorf("expected more lines at narrow width (20: %d lines) than wide width (80: %d lines)",
206+ height20, height80)
207+ }
208+
209+ // Non-wrapping version should always be 1 line
210+ item3 := NewStringItem("3", longText)
211+ if h := item3.Height(20); h != 1 {
212+ t.Errorf("expected height 1 for non-wrapping item, got %d", h)
213+ }
214+}
215+
216+func TestMarkdownItemBasic(t *testing.T) {
217+ markdown := "# Hello\n\nThis is a **test**."
218+ item := NewMarkdownItem("1", markdown)
219+
220+ if item.ID() != "1" {
221+ t.Errorf("expected ID '1', got '%s'", item.ID())
222+ }
223+
224+ // Test that height is calculated
225+ height := item.Height(80)
226+ if height < 1 {
227+ t.Errorf("expected height >= 1, got %d", height)
228+ }
229+
230+ // Test drawing
231+ screen := uv.NewScreenBuffer(80, 10)
232+ area := uv.Rect(0, 0, 80, 10)
233+ item.Draw(&screen, area)
234+
235+ // Should not panic and should render something
236+ rendered := screen.Render()
237+ if len(rendered) == 0 {
238+ t.Error("expected non-empty rendered output")
239+ }
240+}
241+
242+func TestMarkdownItemCache(t *testing.T) {
243+ markdown := "# Test\n\nSome content."
244+ item := NewMarkdownItem("1", markdown)
245+
246+ // First render at width 80 should populate cache
247+ height1 := item.Height(80)
248+ if len(item.cache) != 1 {
249+ t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
250+ }
251+
252+ // Second render at same width should reuse cache
253+ height2 := item.Height(80)
254+ if height1 != height2 {
255+ t.Errorf("expected consistent height, got %d then %d", height1, height2)
256+ }
257+ if len(item.cache) != 1 {
258+ t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
259+ }
260+
261+ // Render at different width should add to cache
262+ _ = item.Height(40)
263+ if len(item.cache) != 2 {
264+ t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
265+ }
266+}
267+
268+func TestMarkdownItemMaxCacheWidth(t *testing.T) {
269+ markdown := "# Test\n\nSome content."
270+ item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
271+
272+ // Render at width 40 (below limit) - should cache at width 40
273+ _ = item.Height(40)
274+ if len(item.cache) != 1 {
275+ t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
276+ }
277+
278+ // Render at width 80 (above limit) - should cap to 50 and cache
279+ _ = item.Height(80)
280+ // Cache should have width 50 entry (capped from 80)
281+ if len(item.cache) != 2 {
282+ t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
283+ }
284+ if _, ok := item.cache[50]; !ok {
285+ t.Error("expected cache to have entry for width 50 (capped from 80)")
286+ }
287+
288+ // Render at width 100 (also above limit) - should reuse cached width 50
289+ _ = item.Height(100)
290+ if len(item.cache) != 2 {
291+ t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
292+ }
293+}
294+
295+func TestMarkdownItemWithStyleConfig(t *testing.T) {
296+ markdown := "# Styled\n\nContent with **bold** text."
297+
298+ // Create a custom style config
299+ styleConfig := ansi.StyleConfig{
300+ Document: ansi.StyleBlock{
301+ Margin: uintPtr(0),
302+ },
303+ }
304+
305+ item := NewMarkdownItem("1", markdown).WithStyleConfig(styleConfig)
306+
307+ // Render should use the custom style
308+ height := item.Height(80)
309+ if height < 1 {
310+ t.Errorf("expected height >= 1, got %d", height)
311+ }
312+
313+ // Draw should work without panic
314+ screen := uv.NewScreenBuffer(80, 10)
315+ area := uv.Rect(0, 0, 80, 10)
316+ item.Draw(&screen, area)
317+
318+ rendered := screen.Render()
319+ if len(rendered) == 0 {
320+ t.Error("expected non-empty rendered output with custom style")
321+ }
322+}
323+
324+func TestMarkdownItemInList(t *testing.T) {
325+ items := []Item{
326+ NewMarkdownItem("1", "# First\n\nMarkdown item."),
327+ NewMarkdownItem("2", "# Second\n\nAnother item."),
328+ NewStringItem("3", "Regular string item"),
329+ }
330+
331+ l := New(items...)
332+ l.SetSize(80, 20)
333+
334+ // Should render without error
335+ output := l.Render()
336+ if len(output) == 0 {
337+ t.Error("expected non-empty output from list with markdown items")
338+ }
339+
340+ // Should contain content from markdown items
341+ if !strings.Contains(output, "First") {
342+ t.Error("expected output to contain 'First'")
343+ }
344+ if !strings.Contains(output, "Second") {
345+ t.Error("expected output to contain 'Second'")
346+ }
347+ if !strings.Contains(output, "Regular string item") {
348+ t.Error("expected output to contain 'Regular string item'")
349+ }
350+}
351+
352+func TestMarkdownItemHeightWithWidth(t *testing.T) {
353+ // Test that widths are capped to maxWidth
354+ markdown := "This is a paragraph with some text."
355+
356+ item := NewMarkdownItem("1", markdown).WithMaxWidth(50)
357+
358+ // At width 30 (below limit), should cache and render at width 30
359+ height30 := item.Height(30)
360+ if height30 < 1 {
361+ t.Errorf("expected height >= 1, got %d", height30)
362+ }
363+
364+ // At width 100 (above maxWidth), should cap to 50 and cache
365+ height100 := item.Height(100)
366+ if height100 < 1 {
367+ t.Errorf("expected height >= 1, got %d", height100)
368+ }
369+
370+ // Both should be cached (width 30 and capped width 50)
371+ if len(item.cache) != 2 {
372+ t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
373+ }
374+ if _, ok := item.cache[30]; !ok {
375+ t.Error("expected cache to have entry for width 30")
376+ }
377+ if _, ok := item.cache[50]; !ok {
378+ t.Error("expected cache to have entry for width 50 (capped from 100)")
379+ }
380+}
381+
382+func BenchmarkMarkdownItemRender(b *testing.B) {
383+ markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
384+ item := NewMarkdownItem("1", markdown)
385+
386+ // Prime the cache
387+ screen := uv.NewScreenBuffer(80, 10)
388+ area := uv.Rect(0, 0, 80, 10)
389+ item.Draw(&screen, area)
390+
391+ b.ResetTimer()
392+ for i := 0; i < b.N; i++ {
393+ screen := uv.NewScreenBuffer(80, 10)
394+ area := uv.Rect(0, 0, 80, 10)
395+ item.Draw(&screen, area)
396+ }
397+}
398+
399+func BenchmarkMarkdownItemUncached(b *testing.B) {
400+ markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
401+
402+ b.ResetTimer()
403+ for i := 0; i < b.N; i++ {
404+ item := NewMarkdownItem("1", markdown)
405+ screen := uv.NewScreenBuffer(80, 10)
406+ area := uv.Rect(0, 0, 80, 10)
407+ item.Draw(&screen, area)
408+ }
409+}
410+
411+func TestSpacerItem(t *testing.T) {
412+ spacer := NewSpacerItem("spacer1", 3)
413+
414+ // Check ID
415+ if spacer.ID() != "spacer1" {
416+ t.Errorf("expected ID 'spacer1', got %q", spacer.ID())
417+ }
418+
419+ // Check height
420+ if h := spacer.Height(80); h != 3 {
421+ t.Errorf("expected height 3, got %d", h)
422+ }
423+
424+ // Height should be constant regardless of width
425+ if h := spacer.Height(20); h != 3 {
426+ t.Errorf("expected height 3 for width 20, got %d", h)
427+ }
428+
429+ // Draw should not produce any visible content
430+ screen := uv.NewScreenBuffer(20, 3)
431+ area := uv.Rect(0, 0, 20, 3)
432+ spacer.Draw(&screen, area)
433+
434+ output := screen.Render()
435+ // Should be empty (just spaces)
436+ for _, line := range strings.Split(output, "\n") {
437+ trimmed := strings.TrimSpace(line)
438+ if trimmed != "" {
439+ t.Errorf("expected empty spacer output, got: %q", line)
440+ }
441+ }
442+}
443+
444+func TestSpacerItemInList(t *testing.T) {
445+ // Create a list with items separated by spacers
446+ items := []Item{
447+ NewStringItem("1", "Item 1"),
448+ NewSpacerItem("spacer1", 1),
449+ NewStringItem("2", "Item 2"),
450+ NewSpacerItem("spacer2", 2),
451+ NewStringItem("3", "Item 3"),
452+ }
453+
454+ l := New(items...)
455+ l.SetSize(20, 10)
456+
457+ output := l.Render()
458+
459+ // Should contain all three items
460+ if !strings.Contains(output, "Item 1") {
461+ t.Error("expected output to contain 'Item 1'")
462+ }
463+ if !strings.Contains(output, "Item 2") {
464+ t.Error("expected output to contain 'Item 2'")
465+ }
466+ if !strings.Contains(output, "Item 3") {
467+ t.Error("expected output to contain 'Item 3'")
468+ }
469+
470+ // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
471+ expectedHeight := 6
472+ if l.TotalHeight() != expectedHeight {
473+ t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
474+ }
475+}
476+
477+func TestSpacerItemNavigation(t *testing.T) {
478+ // Spacers should not be selectable (they're not focusable)
479+ items := []Item{
480+ NewStringItem("1", "Item 1"),
481+ NewSpacerItem("spacer1", 1),
482+ NewStringItem("2", "Item 2"),
483+ }
484+
485+ l := New(items...)
486+ l.SetSize(20, 10)
487+
488+ // Select first item
489+ l.SetSelectedIndex(0)
490+ if l.SelectedIndex() != 0 {
491+ t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
492+ }
493+
494+ // Can select the spacer (it's a valid item, just not focusable)
495+ l.SetSelectedIndex(1)
496+ if l.SelectedIndex() != 1 {
497+ t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
498+ }
499+
500+ // Can select item after spacer
501+ l.SetSelectedIndex(2)
502+ if l.SelectedIndex() != 2 {
503+ t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
504+ }
505+}
506+
507+// Helper function to create a pointer to uint
508+func uintPtr(v uint) *uint {
509+ return &v
510+}
511+
512+func TestListDoesNotEatLastLine(t *testing.T) {
513+ // Create items that exactly fill the viewport
514+ items := []Item{
515+ NewStringItem("1", "Line 1"),
516+ NewStringItem("2", "Line 2"),
517+ NewStringItem("3", "Line 3"),
518+ NewStringItem("4", "Line 4"),
519+ NewStringItem("5", "Line 5"),
520+ }
521+
522+ // Create list with height exactly matching content (5 lines, no gaps)
523+ l := New(items...)
524+ l.SetSize(20, 5)
525+
526+ // Render the list
527+ output := l.Render()
528+
529+ // Count actual lines in output
530+ lines := strings.Split(strings.TrimRight(output, "\r\n"), "\r\n")
531+ actualLineCount := 0
532+ for _, line := range lines {
533+ if strings.TrimSpace(line) != "" {
534+ actualLineCount++
535+ }
536+ }
537+
538+ // All 5 items should be visible
539+ if !strings.Contains(output, "Line 1") {
540+ t.Error("expected output to contain 'Line 1'")
541+ }
542+ if !strings.Contains(output, "Line 2") {
543+ t.Error("expected output to contain 'Line 2'")
544+ }
545+ if !strings.Contains(output, "Line 3") {
546+ t.Error("expected output to contain 'Line 3'")
547+ }
548+ if !strings.Contains(output, "Line 4") {
549+ t.Error("expected output to contain 'Line 4'")
550+ }
551+ if !strings.Contains(output, "Line 5") {
552+ t.Error("expected output to contain 'Line 5'")
553+ }
554+
555+ if actualLineCount != 5 {
556+ t.Errorf("expected 5 lines with content, got %d", actualLineCount)
557+ }
558+}
559+
560+func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
561+ // Create more items than viewport height
562+ items := []Item{
563+ NewStringItem("1", "Item 1"),
564+ NewStringItem("2", "Item 2"),
565+ NewStringItem("3", "Item 3"),
566+ NewStringItem("4", "Item 4"),
567+ NewStringItem("5", "Item 5"),
568+ NewStringItem("6", "Item 6"),
569+ NewStringItem("7", "Item 7"),
570+ }
571+
572+ // Viewport shows 3 items at a time
573+ l := New(items...)
574+ l.SetSize(20, 3)
575+
576+ // Need to render first to build the buffer and calculate total height
577+ _ = l.Render()
578+
579+ // Now scroll to bottom
580+ l.ScrollToBottom()
581+
582+ output := l.Render()
583+
584+ t.Logf("Output:\n%s", output)
585+ t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
586+
587+ // Should show last 3 items: 5, 6, 7
588+ if !strings.Contains(output, "Item 5") {
589+ t.Error("expected output to contain 'Item 5'")
590+ }
591+ if !strings.Contains(output, "Item 6") {
592+ t.Error("expected output to contain 'Item 6'")
593+ }
594+ if !strings.Contains(output, "Item 7") {
595+ t.Error("expected output to contain 'Item 7'")
596+ }
597+
598+ // Should not show earlier items
599+ if strings.Contains(output, "Item 1") {
600+ t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
601+ }
602+}
internal/ui/list/list.gonew file+795
@@ -0,0 +1,795 @@
1+package list
2+
3+import (
4+ "strings"
5+
6+ uv "github.com/charmbracelet/ultraviolet"
7+ "github.com/charmbracelet/ultraviolet/screen"
8+)
9+
10+// List is a scrollable list component that implements uv.Drawable.
11+// It efficiently manages a large number of items by caching rendered content
12+// in a master buffer and extracting only the visible viewport when drawn.
13+type List struct {
14+ // Configuration
15+ width, height int
16+
17+ // Data
18+ items []Item
19+ indexMap map[string]int // ID -> index for fast lookup
20+
21+ // Focus & Selection
22+ focused bool
23+ selectedIdx int // Currently selected item index (-1 if none)
24+
25+ // Master buffer containing ALL rendered items
26+ masterBuffer *uv.ScreenBuffer
27+ totalHeight int
28+
29+ // Item positioning in master buffer
30+ itemPositions map[string]itemPosition
31+
32+ // Viewport state
33+ offset int // Scroll offset in lines from top
34+
35+ // Dirty tracking
36+ dirty bool
37+ dirtyItems map[string]bool
38+}
39+
40+type itemPosition struct {
41+ startLine int
42+ height int
43+}
44+
45+// New creates a new list with the given items.
46+func New(items ...Item) *List {
47+ l := &List{
48+ items: items,
49+ indexMap: make(map[string]int, len(items)),
50+ itemPositions: make(map[string]itemPosition, len(items)),
51+ dirtyItems: make(map[string]bool),
52+ selectedIdx: -1,
53+ }
54+
55+ // Build index map
56+ for i, item := range items {
57+ l.indexMap[item.ID()] = i
58+ }
59+
60+ l.dirty = true
61+ return l
62+}
63+
64+// ensureBuilt ensures the master buffer is built.
65+// This is called by methods that need itemPositions or totalHeight.
66+func (l *List) ensureBuilt() {
67+ if l.width <= 0 || l.height <= 0 {
68+ return
69+ }
70+
71+ if l.dirty {
72+ l.rebuildMasterBuffer()
73+ } else if len(l.dirtyItems) > 0 {
74+ l.updateDirtyItems()
75+ }
76+}
77+
78+// Draw implements uv.Drawable.
79+// Draws the visible viewport of the list to the given screen buffer.
80+func (l *List) Draw(scr uv.Screen, area uv.Rectangle) {
81+ if area.Dx() <= 0 || area.Dy() <= 0 {
82+ return
83+ }
84+
85+ // Update internal dimensions if area size changed
86+ widthChanged := l.width != area.Dx()
87+ heightChanged := l.height != area.Dy()
88+
89+ l.width = area.Dx()
90+ l.height = area.Dy()
91+
92+ // Only width changes require rebuilding master buffer
93+ // Height changes only affect viewport clipping, not item rendering
94+ if widthChanged {
95+ l.dirty = true
96+ }
97+
98+ // Height changes require clamping offset to new bounds
99+ if heightChanged {
100+ l.clampOffset()
101+ }
102+
103+ if len(l.items) == 0 {
104+ screen.ClearArea(scr, area)
105+ return
106+ }
107+
108+ // Ensure buffer is built
109+ l.ensureBuilt()
110+
111+ // Draw visible portion to the target screen
112+ l.drawViewport(scr, area)
113+}
114+
115+// Render renders the visible viewport to a string.
116+// This is a convenience method that creates a temporary screen buffer,
117+// draws to it, and returns the rendered string.
118+func (l *List) Render() string {
119+ if l.width <= 0 || l.height <= 0 {
120+ return ""
121+ }
122+
123+ if len(l.items) == 0 {
124+ return ""
125+ }
126+
127+ // Ensure buffer is built
128+ l.ensureBuilt()
129+
130+ // Extract visible lines directly from master buffer
131+ return l.renderViewport()
132+}
133+
134+// renderViewport renders the visible portion of the master buffer to a string.
135+func (l *List) renderViewport() string {
136+ if l.masterBuffer == nil {
137+ return ""
138+ }
139+
140+ buf := l.masterBuffer.Buffer
141+
142+ // Calculate visible region in master buffer
143+ srcStartY := l.offset
144+ srcEndY := l.offset + l.height
145+
146+ // Clamp to actual buffer bounds
147+ if srcStartY >= len(buf.Lines) {
148+ // Beyond end of content, return empty lines
149+ emptyLine := strings.Repeat(" ", l.width)
150+ lines := make([]string, l.height)
151+ for i := range lines {
152+ lines[i] = emptyLine
153+ }
154+ return strings.Join(lines, "\r\n")
155+ }
156+ if srcEndY > len(buf.Lines) {
157+ srcEndY = len(buf.Lines)
158+ }
159+
160+ // Build result with proper line handling
161+ lines := make([]string, l.height)
162+ lineIdx := 0
163+
164+ // Render visible lines from buffer
165+ for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ {
166+ lines[lineIdx] = buf.Lines[y].Render()
167+ lineIdx++
168+ }
169+
170+ // Pad remaining lines with spaces to maintain viewport height
171+ emptyLine := strings.Repeat(" ", l.width)
172+ for ; lineIdx < l.height; lineIdx++ {
173+ lines[lineIdx] = emptyLine
174+ }
175+
176+ return strings.Join(lines, "\r\n")
177+}
178+
179+// drawViewport draws the visible portion from master buffer to target screen.
180+func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
181+ if l.masterBuffer == nil {
182+ screen.ClearArea(scr, area)
183+ return
184+ }
185+
186+ buf := l.masterBuffer.Buffer
187+
188+ // Calculate visible region in master buffer
189+ srcStartY := l.offset
190+ srcEndY := l.offset + area.Dy()
191+
192+ // Clamp to actual buffer bounds
193+ if srcStartY >= len(buf.Lines) {
194+ screen.ClearArea(scr, area)
195+ return
196+ }
197+ if srcEndY > len(buf.Lines) {
198+ srcEndY = len(buf.Lines)
199+ }
200+
201+ // Copy visible lines to target screen
202+ destY := area.Min.Y
203+ for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
204+ line := buf.Lines[srcY]
205+ destX := area.Min.X
206+
207+ for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
208+ cell := line.At(x)
209+ scr.SetCell(destX, destY, cell)
210+ destX++
211+ }
212+ destY++
213+ }
214+
215+ // Clear any remaining area if content is shorter than viewport
216+ if destY < area.Max.Y {
217+ clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY)
218+ screen.ClearArea(scr, clearArea)
219+ }
220+}
221+
222+// rebuildMasterBuffer composes all items into the master buffer.
223+func (l *List) rebuildMasterBuffer() {
224+ if len(l.items) == 0 {
225+ l.totalHeight = 0
226+ l.dirty = false
227+ return
228+ }
229+
230+ // Calculate total height
231+ l.totalHeight = l.calculateTotalHeight()
232+
233+ // Create or resize master buffer
234+ if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight {
235+ buf := uv.NewScreenBuffer(l.width, l.totalHeight)
236+ l.masterBuffer = &buf
237+ }
238+
239+ // Clear buffer
240+ screen.Clear(l.masterBuffer)
241+
242+ // Draw each item
243+ currentY := 0
244+ for _, item := range l.items {
245+ itemHeight := item.Height(l.width)
246+
247+ // Draw item to master buffer
248+ area := uv.Rect(0, currentY, l.width, itemHeight)
249+ item.Draw(l.masterBuffer, area)
250+
251+ // Store position
252+ l.itemPositions[item.ID()] = itemPosition{
253+ startLine: currentY,
254+ height: itemHeight,
255+ }
256+
257+ // Advance position
258+ currentY += itemHeight
259+ }
260+
261+ l.dirty = false
262+ l.dirtyItems = make(map[string]bool)
263+}
264+
265+// updateDirtyItems efficiently updates only changed items using slice operations.
266+func (l *List) updateDirtyItems() {
267+ if len(l.dirtyItems) == 0 {
268+ return
269+ }
270+
271+ // Check if all dirty items have unchanged heights
272+ allSameHeight := true
273+ for id := range l.dirtyItems {
274+ idx, ok := l.indexMap[id]
275+ if !ok {
276+ continue
277+ }
278+
279+ item := l.items[idx]
280+ pos, ok := l.itemPositions[id]
281+ if !ok {
282+ l.dirty = true
283+ l.dirtyItems = make(map[string]bool)
284+ l.rebuildMasterBuffer()
285+ return
286+ }
287+
288+ newHeight := item.Height(l.width)
289+ if newHeight != pos.height {
290+ allSameHeight = false
291+ break
292+ }
293+ }
294+
295+ // Optimization: If all dirty items have unchanged heights, re-render in place
296+ if allSameHeight {
297+ buf := l.masterBuffer.Buffer
298+ for id := range l.dirtyItems {
299+ idx := l.indexMap[id]
300+ item := l.items[idx]
301+ pos := l.itemPositions[id]
302+
303+ // Clear the item's area
304+ for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
305+ buf.Lines[y] = uv.NewLine(l.width)
306+ }
307+
308+ // Re-render item
309+ area := uv.Rect(0, pos.startLine, l.width, pos.height)
310+ item.Draw(l.masterBuffer, area)
311+ }
312+
313+ l.dirtyItems = make(map[string]bool)
314+ return
315+ }
316+
317+ // Height changed - full rebuild
318+ l.dirty = true
319+ l.dirtyItems = make(map[string]bool)
320+ l.rebuildMasterBuffer()
321+}
322+
323+// updatePositionsBelow updates the startLine for all items below the given index.
324+func (l *List) updatePositionsBelow(fromIdx int, delta int) {
325+ for i := fromIdx + 1; i < len(l.items); i++ {
326+ item := l.items[i]
327+ pos := l.itemPositions[item.ID()]
328+ pos.startLine += delta
329+ l.itemPositions[item.ID()] = pos
330+ }
331+}
332+
333+// calculateTotalHeight calculates the total height of all items plus gaps.
334+func (l *List) calculateTotalHeight() int {
335+ if len(l.items) == 0 {
336+ return 0
337+ }
338+
339+ total := 0
340+ for _, item := range l.items {
341+ total += item.Height(l.width)
342+ }
343+ return total
344+}
345+
346+// SetSize updates the viewport size.
347+func (l *List) SetSize(width, height int) {
348+ widthChanged := l.width != width
349+ heightChanged := l.height != height
350+
351+ l.width = width
352+ l.height = height
353+
354+ // Width changes require full rebuild (items may reflow)
355+ if widthChanged {
356+ l.dirty = true
357+ }
358+
359+ // Height changes require clamping offset to new bounds
360+ if heightChanged {
361+ l.clampOffset()
362+ }
363+}
364+
365+// GetSize returns the current viewport size.
366+func (l *List) GetSize() (int, int) {
367+ return l.width, l.height
368+}
369+
370+// Len returns the number of items in the list.
371+func (l *List) Len() int {
372+ return len(l.items)
373+}
374+
375+// SetItems replaces all items in the list.
376+func (l *List) SetItems(items []Item) {
377+ l.items = items
378+ l.indexMap = make(map[string]int, len(items))
379+ l.itemPositions = make(map[string]itemPosition, len(items))
380+
381+ for i, item := range items {
382+ l.indexMap[item.ID()] = i
383+ }
384+
385+ l.dirty = true
386+}
387+
388+// Items returns all items in the list.
389+func (l *List) Items() []Item {
390+ return l.items
391+}
392+
393+// AppendItem adds an item to the end of the list.
394+func (l *List) AppendItem(item Item) {
395+ l.items = append(l.items, item)
396+ l.indexMap[item.ID()] = len(l.items) - 1
397+
398+ // If buffer not built yet, mark dirty for full rebuild
399+ if l.masterBuffer == nil || l.width <= 0 {
400+ l.dirty = true
401+ return
402+ }
403+
404+ // Process any pending dirty items before modifying buffer structure
405+ if len(l.dirtyItems) > 0 {
406+ l.updateDirtyItems()
407+ }
408+
409+ // Efficient append: insert lines at end of buffer
410+ itemHeight := item.Height(l.width)
411+ startLine := l.totalHeight
412+
413+ // Expand buffer
414+ newLines := make([]uv.Line, itemHeight)
415+ for i := range newLines {
416+ newLines[i] = uv.NewLine(l.width)
417+ }
418+ l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...)
419+
420+ // Draw new item
421+ area := uv.Rect(0, startLine, l.width, itemHeight)
422+ item.Draw(l.masterBuffer, area)
423+
424+ // Update tracking
425+ l.itemPositions[item.ID()] = itemPosition{
426+ startLine: startLine,
427+ height: itemHeight,
428+ }
429+ l.totalHeight += itemHeight
430+}
431+
432+// PrependItem adds an item to the beginning of the list.
433+func (l *List) PrependItem(item Item) {
434+ l.items = append([]Item{item}, l.items...)
435+
436+ // Rebuild index map (all indices shifted)
437+ l.indexMap = make(map[string]int, len(l.items))
438+ for i, itm := range l.items {
439+ l.indexMap[itm.ID()] = i
440+ }
441+
442+ if l.selectedIdx >= 0 {
443+ l.selectedIdx++
444+ }
445+
446+ // If buffer not built yet, mark dirty for full rebuild
447+ if l.masterBuffer == nil || l.width <= 0 {
448+ l.dirty = true
449+ return
450+ }
451+
452+ // Process any pending dirty items before modifying buffer structure
453+ if len(l.dirtyItems) > 0 {
454+ l.updateDirtyItems()
455+ }
456+
457+ // Efficient prepend: insert lines at start of buffer
458+ itemHeight := item.Height(l.width)
459+
460+ // Create new lines
461+ newLines := make([]uv.Line, itemHeight)
462+ for i := range newLines {
463+ newLines[i] = uv.NewLine(l.width)
464+ }
465+
466+ // Insert at beginning
467+ buf := l.masterBuffer.Buffer
468+ buf.Lines = append(newLines, buf.Lines...)
469+
470+ // Draw new item
471+ area := uv.Rect(0, 0, l.width, itemHeight)
472+ item.Draw(l.masterBuffer, area)
473+
474+ // Update all positions (shift everything down)
475+ for i := 1; i < len(l.items); i++ {
476+ itm := l.items[i]
477+ if pos, ok := l.itemPositions[itm.ID()]; ok {
478+ pos.startLine += itemHeight
479+ l.itemPositions[itm.ID()] = pos
480+ }
481+ }
482+
483+ // Add position for new item
484+ l.itemPositions[item.ID()] = itemPosition{
485+ startLine: 0,
486+ height: itemHeight,
487+ }
488+ l.totalHeight += itemHeight
489+}
490+
491+// UpdateItem replaces an item with the same ID.
492+func (l *List) UpdateItem(id string, item Item) {
493+ idx, ok := l.indexMap[id]
494+ if !ok {
495+ return
496+ }
497+
498+ l.items[idx] = item
499+ l.dirtyItems[id] = true
500+}
501+
502+// DeleteItem removes an item by ID.
503+func (l *List) DeleteItem(id string) {
504+ idx, ok := l.indexMap[id]
505+ if !ok {
506+ return
507+ }
508+
509+ // Get position before deleting
510+ pos, hasPos := l.itemPositions[id]
511+
512+ // Process any pending dirty items before modifying buffer structure
513+ if len(l.dirtyItems) > 0 {
514+ l.updateDirtyItems()
515+ }
516+
517+ l.items = append(l.items[:idx], l.items[idx+1:]...)
518+ delete(l.indexMap, id)
519+ delete(l.itemPositions, id)
520+
521+ // Rebuild index map for items after deleted one
522+ for i := idx; i < len(l.items); i++ {
523+ l.indexMap[l.items[i].ID()] = i
524+ }
525+
526+ // Adjust selection
527+ if l.selectedIdx == idx {
528+ if idx > 0 {
529+ l.selectedIdx = idx - 1
530+ } else if len(l.items) > 0 {
531+ l.selectedIdx = 0
532+ } else {
533+ l.selectedIdx = -1
534+ }
535+ } else if l.selectedIdx > idx {
536+ l.selectedIdx--
537+ }
538+
539+ // If buffer not built yet, mark dirty for full rebuild
540+ if l.masterBuffer == nil || !hasPos {
541+ l.dirty = true
542+ return
543+ }
544+
545+ // Efficient delete: remove lines from buffer
546+ deleteStart := pos.startLine
547+ deleteEnd := pos.startLine + pos.height
548+ buf := l.masterBuffer.Buffer
549+
550+ if deleteEnd <= len(buf.Lines) {
551+ buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...)
552+ l.totalHeight -= pos.height
553+ l.updatePositionsBelow(idx-1, -pos.height)
554+ } else {
555+ // Position data corrupt, rebuild
556+ l.dirty = true
557+ }
558+}
559+
560+// Focus focuses the list and the selected item (if focusable).
561+func (l *List) Focus() {
562+ l.focused = true
563+ l.focusSelectedItem()
564+}
565+
566+// Blur blurs the list and the selected item (if focusable).
567+func (l *List) Blur() {
568+ l.focused = false
569+ l.blurSelectedItem()
570+}
571+
572+// IsFocused returns whether the list is focused.
573+func (l *List) IsFocused() bool {
574+ return l.focused
575+}
576+
577+// SetSelected sets the selected item by ID.
578+func (l *List) SetSelected(id string) {
579+ idx, ok := l.indexMap[id]
580+ if !ok {
581+ return
582+ }
583+
584+ if l.selectedIdx == idx {
585+ return
586+ }
587+
588+ prevIdx := l.selectedIdx
589+ l.selectedIdx = idx
590+
591+ // Update focus states if list is focused
592+ if l.focused {
593+ if prevIdx >= 0 && prevIdx < len(l.items) {
594+ if f, ok := l.items[prevIdx].(Focusable); ok {
595+ f.Blur()
596+ l.dirtyItems[l.items[prevIdx].ID()] = true
597+ }
598+ }
599+
600+ if f, ok := l.items[idx].(Focusable); ok {
601+ f.Focus()
602+ l.dirtyItems[l.items[idx].ID()] = true
603+ }
604+ }
605+}
606+
607+// SetSelectedIndex sets the selected item by index.
608+func (l *List) SetSelectedIndex(idx int) {
609+ if idx < 0 || idx >= len(l.items) {
610+ return
611+ }
612+ l.SetSelected(l.items[idx].ID())
613+}
614+
615+// SelectNext selects the next item in the list (wraps to beginning).
616+// When the list is focused, skips non-focusable items.
617+func (l *List) SelectNext() {
618+ if len(l.items) == 0 {
619+ return
620+ }
621+
622+ startIdx := l.selectedIdx
623+ for i := 0; i < len(l.items); i++ {
624+ nextIdx := (startIdx + 1 + i) % len(l.items)
625+
626+ // If list is focused and item is not focusable, skip it
627+ if l.focused {
628+ if _, ok := l.items[nextIdx].(Focusable); !ok {
629+ continue
630+ }
631+ }
632+
633+ // Select and scroll to this item
634+ l.SetSelected(l.items[nextIdx].ID())
635+ l.ScrollToSelected()
636+ return
637+ }
638+}
639+
640+// SelectPrev selects the previous item in the list (wraps to end).
641+// When the list is focused, skips non-focusable items.
642+func (l *List) SelectPrev() {
643+ if len(l.items) == 0 {
644+ return
645+ }
646+
647+ startIdx := l.selectedIdx
648+ for i := 0; i < len(l.items); i++ {
649+ prevIdx := (startIdx - 1 - i + len(l.items)) % len(l.items)
650+
651+ // If list is focused and item is not focusable, skip it
652+ if l.focused {
653+ if _, ok := l.items[prevIdx].(Focusable); !ok {
654+ continue
655+ }
656+ }
657+
658+ // Select and scroll to this item
659+ l.SetSelected(l.items[prevIdx].ID())
660+ l.ScrollToSelected()
661+ return
662+ }
663+}
664+
665+// SelectedItem returns the currently selected item, or nil if none.
666+func (l *List) SelectedItem() Item {
667+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
668+ return nil
669+ }
670+ return l.items[l.selectedIdx]
671+}
672+
673+// SelectedIndex returns the index of the currently selected item, or -1 if none.
674+func (l *List) SelectedIndex() int {
675+ return l.selectedIdx
676+}
677+
678+// AtBottom returns whether the viewport is scrolled to the bottom.
679+func (l *List) AtBottom() bool {
680+ l.ensureBuilt()
681+ return l.offset >= l.totalHeight-l.height
682+}
683+
684+// AtTop returns whether the viewport is scrolled to the top.
685+func (l *List) AtTop() bool {
686+ return l.offset <= 0
687+}
688+
689+// ScrollBy scrolls the viewport by the given number of lines.
690+// Positive values scroll down, negative scroll up.
691+func (l *List) ScrollBy(deltaLines int) {
692+ l.offset += deltaLines
693+ l.clampOffset()
694+}
695+
696+// ScrollToTop scrolls to the top of the list.
697+func (l *List) ScrollToTop() {
698+ l.offset = 0
699+}
700+
701+// ScrollToBottom scrolls to the bottom of the list.
702+func (l *List) ScrollToBottom() {
703+ l.ensureBuilt()
704+ if l.totalHeight > l.height {
705+ l.offset = l.totalHeight - l.height
706+ } else {
707+ l.offset = 0
708+ }
709+}
710+
711+// ScrollToItem scrolls to make the item with the given ID visible.
712+func (l *List) ScrollToItem(id string) {
713+ l.ensureBuilt()
714+ pos, ok := l.itemPositions[id]
715+ if !ok {
716+ return
717+ }
718+
719+ itemStart := pos.startLine
720+ itemEnd := pos.startLine + pos.height
721+ viewStart := l.offset
722+ viewEnd := l.offset + l.height
723+
724+ // Check if item is already fully visible
725+ if itemStart >= viewStart && itemEnd <= viewEnd {
726+ return
727+ }
728+
729+ // Scroll to show item
730+ if itemStart < viewStart {
731+ l.offset = itemStart
732+ } else if itemEnd > viewEnd {
733+ l.offset = itemEnd - l.height
734+ }
735+
736+ l.clampOffset()
737+}
738+
739+// ScrollToSelected scrolls to make the selected item visible.
740+func (l *List) ScrollToSelected() {
741+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
742+ return
743+ }
744+ l.ScrollToItem(l.items[l.selectedIdx].ID())
745+}
746+
747+// Offset returns the current scroll offset.
748+func (l *List) Offset() int {
749+ return l.offset
750+}
751+
752+// TotalHeight returns the total height of all items including gaps.
753+func (l *List) TotalHeight() int {
754+ return l.totalHeight
755+}
756+
757+// clampOffset ensures offset is within valid bounds.
758+func (l *List) clampOffset() {
759+ maxOffset := l.totalHeight - l.height
760+ if maxOffset < 0 {
761+ maxOffset = 0
762+ }
763+
764+ if l.offset < 0 {
765+ l.offset = 0
766+ } else if l.offset > maxOffset {
767+ l.offset = maxOffset
768+ }
769+}
770+
771+// focusSelectedItem focuses the currently selected item if it's focusable.
772+func (l *List) focusSelectedItem() {
773+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
774+ return
775+ }
776+
777+ item := l.items[l.selectedIdx]
778+ if f, ok := item.(Focusable); ok {
779+ f.Focus()
780+ l.dirtyItems[item.ID()] = true
781+ }
782+}
783+
784+// blurSelectedItem blurs the currently selected item if it's focusable.
785+func (l *List) blurSelectedItem() {
786+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
787+ return
788+ }
789+
790+ item := l.items[l.selectedIdx]
791+ if f, ok := item.(Focusable); ok {
792+ f.Blur()
793+ l.dirtyItems[item.ID()] = true
794+ }
795+}
internal/ui/list/list_test.gonew file+586
@@ -0,0 +1,586 @@
1+package list
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "charm.land/lipgloss/v2"
8+ uv "github.com/charmbracelet/ultraviolet"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestNewList(t *testing.T) {
13+ items := []Item{
14+ NewStringItem("1", "Item 1"),
15+ NewStringItem("2", "Item 2"),
16+ NewStringItem("3", "Item 3"),
17+ }
18+
19+ l := New(items...)
20+ l.SetSize(80, 24)
21+
22+ if len(l.items) != 3 {
23+ t.Errorf("expected 3 items, got %d", len(l.items))
24+ }
25+
26+ if l.width != 80 || l.height != 24 {
27+ t.Errorf("expected size 80x24, got %dx%d", l.width, l.height)
28+ }
29+}
30+
31+func TestListDraw(t *testing.T) {
32+ items := []Item{
33+ NewStringItem("1", "Item 1"),
34+ NewStringItem("2", "Item 2"),
35+ NewStringItem("3", "Item 3"),
36+ }
37+
38+ l := New(items...)
39+ l.SetSize(80, 10)
40+
41+ // Create a screen buffer to draw into
42+ screen := uv.NewScreenBuffer(80, 10)
43+ area := uv.Rect(0, 0, 80, 10)
44+
45+ // Draw the list
46+ l.Draw(&screen, area)
47+
48+ // Verify the buffer has content
49+ output := screen.Render()
50+ if len(output) == 0 {
51+ t.Error("expected non-empty output")
52+ }
53+}
54+
55+func TestListAppendItem(t *testing.T) {
56+ items := []Item{
57+ NewStringItem("1", "Item 1"),
58+ }
59+
60+ l := New(items...)
61+ l.AppendItem(NewStringItem("2", "Item 2"))
62+
63+ if len(l.items) != 2 {
64+ t.Errorf("expected 2 items after append, got %d", len(l.items))
65+ }
66+
67+ if l.items[1].ID() != "2" {
68+ t.Errorf("expected item ID '2', got '%s'", l.items[1].ID())
69+ }
70+}
71+
72+func TestListDeleteItem(t *testing.T) {
73+ items := []Item{
74+ NewStringItem("1", "Item 1"),
75+ NewStringItem("2", "Item 2"),
76+ NewStringItem("3", "Item 3"),
77+ }
78+
79+ l := New(items...)
80+ l.DeleteItem("2")
81+
82+ if len(l.items) != 2 {
83+ t.Errorf("expected 2 items after delete, got %d", len(l.items))
84+ }
85+
86+ if l.items[1].ID() != "3" {
87+ t.Errorf("expected item ID '3', got '%s'", l.items[1].ID())
88+ }
89+}
90+
91+func TestListUpdateItem(t *testing.T) {
92+ items := []Item{
93+ NewStringItem("1", "Item 1"),
94+ NewStringItem("2", "Item 2"),
95+ }
96+
97+ l := New(items...)
98+ l.SetSize(80, 10)
99+
100+ // Update item
101+ newItem := NewStringItem("2", "Updated Item 2")
102+ l.UpdateItem("2", newItem)
103+
104+ if l.items[1].(*StringItem).content != "Updated Item 2" {
105+ t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
106+ }
107+}
108+
109+func TestListSelection(t *testing.T) {
110+ items := []Item{
111+ NewStringItem("1", "Item 1"),
112+ NewStringItem("2", "Item 2"),
113+ NewStringItem("3", "Item 3"),
114+ }
115+
116+ l := New(items...)
117+ l.SetSelectedIndex(0)
118+
119+ if l.SelectedIndex() != 0 {
120+ t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
121+ }
122+
123+ l.SelectNext()
124+ if l.SelectedIndex() != 1 {
125+ t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex())
126+ }
127+
128+ l.SelectPrev()
129+ if l.SelectedIndex() != 0 {
130+ t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex())
131+ }
132+}
133+
134+func TestListScrolling(t *testing.T) {
135+ items := []Item{
136+ NewStringItem("1", "Item 1"),
137+ NewStringItem("2", "Item 2"),
138+ NewStringItem("3", "Item 3"),
139+ NewStringItem("4", "Item 4"),
140+ NewStringItem("5", "Item 5"),
141+ }
142+
143+ l := New(items...)
144+ l.SetSize(80, 2) // Small viewport
145+
146+ // Draw to initialize the master buffer
147+ screen := uv.NewScreenBuffer(80, 2)
148+ area := uv.Rect(0, 0, 80, 2)
149+ l.Draw(&screen, area)
150+
151+ if l.Offset() != 0 {
152+ t.Errorf("expected initial offset 0, got %d", l.Offset())
153+ }
154+
155+ l.ScrollBy(2)
156+ if l.Offset() != 2 {
157+ t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset())
158+ }
159+
160+ l.ScrollToTop()
161+ if l.Offset() != 0 {
162+ t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset())
163+ }
164+}
165+
166+// FocusableTestItem is a test item that implements Focusable.
167+type FocusableTestItem struct {
168+ id string
169+ content string
170+ focused bool
171+}
172+
173+func (f *FocusableTestItem) ID() string {
174+ return f.id
175+}
176+
177+func (f *FocusableTestItem) Height(width int) int {
178+ return 1
179+}
180+
181+func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) {
182+ prefix := "[ ]"
183+ if f.focused {
184+ prefix = "[X]"
185+ }
186+ content := prefix + " " + f.content
187+ styled := uv.NewStyledString(content)
188+ styled.Draw(scr, area)
189+}
190+
191+func (f *FocusableTestItem) Focus() {
192+ f.focused = true
193+}
194+
195+func (f *FocusableTestItem) Blur() {
196+ f.focused = false
197+}
198+
199+func (f *FocusableTestItem) IsFocused() bool {
200+ return f.focused
201+}
202+
203+func TestListFocus(t *testing.T) {
204+ items := []Item{
205+ &FocusableTestItem{id: "1", content: "Item 1"},
206+ &FocusableTestItem{id: "2", content: "Item 2"},
207+ }
208+
209+ l := New(items...)
210+ l.SetSize(80, 10)
211+ l.SetSelectedIndex(0)
212+
213+ // Focus the list
214+ l.Focus()
215+
216+ if !l.IsFocused() {
217+ t.Error("expected list to be focused")
218+ }
219+
220+ // Check if selected item is focused
221+ selectedItem := l.SelectedItem().(*FocusableTestItem)
222+ if !selectedItem.IsFocused() {
223+ t.Error("expected selected item to be focused")
224+ }
225+
226+ // Select next and check focus changes
227+ l.SelectNext()
228+ if selectedItem.IsFocused() {
229+ t.Error("expected previous item to be blurred")
230+ }
231+
232+ newSelectedItem := l.SelectedItem().(*FocusableTestItem)
233+ if !newSelectedItem.IsFocused() {
234+ t.Error("expected new selected item to be focused")
235+ }
236+
237+ // Blur the list
238+ l.Blur()
239+ if l.IsFocused() {
240+ t.Error("expected list to be blurred")
241+ }
242+}
243+
244+// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug:
245+// Append items until viewport is full, select last, then navigate backwards.
246+func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
247+ t.Parallel()
248+
249+ focusStyle := lipgloss.NewStyle().
250+ Border(lipgloss.RoundedBorder()).
251+ BorderForeground(lipgloss.Color("86"))
252+
253+ blurStyle := lipgloss.NewStyle().
254+ Border(lipgloss.RoundedBorder()).
255+ BorderForeground(lipgloss.Color("240"))
256+
257+ // Start with one item
258+ items := []Item{
259+ NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
260+ }
261+
262+ l := New(items...)
263+ l.SetSize(20, 15) // 15 lines viewport height
264+ l.SetSelectedIndex(0)
265+ l.Focus()
266+
267+ // Initial draw to build buffer
268+ screen := uv.NewScreenBuffer(20, 15)
269+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
270+
271+ // Append items until we exceed viewport height
272+ // Each focusable item with border is 5 lines tall
273+ for i := 2; i <= 4; i++ {
274+ item := NewStringItem(string(rune('0'+i)), "Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
275+ l.AppendItem(item)
276+ }
277+
278+ // Select the last item
279+ l.SetSelectedIndex(3)
280+
281+ // Draw
282+ screen = uv.NewScreenBuffer(20, 15)
283+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
284+ output := screen.Render()
285+
286+ t.Logf("After selecting last item:\n%s", output)
287+ require.Contains(t, output, "38;5;86", "expected focus color on last item")
288+
289+ // Now navigate backwards
290+ l.SelectPrev()
291+
292+ screen = uv.NewScreenBuffer(20, 15)
293+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
294+ output = screen.Render()
295+
296+ t.Logf("After SelectPrev:\n%s", output)
297+ require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev")
298+
299+ // Navigate backwards again
300+ l.SelectPrev()
301+
302+ screen = uv.NewScreenBuffer(20, 15)
303+ l.Draw(&screen, uv.Rect(0, 0, 20, 15))
304+ output = screen.Render()
305+
306+ t.Logf("After second SelectPrev:\n%s", output)
307+ require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev")
308+}
309+
310+func TestFocusableItemUpdate(t *testing.T) {
311+ // Create styles with borders
312+ focusStyle := lipgloss.NewStyle().
313+ Border(lipgloss.RoundedBorder()).
314+ BorderForeground(lipgloss.Color("86"))
315+
316+ blurStyle := lipgloss.NewStyle().
317+ Border(lipgloss.RoundedBorder()).
318+ BorderForeground(lipgloss.Color("240"))
319+
320+ // Create a focusable item
321+ item := NewStringItem("1", "Test Item").WithFocusStyles(&focusStyle, &blurStyle)
322+
323+ // Initially not focused - render with blur style
324+ screen1 := uv.NewScreenBuffer(20, 5)
325+ area := uv.Rect(0, 0, 20, 5)
326+ item.Draw(&screen1, area)
327+ output1 := screen1.Render()
328+
329+ // Focus the item
330+ item.Focus()
331+
332+ // Render again - should show focus style
333+ screen2 := uv.NewScreenBuffer(20, 5)
334+ item.Draw(&screen2, area)
335+ output2 := screen2.Render()
336+
337+ // Outputs should be different (different border colors)
338+ if output1 == output2 {
339+ t.Error("expected different output after focusing, but got same output")
340+ }
341+
342+ // Verify focus state
343+ if !item.IsFocused() {
344+ t.Error("expected item to be focused")
345+ }
346+
347+ // Blur the item
348+ item.Blur()
349+
350+ // Render again - should show blur style again
351+ screen3 := uv.NewScreenBuffer(20, 5)
352+ item.Draw(&screen3, area)
353+ output3 := screen3.Render()
354+
355+ // Output should match original blur output
356+ if output1 != output3 {
357+ t.Error("expected same output after blurring as initial state")
358+ }
359+
360+ // Verify blur state
361+ if item.IsFocused() {
362+ t.Error("expected item to be blurred")
363+ }
364+}
365+
366+func TestFocusableItemHeightWithBorder(t *testing.T) {
367+ // Create a style with a border (adds 2 to vertical height)
368+ borderStyle := lipgloss.NewStyle().
369+ Border(lipgloss.RoundedBorder())
370+
371+ // Item without styles has height 1
372+ plainItem := NewStringItem("1", "Test")
373+ plainHeight := plainItem.Height(20)
374+ if plainHeight != 1 {
375+ t.Errorf("expected plain height 1, got %d", plainHeight)
376+ }
377+
378+ // Item with border should add border height (2 lines)
379+ item := NewStringItem("2", "Test").WithFocusStyles(&borderStyle, &borderStyle)
380+ itemHeight := item.Height(20)
381+ expectedHeight := 1 + 2 // content + border
382+ if itemHeight != expectedHeight {
383+ t.Errorf("expected height %d (content 1 + border 2), got %d",
384+ expectedHeight, itemHeight)
385+ }
386+}
387+
388+func TestFocusableItemInList(t *testing.T) {
389+ focusStyle := lipgloss.NewStyle().
390+ Border(lipgloss.RoundedBorder()).
391+ BorderForeground(lipgloss.Color("86"))
392+
393+ blurStyle := lipgloss.NewStyle().
394+ Border(lipgloss.RoundedBorder()).
395+ BorderForeground(lipgloss.Color("240"))
396+
397+ // Create list with focusable items
398+ items := []Item{
399+ NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
400+ NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
401+ NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
402+ }
403+
404+ l := New(items...)
405+ l.SetSize(80, 20)
406+ l.SetSelectedIndex(0)
407+
408+ // Focus the list
409+ l.Focus()
410+
411+ // First item should be focused
412+ firstItem := items[0].(*StringItem)
413+ if !firstItem.IsFocused() {
414+ t.Error("expected first item to be focused after focusing list")
415+ }
416+
417+ // Render to ensure changes are visible
418+ output1 := l.Render()
419+ if !strings.Contains(output1, "Item 1") {
420+ t.Error("expected output to contain first item")
421+ }
422+
423+ // Select second item
424+ l.SetSelectedIndex(1)
425+
426+ // First item should be blurred, second focused
427+ if firstItem.IsFocused() {
428+ t.Error("expected first item to be blurred after changing selection")
429+ }
430+
431+ secondItem := items[1].(*StringItem)
432+ if !secondItem.IsFocused() {
433+ t.Error("expected second item to be focused after selection")
434+ }
435+
436+ // Render again - should show updated focus
437+ output2 := l.Render()
438+ if !strings.Contains(output2, "Item 2") {
439+ t.Error("expected output to contain second item")
440+ }
441+
442+ // Outputs should be different
443+ if output1 == output2 {
444+ t.Error("expected different output after selection change")
445+ }
446+}
447+
448+func TestFocusableItemWithNilStyles(t *testing.T) {
449+ // Test with nil styles - should render inner item directly
450+ item := NewStringItem("1", "Plain Item").WithFocusStyles(nil, nil)
451+
452+ // Height should be based on content (no border since styles are nil)
453+ itemHeight := item.Height(20)
454+ if itemHeight != 1 {
455+ t.Errorf("expected height 1 (no border), got %d", itemHeight)
456+ }
457+
458+ // Draw should work without styles
459+ screen := uv.NewScreenBuffer(20, 5)
460+ area := uv.Rect(0, 0, 20, 5)
461+ item.Draw(&screen, area)
462+ output := screen.Render()
463+
464+ // Should contain the inner content
465+ if !strings.Contains(output, "Plain Item") {
466+ t.Error("expected output to contain inner item content")
467+ }
468+
469+ // Focus/blur should still work but not change appearance
470+ item.Focus()
471+ screen2 := uv.NewScreenBuffer(20, 5)
472+ item.Draw(&screen2, area)
473+ output2 := screen2.Render()
474+
475+ // Output should be identical since no styles
476+ if output != output2 {
477+ t.Error("expected same output with nil styles whether focused or not")
478+ }
479+
480+ if !item.IsFocused() {
481+ t.Error("expected item to be focused")
482+ }
483+}
484+
485+func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
486+ // Test with only focus style (blur is nil)
487+ focusStyle := lipgloss.NewStyle().
488+ Border(lipgloss.RoundedBorder()).
489+ BorderForeground(lipgloss.Color("86"))
490+
491+ item := NewStringItem("1", "Test").WithFocusStyles(&focusStyle, nil)
492+
493+ // When not focused, should use nil blur style (no border)
494+ screen1 := uv.NewScreenBuffer(20, 5)
495+ area := uv.Rect(0, 0, 20, 5)
496+ item.Draw(&screen1, area)
497+ output1 := screen1.Render()
498+
499+ // Focus the item
500+ item.Focus()
501+ screen2 := uv.NewScreenBuffer(20, 5)
502+ item.Draw(&screen2, area)
503+ output2 := screen2.Render()
504+
505+ // Outputs should be different (focused has border, blurred doesn't)
506+ if output1 == output2 {
507+ t.Error("expected different output when only focus style is set")
508+ }
509+}
510+
511+func TestFocusableItemLastLineNotEaten(t *testing.T) {
512+ // Create focusable items with borders
513+ focusStyle := lipgloss.NewStyle().
514+ Padding(1).
515+ Border(lipgloss.RoundedBorder()).
516+ BorderForeground(lipgloss.Color("86"))
517+
518+ blurStyle := lipgloss.NewStyle().
519+ BorderForeground(lipgloss.Color("240"))
520+
521+ items := []Item{
522+ NewStringItem("1", "Item 1").WithFocusStyles(&focusStyle, &blurStyle),
523+ Gap,
524+ NewStringItem("2", "Item 2").WithFocusStyles(&focusStyle, &blurStyle),
525+ Gap,
526+ NewStringItem("3", "Item 3").WithFocusStyles(&focusStyle, &blurStyle),
527+ Gap,
528+ NewStringItem("4", "Item 4").WithFocusStyles(&focusStyle, &blurStyle),
529+ Gap,
530+ NewStringItem("5", "Item 5").WithFocusStyles(&focusStyle, &blurStyle),
531+ }
532+
533+ // Items with padding(1) and border are 5 lines each
534+ // Viewport of 10 lines fits exactly 2 items
535+ l := New()
536+ l.SetSize(20, 10)
537+
538+ for _, item := range items {
539+ l.AppendItem(item)
540+ }
541+
542+ // Focus the list
543+ l.Focus()
544+
545+ // Select last item
546+ l.SetSelectedIndex(len(items) - 1)
547+
548+ // Scroll to bottom
549+ l.ScrollToBottom()
550+
551+ output := l.Render()
552+
553+ t.Logf("Output:\n%s", output)
554+ t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
555+
556+ // Select previous - will skip gaps and go to Item 4
557+ l.SelectPrev()
558+
559+ output = l.Render()
560+
561+ t.Logf("Output:\n%s", output)
562+ t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
563+
564+ // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused)
565+ if !strings.Contains(output, "Item 3") {
566+ t.Error("expected output to contain 'Item 3'")
567+ }
568+ if !strings.Contains(output, "Item 4") {
569+ t.Error("expected output to contain 'Item 4'")
570+ }
571+ if !strings.Contains(output, "Item 5") {
572+ t.Error("expected output to contain 'Item 5'")
573+ }
574+
575+ // Count bottom borders - should have 1 (focused item 4)
576+ bottomBorderCount := 0
577+ for _, line := range strings.Split(output, "\r\n") {
578+ if strings.Contains(line, "╰") || strings.Contains(line, "└") {
579+ bottomBorderCount++
580+ }
581+ }
582+
583+ if bottomBorderCount != 1 {
584+ t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount)
585+ }
586+}
internal/ui/model/ui.go+29−16
@@ -2,6 +2,7 @@ package model
2
2
3import (
3import (
4 "context"
4 "context"
5+ "fmt"
5 "image"
6 "image"
6 "math/rand"
7 "math/rand"
7 "os"
8 "os"
@@ -21,6 +22,7 @@ import (
21 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/session"
22 "github.com/charmbracelet/crush/internal/ui/common"
23 "github.com/charmbracelet/crush/internal/ui/common"
23 "github.com/charmbracelet/crush/internal/ui/dialog"
24 "github.com/charmbracelet/crush/internal/ui/dialog"
25+ "github.com/charmbracelet/crush/internal/ui/list"
24 "github.com/charmbracelet/crush/internal/ui/logo"
26 "github.com/charmbracelet/crush/internal/ui/logo"
25 "github.com/charmbracelet/crush/internal/ui/styles"
27 "github.com/charmbracelet/crush/internal/ui/styles"
26 "github.com/charmbracelet/crush/internal/version"
28 "github.com/charmbracelet/crush/internal/version"
@@ -75,7 +77,7 @@ type UI struct {
75 keyMap KeyMap
77 keyMap KeyMap
76 keyenh tea.KeyboardEnhancementsMsg
78 keyenh tea.KeyboardEnhancementsMsg
77
79
78− chat *ChatModel
80+ chat *list.List
79 dialog *dialog.Overlay
81 dialog *dialog.Overlay
80 help help.Model
82 help help.Model
81
83
@@ -123,6 +125,8 @@ func New(com *common.Common) *UI {
123 ta.SetVirtualCursor(false)
125 ta.SetVirtualCursor(false)
124 ta.Focus()
126 ta.Focus()
125
127
128+ l := list.New()
129+
126 ui := &UI{
130 ui := &UI{
127 com: com,
131 com: com,
128 dialog: dialog.NewOverlay(),
132 dialog: dialog.NewOverlay(),
@@ -131,6 +135,7 @@ func New(com *common.Common) *UI {
131 focus: uiFocusNone,
135 focus: uiFocusNone,
132 state: uiConfigure,
136 state: uiConfigure,
133 textarea: ta,
137 textarea: ta,
138+ chat: l,
134 }
139 }
135
140
136 // set onboarding state defaults
141 // set onboarding state defaults
@@ -194,6 +199,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
194 case tea.WindowSizeMsg:
199 case tea.WindowSizeMsg:
195 m.width, m.height = msg.Width, msg.Height
200 m.width, m.height = msg.Width, msg.Height
196 m.updateLayoutAndSize()
201 m.updateLayoutAndSize()
202+ m.chat.ScrollToBottom()
197 case tea.KeyboardEnhancementsMsg:
203 case tea.KeyboardEnhancementsMsg:
198 m.keyenh = msg
204 m.keyenh = msg
199 if msg.SupportsKeyDisambiguation() {
205 if msg.SupportsKeyDisambiguation() {
@@ -236,6 +242,23 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
236 }
242 }
237
243
238 switch {
244 switch {
245+ case msg.String() == "ctrl+shift+t":
246+ m.chat.SelectPrev()
247+ case msg.String() == "ctrl+t":
248+ m.focus = uiFocusMain
249+ m.state = uiChat
250+ if m.chat.Len() > 0 {
251+ m.chat.AppendItem(list.Gap)
252+ }
253+ m.chat.AppendItem(
254+ list.NewStringItem(
255+ fmt.Sprintf("%d", m.chat.Len()),
256+ fmt.Sprintf("Welcome to Crush Chat! %d", rand.Intn(1000)),
257+ ).WithFocusStyles(&m.com.Styles.BorderFocus, &m.com.Styles.BorderBlur),
258+ )
259+ m.chat.SetSelectedIndex(m.chat.Len() - 1)
260+ m.chat.Focus()
261+ m.chat.ScrollToBottom()
239 case key.Matches(msg, m.keyMap.Tab):
262 case key.Matches(msg, m.keyMap.Tab):
240 switch m.state {
263 switch m.state {
241 case uiChat:
264 case uiChat:
@@ -317,11 +340,8 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
317 header := uv.NewStyledString(m.header)
340 header := uv.NewStyledString(m.header)
318 header.Draw(scr, layout.header)
341 header.Draw(scr, layout.header)
319 m.drawSidebar(scr, layout.sidebar)
342 m.drawSidebar(scr, layout.sidebar)
320− mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
343+
321− Height(layout.main.Dy()).
322− Render(" Chat Messages ")
323− main := uv.NewStyledString(mainView)
324− main.Draw(scr, layout.main)
344+ m.chat.Draw(scr, layout.main)
325
345
326 editor := uv.NewStyledString(m.textarea.View())
346 editor := uv.NewStyledString(m.textarea.View())
327 editor.Draw(scr, layout.editor)
347 editor.Draw(scr, layout.editor)
@@ -517,7 +537,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
517 case uiChat, uiLanding, uiChatCompact:
537 case uiChat, uiLanding, uiChatCompact:
518 switch m.focus {
538 switch m.focus {
519 case uiFocusMain:
539 case uiFocusMain:
520− cmds = append(cmds, m.updateChat(msg)...)
521 case uiFocusEditor:
540 case uiFocusEditor:
522 switch {
541 switch {
523 case key.Matches(msg, m.keyMap.Editor.Newline):
542 case key.Matches(msg, m.keyMap.Editor.Newline):
@@ -533,15 +552,6 @@ func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
533 return cmds
552 return cmds
534}
553}
535
554
536−// updateChat updates the chat model with the given message and appends any
537−// resulting commands to the cmds slice.
538−func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
539− updatedChat, cmd := m.chat.Update(msg)
540− m.chat = updatedChat
541− cmds = append(cmds, cmd)
542− return cmds
543−}
544−
545// updateLayoutAndSize updates the layout and sizes of UI components.
555// updateLayoutAndSize updates the layout and sizes of UI components.
546func (m *UI) updateLayoutAndSize() {
556func (m *UI) updateLayoutAndSize() {
547 m.layout = generateLayout(m, m.width, m.height)
557 m.layout = generateLayout(m, m.width, m.height)
@@ -567,6 +577,7 @@ func (m *UI) updateSize() {
567 m.renderSidebarLogo(m.layout.sidebar.Dx())
577 m.renderSidebarLogo(m.layout.sidebar.Dx())
568 m.textarea.SetWidth(m.layout.editor.Dx())
578 m.textarea.SetWidth(m.layout.editor.Dx())
569 m.textarea.SetHeight(m.layout.editor.Dy())
579 m.textarea.SetHeight(m.layout.editor.Dy())
580+ m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
570
581
571 case uiChatCompact:
582 case uiChatCompact:
572 // TODO: set the width and heigh of the chat component
583 // TODO: set the width and heigh of the chat component
@@ -667,6 +678,8 @@ func generateLayout(m *UI, w, h int) layout {
667 // Add padding left
678 // Add padding left
668 sideRect.Min.X += 1
679 sideRect.Min.X += 1
669 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
680 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
681+ // Add bottom margin to main
682+ mainRect.Max.Y -= 1
670 layout.sidebar = sideRect
683 layout.sidebar = sideRect
671 layout.main = mainRect
684 layout.main = mainRect
672 layout.editor = editorRect
685 layout.editor = editorRect