Compare commits
No commits in common. "7a7472fd12f4e4d71a331563a29fb02fd5db91bf" and "064f747b55a9e6a0f9a1c5f879c0627b287a7a02" have entirely different histories.
7a7472fd12
...
064f747b55
@ -1,465 +0,0 @@
|
|||||||
---
|
|
||||||
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
|
|
||||||
10
FEATURES.md
10
FEATURES.md
@ -27,9 +27,9 @@
|
|||||||
### File Movement
|
### File Movement
|
||||||
- [x] `G` - Move to bottom of file (or line N with count)
|
- [x] `G` - Move to bottom of file (or line N with count)
|
||||||
- [x] `gg` - Move to top of file (or line N with count)
|
- [x] `gg` - Move to top of file (or line N with count)
|
||||||
- [x] `H` - Move to top of screen
|
- [ ] `H` - Move to top of screen
|
||||||
- [x] `M` - Move to middle of screen
|
- [ ] `M` - Move to middle of screen
|
||||||
- [x] `L` - Move to bottom of screen
|
- [ ] `L` - Move to bottom of screen
|
||||||
|
|
||||||
### Scroll
|
### Scroll
|
||||||
- [x] `ctrl+u` - Scroll up half page
|
- [x] `ctrl+u` - Scroll up half page
|
||||||
@ -105,8 +105,8 @@
|
|||||||
- [x] `x` - Delete character under cursor
|
- [x] `x` - Delete character under cursor
|
||||||
- [x] `D` - Delete to end of line
|
- [x] `D` - Delete to end of line
|
||||||
- [x] `X` - Delete character before cursor
|
- [x] `X` - Delete character before cursor
|
||||||
- [x] `J` - Join lines
|
- [ ] `J` - Join lines
|
||||||
- [x] `gJ` - Join lines without space
|
- [ ] `gJ` - Join lines without space
|
||||||
|
|
||||||
### Yank/Paste
|
### Yank/Paste
|
||||||
- [x] `p` - Paste after cursor
|
- [x] `p` - Paste after cursor
|
||||||
|
|||||||
4
V0.1.md
4
V0.1.md
@ -10,8 +10,8 @@
|
|||||||
- [ ] Search (/, ?, n, N) with highlighting
|
- [ ] Search (/, ?, n, N) with highlighting
|
||||||
- [ ] Syntax highlighting (Chroma + tree-sitter for Go/Python/JS)
|
- [ ] Syntax highlighting (Chroma + tree-sitter for Go/Python/JS)
|
||||||
- [ ] % (matching bracket)
|
- [ ] % (matching bracket)
|
||||||
- [x] J (join lines)
|
- [ ] J (join lines)
|
||||||
- [x] H/M/L (screen movement)
|
- [ ] H/M/L (screen movement)
|
||||||
- [ ] Status line (mode, filename, position, modified flag)
|
- [ ] Status line (mode, filename, position, modified flag)
|
||||||
|
|
||||||
## Should Have (Makes it Usable)
|
## Should Have (Makes it Usable)
|
||||||
|
|||||||
@ -1,60 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,179 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
package editor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.gophernest.net/azpect/TextEditor/internal/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestScreenTopMotion(t *testing.T) {
|
|
||||||
t.Run("H moves to the top visible line first non-blank", func(t *testing.T) {
|
|
||||||
lines := screenMotionLines(100)
|
|
||||||
lines[82] = " top"
|
|
||||||
|
|
||||||
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "H")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 82, 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("3H moves to third visible line from top", func(t *testing.T) {
|
|
||||||
lines := screenMotionLines(100)
|
|
||||||
lines[84] = " third"
|
|
||||||
|
|
||||||
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "3", "H")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 84, 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("H count clamps to bottom visible line", func(t *testing.T) {
|
|
||||||
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "9", "9", "9", "H")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 99, 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScreenMiddleMotion(t *testing.T) {
|
|
||||||
t.Run("M moves to middle visible line first non-blank", func(t *testing.T) {
|
|
||||||
lines := screenMotionLines(100)
|
|
||||||
lines[90] = "\tmiddle"
|
|
||||||
|
|
||||||
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "M")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 90, 1)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("count before M is ignored", func(t *testing.T) {
|
|
||||||
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "9", "M")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 90, 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScreenBottomMotion(t *testing.T) {
|
|
||||||
t.Run("L moves to bottom visible line first non-blank", func(t *testing.T) {
|
|
||||||
lines := screenMotionLines(100)
|
|
||||||
lines[99] = " bottom"
|
|
||||||
|
|
||||||
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "L")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 99, 2)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("3L moves to third visible line from bottom", func(t *testing.T) {
|
|
||||||
lines := screenMotionLines(100)
|
|
||||||
lines[97] = " above"
|
|
||||||
|
|
||||||
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "3", "L")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 97, 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("L count clamps to top visible line", func(t *testing.T) {
|
|
||||||
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "9", "9", "9", "L")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 82, 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScreenMotionsEdgeCases(t *testing.T) {
|
|
||||||
t.Run("small file: H and L clamp to file bounds", func(t *testing.T) {
|
|
||||||
lines := []string{" one", "two", "three", "four", "five"}
|
|
||||||
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "L")
|
|
||||||
sendKeys(tm, "H")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 0, 4)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("M on blank middle line lands at column 0", func(t *testing.T) {
|
|
||||||
lines := screenMotionLines(100)
|
|
||||||
lines[90] = ""
|
|
||||||
|
|
||||||
tm := newTestModelWithTermSize(t, lines, core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "M")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
assertCursorPos(t, m, 90, 0)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestScreenMotionsIntegration(t *testing.T) {
|
|
||||||
t.Run("dH treats H as a linewise motion", func(t *testing.T) {
|
|
||||||
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "d", "H")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
if m.ActiveBuffer().LineCount() != 82 {
|
|
||||||
t.Errorf("LineCount() = %d, want 82", m.ActiveBuffer().LineCount())
|
|
||||||
}
|
|
||||||
if m.ActiveBuffer().Line(81) != "line 81" {
|
|
||||||
t.Errorf("Line(81) = %q, want %q", m.ActiveBuffer().Line(81), "line 81")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("H works as a visual line motion", func(t *testing.T) {
|
|
||||||
tm := newTestModelWithTermSize(t, screenMotionLines(100), core.Position{Col: 0, Line: 0}, 80, 20)
|
|
||||||
sendKeys(tm, "G", "V", "H", "d")
|
|
||||||
|
|
||||||
m := getFinalModel(t, tm)
|
|
||||||
if m.ActiveBuffer().LineCount() != 82 {
|
|
||||||
t.Errorf("LineCount() = %d, want 82", m.ActiveBuffer().LineCount())
|
|
||||||
}
|
|
||||||
if m.Mode() != core.NormalMode {
|
|
||||||
t.Errorf("Mode() = %v, want %v", m.Mode(), core.NormalMode)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func screenMotionLines(n int) []string {
|
|
||||||
lines := make([]string, n)
|
|
||||||
for i := range n {
|
|
||||||
lines[i] = fmt.Sprintf("line %d", i)
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertCursorPos(t *testing.T, m *Model, wantLine, wantCol int) {
|
|
||||||
t.Helper()
|
|
||||||
if m.ActiveWindow().Cursor.Line != wantLine {
|
|
||||||
t.Errorf("Cursor.Line = %d, want %d", m.ActiveWindow().Cursor.Line, wantLine)
|
|
||||||
}
|
|
||||||
if m.ActiveWindow().Cursor.Col != wantCol {
|
|
||||||
t.Errorf("Cursor.Col = %d, want %d", m.ActiveWindow().Cursor.Col, wantCol)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -26,9 +26,6 @@ func NewNormalKeymap() *Keymap {
|
|||||||
"k": motion.MoveUp{Count: 1},
|
"k": motion.MoveUp{Count: 1},
|
||||||
"h": motion.MoveLeft{Count: 1},
|
"h": motion.MoveLeft{Count: 1},
|
||||||
"l": motion.MoveRight{Count: 1},
|
"l": motion.MoveRight{Count: 1},
|
||||||
"H": motion.MoveToScreenTop{Count: 1},
|
|
||||||
"M": motion.MoveToScreenMiddle{},
|
|
||||||
"L": motion.MoveToScreenBottom{Count: 1},
|
|
||||||
"G": motion.MoveToBottom{},
|
"G": motion.MoveToBottom{},
|
||||||
"gg": motion.MoveToTop{},
|
"gg": motion.MoveToTop{},
|
||||||
"0": motion.MoveToLineStart{},
|
"0": motion.MoveToLineStart{},
|
||||||
@ -81,8 +78,6 @@ func NewNormalKeymap() *Keymap {
|
|||||||
"ctrl+r": action.Redo{},
|
"ctrl+r": action.Redo{},
|
||||||
".": action.Repeat{Count: 1},
|
".": action.Repeat{Count: 1},
|
||||||
"R": action.EnterReplace{},
|
"R": action.EnterReplace{},
|
||||||
"J": action.JoinLines{Preserve: false},
|
|
||||||
"gJ": action.JoinLines{Preserve: true},
|
|
||||||
},
|
},
|
||||||
charMotions: map[string]action.Motion{
|
charMotions: map[string]action.Motion{
|
||||||
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
|
"f": action.FindChar{Forward: true, Inclusive: true, Repeated: false},
|
||||||
@ -124,9 +119,6 @@ func NewVisualKeymap() *Keymap {
|
|||||||
"k": motion.MoveUp{Count: 1},
|
"k": motion.MoveUp{Count: 1},
|
||||||
"h": motion.MoveLeft{Count: 1},
|
"h": motion.MoveLeft{Count: 1},
|
||||||
"l": motion.MoveRight{Count: 1},
|
"l": motion.MoveRight{Count: 1},
|
||||||
"H": motion.MoveToScreenTop{Count: 1},
|
|
||||||
"M": motion.MoveToScreenMiddle{},
|
|
||||||
"L": motion.MoveToScreenBottom{Count: 1},
|
|
||||||
"G": motion.MoveToBottom{},
|
"G": motion.MoveToBottom{},
|
||||||
"gg": motion.MoveToTop{},
|
"gg": motion.MoveToTop{},
|
||||||
"0": motion.MoveToLineStart{},
|
"0": motion.MoveToLineStart{},
|
||||||
|
|||||||
@ -6,30 +6,6 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
func firstNonBlankCol(line string) int {
|
|
||||||
for i := 0; i < len(line); i++ {
|
|
||||||
if line[i] != ' ' && line[i] != '\t' {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func visibleLineBounds(win *core.Window, buf *core.Buffer) (int, int) {
|
|
||||||
if buf.LineCount() == 0 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
start := win.ScrollY
|
|
||||||
end := start + win.ViewportHeight() - 1
|
|
||||||
end = min(end, buf.LineCount()-1)
|
|
||||||
if end < start {
|
|
||||||
end = start
|
|
||||||
}
|
|
||||||
|
|
||||||
return start, end
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveToTop implements Motion (gg) - linewise
|
// MoveToTop implements Motion (gg) - linewise
|
||||||
type MoveToTop struct{}
|
type MoveToTop struct{}
|
||||||
|
|
||||||
@ -222,74 +198,3 @@ func (a ScrollUpPage) Execute(m action.Model) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }
|
func (a ScrollUpPage) Type() core.MotionType { return core.Linewise }
|
||||||
|
|
||||||
// MoveToScreenTop implements Motion (H) - linewise
|
|
||||||
type MoveToScreenTop struct {
|
|
||||||
Count int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveToScreenTop.Execute: Moves the cursor to the count-th line from the top
|
|
||||||
// of the visible window and places it on the first non-blank character.
|
|
||||||
func (a MoveToScreenTop) Execute(m action.Model) tea.Cmd {
|
|
||||||
win := m.ActiveWindow()
|
|
||||||
buf := m.ActiveBuffer()
|
|
||||||
|
|
||||||
start, end := visibleLineBounds(win, buf)
|
|
||||||
count := max(1, a.Count)
|
|
||||||
targetLine := min(start+count-1, end)
|
|
||||||
targetCol := firstNonBlankCol(buf.Line(targetLine))
|
|
||||||
|
|
||||||
win.SetCursorPos(targetLine, targetCol)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a MoveToScreenTop) Type() core.MotionType { return core.Linewise }
|
|
||||||
|
|
||||||
func (a MoveToScreenTop) WithCount(n int) action.Action {
|
|
||||||
return MoveToScreenTop{Count: n}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveToScreenMiddle implements Motion (M) - linewise
|
|
||||||
type MoveToScreenMiddle struct{}
|
|
||||||
|
|
||||||
// MoveToScreenMiddle.Execute: Moves the cursor to the middle visible line and
|
|
||||||
// places it on the first non-blank character.
|
|
||||||
func (a MoveToScreenMiddle) Execute(m action.Model) tea.Cmd {
|
|
||||||
win := m.ActiveWindow()
|
|
||||||
buf := m.ActiveBuffer()
|
|
||||||
|
|
||||||
start, end := visibleLineBounds(win, buf)
|
|
||||||
targetLine := start + (end-start)/2
|
|
||||||
targetCol := firstNonBlankCol(buf.Line(targetLine))
|
|
||||||
|
|
||||||
win.SetCursorPos(targetLine, targetCol)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a MoveToScreenMiddle) Type() core.MotionType { return core.Linewise }
|
|
||||||
|
|
||||||
// MoveToScreenBottom implements Motion (L) - linewise
|
|
||||||
type MoveToScreenBottom struct {
|
|
||||||
Count int
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveToScreenBottom.Execute: Moves the cursor to the count-th line from the
|
|
||||||
// bottom of the visible window and places it on the first non-blank character.
|
|
||||||
func (a MoveToScreenBottom) Execute(m action.Model) tea.Cmd {
|
|
||||||
win := m.ActiveWindow()
|
|
||||||
buf := m.ActiveBuffer()
|
|
||||||
|
|
||||||
start, end := visibleLineBounds(win, buf)
|
|
||||||
count := max(1, a.Count)
|
|
||||||
targetLine := max(end-count+1, start)
|
|
||||||
targetCol := firstNonBlankCol(buf.Line(targetLine))
|
|
||||||
|
|
||||||
win.SetCursorPos(targetLine, targetCol)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a MoveToScreenBottom) Type() core.MotionType { return core.Linewise }
|
|
||||||
|
|
||||||
func (a MoveToScreenBottom) WithCount(n int) action.Action {
|
|
||||||
return MoveToScreenBottom{Count: n}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user