Vectrum
Sign In
272 lines · 8.1 KB · Go
package tui

import (
	"strings"
	"unicode"

	"github.com/charmbracelet/x/ansi"
)

// A selection is Codey'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))) }