Files
diple/text_editor_test.go

581 lines
19 KiB
Go

package main
import (
"errors"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/ansi"
)
type memoryTextClipboard struct {
text string
written string
readErr error
writeErr error
}
func TestVimEditorUsesSharedConfiguredNavigation(t *testing.T) {
editor := newTextEditor("first\nsecond", true)
editor.keys.Navigation.Down = []string{"ctrl+j"}
editor.keys.Navigation.Up = []string{"ctrl+k"}
editor.handleKey(runeKey("j"), true)
if editor.Cursor != 0 {
t.Fatalf("removed default j moved cursor to %d", editor.Cursor)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyCtrlJ}, true)
if editor.Cursor != len([]rune("first\n")) {
t.Fatalf("configured ctrl+j moved cursor to %d", editor.Cursor)
}
}
func (c *memoryTextClipboard) ReadText() (string, error) {
return c.text, c.readErr
}
func (c *memoryTextClipboard) WriteText(value string) error {
c.written = value
return c.writeErr
}
func TestVimTextEditorWordMotions(t *testing.T) {
const value = "one,two THREE four\nlast"
editor := newTextEditor(value, true)
editor.handleKey(runeKey("e"), true)
if editor.Cursor != 2 {
t.Fatalf("e cursor = %d, want 2", editor.Cursor)
}
editor.Cursor = 0
editor.handleKey(runeKey("E"), true)
if editor.Cursor != 6 {
t.Fatalf("E cursor = %d, want 6", editor.Cursor)
}
editor.Cursor = 0
editor.handleKey(runeKey("w"), true)
if editor.Cursor != 3 {
t.Fatalf("w cursor = %d, want punctuation at 3", editor.Cursor)
}
editor.Cursor = 0
editor.handleKey(runeKey("W"), true)
if editor.Cursor != 9 {
t.Fatalf("W cursor = %d, want 9", editor.Cursor)
}
editor.Cursor = 18
editor.handleKey(runeKey("b"), true)
if editor.Cursor != 15 {
t.Fatalf("b cursor = %d, want 15", editor.Cursor)
}
editor.Cursor = 18
editor.handleKey(runeKey("B"), true)
if editor.Cursor != 15 {
t.Fatalf("B cursor = %d, want 15", editor.Cursor)
}
}
func TestVimTextEditorWordEndMotionsRepeat(t *testing.T) {
const value = "one,two THREE four\nlast"
editor := newTextEditor(value, true)
for index, want := range []int{2, 3, 6, 13, 18, 23} {
editor.handleKey(runeKey("e"), true)
if editor.Cursor != want {
t.Fatalf("e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
}
}
editor.Cursor = 0
for index, want := range []int{6, 13, 18, 23} {
editor.handleKey(runeKey("E"), true)
if editor.Cursor != want {
t.Fatalf("E repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
}
}
}
func TestVimTextEditorWordEndRepeatsAcrossSoftWraps(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
for index, want := range []int{9, 19, 21} {
editor.handleKeyAtWidth(runeKey("e"), true, 10)
if editor.Cursor != want {
t.Fatalf("soft-wrap e repetition %d cursor = %d, want %d", index+1, editor.Cursor, want)
}
}
}
func TestVimTextEditorFindAndRepeat(t *testing.T) {
editor := newTextEditor("foo bar foo", true)
editor.handleKey(runeKey("f"), true)
editor.handleKey(runeKey("o"), true)
if editor.Cursor != 1 {
t.Fatalf("fo cursor = %d, want 1", editor.Cursor)
}
editor.handleKey(runeKey(";"), true)
if editor.Cursor != 2 {
t.Fatalf("first ; cursor = %d, want 2", editor.Cursor)
}
editor.handleKey(runeKey(";"), true)
if editor.Cursor != 9 {
t.Fatalf("second ; cursor = %d, want 9", editor.Cursor)
}
editor.handleKey(runeKey(","), true)
if editor.Cursor != 2 {
t.Fatalf(", cursor = %d, want 2", editor.Cursor)
}
}
func TestVimTextEditorTillRepeatAdvancesPastPreviousTarget(t *testing.T) {
editor := newTextEditor("xxa-b-c-d", true)
editor.handleKey(runeKey("t"), true)
editor.handleKey(runeKey("-"), true)
if editor.Cursor != 2 {
t.Fatalf("t- cursor = %d, want 2", editor.Cursor)
}
editor.handleKey(runeKey(";"), true)
if editor.Cursor != 4 {
t.Fatalf("; cursor = %d, want 4", editor.Cursor)
}
}
func TestVimTextEditorSwitchesModesAndInserts(t *testing.T) {
editor := newTextEditor("task", true)
if editor.Mode != textEditorNormal {
t.Fatalf("initial mode = %s", editor.Mode)
}
editor.handleKey(runeKey("i"), true)
editor.handleKey(runeKey("x"), true)
if editor.Text != "xtask" || editor.Mode != textEditorInsert {
t.Fatalf("insert result = %q mode=%s", editor.Text, editor.Mode)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true)
if editor.Mode != textEditorNormal {
t.Fatalf("escape mode = %s", editor.Mode)
}
if editor.Cursor != 0 {
t.Fatalf("escape cursor = %d, want 0", editor.Cursor)
}
}
func TestVimTextEditorSubstituteDeletesCharacterAndEntersInsert(t *testing.T) {
editor := newTextEditor("abc", true)
editor.Cursor = 1
editor.handleKey(runeKey("s"), true)
if editor.Text != "ac" || editor.Cursor != 1 || editor.Mode != textEditorInsert {
t.Fatalf("substitute result = %#v", editor)
}
editor.handleKey(runeKey("X"), true)
if editor.Text != "aXc" {
t.Fatalf("substitute insertion result = %q", editor.Text)
}
}
func TestEditorMotionsAndDeletionPreserveGraphemeClusters(t *testing.T) {
tests := []struct {
name string
cluster string
}{
{name: "combining mark", cluster: "e\u0301"},
{name: "emoji with variation selector", cluster: "❤️"},
{name: "multi-code-point emoji", cluster: "👨‍👩‍👧‍👦"},
{name: "full-width character", cluster: "界"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
editor := newTextEditor(test.cluster+"x", true)
editor.handleKey(runeKey("l"), true)
want := len([]rune(test.cluster))
if editor.Cursor != want {
t.Fatalf("cursor = %d, want grapheme boundary %d", editor.Cursor, want)
}
editor.handleKey(runeKey("h"), true)
if editor.Cursor != 0 {
t.Fatalf("reverse cursor = %d, want 0", editor.Cursor)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "x" || editor.Cursor != 0 {
t.Fatalf("delete split grapheme: text=%q cursor=%d", editor.Text, editor.Cursor)
}
})
}
}
func TestEditorVisualYankIncludesWholeGrapheme(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("e\u0301x", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("y"), true)
if clipboard.written != "e\u0301" {
t.Fatalf("yanked text = %q, want complete combining grapheme", clipboard.written)
}
}
func TestVimTextEditorNormalMotionsStayOnCharactersWithinLine(t *testing.T) {
editor := newTextEditor("ab\n cd\n", true)
editor.Cursor = 1
editor.handleKey(runeKey("l"), true)
if editor.Cursor != 1 {
t.Fatalf("l crossed line at cursor %d", editor.Cursor)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("x result = %q", editor.Text)
}
editor.handleKey(runeKey("x"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("x deleted newline: %q", editor.Text)
}
editor.Cursor = 4
editor.handleKey(runeKey("j"), true)
if editor.Cursor != 7 {
t.Fatalf("j cursor = %d, want empty last line at 7", editor.Cursor)
}
editor.handleKey(runeKey("h"), true)
if editor.Cursor != 7 {
t.Fatalf("h crossed from empty line at cursor %d", editor.Cursor)
}
editor.handleKey(runeKey("X"), true)
if editor.Text != "a\n cd\n" {
t.Fatalf("X deleted newline: %q", editor.Text)
}
}
func TestVimTextEditorDocumentMotionsUseFirstNonBlank(t *testing.T) {
editor := newTextEditor(" first\n last", true)
editor.Cursor = 10
editor.handleKey(runeKey("g"), true)
editor.handleKey(runeKey("g"), true)
if editor.Cursor != 2 {
t.Fatalf("gg cursor = %d, want 2", editor.Cursor)
}
editor.handleKey(runeKey("G"), true)
if editor.Cursor != 11 {
t.Fatalf("G cursor = %d, want 11", editor.Cursor)
}
}
func TestEditorHighlightsCurrentLineWithoutChangingLayout(t *testing.T) {
editor := newTextEditor("short\nsecond", true)
lines := renderTextEditor(editor, 20, true)
if len(lines) != 2 || strings.Contains(joinEditorLines(lines), "█") {
t.Fatalf("cursor changed editor layout: %#v", lines)
}
if width := ansi.StringWidth(lines[0].text); width != 20 {
t.Fatalf("active line width = %d, want 20", width)
}
if width := ansi.StringWidth(lines[1].text); width != len("second") {
t.Fatalf("inactive line width = %d", width)
}
if !lines[0].active || lines[1].active {
t.Fatalf("active rows = %#v", lines)
}
editor.handleKey(runeKey("j"), true)
lines = renderTextEditor(editor, 20, true)
if width := ansi.StringWidth(lines[0].text); width != len("short") {
t.Fatalf("old line remained highlighted at width %d", width)
}
if width := ansi.StringWidth(lines[1].text); width != 20 {
t.Fatalf("new active line width = %d, want 20", width)
}
}
func TestEditorKeepsWrappedRowsAndContextRailsVisible(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.Cursor = 16
rendered := renderTextEditor(editor, 10, true)
if len(rendered) != 3 {
t.Fatalf("wrapped rows = %d, want 3", len(rendered))
}
app := App{prEditField: prEditBodyField}
app.prEditEditors[prEditBodyField] = editor
rows := app.prEditFieldLines("description", prEditBodyField, 14)
if len(rows) != 4 {
t.Fatalf("field rows = %#v", rows)
}
want := []string{"│ abcdefghij", "│ klmnopqrst", "│ uv"}
for index, expected := range want {
plain := strings.TrimRight(ansi.Strip(rows[index+1]), " ")
if plain != expected {
t.Fatalf("wrapped row %d = %q, want %q", index, plain, expected)
}
if ansi.StringWidth(rows[index+1]) > 12 {
t.Fatalf("wrapped row %d is too wide: %d", index, ansi.StringWidth(rows[index+1]))
}
}
}
func TestVimEditorTreatsSoftWrapsAsVisualLinesWithoutChangingText(t *testing.T) {
const value = "abcdefghijklmnopqrstuv"
editor := newTextEditor(value, true)
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("j"), true, 10)
if editor.Cursor != 12 {
t.Fatalf("first visual j cursor = %d, want 12", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("$"), true, 10)
if editor.Cursor != 19 {
t.Fatalf("visual $ cursor = %d, want 19", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("l"), true, 10)
if editor.Cursor != 19 {
t.Fatalf("l crossed soft wrap at cursor %d", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("j"), true, 10)
if editor.Cursor != 21 {
t.Fatalf("second visual j cursor = %d, want 21", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("0"), true, 10)
if editor.Cursor != 20 {
t.Fatalf("visual 0 cursor = %d, want 20", editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("k"), true, 10)
if editor.Cursor != 10 {
t.Fatalf("visual k cursor = %d, want 10", editor.Cursor)
}
if editor.Text != value {
t.Fatalf("visual navigation changed stored text: %q", editor.Text)
}
rendered := renderTextEditor(editor, 10, true)
activeRows := 0
for _, line := range rendered {
if line.active {
activeRows++
}
}
if activeRows != 1 {
t.Fatalf("active visual rows = %d, want 1", activeRows)
}
}
func TestEditorDoesNotAddPhantomRowAtExactSoftWrap(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrst", true)
editor.Cursor = len([]rune(editor.Text))
rendered := renderTextEditor(editor, 10, true)
if len(rendered) != 2 {
t.Fatalf("rendered rows = %d, want 2: %#v", len(rendered), rendered)
}
if !rendered[1].active {
t.Fatalf("last wrapped row is not active: %#v", rendered)
}
}
func TestEditorLineEndingNormalizationRemovesTerminalCarriageReturns(t *testing.T) {
const mixed = "first\nsecond\r\nthird\rfourth"
normalized := normalizeLineEndings(mixed)
if normalized != "first\nsecond\nthird\nfourth" {
t.Fatalf("normalized text = %q", normalized)
}
editor := newTextEditor(normalized, true)
for _, line := range renderTextEditor(editor, 80, true) {
if strings.ContainsRune(line.text, '\r') {
t.Fatalf("rendered terminal carriage return in %#v", line)
}
}
}
func TestVimVisualModeDeletesAcrossSoftWrappedRows(t *testing.T) {
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("v"), true, 10)
editor.handleKeyAtWidth(runeKey("j"), true, 10)
editor.handleKeyAtWidth(runeKey("l"), true, 10)
if editor.Mode != textEditorVisual || editor.Cursor != 13 {
t.Fatalf("visual selection mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
editor.handleKeyAtWidth(runeKey("d"), true, 10)
if editor.Text != "abopqrstuv" {
t.Fatalf("visual delete result = %q", editor.Text)
}
if editor.Mode != textEditorNormal || editor.Cursor != 2 {
t.Fatalf("after visual delete mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
}
func TestVimVisualSubstituteDeletesSelectionAndEntersInsertMode(t *testing.T) {
editor := newTextEditor("abcdef", true)
editor.Cursor = 1
editor.handleKey(runeKey("v"), false)
editor.handleKey(runeKey("l"), false)
editor.handleKey(runeKey("l"), false)
editor.handleKey(runeKey("s"), false)
if editor.Text != "aef" || editor.Cursor != 1 || editor.Mode != textEditorInsert {
t.Fatalf("visual substitute = %#v", editor)
}
editor.handleKey(runeKey("X"), false)
if editor.Text != "aXef" || editor.Cursor != 2 {
t.Fatalf("visual substitute insertion = %#v", editor)
}
}
func TestVimVisualYankAndPasteUseSystemClipboardAbstraction(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("abcdef", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("l"), true)
editor.handleKey(runeKey("l"), true)
editor.handleKey(runeKey("y"), true)
if clipboard.written != "abc" {
t.Fatalf("yanked text = %q, want abc", clipboard.written)
}
if editor.Text != "abcdef" || editor.Mode != textEditorNormal {
t.Fatalf("yank changed editor: %#v", editor)
}
clipboard.text = "XY"
editor.Cursor = 0
editor.handleKey(runeKey("p"), true)
if editor.Text != "aXYbcdef" || editor.Cursor != 2 {
t.Fatalf("paste result text=%q cursor=%d", editor.Text, editor.Cursor)
}
}
func TestVimVisualLineYankCollapsesSoftWraps(t *testing.T) {
clipboard := &memoryTextClipboard{}
editor := newTextEditor("abcdefghijklmnopqrstuv", true)
editor.clipboard = clipboard
editor.Cursor = 2
editor.handleKeyAtWidth(runeKey("V"), true, 10)
editor.handleKeyAtWidth(runeKey("j"), true, 10)
editor.handleKeyAtWidth(runeKey("y"), true, 10)
if clipboard.written != "abcdefghijklmnopqrst" {
t.Fatalf("linewise soft-wrap yank = %q", clipboard.written)
}
if strings.ContainsRune(clipboard.written, '\n') {
t.Fatalf("soft-wrap yank introduced newline: %q", clipboard.written)
}
}
func TestVimVisualFindAcceptsArbitraryTarget(t *testing.T) {
editor := newTextEditor("one x two", true)
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("f"), true)
editor.handleKey(runeKey("x"), true)
if editor.Mode != textEditorVisual || editor.Cursor != 4 {
t.Fatalf("visual fx mode=%s cursor=%d", editor.Mode, editor.Cursor)
}
}
func TestVimClipboardErrorsRemainVisibleAndPreserveSelection(t *testing.T) {
clipboard := &memoryTextClipboard{writeErr: errors.New("clipboard failed")}
editor := newTextEditor("abc", true)
editor.clipboard = clipboard
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("y"), true)
if editor.err == nil || !strings.Contains(editor.err.Error(), "clipboard failed") {
t.Fatalf("clipboard error = %v", editor.err)
}
if editor.Mode != textEditorVisual || editor.Text != "abc" {
t.Fatalf("failed yank changed selection: %#v", editor)
}
}
func TestEditorRendersModeSpecificCursorsAndVisualSelection(t *testing.T) {
editor := newTextEditor("abc", true)
normal := renderTextEditor(editor, 10, true)
if !strings.Contains(normal[0].text, "\x1b[7m") || ansi.Strip(normal[0].text) != "abc " {
t.Fatalf("normal cursor rendering = %q", normal[0].text)
}
editor.handleKey(runeKey("i"), true)
insert := renderTextEditor(editor, 10, true)
if !strings.Contains(insert[0].text, "\x1b[4m") {
t.Fatalf("insert cursor rendering = %q", insert[0].text)
}
if width := ansi.StringWidth(insert[0].text); width != 10 {
t.Fatalf("insert cursor changed row width to %d", width)
}
if plain := strings.TrimRight(ansi.Strip(insert[0].text), " "); plain != "abc" {
t.Fatalf("insert cursor hid or shifted text: %q", plain)
}
editor.handleKey(tea.KeyMsg{Type: tea.KeyEsc}, true)
editor.handleKey(runeKey("v"), true)
editor.handleKey(runeKey("l"), true)
visual := renderTextEditor(editor, 10, true)
if editor.modeLabel() != "VISUAL" || !strings.Contains(visual[0].text, "\x1b[7m") {
t.Fatalf("visual rendering mode=%s text=%q", editor.modeLabel(), visual[0].text)
}
}
func TestHardwareInsertCursorDoesNotAlterRenderedText(t *testing.T) {
editor := newTextEditor("abc", true)
editor.hardwareCursor = true
editor.handleKey(runeKey("i"), true)
rendered := renderTextEditor(editor, 10, true)
if plain := strings.TrimRight(ansi.Strip(rendered[0].text), " "); plain != "abc" {
t.Fatalf("hardware cursor altered text: %q", plain)
}
if strings.Contains(rendered[0].text, "\x1b[4m") {
t.Fatalf("hardware cursor retained fallback underline: %q", rendered[0].text)
}
}
func TestMarkdownHighlightingPreservesTextWidthsAndCursorIndexes(t *testing.T) {
const markdown = "# Heading\nUse `code` and [link](https://example.com)."
editor := newTextEditor(markdown, true)
editor.highlightMarkdown = true
rendered := renderTextEditor(editor, 80, false)
var lines []string
for _, line := range rendered {
lines = append(lines, line.text)
}
highlighted := strings.Join(lines, "\n")
if ansi.Strip(highlighted) != markdown {
t.Fatalf("highlighting changed text:\n%q\nwant:\n%q", ansi.Strip(highlighted), markdown)
}
if !strings.Contains(highlighted, "\x1b[") {
t.Fatalf("Markdown was not highlighted: %q", highlighted)
}
for index, line := range rendered {
if ansi.StringWidth(line.text) != ansi.StringWidth(ansi.Strip(line.text)) {
t.Fatalf("highlighted line %d changed width", index)
}
}
editor.Cursor = strings.Index(markdown, "code")
editor.handleKey(runeKey("s"), true)
editor.handleKey(runeKey("C"), true)
if editor.Text != strings.Replace(markdown, "code", "Code", 1) {
t.Fatalf("highlighted edit changed wrong rune: %q", editor.Text)
}
}
func TestMarkdownHighlightTokenKinds(t *testing.T) {
const markdown = "# Heading\nText **strong** and *emphasis* with `code` and [link](target)\n<!-- comment -->\n"
styles := editorMarkdownStyles(markdown)
assertStyleAt := func(fragment string, want editorMarkdownStyle) {
t.Helper()
index := len([]rune(markdown[:strings.Index(markdown, fragment)]))
if styles[index] != want {
t.Fatalf("style for %q = %d, want %d", fragment, styles[index], want)
}
}
assertStyleAt("# Heading", editorMarkdownHeading)
assertStyleAt("**strong**", editorMarkdownStrong)
assertStyleAt("*emphasis*", editorMarkdownEmphasis)
assertStyleAt("`code`", editorMarkdownCode)
assertStyleAt("link", editorMarkdownLink)
assertStyleAt("target", editorMarkdownDestination)
assertStyleAt("<!-- comment -->", editorMarkdownComment)
}
func joinEditorLines(lines []editorRenderedLine) string {
var values []string
for _, line := range lines {
values = append(values, line.text)
}
return strings.Join(values, "")
}
func runeKey(value string) tea.KeyMsg {
return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(value)}
}