Detailed changes
@@ -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),
+ },
+ }
+}
@@ -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
}
@@ -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.
@@ -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.
@@ -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
+}
@@ -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 {