package tui
import (
"strings"
"testing"
)
// The lines a selection is made over are the transcript's own rendered rows,
// padded to the width by the viewport, which is why trailing space is never
// part of what a person meant to take.
var selLines = []string{
"the quick brown fox ",
"jumps over ",
"the lazy dog ",
}
func TestSelectedTextTakesExactlyTheMarkedCells(t *testing.T) {
cases := []struct {
name string
from, to cell
want string
}{
{"a word inside one line", cell{0, 4}, cell{0, 9}, "quick"},
{"to the end of a line drops the padding", cell{0, 16}, cell{0, 25}, "fox"},
{"across three lines keeps the middle whole", cell{0, 16}, cell{2, 8},
"fox\njumps over\nthe lazy"},
{"a single cell", cell{2, 4}, cell{2, 5}, "l"},
{"an empty span takes nothing", cell{1, 3}, cell{1, 3}, ""},
}
for _, tc := range cases {
if got := selectedText(selLines, tc.from, tc.to); got != tc.want {
t.Errorf("%s: selectedText = %q, want %q", tc.name, got, tc.want)
}
}
}
// What reaches the clipboard is text, not the escape sequences that coloured
// it on screen.
func TestSelectedTextCarriesNoStyling(t *testing.T) {
forceColour(t)
lines := []string{styCoral.Render("ReadFile") + styDim.Render("(path=x)")}
got := selectedText(lines, cell{0, 0}, cell{0, 16})
if got != "ReadFile(path=x)" {
t.Fatalf("selectedText = %q, want %q", got, "ReadFile(path=x)")
}
if strings.ContainsRune(got, 0x1b) {
t.Errorf("copied text still carries escape sequences: %q", got)
}
}
// A drag runs in whichever direction the hand moves, and the cell the drag
// started on belongs to the selection either way.
func TestSpanNormalisesTheDirectionOfADrag(t *testing.T) {
forward := selection{anchor: cell{0, 4}, cursor: cell{1, 2}, on: true}
from, to, ok := forward.span(selLines)
if !ok || from != (cell{0, 4}) || to != (cell{1, 3}) {
t.Errorf("dragging down: span = %v..%v (ok=%v), want {0 4}..{1 3}", from, to, ok)
}
backward := selection{anchor: cell{1, 2}, cursor: cell{0, 4}, on: true}
from, to, ok = backward.span(selLines)
if !ok || from != (cell{0, 4}) || to != (cell{1, 3}) {
t.Errorf("dragging up: span = %v..%v (ok=%v), want {0 4}..{1 3}", from, to, ok)
}
var idle selection
if _, _, ok := idle.span(selLines); ok {
t.Error("a selection nobody made reports a span")
}
}
func TestSpanExpandsToWordsAndLines(t *testing.T) {
word := selection{anchor: cell{0, 6}, cursor: cell{0, 6}, mode: selWord, on: true}
from, to, _ := word.span(selLines)
if got := selectedText(selLines, from, to); got != "quick" {
t.Errorf("double-click inside a word took %q, want %q", got, "quick")
}
line := selection{anchor: cell{1, 3}, cursor: cell{1, 3}, mode: selLine, on: true}
from, to, _ = line.span(selLines)
if got := selectedText(selLines, from, to); got != "jumps over" {
t.Errorf("triple-click took %q, want %q", got, "jumps over")
}
}
// Double-clicking a path is how a person picks one out of a wall of tool
// output, so the run of characters a path is made of counts as one word.
func TestWordSelectionKeepsAPathWhole(t *testing.T) {
lines := []string{"edited internal/tui/model.go:222 for you"}
sel := selection{anchor: cell{0, 12}, cursor: cell{0, 12}, mode: selWord, on: true}
from, to, _ := sel.span(lines)
if got := selectedText(lines, from, to); got != "internal/tui/model.go:222" {
t.Errorf("double-click on a path took %q, want the whole path", got)
}
}
// A press alone is a click: it puts the caret somewhere and clears whatever
// was selected. Only movement with the button down makes a selection.
func TestAClickWithoutMovementSelectsNothing(t *testing.T) {
var s selection
s.press(cell{0, 4}, false)
if s.on {
t.Error("a press with no movement already counts as a selection")
}
s.to(cell{0, 9})
if !s.on {
t.Error("moving with the button down did not start a selection")
}
}
// A column is a display cell, not a rune: two of them go by for every wide
// character to the left of the pointer.
func TestWordSelectionCountsCellsNotRunes(t *testing.T) {
lines := []string{"日本語 fox"}
sel := selection{anchor: cell{0, 7}, cursor: cell{0, 7}, mode: selWord, on: true}
from, to, _ := sel.span(lines)
if got := selectedText(lines, from, to); got != "fox" {
t.Errorf("double-click past a wide rune took %q, want %q", got, "fox")
}
}
func TestPaintMarksExactlyTheSelectedCells(t *testing.T) {
forceColour(t)
view := strings.Join(selLines, "\n")
rows := strings.Split(paintSelection(view, 0, cell{0, 4}, cell{0, 9}), "\n")
if got := backedCells(rows[0]); got != "quick" {
t.Errorf("the highlight covers %q, want %q", got, "quick")
}
for _, row := range rows[1:] {
if got := backedCells(row); got != "" {
t.Errorf("a row outside the selection was painted: %q", got)
}
}
}
// The viewport hands over the rows it is showing, so which content line a row
// holds depends on how far the transcript has been scrolled.
func TestPaintFollowsTheScrollOffset(t *testing.T) {
forceColour(t)
view := strings.Join(selLines, "\n")
rows := strings.Split(paintSelection(view, 5, cell{6, 0}, cell{6, 5}), "\n")
if got := backedCells(rows[1]); got != "jumps" {
t.Errorf("row 1 with the view scrolled to line 5 shows %q, want %q", got, "jumps")
}
if got := backedCells(rows[0]) + backedCells(rows[2]); got != "" {
t.Errorf("the wrong rows were painted: %q", got)
}
}
// The text around a selection keeps the colour it had. The marked text does
// not: 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.
func TestPaintLeavesTheSurroundingColoursAloneAndHasNoHoles(t *testing.T) {
forceColour(t)
view := styToolName.Render("ReadFile") + styToolArgs.Render("(path=x)")
got := paintSelection(view, 0, cell{0, 0}, cell{0, 8})
if marked := backedCells(got); marked != "ReadFile" {
t.Errorf("the highlight covers %q, want %q", marked, "ReadFile")
}
if rest := unbackedCells(got); rest != "(path=x)" {
t.Errorf("outside the selection reads %q, want %q", rest, "(path=x)")
}
// Selecting the whole of a line built from several styles must come back
// as one unbroken run of marked cells.
whole := paintSelection(view, 0, cell{0, 0}, cell{0, 16})
if marked := backedCells(whole); marked != "ReadFile(path=x)" {
t.Errorf("a fully selected line is torn: marked %q", marked)
}
}