item.go

  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}