Files
Christian RochaandCharm Crush 65f30a1343 perf(chat): skip re-parsing the rendered chat when nothing has changed
Each frame the chat list is turned into a string with ANSI color codes
that we then have to parse back into cells before drawing. When the
chat list output is the same as the previous frame, we now reuse the
cells we already produced and skip the parsing step.

Co-Authored-By: Charm Crush <crush@charm.land>
2026-05-12 16:41:14 -04:00

1035 lines
29 KiB
Go

package model
import (
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/chat"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/list"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/clipperhouse/displaywidth"
"github.com/clipperhouse/uax29/v2/words"
)
// Constants for multi-click detection.
const (
doubleClickThreshold = 400 * time.Millisecond // 0.4s is typical double-click threshold
clickTolerance = 2 // x,y tolerance for double/tripple click
)
// DelayedClickMsg is sent after the double-click threshold to trigger a
// single-click action (like expansion) if no double-click occurred.
type DelayedClickMsg struct {
ClickID int
ItemIdx int
X, Y int
}
// Chat represents the chat UI model that handles chat interactions and
// messages.
type Chat struct {
com *common.Common
list *list.List
idInxMap map[string]int // Map of message IDs to their indices in the list
// Animation visibility optimization: track animations paused due to items
// being scrolled out of view. When items become visible again, their
// animations are restarted.
pausedAnimations map[string]struct{}
// 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
// Click tracking for double/triple clicks
lastClickTime time.Time
lastClickX int
lastClickY int
clickCount int
// Pending single click action (delayed to detect double-click)
pendingClickID int // Incremented on each click to invalidate old pending clicks
// follow is a flag to indicate whether the view should auto-scroll to
// bottom on new messages.
follow bool
// drawCache memoizes the decoded form of the last list.Render output so
// repeat frames with byte-identical content skip the per-cell ANSI
// reparse that uv.StyledString.Draw performs every call. See F9
// (docs/notes/2026-05-12-chat-rendering-perf.md §4.8). Bounded to one
// entry; invalidated implicitly by string inequality on the next Draw.
drawCache *chatDrawCache
}
// chatDrawCache holds the pre-decoded form of the last list.Render output.
// The cache is keyed by the rendered string and the screen's width method
// (graphemes vs wcwidth pick different decoders inside ultraviolet's
// printString, so a cached buffer is only valid for the method it was
// decoded with). The cached buffer is independent of the draw area, so
// resize / scroll changes that produce the same string still hit. We cannot
// use uv.StyledString.Lines because it bottoms out at the first iteration
// against a zero-bounds rectangle (see ultraviolet styled.go line 45 — the
// shared printString loop's `y >= bounds.Max.Y` exit applies to the
// line-building branch too). A Buffer of the rendered text's natural
// dimensions is the cheapest correct shape: StyledString.Draw runs once on
// miss to populate it, and Buffer.Draw is an O(cells) cell copy with no
// ANSI re-parse on hit.
type chatDrawCache struct {
rendered string
method ansi.Method
buf uv.ScreenBuffer
}
// NewChat creates a new instance of [Chat] that handles chat interactions and
// messages.
func NewChat(com *common.Common) *Chat {
c := &Chat{
com: com,
idInxMap: make(map[string]int),
pausedAnimations: make(map[string]struct{}),
}
l := list.NewList()
l.SetGap(1)
l.RegisterRenderCallback(c.applyHighlightRange)
l.RegisterRenderCallback(list.FocusedRenderCallback(l))
c.list = l
c.mouseDownItem = -1
c.mouseDragItem = -1
return c
}
// Height returns the height of the chat view port.
func (m *Chat) Height() int {
return m.list.Height()
}
// Draw renders the chat UI component to the screen and the given area.
//
// The list's rendered output is cached in decoded form (see chatDrawCache) so
// that frames with byte-identical content skip the ANSI reparse that
// uv.StyledString.Draw performs on every call. The cache is keyed by the
// rendered string and the screen's width method; area / scroll changes do not
// invalidate it.
func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
rendered := m.list.Render()
method, ok := scr.WidthMethod().(ansi.Method)
if !ok {
// Width method isn't an ansi.Method (unlikely in practice — both
// TerminalScreen and ScreenBuffer store ansi.Method). Fall back
// to the uncached path so behavior matches upstream exactly.
uv.NewStyledString(rendered).Draw(scr, area)
return
}
if m.drawCache == nil ||
m.drawCache.rendered != rendered ||
m.drawCache.method != method {
m.drawCache = newChatDrawCache(rendered, method)
}
drawCachedBuffer(scr, area, m.drawCache.buf)
}
// newChatDrawCache builds a chatDrawCache for the given rendered string by
// running uv.StyledString.Draw into a fresh buffer sized to the text's
// natural bounds under the active width method. This is the only place
// ANSI decoding happens for cached frames — subsequent draws reuse buf
// via drawCachedBuffer.
//
// We can't use uv.StyledString.Bounds() here: it is hard-coded to
// ansi.GraphemeWidth, while StyledString.Draw lays cells using the
// destination buffer's WidthMethod (which we capture in `method`). For
// strings where graphemes and wcwidth disagree (emoji ZWJ sequences,
// some CJK, certain combining marks) the two answers diverge, leaving
// the cached buffer either too small (trailing cells dropped on hit) or
// too large (dead cells past the live content). Computing dimensions
// with `method.StringWidth` per line matches what printString tallies
// cell-by-cell, since both decode ANSI sequences and use the same width
// method.
func newChatDrawCache(rendered string, method ansi.Method) *chatDrawCache {
w, h := renderedBounds(rendered, method)
if w <= 0 {
w = 1
}
if h <= 0 {
h = 1
}
buf := uv.NewScreenBuffer(w, h)
buf.Method = method
uv.NewStyledString(rendered).Draw(buf, buf.Bounds())
return &chatDrawCache{
rendered: rendered,
method: method,
buf: buf,
}
}
// renderedBounds returns the (width, height) cell extent of rendered
// when laid out by method. Width is the widest line's StringWidth (which
// strips ANSI sequences and tallies cells via method, exactly like
// printString); height is the line count. Both match what
// uv.StyledString.Draw will write into a buffer whose WidthMethod is
// method, so the cache buffer is always sized to fit the live content.
func renderedBounds(rendered string, method ansi.Method) (w, h int) {
for line := range strings.SplitSeq(rendered, "\n") {
w = max(w, method.StringWidth(line))
h++
}
return w, h
}
// drawCachedBuffer blits a previously-decoded buffer into scr at area,
// mirroring uv.StyledString.Draw's screen-mode behavior for the default
// Wrap=false, Tail="" case. The clear loop matches StyledString.Draw line
// 51-56; the buf.Draw call replaces the per-cell ANSI decode that
// printString does on every uncached frame with a pure cell copy.
func drawCachedBuffer(scr uv.Screen, area uv.Rectangle, buf uv.ScreenBuffer) {
// Clear the area first to match StyledString.Draw — leftover cells
// from a previous frame outside the new content must be zeroed,
// because Buffer.Draw skips empty cells (it doesn't clear).
for y := area.Min.Y; y < area.Max.Y; y++ {
for x := area.Min.X; x < area.Max.X; x++ {
scr.SetCell(x, y, nil)
}
}
buf.Draw(scr, area)
}
// SetSize sets the size of the chat view port.
func (m *Chat) SetSize(width, height int) {
m.list.SetSize(width, height)
// Anchor to bottom if we were at the bottom.
if m.AtBottom() {
m.ScrollToBottom()
}
}
// Len returns the number of items in the chat list.
func (m *Chat) Len() int {
return m.list.Len()
}
// InvalidateRenderCaches drops cached rendered output on every message
// item so the next draw re-renders with the current styles.
func (m *Chat) InvalidateRenderCaches() {
items := make([]chat.MessageItem, 0, m.list.Len())
for i := range m.list.Len() {
if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
items = append(items, item)
}
}
chat.ClearItemCaches(items)
}
// SetMessages sets the chat messages to the provided list of message items.
func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
m.idInxMap = make(map[string]int)
m.pausedAnimations = make(map[string]struct{})
items := make([]list.Item, len(msgs))
for i, msg := range msgs {
m.idInxMap[msg.ID()] = i
// Register nested tool IDs for tools that contain nested tools.
if container, ok := msg.(chat.NestedToolContainer); ok {
for _, nested := range container.NestedTools() {
m.idInxMap[nested.ID()] = i
}
}
items[i] = msg
}
m.list.SetItems(items...)
m.ScrollToBottom()
}
// AppendMessages appends a new message item to the chat list.
func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
items := make([]list.Item, len(msgs))
indexOffset := m.list.Len()
for i, msg := range msgs {
m.idInxMap[msg.ID()] = indexOffset + i
// Register nested tool IDs for tools that contain nested tools.
if container, ok := msg.(chat.NestedToolContainer); ok {
for _, nested := range container.NestedTools() {
m.idInxMap[nested.ID()] = indexOffset + i
}
}
items[i] = msg
}
m.list.AppendItems(items...)
}
// UpdateNestedToolIDs updates the ID map for nested tools within a container.
// Call this after modifying nested tools to ensure animations work correctly.
func (m *Chat) UpdateNestedToolIDs(containerID string) {
idx, ok := m.idInxMap[containerID]
if !ok {
return
}
item, ok := m.list.ItemAt(idx).(chat.MessageItem)
if !ok {
return
}
container, ok := item.(chat.NestedToolContainer)
if !ok {
return
}
// Register all nested tool IDs to point to the container's index.
for _, nested := range container.NestedTools() {
m.idInxMap[nested.ID()] = idx
}
}
// Animate animates items in the chat list. Only propagates animation messages
// to visible items to save CPU. When items are not visible, their animation ID
// is tracked so it can be restarted when they become visible again.
func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
idx, ok := m.idInxMap[msg.ID]
if !ok {
return nil
}
animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
if !ok {
return nil
}
// Check if item is currently visible.
startIdx, endIdx := m.list.VisibleItemIndices()
isVisible := idx >= startIdx && idx <= endIdx
if !isVisible {
// Item not visible - pause animation by not propagating.
// Track it so we can restart when it becomes visible.
m.pausedAnimations[msg.ID] = struct{}{}
return nil
}
// Item is visible - remove from paused set and animate.
delete(m.pausedAnimations, msg.ID)
return animatable.Animate(msg)
}
// RestartPausedVisibleAnimations restarts animations for items that were paused
// due to being scrolled out of view but are now visible again.
func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
if len(m.pausedAnimations) == 0 {
return nil
}
startIdx, endIdx := m.list.VisibleItemIndices()
var cmds []tea.Cmd
for id := range m.pausedAnimations {
idx, ok := m.idInxMap[id]
if !ok {
// Item no longer exists.
delete(m.pausedAnimations, id)
continue
}
if idx >= startIdx && idx <= endIdx {
// Item is now visible - restart its animation.
if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
if cmd := animatable.StartAnimation(); cmd != nil {
cmds = append(cmds, cmd)
}
}
delete(m.pausedAnimations, id)
}
}
if len(cmds) == 0 {
return nil
}
return tea.Batch(cmds...)
}
// Focus sets the focus state of the chat component.
func (m *Chat) Focus() {
m.list.Focus()
}
// Blur removes the focus state from the chat component.
func (m *Chat) Blur() {
m.list.Blur()
}
// AtBottom returns whether the chat list is currently scrolled to the bottom.
func (m *Chat) AtBottom() bool {
return m.list.AtBottom()
}
// Follow returns whether the chat view is in follow mode (auto-scroll to
// bottom on new messages).
func (m *Chat) Follow() bool {
return m.follow
}
// ScrollToBottom scrolls the chat view to the bottom.
func (m *Chat) ScrollToBottom() {
m.list.ScrollToBottom()
m.follow = true // Enable follow mode when user scrolls to bottom
}
// ScrollToTop scrolls the chat view to the top.
func (m *Chat) ScrollToTop() {
m.list.ScrollToTop()
m.follow = false // Disable follow mode when user scrolls up
}
// ScrollBy scrolls the chat view by the given number of line deltas.
func (m *Chat) ScrollBy(lines int) {
m.list.ScrollBy(lines)
m.follow = lines > 0 && m.AtBottom() // Disable follow mode if user scrolls up
}
// ScrollToSelected scrolls the chat view to the selected item.
func (m *Chat) ScrollToSelected() {
m.list.ScrollToSelected()
m.follow = m.AtBottom() // Disable follow mode if user scrolls up
}
// ScrollToIndex scrolls the chat view to the item at the given index.
func (m *Chat) ScrollToIndex(index int) {
m.list.ScrollToIndex(index)
m.follow = m.AtBottom() // Disable follow mode if user scrolls up
}
// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
// any paused animations that are now visible.
func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
m.ScrollToTop()
return m.RestartPausedVisibleAnimations()
}
// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
// restart any paused animations that are now visible.
func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
m.ScrollToBottom()
return m.RestartPausedVisibleAnimations()
}
// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
// a command to restart any paused animations that are now visible.
func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
m.ScrollBy(lines)
return m.RestartPausedVisibleAnimations()
}
// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
// command to restart any paused animations that are now visible.
func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
m.ScrollToSelected()
return m.RestartPausedVisibleAnimations()
}
// SelectedItemInView returns whether the selected item is currently in view.
func (m *Chat) SelectedItemInView() bool {
return m.list.SelectedItemInView()
}
func (m *Chat) isSelectable(index int) bool {
item := m.list.ItemAt(index)
if item == nil {
return false
}
_, ok := item.(list.Focusable)
return ok
}
// SetSelected sets the selected message index in the chat list.
func (m *Chat) SetSelected(index int) {
m.list.SetSelected(index)
if index < 0 || index >= m.list.Len() {
return
}
for {
if m.isSelectable(m.list.Selected()) {
return
}
if m.list.SelectNext() {
continue
}
// If we're at the end and the last item isn't selectable, walk backwards
// to find the nearest selectable item.
for {
if !m.list.SelectPrev() {
return
}
if m.isSelectable(m.list.Selected()) {
return
}
}
}
}
// SelectPrev selects the previous message in the chat list.
func (m *Chat) SelectPrev() {
for {
if !m.list.SelectPrev() {
return
}
if m.isSelectable(m.list.Selected()) {
return
}
}
}
// SelectNext selects the next message in the chat list.
func (m *Chat) SelectNext() {
for {
if !m.list.SelectNext() {
return
}
if m.isSelectable(m.list.Selected()) {
return
}
}
}
// SelectFirst selects the first message in the chat list.
func (m *Chat) SelectFirst() {
if !m.list.SelectFirst() {
return
}
if m.isSelectable(m.list.Selected()) {
return
}
for {
if !m.list.SelectNext() {
return
}
if m.isSelectable(m.list.Selected()) {
return
}
}
}
// SelectLast selects the last message in the chat list.
func (m *Chat) SelectLast() {
if !m.list.SelectLast() {
return
}
if m.isSelectable(m.list.Selected()) {
return
}
for {
if !m.list.SelectPrev() {
return
}
if m.isSelectable(m.list.Selected()) {
return
}
}
}
// SelectFirstInView selects the first message currently in view.
func (m *Chat) SelectFirstInView() {
startIdx, endIdx := m.list.VisibleItemIndices()
for i := startIdx; i <= endIdx; i++ {
if m.isSelectable(i) {
m.list.SetSelected(i)
return
}
}
}
// SelectLastInView selects the last message currently in view.
func (m *Chat) SelectLastInView() {
startIdx, endIdx := m.list.VisibleItemIndices()
for i := endIdx; i >= startIdx; i-- {
if m.isSelectable(i) {
m.list.SetSelected(i)
return
}
}
}
// ClearMessages removes all messages from the chat list.
func (m *Chat) ClearMessages() {
m.idInxMap = make(map[string]int)
m.pausedAnimations = make(map[string]struct{})
m.list.SetItems()
m.ClearMouse()
}
// RemoveMessage removes a message from the chat list by its ID.
func (m *Chat) RemoveMessage(id string) {
idx, ok := m.idInxMap[id]
if !ok {
return
}
// Remove from list
m.list.RemoveItem(idx)
// Remove from index map
delete(m.idInxMap, id)
// Rebuild index map for all items after the removed one
for i := idx; i < m.list.Len(); i++ {
if item, ok := m.list.ItemAt(i).(chat.MessageItem); ok {
m.idInxMap[item.ID()] = i
}
}
// Clean up any paused animations for this message
delete(m.pausedAnimations, id)
}
// MessageItem returns the message item with the given ID, or nil if not found.
func (m *Chat) MessageItem(id string) chat.MessageItem {
idx, ok := m.idInxMap[id]
if !ok {
return nil
}
item, ok := m.list.ItemAt(idx).(chat.MessageItem)
if !ok {
return nil
}
return item
}
// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
func (m *Chat) ToggleExpandedSelectedItem() {
if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
if !expandable.ToggleExpanded() {
m.ScrollToIndex(m.list.Selected())
}
if m.AtBottom() {
m.ScrollToBottom()
}
}
}
// HandleKeyMsg handles key events for the chat component.
func (m *Chat) HandleKeyMsg(key tea.KeyMsg) (bool, tea.Cmd) {
if m.list.Focused() {
if handler, ok := m.list.SelectedItem().(chat.KeyEventHandler); ok {
return handler.HandleKeyEvent(key)
}
}
return false, nil
}
// HandleMouseDown handles mouse down events for the chat component.
// It detects single, double, and triple clicks for text selection.
// Returns whether the click was handled and an optional command for delayed
// single-click actions.
func (m *Chat) HandleMouseDown(x, y int) (bool, tea.Cmd) {
if m.list.Len() == 0 {
return false, nil
}
itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
if itemIdx < 0 {
return false, nil
}
if !m.isSelectable(itemIdx) {
return false, nil
}
// Increment pending click ID to invalidate any previous pending clicks.
m.pendingClickID++
clickID := m.pendingClickID
// Detect multi-click (double/triple)
now := time.Now()
if now.Sub(m.lastClickTime) <= doubleClickThreshold &&
abs(x-m.lastClickX) <= clickTolerance &&
abs(y-m.lastClickY) <= clickTolerance {
m.clickCount++
} else {
m.clickCount = 1
}
m.lastClickTime = now
m.lastClickX = x
m.lastClickY = y
// Select the item that was clicked
m.list.SetSelected(itemIdx)
var cmd tea.Cmd
switch m.clickCount {
case 1:
// Single click - start selection and schedule delayed click action.
m.mouseDown = true
m.mouseDownItem = itemIdx
m.mouseDownX = x
m.mouseDownY = itemY
m.mouseDragItem = itemIdx
m.mouseDragX = x
m.mouseDragY = itemY
// Schedule delayed click action (e.g., expansion) after a short delay.
// If a double-click occurs, the clickID will be invalidated.
cmd = tea.Tick(doubleClickThreshold, func(t time.Time) tea.Msg {
return DelayedClickMsg{
ClickID: clickID,
ItemIdx: itemIdx,
X: x,
Y: itemY,
}
})
case 2:
// Double click - select word (no delayed action)
m.selectWord(itemIdx, x, itemY)
case 3:
// Triple click - select line (no delayed action)
m.selectLine(itemIdx, itemY)
m.clickCount = 0 // Reset after triple click
}
return true, cmd
}
// HandleDelayedClick handles a delayed single-click action (like expansion).
// It only executes if the click ID matches (i.e., no double-click occurred)
// and no text selection was made (drag to select).
func (m *Chat) HandleDelayedClick(msg DelayedClickMsg) bool {
// Ignore if this click was superseded by a newer click (double/triple).
if msg.ClickID != m.pendingClickID {
return false
}
// Don't expand if user dragged to select text.
if m.HasHighlight() {
return false
}
// Execute the click action (e.g., expansion).
selectedItem := m.list.SelectedItem()
if clickable, ok := selectedItem.(list.MouseClickable); ok {
handled := clickable.HandleMouseClick(ansi.MouseButton1, msg.X, msg.Y)
// Toggle expansion only when the item signalled it handled the
// click. Items like AssistantMessageItem only report handled when
// the click is on their expandable region, so this avoids
// toggling expansion for clicks outside the clickable area.
if handled {
if expandable, ok := selectedItem.(chat.Expandable); ok {
if !expandable.ToggleExpanded() {
m.ScrollToIndex(m.list.Selected())
}
}
}
if m.AtBottom() {
m.ScrollToBottom()
}
return handled
}
return false
}
// HandleMouseUp handles mouse up events for the chat component.
func (m *Chat) HandleMouseUp(x, y int) bool {
if !m.mouseDown {
return false
}
m.mouseDown = false
return true
}
// HandleMouseDrag handles mouse drag events for the chat component.
func (m *Chat) HandleMouseDrag(x, y int) bool {
if !m.mouseDown {
return false
}
if m.list.Len() == 0 {
return false
}
itemIdx, itemY := m.list.ItemIndexAtPosition(x, y)
if itemIdx < 0 {
return false
}
m.mouseDragItem = itemIdx
m.mouseDragX = x
m.mouseDragY = itemY
return true
}
// HasHighlight returns whether there is currently highlighted content.
func (m *Chat) HasHighlight() bool {
startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
return startItemIdx >= 0 && endItemIdx >= 0 && (startLine != endLine || startCol != endCol)
}
// HighlightContent returns the currently highlighted content based on the mouse
// selection. It returns an empty string if no content is highlighted.
func (m *Chat) HighlightContent() string {
startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
if startItemIdx < 0 || endItemIdx < 0 || startLine == endLine && startCol == endCol {
return ""
}
var sb strings.Builder
for i := startItemIdx; i <= endItemIdx; i++ {
item := m.list.ItemAt(i)
if hi, ok := item.(list.Highlightable); ok {
startLine, startCol, endLine, endCol := hi.Highlight()
listWidth := m.list.Width()
var rendered string
if rr, ok := item.(list.RawRenderable); ok {
rendered = rr.RawRender(listWidth)
} else {
rendered = item.Render(listWidth)
}
sb.WriteString(list.HighlightContent(
rendered,
uv.Rect(0, 0, listWidth, lipgloss.Height(rendered)),
startLine,
startCol,
endLine,
endCol,
))
sb.WriteString(strings.Repeat("\n", m.list.Gap()))
}
}
return strings.TrimSpace(sb.String())
}
// ClearMouse clears the current mouse interaction state.
func (m *Chat) ClearMouse() {
m.mouseDown = false
m.mouseDownItem = -1
m.mouseDragItem = -1
m.lastClickTime = time.Time{}
m.lastClickX = 0
m.lastClickY = 0
m.clickCount = 0
m.pendingClickID++ // Invalidate any pending delayed click
}
// applyHighlightRange applies the current highlight range to the chat items.
func (m *Chat) applyHighlightRange(idx, selectedIdx int, item list.Item) list.Item {
if hi, ok := item.(list.Highlightable); ok {
// Apply highlight
startItemIdx, startLine, startCol, endItemIdx, endLine, endCol := m.getHighlightRange()
sLine, sCol, eLine, eCol := -1, -1, -1, -1
if idx >= startItemIdx && idx <= endItemIdx {
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 = -1
eCol = -1
} 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 = -1
eCol = -1
}
}
hi.SetHighlight(sLine, sCol, eLine, eCol)
return hi.(list.Item)
}
return item
}
// getHighlightRange returns the current highlight range.
func (m *Chat) getHighlightRange() (startItemIdx, startLine, startCol, endItemIdx, endLine, endCol int) {
if m.mouseDownItem < 0 {
return -1, -1, -1, -1, -1, -1
}
downItemIdx := m.mouseDownItem
dragItemIdx := m.mouseDragItem
// Determine selection direction
draggingDown := dragItemIdx > downItemIdx ||
(dragItemIdx == downItemIdx && m.mouseDragY > m.mouseDownY) ||
(dragItemIdx == downItemIdx && m.mouseDragY == m.mouseDownY && m.mouseDragX >= m.mouseDownX)
if draggingDown {
// Normal forward selection
startItemIdx = downItemIdx
startLine = m.mouseDownY
startCol = m.mouseDownX
endItemIdx = dragItemIdx
endLine = m.mouseDragY
endCol = m.mouseDragX
} else {
// Backward selection (dragging up)
startItemIdx = dragItemIdx
startLine = m.mouseDragY
startCol = m.mouseDragX
endItemIdx = downItemIdx
endLine = m.mouseDownY
endCol = m.mouseDownX
}
return startItemIdx, startLine, startCol, endItemIdx, endLine, endCol
}
// selectWord selects the word at the given position within an item.
func (m *Chat) selectWord(itemIdx, x, itemY int) {
item := m.list.ItemAt(itemIdx)
if item == nil {
return
}
// Get the rendered content for this item
var rendered string
if rr, ok := item.(list.RawRenderable); ok {
rendered = rr.RawRender(m.list.Width())
} else {
rendered = item.Render(m.list.Width())
}
lines := strings.Split(rendered, "\n")
if itemY < 0 || itemY >= len(lines) {
return
}
// Adjust x for the item's left padding (border + padding) to get content column.
// The mouse x is in viewport space, but we need content space for boundary detection.
offset := chat.MessageLeftPaddingTotal
contentX := max(x-offset, 0)
line := ansi.Strip(lines[itemY])
startCol, endCol := findWordBoundaries(line, contentX)
if startCol == endCol {
// No word found at position, fallback to single click behavior
m.mouseDown = true
m.mouseDownItem = itemIdx
m.mouseDownX = x
m.mouseDownY = itemY
m.mouseDragItem = itemIdx
m.mouseDragX = x
m.mouseDragY = itemY
return
}
// Set selection to the word boundaries (convert back to viewport space).
// Keep mouseDown true so HandleMouseUp triggers the copy.
m.mouseDown = true
m.mouseDownItem = itemIdx
m.mouseDownX = startCol + offset
m.mouseDownY = itemY
m.mouseDragItem = itemIdx
m.mouseDragX = endCol + offset
m.mouseDragY = itemY
}
// selectLine selects the entire line at the given position within an item.
func (m *Chat) selectLine(itemIdx, itemY int) {
item := m.list.ItemAt(itemIdx)
if item == nil {
return
}
// Get the rendered content for this item
var rendered string
if rr, ok := item.(list.RawRenderable); ok {
rendered = rr.RawRender(m.list.Width())
} else {
rendered = item.Render(m.list.Width())
}
lines := strings.Split(rendered, "\n")
if itemY < 0 || itemY >= len(lines) {
return
}
// Get line length (stripped of ANSI codes) and account for padding.
// SetHighlight will subtract the offset, so we need to add it here.
offset := chat.MessageLeftPaddingTotal
lineLen := ansi.StringWidth(lines[itemY])
// Set selection to the entire line.
// Keep mouseDown true so HandleMouseUp triggers the copy.
m.mouseDown = true
m.mouseDownItem = itemIdx
m.mouseDownX = 0
m.mouseDownY = itemY
m.mouseDragItem = itemIdx
m.mouseDragX = lineLen + offset
m.mouseDragY = itemY
}
// findWordBoundaries finds the start and end column of the word at the given column.
// Returns (startCol, endCol) where endCol is exclusive.
func findWordBoundaries(line string, col int) (startCol, endCol int) {
if line == "" || col < 0 {
return 0, 0
}
i := displaywidth.StringGraphemes(line)
for i.Next() {
}
// Segment the line into words using UAX#29.
lineCol := 0 // tracks the visited column widths
lastCol := 0 // tracks the start of the current token
iter := words.FromString(line)
for iter.Next() {
token := iter.Value()
tokenWidth := displaywidth.String(token)
graphemeStart := lineCol
graphemeEnd := lineCol + tokenWidth
lineCol += tokenWidth
// If clicked before this token, return the previous token boundaries.
if col < graphemeStart {
return lastCol, lastCol
}
// Update lastCol to the end of this token for next iteration.
lastCol = graphemeEnd
// If clicked within this token, return its boundaries.
if col >= graphemeStart && col < graphemeEnd {
// If clicked on whitespace, return empty selection.
if strings.TrimSpace(token) == "" {
return col, col
}
return graphemeStart, graphemeEnd
}
}
return col, col
}
// abs returns the absolute value of an integer.
func abs(x int) int {
if x < 0 {
return -x
}
return x
}