item.go

  1package list
  2
  3import (
  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.
 15func 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.
 60type 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).
 70type 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).
 78type 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.
 89type BaseFocusable struct {
 90	focused    bool
 91	focusStyle *lipgloss.Style
 92	blurStyle  *lipgloss.Style
 93}
 94
 95// Focus implements Focusable interface.
 96func (b *BaseFocusable) Focus() {
 97	b.focused = true
 98}
 99
100// Blur implements Focusable interface.
101func (b *BaseFocusable) Blur() {
102	b.focused = false
103}
104
105// IsFocused implements Focusable interface.
106func (b *BaseFocusable) IsFocused() bool {
107	return b.focused
108}
109
110// HasFocusStyles returns true if both focus and blur styles are configured.
111func (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.
117func (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.
125func (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.
132type BaseHighlightable struct {
133	highlightStartLine int
134	highlightStartCol  int
135	highlightEndLine   int
136	highlightEndCol    int
137	highlightStyle     CellStyler
138}
139
140// SetHighlight implements Highlightable interface.
141func (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.
149func (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.
154func (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.
160func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
161	b.highlightStyle = style
162}
163
164// GetHighlightStyle returns the current highlight style function.
165func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
166	return b.highlightStyle
167}
168
169// InitHighlight initializes the highlight fields with default values.
170func (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.
180func (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.
269type 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.
281type CellStyler = func(s uv.Style) uv.Style
282
283var noColor = lipgloss.NoColor{}
284
285// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function.
286func 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.
304func 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.
315func 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.
327func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
328	s.SetFocusStyles(focusStyle, blurStyle)
329	return s
330}
331
332// Height implements Item.
333func (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.
363func (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.
401func (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.
412type 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.
425const 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.
429func 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.
440func (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.
446func (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.
453func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
454	m.SetFocusStyles(focusStyle, blurStyle)
455	return m
456}
457
458// Height implements Item.
459func (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.
472func (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.
499func (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.
541func (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.
548var 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.
552type SpacerItem struct {
553	height int
554}
555
556var _ Item = (*SpacerItem)(nil)
557
558// NewSpacerItem creates a new spacer item with the given ID and height in lines.
559func NewSpacerItem(height int) *SpacerItem {
560	return &SpacerItem{
561		height: height,
562	}
563}
564
565// Height implements Item.
566func (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.
572func (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}