package editor import ( "testing" "glint/internal/theme" tea "github.com/charmbracelet/bubbletea" ) func newEditorWith(lines ...string) *Editor { e := New() e.Lines = append([]string{}, lines...) e.SetSize(80, 10) return e } func TestSetContentSplitsLines(t *testing.T) { e := New() e.SetContent([]byte("a\nb\nc")) if len(e.Lines) != 3 || e.Lines[1] != "b" { t.Fatalf("Lines = %v", e.Lines) } if e.Dirty { t.Error("SetContent should clear Dirty") } } func TestBytesRoundTrip(t *testing.T) { e := New() e.SetContent([]byte("x\ny")) if string(e.Bytes()) != "x\ny" { t.Errorf("Bytes = %q", string(e.Bytes())) } } func TestInsertRune(t *testing.T) { e := newEditorWith("ac") e.Cursor = Position{Row: 0, Col: 1} e.InsertRune('b') if e.Lines[0] != "abc" { t.Errorf("Lines[0] = %q, want abc", e.Lines[0]) } if e.Cursor.Col != 2 { t.Errorf("Cursor.Col = %d, want 2", e.Cursor.Col) } if !e.Dirty { t.Error("insert should set Dirty") } } func TestInsertNewlineSplitsLine(t *testing.T) { e := newEditorWith("abcd") e.Cursor = Position{Row: 0, Col: 2} e.InsertNewline() if len(e.Lines) != 2 || e.Lines[0] != "ab" || e.Lines[1] != "cd" { t.Fatalf("Lines = %v", e.Lines) } if e.Cursor.Row != 1 || e.Cursor.Col != 0 { t.Errorf("Cursor = %+v, want {1 0}", e.Cursor) } } func TestBackspaceJoinsLines(t *testing.T) { e := newEditorWith("ab", "cd") e.Cursor = Position{Row: 1, Col: 0} e.Backspace() if len(e.Lines) != 1 || e.Lines[0] != "abcd" { t.Fatalf("Lines = %v", e.Lines) } if e.Cursor.Row != 0 || e.Cursor.Col != 2 { t.Errorf("Cursor = %+v, want {0 2}", e.Cursor) } } func TestBackspaceWithinLine(t *testing.T) { e := newEditorWith("abc") e.Cursor = Position{Row: 0, Col: 2} e.Backspace() if e.Lines[0] != "ac" || e.Cursor.Col != 1 { t.Errorf("Lines[0]=%q Cursor.Col=%d", e.Lines[0], e.Cursor.Col) } } func TestMovementClampsAndCrossesLines(t *testing.T) { e := newEditorWith("ab", "cde") e.Cursor = Position{Row: 0, Col: 2} // end of "ab" e.MoveRight() // wraps to start of next line if e.Cursor != (Position{Row: 1, Col: 0}) { t.Errorf("after MoveRight: %+v", e.Cursor) } e.MoveLeft() // back to end of "ab" if e.Cursor != (Position{Row: 0, Col: 2}) { t.Errorf("after MoveLeft: %+v", e.Cursor) } e.MoveEnd() e.MoveDown() // col clamps to len("cde")=3 if e.Cursor != (Position{Row: 1, Col: 2}) { t.Errorf("after MoveDown: %+v, want {1 2}", e.Cursor) } } func TestScrollFollowsCursorDown(t *testing.T) { e := New() e.SetSize(80, 3) // 3 visible rows for i := 0; i < 10; i++ { e.Lines = append(e.Lines, "x") } e.Cursor = Position{Row: 0, Col: 0} for i := 0; i < 9; i++ { e.MoveDown() } // cursor at row 9 must be visible within [Scroll, Scroll+3) if e.Cursor.Row < e.Scroll || e.Cursor.Row >= e.Scroll+e.Height { t.Errorf("cursor row %d not in viewport [%d,%d)", e.Cursor.Row, e.Scroll, e.Scroll+e.Height) } } func TestHandleKeyInsertsRunes(t *testing.T) { e := newEditorWith("") e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h', 'i'}}) if e.Lines[0] != "hi" { t.Errorf("Lines[0] = %q, want hi", e.Lines[0]) } } func TestViewRendersVisibleRowsOnly(t *testing.T) { e := New() e.SetSize(80, 2) e.Lines = []string{"one", "two", "three"} e.Cursor = Position{Row: 0, Col: 0} view := e.View() // 2 visible rows -> exactly 2 newline-terminated lines if got := countByte(view, '\n'); got != 2 { t.Errorf("view has %d newlines, want 2", got) } } func countByte(s string, b byte) int { n := 0 for i := 0; i < len(s); i++ { if s[i] == b { n++ } } return n } func TestHandleKeySpaceInsertsSingleSpace(t *testing.T) { e := newEditorWith("ab") e.Cursor = Position{Row: 0, Col: 1} e.HandleKey(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) if e.Lines[0] != "a b" { t.Errorf("Lines[0] = %q, want 'a b'", e.Lines[0]) } } func TestDeleteWithinLine(t *testing.T) { e := newEditorWith("abc") e.Cursor = Position{Row: 0, Col: 1} e.Delete() if e.Lines[0] != "ac" { t.Errorf("Lines[0] = %q, want 'ac'", e.Lines[0]) } if e.Cursor.Col != 1 { t.Errorf("Cursor.Col = %d, want 1", e.Cursor.Col) } if !e.Dirty { t.Error("Delete should set Dirty") } } func TestDeleteAtEndOfLastLineIsNoop(t *testing.T) { e := newEditorWith("ab") e.Cursor = Position{Row: 0, Col: 2} original := e.Lines[0] e.Delete() if e.Lines[0] != original { t.Errorf("Lines[0] = %q, want %q", e.Lines[0], original) } if len(e.Lines) != 1 { t.Errorf("len(Lines) = %d, want 1", len(e.Lines)) } } func TestDeleteJoinsNextLine(t *testing.T) { e := newEditorWith("ab", "cd") e.Cursor = Position{Row: 0, Col: 2} e.Delete() if e.Lines[0] != "abcd" { t.Errorf("Lines[0] = %q, want 'abcd'", e.Lines[0]) } if len(e.Lines) != 1 { t.Errorf("len(Lines) = %d, want 1", len(e.Lines)) } } func TestBackspaceAtOriginIsNoop(t *testing.T) { e := newEditorWith("ab") e.Cursor = Position{Row: 0, Col: 0} original := e.Lines[0] e.Backspace() if e.Lines[0] != original { t.Errorf("Lines[0] = %q, want %q", e.Lines[0], original) } if e.Cursor != (Position{Row: 0, Col: 0}) { t.Errorf("Cursor = %+v, want {0 0}", e.Cursor) } } func TestMoveHomeAndEnd(t *testing.T) { e := newEditorWith("hello") e.Cursor = Position{Row: 0, Col: 3} e.MoveHome() if e.Cursor.Col != 0 { t.Errorf("after MoveHome: Cursor.Col = %d, want 0", e.Cursor.Col) } e.MoveEnd() if e.Cursor.Col != 5 { t.Errorf("after MoveEnd: Cursor.Col = %d, want 5", e.Cursor.Col) } } func TestVisualDownAndUpAcrossWrappedLine(t *testing.T) { e := New() e.Lines = []string{"aaaa bbbb cccc"} // wraps at width 9 into ["aaaa ","bbbb cccc"] e.SetSize(9, 10) e.Cursor = Position{Row: 0, Col: 0} e.setGoal() // ops normally set the goal; set it directly here e.MoveDown() // to the second visual row, goal column 0 if e.Cursor != (Position{Row: 0, Col: 5}) { t.Errorf("after MoveDown: %+v, want {0 5}", e.Cursor) } e.MoveUp() // back to the first visual row, goal column 0 if e.Cursor != (Position{Row: 0, Col: 0}) { t.Errorf("after MoveUp: %+v, want {0 0}", e.Cursor) } } func TestVisualDownKeepsGoalColumn(t *testing.T) { e := New() e.Lines = []string{"aaaa bbbb cccc"} e.SetSize(9, 10) e.Cursor = Position{Row: 0, Col: 2} // visual column 2 in segment 0 e.setGoal() // ops normally set the goal; set it directly here e.MoveDown() // segment 1 starts at col 5 → col 5+2 = 7 if e.Cursor != (Position{Row: 0, Col: 7}) { t.Errorf("after MoveDown with goal: %+v, want {0 7}", e.Cursor) } } func TestBytesRoundTripUnaffectedByWrap(t *testing.T) { e := New() e.SetContent([]byte("a very long single logical line that will wrap many times\nsecond")) e.SetSize(10, 5) if string(e.Bytes()) != "a very long single logical line that will wrap many times\nsecond" { t.Errorf("wrap altered the buffer: %q", string(e.Bytes())) } } func TestScrollIsVisualRowIndex(t *testing.T) { e := New() e.Lines = []string{"aaaa bbbb cccc dddd eeee ffff"} // one logical line, many visual rows at width 9 e.SetSize(9, 2) // only 2 visible visual rows e.Cursor = Position{Row: 0, Col: 0} // Move down several visual rows; Scroll must follow so the cursor stays visible. for i := 0; i < 3; i++ { e.MoveDown() } rows := e.buildVisual() ci := cursorVIndex(rows, e.Cursor) if ci < e.Scroll || ci >= e.Scroll+e.Height { t.Errorf("cursor visual row %d not in viewport [%d,%d)", ci, e.Scroll, e.Scroll+e.Height) } } func TestBuildVisualSpansCarryThemeBackground(t *testing.T) { e := New() th := theme.FlexokiDark() e.SetTheme(th) e.Lines = []string{"# Heading **bold**", "plain text", ""} for _, vr := range e.buildVisual() { for _, sp := range vr.spans { if sp.Style.GetBackground() != th.Background { t.Errorf("span %q background = %v, want theme background %v", sp.Text, sp.Style.GetBackground(), th.Background) } } } } func TestKillToLineStart(t *testing.T) { e := newEditorWith("hello world") e.Cursor = Position{Row: 0, Col: 6} e.KillToLineStart() if e.Lines[0] != "world" { t.Errorf("Lines[0] = %q, want 'world'", e.Lines[0]) } if e.Cursor.Col != 0 { t.Errorf("Cursor.Col = %d, want 0", e.Cursor.Col) } if !e.Dirty { t.Error("kill should set Dirty") } } func TestKillToLineEnd(t *testing.T) { e := newEditorWith("hello world") e.Cursor = Position{Row: 0, Col: 5} e.KillToLineEnd() if e.Lines[0] != "hello" { t.Errorf("Lines[0] = %q, want 'hello'", e.Lines[0]) } if !e.Dirty { t.Error("kill should set Dirty") } } func TestKillToLineStartAtColZeroNoop(t *testing.T) { e := newEditorWith("abc") e.Cursor = Position{Row: 0, Col: 0} e.KillToLineStart() if e.Lines[0] != "abc" || e.Dirty { t.Errorf("kill at col 0 should be a noop; line=%q dirty=%v", e.Lines[0], e.Dirty) } } func TestVisualMoveDownDoesNotSkipWrapBoundary(t *testing.T) { e := New() e.Lines = []string{"zzzzz", "aaaa bbbb"} // line1 wraps at width 5 into ["aaaa ","bbbb"] e.SetSize(5, 10) e.Cursor = Position{Row: 0, Col: 5} // end of line0 e.setGoal() // goalCol = 5 e.MoveDown() rows := e.buildVisual() // rows: [0]=line0, [1]=line1 "aaaa ", [2]=line1 "bbbb". // MoveDown must land on visual row 1 (first segment of line1), not skip to 2. if ci := cursorVIndex(rows, e.Cursor); ci != 1 { t.Errorf("MoveDown landed on visual row %d, want 1 (did not stay on the wrap-boundary row)", ci) } if e.Cursor.Row != 1 { t.Errorf("Cursor.Row = %d, want 1", e.Cursor.Row) } } func TestScrollByClamps(t *testing.T) { e := New() for i := 0; i < 20; i++ { e.Lines = append(e.Lines, "x") } e.SetSize(80, 5) e.Cursor = Position{Row: 0, Col: 0} e.ScrollBy(-5) // can't go below 0 if e.Scroll != 0 { t.Errorf("Scroll = %d, want 0", e.Scroll) } e.ScrollBy(100) // clamps to max max := len(e.buildVisual()) - e.Height if e.Scroll != max { t.Errorf("Scroll = %d, want %d (clamped)", e.Scroll, max) } } func TestWordNavigationAndDelete(t *testing.T) { e := newEditorWith("hello world foo") e.Cursor = Position{Row: 0, Col: 15} // end e.MoveWordLeft() if e.Cursor.Col != 12 { // start of "foo" t.Errorf("MoveWordLeft → col %d, want 12", e.Cursor.Col) } e.MoveWordLeft() if e.Cursor.Col != 6 { // start of "world" t.Errorf("MoveWordLeft → col %d, want 6", e.Cursor.Col) } e.MoveWordRight() if e.Cursor.Col != 11 { // end of "world" t.Errorf("MoveWordRight → col %d, want 11", e.Cursor.Col) } // delete the word before the cursor ("world") e.DeleteWordLeft() if e.Lines[0] != "hello foo" { t.Errorf("DeleteWordLeft → %q, want 'hello foo'", e.Lines[0]) } } func TestHandleKeyAltWord(t *testing.T) { e := newEditorWith("alpha beta") e.Cursor = Position{Row: 0, Col: 10} e.HandleKey(tea.KeyMsg{Type: tea.KeyLeft, Alt: true}) if e.Cursor.Col != 6 { t.Errorf("Alt+Left → col %d, want 6 (start of beta)", e.Cursor.Col) } e.Cursor.Col = 10 // back to end e.HandleKey(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true}) if e.Lines[0] != "alpha " { t.Errorf("Alt+Backspace at end → %q, want 'alpha '", e.Lines[0]) } } func TestHandleKeyAltBFAreWordMotionNotText(t *testing.T) { e := newEditorWith("alpha beta gamma") e.Cursor = Position{Row: 0, Col: 16} e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("b"), Alt: true}) if e.Cursor.Col != 11 { t.Errorf("Alt+b → col %d, want 11 (start of gamma)", e.Cursor.Col) } if e.Lines[0] != "alpha beta gamma" { t.Errorf("Alt+b inserted text: %q", e.Lines[0]) } e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f"), Alt: true}) if e.Cursor.Col != 16 { t.Errorf("Alt+f → col %d, want 16 (end of gamma)", e.Cursor.Col) } } func TestDocStartEnd(t *testing.T) { e := newEditorWith("first", "middle", "last line") e.Cursor = Position{Row: 1, Col: 2} e.HandleKey(tea.KeyMsg{Type: tea.KeyEnd}) if e.Cursor != (Position{Row: 2, Col: 9}) { t.Errorf("End → %+v, want {2 9}", e.Cursor) } e.HandleKey(tea.KeyMsg{Type: tea.KeyHome}) if e.Cursor != (Position{Row: 0, Col: 0}) { t.Errorf("Home → %+v, want {0 0}", e.Cursor) } } func TestLineStartEndViaCtrlUpDown(t *testing.T) { e := newEditorWith(" indented line") e.Cursor = Position{Row: 0, Col: 8} e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlDown}) if e.Cursor.Col != len(" indented line") { t.Errorf("Ctrl+Down → col %d, want %d (line end)", e.Cursor.Col, len(" indented line")) } e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlUp}) if e.Cursor.Col != 2 { t.Errorf("Ctrl+Up → col %d, want 2 (first non-blank)", e.Cursor.Col) } e.HandleKey(tea.KeyMsg{Type: tea.KeyCtrlUp}) if e.Cursor.Col != 0 { t.Errorf("Ctrl+Up again → col %d, want 0 (toggle to column 0)", e.Cursor.Col) } } func TestShiftSelectAndReplace(t *testing.T) { e := newEditorWith("hello world") e.Cursor = Position{Row: 0, Col: 0} for i := 0; i < 5; i++ { e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight}) } if !e.HasSelection() || e.SelectedText() != "hello" { t.Fatalf("selection = %q, want hello", e.SelectedText()) } e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("X")}) if e.Lines[0] != "X world" { t.Errorf("typing over selection → %q, want 'X world'", e.Lines[0]) } if e.HasSelection() { t.Error("selection should clear after replace") } } func TestBackspaceDeletesSelection(t *testing.T) { e := newEditorWith("abcdef") e.Cursor = Position{Row: 0, Col: 2} for i := 0; i < 3; i++ { e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight}) // select "cde" } e.HandleKey(tea.KeyMsg{Type: tea.KeyBackspace}) if e.Lines[0] != "abf" { t.Errorf("backspace over selection → %q, want abf", e.Lines[0]) } } func TestMultilineSelectedTextAndDelete(t *testing.T) { e := newEditorWith("abc", "def", "ghi") e.anchor = &Position{Row: 0, Col: 1} e.Cursor = Position{Row: 2, Col: 2} if got := e.SelectedText(); got != "bc\ndef\ngh" { t.Errorf("SelectedText = %q, want 'bc\\ndef\\ngh'", got) } e.DeleteSelection() if len(e.Lines) != 1 || e.Lines[0] != "ai" { t.Errorf("after delete: %v, want [ai]", e.Lines) } } func TestPlainMoveClearsSelection(t *testing.T) { e := newEditorWith("hello") e.Cursor = Position{Row: 0, Col: 0} e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight}) e.HandleKey(tea.KeyMsg{Type: tea.KeyRight}) // plain move collapses if e.HasSelection() { t.Error("plain move should clear selection") } }