Compare commits
No commits in common. "ddbc860530ae816865e854c17d51728db31ca5b7" and "402c93db50886d1628434a83f32ec14038ad2c97" have entirely different histories.
ddbc860530
...
402c93db50
31
FEATURES.md
31
FEATURES.md
@ -127,8 +127,8 @@
|
||||
- [ ] Last search register (`/`)
|
||||
|
||||
### Undo/Redo
|
||||
- [x] `u` - Undo
|
||||
- [x] `ctrl+r` - Redo
|
||||
- [ ] `u` - Undo
|
||||
- [ ] `ctrl+r` - Redo
|
||||
- [ ] `.` - Repeat last change
|
||||
- [ ] `U` - Undo all changes on line
|
||||
|
||||
@ -228,20 +228,18 @@
|
||||
|
||||
## Text Objects
|
||||
|
||||
### Implemented
|
||||
|
||||
- [x] `iw` / `aw` - Inner/around word
|
||||
- [x] `iW` / `aW` - Inner/around WORD
|
||||
- [x] `is` / `as` - Inner/around sentence
|
||||
- [x] `ip` / `ap` - Inner/around paragraph
|
||||
- [x] `i"` / `a"` - Inner/around double quotes
|
||||
- [x] `i'` / `a'` - Inner/around single quotes
|
||||
- [x] `` i` `` / `` a` `` - Inner/around backticks
|
||||
- [x] `i(` / `a(` - Inner/around parentheses
|
||||
- [x] `i[` / `a[` - Inner/around brackets
|
||||
- [x] `i{` / `a{` - Inner/around braces
|
||||
- [x] `i<` / `a<` - Inner/around angle brackets
|
||||
### Not Implemented
|
||||
- [ ] `iw` / `aw` - Inner/around word
|
||||
- [ ] `iW` / `aW` - Inner/around WORD
|
||||
- [ ] `is` / `as` - Inner/around sentence
|
||||
- [ ] `ip` / `ap` - Inner/around paragraph
|
||||
- [ ] `i"` / `a"` - Inner/around double quotes
|
||||
- [ ] `i'` / `a'` - Inner/around single quotes
|
||||
- [ ] `` i` `` / `` a` `` - Inner/around backticks
|
||||
- [ ] `i(` / `a(` - Inner/around parentheses
|
||||
- [ ] `i[` / `a[` - Inner/around brackets
|
||||
- [ ] `i{` / `a{` - Inner/around braces
|
||||
- [ ] `i<` / `a<` - Inner/around angle brackets
|
||||
- [ ] `it` / `at` - Inner/around tag
|
||||
|
||||
---
|
||||
@ -373,8 +371,7 @@ Buffers are in-memory representations of files. A buffer exists for each open fi
|
||||
### Display
|
||||
- [x] Line numbers
|
||||
- [x] Cursor position tracking
|
||||
- [x] Viewport/scrolling (Y)
|
||||
- [ ] Viewport/scrolling (X)
|
||||
- [x] Viewport/scrolling
|
||||
- [x] ScrollOff setting
|
||||
- [x] Relative line numbers
|
||||
- [ ] Cursor line highlight
|
||||
|
||||
@ -60,27 +60,3 @@ func (a EnterVisualBlockMode) Execute(m Model) tea.Cmd {
|
||||
m.SetMode(core.VisualBlockMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Implement count?
|
||||
type Undo struct{}
|
||||
|
||||
func (a Undo) Execute(m Model) tea.Cmd {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
if buf.UndoStack.CanUndo() {
|
||||
buf.Undo(win)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Implement count?
|
||||
type Redo struct{}
|
||||
|
||||
func (a Redo) Execute(m Model) tea.Cmd {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
if buf.UndoStack.CanRedo() {
|
||||
buf.Redo(win)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -933,29 +933,3 @@ func cmdListColorschemes(m action.Model, args []string, force bool) tea.Cmd {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func cmdUndoList(m action.Model, args []string, force bool) tea.Cmd {
|
||||
_, _ = args, force
|
||||
|
||||
lines := m.ActiveBuffer().UndoStack.List()
|
||||
|
||||
// For now, display an error when empty
|
||||
if len(lines) == 0 {
|
||||
m.SetCommandOutput(&core.CommandOutput{
|
||||
Lines: []string{"Undo stack is empty"},
|
||||
Inline: true,
|
||||
IsError: true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
m.SetMode(core.CommandOutputMode)
|
||||
m.SetCommandOutput(&core.CommandOutput{
|
||||
Title: ":undo",
|
||||
Lines: lines,
|
||||
Inline: false,
|
||||
IsError: false,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -237,11 +237,4 @@ func (r *Registry) registerDefaults() {
|
||||
ShortForm: "colorschemes",
|
||||
Handler: cmdListColorschemes,
|
||||
})
|
||||
|
||||
// Undo stack commands
|
||||
r.Register(Command{
|
||||
Name: "undo",
|
||||
ShortForm: "u",
|
||||
Handler: cmdUndoList,
|
||||
})
|
||||
}
|
||||
|
||||
@ -29,7 +29,7 @@ type Buffer struct {
|
||||
ReadOnly bool
|
||||
|
||||
// Options BufferOptions
|
||||
UndoStack *UndoStack
|
||||
// UndoTree TODO: This will be big
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
@ -49,10 +49,6 @@ func (b *Buffer) Line(idx int) string {
|
||||
// index is out of bounds. This function sets the modified flag.
|
||||
func (b *Buffer) SetLine(idx int, content string) {
|
||||
if idx >= 0 && idx < len(b.Lines) {
|
||||
// Record set line in undo stack
|
||||
if b.UndoStack != nil {
|
||||
b.UndoStack.RecordSetLine(idx, b.Lines[idx], content)
|
||||
}
|
||||
b.Lines[idx] = content
|
||||
}
|
||||
b.Modified = true
|
||||
@ -68,12 +64,6 @@ func (b *Buffer) InsertLine(idx int, content string) {
|
||||
if idx > len(b.Lines) {
|
||||
idx = len(b.Lines)
|
||||
}
|
||||
|
||||
// Record insert line in undo stack
|
||||
if b.UndoStack != nil {
|
||||
b.UndoStack.RecordInsertLine(idx, content)
|
||||
}
|
||||
|
||||
b.Lines = append(b.Lines[:idx], append([]string{content}, b.Lines[idx:]...)...)
|
||||
b.Modified = true
|
||||
}
|
||||
@ -82,10 +72,6 @@ func (b *Buffer) InsertLine(idx int, content string) {
|
||||
// of bounds. This function sets the modified flag.
|
||||
func (b *Buffer) DeleteLine(idx int) {
|
||||
if idx >= 0 && idx < len(b.Lines) {
|
||||
// Record delete line in undo stack
|
||||
if b.UndoStack != nil {
|
||||
b.UndoStack.RecordDeleteLine(idx, b.Lines[idx])
|
||||
}
|
||||
b.Lines = append(b.Lines[:idx], b.Lines[idx+1:]...)
|
||||
}
|
||||
b.Modified = true
|
||||
@ -96,99 +82,6 @@ func (b *Buffer) LineCount() int {
|
||||
return len(b.Lines)
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Undo Stack
|
||||
// ==================================================
|
||||
func (b *Buffer) Undo(w *Window) bool {
|
||||
if b.UndoStack == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
block := b.UndoStack.Undo()
|
||||
if block == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Apply changes in REVERSE order
|
||||
for i := len(block.Changes) - 1; i >= 0; i-- {
|
||||
change := block.Changes[i]
|
||||
|
||||
// Temporarily disable recording while we undo
|
||||
wasRecording := b.UndoStack.recording
|
||||
b.UndoStack.recording = false
|
||||
|
||||
switch change.Type {
|
||||
case SetLineChange:
|
||||
// Restore old data
|
||||
if change.Line >= 0 && change.Line < len(b.Lines) {
|
||||
b.Lines[change.Line] = change.OldData
|
||||
}
|
||||
case InsertLineChange:
|
||||
// Remove the inserted line
|
||||
if change.Line >= 0 && change.Line < len(b.Lines) {
|
||||
b.Lines = append(b.Lines[:change.Line], b.Lines[change.Line+1:]...)
|
||||
}
|
||||
case DeleteLineChange:
|
||||
// Re-insert the deleted line
|
||||
if change.Line <= len(b.Lines) {
|
||||
b.Lines = append(b.Lines[:change.Line], append([]string{change.OldData}, b.Lines[change.Line:]...)...)
|
||||
}
|
||||
}
|
||||
|
||||
b.UndoStack.recording = wasRecording
|
||||
}
|
||||
|
||||
// Restore cursor position
|
||||
w.SetCursorLine(block.OldCursor.Line)
|
||||
w.SetCursorCol(block.OldCursor.Col)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Buffer) Redo(w *Window) bool {
|
||||
if b.UndoStack == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
block := b.UndoStack.Redo()
|
||||
if block == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Apply changes in FORWARD order
|
||||
for _, change := range block.Changes {
|
||||
// Temporarily disable recording while we redo
|
||||
wasRecording := b.UndoStack.recording
|
||||
b.UndoStack.recording = false
|
||||
|
||||
switch change.Type {
|
||||
case SetLineChange:
|
||||
// Apply new data
|
||||
if change.Line >= 0 && change.Line < len(b.Lines) {
|
||||
b.Lines[change.Line] = change.NewData
|
||||
}
|
||||
case InsertLineChange:
|
||||
// Re-insert the line
|
||||
if change.Line <= len(b.Lines) {
|
||||
b.Lines = append(b.Lines[:change.Line], append([]string{change.NewData}, b.Lines[change.Line:]...)...)
|
||||
}
|
||||
case DeleteLineChange:
|
||||
// Re-delete the line
|
||||
if change.Line >= 0 && change.Line < len(b.Lines) {
|
||||
b.Lines = append(b.Lines[:change.Line], b.Lines[change.Line+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
b.UndoStack.recording = wasRecording
|
||||
}
|
||||
|
||||
// Restore cursor position
|
||||
w.SetCursorLine(block.NewCursor.Line)
|
||||
w.SetCursorCol(block.NewCursor.Col)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Setters
|
||||
// ==================================================
|
||||
|
||||
@ -12,16 +12,15 @@ type BufferBuilder struct {
|
||||
func NewBufferBuilder() *BufferBuilder {
|
||||
return &BufferBuilder{
|
||||
buffer: Buffer{
|
||||
Id: 0, // This is set when built
|
||||
Type: ScatchBuffer, // Default buffer type
|
||||
Filename: "",
|
||||
Filetype: "",
|
||||
Lines: []string{""},
|
||||
Modified: false,
|
||||
Loaded: false,
|
||||
Listed: false,
|
||||
ReadOnly: false,
|
||||
UndoStack: NewUndoStack(), // Empty undo stack
|
||||
Id: 0, // This is set when built
|
||||
Type: ScatchBuffer, // Default buffer type
|
||||
Filename: "",
|
||||
Filetype: "",
|
||||
Lines: []string{""},
|
||||
Modified: false,
|
||||
Loaded: false,
|
||||
Listed: false,
|
||||
ReadOnly: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import (
|
||||
)
|
||||
|
||||
const CommandOutputExitMessage = "Press ENTER to continue"
|
||||
const CommandOutputScrollMessage = "Use j/k to scroll"
|
||||
|
||||
type CommandOutput struct {
|
||||
Title string
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type ChangeType string
|
||||
|
||||
const (
|
||||
SetLineChange ChangeType = "SetLine"
|
||||
InsertLineChange ChangeType = "InsertLine"
|
||||
DeleteLineChange ChangeType = "DeleteLine"
|
||||
)
|
||||
|
||||
type Change struct {
|
||||
Type ChangeType
|
||||
Line int
|
||||
OldData string
|
||||
NewData string
|
||||
}
|
||||
|
||||
type ChangeBlock struct {
|
||||
Changes []Change
|
||||
OldCursor Position // Before OP
|
||||
NewCursor Position // After OP
|
||||
}
|
||||
|
||||
type UndoStack struct {
|
||||
undoStack []ChangeBlock
|
||||
redoStack []ChangeBlock
|
||||
current []Change
|
||||
recording bool
|
||||
oldCursor Position
|
||||
}
|
||||
|
||||
func NewUndoStack() *UndoStack {
|
||||
return &UndoStack{
|
||||
undoStack: []ChangeBlock{},
|
||||
redoStack: []ChangeBlock{},
|
||||
current: []Change{},
|
||||
recording: false,
|
||||
oldCursor: Position{},
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UndoStack) BeginBlock(cursor Position) {
|
||||
u.current = []Change{}
|
||||
u.recording = true
|
||||
u.oldCursor = cursor
|
||||
}
|
||||
|
||||
func (u *UndoStack) EndBlock(cursor Position) {
|
||||
// If not recording or nothing changed, we can exit safely
|
||||
if !u.recording || len(u.current) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
block := ChangeBlock{
|
||||
Changes: u.current,
|
||||
OldCursor: u.oldCursor,
|
||||
NewCursor: cursor,
|
||||
}
|
||||
|
||||
u.undoStack = append(u.undoStack, block)
|
||||
u.redoStack = []ChangeBlock{} // Reset old changes, can no longer redo
|
||||
|
||||
u.recording = false
|
||||
u.current = []Change{}
|
||||
}
|
||||
|
||||
func (u *UndoStack) RecordSetLine(line int, oldData, newData string) {
|
||||
if !u.recording {
|
||||
return
|
||||
}
|
||||
change := Change{
|
||||
Type: SetLineChange,
|
||||
Line: line,
|
||||
OldData: oldData,
|
||||
NewData: newData,
|
||||
}
|
||||
u.current = append(u.current, change)
|
||||
}
|
||||
|
||||
func (u *UndoStack) RecordInsertLine(line int, newData string) {
|
||||
if !u.recording {
|
||||
return
|
||||
}
|
||||
change := Change{
|
||||
Type: InsertLineChange,
|
||||
Line: line,
|
||||
NewData: newData,
|
||||
}
|
||||
u.current = append(u.current, change)
|
||||
}
|
||||
|
||||
func (u *UndoStack) RecordDeleteLine(line int, oldData string) {
|
||||
if !u.recording {
|
||||
return
|
||||
}
|
||||
change := Change{
|
||||
Type: DeleteLineChange,
|
||||
Line: line,
|
||||
OldData: oldData,
|
||||
}
|
||||
u.current = append(u.current, change)
|
||||
}
|
||||
|
||||
func (u *UndoStack) Undo() *ChangeBlock {
|
||||
if len(u.undoStack) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pop from undo stack
|
||||
size := len(u.undoStack)
|
||||
block := u.undoStack[size-1]
|
||||
u.undoStack = u.undoStack[:size-1]
|
||||
|
||||
// Push to redo stack
|
||||
u.redoStack = append(u.redoStack, block)
|
||||
|
||||
return &block
|
||||
}
|
||||
|
||||
func (u *UndoStack) Redo() *ChangeBlock {
|
||||
if len(u.redoStack) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pop from redo stack
|
||||
size := len(u.redoStack)
|
||||
block := u.redoStack[size-1]
|
||||
u.redoStack = u.redoStack[:size-1]
|
||||
|
||||
// Push to undo stack
|
||||
u.undoStack = append(u.undoStack, block)
|
||||
|
||||
return &block
|
||||
}
|
||||
|
||||
func (u *UndoStack) CanUndo() bool {
|
||||
return len(u.undoStack) > 0
|
||||
}
|
||||
|
||||
func (u *UndoStack) CanRedo() bool {
|
||||
return len(u.redoStack) > 0
|
||||
}
|
||||
|
||||
func (u *UndoStack) Recording() bool {
|
||||
return u.recording
|
||||
}
|
||||
|
||||
func (u *UndoStack) List() []string {
|
||||
var lines []string
|
||||
|
||||
stack := slices.Clone(u.undoStack)
|
||||
slices.Reverse(stack)
|
||||
|
||||
for _, b := range stack {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"block (%d:%d) -> (%d:%d)",
|
||||
b.OldCursor.Line,
|
||||
b.OldCursor.Col,
|
||||
b.NewCursor.Line,
|
||||
b.NewCursor.Col,
|
||||
))
|
||||
|
||||
for _, c := range b.Changes {
|
||||
lines = append(lines, fmt.Sprintf(
|
||||
"\t%q #%d (%s) -> (%s)",
|
||||
c.Type,
|
||||
c.Line,
|
||||
c.OldData,
|
||||
c.NewData,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
@ -30,8 +30,6 @@ func sendKeys(tm *teatest.TestModel, keys ...string) {
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlU})
|
||||
case "ctrl+v":
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlV})
|
||||
case "ctrl+r":
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlR})
|
||||
case "ctrl+w":
|
||||
tm.Send(tea.KeyMsg{Type: tea.KeyCtrlW})
|
||||
default:
|
||||
|
||||
@ -1,992 +0,0 @@
|
||||
package editor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
// equalStringSlices compares two string slices for equality
|
||||
func equalStringSlices(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BASIC UNDO/REDO TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoBasicOperations(t *testing.T) {
|
||||
t.Run("undo single character delete with x", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "x") // Delete 'h'
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
|
||||
// Verify undo stack is empty
|
||||
if m.ActiveBuffer().UndoStack.CanUndo() {
|
||||
t.Error("Expected undo stack to be empty after undoing all changes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo and redo single character delete", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "x") // Delete 'h'
|
||||
sendKeys(tm, "u") // Undo
|
||||
sendKeys(tm, "ctrl+r") // Redo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "ello" {
|
||||
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
|
||||
// Verify redo stack is empty
|
||||
if m.ActiveBuffer().UndoStack.CanRedo() {
|
||||
t.Error("Expected redo stack to be empty after redoing all changes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo multiple x operations creates separate undo blocks", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "x") // Delete 'h' -> "ello"
|
||||
sendKeys(tm, "x") // Delete 'e' -> "llo"
|
||||
sendKeys(tm, "x") // Delete first 'l' -> "lo"
|
||||
sendKeys(tm, "u") // Undo last x -> "llo"
|
||||
sendKeys(tm, "u") // Undo second x -> "ello"
|
||||
sendKeys(tm, "u") // Undo first x -> "hello"
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("After 3 undos: lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
|
||||
// Verify undo stack is empty
|
||||
if m.ActiveBuffer().UndoStack.CanUndo() {
|
||||
t.Error("Expected undo stack to be empty after undoing all changes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo single X (delete backward)", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||
sendKeys(tm, "X") // Delete 'e'
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUndoCursorRestoration(t *testing.T) {
|
||||
t.Run("undo restores cursor position after x", func(t *testing.T) {
|
||||
lines := []string{"hello world"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
||||
sendKeys(tm, "x") // Delete 'w' at position 6
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveWindow().Cursor.Col != 6 {
|
||||
t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0] != "hello world" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("redo restores cursor position after operation", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "x") // Delete at position 0
|
||||
sendKeys(tm, "u") // Undo
|
||||
sendKeys(tm, "ctrl+r") // Redo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("cursor col = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INSERT MODE UNDO TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoInsertMode(t *testing.T) {
|
||||
t.Run("insert mode groups all characters into one undo", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "i") // Enter insert mode
|
||||
sendKeyString(tm, "hello") // Type 5 characters
|
||||
sendKeys(tm, "esc") // Exit insert mode
|
||||
sendKeys(tm, "u") // Undo once
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
|
||||
// Verify only one undo was needed
|
||||
if m.ActiveBuffer().UndoStack.CanUndo() {
|
||||
t.Error("Expected undo stack to be empty after single undo of insert session")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple insert sessions create separate undo blocks", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
|
||||
// First insert session
|
||||
sendKeys(tm, "i")
|
||||
sendKeyString(tm, "hello")
|
||||
sendKeys(tm, "esc")
|
||||
|
||||
// Second insert session
|
||||
sendKeys(tm, "a") // Append
|
||||
sendKeyString(tm, " world")
|
||||
sendKeys(tm, "esc")
|
||||
|
||||
// Undo second session
|
||||
sendKeys(tm, "u")
|
||||
// Undo first session
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("After 2 undos: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
|
||||
// Verify undo stack is empty
|
||||
if m.ActiveBuffer().UndoStack.CanUndo() {
|
||||
t.Error("Expected undo stack to be empty after undoing all changes")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("insert with newlines groups everything into one undo", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "i")
|
||||
sendKeyString(tm, "line1")
|
||||
sendKeys(tm, "enter")
|
||||
sendKeyString(tm, "line2")
|
||||
sendKeys(tm, "enter")
|
||||
sendKeyString(tm, "line3")
|
||||
sendKeys(tm, "esc")
|
||||
// Single undo should remove everything
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 1 || m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("After undo: got %d lines with content %q, want 1 empty line",
|
||||
len(m.ActiveBuffer().Lines), m.ActiveBuffer().Lines)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("insert mode with backspace is grouped into one undo", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "i")
|
||||
sendKeyString(tm, "hello")
|
||||
sendKeys(tm, "backspace", "backspace") // Delete "lo"
|
||||
sendKeyString(tm, "y") // Type "y"
|
||||
sendKeys(tm, "esc")
|
||||
// Single undo should remove entire insert session
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPERATOR UNDO TESTS (dd, cc, etc.)
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoDeleteOperator(t *testing.T) {
|
||||
t.Run("dd creates one undo block", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2", "line3"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "d", "d") // Delete line1
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 3 {
|
||||
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0] != "line1" {
|
||||
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("3dd creates one undo block for all 3 lines", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2", "line3", "line4"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "3", "d", "d") // Delete 3 lines
|
||||
sendKeys(tm, "u") // Undo once
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 4 {
|
||||
t.Errorf("line count = %d, want 4", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0] != "line1" {
|
||||
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.ActiveBuffer().Lines[2] != "line3" {
|
||||
t.Errorf("lines[2] = %q, want 'line3'", m.ActiveBuffer().Lines[2])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dw (delete word) creates one undo block", func(t *testing.T) {
|
||||
lines := []string{"hello world foo"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "d", "w") // Delete "hello "
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello world foo" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("D (delete to end of line) undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello world"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
||||
sendKeys(tm, "D") // Delete "world"
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello world" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 6 {
|
||||
t.Errorf("cursor col = %d, want 6", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUndoChangeOperator(t *testing.T) {
|
||||
t.Run("cc (change line) undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"original line", "line2"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "c", "c") // Change line (deletes and enters insert)
|
||||
sendKeyString(tm, "new line") // Type new content
|
||||
sendKeys(tm, "esc") // Exit insert mode
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "original line" {
|
||||
t.Errorf("lines[0] = %q, want 'original line'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cw (change word) undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello world"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "c", "w") // Change word
|
||||
sendKeyString(tm, "hi") // Type replacement
|
||||
sendKeys(tm, "esc")
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello world" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("s (substitute char) undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "s") // Substitute character
|
||||
sendKeyString(tm, "H") // Type replacement
|
||||
sendKeys(tm, "esc")
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("S (substitute line) undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"original", "line2"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "S") // Substitute line
|
||||
sendKeyString(tm, "replaced") // Type replacement
|
||||
sendKeys(tm, "esc")
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "original" {
|
||||
t.Errorf("lines[0] = %q, want 'original'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// VISUAL MODE UNDO TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoVisualMode(t *testing.T) {
|
||||
t.Run("visual char mode delete undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello world"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "v") // Enter visual char mode
|
||||
sendKeys(tm, "l", "l", "l", "l") // Select "hello"
|
||||
sendKeys(tm, "d") // Delete selection
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello world" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual line mode delete undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2", "line3"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "V") // Enter visual line mode
|
||||
sendKeys(tm, "j") // Select 2 lines
|
||||
sendKeys(tm, "d") // Delete
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 3 {
|
||||
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0] != "line1" {
|
||||
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual block mode delete undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello", "world", "test"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "ctrl+v") // Enter visual block mode
|
||||
sendKeys(tm, "j", "j") // Select 3 lines
|
||||
sendKeys(tm, "l", "l") // Select 3 columns
|
||||
sendKeys(tm, "d") // Delete block
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.ActiveBuffer().Lines[1] != "world" {
|
||||
t.Errorf("lines[1] = %q, want 'world'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual char mode change undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello world"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "v") // Visual char mode
|
||||
sendKeys(tm, "l", "l", "l", "l") // Select "hello"
|
||||
sendKeys(tm, "c") // Change
|
||||
sendKeyString(tm, "hi") // Type replacement
|
||||
sendKeys(tm, "esc")
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello world" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TEXT OBJECT UNDO TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoTextObjects(t *testing.T) {
|
||||
t.Run("diw (delete inner word) undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello world foo"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 2, Line: 0})
|
||||
sendKeys(tm, "d", "i", "w") // Delete inner word
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello world foo" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("daw (delete a word) undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"hello world foo"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 6, Line: 0})
|
||||
sendKeys(tm, "d", "a", "w") // Delete a word
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello world foo" {
|
||||
t.Errorf("lines[0] = %q, want 'hello world foo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ci( changes inside parens undoes correctly", func(t *testing.T) {
|
||||
lines := []string{"before (hello) after"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Col: 9, Line: 0})
|
||||
sendKeys(tm, "c", "i", "(") // Change inside parens
|
||||
sendKeyString(tm, "world") // Type replacement
|
||||
sendKeys(tm, "esc")
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "before (hello) after" {
|
||||
t.Errorf("lines[0] = %q, want 'before (hello) after'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UNDO/REDO SEQUENCE TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoRedoSequences(t *testing.T) {
|
||||
t.Run("undo then redo multiple times", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "x") // Delete 'h' -> "ello"
|
||||
sendKeys(tm, "x") // Delete 'e' -> "llo"
|
||||
// Undo twice
|
||||
sendKeys(tm, "u", "u")
|
||||
// Redo twice
|
||||
sendKeys(tm, "ctrl+r", "ctrl+r")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "llo" {
|
||||
t.Errorf("After 2 redos: lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new change after undo clears redo stack", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "x") // Delete 'h' -> "ello"
|
||||
sendKeys(tm, "x") // Delete 'e' -> "llo"
|
||||
sendKeys(tm, "u") // Undo -> "ello"
|
||||
sendKeys(tm, "x") // New change -> "llo"
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
// Verify redo is not possible
|
||||
if m.ActiveBuffer().UndoStack.CanRedo() {
|
||||
t.Error("Expected redo stack to be cleared after new change")
|
||||
}
|
||||
|
||||
// Verify content
|
||||
if m.ActiveBuffer().Lines[0] != "llo" {
|
||||
t.Errorf("lines[0] = %q, want 'llo'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("complex sequence: operations, undo, redo, more operations", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2", "line3"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
|
||||
// Do operations
|
||||
sendKeys(tm, "d", "d") // Delete line1
|
||||
sendKeys(tm, "d", "d") // Delete line2
|
||||
// Undo once
|
||||
sendKeys(tm, "u")
|
||||
// Redo
|
||||
sendKeys(tm, "ctrl+r")
|
||||
// New operation
|
||||
sendKeys(tm, "i")
|
||||
sendKeyString(tm, "new")
|
||||
sendKeys(tm, "esc")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "newline3" {
|
||||
t.Errorf("After insert: lines[0] = %q, want 'newline3'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EDGE CASE TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoEdgeCases(t *testing.T) {
|
||||
t.Run("undo on empty undo stack does nothing", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "u") // Undo when nothing to undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("redo on empty redo stack does nothing", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "ctrl+r") // Redo when nothing to redo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "hello" {
|
||||
t.Errorf("lines[0] = %q, want 'hello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo after exhausting redo stack", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "x") // Delete
|
||||
sendKeys(tm, "u") // Undo
|
||||
sendKeys(tm, "ctrl+r") // Redo
|
||||
sendKeys(tm, "ctrl+r") // Try redo again (should do nothing)
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "ello" {
|
||||
t.Errorf("lines[0] = %q, want 'ello'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo operation that left buffer empty", func(t *testing.T) {
|
||||
lines := []string{"only line"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "d", "d") // Delete only line (buffer should have empty line)
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 1 {
|
||||
t.Errorf("line count = %d, want 1", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0] != "only line" {
|
||||
t.Errorf("lines[0] = %q, want 'only line'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MULTI-LINE OPERATION TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoMultiLineOperations(t *testing.T) {
|
||||
t.Run("undo multi-line delete from visual mode", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2", "line3", "line4", "line5"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "V") // Visual line mode
|
||||
sendKeys(tm, "j", "j") // Select 3 lines
|
||||
sendKeys(tm, "d") // Delete
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 5 {
|
||||
t.Errorf("line count = %d, want 5", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
expected := "line" + string(rune('1'+i))
|
||||
if m.ActiveBuffer().Lines[i] != expected {
|
||||
t.Errorf("lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo delete spanning multiple lines with motion", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2", "line3"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "d", "j") // Delete current line and line below
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 3 {
|
||||
t.Errorf("line count = %d, want 3", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0] != "line1" {
|
||||
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo o (open line below) operation", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "o") // Open line below
|
||||
sendKeyString(tm, "new line") // Type content
|
||||
sendKeys(tm, "esc") // Exit insert
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 2 {
|
||||
t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
if m.ActiveBuffer().Lines[0] != "line1" {
|
||||
t.Errorf("lines[0] = %q, want 'line1'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("undo O (open line above) operation", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2"}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0})
|
||||
sendKeys(tm, "O") // Open line above
|
||||
sendKeyString(tm, "new line") // Type content
|
||||
sendKeys(tm, "esc") // Exit insert
|
||||
sendKeys(tm, "u") // Undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 2 {
|
||||
t.Errorf("line count = %d, want 2", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
if m.ActiveBuffer().Lines[1] != "line2" {
|
||||
t.Errorf("lines[1] = %q, want 'line2'", m.ActiveBuffer().Lines[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UNDO STACK INSPECTION TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoStackStructure(t *testing.T) {
|
||||
t.Run("verify undo stack has correct number of blocks", func(t *testing.T) {
|
||||
lines := []string{"hello"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
|
||||
// Perform 3 separate operations
|
||||
sendKeys(tm, "x") // Op 1
|
||||
sendKeys(tm, "x") // Op 2
|
||||
sendKeys(tm, "x") // Op 3
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
|
||||
// Should have 3 undo blocks
|
||||
undoCount := 0
|
||||
for m.ActiveBuffer().UndoStack.CanUndo() {
|
||||
m.ActiveBuffer().UndoStack.Undo()
|
||||
undoCount++
|
||||
}
|
||||
|
||||
if undoCount != 3 {
|
||||
t.Errorf("undo block count = %d, want 3", undoCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify insert mode creates single block with multiple changes", func(t *testing.T) {
|
||||
lines := []string{""}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "i")
|
||||
sendKeyString(tm, "hello")
|
||||
sendKeys(tm, "esc")
|
||||
// Verify single undo removes everything
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "" {
|
||||
t.Errorf("After undo: lines[0] = %q, want ''", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
if m.ActiveBuffer().UndoStack.CanUndo() {
|
||||
t.Error("Expected empty undo stack after single undo")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify dd creates single block with correct change types", func(t *testing.T) {
|
||||
lines := []string{"line1", "line2"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
sendKeys(tm, "d", "d")
|
||||
// Verify undo restores correctly
|
||||
sendKeys(tm, "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 2 {
|
||||
t.Errorf("line count after undo = %d, want 2", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
// Verify undo stack is empty after undo
|
||||
if m.ActiveBuffer().UndoStack.CanUndo() {
|
||||
t.Error("Expected empty undo stack after undoing all changes")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPLEX SCENARIO TESTS
|
||||
// ============================================================================
|
||||
|
||||
func TestUndoComplexScenarios(t *testing.T) {
|
||||
t.Run("realistic editing session with multiple undo/redo", func(t *testing.T) {
|
||||
lines := []string{"func main() {", "}", ""}
|
||||
tm := newTestModelWithLinesAndCursorPos(t, lines, core.Position{Line: 1, Col: 0})
|
||||
|
||||
// Insert a line
|
||||
sendKeys(tm, "O")
|
||||
sendKeyString(tm, "\tfmt.Println(\"hello\")")
|
||||
sendKeys(tm, "esc")
|
||||
// Delete a word
|
||||
sendKeys(tm, "d", "i", "w")
|
||||
// Undo delete
|
||||
sendKeys(tm, "u")
|
||||
// Undo insert
|
||||
sendKeys(tm, "u")
|
||||
// Redo both
|
||||
sendKeys(tm, "ctrl+r", "ctrl+r")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if len(m.ActiveBuffer().Lines) != 4 {
|
||||
t.Errorf("After 2 redos: line count = %d, want 4", len(m.ActiveBuffer().Lines))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("alternating operations and undos", func(t *testing.T) {
|
||||
lines := []string{"abc"}
|
||||
tm := newTestModelWithLines(t, lines)
|
||||
|
||||
sendKeys(tm, "x") // Delete 'a' -> "bc"
|
||||
sendKeys(tm, "u") // Undo -> "abc"
|
||||
sendKeys(tm, "$") // Move to end
|
||||
sendKeys(tm, "x") // Delete 'c' -> "ab"
|
||||
sendKeys(tm, "u") // Undo -> "abc"
|
||||
sendKeys(tm, "0") // Move to start
|
||||
sendKeys(tm, "x") // Delete 'a' -> "bc"
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
if m.ActiveBuffer().Lines[0] != "bc" {
|
||||
t.Errorf("lines[0] = %q, want 'bc'", m.ActiveBuffer().Lines[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// PASTE OPERATIONS TESTS
|
||||
// =================================================================
|
||||
|
||||
func TestUndoPasteOperations(t *testing.T) {
|
||||
t.Run("basic p (paste after) undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"line1", "line2"})
|
||||
|
||||
// Yank first line and paste after second line
|
||||
sendKeys(tm, "y", "y") // yank current line (line1)
|
||||
sendKeys(tm, "j") // move to line2
|
||||
sendKeys(tm, "p") // paste after line2
|
||||
sendKeys(tm, "u") // undo paste
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"line1", "line2"}
|
||||
if len(m.ActiveBuffer().Lines) != len(expected) {
|
||||
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
|
||||
}
|
||||
for i, exp := range expected {
|
||||
if m.ActiveBuffer().Lines[i] != exp {
|
||||
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], exp)
|
||||
}
|
||||
}
|
||||
// Cursor should be back at line2
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("basic P (paste before) undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"line1", "line2"})
|
||||
|
||||
// Yank first line and paste before second line
|
||||
sendKeys(tm, "y", "y") // yank current line (line1)
|
||||
sendKeys(tm, "j") // move to line2
|
||||
sendKeys(tm, "P") // paste before line2
|
||||
sendKeys(tm, "u") // undo paste
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"line1", "line2"}
|
||||
if len(m.ActiveBuffer().Lines) != len(expected) {
|
||||
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
|
||||
}
|
||||
for i, exp := range expected {
|
||||
if m.ActiveBuffer().Lines[i] != exp {
|
||||
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], exp)
|
||||
}
|
||||
}
|
||||
// Cursor should be back at line2
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("charwise paste undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"hello world"})
|
||||
|
||||
// Yank "hello" and paste after "world"
|
||||
sendKeys(tm, "y", "w") // yank word "hello"
|
||||
sendKeys(tm, "$") // move to end
|
||||
sendKeys(tm, "p") // paste after cursor
|
||||
sendKeys(tm, "u") // undo paste
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"hello world"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visual mode paste undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"hello world", "foo bar"})
|
||||
|
||||
// Yank "hello" then select "world" and paste over it
|
||||
sendKeys(tm, "y", "w") // yank "hello"
|
||||
sendKeys(tm, "w") // move to "world"
|
||||
sendKeys(tm, "v", "e") // select "world"
|
||||
sendKeys(tm, "p") // paste over selection
|
||||
sendKeys(tm, "u") // undo paste
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"hello world", "foo bar"}
|
||||
if len(m.ActiveBuffer().Lines) != len(expected) {
|
||||
t.Errorf("len(Lines) = %d, want %d", len(m.ActiveBuffer().Lines), len(expected))
|
||||
}
|
||||
for i, exp := range expected {
|
||||
if m.ActiveBuffer().Lines[i] != exp {
|
||||
t.Errorf("Lines[%d] = %q, want %q", i, m.ActiveBuffer().Lines[i], exp)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiple paste operations undo separately", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"base"})
|
||||
|
||||
sendKeys(tm, "y", "y") // yank "base"
|
||||
sendKeys(tm, "p") // paste: "base\nbase"
|
||||
sendKeys(tm, "p") // paste: "base\nbase\nbase"
|
||||
sendKeys(tm, "u") // undo last paste: "base\nbase"
|
||||
sendKeys(tm, "u") // undo first paste: "base"
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"base"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("paste with count undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"test"})
|
||||
|
||||
sendKeys(tm, "y", "y") // yank "test"
|
||||
sendKeyString(tm, "3p") // paste 3 times
|
||||
sendKeys(tm, "u") // undo (should undo all 3 pastes as one block)
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"test"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// COMPLEX COUNT OPERATIONS TESTS
|
||||
// =================================================================
|
||||
|
||||
func TestUndoComplexCountOperations(t *testing.T) {
|
||||
t.Run("5dd undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"1", "2", "3", "4", "5", "6", "7"})
|
||||
|
||||
sendKeys(tm, "j", "j") // move to line 3
|
||||
sendKeyString(tm, "5dd") // delete 5 lines (3,4,5,6,7)
|
||||
sendKeys(tm, "u") // undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"1", "2", "3", "4", "5", "6", "7"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
// Cursor should be back at line 3 (index 2)
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("3cw (change 3 words) undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"one two three four five"})
|
||||
|
||||
sendKeys(tm, "3", "c", "w") // change 3 words
|
||||
sendKeys(tm, "CHANGED") // type replacement
|
||||
sendKeys(tm, "esc") // exit insert mode
|
||||
sendKeys(tm, "u") // undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"one two three four five"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("10x (delete 10 chars) undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"abcdefghijklmnopqrstuvwxyz"})
|
||||
|
||||
sendKeys(tm, "5", "|") // move to column 5 (f)
|
||||
sendKeyString(tm, "10x") // delete 10 chars (fghijklmno)
|
||||
sendKeys(tm, "u") // undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"abcdefghijklmnopqrstuvwxyz"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
// Cursor should be back at column 4 (index of 'e', 0-based)
|
||||
if m.ActiveWindow().Cursor.Col != 4 {
|
||||
t.Errorf("Cursor.Col = %d, want 4", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("2cc (change 2 lines) undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"line1", "line2", "line3", "line4"})
|
||||
|
||||
sendKeys(tm, "j") // move to line2
|
||||
sendKeys(tm, "2", "c", "c") // change 2 lines (line2, line3)
|
||||
sendKeys(tm, "NEW", "LINE") // type replacement
|
||||
sendKeys(tm, "esc") // exit insert mode
|
||||
sendKeys(tm, "u") // undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"line1", "line2", "line3", "line4"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("4diw (delete 4 words) undo", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"word1 word2 word3 word4 word5"})
|
||||
|
||||
sendKeys(tm, "w") // move to word2
|
||||
sendKeyString(tm, "4diw") // delete 4 words (word2, word3, word4, word5)
|
||||
sendKeys(tm, "u") // undo
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"word1 word2 word3 word4 word5"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("complex count with paste: 3p after 2yy", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"A", "B", "C", "D"})
|
||||
|
||||
sendKeyString(tm, "2yy") // yank 2 lines (A, B)
|
||||
sendKeys(tm, "j", "j") // move to line C
|
||||
sendKeyString(tm, "3p") // paste 3 times
|
||||
sendKeys(tm, "u") // undo paste
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
expected := []string{"A", "B", "C", "D"}
|
||||
if !equalStringSlices(m.ActiveBuffer().Lines, expected) {
|
||||
t.Errorf("Lines = %v, want %v", m.ActiveBuffer().Lines, expected)
|
||||
}
|
||||
// Cursor should be back at line C (index 2)
|
||||
if m.ActiveWindow().Cursor.Line != 2 {
|
||||
t.Errorf("Cursor.Line = %d, want 2", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -341,11 +341,7 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
|
||||
content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n"))
|
||||
overlay = append(overlay, content)
|
||||
}
|
||||
msg := core.CommandOutputExitMessage
|
||||
if len(cmd.Lines) > len(cmd.Viewport(termHeight)) {
|
||||
msg += ". " + core.CommandOutputScrollMessage
|
||||
}
|
||||
overlay = append(overlay, styles.CommandContinueMessage.Render(msg))
|
||||
overlay = append(overlay, styles.CommandContinueMessage.Render(core.CommandOutputExitMessage))
|
||||
|
||||
// NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(),
|
||||
// which would cause Lipgloss to embed newlines internally and corrupt the line count.
|
||||
|
||||
@ -3,7 +3,6 @@ package input
|
||||
import (
|
||||
"git.gophernest.net/azpect/TextEditor/internal/action"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
"git.gophernest.net/azpect/TextEditor/internal/operator"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
@ -60,12 +59,6 @@ func (h *Handler) Handle(m action.Model, key string) tea.Cmd {
|
||||
if key == "esc" {
|
||||
h.Reset()
|
||||
if m.Mode() == core.InsertMode {
|
||||
// Before exiting insert mode, end the block in the undo stack
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
if buf.UndoStack != nil {
|
||||
buf.UndoStack.EndBlock(win.Cursor)
|
||||
}
|
||||
m.ExitInsertMode()
|
||||
} else {
|
||||
m.SetMode(core.NormalMode)
|
||||
@ -187,7 +180,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
|
||||
if res, ok := mot.(action.Resolvable); ok {
|
||||
mot = res.Resolve(m)
|
||||
}
|
||||
cmd := h.executeMotion(m, mot)
|
||||
cmd := mot.Execute(m)
|
||||
h.Reset()
|
||||
return cmd
|
||||
|
||||
@ -201,7 +194,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
|
||||
if m.Mode() == core.VisualLineMode {
|
||||
mtype = core.Linewise
|
||||
}
|
||||
cmd := h.executeOperator(m, op, start, end, mtype)
|
||||
cmd := op.Operate(m, start, end, mtype)
|
||||
// Only reset to normal mode if operator didn't enter insert mode
|
||||
if m.Mode() != core.InsertMode {
|
||||
m.SetMode(core.NormalMode)
|
||||
@ -220,7 +213,7 @@ func (h *Handler) handleInitial(m action.Model, kind string, binding any, key st
|
||||
if r, ok := act.(action.Repeatable); ok {
|
||||
act = r.WithCount(count)
|
||||
}
|
||||
cmd := h.executeAction(m, act)
|
||||
cmd := act.Execute(m)
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
@ -239,7 +232,7 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
|
||||
if kind == "operator" && key == h.operatorKey {
|
||||
// Only call DoublePress if the operator supports it
|
||||
if dp, ok := h.operator.(action.DoublePresser); ok {
|
||||
cmd := h.executeDoublePress(m, dp, count)
|
||||
cmd := dp.DoublePress(m, count)
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
@ -265,9 +258,9 @@ func (h *Handler) handleAfterOperator(m action.Model, kind string, binding any,
|
||||
}
|
||||
// Get range and motion type
|
||||
start := win.Cursor
|
||||
h.executeMotion(m, mot)
|
||||
mot.Execute(m)
|
||||
end := win.Cursor
|
||||
cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
|
||||
cmd := h.operator.Operate(m, start, end, mot.Type())
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
@ -340,15 +333,15 @@ func (h *Handler) handleCharMotion(m action.Model, key string) tea.Cmd {
|
||||
if h.operator != nil {
|
||||
win := m.ActiveWindow()
|
||||
start := win.Cursor
|
||||
h.executeMotion(m, mot)
|
||||
mot.Execute(m)
|
||||
end := win.Cursor
|
||||
cmd := h.executeOperator(m, h.operator, start, end, mot.Type())
|
||||
cmd := h.operator.Operate(m, start, end, mot.Type())
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Otherwise just execute the motion
|
||||
cmd := h.executeMotion(m, mot)
|
||||
cmd := mot.Execute(m)
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
@ -373,7 +366,7 @@ func (h *Handler) handleTextObject(m action.Model, kind string, binding any, key
|
||||
|
||||
// If we have an operator pending (e.g., "diw")
|
||||
if h.operator != nil {
|
||||
cmd := h.executeOperator(m, h.operator, start, end, mtype)
|
||||
cmd := h.operator.Operate(m, start, end, mtype)
|
||||
h.Reset()
|
||||
return cmd
|
||||
}
|
||||
@ -475,18 +468,7 @@ func (h *Handler) Pending() string {
|
||||
|
||||
// Handler.handleInsertKey: Processes a keypress in insert mode, recording it
|
||||
// for count replay and executing it as an action or character insertion.
|
||||
//
|
||||
// This function does not make use of the execute abstractions, to prevent each
|
||||
// key inserted from creating a new block in the undo stack.
|
||||
func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
|
||||
buf := m.ActiveBuffer()
|
||||
win := m.ActiveWindow()
|
||||
|
||||
// Start undo block on first insert key
|
||||
if buf.UndoStack != nil && !buf.UndoStack.Recording() {
|
||||
buf.UndoStack.BeginBlock(win.Cursor)
|
||||
}
|
||||
|
||||
// Record the key for count replay (e.g. 5i...)
|
||||
m.SetInsertKeys(append(m.InsertKeys(), key))
|
||||
|
||||
@ -504,8 +486,7 @@ func (h *Handler) handleInsertKey(m action.Model, key string) tea.Cmd {
|
||||
}
|
||||
|
||||
// Handler.handleCommandKey: Processes a keypress in command mode, executing
|
||||
// it as an action or inserting it into the command line. This does not record
|
||||
// anything into the undo stack.
|
||||
// it as an action or inserting it into the command line.
|
||||
func (h *Handler) handleCommandKey(m action.Model, key string) tea.Cmd {
|
||||
kind, binding := h.commandKeymap.Lookup(key)
|
||||
switch kind {
|
||||
@ -530,83 +511,3 @@ func normalizeVisualSelection(m action.Model) (core.Position, core.Position) {
|
||||
}
|
||||
return c, a
|
||||
}
|
||||
|
||||
func (h *Handler) executeAction(m action.Model, act action.Action) tea.Cmd {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
if buf.UndoStack != nil {
|
||||
buf.UndoStack.BeginBlock(win.Cursor)
|
||||
}
|
||||
|
||||
cmd := act.Execute(m)
|
||||
|
||||
// If the action one that includes insert mode, we should not end the block, we want to
|
||||
// include the text from the insert mode in the block.
|
||||
_, O := act.(action.OpenLineAbove)
|
||||
_, o := act.(action.OpenLineBelow)
|
||||
_, s := act.(action.SubstituteChar)
|
||||
_, S := act.(action.SubstituteLine)
|
||||
_, C := act.(action.ChangeToEndOfLine)
|
||||
if o || O || s || S || C {
|
||||
return nil
|
||||
}
|
||||
|
||||
if buf.UndoStack != nil {
|
||||
buf.UndoStack.EndBlock(win.Cursor)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (h *Handler) executeMotion(m action.Model, mot action.Motion) tea.Cmd {
|
||||
// These do not change the buffer, so no need to record anything
|
||||
return mot.Execute(m)
|
||||
}
|
||||
|
||||
func (h *Handler) executeOperator(m action.Model, op action.Operator, start, end core.Position, mtype core.MotionType) tea.Cmd {
|
||||
buf := m.ActiveBuffer()
|
||||
win := m.ActiveWindow()
|
||||
|
||||
if buf.UndoStack != nil {
|
||||
buf.UndoStack.BeginBlock(win.Cursor)
|
||||
}
|
||||
|
||||
cmd := op.Operate(m, start, end, mtype)
|
||||
|
||||
// If operator is one that enters insert mode, we do not want to end the block.
|
||||
_, c := op.(operator.ChangeOperator)
|
||||
if c {
|
||||
return cmd
|
||||
}
|
||||
|
||||
if buf.UndoStack != nil {
|
||||
buf.UndoStack.EndBlock(win.Cursor)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (h *Handler) executeDoublePress(m action.Model, dp action.DoublePresser, count int) tea.Cmd {
|
||||
buf := m.ActiveBuffer()
|
||||
win := m.ActiveWindow()
|
||||
|
||||
if buf.UndoStack != nil {
|
||||
buf.UndoStack.BeginBlock(win.Cursor)
|
||||
}
|
||||
|
||||
cmd := dp.DoublePress(m, count)
|
||||
|
||||
// If operator being double pressed is one that enters insert mode, we do not
|
||||
// want to end the block.
|
||||
_, c := dp.(operator.ChangeOperator)
|
||||
if c {
|
||||
return cmd
|
||||
}
|
||||
|
||||
if buf.UndoStack != nil {
|
||||
buf.UndoStack.EndBlock(win.Cursor)
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -69,8 +69,6 @@ func NewNormalKeymap() *Keymap {
|
||||
"S": action.SubstituteLine{Count: 1},
|
||||
"p": action.Paste{Count: 1},
|
||||
"P": action.PasteBefore{Count: 1},
|
||||
"u": action.Undo{},
|
||||
"ctrl+r": action.Redo{},
|
||||
},
|
||||
charMotions: map[string]action.Motion{
|
||||
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
|
||||
@ -123,7 +121,6 @@ func NewVisualKeymap() *Keymap {
|
||||
"e": motion.MoveForwardWordEnd{Count: 1},
|
||||
"E": motion.MoveForwardWORDEnd{Count: 1},
|
||||
"b": motion.MoveBackwardWord{Count: 1},
|
||||
// TODO: O and o. These are fun ones! Should be simple too
|
||||
},
|
||||
operators: map[string]action.Operator{
|
||||
"d": operator.DeleteOperator{},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user