feat: impl and tested J and gJ
This commit is contained in:
parent
064f747b55
commit
f17573edd2
465
.opencode/skills/gim/SKILL.md
Normal file
465
.opencode/skills/gim/SKILL.md
Normal file
@ -0,0 +1,465 @@
|
||||
---
|
||||
name: gim
|
||||
description: Guidelines for working with the Gim vim-like terminal text editor
|
||||
license: Proprietary
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
language: Go 1.25.5
|
||||
framework: BubbleTea
|
||||
module: git.gophernest.net/azpect/TextEditor
|
||||
---
|
||||
|
||||
# Gim — Agent Skill Guide
|
||||
|
||||
Gim is a vim-like terminal text editor written in Go using the BubbleTea TUI framework. This document gives AI agents everything needed to contribute effectively.
|
||||
|
||||
---
|
||||
|
||||
## Project Identity
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Module path | `git.gophernest.net/azpect/TextEditor` |
|
||||
| Go version | 1.25.5 |
|
||||
| Entry point | `cmd/gim/main.go` |
|
||||
| TUI framework | BubbleTea v1.3.10 |
|
||||
| Styling | Lipgloss v1.1.0 |
|
||||
| Syntax highlighting | Chroma v2.23.1 |
|
||||
| Testing | teatest (charmbracelet/x/exp/teatest) |
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
Gim/
|
||||
├── cmd/gim/
|
||||
│ └── main.go # Binary entry point
|
||||
├── internal/
|
||||
│ ├── action/ # Executable actions (implement Action interface)
|
||||
│ │ ├── interface.go # Core interfaces: Action, Motion, Operator, etc.
|
||||
│ │ ├── insert.go # Insert mode entry actions (i, a, o, O, I, A, C, s, S)
|
||||
│ │ ├── delete.go # Delete actions (x, X, dd, D)
|
||||
│ │ ├── replace.go # Replace mode actions (r, R)
|
||||
│ │ ├── find.go # Find char motions (f, F, t, T, ;, ,)
|
||||
│ │ ├── command.go # Command mode state management
|
||||
│ │ ├── command_history.go # Command history navigation
|
||||
│ │ ├── paste.go # Paste (p, P)
|
||||
│ │ ├── yank.go # Yank (y, yy, Y)
|
||||
│ │ ├── undo.go # Undo/redo (u, ctrl+r)
|
||||
│ │ ├── visual.go # Visual mode entry (v, V, ctrl+v)
|
||||
│ │ ├── scroll.go # Scroll actions (ctrl+d, ctrl+u, etc.)
|
||||
│ │ └── repeat.go # Dot operator (.)
|
||||
│ ├── command/ # Ex command mode (:) registry and handlers
|
||||
│ │ ├── registry.go # Command registration and lookup
|
||||
│ │ └── handlers.go # Handler functions (:w, :q, :wq, etc.)
|
||||
│ ├── core/ # Shared data structures and types
|
||||
│ │ ├── types.go # Mode, MotionType, RegisterType enums
|
||||
│ │ ├── buffer.go # Buffer struct and operations
|
||||
│ │ ├── gap_buffer.go # GapBuffer (efficient per-line editing)
|
||||
│ │ ├── window.go # Window (viewport over a buffer)
|
||||
│ │ ├── position.go # Position{Line, Col} type
|
||||
│ │ ├── register.go # Register (vim clipboard) types
|
||||
│ │ ├── undo.go # UndoStack (UndoStack, Change, ChangeBlock)
|
||||
│ │ ├── settings.go # EditorSettings (NewDefaultSettings)
|
||||
│ │ └── command.go # Command parsing helpers
|
||||
│ ├── editor/ # BubbleTea Model — orchestrates everything
|
||||
│ │ ├── model.go # Model struct definition
|
||||
│ │ ├── init.go # tea.Model Init()
|
||||
│ │ ├── update.go # tea.Model Update()
|
||||
│ │ ├── view.go # tea.Model View()
|
||||
│ │ ├── builder.go # ModelBuilder (fluent constructor)
|
||||
│ │ ├── helpers_test.go # Shared test helpers (all tests use these)
|
||||
│ │ └── integration_*_test.go # Integration tests by feature area
|
||||
│ ├── input/ # Key-input state machine
|
||||
│ │ ├── handler.go # InputState machine, key dispatch
|
||||
│ │ └── keymap.go # Keybinding registries per mode
|
||||
│ ├── motion/ # Motion implementations
|
||||
│ │ ├── basic.go # h, j, k, l
|
||||
│ │ ├── jump.go # gg, G, 0, ^, $, %, {, }
|
||||
│ │ └── word.go # w, W, b, B, e, E
|
||||
│ ├── operator/ # Operator implementations
|
||||
│ │ ├── delete.go # d{motion}, dd
|
||||
│ │ ├── yank.go # y{motion}, yy
|
||||
│ │ └── change.go # c{motion}, cc
|
||||
│ ├── program/ # BubbleTea program bootstrap
|
||||
│ │ └── program_builder.go # ProgramBuilder (EmptyProgram, FileProgram)
|
||||
│ ├── style/ # Visual rendering
|
||||
│ │ └── style.go # Lipgloss styles, Chroma syntax highlighting
|
||||
│ ├── textobject/ # Text object ranges
|
||||
│ │ └── word.go # iw, aw, iW, aW
|
||||
│ └── theme/ # Color themes
|
||||
│ └── theme.go # RegisterAll(), kanagawa-wave default
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Interfaces (`internal/action/interface.go`)
|
||||
|
||||
All extension points in the editor are interface-based. Implementing the right interface is how new features are added.
|
||||
|
||||
```go
|
||||
// Action — any executable editor command
|
||||
type Action interface {
|
||||
Execute(m Model) tea.Cmd
|
||||
}
|
||||
|
||||
// Motion — moves the cursor; returns a range type
|
||||
type Motion interface {
|
||||
Action
|
||||
Type() core.MotionType // CharwiseExclusive | CharwiseInclusive | Linewise
|
||||
}
|
||||
|
||||
// Operator — acts on a range defined by a motion
|
||||
type Operator interface {
|
||||
Operate(m Model, start, end core.Position, mtype core.MotionType) tea.Cmd
|
||||
}
|
||||
|
||||
// DoublePresser — supports the doubled form (dd, yy, cc)
|
||||
type DoublePresser interface {
|
||||
DoublePress(m Model, count int) tea.Cmd
|
||||
}
|
||||
|
||||
// Repeatable — action that accepts a leading count (3w, 5dd)
|
||||
type Repeatable interface {
|
||||
WithCount(n int) Action
|
||||
}
|
||||
|
||||
// CharMotion — motion that needs a char argument (f, F, t, T)
|
||||
type CharMotion interface {
|
||||
Motion
|
||||
WithChar(char string) Motion
|
||||
}
|
||||
|
||||
// Resolvable — motion whose behavior depends on model state at execution time
|
||||
type Resolvable interface {
|
||||
Motion
|
||||
Resolve(m Model) Motion
|
||||
}
|
||||
|
||||
// TextObject — selects a structured range (iw, aw, iW, aW)
|
||||
type TextObject interface {
|
||||
GetRange(m Model, cursor core.Position, modifier string) (start, end core.Position, mtype core.MotionType)
|
||||
}
|
||||
```
|
||||
|
||||
`action.Model` is the main interface that all actions receive — it provides access to windows, buffers, mode, registers, and input dispatch.
|
||||
|
||||
---
|
||||
|
||||
## Core Types (`internal/core/`)
|
||||
|
||||
```go
|
||||
// Position — line and column (both 0-indexed)
|
||||
type Position struct{ Line, Col int }
|
||||
|
||||
// Mode
|
||||
type Mode int
|
||||
const (
|
||||
NormalMode, InsertMode, CommandMode, CommandOutputMode,
|
||||
VisualMode, VisualLineMode, VisualBlockMode, ReplaceMode, WaitingMode
|
||||
)
|
||||
|
||||
// MotionType — determines how operator ranges are calculated
|
||||
type MotionType int
|
||||
const (
|
||||
CharwiseExclusive // end col not included (w, b, h, l, 0, ^)
|
||||
CharwiseInclusive // end col included (e, $, f)
|
||||
Linewise // whole lines (j, k, G, gg, {, })
|
||||
)
|
||||
|
||||
// RegisterType
|
||||
type RegisterType int
|
||||
const (
|
||||
CharwiseRegister, LinewiseRegister, BlockwiseRegister
|
||||
)
|
||||
|
||||
// Buffer — document held in memory
|
||||
type Buffer struct {
|
||||
Id, Type int
|
||||
Filename string
|
||||
Filetype string
|
||||
Lines []*GapBuffer
|
||||
Modified bool
|
||||
UndoStack *UndoStack
|
||||
}
|
||||
|
||||
// Window — viewport over a buffer
|
||||
type Window struct {
|
||||
Id, Number int
|
||||
Buffer *Buffer
|
||||
Cursor Position
|
||||
Anchor Position // visual mode anchor
|
||||
ScrollY int
|
||||
Height int
|
||||
Width int
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Features — Step-by-Step Patterns
|
||||
|
||||
### New Normal-Mode Motion
|
||||
|
||||
1. Create `internal/motion/<name>.go`:
|
||||
```go
|
||||
package motion
|
||||
|
||||
type MyMotion struct{ Count int }
|
||||
|
||||
func (m MyMotion) Execute(model action.Model) tea.Cmd {
|
||||
// Move model.ActiveWindow().Cursor
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m MyMotion) Type() core.MotionType { return core.CharwiseExclusive }
|
||||
|
||||
func (m MyMotion) WithCount(n int) action.Action { return MyMotion{Count: n} }
|
||||
```
|
||||
|
||||
2. Register in `internal/input/keymap.go` inside `NewNormalKeymap()`:
|
||||
```go
|
||||
motions: map[string]action.Motion{
|
||||
"X": motion.MyMotion{},
|
||||
}
|
||||
```
|
||||
|
||||
3. Write integration tests in `internal/editor/integration_motion_<name>_test.go`.
|
||||
|
||||
### New Operator
|
||||
|
||||
1. Create `internal/operator/<name>.go`:
|
||||
```go
|
||||
package operator
|
||||
|
||||
type MyOp struct{}
|
||||
|
||||
func (o MyOp) Operate(m action.Model, start, end core.Position, mt core.MotionType) tea.Cmd {
|
||||
// Act on the range [start, end)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Double-press support (e.g. "xx" acts on current line)
|
||||
var _ action.DoublePresser = MyOp{}
|
||||
|
||||
func (o MyOp) DoublePress(m action.Model, count int) tea.Cmd { return nil }
|
||||
```
|
||||
|
||||
2. Register in `internal/input/keymap.go`:
|
||||
```go
|
||||
operators: map[string]action.Operator{
|
||||
"X": operator.MyOp{},
|
||||
}
|
||||
```
|
||||
|
||||
3. Write integration tests in `internal/editor/integration_operator_<name>_test.go`.
|
||||
|
||||
### New Standalone Action
|
||||
|
||||
1. Create `internal/action/<name>.go`:
|
||||
```go
|
||||
package action
|
||||
|
||||
type MyAction struct{ Count int }
|
||||
|
||||
func (a MyAction) Execute(m Model) tea.Cmd {
|
||||
// Mutate model state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MyAction) WithCount(n int) Action { return MyAction{Count: n} }
|
||||
```
|
||||
|
||||
2. Register in `internal/input/keymap.go`:
|
||||
```go
|
||||
actions: map[string]action.Action{
|
||||
"X": action.MyAction{},
|
||||
}
|
||||
```
|
||||
|
||||
### New Ex Command (`:mycommand`)
|
||||
|
||||
1. Add handler in `internal/command/handlers.go`:
|
||||
```go
|
||||
func handleMyCommand(m action.Model, args []string, force bool) tea.Cmd {
|
||||
// force = true when called as :mycommand!
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
2. Register in `internal/command/registry.go` inside `registerDefaults()`:
|
||||
```go
|
||||
r.Register(Command{
|
||||
Name: "mycommand",
|
||||
ShortForm: "myc",
|
||||
Handler: handleMyCommand,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Framework
|
||||
|
||||
All tests use **teatest** — a BubbleTea test driver that runs the full model loop. Integration tests live in `internal/editor/`. Unit tests for isolated logic live alongside their package (`*_test.go` next to the source).
|
||||
|
||||
### Shared Test Helpers (`internal/editor/helpers_test.go`)
|
||||
|
||||
```go
|
||||
// Create a default test model
|
||||
newTestModel(t *testing.T) *teatest.TestModel
|
||||
|
||||
// Create a model with specific initial buffer content
|
||||
newTestModelWithLines(t *testing.T, lines []string) *teatest.TestModel
|
||||
|
||||
// Create a model with content and cursor at a specific position
|
||||
newTestModelWithLinesAndCursorPos(t *testing.T, lines []string, pos core.Position) *teatest.TestModel
|
||||
|
||||
// Functional options variant (preferred for complex setups)
|
||||
newTestModelWithOptions(t *testing.T, opts ...TestModelOption) *teatest.TestModel
|
||||
|
||||
// Options:
|
||||
WithLines(lines []string)
|
||||
WithCursorPos(pos core.Position)
|
||||
WithTermSize(width, height int)
|
||||
WithRegister(name rune, regType core.RegisterType, content []string)
|
||||
|
||||
// Send one or more keys to the model
|
||||
sendKeys(tm *teatest.TestModel, keys ...string)
|
||||
|
||||
// Send a string of keys as a sequence
|
||||
sendKeyString(tm *teatest.TestModel, keyString string)
|
||||
|
||||
// Retrieve the final model state for assertions
|
||||
getFinalModel(t *testing.T, tm *teatest.TestModel) *editor.Model
|
||||
```
|
||||
|
||||
### Test File Naming Convention
|
||||
|
||||
| File | Coverage area |
|
||||
|------|---------------|
|
||||
| `integration_motion_basic_test.go` | h, j, k, l |
|
||||
| `integration_motion_jump_test.go` | gg, G, 0, $, %, {, } |
|
||||
| `integration_motion_word_test.go` | w, W, b, B, e, E |
|
||||
| `integration_operator_delete_test.go` | d{motion}, dd |
|
||||
| `integration_operator_change_test.go` | c{motion}, cc |
|
||||
| `integration_operator_yank_test.go` | y{motion}, yy |
|
||||
| `integration_insert_test.go` | i, a, o, O, I, A, C, s, S |
|
||||
| `integration_delete_test.go` | x, X, D, dd |
|
||||
| `integration_visual_test.go` | v, V, ctrl+v |
|
||||
| `integration_paste_test.go` | p, P |
|
||||
| `integration_yank_test.go` | y, yy, Y |
|
||||
| `integration_repeat_test.go` | . (dot operator) |
|
||||
| `integration_replace_test.go` | r, R |
|
||||
| `integration_undo_test.go` | u, ctrl+r |
|
||||
| `integration_scroll_test.go` | ctrl+d, ctrl+u, etc. |
|
||||
| `integration_command_test.go` | :w, :q, :wq, etc. |
|
||||
| `integration_textobject_test.go` | iw, aw, iW, aW |
|
||||
|
||||
### Example Integration Test
|
||||
|
||||
```go
|
||||
func TestMyFeature(t *testing.T) {
|
||||
t.Run("moves forward one word", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"hello world"})
|
||||
sendKeys(tm, "w")
|
||||
m := getFinalModel(t, tm)
|
||||
pos := m.ActiveWindow().Cursor
|
||||
if pos.Col != 6 {
|
||||
t.Errorf("expected col 6, got %d", pos.Col)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Run all tests:
|
||||
```
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Run a specific package:
|
||||
```
|
||||
go test git.gophernest.net/azpect/TextEditor/internal/editor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Input State Machine (`internal/input/handler.go`)
|
||||
|
||||
The handler dispatches key presses through a state machine. Understanding states is critical when tracing how a key sequence is processed.
|
||||
|
||||
| State | Meaning |
|
||||
|-------|---------|
|
||||
| `StateReady` | Waiting for first key of a command |
|
||||
| `StateCount` | Accumulating leading count digits (e.g. `12`) |
|
||||
| `StateOperatorPending` | Operator received, waiting for motion (e.g. after `d`) |
|
||||
| `StateMotionCount` | Accumulating count after operator (e.g. `d3`) |
|
||||
| `StateWaitingForChar` | f/t/F/T received, waiting for target char |
|
||||
| `StateWaitingForTextObject` | i/a received in operator pending, waiting for object (w/W) |
|
||||
|
||||
---
|
||||
|
||||
## BubbleTea Elm Architecture
|
||||
|
||||
```
|
||||
tea.KeyMsg
|
||||
↓
|
||||
editor.Update()
|
||||
↓
|
||||
input.Handler.Handle(key)
|
||||
↓
|
||||
action.Action.Execute(model) ← motions, operators, actions all land here
|
||||
↓
|
||||
model state mutation
|
||||
↓
|
||||
editor.View() ← renders buffer with Lipgloss + Chroma
|
||||
```
|
||||
|
||||
The editor model implements `tea.Model`:
|
||||
- `Init() tea.Cmd` — returns nil (no startup IO)
|
||||
- `Update(msg tea.Msg) (tea.Model, tea.Cmd)` — handles key, resize, and mouse messages
|
||||
- `View() string` — renders current state as a string
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
||||
- **Comment format:** `// ReceiverType.MethodName: description` on every exported method
|
||||
- **Builder pattern** for complex structs — `NewBufferBuilder().WithFilename(f).Build()`
|
||||
- **Functional options** for test setup — `WithLines(...)`, `WithCursorPos(...)`
|
||||
- **Interface guards** for compile-time checks: `var _ action.DoublePresser = MyOp{}`
|
||||
- **No panics** in normal paths — return early or propagate via `tea.Cmd`
|
||||
- **No global mutable state** — all state lives inside `editor.Model`
|
||||
- `tea.Cmd` return value is almost always `nil` unless async IO is needed
|
||||
|
||||
---
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
# Run the editor (no file)
|
||||
go run ./cmd/gim
|
||||
|
||||
# Run with a file
|
||||
go run ./cmd/gim myfile.go
|
||||
|
||||
# Build binary
|
||||
go build -o gim ./cmd/gim
|
||||
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run tests with verbose output
|
||||
go test -v ./internal/editor/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Change Without Care
|
||||
|
||||
- `internal/core/types.go` — changing `MotionType` or `Mode` constants affects the entire codebase
|
||||
- `internal/action/interface.go` — changing `Action` or `Model` interfaces requires updating all implementations
|
||||
- `internal/input/keymap.go` — duplicate key registrations silently shadow each other; verify no conflicts
|
||||
- `internal/editor/update.go` — the central dispatch loop; changes here affect all modes
|
||||
60
internal/action/join.go
Normal file
60
internal/action/join.go
Normal file
@ -0,0 +1,60 @@
|
||||
package action
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type JoinLines struct {
|
||||
Preserve bool
|
||||
Count int
|
||||
}
|
||||
|
||||
// WithCount sets the count (required by Repeatable interface)
|
||||
func (m JoinLines) WithCount(n int) Action {
|
||||
m.Count = n
|
||||
return m
|
||||
}
|
||||
|
||||
func (a JoinLines) Execute(m Model) tea.Cmd {
|
||||
win := m.ActiveWindow()
|
||||
buf := m.ActiveBuffer()
|
||||
|
||||
y := win.Cursor.Line
|
||||
|
||||
var line strings.Builder
|
||||
line.WriteString(buf.Line(y))
|
||||
spacePending := false
|
||||
|
||||
for range max(1, a.Count-1) {
|
||||
if (y + 1) >= buf.LineCount() {
|
||||
break
|
||||
}
|
||||
|
||||
l := buf.Line(y + 1)
|
||||
|
||||
if a.Preserve {
|
||||
line.WriteString(l)
|
||||
} else {
|
||||
l = strings.TrimLeft(l, " ")
|
||||
if l == "" {
|
||||
spacePending = true
|
||||
} else {
|
||||
line.WriteString(" " + l)
|
||||
spacePending = false
|
||||
}
|
||||
}
|
||||
|
||||
buf.DeleteLine(y + 1)
|
||||
}
|
||||
|
||||
if !a.Preserve && spacePending {
|
||||
line.WriteString(" ")
|
||||
}
|
||||
|
||||
if line.String() != buf.Line(y) {
|
||||
buf.SetLine(y, line.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
179
internal/editor/integration_join_test.go
Normal file
179
internal/editor/integration_join_test.go
Normal file
@ -0,0 +1,179 @@
|
||||
package editor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
||||
)
|
||||
|
||||
func TestJoinLines(t *testing.T) {
|
||||
t.Run("J joins current line with next line using a space", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"hello", "world"})
|
||||
sendKeys(tm, "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"hello world"})
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 0 {
|
||||
t.Errorf("Cursor.Col = %d, want 0", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("J joins from the cursor line, not always the first line", func(t *testing.T) {
|
||||
tm := newTestModelWithLinesAndCursorPos(t, []string{"one", "two", "three"}, core.Position{Line: 1, Col: 2})
|
||||
sendKeys(tm, "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"one", "two three"})
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("Cursor.Col = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("J trims indentation from the joined line", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"foo", " bar"})
|
||||
sendKeys(tm, "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestJoinLinesNoSpace(t *testing.T) {
|
||||
t.Run("gJ joins without inserting a space", func(t *testing.T) {
|
||||
tm := newTestModelWithLinesAndCursorPos(t, []string{"hello", "world"}, core.Position{Line: 0, Col: 3})
|
||||
sendKeys(tm, "g", "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"helloworld"})
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 3 {
|
||||
t.Errorf("Cursor.Col = %d, want 3", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gJ preserves leading whitespace on the next line", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"foo", " bar"})
|
||||
sendKeys(tm, "g", "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestJoinLinesWithCount(t *testing.T) {
|
||||
t.Run("3J joins three lines with spaces", func(t *testing.T) {
|
||||
tm := newTestModelWithLinesAndCursorPos(t, []string{"a", "b", "c", "d"}, core.Position{Line: 0, Col: 1})
|
||||
sendKeys(tm, "3", "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"a b c", "d"})
|
||||
if m.ActiveWindow().Cursor.Line != 0 {
|
||||
t.Errorf("Cursor.Line = %d, want 0", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 1 {
|
||||
t.Errorf("Cursor.Col = %d, want 1", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("3gJ joins three lines without spaces", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"})
|
||||
sendKeys(tm, "3", "g", "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"abc", "d"})
|
||||
})
|
||||
|
||||
t.Run("count larger than remaining lines joins through end of file", func(t *testing.T) {
|
||||
tm := newTestModelWithLinesAndCursorPos(t, []string{"a", "b", "c"}, core.Position{Line: 1, Col: 0})
|
||||
sendKeys(tm, "9", "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"a", "b c"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestJoinLinesEdgeCases(t *testing.T) {
|
||||
t.Run("J on last line does nothing", func(t *testing.T) {
|
||||
tm := newTestModelWithLinesAndCursorPos(t, []string{"one", "two"}, core.Position{Line: 1, Col: 2})
|
||||
sendKeys(tm, "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"one", "two"})
|
||||
if m.ActiveWindow().Cursor.Line != 1 {
|
||||
t.Errorf("Cursor.Line = %d, want 1", m.ActiveWindow().Cursor.Line)
|
||||
}
|
||||
if m.ActiveWindow().Cursor.Col != 2 {
|
||||
t.Errorf("Cursor.Col = %d, want 2", m.ActiveWindow().Cursor.Col)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gJ on single-line buffer does nothing", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"solo"})
|
||||
sendKeys(tm, "g", "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"solo"})
|
||||
})
|
||||
|
||||
t.Run("J across many empty lines keeps a single separating space", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"foo", "", "", "", "bar"})
|
||||
sendKeys(tm, "5", "J")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"foo bar"})
|
||||
})
|
||||
}
|
||||
|
||||
func TestJoinLinesRepeatAndUndo(t *testing.T) {
|
||||
t.Run("dot repeats J join", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"})
|
||||
sendKeys(tm, "J", "j", ".")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"a b", "c d"})
|
||||
})
|
||||
|
||||
t.Run("dot repeats gJ join", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"a", "b", "c", "d"})
|
||||
sendKeys(tm, "g", "J", "j", ".")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"ab", "cd"})
|
||||
})
|
||||
|
||||
t.Run("undo restores lines after J", func(t *testing.T) {
|
||||
tm := newTestModelWithLines(t, []string{"left", "right"})
|
||||
sendKeys(tm, "J", "u")
|
||||
|
||||
m := getFinalModel(t, tm)
|
||||
assertBufferLines(t, m.ActiveBuffer(), []string{"left", "right"})
|
||||
})
|
||||
}
|
||||
|
||||
func assertBufferLines(t *testing.T, buf *core.Buffer, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, buf.LineCount())
|
||||
for i := 0; i < buf.LineCount(); i++ {
|
||||
got[i] = buf.Line(i)
|
||||
}
|
||||
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("lines = %q (len=%d), want %q (len=%d)", got, len(got), want, len(want))
|
||||
return
|
||||
}
|
||||
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("lines = %q (len=%d), want %q (len=%d)", got, len(got), want, len(want))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,6 +78,8 @@ func NewNormalKeymap() *Keymap {
|
||||
"ctrl+r": action.Redo{},
|
||||
".": action.Repeat{Count: 1},
|
||||
"R": action.EnterReplace{},
|
||||
"J": action.JoinLines{Preserve: false},
|
||||
"gJ": action.JoinLines{Preserve: true},
|
||||
},
|
||||
charMotions: map[string]action.Motion{
|
||||
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user