on main help sign in
refactor(ui): cleanup and remove unused list code
commit6dc2dd8fdcfca657ceb572348c410dc32b1c7e91 authorAyman Bagabas <ayman.bagabas@gmail.com> dateWed 10 Dec 2025, 13:00 (1 year ago) parentafb5467
9 files changed+4−5608
internal/ui/lazylist/highlight.go−129 internal/ui/list/example_test.go−275 internal/ui/list/item.go−578 internal/ui/list/item_test.go−593 internal/ui/list/lazylist.go−1007 internal/ui/list/list.go−1126 internal/ui/list/list_test.go−578 internal/ui/list/simplelist.go−972 internal/ui/model/items.go+4−350
internal/ui/lazylist/highlight.go−129
@@ -86,135 +86,6 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
8686 return buf.Render()
8787}
8888
89−// RenderWithHighlight renders content with optional focus styling and highlighting.
90−// This is a helper that combines common rendering logic for all items.
91−// The content parameter should be the raw rendered content before focus styling.
92−// The style parameter should come from CurrentStyle() and may be nil.
93−// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
94−// // Apply focus/blur styling if configured
95−// rendered := content
96−// if style != nil {
97−// rendered = style.Render(rendered)
98−// }
99−//
100−// if !b.HasHighlight() {
101−// return rendered
102−// }
103−//
104−// height := lipgloss.Height(rendered)
105−//
106−// // Create temp buffer to draw content with highlighting
107−// tempBuf := uv.NewScreenBuffer(width, height)
108−//
109−// // Draw the rendered content to temp buffer
110−// styled := uv.NewStyledString(rendered)
111−// styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
112−//
113−// // Apply highlighting if active
114−// b.ApplyHighlight(&tempBuf, width, height, style)
115−//
116−// return tempBuf.Render()
117−// }
118−
119−// ApplyHighlight applies highlighting to a screen buffer.
120−// This should be called after drawing content to the buffer.
121−// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
122−// if b.highlightStartLine < 0 {
123−// return
124−// }
125−//
126−// var (
127−// topMargin, topBorder, topPadding int
128−// rightMargin, rightBorder, rightPadding int
129−// bottomMargin, bottomBorder, bottomPadding int
130−// leftMargin, leftBorder, leftPadding int
131−// )
132−// if style != nil {
133−// topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
134−// topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
135−// style.GetBorderRightSize(),
136−// style.GetBorderBottomSize(),
137−// style.GetBorderLeftSize()
138−// topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
139−// }
140−//
141−// slog.Info("Applying highlight",
142−// "highlightStartLine", b.highlightStartLine,
143−// "highlightStartCol", b.highlightStartCol,
144−// "highlightEndLine", b.highlightEndLine,
145−// "highlightEndCol", b.highlightEndCol,
146−// "width", width,
147−// "height", height,
148−// "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
149−// "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
150−// "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
151−// )
152−//
153−// // Calculate content area offsets
154−// contentArea := image.Rectangle{
155−// Min: image.Point{
156−// X: leftMargin + leftBorder + leftPadding,
157−// Y: topMargin + topBorder + topPadding,
158−// },
159−// Max: image.Point{
160−// X: width - (rightMargin + rightBorder + rightPadding),
161−// Y: height - (bottomMargin + bottomBorder + bottomPadding),
162−// },
163−// }
164−//
165−// for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
166−// if y >= buf.Height() {
167−// break
168−// }
169−//
170−// line := buf.Line(y)
171−//
172−// // Determine column range for this line
173−// startCol := 0
174−// if y == b.highlightStartLine {
175−// startCol = min(b.highlightStartCol, len(line))
176−// }
177−//
178−// endCol := len(line)
179−// if y == b.highlightEndLine {
180−// endCol = min(b.highlightEndCol, len(line))
181−// }
182−//
183−// // Track last non-empty position as we go
184−// lastContentX := -1
185−//
186−// // Single pass: check content and track last non-empty position
187−// for x := startCol; x < endCol; x++ {
188−// cell := line.At(x)
189−// if cell == nil {
190−// continue
191−// }
192−//
193−// // Update last content position if non-empty
194−// if cell.Content != "" && cell.Content != " " {
195−// lastContentX = x
196−// }
197−// }
198−//
199−// // Only apply highlight up to last content position
200−// highlightEnd := endCol
201−// if lastContentX >= 0 {
202−// highlightEnd = lastContentX + 1
203−// } else if lastContentX == -1 {
204−// highlightEnd = startCol // No content on this line
205−// }
206−//
207−// // Apply highlight style only to cells with content
208−// for x := startCol; x < highlightEnd; x++ {
209−// if !image.Pt(x, y).In(contentArea) {
210−// continue
211−// }
212−// cell := line.At(x)
213−// cell.Style = b.highlightStyle(cell.Style)
214−// }
215−// }
216−// }
217−
21889// ToHighlighter converts a [lipgloss.Style] to a [Highlighter].
21990func ToHighlighter(lgStyle lipgloss.Style) Highlighter {
22091 return func(uv.Style) uv.Style {
internal/ui/list/example_test.godeleted−275
@@ -1,275 +0,0 @@
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("First item"),
16− list.NewStringItem("Second item"),
17− list.NewStringItem("Third item"),
18− }
19−
20− // Create a list with options
21− l := list.New(items...)
22− l.SetSize(80, 10)
23− l.SetSelected(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.SetSelected(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("Item 1"),
131− list.NewStringItem("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("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("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("Item %d", i),
165− )
166− }
167−
168− // Create list with small viewport
169− l := list.New(items...)
170− l.SetSize(80, 10)
171− l.SetSelected(0)
172−
173− // Draw initial view (shows items 0-9)
174− screen := uv.NewScreenBuffer(80, 10)
175− area := uv.Rect(0, 0, 80, 10)
176− l.Draw(&screen, area)
177−
178− // Scroll down
179− l.ScrollBy(5)
180− l.Draw(&screen, area) // Now shows items 5-14
181−
182− // Jump to specific item
183− l.ScrollToItem(50)
184− l.Draw(&screen, area) // Now shows item 50 and neighbors
185−
186− // Scroll to bottom
187− l.ScrollToBottom()
188− l.Draw(&screen, area) // Now shows last 10 items
189−
190− output := screen.Render()
191− fmt.Println(output)
192−}
193−
194−// VariableHeightItem demonstrates items with different heights.
195−type VariableHeightItem struct {
196− id string
197− lines []string
198− width int
199−}
200−
201−func NewVariableHeightItem(id string, lines []string) *VariableHeightItem {
202− return &VariableHeightItem{
203− id: id,
204− lines: lines,
205− width: 80,
206− }
207−}
208−
209−func (v *VariableHeightItem) ID() string {
210− return v.id
211−}
212−
213−func (v *VariableHeightItem) Height(width int) int {
214− return len(v.lines)
215−}
216−
217−func (v *VariableHeightItem) Draw(scr uv.Screen, area uv.Rectangle) {
218− content := ""
219− for i, line := range v.lines {
220− if i > 0 {
221− content += "\n"
222− }
223− content += line
224− }
225− styled := uv.NewStyledString(content)
226− styled.Draw(scr, area)
227−}
228−
229−// Example demonstrates variable height items.
230−func Example_variableHeights() {
231− items := []list.Item{
232− NewVariableHeightItem("1", []string{"Short item"}),
233− NewVariableHeightItem("2", []string{
234− "This is a taller item",
235− "that spans multiple lines",
236− "to demonstrate variable heights",
237− }),
238− NewVariableHeightItem("3", []string{"Another short item"}),
239− NewVariableHeightItem("4", []string{
240− "A medium height item",
241− "with two lines",
242− }),
243− }
244−
245− l := list.New(items...)
246− l.SetSize(80, 15)
247−
248− screen := uv.NewScreenBuffer(80, 15)
249− area := uv.Rect(0, 0, 80, 15)
250− l.Draw(&screen, area)
251−
252− output := screen.Render()
253− fmt.Println(output)
254−}
255−
256−// Example demonstrates markdown items in a list.
257−func Example_markdown() {
258− // Create markdown items
259− items := []list.Item{
260− list.NewMarkdownItem("# Welcome\n\nThis is a **markdown** item."),
261− list.NewMarkdownItem("## Features\n\n- Supports **bold**\n- Supports *italic*\n- Supports `code`"),
262− list.NewMarkdownItem("### Code Block\n\n```go\nfunc main() {\n fmt.Println(\"Hello\")\n}\n```"),
263− }
264−
265− // Create list
266− l := list.New(items...)
267− l.SetSize(80, 20)
268−
269− screen := uv.NewScreenBuffer(80, 20)
270− area := uv.Rect(0, 0, 80, 20)
271− l.Draw(&screen, area)
272−
273− output := screen.Render()
274− fmt.Println(output)
275−}
internal/ui/list/item.godeleted−578
@@ -1,578 +0,0 @@
1−package list
2−
3−import (
4− "image"
5− "strings"
6−
7− "charm.land/lipgloss/v2"
8− "github.com/charmbracelet/glamour/v2"
9− "github.com/charmbracelet/glamour/v2/ansi"
10− uv "github.com/charmbracelet/ultraviolet"
11− "github.com/charmbracelet/ultraviolet/screen"
12−)
13−
14−// toUVStyle converts a lipgloss.Style to a uv.Style, stripping multiline attributes.
15−func toUVStyle(lgStyle lipgloss.Style) uv.Style {
16− var uvStyle uv.Style
17−
18− // Colors are already color.Color
19− uvStyle.Fg = lgStyle.GetForeground()
20− uvStyle.Bg = lgStyle.GetBackground()
21−
22− // Build attributes using bitwise OR
23− var attrs uint8
24−
25− if lgStyle.GetBold() {
26− attrs |= uv.AttrBold
27− }
28−
29− if lgStyle.GetItalic() {
30− attrs |= uv.AttrItalic
31− }
32−
33− if lgStyle.GetUnderline() {
34− uvStyle.Underline = uv.UnderlineSingle
35− }
36−
37− if lgStyle.GetStrikethrough() {
38− attrs |= uv.AttrStrikethrough
39− }
40−
41− if lgStyle.GetFaint() {
42− attrs |= uv.AttrFaint
43− }
44−
45− if lgStyle.GetBlink() {
46− attrs |= uv.AttrBlink
47− }
48−
49− if lgStyle.GetReverse() {
50− attrs |= uv.AttrReverse
51− }
52−
53− uvStyle.Attrs = attrs
54−
55− return uvStyle
56−}
57−
58−// Item represents a list item that can draw itself to a UV buffer.
59−// Items implement the uv.Drawable interface.
60−type Item interface {
61− uv.Drawable
62−
63− // Height returns the item's height in lines for the given width.
64− // This allows items to calculate height based on text wrapping and available space.
65− Height(width int) int
66−}
67−
68−// Focusable is an optional interface for items that support focus.
69−// When implemented, items can change appearance when focused (borders, colors, etc).
70−type Focusable interface {
71− Focus()
72− Blur()
73− IsFocused() bool
74−}
75−
76−// Highlightable is an optional interface for items that support highlighting.
77−// When implemented, items can highlight specific regions (e.g. for search matches).
78−type Highlightable interface {
79− // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol).
80− // Use -1 for all values to clear highlighting.
81− SetHighlight(startLine, startCol, endLine, endCol int)
82−
83− // GetHighlight returns the current highlight region.
84− GetHighlight() (startLine, startCol, endLine, endCol int)
85−}
86−
87−// BaseFocusable provides common focus state and styling for items.
88−// Embed this type to add focus behavior to any item.
89−type BaseFocusable struct {
90− focused bool
91− focusStyle *lipgloss.Style
92− blurStyle *lipgloss.Style
93−}
94−
95−// Focus implements Focusable interface.
96−func (b *BaseFocusable) Focus() {
97− b.focused = true
98−}
99−
100−// Blur implements Focusable interface.
101−func (b *BaseFocusable) Blur() {
102− b.focused = false
103−}
104−
105−// IsFocused implements Focusable interface.
106−func (b *BaseFocusable) IsFocused() bool {
107− return b.focused
108−}
109−
110−// HasFocusStyles returns true if both focus and blur styles are configured.
111−func (b *BaseFocusable) HasFocusStyles() bool {
112− return b.focusStyle != nil && b.blurStyle != nil
113−}
114−
115−// CurrentStyle returns the current style based on focus state.
116−// Returns nil if no styles are configured, or if the current state's style is nil.
117−func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
118− if b.focused {
119− return b.focusStyle
120− }
121− return b.blurStyle
122−}
123−
124−// SetFocusStyles sets the focus and blur styles.
125−func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
126− b.focusStyle = focusStyle
127− b.blurStyle = blurStyle
128−}
129−
130−// BaseHighlightable provides common highlight state for items.
131−// Embed this type to add highlight behavior to any item.
132−type BaseHighlightable struct {
133− highlightStartLine int
134− highlightStartCol int
135− highlightEndLine int
136− highlightEndCol int
137− highlightStyle CellStyler
138−}
139−
140−// SetHighlight implements Highlightable interface.
141−func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
142− b.highlightStartLine = startLine
143− b.highlightStartCol = startCol
144− b.highlightEndLine = endLine
145− b.highlightEndCol = endCol
146−}
147−
148−// GetHighlight implements Highlightable interface.
149−func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
150− return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
151−}
152−
153−// HasHighlight returns true if a highlight region is set.
154−func (b *BaseHighlightable) HasHighlight() bool {
155− return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
156− b.highlightEndLine >= 0 || b.highlightEndCol >= 0
157−}
158−
159−// SetHighlightStyle sets the style function used for highlighting.
160−func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
161− b.highlightStyle = style
162−}
163−
164−// GetHighlightStyle returns the current highlight style function.
165−func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
166− return b.highlightStyle
167−}
168−
169−// InitHighlight initializes the highlight fields with default values.
170−func (b *BaseHighlightable) InitHighlight() {
171− b.highlightStartLine = -1
172− b.highlightStartCol = -1
173− b.highlightEndLine = -1
174− b.highlightEndCol = -1
175− b.highlightStyle = LipglossStyleToCellStyler(lipgloss.NewStyle().Reverse(true))
176−}
177−
178−// ApplyHighlight applies highlighting to a screen buffer.
179−// This should be called after drawing content to the buffer.
180−func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
181− if b.highlightStartLine < 0 {
182− return
183− }
184−
185− var (
186− topMargin, topBorder, topPadding int
187− rightMargin, rightBorder, rightPadding int
188− bottomMargin, bottomBorder, bottomPadding int
189− leftMargin, leftBorder, leftPadding int
190− )
191− if style != nil {
192− topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
193− topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
194− style.GetBorderRightSize(),
195− style.GetBorderBottomSize(),
196− style.GetBorderLeftSize()
197− topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
198− }
199−
200− // Calculate content area offsets
201− contentArea := image.Rectangle{
202− Min: image.Point{
203− X: leftMargin + leftBorder + leftPadding,
204− Y: topMargin + topBorder + topPadding,
205− },
206− Max: image.Point{
207− X: width - (rightMargin + rightBorder + rightPadding),
208− Y: height - (bottomMargin + bottomBorder + bottomPadding),
209− },
210− }
211−
212− for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
213− if y >= buf.Height() {
214− break
215− }
216−
217− line := buf.Line(y)
218−
219− // Determine column range for this line
220− startCol := 0
221− if y == b.highlightStartLine {
222− startCol = min(b.highlightStartCol, len(line))
223− }
224−
225− endCol := len(line)
226− if y == b.highlightEndLine {
227− endCol = min(b.highlightEndCol, len(line))
228− }
229−
230− // Track last non-empty position as we go
231− lastContentX := -1
232−
233− // Single pass: check content and track last non-empty position
234− for x := startCol; x < endCol; x++ {
235− cell := line.At(x)
236− if cell == nil {
237− continue
238− }
239−
240− // Update last content position if non-empty
241− if cell.Content != "" && cell.Content != " " {
242− lastContentX = x
243− }
244− }
245−
246− // Only apply highlight up to last content position
247− highlightEnd := endCol
248− if lastContentX >= 0 {
249− highlightEnd = lastContentX + 1
250− } else if lastContentX == -1 {
251− highlightEnd = startCol // No content on this line
252− }
253−
254− // Apply highlight style only to cells with content
255− for x := startCol; x < highlightEnd; x++ {
256− if !image.Pt(x, y).In(contentArea) {
257− continue
258− }
259− cell := line.At(x)
260− cell.Style = b.highlightStyle(cell.Style)
261− }
262− }
263−}
264−
265−// StringItem is a simple string-based item with optional text wrapping.
266−// It caches rendered content by width for efficient repeated rendering.
267−// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
268−// StringItem implements Highlightable for text selection/search highlighting.
269−type StringItem struct {
270− BaseFocusable
271− BaseHighlightable
272− content string // Raw content string (may contain ANSI styles)
273− wrap bool // Whether to wrap text
274−
275− // Cache for rendered content at specific widths
276− // Key: width, Value: string
277− cache map[int]string
278−}
279−
280−// CellStyler is a function that applies styles to UV cells.
281−type CellStyler = func(s uv.Style) uv.Style
282−
283−var noColor = lipgloss.NoColor{}
284−
285−// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function.
286−func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler {
287− uvStyle := toUVStyle(lgStyle)
288− return func(s uv.Style) uv.Style {
289− if uvStyle.Fg != nil && lgStyle.GetForeground() != noColor {
290− s.Fg = uvStyle.Fg
291− }
292− if uvStyle.Bg != nil && lgStyle.GetBackground() != noColor {
293− s.Bg = uvStyle.Bg
294− }
295− s.Attrs |= uvStyle.Attrs
296− if uvStyle.Underline != 0 {
297− s.Underline = uvStyle.Underline
298− }
299− return s
300− }
301−}
302−
303−// NewStringItem creates a new string item with the given ID and content.
304−func NewStringItem(content string) *StringItem {
305− s := &StringItem{
306− content: content,
307− wrap: false,
308− cache: make(map[int]string),
309− }
310− s.InitHighlight()
311− return s
312−}
313−
314−// NewWrappingStringItem creates a new string item that wraps text to fit width.
315−func NewWrappingStringItem(content string) *StringItem {
316− s := &StringItem{
317− content: content,
318− wrap: true,
319− cache: make(map[int]string),
320− }
321− s.InitHighlight()
322− return s
323−}
324−
325−// WithFocusStyles sets the focus and blur styles for the string item.
326−// If both styles are non-nil, the item will implement Focusable.
327−func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
328− s.SetFocusStyles(focusStyle, blurStyle)
329− return s
330−}
331−
332−// Height implements Item.
333−func (s *StringItem) Height(width int) int {
334− // Calculate content width if we have styles
335− contentWidth := width
336− if style := s.CurrentStyle(); style != nil {
337− hFrameSize := style.GetHorizontalFrameSize()
338− if hFrameSize > 0 {
339− contentWidth -= hFrameSize
340− }
341− }
342−
343− var lines int
344− if !s.wrap {
345− // No wrapping - height is just the number of newlines + 1
346− lines = strings.Count(s.content, "\n") + 1
347− } else {
348− // Use lipgloss.Wrap to wrap the content and count lines
349− // This preserves ANSI styles and is much faster than rendering to a buffer
350− wrapped := lipgloss.Wrap(s.content, contentWidth, "")
351− lines = strings.Count(wrapped, "\n") + 1
352− }
353−
354− // Add vertical frame size if we have styles
355− if style := s.CurrentStyle(); style != nil {
356− lines += style.GetVerticalFrameSize()
357− }
358−
359− return lines
360−}
361−
362−// Draw implements Item and uv.Drawable.
363−func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
364− width := area.Dx()
365− height := area.Dy()
366−
367− // Check cache first
368− content, ok := s.cache[width]
369− if !ok {
370− // Not cached - create and cache
371− content = s.content
372− if s.wrap {
373− // Wrap content using lipgloss
374− content = lipgloss.Wrap(s.content, width, "")
375− }
376− s.cache[width] = content
377− }
378−
379− // Apply focus/blur styling if configured
380− style := s.CurrentStyle()
381− if style != nil {
382− content = style.Width(width).Render(content)
383− }
384−
385− // Create temp buffer to draw content with highlighting
386− tempBuf := uv.NewScreenBuffer(width, height)
387−
388− // Draw content to temp buffer first
389− styled := uv.NewStyledString(content)
390− styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
391−
392− // Apply highlighting if active
393− s.ApplyHighlight(&tempBuf, width, height, style)
394−
395− // Copy temp buffer to actual screen at the target area
396− tempBuf.Draw(scr, area)
397−}
398−
399−// SetHighlight implements Highlightable and extends BaseHighlightable.
400−// Clears the cache when highlight changes.
401−func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) {
402− s.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
403− // Clear cache when highlight changes
404− s.cache = make(map[int]string)
405−}
406−
407−// MarkdownItem renders markdown content using Glamour.
408−// It caches all rendered content by width for efficient repeated rendering.
409−// The wrap width is capped at 120 cells by default to ensure readable line lengths.
410−// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
411−// MarkdownItem implements Highlightable for text selection/search highlighting.
412−type MarkdownItem struct {
413− BaseFocusable
414− BaseHighlightable
415− markdown string // Raw markdown content
416− styleConfig *ansi.StyleConfig // Optional style configuration
417− maxWidth int // Maximum wrap width (default 120)
418−
419− // Cache for rendered content at specific widths
420− // Key: width (capped to maxWidth), Value: rendered markdown string
421− cache map[int]string
422−}
423−
424−// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering.
425−const DefaultMarkdownMaxWidth = 120
426−
427−// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
428−// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
429−func NewMarkdownItem(markdown string) *MarkdownItem {
430− m := &MarkdownItem{
431− markdown: markdown,
432− maxWidth: DefaultMarkdownMaxWidth,
433− cache: make(map[int]string),
434− }
435− m.InitHighlight()
436− return m
437−}
438−
439−// WithStyleConfig sets a custom Glamour style configuration for the markdown item.
440−func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem {
441− m.styleConfig = &styleConfig
442− return m
443−}
444−
445−// WithMaxWidth sets the maximum wrap width for markdown rendering.
446−func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem {
447− m.maxWidth = maxWidth
448− return m
449−}
450−
451−// WithFocusStyles sets the focus and blur styles for the markdown item.
452−// If both styles are non-nil, the item will implement Focusable.
453−func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
454− m.SetFocusStyles(focusStyle, blurStyle)
455− return m
456−}
457−
458−// Height implements Item.
459−func (m *MarkdownItem) Height(width int) int {
460− // Render the markdown to get its height
461− rendered := m.renderMarkdown(width)
462−
463− // Apply focus/blur styling if configured to get accurate height
464− if style := m.CurrentStyle(); style != nil {
465− rendered = style.Render(rendered)
466− }
467−
468− return strings.Count(rendered, "\n") + 1
469−}
470−
471−// Draw implements Item and uv.Drawable.
472−func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
473− width := area.Dx()
474− height := area.Dy()
475− rendered := m.renderMarkdown(width)
476−
477− // Apply focus/blur styling if configured
478− style := m.CurrentStyle()
479− if style != nil {
480− rendered = style.Render(rendered)
481− }
482−
483− // Create temp buffer to draw content with highlighting
484− tempBuf := uv.NewScreenBuffer(width, height)
485−
486− // Draw the rendered markdown to temp buffer
487− styled := uv.NewStyledString(rendered)
488− styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
489−
490− // Apply highlighting if active
491− m.ApplyHighlight(&tempBuf, width, height, style)
492−
493− // Copy temp buffer to actual screen at the target area
494− tempBuf.Draw(scr, area)
495−}
496−
497−// renderMarkdown renders the markdown content at the given width, using cache if available.
498−// Width is always capped to maxWidth to ensure readable line lengths.
499−func (m *MarkdownItem) renderMarkdown(width int) string {
500− // Cap width to maxWidth
501− cappedWidth := min(width, m.maxWidth)
502−
503− // Check cache first (always cache all rendered markdown)
504− if cached, ok := m.cache[cappedWidth]; ok {
505− return cached
506− }
507−
508− // Not cached - render now
509− opts := []glamour.TermRendererOption{
510− glamour.WithWordWrap(cappedWidth),
511− }
512−
513− // Add style config if provided
514− if m.styleConfig != nil {
515− opts = append(opts, glamour.WithStyles(*m.styleConfig))
516− }
517−
518− renderer, err := glamour.NewTermRenderer(opts...)
519− if err != nil {
520− // Fallback to plain text on error
521− return m.markdown
522− }
523−
524− rendered, err := renderer.Render(m.markdown)
525− if err != nil {
526− // Fallback to plain text on error
527− return m.markdown
528− }
529−
530− // Trim trailing whitespace
531− rendered = strings.TrimRight(rendered, "\n\r ")
532−
533− // Always cache
534− m.cache[cappedWidth] = rendered
535−
536− return rendered
537−}
538−
539−// SetHighlight implements Highlightable and extends BaseHighlightable.
540−// Clears the cache when highlight changes.
541−func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) {
542− m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
543− // Clear cache when highlight changes
544− m.cache = make(map[int]string)
545−}
546−
547−// Gap is a 1-line spacer item used to add gaps between items.
548−var Gap = NewSpacerItem(1)
549−
550−// SpacerItem is an empty item that takes up vertical space.
551−// Useful for adding gaps between items in a list.
552−type SpacerItem struct {
553− height int
554−}
555−
556−var _ Item = (*SpacerItem)(nil)
557−
558−// NewSpacerItem creates a new spacer item with the given ID and height in lines.
559−func NewSpacerItem(height int) *SpacerItem {
560− return &SpacerItem{
561− height: height,
562− }
563−}
564−
565−// Height implements Item.
566−func (s *SpacerItem) Height(width int) int {
567− return s.height
568−}
569−
570−// Draw implements Item.
571−// Spacer items don't draw anything, they just take up space.
572−func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) {
573− // Ensure the area is filled with spaces to clear any existing content
574− spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height))
575− if spacerArea.Overlaps(area) {
576− screen.ClearArea(scr, spacerArea)
577− }
578−}
internal/ui/list/item_test.godeleted−593
@@ -1,593 +0,0 @@
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("Item 1"),
14− NewStringItem("Item 2"),
15− NewStringItem("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("Item 1"),
43− NewStringItem("Item 2"),
44− NewStringItem("Item 3"),
45− NewStringItem("Item 4"),
46− NewStringItem("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("Item 1"),
93− NewStringItem("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("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("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("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("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(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(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(markdown)
219−
220− // Test that height is calculated
221− height := item.Height(80)
222− if height < 1 {
223− t.Errorf("expected height >= 1, got %d", height)
224− }
225−
226− // Test drawing
227− screen := uv.NewScreenBuffer(80, 10)
228− area := uv.Rect(0, 0, 80, 10)
229− item.Draw(&screen, area)
230−
231− // Should not panic and should render something
232− rendered := screen.Render()
233− if len(rendered) == 0 {
234− t.Error("expected non-empty rendered output")
235− }
236−}
237−
238−func TestMarkdownItemCache(t *testing.T) {
239− markdown := "# Test\n\nSome content."
240− item := NewMarkdownItem(markdown)
241−
242− // First render at width 80 should populate cache
243− height1 := item.Height(80)
244− if len(item.cache) != 1 {
245− t.Errorf("expected cache to have 1 entry after first render, got %d", len(item.cache))
246− }
247−
248− // Second render at same width should reuse cache
249− height2 := item.Height(80)
250− if height1 != height2 {
251− t.Errorf("expected consistent height, got %d then %d", height1, height2)
252− }
253− if len(item.cache) != 1 {
254− t.Errorf("expected cache to still have 1 entry, got %d", len(item.cache))
255− }
256−
257− // Render at different width should add to cache
258− _ = item.Height(40)
259− if len(item.cache) != 2 {
260− t.Errorf("expected cache to have 2 entries after different width, got %d", len(item.cache))
261− }
262−}
263−
264−func TestMarkdownItemMaxCacheWidth(t *testing.T) {
265− markdown := "# Test\n\nSome content."
266− item := NewMarkdownItem(markdown).WithMaxWidth(50)
267−
268− // Render at width 40 (below limit) - should cache at width 40
269− _ = item.Height(40)
270− if len(item.cache) != 1 {
271− t.Errorf("expected cache to have 1 entry for width 40, got %d", len(item.cache))
272− }
273−
274− // Render at width 80 (above limit) - should cap to 50 and cache
275− _ = item.Height(80)
276− // Cache should have width 50 entry (capped from 80)
277− if len(item.cache) != 2 {
278− t.Errorf("expected cache to have 2 entries (40 and 50), got %d", len(item.cache))
279− }
280− if _, ok := item.cache[50]; !ok {
281− t.Error("expected cache to have entry for width 50 (capped from 80)")
282− }
283−
284− // Render at width 100 (also above limit) - should reuse cached width 50
285− _ = item.Height(100)
286− if len(item.cache) != 2 {
287− t.Errorf("expected cache to still have 2 entries (reusing 50), got %d", len(item.cache))
288− }
289−}
290−
291−func TestMarkdownItemWithStyleConfig(t *testing.T) {
292− markdown := "# Styled\n\nContent with **bold** text."
293−
294− // Create a custom style config
295− styleConfig := ansi.StyleConfig{
296− Document: ansi.StyleBlock{
297− Margin: uintPtr(0),
298− },
299− }
300−
301− item := NewMarkdownItem(markdown).WithStyleConfig(styleConfig)
302−
303− // Render should use the custom style
304− height := item.Height(80)
305− if height < 1 {
306− t.Errorf("expected height >= 1, got %d", height)
307− }
308−
309− // Draw should work without panic
310− screen := uv.NewScreenBuffer(80, 10)
311− area := uv.Rect(0, 0, 80, 10)
312− item.Draw(&screen, area)
313−
314− rendered := screen.Render()
315− if len(rendered) == 0 {
316− t.Error("expected non-empty rendered output with custom style")
317− }
318−}
319−
320−func TestMarkdownItemInList(t *testing.T) {
321− items := []Item{
322− NewMarkdownItem("# First\n\nMarkdown item."),
323− NewMarkdownItem("# Second\n\nAnother item."),
324− NewStringItem("Regular string item"),
325− }
326−
327− l := New(items...)
328− l.SetSize(80, 20)
329−
330− // Should render without error
331− output := l.Render()
332− if len(output) == 0 {
333− t.Error("expected non-empty output from list with markdown items")
334− }
335−
336− // Should contain content from markdown items
337− if !strings.Contains(output, "First") {
338− t.Error("expected output to contain 'First'")
339− }
340− if !strings.Contains(output, "Second") {
341− t.Error("expected output to contain 'Second'")
342− }
343− if !strings.Contains(output, "Regular string item") {
344− t.Error("expected output to contain 'Regular string item'")
345− }
346−}
347−
348−func TestMarkdownItemHeightWithWidth(t *testing.T) {
349− // Test that widths are capped to maxWidth
350− markdown := "This is a paragraph with some text."
351−
352− item := NewMarkdownItem(markdown).WithMaxWidth(50)
353−
354− // At width 30 (below limit), should cache and render at width 30
355− height30 := item.Height(30)
356− if height30 < 1 {
357− t.Errorf("expected height >= 1, got %d", height30)
358− }
359−
360− // At width 100 (above maxWidth), should cap to 50 and cache
361− height100 := item.Height(100)
362− if height100 < 1 {
363− t.Errorf("expected height >= 1, got %d", height100)
364− }
365−
366− // Both should be cached (width 30 and capped width 50)
367− if len(item.cache) != 2 {
368− t.Errorf("expected cache to have 2 entries (30 and 50), got %d", len(item.cache))
369− }
370− if _, ok := item.cache[30]; !ok {
371− t.Error("expected cache to have entry for width 30")
372− }
373− if _, ok := item.cache[50]; !ok {
374− t.Error("expected cache to have entry for width 50 (capped from 100)")
375− }
376−}
377−
378−func BenchmarkMarkdownItemRender(b *testing.B) {
379− markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
380− item := NewMarkdownItem(markdown)
381−
382− // Prime the cache
383− screen := uv.NewScreenBuffer(80, 10)
384− area := uv.Rect(0, 0, 80, 10)
385− item.Draw(&screen, area)
386−
387− b.ResetTimer()
388− for i := 0; i < b.N; i++ {
389− screen := uv.NewScreenBuffer(80, 10)
390− area := uv.Rect(0, 0, 80, 10)
391− item.Draw(&screen, area)
392− }
393−}
394−
395−func BenchmarkMarkdownItemUncached(b *testing.B) {
396− markdown := "# Heading\n\nThis is a paragraph with **bold** and *italic* text.\n\n- Item 1\n- Item 2\n- Item 3"
397−
398− b.ResetTimer()
399− for i := 0; i < b.N; i++ {
400− item := NewMarkdownItem(markdown)
401− screen := uv.NewScreenBuffer(80, 10)
402− area := uv.Rect(0, 0, 80, 10)
403− item.Draw(&screen, area)
404− }
405−}
406−
407−func TestSpacerItem(t *testing.T) {
408− spacer := NewSpacerItem(3)
409−
410− // Check height
411− if h := spacer.Height(80); h != 3 {
412− t.Errorf("expected height 3, got %d", h)
413− }
414−
415− // Height should be constant regardless of width
416− if h := spacer.Height(20); h != 3 {
417− t.Errorf("expected height 3 for width 20, got %d", h)
418− }
419−
420− // Draw should not produce any visible content
421− screen := uv.NewScreenBuffer(20, 3)
422− area := uv.Rect(0, 0, 20, 3)
423− spacer.Draw(&screen, area)
424−
425− output := screen.Render()
426− // Should be empty (just spaces)
427− for _, line := range strings.Split(output, "\n") {
428− trimmed := strings.TrimSpace(line)
429− if trimmed != "" {
430− t.Errorf("expected empty spacer output, got: %q", line)
431− }
432− }
433−}
434−
435−func TestSpacerItemInList(t *testing.T) {
436− // Create a list with items separated by spacers
437− items := []Item{
438− NewStringItem("Item 1"),
439− NewSpacerItem(1),
440− NewStringItem("Item 2"),
441− NewSpacerItem(2),
442− NewStringItem("Item 3"),
443− }
444−
445− l := New(items...)
446− l.SetSize(20, 10)
447−
448− output := l.Render()
449−
450− // Should contain all three items
451− if !strings.Contains(output, "Item 1") {
452− t.Error("expected output to contain 'Item 1'")
453− }
454− if !strings.Contains(output, "Item 2") {
455− t.Error("expected output to contain 'Item 2'")
456− }
457− if !strings.Contains(output, "Item 3") {
458− t.Error("expected output to contain 'Item 3'")
459− }
460−
461− // Total height should be: 1 (item1) + 1 (spacer1) + 1 (item2) + 2 (spacer2) + 1 (item3) = 6
462− expectedHeight := 6
463− if l.TotalHeight() != expectedHeight {
464− t.Errorf("expected total height %d, got %d", expectedHeight, l.TotalHeight())
465− }
466−}
467−
468−func TestSpacerItemNavigation(t *testing.T) {
469− // Spacers should not be selectable (they're not focusable)
470− items := []Item{
471− NewStringItem("Item 1"),
472− NewSpacerItem(1),
473− NewStringItem("Item 2"),
474− }
475−
476− l := New(items...)
477− l.SetSize(20, 10)
478−
479− // Select first item
480− l.SetSelected(0)
481− if l.SelectedIndex() != 0 {
482− t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
483− }
484−
485− // Can select the spacer (it's a valid item, just not focusable)
486− l.SetSelected(1)
487− if l.SelectedIndex() != 1 {
488− t.Errorf("expected selected index 1, got %d", l.SelectedIndex())
489− }
490−
491− // Can select item after spacer
492− l.SetSelected(2)
493− if l.SelectedIndex() != 2 {
494− t.Errorf("expected selected index 2, got %d", l.SelectedIndex())
495− }
496−}
497−
498−// Helper function to create a pointer to uint
499−func uintPtr(v uint) *uint {
500− return &v
501−}
502−
503−func TestListDoesNotEatLastLine(t *testing.T) {
504− // Create items that exactly fill the viewport
505− items := []Item{
506− NewStringItem("Line 1"),
507− NewStringItem("Line 2"),
508− NewStringItem("Line 3"),
509− NewStringItem("Line 4"),
510− NewStringItem("Line 5"),
511− }
512−
513− // Create list with height exactly matching content (5 lines, no gaps)
514− l := New(items...)
515− l.SetSize(20, 5)
516−
517− // Render the list
518− output := l.Render()
519−
520− // Count actual lines in output
521− lines := strings.Split(strings.TrimRight(output, "\n"), "\n")
522− actualLineCount := 0
523− for _, line := range lines {
524− if strings.TrimSpace(line) != "" {
525− actualLineCount++
526− }
527− }
528−
529− // All 5 items should be visible
530− if !strings.Contains(output, "Line 1") {
531− t.Error("expected output to contain 'Line 1'")
532− }
533− if !strings.Contains(output, "Line 2") {
534− t.Error("expected output to contain 'Line 2'")
535− }
536− if !strings.Contains(output, "Line 3") {
537− t.Error("expected output to contain 'Line 3'")
538− }
539− if !strings.Contains(output, "Line 4") {
540− t.Error("expected output to contain 'Line 4'")
541− }
542− if !strings.Contains(output, "Line 5") {
543− t.Error("expected output to contain 'Line 5'")
544− }
545−
546− if actualLineCount != 5 {
547− t.Errorf("expected 5 lines with content, got %d", actualLineCount)
548− }
549−}
550−
551−func TestListWithScrollDoesNotEatLastLine(t *testing.T) {
552− // Create more items than viewport height
553− items := []Item{
554− NewStringItem("Item 1"),
555− NewStringItem("Item 2"),
556− NewStringItem("Item 3"),
557− NewStringItem("Item 4"),
558− NewStringItem("Item 5"),
559− NewStringItem("Item 6"),
560− NewStringItem("Item 7"),
561− }
562−
563− // Viewport shows 3 items at a time
564− l := New(items...)
565− l.SetSize(20, 3)
566−
567− // Need to render first to build the buffer and calculate total height
568− _ = l.Render()
569−
570− // Now scroll to bottom
571− l.ScrollToBottom()
572−
573− output := l.Render()
574−
575− t.Logf("Output:\n%s", output)
576− t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
577−
578− // Should show last 3 items: 5, 6, 7
579− if !strings.Contains(output, "Item 5") {
580− t.Error("expected output to contain 'Item 5'")
581− }
582− if !strings.Contains(output, "Item 6") {
583− t.Error("expected output to contain 'Item 6'")
584− }
585− if !strings.Contains(output, "Item 7") {
586− t.Error("expected output to contain 'Item 7'")
587− }
588−
589− // Should not show earlier items
590− if strings.Contains(output, "Item 1") {
591− t.Error("expected output to NOT contain 'Item 1' when scrolled to bottom")
592− }
593−}
internal/ui/list/lazylist.godeleted−1007
@@ -1,1007 +0,0 @@
1−package list
2−
3−import (
4− "strings"
5−
6− uv "github.com/charmbracelet/ultraviolet"
7− "github.com/charmbracelet/ultraviolet/screen"
8−)
9−
10−// LazyList is a virtual scrolling list that only renders visible items.
11−// It uses height estimates to avoid expensive renders during initial layout.
12−type LazyList struct {
13− // Configuration
14− width, height int
15−
16− // Data
17− items []Item
18−
19− // Focus & Selection
20− focused bool
21− selectedIdx int // Currently selected item index (-1 if none)
22−
23− // Item positioning - tracks measured and estimated positions
24− itemHeights []itemHeight
25− totalHeight int // Sum of all item heights (measured or estimated)
26−
27− // Viewport state
28− offset int // Scroll offset in lines from top
29−
30− // Rendered items cache - only visible items are rendered
31− renderedCache map[int]*renderedItemCache
32−
33− // Virtual scrolling configuration
34− defaultEstimate int // Default height estimate for unmeasured items
35− overscan int // Number of items to render outside viewport for smooth scrolling
36−
37− // Dirty tracking
38− needsLayout bool
39− dirtyItems map[int]bool
40− dirtyViewport bool // True if we need to re-render viewport
41−
42− // Mouse state
43− mouseDown bool
44− mouseDownItem int
45− mouseDownX int
46− mouseDownY int
47− mouseDragItem int
48− mouseDragX int
49− mouseDragY int
50−}
51−
52−// itemHeight tracks the height of an item - either measured or estimated.
53−type itemHeight struct {
54− height int
55− measured bool // true if height is actual measurement, false if estimate
56−}
57−
58−// renderedItemCache stores a rendered item's buffer.
59−type renderedItemCache struct {
60− buffer *uv.ScreenBuffer
61− height int // Actual measured height after rendering
62−}
63−
64−// NewLazyList creates a new lazy-rendering list.
65−func NewLazyList(items ...Item) *LazyList {
66− l := &LazyList{
67− items: items,
68− itemHeights: make([]itemHeight, len(items)),
69− renderedCache: make(map[int]*renderedItemCache),
70− dirtyItems: make(map[int]bool),
71− selectedIdx: -1,
72− mouseDownItem: -1,
73− mouseDragItem: -1,
74− defaultEstimate: 10, // Conservative estimate: 5 lines per item
75− overscan: 5, // Render 3 items above/below viewport
76− needsLayout: true,
77− dirtyViewport: true,
78− }
79−
80− // Initialize all items with estimated heights
81− for i := range l.items {
82− l.itemHeights[i] = itemHeight{
83− height: l.defaultEstimate,
84− measured: false,
85− }
86− }
87− l.calculateTotalHeight()
88−
89− return l
90−}
91−
92−// calculateTotalHeight sums all item heights (measured or estimated).
93−func (l *LazyList) calculateTotalHeight() {
94− l.totalHeight = 0
95− for _, h := range l.itemHeights {
96− l.totalHeight += h.height
97− }
98−}
99−
100−// getItemPosition returns the Y position where an item starts.
101−func (l *LazyList) getItemPosition(idx int) int {
102− pos := 0
103− for i := 0; i < idx && i < len(l.itemHeights); i++ {
104− pos += l.itemHeights[i].height
105− }
106− return pos
107−}
108−
109−// findVisibleItems returns the range of items that are visible or near the viewport.
110−func (l *LazyList) findVisibleItems() (firstIdx, lastIdx int) {
111− if len(l.items) == 0 {
112− return 0, 0
113− }
114−
115− viewportStart := l.offset
116− viewportEnd := l.offset + l.height
117−
118− // Find first visible item
119− firstIdx = -1
120− pos := 0
121− for i := 0; i < len(l.items); i++ {
122− itemEnd := pos + l.itemHeights[i].height
123− if itemEnd > viewportStart {
124− firstIdx = i
125− break
126− }
127− pos = itemEnd
128− }
129−
130− // Apply overscan above
131− firstIdx = max(0, firstIdx-l.overscan)
132−
133− // Find last visible item
134− lastIdx = firstIdx
135− pos = l.getItemPosition(firstIdx)
136− for i := firstIdx; i < len(l.items); i++ {
137− if pos >= viewportEnd {
138− break
139− }
140− pos += l.itemHeights[i].height
141− lastIdx = i
142− }
143−
144− // Apply overscan below
145− lastIdx = min(len(l.items)-1, lastIdx+l.overscan)
146−
147− return firstIdx, lastIdx
148−}
149−
150−// renderItem renders a single item and caches it.
151−// Returns the actual measured height.
152−func (l *LazyList) renderItem(idx int) int {
153− if idx < 0 || idx >= len(l.items) {
154− return 0
155− }
156−
157− item := l.items[idx]
158−
159− // Measure actual height
160− actualHeight := item.Height(l.width)
161−
162− // Create buffer and render
163− buf := uv.NewScreenBuffer(l.width, actualHeight)
164− area := uv.Rect(0, 0, l.width, actualHeight)
165− item.Draw(&buf, area)
166−
167− // Cache rendered item
168− l.renderedCache[idx] = &renderedItemCache{
169− buffer: &buf,
170− height: actualHeight,
171− }
172−
173− // Update height if it was estimated or changed
174− if !l.itemHeights[idx].measured || l.itemHeights[idx].height != actualHeight {
175− oldHeight := l.itemHeights[idx].height
176− l.itemHeights[idx] = itemHeight{
177− height: actualHeight,
178− measured: true,
179− }
180−
181− // Adjust total height
182− l.totalHeight += actualHeight - oldHeight
183− }
184−
185− return actualHeight
186−}
187−
188−// Draw implements uv.Drawable.
189−func (l *LazyList) Draw(scr uv.Screen, area uv.Rectangle) {
190− if area.Dx() <= 0 || area.Dy() <= 0 {
191− return
192− }
193−
194− widthChanged := l.width != area.Dx()
195− heightChanged := l.height != area.Dy()
196−
197− l.width = area.Dx()
198− l.height = area.Dy()
199−
200− // Width changes invalidate all cached renders
201− if widthChanged {
202− l.renderedCache = make(map[int]*renderedItemCache)
203− // Mark all heights as needing remeasurement
204− for i := range l.itemHeights {
205− l.itemHeights[i].measured = false
206− l.itemHeights[i].height = l.defaultEstimate
207− }
208− l.calculateTotalHeight()
209− l.needsLayout = true
210− l.dirtyViewport = true
211− }
212−
213− if heightChanged {
214− l.clampOffset()
215− l.dirtyViewport = true
216− }
217−
218− if len(l.items) == 0 {
219− screen.ClearArea(scr, area)
220− return
221− }
222−
223− // Find visible items based on current estimates
224− firstIdx, lastIdx := l.findVisibleItems()
225−
226− // Track the first visible item's position to maintain stability
227− // Only stabilize if we're not at the top boundary
228− stabilizeIdx := -1
229− stabilizeY := 0
230− if l.offset > 0 {
231− for i := firstIdx; i <= lastIdx; i++ {
232− itemPos := l.getItemPosition(i)
233− if itemPos >= l.offset {
234− stabilizeIdx = i
235− stabilizeY = itemPos
236− break
237− }
238− }
239− }
240−
241− // Track if any heights changed during rendering
242− heightsChanged := false
243−
244− // Render visible items that aren't cached (measurement pass)
245− for i := firstIdx; i <= lastIdx; i++ {
246− if _, cached := l.renderedCache[i]; !cached {
247− oldHeight := l.itemHeights[i].height
248− l.renderItem(i)
249− if l.itemHeights[i].height != oldHeight {
250− heightsChanged = true
251− }
252− } else if l.dirtyItems[i] {
253− // Re-render dirty items
254− oldHeight := l.itemHeights[i].height
255− l.renderItem(i)
256− delete(l.dirtyItems, i)
257− if l.itemHeights[i].height != oldHeight {
258− heightsChanged = true
259− }
260− }
261− }
262−
263− // If heights changed, adjust offset to keep stabilization point stable
264− if heightsChanged && stabilizeIdx >= 0 {
265− newStabilizeY := l.getItemPosition(stabilizeIdx)
266− offsetDelta := newStabilizeY - stabilizeY
267−
268− // Adjust offset to maintain visual stability
269− l.offset += offsetDelta
270− l.clampOffset()
271−
272− // Re-find visible items with adjusted positions
273− firstIdx, lastIdx = l.findVisibleItems()
274−
275− // Render any newly visible items after position adjustments
276− for i := firstIdx; i <= lastIdx; i++ {
277− if _, cached := l.renderedCache[i]; !cached {
278− l.renderItem(i)
279− }
280− }
281− }
282−
283− // Clear old cache entries outside visible range
284− if len(l.renderedCache) > (lastIdx-firstIdx+1)*2 {
285− l.pruneCache(firstIdx, lastIdx)
286− }
287−
288− // Composite visible items into viewport with stable positions
289− l.drawViewport(scr, area, firstIdx, lastIdx)
290−
291− l.dirtyViewport = false
292− l.needsLayout = false
293−}
294−
295−// drawViewport composites visible items into the screen.
296−func (l *LazyList) drawViewport(scr uv.Screen, area uv.Rectangle, firstIdx, lastIdx int) {
297− screen.ClearArea(scr, area)
298−
299− itemStartY := l.getItemPosition(firstIdx)
300−
301− for i := firstIdx; i <= lastIdx; i++ {
302− cached, ok := l.renderedCache[i]
303− if !ok {
304− continue
305− }
306−
307− // Calculate where this item appears in viewport
308− itemY := itemStartY - l.offset
309− itemHeight := cached.height
310−
311− // Skip if entirely above viewport
312− if itemY+itemHeight < 0 {
313− itemStartY += itemHeight
314− continue
315− }
316−
317− // Stop if entirely below viewport
318− if itemY >= l.height {
319− break
320− }
321−
322− // Calculate visible portion of item
323− srcStartY := 0
324− dstStartY := itemY
325−
326− if itemY < 0 {
327− // Item starts above viewport
328− srcStartY = -itemY
329− dstStartY = 0
330− }
331−
332− srcEndY := srcStartY + (l.height - dstStartY)
333− if srcEndY > itemHeight {
334− srcEndY = itemHeight
335− }
336−
337− // Copy visible lines from item buffer to screen
338− buf := cached.buffer.Buffer
339− destY := area.Min.Y + dstStartY
340−
341− for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
342− if srcY >= buf.Height() {
343− break
344− }
345−
346− line := buf.Line(srcY)
347− destX := area.Min.X
348−
349− for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
350− cell := line.At(x)
351− scr.SetCell(destX, destY, cell)
352− destX++
353− }
354− destY++
355− }
356−
357− itemStartY += itemHeight
358− }
359−}
360−
361−// pruneCache removes cached items outside the visible range.
362−func (l *LazyList) pruneCache(firstIdx, lastIdx int) {
363− keepStart := max(0, firstIdx-l.overscan*2)
364− keepEnd := min(len(l.items)-1, lastIdx+l.overscan*2)
365−
366− for idx := range l.renderedCache {
367− if idx < keepStart || idx > keepEnd {
368− delete(l.renderedCache, idx)
369− }
370− }
371−}
372−
373−// clampOffset ensures scroll offset stays within valid bounds.
374−func (l *LazyList) clampOffset() {
375− maxOffset := l.totalHeight - l.height
376− if maxOffset < 0 {
377− maxOffset = 0
378− }
379−
380− if l.offset > maxOffset {
381− l.offset = maxOffset
382− }
383− if l.offset < 0 {
384− l.offset = 0
385− }
386−}
387−
388−// SetItems replaces all items in the list.
389−func (l *LazyList) SetItems(items []Item) {
390− l.items = items
391− l.itemHeights = make([]itemHeight, len(items))
392− l.renderedCache = make(map[int]*renderedItemCache)
393− l.dirtyItems = make(map[int]bool)
394−
395− // Initialize with estimates
396− for i := range l.items {
397− l.itemHeights[i] = itemHeight{
398− height: l.defaultEstimate,
399− measured: false,
400− }
401− }
402− l.calculateTotalHeight()
403− l.needsLayout = true
404− l.dirtyViewport = true
405−}
406−
407−// AppendItem adds an item to the end of the list.
408−func (l *LazyList) AppendItem(item Item) {
409− l.items = append(l.items, item)
410− l.itemHeights = append(l.itemHeights, itemHeight{
411− height: l.defaultEstimate,
412− measured: false,
413− })
414− l.totalHeight += l.defaultEstimate
415− l.dirtyViewport = true
416−}
417−
418−// PrependItem adds an item to the beginning of the list.
419−func (l *LazyList) PrependItem(item Item) {
420− l.items = append([]Item{item}, l.items...)
421− l.itemHeights = append([]itemHeight{{
422− height: l.defaultEstimate,
423− measured: false,
424− }}, l.itemHeights...)
425−
426− // Shift cache indices
427− newCache := make(map[int]*renderedItemCache)
428− for idx, cached := range l.renderedCache {
429− newCache[idx+1] = cached
430− }
431− l.renderedCache = newCache
432−
433− l.totalHeight += l.defaultEstimate
434− l.offset += l.defaultEstimate // Maintain scroll position
435− l.dirtyViewport = true
436−}
437−
438−// UpdateItem replaces an item at the given index.
439−func (l *LazyList) UpdateItem(idx int, item Item) {
440− if idx < 0 || idx >= len(l.items) {
441− return
442− }
443−
444− l.items[idx] = item
445− delete(l.renderedCache, idx)
446− l.dirtyItems[idx] = true
447− // Keep height estimate - will remeasure on next render
448− l.dirtyViewport = true
449−}
450−
451−// ScrollBy scrolls by the given number of lines.
452−func (l *LazyList) ScrollBy(delta int) {
453− l.offset += delta
454− l.clampOffset()
455− l.dirtyViewport = true
456−}
457−
458−// ScrollToBottom scrolls to the end of the list.
459−func (l *LazyList) ScrollToBottom() {
460− l.offset = l.totalHeight - l.height
461− l.clampOffset()
462− l.dirtyViewport = true
463−}
464−
465−// ScrollToTop scrolls to the beginning of the list.
466−func (l *LazyList) ScrollToTop() {
467− l.offset = 0
468− l.dirtyViewport = true
469−}
470−
471−// Len returns the number of items in the list.
472−func (l *LazyList) Len() int {
473− return len(l.items)
474−}
475−
476−// Focus sets the list as focused.
477−func (l *LazyList) Focus() {
478− l.focused = true
479− l.focusSelectedItem()
480− l.dirtyViewport = true
481−}
482−
483−// Blur removes focus from the list.
484−func (l *LazyList) Blur() {
485− l.focused = false
486− l.blurSelectedItem()
487− l.dirtyViewport = true
488−}
489−
490−// focusSelectedItem focuses the currently selected item if it's focusable.
491−func (l *LazyList) focusSelectedItem() {
492− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
493− return
494− }
495−
496− item := l.items[l.selectedIdx]
497− if f, ok := item.(Focusable); ok {
498− f.Focus()
499− delete(l.renderedCache, l.selectedIdx)
500− l.dirtyItems[l.selectedIdx] = true
501− }
502−}
503−
504−// blurSelectedItem blurs the currently selected item if it's focusable.
505−func (l *LazyList) blurSelectedItem() {
506− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
507− return
508− }
509−
510− item := l.items[l.selectedIdx]
511− if f, ok := item.(Focusable); ok {
512− f.Blur()
513− delete(l.renderedCache, l.selectedIdx)
514− l.dirtyItems[l.selectedIdx] = true
515− }
516−}
517−
518−// IsFocused returns whether the list is focused.
519−func (l *LazyList) IsFocused() bool {
520− return l.focused
521−}
522−
523−// Width returns the current viewport width.
524−func (l *LazyList) Width() int {
525− return l.width
526−}
527−
528−// Height returns the current viewport height.
529−func (l *LazyList) Height() int {
530− return l.height
531−}
532−
533−// SetSize sets the viewport size explicitly.
534−// This is useful when you want to pre-configure the list size before drawing.
535−func (l *LazyList) SetSize(width, height int) {
536− widthChanged := l.width != width
537− heightChanged := l.height != height
538−
539− l.width = width
540− l.height = height
541−
542− // Width changes invalidate all cached renders
543− if widthChanged && width > 0 {
544− l.renderedCache = make(map[int]*renderedItemCache)
545− // Mark all heights as needing remeasurement
546− for i := range l.itemHeights {
547− l.itemHeights[i].measured = false
548− l.itemHeights[i].height = l.defaultEstimate
549− }
550− l.calculateTotalHeight()
551− l.needsLayout = true
552− l.dirtyViewport = true
553− }
554−
555− if heightChanged && height > 0 {
556− l.clampOffset()
557− l.dirtyViewport = true
558− }
559−
560− // After cache invalidation, scroll to selected item or bottom
561− if widthChanged || heightChanged {
562− if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
563− // Scroll to selected item
564− l.ScrollToSelected()
565− } else if len(l.items) > 0 {
566− // No selection - scroll to bottom
567− l.ScrollToBottom()
568− }
569− }
570−}
571−
572−// Selection methods
573−
574−// Selected returns the currently selected item index (-1 if none).
575−func (l *LazyList) Selected() int {
576− return l.selectedIdx
577−}
578−
579−// SetSelected sets the selected item by index.
580−func (l *LazyList) SetSelected(idx int) {
581− if idx < -1 || idx >= len(l.items) {
582− return
583− }
584−
585− if l.selectedIdx != idx {
586− prevIdx := l.selectedIdx
587− l.selectedIdx = idx
588− l.dirtyViewport = true
589−
590− // Update focus states if list is focused.
591− if l.focused {
592− // Blur previously selected item.
593− if prevIdx >= 0 && prevIdx < len(l.items) {
594− if f, ok := l.items[prevIdx].(Focusable); ok {
595− f.Blur()
596− delete(l.renderedCache, prevIdx)
597− l.dirtyItems[prevIdx] = true
598− }
599− }
600−
601− // Focus newly selected item.
602− if idx >= 0 && idx < len(l.items) {
603− if f, ok := l.items[idx].(Focusable); ok {
604− f.Focus()
605− delete(l.renderedCache, idx)
606− l.dirtyItems[idx] = true
607− }
608− }
609− }
610− }
611−}
612−
613−// SelectPrev selects the previous item.
614−func (l *LazyList) SelectPrev() {
615− if len(l.items) == 0 {
616− return
617− }
618−
619− if l.selectedIdx <= 0 {
620− l.selectedIdx = 0
621− } else {
622− l.selectedIdx--
623− }
624−
625− l.dirtyViewport = true
626−}
627−
628−// SelectNext selects the next item.
629−func (l *LazyList) SelectNext() {
630− if len(l.items) == 0 {
631− return
632− }
633−
634− if l.selectedIdx < 0 {
635− l.selectedIdx = 0
636− } else if l.selectedIdx < len(l.items)-1 {
637− l.selectedIdx++
638− }
639−
640− l.dirtyViewport = true
641−}
642−
643−// SelectFirst selects the first item.
644−func (l *LazyList) SelectFirst() {
645− if len(l.items) > 0 {
646− l.selectedIdx = 0
647− l.dirtyViewport = true
648− }
649−}
650−
651−// SelectLast selects the last item.
652−func (l *LazyList) SelectLast() {
653− if len(l.items) > 0 {
654− l.selectedIdx = len(l.items) - 1
655− l.dirtyViewport = true
656− }
657−}
658−
659−// SelectFirstInView selects the first visible item in the viewport.
660−func (l *LazyList) SelectFirstInView() {
661− if len(l.items) == 0 {
662− return
663− }
664−
665− firstIdx, _ := l.findVisibleItems()
666− l.selectedIdx = firstIdx
667− l.dirtyViewport = true
668−}
669−
670−// SelectLastInView selects the last visible item in the viewport.
671−func (l *LazyList) SelectLastInView() {
672− if len(l.items) == 0 {
673− return
674− }
675−
676− _, lastIdx := l.findVisibleItems()
677− l.selectedIdx = lastIdx
678− l.dirtyViewport = true
679−}
680−
681−// SelectedItemInView returns whether the selected item is visible in the viewport.
682−func (l *LazyList) SelectedItemInView() bool {
683− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
684− return false
685− }
686−
687− firstIdx, lastIdx := l.findVisibleItems()
688− return l.selectedIdx >= firstIdx && l.selectedIdx <= lastIdx
689−}
690−
691−// ScrollToSelected scrolls the viewport to ensure the selected item is visible.
692−func (l *LazyList) ScrollToSelected() {
693− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
694− return
695− }
696−
697− // Get selected item position
698− itemY := l.getItemPosition(l.selectedIdx)
699− itemHeight := l.itemHeights[l.selectedIdx].height
700−
701− // Check if item is above viewport
702− if itemY < l.offset {
703− l.offset = itemY
704− l.dirtyViewport = true
705− return
706− }
707−
708− // Check if item is below viewport
709− itemBottom := itemY + itemHeight
710− viewportBottom := l.offset + l.height
711−
712− if itemBottom > viewportBottom {
713− // Scroll so item bottom is at viewport bottom
714− l.offset = itemBottom - l.height
715− l.clampOffset()
716− l.dirtyViewport = true
717− }
718−}
719−
720−// Mouse interaction methods
721−
722−// HandleMouseDown handles mouse button down events.
723−// Returns true if the event was handled.
724−func (l *LazyList) HandleMouseDown(x, y int) bool {
725− if x < 0 || y < 0 || x >= l.width || y >= l.height {
726− return false
727− }
728−
729− // Find which item was clicked
730− clickY := l.offset + y
731− itemIdx := l.findItemAtY(clickY)
732−
733− if itemIdx < 0 {
734− return false
735− }
736−
737− // Calculate item-relative Y position.
738− itemY := clickY - l.getItemPosition(itemIdx)
739−
740− l.mouseDown = true
741− l.mouseDownItem = itemIdx
742− l.mouseDownX = x
743− l.mouseDownY = itemY
744− l.mouseDragItem = itemIdx
745− l.mouseDragX = x
746− l.mouseDragY = itemY
747−
748− // Select the clicked item
749− l.SetSelected(itemIdx)
750−
751− return true
752−}
753−
754−// HandleMouseDrag handles mouse drag events.
755−func (l *LazyList) HandleMouseDrag(x, y int) {
756− if !l.mouseDown {
757− return
758− }
759−
760− // Find item under cursor
761− if y >= 0 && y < l.height {
762− dragY := l.offset + y
763− itemIdx := l.findItemAtY(dragY)
764− if itemIdx >= 0 {
765− l.mouseDragItem = itemIdx
766− // Calculate item-relative Y position.
767− l.mouseDragY = dragY - l.getItemPosition(itemIdx)
768− l.mouseDragX = x
769− }
770− }
771−
772− // Update highlight if item supports it.
773− l.updateHighlight()
774−}
775−
776−// HandleMouseUp handles mouse button up events.
777−func (l *LazyList) HandleMouseUp(x, y int) {
778− if !l.mouseDown {
779− return
780− }
781−
782− l.mouseDown = false
783−
784− // Final highlight update.
785− l.updateHighlight()
786−}
787−
788−// findItemAtY finds the item index at the given Y coordinate (in content space, not viewport).
789−func (l *LazyList) findItemAtY(y int) int {
790− if y < 0 || len(l.items) == 0 {
791− return -1
792− }
793−
794− pos := 0
795− for i := 0; i < len(l.items); i++ {
796− itemHeight := l.itemHeights[i].height
797− if y >= pos && y < pos+itemHeight {
798− return i
799− }
800− pos += itemHeight
801− }
802−
803− return -1
804−}
805−
806−// updateHighlight updates the highlight range for highlightable items.
807−// Supports highlighting within a single item and respects drag direction.
808−func (l *LazyList) updateHighlight() {
809− if l.mouseDownItem < 0 {
810− return
811− }
812−
813− // Get start and end item indices.
814− downItemIdx := l.mouseDownItem
815− dragItemIdx := l.mouseDragItem
816−
817− // Determine selection direction.
818− draggingDown := dragItemIdx > downItemIdx ||
819− (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
820− (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
821−
822− // Determine actual start and end based on direction.
823− var startItemIdx, endItemIdx int
824− var startLine, startCol, endLine, endCol int
825−
826− if draggingDown {
827− // Normal forward selection.
828− startItemIdx = downItemIdx
829− endItemIdx = dragItemIdx
830− startLine = l.mouseDownY
831− startCol = l.mouseDownX
832− endLine = l.mouseDragY
833− endCol = l.mouseDragX
834− } else {
835− // Backward selection (dragging up).
836− startItemIdx = dragItemIdx
837− endItemIdx = downItemIdx
838− startLine = l.mouseDragY
839− startCol = l.mouseDragX
840− endLine = l.mouseDownY
841− endCol = l.mouseDownX
842− }
843−
844− // Clear all highlights first.
845− for i, item := range l.items {
846− if h, ok := item.(Highlightable); ok {
847− h.SetHighlight(-1, -1, -1, -1)
848− delete(l.renderedCache, i)
849− l.dirtyItems[i] = true
850− }
851− }
852−
853− // Highlight all items in range.
854− for idx := startItemIdx; idx <= endItemIdx; idx++ {
855− item, ok := l.items[idx].(Highlightable)
856− if !ok {
857− continue
858− }
859−
860− if idx == startItemIdx && idx == endItemIdx {
861− // Single item selection.
862− item.SetHighlight(startLine, startCol, endLine, endCol)
863− } else if idx == startItemIdx {
864− // First item - from start position to end of item.
865− itemHeight := l.itemHeights[idx].height
866− item.SetHighlight(startLine, startCol, itemHeight-1, 9999) // 9999 = end of line
867− } else if idx == endItemIdx {
868− // Last item - from start of item to end position.
869− item.SetHighlight(0, 0, endLine, endCol)
870− } else {
871− // Middle item - fully highlighted.
872− itemHeight := l.itemHeights[idx].height
873− item.SetHighlight(0, 0, itemHeight-1, 9999)
874− }
875−
876− delete(l.renderedCache, idx)
877− l.dirtyItems[idx] = true
878− }
879−}
880−
881−// ClearHighlight clears any active text highlighting.
882−func (l *LazyList) ClearHighlight() {
883− for i, item := range l.items {
884− if h, ok := item.(Highlightable); ok {
885− h.SetHighlight(-1, -1, -1, -1)
886− delete(l.renderedCache, i)
887− l.dirtyItems[i] = true
888− }
889− }
890− l.mouseDownItem = -1
891− l.mouseDragItem = -1
892−}
893−
894−// GetHighlightedText returns the plain text content of all highlighted regions
895−// across items, without any styling. Returns empty string if no highlights exist.
896−func (l *LazyList) GetHighlightedText() string {
897− var result strings.Builder
898−
899− // Iterate through items to find highlighted ones.
900− for i, item := range l.items {
901− h, ok := item.(Highlightable)
902− if !ok {
903− continue
904− }
905−
906− startLine, startCol, endLine, endCol := h.GetHighlight()
907− if startLine < 0 {
908− continue
909− }
910−
911− // Ensure item is rendered so we can access its buffer.
912− if _, ok := l.renderedCache[i]; !ok {
913− l.renderItem(i)
914− }
915−
916− cached := l.renderedCache[i]
917− if cached == nil || cached.buffer == nil {
918− continue
919− }
920−
921− buf := cached.buffer
922− itemHeight := cached.height
923−
924− // Extract text from highlighted region in item buffer.
925− for y := startLine; y <= endLine && y < itemHeight; y++ {
926− if y >= buf.Height() {
927− break
928− }
929−
930− line := buf.Line(y)
931−
932− // Determine column range for this line.
933− colStart := 0
934− if y == startLine {
935− colStart = startCol
936− }
937−
938− colEnd := len(line)
939− if y == endLine {
940− colEnd = min(endCol, len(line))
941− }
942−
943− // Track last non-empty position to trim trailing spaces.
944− lastContentX := -1
945− for x := colStart; x < colEnd && x < len(line); x++ {
946− cell := line.At(x)
947− if cell == nil || cell.IsZero() {
948− continue
949− }
950− if cell.Content != "" && cell.Content != " " {
951− lastContentX = x
952− }
953− }
954−
955− // Extract text from cells, up to last content.
956− endX := colEnd
957− if lastContentX >= 0 {
958− endX = lastContentX + 1
959− }
960−
961− for x := colStart; x < endX && x < len(line); x++ {
962− cell := line.At(x)
963− if cell != nil && !cell.IsZero() {
964− result.WriteString(cell.Content)
965− }
966− }
967−
968− // Add newline if not the last line.
969− if y < endLine {
970− result.WriteString("\n")
971− }
972− }
973−
974− // Add newline between items if this isn't the last highlighted item.
975− if i < len(l.items)-1 {
976− nextHasHighlight := false
977− for j := i + 1; j < len(l.items); j++ {
978− if h, ok := l.items[j].(Highlightable); ok {
979− s, _, _, _ := h.GetHighlight()
980− if s >= 0 {
981− nextHasHighlight = true
982− break
983− }
984− }
985− }
986− if nextHasHighlight {
987− result.WriteString("\n")
988− }
989− }
990− }
991−
992− return result.String()
993−}
994−
995−func min(a, b int) int {
996− if a < b {
997− return a
998− }
999− return b
1000−}
1001−
1002−func max(a, b int) int {
1003− if a > b {
1004− return a
1005− }
1006− return b
1007−}
internal/ui/list/list.godeleted−1126
@@ -1,1126 +0,0 @@
1−package list
2−
3−import (
4− "strings"
5−
6− uv "github.com/charmbracelet/ultraviolet"
7− "github.com/charmbracelet/ultraviolet/screen"
8− "github.com/charmbracelet/x/exp/ordered"
9−)
10−
11−// List is a scrollable list component that implements uv.Drawable.
12−// It efficiently manages a large number of items by caching rendered content
13−// in a master buffer and extracting only the visible viewport when drawn.
14−type List struct {
15− // Configuration
16− width, height int
17−
18− // Data
19− items []Item
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 []itemPosition
31−
32− // Viewport state
33− offset int // Scroll offset in lines from top
34−
35− // Mouse state
36− mouseDown bool
37− mouseDownItem int // Item index where mouse was pressed
38− mouseDownX int // X position in item content (character offset)
39− mouseDownY int // Y position in item (line offset)
40− mouseDragItem int // Current item index being dragged over
41− mouseDragX int // Current X in item content
42− mouseDragY int // Current Y in item
43−
44− // Dirty tracking
45− dirty bool
46− dirtyItems map[int]bool
47−}
48−
49−type itemPosition struct {
50− startLine int
51− height int
52−}
53−
54−// New creates a new list with the given items.
55−func New(items ...Item) *List {
56− l := &List{
57− items: items,
58− itemPositions: make([]itemPosition, len(items)),
59− dirtyItems: make(map[int]bool),
60− selectedIdx: -1,
61− mouseDownItem: -1,
62− mouseDragItem: -1,
63− }
64−
65− l.dirty = true
66− return l
67−}
68−
69−// ensureBuilt ensures the master buffer is built.
70−// This is called by methods that need itemPositions or totalHeight.
71−func (l *List) ensureBuilt() {
72− if l.width <= 0 || l.height <= 0 {
73− return
74− }
75−
76− if l.dirty {
77− l.rebuildMasterBuffer()
78− } else if len(l.dirtyItems) > 0 {
79− l.updateDirtyItems()
80− }
81−}
82−
83−// Draw implements uv.Drawable.
84−// Draws the visible viewport of the list to the given screen buffer.
85−func (l *List) Draw(scr uv.Screen, area uv.Rectangle) {
86− if area.Dx() <= 0 || area.Dy() <= 0 {
87− return
88− }
89−
90− // Update internal dimensions if area size changed
91− widthChanged := l.width != area.Dx()
92− heightChanged := l.height != area.Dy()
93−
94− l.width = area.Dx()
95− l.height = area.Dy()
96−
97− // Only width changes require rebuilding master buffer
98− // Height changes only affect viewport clipping, not item rendering
99− if widthChanged {
100− l.dirty = true
101− }
102−
103− // Height changes require clamping offset to new bounds
104− if heightChanged {
105− l.clampOffset()
106− }
107−
108− if len(l.items) == 0 {
109− screen.ClearArea(scr, area)
110− return
111− }
112−
113− // Ensure buffer is built
114− l.ensureBuilt()
115−
116− // Draw visible portion to the target screen
117− l.drawViewport(scr, area)
118−}
119−
120−// Render renders the visible viewport to a string.
121−// This is a convenience method that creates a temporary screen buffer,
122−// draws to it, and returns the rendered string.
123−func (l *List) Render() string {
124− if l.width <= 0 || l.height <= 0 {
125− return ""
126− }
127−
128− if len(l.items) == 0 {
129− return ""
130− }
131−
132− // Ensure buffer is built
133− l.ensureBuilt()
134−
135− // Extract visible lines directly from master buffer
136− return l.renderViewport()
137−}
138−
139−// renderViewport renders the visible portion of the master buffer to a string.
140−func (l *List) renderViewport() string {
141− if l.masterBuffer == nil {
142− return ""
143− }
144−
145− buf := l.masterBuffer.Buffer
146−
147− // Calculate visible region in master buffer
148− srcStartY := l.offset
149− srcEndY := l.offset + l.height
150−
151− // Clamp to actual buffer bounds
152− if srcStartY >= len(buf.Lines) {
153− // Beyond end of content, return empty lines
154− emptyLine := strings.Repeat(" ", l.width)
155− lines := make([]string, l.height)
156− for i := range lines {
157− lines[i] = emptyLine
158− }
159− return strings.Join(lines, "\n")
160− }
161− if srcEndY > len(buf.Lines) {
162− srcEndY = len(buf.Lines)
163− }
164−
165− // Build result with proper line handling
166− lines := make([]string, l.height)
167− lineIdx := 0
168−
169− // Render visible lines from buffer
170− for y := srcStartY; y < srcEndY && lineIdx < l.height; y++ {
171− lines[lineIdx] = buf.Lines[y].Render()
172− lineIdx++
173− }
174−
175− // Pad remaining lines with spaces to maintain viewport height
176− emptyLine := strings.Repeat(" ", l.width)
177− for ; lineIdx < l.height; lineIdx++ {
178− lines[lineIdx] = emptyLine
179− }
180−
181− return strings.Join(lines, "\n")
182−}
183−
184−// drawViewport draws the visible portion from master buffer to target screen.
185−func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) {
186− if l.masterBuffer == nil {
187− screen.ClearArea(scr, area)
188− return
189− }
190−
191− buf := l.masterBuffer.Buffer
192−
193− // Calculate visible region in master buffer
194− srcStartY := l.offset
195− srcEndY := l.offset + area.Dy()
196−
197− // Clamp to actual buffer bounds
198− if srcStartY >= buf.Height() {
199− screen.ClearArea(scr, area)
200− return
201− }
202− if srcEndY > buf.Height() {
203− srcEndY = buf.Height()
204− }
205−
206− // Copy visible lines to target screen
207− destY := area.Min.Y
208− for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
209− line := buf.Line(srcY)
210− destX := area.Min.X
211−
212− for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
213− cell := line.At(x)
214− scr.SetCell(destX, destY, cell)
215− destX++
216− }
217− destY++
218− }
219−
220− // Clear any remaining area if content is shorter than viewport
221− if destY < area.Max.Y {
222− clearArea := uv.Rect(area.Min.X, destY, area.Dx(), area.Max.Y-destY)
223− screen.ClearArea(scr, clearArea)
224− }
225−}
226−
227−// rebuildMasterBuffer composes all items into the master buffer.
228−func (l *List) rebuildMasterBuffer() {
229− if len(l.items) == 0 {
230− l.totalHeight = 0
231− l.dirty = false
232− return
233− }
234−
235− // Calculate total height
236− l.totalHeight = l.calculateTotalHeight()
237−
238− // Create or resize master buffer
239− if l.masterBuffer == nil || l.masterBuffer.Width() != l.width || l.masterBuffer.Height() != l.totalHeight {
240− buf := uv.NewScreenBuffer(l.width, l.totalHeight)
241− l.masterBuffer = &buf
242− }
243−
244− // Clear buffer
245− screen.Clear(l.masterBuffer)
246−
247− // Draw each item
248− currentY := 0
249− for i, item := range l.items {
250− itemHeight := item.Height(l.width)
251−
252− // Draw item to master buffer
253− area := uv.Rect(0, currentY, l.width, itemHeight)
254− item.Draw(l.masterBuffer, area)
255−
256− // Store position
257− l.itemPositions[i] = itemPosition{
258− startLine: currentY,
259− height: itemHeight,
260− }
261−
262− // Advance position
263− currentY += itemHeight
264− }
265−
266− l.dirty = false
267− l.dirtyItems = make(map[int]bool)
268−}
269−
270−// updateDirtyItems efficiently updates only changed items using slice operations.
271−func (l *List) updateDirtyItems() {
272− if len(l.dirtyItems) == 0 {
273− return
274− }
275−
276− // Check if all dirty items have unchanged heights
277− allSameHeight := true
278− for idx := range l.dirtyItems {
279− item := l.items[idx]
280− pos := l.itemPositions[idx]
281− newHeight := item.Height(l.width)
282− if newHeight != pos.height {
283− allSameHeight = false
284− break
285− }
286− }
287−
288− // Optimization: If all dirty items have unchanged heights, re-render in place
289− if allSameHeight {
290− buf := l.masterBuffer.Buffer
291− for idx := range l.dirtyItems {
292− item := l.items[idx]
293− pos := l.itemPositions[idx]
294−
295− // Clear the item's area
296− for y := pos.startLine; y < pos.startLine+pos.height && y < len(buf.Lines); y++ {
297− buf.Lines[y] = uv.NewLine(l.width)
298− }
299−
300− // Re-render item
301− area := uv.Rect(0, pos.startLine, l.width, pos.height)
302− item.Draw(l.masterBuffer, area)
303− }
304−
305− l.dirtyItems = make(map[int]bool)
306− return
307− }
308−
309− // Height changed - full rebuild
310− l.dirty = true
311− l.dirtyItems = make(map[int]bool)
312− l.rebuildMasterBuffer()
313−}
314−
315−// updatePositionsBelow updates the startLine for all items below the given index.
316−func (l *List) updatePositionsBelow(fromIdx int, delta int) {
317− for i := fromIdx + 1; i < len(l.items); i++ {
318− pos := l.itemPositions[i]
319− pos.startLine += delta
320− l.itemPositions[i] = pos
321− }
322−}
323−
324−// calculateTotalHeight calculates the total height of all items plus gaps.
325−func (l *List) calculateTotalHeight() int {
326− if len(l.items) == 0 {
327− return 0
328− }
329−
330− total := 0
331− for _, item := range l.items {
332− total += item.Height(l.width)
333− }
334− return total
335−}
336−
337−// SetSize updates the viewport size.
338−func (l *List) SetSize(width, height int) {
339− widthChanged := l.width != width
340− heightChanged := l.height != height
341−
342− l.width = width
343− l.height = height
344−
345− // Width changes require full rebuild (items may reflow)
346− if widthChanged {
347− l.dirty = true
348− }
349−
350− // Height changes require clamping offset to new bounds
351− if heightChanged {
352− l.clampOffset()
353− }
354−}
355−
356−// Height returns the current viewport height.
357−func (l *List) Height() int {
358− return l.height
359−}
360−
361−// Width returns the current viewport width.
362−func (l *List) Width() int {
363− return l.width
364−}
365−
366−// GetSize returns the current viewport size.
367−func (l *List) GetSize() (int, int) {
368− return l.width, l.height
369−}
370−
371−// Len returns the number of items in the list.
372−func (l *List) Len() int {
373− return len(l.items)
374−}
375−
376−// SetItems replaces all items in the list.
377−func (l *List) SetItems(items []Item) {
378− l.items = items
379− l.itemPositions = make([]itemPosition, len(items))
380− l.dirty = true
381−}
382−
383−// Items returns all items in the list.
384−func (l *List) Items() []Item {
385− return l.items
386−}
387−
388−// AppendItem adds an item to the end of the list. Returns true if successful.
389−func (l *List) AppendItem(item Item) bool {
390− l.items = append(l.items, item)
391− l.itemPositions = append(l.itemPositions, itemPosition{})
392−
393− // If buffer not built yet, mark dirty for full rebuild
394− if l.masterBuffer == nil || l.width <= 0 {
395− l.dirty = true
396− return true
397− }
398−
399− // Process any pending dirty items before modifying buffer structure
400− if len(l.dirtyItems) > 0 {
401− l.updateDirtyItems()
402− }
403−
404− // Efficient append: insert lines at end of buffer
405− itemHeight := item.Height(l.width)
406− startLine := l.totalHeight
407−
408− // Expand buffer
409− newLines := make([]uv.Line, itemHeight)
410− for i := range newLines {
411− newLines[i] = uv.NewLine(l.width)
412− }
413− l.masterBuffer.Buffer.Lines = append(l.masterBuffer.Buffer.Lines, newLines...)
414−
415− // Draw new item
416− area := uv.Rect(0, startLine, l.width, itemHeight)
417− item.Draw(l.masterBuffer, area)
418−
419− // Update tracking
420− l.itemPositions[len(l.items)-1] = itemPosition{
421− startLine: startLine,
422− height: itemHeight,
423− }
424− l.totalHeight += itemHeight
425−
426− return true
427−}
428−
429−// PrependItem adds an item to the beginning of the list. Returns true if
430−// successful.
431−func (l *List) PrependItem(item Item) bool {
432− l.items = append([]Item{item}, l.items...)
433− l.itemPositions = append([]itemPosition{{}}, l.itemPositions...)
434− if l.selectedIdx >= 0 {
435− l.selectedIdx++
436− }
437−
438− // If buffer not built yet, mark dirty for full rebuild
439− if l.masterBuffer == nil || l.width <= 0 {
440− l.dirty = true
441− return true
442− }
443−
444− // Process any pending dirty items before modifying buffer structure
445− if len(l.dirtyItems) > 0 {
446− l.updateDirtyItems()
447− }
448−
449− // Efficient prepend: insert lines at start of buffer
450− itemHeight := item.Height(l.width)
451−
452− // Create new lines
453− newLines := make([]uv.Line, itemHeight)
454− for i := range newLines {
455− newLines[i] = uv.NewLine(l.width)
456− }
457−
458− // Insert at beginning
459− buf := l.masterBuffer.Buffer
460− buf.Lines = append(newLines, buf.Lines...)
461−
462− // Draw new item
463− area := uv.Rect(0, 0, l.width, itemHeight)
464− item.Draw(l.masterBuffer, area)
465−
466− // Update all positions (shift everything down)
467− for i := range l.itemPositions {
468− pos := l.itemPositions[i]
469− pos.startLine += itemHeight
470− l.itemPositions[i] = pos
471− }
472−
473− // Add position for new item at start
474− l.itemPositions[0] = itemPosition{
475− startLine: 0,
476− height: itemHeight,
477− }
478−
479− l.totalHeight += itemHeight
480−
481− return true
482−}
483−
484−// UpdateItem replaces an item with the same index. Returns true if successful.
485−func (l *List) UpdateItem(idx int, item Item) bool {
486− if idx < 0 || idx >= len(l.items) {
487− return false
488− }
489− l.items[idx] = item
490− l.dirtyItems[idx] = true
491− return true
492−}
493−
494−// DeleteItem removes an item by index. Returns true if successful.
495−func (l *List) DeleteItem(idx int) bool {
496− if idx < 0 || idx >= len(l.items) {
497− return false
498− }
499−
500− // Get position before deleting
501− pos := l.itemPositions[idx]
502−
503− // Process any pending dirty items before modifying buffer structure
504− if len(l.dirtyItems) > 0 {
505− l.updateDirtyItems()
506− }
507−
508− l.items = append(l.items[:idx], l.items[idx+1:]...)
509− l.itemPositions = append(l.itemPositions[:idx], l.itemPositions[idx+1:]...)
510−
511− // Adjust selection
512− if l.selectedIdx == idx {
513− if idx > 0 {
514− l.selectedIdx = idx - 1
515− } else if len(l.items) > 0 {
516− l.selectedIdx = 0
517− } else {
518− l.selectedIdx = -1
519− }
520− } else if l.selectedIdx > idx {
521− l.selectedIdx--
522− }
523−
524− // If buffer not built yet, mark dirty for full rebuild
525− if l.masterBuffer == nil {
526− l.dirty = true
527− return true
528− }
529−
530− // Efficient delete: remove lines from buffer
531− deleteStart := pos.startLine
532− deleteEnd := pos.startLine + pos.height
533− buf := l.masterBuffer.Buffer
534−
535− if deleteEnd <= len(buf.Lines) {
536− buf.Lines = append(buf.Lines[:deleteStart], buf.Lines[deleteEnd:]...)
537− l.totalHeight -= pos.height
538− l.updatePositionsBelow(idx-1, -pos.height)
539− } else {
540− // Position data corrupt, rebuild
541− l.dirty = true
542− }
543−
544− return true
545−}
546−
547−// Focus focuses the list and the selected item (if focusable).
548−func (l *List) Focus() {
549− l.focused = true
550− l.focusSelectedItem()
551−}
552−
553−// Blur blurs the list and the selected item (if focusable).
554−func (l *List) Blur() {
555− l.focused = false
556− l.blurSelectedItem()
557−}
558−
559−// Focused returns whether the list is focused.
560−func (l *List) Focused() bool {
561− return l.focused
562−}
563−
564−// SetSelected sets the selected item by ID.
565−func (l *List) SetSelected(idx int) {
566− if idx < 0 || idx >= len(l.items) {
567− return
568− }
569− if l.selectedIdx == idx {
570− return
571− }
572−
573− prevIdx := l.selectedIdx
574− l.selectedIdx = idx
575−
576− // Update focus states if list is focused
577− if l.focused {
578− if prevIdx >= 0 && prevIdx < len(l.items) {
579− if f, ok := l.items[prevIdx].(Focusable); ok {
580− f.Blur()
581− l.dirtyItems[prevIdx] = true
582− }
583− }
584−
585− if f, ok := l.items[idx].(Focusable); ok {
586− f.Focus()
587− l.dirtyItems[idx] = true
588− }
589− }
590−}
591−
592−// SelectFirst selects the first item in the list.
593−func (l *List) SelectFirst() {
594− l.SetSelected(0)
595−}
596−
597−// SelectLast selects the last item in the list.
598−func (l *List) SelectLast() {
599− l.SetSelected(len(l.items) - 1)
600−}
601−
602−// SelectNextWrap selects the next item in the list (wraps to beginning).
603−// When the list is focused, skips non-focusable items.
604−func (l *List) SelectNextWrap() {
605− l.selectNext(true)
606−}
607−
608−// SelectNext selects the next item in the list (no wrap).
609−// When the list is focused, skips non-focusable items.
610−func (l *List) SelectNext() {
611− l.selectNext(false)
612−}
613−
614−func (l *List) selectNext(wrap bool) {
615− if len(l.items) == 0 {
616− return
617− }
618−
619− startIdx := l.selectedIdx
620− for i := 0; i < len(l.items); i++ {
621− var nextIdx int
622− if wrap {
623− nextIdx = (startIdx + 1 + i) % len(l.items)
624− } else {
625− nextIdx = startIdx + 1 + i
626− if nextIdx >= len(l.items) {
627− return
628− }
629− }
630−
631− // If list is focused and item is not focusable, skip it
632− if l.focused {
633− if _, ok := l.items[nextIdx].(Focusable); !ok {
634− continue
635− }
636− }
637−
638− // Select and scroll to this item
639− l.SetSelected(nextIdx)
640− return
641− }
642−}
643−
644−// SelectPrevWrap selects the previous item in the list (wraps to end).
645−// When the list is focused, skips non-focusable items.
646−func (l *List) SelectPrevWrap() {
647− l.selectPrev(true)
648−}
649−
650−// SelectPrev selects the previous item in the list (no wrap).
651−// When the list is focused, skips non-focusable items.
652−func (l *List) SelectPrev() {
653− l.selectPrev(false)
654−}
655−
656−func (l *List) selectPrev(wrap bool) {
657− if len(l.items) == 0 {
658− return
659− }
660−
661− startIdx := l.selectedIdx
662− for i := 0; i < len(l.items); i++ {
663− var prevIdx int
664− if wrap {
665− prevIdx = (startIdx - 1 - i + len(l.items)) % len(l.items)
666− } else {
667− prevIdx = startIdx - 1 - i
668− if prevIdx < 0 {
669− return
670− }
671− }
672−
673− // If list is focused and item is not focusable, skip it
674− if l.focused {
675− if _, ok := l.items[prevIdx].(Focusable); !ok {
676− continue
677− }
678− }
679−
680− // Select and scroll to this item
681− l.SetSelected(prevIdx)
682− return
683− }
684−}
685−
686−// SelectedItem returns the currently selected item, or nil if none.
687−func (l *List) SelectedItem() Item {
688− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
689− return nil
690− }
691− return l.items[l.selectedIdx]
692−}
693−
694−// SelectedIndex returns the index of the currently selected item, or -1 if none.
695−func (l *List) SelectedIndex() int {
696− return l.selectedIdx
697−}
698−
699−// AtBottom returns whether the viewport is scrolled to the bottom.
700−func (l *List) AtBottom() bool {
701− l.ensureBuilt()
702− return l.offset >= l.totalHeight-l.height
703−}
704−
705−// AtTop returns whether the viewport is scrolled to the top.
706−func (l *List) AtTop() bool {
707− return l.offset <= 0
708−}
709−
710−// ScrollBy scrolls the viewport by the given number of lines.
711−// Positive values scroll down, negative scroll up.
712−func (l *List) ScrollBy(deltaLines int) {
713− l.offset += deltaLines
714− l.clampOffset()
715−}
716−
717−// ScrollToTop scrolls to the top of the list.
718−func (l *List) ScrollToTop() {
719− l.offset = 0
720−}
721−
722−// ScrollToBottom scrolls to the bottom of the list.
723−func (l *List) ScrollToBottom() {
724− l.ensureBuilt()
725− if l.totalHeight > l.height {
726− l.offset = l.totalHeight - l.height
727− } else {
728− l.offset = 0
729− }
730−}
731−
732−// ScrollToItem scrolls to make the item with the given ID visible.
733−func (l *List) ScrollToItem(idx int) {
734− l.ensureBuilt()
735− pos := l.itemPositions[idx]
736− itemStart := pos.startLine
737− itemEnd := pos.startLine + pos.height
738− viewStart := l.offset
739− viewEnd := l.offset + l.height
740−
741− // Check if item is already fully visible
742− if itemStart >= viewStart && itemEnd <= viewEnd {
743− return
744− }
745−
746− // Scroll to show item
747− if itemStart < viewStart {
748− l.offset = itemStart
749− } else if itemEnd > viewEnd {
750− l.offset = itemEnd - l.height
751− }
752−
753− l.clampOffset()
754−}
755−
756−// ScrollToSelected scrolls to make the selected item visible.
757−func (l *List) ScrollToSelected() {
758− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
759− return
760− }
761− l.ScrollToItem(l.selectedIdx)
762−}
763−
764−// Offset returns the current scroll offset.
765−func (l *List) Offset() int {
766− return l.offset
767−}
768−
769−// TotalHeight returns the total height of all items including gaps.
770−func (l *List) TotalHeight() int {
771− return l.totalHeight
772−}
773−
774−// SelectFirstInView selects the first item that is fully visible in the viewport.
775−func (l *List) SelectFirstInView() {
776− l.ensureBuilt()
777−
778− viewportStart := l.offset
779− viewportEnd := l.offset + l.height
780−
781− for i := range l.items {
782− pos := l.itemPositions[i]
783−
784− // Check if item is fully within viewport bounds
785− if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
786− l.SetSelected(i)
787− return
788− }
789− }
790−}
791−
792−// SelectLastInView selects the last item that is fully visible in the viewport.
793−func (l *List) SelectLastInView() {
794− l.ensureBuilt()
795−
796− viewportStart := l.offset
797− viewportEnd := l.offset + l.height
798−
799− for i := len(l.items) - 1; i >= 0; i-- {
800− pos := l.itemPositions[i]
801−
802− // Check if item is fully within viewport bounds
803− if pos.startLine >= viewportStart && (pos.startLine+pos.height) <= viewportEnd {
804− l.SetSelected(i)
805− return
806− }
807− }
808−}
809−
810−// SelectedItemInView returns true if the selected item is currently visible in the viewport.
811−func (l *List) SelectedItemInView() bool {
812− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
813− return false
814− }
815−
816− // Get selected item ID and position
817− pos := l.itemPositions[l.selectedIdx]
818−
819− // Check if item is within viewport bounds
820− viewportStart := l.offset
821− viewportEnd := l.offset + l.height
822−
823− // Item is visible if any part of it overlaps with the viewport
824− return pos.startLine < viewportEnd && (pos.startLine+pos.height) > viewportStart
825−}
826−
827−// clampOffset ensures offset is within valid bounds.
828−func (l *List) clampOffset() {
829− l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height)
830−}
831−
832−// focusSelectedItem focuses the currently selected item if it's focusable.
833−func (l *List) focusSelectedItem() {
834− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
835− return
836− }
837−
838− item := l.items[l.selectedIdx]
839− if f, ok := item.(Focusable); ok {
840− f.Focus()
841− l.dirtyItems[l.selectedIdx] = true
842− }
843−}
844−
845−// blurSelectedItem blurs the currently selected item if it's focusable.
846−func (l *List) blurSelectedItem() {
847− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
848− return
849− }
850−
851− item := l.items[l.selectedIdx]
852− if f, ok := item.(Focusable); ok {
853− f.Blur()
854− l.dirtyItems[l.selectedIdx] = true
855− }
856−}
857−
858−// HandleMouseDown handles mouse button press events.
859−// x and y are viewport-relative coordinates (0,0 = top-left of visible area).
860−// Returns true if the event was handled.
861−func (l *List) HandleMouseDown(x, y int) bool {
862− l.ensureBuilt()
863−
864− // Convert viewport y to master buffer y
865− bufferY := y + l.offset
866−
867− // Find which item was clicked
868− itemIdx, itemY := l.findItemAtPosition(bufferY)
869− if itemIdx < 0 {
870− return false
871− }
872−
873− // Calculate x position within item content
874− // For now, x is just the viewport x coordinate
875− // Items can interpret this as character offset in their content
876−
877− l.mouseDown = true
878− l.mouseDownItem = itemIdx
879− l.mouseDownX = x
880− l.mouseDownY = itemY
881− l.mouseDragItem = itemIdx
882− l.mouseDragX = x
883− l.mouseDragY = itemY
884−
885− // Select the clicked item
886− l.SetSelected(itemIdx)
887−
888− return true
889−}
890−
891−// HandleMouseDrag handles mouse drag events during selection.
892−// x and y are viewport-relative coordinates.
893−// Returns true if the event was handled.
894−func (l *List) HandleMouseDrag(x, y int) bool {
895− if !l.mouseDown {
896− return false
897− }
898−
899− l.ensureBuilt()
900−
901− // Convert viewport y to master buffer y
902− bufferY := y + l.offset
903−
904− // Find which item we're dragging over
905− itemIdx, itemY := l.findItemAtPosition(bufferY)
906− if itemIdx < 0 {
907− return false
908− }
909−
910− l.mouseDragItem = itemIdx
911− l.mouseDragX = x
912− l.mouseDragY = itemY
913−
914− // Update highlight if item supports it
915− l.updateHighlight()
916−
917− return true
918−}
919−
920−// HandleMouseUp handles mouse button release events.
921−// Returns true if the event was handled.
922−func (l *List) HandleMouseUp(x, y int) bool {
923− if !l.mouseDown {
924− return false
925− }
926−
927− l.mouseDown = false
928−
929− // Final highlight update
930− l.updateHighlight()
931−
932− return true
933−}
934−
935−// ClearHighlight clears any active text highlighting.
936−func (l *List) ClearHighlight() {
937− for i, item := range l.items {
938− if h, ok := item.(Highlightable); ok {
939− h.SetHighlight(-1, -1, -1, -1)
940− l.dirtyItems[i] = true
941− }
942− }
943− l.mouseDownItem = -1
944− l.mouseDragItem = -1
945−}
946−
947−// findItemAtPosition finds the item at the given master buffer y coordinate.
948−// Returns the item index and the y offset within that item. It returns -1, -1
949−// if no item is found.
950−func (l *List) findItemAtPosition(bufferY int) (itemIdx int, itemY int) {
951− if bufferY < 0 || bufferY >= l.totalHeight {
952− return -1, -1
953− }
954−
955− // Linear search through items to find which one contains this y
956− // This could be optimized with binary search if needed
957− for i := range l.items {
958− pos := l.itemPositions[i]
959− if bufferY >= pos.startLine && bufferY < pos.startLine+pos.height {
960− return i, bufferY - pos.startLine
961− }
962− }
963−
964− return -1, -1
965−}
966−
967−// updateHighlight updates the highlight range for highlightable items.
968−// Supports highlighting across multiple items and respects drag direction.
969−func (l *List) updateHighlight() {
970− if l.mouseDownItem < 0 {
971− return
972− }
973−
974− // Get start and end item indices
975− downItemIdx := l.mouseDownItem
976− dragItemIdx := l.mouseDragItem
977−
978− // Determine selection direction
979− draggingDown := dragItemIdx > downItemIdx ||
980− (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) ||
981− (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX)
982−
983− // Determine actual start and end based on direction
984− var startItemIdx, endItemIdx int
985− var startLine, startCol, endLine, endCol int
986−
987− if draggingDown {
988− // Normal forward selection
989− startItemIdx = downItemIdx
990− endItemIdx = dragItemIdx
991− startLine = l.mouseDownY
992− startCol = l.mouseDownX
993− endLine = l.mouseDragY
994− endCol = l.mouseDragX
995− } else {
996− // Backward selection (dragging up)
997− startItemIdx = dragItemIdx
998− endItemIdx = downItemIdx
999− startLine = l.mouseDragY
1000− startCol = l.mouseDragX
1001− endLine = l.mouseDownY
1002− endCol = l.mouseDownX
1003− }
1004−
1005− // Clear all highlights first
1006− for i, item := range l.items {
1007− if h, ok := item.(Highlightable); ok {
1008− h.SetHighlight(-1, -1, -1, -1)
1009− l.dirtyItems[i] = true
1010− }
1011− }
1012−
1013− // Highlight all items in range
1014− for idx := startItemIdx; idx <= endItemIdx; idx++ {
1015− item, ok := l.items[idx].(Highlightable)
1016− if !ok {
1017− continue
1018− }
1019−
1020− if idx == startItemIdx && idx == endItemIdx {
1021− // Single item selection
1022− item.SetHighlight(startLine, startCol, endLine, endCol)
1023− } else if idx == startItemIdx {
1024− // First item - from start position to end of item
1025− pos := l.itemPositions[idx]
1026− item.SetHighlight(startLine, startCol, pos.height-1, 9999) // 9999 = end of line
1027− } else if idx == endItemIdx {
1028− // Last item - from start of item to end position
1029− item.SetHighlight(0, 0, endLine, endCol)
1030− } else {
1031− // Middle item - fully highlighted
1032− pos := l.itemPositions[idx]
1033− item.SetHighlight(0, 0, pos.height-1, 9999)
1034− }
1035−
1036− l.dirtyItems[idx] = true
1037− }
1038−}
1039−
1040−// GetHighlightedText returns the plain text content of all highlighted regions
1041−// across items, without any styling. Returns empty string if no highlights exist.
1042−func (l *List) GetHighlightedText() string {
1043− l.ensureBuilt()
1044−
1045− if l.masterBuffer == nil {
1046− return ""
1047− }
1048−
1049− var result strings.Builder
1050−
1051− // Iterate through items to find highlighted ones
1052− for i, item := range l.items {
1053− h, ok := item.(Highlightable)
1054− if !ok {
1055− continue
1056− }
1057−
1058− startLine, startCol, endLine, endCol := h.GetHighlight()
1059− if startLine < 0 {
1060− continue
1061− }
1062−
1063− pos := l.itemPositions[i]
1064−
1065− // Extract text from highlighted region in master buffer
1066− for y := startLine; y <= endLine && y < pos.height; y++ {
1067− bufferY := pos.startLine + y
1068− if bufferY >= l.masterBuffer.Height() {
1069− break
1070− }
1071−
1072− line := l.masterBuffer.Line(bufferY)
1073−
1074− // Determine column range for this line
1075− colStart := 0
1076− if y == startLine {
1077− colStart = startCol
1078− }
1079−
1080− colEnd := len(line)
1081− if y == endLine {
1082− colEnd = min(endCol, len(line))
1083− }
1084−
1085− // Track last non-empty position to trim trailing spaces
1086− lastContentX := -1
1087− for x := colStart; x < colEnd && x < len(line); x++ {
1088− cell := line.At(x)
1089− if cell == nil || cell.IsZero() {
1090− continue
1091− }
1092− if cell.Content != "" && cell.Content != " " {
1093− lastContentX = x
1094− }
1095− }
1096−
1097− // Extract text from cells using String() method, up to last content
1098− endX := colEnd
1099− if lastContentX >= 0 {
1100− endX = lastContentX + 1
1101− }
1102−
1103− for x := colStart; x < endX && x < len(line); x++ {
1104− cell := line.At(x)
1105− if cell == nil || cell.IsZero() {
1106− continue
1107− }
1108− result.WriteString(cell.String())
1109− }
1110−
1111− // Add newline between lines (but not after the last line)
1112− if y < endLine && y < pos.height-1 {
1113− result.WriteRune('\n')
1114− }
1115− }
1116−
1117− // Add newline between items if there are more highlighted items
1118− if result.Len() > 0 {
1119− result.WriteRune('\n')
1120− }
1121− }
1122−
1123− // Trim trailing newline if present
1124− text := result.String()
1125− return strings.TrimSuffix(text, "\n")
1126−}
internal/ui/list/list_test.godeleted−578
@@ -1,578 +0,0 @@
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("Item 1"),
15− NewStringItem("Item 2"),
16− NewStringItem("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("Item 1"),
34− NewStringItem("Item 2"),
35− NewStringItem("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("Item 1"),
58− }
59−
60− l := New(items...)
61− l.AppendItem(NewStringItem("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−
68−func TestListDeleteItem(t *testing.T) {
69− items := []Item{
70− NewStringItem("Item 1"),
71− NewStringItem("Item 2"),
72− NewStringItem("Item 3"),
73− }
74−
75− l := New(items...)
76− l.DeleteItem(2)
77−
78− if len(l.items) != 2 {
79− t.Errorf("expected 2 items after delete, got %d", len(l.items))
80− }
81−}
82−
83−func TestListUpdateItem(t *testing.T) {
84− items := []Item{
85− NewStringItem("Item 1"),
86− NewStringItem("Item 2"),
87− }
88−
89− l := New(items...)
90− l.SetSize(80, 10)
91−
92− // Update item
93− newItem := NewStringItem("Updated Item 2")
94− l.UpdateItem(1, newItem)
95−
96− if l.items[1].(*StringItem).content != "Updated Item 2" {
97− t.Errorf("expected updated content, got '%s'", l.items[1].(*StringItem).content)
98− }
99−}
100−
101−func TestListSelection(t *testing.T) {
102− items := []Item{
103− NewStringItem("Item 1"),
104− NewStringItem("Item 2"),
105− NewStringItem("Item 3"),
106− }
107−
108− l := New(items...)
109− l.SetSelected(0)
110−
111− if l.SelectedIndex() != 0 {
112− t.Errorf("expected selected index 0, got %d", l.SelectedIndex())
113− }
114−
115− l.SelectNext()
116− if l.SelectedIndex() != 1 {
117− t.Errorf("expected selected index 1 after SelectNext, got %d", l.SelectedIndex())
118− }
119−
120− l.SelectPrev()
121− if l.SelectedIndex() != 0 {
122− t.Errorf("expected selected index 0 after SelectPrev, got %d", l.SelectedIndex())
123− }
124−}
125−
126−func TestListScrolling(t *testing.T) {
127− items := []Item{
128− NewStringItem("Item 1"),
129− NewStringItem("Item 2"),
130− NewStringItem("Item 3"),
131− NewStringItem("Item 4"),
132− NewStringItem("Item 5"),
133− }
134−
135− l := New(items...)
136− l.SetSize(80, 2) // Small viewport
137−
138− // Draw to initialize the master buffer
139− screen := uv.NewScreenBuffer(80, 2)
140− area := uv.Rect(0, 0, 80, 2)
141− l.Draw(&screen, area)
142−
143− if l.Offset() != 0 {
144− t.Errorf("expected initial offset 0, got %d", l.Offset())
145− }
146−
147− l.ScrollBy(2)
148− if l.Offset() != 2 {
149− t.Errorf("expected offset 2 after ScrollBy(2), got %d", l.Offset())
150− }
151−
152− l.ScrollToTop()
153− if l.Offset() != 0 {
154− t.Errorf("expected offset 0 after ScrollToTop, got %d", l.Offset())
155− }
156−}
157−
158−// FocusableTestItem is a test item that implements Focusable.
159−type FocusableTestItem struct {
160− id string
161− content string
162− focused bool
163−}
164−
165−func (f *FocusableTestItem) ID() string {
166− return f.id
167−}
168−
169−func (f *FocusableTestItem) Height(width int) int {
170− return 1
171−}
172−
173−func (f *FocusableTestItem) Draw(scr uv.Screen, area uv.Rectangle) {
174− prefix := "[ ]"
175− if f.focused {
176− prefix = "[X]"
177− }
178− content := prefix + " " + f.content
179− styled := uv.NewStyledString(content)
180− styled.Draw(scr, area)
181−}
182−
183−func (f *FocusableTestItem) Focus() {
184− f.focused = true
185−}
186−
187−func (f *FocusableTestItem) Blur() {
188− f.focused = false
189−}
190−
191−func (f *FocusableTestItem) IsFocused() bool {
192− return f.focused
193−}
194−
195−func TestListFocus(t *testing.T) {
196− items := []Item{
197− &FocusableTestItem{id: "1", content: "Item 1"},
198− &FocusableTestItem{id: "2", content: "Item 2"},
199− }
200−
201− l := New(items...)
202− l.SetSize(80, 10)
203− l.SetSelected(0)
204−
205− // Focus the list
206− l.Focus()
207−
208− if !l.Focused() {
209− t.Error("expected list to be focused")
210− }
211−
212− // Check if selected item is focused
213− selectedItem := l.SelectedItem().(*FocusableTestItem)
214− if !selectedItem.IsFocused() {
215− t.Error("expected selected item to be focused")
216− }
217−
218− // Select next and check focus changes
219− l.SelectNext()
220− if selectedItem.IsFocused() {
221− t.Error("expected previous item to be blurred")
222− }
223−
224− newSelectedItem := l.SelectedItem().(*FocusableTestItem)
225− if !newSelectedItem.IsFocused() {
226− t.Error("expected new selected item to be focused")
227− }
228−
229− // Blur the list
230− l.Blur()
231− if l.Focused() {
232− t.Error("expected list to be blurred")
233− }
234−}
235−
236−// TestFocusNavigationAfterAppendingToViewportHeight reproduces the bug:
237−// Append items until viewport is full, select last, then navigate backwards.
238−func TestFocusNavigationAfterAppendingToViewportHeight(t *testing.T) {
239− t.Parallel()
240−
241− focusStyle := lipgloss.NewStyle().
242− Border(lipgloss.RoundedBorder()).
243− BorderForeground(lipgloss.Color("86"))
244−
245− blurStyle := lipgloss.NewStyle().
246− Border(lipgloss.RoundedBorder()).
247− BorderForeground(lipgloss.Color("240"))
248−
249− // Start with one item
250− items := []Item{
251− NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
252− }
253−
254− l := New(items...)
255− l.SetSize(20, 15) // 15 lines viewport height
256− l.SetSelected(0)
257− l.Focus()
258−
259− // Initial draw to build buffer
260− screen := uv.NewScreenBuffer(20, 15)
261− l.Draw(&screen, uv.Rect(0, 0, 20, 15))
262−
263− // Append items until we exceed viewport height
264− // Each focusable item with border is 5 lines tall
265− for i := 2; i <= 4; i++ {
266− item := NewStringItem("Item "+string(rune('0'+i))).WithFocusStyles(&focusStyle, &blurStyle)
267− l.AppendItem(item)
268− }
269−
270− // Select the last item
271− l.SetSelected(3)
272−
273− // Draw
274− screen = uv.NewScreenBuffer(20, 15)
275− l.Draw(&screen, uv.Rect(0, 0, 20, 15))
276− output := screen.Render()
277−
278− t.Logf("After selecting last item:\n%s", output)
279− require.Contains(t, output, "38;5;86", "expected focus color on last item")
280−
281− // Now navigate backwards
282− l.SelectPrev()
283−
284− screen = uv.NewScreenBuffer(20, 15)
285− l.Draw(&screen, uv.Rect(0, 0, 20, 15))
286− output = screen.Render()
287−
288− t.Logf("After SelectPrev:\n%s", output)
289− require.Contains(t, output, "38;5;86", "expected focus color after SelectPrev")
290−
291− // Navigate backwards again
292− l.SelectPrev()
293−
294− screen = uv.NewScreenBuffer(20, 15)
295− l.Draw(&screen, uv.Rect(0, 0, 20, 15))
296− output = screen.Render()
297−
298− t.Logf("After second SelectPrev:\n%s", output)
299− require.Contains(t, output, "38;5;86", "expected focus color after second SelectPrev")
300−}
301−
302−func TestFocusableItemUpdate(t *testing.T) {
303− // Create styles with borders
304− focusStyle := lipgloss.NewStyle().
305− Border(lipgloss.RoundedBorder()).
306− BorderForeground(lipgloss.Color("86"))
307−
308− blurStyle := lipgloss.NewStyle().
309− Border(lipgloss.RoundedBorder()).
310− BorderForeground(lipgloss.Color("240"))
311−
312− // Create a focusable item
313− item := NewStringItem("Test Item").WithFocusStyles(&focusStyle, &blurStyle)
314−
315− // Initially not focused - render with blur style
316− screen1 := uv.NewScreenBuffer(20, 5)
317− area := uv.Rect(0, 0, 20, 5)
318− item.Draw(&screen1, area)
319− output1 := screen1.Render()
320−
321− // Focus the item
322− item.Focus()
323−
324− // Render again - should show focus style
325− screen2 := uv.NewScreenBuffer(20, 5)
326− item.Draw(&screen2, area)
327− output2 := screen2.Render()
328−
329− // Outputs should be different (different border colors)
330− if output1 == output2 {
331− t.Error("expected different output after focusing, but got same output")
332− }
333−
334− // Verify focus state
335− if !item.IsFocused() {
336− t.Error("expected item to be focused")
337− }
338−
339− // Blur the item
340− item.Blur()
341−
342− // Render again - should show blur style again
343− screen3 := uv.NewScreenBuffer(20, 5)
344− item.Draw(&screen3, area)
345− output3 := screen3.Render()
346−
347− // Output should match original blur output
348− if output1 != output3 {
349− t.Error("expected same output after blurring as initial state")
350− }
351−
352− // Verify blur state
353− if item.IsFocused() {
354− t.Error("expected item to be blurred")
355− }
356−}
357−
358−func TestFocusableItemHeightWithBorder(t *testing.T) {
359− // Create a style with a border (adds 2 to vertical height)
360− borderStyle := lipgloss.NewStyle().
361− Border(lipgloss.RoundedBorder())
362−
363− // Item without styles has height 1
364− plainItem := NewStringItem("Test")
365− plainHeight := plainItem.Height(20)
366− if plainHeight != 1 {
367− t.Errorf("expected plain height 1, got %d", plainHeight)
368− }
369−
370− // Item with border should add border height (2 lines)
371− item := NewStringItem("Test").WithFocusStyles(&borderStyle, &borderStyle)
372− itemHeight := item.Height(20)
373− expectedHeight := 1 + 2 // content + border
374− if itemHeight != expectedHeight {
375− t.Errorf("expected height %d (content 1 + border 2), got %d",
376− expectedHeight, itemHeight)
377− }
378−}
379−
380−func TestFocusableItemInList(t *testing.T) {
381− focusStyle := lipgloss.NewStyle().
382− Border(lipgloss.RoundedBorder()).
383− BorderForeground(lipgloss.Color("86"))
384−
385− blurStyle := lipgloss.NewStyle().
386− Border(lipgloss.RoundedBorder()).
387− BorderForeground(lipgloss.Color("240"))
388−
389− // Create list with focusable items
390− items := []Item{
391− NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
392− NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
393− NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
394− }
395−
396− l := New(items...)
397− l.SetSize(80, 20)
398− l.SetSelected(0)
399−
400− // Focus the list
401− l.Focus()
402−
403− // First item should be focused
404− firstItem := items[0].(*StringItem)
405− if !firstItem.IsFocused() {
406− t.Error("expected first item to be focused after focusing list")
407− }
408−
409− // Render to ensure changes are visible
410− output1 := l.Render()
411− if !strings.Contains(output1, "Item 1") {
412− t.Error("expected output to contain first item")
413− }
414−
415− // Select second item
416− l.SetSelected(1)
417−
418− // First item should be blurred, second focused
419− if firstItem.IsFocused() {
420− t.Error("expected first item to be blurred after changing selection")
421− }
422−
423− secondItem := items[1].(*StringItem)
424− if !secondItem.IsFocused() {
425− t.Error("expected second item to be focused after selection")
426− }
427−
428− // Render again - should show updated focus
429− output2 := l.Render()
430− if !strings.Contains(output2, "Item 2") {
431− t.Error("expected output to contain second item")
432− }
433−
434− // Outputs should be different
435− if output1 == output2 {
436− t.Error("expected different output after selection change")
437− }
438−}
439−
440−func TestFocusableItemWithNilStyles(t *testing.T) {
441− // Test with nil styles - should render inner item directly
442− item := NewStringItem("Plain Item").WithFocusStyles(nil, nil)
443−
444− // Height should be based on content (no border since styles are nil)
445− itemHeight := item.Height(20)
446− if itemHeight != 1 {
447− t.Errorf("expected height 1 (no border), got %d", itemHeight)
448− }
449−
450− // Draw should work without styles
451− screen := uv.NewScreenBuffer(20, 5)
452− area := uv.Rect(0, 0, 20, 5)
453− item.Draw(&screen, area)
454− output := screen.Render()
455−
456− // Should contain the inner content
457− if !strings.Contains(output, "Plain Item") {
458− t.Error("expected output to contain inner item content")
459− }
460−
461− // Focus/blur should still work but not change appearance
462− item.Focus()
463− screen2 := uv.NewScreenBuffer(20, 5)
464− item.Draw(&screen2, area)
465− output2 := screen2.Render()
466−
467− // Output should be identical since no styles
468− if output != output2 {
469− t.Error("expected same output with nil styles whether focused or not")
470− }
471−
472− if !item.IsFocused() {
473− t.Error("expected item to be focused")
474− }
475−}
476−
477−func TestFocusableItemWithOnlyFocusStyle(t *testing.T) {
478− // Test with only focus style (blur is nil)
479− focusStyle := lipgloss.NewStyle().
480− Border(lipgloss.RoundedBorder()).
481− BorderForeground(lipgloss.Color("86"))
482−
483− item := NewStringItem("Test").WithFocusStyles(&focusStyle, nil)
484−
485− // When not focused, should use nil blur style (no border)
486− screen1 := uv.NewScreenBuffer(20, 5)
487− area := uv.Rect(0, 0, 20, 5)
488− item.Draw(&screen1, area)
489− output1 := screen1.Render()
490−
491− // Focus the item
492− item.Focus()
493− screen2 := uv.NewScreenBuffer(20, 5)
494− item.Draw(&screen2, area)
495− output2 := screen2.Render()
496−
497− // Outputs should be different (focused has border, blurred doesn't)
498− if output1 == output2 {
499− t.Error("expected different output when only focus style is set")
500− }
501−}
502−
503−func TestFocusableItemLastLineNotEaten(t *testing.T) {
504− // Create focusable items with borders
505− focusStyle := lipgloss.NewStyle().
506− Padding(1).
507− Border(lipgloss.RoundedBorder()).
508− BorderForeground(lipgloss.Color("86"))
509−
510− blurStyle := lipgloss.NewStyle().
511− BorderForeground(lipgloss.Color("240"))
512−
513− items := []Item{
514− NewStringItem("Item 1").WithFocusStyles(&focusStyle, &blurStyle),
515− Gap,
516− NewStringItem("Item 2").WithFocusStyles(&focusStyle, &blurStyle),
517− Gap,
518− NewStringItem("Item 3").WithFocusStyles(&focusStyle, &blurStyle),
519− Gap,
520− NewStringItem("Item 4").WithFocusStyles(&focusStyle, &blurStyle),
521− Gap,
522− NewStringItem("Item 5").WithFocusStyles(&focusStyle, &blurStyle),
523− }
524−
525− // Items with padding(1) and border are 5 lines each
526− // Viewport of 10 lines fits exactly 2 items
527− l := New()
528− l.SetSize(20, 10)
529−
530− for _, item := range items {
531− l.AppendItem(item)
532− }
533−
534− // Focus the list
535− l.Focus()
536−
537− // Select last item
538− l.SetSelected(len(items) - 1)
539−
540− // Scroll to bottom
541− l.ScrollToBottom()
542−
543− output := l.Render()
544−
545− t.Logf("Output:\n%s", output)
546− t.Logf("Offset: %d, Total height: %d", l.offset, l.TotalHeight())
547−
548− // Select previous - will skip gaps and go to Item 4
549− l.SelectPrev()
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− // Should show items 3 (unfocused), 4 (focused), and part of 5 (unfocused)
557− if !strings.Contains(output, "Item 3") {
558− t.Error("expected output to contain 'Item 3'")
559− }
560− if !strings.Contains(output, "Item 4") {
561− t.Error("expected output to contain 'Item 4'")
562− }
563− if !strings.Contains(output, "Item 5") {
564− t.Error("expected output to contain 'Item 5'")
565− }
566−
567− // Count bottom borders - should have 1 (focused item 4)
568− bottomBorderCount := 0
569− for _, line := range strings.Split(output, "\r\n") {
570− if strings.Contains(line, "╰") || strings.Contains(line, "└") {
571− bottomBorderCount++
572− }
573− }
574−
575− if bottomBorderCount != 1 {
576− t.Errorf("expected 1 bottom border (focused item 4), got %d", bottomBorderCount)
577− }
578−}
internal/ui/list/simplelist.godeleted−972
@@ -1,972 +0,0 @@
1−package list
2−
3−import (
4− "strings"
5−
6− tea "charm.land/bubbletea/v2"
7− "charm.land/lipgloss/v2"
8− uv "github.com/charmbracelet/ultraviolet"
9− "github.com/charmbracelet/x/exp/ordered"
10−)
11−
12−const maxGapSize = 100
13−
14−var newlineBuffer = strings.Repeat("\n", maxGapSize)
15−
16−// SimpleList is a string-based list with virtual scrolling behavior.
17−// Based on exp/list but simplified for our needs.
18−type SimpleList struct {
19− // Viewport dimensions.
20− width, height int
21−
22− // Scroll offset (in lines from top).
23− offset int
24−
25− // Items.
26− items []Item
27− itemIDs map[string]int // ID -> index mapping
28−
29− // Rendered content (all items stacked).
30− rendered string
31− renderedHeight int // Total height of rendered content in lines
32− lineOffsets []int // Byte offsets for each line (for fast slicing)
33−
34− // Rendered item metadata.
35− renderedItems map[string]renderedItem
36−
37− // Selection.
38− selectedIdx int
39− focused bool
40−
41− // Focus tracking.
42− prevSelectedIdx int
43−
44− // Mouse/highlight state.
45− mouseDown bool
46− mouseDownItem int
47− mouseDownX int
48− mouseDownY int // viewport-relative Y
49− mouseDragItem int
50− mouseDragX int
51− mouseDragY int // viewport-relative Y
52− selectionStartLine int
53− selectionStartCol int
54− selectionEndLine int
55− selectionEndCol int
56−
57− // Configuration.
58− gap int // Gap between items in lines
59−}
60−
61−type renderedItem struct {
62− view string
63− height int
64− start int // Start line in rendered content
65− end int // End line in rendered content
66−}
67−
68−// NewSimpleList creates a new simple list.
69−func NewSimpleList(items ...Item) *SimpleList {
70− l := &SimpleList{
71− items: items,
72− itemIDs: make(map[string]int, len(items)),
73− renderedItems: make(map[string]renderedItem),
74− selectedIdx: -1,
75− prevSelectedIdx: -1,
76− gap: 0,
77− selectionStartLine: -1,
78− selectionStartCol: -1,
79− selectionEndLine: -1,
80− selectionEndCol: -1,
81− }
82−
83− // Build ID map.
84− for i, item := range items {
85− if idItem, ok := item.(interface{ ID() string }); ok {
86− l.itemIDs[idItem.ID()] = i
87− }
88− }
89−
90− return l
91−}
92−
93−// Init initializes the list (Bubbletea lifecycle).
94−func (l *SimpleList) Init() tea.Cmd {
95− return l.render()
96−}
97−
98−// Update handles messages (Bubbletea lifecycle).
99−func (l *SimpleList) Update(msg tea.Msg) (*SimpleList, tea.Cmd) {
100− return l, nil
101−}
102−
103−// View returns the visible viewport (Bubbletea lifecycle).
104−func (l *SimpleList) View() string {
105− if l.height <= 0 || l.width <= 0 {
106− return ""
107− }
108−
109− start, end := l.viewPosition()
110− viewStart := max(0, start)
111− viewEnd := end
112−
113− if viewStart > viewEnd {
114− return ""
115− }
116−
117− view := l.getLines(viewStart, viewEnd)
118−
119− // Apply width/height constraints.
120− view = lipgloss.NewStyle().
121− Height(l.height).
122− Width(l.width).
123− Render(view)
124−
125− // Apply highlighting if active.
126− if l.hasSelection() {
127− return l.renderSelection(view)
128− }
129−
130− return view
131−}
132−
133−// viewPosition returns the start and end line indices for the viewport.
134−func (l *SimpleList) viewPosition() (int, int) {
135− start := max(0, l.offset)
136− end := min(l.offset+l.height-1, l.renderedHeight-1)
137− start = min(start, end)
138− return start, end
139−}
140−
141−// getLines returns lines [start, end] from rendered content.
142−func (l *SimpleList) getLines(start, end int) string {
143− if len(l.lineOffsets) == 0 || start >= len(l.lineOffsets) {
144− return ""
145− }
146−
147− if end >= len(l.lineOffsets) {
148− end = len(l.lineOffsets) - 1
149− }
150− if start > end {
151− return ""
152− }
153−
154− startOffset := l.lineOffsets[start]
155− var endOffset int
156− if end+1 < len(l.lineOffsets) {
157− endOffset = l.lineOffsets[end+1] - 1 // Exclude newline
158− } else {
159− endOffset = len(l.rendered)
160− }
161−
162− if startOffset >= len(l.rendered) {
163− return ""
164− }
165− endOffset = min(endOffset, len(l.rendered))
166−
167− return l.rendered[startOffset:endOffset]
168−}
169−
170−// render rebuilds the rendered content from all items.
171−func (l *SimpleList) render() tea.Cmd {
172− if l.width <= 0 || l.height <= 0 || len(l.items) == 0 {
173− return nil
174− }
175−
176− // Set default selection if none.
177− if l.selectedIdx < 0 && len(l.items) > 0 {
178− l.selectedIdx = 0
179− }
180−
181− // Handle focus changes.
182− var focusCmd tea.Cmd
183− if l.focused {
184− focusCmd = l.focusSelectedItem()
185− } else {
186− focusCmd = l.blurSelectedItem()
187− }
188−
189− // Render all items.
190− var b strings.Builder
191− currentLine := 0
192−
193− for i, item := range l.items {
194− // Render item.
195− view := l.renderItem(item)
196− height := lipgloss.Height(view)
197−
198− // Store metadata.
199− rItem := renderedItem{
200− view: view,
201− height: height,
202− start: currentLine,
203− end: currentLine + height - 1,
204− }
205−
206− if idItem, ok := item.(interface{ ID() string }); ok {
207− l.renderedItems[idItem.ID()] = rItem
208− }
209−
210− // Append to rendered content.
211− b.WriteString(view)
212−
213− // Add gap after item (except last).
214− gap := l.gap
215− if i == len(l.items)-1 {
216− gap = 0
217− }
218−
219− if gap > 0 {
220− if gap <= maxGapSize {
221− b.WriteString(newlineBuffer[:gap])
222− } else {
223− b.WriteString(strings.Repeat("\n", gap))
224− }
225− }
226−
227− currentLine += height + gap
228− }
229−
230− l.setRendered(b.String())
231−
232− // Scroll to selected item.
233− if l.focused && l.selectedIdx >= 0 {
234− l.scrollToSelection()
235− }
236−
237− return focusCmd
238−}
239−
240−// renderItem renders a single item.
241−func (l *SimpleList) renderItem(item Item) string {
242− // Create a buffer for the item.
243− buf := uv.NewScreenBuffer(l.width, 1000) // Max height
244− area := uv.Rect(0, 0, l.width, 1000)
245− item.Draw(&buf, area)
246−
247− // Find actual height.
248− height := l.measureBufferHeight(&buf)
249− if height == 0 {
250− height = 1
251− }
252−
253− // Render to string.
254− return buf.Render()
255−}
256−
257−// measureBufferHeight finds the actual content height in a buffer.
258−func (l *SimpleList) measureBufferHeight(buf *uv.ScreenBuffer) int {
259− height := buf.Height()
260−
261− // Scan from bottom up to find last non-empty line.
262− for y := height - 1; y >= 0; y-- {
263− line := buf.Line(y)
264− if l.lineHasContent(line) {
265− return y + 1
266− }
267− }
268−
269− return 0
270−}
271−
272−// lineHasContent checks if a line has any non-empty cells.
273−func (l *SimpleList) lineHasContent(line uv.Line) bool {
274− for x := 0; x < len(line); x++ {
275− cell := line.At(x)
276− if cell != nil && !cell.IsZero() && cell.Content != "" && cell.Content != " " {
277− return true
278− }
279− }
280− return false
281−}
282−
283−// setRendered updates the rendered content and caches line offsets.
284−func (l *SimpleList) setRendered(rendered string) {
285− l.rendered = rendered
286− l.renderedHeight = lipgloss.Height(rendered)
287−
288− // Build line offset cache.
289− if len(rendered) > 0 {
290− l.lineOffsets = make([]int, 0, l.renderedHeight)
291− l.lineOffsets = append(l.lineOffsets, 0)
292−
293− offset := 0
294− for {
295− idx := strings.IndexByte(rendered[offset:], '\n')
296− if idx == -1 {
297− break
298− }
299− offset += idx + 1
300− l.lineOffsets = append(l.lineOffsets, offset)
301− }
302− } else {
303− l.lineOffsets = nil
304− }
305−}
306−
307−// scrollToSelection scrolls to make the selected item visible.
308−func (l *SimpleList) scrollToSelection() {
309− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
310− return
311− }
312−
313− // Get selected item metadata.
314− var rItem *renderedItem
315− if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
316− if ri, ok := l.renderedItems[idItem.ID()]; ok {
317− rItem = &ri
318− }
319− }
320−
321− if rItem == nil {
322− return
323− }
324−
325− start, end := l.viewPosition()
326−
327− // Already visible.
328− if rItem.start >= start && rItem.end <= end {
329− return
330− }
331−
332− // Item is above viewport - scroll up.
333− if rItem.start < start {
334− l.offset = rItem.start
335− return
336− }
337−
338− // Item is below viewport - scroll down.
339− if rItem.end > end {
340− l.offset = max(0, rItem.end-l.height+1)
341− }
342−}
343−
344−// Focus/blur management.
345−
346−func (l *SimpleList) focusSelectedItem() tea.Cmd {
347− if l.selectedIdx < 0 || !l.focused {
348− return nil
349− }
350−
351− var cmds []tea.Cmd
352−
353− // Blur previous.
354− if l.prevSelectedIdx >= 0 && l.prevSelectedIdx != l.selectedIdx && l.prevSelectedIdx < len(l.items) {
355− if f, ok := l.items[l.prevSelectedIdx].(Focusable); ok && f.IsFocused() {
356− f.Blur()
357− }
358− }
359−
360− // Focus current.
361− if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
362− if f, ok := l.items[l.selectedIdx].(Focusable); ok && !f.IsFocused() {
363− f.Focus()
364− }
365− }
366−
367− l.prevSelectedIdx = l.selectedIdx
368− return tea.Batch(cmds...)
369−}
370−
371−func (l *SimpleList) blurSelectedItem() tea.Cmd {
372− if l.selectedIdx < 0 || l.focused {
373− return nil
374− }
375−
376− if l.selectedIdx >= 0 && l.selectedIdx < len(l.items) {
377− if f, ok := l.items[l.selectedIdx].(Focusable); ok && f.IsFocused() {
378− f.Blur()
379− }
380− }
381−
382− return nil
383−}
384−
385−// Public API.
386−
387−// SetSize sets the viewport dimensions.
388−func (l *SimpleList) SetSize(width, height int) tea.Cmd {
389− oldWidth := l.width
390− l.width = width
391− l.height = height
392−
393− if oldWidth != width {
394− // Width changed - need to re-render.
395− return l.render()
396− }
397−
398− return nil
399−}
400−
401−// Width returns the viewport width.
402−func (l *SimpleList) Width() int {
403− return l.width
404−}
405−
406−// Height returns the viewport height.
407−func (l *SimpleList) Height() int {
408− return l.height
409−}
410−
411−// GetSize returns the viewport dimensions.
412−func (l *SimpleList) GetSize() (int, int) {
413− return l.width, l.height
414−}
415−
416−// Items returns all items.
417−func (l *SimpleList) Items() []Item {
418− return l.items
419−}
420−
421−// Len returns the number of items.
422−func (l *SimpleList) Len() int {
423− return len(l.items)
424−}
425−
426−// SetItems replaces all items.
427−func (l *SimpleList) SetItems(items []Item) tea.Cmd {
428− l.items = items
429− l.itemIDs = make(map[string]int, len(items))
430− l.renderedItems = make(map[string]renderedItem)
431− l.selectedIdx = -1
432− l.prevSelectedIdx = -1
433− l.offset = 0
434−
435− // Build ID map.
436− for i, item := range items {
437− if idItem, ok := item.(interface{ ID() string }); ok {
438− l.itemIDs[idItem.ID()] = i
439− }
440− }
441−
442− return l.render()
443−}
444−
445−// AppendItem adds an item to the end.
446−func (l *SimpleList) AppendItem(item Item) tea.Cmd {
447− l.items = append(l.items, item)
448−
449− if idItem, ok := item.(interface{ ID() string }); ok {
450− l.itemIDs[idItem.ID()] = len(l.items) - 1
451− }
452−
453− return l.render()
454−}
455−
456−// PrependItem adds an item to the beginning.
457−func (l *SimpleList) PrependItem(item Item) tea.Cmd {
458− l.items = append([]Item{item}, l.items...)
459−
460− // Rebuild ID map (indices shifted).
461− l.itemIDs = make(map[string]int, len(l.items))
462− for i, it := range l.items {
463− if idItem, ok := it.(interface{ ID() string }); ok {
464− l.itemIDs[idItem.ID()] = i
465− }
466− }
467−
468− // Adjust selection.
469− if l.selectedIdx >= 0 {
470− l.selectedIdx++
471− }
472− if l.prevSelectedIdx >= 0 {
473− l.prevSelectedIdx++
474− }
475−
476− return l.render()
477−}
478−
479−// UpdateItem replaces an item at the given index.
480−func (l *SimpleList) UpdateItem(idx int, item Item) tea.Cmd {
481− if idx < 0 || idx >= len(l.items) {
482− return nil
483− }
484−
485− l.items[idx] = item
486−
487− // Update ID map.
488− if idItem, ok := item.(interface{ ID() string }); ok {
489− l.itemIDs[idItem.ID()] = idx
490− }
491−
492− return l.render()
493−}
494−
495−// DeleteItem removes an item at the given index.
496−func (l *SimpleList) DeleteItem(idx int) tea.Cmd {
497− if idx < 0 || idx >= len(l.items) {
498− return nil
499− }
500−
501− l.items = append(l.items[:idx], l.items[idx+1:]...)
502−
503− // Rebuild ID map (indices shifted).
504− l.itemIDs = make(map[string]int, len(l.items))
505− for i, it := range l.items {
506− if idItem, ok := it.(interface{ ID() string }); ok {
507− l.itemIDs[idItem.ID()] = i
508− }
509− }
510−
511− // Adjust selection.
512− if l.selectedIdx == idx {
513− if idx > 0 {
514− l.selectedIdx = idx - 1
515− } else if len(l.items) > 0 {
516− l.selectedIdx = 0
517− } else {
518− l.selectedIdx = -1
519− }
520− } else if l.selectedIdx > idx {
521− l.selectedIdx--
522− }
523−
524− if l.prevSelectedIdx == idx {
525− l.prevSelectedIdx = -1
526− } else if l.prevSelectedIdx > idx {
527− l.prevSelectedIdx--
528− }
529−
530− return l.render()
531−}
532−
533−// Focus sets the list as focused.
534−func (l *SimpleList) Focus() tea.Cmd {
535− l.focused = true
536− return l.render()
537−}
538−
539−// Blur removes focus from the list.
540−func (l *SimpleList) Blur() tea.Cmd {
541− l.focused = false
542− return l.render()
543−}
544−
545−// Focused returns whether the list is focused.
546−func (l *SimpleList) Focused() bool {
547− return l.focused
548−}
549−
550−// Selection.
551−
552−// Selected returns the currently selected item index.
553−func (l *SimpleList) Selected() int {
554− return l.selectedIdx
555−}
556−
557−// SelectedIndex returns the currently selected item index.
558−func (l *SimpleList) SelectedIndex() int {
559− return l.selectedIdx
560−}
561−
562−// SelectedItem returns the currently selected item.
563−func (l *SimpleList) SelectedItem() Item {
564− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
565− return nil
566− }
567− return l.items[l.selectedIdx]
568−}
569−
570−// SetSelected sets the selected item by index.
571−func (l *SimpleList) SetSelected(idx int) tea.Cmd {
572− if idx < -1 || idx >= len(l.items) {
573− return nil
574− }
575−
576− if l.selectedIdx == idx {
577− return nil
578− }
579−
580− l.prevSelectedIdx = l.selectedIdx
581− l.selectedIdx = idx
582−
583− return l.render()
584−}
585−
586−// SelectFirst selects the first item.
587−func (l *SimpleList) SelectFirst() tea.Cmd {
588− return l.SetSelected(0)
589−}
590−
591−// SelectLast selects the last item.
592−func (l *SimpleList) SelectLast() tea.Cmd {
593− if len(l.items) > 0 {
594− return l.SetSelected(len(l.items) - 1)
595− }
596− return nil
597−}
598−
599−// SelectNext selects the next item.
600−func (l *SimpleList) SelectNext() tea.Cmd {
601− if l.selectedIdx < len(l.items)-1 {
602− return l.SetSelected(l.selectedIdx + 1)
603− }
604− return nil
605−}
606−
607−// SelectPrev selects the previous item.
608−func (l *SimpleList) SelectPrev() tea.Cmd {
609− if l.selectedIdx > 0 {
610− return l.SetSelected(l.selectedIdx - 1)
611− }
612− return nil
613−}
614−
615−// SelectNextWrap selects the next item (wraps to beginning).
616−func (l *SimpleList) SelectNextWrap() tea.Cmd {
617− if len(l.items) == 0 {
618− return nil
619− }
620− nextIdx := (l.selectedIdx + 1) % len(l.items)
621− return l.SetSelected(nextIdx)
622−}
623−
624−// SelectPrevWrap selects the previous item (wraps to end).
625−func (l *SimpleList) SelectPrevWrap() tea.Cmd {
626− if len(l.items) == 0 {
627− return nil
628− }
629− prevIdx := (l.selectedIdx - 1 + len(l.items)) % len(l.items)
630− return l.SetSelected(prevIdx)
631−}
632−
633−// SelectFirstInView selects the first fully visible item.
634−func (l *SimpleList) SelectFirstInView() tea.Cmd {
635− if len(l.items) == 0 {
636− return nil
637− }
638−
639− start, end := l.viewPosition()
640−
641− for i := 0; i < len(l.items); i++ {
642− if idItem, ok := l.items[i].(interface{ ID() string }); ok {
643− if rItem, ok := l.renderedItems[idItem.ID()]; ok {
644− // Check if fully visible.
645− if rItem.start >= start && rItem.end <= end {
646− return l.SetSelected(i)
647− }
648− }
649− }
650− }
651−
652− return nil
653−}
654−
655−// SelectLastInView selects the last fully visible item.
656−func (l *SimpleList) SelectLastInView() tea.Cmd {
657− if len(l.items) == 0 {
658− return nil
659− }
660−
661− start, end := l.viewPosition()
662−
663− for i := len(l.items) - 1; i >= 0; i-- {
664− if idItem, ok := l.items[i].(interface{ ID() string }); ok {
665− if rItem, ok := l.renderedItems[idItem.ID()]; ok {
666− // Check if fully visible.
667− if rItem.start >= start && rItem.end <= end {
668− return l.SetSelected(i)
669− }
670− }
671− }
672− }
673−
674− return nil
675−}
676−
677−// SelectedItemInView returns true if the selected item is visible.
678−func (l *SimpleList) SelectedItemInView() bool {
679− if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
680− return false
681− }
682−
683− var rItem *renderedItem
684− if idItem, ok := l.items[l.selectedIdx].(interface{ ID() string }); ok {
685− if ri, ok := l.renderedItems[idItem.ID()]; ok {
686− rItem = &ri
687− }
688− }
689−
690− if rItem == nil {
691− return false
692− }
693−
694− start, end := l.viewPosition()
695− return rItem.start < end && rItem.end > start
696−}
697−
698−// Scrolling.
699−
700−// Offset returns the current scroll offset.
701−func (l *SimpleList) Offset() int {
702− return l.offset
703−}
704−
705−// TotalHeight returns the total height of all items.
706−func (l *SimpleList) TotalHeight() int {
707− return l.renderedHeight
708−}
709−
710−// ScrollBy scrolls by the given number of lines.
711−func (l *SimpleList) ScrollBy(deltaLines int) tea.Cmd {
712− l.offset += deltaLines
713− l.clampOffset()
714− return nil
715−}
716−
717−// ScrollToTop scrolls to the top.
718−func (l *SimpleList) ScrollToTop() tea.Cmd {
719− l.offset = 0
720− return nil
721−}
722−
723−// ScrollToBottom scrolls to the bottom.
724−func (l *SimpleList) ScrollToBottom() tea.Cmd {
725− l.offset = l.renderedHeight - l.height
726− l.clampOffset()
727− return nil
728−}
729−
730−// AtTop returns true if scrolled to the top.
731−func (l *SimpleList) AtTop() bool {
732− return l.offset <= 0
733−}
734−
735−// AtBottom returns true if scrolled to the bottom.
736−func (l *SimpleList) AtBottom() bool {
737− return l.offset >= l.renderedHeight-l.height
738−}
739−
740−// ScrollToItem scrolls to make an item visible.
741−func (l *SimpleList) ScrollToItem(idx int) tea.Cmd {
742− if idx < 0 || idx >= len(l.items) {
743− return nil
744− }
745−
746− var rItem *renderedItem
747− if idItem, ok := l.items[idx].(interface{ ID() string }); ok {
748− if ri, ok := l.renderedItems[idItem.ID()]; ok {
749− rItem = &ri
750− }
751− }
752−
753− if rItem == nil {
754− return nil
755− }
756−
757− start, end := l.viewPosition()
758−
759− // Already visible.
760− if rItem.start >= start && rItem.end <= end {
761− return nil
762− }
763−
764− // Above viewport.
765− if rItem.start < start {
766− l.offset = rItem.start
767− return nil
768− }
769−
770− // Below viewport.
771− if rItem.end > end {
772− l.offset = rItem.end - l.height + 1
773− l.clampOffset()
774− }
775−
776− return nil
777−}
778−
779−// ScrollToSelected scrolls to the selected item.
780−func (l *SimpleList) ScrollToSelected() tea.Cmd {
781− if l.selectedIdx >= 0 {
782− return l.ScrollToItem(l.selectedIdx)
783− }
784− return nil
785−}
786−
787−func (l *SimpleList) clampOffset() {
788− maxOffset := l.renderedHeight - l.height
789− if maxOffset < 0 {
790− maxOffset = 0
791− }
792− l.offset = ordered.Clamp(l.offset, 0, maxOffset)
793−}
794−
795−// Mouse and highlighting.
796−
797−// HandleMouseDown handles mouse press.
798−func (l *SimpleList) HandleMouseDown(x, y int) bool {
799− if x < 0 || y < 0 || x >= l.width || y >= l.height {
800− return false
801− }
802−
803− // Find item at viewport y.
804− contentY := l.offset + y
805− itemIdx := l.findItemAtLine(contentY)
806−
807− if itemIdx < 0 {
808− return false
809− }
810−
811− l.mouseDown = true
812− l.mouseDownItem = itemIdx
813− l.mouseDownX = x
814− l.mouseDownY = y
815− l.mouseDragItem = itemIdx
816− l.mouseDragX = x
817− l.mouseDragY = y
818−
819− // Start selection.
820− l.selectionStartLine = y
821− l.selectionStartCol = x
822− l.selectionEndLine = y
823− l.selectionEndCol = x
824−
825− // Select item.
826− l.SetSelected(itemIdx)
827−
828− return true
829−}
830−
831−// HandleMouseDrag handles mouse drag.
832−func (l *SimpleList) HandleMouseDrag(x, y int) bool {
833− if !l.mouseDown {
834− return false
835− }
836−
837− // Clamp coordinates to viewport bounds.
838− clampedX := max(0, min(x, l.width-1))
839− clampedY := max(0, min(y, l.height-1))
840−
841− if clampedY >= 0 && clampedY < l.height {
842− contentY := l.offset + clampedY
843− itemIdx := l.findItemAtLine(contentY)
844− if itemIdx >= 0 {
845− l.mouseDragItem = itemIdx
846− l.mouseDragX = clampedX
847− l.mouseDragY = clampedY
848− }
849− }
850−
851− // Update selection end (clamped to viewport).
852− l.selectionEndLine = clampedY
853− l.selectionEndCol = clampedX
854−
855− return true
856−}
857−
858−// HandleMouseUp handles mouse release.
859−func (l *SimpleList) HandleMouseUp(x, y int) bool {
860− if !l.mouseDown {
861− return false
862− }
863−
864− l.mouseDown = false
865−
866− // Final selection update (clamped to viewport).
867− clampedX := max(0, min(x, l.width-1))
868− clampedY := max(0, min(y, l.height-1))
869− l.selectionEndLine = clampedY
870− l.selectionEndCol = clampedX
871−
872− return true
873−}
874−
875−// ClearHighlight clears the selection.
876−func (l *SimpleList) ClearHighlight() {
877− l.selectionStartLine = -1
878− l.selectionStartCol = -1
879− l.selectionEndLine = -1
880− l.selectionEndCol = -1
881− l.mouseDown = false
882− l.mouseDownItem = -1
883− l.mouseDragItem = -1
884−}
885−
886−// GetHighlightedText returns the selected text.
887−func (l *SimpleList) GetHighlightedText() string {
888− if !l.hasSelection() {
889− return ""
890− }
891−
892− return l.renderSelection(l.View())
893−}
894−
895−func (l *SimpleList) hasSelection() bool {
896− return l.selectionEndCol != l.selectionStartCol || l.selectionEndLine != l.selectionStartLine
897−}
898−
899−// renderSelection applies highlighting to the view and extracts text.
900−func (l *SimpleList) renderSelection(view string) string {
901− // Create a screen buffer spanning the viewport.
902− buf := uv.NewScreenBuffer(l.width, l.height)
903− area := uv.Rect(0, 0, l.width, l.height)
904− uv.NewStyledString(view).Draw(&buf, area)
905−
906− // Calculate selection bounds.
907− startLine := min(l.selectionStartLine, l.selectionEndLine)
908− endLine := max(l.selectionStartLine, l.selectionEndLine)
909− startCol := l.selectionStartCol
910− endCol := l.selectionEndCol
911−
912− if l.selectionEndLine < l.selectionStartLine {
913− startCol = l.selectionEndCol
914− endCol = l.selectionStartCol
915− }
916−
917− // Apply highlighting.
918− for y := startLine; y <= endLine && y < l.height; y++ {
919− if y >= buf.Height() {
920− break
921− }
922−
923− line := buf.Line(y)
924−
925− // Determine column range for this line.
926− colStart := 0
927− if y == startLine {
928− colStart = startCol
929− }
930−
931− colEnd := len(line)
932− if y == endLine {
933− colEnd = min(endCol, len(line))
934− }
935−
936− // Apply highlight style.
937− for x := colStart; x < colEnd && x < len(line); x++ {
938− cell := line.At(x)
939− if cell != nil && !cell.IsZero() {
940− cell = cell.Clone()
941− // Toggle reverse for highlight.
942− if cell.Style.Attrs&uv.AttrReverse != 0 {
943− cell.Style.Attrs &^= uv.AttrReverse
944− } else {
945− cell.Style.Attrs |= uv.AttrReverse
946− }
947− buf.SetCell(x, y, cell)
948− }
949− }
950− }
951−
952− return buf.Render()
953−}
954−
955−// findItemAtLine finds the item index at the given content line.
956−func (l *SimpleList) findItemAtLine(line int) int {
957− for i := 0; i < len(l.items); i++ {
958− if idItem, ok := l.items[i].(interface{ ID() string }); ok {
959− if rItem, ok := l.renderedItems[idItem.ID()]; ok {
960− if line >= rItem.start && line <= rItem.end {
961− return i
962− }
963− }
964− }
965− }
966− return -1
967−}
968−
969−// Render returns the view (for compatibility).
970−func (l *SimpleList) Render() string {
971− return l.View()
972−}
internal/ui/model/items.go+4−350
@@ -2,14 +2,11 @@ package model
22
33import (
44 "fmt"
5− "image"
6− "log/slog"
75 "path/filepath"
86 "strings"
97 "time"
108
119 "charm.land/lipgloss/v2"
12− uv "github.com/charmbracelet/ultraviolet"
1310 "github.com/charmbracelet/x/ansi"
1411
1512 "github.com/charmbracelet/crush/internal/config"
@@ -111,8 +108,6 @@ func (m *MessageContentItem) Render(width int) string {
111108
112109// ToolCallItem represents a rendered tool call with its header and content.
113110type ToolCallItem struct {
114− BaseFocusable
115− BaseHighlightable
116111 id string
117112 toolCall message.ToolCall
118113 toolResult message.ToolResult
@@ -139,7 +134,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To
139134 maxWidth: 120,
140135 sty: sty,
141136 }
142− t.InitHighlight()
143137 return t
144138}
145139
@@ -156,18 +150,12 @@ func (t *ToolCallItem) ID() string {
156150
157151// FocusStyle returns the focus style.
158152func (t *ToolCallItem) FocusStyle() lipgloss.Style {
159− if t.focusStyle != nil {
160− return *t.focusStyle
161− }
162− return lipgloss.Style{}
153+ return t.sty.Chat.Message.ToolCallFocused
163154}
164155
165156// BlurStyle returns the blur style.
166157func (t *ToolCallItem) BlurStyle() lipgloss.Style {
167− if t.blurStyle != nil {
168− return *t.blurStyle
169− }
170− return lipgloss.Style{}
158+ return t.sty.Chat.Message.ToolCallBlurred
171159}
172160
173161// HighlightStyle returns the highlight style.
@@ -189,24 +177,10 @@ func (t *ToolCallItem) Render(width int) string {
189177
190178 rendered := toolrender.Render(ctx)
191179 return rendered
192−
193− // return t.RenderWithHighlight(rendered, width, style)
194−}
195−
196−// SetHighlight implements list.Highlightable.
197−func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
198− t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
199−}
200−
201−// UpdateResult updates the tool result and invalidates the cache if needed.
202−func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
203− t.toolResult = result
204180}
205181
206182// AttachmentItem represents a file attachment in a user message.
207183type AttachmentItem struct {
208− BaseFocusable
209− BaseHighlightable
210184 id string
211185 filename string
212186 path string
@@ -221,7 +195,6 @@ func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *Attachmen
221195 path: path,
222196 sty: sty,
223197 }
224− a.InitHighlight()
225198 return a
226199}
227200
@@ -232,18 +205,12 @@ func (a *AttachmentItem) ID() string {
232205
233206// FocusStyle returns the focus style.
234207func (a *AttachmentItem) FocusStyle() lipgloss.Style {
235− if a.focusStyle != nil {
236− return *a.focusStyle
237− }
238− return lipgloss.Style{}
208+ return a.sty.Chat.Message.AssistantFocused
239209}
240210
241211// BlurStyle returns the blur style.
242212func (a *AttachmentItem) BlurStyle() lipgloss.Style {
243− if a.blurStyle != nil {
244− return *a.blurStyle
245− }
246− return lipgloss.Style{}
213+ return a.sty.Chat.Message.AssistantBlurred
247214}
248215
249216// HighlightStyle returns the highlight style.
@@ -410,16 +377,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
410377 return items
411378 }
412379
413− // Create base styles for the message
414− var focusStyle, blurStyle lipgloss.Style
415− if msg.Role == message.User {
416− focusStyle = sty.Chat.Message.UserFocused
417− blurStyle = sty.Chat.Message.UserBlurred
418− } else {
419− focusStyle = sty.Chat.Message.AssistantFocused
420− blurStyle = sty.Chat.Message.AssistantBlurred
421− }
422−
423380 // Process user messages
424381 if msg.Role == message.User {
425382 // Add main text content
@@ -444,8 +401,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
444401 attachment.Path,
445402 sty,
446403 )
447− item.SetHighlightStyle(ToStyler(sty.TextSelection))
448− item.SetFocusStyles(&focusStyle, &blurStyle)
449404 items = append(items, item)
450405 }
451406
@@ -566,11 +521,6 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
566521 sty,
567522 )
568523
569− item.SetHighlightStyle(ToStyler(sty.TextSelection))
570−
571− // Tool calls use muted style with optional focus border
572− item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
573−
574524 items = append(items, item)
575525 }
576526
@@ -596,299 +546,3 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu
596546 }
597547 return resultMap
598548}
599−
600−// BaseFocusable provides common focus state and styling for items.
601−// Embed this type to add focus behavior to any item.
602−type BaseFocusable struct {
603− focused bool
604− focusStyle *lipgloss.Style
605− blurStyle *lipgloss.Style
606−}
607−
608−// Focus implements Focusable interface.
609−func (b *BaseFocusable) Focus(width int, content string) string {
610− if b.focusStyle != nil {
611− return b.focusStyle.Render(content)
612− }
613− return content
614−}
615−
616−// Blur implements Focusable interface.
617−func (b *BaseFocusable) Blur(width int, content string) string {
618− if b.blurStyle != nil {
619− return b.blurStyle.Render(content)
620− }
621− return content
622−}
623−
624−// Focus implements Focusable interface.
625−// func (b *BaseFocusable) Focus() {
626−// b.focused = true
627−// }
628−
629−// Blur implements Focusable interface.
630−// func (b *BaseFocusable) Blur() {
631−// b.focused = false
632−// }
633−
634−// Focused implements Focusable interface.
635−func (b *BaseFocusable) Focused() bool {
636− return b.focused
637−}
638−
639−// HasFocusStyles returns true if both focus and blur styles are configured.
640−func (b *BaseFocusable) HasFocusStyles() bool {
641− return b.focusStyle != nil && b.blurStyle != nil
642−}
643−
644−// CurrentStyle returns the current style based on focus state.
645−// Returns nil if no styles are configured, or if the current state's style is nil.
646−func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
647− if b.focused {
648− return b.focusStyle
649− }
650− return b.blurStyle
651−}
652−
653−// SetFocusStyles sets the focus and blur styles.
654−func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
655− b.focusStyle = focusStyle
656− b.blurStyle = blurStyle
657−}
658−
659−// CellStyler defines a function that styles a [uv.Style].
660−type CellStyler func(uv.Style) uv.Style
661−
662−// BaseHighlightable provides common highlight state for items.
663−// Embed this type to add highlight behavior to any item.
664−type BaseHighlightable struct {
665− highlightStartLine int
666− highlightStartCol int
667− highlightEndLine int
668− highlightEndCol int
669− highlightStyle CellStyler
670−}
671−
672−// SetHighlight implements Highlightable interface.
673−func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
674− b.highlightStartLine = startLine
675− b.highlightStartCol = startCol
676− b.highlightEndLine = endLine
677− b.highlightEndCol = endCol
678−}
679−
680−// GetHighlight implements Highlightable interface.
681−func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
682− return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
683−}
684−
685−// HasHighlight returns true if a highlight region is set.
686−func (b *BaseHighlightable) HasHighlight() bool {
687− return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
688− b.highlightEndLine >= 0 || b.highlightEndCol >= 0
689−}
690−
691−// SetHighlightStyle sets the style function used for highlighting.
692−func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
693− b.highlightStyle = style
694−}
695−
696−// GetHighlightStyle returns the current highlight style function.
697−func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
698− return b.highlightStyle
699−}
700−
701−// InitHighlight initializes the highlight fields with default values.
702−func (b *BaseHighlightable) InitHighlight() {
703− b.highlightStartLine = -1
704− b.highlightStartCol = -1
705− b.highlightEndLine = -1
706− b.highlightEndCol = -1
707− b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true))
708−}
709−
710−// Highlight implements Highlightable interface.
711−func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string {
712− b.SetHighlight(startLine, startCol, endLine, endCol)
713− return b.RenderWithHighlight(content, width, nil)
714−}
715−
716−// RenderWithHighlight renders content with optional focus styling and highlighting.
717−// This is a helper that combines common rendering logic for all items.
718−// The content parameter should be the raw rendered content before focus styling.
719−// The style parameter should come from CurrentStyle() and may be nil.
720−func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
721− // Apply focus/blur styling if configured
722− rendered := content
723− if style != nil {
724− rendered = style.Render(rendered)
725− }
726−
727− if !b.HasHighlight() {
728− return rendered
729− }
730−
731− height := lipgloss.Height(rendered)
732−
733− // Create temp buffer to draw content with highlighting
734− tempBuf := uv.NewScreenBuffer(width, height)
735−
736− // Draw the rendered content to temp buffer
737− styled := uv.NewStyledString(rendered)
738− styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
739−
740− // Apply highlighting if active
741− b.ApplyHighlight(&tempBuf, width, height, style)
742−
743− return tempBuf.Render()
744−}
745−
746−// ApplyHighlight applies highlighting to a screen buffer.
747−// This should be called after drawing content to the buffer.
748−func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
749− if b.highlightStartLine < 0 {
750− return
751− }
752−
753− var (
754− topMargin, topBorder, topPadding int
755− rightMargin, rightBorder, rightPadding int
756− bottomMargin, bottomBorder, bottomPadding int
757− leftMargin, leftBorder, leftPadding int
758− )
759− if style != nil {
760− topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
761− topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
762− style.GetBorderRightSize(),
763− style.GetBorderBottomSize(),
764− style.GetBorderLeftSize()
765− topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
766− }
767−
768− slog.Info("Applying highlight",
769− "highlightStartLine", b.highlightStartLine,
770− "highlightStartCol", b.highlightStartCol,
771− "highlightEndLine", b.highlightEndLine,
772− "highlightEndCol", b.highlightEndCol,
773− "width", width,
774− "height", height,
775− "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
776− "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
777− "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
778− )
779−
780− // Calculate content area offsets
781− contentArea := image.Rectangle{
782− Min: image.Point{
783− X: leftMargin + leftBorder + leftPadding,
784− Y: topMargin + topBorder + topPadding,
785− },
786− Max: image.Point{
787− X: width - (rightMargin + rightBorder + rightPadding),
788− Y: height - (bottomMargin + bottomBorder + bottomPadding),
789− },
790− }
791−
792− for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
793− if y >= buf.Height() {
794− break
795− }
796−
797− line := buf.Line(y)
798−
799− // Determine column range for this line
800− startCol := 0
801− if y == b.highlightStartLine {
802− startCol = min(b.highlightStartCol, len(line))
803− }
804−
805− endCol := len(line)
806− if y == b.highlightEndLine {
807− endCol = min(b.highlightEndCol, len(line))
808− }
809−
810− // Track last non-empty position as we go
811− lastContentX := -1
812−
813− // Single pass: check content and track last non-empty position
814− for x := startCol; x < endCol; x++ {
815− cell := line.At(x)
816− if cell == nil {
817− continue
818− }
819−
820− // Update last content position if non-empty
821− if cell.Content != "" && cell.Content != " " {
822− lastContentX = x
823− }
824− }
825−
826− // Only apply highlight up to last content position
827− highlightEnd := endCol
828− if lastContentX >= 0 {
829− highlightEnd = lastContentX + 1
830− } else if lastContentX == -1 {
831− highlightEnd = startCol // No content on this line
832− }
833−
834− // Apply highlight style only to cells with content
835− for x := startCol; x < highlightEnd; x++ {
836− if !image.Pt(x, y).In(contentArea) {
837− continue
838− }
839− cell := line.At(x)
840− cell.Style = b.highlightStyle(cell.Style)
841− }
842− }
843−}
844−
845−// ToStyler converts a [lipgloss.Style] to a [CellStyler].
846−func ToStyler(lgStyle lipgloss.Style) CellStyler {
847− return func(uv.Style) uv.Style {
848− return ToStyle(lgStyle)
849− }
850−}
851−
852−// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
853−func ToStyle(lgStyle lipgloss.Style) uv.Style {
854− var uvStyle uv.Style
855−
856− // Colors are already color.Color
857− uvStyle.Fg = lgStyle.GetForeground()
858− uvStyle.Bg = lgStyle.GetBackground()
859−
860− // Build attributes using bitwise OR
861− var attrs uint8
862−
863− if lgStyle.GetBold() {
864− attrs |= uv.AttrBold
865− }
866−
867− if lgStyle.GetItalic() {
868− attrs |= uv.AttrItalic
869− }
870−
871− if lgStyle.GetUnderline() {
872− uvStyle.Underline = uv.UnderlineSingle
873− }
874−
875− if lgStyle.GetStrikethrough() {
876− attrs |= uv.AttrStrikethrough
877− }
878−
879− if lgStyle.GetFaint() {
880− attrs |= uv.AttrFaint
881− }
882−
883− if lgStyle.GetBlink() {
884− attrs |= uv.AttrBlink
885− }
886−
887− if lgStyle.GetReverse() {
888− attrs |= uv.AttrReverse
889− }
890−
891− uvStyle.Attrs = attrs
892−
893− return uvStyle
894−}