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