1package list
2
3import (
4 "strings"
5
6 "charm.land/lipgloss/v2"
7 "github.com/charmbracelet/glamour/v2"
8 "github.com/charmbracelet/glamour/v2/ansi"
9 uv "github.com/charmbracelet/ultraviolet"
10 "github.com/charmbracelet/ultraviolet/screen"
11)
12
13// Item represents a list item that can draw itself to a UV buffer.
14// Items implement the uv.Drawable interface.
15type Item interface {
16 uv.Drawable
17
18 // ID returns unique identifier for this item.
19 ID() string
20
21 // Height returns the item's height in lines for the given width.
22 // This allows items to calculate height based on text wrapping and available space.
23 Height(width int) int
24}
25
26// Focusable is an optional interface for items that support focus.
27// When implemented, items can change appearance when focused (borders, colors, etc).
28type Focusable interface {
29 Focus()
30 Blur()
31 IsFocused() bool
32}
33
34// BaseFocusable provides common focus state and styling for items.
35// Embed this type to add focus behavior to any item.
36type BaseFocusable struct {
37 focused bool
38 focusStyle *lipgloss.Style
39 blurStyle *lipgloss.Style
40}
41
42// Focus implements Focusable interface.
43func (b *BaseFocusable) Focus() {
44 b.focused = true
45}
46
47// Blur implements Focusable interface.
48func (b *BaseFocusable) Blur() {
49 b.focused = false
50}
51
52// IsFocused implements Focusable interface.
53func (b *BaseFocusable) IsFocused() bool {
54 return b.focused
55}
56
57// HasFocusStyles returns true if both focus and blur styles are configured.
58func (b *BaseFocusable) HasFocusStyles() bool {
59 return b.focusStyle != nil && b.blurStyle != nil
60}
61
62// CurrentStyle returns the current style based on focus state.
63// Returns nil if no styles are configured, or if the current state's style is nil.
64func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
65 if b.focused {
66 return b.focusStyle
67 }
68 return b.blurStyle
69}
70
71// SetFocusStyles sets the focus and blur styles.
72func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
73 b.focusStyle = focusStyle
74 b.blurStyle = blurStyle
75}
76
77// StringItem is a simple string-based item with optional text wrapping.
78// It caches rendered content by width for efficient repeated rendering.
79// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
80type StringItem struct {
81 BaseFocusable
82 id string
83 content string // Raw content string (may contain ANSI styles)
84 wrap bool // Whether to wrap text
85
86 // Cache for rendered content at specific widths
87 // Key: width, Value: string
88 cache map[int]string
89}
90
91// NewStringItem creates a new string item with the given ID and content.
92func NewStringItem(id, content string) *StringItem {
93 return &StringItem{
94 id: id,
95 content: content,
96 wrap: false,
97 cache: make(map[int]string),
98 }
99}
100
101// NewWrappingStringItem creates a new string item that wraps text to fit width.
102func NewWrappingStringItem(id, content string) *StringItem {
103 return &StringItem{
104 id: id,
105 content: content,
106 wrap: true,
107 cache: make(map[int]string),
108 }
109}
110
111// WithFocusStyles sets the focus and blur styles for the string item.
112// If both styles are non-nil, the item will implement Focusable.
113func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
114 s.SetFocusStyles(focusStyle, blurStyle)
115 return s
116}
117
118// ID implements Item.
119func (s *StringItem) ID() string {
120 return s.id
121}
122
123// Height implements Item.
124func (s *StringItem) Height(width int) int {
125 // Calculate content width if we have styles
126 contentWidth := width
127 if style := s.CurrentStyle(); style != nil {
128 hFrameSize := style.GetHorizontalFrameSize()
129 if hFrameSize > 0 {
130 contentWidth -= hFrameSize
131 }
132 }
133
134 var lines int
135 if !s.wrap {
136 // No wrapping - height is just the number of newlines + 1
137 lines = strings.Count(s.content, "\n") + 1
138 } else {
139 // Use lipgloss.Wrap to wrap the content and count lines
140 // This preserves ANSI styles and is much faster than rendering to a buffer
141 wrapped := lipgloss.Wrap(s.content, contentWidth, "")
142 lines = strings.Count(wrapped, "\n") + 1
143 }
144
145 // Add vertical frame size if we have styles
146 if style := s.CurrentStyle(); style != nil {
147 lines += style.GetVerticalFrameSize()
148 }
149
150 return lines
151}
152
153// Draw implements Item and uv.Drawable.
154func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
155 width := area.Dx()
156
157 // Check cache first
158 content, ok := s.cache[width]
159 if !ok {
160 // Not cached - create and cache
161 content = s.content
162 if s.wrap {
163 // Wrap content using lipgloss
164 content = lipgloss.Wrap(s.content, width, "")
165 }
166 s.cache[width] = content
167 }
168
169 // Apply focus/blur styling if configured
170 if style := s.CurrentStyle(); style != nil {
171 content = style.Width(width).Render(content)
172 }
173
174 // Draw the styled string
175 styled := uv.NewStyledString(content)
176 styled.Draw(scr, area)
177}
178
179// MarkdownItem renders markdown content using Glamour.
180// It caches all rendered content by width for efficient repeated rendering.
181// The wrap width is capped at 120 cells by default to ensure readable line lengths.
182// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
183type MarkdownItem struct {
184 BaseFocusable
185 id string
186 markdown string // Raw markdown content
187 styleConfig *ansi.StyleConfig // Optional style configuration
188 maxWidth int // Maximum wrap width (default 120)
189
190 // Cache for rendered content at specific widths
191 // Key: width (capped to maxWidth), Value: rendered markdown string
192 cache map[int]string
193}
194
195// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering.
196const DefaultMarkdownMaxWidth = 120
197
198// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
199// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
200func NewMarkdownItem(id, markdown string) *MarkdownItem {
201 m := &MarkdownItem{
202 id: id,
203 markdown: markdown,
204 maxWidth: DefaultMarkdownMaxWidth,
205 cache: make(map[int]string),
206 }
207
208 return m
209}
210
211// WithStyleConfig sets a custom Glamour style configuration for the markdown item.
212func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem {
213 m.styleConfig = &styleConfig
214 return m
215}
216
217// WithMaxWidth sets the maximum wrap width for markdown rendering.
218func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem {
219 m.maxWidth = maxWidth
220 return m
221}
222
223// WithFocusStyles sets the focus and blur styles for the markdown item.
224// If both styles are non-nil, the item will implement Focusable.
225func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
226 m.SetFocusStyles(focusStyle, blurStyle)
227 return m
228}
229
230// ID implements Item.
231func (m *MarkdownItem) ID() string {
232 return m.id
233}
234
235// Height implements Item.
236func (m *MarkdownItem) Height(width int) int {
237 // Render the markdown to get its height
238 rendered := m.renderMarkdown(width)
239
240 // Apply focus/blur styling if configured to get accurate height
241 if style := m.CurrentStyle(); style != nil {
242 rendered = style.Render(rendered)
243 }
244
245 return strings.Count(rendered, "\n") + 1
246}
247
248// Draw implements Item and uv.Drawable.
249func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
250 width := area.Dx()
251 rendered := m.renderMarkdown(width)
252
253 // Apply focus/blur styling if configured
254 if style := m.CurrentStyle(); style != nil {
255 rendered = style.Render(rendered)
256 }
257
258 // Draw the rendered markdown
259 styled := uv.NewStyledString(rendered)
260 styled.Draw(scr, area)
261}
262
263// renderMarkdown renders the markdown content at the given width, using cache if available.
264// Width is always capped to maxWidth to ensure readable line lengths.
265func (m *MarkdownItem) renderMarkdown(width int) string {
266 // Cap width to maxWidth
267 cappedWidth := min(width, m.maxWidth)
268
269 // Check cache first (always cache all rendered markdown)
270 if cached, ok := m.cache[cappedWidth]; ok {
271 return cached
272 }
273
274 // Not cached - render now
275 opts := []glamour.TermRendererOption{
276 glamour.WithWordWrap(cappedWidth),
277 }
278
279 // Add style config if provided
280 if m.styleConfig != nil {
281 opts = append(opts, glamour.WithStyles(*m.styleConfig))
282 }
283
284 renderer, err := glamour.NewTermRenderer(opts...)
285 if err != nil {
286 // Fallback to plain text on error
287 return m.markdown
288 }
289
290 rendered, err := renderer.Render(m.markdown)
291 if err != nil {
292 // Fallback to plain text on error
293 return m.markdown
294 }
295
296 // Trim trailing whitespace
297 rendered = strings.TrimRight(rendered, "\n\r ")
298
299 // Always cache
300 m.cache[cappedWidth] = rendered
301
302 return rendered
303}
304
305// Gap is a 1-line spacer item used to add gaps between items.
306var Gap = NewSpacerItem("spacer-gap", 1)
307
308// SpacerItem is an empty item that takes up vertical space.
309// Useful for adding gaps between items in a list.
310type SpacerItem struct {
311 id string
312 height int
313}
314
315var _ Item = (*SpacerItem)(nil)
316
317// NewSpacerItem creates a new spacer item with the given ID and height in lines.
318func NewSpacerItem(id string, height int) *SpacerItem {
319 return &SpacerItem{
320 id: id,
321 height: height,
322 }
323}
324
325// ID implements Item.
326func (s *SpacerItem) ID() string {
327 return s.id
328}
329
330// Height implements Item.
331func (s *SpacerItem) Height(width int) int {
332 return s.height
333}
334
335// Draw implements Item.
336// Spacer items don't draw anything, they just take up space.
337func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) {
338 // Ensure the area is filled with spaces to clear any existing content
339 spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height))
340 if spacerArea.Overlaps(area) {
341 screen.ClearArea(scr, spacerArea)
342 }
343}