1package model
2
3import (
4 "fmt"
5 "image"
6 "log/slog"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "charm.land/lipgloss/v2"
12 uv "github.com/charmbracelet/ultraviolet"
13 "github.com/charmbracelet/x/ansi"
14
15 "github.com/charmbracelet/crush/internal/config"
16 "github.com/charmbracelet/crush/internal/message"
17 "github.com/charmbracelet/crush/internal/ui/common"
18 "github.com/charmbracelet/crush/internal/ui/lazylist"
19 "github.com/charmbracelet/crush/internal/ui/styles"
20 "github.com/charmbracelet/crush/internal/ui/toolrender"
21)
22
23// Identifiable is an interface for items that can provide a unique identifier.
24type Identifiable interface {
25 ID() string
26}
27
28// MessageItem represents a [message.Message] item that can be displayed in the
29// UI and be part of a [lazylist.List] identifiable by a unique ID.
30type MessageItem interface {
31 lazylist.Item
32 lazylist.Item
33 Identifiable
34}
35
36// MessageContentItem represents rendered message content (text, markdown, errors, etc).
37type MessageContentItem struct {
38 id string
39 content string
40 role message.MessageRole
41 isMarkdown bool
42 maxWidth int
43 sty *styles.Styles
44}
45
46// NewMessageContentItem creates a new message content item.
47func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
48 m := &MessageContentItem{
49 id: id,
50 content: content,
51 isMarkdown: isMarkdown,
52 role: role,
53 maxWidth: 120,
54 sty: sty,
55 }
56 return m
57}
58
59// ID implements Identifiable.
60func (m *MessageContentItem) ID() string {
61 return m.id
62}
63
64// FocusStyle returns the focus style.
65func (m *MessageContentItem) FocusStyle() lipgloss.Style {
66 if m.role == message.User {
67 return m.sty.Chat.Message.UserFocused
68 }
69 return m.sty.Chat.Message.AssistantFocused
70}
71
72// BlurStyle returns the blur style.
73func (m *MessageContentItem) BlurStyle() lipgloss.Style {
74 if m.role == message.User {
75 return m.sty.Chat.Message.UserBlurred
76 }
77 return m.sty.Chat.Message.AssistantBlurred
78}
79
80// HighlightStyle returns the highlight style.
81func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
82 return m.sty.TextSelection
83}
84
85// Render renders the content at the given width, using cache if available.
86//
87// It implements [lazylist.Item].
88func (m *MessageContentItem) Render(width int) string {
89 contentWidth := width
90 // Cap width to maxWidth for markdown
91 cappedWidth := contentWidth
92 if m.isMarkdown {
93 cappedWidth = min(contentWidth, m.maxWidth)
94 }
95
96 var rendered string
97 if m.isMarkdown {
98 renderer := common.MarkdownRenderer(m.sty, cappedWidth)
99 result, err := renderer.Render(m.content)
100 if err != nil {
101 rendered = m.content
102 } else {
103 rendered = strings.TrimSuffix(result, "\n")
104 }
105 } else {
106 rendered = m.content
107 }
108
109 return rendered
110}
111
112// ToolCallItem represents a rendered tool call with its header and content.
113type ToolCallItem struct {
114 BaseFocusable
115 BaseHighlightable
116 id string
117 toolCall message.ToolCall
118 toolResult message.ToolResult
119 cancelled bool
120 isNested bool
121 maxWidth int
122 sty *styles.Styles
123}
124
125// cachedToolRender stores both the rendered string and its height.
126type cachedToolRender struct {
127 content string
128 height int
129}
130
131// NewToolCallItem creates a new tool call item.
132func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
133 t := &ToolCallItem{
134 id: id,
135 toolCall: toolCall,
136 toolResult: toolResult,
137 cancelled: cancelled,
138 isNested: isNested,
139 maxWidth: 120,
140 sty: sty,
141 }
142 t.InitHighlight()
143 return t
144}
145
146// generateCacheKey creates a key that changes when tool call content changes.
147func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
148 // Simple key based on result state - when result arrives or changes, key changes
149 return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
150}
151
152// ID implements Identifiable.
153func (t *ToolCallItem) ID() string {
154 return t.id
155}
156
157// FocusStyle returns the focus style.
158func (t *ToolCallItem) FocusStyle() lipgloss.Style {
159 if t.focusStyle != nil {
160 return *t.focusStyle
161 }
162 return lipgloss.Style{}
163}
164
165// BlurStyle returns the blur style.
166func (t *ToolCallItem) BlurStyle() lipgloss.Style {
167 if t.blurStyle != nil {
168 return *t.blurStyle
169 }
170 return lipgloss.Style{}
171}
172
173// HighlightStyle returns the highlight style.
174func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
175 return t.sty.TextSelection
176}
177
178// Render implements lazylist.Item.
179func (t *ToolCallItem) Render(width int) string {
180 // Render the tool call
181 ctx := &toolrender.RenderContext{
182 Call: t.toolCall,
183 Result: t.toolResult,
184 Cancelled: t.cancelled,
185 IsNested: t.isNested,
186 Width: width,
187 Styles: t.sty,
188 }
189
190 rendered := toolrender.Render(ctx)
191 return rendered
192
193 // return t.RenderWithHighlight(rendered, width, style)
194}
195
196// SetHighlight implements list.Highlightable.
197func (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.
202func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
203 t.toolResult = result
204}
205
206// AttachmentItem represents a file attachment in a user message.
207type AttachmentItem struct {
208 BaseFocusable
209 BaseHighlightable
210 id string
211 filename string
212 path string
213 sty *styles.Styles
214}
215
216// NewAttachmentItem creates a new attachment item.
217func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
218 a := &AttachmentItem{
219 id: id,
220 filename: filename,
221 path: path,
222 sty: sty,
223 }
224 a.InitHighlight()
225 return a
226}
227
228// ID implements Identifiable.
229func (a *AttachmentItem) ID() string {
230 return a.id
231}
232
233// FocusStyle returns the focus style.
234func (a *AttachmentItem) FocusStyle() lipgloss.Style {
235 if a.focusStyle != nil {
236 return *a.focusStyle
237 }
238 return lipgloss.Style{}
239}
240
241// BlurStyle returns the blur style.
242func (a *AttachmentItem) BlurStyle() lipgloss.Style {
243 if a.blurStyle != nil {
244 return *a.blurStyle
245 }
246 return lipgloss.Style{}
247}
248
249// HighlightStyle returns the highlight style.
250func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
251 return a.sty.TextSelection
252}
253
254// Render implements lazylist.Item.
255func (a *AttachmentItem) Render(width int) string {
256 const maxFilenameWidth = 10
257 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
258 " %s %s ",
259 styles.DocumentIcon,
260 ansi.Truncate(a.filename, maxFilenameWidth, "..."),
261 ))
262
263 return content
264
265 // return a.RenderWithHighlight(content, width, a.CurrentStyle())
266}
267
268// ThinkingItem represents thinking/reasoning content in assistant messages.
269type ThinkingItem struct {
270 id string
271 thinking string
272 duration time.Duration
273 finished bool
274 maxWidth int
275 sty *styles.Styles
276}
277
278// NewThinkingItem creates a new thinking item.
279func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
280 t := &ThinkingItem{
281 id: id,
282 thinking: thinking,
283 duration: duration,
284 finished: finished,
285 maxWidth: 120,
286 sty: sty,
287 }
288 return t
289}
290
291// ID implements Identifiable.
292func (t *ThinkingItem) ID() string {
293 return t.id
294}
295
296// FocusStyle returns the focus style.
297func (t *ThinkingItem) FocusStyle() lipgloss.Style {
298 return t.sty.Chat.Message.AssistantFocused
299}
300
301// BlurStyle returns the blur style.
302func (t *ThinkingItem) BlurStyle() lipgloss.Style {
303 return t.sty.Chat.Message.AssistantBlurred
304}
305
306// HighlightStyle returns the highlight style.
307func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
308 return t.sty.TextSelection
309}
310
311// Render implements lazylist.Item.
312func (t *ThinkingItem) Render(width int) string {
313 cappedWidth := min(width, t.maxWidth)
314
315 renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
316 rendered, err := renderer.Render(t.thinking)
317 if err != nil {
318 // Fallback to line-by-line rendering
319 lines := strings.Split(t.thinking, "\n")
320 var content strings.Builder
321 lineStyle := t.sty.PanelMuted
322 for i, line := range lines {
323 if line == "" {
324 continue
325 }
326 content.WriteString(lineStyle.Width(cappedWidth).Render(line))
327 if i < len(lines)-1 {
328 content.WriteString("\n")
329 }
330 }
331 rendered = content.String()
332 }
333
334 fullContent := strings.TrimSpace(rendered)
335
336 // Add footer if finished
337 if t.finished && t.duration > 0 {
338 footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
339 fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
340 }
341
342 result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
343
344 return result
345}
346
347// SectionHeaderItem represents a section header (e.g., assistant info).
348type SectionHeaderItem struct {
349 id string
350 modelName string
351 duration time.Duration
352 isSectionHeader bool
353 sty *styles.Styles
354}
355
356// NewSectionHeaderItem creates a new section header item.
357func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
358 s := &SectionHeaderItem{
359 id: id,
360 modelName: modelName,
361 duration: duration,
362 isSectionHeader: true,
363 sty: sty,
364 }
365 return s
366}
367
368// ID implements Identifiable.
369func (s *SectionHeaderItem) ID() string {
370 return s.id
371}
372
373// IsSectionHeader returns true if this is a section header.
374func (s *SectionHeaderItem) IsSectionHeader() bool {
375 return s.isSectionHeader
376}
377
378// FocusStyle returns the focus style.
379func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
380 return s.sty.Chat.Message.AssistantFocused
381}
382
383// BlurStyle returns the blur style.
384func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
385 return s.sty.Chat.Message.AssistantBlurred
386}
387
388// Render implements lazylist.Item.
389func (s *SectionHeaderItem) Render(width int) string {
390 content := s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s",
391 s.sty.Subtle.Render(styles.ModelIcon),
392 s.sty.Muted.Render(s.modelName),
393 s.sty.Subtle.Render(s.duration.String()),
394 ))
395
396 return content
397}
398
399// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
400// all parts of the message as [MessageItem]s.
401//
402// For assistant messages with tool calls, pass a toolResults map to link results.
403// Use BuildToolResultMap to create this map from all messages in a session.
404func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
405 var items []MessageItem
406
407 // Skip tool result messages - they're displayed inline with tool calls
408 if msg.Role == message.Tool {
409 return items
410 }
411
412 // Create base styles for the message
413 var focusStyle, blurStyle lipgloss.Style
414 if msg.Role == message.User {
415 focusStyle = sty.Chat.Message.UserFocused
416 blurStyle = sty.Chat.Message.UserBlurred
417 } else {
418 focusStyle = sty.Chat.Message.AssistantFocused
419 blurStyle = sty.Chat.Message.AssistantBlurred
420 }
421
422 // Process user messages
423 if msg.Role == message.User {
424 // Add main text content
425 content := msg.Content().String()
426 if content != "" {
427 item := NewMessageContentItem(
428 fmt.Sprintf("%s-content", msg.ID),
429 content,
430 msg.Role,
431 true, // User messages are markdown
432 sty,
433 )
434 items = append(items, item)
435 }
436
437 // Add attachments
438 for i, attachment := range msg.BinaryContent() {
439 filename := filepath.Base(attachment.Path)
440 item := NewAttachmentItem(
441 fmt.Sprintf("%s-attachment-%d", msg.ID, i),
442 filename,
443 attachment.Path,
444 sty,
445 )
446 item.SetHighlightStyle(ToStyler(sty.TextSelection))
447 item.SetFocusStyles(&focusStyle, &blurStyle)
448 items = append(items, item)
449 }
450
451 return items
452 }
453
454 // Process assistant messages
455 if msg.Role == message.Assistant {
456 // Check if we need to add a section header
457 finishData := msg.FinishPart()
458 if finishData != nil && msg.Model != "" {
459 model := config.Get().GetModel(msg.Provider, msg.Model)
460 modelName := "Unknown Model"
461 if model != nil {
462 modelName = model.Name
463 }
464
465 // Calculate duration (this would need the last user message time)
466 duration := time.Duration(0)
467 if finishData.Time > 0 {
468 duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
469 }
470
471 header := NewSectionHeaderItem(
472 fmt.Sprintf("%s-header", msg.ID),
473 modelName,
474 duration,
475 sty,
476 )
477 items = append(items, header)
478 }
479
480 // Add thinking content if present
481 reasoning := msg.ReasoningContent()
482 if strings.TrimSpace(reasoning.Thinking) != "" {
483 duration := time.Duration(0)
484 if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
485 duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
486 }
487
488 item := NewThinkingItem(
489 fmt.Sprintf("%s-thinking", msg.ID),
490 reasoning.Thinking,
491 duration,
492 reasoning.FinishedAt > 0,
493 sty,
494 )
495 items = append(items, item)
496 }
497
498 // Add main text content
499 content := msg.Content().String()
500 finished := msg.IsFinished()
501
502 // Handle special finish states
503 if finished && content == "" && finishData != nil {
504 switch finishData.Reason {
505 case message.FinishReasonEndTurn:
506 // No content to show
507 case message.FinishReasonCanceled:
508 item := NewMessageContentItem(
509 fmt.Sprintf("%s-content", msg.ID),
510 "*Canceled*",
511 msg.Role,
512 true,
513 sty,
514 )
515 items = append(items, item)
516 case message.FinishReasonError:
517 // Render error
518 errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
519 truncated := ansi.Truncate(finishData.Message, 100, "...")
520 title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
521 details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
522 errorContent := fmt.Sprintf("%s\n\n%s", title, details)
523
524 item := NewMessageContentItem(
525 fmt.Sprintf("%s-error", msg.ID),
526 errorContent,
527 msg.Role,
528 false,
529 sty,
530 )
531 items = append(items, item)
532 }
533 } else if content != "" {
534 item := NewMessageContentItem(
535 fmt.Sprintf("%s-content", msg.ID),
536 content,
537 msg.Role,
538 true, // Assistant messages are markdown
539 sty,
540 )
541 items = append(items, item)
542 }
543
544 // Add tool calls
545 toolCalls := msg.ToolCalls()
546
547 // Use passed-in tool results map (if nil, use empty map)
548 resultMap := toolResults
549 if resultMap == nil {
550 resultMap = make(map[string]message.ToolResult)
551 }
552
553 for _, tc := range toolCalls {
554 result, hasResult := resultMap[tc.ID]
555 if !hasResult {
556 result = message.ToolResult{}
557 }
558
559 item := NewToolCallItem(
560 fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
561 tc,
562 result,
563 false, // cancelled state would need to be tracked separately
564 false, // nested state would be detected from tool results
565 sty,
566 )
567
568 item.SetHighlightStyle(ToStyler(sty.TextSelection))
569
570 // Tool calls use muted style with optional focus border
571 item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
572
573 items = append(items, item)
574 }
575
576 return items
577 }
578
579 return items
580}
581
582// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
583// Tool result messages (role == message.Tool) contain the results that should be linked
584// to tool calls in assistant messages.
585func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
586 resultMap := make(map[string]message.ToolResult)
587 for _, msg := range messages {
588 if msg.Role == message.Tool {
589 for _, result := range msg.ToolResults() {
590 if result.ToolCallID != "" {
591 resultMap[result.ToolCallID] = result
592 }
593 }
594 }
595 }
596 return resultMap
597}
598
599// BaseFocusable provides common focus state and styling for items.
600// Embed this type to add focus behavior to any item.
601type BaseFocusable struct {
602 focused bool
603 focusStyle *lipgloss.Style
604 blurStyle *lipgloss.Style
605}
606
607// Focus implements Focusable interface.
608func (b *BaseFocusable) Focus(width int, content string) string {
609 if b.focusStyle != nil {
610 return b.focusStyle.Render(content)
611 }
612 return content
613}
614
615// Blur implements Focusable interface.
616func (b *BaseFocusable) Blur(width int, content string) string {
617 if b.blurStyle != nil {
618 return b.blurStyle.Render(content)
619 }
620 return content
621}
622
623// Focus implements Focusable interface.
624// func (b *BaseFocusable) Focus() {
625// b.focused = true
626// }
627
628// Blur implements Focusable interface.
629// func (b *BaseFocusable) Blur() {
630// b.focused = false
631// }
632
633// Focused implements Focusable interface.
634func (b *BaseFocusable) Focused() bool {
635 return b.focused
636}
637
638// HasFocusStyles returns true if both focus and blur styles are configured.
639func (b *BaseFocusable) HasFocusStyles() bool {
640 return b.focusStyle != nil && b.blurStyle != nil
641}
642
643// CurrentStyle returns the current style based on focus state.
644// Returns nil if no styles are configured, or if the current state's style is nil.
645func (b *BaseFocusable) CurrentStyle() *lipgloss.Style {
646 if b.focused {
647 return b.focusStyle
648 }
649 return b.blurStyle
650}
651
652// SetFocusStyles sets the focus and blur styles.
653func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) {
654 b.focusStyle = focusStyle
655 b.blurStyle = blurStyle
656}
657
658// CellStyler defines a function that styles a [uv.Style].
659type CellStyler func(uv.Style) uv.Style
660
661// BaseHighlightable provides common highlight state for items.
662// Embed this type to add highlight behavior to any item.
663type BaseHighlightable struct {
664 highlightStartLine int
665 highlightStartCol int
666 highlightEndLine int
667 highlightEndCol int
668 highlightStyle CellStyler
669}
670
671// SetHighlight implements Highlightable interface.
672func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) {
673 b.highlightStartLine = startLine
674 b.highlightStartCol = startCol
675 b.highlightEndLine = endLine
676 b.highlightEndCol = endCol
677}
678
679// GetHighlight implements Highlightable interface.
680func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) {
681 return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol
682}
683
684// HasHighlight returns true if a highlight region is set.
685func (b *BaseHighlightable) HasHighlight() bool {
686 return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 ||
687 b.highlightEndLine >= 0 || b.highlightEndCol >= 0
688}
689
690// SetHighlightStyle sets the style function used for highlighting.
691func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) {
692 b.highlightStyle = style
693}
694
695// GetHighlightStyle returns the current highlight style function.
696func (b *BaseHighlightable) GetHighlightStyle() CellStyler {
697 return b.highlightStyle
698}
699
700// InitHighlight initializes the highlight fields with default values.
701func (b *BaseHighlightable) InitHighlight() {
702 b.highlightStartLine = -1
703 b.highlightStartCol = -1
704 b.highlightEndLine = -1
705 b.highlightEndCol = -1
706 b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true))
707}
708
709// Highlight implements Highlightable interface.
710func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string {
711 b.SetHighlight(startLine, startCol, endLine, endCol)
712 return b.RenderWithHighlight(content, width, nil)
713}
714
715// RenderWithHighlight renders content with optional focus styling and highlighting.
716// This is a helper that combines common rendering logic for all items.
717// The content parameter should be the raw rendered content before focus styling.
718// The style parameter should come from CurrentStyle() and may be nil.
719func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string {
720 // Apply focus/blur styling if configured
721 rendered := content
722 if style != nil {
723 rendered = style.Render(rendered)
724 }
725
726 if !b.HasHighlight() {
727 return rendered
728 }
729
730 height := lipgloss.Height(rendered)
731
732 // Create temp buffer to draw content with highlighting
733 tempBuf := uv.NewScreenBuffer(width, height)
734
735 // Draw the rendered content to temp buffer
736 styled := uv.NewStyledString(rendered)
737 styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
738
739 // Apply highlighting if active
740 b.ApplyHighlight(&tempBuf, width, height, style)
741
742 return tempBuf.Render()
743}
744
745// ApplyHighlight applies highlighting to a screen buffer.
746// This should be called after drawing content to the buffer.
747func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) {
748 if b.highlightStartLine < 0 {
749 return
750 }
751
752 var (
753 topMargin, topBorder, topPadding int
754 rightMargin, rightBorder, rightPadding int
755 bottomMargin, bottomBorder, bottomPadding int
756 leftMargin, leftBorder, leftPadding int
757 )
758 if style != nil {
759 topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin()
760 topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(),
761 style.GetBorderRightSize(),
762 style.GetBorderBottomSize(),
763 style.GetBorderLeftSize()
764 topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding()
765 }
766
767 slog.Info("Applying highlight",
768 "highlightStartLine", b.highlightStartLine,
769 "highlightStartCol", b.highlightStartCol,
770 "highlightEndLine", b.highlightEndLine,
771 "highlightEndCol", b.highlightEndCol,
772 "width", width,
773 "height", height,
774 "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin),
775 "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder),
776 "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding),
777 )
778
779 // Calculate content area offsets
780 contentArea := image.Rectangle{
781 Min: image.Point{
782 X: leftMargin + leftBorder + leftPadding,
783 Y: topMargin + topBorder + topPadding,
784 },
785 Max: image.Point{
786 X: width - (rightMargin + rightBorder + rightPadding),
787 Y: height - (bottomMargin + bottomBorder + bottomPadding),
788 },
789 }
790
791 for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ {
792 if y >= buf.Height() {
793 break
794 }
795
796 line := buf.Line(y)
797
798 // Determine column range for this line
799 startCol := 0
800 if y == b.highlightStartLine {
801 startCol = min(b.highlightStartCol, len(line))
802 }
803
804 endCol := len(line)
805 if y == b.highlightEndLine {
806 endCol = min(b.highlightEndCol, len(line))
807 }
808
809 // Track last non-empty position as we go
810 lastContentX := -1
811
812 // Single pass: check content and track last non-empty position
813 for x := startCol; x < endCol; x++ {
814 cell := line.At(x)
815 if cell == nil {
816 continue
817 }
818
819 // Update last content position if non-empty
820 if cell.Content != "" && cell.Content != " " {
821 lastContentX = x
822 }
823 }
824
825 // Only apply highlight up to last content position
826 highlightEnd := endCol
827 if lastContentX >= 0 {
828 highlightEnd = lastContentX + 1
829 } else if lastContentX == -1 {
830 highlightEnd = startCol // No content on this line
831 }
832
833 // Apply highlight style only to cells with content
834 for x := startCol; x < highlightEnd; x++ {
835 if !image.Pt(x, y).In(contentArea) {
836 continue
837 }
838 cell := line.At(x)
839 cell.Style = b.highlightStyle(cell.Style)
840 }
841 }
842}
843
844// ToStyler converts a [lipgloss.Style] to a [CellStyler].
845func ToStyler(lgStyle lipgloss.Style) CellStyler {
846 return func(uv.Style) uv.Style {
847 return ToStyle(lgStyle)
848 }
849}
850
851// ToStyle converts an inline [lipgloss.Style] to a [uv.Style].
852func ToStyle(lgStyle lipgloss.Style) uv.Style {
853 var uvStyle uv.Style
854
855 // Colors are already color.Color
856 uvStyle.Fg = lgStyle.GetForeground()
857 uvStyle.Bg = lgStyle.GetBackground()
858
859 // Build attributes using bitwise OR
860 var attrs uint8
861
862 if lgStyle.GetBold() {
863 attrs |= uv.AttrBold
864 }
865
866 if lgStyle.GetItalic() {
867 attrs |= uv.AttrItalic
868 }
869
870 if lgStyle.GetUnderline() {
871 uvStyle.Underline = uv.UnderlineSingle
872 }
873
874 if lgStyle.GetStrikethrough() {
875 attrs |= uv.AttrStrikethrough
876 }
877
878 if lgStyle.GetFaint() {
879 attrs |= uv.AttrFaint
880 }
881
882 if lgStyle.GetBlink() {
883 attrs |= uv.AttrBlink
884 }
885
886 if lgStyle.GetReverse() {
887 attrs |= uv.AttrReverse
888 }
889
890 uvStyle.Attrs = attrs
891
892 return uvStyle
893}