diff --git a/internal/ui/lazylist/highlight.go b/internal/ui/lazylist/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..c5b885238771d57f28e4177684c7e24e1336a5b8 --- /dev/null +++ b/internal/ui/lazylist/highlight.go @@ -0,0 +1,289 @@ +package lazylist + +import ( + "image" + + "charm.land/lipgloss/v2" + uv "github.com/charmbracelet/ultraviolet" +) + +// DefaultHighlighter is the default highlighter function that applies inverse style. +var DefaultHighlighter Highlighter = func(s uv.Style) uv.Style { + s.Attrs |= uv.AttrReverse + return s +} + +// Highlighter represents a function that defines how to highlight text. +type Highlighter func(uv.Style) uv.Style + +// Highlight highlights a region of text within the given content and region. +func Highlight(content string, area image.Rectangle, startLine, startCol, endLine, endCol int, highlighter Highlighter) string { + if startLine < 0 || startCol < 0 { + return content + } + + if highlighter == nil { + highlighter = DefaultHighlighter + } + + width, height := area.Dx(), area.Dy() + buf := uv.NewScreenBuffer(width, height) + styled := uv.NewStyledString(content) + styled.Draw(&buf, area) + + for y := startLine; y <= endLine && y < height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line + colStart := 0 + if y == startLine { + colStart = min(startCol, len(line)) + } + + colEnd := len(line) + if y == endLine { + colEnd = min(endCol, len(line)) + } + + // Track last non-empty position as we go + lastContentX := -1 + + // Single pass: check content and track last non-empty position + for x := colStart; x < colEnd; x++ { + cell := line.At(x) + if cell == nil { + continue + } + + // Update last content position if non-empty + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Only apply highlight up to last content position + highlightEnd := colEnd + if lastContentX >= 0 { + highlightEnd = lastContentX + 1 + } else if lastContentX == -1 { + highlightEnd = colStart // No content on this line + } + + // Apply highlight style only to cells with content + for x := colStart; x < highlightEnd; x++ { + if !image.Pt(x, y).In(area) { + continue + } + cell := line.At(x) + cell.Style = highlighter(cell.Style) + } + } + + return buf.Render() +} + +// RenderWithHighlight renders content with optional focus styling and highlighting. +// This is a helper that combines common rendering logic for all items. +// The content parameter should be the raw rendered content before focus styling. +// The style parameter should come from CurrentStyle() and may be nil. +// func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { +// // Apply focus/blur styling if configured +// rendered := content +// if style != nil { +// rendered = style.Render(rendered) +// } +// +// if !b.HasHighlight() { +// return rendered +// } +// +// height := lipgloss.Height(rendered) +// +// // Create temp buffer to draw content with highlighting +// tempBuf := uv.NewScreenBuffer(width, height) +// +// // Draw the rendered content to temp buffer +// styled := uv.NewStyledString(rendered) +// styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) +// +// // Apply highlighting if active +// b.ApplyHighlight(&tempBuf, width, height, style) +// +// return tempBuf.Render() +// } + +// ApplyHighlight applies highlighting to a screen buffer. +// This should be called after drawing content to the buffer. +// func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { +// if b.highlightStartLine < 0 { +// return +// } +// +// var ( +// topMargin, topBorder, topPadding int +// rightMargin, rightBorder, rightPadding int +// bottomMargin, bottomBorder, bottomPadding int +// leftMargin, leftBorder, leftPadding int +// ) +// if style != nil { +// topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() +// topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), +// style.GetBorderRightSize(), +// style.GetBorderBottomSize(), +// style.GetBorderLeftSize() +// topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() +// } +// +// slog.Info("Applying highlight", +// "highlightStartLine", b.highlightStartLine, +// "highlightStartCol", b.highlightStartCol, +// "highlightEndLine", b.highlightEndLine, +// "highlightEndCol", b.highlightEndCol, +// "width", width, +// "height", height, +// "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), +// "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), +// "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), +// ) +// +// // Calculate content area offsets +// contentArea := image.Rectangle{ +// Min: image.Point{ +// X: leftMargin + leftBorder + leftPadding, +// Y: topMargin + topBorder + topPadding, +// }, +// Max: image.Point{ +// X: width - (rightMargin + rightBorder + rightPadding), +// Y: height - (bottomMargin + bottomBorder + bottomPadding), +// }, +// } +// +// for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { +// if y >= buf.Height() { +// break +// } +// +// line := buf.Line(y) +// +// // Determine column range for this line +// startCol := 0 +// if y == b.highlightStartLine { +// startCol = min(b.highlightStartCol, len(line)) +// } +// +// endCol := len(line) +// if y == b.highlightEndLine { +// endCol = min(b.highlightEndCol, len(line)) +// } +// +// // Track last non-empty position as we go +// lastContentX := -1 +// +// // Single pass: check content and track last non-empty position +// for x := startCol; x < endCol; x++ { +// cell := line.At(x) +// if cell == nil { +// continue +// } +// +// // Update last content position if non-empty +// if cell.Content != "" && cell.Content != " " { +// lastContentX = x +// } +// } +// +// // Only apply highlight up to last content position +// highlightEnd := endCol +// if lastContentX >= 0 { +// highlightEnd = lastContentX + 1 +// } else if lastContentX == -1 { +// highlightEnd = startCol // No content on this line +// } +// +// // Apply highlight style only to cells with content +// for x := startCol; x < highlightEnd; x++ { +// if !image.Pt(x, y).In(contentArea) { +// continue +// } +// cell := line.At(x) +// cell.Style = b.highlightStyle(cell.Style) +// } +// } +// } + +// ToHighlighter converts a [lipgloss.Style] to a [Highlighter]. +func ToHighlighter(lgStyle lipgloss.Style) Highlighter { + return func(uv.Style) uv.Style { + return ToStyle(lgStyle) + } +} + +// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. +func ToStyle(lgStyle lipgloss.Style) uv.Style { + var uvStyle uv.Style + + // Colors are already color.Color + uvStyle.Fg = lgStyle.GetForeground() + uvStyle.Bg = lgStyle.GetBackground() + + // Build attributes using bitwise OR + var attrs uint8 + + if lgStyle.GetBold() { + attrs |= uv.AttrBold + } + + if lgStyle.GetItalic() { + attrs |= uv.AttrItalic + } + + if lgStyle.GetUnderline() { + uvStyle.Underline = uv.UnderlineSingle + } + + if lgStyle.GetStrikethrough() { + attrs |= uv.AttrStrikethrough + } + + if lgStyle.GetFaint() { + attrs |= uv.AttrFaint + } + + if lgStyle.GetBlink() { + attrs |= uv.AttrBlink + } + + if lgStyle.GetReverse() { + attrs |= uv.AttrReverse + } + + uvStyle.Attrs = attrs + + return uvStyle +} + +// AdjustArea adjusts the given area rectangle by subtracting margins, borders, +// and padding from the style. +func AdjustArea(area image.Rectangle, style lipgloss.Style) image.Rectangle { + topMargin, rightMargin, bottomMargin, leftMargin := style.GetMargin() + topBorder, rightBorder, bottomBorder, leftBorder := style.GetBorderTopSize(), + style.GetBorderRightSize(), + style.GetBorderBottomSize(), + style.GetBorderLeftSize() + topPadding, rightPadding, bottomPadding, leftPadding := style.GetPadding() + + return image.Rectangle{ + Min: image.Point{ + X: area.Min.X + leftMargin + leftBorder + leftPadding, + Y: area.Min.Y + topMargin + topBorder + topPadding, + }, + Max: image.Point{ + X: area.Max.X - (rightMargin + rightBorder + rightPadding), + Y: area.Max.Y - (bottomMargin + bottomBorder + bottomPadding), + }, + } +} diff --git a/internal/ui/lazylist/item.go b/internal/ui/lazylist/item.go index 3ec31fbcf3d091e8d3438b2ecccfd467658b5f0f..7c6904d7ad2cd30aec0fab62b9238141343c4e9f 100644 --- a/internal/ui/lazylist/item.go +++ b/internal/ui/lazylist/item.go @@ -1,5 +1,7 @@ package lazylist +import "charm.land/lipgloss/v2" + // Item represents a single item in the lazy-loaded list. type Item interface { // Render returns the string representation of the item for the given @@ -7,24 +9,16 @@ type Item interface { Render(width int) string } -// Focusable represents an item that can gain or lose focus. -type Focusable interface { - // Focus sets the focus state of the item. - Focus() - - // Blur removes the focus state of the item. - Blur() - - // Focused returns whether the item is focused. - Focused() bool +// FocusStylable represents an item that can be styled based on focus state. +type FocusStylable interface { + // FocusStyle returns the style to apply when the item is focused. + FocusStyle() lipgloss.Style + // BlurStyle returns the style to apply when the item is unfocused. + BlurStyle() lipgloss.Style } -// Highlightable represents an item that can have a highlighted region. -type Highlightable interface { - // SetHighlight sets the highlight region (startLine, startCol) to (endLine, endCol). - // Use -1 for all values to clear highlighting. - SetHighlight(startLine, startCol, endLine, endCol int) - - // GetHighlight returns the current highlight region. - GetHighlight() (startLine, startCol, endLine, endCol int) +// HighlightStylable represents an item that can be styled for highlighted regions. +type HighlightStylable interface { + // HighlightStyle returns the style to apply for highlighted regions. + HighlightStyle() lipgloss.Style } diff --git a/internal/ui/lazylist/list.go b/internal/ui/lazylist/list.go index bbf8d970852645643b5b5a227aa671dd3e00161a..0cb6755681e7e56e7236bfca6ebf7dd2cfe41928 100644 --- a/internal/ui/lazylist/list.go +++ b/internal/ui/lazylist/list.go @@ -1,8 +1,11 @@ package lazylist import ( + "image" "log/slog" "strings" + + "charm.land/lipgloss/v2" ) // List represents a list of items that can be lazily rendered. A list is @@ -22,6 +25,16 @@ type List struct { focused bool selectedIdx int // The current selected index -1 means no selection + // Mouse state + mouseDown bool + mouseDownItem int // Item index where mouse was pressed + mouseDownX int // X position in item content (character offset) + mouseDownY int // Y position in item (line offset) + mouseDragItem int // Current item index being dragged over + mouseDragX int // Current X in item content + mouseDragY int // Current Y in item + lastHighlighted map[int]bool // Track which items were highlighted in last update + // Rendered content and cache renderedItems map[int]renderedItem @@ -44,6 +57,10 @@ func NewList(items ...Item) *List { l := new(List) l.items = items l.renderedItems = make(map[int]renderedItem) + l.selectedIdx = -1 + l.mouseDownItem = -1 + l.mouseDragItem = -1 + l.lastHighlighted = make(map[int]bool) return l } @@ -79,25 +96,98 @@ func (l *List) Len() int { // getItem renders (if needed) and returns the item at the given index. func (l *List) getItem(idx int) renderedItem { + return l.renderItem(idx, false) +} + +// renderItem renders (if needed) and returns the item at the given index. If +// process is true, it applies focus and highlight styling. +func (l *List) renderItem(idx int, process bool) renderedItem { if idx < 0 || idx >= len(l.items) { return renderedItem{} } - if item, ok := l.renderedItems[idx]; ok { - return item + var style lipgloss.Style + focusable, isFocusable := l.items[idx].(FocusStylable) + if isFocusable { + style = focusable.BlurStyle() + if l.focused && idx == l.selectedIdx { + style = focusable.FocusStyle() + } } - item := l.items[idx] - rendered := item.Render(l.width) - height := countLines(rendered) - // slog.Info("Rendered item", "idx", idx, "height", height) + ri, ok := l.renderedItems[idx] + if !ok { + item := l.items[idx] + rendered := item.Render(l.width - style.GetHorizontalFrameSize()) + height := countLines(rendered) + + ri = renderedItem{ + content: rendered, + height: height, + } + + l.renderedItems[idx] = ri + } + + if !process { + return ri + } + + // We apply highlighting before focus styling so that focus styling + // overrides highlight styles. + // Apply highlight if item supports it + if l.mouseDownItem >= 0 { + if highlightable, ok := l.items[idx].(HighlightStylable); ok { + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() + if idx >= startItemIdx && idx <= endItemIdx { + var sLine, sCol, eLine, eCol int + if idx == startItemIdx && idx == endItemIdx { + // Single item selection + sLine = startLine + sCol = startCol + eLine = endLine + eCol = endCol + } else if idx == startItemIdx { + // First item - from start position to end of item + sLine = startLine + sCol = startCol + eLine = ri.height - 1 + eCol = 9999 // 9999 = end of line + } else if idx == endItemIdx { + // Last item - from start of item to end position + sLine = 0 + sCol = 0 + eLine = endLine + eCol = endCol + } else { + // Middle item - fully highlighted + sLine = 0 + sCol = 0 + eLine = ri.height - 1 + eCol = 9999 + } - ri := renderedItem{ - content: rendered, - height: height, + // Apply offset for styling frame + contentArea := image.Rect(0, 0, l.width, ri.height) + + hiStyle := highlightable.HighlightStyle() + slog.Info("Highlighting item", "idx", idx, + "sLine", sLine, "sCol", sCol, + "eLine", eLine, "eCol", eCol, + ) + rendered := Highlight(ri.content, contentArea, sLine, sCol, eLine, eCol, ToHighlighter(hiStyle)) + ri.content = rendered + } + } } - l.renderedItems[idx] = ri + if isFocusable { + // Apply focus/blur styling if needed + rendered := style.Render(ri.content) + height := countLines(rendered) + ri.content = rendered + ri.height = height + } return ri } @@ -242,8 +332,6 @@ func (l *List) Render() string { return "" } - slog.Info("Render", "offsetIdx", l.offsetIdx, "offsetLine", l.offsetLine, "width", l.width, "height", l.height) - var lines []string currentIdx := l.offsetIdx currentOffset := l.offsetLine @@ -251,7 +339,7 @@ func (l *List) Render() string { linesNeeded := l.height for linesNeeded > 0 && currentIdx < len(l.items) { - item := l.getItem(currentIdx) + item := l.renderItem(currentIdx, true) itemLines := strings.Split(item.content, "\n") itemHeight := len(itemLines) @@ -321,11 +409,12 @@ func (l *List) Focus() { return } - item := l.items[l.selectedIdx] - if focusable, ok := item.(Focusable); ok { - focusable.Focus() - l.invalidateItem(l.selectedIdx) - } + // item := l.items[l.selectedIdx] + // if focusable, ok := item.(Focusable); ok { + // focusable.Focus() + // l.items[l.selectedIdx] = focusable.(Item) + // l.invalidateItem(l.selectedIdx) + // } } // Blur removes the focus state from the list. @@ -335,11 +424,12 @@ func (l *List) Blur() { return } - item := l.items[l.selectedIdx] - if focusable, ok := item.(Focusable); ok { - focusable.Blur() - l.invalidateItem(l.selectedIdx) - } + // item := l.items[l.selectedIdx] + // if focusable, ok := item.(Focusable); ok { + // focusable.Blur() + // l.items[l.selectedIdx] = focusable.(Item) + // l.invalidateItem(l.selectedIdx) + // } } // ScrollToTop scrolls the list to the top. @@ -467,15 +557,292 @@ func (l *List) SelectLastInView() { } // HandleMouseDown handles mouse down events at the given line in the viewport. -func (l *List) HandleMouseDown(x, y int) { +// x and y are viewport-relative coordinates (0,0 = top-left of visible area). +// Returns true if the event was handled. +func (l *List) HandleMouseDown(x, y int) bool { + if len(l.items) == 0 { + return false + } + + // Find which item was clicked + itemIdx, itemY := l.findItemAtY(x, y) + if itemIdx < 0 { + return false + } + + l.mouseDown = true + l.mouseDownItem = itemIdx + l.mouseDownX = x + l.mouseDownY = itemY + l.mouseDragItem = itemIdx + l.mouseDragX = x + l.mouseDragY = itemY + + // Select the clicked item + l.SetSelected(itemIdx) + + return true } // HandleMouseUp handles mouse up events at the given line in the viewport. -func (l *List) HandleMouseUp(x, y int) { +// Returns true if the event was handled. +func (l *List) HandleMouseUp(x, y int) bool { + if !l.mouseDown { + return false + } + + l.mouseDown = false + + return true } // HandleMouseDrag handles mouse drag events at the given line in the viewport. -func (l *List) HandleMouseDrag(x, y int) { +// x and y are viewport-relative coordinates. +// Returns true if the event was handled. +func (l *List) HandleMouseDrag(x, y int) bool { + if !l.mouseDown { + return false + } + + if len(l.items) == 0 { + return false + } + + // Find which item we're dragging over + itemIdx, itemY := l.findItemAtY(x, y) + if itemIdx < 0 { + return false + } + + l.mouseDragItem = itemIdx + l.mouseDragX = x + l.mouseDragY = itemY + + startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := l.getHighlightRange() + + slog.Info("HandleMouseDrag", "mouseDownItem", l.mouseDownItem, + "mouseDragItem", l.mouseDragItem, + "startItemIdx", startItemIdx, + "endItemIdx", endItemIdx, + "startLine", startLine, + "startCol", startCol, + "endLine", endLine, + "endCol", endCol, + ) + + // for i := startItemIdx; i <= endItemIdx; i++ { + // item := l.getItem(i) + // itemHi, ok := l.items[i].(Highlightable) + // if ok { + // if i == startItemIdx && i == endItemIdx { + // // Single item selection + // itemHi.SetHighlight(startLine, startCol, endLine, endCol) + // } else if i == startItemIdx { + // // First item - from start position to end of item + // itemHi.SetHighlight(startLine, startCol, item.height-1, 9999) // 9999 = end of line + // } else if i == endItemIdx { + // // Last item - from start of item to end position + // itemHi.SetHighlight(0, 0, endLine, endCol) + // } else { + // // Middle item - fully highlighted + // itemHi.SetHighlight(0, 0, item.height-1, 9999) + // } + // + // // Invalidate item to re-render + // l.items[i] = itemHi.(Item) + // l.invalidateItem(i) + // } + // } + + // Update highlight if item supports it + // l.updateHighlight() + + return true +} + +// ClearHighlight clears any active text highlighting. +func (l *List) ClearHighlight() { + // for i, item := range l.renderedItems { + // if !item.highlighted { + // continue + // } + // if h, ok := l.items[i].(Highlightable); ok { + // h.SetHighlight(-1, -1, -1, -1) + // l.items[i] = h.(Item) + // l.invalidateItem(i) + // } + // } + l.mouseDownItem = -1 + l.mouseDragItem = -1 + l.lastHighlighted = make(map[int]bool) +} + +// findItemAtY finds the item at the given viewport y coordinate. +// Returns the item index and the y offset within that item. It returns -1, -1 +// if no item is found. +func (l *List) findItemAtY(_, y int) (itemIdx int, itemY int) { + if y < 0 || y >= l.height { + return -1, -1 + } + + // Walk through visible items to find which one contains this y + currentIdx := l.offsetIdx + currentLine := -l.offsetLine // Negative because offsetLine is how many lines are hidden + + for currentIdx < len(l.items) && currentLine < l.height { + item := l.getItem(currentIdx) + itemEndLine := currentLine + item.height + + // Check if y is within this item's visible range + if y >= currentLine && y < itemEndLine { + // Found the item, calculate itemY (offset within the item) + itemY = y - currentLine + return currentIdx, itemY + } + + // Move to next item + currentLine = itemEndLine + if l.gap > 0 { + currentLine += l.gap + } + currentIdx++ + } + + return -1, -1 +} + +// getHighlightRange returns the current highlight range. +func (l *List) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) { + if l.mouseDownItem < 0 { + return -1, -1, -1, -1, -1, -1 + } + + downItemIdx := l.mouseDownItem + dragItemIdx := l.mouseDragItem + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || + (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + startLine = l.mouseDownY + startCol = l.mouseDownX + endItemIdx = dragItemIdx + endLine = l.mouseDragY + endCol = l.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + startLine = l.mouseDragY + startCol = l.mouseDragX + endItemIdx = downItemIdx + endLine = l.mouseDownY + endCol = l.mouseDownX + } + + slog.Info("Apply highlight", + "startItemIdx", startItemIdx, + "endItemIdx", endItemIdx, + "startLine", startLine, + "startCol", startCol, + "endLine", endLine, + "endCol", endCol, + ) + + return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol +} + +// updateHighlight updates the highlight range for highlightable items. +// Supports highlighting across multiple items and respects drag direction. +func (l *List) updateHighlight() { + if l.mouseDownItem < 0 { + return + } + + // Get start and end item indices + downItemIdx := l.mouseDownItem + dragItemIdx := l.mouseDragItem + + // Determine selection direction + draggingDown := dragItemIdx > downItemIdx || + (dragItemIdx == downItemIdx && l.mouseDragY > l.mouseDownY) || + (dragItemIdx == downItemIdx && l.mouseDragY == l.mouseDownY && l.mouseDragX >= l.mouseDownX) + + // Determine actual start and end based on direction + var startItemIdx, endItemIdx int + var startLine, startCol, endLine, endCol int + + if draggingDown { + // Normal forward selection + startItemIdx = downItemIdx + endItemIdx = dragItemIdx + startLine = l.mouseDownY + startCol = l.mouseDownX + endLine = l.mouseDragY + endCol = l.mouseDragX + } else { + // Backward selection (dragging up) + startItemIdx = dragItemIdx + endItemIdx = downItemIdx + startLine = l.mouseDragY + startCol = l.mouseDragX + endLine = l.mouseDownY + endCol = l.mouseDownX + } + + slog.Info("Update highlight", "startItemIdx", startItemIdx, "endItemIdx", endItemIdx, + "startLine", startLine, "startCol", startCol, + "endLine", endLine, "endCol", endCol, + "draggingDown", draggingDown, + ) + + // Track newly highlighted items + // newHighlighted := make(map[int]bool) + + // Clear highlights on items that are no longer in range + // for i := range l.lastHighlighted { + // if i < startItemIdx || i > endItemIdx { + // if h, ok := l.items[i].(Highlightable); ok { + // h.SetHighlight(-1, -1, -1, -1) + // l.items[i] = h.(Item) + // l.invalidateItem(i) + // } + // } + // } + + // Highlight all items in range + // for idx := startItemIdx; idx <= endItemIdx; idx++ { + // item, ok := l.items[idx].(Highlightable) + // if !ok { + // continue + // } + // + // renderedItem := l.getItem(idx) + // + // if idx == startItemIdx && idx == endItemIdx { + // // Single item selection + // item.SetHighlight(startLine, startCol, endLine, endCol) + // } else if idx == startItemIdx { + // // First item - from start position to end of item + // item.SetHighlight(startLine, startCol, renderedItem.height-1, 9999) // 9999 = end of line + // } else if idx == endItemIdx { + // // Last item - from start of item to end position + // item.SetHighlight(0, 0, endLine, endCol) + // } else { + // // Middle item - fully highlighted + // item.SetHighlight(0, 0, renderedItem.height-1, 9999) + // } + // + // l.items[idx] = item.(Item) + // + // l.invalidateItem(idx) + // newHighlighted[idx] = true + // } + // + // l.lastHighlighted = newHighlighted } // countLines counts the number of lines in a string. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 8872081b01794c5a3acad1d0057c6b4d49fea8da..b34b6e43aeb9294bf33a835bbc4c5ba786082e49 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,198 +1,11 @@ package model import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/lazylist" - "github.com/charmbracelet/crush/internal/ui/list" - "github.com/charmbracelet/crush/internal/ui/styles" uv "github.com/charmbracelet/ultraviolet" ) -// ChatAnimItem represents a chat animation item in the chat UI. -type ChatAnimItem struct { - list.BaseFocusable - anim *anim.Anim -} - -var ( - _ list.Item = (*ChatAnimItem)(nil) - _ list.Focusable = (*ChatAnimItem)(nil) -) - -// NewChatAnimItem creates a new instance of [ChatAnimItem]. -func NewChatAnimItem(a *anim.Anim) *ChatAnimItem { - m := new(ChatAnimItem) - return m -} - -// Init initializes the chat animation item. -func (c *ChatAnimItem) Init() tea.Cmd { - return c.anim.Init() -} - -// Step advances the animation by one step. -func (c *ChatAnimItem) Step() tea.Cmd { - return c.anim.Step() -} - -// SetLabel sets the label for the animation item. -func (c *ChatAnimItem) SetLabel(label string) { - c.anim.SetLabel(label) -} - -// Draw implements list.Item. -func (c *ChatAnimItem) Draw(scr uv.Screen, area uv.Rectangle) { - styled := uv.NewStyledString(c.anim.View()) - styled.Draw(scr, area) -} - -// Height implements list.Item. -func (c *ChatAnimItem) Height(int) int { - return 1 -} - -// ChatNoContentItem represents a chat item with no content. -type ChatNoContentItem struct { - *list.StringItem -} - -// NewChatNoContentItem creates a new instance of [ChatNoContentItem]. -func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem { - c := new(ChatNoContentItem) - c.StringItem = list.NewStringItem("No message content"). - WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent) - return c -} - -// ChatMessageItem represents a chat message item in the chat UI. -type ChatMessageItem struct { - item list.Item - msg message.Message -} - -var ( - _ list.Item = (*ChatMessageItem)(nil) - _ list.Focusable = (*ChatMessageItem)(nil) - _ list.Highlightable = (*ChatMessageItem)(nil) -) - -// NewChatMessageItem creates a new instance of [ChatMessageItem]. -func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem { - c := new(ChatMessageItem) - - switch msg.Role { - case message.User: - item := list.NewMarkdownItem(msg.Content().String()). - WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred) - item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) - // TODO: Add attachments - c.item = item - default: - var thinkingContent string - content := msg.Content().String() - thinking := msg.IsThinking() - finished := msg.IsFinished() - finishedData := msg.FinishPart() - reasoningContent := msg.ReasoningContent() - reasoningThinking := strings.TrimSpace(reasoningContent.Thinking) - - if finished && content == "" && finishedData.Reason == message.FinishReasonError { - tag := t.Chat.Message.ErrorTag.Render("ERROR") - title := t.Chat.Message.ErrorTitle.Render(finishedData.Message) - details := t.Chat.Message.ErrorDetails.Render(finishedData.Details) - errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details) - - item := list.NewStringItem(errContent). - WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred) - - c.item = item - - return c - } - - if thinking || reasoningThinking != "" { - // TODO: animation item? - // TODO: thinking item - thinkingContent = reasoningThinking - } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled { - content = "*Canceled*" - } - - var parts []string - if thinkingContent != "" { - parts = append(parts, thinkingContent) - } - - if content != "" { - if len(parts) > 0 { - parts = append(parts, "") - } - parts = append(parts, content) - } - - item := list.NewMarkdownItem(strings.Join(parts, "\n")). - WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred) - item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) - - c.item = item - } - - return c -} - -// Draw implements list.Item. -func (c *ChatMessageItem) Draw(scr uv.Screen, area uv.Rectangle) { - c.item.Draw(scr, area) -} - -// Height implements list.Item. -func (c *ChatMessageItem) Height(width int) int { - return c.item.Height(width) -} - -// Blur implements list.Focusable. -func (c *ChatMessageItem) Blur() { - if blurable, ok := c.item.(list.Focusable); ok { - blurable.Blur() - } -} - -// Focus implements list.Focusable. -func (c *ChatMessageItem) Focus() { - if focusable, ok := c.item.(list.Focusable); ok { - focusable.Focus() - } -} - -// IsFocused implements list.Focusable. -func (c *ChatMessageItem) IsFocused() bool { - if focusable, ok := c.item.(list.Focusable); ok { - return focusable.IsFocused() - } - return false -} - -// GetHighlight implements list.Highlightable. -func (c *ChatMessageItem) GetHighlight() (startLine int, startCol int, endLine int, endCol int) { - if highlightable, ok := c.item.(list.Highlightable); ok { - return highlightable.GetHighlight() - } - return 0, 0, 0, 0 -} - -// SetHighlight implements list.Highlightable. -func (c *ChatMessageItem) SetHighlight(startLine int, startCol int, endLine int, endCol int) { - if highlightable, ok := c.item.(list.Highlightable); ok { - highlightable.SetHighlight(startLine, startCol, endLine, endCol) - } -} - // Chat represents the chat UI model that handles chat interactions and // messages. type Chat struct { @@ -239,9 +52,11 @@ func (m *Chat) PrependItems(items ...lazylist.Item) { // AppendMessages appends a new message item to the chat list. func (m *Chat) AppendMessages(msgs ...MessageItem) { - for _, msg := range msgs { - m.AppendItems(msg) + items := make([]lazylist.Item, len(msgs)) + for i, msg := range msgs { + items[i] = msg } + m.list.AppendItems(items...) } // AppendItems appends new items to the chat list. diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go index 48bf79d2f84c0000cd0c8c7e07b9da44401997fc..7e5f732ca8e60e824b3bced67feab3667e3823af 100644 --- a/internal/ui/model/items.go +++ b/internal/ui/model/items.go @@ -2,6 +2,8 @@ package model import ( "fmt" + "image" + "log/slog" "path/filepath" "strings" "time" @@ -14,7 +16,6 @@ import ( "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/lazylist" - "github.com/charmbracelet/crush/internal/ui/list" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/ui/toolrender" ) @@ -25,38 +26,33 @@ type Identifiable interface { } // MessageItem represents a [message.Message] item that can be displayed in the -// UI and be part of a [list.List] identifiable by a unique ID. +// UI and be part of a [lazylist.List] identifiable by a unique ID. type MessageItem interface { - list.Item - list.Focusable - list.Highlightable + lazylist.Item lazylist.Item Identifiable } // MessageContentItem represents rendered message content (text, markdown, errors, etc). type MessageContentItem struct { - list.BaseFocusable - list.BaseHighlightable id string content string + role message.MessageRole isMarkdown bool maxWidth int - cache map[int]string // Cache for rendered content at different widths sty *styles.Styles } // NewMessageContentItem creates a new message content item. -func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem { +func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem { m := &MessageContentItem{ id: id, content: content, isMarkdown: isMarkdown, + role: role, maxWidth: 120, - cache: make(map[int]string), sty: sty, } - m.InitHighlight() return m } @@ -65,76 +61,38 @@ func (m *MessageContentItem) ID() string { return m.id } -// Height implements list.Item. -func (m *MessageContentItem) Height(width int) int { - // Calculate content width accounting for frame size - contentWidth := width - if style := m.CurrentStyle(); style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := m.render(contentWidth) - - // Apply focus/blur styling if configured to get accurate height - if style := m.CurrentStyle(); style != nil { - rendered = style.Render(rendered) +// FocusStyle returns the focus style. +func (m *MessageContentItem) FocusStyle() lipgloss.Style { + if m.role == message.User { + return m.sty.Chat.Message.UserFocused } - - return strings.Count(rendered, "\n") + 1 + return m.sty.Chat.Message.AssistantFocused } -// Draw implements list.Item. -func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := m.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := m.render(contentWidth) - - // Apply focus/blur styling if configured - if style != nil { - rendered = style.Render(rendered) +// BlurStyle returns the blur style. +func (m *MessageContentItem) BlurStyle() lipgloss.Style { + if m.role == message.User { + return m.sty.Chat.Message.UserBlurred } - - // Create temp buffer to draw content with highlighting - tempBuf := uv.NewScreenBuffer(width, height) - - // Draw the rendered content to temp buffer - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - // Apply highlighting if active - m.ApplyHighlight(&tempBuf, width, height, style) - - // Copy temp buffer to actual screen at the target area - tempBuf.Draw(scr, area) + return m.sty.Chat.Message.AssistantBlurred } -// Render implements lazylist.Item. -func (m *MessageContentItem) Render(width int) string { - return m.render(width) +// HighlightStyle returns the highlight style. +func (m *MessageContentItem) HighlightStyle() lipgloss.Style { + return m.sty.TextSelection } -// render renders the content at the given width, using cache if available. -func (m *MessageContentItem) render(width int) string { +// Render renders the content at the given width, using cache if available. +// +// It implements [lazylist.Item]. +func (m *MessageContentItem) Render(width int) string { + contentWidth := width // Cap width to maxWidth for markdown - cappedWidth := width + cappedWidth := contentWidth if m.isMarkdown { - cappedWidth = min(width, m.maxWidth) - } - - // Check cache first - if cached, ok := m.cache[cappedWidth]; ok { - return cached + cappedWidth = min(contentWidth, m.maxWidth) } - // Not cached - render now var rendered string if m.isMarkdown { renderer := common.MarkdownRenderer(m.sty, cappedWidth) @@ -148,30 +106,19 @@ func (m *MessageContentItem) render(width int) string { rendered = m.content } - // Cache the result - m.cache[cappedWidth] = rendered return rendered } -// SetHighlight implements list.Highlightable and extends BaseHighlightable. -func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) { - m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - m.cache = make(map[int]string) -} - // ToolCallItem represents a rendered tool call with its header and content. type ToolCallItem struct { - list.BaseFocusable - list.BaseHighlightable + BaseFocusable + BaseHighlightable id string toolCall message.ToolCall toolResult message.ToolResult cancelled bool isNested bool maxWidth int - cache map[int]cachedToolRender // Cache for rendered content at different widths - cacheKey string // Key to invalidate cache when content changes sty *styles.Styles } @@ -190,8 +137,6 @@ func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.To cancelled: cancelled, isNested: isNested, maxWidth: 120, - cache: make(map[int]cachedToolRender), - cacheKey: generateCacheKey(toolCall, toolResult, cancelled), sty: sty, } t.InitHighlight() @@ -209,116 +154,59 @@ func (t *ToolCallItem) ID() string { return t.id } -// Height implements list.Item. -func (t *ToolCallItem) Height(width int) int { - // Calculate content width accounting for frame size - contentWidth := width - frameSize := 0 - if style := t.CurrentStyle(); style != nil { - frameSize = style.GetHorizontalFrameSize() - contentWidth -= frameSize - } - - cached := t.renderCached(contentWidth) - - // Add frame size to height if needed - height := cached.height - if frameSize > 0 { - // Frame can add to height (borders, padding) - if style := t.CurrentStyle(); style != nil { - // Quick render to get accurate height with frame - rendered := style.Render(cached.content) - height = strings.Count(rendered, "\n") + 1 - } +// FocusStyle returns the focus style. +func (t *ToolCallItem) FocusStyle() lipgloss.Style { + if t.focusStyle != nil { + return *t.focusStyle } - - return height + return lipgloss.Style{} } -// Render implements lazylist.Item. -func (t *ToolCallItem) Render(width int) string { - cached := t.renderCached(width) - return cached.content -} - -// Draw implements list.Item. -func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := t.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() +// BlurStyle returns the blur style. +func (t *ToolCallItem) BlurStyle() lipgloss.Style { + if t.blurStyle != nil { + return *t.blurStyle } - - cached := t.renderCached(contentWidth) - rendered := cached.content - - if style != nil { - rendered = style.Render(rendered) - } - - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - t.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) + return lipgloss.Style{} } -// renderCached renders the tool call at the given width with caching. -func (t *ToolCallItem) renderCached(width int) cachedToolRender { - cappedWidth := min(width, t.maxWidth) - - // Check if we have a valid cache entry - if cached, ok := t.cache[cappedWidth]; ok { - return cached - } +// HighlightStyle returns the highlight style. +func (t *ToolCallItem) HighlightStyle() lipgloss.Style { + return t.sty.TextSelection +} +// Render implements lazylist.Item. +func (t *ToolCallItem) Render(width int) string { // Render the tool call ctx := &toolrender.RenderContext{ Call: t.toolCall, Result: t.toolResult, Cancelled: t.cancelled, IsNested: t.isNested, - Width: cappedWidth, + Width: width, Styles: t.sty, } rendered := toolrender.Render(ctx) - height := strings.Count(rendered, "\n") + 1 + return rendered - cached := cachedToolRender{ - content: rendered, - height: height, - } - t.cache[cappedWidth] = cached - return cached + // return t.RenderWithHighlight(rendered, width, style) } // SetHighlight implements list.Highlightable. func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) { t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - // Clear cache when highlight changes - t.cache = make(map[int]cachedToolRender) } // UpdateResult updates the tool result and invalidates the cache if needed. func (t *ToolCallItem) UpdateResult(result message.ToolResult) { - newKey := generateCacheKey(t.toolCall, result, t.cancelled) - if newKey != t.cacheKey { - t.toolResult = result - t.cacheKey = newKey - t.cache = make(map[int]cachedToolRender) - } + t.toolResult = result } // AttachmentItem represents a file attachment in a user message. type AttachmentItem struct { - list.BaseFocusable - list.BaseHighlightable + BaseFocusable + BaseHighlightable id string filename string path string @@ -342,33 +230,29 @@ func (a *AttachmentItem) ID() string { return a.id } -// Height implements list.Item. -func (a *AttachmentItem) Height(width int) int { - return 1 +// FocusStyle returns the focus style. +func (a *AttachmentItem) FocusStyle() lipgloss.Style { + if a.focusStyle != nil { + return *a.focusStyle + } + return lipgloss.Style{} } -// Render implements lazylist.Item. -func (a *AttachmentItem) Render(width int) string { - const maxFilenameWidth = 10 - return a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( - " %s %s ", - styles.DocumentIcon, - ansi.Truncate(a.filename, maxFilenameWidth, "..."), - )) +// BlurStyle returns the blur style. +func (a *AttachmentItem) BlurStyle() lipgloss.Style { + if a.blurStyle != nil { + return *a.blurStyle + } + return lipgloss.Style{} } -// Draw implements list.Item. -func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := a.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } +// HighlightStyle returns the highlight style. +func (a *AttachmentItem) HighlightStyle() lipgloss.Style { + return a.sty.TextSelection +} +// Render implements lazylist.Item. +func (a *AttachmentItem) Render(width int) string { const maxFilenameWidth = 10 content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( " %s %s ", @@ -376,28 +260,18 @@ func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) { ansi.Truncate(a.filename, maxFilenameWidth, "..."), )) - if style != nil { - content = style.Render(content) - } + return content - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(content) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - a.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) + // return a.RenderWithHighlight(content, width, a.CurrentStyle()) } // ThinkingItem represents thinking/reasoning content in assistant messages. type ThinkingItem struct { - list.BaseFocusable - list.BaseHighlightable id string thinking string duration time.Duration finished bool maxWidth int - cache map[int]string sty *styles.Styles } @@ -409,10 +283,8 @@ func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, duration: duration, finished: finished, maxWidth: 120, - cache: make(map[int]string), sty: sty, } - t.InitHighlight() return t } @@ -421,57 +293,25 @@ func (t *ThinkingItem) ID() string { return t.id } -// Height implements list.Item. -func (t *ThinkingItem) Height(width int) int { - // Calculate content width accounting for frame size - contentWidth := width - if style := t.CurrentStyle(); style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := t.render(contentWidth) - return strings.Count(rendered, "\n") + 1 +// FocusStyle returns the focus style. +func (t *ThinkingItem) FocusStyle() lipgloss.Style { + return t.sty.Chat.Message.AssistantFocused } -// Render implements lazylist.Item. -func (t *ThinkingItem) Render(width int) string { - return t.render(width) +// BlurStyle returns the blur style. +func (t *ThinkingItem) BlurStyle() lipgloss.Style { + return t.sty.Chat.Message.AssistantBlurred } -// Draw implements list.Item. -func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := t.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - rendered := t.render(contentWidth) - - if style != nil { - rendered = style.Render(rendered) - } - - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(rendered) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - t.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) +// HighlightStyle returns the highlight style. +func (t *ThinkingItem) HighlightStyle() lipgloss.Style { + return t.sty.TextSelection } -// render renders the thinking content. -func (t *ThinkingItem) render(width int) string { +// Render implements lazylist.Item. +func (t *ThinkingItem) Render(width int) string { cappedWidth := min(width, t.maxWidth) - if cached, ok := t.cache[cappedWidth]; ok { - return cached - } - renderer := common.PlainMarkdownRenderer(cappedWidth - 1) rendered, err := renderer.Render(t.thinking) if err != nil { @@ -501,20 +341,11 @@ func (t *ThinkingItem) render(width int) string { result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent) - t.cache[cappedWidth] = result return result } -// SetHighlight implements list.Highlightable. -func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) { - t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol) - t.cache = make(map[int]string) -} - // SectionHeaderItem represents a section header (e.g., assistant info). type SectionHeaderItem struct { - list.BaseFocusable - list.BaseHighlightable id string modelName string duration time.Duration @@ -531,7 +362,6 @@ func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *sty isSectionHeader: true, sty: sty, } - s.InitHighlight() return s } @@ -545,49 +375,25 @@ func (s *SectionHeaderItem) IsSectionHeader() bool { return s.isSectionHeader } -// Height implements list.Item. -func (s *SectionHeaderItem) Height(width int) int { - return 1 +// FocusStyle returns the focus style. +func (s *SectionHeaderItem) FocusStyle() lipgloss.Style { + return s.sty.Chat.Message.AssistantFocused +} + +// BlurStyle returns the blur style. +func (s *SectionHeaderItem) BlurStyle() lipgloss.Style { + return s.sty.Chat.Message.AssistantBlurred } // Render implements lazylist.Item. func (s *SectionHeaderItem) Render(width int) string { - return s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s", + content := s.sty.Chat.Message.SectionHeader.Render(fmt.Sprintf("%s %s %s", s.sty.Subtle.Render(styles.ModelIcon), s.sty.Muted.Render(s.modelName), s.sty.Subtle.Render(s.duration.String()), )) -} - -// Draw implements list.Item. -func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) { - width := area.Dx() - height := area.Dy() - - // Calculate content width accounting for frame size - contentWidth := width - style := s.CurrentStyle() - if style != nil { - contentWidth -= style.GetHorizontalFrameSize() - } - - infoMsg := s.sty.Subtle.Render(s.duration.String()) - icon := s.sty.Subtle.Render(styles.ModelIcon) - modelFormatted := s.sty.Muted.Render(s.modelName) - content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) - - content = s.sty.Chat.Message.SectionHeader.Render(content) - - if style != nil { - content = style.Render(content) - } - tempBuf := uv.NewScreenBuffer(width, height) - styled := uv.NewStyledString(content) - styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) - - s.ApplyHighlight(&tempBuf, width, height, style) - tempBuf.Draw(scr, area) + return content } // GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns @@ -595,8 +401,7 @@ func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) { // // For assistant messages with tool calls, pass a toolResults map to link results. // Use BuildToolResultMap to create this map from all messages in a session. -func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { - sty := styles.DefaultStyles() +func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { var items []MessageItem // Skip tool result messages - they're displayed inline with tool calls @@ -622,10 +427,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe item := NewMessageContentItem( fmt.Sprintf("%s-content", msg.ID), content, + msg.Role, true, // User messages are markdown - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -636,8 +441,9 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe fmt.Sprintf("%s-attachment-%d", msg.ID, i), filename, attachment.Path, - &sty, + sty, ) + item.SetHighlightStyle(ToStyler(sty.TextSelection)) item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -666,7 +472,7 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe fmt.Sprintf("%s-header", msg.ID), modelName, duration, - &sty, + sty, ) items = append(items, header) } @@ -684,9 +490,8 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe reasoning.Thinking, duration, reasoning.FinishedAt > 0, - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -703,10 +508,10 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe item := NewMessageContentItem( fmt.Sprintf("%s-content", msg.ID), "*Canceled*", + msg.Role, true, - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) case message.FinishReasonError: // Render error @@ -719,20 +524,20 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe item := NewMessageContentItem( fmt.Sprintf("%s-error", msg.ID), errorContent, + msg.Role, false, - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } } else if content != "" { item := NewMessageContentItem( fmt.Sprintf("%s-content", msg.ID), content, + msg.Role, true, // Assistant messages are markdown - &sty, + sty, ) - item.SetFocusStyles(&focusStyle, &blurStyle) items = append(items, item) } @@ -757,9 +562,11 @@ func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolRe result, false, // cancelled state would need to be tracked separately false, // nested state would be detected from tool results - &sty, + sty, ) + item.SetHighlightStyle(ToStyler(sty.TextSelection)) + // Tool calls use muted style with optional focus border item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred) @@ -788,3 +595,299 @@ func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResu } return resultMap } + +// BaseFocusable provides common focus state and styling for items. +// Embed this type to add focus behavior to any item. +type BaseFocusable struct { + focused bool + focusStyle *lipgloss.Style + blurStyle *lipgloss.Style +} + +// Focus implements Focusable interface. +func (b *BaseFocusable) Focus(width int, content string) string { + if b.focusStyle != nil { + return b.focusStyle.Render(content) + } + return content +} + +// Blur implements Focusable interface. +func (b *BaseFocusable) Blur(width int, content string) string { + if b.blurStyle != nil { + return b.blurStyle.Render(content) + } + return content +} + +// Focus implements Focusable interface. +// func (b *BaseFocusable) Focus() { +// b.focused = true +// } + +// Blur implements Focusable interface. +// func (b *BaseFocusable) Blur() { +// b.focused = false +// } + +// Focused implements Focusable interface. +func (b *BaseFocusable) Focused() bool { + return b.focused +} + +// HasFocusStyles returns true if both focus and blur styles are configured. +func (b *BaseFocusable) HasFocusStyles() bool { + return b.focusStyle != nil && b.blurStyle != nil +} + +// CurrentStyle returns the current style based on focus state. +// Returns nil if no styles are configured, or if the current state's style is nil. +func (b *BaseFocusable) CurrentStyle() *lipgloss.Style { + if b.focused { + return b.focusStyle + } + return b.blurStyle +} + +// SetFocusStyles sets the focus and blur styles. +func (b *BaseFocusable) SetFocusStyles(focusStyle, blurStyle *lipgloss.Style) { + b.focusStyle = focusStyle + b.blurStyle = blurStyle +} + +// CellStyler defines a function that styles a [uv.Style]. +type CellStyler func(uv.Style) uv.Style + +// BaseHighlightable provides common highlight state for items. +// Embed this type to add highlight behavior to any item. +type BaseHighlightable struct { + highlightStartLine int + highlightStartCol int + highlightEndLine int + highlightEndCol int + highlightStyle CellStyler +} + +// SetHighlight implements Highlightable interface. +func (b *BaseHighlightable) SetHighlight(startLine, startCol, endLine, endCol int) { + b.highlightStartLine = startLine + b.highlightStartCol = startCol + b.highlightEndLine = endLine + b.highlightEndCol = endCol +} + +// GetHighlight implements Highlightable interface. +func (b *BaseHighlightable) GetHighlight() (startLine, startCol, endLine, endCol int) { + return b.highlightStartLine, b.highlightStartCol, b.highlightEndLine, b.highlightEndCol +} + +// HasHighlight returns true if a highlight region is set. +func (b *BaseHighlightable) HasHighlight() bool { + return b.highlightStartLine >= 0 || b.highlightStartCol >= 0 || + b.highlightEndLine >= 0 || b.highlightEndCol >= 0 +} + +// SetHighlightStyle sets the style function used for highlighting. +func (b *BaseHighlightable) SetHighlightStyle(style CellStyler) { + b.highlightStyle = style +} + +// GetHighlightStyle returns the current highlight style function. +func (b *BaseHighlightable) GetHighlightStyle() CellStyler { + return b.highlightStyle +} + +// InitHighlight initializes the highlight fields with default values. +func (b *BaseHighlightable) InitHighlight() { + b.highlightStartLine = -1 + b.highlightStartCol = -1 + b.highlightEndLine = -1 + b.highlightEndCol = -1 + b.highlightStyle = ToStyler(lipgloss.NewStyle().Reverse(true)) +} + +// Highlight implements Highlightable interface. +func (b *BaseHighlightable) Highlight(width int, content string, startLine, startCol, endLine, endCol int) string { + b.SetHighlight(startLine, startCol, endLine, endCol) + return b.RenderWithHighlight(content, width, nil) +} + +// RenderWithHighlight renders content with optional focus styling and highlighting. +// This is a helper that combines common rendering logic for all items. +// The content parameter should be the raw rendered content before focus styling. +// The style parameter should come from CurrentStyle() and may be nil. +func (b *BaseHighlightable) RenderWithHighlight(content string, width int, style *lipgloss.Style) string { + // Apply focus/blur styling if configured + rendered := content + if style != nil { + rendered = style.Render(rendered) + } + + if !b.HasHighlight() { + return rendered + } + + height := lipgloss.Height(rendered) + + // Create temp buffer to draw content with highlighting + tempBuf := uv.NewScreenBuffer(width, height) + + // Draw the rendered content to temp buffer + styled := uv.NewStyledString(rendered) + styled.Draw(&tempBuf, uv.Rect(0, 0, width, height)) + + // Apply highlighting if active + b.ApplyHighlight(&tempBuf, width, height, style) + + return tempBuf.Render() +} + +// ApplyHighlight applies highlighting to a screen buffer. +// This should be called after drawing content to the buffer. +func (b *BaseHighlightable) ApplyHighlight(buf *uv.ScreenBuffer, width, height int, style *lipgloss.Style) { + if b.highlightStartLine < 0 { + return + } + + var ( + topMargin, topBorder, topPadding int + rightMargin, rightBorder, rightPadding int + bottomMargin, bottomBorder, bottomPadding int + leftMargin, leftBorder, leftPadding int + ) + if style != nil { + topMargin, rightMargin, bottomMargin, leftMargin = style.GetMargin() + topBorder, rightBorder, bottomBorder, leftBorder = style.GetBorderTopSize(), + style.GetBorderRightSize(), + style.GetBorderBottomSize(), + style.GetBorderLeftSize() + topPadding, rightPadding, bottomPadding, leftPadding = style.GetPadding() + } + + slog.Info("Applying highlight", + "highlightStartLine", b.highlightStartLine, + "highlightStartCol", b.highlightStartCol, + "highlightEndLine", b.highlightEndLine, + "highlightEndCol", b.highlightEndCol, + "width", width, + "height", height, + "margins", fmt.Sprintf("%d,%d,%d,%d", topMargin, rightMargin, bottomMargin, leftMargin), + "borders", fmt.Sprintf("%d,%d,%d,%d", topBorder, rightBorder, bottomBorder, leftBorder), + "paddings", fmt.Sprintf("%d,%d,%d,%d", topPadding, rightPadding, bottomPadding, leftPadding), + ) + + // Calculate content area offsets + contentArea := image.Rectangle{ + Min: image.Point{ + X: leftMargin + leftBorder + leftPadding, + Y: topMargin + topBorder + topPadding, + }, + Max: image.Point{ + X: width - (rightMargin + rightBorder + rightPadding), + Y: height - (bottomMargin + bottomBorder + bottomPadding), + }, + } + + for y := b.highlightStartLine; y <= b.highlightEndLine && y < height; y++ { + if y >= buf.Height() { + break + } + + line := buf.Line(y) + + // Determine column range for this line + startCol := 0 + if y == b.highlightStartLine { + startCol = min(b.highlightStartCol, len(line)) + } + + endCol := len(line) + if y == b.highlightEndLine { + endCol = min(b.highlightEndCol, len(line)) + } + + // Track last non-empty position as we go + lastContentX := -1 + + // Single pass: check content and track last non-empty position + for x := startCol; x < endCol; x++ { + cell := line.At(x) + if cell == nil { + continue + } + + // Update last content position if non-empty + if cell.Content != "" && cell.Content != " " { + lastContentX = x + } + } + + // Only apply highlight up to last content position + highlightEnd := endCol + if lastContentX >= 0 { + highlightEnd = lastContentX + 1 + } else if lastContentX == -1 { + highlightEnd = startCol // No content on this line + } + + // Apply highlight style only to cells with content + for x := startCol; x < highlightEnd; x++ { + if !image.Pt(x, y).In(contentArea) { + continue + } + cell := line.At(x) + cell.Style = b.highlightStyle(cell.Style) + } + } +} + +// ToStyler converts a [lipgloss.Style] to a [CellStyler]. +func ToStyler(lgStyle lipgloss.Style) CellStyler { + return func(uv.Style) uv.Style { + return ToStyle(lgStyle) + } +} + +// ToStyle converts an inline [lipgloss.Style] to a [uv.Style]. +func ToStyle(lgStyle lipgloss.Style) uv.Style { + var uvStyle uv.Style + + // Colors are already color.Color + uvStyle.Fg = lgStyle.GetForeground() + uvStyle.Bg = lgStyle.GetBackground() + + // Build attributes using bitwise OR + var attrs uint8 + + if lgStyle.GetBold() { + attrs |= uv.AttrBold + } + + if lgStyle.GetItalic() { + attrs |= uv.AttrItalic + } + + if lgStyle.GetUnderline() { + uvStyle.Underline = uv.UnderlineSingle + } + + if lgStyle.GetStrikethrough() { + attrs |= uv.AttrStrikethrough + } + + if lgStyle.GetFaint() { + attrs |= uv.AttrFaint + } + + if lgStyle.GetBlink() { + attrs |= uv.AttrBlink + } + + if lgStyle.GetReverse() { + attrs |= uv.AttrReverse + } + + uvStyle.Attrs = attrs + + return uvStyle +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5e24004d84a56d8c07bad623e59c7c2df6cdb31b..6d58ce3b1b6ec5172c0ec3cfe0f1be239874ed60 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -7,7 +7,6 @@ import ( "os" "slices" "strings" - "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" @@ -171,8 +170,8 @@ func (m *UI) Init() tea.Cmd { allSessions, _ := m.com.App.Sessions.List(context.Background()) if len(allSessions) > 0 { cmds = append(cmds, func() tea.Msg { - time.Sleep(2 * time.Second) - return m.loadSession(allSessions[1].ID)() + // time.Sleep(2 * time.Second) + return m.loadSession(allSessions[0].ID)() }) } return tea.Batch(cmds...) @@ -207,7 +206,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Add messages to chat with linked tool results items := make([]MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { - items = append(items, GetMessageItems(msg, toolResultMap)...) + items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...) } m.chat.AppendMessages(items...) @@ -247,7 +246,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseClickMsg: switch m.state { case uiChat: - m.chat.HandleMouseDown(msg.X, msg.Y) + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseDown(x, y) } case tea.MouseMotionMsg: @@ -258,13 +261,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else if msg.Y >= m.chat.Height()-1 { m.chat.ScrollBy(1) } - m.chat.HandleMouseDrag(msg.X, msg.Y) + + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseDrag(x, y) } case tea.MouseReleaseMsg: switch m.state { case uiChat: - m.chat.HandleMouseUp(msg.X, msg.Y) + x, y := msg.X, msg.Y + // Adjust for chat area position + x -= m.layout.main.Min.X + y -= m.layout.main.Min.Y + m.chat.HandleMouseUp(x, y) } case tea.MouseWheelMsg: switch m.state {