package tui
import (
"strings"
"unicode"
"github.com/charmbracelet/x/ansi"
)
// A selection is the harness's own answer to something the terminal normally
// does for us. Asking for mouse reporting takes the terminal's text selection
// away — it stops sending drags to the clipboard and starts sending them here
// — so the alternative to drawing and copying a selection ourselves is not
// having one at all.
//
// Coordinates are content cells, not screen cells: a line is an index into
// the transcript's rendered rows and a column is a display cell within that
// row. That is what lets a selection survive scrolling and a streaming turn,
// which only ever append rows below it.
// cell is one position in the rendered transcript.
type cell struct{ line, col int }
// before reports whether c comes earlier in reading order than d.
func (c cell) before(d cell) bool {
return c.line < d.line || (c.line == d.line && c.col < d.col)
}
// selMode is how much of the text a drag takes: what the pointer covers,
// whole words, or whole lines. Double- and triple-clicks pick the wider two.
type selMode int
const (
selChar selMode = iota
selWord
selLine
)
// selection is the span the mouse has marked out.
type selection struct {
anchor cell // where the drag started
cursor cell // where the pointer is now
mode selMode
// on means there is a span to paint and copy. A press alone does not set
// it: a click is how you dismiss a selection, so it takes movement — or a
// double-click, which selects a word without any — to make one.
on bool
// drag means the button is still down, so motion belongs to this
// selection rather than being stray movement after a release.
drag bool
}
// press starts a selection at c. shift keeps the existing anchor, which is
// how a click extends a selection rather than replacing it.
func (s *selection) press(c cell, shift bool) {
if shift && (s.on || s.drag) {
s.cursor, s.drag = c, true
s.on = true
return
}
s.anchor, s.cursor = c, c
s.mode, s.on, s.drag = selChar, false, true
}
// to moves the pointer while the button is down. The first movement is what
// turns a press into a selection.
func (s *selection) to(c cell) {
if !s.drag {
return
}
s.cursor = c
if c != s.anchor || s.mode != selChar {
s.on = true
}
}
// release ends the drag, leaving the span on screen to be copied.
func (s *selection) release() { s.drag = false }
// widen is what a second or third click on the same cell does.
func (s *selection) widen(mode selMode) {
s.mode, s.on = mode, true
}
func (s *selection) clear() { *s = selection{} }
// span resolves the selection against the lines it was made over: a pair of
// cells in reading order, the second exclusive, with the mode applied. It
// reports false when there is nothing to paint.
//
// The cell the drag started on is inside the span whichever way the hand
// moved, which is what a terminal does and what makes dragging one character
// to the right select that character rather than nothing.
func (s selection) span(lines []string) (from, to cell, ok bool) {
if !s.on || len(lines) == 0 {
return cell{}, cell{}, false
}
from, to = s.anchor, s.cursor
if to.before(from) {
from, to = to, from
}
from.line, to.line = clampLine(from.line, lines), clampLine(to.line, lines)
switch s.mode {
case selLine:
from.col, to.col = 0, lineWidth(lines, to.line)
case selWord:
from.col = wordStart(lines[from.line], from.col)
to.col = wordEnd(lines[to.line], to.col)
default:
// The cursor's own cell is taken, not just the ground it passed over.
to.col++
}
from.col = maxInt(0, from.col)
to.col = minInt(to.col, lineWidth(lines, to.line))
if from.line == to.line && to.col <= from.col {
return cell{}, cell{}, false
}
return from, to, true
}
// selectedText is what the selection puts on the clipboard: the marked cells
// with their styling stripped, and without the padding the viewport adds to
// square off a line — nobody means to copy twenty trailing spaces.
func selectedText(lines []string, from, to cell) string {
if from.line > to.line || len(lines) == 0 {
return ""
}
var out []string
for i := from.line; i <= to.line && i < len(lines); i++ {
plain := ansi.Strip(lines[i])
start, end := 0, ansi.StringWidth(plain)
if i == from.line {
start = from.col
}
if i == to.line {
end = minInt(end, to.col)
}
if end <= start {
out = append(out, "")
continue
}
out = append(out, strings.TrimRight(ansi.Cut(plain, start, end), " "))
}
return strings.Join(out, "\n")
}
// paintSelection draws the span over the rows the viewport is showing. It
// works on the viewport's own output rather than on the scrollback, so the
// cost is the height of the screen and the transcript's render cache — the one
// thing a streaming turn hits hardest — is never touched.
//
// top is the content line the first visible row holds.
func paintSelection(view string, top int, from, to cell) string {
rows := strings.Split(view, "\n")
for i := range rows {
line := top + i
if line < from.line || line > to.line {
continue
}
start, end := 0, ansi.StringWidth(ansi.Strip(rows[i]))
if line == from.line {
start = from.col
}
if line == to.line {
end = minInt(end, to.col)
}
rows[i] = paintRow(rows[i], start, end)
}
return strings.Join(rows, "\n")
}
// paintRow puts the selection colour behind cells [start, end) of one row.
//
// The marked cells are stripped before they are restyled. A style nested
// inside a background ends with a reset, which would drop the background for
// the rest of the span and tear a hole in it — the same trap the context pill
// documents, and far more visible across a whole line of syntax-highlighted
// code.
func paintRow(row string, start, end int) string {
if end <= start {
return row
}
marked := ansi.Strip(ansi.Cut(row, start, end))
if marked == "" {
return row
}
return ansi.Truncate(row, start, "") + stySelect.Render(marked) + ansi.TruncateLeft(row, end, "")
}
func clampLine(line int, lines []string) int {
return maxInt(0, minInt(line, len(lines)-1))
}
func lineWidth(lines []string, line int) int {
if line < 0 || line >= len(lines) {
return 0
}
return ansi.StringWidth(ansi.Strip(lines[line]))
}
// wordRune says which characters hang together as one word. It is wider than
// a letter test on purpose: the strings worth double-clicking in a transcript
// are paths, ids and flags, and a word rule that stops at the first slash
// makes picking "internal/tui/model.go:222" out of a tool result impossible.
func wordRune(r rune) bool {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return true
}
return strings.ContainsRune("_-./~@:+=", r)
}
// wordStart and wordEnd grow a column out to the bounds of the word under it,
// in columns. A column on a space takes just that space, so a double-click in
// the gutter selects nothing surprising.
//
// The two go through cells rather than runes because a column is a display
// cell: every wide character to the left of the pointer moves the rune the
// pointer is over one further along.
func wordStart(row string, col int) int {
cells := rowCells(row)
i := cellIndex(cells, col)
if i >= len(cells) || !wordRune(cells[i]) {
return col
}
for i > 0 && wordRune(cells[i-1]) {
i--
}
return cellColumn(cells, i)
}
func wordEnd(row string, col int) int {
cells := rowCells(row)
i := cellIndex(cells, col)
if i >= len(cells) || !wordRune(cells[i]) {
return col + 1
}
for i+1 < len(cells) && wordRune(cells[i+1]) {
i++
}
return cellColumn(cells, i) + runeWidth(cells[i])
}
// rowCells is a row as the runes it is made of, with its styling stripped.
func rowCells(row string) []rune { return []rune(ansi.Strip(row)) }
// cellIndex is which rune occupies a column, and cellColumn is the column a
// rune starts at: the two directions of the same walk along the row.
func cellIndex(cells []rune, col int) int {
w := 0
for i, r := range cells {
if col < w+runeWidth(r) {
return i
}
w += runeWidth(r)
}
return len(cells)
}
func cellColumn(cells []rune, idx int) int {
w := 0
for i, r := range cells {
if i >= idx {
break
}
w += runeWidth(r)
}
return w
}
func runeWidth(r rune) int { return maxInt(1, ansi.StringWidth(string(r))) }