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	// ID returns unique identifier for this item.
 64	ID() string
 65
 66	// Height returns the item's height in lines for the given width.
 67	// This allows items to calculate height based on text wrapping and available space.
 68	Height(width int) int
 69}
 70
 71// Focusable is an optional interface for items that support focus.
 72// When implemented, items can change appearance when focused (borders, colors, etc).
 73type Focusable interface {
 74	Focus()
 75	Blur()
 76	IsFocused() bool
 77}
 78
 79// Highlightable is an optional interface for items that support highlighting.
 80// When implemented, items can highlight specific regions (e.g. for search matches).
 81type Highlightable interface {
 82	// SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol).
 83	// Use -1 for all values to clear highlighting.
 84	SetHighlight(startLine, startCol, endLine, endCol int)
 85
 86	// GetHighlight returns the current highlight region.
 87	GetHighlight() (startLine, startCol, endLine, endCol int)
 88}
 89
 90// BaseFocusable provides common focus state and styling for items.
 91// Embed this type to add focus behavior to any item.
 92type BaseFocusable struct {
 93	focused    bool
 94	focusStyle *lipgloss.Style
 95	blurStyle  *lipgloss.Style
 96}
 97
 98// Focus implements Focusable interface.
 99func (b *BaseFocusable) Focus() {
100	b.focused = true
101}
102
103// Blur implements Focusable interface.
104func (b *BaseFocusable) Blur() {
105	b.focused = false
106}
107
108// IsFocused implements Focusable interface.
109func (b *BaseFocusable) IsFocused() bool {
110	return b.focused
111}
112
113// HasFocusStyles returns true if both focus and blur styles are configured.
114func (b *BaseFocusable) HasFocusStyles() bool {
115	return b.focusStyle != nil && b.blurStyle != nil
116}
117
118// CurrentStyle returns the current style based on focus state.
119// Returns nil if no styles are configured, or if the current state's style is nil.
120func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
121	if b.focused {
122		return b.focusStyle
123	}
124	return b.blurStyle
125}
126
127// SetFocusStyles sets the focus and blur styles.
128func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
129	b.focusStyle = focusStyle
130	b.blurStyle = blurStyle
131}
132
133// BaseHighlightable provides common highlight state for items.
134// Embed this type to add highlight behavior to any item.
135type BaseHighlightable struct {
136	highlightStartLine int
137	highlightStartCol  int
138	highlightEndLine   int
139	highlightEndCol    int
140	highlightStyle     CellStyler
141}
142
143// SetHighlight implements Highlightable interface.
144func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
145	b.highlightStartLine = startLine
146	b.highlightStartCol = startCol
147	b.highlightEndLine = endLine
148	b.highlightEndCol = endCol
149}
150
151// GetHighlight implements Highlightable interface.
152func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
153	return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
154}
155
156// HasHighlight returns true if a highlight region is set.
157func (b *BaseHighlightable) HasHighlight() bool {
158	return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
159		b.highlightEndLine >= 0 || b.highlightEndCol >= 0
160}
161
162// SetHighlightStyle sets the style function used for highlighting.
163func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
164	b.highlightStyle = style
165}
166
167// GetHighlightStyle returns the current highlight style function.
168func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
169	return b.highlightStyle
170}
171
172// InitHighlight initializes the highlight fields with default values.
173func (b *BaseHighlightable) InitHighlight() {
174	b.highlightStartLine = -1
175	b.highlightStartCol = -1
176	b.highlightEndLine = -1
177	b.highlightEndCol = -1
178	b.highlightStyle = LipglossStyleToCellStyler(lipgloss.NewStyle().Reverse(true))
179}
180
181// ApplyHighlight applies highlighting to a screen buffer.
182// This should be called after drawing content to the buffer.
183func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
184	if b.highlightStartLine < 0 {
185		return
186	}
187
188	var (
189		topMargin, topBorder, topPadding          int
190		rightMargin, rightBorder, rightPadding    int
191		bottomMargin, bottomBorder, bottomPadding int
192		leftMargin, leftBorder, leftPadding       int
193	)
194	if style != nil {
195		topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
196		topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
197			style.GetBorderRightSize(),
198			style.GetBorderBottomSize(),
199			style.GetBorderLeftSize()
200		topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
201	}
202
203	// Calculate content area offsets
204	contentArea := image.Rectangle{
205		Min: image.Point{
206			X: leftMargin + leftBorder + leftPadding,
207			Y: topMargin + topBorder + topPadding,
208		},
209		Max: image.Point{
210			X: width - (rightMargin + rightBorder + rightPadding),
211			Y: height - (bottomMargin + bottomBorder + bottomPadding),
212		},
213	}
214
215	for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
216		if y >= buf.Height() {
217			break
218		}
219
220		line := buf.Line(y)
221
222		// Determine column range for this line
223		startCol := 0
224		if y == b.highlightStartLine {
225			startCol = min(b.highlightStartCol, len(line))
226		}
227
228		endCol := len(line)
229		if y == b.highlightEndLine {
230			endCol = min(b.highlightEndCol, len(line))
231		}
232
233		// Track last non-empty position as we go
234		lastContentX := -1
235
236		// Single pass: check content and track last non-empty position
237		for x := startCol; x < endCol; x++ {
238			cell := line.At(x)
239			if cell == nil {
240				continue
241			}
242
243			// Update last content position if non-empty
244			if cell.Content != "" && cell.Content != " " {
245				lastContentX = x
246			}
247		}
248
249		// Only apply highlight up to last content position
250		highlightEnd := endCol
251		if lastContentX >= 0 {
252			highlightEnd = lastContentX + 1
253		} else if lastContentX == -1 {
254			highlightEnd = startCol // No content on this line
255		}
256
257		// Apply highlight style only to cells with content
258		for x := startCol; x < highlightEnd; x++ {
259			if !image.Pt(x, y).In(contentArea) {
260				continue
261			}
262			cell := line.At(x)
263			cell.Style = b.highlightStyle(cell.Style)
264		}
265	}
266}
267
268// StringItem is a simple string-based item with optional text wrapping.
269// It caches rendered content by width for efficient repeated rendering.
270// StringItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
271// StringItem implements Highlightable for text selection/search highlighting.
272type StringItem struct {
273	BaseFocusable
274	BaseHighlightable
275	id      string
276	content string // Raw content string (may contain ANSI styles)
277	wrap    bool   // Whether to wrap text
278
279	// Cache for rendered content at specific widths
280	// Key: width, Value: string
281	cache map[int]string
282}
283
284// CellStyler is a function that applies styles to UV cells.
285type CellStyler = func(s uv.Style) uv.Style
286
287var noColor = lipgloss.NoColor{}
288
289// LipglossStyleToCellStyler converts a Lip Gloss style to a CellStyler function.
290func LipglossStyleToCellStyler(lgStyle lipgloss.Style) CellStyler {
291	uvStyle := toUVStyle(lgStyle)
292	return func(s uv.Style) uv.Style {
293		if uvStyle.Fg != nil && lgStyle.GetForeground() != noColor {
294			s.Fg = uvStyle.Fg
295		}
296		if uvStyle.Bg != nil && lgStyle.GetBackground() != noColor {
297			s.Bg = uvStyle.Bg
298		}
299		s.Attrs |= uvStyle.Attrs
300		if uvStyle.Underline != 0 {
301			s.Underline = uvStyle.Underline
302		}
303		return s
304	}
305}
306
307// NewStringItem creates a new string item with the given ID and content.
308func NewStringItem(id, content string) *StringItem {
309	s := &StringItem{
310		id:      id,
311		content: content,
312		wrap:    false,
313		cache:   make(map[int]string),
314	}
315	s.InitHighlight()
316	return s
317}
318
319// NewWrappingStringItem creates a new string item that wraps text to fit width.
320func NewWrappingStringItem(id, content string) *StringItem {
321	s := &StringItem{
322		id:      id,
323		content: content,
324		wrap:    true,
325		cache:   make(map[int]string),
326	}
327	s.InitHighlight()
328	return s
329}
330
331// WithFocusStyles sets the focus and blur styles for the string item.
332// If both styles are non-nil, the item will implement Focusable.
333func (s *StringItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *StringItem {
334	s.SetFocusStyles(focusStyle, blurStyle)
335	return s
336}
337
338// ID implements Item.
339func (s *StringItem) ID() string {
340	return s.id
341}
342
343// Height implements Item.
344func (s *StringItem) Height(width int) int {
345	// Calculate content width if we have styles
346	contentWidth := width
347	if style := s.CurrentStyle(); style != nil {
348		hFrameSize := style.GetHorizontalFrameSize()
349		if hFrameSize > 0 {
350			contentWidth -= hFrameSize
351		}
352	}
353
354	var lines int
355	if !s.wrap {
356		// No wrapping - height is just the number of newlines + 1
357		lines = strings.Count(s.content, "\n") + 1
358	} else {
359		// Use lipgloss.Wrap to wrap the content and count lines
360		// This preserves ANSI styles and is much faster than rendering to a buffer
361		wrapped := lipgloss.Wrap(s.content, contentWidth, "")
362		lines = strings.Count(wrapped, "\n") + 1
363	}
364
365	// Add vertical frame size if we have styles
366	if style := s.CurrentStyle(); style != nil {
367		lines += style.GetVerticalFrameSize()
368	}
369
370	return lines
371}
372
373// Draw implements Item and uv.Drawable.
374func (s *StringItem) Draw(scr uv.Screen, area uv.Rectangle) {
375	width := area.Dx()
376	height := area.Dy()
377
378	// Check cache first
379	content, ok := s.cache[width]
380	if !ok {
381		// Not cached - create and cache
382		content = s.content
383		if s.wrap {
384			// Wrap content using lipgloss
385			content = lipgloss.Wrap(s.content, width, "")
386		}
387		s.cache[width] = content
388	}
389
390	// Apply focus/blur styling if configured
391	style := s.CurrentStyle()
392	if style != nil {
393		content = style.Width(width).Render(content)
394	}
395
396	// Create temp buffer to draw content with highlighting
397	tempBuf := uv.NewScreenBuffer(width, height)
398
399	// Draw content to temp buffer first
400	styled := uv.NewStyledString(content)
401	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
402
403	// Apply highlighting if active
404	s.ApplyHighlight(&tempBuf, width, height, style)
405
406	// Copy temp buffer to actual screen at the target area
407	tempBuf.Draw(scr, area)
408}
409
410// SetHighlight implements Highlightable and extends BaseHighlightable.
411// Clears the cache when highlight changes.
412func (s *StringItem) SetHighlight(startLine, startCol, endLine, endCol int) {
413	s.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
414	// Clear cache when highlight changes
415	s.cache = make(map[int]string)
416}
417
418// MarkdownItem renders markdown content using Glamour.
419// It caches all rendered content by width for efficient repeated rendering.
420// The wrap width is capped at 120 cells by default to ensure readable line lengths.
421// MarkdownItem implements Focusable if focusStyle and blurStyle are set via WithFocusStyles.
422// MarkdownItem implements Highlightable for text selection/search highlighting.
423type MarkdownItem struct {
424	BaseFocusable
425	BaseHighlightable
426	id          string
427	markdown    string            // Raw markdown content
428	styleConfig *ansi.StyleConfig // Optional style configuration
429	maxWidth    int               // Maximum wrap width (default 120)
430
431	// Cache for rendered content at specific widths
432	// Key: width (capped to maxWidth), Value: rendered markdown string
433	cache map[int]string
434}
435
436// DefaultMarkdownMaxWidth is the default maximum width for markdown rendering.
437const DefaultMarkdownMaxWidth = 120
438
439// NewMarkdownItem creates a new markdown item with the given ID and markdown content.
440// If focusStyle and blurStyle are both non-nil, the item will implement Focusable.
441func NewMarkdownItem(id, markdown string) *MarkdownItem {
442	m := &MarkdownItem{
443		id:       id,
444		markdown: markdown,
445		maxWidth: DefaultMarkdownMaxWidth,
446		cache:    make(map[int]string),
447	}
448	m.InitHighlight()
449	return m
450}
451
452// WithStyleConfig sets a custom Glamour style configuration for the markdown item.
453func (m *MarkdownItem) WithStyleConfig(styleConfig ansi.StyleConfig) *MarkdownItem {
454	m.styleConfig = &styleConfig
455	return m
456}
457
458// WithMaxWidth sets the maximum wrap width for markdown rendering.
459func (m *MarkdownItem) WithMaxWidth(maxWidth int) *MarkdownItem {
460	m.maxWidth = maxWidth
461	return m
462}
463
464// WithFocusStyles sets the focus and blur styles for the markdown item.
465// If both styles are non-nil, the item will implement Focusable.
466func (m *MarkdownItem) WithFocusStyles(focusStyle, blurStyle *lipgloss.Style) *MarkdownItem {
467	m.SetFocusStyles(focusStyle, blurStyle)
468	return m
469}
470
471// ID implements Item.
472func (m *MarkdownItem) ID() string {
473	return m.id
474}
475
476// Height implements Item.
477func (m *MarkdownItem) Height(width int) int {
478	// Render the markdown to get its height
479	rendered := m.renderMarkdown(width)
480
481	// Apply focus/blur styling if configured to get accurate height
482	if style := m.CurrentStyle(); style != nil {
483		rendered = style.Render(rendered)
484	}
485
486	return strings.Count(rendered, "\n") + 1
487}
488
489// Draw implements Item and uv.Drawable.
490func (m *MarkdownItem) Draw(scr uv.Screen, area uv.Rectangle) {
491	width := area.Dx()
492	height := area.Dy()
493	rendered := m.renderMarkdown(width)
494
495	// Apply focus/blur styling if configured
496	style := m.CurrentStyle()
497	if style != nil {
498		rendered = style.Render(rendered)
499	}
500
501	// Create temp buffer to draw content with highlighting
502	tempBuf := uv.NewScreenBuffer(width, height)
503
504	// Draw the rendered markdown to temp buffer
505	styled := uv.NewStyledString(rendered)
506	styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
507
508	// Apply highlighting if active
509	m.ApplyHighlight(&tempBuf, width, height, style)
510
511	// Copy temp buffer to actual screen at the target area
512	tempBuf.Draw(scr, area)
513}
514
515// renderMarkdown renders the markdown content at the given width, using cache if available.
516// Width is always capped to maxWidth to ensure readable line lengths.
517func (m *MarkdownItem) renderMarkdown(width int) string {
518	// Cap width to maxWidth
519	cappedWidth := min(width, m.maxWidth)
520
521	// Check cache first (always cache all rendered markdown)
522	if cached, ok := m.cache[cappedWidth]; ok {
523		return cached
524	}
525
526	// Not cached - render now
527	opts := []glamour.TermRendererOption{
528		glamour.WithWordWrap(cappedWidth),
529	}
530
531	// Add style config if provided
532	if m.styleConfig != nil {
533		opts = append(opts, glamour.WithStyles(*m.styleConfig))
534	}
535
536	renderer, err := glamour.NewTermRenderer(opts...)
537	if err != nil {
538		// Fallback to plain text on error
539		return m.markdown
540	}
541
542	rendered, err := renderer.Render(m.markdown)
543	if err != nil {
544		// Fallback to plain text on error
545		return m.markdown
546	}
547
548	// Trim trailing whitespace
549	rendered = strings.TrimRight(rendered, "\n\r ")
550
551	// Always cache
552	m.cache[cappedWidth] = rendered
553
554	return rendered
555}
556
557// SetHighlight implements Highlightable and extends BaseHighlightable.
558// Clears the cache when highlight changes.
559func (m *MarkdownItem) SetHighlight(startLine, startCol, endLine, endCol int) {
560	m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
561	// Clear cache when highlight changes
562	m.cache = make(map[int]string)
563}
564
565// Gap is a 1-line spacer item used to add gaps between items.
566var Gap = NewSpacerItem("spacer-gap", 1)
567
568// SpacerItem is an empty item that takes up vertical space.
569// Useful for adding gaps between items in a list.
570type SpacerItem struct {
571	id     string
572	height int
573}
574
575var _ Item = (*SpacerItem)(nil)
576
577// NewSpacerItem creates a new spacer item with the given ID and height in lines.
578func NewSpacerItem(id string, height int) *SpacerItem {
579	return &SpacerItem{
580		id:     id,
581		height: height,
582	}
583}
584
585// ID implements Item.
586func (s *SpacerItem) ID() string {
587	return s.id
588}
589
590// Height implements Item.
591func (s *SpacerItem) Height(width int) int {
592	return s.height
593}
594
595// Draw implements Item.
596// Spacer items don't draw anything, they just take up space.
597func (s *SpacerItem) Draw(scr uv.Screen, area uv.Rectangle) {
598	// Ensure the area is filled with spaces to clear any existing content
599	spacerArea := uv.Rect(area.Min.X, area.Min.Y, area.Dx(), area.Min.Y+min(1, s.height))
600	if spacerArea.Overlaps(area) {
601		screen.ClearArea(scr, spacerArea)
602	}
603}