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))) }