diff --git a/README.md b/README.md index b8ea01b..15ec172 100644 --- a/README.md +++ b/README.md @@ -492,7 +492,7 @@ chrome. ### Moving around `Space` is the leader. The which-key panel appears immediately. Timed hints -expire after `vim.which_key_hint_timeout_ms`; `vim.which_key_hint_mode = "sticky"` +expire after `helix.which_key_hint_timeout_ms`; `helix.which_key_hint_mode = "sticky"` keeps them open until you choose an action, press Space again, or cancel. | Keys | What | @@ -529,9 +529,30 @@ and preview, `Alt+.` for zen) work where the terminal sends Option as Alt ### Editing -The editor is a full Vim: counts, registers, marks, macros, dot-repeat, -undo and redo, text objects, visual, visual line and visual block, search, -`:s` `:g` `:sort` `:norm` with ranges, `gq`, and heading motions `]]` `[[`. +The editor ships two motion engines: Vim (default) and Helix. Pick one with +`[vim] motions = "vim" | "helix"` in `config.toml` (or Settings). The Vim +engine keeps classic operator-pending editing — `dw` `cw` `yiw`, visual, +visual line and visual block, and the rest of the Vim toolkit. The Helix +engine is selection-first — motions like `w` select as they travel, so `wd` +deletes a word and `n` selects the next match; `miw` picks a word precisely. +Tab switching works in either engine: `gt` `gT` and `]b` `[b` everywhere, +plus `gn` `gp` with the Helix engine. The leader, registers and macros work +in both. + +The Helix engine also grows and shrinks a selection by context: `Alt+O` +(`Alt+Up`) expands it — word, paragraph, function in a code block, code +block, heading section, note — and `Alt+I` (`Alt+Down`) shrinks it back, the +way Helix walks the syntax tree. Its word motions follow one rule: the +selection covers a whole word plus the space that follows it, and never the +first character of the word the motion lands on. `w` moves over the word the +cursor sits on, `b` lands on the previous word and selects it, so `wd` and +`bd` both delete one word. The block cursor stays inside the selection. `w` +runs to the end of the line when the last word of a line is reached, and the +next `w` moves on to the next line. + +The editor also has counts, registers, marks, macros, dot-repeat, undo and +redo, text objects, search, `:s` `:g` `:sort` `:norm` with ranges, `gq`, and +heading motions `]]` `[[`. Markdown on top: `Ctrl+L` toggles the checkbox on the line, `gd` follows a link, `gx` opens a URL, `gy` copies a link, `zc zo zM zR` fold headings, Enter continues lists, Tab indents list items, `[[` opens the note picker @@ -544,10 +565,28 @@ linewise and blockwise selections, and delete history work across notes too. Closing the source note does not discard its yank. Each note keeps its own cursor and undo history; system-clipboard integration remains a separate preference. -Vim mode follows `[vim] enabled` in `config.toml`. With it off, the editor +Modal editing follows `[vim] enabled` in `config.toml`. With it off, the editor is a plain editor. Use arrows and Enter in lists, F2 for context actions, and Ctrl+G for the command palette. Ctrl+Z undoes plain-editor changes. +All editor engine settings live under `[vim]`: + +```toml +[vim] +enabled = true # modal editing; false = plain editor +motions = "vim" # or "helix": the selection-first engine +insert_escape = "jk" # two-key escape from insert mode +yank_to_clipboard = false # yanks mirror the system clipboard +which_key_hints = true # leader hints +which_key_hint_mode = "timed" # timed, sticky or instant +which_key_hint_timeout_ms = 2500 +wrapped_line_motions = "display" # $, I, A follow the wrapped row +``` + +`motions` picks the engine for new buffers; individual keys are overridden +under `[keymaps]` and listed by `:keymaps`. A `[helix]` section from earlier +helix-branch builds is still honored when `[vim]` is absent. + `Ctrl+Space` in insert mode (or `:complete`) completes tags, frontmatter tag values, callouts and code-fence languages. With `markdown_snippets` enabled, Space after an opening Markdown delimiter adds its closer; @@ -714,8 +753,8 @@ layout choices do not change these terminal layouts. | Section | Examples | | --- | --- | -| `[vim]` | `enabled`, `which_key_hints`, `insert_escape` | -| `[editor]` | `line_number_mode`, `word_wrap`, `completed_task_style` | +| `[vim]` | `enabled`, `motions` (`vim` or `helix`), `insert_escape`, `yank_to_clipboard`, `which_key_hints`, `which_key_hint_mode`, `wrapped_line_motions` (a `[helix]` section is still honored when `[vim]` is absent) | +| `[editor]` | `motions` (`vim` or `helix`), `line_number_mode`, `word_wrap`, `completed_task_style` | | `[view]` | `tasks_view_mode`, `kanban_group_by`, `kanban_statuses`, `note_sort_order` | | `[appearance]` | `theme_family`, `theme_mode`, `theme_id` | | `[tweaks]` | `accent = "#ff3b30"`: the desktop's Quick tweaks colors | diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 4a08a03..0231d64 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -11,11 +11,12 @@ import ( // honor. Defaults match the desktop's PORTABLE_DEFAULTS; a missing file or // key keeps the default. type Prefs struct { - VimWrappedLineMotions string - IgnoredKeys []string - VimMode bool - VimInsertEscape string - VimYankToClipboard bool + HelixWrappedLineMotions string + IgnoredKeys []string + HelixMode bool + Motions string + HelixInsertEscape string + HelixYankToClipboard bool WhichKeyHints bool WhichKeyHintMode string WhichKeyHintTimeoutMs int @@ -82,8 +83,9 @@ type Prefs struct { // DefaultPrefs are the shipped defaults. func DefaultPrefs() Prefs { return Prefs{ - VimMode: true, - VimWrappedLineMotions: "display", + HelixMode: true, + Motions: "vim", + HelixWrappedLineMotions: "display", TabsEnabled: true, SavedTaskFilters: map[string]string{}, WhichKeyHints: true, @@ -153,14 +155,18 @@ func applyPrefs(p *Prefs, doc map[string]any) { m, _ := doc[name].(map[string]any) return m } - vim := section("vim") - setBool(vim, "enabled", &p.VimMode) - setString(vim, "wrapped_line_motions", &p.VimWrappedLineMotions) - setString(vim, "insert_escape", &p.VimInsertEscape) - setBool(vim, "yank_to_clipboard", &p.VimYankToClipboard) - setBool(vim, "which_key_hints", &p.WhichKeyHints) - setString(vim, "which_key_hint_mode", &p.WhichKeyHintMode) - setInt(vim, "which_key_hint_timeout_ms", &p.WhichKeyHintTimeoutMs) + modal := section("vim") + if modal == nil { + modal = section("helix") + } + setBool(modal, "enabled", &p.HelixMode) + setString(modal, "motions", &p.Motions) + setString(modal, "wrapped_line_motions", &p.HelixWrappedLineMotions) + setString(modal, "insert_escape", &p.HelixInsertEscape) + setBool(modal, "yank_to_clipboard", &p.HelixYankToClipboard) + setBool(modal, "which_key_hints", &p.WhichKeyHints) + setString(modal, "which_key_hint_mode", &p.WhichKeyHintMode) + setInt(modal, "which_key_hint_timeout_ms", &p.WhichKeyHintTimeoutMs) editor := section("editor") setBool(editor, "sync_title_heading_on_rename", &p.SyncTitleHeadingOnRename) @@ -293,6 +299,7 @@ const DefaultConfigTOML = `# ZenNotes portable preferences. Shared by the deskto [vim] enabled = true +motions = "vim" # or "helix": the selection-first engine insert_escape = "jk" yank_to_clipboard = true which_key_hints = true diff --git a/internal/config/prefs_validate.go b/internal/config/prefs_validate.go index 3ab8ff9..515ab10 100644 --- a/internal/config/prefs_validate.go +++ b/internal/config/prefs_validate.go @@ -57,7 +57,7 @@ func validateKnownValues(doc map[string]any) error { } } enums := map[string]map[string][]string{ - "vim": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}}, + "vim": {"motions": {"helix", "vim"}, "which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}}, "editor": {"default_view_mode": {"edit", "split", "preview"}, "line_number_mode": {"off", "absolute", "relative", "hybrid"}, "completed_task_style": {"none", "gray", "strikethrough", "gray-strikethrough"}, "time_format": {"12h", "24h"}}, "view": {"tasks_view_mode": {"list", "kanban", "board", "calendar"}, "calendar_week_start": {"monday", "sunday"}, "note_sort_order": {"none", "name-asc", "name-desc", "updated-desc", "updated-asc", "created-desc", "created-asc"}}, "appearance": {"theme_mode": {"dark", "light", "system", "auto"}}, diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index e33ea1c..82c40f9 100644 --- a/internal/config/prefs_values.go +++ b/internal/config/prefs_values.go @@ -9,7 +9,7 @@ import ( // their file names in one place also drives the in-app settings controls. func ValueTables(p Prefs) map[string]map[string]any { return map[string]map[string]any{ - "vim": {"enabled": p.VimMode, "insert_escape": p.VimInsertEscape, "yank_to_clipboard": p.VimYankToClipboard, "which_key_hints": p.WhichKeyHints, "which_key_hint_mode": p.WhichKeyHintMode, "which_key_hint_timeout_ms": p.WhichKeyHintTimeoutMs, "wrapped_line_motions": p.VimWrappedLineMotions}, + "vim": {"motions": p.Motions, "enabled": p.HelixMode, "insert_escape": p.HelixInsertEscape, "yank_to_clipboard": p.HelixYankToClipboard, "which_key_hints": p.WhichKeyHints, "which_key_hint_mode": p.WhichKeyHintMode, "which_key_hint_timeout_ms": p.WhichKeyHintTimeoutMs, "wrapped_line_motions": p.HelixWrappedLineMotions}, "editor": {"tab_size": p.EditorTabSize, "scroll_off": p.EditorScrollOff, "word_wrap": p.WordWrap, "default_view_mode": p.DefaultPaneMode, "line_number_mode": p.LineNumberMode, "completed_task_style": p.CompletedTaskStyle, "time_format": p.TimeFormat, "auto_pairs": p.AutoPairs, "auto_pair_quotes_in_prose": p.AutoPairQuotesInProse, "tabs_enabled": p.TabsEnabled, "wrap_tabs": p.WrapTabs, "keep_view_mode_across_notes": p.RetainViewMode, "markdown_snippets": p.MarkdownSnippets, "hide_builtin_templates": p.HideBuiltinTemplates, "text_replacements_enabled": p.TextReplacementsEnabled, "sync_title_heading_on_rename": p.SyncTitleHeadingOnRename}, "appearance": {"theme_family": p.ThemeFamily, "theme_mode": p.ThemeMode, "theme_id": p.ThemeID, "unified_sidebar": p.UnifiedSidebar}, "view": {"note_sort_order": p.NoteSortOrder, "group_by_kind": p.GroupByKind, "nested_tags": p.NestedTags, "quick_note_date_title": p.QuickNoteDateTitle, "quick_note_title_prefix": p.QuickNoteTitlePrefix, "calendar_week_start": p.CalendarWeekStart, "calendar_show_week_numbers": p.CalendarShowWeekNumbers, "tasks_view_mode": p.TasksViewMode, "show_archived_tasks": p.ShowArchivedTasks, "kanban_group_by": p.KanbanGroupBy, "kanban_folder_root": p.KanbanFolderRoot}, diff --git a/internal/helix/buffer.go b/internal/helix/buffer.go new file mode 100644 index 0000000..185aeb0 --- /dev/null +++ b/internal/helix/buffer.go @@ -0,0 +1,340 @@ +package helix + +import ( + "strings" + "unicode" +) + +// Pos is a position in the buffer: a zero-based line and a rune column. The +// vim engine aliases it so both engines share one coordinate type. +type Pos struct { + Line, Col int +} + +// Less orders positions. +func (p Pos) Less(q Pos) bool { + if p.Line != q.Line { + return p.Line < q.Line + } + return p.Col < q.Col +} + +func minPos(a, b Pos) Pos { + if b.Less(a) { + return b + } + return a +} + +func maxPos(a, b Pos) Pos { + if a.Less(b) { + return b + } + return a +} + +// Buffer is the text: a slice of lines without their newlines. Line slices +// are treated as immutable, so an undo snapshot is just a copy of the outer +// slice. +type Buffer struct { + lines [][]rune +} + +// NewBuffer splits text into lines; CRLF is normalized to LF. +func NewBuffer(text string) *Buffer { + b := &Buffer{} + b.SetText(text) + return b +} + +// SetText replaces the whole buffer. +func (b *Buffer) SetText(text string) { + text = strings.ReplaceAll(text, "\r\n", "\n") + parts := strings.Split(text, "\n") + b.lines = make([][]rune, len(parts)) + for i, p := range parts { + b.lines[i] = []rune(p) + } +} + +// Text joins the lines with LF. +func (b *Buffer) Text() string { + parts := make([]string, len(b.lines)) + for i, l := range b.lines { + parts[i] = string(l) + } + return strings.Join(parts, "\n") +} + +// Lines copies every line as a string. +func (b *Buffer) Lines() []string { + out := make([]string, len(b.lines)) + for i, l := range b.lines { + out[i] = string(l) + } + return out +} + +// LineCount is the number of lines, at least one. +func (b *Buffer) LineCount() int { return len(b.lines) } + +// Line is a line's runes. Callers must not mutate the slice. +func (b *Buffer) Line(i int) []rune { + if i < 0 || i >= len(b.lines) { + return nil + } + return b.lines[i] +} + +// LineString is a line as a string. +func (b *Buffer) LineString(i int) string { return string(b.Line(i)) } + +// LineLen is a line's rune count. +func (b *Buffer) LineLen(i int) int { return len(b.Line(i)) } + +// Snapshot copies the outer slice, enough for undo since lines are never +// mutated in place. +func (b *Buffer) Snapshot() [][]rune { + return append([][]rune(nil), b.lines...) +} + +// Restore puts a snapshot back. +func (b *Buffer) Restore(lines [][]rune) { + b.lines = append([][]rune(nil), lines...) + if len(b.lines) == 0 { + b.lines = [][]rune{{}} + } +} + +func cloneRunes(r []rune) []rune { + return append([]rune(nil), r...) +} + +// ReplaceLine swaps a line's content. +func (b *Buffer) ReplaceLine(i int, text []rune) { + if i < 0 || i >= len(b.lines) { + return + } + b.lines[i] = cloneRunes(text) +} + +// InsertLines inserts lines before index at (at may equal LineCount). +func (b *Buffer) InsertLines(at int, lines []string) { + if at < 0 { + at = 0 + } + if at > len(b.lines) { + at = len(b.lines) + } + inserted := make([][]rune, len(lines)) + for i, l := range lines { + inserted[i] = []rune(l) + } + next := make([][]rune, 0, len(b.lines)+len(inserted)) + next = append(next, b.lines[:at]...) + next = append(next, inserted...) + next = append(next, b.lines[at:]...) + b.lines = next +} + +// DeleteLines removes lines from..to inclusive and returns them. The buffer +// always keeps one line. +func (b *Buffer) DeleteLines(from, to int) []string { + if from < 0 { + from = 0 + } + if to >= len(b.lines) { + to = len(b.lines) - 1 + } + if from > to { + return nil + } + removed := make([]string, 0, to-from+1) + for i := from; i <= to; i++ { + removed = append(removed, string(b.lines[i])) + } + next := make([][]rune, 0, len(b.lines)-(to-from+1)) + next = append(next, b.lines[:from]...) + next = append(next, b.lines[to+1:]...) + if len(next) == 0 { + next = [][]rune{{}} + } + b.lines = next + return removed +} + +// ReplaceLines swaps lines from..to (inclusive) for new ones. Replacing +// every line replaces the buffer's mandatory single line too, so no empty +// line lingers. +func (b *Buffer) ReplaceLines(from, to int, lines []string) { + if from <= 0 && to >= len(b.lines)-1 { + if len(lines) == 0 { + lines = []string{""} + } + next := make([][]rune, len(lines)) + for i, l := range lines { + next[i] = []rune(l) + } + b.lines = next + return + } + b.DeleteLines(from, to) + b.InsertLines(from, lines) +} + +// InsertText inserts text (which may contain newlines) at p and returns the +// position just after it. +func (b *Buffer) InsertText(p Pos, text string) Pos { + p = b.clampInsert(p) + line := b.lines[p.Line] + parts := strings.Split(text, "\n") + if len(parts) == 1 { + next := make([]rune, 0, len(line)+len([]rune(text))) + next = append(next, line[:p.Col]...) + next = append(next, []rune(text)...) + next = append(next, line[p.Col:]...) + b.lines[p.Line] = next + return Pos{p.Line, p.Col + len([]rune(text))} + } + head := cloneRunes(line[:p.Col]) + tail := cloneRunes(line[p.Col:]) + first := append(head, []rune(parts[0])...) + lastRunes := []rune(parts[len(parts)-1]) + last := append(cloneRunes(lastRunes), tail...) + middle := make([][]rune, 0, len(parts)) + middle = append(middle, first) + for _, m := range parts[1 : len(parts)-1] { + middle = append(middle, []rune(m)) + } + middle = append(middle, last) + next := make([][]rune, 0, len(b.lines)+len(middle)-1) + next = append(next, b.lines[:p.Line]...) + next = append(next, middle...) + next = append(next, b.lines[p.Line+1:]...) + b.lines = next + return Pos{p.Line + len(parts) - 1, len(lastRunes)} +} + +// Range is the text between start (inclusive) and end (exclusive). +func (b *Buffer) Range(start, end Pos) string { + start, end = b.clampInsert(start), b.clampInsert(end) + if end.Less(start) { + start, end = end, start + } + if start.Line == end.Line { + return string(b.lines[start.Line][start.Col:end.Col]) + } + var sb strings.Builder + sb.WriteString(string(b.lines[start.Line][start.Col:])) + for i := start.Line + 1; i < end.Line; i++ { + sb.WriteByte('\n') + sb.WriteString(string(b.lines[i])) + } + sb.WriteByte('\n') + sb.WriteString(string(b.lines[end.Line][:end.Col])) + return sb.String() +} + +// DeleteRange removes the text between start (inclusive) and end +// (exclusive) and returns it. +func (b *Buffer) DeleteRange(start, end Pos) string { + start, end = b.clampInsert(start), b.clampInsert(end) + if end.Less(start) { + start, end = end, start + } + removed := b.Range(start, end) + head := cloneRunes(b.lines[start.Line][:start.Col]) + tail := b.lines[end.Line][end.Col:] + joined := append(head, tail...) + next := make([][]rune, 0, len(b.lines)) + next = append(next, b.lines[:start.Line]...) + next = append(next, joined) + next = append(next, b.lines[end.Line+1:]...) + b.lines = next + return removed +} + +// clampInsert keeps a position inside the buffer, allowing the column just +// past the last rune. +func (b *Buffer) clampInsert(p Pos) Pos { + if p.Line < 0 { + p.Line = 0 + } + if p.Line >= len(b.lines) { + p.Line = len(b.lines) - 1 + } + if p.Col < 0 { + p.Col = 0 + } + if p.Col > len(b.lines[p.Line]) { + p.Col = len(b.lines[p.Line]) + } + return p +} + +// clampNormal keeps a position on a character, the way a normal-mode +// cursor sits. +func (b *Buffer) clampNormal(p Pos) Pos { + p = b.clampInsert(p) + if n := len(b.lines[p.Line]); p.Col >= n { + p.Col = max(0, n-1) + } + return p +} + +// --- character classes --- + +// isWordRune is Vim's `iskeyword`: letters, digits and underscore. +func isWordRune(r rune) bool { + return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r) +} + +func isBlank(r rune) bool { + return r == ' ' || r == '\t' +} + +// charClass buckets a rune for word motions: 0 blank, 1 keyword, 2 other. +func charClass(r rune, bigWord bool) int { + if isBlank(r) { + return 0 + } + if bigWord || isWordRune(r) { + return 1 + } + return 2 +} + +func firstNonBlank(line []rune) int { + for i, r := range line { + if !isBlank(r) { + return i + } + } + return max(0, len(line)-1) +} + +func firstNonBlankInsert(line []rune) int { + for i, r := range line { + if !isBlank(r) { + return i + } + } + return len(line) +} + +func leadingWhitespace(line []rune) []rune { + i := 0 + for i < len(line) && isBlank(line[i]) { + i++ + } + return cloneRunes(line[:i]) +} + +func isBlankLine(line []rune) bool { + for _, r := range line { + if !isBlank(r) { + return false + } + } + return true +} diff --git a/internal/helix/display_motion_test.go b/internal/helix/display_motion_test.go new file mode 100644 index 0000000..dc7f1ac --- /dev/null +++ b/internal/helix/display_motion_test.go @@ -0,0 +1,46 @@ +package helix + +import "testing" + +func TestDisplayRowBoundariesRespectCountsAndLogicalMode(t *testing.T) { + opts := DefaultOptions() + opts.WrappedLineMotions = true + ed := New("abcdefghij\nsecond", opts, Hooks{DisplayRowBounds: func(p Pos) (int, int) { + if p.Line == 0 { + if p.Col < 5 { + return 0, 5 + } + return 5, 10 + } + return 0, 6 + }}) + ed.HandleKey(R('$')) + if ed.Cursor().Col != 4 { + t.Fatal(ed.Cursor()) + } + ed.SetCursor(Pos{}) + ed.HandleKey(R('A')) + if ed.Cursor().Col != 5 { + t.Fatal(ed.Cursor()) + } + ed.HandleKey(Special("esc")) + ed.SetCursor(Pos{0, 7}) + ed.HandleKey(R('I')) + if ed.Cursor().Col != 5 { + t.Fatal(ed.Cursor()) + } + ed.HandleKey(Special("esc")) + ed.SetCursor(Pos{}) + ed.HandleKey(R('2')) + ed.HandleKey(R('$')) + if ed.Cursor() != (Pos{1, 5}) { + t.Fatal(ed.Cursor()) + } + opts.WrappedLineMotions = false + ed.SetOptions(opts) + ed.SetCursor(Pos{}) + ed.HandleKey(R('$')) + if ed.Cursor().Col != 9 { + t.Fatal(ed.Cursor()) + } +} diff --git a/internal/helix/editor.go b/internal/helix/editor.go new file mode 100644 index 0000000..b4efef7 --- /dev/null +++ b/internal/helix/editor.go @@ -0,0 +1,1815 @@ +package helix + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "unicode" +) + +// Mode is the editor's state. +type Mode int + +const ( + ModeNormal Mode = iota + ModeInsert + ModeSelect + ModeCmdline +) + +// Label is the mode name for a status bar. +func (m Mode) Label() string { + switch m { + case ModeInsert: + return "INSERT" + case ModeSelect: + return "SELECT" + case ModeCmdline: + return "COMMAND" + } + return "NORMAL" +} + +// selKind is what a selection covers: single characters, whole lines, or a +// rectangular block (a ZenNotes extension; Helix has no block mode). +type selKind int + +const ( + selChar selKind = iota + selLine + selBlock +) + +// Options tune the engine. Zero values are filled by New. The vim engine +// aliases it so the host can configure either engine the same way; the +// vim engine reads the fields it knows and ignores the rest. +type Options struct { + WrappedLineMotions bool + TabSize int + ScrollOff int + IgnoreCase bool + SmartCase bool + WrapScan bool + InsertEscape string + AutoPairs bool + AutoPairQuotes bool + TextReplacements map[string]string + TextReplacementsEnabled bool + MarkdownLists bool + YankToClipboard bool + // HelixEnabled false runs the buffer as a plain editor: always inserting, + // arrows and the usual editing keys only. + HelixEnabled bool +} + +// DefaultOptions are the shipped defaults. +func DefaultOptions() Options { + return Options{ + TabSize: 4, + WrapScan: true, + AutoPairs: true, + TextReplacements: map[string]string{"->": "→"}, + TextReplacementsEnabled: true, + MarkdownLists: true, + HelixEnabled: true, + } +} + +// Register is a yank or delete. The vim engine aliases it so hosts share +// register stores across engines. +type Register struct { + Text string + Linewise bool + Blockwise bool +} + +// RegisterStore holds yank, delete, and macro registers shared by editors in +// one host session. Its zero value is ready to use. Like Editor, it is owned by +// the host's event loop rather than accessed concurrently. +type RegisterStore struct { + values map[rune]Register +} + +// Init makes a zero-value store usable. +func (s *RegisterStore) Init() { + if s.values == nil { + s.values = make(map[rune]Register) + } +} + +// Map is the backing register map. Both engines point their Editor at it so +// registers stay session-wide; the host owns the store, so this stays on the +// event loop like the rest of the engine. +func (s *RegisterStore) Map() map[rune]Register { + s.Init() + return s.values +} + +// Range is a line range for an ex command, zero-based and inclusive. +type Range struct { + Start, End int + Given bool +} + +// ExCommand is a command the editor hands to its host. +type ExCommand struct { + Name string + Args string + Bang bool + Range Range + Raw string +} + +// Hooks are the host's side of the engine. Every field is optional. The vim +// engine aliases it so hosts wire either engine the same way. +type Hooks struct { + DisplayRowBounds func(Pos) (int, int) + // ExCommand runs a command the editor does not own; handled false means + // unknown command. + ExCommand func(cmd ExCommand) (handled bool, err error) + // ExComplete proposes completions for the ex line. It receives the text + // before the cursor and returns the part to keep plus the candidates + // that replace the rest, so a host can complete note titles with spaces + // as one argument. Nil candidates fall back to the built-in command + // names. + ExComplete func(text string) (keep string, candidates []string) + ReadClipboard func() (string, bool) + WriteClipboard func(text string) bool + // OnJump is told before a jump motion moves the cursor. + OnJump func(from Pos) + // FollowLink is `gd`, CopyLink is `gy`, OpenURL is `gx`. + FollowLink func() + CopyLink func() + OpenURL func() + // LineRows is how many screen rows a line takes when soft-wrapped. + LineRows func(line int) int + // Changed is called after every committed change. + Changed func() +} + +type snapshot struct { + lines [][]rune + cursor Pos +} + +type undoEntry struct { + before, after snapshot +} + +type lastChange struct { + keys []Key + count int + register rune +} + +type cmdlineState struct { + kind rune + text []rune + pos int + histIdx int + saved string + original []rune + searchOrg Pos + pendingRegister bool + // completion cycling on the ex line + completions []string + compIdx int + compPrefix string + compBase string +} + +// pendingOpSearch is an operator waiting on a `/` or `?` motion typed on +// the command line, like `d/foo`. + +type insertState struct { + start Pos + escPending bool + ctrlO bool + ctrlR bool + ctrlV bool + completion *completionState + pairedInsert Pos +} + +type completionState struct { + prefix string + candidates []string + idx int + start Pos +} + +// Editor is one buffer with its full Vim state. +type Editor struct { + buf *Buffer + mode Mode + opts Options + hooks Hooks + + cursor Pos + desiredCol int + + // anchor is the selection tail and kind what it covers; both are only + // meaningful in ModeSelect, where the selection runs from anchor to + // cursor (either order). Normal mode is a collapsed selection on the + // cursor character, the way Helix models cursors. + anchor Pos + kind selKind + lastSelect struct { + start, end Pos + kind selKind + set bool + } + + registers map[rune]Register + marks map[rune]Pos + + undoStack []undoEntry + redoStack []undoEntry + active *snapshot + version int + savedAtVer int + + pending []Key + + last lastChange + changeKeys []Key + recordingIns bool + replaying bool + dotCountOvr int + lastInsertKey []Key + + macroReg rune + macroKeys []Key + lastMacro rune + macroDepth int + lastExLine string + lastSubst *substitution + lastSearch string + searchDir int + highlight bool + lastFind struct { + ch rune + kind rune + set bool + } + lastMotion motion + // motionSelected marks a command that deliberately leaves a selection + // behind (the selector motions); finishNormal collapses stale anchors + // after every other normal-mode command. + motionSelected bool + // selExcl marks a selection that leaves out the character under the + // head: a forward word motion stops on the first character of the word + // it lands on without selecting it. + selExcl bool + // expandStack holds the selections the growth commands replaced, so + // Alt+i can restore the one before it. + expandStack []selState + lastMotionArgs struct { + count int + hasCount bool + } + cmd cmdlineState + ins insertState + histories map[rune]*[]string + incremental *regexp.Regexp + blockInsert *blockInsertState + + rows int + scrollTop int + msg string + msgErr bool + + folds map[int]int + + inChange bool +} + +// New creates an editor over text. +func New(text string, opts Options, hooks Hooks) *Editor { + return NewWithRegisters(text, opts, hooks, nil) +} + +// NewWithRegisters creates an editor with session-wide registers. Cursor, +// selections, marks, and undo history remain local to this editor. A nil store +// gives the editor private registers, as New does. +func NewWithRegisters(text string, opts Options, hooks Hooks, store *RegisterStore) *Editor { + if opts.TabSize <= 0 { + opts.TabSize = 4 + } + if store == nil { + store = &RegisterStore{} + } + if store.values == nil { + store.values = make(map[rune]Register) + } + e := &Editor{ + buf: NewBuffer(text), + opts: opts, + hooks: hooks, + registers: store.values, + marks: map[rune]Pos{}, + desiredCol: -1, + searchDir: 1, + rows: 24, + folds: map[int]int{}, + } + if !opts.HelixEnabled { + e.mode = ModeInsert + } + return e +} + +// --- accessors --- + +func (e *Editor) Text() string { return e.buf.Text() } +func (e *Editor) Lines() []string { return e.buf.Lines() } +func (e *Editor) LineCount() int { return e.buf.LineCount() } +func (e *Editor) Line(i int) string { return e.buf.LineString(i) } +func (e *Editor) LineRunes(i int) []rune { return e.buf.Line(i) } +func (e *Editor) Cursor() Pos { return e.cursor } +func (e *Editor) Mode() Mode { return e.mode } +func (e *Editor) Options() Options { return e.opts } +func (e *Editor) Version() int { return e.version } +func (e *Editor) ScrollTop() int { return e.scrollTop } +func (e *Editor) Rows() int { return e.rows } + +// SetOptions replaces the options live. +func (e *Editor) SetOptions(opts Options) { + if opts.TabSize <= 0 { + opts.TabSize = 4 + } + wasEnabled := e.opts.HelixEnabled + e.opts = opts + if wasEnabled && !opts.HelixEnabled { + e.leaveToInsertOnly() + } + if !wasEnabled && opts.HelixEnabled && e.mode == ModeInsert { + e.mode = ModeNormal + e.cursor = e.buf.clampNormal(e.cursor) + } +} + +func (e *Editor) leaveToInsertOnly() { + e.pending = nil + e.commitChange() + e.mode = ModeInsert + e.cursor = e.buf.clampInsert(e.cursor) +} + +// SetHooks replaces the host hooks. +func (e *Editor) SetHooks(h Hooks) { e.hooks = h } + +// Dirty is true when the buffer changed since the last MarkSaved. +func (e *Editor) Dirty() bool { return e.version != e.savedAtVer } + +// MarkSaved records the current version as the on-disk one. +func (e *Editor) MarkSaved() { e.savedAtVer = e.version } + +// Message is the last status message and whether it is an error. +func (e *Editor) Message() (string, bool) { return e.msg, e.msgErr } + +// ClearMessage drops the status message. +func (e *Editor) ClearMessage() { e.msg, e.msgErr = "", false } + +func (e *Editor) setMsg(s string) { e.msg, e.msgErr = s, false } +func (e *Editor) setError(s string) { e.msg, e.msgErr = s, true } + +// Pending is the keys of an unfinished command, for a showcmd area. +func (e *Editor) Pending() string { + if e.mode == ModeNormal || e.mode == ModeSelect { + return KeysString(e.pending) + } + return "" +} + +// Recording is the register a macro is being recorded into, or 0. +func (e *Editor) Recording() rune { return e.macroReg } + +// SetViewport tells the editor how many screen rows it has. +func (e *Editor) SetViewport(rows int) { + if rows < 1 { + rows = 1 + } + e.rows = rows +} + +// SetScrollTop moves the viewport. +func (e *Editor) SetScrollTop(line int) { + e.scrollTop = min(max(0, line), e.buf.LineCount()-1) +} + +// SetCursor moves the cursor, clamped for the current mode. +func (e *Editor) SetCursor(p Pos) { + if e.mode == ModeInsert { + e.cursor = e.buf.clampInsert(p) + } else { + e.cursor = e.buf.clampNormal(p) + } + if e.mode != ModeSelect { + e.anchor = e.cursor + e.selExcl = false + } + e.desiredCol = -1 + e.openFoldAt(e.cursor.Line) +} + +// SetText replaces the text and resets history. +func (e *Editor) SetText(text string) { + e.buf.SetText(text) + e.undoStack, e.redoStack, e.active = nil, nil, nil + e.version, e.savedAtVer = 0, 0 + e.cursor = e.buf.clampNormal(Pos{}) + e.scrollTop = 0 + e.folds = map[int]int{} + if e.mode == ModeInsert { + e.cursor = e.buf.clampInsert(e.cursor) + } +} + +// ReplaceText swaps the text in as a change (undoable) keeping the cursor +// where it was where possible, for a note reloaded from disk. +func (e *Editor) ReplaceText(text string) { + e.beginChange() + e.buf.SetText(text) + e.cursor = e.buf.clampNormal(e.cursor) + if e.mode == ModeInsert { + e.cursor = e.buf.clampInsert(e.cursor) + } + e.commitChange() +} + +// Marks copies the mark table. +func (e *Editor) Marks() map[rune]Pos { + out := map[rune]Pos{} + for k, v := range e.marks { + out[k] = v + } + return out +} + +// RegisterText reads a register. +func (e *Editor) RegisterText(r rune) (Register, bool) { + reg, ok := e.registers[r] + return reg, ok +} + +// --- undo --- + +func (e *Editor) beginChange() { + // Text that moves under the selection makes the saved growth steps + // stale, so the stack starts over. + e.expandStack = nil + if e.active != nil { + return + } + e.active = &snapshot{lines: e.buf.Snapshot(), cursor: e.cursor} +} + +func (e *Editor) commitChange() { + if e.active == nil { + return + } + before := e.active + e.active = nil + if sameLines(before.lines, e.buf.lines) { + return + } + e.undoStack = append(e.undoStack, undoEntry{ + before: *before, + after: snapshot{lines: e.buf.Snapshot(), cursor: e.cursor}, + }) + if len(e.undoStack) > 1000 { + e.undoStack = e.undoStack[len(e.undoStack)-1000:] + } + e.redoStack = nil + e.version++ + e.marks['.'] = e.cursor + e.repairFolds() + if e.hooks.Changed != nil { + e.hooks.Changed() + } +} + +func sameLines(a, b [][]rune) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if len(a[i]) != len(b[i]) { + return false + } + for j := range a[i] { + if a[i][j] != b[i][j] { + return false + } + } + } + return true +} + +// Undo reverts the last change. +func (e *Editor) Undo() { + e.commitChange() + if len(e.undoStack) == 0 { + e.setError("Already at oldest change") + return + } + entry := e.undoStack[len(e.undoStack)-1] + e.undoStack = e.undoStack[:len(e.undoStack)-1] + e.redoStack = append(e.redoStack, entry) + e.buf.Restore(entry.before.lines) + e.cursor = e.buf.clampNormal(entry.before.cursor) + e.version++ + e.repairFolds() + if e.hooks.Changed != nil { + e.hooks.Changed() + } +} + +// Redo re-applies an undone change. +func (e *Editor) Redo() { + e.commitChange() + if len(e.redoStack) == 0 { + e.setError("Already at newest change") + return + } + entry := e.redoStack[len(e.redoStack)-1] + e.redoStack = e.redoStack[:len(e.redoStack)-1] + e.undoStack = append(e.undoStack, entry) + e.buf.Restore(entry.after.lines) + e.cursor = e.buf.clampNormal(entry.after.cursor) + e.version++ + e.repairFolds() + if e.hooks.Changed != nil { + e.hooks.Changed() + } +} + +// --- key entry --- + +// Feed runs keys given in Vim notation. +func (e *Editor) Feed(notation string) { + for _, k := range ParseKeys(notation) { + e.HandleKey(k) + } +} + +// HandleKey processes one keystroke. It reports false only in plain (non +// Vim) mode for keys the editor has no use for, so the host may act. +func (e *Editor) HandleKey(k Key) bool { + if e.macroReg != 0 && !e.replaying && e.macroDepth == 0 { + e.macroKeys = append(e.macroKeys, k) + } + if e.recordingIns && !e.replaying { + e.changeKeys = append(e.changeKeys, k) + } + switch e.mode { + case ModeInsert: + if !e.opts.HelixEnabled { + return e.plainKey(k) + } + e.insertKey(k) + return true + case ModeCmdline: + e.cmdlineKey(k) + return true + case ModeSelect: + e.selectKey(k) + return true + default: + e.normalKey(k) + return true + } +} + +// --- normal mode parsing --- + +type parseStatus int + +const ( + parseIncomplete parseStatus = iota + parseInvalid + parseComplete +) + +func (e *Editor) normalKey(k Key) { + e.ClearMessage() + e.pending = append(e.pending, k) + status := e.runNormal(e.pending) + if status != parseIncomplete { + e.pending = nil + } +} + +// splitPrefix reads an optional register and count off the front of keys. +func splitPrefix(keys []Key) (register rune, count int, hasCount bool, rest []Key, status parseStatus) { + i := 0 + for { + if i >= len(keys) { + return register, count, hasCount, nil, parseIncomplete + } + if keys[i].IsRune('"') { + if i+1 >= len(keys) { + return register, count, hasCount, nil, parseIncomplete + } + r := keys[i+1] + if !r.Printable() || !validRegister(r.Rune) { + return 0, 0, false, nil, parseInvalid + } + register = r.Rune + i += 2 + continue + } + break + } + for i < len(keys) && keys[i].Printable() && keys[i].Rune >= '0' && keys[i].Rune <= '9' && !(keys[i].Rune == '0' && !hasCount) { + count = count*10 + int(keys[i].Rune-'0') + hasCount = true + i++ + } + if i >= len(keys) { + return register, count, hasCount, nil, parseIncomplete + } + return register, count, hasCount, keys[i:], parseComplete +} + +func validRegister(r rune) bool { + return r == '"' || r == '_' || r == '+' || r == '*' || r == '-' || r == '.' || r == ':' || r == '/' || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') +} + +// runNormal applies a completed normal-mode key sequence. There is no +// operator-pending state: `d`, `y`, `c` and friends act on the current +// selection the Helix way, so a sequence is a prefix plus one command key. +func (e *Editor) runNormal(keys []Key) parseStatus { + e.motionSelected = false + register, count, hasCount, rest, status := splitPrefix(keys) + if status != parseComplete { + return status + } + versionBefore := e.version + enteredInsert := false + + st, entered := e.normalCommand(rest, count, hasCount, register) + if st != parseComplete { + return st + } + enteredInsert = entered + e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) + return parseComplete +} + +// finishNormal closes the undo transaction and records dot-repeat. +func (e *Editor) finishNormal(keys []Key, versionBefore int, enteredInsert bool, register rune, count int, hasCount bool) { + if enteredInsert { + if !e.replaying { + e.recordingIns = true + e.changeKeys = append([]Key(nil), keys...) + } + return + } + e.commitChange() + if e.version != versionBefore && !e.replaying && !isUndoRedo(keys) { + e.last = lastChange{keys: stripCount(keys), count: count, register: register} + if !hasCount { + e.last.count = 0 + } + } + e.recordingIns = false + if !e.motionSelected && e.mode != ModeSelect { + // Every non-selector normal-mode command leaves a collapsed + // selection, so a stale anchor never lingers as a highlight. + e.anchor = e.cursor + e.selExcl = false + } + e.motionSelected = false + e.cursor = e.buf.clampNormal(e.cursor) + e.ensureCursorVisible() +} + +func isUndoRedo(keys []Key) bool { + _, _, _, rest, st := splitPrefix(keys) + if st != parseComplete || len(rest) == 0 { + return false + } + k := rest[0] + return k.IsRune('u') || k.IsRune('U') || k.IsCtrl('r') || k.IsRune('.') || k.IsRune('@') || k.IsRune('q') +} + +// stripCount removes the leading register and count so `.` can supply its +// own count. +func stripCount(keys []Key) []Key { + i := 0 + for i+1 < len(keys) && keys[i].IsRune('"') { + i += 2 + } + for i < len(keys) && keys[i].Printable() && keys[i].Rune >= '0' && keys[i].Rune <= '9' && !(keys[i].Rune == '0' && (i == 0 || !keys[i-1].Printable() || keys[i-1].Rune < '0' || keys[i-1].Rune > '9')) { + i++ + } + return append([]Key(nil), keys[i:]...) +} + +// --- normal mode commands --- + +// normalCommand runs one normal-mode command key (after the register and +// count prefix). Selection operations — delete, yank, change and friends — +// act on the current selection, which is the single character under the +// cursor unless a selection is active. Returns the parse status and whether +// the command entered insert mode. +func (e *Editor) normalCommand(rest []Key, count int, hasCount bool, register rune) (parseStatus, bool) { + if len(rest) == 0 { + return parseIncomplete, false + } + k := rest[0] + n := max(1, count) + if k.Printable() { + switch k.Rune { + // --- entering insert --- + // Helix inserts before the selection (`i`) or after it (`a`); + // on a collapsed selection that is the usual cursor behaviour. + case 'i': + start, _ := e.selRange() + e.startInsert(e.buf.clampNormal(start)) + return parseComplete, true + case 'a': + _, end := e.selRange() + e.startInsert(e.stepInsertPos(e.buf.clampNormal(end))) + return parseComplete, true + case 'I': + p := Pos{e.cursor.Line, firstNonBlankInsert(e.buf.Line(e.cursor.Line))} + if e.opts.WrappedLineMotions && e.hooks.DisplayRowBounds != nil { + start, end := e.hooks.DisplayRowBounds(e.cursor) + p.Col = start + line := e.buf.Line(p.Line) + for p.Col < end && (line[p.Col] == ' ' || line[p.Col] == '\t') { + p.Col++ + } + } + e.startInsert(p) + return parseComplete, true + case 'A': + p := Pos{e.cursor.Line, e.buf.LineLen(e.cursor.Line)} + if e.opts.WrappedLineMotions && e.hooks.DisplayRowBounds != nil { + _, p.Col = e.hooks.DisplayRowBounds(e.cursor) + } + e.startInsert(p) + return parseComplete, true + case 'o': + e.beginChange() + e.openLine(false) + return parseComplete, true + case 'O': + e.beginChange() + e.openLine(true) + return parseComplete, true + // --- the selection --- + case 'v': + e.enterSelect(selChar) + return parseComplete, false + case 'V': + e.enterSelect(selLine) + return parseComplete, false + case 'x': + e.extendLineBelow() + return parseComplete, false + case 'X': + e.extendToLineBounds() + return parseComplete, false + case ';': + e.collapseToCursor() + return parseComplete, false + case '%': + e.selectAll() + return parseComplete, false + // --- selection operations --- + case 'd': + e.beginChange() + e.runOperator("d", e.selectionSpan(), register, false) + return parseComplete, false + case 'y': + e.runOperator("y", e.selectionSpan(), register, false) + return parseComplete, false + case 'c': + e.beginChange() + entered := e.runOperator("c", e.selectionSpan(), register, false) + return parseComplete, entered + case 'p': + e.beginChange() + e.putSelection(register, n, true) + return parseComplete, false + case 'P': + e.beginChange() + e.putSelection(register, n, false) + return parseComplete, false + case 'R': + e.beginChange() + e.putOverSpan(e.selectionSpan(), register, n, false) + return parseComplete, false + case '>': + e.beginChange() + e.indentSelection(1) + return parseComplete, false + case '<': + e.beginChange() + e.indentSelection(-1) + return parseComplete, false + case '~': + e.beginChange() + e.transformSelection("g~") + return parseComplete, false + case '`': + e.beginChange() + e.transformSelection("gu") + return parseComplete, false + case 'r': + if len(rest) < 2 { + return parseIncomplete, false + } + if !rest[1].Printable() { + return parseInvalid, false + } + e.beginChange() + e.replaceSelectionWith(rest[1].Rune) + return parseComplete, false + case 'J': + e.beginChange() + e.joinSelection(n) + return parseComplete, false + // --- search --- + case '*': + e.searchSelection(false) + return parseComplete, false + case '#': + e.searchWordUnderCursor(1) + return parseComplete, false + case ':': + e.openCmdline(':', count, hasCount) + return parseComplete, false + case '/': + e.openCmdline('/', count, hasCount) + return parseComplete, false + case '?': + e.openCmdline('?', count, hasCount) + return parseComplete, false + // --- macros --- + case 'Q': + if e.macroReg != 0 { + e.stopMacro() + return parseComplete, false + } + reg := register + if reg == 0 { + reg = 'q' + } + if validRegister(reg) { + e.macroReg = unicode.ToLower(reg) + e.macroKeys = nil + e.setMsg("recording @" + string(e.macroReg)) + } + return parseComplete, false + case 'q': + reg := register + if reg == 0 { + reg = 'q' + } + e.runMacro(reg, n) + return parseComplete, false + case '@': + if len(rest) < 2 { + return parseIncomplete, false + } + if rest[1].Printable() { + e.runMacro(rest[1].Rune, n) + } + return parseComplete, false + // --- undo, redo, repeat --- + case 'u': + for i := 0; i < n; i++ { + e.Undo() + } + return parseComplete, false + case 'U': + for i := 0; i < n; i++ { + e.Redo() + } + return parseComplete, false + case '.': + e.repeatLastChange(count, hasCount) + return parseComplete, false + // --- extras kept from ZenNotes (unbound in Helix) --- + case 'D': + e.beginChange() + e.deleteToEnd(n, register) + e.cursor = e.buf.clampNormal(e.cursor) + return parseComplete, false + case 'C': + e.beginChange() + e.deleteToEnd(n, register) + e.startInsert(Pos{e.cursor.Line, e.buf.LineLen(e.cursor.Line)}) + return parseComplete, true + case '&': + e.beginChange() + e.repeatSubstitute(false) + return parseComplete, false + case ',': + e.repeatFindChar(-1, n) + return parseComplete, false + case 'Z': + if len(rest) < 2 { + return parseIncomplete, false + } + if rest[1].IsRune('Z') { + e.hostEx(ExCommand{Name: "wq", Raw: "wq"}) + } else if rest[1].IsRune('Q') { + e.hostEx(ExCommand{Name: "q", Bang: true, Raw: "q!"}) + } + return parseComplete, false + // --- minor modes --- + case 'g': + return e.gotoMode(rest, count, hasCount) + case 'z': + return e.viewMode(rest, n) + case 'm': + return e.matchMode(rest) + } + } + if k.IsCtrl('r') { + for i := 0; i < n; i++ { + e.Redo() + } + return parseComplete, false + } + if k.IsCtrl('a') || k.IsCtrl('x') { + e.beginChange() + delta := n + if k.IsCtrl('x') { + delta = -n + } + e.incrementNumber(delta) + return parseComplete, false + } + if k.IsCtrl('v') { + e.enterSelect(selBlock) + return parseComplete, false + } + if k.IsAlt('.') { + e.repeatLastMotion(n) + return parseComplete, false + } + if k.IsAlt('o') || (k.Alt && k.Name == "up") { + e.expandSelection() + return parseComplete, false + } + if k.IsAlt('i') || (k.Alt && k.Name == "down") { + e.shrinkSelection() + return parseComplete, false + } + if k.IsCtrl('d') { + e.scrollHalfPage(1, count, hasCount) + return parseComplete, false + } + if k.IsCtrl('u') { + e.scrollHalfPage(-1, count, hasCount) + return parseComplete, false + } + if k.IsCtrl('f') || k.Is("pgdn") { + e.scrollPage(n) + return parseComplete, false + } + if k.IsCtrl('b') || k.Is("pgup") { + e.scrollPage(-n) + return parseComplete, false + } + if k.IsCtrl('e') { + e.scrollLines(n) + return parseComplete, false + } + if k.IsCtrl('y') { + e.scrollLines(-n) + return parseComplete, false + } + if k.IsCtrl('l') { + e.highlight = false + return parseComplete, false + } + if k.Is("esc") { + e.highlightOffOnEsc() + return parseComplete, false + } + if k.Is("insert") { + e.startInsert(e.cursor) + return parseComplete, true + } + if k.Is("delete") { + e.beginChange() + e.runOperator("d", e.selectionSpan(), register, false) + return parseComplete, false + } + // Motions. Helix is selection-first: word, paragraph and find motions + // replace the selection with the span they traverse, so `wd` deletes a + // word and `n` selects the next match. Character and line moves collapse + // the selection onto their target; in select mode they extend it. + m, st := parseMotion(rest) + if st != parseComplete { + return st, false + } + switch { + case m.kind == "n" || m.kind == "N": + e.selectMatch(m, count, hasCount) + case selectorMotion(m): + e.selectByMotion(m, count, hasCount) + case e.mode == ModeSelect: + e.extendByMotion(m, count, hasCount) + default: + e.moveByMotion(m, count, hasCount) + } + return parseComplete, false +} + +// gotoMode is Helix's goto mode (g): file, line and screen positions plus the +// ZenNotes link keys. +func (e *Editor) gotoMode(rest []Key, count int, hasCount bool) (parseStatus, bool) { + if len(rest) < 2 { + return parseIncomplete, false + } + k := rest[1] + if !k.Printable() { + return parseInvalid, false + } + switch k.Rune { + case 'g': + // First line, or line N with a count. + if hasCount { + e.moveByMotion(motion{kind: "G"}, count, true) + } else { + e.moveByMotion(motion{kind: "gg"}, 1, false) + } + return parseComplete, false + case 'e': + e.moveByMotion(motion{kind: "G"}, 1, false) + return parseComplete, false + case 'j', 'k': + e.moveByMotion(motion{kind: "g" + string(k.Rune)}, 1, false) + return parseComplete, false + case 'h': + e.moveByMotion(motion{kind: "0"}, 1, false) + return parseComplete, false + case 'l': + e.moveByMotion(motion{kind: "$"}, 1, false) + return parseComplete, false + case 's': + e.moveByMotion(motion{kind: "^"}, 1, false) + return parseComplete, false + case 't': + e.moveByMotion(motion{kind: "H"}, 1, false) + return parseComplete, false + case 'c': + e.moveByMotion(motion{kind: "M"}, 1, false) + return parseComplete, false + case 'b': + e.moveByMotion(motion{kind: "L"}, 1, false) + return parseComplete, false + case 'd': + if e.hooks.FollowLink != nil { + e.hooks.FollowLink() + } + return parseComplete, false + case 'y': + if e.hooks.CopyLink != nil { + e.hooks.CopyLink() + } + return parseComplete, false + case 'x': + if e.hooks.OpenURL != nil { + e.hooks.OpenURL() + } + return parseComplete, false + case 'v': + e.reselect() + return parseComplete, false + case '&': + e.beginChange() + e.repeatSubstitute(true) + return parseComplete, false + } + return parseInvalid, false +} + +// viewMode is Helix's view mode (z): where the cursor line sits on screen, +// plus the ZenNotes fold keys (folds moved off z c and z o, which Helix +// gives to centre view and the fold motion pair). +func (e *Editor) viewMode(rest []Key, n int) (parseStatus, bool) { + if len(rest) < 2 { + return parseIncomplete, false + } + k := rest[1] + switch { + case k.IsRune('z') || k.IsRune('c') || k.IsRune('.'): + e.scrollCursorTo(0.5) + if k.IsRune('.') { + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) + } + case k.IsRune('t') || k.Is("enter"): + e.scrollCursorTo(0) + if k.Is("enter") { + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) + } + case k.IsRune('b') || k.IsRune('-'): + e.scrollCursorTo(1) + if k.IsRune('-') { + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) + } + case k.IsRune('j'): + e.scrollLines(n) + case k.IsRune('k'): + e.scrollLines(-n) + case k.IsRune('f'): + e.foldAtCursor() + case k.IsRune('o'): + e.unfoldAtCursor() + case k.IsRune('a'): + e.toggleFoldAtCursor() + case k.IsRune('M'): + e.foldAll() + case k.IsRune('R'): + e.unfoldAll() + case k.IsRune('J'): + e.moveToFold(1, n) + case k.IsRune('K'): + e.moveToFold(-1, n) + default: + return parseInvalid, false + } + return parseComplete, false +} + +func (e *Editor) highlightOffOnEsc() {} + +// --- registers --- + +func (e *Editor) readRegister(r rune) (Register, bool) { + switch r { + case 0, '"': + if e.opts.YankToClipboard { + if text, ok := e.readClipboard(); ok { + reg := e.registers['"'] + if text != reg.Text { + return Register{Text: text, Linewise: strings.HasSuffix(text, "\n")}, true + } + } + } + reg, ok := e.registers['"'] + return reg, ok + case '+', '*': + if text, ok := e.readClipboard(); ok { + return Register{Text: text, Linewise: strings.HasSuffix(text, "\n") && strings.Count(text, "\n") >= 1}, true + } + reg, ok := e.registers['"'] + return reg, ok + case '_': + return Register{}, false + case '/': + return Register{Text: e.lastSearch}, e.lastSearch != "" + case ':': + return Register{Text: e.lastExLine}, e.lastExLine != "" + } + reg, ok := e.registers[unicode.ToLower(r)] + return reg, ok +} + +func (e *Editor) readClipboard() (string, bool) { + if e.hooks.ReadClipboard == nil { + return "", false + } + return e.hooks.ReadClipboard() +} + +// storeRegister files a yank or delete the way Vim does: the named +// register when one was given (uppercase appends), the unnamed register +// always, `0` for yanks, `1`..`9` shifting for multi-line deletes and `-` +// for small ones. +func (e *Editor) storeRegister(named rune, reg Register, isYank bool) { + if named == '_' { + return + } + if named != 0 && named != '"' { + switch { + case named == '+' || named == '*': + e.writeClipboard(reg.Text) + case named >= 'A' && named <= 'Z': + lower := unicode.ToLower(named) + prev := e.registers[lower] + if prev.Linewise || reg.Linewise { + text := prev.Text + if !strings.HasSuffix(text, "\n") && text != "" { + text += "\n" + } + text += reg.Text + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + e.registers[lower] = Register{Text: text, Linewise: true} + } else { + e.registers[lower] = Register{Text: prev.Text + reg.Text} + } + reg = e.registers[lower] + default: + e.registers[named] = reg + } + } else { + if isYank { + e.registers['0'] = reg + } else if reg.Linewise || strings.Contains(reg.Text, "\n") { + for i := '9'; i > '1'; i-- { + if prev, ok := e.registers[i-1]; ok { + e.registers[i] = prev + } + } + e.registers['1'] = reg + } else { + e.registers['-'] = reg + } + } + e.registers['"'] = reg + if e.opts.YankToClipboard && named != '+' && named != '*' { + e.writeClipboard(reg.Text) + } +} + +func (e *Editor) writeClipboard(text string) { + if e.hooks.WriteClipboard != nil { + e.hooks.WriteClipboard(text) + } +} + +// --- dot repeat and macros --- + +func (e *Editor) repeatLastChange(count int, hasCount bool) { + if len(e.last.keys) == 0 { + return + } + keys := e.last.keys + prefix := []Key{} + if e.last.register != 0 { + prefix = append(prefix, R('"'), R(e.last.register)) + } + c := e.last.count + if hasCount { + c = count + e.last.count = count + } + if c > 0 { + for _, r := range strconv.Itoa(c) { + prefix = append(prefix, R(r)) + } + } + e.replaying = true + e.pending = nil + for _, k := range append(prefix, keys...) { + e.HandleKey(k) + } + if e.mode == ModeInsert { + e.HandleKey(KeyEsc) + } + e.replaying = false +} + +func (e *Editor) stopMacro() { + keys := e.macroKeys + if len(keys) > 0 { + keys = keys[:len(keys)-1] + } + reg := e.macroReg + e.macroReg = 0 + e.macroKeys = nil + e.registers[reg] = Register{Text: KeysString(keys)} + e.setMsg("") +} + +func (e *Editor) runMacro(r rune, n int) { + if r == '@' { + r = e.lastMacro + } + if r == ':' { + for i := 0; i < n; i++ { + e.executeExLine(e.lastExLine) + } + return + } + reg, ok := e.readRegister(unicode.ToLower(r)) + if !ok || reg.Text == "" || e.macroDepth > 50 { + return + } + e.lastMacro = unicode.ToLower(r) + keys := ParseKeys(reg.Text) + e.macroDepth++ + e.pending = nil + for i := 0; i < n; i++ { + for _, k := range keys { + e.HandleKey(k) + } + } + e.macroDepth-- +} + +// --- cursor movement helpers --- + +// selectorMotion reports whether the motion selects the span it traverses. +// Helix's word, paragraph and find motions are selectors: `w` selects up to +// the next word start, so `wd` deletes a word and `fx` selects through x. +func selectorMotion(m motion) bool { + switch m.kind { + case "w", "W", "b", "B", "e", "E", "f", "F", "t", "T", "{", "}", "(", ")": + return true + } + return false +} + +// selectByMotion applies a selector motion: the anchor drops at the old +// cursor and the selection head lands on the motion target. +func (e *Editor) selectByMotion(m motion, count int, hasCount bool) { + from := e.cursor + e.lastMotion = m + e.lastMotionArgs.count, e.lastMotionArgs.hasCount = count, hasCount + res, ok := e.applyMotion(m, count, hasCount, e.cursor, false) + if !ok { + return + } + if res.jump && e.cursor != res.pos { + e.pushJump() + } + e.motionSelected = true + // Word-start motions leave out the word they land on: they stop on its + // first character without selecting it. A backward word motion is the + // exception: it selects the word it lands on, plus the space that + // follows it. Every other selector includes its target. A word motion + // that ran out of words stops on the last character of a word instead, + // and that character is part of the selection. + e.selExcl = !res.inclusive && !res.hasWordEnd && (m.kind == "w" || m.kind == "W" || m.kind == "b" || m.kind == "B") + switch { + case res.hasWordEnd: + // A backward word motion selects the word it lands on, so the + // selection ends there and not at the cursor. + e.anchor = e.buf.clampNormal(res.wordEnd) + case res.hasAnchor && e.mode != ModeSelect: + // The motion crossed to the next line: the selection starts + // there, like Helix. + e.anchor = res.anchor + case res.hasSelStart: + // The selection starts at the first character of the word under + // the cursor, so the word is covered whole. + e.anchor = e.buf.clampNormal(res.selStart) + default: + e.anchor = e.buf.clampNormal(from) + } + e.cursor = e.buf.clampNormal(res.pos) + e.desiredCol = -1 + e.openFoldAt(e.cursor.Line) + e.ensureCursorVisible() +} + +// selectMatch replaces the selection with the next search match (Helix's +// `n`/`N` select the match, not just its start). +func (e *Editor) selectMatch(m motion, count int, hasCount bool) { + e.lastMotion = m + e.lastMotionArgs.count, e.lastMotionArgs.hasCount = count, hasCount + res, ok := e.applyMotion(m, count, hasCount, e.cursor, false) + if !ok { + return + } + if res.jump { + e.pushJump() + } + e.motionSelected = true + // The selection covers the whole match; the head sits at the end for a + // forward search and at the start for a backward one. + if e.searchDir >= 0 { + e.anchor = e.buf.clampNormal(res.pos) + e.cursor = e.buf.clampNormal(Pos{res.matchEnd.Line, max(0, res.matchEnd.Col-1)}) + } else { + e.anchor = e.buf.clampNormal(Pos{res.matchEnd.Line, max(0, res.matchEnd.Col-1)}) + e.cursor = e.buf.clampNormal(res.pos) + } + e.desiredCol = -1 + e.openFoldAt(e.cursor.Line) + e.ensureCursorVisible() +} + +func (e *Editor) moveByMotion(m motion, count int, hasCount bool) { + from := e.cursor + e.lastMotion = m + e.lastMotionArgs.count, e.lastMotionArgs.hasCount = count, hasCount + res, ok := e.applyMotion(m, count, hasCount, e.cursor, false) + if !ok { + return + } + if res.jump && e.cursor != res.pos { + e.pushJump() + } + e.cursor = e.buf.clampNormal(res.pos) + if res.keepCol { + if e.desiredCol < 0 { + e.desiredCol = from.Col + } + } else if res.stickyEnd { + e.desiredCol = math.MaxInt32 + } else { + e.desiredCol = -1 + } + e.openFoldAt(e.cursor.Line) + e.ensureCursorVisible() +} + +// repeatLastMotion replays the last motion (Helix's Alt-.); in select mode +// it extends the selection instead of collapsing it. +func (e *Editor) repeatLastMotion(n int) { + if e.lastMotion.kind == "" { + return + } + count := e.lastMotionArgs.count + hasCount := e.lastMotionArgs.hasCount + if n > 1 { + count = n * max(1, count) + hasCount = true + } + if e.mode == ModeSelect { + if selectorMotion(e.lastMotion) { + e.selectByMotion(e.lastMotion, count, hasCount) + } else { + e.extendByMotion(e.lastMotion, count, hasCount) + } + return + } + if selectorMotion(e.lastMotion) { + e.selectByMotion(e.lastMotion, count, hasCount) + return + } + e.moveByMotion(e.lastMotion, count, hasCount) +} + +func (e *Editor) pushJump() { + e.marks['\''] = e.cursor + e.marks['`'] = e.cursor + if e.hooks.OnJump != nil { + e.hooks.OnJump(e.cursor) + } +} + +// --- scrolling --- + +func (e *Editor) lineRows(line int) int { + if e.hooks.LineRows != nil { + if n := e.hooks.LineRows(line); n > 0 { + return n + } + } + return 1 +} + +// visibleLines walks from scrollTop counting screen rows, skipping folded +// lines, and returns the last line that fits. +func (e *Editor) lastVisibleLine() int { + rows := 0 + last := e.scrollTop + for line := e.scrollTop; line < e.buf.LineCount(); line = e.nextVisibleLine(line) { + rows += e.lineRows(line) + if rows > e.rows { + break + } + last = line + } + return last +} + +func (e *Editor) nextVisibleLine(line int) int { + if end, ok := e.folds[line]; ok { + return end + 1 + } + return line + 1 +} + +func (e *Editor) prevVisibleLine(line int) int { + prev := line - 1 + if prev < 0 { + return -1 + } + if start, ok := e.foldContaining(prev); ok { + return start + } + return prev +} + +// EnsureCursorVisible scrolls so the cursor is on screen, honoring +// scrolloff. +func (e *Editor) EnsureCursorVisible() { e.ensureCursorVisible() } + +func (e *Editor) ensureCursorVisible() { + if e.buf.LineCount() == 0 { + return + } + if start, ok := e.foldContaining(e.cursor.Line); ok && start != e.cursor.Line { + e.cursor.Line = start + } + so := min(e.opts.ScrollOff, (e.rows-1)/2) + cur := e.cursor.Line + // Cursor above the top. + top := e.scrollTop + for i := 0; i < so; i++ { + top = e.prevVisibleLine(top) + if top < 0 { + top = 0 + break + } + } + if cur < e.scrollTop || (cur < top && e.scrollTop > 0) { + e.scrollTop = cur + for i := 0; i < so; i++ { + p := e.prevVisibleLine(e.scrollTop) + if p < 0 { + break + } + e.scrollTop = p + } + return + } + // Cursor below the bottom: scroll until cur plus scrolloff rows fits. + for { + rows := 0 + fits := false + line := e.scrollTop + extra := 0 + for line < e.buf.LineCount() { + rows += e.lineRows(line) + if rows > e.rows { + break + } + if line == cur { + fits = true + } + if line > cur { + extra++ + } + line = e.nextVisibleLine(line) + } + if fits && (extra >= so || line >= e.buf.LineCount()) { + return + } + next := e.nextVisibleLine(e.scrollTop) + if next >= e.buf.LineCount() || next > cur { + return + } + e.scrollTop = next + } +} + +func (e *Editor) scrollCursorTo(fraction float64) { + target := int(float64(e.rows-1) * fraction) + top := e.cursor.Line + rows := 0 + for top > 0 { + prev := e.prevVisibleLine(top) + if prev < 0 { + break + } + rows += e.lineRows(prev) + if rows > target { + break + } + top = prev + } + e.scrollTop = top +} + +func (e *Editor) scrollHalfPage(dir int, count int, hasCount bool) { + amount := max(1, e.rows/2) + if hasCount { + amount = count + } + for i := 0; i < amount; i++ { + if dir > 0 { + next := e.nextVisibleLine(e.scrollTop) + if next < e.buf.LineCount() { + e.scrollTop = next + } + nc := e.nextVisibleLine(e.cursor.Line) + if nc < e.buf.LineCount() { + e.cursor.Line = nc + } + } else { + if p := e.prevVisibleLine(e.scrollTop); p >= 0 { + e.scrollTop = p + } + if p := e.prevVisibleLine(e.cursor.Line); p >= 0 { + e.cursor.Line = p + } + } + } + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) + e.ensureCursorVisible() +} + +func (e *Editor) scrollPage(pages int) { + dir := 1 + if pages < 0 { + dir, pages = -1, -pages + } + for p := 0; p < pages; p++ { + for i := 0; i < max(1, e.rows-2); i++ { + if dir > 0 { + next := e.nextVisibleLine(e.scrollTop) + if next >= e.buf.LineCount() { + break + } + e.scrollTop = next + } else { + prev := e.prevVisibleLine(e.scrollTop) + if prev < 0 { + break + } + e.scrollTop = prev + } + } + } + if dir > 0 { + e.cursor.Line = max(e.cursor.Line, e.scrollTop) + } else { + e.cursor.Line = min(e.cursor.Line, e.lastVisibleLine()) + } + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) +} + +func (e *Editor) scrollLines(n int) { + for i := 0; i < abs(n); i++ { + if n > 0 { + next := e.nextVisibleLine(e.scrollTop) + if next >= e.buf.LineCount() { + break + } + e.scrollTop = next + } else { + prev := e.prevVisibleLine(e.scrollTop) + if prev < 0 { + break + } + e.scrollTop = prev + } + } + if e.cursor.Line < e.scrollTop { + e.cursor.Line = e.scrollTop + e.cursor = e.buf.clampNormal(e.cursor) + } + if last := e.lastVisibleLine(); e.cursor.Line > last { + e.cursor.Line = last + e.cursor = e.buf.clampNormal(e.cursor) + } +} + +func abs(n int) int { + if n < 0 { + return -n + } + return n +} + +// --- host ex --- + +func (e *Editor) hostEx(cmd ExCommand) bool { + if e.hooks.ExCommand == nil { + return false + } + handled, err := e.hooks.ExCommand(cmd) + if err != nil { + e.setError(err.Error()) + } + return handled +} + +// --- misc public helpers --- + +// WordUnderCursor is the keyword at or after the cursor on its line. +func (e *Editor) WordUnderCursor() string { + line := e.buf.Line(e.cursor.Line) + if len(line) == 0 { + return "" + } + col := min(e.cursor.Col, len(line)-1) + if !isWordRune(line[col]) { + for col < len(line) && !isWordRune(line[col]) { + col++ + } + if col >= len(line) { + return "" + } + } + start, end := col, col + for start > 0 && isWordRune(line[start-1]) { + start-- + } + for end < len(line) && isWordRune(line[end]) { + end++ + } + return string(line[start:end]) +} + +// InsertAtCursor inserts text as one undoable change and leaves the cursor +// after it. +func (e *Editor) InsertAtCursor(text string) { + e.beginChange() + p := e.cursor + if e.mode != ModeInsert && e.buf.LineLen(p.Line) > 0 && p.Col < e.buf.LineLen(p.Line) { + p.Col++ + } + end := e.buf.InsertText(p, text) + e.cursor = end + if e.mode != ModeInsert { + e.cursor = e.buf.clampNormal(e.cursor) + e.commitChange() + } +} + +// ReplaceAtCursor swaps the before runes left of the cursor and the after +// runes right of it for text and leaves the cursor behind the text: what +// accepting a completion does. In insert mode it joins the insert in +// progress, so one undo takes the typing and the completion together. +func (e *Editor) ReplaceAtCursor(before, after int, text string) { + e.beginChange() + n := e.buf.LineLen(e.cursor.Line) + start := Pos{e.cursor.Line, max(0, e.cursor.Col-before)} + end := Pos{e.cursor.Line, min(n, e.cursor.Col+after)} + e.buf.DeleteRange(start, end) + e.cursor = e.buf.InsertText(start, text) + if e.mode != ModeInsert { + e.cursor = e.buf.clampNormal(e.cursor) + e.commitChange() + } +} + +// ReplaceLineText swaps one line as a change. +func (e *Editor) ReplaceLineText(line int, text string) { + e.beginChange() + e.buf.ReplaceLine(line, []rune(text)) + e.cursor = e.buf.clampNormal(e.cursor) + if e.mode == ModeInsert { + e.cursor = e.buf.clampInsert(e.cursor) + } else { + e.commitChange() + } +} + +// ReplaceLines swaps a block of lines as a change. +func (e *Editor) ReplaceLines(from, to int, lines []string) { + e.beginChange() + e.buf.ReplaceLines(from, to, lines) + e.cursor = e.buf.clampNormal(e.cursor) + e.commitChange() +} + +// GotoLine moves to a 1-based line as a jump. +func (e *Editor) GotoLine(line int) { + e.pushJump() + e.cursor = e.buf.clampNormal(Pos{line - 1, 0}) + e.anchor = e.cursor + e.selExcl = false + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) + e.desiredCol = -1 + e.openFoldAt(e.cursor.Line) + e.ensureCursorVisible() +} + +// StatusSuffix is the text a status line shows after the mode: pending +// keys and the macro being recorded. +// ModeLabel is the status-bar name of the current mode. +func (e *Editor) ModeLabel() string { return e.mode.Label() } + +func (e *Editor) StatusSuffix() string { + parts := []string{} + if p := e.Pending(); p != "" { + parts = append(parts, p) + } + if e.macroReg != 0 { + parts = append(parts, fmt.Sprintf("recording @%c", e.macroReg)) + } + return strings.Join(parts, " ") +} + +// ResetPending drops an unfinished normal-mode command, for a host that +// takes a key sequence over from the engine. +func (e *Editor) ResetPending() { e.pending = nil } + +// PendingCount is the count typed so far when the pending keys are only +// digits: the host reads it before taking over a sequence like `3gt`. +func (e *Editor) PendingCount() (int, bool) { + if len(e.pending) == 0 { + return 0, false + } + n := 0 + for _, k := range e.pending { + if !k.Printable() || k.Rune < '0' || k.Rune > '9' { + return 0, false + } + n = n*10 + int(k.Rune-'0') + } + return n, true +} diff --git a/internal/helix/editor_test.go b/internal/helix/editor_test.go new file mode 100644 index 0000000..7a9932b --- /dev/null +++ b/internal/helix/editor_test.go @@ -0,0 +1,586 @@ +package helix + +import ( + "strings" + "testing" +) + +type keyCase struct { + name string + text string + cursor Pos + keys string + want string + wantC Pos + mode Mode + setup func(o *Options) +} + +func run(t *testing.T, cases []keyCase) { + t.Helper() + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + opts := DefaultOptions() + opts.TextReplacementsEnabled = false + if c.setup != nil { + c.setup(&opts) + } + e := New(c.text, opts, Hooks{}) + e.SetViewport(10) + e.SetCursor(c.cursor) + e.Feed(c.keys) + if got := e.Text(); got != c.want { + t.Fatalf("text:\n got %q\nwant %q", got, c.want) + } + if e.Cursor() != c.wantC { + t.Fatalf("cursor: got %+v want %+v", e.Cursor(), c.wantC) + } + if e.Mode() != c.mode { + t.Fatalf("mode: got %v want %v", e.Mode(), c.mode) + } + }) + } +} + +func TestMotions(t *testing.T) { + text := "one two three\nfour five\n\nsix (seven) eight\n" + run(t, []keyCase{ + {"l", text, Pos{0, 0}, "l", text, Pos{0, 1}, ModeNormal, nil}, + {"3l", text, Pos{0, 0}, "3l", text, Pos{0, 3}, ModeNormal, nil}, + {"l stops at end", text, Pos{0, 12}, "l", text, Pos{0, 12}, ModeNormal, nil}, + {"h", text, Pos{0, 3}, "h", text, Pos{0, 2}, ModeNormal, nil}, + {"j keeps column", text, Pos{0, 5}, "j", text, Pos{1, 5}, ModeNormal, nil}, + {"j clamps then restores", text, Pos{0, 10}, "jj", text, Pos{2, 0}, ModeNormal, nil}, + {"jjj restores desired col", text, Pos{0, 10}, "jjj", text, Pos{3, 10}, ModeNormal, nil}, + {"k", text, Pos{1, 2}, "k", text, Pos{0, 2}, ModeNormal, nil}, + {"w", text, Pos{0, 0}, "w", text, Pos{0, 4}, ModeNormal, nil}, + {"2w", text, Pos{0, 0}, "2w", text, Pos{0, 8}, ModeNormal, nil}, + {"w stops at the last word", text, Pos{0, 8}, "w", text, Pos{0, 12}, ModeNormal, nil}, + {"w does not cross to blank line", text, Pos{1, 5}, "w", text, Pos{1, 8}, ModeNormal, nil}, + {"w at the line end crosses to the next line", text, Pos{0, 12}, "w", text, Pos{1, 0}, ModeNormal, nil}, + {"W", "a.b c", Pos{0, 0}, "W", "a.b c", Pos{0, 4}, ModeNormal, nil}, + {"W stops at the last word", "a.b cd", Pos{0, 4}, "W", "a.b cd", Pos{0, 5}, ModeNormal, nil}, + {"w punctuation", "a.b c", Pos{0, 0}, "w", "a.b c", Pos{0, 1}, ModeNormal, nil}, + {"b", text, Pos{0, 8}, "b", text, Pos{0, 4}, ModeNormal, nil}, + {"b crosses lines", text, Pos{1, 0}, "b", text, Pos{0, 8}, ModeNormal, nil}, + {"e", text, Pos{0, 0}, "e", text, Pos{0, 2}, ModeNormal, nil}, + {"e from end of word", text, Pos{0, 2}, "e", text, Pos{0, 6}, ModeNormal, nil}, + {"0", text, Pos{0, 5}, "0", text, Pos{0, 0}, ModeNormal, nil}, + {"$", text, Pos{0, 0}, "$", text, Pos{0, 12}, ModeNormal, nil}, + {"$ then j sticks", text, Pos{0, 0}, "$j", text, Pos{1, 8}, ModeNormal, nil}, + {"^", " x y", Pos{0, 5}, "^", " x y", Pos{0, 3}, ModeNormal, nil}, + {"gg", text, Pos{3, 2}, "gg", text, Pos{0, 0}, ModeNormal, nil}, + {"ge last line", text, Pos{0, 0}, "ge", text, Pos{4, 0}, ModeNormal, nil}, + {"G", text, Pos{0, 0}, "G", text, Pos{4, 0}, ModeNormal, nil}, + {"2G", text, Pos{0, 0}, "2G", text, Pos{1, 0}, ModeNormal, nil}, + {"3gg", text, Pos{0, 0}, "3gg", text, Pos{2, 0}, ModeNormal, nil}, + {"g h line start", " a b", Pos{0, 4}, "gh", " a b", Pos{0, 0}, ModeNormal, nil}, + {"g l line end", " a b", Pos{0, 0}, "gl", " a b", Pos{0, 4}, ModeNormal, nil}, + {"g s first non-blank", " a b", Pos{0, 4}, "gs", " a b", Pos{0, 2}, ModeNormal, nil}, + {"f", text, Pos{0, 0}, "ft", text, Pos{0, 4}, ModeNormal, nil}, + {"2f", text, Pos{0, 0}, "2ft", text, Pos{0, 8}, ModeNormal, nil}, + {"t", text, Pos{0, 0}, "tt", text, Pos{0, 3}, ModeNormal, nil}, + {", repeats find backwards", text, Pos{0, 0}, "ft,,", text, Pos{0, 4}, ModeNormal, nil}, + {"f is document wide", "ab\ncd", Pos{0, 0}, "fd", "ab\ncd", Pos{1, 1}, ModeNormal, nil}, + {"F", text, Pos{0, 12}, "Fo", text, Pos{0, 6}, ModeNormal, nil}, + {"T", text, Pos{0, 12}, "To", text, Pos{0, 7}, ModeNormal, nil}, + {"f fail stays", text, Pos{0, 0}, "fz", text, Pos{0, 0}, ModeNormal, nil}, + {"m m bracket", text, Pos{3, 4}, "mm", text, Pos{3, 10}, ModeNormal, nil}, + {"m m back", text, Pos{3, 10}, "mm", text, Pos{3, 4}, ModeNormal, nil}, + {"m m from before", text, Pos{3, 0}, "mm", text, Pos{3, 10}, ModeNormal, nil}, + {"}", text, Pos{0, 0}, "}", text, Pos{2, 0}, ModeNormal, nil}, + {"{", text, Pos{3, 3}, "{", text, Pos{2, 0}, ModeNormal, nil}, + {"| column", text, Pos{0, 0}, "5|", text, Pos{0, 4}, ModeNormal, nil}, + {"enter first nonblank", "a\n b", Pos{0, 0}, "", "a\n b", Pos{1, 2}, ModeNormal, nil}, + {"- up", " a\nb", Pos{1, 0}, "-", " a\nb", Pos{0, 2}, ModeNormal, nil}, + {"]] heading", "x\n# A\ny\n## B\n", Pos{0, 0}, "]]", "x\n# A\ny\n## B\n", Pos{1, 0}, ModeNormal, nil}, + {"2]] heading", "x\n# A\ny\n## B\n", Pos{0, 0}, "2]]", "x\n# A\ny\n## B\n", Pos{3, 0}, ModeNormal, nil}, + {"[[ heading", "x\n# A\ny\n## B\n", Pos{3, 0}, "[[", "x\n# A\ny\n## B\n", Pos{1, 0}, ModeNormal, nil}, + {"( sentence", "One. Two. Three.", Pos{0, 12}, "(", "One. Two. Three.", Pos{0, 10}, ModeNormal, nil}, + {") sentence", "One. Two. Three.", Pos{0, 0}, ")", "One. Two. Three.", Pos{0, 5}, ModeNormal, nil}, + }) +} + +func TestSelectionOperations(t *testing.T) { + run(t, []keyCase{ + // A collapsed selection is one character: d deletes it, like vim x. + {"d deletes a char", "abc", Pos{0, 1}, "d", "ac", Pos{0, 1}, ModeNormal, nil}, + {"3d deletes three", "abcdef", Pos{0, 1}, "3d", "acdef", Pos{0, 1}, ModeNormal, nil}, + {"d at end clamps", "abc", Pos{0, 2}, "d", "ab", Pos{0, 1}, ModeNormal, nil}, + {"w d deletes a word", "one two three", Pos{0, 0}, "wd", "two three", Pos{0, 0}, ModeNormal, nil}, + {"e d deletes to word end", "one two", Pos{0, 0}, "ed", " two", Pos{0, 0}, ModeNormal, nil}, + {"w d twice", "one two three", Pos{0, 0}, "wdd", "wo three", Pos{0, 0}, ModeNormal, nil}, + // x selects the line; d then deletes it. + {"x d deletes the line", "a\nb\nc", Pos{1, 0}, "xd", "a\nc", Pos{1, 0}, ModeNormal, nil}, + {"x x d deletes two lines", "a\nb\nc\nd", Pos{1, 0}, "xxd", "a\nd", Pos{1, 0}, ModeNormal, nil}, + {"x d last line", "a\nb", Pos{1, 0}, "xd", "a", Pos{0, 0}, ModeNormal, nil}, + {"x d only line", "abc", Pos{0, 1}, "xd", "", Pos{0, 0}, ModeNormal, nil}, + // X extends to line bounds. + {"X d line bounds", "one two", Pos{0, 3}, "Xd", "", Pos{0, 0}, ModeNormal, nil}, + // Selecting with motions then operating. + {"v l l d", "abcdef", Pos{0, 1}, "vlld", "aef", Pos{0, 1}, ModeNormal, nil}, + {"v $ d", "abc\ndef", Pos{0, 1}, "v$d", "a\ndef", Pos{0, 0}, ModeNormal, nil}, + {"v j d", "a\nb\nc", Pos{0, 0}, "vjd", "\nc", Pos{0, 0}, ModeNormal, nil}, + {"V j d", "a\nb\nc", Pos{0, 0}, "Vjd", "c", Pos{0, 0}, ModeNormal, nil}, + {"v e c", "one two", Pos{0, 0}, "vecX", "X two", Pos{0, 0}, ModeNormal, nil}, + {"v l l y P", "abcdef", Pos{0, 1}, "vllyP", "abcdbcdef", Pos{0, 3}, ModeNormal, nil}, + {"v o swaps", "abcdef", Pos{0, 2}, "vlohd", "aef", Pos{0, 1}, ModeNormal, nil}, + {"v U uppercase", "ABc", Pos{0, 0}, "vl`", "abc", Pos{0, 0}, ModeNormal, nil}, + {"v ~ toggles", "Abc", Pos{0, 0}, "vl~", "aBc", Pos{0, 0}, ModeNormal, nil}, + {"v r", "abc", Pos{0, 0}, "vlrx", "xxc", Pos{0, 0}, ModeNormal, nil}, + {"V j J", "a\nb\nc", Pos{0, 0}, "VjJ", "a b\nc", Pos{0, 1}, ModeNormal, nil}, + {"V j >", "a\nb", Pos{0, 0}, "Vj>", " a\n b", Pos{0, 4}, ModeNormal, nil}, + {"v R replaces with yank", "one two", Pos{0, 0}, "miwyvR", "onene two", Pos{0, 2}, ModeSelect, nil}, + {"; collapses", "abcdef", Pos{0, 1}, "vll;d", "abcef", Pos{0, 3}, ModeNormal, nil}, + {"% selects all", "a\nb", Pos{0, 0}, "%d", "", Pos{0, 0}, ModeNormal, nil}, + {"gv reselects", "abcdef", Pos{0, 1}, "vl0gvd", "adef", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-v block delete", "abc\ndef\nghi", Pos{0, 1}, "jld", "a\nd\nghi", Pos{0, 0}, ModeNormal, nil}, + {"ctrl-v block I", "abc\ndef", Pos{0, 1}, "jIX", "aXbc\ndXef", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-v block A", "abc\ndef", Pos{0, 1}, "jAX", "abXc\ndeXf", Pos{0, 2}, ModeNormal, nil}, + {"ctrl-v $A", "ab\ndefg", Pos{0, 0}, "j$AX", "abX\ndefgX", Pos{0, 2}, ModeNormal, nil}, + {"ctrl-v block y p", "abc\ndef", Pos{0, 0}, "jly$p", "abcab\ndefde", Pos{0, 3}, ModeNormal, nil}, + {"esc leaves select", "abc", Pos{0, 0}, "vl", "abc", Pos{0, 1}, ModeNormal, nil}, + {"V then v narrows", "abc", Pos{0, 1}, "Vvd", "ac", Pos{0, 1}, ModeNormal, nil}, + {"v m i p", "a\nb\n\nc", Pos{0, 0}, "vmipd", "\nc", Pos{0, 0}, ModeNormal, nil}, + // Match mode text objects select, then d deletes. + {"m i w d", "one two three", Pos{0, 5}, "miwd", "one three", Pos{0, 4}, ModeNormal, nil}, + {"m a w d", "one two three", Pos{0, 5}, "mawd", "one three", Pos{0, 4}, ModeNormal, nil}, + {"m a w at end", "one two", Pos{0, 5}, "mawd", "one", Pos{0, 2}, ModeNormal, nil}, + {"m i ( d", "f(a, b) c", Pos{0, 3}, "mi(d", "f() c", Pos{0, 2}, ModeNormal, nil}, + {"m a ( d", "f(a, b) c", Pos{0, 3}, "ma(d", "f c", Pos{0, 1}, ModeNormal, nil}, + {"m i ( on bracket", "f(a, b) c", Pos{0, 1}, "mi(d", "f() c", Pos{0, 2}, ModeNormal, nil}, + {"m i [ nested", "[a [b] c]", Pos{0, 4}, "mi[d", "[a [] c]", Pos{0, 4}, ModeNormal, nil}, + {"2 m i [", "[a [b] c]", Pos{0, 4}, "2mi[d", "[a [] c]", Pos{0, 4}, ModeNormal, nil}, + {"m i { multiline", "{\n a\n b\n}", Pos{1, 1}, "mi{d", "{\n}", Pos{1, 0}, ModeNormal, nil}, + {"m i quote", `say "hello world" now`, Pos{0, 7}, `mi"d`, `say "" now`, Pos{0, 5}, ModeNormal, nil}, + {"m a quote", `say "hello world" now`, Pos{0, 7}, `ma"d`, `say now`, Pos{0, 4}, ModeNormal, nil}, + {"m i t", "bold x", Pos{0, 4}, "mitd", " x", Pos{0, 3}, ModeNormal, nil}, + {"m a t", "bold x", Pos{0, 4}, "matd", " x", Pos{0, 0}, ModeNormal, nil}, + {"m i p", "a\nb\n\nc", Pos{0, 0}, "mipd", "\nc", Pos{0, 0}, ModeNormal, nil}, + {"m a p", "a\nb\n\nc", Pos{0, 0}, "mapd", "c", Pos{0, 0}, ModeNormal, nil}, + {"m i w c", "one two three", Pos{0, 5}, "miwcX", "one X three", Pos{0, 4}, ModeNormal, nil}, + {"c changes selection", "one two", Pos{0, 0}, "wcX", "Xtwo", Pos{0, 0}, ModeNormal, nil}, + {"C changes to line end", "one two", Pos{0, 3}, "C!", "one!", Pos{0, 3}, ModeNormal, nil}, + {"r replaces the selection", "abc", Pos{0, 1}, "rx", "axc", Pos{0, 1}, ModeNormal, nil}, + {"3r replaces three", "abcd", Pos{0, 1}, "3rx", "axcd", Pos{0, 1}, ModeNormal, nil}, + {"3r too short", "abc", Pos{0, 1}, "5rx", "axc", Pos{0, 1}, ModeNormal, nil}, + {"r enter", "ab cd", Pos{0, 2}, "r", "ab cd", Pos{0, 2}, ModeNormal, nil}, + {"~ toggles the selection", "abc", Pos{0, 0}, "~", "Abc", Pos{0, 0}, ModeNormal, nil}, + {">> indents the line", "a\nb", Pos{0, 0}, ">>", " a\nb", Pos{0, 8}, ModeNormal, nil}, + {"2>> indents two lines", "a\nb", Pos{0, 0}, "2>>", " a\nb", Pos{0, 8}, ModeNormal, nil}, + {"<< outdents", " a", Pos{0, 4}, "<<", "a", Pos{0, 0}, ModeNormal, nil}, + {"J joins lines", "a\n b\nc", Pos{0, 0}, "J", "a b\nc", Pos{0, 1}, ModeNormal, nil}, + {"3J joins three", "a\nb\nc\nd", Pos{0, 0}, "3J", "a b\nc\nd", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-a", "x 41 y", Pos{0, 0}, "", "x 42 y", Pos{0, 3}, ModeNormal, nil}, + {"5 ctrl-x", "x 41 y", Pos{0, 0}, "5", "x 36 y", Pos{0, 3}, ModeNormal, nil}, + {"ctrl-a negative", "x -1 y", Pos{0, 0}, "2", "x 1 y", Pos{0, 2}, ModeNormal, nil}, + {"o list continues", "- item", Pos{0, 3}, "onext", "- item\n- next", Pos{1, 5}, ModeNormal, nil}, + {"o numbered continues", "1. item", Pos{0, 3}, "ox", "1. item\n2. x", Pos{1, 3}, ModeNormal, nil}, + {"o checkbox continues", "- [x] done", Pos{0, 3}, "ox", "- [x] done\n- [ ] x", Pos{1, 6}, ModeNormal, nil}, + {"O above", " abc", Pos{0, 3}, "Ox", " x\n abc", Pos{0, 2}, ModeNormal, nil}, + {"g q joins paragraph", "one\ntwo\n\nthree", Pos{0, 0}, "miqgq", "one\ntwo\n\nthree", Pos{0, 0}, ModeNormal, nil}, + }) +} + +func TestPutSelection(t *testing.T) { + run(t, []keyCase{ + {"y i w P", "one two", Pos{0, 0}, "miwyP", "oneone two", Pos{0, 2}, ModeNormal, nil}, + {"y i w p", "one two", Pos{0, 0}, "miwyp", "oonene two", Pos{0, 3}, ModeNormal, nil}, + {"x y p pastes below", "a\nb", Pos{1, 0}, "xyp", "a\nb\nb", Pos{2, 0}, ModeNormal, nil}, + {"x y P pastes above", "a\nb", Pos{1, 0}, "xyP", "a\nb\nb", Pos{1, 0}, ModeNormal, nil}, + {"x d p moves the line", "a\nb\nc", Pos{0, 0}, "xdp", "b\na\nc", Pos{1, 0}, ModeNormal, nil}, + {"named register", "one two", Pos{0, 0}, "x\"ayp", "one two\none two", Pos{1, 0}, ModeNormal, nil}, + {"append register", "one two", Pos{0, 0}, "x\"ay", "one two", Pos{0, 0}, ModeNormal, nil}, + {"0 register survives delete", "one two", Pos{0, 0}, "miwyxwd\"0P", "one", Pos{0, 2}, ModeNormal, nil}, + {"blackhole", "one two", Pos{0, 0}, "x\"_dp", "", Pos{0, 0}, ModeNormal, nil}, + {"R replaces with yank", "one two", Pos{0, 0}, "miwyR", "onene two", Pos{0, 2}, ModeNormal, nil}, + {"p multi-line charwise", "ab\ncd", Pos{0, 0}, "vjy$p", "abab\nc\ncd", Pos{0, 2}, ModeNormal, nil}, + }) +} + +func TestInsertMode(t *testing.T) { + run(t, []keyCase{ + {"i", "abc", Pos{0, 1}, "iX", "aXbc", Pos{0, 1}, ModeNormal, nil}, + {"a", "abc", Pos{0, 1}, "aX", "abXc", Pos{0, 2}, ModeNormal, nil}, + {"A", "abc", Pos{0, 0}, "AX", "abcX", Pos{0, 3}, ModeNormal, nil}, + {"I", " abc", Pos{0, 4}, "IX", " Xabc", Pos{0, 2}, ModeNormal, nil}, + {"esc at col 0 stays", "abc", Pos{0, 0}, "i", "abc", Pos{0, 0}, ModeNormal, nil}, + {"enter splits", "abcd", Pos{0, 2}, "i", "ab\ncd", Pos{1, 0}, ModeNormal, nil}, + {"enter autoindent", " ab", Pos{0, 3}, "ax", " ab\n x", Pos{1, 2}, ModeNormal, nil}, + {"enter continues list", "- ab", Pos{0, 3}, "ax", "- ab\n- x", Pos{1, 2}, ModeNormal, nil}, + {"enter on empty item ends list", "- ab\n- ", Pos{1, 1}, "ax", "- ab\nx", Pos{1, 0}, ModeNormal, nil}, + {"enter renumbers", "1. a\n2. b", Pos{0, 3}, "ac", "1. a\n2. c\n3. b", Pos{1, 3}, ModeNormal, nil}, + {"backspace", "abc", Pos{0, 2}, "i", "ac", Pos{0, 0}, ModeNormal, nil}, + {"backspace joins", "ab\ncd", Pos{1, 0}, "i", "abcd", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-w", "one two", Pos{0, 6}, "a", "one ", Pos{0, 3}, ModeNormal, nil}, + {"ctrl-u", "one two", Pos{0, 6}, "a", "", Pos{0, 0}, ModeNormal, nil}, + {"tab spaces", "ab", Pos{0, 1}, "i", "a b", Pos{0, 3}, ModeNormal, nil}, + {"autopair", "", Pos{0, 0}, "i(", "()", Pos{0, 0}, ModeNormal, nil}, + {"autopair skip", "", Pos{0, 0}, "i(a)", "(a)", Pos{0, 2}, ModeNormal, nil}, + {"autopair backspace", "", Pos{0, 0}, "i(", "", Pos{0, 0}, ModeNormal, nil}, + {"no autopair before word", "x", Pos{0, 0}, "i(", "(x", Pos{0, 0}, ModeNormal, nil}, + {"insert escape jk", "", Pos{0, 0}, "iajk", "a", Pos{0, 0}, ModeNormal, func(o *Options) { o.InsertEscape = "jk" }}, + {"insert escape j alone", "", Pos{0, 0}, "iaj", "aj", Pos{0, 1}, ModeNormal, func(o *Options) { o.InsertEscape = "jk" }}, + {"text replacement", "", Pos{0, 0}, "ia->b", "a→b", Pos{0, 2}, ModeNormal, func(o *Options) { o.TextReplacementsEnabled = true }}, + {"ctrl-o one command", "abc def", Pos{0, 0}, "i$X", "abc defX", Pos{0, 7}, ModeNormal, nil}, + {"ctrl-r register", "abc", Pos{0, 0}, "miwyA \"", "abc abc", Pos{0, 6}, ModeNormal, nil}, + {"ctrl-n completion", "hello help\nhe", Pos{1, 1}, "A", "hello help\nhello", Pos{1, 4}, ModeNormal, nil}, + {"ctrl-n twice", "hello help\nhe", Pos{1, 1}, "A", "hello help\nhelp", Pos{1, 3}, ModeNormal, nil}, + {"ctrl-t indents", "ab", Pos{0, 1}, "i", " ab", Pos{0, 4}, ModeNormal, nil}, + {"shift-tab outdents", " ab", Pos{0, 5}, "i", "ab", Pos{0, 0}, ModeNormal, nil}, + {"tab indents list item", "- ab", Pos{0, 2}, "i", " - ab", Pos{0, 5}, ModeNormal, nil}, + {"arrows in insert", "abc", Pos{0, 0}, "iX", "abXc", Pos{0, 2}, ModeNormal, nil}, + {"3ix", "", Pos{0, 0}, "3ix", "x", Pos{0, 0}, ModeNormal, nil}, + }) +} + +func TestSurround(t *testing.T) { + run(t, []keyCase{ + {"m s ) wraps the char", "abc", Pos{0, 1}, "ms)", "a(b)c", Pos{0, 1}, ModeNormal, nil}, + {"m s } wraps a line selection", "abc", Pos{0, 0}, "xms}", "{abc}", Pos{0, 0}, ModeNormal, nil}, + {"m s quote", "abc", Pos{0, 1}, `ms"`, `a"b"c`, Pos{0, 1}, ModeNormal, nil}, + {"m d ) deletes the pair", "a(b)c", Pos{0, 2}, "md)", "abc", Pos{0, 1}, ModeNormal, nil}, + {"m d quote", `a"b"c`, Pos{0, 2}, `md"`, "abc", Pos{0, 1}, ModeNormal, nil}, + {"m r ) ] replaces the pair", "a(b)c", Pos{0, 2}, "mr)]", "a[b]c", Pos{0, 1}, ModeNormal, nil}, + }) +} + +func TestUndoRedoDot(t *testing.T) { + run(t, []keyCase{ + {"u", "abc", Pos{0, 0}, "du", "abc", Pos{0, 0}, ModeNormal, nil}, + {"u insert session", "abc", Pos{0, 0}, "ixyzu", "abc", Pos{0, 0}, ModeNormal, nil}, + {"redo", "abc", Pos{0, 0}, "du", "bc", Pos{0, 0}, ModeNormal, nil}, + {"U redo", "abc", Pos{0, 0}, "duU", "bc", Pos{0, 0}, ModeNormal, nil}, + {"2u", "abcd", Pos{0, 0}, "dd2u", "abcd", Pos{0, 0}, ModeNormal, nil}, + {"dot d", "abcdef", Pos{0, 0}, "d.", "cdef", Pos{0, 0}, ModeNormal, nil}, + {"dot insert", "", Pos{0, 0}, "ix.", "xx", Pos{0, 0}, ModeNormal, nil}, + {"dot A", "a\nb", Pos{0, 0}, "A!j.", "a!\nb!", Pos{1, 1}, ModeNormal, nil}, + {"dot with count", "abcdef", Pos{0, 0}, "dd.", "def", Pos{0, 0}, ModeNormal, nil}, + {"dot o", "a", Pos{0, 0}, "ob.", "a\nb\nb", Pos{2, 0}, ModeNormal, nil}, + {"Q records, q replays", "a\nb\nc", Pos{0, 0}, "QA!jQjq", "a!\nb\nc!", Pos{2, 1}, ModeNormal, nil}, + {"macro count", "a\nb\nc\nd", Pos{0, 0}, "QA!jQ2q", "a!\nb!\nc!\nd", Pos{3, 0}, ModeNormal, nil}, + {"macro register prefix", "a\nb", Pos{0, 0}, "\"zQA!jQ\"zq", "a!\nb!", Pos{1, 1}, ModeNormal, nil}, + }) +} + +func TestSearchAndEx(t *testing.T) { + run(t, []keyCase{ + {"search", "one two\nthree two", Pos{0, 0}, "/two", "one two\nthree two", Pos{0, 4}, ModeNormal, nil}, + {"search n", "one two\nthree two", Pos{0, 0}, "/twon", "one two\nthree two", Pos{1, 8}, ModeNormal, nil}, + {"search wraps", "one two\nthree two", Pos{0, 0}, "/twonn", "one two\nthree two", Pos{0, 6}, ModeNormal, nil}, + {"search backward", "one two\nthree two", Pos{0, 0}, "?two", "one two\nthree two", Pos{1, 6}, ModeNormal, nil}, + {"search N", "one two\nthree two", Pos{0, 0}, "/twoN", "one two\nthree two", Pos{1, 8}, ModeNormal, nil}, + {"search esc restores", "one two", Pos{0, 0}, "/two", "one two", Pos{0, 0}, ModeNormal, nil}, + {"star selects the word then searches", "foo bar foo", Pos{0, 0}, "miw*", "foo bar foo", Pos{0, 2}, ModeSelect, nil}, + {"star searches the selection", "foo foobar foo", Pos{0, 0}, "miw*", "foo foobar foo", Pos{0, 2}, ModeSelect, nil}, + {"hash searches the word back", "foo bar foo", Pos{0, 8}, "#", "foo bar foo", Pos{0, 0}, ModeNormal, nil}, + {"search vim word boundary", "cat concat cat", Pos{0, 0}, "/\\", "cat concat cat", Pos{0, 11}, ModeNormal, nil}, + {":s", "one two one", Pos{0, 0}, ":s/one/1/", "1 two one", Pos{0, 0}, ModeNormal, nil}, + {":s g", "one two one", Pos{0, 0}, ":s/one/1/g", "1 two 1", Pos{0, 0}, ModeNormal, nil}, + {":%s", "a\nb\na", Pos{0, 0}, ":%s/a/x/", "x\nb\nx", Pos{2, 0}, ModeNormal, nil}, + {":s groups", "john smith", Pos{0, 0}, ":s/\\(\\w\\+\\) \\(\\w\\+\\)/\\2 \\1/", "smith john", Pos{0, 0}, ModeNormal, nil}, + {":s ampersand", "abc", Pos{0, 0}, ":s/b/[&]/", "a[b]c", Pos{0, 0}, ModeNormal, nil}, + {":s case", "Abc abc", Pos{0, 0}, ":s/abc/x/gi", "x x", Pos{0, 0}, ModeNormal, nil}, + {":s newline", "a,b", Pos{0, 0}, ":s/,/\\r/", "a\nb", Pos{0, 0}, ModeNormal, nil}, + {":g/d", "keep\ndrop\nkeep2\ndrop", Pos{0, 0}, ":g/drop/d", "keep\nkeep2", Pos{1, 0}, ModeNormal, nil}, + {":v/d", "keep\ndrop\nkeep2", Pos{0, 0}, ":v/keep/d", "keep\nkeep2", Pos{1, 0}, ModeNormal, nil}, + {":d range", "a\nb\nc\nd", Pos{0, 0}, ":2,3d", "a\nd", Pos{1, 0}, ModeNormal, nil}, + {":N goto", "a\nb\nc", Pos{0, 0}, ":3", "a\nb\nc", Pos{2, 0}, ModeNormal, nil}, + {":m 0", "a\nb\nc", Pos{2, 0}, ":m 0", "c\na\nb", Pos{0, 0}, ModeNormal, nil}, + {":m $", "a\nb\nc", Pos{0, 0}, ":m $", "b\nc\na", Pos{2, 0}, ModeNormal, nil}, + {":t", "a\nb", Pos{0, 0}, ":t.", "a\na\nb", Pos{1, 0}, ModeNormal, nil}, + {":sort", "c\na\nb", Pos{0, 0}, ":sort", "a\nb\nc", Pos{0, 0}, ModeNormal, nil}, + {":sort!", "c\na\nb", Pos{0, 0}, ":sort!", "c\nb\na", Pos{0, 0}, ModeNormal, nil}, + {":normal", "a\nb", Pos{0, 0}, ":%norm A;", "a;\nb;", Pos{1, 1}, ModeNormal, nil}, + {":>", "a", Pos{0, 0}, ":>", " a", Pos{0, 4}, ModeNormal, nil}, + {":j", "a\nb\nc", Pos{0, 0}, ":j", "a b\nc", Pos{0, 1}, ModeNormal, nil}, + {":'<,'>", "a\nb\nc", Pos{0, 0}, "Vj:d", "c", Pos{0, 0}, ModeNormal, nil}, + {"& repeat", "aa\naa", Pos{0, 0}, ":s/a/x/j&", "xa\nxa", Pos{1, 0}, ModeNormal, nil}, + {":pu", "a\nb", Pos{0, 0}, "xy:pu", "a\na\nb", Pos{1, 0}, ModeNormal, nil}, + {"ctrl-r on cmdline", "word", Pos{0, 0}, "miwy:s/\"/x/", "x", Pos{0, 0}, ModeNormal, nil}, + }) +} + +func TestCursorFollowsPaste(t *testing.T) { + // p on a collapsed selection pastes after the character under the + // cursor, the Helix behaviour. + e := New("ab", DefaultOptions(), Hooks{}) + e.Feed("xp") + if e.Text() != "ab" || e.Cursor() != (Pos{0, 1}) { + t.Fatalf("p after the cursor: %q %+v", e.Text(), e.Cursor()) + } +} + +func TestFolds(t *testing.T) { + text := "# A\na1\na2\n# B\nb1" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.Feed("jzf") + if end, ok := e.IsFoldStart(0); !ok || end != 2 { + t.Fatalf("expected fold 0..2, got %d %v", end, ok) + } + if e.Cursor().Line != 0 { + t.Fatalf("cursor should sit on fold start, got %d", e.Cursor().Line) + } + e.Feed("j") + if e.Cursor().Line != 3 { + t.Fatalf("j should skip the fold, got %d", e.Cursor().Line) + } + e.Feed("k") + if e.Cursor().Line != 0 { + t.Fatalf("k should land on fold start, got %d", e.Cursor().Line) + } + e.Feed("zo") + if _, ok := e.IsFoldStart(0); ok { + t.Fatal("zo should open") + } + e.Feed("zM") + if len(e.Folds()) != 2 { + t.Fatalf("zM should fold both sections, got %v", e.Folds()) + } + e.Feed("zR") + if len(e.Folds()) != 0 { + t.Fatal("zR should open all") + } +} + +func TestRegistersAndMarks(t *testing.T) { + e := New("hello world", DefaultOptions(), Hooks{}) + e.Feed("miwy") + if reg, ok := e.RegisterText('0'); !ok || reg.Text != "hello" { + t.Fatalf("yank register = %+v", reg) + } + e.Feed("xd") + if reg, _ := e.RegisterText('1'); !reg.Linewise || reg.Text != "hello world\n" { + t.Fatalf("delete register = %+v", reg) + } + e.Feed("u") + e.Feed("wd") + if reg, _ := e.RegisterText('-'); reg.Text != "d" && reg.Text != "hello " { + t.Fatalf("small delete register = %+v", reg) + } +} + +func TestPlainMode(t *testing.T) { + opts := DefaultOptions() + opts.HelixEnabled = false + e := New("ab", opts, Hooks{}) + if e.Mode() != ModeInsert { + t.Fatal("plain mode starts inserting") + } + e.Feed("x") + e.HandleKey(KeyRight) + e.Feed("y") + if e.Text() != "xayb" { + t.Fatalf("text = %q", e.Text()) + } + e.HandleKey(KeyEsc) + if e.Mode() != ModeInsert { + t.Fatal("esc must not leave insert in plain mode") + } + if e.HandleKey(Ctrl('p')) { + t.Fatal("unused keys should be reported unhandled") + } + e.HandleKey(Ctrl('z')) + if e.Text() != "xa" && e.Text() != "xab" && e.Text() != "ab" { + t.Fatalf("undo = %q", e.Text()) + } +} + +func TestToggleCheckbox(t *testing.T) { + e := New("plain\n- bullet\n- [ ] open\n- [x] done\n- [/] doing", DefaultOptions(), Hooks{}) + for i := 0; i < 5; i++ { + e.SetCursor(Pos{i, 0}) + e.ToggleCheckboxAtCursor() + } + want := "- [ ] plain\n- [ ] bullet\n- [x] open\n- [ ] done\n- [x] doing" + if e.Text() != want { + t.Fatalf("got %q", e.Text()) + } +} + +func TestScrolling(t *testing.T) { + lines := make([]string, 50) + for i := range lines { + lines[i] = "line" + } + e := New(strings.Join(lines, "\n"), DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.Feed("") + if e.Cursor().Line != 5 || e.ScrollTop() != 5 { + t.Fatalf("ctrl-d: cursor %d top %d", e.Cursor().Line, e.ScrollTop()) + } + e.Feed("G") + if e.ScrollTop() != 40 { + t.Fatalf("G scroll top = %d", e.ScrollTop()) + } + e.Feed("zt") + if e.ScrollTop() != 49 { + t.Fatalf("zt top = %d", e.ScrollTop()) + } + e.Feed("gg") + if e.ScrollTop() != 8 { + t.Fatalf("ctrl-f top = %d", e.ScrollTop()) + } + e.Feed("gg") + e.Feed("L") + if e.Cursor().Line != 9 { + t.Fatalf("L = %d", e.Cursor().Line) + } + e.Feed("M") + if e.Cursor().Line != 4 { + t.Fatalf("M = %d", e.Cursor().Line) + } + // Helix view mode: z c centres without moving the cursor. + e.Feed("ggzc") + if e.Cursor().Line != 0 { + t.Fatalf("zc moved the cursor to %d", e.Cursor().Line) + } +} + +func TestHostExCommand(t *testing.T) { + var got ExCommand + e := New("x", DefaultOptions(), Hooks{ExCommand: func(cmd ExCommand) (bool, error) { + got = cmd + return true, nil + }}) + e.Feed(":wq!") + if got.Name != "wq" || !got.Bang { + t.Fatalf("host got %+v", got) + } + e.Feed(":move inbox/Work") + if got.Name != "move" || got.Args != "inbox/Work" { + t.Fatalf("host got %+v", got) + } + e.Feed("ZZ") + if got.Name != "wq" { + t.Fatalf("ZZ got %+v", got) + } +} + +func TestSelectionKindAndRange(t *testing.T) { + e := New("abc\ndef", DefaultOptions(), Hooks{}) + if k := e.SelectionKind(); k != "v" { + t.Fatalf("collapsed selection kind = %q", k) + } + e.Feed("x") + if k := e.SelectionKind(); k != "V" { + t.Fatalf("x selection kind = %q", k) + } + start, end, mode, ok := e.VisualRange() + if !ok || mode != ModeSelect || start != (Pos{0, 0}) || end != (Pos{0, 2}) { + t.Fatalf("x range = %+v..%+v %v %v", start, end, mode, ok) + } + e.Feed("") + if k := e.SelectionKind(); k != "^V" { + t.Fatalf("ctrl-v selection kind = %q", k) + } +} + +func TestReplaceAtCursorJoinsTheInsert(t *testing.T) { + opts := DefaultOptions() + opts.TextReplacementsEnabled = false + e := New("see end", opts, Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{0, 3}) + e.Feed("a#pro") + e.ReplaceAtCursor(4, 0, "#project") + if got, cur := e.Text(), e.Cursor(); got != "see #project end" || cur != (Pos{0, 12}) || e.Mode() != ModeInsert { + t.Fatalf("after the completion: %q %+v %v", got, cur, e.Mode()) + } + // The runes right of the cursor go too, clamped to the line. + e.ReplaceAtCursor(0, 99, "!") + if got := e.Text(); got != "see #project!" { + t.Fatalf("replacing to the end: %q", got) + } + // One undo takes the typing and both replacements. + e.Feed("u") + if got := e.Text(); got != "see end" { + t.Fatalf("after undo: %q", got) + } +} + +func TestSelectorMotions(t *testing.T) { + // Helix motions are selection-first: word, find and search motions + // replace the selection with the span they traverse, so actions work on + // the selection ("wd" deletes a word). Plain character and line moves + // collapse the selection onto their target. + run(t, []keyCase{ + {"w d deletes a word", "one two three", Pos{0, 0}, "wd", "two three", Pos{0, 0}, ModeNormal, nil}, + {"w d deletes the whole word from the middle", "one two three", Pos{0, 1}, "wd", "two three", Pos{0, 0}, ModeNormal, nil}, + {"2w d", "one two three", Pos{0, 0}, "2wd", "three", Pos{0, 0}, ModeNormal, nil}, + {"w d w continues", "one two three", Pos{0, 0}, "wdw", "two three", Pos{0, 4}, ModeNormal, nil}, + {"b d deletes the word and its space", "one two", Pos{0, 4}, "bd", "two", Pos{0, 0}, ModeNormal, nil}, + {"e d deletes the word", "one two", Pos{0, 0}, "ed", " two", Pos{0, 0}, ModeNormal, nil}, + {"f d selects through the char", "banana", Pos{0, 0}, "fad", "nana", Pos{0, 0}, ModeNormal, nil}, + {"; collapses then d deletes one", "hello world", Pos{0, 0}, "w;d", "hello orld", Pos{0, 6}, ModeNormal, nil}, + {"l still moves one char", "abcdef", Pos{0, 0}, "l", "abcdef", Pos{0, 1}, ModeNormal, nil}, + {"w then j collapses", "a\nb", Pos{0, 0}, "wj", "a\nb", Pos{1, 0}, ModeNormal, nil}, + {"i inserts before the selection", "hello world", Pos{0, 0}, "wiX", "Xhello world", Pos{0, 0}, ModeNormal, nil}, + {"a appends after the selection", "hello world", Pos{0, 0}, "waX", "hello wXorld", Pos{0, 7}, ModeNormal, nil}, + {"v keeps a selector selection", "hello world", Pos{0, 0}, "wvd", "orld", Pos{0, 0}, ModeNormal, nil}, + }) + + // `w` leaves a spanning selection that renders even in normal mode. + e := New("hello world foo", DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{0, 0}) + e.Feed("w") + start, end, mode, ok := e.VisualRange() + if !ok || mode != ModeNormal || start != (Pos{0, 0}) || end != (Pos{0, 5}) { + t.Fatalf("w selection = %v..%v ok=%t mode=%v", start, end, ok, mode) + } + // repeated w re-anchors from the head: one hop at a time + e.Feed("w") + start, end, _, _ = e.VisualRange() + if start != (Pos{0, 6}) || end != (Pos{0, 11}) { + t.Fatalf("second w selection = %v..%v", start, end) + } + // `;` collapses onto the cursor and clears the highlight + e.Feed(";") + if _, _, _, ok := e.VisualRange(); ok { + t.Fatal("; left a selection behind") + } + // `n` selects the whole match + e2 := New("cat dog cat", DefaultOptions(), Hooks{}) + e2.SetViewport(20) + e2.Feed("/catn") + start, end, _, ok = e2.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 2}) || e2.Cursor() != (Pos{0, 2}) { + t.Fatalf("n selection = %v..%v ok=%t cursor=%+v", start, end, ok, e2.Cursor()) + } +} + +func TestInsertModeNeverShowsASelection(t *testing.T) { + // Typing must not grow a selection: the anchor stays where insert began + // while the cursor advances, and insert mode renders no highlight. + e := New("one two", DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{0, 0}) + e.Feed("ihello") + _, _, _, ok := e.VisualRange() + if ok { + t.Fatal("insert mode rendered a selection") + } + sp := e.selectionSpan() + if sp.start != e.cursor { + t.Fatalf("insert span started at %+v, want the cursor %+v", sp.start, e.cursor) + } + // Esc collapses cleanly; the typed text stays. + e.Feed("") + if got := e.Text(); got != "helloone two" || e.Cursor() != (Pos{0, 4}) { + t.Fatalf("after insert: %q %+v", got, e.Cursor()) + } + if _, _, _, ok := e.VisualRange(); ok { + t.Fatal("esc left a selection behind") + } + // The same through a selector motion: wiX inserts after the hop. + e2 := New("hello world", DefaultOptions(), Hooks{}) + e2.SetViewport(10) + e2.SetCursor(Pos{0, 0}) + e2.Feed("wi") + _, _, _, ok = e2.VisualRange() + if ok { + t.Fatal("insert after a selector motion rendered a selection") + } +} diff --git a/internal/helix/ex.go b/internal/helix/ex.go new file mode 100644 index 0000000..c1bfdaf --- /dev/null +++ b/internal/helix/ex.go @@ -0,0 +1,965 @@ +package helix + +import ( + "fmt" + "sort" + "strconv" + "strings" + "unicode" +) + +// Cmdline is the command line's current state for rendering. +func (e *Editor) Cmdline() (kind rune, text string, cursor int, active bool) { + if e.mode != ModeCmdline { + return 0, "", 0, false + } + return e.cmd.kind, string(e.cmd.text), e.cmd.pos, true +} + +// OpenCmdline starts an ex or search line from the host. +func (e *Editor) OpenCmdline(kind rune, initial string) { + e.openCmdline(kind, 0, false) + e.cmd.text = []rune(initial) + e.cmd.pos = len(e.cmd.text) +} + +func (e *Editor) openCmdline(kind rune, count int, hasCount bool) { + if e.inSelect() { + e.exitSelect() + } + e.cmd = cmdlineState{kind: kind, histIdx: -1, searchOrg: e.cursor} + if kind == ':' && hasCount { + if count <= 1 { + e.cmd.text = []rune(".") + } else { + e.cmd.text = []rune(".,.+" + strconv.Itoa(count-1)) + } + e.cmd.pos = len(e.cmd.text) + } + if kind != ':' && hasCount { + e.cmd.original = []rune(strconv.Itoa(count)) + } + e.mode = ModeCmdline +} + +func (e *Editor) history(kind rune) *[]string { + if e.histories == nil { + e.histories = map[rune]*[]string{} + } + key := kind + if kind == '?' { + key = '/' + } + if e.histories[key] == nil { + h := []string{} + e.histories[key] = &h + } + return e.histories[key] +} + +func (e *Editor) cmdlineKey(k Key) { + c := &e.cmd + switch { + case k.Is("esc") || k.IsCtrl('c') || k.IsCtrl('['): + e.cancelCmdline() + return + case k.Is("enter") || k.IsCtrl('j') || k.IsCtrl('m'): + e.submitCmdline() + return + case k.Is("backspace") || k.IsCtrl('h'): + if len(c.text) == 0 { + e.cancelCmdline() + return + } + if c.pos > 0 { + c.text = append(c.text[:c.pos-1], c.text[c.pos:]...) + c.pos-- + } + case k.Is("delete"): + if c.pos < len(c.text) { + c.text = append(c.text[:c.pos], c.text[c.pos+1:]...) + } + case k.Is("left"): + if c.pos > 0 { + c.pos-- + } + case k.Is("right"): + if c.pos < len(c.text) { + c.pos++ + } + case k.Is("home") || k.IsCtrl('b'): + c.pos = 0 + case k.Is("end") || k.IsCtrl('e'): + c.pos = len(c.text) + case k.IsCtrl('u'): + c.text = c.text[c.pos:] + c.pos = 0 + case k.IsCtrl('w'): + i := c.pos + for i > 0 && isBlank(c.text[i-1]) { + i-- + } + for i > 0 && !isBlank(c.text[i-1]) { + i-- + } + c.text = append(c.text[:i], c.text[c.pos:]...) + c.pos = i + case k.Is("up") || k.IsCtrl('p'): + e.cmdlineHistory(-1) + case k.Is("down") || k.IsCtrl('n'): + e.cmdlineHistory(1) + case k.Is("tab"): + if c.kind == ':' { + e.cmdlineComplete(k.Shift) + return + } + case k.IsCtrl('r'): + c.pendingRegister = true + return + case k.Printable(): + if c.pendingRegister { + c.pendingRegister = false + if k.Rune == 'w' { + c.insertText(e.WordUnderCursor()) + } else if reg, ok := e.readRegister(k.Rune); ok { + c.insertText(strings.TrimSuffix(reg.Text, "\n")) + } + break + } + c.insertText(string(k.Rune)) + default: + return + } + c.completions = nil + if c.kind != ':' { + e.incrementalSearch() + } +} + +func (c *cmdlineState) insertText(s string) { + r := []rune(s) + next := make([]rune, 0, len(c.text)+len(r)) + next = append(next, c.text[:c.pos]...) + next = append(next, r...) + next = append(next, c.text[c.pos:]...) + c.text = next + c.pos += len(r) +} + +func (e *Editor) cmdlineHistory(dir int) { + h := *e.history(e.cmd.kind) + if len(h) == 0 { + return + } + if e.cmd.histIdx < 0 { + e.cmd.saved = string(e.cmd.text) + e.cmd.histIdx = len(h) + } + e.cmd.histIdx += dir + if e.cmd.histIdx < 0 { + e.cmd.histIdx = 0 + } + if e.cmd.histIdx >= len(h) { + e.cmd.histIdx = -1 + e.cmd.text = []rune(e.cmd.saved) + e.cmd.pos = len(e.cmd.text) + return + } + e.cmd.text = []rune(h[e.cmd.histIdx]) + e.cmd.pos = len(e.cmd.text) +} + +var builtinExNames = []string{ + "substitute", "s", "global", "g", "vglobal", "v", "delete", "d", "yank", "y", "move", "m", "copy", "co", "t", + "join", "j", "normal", "norm", "sort", "nohlsearch", "noh", "set", "undo", "redo", "registers", "reg", "display", + "marks", "put", "pu", "retab", "left", "right", "center", +} + +// cmdlineComplete cycles command names matching the typed prefix. +func (e *Editor) cmdlineComplete(backward bool) { + c := &e.cmd + if c.completions == nil { + text := string(c.text[:c.pos]) + word := text + if i := strings.LastIndexAny(text, " "); i >= 0 { + word = text[i+1:] + } + base := text[:len(text)-len(word)] + var names []string + hostOwned := false + if e.hooks.ExComplete != nil { + if keep, cands := e.hooks.ExComplete(text); cands != nil && strings.HasPrefix(text, keep) { + base, word = keep, text[len(keep):] + names = cands + hostOwned = true + } + } + if !hostOwned || base == "" { + names = append(append([]string(nil), builtinExNames...), names...) + } + c.compPrefix = word + c.compBase = base + seen := map[string]bool{} + matches := []string{} + lowerWord := strings.ToLower(word) + for _, n := range names { + if strings.HasPrefix(strings.ToLower(n), lowerWord) && !seen[n] { + seen[n] = true + matches = append(matches, n) + } + } + if !hostOwned { + sort.Strings(matches) + } + if len(matches) == 0 { + return + } + c.completions = append(matches, word) + c.compIdx = -1 + } + if backward { + c.compIdx-- + if c.compIdx < 0 { + c.compIdx = len(c.completions) - 1 + } + } else { + c.compIdx = (c.compIdx + 1) % len(c.completions) + } + tail := string(c.text[c.pos:]) + c.text = []rune(c.compBase + c.completions[c.compIdx] + tail) + c.pos = len([]rune(c.compBase + c.completions[c.compIdx])) +} + +// Completions lists the current completion candidates (for a wildmenu). +func (e *Editor) Completions() ([]string, int) { + if e.mode != ModeCmdline || len(e.cmd.completions) == 0 { + return nil, -1 + } + return e.cmd.completions[:len(e.cmd.completions)-1], e.cmd.compIdx +} + +func (e *Editor) incrementalSearch() { + pattern := string(e.cmd.text) + if pattern == "" { + e.cursor = e.buf.clampNormal(e.cmd.searchOrg) + e.incremental = nil + return + } + re, err := e.compilePattern(pattern) + if err != nil { + return + } + dir := 1 + if e.cmd.kind == '?' { + dir = -1 + } + p, _, _, ok := e.findMatch(re, e.cmd.searchOrg, dir) + if !ok { + e.cursor = e.buf.clampNormal(e.cmd.searchOrg) + e.incremental = nil + return + } + e.cursor = e.buf.clampNormal(p) + e.incremental = re + e.ensureCursorVisible() +} + +// IncrementalHighlights lists spans of the pattern being typed on a line. +func (e *Editor) IncrementalHighlights(line int) [][2]int { + if e.mode != ModeCmdline || e.incremental == nil { + return nil + } + return runeMatches(e.incremental, e.buf.LineString(line)) +} + +func (e *Editor) cancelCmdline() { + if e.cmd.kind != ':' { + e.cursor = e.buf.clampNormal(e.cmd.searchOrg) + } + e.incremental = nil + e.mode = ModeNormal + e.cmd = cmdlineState{} + e.ensureCursorVisible() +} + +func (e *Editor) submitCmdline() { + kind := e.cmd.kind + text := string(e.cmd.text) + count := 1 + if s := string(e.cmd.original); s != "" { + count, _ = strconv.Atoi(s) + } + e.incremental = nil + e.mode = ModeNormal + if strings.TrimSpace(text) != "" { + h := e.history(kind) + *h = append(*h, text) + if len(*h) > 100 { + *h = (*h)[len(*h)-100:] + } + } + switch kind { + case '/', '?': + dir := 1 + if kind == '?' { + dir = -1 + } + e.runSearch(text, dir, count) + default: + if text != "" { + e.lastExLine = text + } + e.executeExLine(text) + } + e.cmd = cmdlineState{} + e.cursor = e.buf.clampNormal(e.cursor) + if e.mode == ModeNormal { + e.ensureCursorVisible() + } +} + +// --- ex parsing --- + +// parseRange reads an address range off the front of a command line. +func (e *Editor) parseRange(line string) (Range, string, error) { + rest := strings.TrimLeft(line, " \t:") + rng := Range{} + if strings.HasPrefix(rest, "%") { + rng = Range{Start: 0, End: e.buf.LineCount() - 1, Given: true} + rest = rest[1:] + return rng, strings.TrimLeft(rest, " "), nil + } + addrs := []int{} + for { + addr, next, ok, err := e.parseAddress(rest) + if err != nil { + return Range{}, "", err + } + if !ok { + break + } + addrs = append(addrs, addr) + rest = next + if strings.HasPrefix(rest, ",") || strings.HasPrefix(rest, ";") { + if rest[0] == ';' { + e.cursor = e.buf.clampNormal(Pos{addr, 0}) + } + rest = rest[1:] + continue + } + break + } + switch len(addrs) { + case 0: + case 1: + rng = Range{Start: addrs[0], End: addrs[0], Given: true} + default: + rng = Range{Start: addrs[len(addrs)-2], End: addrs[len(addrs)-1], Given: true} + } + if rng.Given { + if rng.Start > rng.End { + rng.Start, rng.End = rng.End, rng.Start + } + rng.Start = min(max(0, rng.Start), e.buf.LineCount()-1) + rng.End = min(max(0, rng.End), e.buf.LineCount()-1) + } + return rng, strings.TrimLeft(rest, " "), nil +} + +// parseAddress reads one address: N, ., $, 'x, /pat/, ?pat?, with +N/-N +// offsets; ok is false when there is none. +func (e *Editor) parseAddress(s string) (int, string, bool, error) { + if s == "" { + return 0, s, false, nil + } + base := -1 + rest := s + switch { + case s[0] >= '0' && s[0] <= '9': + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + n, _ := strconv.Atoi(s[:i]) + base = n - 1 + rest = s[i:] + case s[0] == '.': + base = e.cursor.Line + rest = s[1:] + case s[0] == '$': + base = e.buf.LineCount() - 1 + rest = s[1:] + case s[0] == '\'' && len(s) > 1: + p, ok := e.markPos(rune(s[1])) + if !ok { + return 0, s, false, fmt.Errorf("E20: Mark not set") + } + base = p.Line + rest = s[2:] + case s[0] == '/' || s[0] == '?': + delim := s[0] + end := strings.IndexByte(s[1:], delim) + var pattern string + if end < 0 { + pattern = s[1:] + rest = "" + } else { + pattern = s[1 : 1+end] + rest = s[2+end:] + } + if pattern == "" { + pattern = e.lastSearch + } + re, err := e.compilePattern(pattern) + if err != nil { + return 0, s, false, fmt.Errorf("E486: Pattern not found: %s", pattern) + } + dir := 1 + if delim == '?' { + dir = -1 + } + from := Pos{e.cursor.Line, 0} + if dir > 0 { + from = Pos{e.cursor.Line, e.buf.LineLen(e.cursor.Line)} + } + p, _, _, ok := e.findMatch(re, from, dir) + if !ok { + return 0, s, false, fmt.Errorf("E486: Pattern not found: %s", pattern) + } + base = p.Line + case s[0] == '+' || s[0] == '-': + base = e.cursor.Line + default: + return 0, s, false, nil + } + for len(rest) > 0 && (rest[0] == '+' || rest[0] == '-') { + sign := 1 + if rest[0] == '-' { + sign = -1 + } + rest = rest[1:] + i := 0 + for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' { + i++ + } + n := 1 + if i > 0 { + n, _ = strconv.Atoi(rest[:i]) + rest = rest[i:] + } + base += sign * n + } + return base, rest, true, nil +} + +// executeExLine runs one command line, ranges included. +func (e *Editor) executeExLine(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + defer func() { + if e.mode == ModeNormal { + e.anchor = e.cursor + e.selExcl = false + } + }() + rng, rest, err := e.parseRange(line) + if err != nil { + e.setError(err.Error()) + return + } + if rest == "" { + if rng.Given { + e.GotoLine(rng.End + 1) + } + return + } + // Command name: letters, or a single symbol like & < > = !. + name := "" + i := 0 + for i < len(rest) && (unicode.IsLetter(rune(rest[i])) || rest[i] == '_') { + i++ + } + if i == 0 { + name = rest[:1] + i = 1 + } else { + name = rest[:i] + } + bang := false + if i < len(rest) && rest[i] == '!' { + bang = true + i++ + } + args := strings.TrimSpace(rest[i:]) + cmd := ExCommand{Name: name, Args: args, Bang: bang, Range: rng, Raw: line} + if e.builtinEx(cmd) { + return + } + if e.hostEx(cmd) { + return + } + e.setError("E492: Not an editor command: " + line) +} + +func (e *Editor) rangeOrCurrent(r Range) (int, int) { + if r.Given { + return r.Start, r.End + } + return e.cursor.Line, e.cursor.Line +} + +// builtinEx runs the commands the buffer owns. +func (e *Editor) builtinEx(cmd ExCommand) bool { + start, end := e.rangeOrCurrent(cmd.Range) + switch cmd.Name { + case "s", "substitute", "&", "~": + e.beginChange() + defer e.commitChange() + if cmd.Name == "&" || cmd.Name == "~" { + if e.lastSubst == nil { + e.setError("E35: No previous regular expression") + return true + } + flags := cmd.Args + if _, err := e.substitute(start, end, e.lastSubst.pattern, e.lastSubst.repl, flags); err != nil { + e.setError(err.Error()) + } + return true + } + pattern, repl, flags, ok := parseSubstitute(cmd.Args) + if !ok { + if e.lastSubst == nil { + e.setError("E35: No previous regular expression") + return true + } + pattern, repl, flags = e.lastSubst.pattern, e.lastSubst.repl, strings.TrimSpace(cmd.Args) + } + if strings.Contains(flags, "&") && e.lastSubst != nil { + flags = strings.ReplaceAll(flags, "&", "") + e.lastSubst.flags + } + if _, err := e.substitute(start, end, pattern, repl, flags); err != nil { + e.setError(err.Error()) + } + return true + case "g", "global", "v", "vglobal": + e.beginChange() + defer e.commitChange() + e.globalCommand(cmd, cmd.Name == "v" || cmd.Name == "vglobal" || (cmd.Bang && (cmd.Name == "g" || cmd.Name == "global"))) + return true + case "d", "delete": + e.beginChange() + defer e.commitChange() + reg, count := parseRegisterAndCount(cmd.Args) + if count > 0 { + start = end + end = min(e.buf.LineCount()-1, start+count-1) + } + e.deleteSpan(span{start: Pos{start, 0}, end: Pos{end, 0}, linewise: true}, reg) + return true + case "y", "yank": + reg, count := parseRegisterAndCount(cmd.Args) + if count > 0 { + start = end + end = min(e.buf.LineCount()-1, start+count-1) + } + e.yankLines(start, end, reg) + return true + case "m", "move": + if !looksLikeAddress(cmd.Args) { + return false + } + e.beginChange() + defer e.commitChange() + target, _, ok, err := e.parseAddress(cmd.Args) + if err != nil || !ok { + if cmd.Args == "0" { + target, ok = -1, true + } else { + e.setError("E14: Invalid address") + return true + } + } + e.moveLines(start, end, target) + return true + case "t", "co", "copy": + e.beginChange() + defer e.commitChange() + target, _, ok, err := e.parseAddress(cmd.Args) + if err != nil || (!ok && cmd.Args != "0") { + e.setError("E14: Invalid address") + return true + } + if cmd.Args == "0" { + target = -1 + } + lines := e.buf.Lines()[start : end+1] + e.buf.InsertLines(target+1, lines) + e.cursor = Pos{target + len(lines), firstNonBlank(e.buf.Line(target + len(lines)))} + return true + case ">", "<": + e.beginChange() + defer e.commitChange() + times := 1 + strings.Count(cmd.Args, cmd.Name) + e.shiftLines(start, end, cmd.Name == ">", times) + e.cursor = Pos{end, firstNonBlank(e.buf.Line(end))} + return true + case "j", "join": + e.beginChange() + defer e.commitChange() + count := end - start + 1 + if n, err := strconv.Atoi(strings.TrimSpace(cmd.Args)); err == nil { + count = n + } + if !cmd.Range.Given || count < 2 { + count = max(count, 2) + } + e.joinLines(start, count, !cmd.Bang) + return true + case "norm", "normal": + e.beginChange() + defer e.commitChange() + keys := ParseKeys(cmd.Args) + for l := start; l <= end; l++ { + e.cursor = e.buf.clampNormal(Pos{l, 0}) + e.pending = nil + for _, k := range keys { + e.HandleKey(k) + } + if e.mode == ModeInsert { + e.HandleKey(KeyEsc) + } + if e.inSelect() { + e.exitSelect() + } + if e.mode == ModeCmdline { + e.cancelCmdline() + } + } + return true + case "sort": + e.beginChange() + defer e.commitChange() + if !cmd.Range.Given { + start, end = 0, e.buf.LineCount()-1 + } + e.sortLines(start, end, cmd.Bang, cmd.Args) + return true + case "noh", "nohlsearch": + e.highlight = false + return true + case "set", "se": + e.setOption(cmd.Args) + return true + case "undo", "u": + e.Undo() + return true + case "redo", "red": + e.Redo() + return true + case "reg", "registers", "di", "display": + e.setMsg(e.registerListing()) + return true + case "marks": + e.setMsg(e.markListing()) + return true + case "pu", "put": + e.beginChange() + defer e.commitChange() + reg := rune(0) + if a := strings.TrimSpace(cmd.Args); a != "" { + reg = []rune(a)[0] + } + r, ok := e.readRegister(reg) + if !ok { + e.setError("E353: Nothing in register") + return true + } + lines := strings.Split(strings.TrimSuffix(r.Text, "\n"), "\n") + at := end + 1 + if cmd.Bang { + at = start + } + e.buf.InsertLines(at, lines) + e.cursor = Pos{at + len(lines) - 1, 0} + return true + case "=": + e.setMsg(strconv.Itoa(end + 1)) + return true + case "k", "mark", "ma": + // A bare :mark may be owned by the host (for note selection). Vim's + // :mark {letter} keeps its standard line-mark behavior. + if cmd.Name == "mark" && strings.TrimSpace(cmd.Args) == "" { + return false + } + if a := strings.TrimSpace(cmd.Args); a != "" { + e.marks[[]rune(a)[0]] = Pos{end, 0} + } + return true + case "retab": + e.beginChange() + defer e.commitChange() + for l := 0; l < e.buf.LineCount(); l++ { + s := e.buf.LineString(l) + if strings.Contains(s, "\t") { + e.buf.ReplaceLine(l, []rune(strings.ReplaceAll(s, "\t", strings.Repeat(" ", e.opts.TabSize)))) + } + } + return true + } + return false +} + +func looksLikeAddress(args string) bool { + a := strings.TrimSpace(args) + if a == "" { + return false + } + switch a[0] { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '$', '\'', '+', '-', '/', '?': + return true + } + return false +} + +func parseRegisterAndCount(args string) (rune, int) { + args = strings.TrimSpace(args) + reg := rune(0) + if args != "" && !unicode.IsDigit(rune(args[0])) { + reg = []rune(args)[0] + args = strings.TrimSpace(args[len(string(reg)):]) + } + count := 0 + if n, err := strconv.Atoi(args); err == nil { + count = n + } + return reg, count +} + +func (e *Editor) moveLines(start, end, target int) { + if target >= start && target <= end { + if target == end { + return + } + e.setError("E134: Cannot move a range of lines into itself") + return + } + lines := e.buf.DeleteLines(start, end) + if target > end { + target -= len(lines) + } + e.buf.InsertLines(target+1, lines) + e.cursor = Pos{target + len(lines), 0} + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) +} + +func (e *Editor) globalCommand(cmd ExCommand, invert bool) { + args := cmd.Args + if args == "" { + e.setError("E35: No previous regular expression") + return + } + delim := []rune(args)[0] + rest := []rune(args)[1:] + end := -1 + for i, r := range rest { + if r == delim && (i == 0 || rest[i-1] != '\\') { + end = i + break + } + } + var pattern, sub string + if end < 0 { + pattern = string(rest) + sub = "p" + } else { + pattern = string(rest[:end]) + sub = strings.TrimSpace(string(rest[end+1:])) + } + if pattern == "" { + pattern = e.lastSearch + } + re, err := e.compilePattern(pattern) + if err != nil { + e.setError("E486: Pattern not found: " + pattern) + return + } + e.lastSearch = pattern + start, stop := 0, e.buf.LineCount()-1 + if cmd.Range.Given { + start, stop = cmd.Range.Start, cmd.Range.End + } + if sub == "" { + sub = "p" + } + marked := []int{} + for l := start; l <= stop && l < e.buf.LineCount(); l++ { + if re.MatchString(e.buf.LineString(l)) != invert { + marked = append(marked, l) + } + } + // Run the command on each marked line, tracking line shifts. + shift := 0 + before := e.buf.LineCount() + for _, l := range marked { + line := l + shift + if line < 0 || line >= e.buf.LineCount() { + continue + } + e.cursor = e.buf.clampNormal(Pos{line, 0}) + countBefore := e.buf.LineCount() + e.executeExLine(sub) + shift += e.buf.LineCount() - countBefore + } + _ = before +} + +func (e *Editor) sortLines(start, end int, reverse bool, args string) { + lines := e.buf.Lines()[start : end+1] + unique := strings.Contains(args, "u") + ignoreCase := strings.Contains(args, "i") + numeric := strings.Contains(args, "n") + sort.SliceStable(lines, func(i, j int) bool { + a, b := lines[i], lines[j] + if numeric { + na, nb := leadingNumber(a), leadingNumber(b) + if na != nb { + return na < nb + } + } + if ignoreCase { + a, b = strings.ToLower(a), strings.ToLower(b) + } + return a < b + }) + if reverse { + for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 { + lines[i], lines[j] = lines[j], lines[i] + } + } + if unique { + out := []string{} + for i, l := range lines { + if i > 0 && (l == lines[i-1] || (ignoreCase && strings.EqualFold(l, lines[i-1]))) { + continue + } + out = append(out, l) + } + lines = out + } + e.buf.ReplaceLines(start, end, lines) + e.cursor = e.buf.clampNormal(Pos{start, 0}) +} + +func leadingNumber(s string) int { + i := 0 + for i < len(s) && !(s[i] >= '0' && s[i] <= '9') && s[i] != '-' { + i++ + } + j := i + if j < len(s) && s[j] == '-' { + j++ + } + for j < len(s) && s[j] >= '0' && s[j] <= '9' { + j++ + } + n, _ := strconv.Atoi(s[i:j]) + return n +} + +// setOption handles the :set options the buffer understands. +func (e *Editor) setOption(args string) { + for _, opt := range strings.Fields(args) { + name, value, hasValue := strings.Cut(opt, "=") + on := true + if strings.HasPrefix(name, "no") && !hasValue { + on = false + name = name[2:] + } + if strings.HasSuffix(name, "!") { + name = strings.TrimSuffix(name, "!") + on = !e.optionBool(name) + } + switch name { + case "ic", "ignorecase": + e.opts.IgnoreCase = on + case "scs", "smartcase": + e.opts.SmartCase = on + case "ws", "wrapscan": + e.opts.WrapScan = on + case "hls", "hlsearch": + e.highlight = on + case "ts", "tabstop", "sw", "shiftwidth": + if n, err := strconv.Atoi(value); err == nil && n > 0 { + e.opts.TabSize = n + } + case "so", "scrolloff": + if n, err := strconv.Atoi(value); err == nil && n >= 0 { + e.opts.ScrollOff = n + } + default: + if e.hooks.ExCommand != nil { + e.hostEx(ExCommand{Name: "set", Args: opt, Raw: "set " + opt}) + } + } + } +} + +func (e *Editor) optionBool(name string) bool { + switch name { + case "ic", "ignorecase": + return e.opts.IgnoreCase + case "scs", "smartcase": + return e.opts.SmartCase + case "ws", "wrapscan": + return e.opts.WrapScan + case "hls", "hlsearch": + return e.highlight + } + return false +} + +func (e *Editor) registerListing() string { + names := []rune{} + for r := range e.registers { + names = append(names, r) + } + sort.Slice(names, func(i, j int) bool { return names[i] < names[j] }) + parts := []string{} + for _, r := range names { + text := strings.ReplaceAll(e.registers[r].Text, "\n", "^J") + if len([]rune(text)) > 40 { + text = string([]rune(text)[:40]) + "…" + } + parts = append(parts, fmt.Sprintf("\"%c %s", r, text)) + } + if len(parts) == 0 { + return "--- Registers --- (empty)" + } + return "--- Registers --- " + strings.Join(parts, " | ") +} + +func (e *Editor) markListing() string { + names := []rune{} + for r := range e.marks { + names = append(names, r) + } + sort.Slice(names, func(i, j int) bool { return names[i] < names[j] }) + parts := []string{} + for _, r := range names { + p := e.marks[r] + parts = append(parts, fmt.Sprintf("%c %d:%d", r, p.Line+1, p.Col+1)) + } + if len(parts) == 0 { + return "--- Marks --- (none)" + } + return "--- Marks --- " + strings.Join(parts, " ") +} + +// ExecuteEx runs an ex command line from the host (`:` prefix optional). +func (e *Editor) ExecuteEx(line string) { + e.executeExLine(strings.TrimPrefix(line, ":")) + e.cursor = e.buf.clampNormal(e.cursor) + e.ensureCursorVisible() +} diff --git a/internal/helix/expand.go b/internal/helix/expand.go new file mode 100644 index 0000000..23f9cb8 --- /dev/null +++ b/internal/helix/expand.go @@ -0,0 +1,404 @@ +package helix + +import ( + "regexp" + "strings" +) + +// Selection growth. Helix grows a selection through the syntax tree: +// Alt+o expands to the parent node, Alt+i shrinks back to the child. There +// is no syntax tree here, so the contexts a note is built from take its +// place: the character, the word, the paragraph, the function inside a code +// block, the block, the heading section and the note itself. The ladder is +// a chain — every context holds the one before it — so growing and shrinking +// walk the same rungs. + +// selState is a selection the growth commands can restore. +type selState struct { + anchor Pos + cursor Pos + kind selKind + selExcl bool + from Pos // the selection's first character + to Pos // its last character +} + +// holds reports whether the saved selection sits inside the given range. +func (s selState) holds(from, to Pos) bool { + return !s.from.Less(from) && !to.Less(s.to) +} + +// context is one rung of the ladder: the characters it covers, from start +// to end inclusive, and whether it selects whole lines. +type context struct { + start, end Pos + linewise bool + label string +} + +// covers reports whether the context holds the whole range. +func (c context) covers(from, to Pos) bool { + return !from.Less(c.start) && !c.end.Less(to) +} + +// inside reports whether the context sits inside the range. +func (c context) inside(from, to Pos) bool { + return !from.Less(c.start) && !to.Less(c.end) +} + +// sameAs reports whether the context is exactly the range. +func (c context) sameAs(from, to Pos) bool { + return c.start == from && c.end == to +} + +// expandSelection grows the selection to the next context around it +// (Helix's expand_selection). The smallest context holding the selection is +// the one to grow to; when the selection already is that context, the next +// one up is. +func (e *Editor) expandSelection() { + e.markSelection() + from, to := e.selectionChars() + ladder := e.contextLadder(from) + for i, c := range ladder { + if !c.covers(from, to) { + continue + } + if c.sameAs(from, to) { + // The selection already is that context: grow to the next one. + if i+1 < len(ladder) { + e.applyContext(ladder[i+1], true) + } + return + } + e.applyContext(c, true) + return + } +} + +// shrinkSelection shrinks the selection to the previous context (Helix's +// shrink_selection). It restores the selection the growth pushed when there +// is one, and otherwise falls to the largest context inside the selection. +func (e *Editor) shrinkSelection() { + e.markSelection() + from, to := e.selectionChars() + if n := len(e.expandStack); n > 0 { + st := e.expandStack[n-1] + if st.holds(from, to) { + e.expandStack = e.expandStack[:n-1] + e.restoreSelState(st) + return + } + e.expandStack = nil + } + ladder := e.contextLadder(from) + for i := len(ladder) - 1; i >= 0; i-- { + c := ladder[i] + if c.sameAs(from, to) || !c.inside(from, to) { + continue + } + e.applyContext(c, false) + return + } +} + +// markSelection keeps the selection alive when the command leaves it +// untouched: normal mode collapses a selection that is not a motion's. +func (e *Editor) markSelection() { + if e.mode != ModeSelect { + e.motionSelected = true + } +} + +// applyContext selects a context. The head keeps the side of the selection +// it was on, so a backward selection grows to the left. When remember is +// set the selection it replaces is kept for the next shrink. +func (e *Editor) applyContext(c context, remember bool) { + from, to := e.selectionChars() + // The head keeps the side of the selection it was on: a backward + // selection grows to the left. + forward := !e.cursor.Less(e.anchor) + if remember { + e.expandStack = append(e.expandStack, selState{ + anchor: e.anchor, + cursor: e.cursor, + kind: e.kind, + selExcl: e.selExcl, + from: from, + to: to, + }) + } + e.selExcl = false + if c.linewise { + e.kind = selLine + } else { + e.kind = selChar + } + if forward { + e.anchor = e.buf.clampNormal(c.start) + e.cursor = e.buf.clampNormal(c.end) + } else { + e.anchor = e.buf.clampNormal(c.end) + e.cursor = e.buf.clampNormal(c.start) + } + e.desiredCol = -1 + e.openFoldAt(e.cursor.Line) + e.ensureCursorVisible() + e.setMsg("selection: " + c.label) +} + +// restoreSelState puts back a selection the growth replaced. +func (e *Editor) restoreSelState(s selState) { + e.anchor = e.buf.clampNormal(s.anchor) + e.cursor = e.buf.clampNormal(s.cursor) + e.kind = s.kind + e.selExcl = s.selExcl + e.desiredCol = -1 + e.openFoldAt(e.cursor.Line) + e.ensureCursorVisible() + e.ClearMessage() +} + +// selectionChars is the selection as an inclusive range of characters. A +// word-start motion leaves out the word it landed on: the character under the +// head is not part of the selection. The head is the upper bound for a +// forward motion and the lower bound for a backward one. +func (e *Editor) selectionChars() (from, to Pos) { + from, to = e.selRange() + if !e.selExcl || !from.Less(to) { + return from, to + } + if e.cursor.Less(e.anchor) { + // The head is the lower bound: the word it landed on starts + // after the selection. + next := e.nextCharPos(from) + if !next.Less(to) { + return from, to + } + return next, to + } + prev := e.prevCharPos(to) + if prev.Less(from) { + // The head sits before the selection: there is nothing to leave + // out of it. + return from, to + } + return from, prev +} + +// contextLadder lists the contexts around p, smallest first. Every context +// holds the one before it. +func (e *Editor) contextLadder(p Pos) []context { + out := []context{} + if c, ok := e.charContext(p); ok { + out = append(out, c) + } + if c, ok := e.wordContext(p); ok { + out = append(out, c) + } + if fenceStart, fenceEnd, inFence := e.fencedBlockAt(p.Line); inFence { + if fnStart, fnEnd, ok := e.codeFunctionAt(p.Line, fenceStart, fenceEnd); ok { + out = append(out, e.lineContext(fnStart, fnEnd, "function")) + } + out = append(out, e.lineContext(fenceStart, fenceEnd, "code block")) + } else { + start, end := e.paragraphRangeAt(p.Line) + out = append(out, e.lineContext(start, end, "paragraph")) + } + if c, ok := e.sectionContext(p); ok { + out = append(out, c) + } + out = append(out, e.noteContext()) + return out +} + +// charContext is the character under p. +func (e *Editor) charContext(p Pos) (context, bool) { + if p.Col >= e.buf.LineLen(p.Line) { + return context{}, false + } + return context{start: p, end: p, label: "character"}, true +} + +// wordContext is the word under p: the run of characters of the same class. +func (e *Editor) wordContext(p Pos) (context, bool) { + line := e.buf.Line(p.Line) + if p.Col >= len(line) { + return context{}, false + } + cls := charClass(line[p.Col], false) + start, end := p.Col, p.Col + for start > 0 && charClass(line[start-1], false) == cls { + start-- + } + for end+1 < len(line) && charClass(line[end+1], false) == cls { + end++ + } + return context{start: Pos{p.Line, start}, end: Pos{p.Line, end}, label: "word"}, true +} + +// paragraphRangeAt is the block of text around line: the run of non-blank +// lines, stopped by a blank line, a heading or a fence. +func (e *Editor) paragraphRangeAt(line int) (int, int) { + start := line + for start > 0 && e.inParagraphLine(start-1) { + start-- + } + end := line + for end+1 < e.buf.LineCount() && e.inParagraphLine(end+1) { + end++ + } + return start, end +} + +// inParagraphLine reports whether the line belongs to a paragraph. +func (e *Editor) inParagraphLine(i int) bool { + if isBlankLine(e.buf.Line(i)) { + return false + } + trimmed := strings.TrimLeft(e.buf.LineString(i), " \t") + return !strings.HasPrefix(trimmed, "#") && + !strings.HasPrefix(trimmed, "```") && + !strings.HasPrefix(trimmed, "~~~") +} + +// fencedBlockAt reports the fenced code block containing line. +func (e *Editor) fencedBlockAt(line int) (int, int, bool) { + inFence := false + marker := "" + open := 0 + for i := 0; i < e.buf.LineCount(); i++ { + trimmed := strings.TrimLeft(e.buf.LineString(i), " \t") + if !strings.HasPrefix(trimmed, "```") && !strings.HasPrefix(trimmed, "~~~") { + continue + } + m := trimmed[:3] + if !inFence { + inFence, marker, open = true, m, i + continue + } + if m != marker { + continue + } + inFence = false + if line >= open && line <= i { + return open, i, true + } + } + return 0, 0, false +} + +// codeFuncRe opens a function, a class or a block in code. +var codeFuncRe = regexp.MustCompile(`^\s*(?:pub |export |public |private |protected |static |async |export default )*(?:func|fn|def|function|class|impl|struct|trait|interface|type|enum|const|let|var|module|package)\b`) + +// codeFunctionAt is the definition around line and the body under it, inside +// the fence that runs from fenceStart to fenceEnd. +func (e *Editor) codeFunctionAt(line, fenceStart, fenceEnd int) (int, int, bool) { + for i := line; i >= fenceStart; i-- { + if !codeFuncRe.MatchString(e.buf.LineString(i)) { + continue + } + indent := indentWidth(e.buf.Line(i)) + end := i + for j := i + 1; j <= fenceEnd; j++ { + l := e.buf.Line(j) + if isBlankLine(l) { + continue + } + if indentWidth(l) > indent { + end = j + continue + } + // The body ends here: a closing brace belongs to it. + if isClosingLine(l) { + end = j + } + break + } + return i, end, true + } + return 0, 0, false +} + +// isClosingLine reports whether the line closes the block above it. +func isClosingLine(line []rune) bool { + trimmed := strings.TrimLeft(string(line), " \t") + for _, close := range []string{"}", ")", "]", "end", "fi", "done", "EOF"} { + if trimmed == close || strings.HasPrefix(trimmed, close+" ") || strings.HasPrefix(trimmed, close+",") { + return true + } + } + return false +} + +// indentWidth is the width of the line's leading whitespace. +func indentWidth(line []rune) int { + n := 0 + for _, r := range line { + switch r { + case '\t': + n += 4 + case ' ': + n++ + default: + return n + } + } + return n +} + +// sectionContext is the heading section around p: the heading and the text +// under it, up to the next heading. +func (e *Editor) sectionContext(p Pos) (context, bool) { + if s, found := e.enclosingOutlineSection(p.Line); found { + return e.lineContext(s.start, s.end, "section"), true + } + headings := e.headingLines() + if len(headings) > 0 && p.Line < headings[0] { + return e.lineContext(0, headings[0]-1, "section"), true + } + return context{}, false +} + +// outlineSections are the heading sections of the buffer: every heading and +// the text under it, up to the next heading of any level. The folds in +// folds.go nest the other way round — a heading folds its subsections too — +// but the ladder needs the smaller section to sit inside the bigger one. +func (e *Editor) outlineSections() []section { + headings := e.headingLines() + out := []section{} + for i, h := range headings { + end := e.buf.LineCount() - 1 + if i+1 < len(headings) { + end = headings[i+1] - 1 + } + out = append(out, section{start: h, end: end, level: headingLevel(e.buf.LineString(h))}) + } + return out +} + +// enclosingOutlineSection is the innermost heading section holding line. +func (e *Editor) enclosingOutlineSection(line int) (section, bool) { + var best section + found := false + for _, s := range e.outlineSections() { + if s.start <= line && line <= s.end { + best, found = s, true + } + } + return best, found +} + +// noteContext is the whole buffer. +func (e *Editor) noteContext() context { + return e.lineContext(0, e.buf.LineCount()-1, "note") +} + +// lineContext is a range of whole lines. +func (e *Editor) lineContext(start, end int, label string) context { + return context{ + start: Pos{start, 0}, + end: Pos{end, max(0, e.buf.LineLen(end)-1)}, + linewise: true, + label: label, + } +} diff --git a/internal/helix/expand_test.go b/internal/helix/expand_test.go new file mode 100644 index 0000000..1fc6fab --- /dev/null +++ b/internal/helix/expand_test.go @@ -0,0 +1,443 @@ +package helix + +import "testing" + +// The ladder a selection grows through: the word, the paragraph, the +// heading section and the note. Every context holds the one before it, so +// growing and shrinking walk the same rungs. +const expandText = "# Title\n\nfirst paragraph here\nsecond line of it\n\n## Section\n\n- one\n- two\n" + +func TestExpandSelectionGrowsThroughTheContexts(t *testing.T) { + e := New(expandText, DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{2, 6}) + want := []struct { + start, end Pos + label string + }{ + {Pos{2, 6}, Pos{2, 14}, "word"}, + {Pos{2, 0}, Pos{3, 16}, "paragraph"}, + {Pos{0, 0}, Pos{4, 0}, "section"}, + {Pos{0, 0}, Pos{9, 0}, "note"}, + } + for i, w := range want { + e.Feed("") + start, end, _, ok := e.VisualRange() + if !ok || start != w.start || end != w.end { + t.Fatalf("step %d: selection %v..%v ok=%t, want %v..%v", i, start, end, ok, w.start, w.end) + } + if e.msg != "selection: "+w.label { + t.Fatalf("step %d: message %q, want %q", i, e.msg, w.label) + } + } + // The note is the top of the ladder: growing again changes nothing. + e.Feed("") + start, end, _, ok := e.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{9, 0}) { + t.Fatalf("growing past the note: %v..%v ok=%t", start, end, ok) + } +} + +func TestShrinkSelectionRestoresThePreviousSelection(t *testing.T) { + e := New(expandText, DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{2, 6}) + // Grow to the word, then to the paragraph. + e.Feed("") + // The shrink walks back down the ladder. + start, end, _, ok := e.VisualRange() + if !ok || start != (Pos{2, 0}) || end != (Pos{3, 16}) { + t.Fatalf("grown selection = %v..%v ok=%t", start, end, ok) + } + e.Feed("") + start, end, _, ok = e.VisualRange() + if !ok || start != (Pos{2, 6}) || end != (Pos{2, 14}) { + t.Fatalf("shrunk selection = %v..%v ok=%t, want the word", start, end, ok) + } + // The last step puts the cursor back where the growth started. + e.Feed("") + if _, _, _, ok := e.VisualRange(); ok { + t.Fatal("the shrink left a selection behind") + } + if e.Cursor() != (Pos{2, 6}) { + t.Fatalf("cursor after the shrink: %+v", e.Cursor()) + } +} + +func TestShrinkSelectionWithoutAStackTakesTheContextInside(t *testing.T) { + // A selection made by hand has no stack: the shrink falls to the + // largest context inside it. + e := New("one two three\n", DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{0, 0}) + e.Feed("v") + e.Feed("llllll") + start, end, _, ok := e.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 6}) { + t.Fatalf("handmade selection = %v..%v ok=%t", start, end, ok) + } + // The word at the start of the selection. + e.Feed("") + start, end, _, ok = e.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 2}) { + t.Fatalf("shrunk to %v..%v ok=%t, want the first word", start, end, ok) + } + // Then the character under it. + e.Feed("") + start, end, _, ok = e.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 0}) { + t.Fatalf("shrunk to %v..%v ok=%t, want the first character", start, end, ok) + } +} + +func TestExpandSelectionInSelectMode(t *testing.T) { + e := New(expandText, DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{2, 6}) + e.Feed("v") + e.Feed("") + start, end, mode, ok := e.VisualRange() + if !ok || mode != ModeSelect || start != (Pos{2, 6}) || end != (Pos{2, 14}) { + t.Fatalf("grown selection = %v..%v ok=%t mode=%v", start, end, ok, mode) + } + // The shrink puts the character under the cursor back. + e.Feed("") + start, end, _, ok = e.VisualRange() + if !ok || start != (Pos{2, 6}) || end != (Pos{2, 6}) { + t.Fatalf("shrunk selection = %v..%v ok=%t", start, end, ok) + } +} + +func TestExpandSelectionInCode(t *testing.T) { + text := "intro\n\n```go\nfunc add(a int) int {\n\treturn a + 1\n}\n```\n\nafter\n" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{4, 2}) + want := []struct { + start, end Pos + label string + }{ + {Pos{4, 1}, Pos{4, 6}, "word"}, + {Pos{3, 0}, Pos{5, 0}, "function"}, + {Pos{2, 0}, Pos{6, 2}, "code block"}, + {Pos{0, 0}, Pos{9, 0}, "note"}, + } + for i, w := range want { + e.Feed("") + start, end, _, ok := e.VisualRange() + if !ok || start != w.start || end != w.end { + t.Fatalf("step %d: selection %v..%v ok=%t, want %v..%v", i, start, end, ok, w.start, w.end) + } + if e.msg != "selection: "+w.label { + t.Fatalf("step %d: message %q, want %q", i, e.msg, w.label) + } + } +} + +func TestExpandSelectionWithTheArrowKeys(t *testing.T) { + e := New("one two three\n", DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{0, 0}) + e.Feed("") + start, end, _, ok := e.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 2}) { + t.Fatalf("alt-up selection = %v..%v ok=%t", start, end, ok) + } + // The shrink puts the character under the cursor back. + e.Feed("") + if _, _, _, ok := e.VisualRange(); ok { + t.Fatal("alt-down left a selection behind") + } + if e.Cursor() != (Pos{0, 0}) { + t.Fatalf("cursor after alt-down: %+v", e.Cursor()) + } +} + +func TestExpandedSelectionCoversWholeLines(t *testing.T) { + // A grown paragraph selects whole lines, so deleting it takes the + // lines with it. + e := New(expandText, DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{2, 6}) + e.Feed("") + e.Feed("d") + if got, want := e.Text(), "# Title\n\n\n## Section\n\n- one\n- two\n"; got != want { + t.Fatalf("text after deleting the paragraph:\n got %q\nwant %q", got, want) + } +} + +func TestWordMotionRunsToTheLineEnd(t *testing.T) { + text := "one two three\nfour five\n" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{0, 8}) + // The last word of the line is selected in full. + e.Feed("w") + if e.Cursor() != (Pos{0, 12}) { + t.Fatalf("cursor after w at the line end: %+v", e.Cursor()) + } + start, end, _, ok := e.VisualRange() + if !ok || start != (Pos{0, 8}) || end != (Pos{0, 12}) { + t.Fatalf("selection after w at the line end = %v..%v ok=%t", start, end, ok) + } + // The word motion walks the line and stops at its last word. + e2 := New(text, DefaultOptions(), Hooks{}) + e2.SetViewport(10) + e2.SetCursor(Pos{0, 0}) + e2.Feed("ww") + if e2.Cursor() != (Pos{0, 8}) { + t.Fatalf("cursor after two w: %+v", e2.Cursor()) + } + // The last word of the line is selected too. + e2.Feed("w") + start, end, _, ok = e2.VisualRange() + if !ok || start != (Pos{0, 8}) || end != (Pos{0, 12}) { + t.Fatalf("selection after the last w = %v..%v ok=%t", start, end, ok) + } + if e2.Cursor() != (Pos{0, 12}) { + t.Fatalf("cursor after the last w: %+v", e2.Cursor()) + } + // With no word left on the line, the motion crosses to the next line. + e2.Feed("w") + if e2.Cursor() != (Pos{1, 0}) { + t.Fatalf("cursor after the crossing w: %+v", e2.Cursor()) + } + // The next word on the new line is selected, without its first + // character. + e2.Feed("w") + start, end, _, ok = e2.VisualRange() + if !ok || start != (Pos{1, 0}) || end != (Pos{1, 4}) { + t.Fatalf("selection after the w on the next line = %v..%v ok=%t", start, end, ok) + } + if e2.Cursor() != (Pos{1, 5}) { + t.Fatalf("cursor after the w on the next line: %+v", e2.Cursor()) + } +} + +func TestBlockCursorStaysInsideTheSelection(t *testing.T) { + // Helix draws the block cursor on the head side of the selection, + // inside it: the word a motion lands on is neither selected nor + // covered by the cursor. + text := "one two three\n" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{0, 0}) + e.Feed("w") + if got, want := e.BlockCursor(), (Pos{0, 3}); got != want { + t.Fatalf("block cursor after w = %+v, want %+v", got, want) + } + start, end, _, _ := e.VisualRange() + if got, want := e.buf.Range(start, Pos{end.Line, end.Col + 1}), "one "; got != want { + t.Fatalf("selection after w = %q, want %q", got, want) + } + // A backward motion selects the word it lands on, plus the space that + // follows it, and the cursor sits on its first character. + e2 := New(text, DefaultOptions(), Hooks{}) + e2.SetViewport(10) + e2.SetCursor(Pos{0, 8}) + e2.Feed("b") + if got, want := e2.BlockCursor(), (Pos{0, 4}); got != want { + t.Fatalf("block cursor after b = %+v, want %+v", got, want) + } + start, end, _, _ = e2.VisualRange() + if got, want := e2.buf.Range(start, Pos{end.Line, end.Col + 1}), "two "; got != want { + t.Fatalf("selection after b = %q, want %q", got, want) + } +} + +func TestWordMotionSelectsTheLastWord(t *testing.T) { + // The last word of a line can be selected, in normal and select mode. + text := "one two three\n" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{0, 8}) + e.Feed("w") + start, end, mode, ok := e.VisualRange() + if !ok || mode != ModeNormal || start != (Pos{0, 8}) || end != (Pos{0, 12}) { + t.Fatalf("selection in normal mode = %v..%v mode=%v ok=%t", start, end, mode, ok) + } + e2 := New(text, DefaultOptions(), Hooks{}) + e2.SetViewport(10) + e2.SetCursor(Pos{0, 8}) + e2.Feed("v") + e2.Feed("w") + start, end, mode, ok = e2.VisualRange() + if !ok || mode != ModeSelect || start != (Pos{0, 8}) || end != (Pos{0, 12}) { + t.Fatalf("selection in select mode = %v..%v mode=%v ok=%t", start, end, mode, ok) + } +} + +func TestBackwardWordSelectsTheWordAndItsSpace(t *testing.T) { + // A backward word motion selects the word it lands on and the space + // that follows it. The word the cursor sat in is left out. + text := "one two three\n" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{0, 8}) + e.Feed("b") + start, end, mode, ok := e.VisualRange() + if !ok || mode != ModeNormal || start != (Pos{0, 4}) || end != (Pos{0, 7}) { + t.Fatalf("selection after b = %v..%v mode=%v ok=%t", start, end, mode, ok) + } + if got, want := e.buf.Range(start, Pos{end.Line, end.Col + 1}), "two "; got != want { + t.Fatalf("selection after b = %q, want %q", got, want) + } + // The same in select mode. + e2 := New(text, DefaultOptions(), Hooks{}) + e2.SetViewport(10) + e2.SetCursor(Pos{0, 8}) + e2.Feed("v") + e2.Feed("b") + start, end, mode, ok = e2.VisualRange() + if !ok || mode != ModeSelect || start != (Pos{0, 4}) || end != (Pos{0, 7}) { + t.Fatalf("selection after vb = %v..%v mode=%v ok=%t", start, end, mode, ok) + } + // A big word motion behaves the same way: "one.two" is one big word. + e3 := New("one.two three\n", DefaultOptions(), Hooks{}) + e3.SetViewport(10) + e3.SetCursor(Pos{0, 8}) + e3.Feed("B") + start, end, _, ok = e3.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 7}) { + t.Fatalf("selection after B = %v..%v ok=%t", start, end, ok) + } + // A word at the end of a line has no space after it, so only the word + // is selected. + e4 := New("one two\n", DefaultOptions(), Hooks{}) + e4.SetViewport(10) + e4.SetCursor(Pos{0, 6}) + e4.Feed("b") + start, end, _, ok = e4.VisualRange() + if !ok || start != (Pos{0, 4}) || end != (Pos{0, 6}) { + t.Fatalf("selection after b at the line end = %v..%v ok=%t", start, end, ok) + } + // A motion with a count covers the words it travelled over. + e5 := New(text, DefaultOptions(), Hooks{}) + e5.SetViewport(10) + e5.SetCursor(Pos{0, 8}) + e5.Feed("2b") + start, end, _, ok = e5.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 7}) { + t.Fatalf("selection after 2b = %v..%v ok=%t", start, end, ok) + } +} + +func TestForwardWordSelectsTheWordAndItsSpace(t *testing.T) { + // A forward word motion covers the word it moves over, whole, plus + // the space that follows it. The word it lands on stays out of the + // selection. + text := "one two three\n" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(10) + // From the middle of a word the selection still covers the word. + e.SetCursor(Pos{0, 1}) + e.Feed("w") + start, end, _, ok := e.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 3}) { + t.Fatalf("selection after w in a word = %v..%v ok=%t", start, end, ok) + } + if got, want := e.buf.Range(start, Pos{end.Line, end.Col + 1}), "one "; got != want { + t.Fatalf("selection after w in a word = %q, want %q", got, want) + } + // From a blank the selection is the blank itself: the first character + // of the next word is never part of it. + e2 := New(text, DefaultOptions(), Hooks{}) + e2.SetViewport(10) + e2.SetCursor(Pos{0, 3}) + e2.Feed("w") + start, end, _, ok = e2.VisualRange() + if !ok || start != (Pos{0, 3}) || end != (Pos{0, 3}) { + t.Fatalf("selection after w on a blank = %v..%v ok=%t", start, end, ok) + } + if got, want := e2.BlockCursor(), (Pos{0, 3}); got != want { + t.Fatalf("block cursor after w on a blank = %+v, want %+v", got, want) + } + // The last word of a line has no space after it, so only the word is + // selected. + e3 := New(text, DefaultOptions(), Hooks{}) + e3.SetViewport(10) + e3.SetCursor(Pos{0, 8}) + e3.Feed("w") + start, end, _, ok = e3.VisualRange() + if !ok || start != (Pos{0, 8}) || end != (Pos{0, 12}) { + t.Fatalf("selection after w at the line end = %v..%v ok=%t", start, end, ok) + } +} + +func TestWordMotionDoesNotJoinLines(t *testing.T) { + // `wd` on the last word of a line must not eat the line break. + e := New("one two\nthree four\n", DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{0, 4}) + e.Feed("wd") + if got, want := e.Text(), "one \nthree four\n"; got != want { + t.Fatalf("text after wd at the line end:\n got %q\nwant %q", got, want) + } +} + +func TestBackwardWordMotionOntoAnEmptyLine(t *testing.T) { + // An empty line is a word stop like any other: the motion lands on + // the line break and the selection stays empty. + e := New("one\n\nthree\n", DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.SetCursor(Pos{2, 0}) + e.Feed("b") + if e.Cursor() != (Pos{1, 0}) { + t.Fatalf("cursor after b onto an empty line: %+v", e.Cursor()) + } + if _, _, _, ok := e.VisualRange(); ok { + t.Fatal("b onto an empty line left a selection behind") + } + // A count over several empty lines lands on the last one. + e2 := New("one two\n\n\nsix\n", DefaultOptions(), Hooks{}) + e2.SetViewport(10) + e2.SetCursor(Pos{3, 0}) + e2.Feed("2b") + if e2.Cursor() != (Pos{1, 0}) { + t.Fatalf("cursor after 2b over empty lines: %+v", e2.Cursor()) + } + if _, _, _, ok := e2.VisualRange(); ok { + t.Fatal("2b over empty lines left a selection behind") + } +} + +func TestExpandSelectionGrowsFromAPartialSelection(t *testing.T) { + // A selection that is no rung of the ladder — a word motion's or a + // handmade one — grows to the next context around it, not straight + // to the note. + e := New("one two three\n\nnext para\n", DefaultOptions(), Hooks{}) + e.SetViewport(20) + e.SetCursor(Pos{0, 5}) + e.Feed("w") + start, end, _, ok := e.VisualRange() + if !ok || start != (Pos{0, 4}) || end != (Pos{0, 7}) { + t.Fatalf("selection after w = %v..%v ok=%t", start, end, ok) + } + e.Feed("") + start, end, _, ok = e.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 12}) { + t.Fatalf("grown selection = %v..%v ok=%t, want the paragraph", start, end, ok) + } + if e.msg != "selection: paragraph" { + t.Fatalf("message %q, want %q", e.msg, "selection: paragraph") + } + // The same for a handmade selection in select mode. + e2 := New("one two three\n\nnext para\n", DefaultOptions(), Hooks{}) + e2.SetViewport(20) + e2.SetCursor(Pos{0, 4}) + e2.Feed("v6l") + e2.Feed("") + start, end, _, ok = e2.VisualRange() + if !ok || start != (Pos{0, 0}) || end != (Pos{0, 12}) { + t.Fatalf("grown handmade selection = %v..%v ok=%t, want the paragraph", start, end, ok) + } + // Shrinking a word motion's selection falls to the word inside it. + e3 := New("one two three\n\nnext para\n", DefaultOptions(), Hooks{}) + e3.SetViewport(20) + e3.SetCursor(Pos{0, 5}) + e3.Feed("w") + start, end, _, ok = e3.VisualRange() + if !ok || start != (Pos{0, 4}) || end != (Pos{0, 6}) { + t.Fatalf("shrunk w selection = %v..%v ok=%t, want the word", start, end, ok) + } +} diff --git a/internal/helix/folds.go b/internal/helix/folds.go new file mode 100644 index 0000000..adca2c0 --- /dev/null +++ b/internal/helix/folds.go @@ -0,0 +1,200 @@ +package helix + +import "strings" + +// Heading folds: a fold hides the section beneath a markdown heading, up to +// the next heading of the same or a higher level. + +type section struct { + start, end, level int +} + +func (e *Editor) sections() []section { + headings := e.headingLines() + out := []section{} + for i, h := range headings { + level := headingLevel(e.buf.LineString(h)) + end := e.buf.LineCount() - 1 + for _, next := range headings[i+1:] { + if headingLevel(e.buf.LineString(next)) <= level { + end = next - 1 + break + } + } + out = append(out, section{start: h, end: end, level: level}) + } + return out +} + +func headingLevel(line string) int { + trimmed := strings.TrimLeft(line, " ") + n := 0 + for n < len(trimmed) && trimmed[n] == '#' { + n++ + } + return n +} + +// enclosingSection is the innermost section containing line. +func (e *Editor) enclosingSection(line int) (section, bool) { + var best section + found := false + for _, s := range e.sections() { + if s.start <= line && line <= s.end { + if !found || s.start >= best.start { + best, found = s, true + } + } + } + return best, found +} + +func (e *Editor) foldAtCursor() { + s, ok := e.enclosingSection(e.cursor.Line) + if !ok || s.end <= s.start { + return + } + e.folds[s.start] = s.end + e.cursor = e.buf.clampNormal(Pos{s.start, e.cursor.Col}) +} + +func (e *Editor) unfoldAtCursor() { + if start, ok := e.foldContaining(e.cursor.Line); ok { + delete(e.folds, start) + return + } + delete(e.folds, e.cursor.Line) +} + +func (e *Editor) toggleFoldAtCursor() { + if _, ok := e.foldContaining(e.cursor.Line); ok { + e.unfoldAtCursor() + return + } + e.foldAtCursor() +} + +func (e *Editor) foldAll() { + for _, s := range e.sections() { + if s.end > s.start { + e.folds[s.start] = s.end + } + } + if start, ok := e.foldContaining(e.cursor.Line); ok { + e.cursor = e.buf.clampNormal(Pos{start, e.cursor.Col}) + } +} + +func (e *Editor) unfoldAll() { + e.folds = map[int]int{} +} + +// foldContaining is the start of the outermost closed fold covering line. +func (e *Editor) foldContaining(line int) (int, bool) { + best, found := -1, false + for start, end := range e.folds { + if start <= line && line <= end && (!found || start < best) { + best, found = start, true + } + } + return best, found +} + +// IsFoldStart reports whether line starts a closed fold, and where it ends. +func (e *Editor) IsFoldStart(line int) (int, bool) { + end, ok := e.folds[line] + if !ok { + return 0, false + } + if start, covered := e.foldContaining(line); covered && start != line { + return 0, false + } + return end, true +} + +// Hidden reports whether a line is inside a closed fold (not its first line). +func (e *Editor) Hidden(line int) bool { + start, ok := e.foldContaining(line) + return ok && start != line +} + +// openFoldAt opens the fold covering line when the cursor lands inside it. +func (e *Editor) openFoldAt(line int) { + for { + start, ok := e.foldContaining(line) + if !ok || start == line { + return + } + delete(e.folds, start) + } +} + +func (e *Editor) moveToFold(dir, n int) { + starts := []int{} + for s := range e.folds { + starts = append(starts, s) + } + for i := 0; i < len(starts); i++ { + for j := i + 1; j < len(starts); j++ { + if starts[j] < starts[i] { + starts[i], starts[j] = starts[j], starts[i] + } + } + } + target := -1 + if dir > 0 { + count := 0 + for _, s := range starts { + if s > e.cursor.Line { + count++ + target = s + if count == n { + break + } + } + } + } else { + count := 0 + for i := len(starts) - 1; i >= 0; i-- { + if starts[i] < e.cursor.Line { + count++ + target = starts[i] + if count == n { + break + } + } + } + } + if target >= 0 { + e.cursor = e.buf.clampNormal(Pos{target, 0}) + } +} + +// repairFolds drops folds the text no longer supports. +func (e *Editor) repairFolds() { + if len(e.folds) == 0 { + return + } + sections := e.sections() + byStart := map[int]section{} + for _, s := range sections { + byStart[s.start] = s + } + for start := range e.folds { + s, ok := byStart[start] + if !ok || s.end <= s.start { + delete(e.folds, start) + continue + } + e.folds[start] = s.end + } +} + +// Folds lists closed folds as start..end pairs. +func (e *Editor) Folds() map[int]int { + out := map[int]int{} + for k, v := range e.folds { + out[k] = v + } + return out +} diff --git a/internal/helix/insert.go b/internal/helix/insert.go new file mode 100644 index 0000000..55a74a1 --- /dev/null +++ b/internal/helix/insert.go @@ -0,0 +1,539 @@ +package helix + +import ( + "strings" + "unicode" +) + +var autoPairOpeners = map[rune]rune{'(': ')', '[': ']', '{': '}', '`': '`'} +var autoPairQuotes = map[rune]rune{'"': '"', '\'': '\''} + +func (e *Editor) insertKey(k Key) { + e.ClearMessage() + if e.ins.ctrlO { + e.ctrlOKey(k) + return + } + if e.ins.ctrlR { + e.ins.ctrlR = false + if k.Printable() { + if reg, ok := e.readRegister(k.Rune); ok { + e.insertRaw(reg.Text) + } + } + return + } + if e.ins.ctrlV { + e.ins.ctrlV = false + switch { + case k.Printable(): + e.insertRaw(string(k.Rune)) + case k.Is("tab"): + e.insertRaw("\t") + case k.Is("enter"): + e.insertRaw("\n") + } + return + } + if e.ins.completion != nil && !k.IsCtrl('n') && !k.IsCtrl('p') { + e.ins.completion = nil + } + // Insert-escape sequence such as `jk`. + if seq := []rune(e.opts.InsertEscape); len(seq) == 2 { + if e.ins.escPending { + e.ins.escPending = false + if k.IsRune(seq[1]) { + e.deleteBefore(1) + e.finishInsert() + return + } + } else if k.IsRune(seq[0]) { + e.ins.escPending = true + } + } + switch { + case k.Is("esc"): + e.finishInsert() + case k.IsCtrl('c'): + e.finishInsert() + case k.IsCtrl('['): + e.finishInsert() + case k.Is("enter") || k.IsCtrl('j') || k.IsCtrl('m'): + e.insertNewline() + case k.Is("backspace") || k.IsCtrl('h'): + e.insertBackspace() + case k.Is("delete"): + e.insertDelete() + case k.Is("shift+tab"): + before := e.buf.LineLen(e.cursor.Line) + e.shiftLines(e.cursor.Line, e.cursor.Line, false, 1) + e.cursor.Col = max(0, e.cursor.Col-(before-e.buf.LineLen(e.cursor.Line))) + case k.Is("tab"): + e.insertTab() + case k.Is("left"): + if e.cursor.Col > 0 { + e.cursor.Col-- + } + e.desiredCol = -1 + case k.Is("right"): + if e.cursor.Col < e.buf.LineLen(e.cursor.Line) { + e.cursor.Col++ + } + e.desiredCol = -1 + case k.Is("up"): + if e.cursor.Line > 0 { + if e.desiredCol < 0 { + e.desiredCol = e.cursor.Col + } + e.cursor = Pos{e.cursor.Line - 1, e.colForDesired(e.cursor.Line - 1)} + } + case k.Is("down"): + if e.cursor.Line+1 < e.buf.LineCount() { + if e.desiredCol < 0 { + e.desiredCol = e.cursor.Col + } + e.cursor = Pos{e.cursor.Line + 1, e.colForDesired(e.cursor.Line + 1)} + } + case k.Is("home"): + e.cursor.Col = 0 + case k.Is("end"): + e.cursor.Col = e.buf.LineLen(e.cursor.Line) + case k.Is("pgup"): + e.scrollPage(-1) + e.cursor = e.buf.clampInsert(e.cursor) + case k.Is("pgdn"): + e.scrollPage(1) + e.cursor = e.buf.clampInsert(e.cursor) + case k.IsCtrl('w'): + e.deleteWordBefore() + case k.IsCtrl('u'): + e.deleteBefore(e.cursor.Col) + case k.IsCtrl('o'): + e.ins.ctrlO = true + case k.IsCtrl('r'): + e.ins.ctrlR = true + case k.IsCtrl('v') || k.IsCtrl('q'): + e.ins.ctrlV = true + case k.IsCtrl('t'): + e.shiftLines(e.cursor.Line, e.cursor.Line, true, 1) + e.cursor.Col += e.opts.TabSize + case k.IsCtrl('d'): + before := e.buf.LineLen(e.cursor.Line) + e.shiftLines(e.cursor.Line, e.cursor.Line, false, 1) + e.cursor.Col = max(0, e.cursor.Col-(before-e.buf.LineLen(e.cursor.Line))) + case k.IsCtrl('n'): + e.completeWord(1) + case k.IsCtrl('p'): + e.completeWord(-1) + case k.IsCtrl('e'): + e.copyFromLine(1) + case k.IsCtrl('y'): + e.copyFromLine(-1) + case k.IsCtrl('a'): + e.insertRaw(KeysString(e.lastInsertKey)) + case k.Printable(): + e.insertPrintable(k.Rune) + } + e.ensureCursorVisible() +} + +func (e *Editor) ctrlOKey(k Key) { + e.mode = ModeNormal + atEnd := e.cursor.Col >= e.buf.LineLen(e.cursor.Line) + e.cursor = e.buf.clampNormal(e.cursor) + e.normalKey(k) + if len(e.pending) > 0 { + e.mode = ModeNormal + return + } + e.ins.ctrlO = false + if e.mode == ModeNormal { + e.mode = ModeInsert + // `$` lands after the last character, and a cursor that was at + // the end stays there when the command did not move it. + if k.IsRune('$') || k.Is("end") || (atEnd && e.cursor.Col == max(0, e.buf.LineLen(e.cursor.Line)-1)) { + e.cursor.Col = e.buf.LineLen(e.cursor.Line) + } + e.cursor = e.buf.clampInsert(e.cursor) + } +} + +// finishInsert leaves insert or replace mode: the cursor steps back one +// column like Vim's, the change commits, and dot-repeat records it. +func (e *Editor) finishInsert() { + wasReplaying := e.replaying + if e.blockInsert != nil { + e.finishBlockInsert() + } + e.marks['^'] = e.cursor + if e.cursor.Col > 0 { + e.cursor.Col-- + } + e.mode = ModeNormal + e.anchor = e.cursor + e.selExcl = false + e.cursor = e.buf.clampNormal(e.cursor) + e.ins = insertState{} + e.commitChange() + if e.recordingIns && !wasReplaying { + keys := e.changeKeys + e.last = lastChange{keys: stripCount(keys)} + _, count, hasCount, _, _ := splitPrefix(keys) + if hasCount { + e.last.count = count + } + for i := 0; i+1 < len(keys); i++ { + if keys[i].IsRune('"') && keys[i+1].Printable() { + e.last.register = keys[i+1].Rune + break + } + } + } + e.recordingIns = false + e.changeKeys = nil + e.desiredCol = -1 + e.ensureCursorVisible() +} + +func (e *Editor) insertRaw(text string) { + e.cursor = e.buf.InsertText(e.cursor, text) +} + +func (e *Editor) deleteBefore(n int) { + if n <= 0 || e.cursor.Col == 0 { + return + } + start := Pos{e.cursor.Line, max(0, e.cursor.Col-n)} + e.buf.DeleteRange(start, e.cursor) + e.cursor = start +} + +func (e *Editor) insertPrintable(r rune) { + line := e.buf.Line(e.cursor.Line) + next := rune(0) + if e.cursor.Col < len(line) { + next = line[e.cursor.Col] + } + // Typing a closer that is already there steps over it. + if e.opts.AutoPairs && (r == ')' || r == ']' || r == '}' || r == '`') && next == r { + e.cursor.Col++ + return + } + if (e.opts.AutoPairQuotes || e.opts.AutoPairs) && (r == '"' || r == '\'') && next == r && e.ins.pairedInsert.Line == e.cursor.Line { + e.cursor.Col++ + return + } + e.insertRaw(string(r)) + if closer, ok := autoPairOpeners[r]; ok && e.opts.AutoPairs && (next == 0 || isBlank(next) || next == ')' || next == ']' || next == '}') { + if r != '`' || next == 0 || isBlank(next) { + e.buf.InsertText(e.cursor, string(closer)) + e.ins.pairedInsert = e.cursor + } + } else if closer, ok := autoPairQuotes[r]; ok && e.opts.AutoPairQuotes && (next == 0 || isBlank(next)) { + prev := rune(0) + if e.cursor.Col >= 2 { + prev = line[e.cursor.Col-2] + } + if prev == 0 || isBlank(prev) || prev == '(' || prev == '[' || prev == '{' { + e.buf.InsertText(e.cursor, string(closer)) + e.ins.pairedInsert = e.cursor + } + } + e.applyTextReplacement() +} + +// applyTextReplacement expands a configured trigger that just completed +// before the cursor; the longest matching trigger wins. +func (e *Editor) applyTextReplacement() { + if !e.opts.TextReplacementsEnabled || len(e.opts.TextReplacements) == 0 { + return + } + line := e.buf.Line(e.cursor.Line) + before := string(line[:e.cursor.Col]) + best := "" + for trigger := range e.opts.TextReplacements { + if trigger != "" && strings.HasSuffix(before, trigger) && len(trigger) > len(best) { + best = trigger + } + } + if best == "" { + return + } + replacement := e.opts.TextReplacements[best] + start := Pos{e.cursor.Line, e.cursor.Col - len([]rune(best))} + e.buf.DeleteRange(start, e.cursor) + e.cursor = e.buf.InsertText(start, replacement) +} + +func (e *Editor) insertNewline() { + line := e.buf.Line(e.cursor.Line) + if e.opts.MarkdownLists && e.cursor.Col >= len(line) && isEmptyListItem(line) { + // Enter on an empty item ends the list. + e.buf.ReplaceLine(e.cursor.Line, leadingWhitespace(line)) + e.cursor.Col = e.buf.LineLen(e.cursor.Line) + return + } + prefix := string(leadingWhitespace(line)) + if e.opts.MarkdownLists && e.cursor.Col > 0 { + if cont, ok := listContinuation(line); ok { + prefix = cont + } + } + // Auto-pair: Enter between brackets opens a block. + if e.opts.AutoPairs && e.cursor.Col > 0 && e.cursor.Col < len(line) { + open, close := line[e.cursor.Col-1], line[e.cursor.Col] + if (open == '(' && close == ')') || (open == '[' && close == ']') || (open == '{' && close == '}') { + indent := string(leadingWhitespace(line)) + e.insertRaw("\n" + indent + strings.Repeat(" ", e.opts.TabSize) + "\n" + indent) + e.cursor = Pos{e.cursor.Line - 1, len([]rune(indent)) + e.opts.TabSize} + return + } + } + e.insertRaw("\n" + prefix) + if e.opts.MarkdownLists { + e.renumberList(e.cursor.Line) + } +} + +func (e *Editor) insertBackspace() { + if e.cursor.Col == 0 { + if e.cursor.Line == 0 { + return + } + prevLen := e.buf.LineLen(e.cursor.Line - 1) + e.buf.DeleteRange(Pos{e.cursor.Line - 1, prevLen}, Pos{e.cursor.Line, 0}) + e.cursor = Pos{e.cursor.Line - 1, prevLen} + return + } + line := e.buf.Line(e.cursor.Line) + if e.opts.AutoPairs && e.cursor.Col < len(line) { + prev, next := line[e.cursor.Col-1], line[e.cursor.Col] + if closer, ok := autoPairOpeners[prev]; ok && closer == next { + e.buf.DeleteRange(Pos{e.cursor.Line, e.cursor.Col - 1}, Pos{e.cursor.Line, e.cursor.Col + 1}) + e.cursor.Col-- + return + } + if closer, ok := autoPairQuotes[prev]; ok && closer == next && e.ins.pairedInsert == e.cursor { + e.buf.DeleteRange(Pos{e.cursor.Line, e.cursor.Col - 1}, Pos{e.cursor.Line, e.cursor.Col + 1}) + e.cursor.Col-- + return + } + } + e.deleteBefore(1) +} + +func (e *Editor) insertDelete() { + line := e.buf.Line(e.cursor.Line) + if e.cursor.Col < len(line) { + e.buf.DeleteRange(e.cursor, Pos{e.cursor.Line, e.cursor.Col + 1}) + return + } + if e.cursor.Line+1 < e.buf.LineCount() { + e.buf.DeleteRange(e.cursor, Pos{e.cursor.Line + 1, 0}) + } +} + +func (e *Editor) insertTab() { + line := e.buf.Line(e.cursor.Line) + if e.opts.MarkdownLists && listMarkerRe.MatchString(string(line)) && e.cursor.Col <= firstNonBlankInsert(line)+len(listMarkerRe.FindString(string(line)))-len(string(leadingWhitespace(line))) { + e.shiftLines(e.cursor.Line, e.cursor.Line, true, 1) + e.cursor.Col += e.opts.TabSize + return + } + width := e.opts.TabSize - (e.cursor.Col % e.opts.TabSize) + e.insertRaw(strings.Repeat(" ", width)) +} + +func (e *Editor) deleteWordBefore() { + line := e.buf.Line(e.cursor.Line) + col := e.cursor.Col + if col == 0 { + e.insertBackspace() + return + } + i := col + for i > 0 && isBlank(line[i-1]) { + i-- + } + if i > 0 { + cls := charClass(line[i-1], false) + for i > 0 && charClass(line[i-1], false) == cls { + i-- + } + } + e.buf.DeleteRange(Pos{e.cursor.Line, i}, e.cursor) + e.cursor.Col = i +} + +func (e *Editor) copyFromLine(dir int) { + src := e.cursor.Line + dir + if src < 0 || src >= e.buf.LineCount() { + return + } + line := e.buf.Line(src) + if e.cursor.Col < len(line) { + e.insertRaw(string(line[e.cursor.Col])) + } +} + +// completeWord cycles buffer words starting with the prefix before the +// cursor (Ctrl-N / Ctrl-P). +func (e *Editor) completeWord(dir int) { + if e.ins.completion == nil { + line := e.buf.Line(e.cursor.Line) + start := e.cursor.Col + for start > 0 && isWordRune(line[start-1]) { + start-- + } + prefix := string(line[start:e.cursor.Col]) + if prefix == "" { + return + } + seen := map[string]bool{prefix: true} + candidates := []string{} + for l := 0; l < e.buf.LineCount(); l++ { + text := e.buf.Line(l) + for i := 0; i < len(text); i++ { + if !isWordRune(text[i]) || (i > 0 && isWordRune(text[i-1])) { + continue + } + j := i + for j < len(text) && isWordRune(text[j]) { + j++ + } + word := string(text[i:j]) + if strings.HasPrefix(word, prefix) && !seen[word] { + seen[word] = true + candidates = append(candidates, word) + } + i = j + } + } + if len(candidates) == 0 { + e.setError("Pattern not found") + return + } + candidates = append(candidates, prefix) + e.ins.completion = &completionState{prefix: prefix, candidates: candidates, idx: -1, start: Pos{e.cursor.Line, start}} + } + c := e.ins.completion + c.idx = (c.idx + dir + len(c.candidates)) % len(c.candidates) + e.buf.DeleteRange(c.start, e.cursor) + e.cursor = e.buf.InsertText(c.start, c.candidates[c.idx]) + if c.idx == len(c.candidates)-1 { + e.setMsg("Back at original") + } else { + e.setMsg("match " + itoa(c.idx+1) + " of " + itoa(len(c.candidates)-1)) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + digits := []byte{} + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + if neg { + return "-" + string(digits) + } + return string(digits) +} + +// --- plain (non Vim) editing --- + +func (e *Editor) plainKey(k Key) bool { + e.ClearMessage() + commitAfter := true + e.beginChange() + switch { + case k.Printable(): + e.insertPrintable(k.Rune) + commitAfter = false + case k.Is("enter"): + e.insertNewline() + case k.Is("backspace") || k.IsCtrl('h'): + e.insertBackspace() + commitAfter = false + case k.Is("delete"): + e.insertDelete() + case k.Is("shift+tab"): + e.shiftLines(e.cursor.Line, e.cursor.Line, false, 1) + e.cursor = e.buf.clampInsert(e.cursor) + case k.Is("tab"): + e.insertTab() + case k.Is("left"): + if e.cursor.Col > 0 { + e.cursor.Col-- + } else if e.cursor.Line > 0 { + e.cursor = Pos{e.cursor.Line - 1, e.buf.LineLen(e.cursor.Line - 1)} + } + e.desiredCol = -1 + case k.Is("right"): + if e.cursor.Col < e.buf.LineLen(e.cursor.Line) { + e.cursor.Col++ + } else if e.cursor.Line+1 < e.buf.LineCount() { + e.cursor = Pos{e.cursor.Line + 1, 0} + } + e.desiredCol = -1 + case k.Is("up"): + if e.cursor.Line > 0 { + if e.desiredCol < 0 { + e.desiredCol = e.cursor.Col + } + e.cursor = Pos{e.cursor.Line - 1, e.colForDesired(e.cursor.Line - 1)} + } + case k.Is("down"): + if e.cursor.Line+1 < e.buf.LineCount() { + if e.desiredCol < 0 { + e.desiredCol = e.cursor.Col + } + e.cursor = Pos{e.cursor.Line + 1, e.colForDesired(e.cursor.Line + 1)} + } + case k.Is("home") || k.IsCtrl('a'): + e.cursor.Col = 0 + case k.Is("end") || k.IsCtrl('e'): + e.cursor.Col = e.buf.LineLen(e.cursor.Line) + case k.Is("pgup"): + e.scrollPage(-1) + e.cursor = e.buf.clampInsert(e.cursor) + case k.Is("pgdn"): + e.scrollPage(1) + e.cursor = e.buf.clampInsert(e.cursor) + case k.IsCtrl('z'): + e.commitChange() + e.Undo() + e.cursor = e.buf.clampInsert(e.cursor) + return true + case k.IsCtrl('y'): + e.commitChange() + e.Redo() + e.cursor = e.buf.clampInsert(e.cursor) + return true + case k.IsCtrl('w'): + e.deleteWordBefore() + case k.IsCtrl('k'): + line := e.buf.Line(e.cursor.Line) + if e.cursor.Col < len(line) { + e.buf.DeleteRange(e.cursor, Pos{e.cursor.Line, len(line)}) + } else { + e.insertDelete() + } + default: + e.commitChange() + return false + } + if commitAfter { + e.commitChange() + } + e.ensureCursorVisible() + return true +} + +var _ = unicode.IsLetter diff --git a/internal/helix/keys.go b/internal/helix/keys.go new file mode 100644 index 0000000..300dac9 --- /dev/null +++ b/internal/helix/keys.go @@ -0,0 +1,208 @@ +// Package helix is a Helix editing engine over a plain text buffer: modes, +// counts, registers, motions, selections, text objects, undo, dot-repeat, +// macros, search and substitute, an ex command line, and heading folds. It +// knows nothing about terminals; the terminal UI feeds it keys and draws +// what it exposes. +package helix + +import ( + "strings" + "unicode" +) + +// Key is one keystroke, normalized. Printable keys carry a Rune; special +// keys carry a Name (esc, enter, tab, backspace, delete, up, down, left, +// right, home, end, pgup, pgdn, insert). The vim engine shares this +// terminal-facing key model so the TUI can drive either through one +// interface; its aliases keep the two packages signature-compatible. +type Key struct { + Rune rune + Name string + Ctrl bool + Alt bool + Shift bool +} + +// R is a plain rune key. +func R(r rune) Key { return Key{Rune: r} } + +// Ctrl is a control chord like Ctrl+d. +func Ctrl(r rune) Key { return Key{Rune: unicode.ToLower(r), Ctrl: true} } + +// Alt is an alt chord. +func Alt(r rune) Key { return Key{Rune: r, Alt: true} } + +// Special is a named key. +func Special(name string) Key { return Key{Name: name} } + +var ( + KeyEsc = Special("esc") + KeyEnter = Special("enter") + KeyTab = Special("tab") + KeyShiftTab = Key{Name: "tab", Shift: true} + KeyBackspace = Special("backspace") + KeyDelete = Special("delete") + KeyUp = Special("up") + KeyDown = Special("down") + KeyLeft = Special("left") + KeyRight = Special("right") + KeyHome = Special("home") + KeyEnd = Special("end") + KeyPgUp = Special("pgup") + KeyPgDn = Special("pgdn") +) + +// IsRune is true for the plain printable rune r. +func (k Key) IsRune(r rune) bool { + return k.Name == "" && !k.Ctrl && !k.Alt && k.Rune == r +} + +// IsCtrl is true for the control chord Ctrl+r. +func (k Key) IsCtrl(r rune) bool { + return k.Ctrl && !k.Alt && k.Rune == unicode.ToLower(r) +} + +// IsAlt is true for the alt chord Alt+r. +func (k Key) IsAlt(r rune) bool { + return k.Alt && !k.Ctrl && k.Rune == r +} + +// Is is true for a special key by name (shift-insensitive unless the +// name is "shift+tab"). +func (k Key) Is(name string) bool { + if name == "shift+tab" { + return k.Name == "tab" && k.Shift + } + return k.Name == name && !k.Ctrl && !k.Alt && !(name == "tab" && k.Shift) +} + +// Printable is true for a plain rune the user could type into text. +func (k Key) Printable() bool { + return k.Name == "" && !k.Ctrl && !k.Alt && k.Rune != 0 +} + +var specialNotation = map[string]string{ + "esc": "Esc", "enter": "CR", "tab": "Tab", "backspace": "BS", "delete": "Del", + "up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", + "end": "End", "pgup": "PageUp", "pgdn": "PageDown", "insert": "Insert", +} + +// String renders the key in Vim's notation: `a`, ``, ``, ``. +func (k Key) String() string { + var b strings.Builder + if k.Name != "" { + b.WriteString("<") + if k.Ctrl { + b.WriteString("C-") + } + if k.Alt { + b.WriteString("A-") + } + if k.Shift { + b.WriteString("S-") + } + if n, ok := specialNotation[k.Name]; ok { + b.WriteString(n) + } else { + b.WriteString(k.Name) + } + b.WriteString(">") + return b.String() + } + if k.Ctrl || k.Alt { + b.WriteString("<") + if k.Ctrl { + b.WriteString("C-") + } + if k.Alt { + b.WriteString("A-") + } + if k.Rune == ' ' { + b.WriteString("Space") + } else { + b.WriteRune(k.Rune) + } + b.WriteString(">") + return b.String() + } + if k.Rune == ' ' { + return "" + } + if k.Rune == '<' { + return "" + } + return string(k.Rune) +} + +var notationSpecial = map[string]Key{ + "esc": KeyEsc, "cr": KeyEnter, "enter": KeyEnter, "return": KeyEnter, "tab": KeyTab, + "s-tab": KeyShiftTab, "bs": KeyBackspace, "backspace": KeyBackspace, "del": KeyDelete, + "delete": KeyDelete, "up": KeyUp, "down": KeyDown, "left": KeyLeft, "right": KeyRight, + "home": KeyHome, "end": KeyEnd, "pageup": KeyPgUp, "pagedown": KeyPgDn, "space": R(' '), + "lt": R('<'), "bar": R('|'), "bslash": R('\\'), "nl": KeyEnter, +} + +// ParseKeys turns Vim notation into keys: `dwihello`, ``. +// An unterminated `<` is a literal less-than. +func ParseKeys(s string) []Key { + out := []Key{} + runes := []rune(s) + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '<' { + end := -1 + for j := i + 1; j < len(runes) && j < i+24; j++ { + if runes[j] == '>' { + end = j + break + } + } + if end > i+1 { + name := strings.ToLower(string(runes[i+1 : end])) + if k, ok := parseNotation(name); ok { + out = append(out, k) + i = end + continue + } + } + } + out = append(out, R(r)) + } + return out +} + +func parseNotation(name string) (Key, bool) { + if k, ok := notationSpecial[name]; ok { + return k, true + } + if strings.HasPrefix(name, "c-") && len(name) > 2 { + rest := name[2:] + if k, ok := notationSpecial[rest]; ok { + k.Ctrl = true + return k, true + } + if r := []rune(rest); len(r) == 1 { + return Ctrl(r[0]), true + } + } + if strings.HasPrefix(name, "a-") || strings.HasPrefix(name, "m-") { + rest := name[2:] + if k, ok := notationSpecial[rest]; ok { + k.Alt = true + return k, true + } + if r := []rune(rest); len(r) == 1 { + return Alt(r[0]), true + } + } + return Key{}, false +} + +// KeysString renders a key sequence in notation. +func KeysString(keys []Key) string { + var b strings.Builder + for _, k := range keys { + b.WriteString(k.String()) + } + return b.String() +} diff --git a/internal/helix/matchmode.go b/internal/helix/matchmode.go new file mode 100644 index 0000000..92207eb --- /dev/null +++ b/internal/helix/matchmode.go @@ -0,0 +1,334 @@ +package helix + +import "strings" + +// Match mode (m): bracket matching, text-object selection, and surround +// editing — Helix's take on what Vim does with `i`/`a` objects and its +// surround plugin. + +func (e *Editor) matchMode(rest []Key) (parseStatus, bool) { + if len(rest) < 2 { + return parseIncomplete, false + } + k := rest[1] + if !k.Printable() { + return parseInvalid, false + } + switch k.Rune { + case 'm': + // Match the bracket under the cursor (Helix's m m). + p, ok := e.bracketMatch(e.cursor) + if !ok { + e.setError("No matching pair") + return parseComplete, false + } + e.pushJump() + e.cursor = e.buf.clampNormal(p) + e.desiredCol = -1 + e.ensureCursorVisible() + return parseComplete, false + case 'i', 'a': + inner := k.Rune == 'i' + if len(rest) < 3 { + return parseIncomplete, false + } + obj, st := parseTextObject(rest[1:]) + if st != parseComplete { + if st == parseIncomplete { + return parseIncomplete, false + } + // `t` and friends need one more key; anything else is invalid. + return parseInvalid, false + } + if e.mode == ModeSelect { + e.extendByObject(obj, 1) + return parseComplete, false + } + rng := e.textObjectRange(obj, 1) + if !rng.ok { + e.setError("No matching object") + return parseComplete, false + } + start, end := rng.start, rng.end + if rng.linewise { + start = Pos{start.Line, 0} + end = Pos{end.Line, max(0, e.buf.LineLen(end.Line)-1)} + } + e.mode = ModeSelect + if rng.linewise { + e.kind = selLine + } else { + e.kind = selChar + } + e.selExcl = false + e.anchor = e.buf.clampNormal(start) + e.cursor = e.buf.clampNormal(end) + e.desiredCol = -1 + e.pending = nil + _ = inner + return parseComplete, false + case 's', 'd', 'r': + if len(rest) < 3 { + return parseIncomplete, false + } + if !rest[2].Printable() { + return parseInvalid, false + } + return e.surroundCommand(k.Rune, rest[2].Rune, rest) + } + return parseInvalid, false +} + +// --- surround editing --- + +var surroundPairs = map[rune][2]rune{ + '(': {'(', ')'}, ')': {'(', ')'}, + '[': {'[', ']'}, ']': {'[', ']'}, + '{': {'{', '}'}, '}': {'{', '}'}, + '<': {'<', '>'}, '>': {'<', '>'}, + '"': {'"', '"'}, '\'': {'\'', '\''}, + '`': {'`', '`'}, +} + +// surroundCommand edits the delimiters around the selection: add (s), +// delete (d) or replace (r) them. `r` wants two keys (old, new). +func (e *Editor) surroundCommand(action, ch rune, rest []Key) (parseStatus, bool) { + oldPair, ok := surroundPairs[ch] + if !ok { + return parseInvalid, false + } + pair := oldPair + if action == 'r' { + if len(rest) < 4 || !rest[3].Printable() { + return parseIncomplete, false + } + newPair, ok := surroundPairs[rest[3].Rune] + if !ok { + return parseInvalid, false + } + pair = newPair + } + from, to := e.selRange() + // For whole lines the delimiters sit at the line edges. + left, right := from, to + if e.mode == ModeSelect && e.kind == selLine { + left = Pos{from.Line, 0} + right = Pos{to.Line, e.buf.LineLen(to.Line)} + } else { + right = e.stepInsertPos(to) + } + openPos, closePos, ok := e.enclosingPair(oldPair[0], oldPair[1], left, right) + e.beginChange() + switch action { + case 's': + // Add: wrap the selection. Later edits shift columns, so insert the + // closing delimiter first. + e.buf.InsertText(right, string(pair[1])) + e.buf.InsertText(left, string(pair[0])) + e.cursor = e.buf.clampNormal(left) + case 'd': + if !ok { + e.setError("No surrounding pair") + return parseComplete, false + } + e.buf.DeleteRange(closePos, e.stepInsertPos(closePos)) + e.buf.DeleteRange(openPos, e.stepInsertPos(openPos)) + e.cursor = e.buf.clampNormal(openPos) + case 'r': + if !ok { + e.setError("No surrounding pair") + return parseComplete, false + } + e.buf.DeleteRange(closePos, e.stepInsertPos(closePos)) + e.buf.InsertText(closePos, string(pair[1])) + e.buf.DeleteRange(openPos, e.stepInsertPos(openPos)) + e.buf.InsertText(openPos, string(pair[0])) + e.cursor = e.buf.clampNormal(openPos) + } + e.cursor = e.buf.clampNormal(e.cursor) + e.anchor = e.cursor + if e.mode == ModeSelect { + e.exitSelect() + } + e.commitChange() + return parseComplete, false +} + +// enclosingPair finds the innermost open..close pair of the given kind that +// surrounds the range start..end (end exclusive). +func (e *Editor) enclosingPair(open, close rune, start, end Pos) (openPos, closePos Pos, ok bool) { + // Walk backwards from start for an opener whose match lands after end. + p := start + nesting := 0 + for p.Line >= 0 { + for p.Col >= 0 { + p.Col-- + if p.Col < 0 { + break + } + r := e.buf.Line(p.Line)[p.Col] + if r == close && open != close { + nesting++ + } else if r == open { + if nesting == 0 { + match, found := e.bracketMatchPos(p, open, close) + if found && (match.Line > end.Line || (match.Line == end.Line && match.Col >= end.Col)) { + return p, match, true + } + // This opener does not enclose the range; it is part of + // the selection's own text. Keep looking outward. + } else { + nesting-- + } + } + } + p.Line-- + if p.Line < 0 { + break + } + p.Col = e.buf.LineLen(p.Line) + } + // Quote-like pairs are their own opener; scan for the nearest one + // before the selection whose twin lands after it. + if open == close { + p = start + for p.Line >= 0 { + for p.Col >= 0 { + p.Col-- + if p.Col < 0 { + break + } + if e.buf.Line(p.Line)[p.Col] == open { + match, found := e.quoteMatchPos(p, open) + if found && (match.Line > end.Line || (match.Line == end.Line && match.Col >= end.Col)) { + return p, match, true + } + } + } + p.Line-- + if p.Line < 0 { + break + } + p.Col = e.buf.LineLen(p.Line) + } + } + return Pos{}, Pos{}, false +} + +// bracketMatchPos matches one opener forward through the buffer. +func (e *Editor) bracketMatchPos(from Pos, open, close rune) (Pos, bool) { + depth := 0 + p := from + for p.Line < e.buf.LineCount() { + line := e.buf.Line(p.Line) + for p.Col < len(line) { + r := line[p.Col] + if r == open { + depth++ + } else if r == close { + depth-- + if depth == 0 { + return p, true + } + } + p.Col++ + } + p.Line++ + p.Col = 0 + } + return Pos{}, false +} + +// quoteMatchPos matches a quote-like delimiter forward, skipping escaped +// occurrences. +func (e *Editor) quoteMatchPos(from Pos, q rune) (Pos, bool) { + p := from + first := true + for p.Line < e.buf.LineCount() { + line := e.buf.Line(p.Line) + for p.Col < len(line) { + r := line[p.Col] + if r == '\\' && q != '\\' { + p.Col += 2 + continue + } + if r == q { + if first { + first = false + } else { + return p, true + } + } + p.Col++ + } + p.Line++ + p.Col = 0 + } + return Pos{}, false +} + +// bracketMatch returns the position of the bracket matching the one at or +// after the cursor on the current line, scanning brackets anywhere in the +// buffer when the cursor sits on one. +func (e *Editor) bracketMatch(from Pos) (Pos, bool) { + line := e.buf.Line(from.Line) + opens := "([{<" + closes := ")]}>" + c := from.Col + if c < len(line) { + if i := strings.IndexRune(opens, line[c]); i >= 0 { + if p, ok := e.bracketMatchPos(from, rune(opens[i]), rune(closes[i])); ok { + return p, true + } + } else if i := strings.IndexRune(closes, line[c]); i >= 0 { + // Walk back for this closer's opener. + if p, ok := e.bracketMatchReverse(from, rune(opens[i]), rune(closes[i])); ok { + return p, true + } + } + } + // Not on a bracket: try the next one on the line, then the previous. + for i := c + 1; i < len(line); i++ { + if j := strings.IndexRune(opens, line[i]); j >= 0 { + p := Pos{from.Line, i} + if m, ok := e.bracketMatchPos(p, rune(opens[j]), rune(closes[j])); ok { + return m, true + } + } + } + for i := c - 1; i >= 0; i-- { + if j := strings.IndexRune(closes, line[i]); j >= 0 { + p := Pos{from.Line, i} + if m, ok := e.bracketMatchReverse(p, rune(opens[j]), rune(closes[j])); ok { + return m, true + } + } + } + return Pos{}, false +} + +// bracketMatchReverse matches one closer backwards through the buffer. +func (e *Editor) bracketMatchReverse(from Pos, open, close rune) (Pos, bool) { + depth := 0 + p := from + for p.Line >= 0 { + line := e.buf.Line(p.Line) + for p.Col >= 0 && p.Col < len(line) { + r := line[p.Col] + if r == close { + depth++ + } else if r == open { + depth-- + if depth == 0 { + return p, true + } + } + p.Col-- + } + p.Line-- + if p.Line >= 0 { + p.Col = e.buf.LineLen(p.Line) - 1 + } + } + return Pos{}, false +} diff --git a/internal/helix/motions.go b/internal/helix/motions.go new file mode 100644 index 0000000..adbca19 --- /dev/null +++ b/internal/helix/motions.go @@ -0,0 +1,1137 @@ +package helix + +import ( + "math" + "regexp" + "strings" + "unicode" +) + +// motion is a parsed cursor movement. +type motion struct { + kind string + arg rune +} + +// motionResult is where a motion lands and how an operator treats it. +type motionResult struct { + pos Pos + linewise bool + // inclusive marks a motion whose target character is part of the + // selection: an operator's span covers it and a selection includes it. + inclusive bool + keepCol bool + stickyEnd bool + jump bool + // matchEnd is the exclusive end of the search match for `n`/`N`, so the + // motion can select the whole match the way Helix does. + matchEnd Pos + matched bool + // anchor overrides where a selection starts. A word motion that + // crosses to the next line starts the selection there, like Helix. + anchor Pos + hasAnchor bool + // selStart is where a forward word motion starts its selection: the + // first character of the word under the cursor, so the selection + // covers whole words. + selStart Pos + hasSelStart bool + // wordEnd is where the selection of a backward word motion ends: the + // word it lands on, plus the space that follows it. + wordEnd Pos + hasWordEnd bool + // exclusiveLinewise applies Vim's rule: an exclusive motion ending in + // column 0 pulls back to the end of the previous line, and becomes + // linewise when it also started at or before the first non-blank. + failed bool +} + +var charArgMotions = map[rune]bool{'f': true, 'F': true, 't': true, 'T': true} + +// parseMotion reads a motion off the front of keys. +func parseMotion(keys []Key) (motion, parseStatus) { + if len(keys) == 0 { + return motion{}, parseIncomplete + } + k := keys[0] + switch { + case k.Is("left") || k.Is("backspace") || k.IsCtrl('h'): + return motion{kind: "h"}, parseComplete + case k.Is("right") || (k.IsRune(' ')): + return motion{kind: "l"}, parseComplete + case k.Is("down") || k.IsCtrl('j') || k.IsCtrl('n'): + return motion{kind: "j"}, parseComplete + case k.Is("up") || k.IsCtrl('p'): + return motion{kind: "k"}, parseComplete + case k.Is("enter") || k.IsCtrl('m'): + return motion{kind: "+"}, parseComplete + case k.Is("home"): + return motion{kind: "0"}, parseComplete + case k.Is("end"): + return motion{kind: "$"}, parseComplete + } + if !k.Printable() { + return motion{}, parseInvalid + } + switch k.Rune { + case 'h', 'l', 'j', 'k', '0', '^', '$', '|', 'w', 'W', 'b', 'B', 'e', 'E', 'G', 'H', 'M', 'L', + '{', '}', '(', ')', 'n', 'N', '_', '-', '+': + return motion{kind: string(k.Rune)}, parseComplete + case 'f', 'F', 't', 'T': + if len(keys) < 2 { + return motion{}, parseIncomplete + } + if !keys[1].Printable() { + return motion{}, parseInvalid + } + return motion{kind: string(k.Rune), arg: keys[1].Rune}, parseComplete + case 'g': + if len(keys) < 2 { + return motion{}, parseIncomplete + } + if !keys[1].Printable() { + return motion{}, parseInvalid + } + switch keys[1].Rune { + case 'g', 'j', 'k': + return motion{kind: "g" + string(keys[1].Rune)}, parseComplete + } + return motion{}, parseInvalid + case ']', '[': + if len(keys) < 2 { + return motion{}, parseIncomplete + } + if !keys[1].Printable() { + return motion{}, parseInvalid + } + second := keys[1].Rune + switch second { + case ']', '[', '(', ')', '{', '}', 's': + return motion{kind: string(k.Rune) + string(second)}, parseComplete + } + return motion{}, parseInvalid + } + return motion{}, parseInvalid +} + +// applyMotion computes where a motion lands from `from`. forOperator marks +// operator-pending use, where a few motions behave differently (cw, e). +func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOperator bool) (motionResult, bool) { + n := max(1, count) + line := e.buf.Line(from.Line) + lineCount := e.buf.LineCount() + res := motionResult{pos: from} + if e.hooks.DisplayRowBounds != nil && ((m.kind == "$" && n == 1 && e.opts.WrappedLineMotions) || m.kind == "g$" || m.kind == "g0" || m.kind == "g^") { + start, end := e.hooks.DisplayRowBounds(from) + res.pos.Col = start + if m.kind == "$" || m.kind == "g$" { + res.pos.Col = max(start, end-1) + res.inclusive = true + if forOperator { + res.pos.Col = end + res.inclusive = false + } + } else if m.kind == "g^" { + for res.pos.Col < end && (line[res.pos.Col] == ' ' || line[res.pos.Col] == '\t') { + res.pos.Col++ + } + } + return res, true + } + switch m.kind { + case "h": + if from.Col == 0 { + return res, false + } + res.pos.Col = max(0, from.Col-n) + case "l": + limit := len(line) - 1 + if forOperator || e.inSelect() { + limit = len(line) + } + if from.Col >= limit && !(forOperator && from.Col < len(line)) { + if from.Col >= len(line) { + return res, false + } + } + res.pos.Col = min(from.Col+n, max(0, limit)) + if !forOperator && !e.inSelect() && res.pos.Col == from.Col { + return res, false + } + case "j": + target := from.Line + for i := 0; i < n; i++ { + next := e.nextVisibleLine(target) + if next >= lineCount { + break + } + target = next + } + if target == from.Line && !forOperator { + return res, false + } + res.pos = Pos{target, e.colForDesired(target)} + res.linewise, res.keepCol = true, true + case "k": + target := from.Line + for i := 0; i < n; i++ { + prev := e.prevVisibleLine(target) + if prev < 0 { + break + } + target = prev + } + if target == from.Line && !forOperator { + return res, false + } + res.pos = Pos{target, e.colForDesired(target)} + res.linewise, res.keepCol = true, true + case "gj", "gk": + // Display-line motions: the host wraps, so without a wrap width + // they are j/k. + dir := 1 + if m.kind == "gk" { + dir = -1 + } + target := from.Line + dir*n + target = min(max(0, target), lineCount-1) + res.pos = Pos{target, e.colForDesired(target)} + res.keepCol = true + case "0", "g0": + res.pos.Col = 0 + case "^", "g^": + res.pos.Col = firstNonBlankInsert(line) + if !forOperator { + res.pos.Col = firstNonBlank(line) + } + case "$", "g$": + target := min(from.Line+n-1, lineCount-1) + res.pos = Pos{target, max(0, e.buf.LineLen(target)-1)} + if forOperator { + res.pos.Col = e.buf.LineLen(target) + } + res.inclusive, res.stickyEnd = true, true + if forOperator { + res.inclusive = false + } + case "g_": + target := min(from.Line+n-1, lineCount-1) + l := e.buf.Line(target) + col := len(l) - 1 + for col > 0 && isBlank(l[col]) { + col-- + } + res.pos = Pos{target, max(0, col)} + res.inclusive = true + case "gm": + res.pos.Col = min(len(line)/2, max(0, len(line)-1)) + case "|": + col := 0 + if hasCount { + col = count - 1 + } + res.pos.Col = min(max(0, col), max(0, len(line)-1)) + case "w", "W": + big := m.kind == "W" + p := from + inclusive := false + for i := 0; i < n; i++ { + var next Pos + var ok bool + if forOperator { + next, ok = e.wordForward(p, big, i == n-1) + } else { + // The word motion follows Helix: the head lands on the + // first character of the next word, which stays out of + // the selection. The last word of a line runs to the end + // of the line, and the next motion crosses to the next + // line. + next, ok, inclusive = e.wordForwardStart(p, big) + } + if !ok { + break + } + p = next + } + if !forOperator && p == from { + // The word motion had nothing to select: the cursor and the + // selection stay where they are. + e.motionSelected = true + return res, false + } + res.pos = p + res.inclusive = inclusive + if !forOperator && p.Line > from.Line { + // The motion crossed to the next line: the selection starts + // there, like Helix. + res.anchor = Pos{p.Line, 0} + res.hasAnchor = true + } else if !forOperator { + // The selection starts at the first character of the word + // under the cursor: a word motion covers whole words, so + // the word it moves over is never cut in half. + if start, ok := e.wordStart(from, big); ok { + res.selStart = start + res.hasSelStart = true + } + } + if forOperator && p.Line > from.Line && p.Col == 0 { + // `dw` on the last word of a line stops at the line end. + prevLine := p.Line - 1 + res.pos = Pos{prevLine, e.buf.LineLen(prevLine)} + if res.pos.Line == from.Line && res.pos.Col <= from.Col { + res.pos = p + } + } + case "b", "B": + big := m.kind == "B" + p := from + for i := 0; i < n; i++ { + next, ok := e.wordBackward(p, big) + if !ok { + break + } + p = next + } + if !forOperator && p == from { + // There is no word before the cursor: the cursor and the + // selection stay where they are. + e.motionSelected = true + return res, false + } + res.pos = p + if !forOperator { + // The selection covers the word the motion lands on, plus the + // space that follows it. + res.wordEnd = e.wordSelectionEnd(p, from, big) + res.hasWordEnd = true + } + case "e", "E": + big := m.kind == "E" + p := from + for i := 0; i < n; i++ { + next, ok := e.wordEnd(p, big) + if !ok { + break + } + p = next + } + res.pos = p + res.inclusive = true + case "ge", "gE": + big := m.kind == "gE" + p := from + for i := 0; i < n; i++ { + next, ok := e.wordEndBackward(p, big) + if !ok { + break + } + p = next + } + res.pos = p + res.inclusive = true + case "gg": + target := 0 + if hasCount { + target = min(max(0, count-1), lineCount-1) + } + res.pos = Pos{target, firstNonBlank(e.buf.Line(target))} + res.linewise, res.jump = true, true + case "G": + target := lineCount - 1 + if hasCount { + target = min(max(0, count-1), lineCount-1) + } + res.pos = Pos{target, firstNonBlank(e.buf.Line(target))} + res.linewise, res.jump = true, true + case "H", "M", "L": + top, bottom := e.scrollTop, e.lastVisibleLine() + var target int + switch m.kind { + case "H": + target = min(top+n-1, bottom) + case "L": + target = max(bottom-n+1, top) + default: + target = (top + bottom) / 2 + } + res.pos = Pos{target, firstNonBlank(e.buf.Line(target))} + res.linewise, res.jump = true, true + case "_": + target := min(from.Line+n-1, lineCount-1) + res.pos = Pos{target, firstNonBlank(e.buf.Line(target))} + res.linewise = true + case "+": + target := min(from.Line+n, lineCount-1) + if target == from.Line { + return res, false + } + res.pos = Pos{target, firstNonBlank(e.buf.Line(target))} + res.linewise = true + case "-": + target := max(from.Line-n, 0) + if target == from.Line { + return res, false + } + res.pos = Pos{target, firstNonBlank(e.buf.Line(target))} + res.linewise = true + case "}", "{": + dir := 1 + if m.kind == "{" { + dir = -1 + } + p := from + for i := 0; i < n; i++ { + p = e.paragraphMove(p, dir) + } + res.pos = p + res.jump = true + case ")", "(": + dir := 1 + if m.kind == "(" { + dir = -1 + } + p := from + for i := 0; i < n; i++ { + p = e.sentenceMove(p, dir) + } + res.pos = p + res.jump = true + case "f", "F", "t", "T": + p, ok := e.findChar(from, m.kind[0], m.arg, n) + if !ok { + return res, false + } + res.pos = p + res.inclusive = m.kind == "f" || m.kind == "t" + e.lastFind.ch, e.lastFind.kind, e.lastFind.set = m.arg, rune(m.kind[0]), true + case "n", "N": + dir := e.searchDir + if m.kind == "N" { + dir = -dir + } + p, end, ok := e.searchNextRange(from, dir, n, true) + if !ok { + return res, false + } + res.pos, res.matchEnd, res.matched, res.jump = p, end, true, true + case "]]", "[[": + dir := 1 + if m.kind == "[[" { + dir = -1 + } + res.pos = e.headingMove(from, dir, n) + res.jump = true + res.linewise = false + case "](", "[(", "]{", "[{", "])", "[}": + open, close := '(', ')' + if strings.ContainsAny(m.kind, "{}") { + open, close = '{', '}' + } + var p Pos + var ok bool + if m.kind[0] == '[' { + p, ok = e.findUnmatched(from, open, close, -1, n) + } else { + p, ok = e.findUnmatched(from, open, close, 1, n) + } + if !ok { + return res, false + } + res.pos, res.jump, res.inclusive = p, true, true + case "]s", "[s": + return res, false + default: + return res, false + } + return res, true +} + +func (e *Editor) inSelect() bool { + return e.mode == ModeSelect +} + +func (e *Editor) colForDesired(line int) int { + l := e.buf.Line(line) + want := e.desiredCol + if want < 0 { + want = e.cursor.Col + } + if e.mode == ModeInsert { + return min(want, len(l)) + } + if want == math.MaxInt32 { + return max(0, len(l)-1) + } + return min(want, max(0, len(l)-1)) +} + +// --- words --- + +func (e *Editor) runeAt(p Pos) (rune, bool) { + line := e.buf.Line(p.Line) + if p.Col < 0 || p.Col >= len(line) { + return 0, false + } + return line[p.Col], true +} + +// wordForward moves to the start of the next word; a blank line counts as a +// word. lastForOperator stops an operator's final step at line end instead +// of crossing into the next line. +func (e *Editor) wordForward(p Pos, big bool, lastForOperator bool) (Pos, bool) { + line := e.buf.Line(p.Line) + lineCount := e.buf.LineCount() + col := p.Col + if col < len(line) { + cls := charClass(line[col], big) + if cls != 0 { + for col < len(line) && charClass(line[col], big) == cls { + col++ + } + } + for col < len(line) && isBlank(line[col]) { + col++ + } + if col < len(line) { + return Pos{p.Line, col}, true + } + } + if lastForOperator { + return Pos{p.Line, len(line)}, true + } + // Next lines: the first non-blank, or a blank line. + for l := p.Line + 1; l < lineCount; l++ { + next := e.buf.Line(l) + if len(next) == 0 { + return Pos{l, 0}, true + } + c := 0 + for c < len(next) && isBlank(next[c]) { + c++ + } + if c < len(next) { + return Pos{l, c}, true + } + } + last := lineCount - 1 + end := Pos{last, max(0, e.buf.LineLen(last)-1)} + if end == p { + return p, false + } + return end, true +} + +// wordForwardStart finds the start of the next word, following Helix. The +// head lands on the first character of the next word, which stays out of +// the selection. The word under the cursor can be the last one on its line: +// then the head lands on the line end and the selection covers the rest of +// the line. From the end of a line the motion crosses to the next line. +func (e *Editor) wordForwardStart(p Pos, big bool) (pos Pos, moved bool, inclusive bool) { + line := e.buf.Line(p.Line) + col := min(p.Col, len(line)) + if col >= len(line) { + // The cursor sits past the last character: the motion crosses to + // the next line. + return e.wordForwardNextLine(p.Line+1, big) + } + // The end of the word under the cursor, if it is on one. + wordEnd := col + if cls := charClass(line[col], big); cls != 0 { + for wordEnd < len(line) && charClass(line[wordEnd], big) == cls { + wordEnd++ + } + } + // The next word on the line. + next := wordEnd + for next < len(line) && isBlank(line[next]) { + next++ + } + if next < len(line) { + // The next word starts here: the selection stops short of it. + return Pos{p.Line, next}, true, false + } + if wordEnd > col && col < len(line)-1 { + // The word under the cursor is the last one on the line: the + // selection covers the rest of the line, the last character + // included. + return Pos{p.Line, len(line) - 1}, true, true + } + // The cursor is at the end of the line, or in the space behind the + // last word: the motion crosses to the next line. + return e.wordForwardNextLine(p.Line+1, big) +} + +// wordForwardNextLine finds the first word on one of the lines after line. +// Helix skips the lines that hold no word. +func (e *Editor) wordForwardNextLine(line int, big bool) (Pos, bool, bool) { + for ; line < e.buf.LineCount(); line++ { + l := e.buf.Line(line) + for col := 0; col < len(l); col++ { + if charClass(l[col], big) != 0 { + // The first word of the line starts here. + return Pos{line, col}, true, false + } + } + } + return Pos{}, false, false +} + +// wordStart is the first character of the word under p. It reports false +// when the cursor does not sit on a word. +func (e *Editor) wordStart(p Pos, big bool) (Pos, bool) { + line := e.buf.Line(p.Line) + if p.Col >= len(line) { + return p, false + } + cls := charClass(line[p.Col], big) + if cls == 0 { + // The cursor is on a blank, not on a word. + return p, false + } + col := p.Col + for col > 0 && charClass(line[col-1], big) == cls { + col-- + } + return Pos{p.Line, col}, true +} + +// wordSelectionEnd is the last character of the selection a backward word +// motion makes. The selection covers the word the motion landed on and the +// space that follows it; the words it travelled over follow that word, as +// long as the cursor does not cut them in half. +func (e *Editor) wordSelectionEnd(head, anchor Pos, big bool) Pos { + line := e.buf.Line(head.Line) + if len(line) == 0 { + // The motion landed on an empty line: the "word" is the line + // break itself, so the selection ends on the line's one position. + return Pos{head.Line, 0} + } + limit := len(line) + if anchor.Line == head.Line { + // The selection stops before the character under the cursor. + limit = min(anchor.Col, len(line)) + } + end := min(head.Col, len(line)) + // The word the motion landed on is always part of the selection. + cls := charClass(line[end], big) + for end < len(line) && charClass(line[end], big) == cls { + end++ + } + // The words the motion travelled over follow it. + for end < limit { + cls := charClass(line[end], big) + if cls == 0 { + // A blank belongs to the space after the word. + end++ + continue + } + wordEnd := end + for wordEnd < limit && charClass(line[wordEnd], big) == cls { + wordEnd++ + } + if wordEnd == limit && wordEnd < len(line) && charClass(line[wordEnd], big) == cls { + // The word runs on past the cursor: leave it out. + break + } + end = wordEnd + } + // The space after the last word is part of the selection. + if end < len(line) && isBlank(line[end]) { + end++ + } + return Pos{head.Line, end - 1} +} + +func (e *Editor) wordBackward(p Pos, big bool) (Pos, bool) { + l, col := p.Line, p.Col + for { + line := e.buf.Line(l) + col = min(col, len(line)) + // Step back over blanks. + c := col - 1 + for c >= 0 && isBlank(line[c]) { + c-- + } + if c < 0 { + if l == 0 { + if p.Col == 0 && p.Line == 0 { + return p, false + } + return Pos{0, 0}, true + } + l-- + col = e.buf.LineLen(l) + if col == 0 { + return Pos{l, 0}, true + } + continue + } + cls := charClass(line[c], big) + for c > 0 && charClass(line[c-1], big) == cls { + c-- + } + return Pos{l, c}, true + } +} + +func (e *Editor) wordEnd(p Pos, big bool) (Pos, bool) { + l, col := p.Line, p.Col+1 + for l < e.buf.LineCount() { + line := e.buf.Line(l) + for col < len(line) && isBlank(line[col]) { + col++ + } + if col < len(line) { + cls := charClass(line[col], big) + for col+1 < len(line) && charClass(line[col+1], big) == cls { + col++ + } + return Pos{l, col}, true + } + l++ + col = 0 + } + return p, false +} + +func (e *Editor) wordEndBackward(p Pos, big bool) (Pos, bool) { + l, col := p.Line, p.Col + line := e.buf.Line(l) + if col < len(line) { + cls := charClass(line[col], big) + if cls != 0 { + for col > 0 && charClass(line[col-1], big) == cls { + col-- + } + } + } + col-- + for { + if col < 0 { + if l == 0 { + return Pos{0, 0}, p != Pos{} + } + l-- + line = e.buf.Line(l) + col = len(line) - 1 + if len(line) == 0 { + return Pos{l, 0}, true + } + continue + } + line = e.buf.Line(l) + for col >= 0 && isBlank(line[col]) { + col-- + } + if col < 0 { + continue + } + return Pos{l, col}, true + } +} + +// --- paragraphs, sentences, headings --- + +func (e *Editor) paragraphMove(p Pos, dir int) Pos { + l := p.Line + lineCount := e.buf.LineCount() + // Skip blank lines we are on, then find the next blank. + for l+dir >= 0 && l+dir < lineCount && isBlankLine(e.buf.Line(l)) { + l += dir + } + for l+dir >= 0 && l+dir < lineCount { + l += dir + if isBlankLine(e.buf.Line(l)) { + return Pos{l, 0} + } + } + if dir > 0 { + return Pos{lineCount - 1, max(0, e.buf.LineLen(lineCount-1)-1)} + } + return Pos{0, 0} +} + +func (e *Editor) sentenceMove(p Pos, dir int) Pos { + starts := e.sentenceStarts() + if dir > 0 { + for _, s := range starts { + if p.Less(s) { + return s + } + } + last := e.buf.LineCount() - 1 + return Pos{last, max(0, e.buf.LineLen(last)-1)} + } + for i := len(starts) - 1; i >= 0; i-- { + if starts[i].Less(p) { + return starts[i] + } + } + return Pos{0, 0} +} + +// sentenceStarts lists every sentence start: the first non-blank after a +// paragraph boundary or after `.`, `!`, `?` followed by whitespace. +func (e *Editor) sentenceStarts() []Pos { + out := []Pos{} + prevBlank := true + for l := 0; l < e.buf.LineCount(); l++ { + line := e.buf.Line(l) + if isBlankLine(line) { + if !prevBlank { + out = append(out, Pos{l, 0}) + } + prevBlank = true + continue + } + start := firstNonBlank(line) + if prevBlank { + out = append(out, Pos{l, start}) + } + prevBlank = false + for c := start; c < len(line); c++ { + r := line[c] + if r == '.' || r == '!' || r == '?' { + k := c + 1 + for k < len(line) && (line[k] == ')' || line[k] == ']' || line[k] == '"' || line[k] == '\'') { + k++ + } + if k >= len(line) { + if l+1 < e.buf.LineCount() && !isBlankLine(e.buf.Line(l+1)) { + next := e.buf.Line(l + 1) + out = append(out, Pos{l + 1, firstNonBlank(next)}) + } + break + } + if isBlank(line[k]) { + for k < len(line) && isBlank(line[k]) { + k++ + } + if k < len(line) { + out = append(out, Pos{l, k}) + } + } + } + } + } + return out +} + +var headingLineRe = regexp.MustCompile(`^ {0,3}#{1,6}[ \t]`) + +// headingLines lists heading lines outside fenced code and frontmatter. +func (e *Editor) headingLines() []int { + out := []int{} + inFence := false + fenceMarker := "" + start := 0 + if e.buf.LineCount() > 0 && e.buf.LineString(0) == "---" { + for i := 1; i < e.buf.LineCount(); i++ { + if e.buf.LineString(i) == "---" { + start = i + 1 + break + } + } + } + for i := start; i < e.buf.LineCount(); i++ { + line := strings.TrimLeft(e.buf.LineString(i), " \t") + if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") { + marker := line[:3] + if !inFence { + inFence, fenceMarker = true, marker + } else if marker == fenceMarker { + inFence, fenceMarker = false, "" + } + continue + } + if inFence { + continue + } + if headingLineRe.MatchString(e.buf.LineString(i)) { + out = append(out, i) + } + } + return out +} + +func (e *Editor) headingMove(from Pos, dir, n int) Pos { + headings := e.headingLines() + var ahead []int + if dir > 0 { + for _, h := range headings { + if h > from.Line { + ahead = append(ahead, h) + } + } + } else { + for i := len(headings) - 1; i >= 0; i-- { + if headings[i] < from.Line { + ahead = append(ahead, headings[i]) + } + } + } + if len(ahead) == 0 { + if dir > 0 { + return Pos{e.buf.LineCount() - 1, 0} + } + return Pos{0, 0} + } + return Pos{ahead[min(n, len(ahead))-1], 0} +} + +// --- find char --- + +// findChar searches for the n-th occurrence of ch. Helix searches the whole +// document, continuing onto following lines (and backwards onto earlier +// ones); the result stays on the found character. +func (e *Editor) findChar(from Pos, kind byte, ch rune, n int) (Pos, bool) { + p := from + for i := 0; i < n; i++ { + next, ok := e.findCharStep(p, kind, ch, i == 0) + if !ok { + return from, false + } + p = next + } + return p, true +} + +// findCharStep finds the next occurrence after (or before) p in the search +// direction for kind. first skips the "stay put" case for t/T on the very +// first step only when the adjacent char matches. +func (e *Editor) findCharStep(from Pos, kind byte, ch rune, first bool) (Pos, bool) { + forward := kind == 'f' || kind == 't' + p := from + if forward { + p.Col++ + } else { + p.Col-- + } + for p.Line >= 0 && p.Line < e.buf.LineCount() { + line := e.buf.Line(p.Line) + if forward { + for c := p.Col; c < len(line); c++ { + if line[c] == ch { + if kind == 't' { + if c == from.Col+1 && first && len(line) > c { + // `t` onto an adjacent char: Helix lands just + // before it, i.e. stays. Step past it for + // repeats. + continue + } + return Pos{p.Line, c - 1}, true + } + return Pos{p.Line, c}, true + } + } + } else { + for c := min(p.Col, len(line)-1); c >= 0; c-- { + if line[c] == ch { + if kind == 'T' { + return Pos{p.Line, c + 1}, true + } + return Pos{p.Line, c}, true + } + } + } + if forward { + p.Line++ + } else { + p.Line-- + } + if p.Line >= 0 && p.Line < e.buf.LineCount() { + if forward { + p.Col = 0 + } else { + p.Col = e.buf.LineLen(p.Line) + } + } + } + return Pos{}, false +} + +// repeatFindChar repeats the last f/t/F/T with the given direction (1 for +// the same way, -1 reversed — Helix's Alt-. and the ZenNotes , key). +func (e *Editor) repeatFindChar(dir int, n int) { + if !e.lastFind.set { + return + } + kind := e.lastFind.kind + if dir < 0 { + kind = map[rune]rune{'f': 'F', 'F': 'f', 't': 'T', 'T': 't'}[kind] + } + p, ok := e.findChar(e.cursor, byte(kind), e.lastFind.ch, max(1, n)) + if !ok { + e.setError("E486: Character not found: " + string(e.lastFind.ch)) + return + } + e.cursor = e.buf.clampNormal(p) + e.desiredCol = -1 + e.ensureCursorVisible() +} + +// --- brackets --- + +var pairs = map[rune]rune{'(': ')', '[': ']', '{': '}', ')': '(', ']': '[', '}': '{'} + +// step moves one character forward or backward across lines (an empty line +// counts as a position). +func (e *Editor) step(p Pos, dir int) (Pos, bool) { + if dir > 0 { + if p.Col+1 < e.buf.LineLen(p.Line) { + return Pos{p.Line, p.Col + 1}, true + } + if p.Line+1 < e.buf.LineCount() { + return Pos{p.Line + 1, 0}, true + } + return p, false + } + if p.Col > 0 { + return Pos{p.Line, min(p.Col-1, max(0, e.buf.LineLen(p.Line)-1))}, true + } + if p.Line > 0 { + return Pos{p.Line - 1, max(0, e.buf.LineLen(p.Line-1)-1)}, true + } + return p, false +} + +func (e *Editor) findUnmatched(from Pos, open, close rune, dir, n int) (Pos, bool) { + p := from + for i := 0; i < n; i++ { + depth := 0 + found := false + for { + next, ok := e.step(p, dir) + if !ok { + return from, false + } + p = next + r, ok := e.runeAt(p) + if !ok { + continue + } + if dir < 0 { + if r == close { + depth++ + } else if r == open { + if depth == 0 { + found = true + break + } + depth-- + } + } else { + if r == open { + depth++ + } else if r == close { + if depth == 0 { + found = true + break + } + depth-- + } + } + } + if !found { + return from, false + } + } + return p, true +} + +func (e *Editor) markPos(r rune) (Pos, bool) { + switch r { + case '`', '\'': + p, ok := e.marks['\''] + if !ok { + return Pos{}, false + } + return p, true + } + p, ok := e.marks[r] + if !ok { + return Pos{}, false + } + if p.Line >= e.buf.LineCount() { + return Pos{}, false + } + return p, true +} + +var _ = unicode.IsLetter + +// --- search on selection / word --- + +// searchSelection searches forward for the current selection's text (Helix's +// *). With detectBound it wraps the pattern in word boundaries when the +// selection looks like a whole word, which is Helix's * versus Alt-*. +func (e *Editor) searchSelection(detectBound bool) { + from, to := e.selectionChars() + word := e.buf.Range(from, e.stepInsertPos(to)) + if word == "" { + return + } + if strings.ContainsAny(word, "\n\t ") { + return + } + pattern := regexp.QuoteMeta(word) + if detectBound && isWordPattern(word) { + pattern = `\b` + pattern + `\b` + } + e.lastSearch = pattern + e.highlight = true + e.searchDir = 1 + p, ok := e.searchNext(e.cursor, 1, 1, true) + if !ok { + return + } + e.pushJump() + e.cursor = e.buf.clampNormal(p) + e.selExcl = false + if e.mode == ModeSelect { + e.anchor = e.cursor + } + e.desiredCol = -1 + e.ensureCursorVisible() +} + +// searchWordUnderCursor searches for the word under the cursor in dir (1 or +// -1); the ZenNotes # key uses it backwards. +func (e *Editor) searchWordUnderCursor(dir int) { + word := e.WordUnderCursor() + if word == "" { + e.setError("E348: No string under cursor") + return + } + pattern := `\b` + regexp.QuoteMeta(word) + `\b` + e.lastSearch = pattern + e.highlight = true + e.searchDir = dir + p, ok := e.searchNext(e.cursor, dir, 1, true) + if !ok { + return + } + e.pushJump() + e.cursor = e.buf.clampNormal(p) + e.selExcl = false + if e.mode == ModeSelect { + e.anchor = e.cursor + } + e.desiredCol = -1 + e.ensureCursorVisible() +} + +// isWordPattern reports whether the text is a single run of word characters, +// the case where Helix adds word boundaries automatically. +func isWordPattern(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' { + return false + } + } + return true +} diff --git a/internal/helix/operators.go b/internal/helix/operators.go new file mode 100644 index 0000000..9a517d0 --- /dev/null +++ b/internal/helix/operators.go @@ -0,0 +1,781 @@ +package helix + +import ( + "regexp" + "strconv" + "strings" + "unicode" +) + +// span is a resolved operator target. +type span struct { + start, end Pos // end exclusive for charwise; for linewise, lines start.Line..end.Line + linewise bool + blockwise bool + blockLeft int + blockRight int // inclusive column + blockToEnd bool +} + +// applyOperatorToMotion resolves the motion under Vim's operator rules and +// applies the operator. Returns true when it entered insert mode. +func (e *Editor) applyOperatorToMotion(op string, m motion, count int, hasCount bool, register rune) bool { + from := e.cursor + // `cw` on a word acts like `ce`. + if op == "c" && (m.kind == "w" || m.kind == "W") { + if r, ok := e.runeAt(from); ok && !isBlank(r) { + m.kind = map[string]string{"w": "e", "W": "E"}[m.kind] + } + } + res, ok := e.applyMotion(m, count, hasCount, from, true) + if !ok { + return false + } + return e.applyOperatorResult(op, from, res, register, m.kind == "j") +} + +// applyOperatorResult applies op between the cursor and a motion result, +// honoring Vim's exclusive and linewise adjustments. +func (e *Editor) applyOperatorResult(op string, from Pos, res motionResult, register rune, isJ bool) bool { + start, end := from, res.pos + if end.Less(start) { + start, end = end, start + } + sp := span{start: start, end: end, linewise: res.linewise} + if !res.linewise { + if res.inclusive { + sp.end = e.stepInsertPos(sp.end) + } else if sp.end.Col == 0 && sp.end.Line > sp.start.Line && !isJ { + // Exclusive motion ending in column 0: pull back to the end of the + // previous line; linewise when it started at or before the first + // non-blank. + if sp.start.Col <= firstNonBlankInsert(e.buf.Line(sp.start.Line)) { + sp.linewise = true + sp.end = Pos{sp.end.Line - 1, 0} + } else { + sp.end = Pos{sp.end.Line - 1, e.buf.LineLen(sp.end.Line - 1)} + } + } + } + if res.jump { + e.pushJump() + } + return e.runOperator(op, sp, register, res.keepCol) +} + +// stepInsertPos advances an inclusive end to its exclusive form. +func (e *Editor) stepInsertPos(p Pos) Pos { + if p.Col < e.buf.LineLen(p.Line) { + return Pos{p.Line, p.Col + 1} + } + return p +} + +func (e *Editor) applyOperatorToObject(op string, obj textObject, count int, register rune) bool { + rng := e.textObjectRange(obj, count) + if !rng.ok { + return false + } + sp := span{start: rng.start, end: rng.end, linewise: rng.linewise} + if !rng.linewise { + if rng.end.Less(rng.start) { + // Empty object (e.g. `di"` on `""`): nothing to do but c enters insert. + sp.end = sp.start + } else { + sp.end = e.stepInsertPos(rng.end) + } + } + return e.runOperator(op, sp, register, false) +} + +func (e *Editor) applyLinewiseOperator(op string, count int, register rune) bool { + start := e.cursor.Line + end := min(e.buf.LineCount()-1, start+max(1, count)-1) + if end, ok := e.folds[start]; ok { + _ = end + } + sp := span{start: Pos{start, 0}, end: Pos{end, 0}, linewise: true} + return e.runOperator(op, sp, register, false) +} + +// runOperator applies op to a span. Returns true when insert mode begins. +func (e *Editor) runOperator(op string, sp span, register rune, keepCol bool) bool { + switch op { + case "y": + e.yankSpan(sp, register, true) + if sp.linewise { + e.cursor = Pos{sp.start.Line, e.cursor.Col} + if !keepCol { + e.cursor = e.buf.clampNormal(Pos{sp.start.Line, min(e.cursor.Col, sp.start.Col)}) + if sp.start.Line != e.cursor.Line || true { + e.cursor = e.buf.clampNormal(Pos{sp.start.Line, e.cursor.Col}) + } + } + } else { + e.cursor = e.buf.clampNormal(sp.start) + } + return false + case "d": + e.deleteSpan(sp, register) + return false + case "c": + if sp.linewise { + indent := leadingWhitespace(e.buf.Line(sp.start.Line)) + e.yankSpan(sp, register, false) + e.buf.ReplaceLines(sp.start.Line, sp.end.Line, []string{string(indent)}) + e.startInsert(Pos{sp.start.Line, len(indent)}) + return true + } + if sp.blockwise { + e.deleteSpan(sp, register) + e.startBlockInsert(sp.start.Line, sp.end.Line, sp.blockLeft, false) + return true + } + e.deleteSpan(sp, register) + e.startInsert(sp.start) + return true + case "<", ">": + e.shiftLines(sp.start.Line, sp.end.Line, op == ">", 1) + e.cursor = Pos{sp.start.Line, firstNonBlank(e.buf.Line(sp.start.Line))} + return false + case "=": + e.cursor = Pos{sp.start.Line, firstNonBlank(e.buf.Line(sp.start.Line))} + return false + case "gu", "gU", "g~", "g?": + e.transformSpan(sp, op) + e.cursor = e.buf.clampNormal(sp.start) + return false + case "gq", "gw": + e.reflowLines(sp.start.Line, sp.end.Line) + if op == "gq" { + e.cursor = e.buf.clampNormal(Pos{sp.start.Line, 0}) + e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) + } + return false + } + return false +} + +// spanText reads a span as register content. +func (e *Editor) spanText(sp span) Register { + if sp.linewise { + var sb strings.Builder + for l := sp.start.Line; l <= sp.end.Line; l++ { + sb.WriteString(e.buf.LineString(l)) + sb.WriteByte('\n') + } + return Register{Text: sb.String(), Linewise: true} + } + if sp.blockwise { + lines := []string{} + for l := sp.start.Line; l <= sp.end.Line; l++ { + line := e.buf.Line(l) + right := sp.blockRight + if sp.blockToEnd { + right = len(line) - 1 + } + from := min(sp.blockLeft, len(line)) + to := min(right+1, len(line)) + if to < from { + to = from + } + lines = append(lines, string(line[from:to])) + } + return Register{Text: strings.Join(lines, "\n"), Blockwise: true} + } + return Register{Text: e.buf.Range(sp.start, sp.end)} +} + +func (e *Editor) yankSpan(sp span, register rune, isYank bool) { + reg := e.spanText(sp) + e.storeRegister(register, reg, isYank) + if isYank { + if sp.linewise { + n := sp.end.Line - sp.start.Line + 1 + if n > 2 { + e.setMsg(strconv.Itoa(n) + " lines yanked") + } + } else if sp.blockwise { + e.setMsg("block of " + strconv.Itoa(sp.end.Line-sp.start.Line+1) + " lines yanked") + } + } + e.marks['['] = sp.start + e.marks[']'] = sp.end +} + +func (e *Editor) deleteSpan(sp span, register rune) { + e.yankSpan(sp, register, false) + switch { + case sp.linewise: + e.buf.DeleteLines(sp.start.Line, sp.end.Line) + line := min(sp.start.Line, e.buf.LineCount()-1) + e.cursor = Pos{line, firstNonBlank(e.buf.Line(line))} + n := sp.end.Line - sp.start.Line + 1 + if n > 2 { + e.setMsg(strconv.Itoa(n) + " fewer lines") + } + case sp.blockwise: + for l := sp.start.Line; l <= sp.end.Line; l++ { + line := e.buf.Line(l) + right := sp.blockRight + if sp.blockToEnd { + right = len(line) - 1 + } + from := min(sp.blockLeft, len(line)) + to := min(right+1, len(line)) + if to <= from { + continue + } + next := append(cloneRunes(line[:from]), line[to:]...) + e.buf.ReplaceLine(l, next) + } + e.cursor = e.buf.clampNormal(Pos{sp.start.Line, sp.blockLeft}) + default: + e.buf.DeleteRange(sp.start, sp.end) + e.cursor = e.buf.clampNormal(sp.start) + } +} + +func (e *Editor) transformSpan(sp span, op string) { + transform := func(r rune) rune { + switch op { + case "gu": + return unicode.ToLower(r) + case "gU": + return unicode.ToUpper(r) + case "g~": + if unicode.IsUpper(r) { + return unicode.ToLower(r) + } + return unicode.ToUpper(r) + case "g?": + return rot13(r) + } + return r + } + apply := func(l, from, to int) { + line := cloneRunes(e.buf.Line(l)) + to = min(to, len(line)) + for i := max(0, from); i < to; i++ { + line[i] = transform(line[i]) + } + e.buf.ReplaceLine(l, line) + } + switch { + case sp.linewise: + for l := sp.start.Line; l <= sp.end.Line; l++ { + apply(l, 0, e.buf.LineLen(l)) + } + case sp.blockwise: + for l := sp.start.Line; l <= sp.end.Line; l++ { + right := sp.blockRight + 1 + if sp.blockToEnd { + right = e.buf.LineLen(l) + } + apply(l, sp.blockLeft, right) + } + default: + for l := sp.start.Line; l <= sp.end.Line; l++ { + from, to := 0, e.buf.LineLen(l) + if l == sp.start.Line { + from = sp.start.Col + } + if l == sp.end.Line { + to = sp.end.Col + } + apply(l, from, to) + } + } +} + +func rot13(r rune) rune { + switch { + case r >= 'a' && r <= 'z': + return 'a' + (r-'a'+13)%26 + case r >= 'A' && r <= 'Z': + return 'A' + (r-'A'+13)%26 + } + return r +} + +// shiftLines indents or outdents whole lines by tabsize columns. +func (e *Editor) shiftLines(from, to int, right bool, times int) { + width := e.opts.TabSize * times + for l := from; l <= to; l++ { + line := e.buf.Line(l) + if len(line) == 0 { + continue + } + if right { + e.buf.ReplaceLine(l, append([]rune(strings.Repeat(" ", width)), line...)) + continue + } + remove := 0 + for remove < len(line) && remove < width && line[remove] == ' ' { + remove++ + } + if remove == 0 && len(line) > 0 && line[0] == '\t' { + remove = 1 + } + e.buf.ReplaceLine(l, line[remove:]) + } +} + +// --- simple edits --- + +func (e *Editor) deleteChars(n int, register rune) { + line := e.buf.Line(e.cursor.Line) + if len(line) == 0 { + return + } + end := min(e.cursor.Col+n, len(line)) + e.deleteSpan(span{start: e.cursor, end: Pos{e.cursor.Line, end}}, register) +} + +func (e *Editor) deleteCharsBack(n int, register rune) { + if e.cursor.Col == 0 { + return + } + start := max(0, e.cursor.Col-n) + e.deleteSpan(span{start: Pos{e.cursor.Line, start}, end: e.cursor}, register) + e.cursor = e.buf.clampNormal(Pos{e.cursor.Line, start}) +} + +func (e *Editor) deleteToEnd(n int, register rune) { + endLine := min(e.buf.LineCount()-1, e.cursor.Line+n-1) + sp := span{start: e.cursor, end: Pos{endLine, e.buf.LineLen(endLine)}} + e.deleteSpan(sp, register) + e.cursor = e.buf.clampNormal(Pos{e.cursor.Line, e.cursor.Col}) + if e.cursor.Col > 0 && e.cursor.Col >= e.buf.LineLen(e.cursor.Line) { + e.cursor.Col = max(0, e.buf.LineLen(e.cursor.Line)-1) + } +} + +func (e *Editor) changeLines(n int, register rune) { + end := min(e.buf.LineCount()-1, e.cursor.Line+n-1) + e.runOperator("c", span{start: Pos{e.cursor.Line, 0}, end: Pos{end, 0}, linewise: true}, register, false) +} + +func (e *Editor) yankLines(from, to int, register rune) { + e.yankSpan(span{start: Pos{from, 0}, end: Pos{to, 0}, linewise: true}, register, true) +} + +func (e *Editor) toggleCaseChars(n int) { + line := cloneRunes(e.buf.Line(e.cursor.Line)) + if len(line) == 0 { + return + } + end := min(e.cursor.Col+n, len(line)) + for i := e.cursor.Col; i < end; i++ { + if unicode.IsUpper(line[i]) { + line[i] = unicode.ToLower(line[i]) + } else { + line[i] = unicode.ToUpper(line[i]) + } + } + e.buf.ReplaceLine(e.cursor.Line, line) + e.cursor = e.buf.clampNormal(Pos{e.cursor.Line, end}) +} + +func (e *Editor) replaceChars(k Key, n int) { + line := e.buf.Line(e.cursor.Line) + if e.cursor.Col+n > len(line) { + return + } + if k.Is("enter") { + head := cloneRunes(line[:e.cursor.Col]) + tail := cloneRunes(line[e.cursor.Col+n:]) + e.buf.ReplaceLine(e.cursor.Line, head) + e.buf.InsertLines(e.cursor.Line+1, []string{string(tail)}) + e.cursor = Pos{e.cursor.Line + 1, 0} + return + } + if !k.Printable() { + return + } + next := cloneRunes(line) + for i := 0; i < n; i++ { + next[e.cursor.Col+i] = k.Rune + } + e.buf.ReplaceLine(e.cursor.Line, next) + e.cursor.Col += n - 1 +} + +// joinLines joins count lines starting at line; spaces controls the single +// space Vim inserts (J) versus none (gJ). +func (e *Editor) joinLines(line, count int, spaces bool) { + last := min(e.buf.LineCount()-1, line+count-1) + if last == line { + return + } + result := cloneRunes(e.buf.Line(line)) + joinCol := 0 + for l := line + 1; l <= last; l++ { + next := e.buf.Line(l) + if spaces { + trimmed := next[firstNonBlankInsert(next):] + result = trimRightBlanks(result) + joinCol = len(result) + if len(trimmed) > 0 && len(result) > 0 && trimmed[0] != ')' { + result = append(result, ' ') + joinCol = len(result) - 1 + } + result = append(result, trimmed...) + } else { + joinCol = len(result) + result = append(result, next...) + } + } + e.buf.ReplaceLine(line, result) + e.buf.DeleteLines(line+1, last) + e.cursor = e.buf.clampNormal(Pos{line, joinCol}) +} + +func trimRightBlanks(r []rune) []rune { + n := len(r) + for n > 0 && isBlank(r[n-1]) { + n-- + } + return r[:n] +} + +var numberRe = regexp.MustCompile(`-?\d+`) + +// incrementNumber adds delta to the number at or after the cursor. +func (e *Editor) incrementNumber(delta int) { + line := e.buf.LineString(e.cursor.Line) + runes := e.buf.Line(e.cursor.Line) + byteCol := len(string(runes[:min(e.cursor.Col, len(runes))])) + locs := numberRe.FindAllStringIndex(line, -1) + for _, loc := range locs { + if loc[1] <= byteCol { + continue + } + value, err := strconv.Atoi(line[loc[0]:loc[1]]) + if err != nil { + continue + } + next := strconv.Itoa(value + delta) + updated := line[:loc[0]] + next + line[loc[1]:] + e.buf.ReplaceLine(e.cursor.Line, []rune(updated)) + e.cursor.Col = len([]rune(updated[:loc[0]+len(next)])) - 1 + return + } +} + +// --- put --- + +func (e *Editor) put(register rune, n int, after bool, cursorAfter bool) { + reg, ok := e.readRegister(register) + if !ok || reg.Text == "" { + if register == 0 || register == '"' { + e.setError("E353: Nothing in register \"") + } else { + e.setError("E353: Nothing in register " + string(register)) + } + return + } + switch { + case reg.Linewise: + text := strings.TrimSuffix(reg.Text, "\n") + lines := strings.Split(text, "\n") + all := []string{} + for i := 0; i < n; i++ { + all = append(all, lines...) + } + at := e.cursor.Line + if after { + at = e.nextVisibleLine(e.cursor.Line) + } + e.buf.InsertLines(at, all) + if cursorAfter { + e.cursor = e.buf.clampNormal(Pos{min(at+len(all), e.buf.LineCount()-1), 0}) + } else { + e.cursor = Pos{at, firstNonBlank(e.buf.Line(at))} + } + if len(all) > 2 { + e.setMsg(strconv.Itoa(len(all)) + " more lines") + } + case reg.Blockwise: + lines := strings.Split(reg.Text, "\n") + col := e.cursor.Col + if after && e.buf.LineLen(e.cursor.Line) > 0 { + col++ + } + width := 0 + for _, l := range lines { + width = max(width, len([]rune(l))) + } + for i, l := range lines { + lineIdx := e.cursor.Line + i + if lineIdx >= e.buf.LineCount() { + e.buf.InsertLines(e.buf.LineCount(), []string{""}) + } + cur := cloneRunes(e.buf.Line(lineIdx)) + for len(cur) < col { + cur = append(cur, ' ') + } + chunk := []rune(strings.Repeat(l, n)) + if n > 1 { + chunk = []rune{} + for k := 0; k < n; k++ { + pad := []rune(l) + for len(pad) < width { + pad = append(pad, ' ') + } + chunk = append(chunk, pad...) + } + } + next := append(cloneRunes(cur[:col]), chunk...) + next = append(next, cur[col:]...) + e.buf.ReplaceLine(lineIdx, next) + } + e.cursor = e.buf.clampNormal(Pos{e.cursor.Line, col}) + default: + text := strings.Repeat(reg.Text, n) + p := e.cursor + if after && e.buf.LineLen(p.Line) > 0 { + p.Col++ + } + end := e.buf.InsertText(p, text) + if cursorAfter { + e.cursor = e.buf.clampNormal(end) + if end.Col >= e.buf.LineLen(end.Line) && end.Line+1 < e.buf.LineCount() && strings.HasSuffix(text, "\n") { + e.cursor = Pos{end.Line, 0} + } else { + e.cursor = e.buf.clampInsert(end) + e.cursor = e.buf.clampNormal(e.cursor) + if end.Col < e.buf.LineLen(end.Line) { + e.cursor = end + } + } + } else if strings.Contains(text, "\n") { + e.cursor = p + } else { + e.cursor = e.buf.clampNormal(Pos{end.Line, end.Col - 1}) + } + } + e.marks['['] = e.cursor +} + +// --- insert entry helpers --- + +func (e *Editor) startInsert(p Pos) { + e.beginChange() + e.mode = ModeInsert + // Typing owns the whole range: a stale anchor must not grow a highlight + // or a span while the cursor advances through inserted text. + e.anchor = e.cursor + e.cursor = e.buf.clampInsert(p) + e.ins = insertState{start: e.cursor} + e.desiredCol = -1 +} + +func (e *Editor) openLine(above bool) { + cur := e.buf.Line(e.cursor.Line) + indent := leadingWhitespace(cur) + prefix := string(indent) + if e.opts.MarkdownLists && !above { + if cont, ok := listContinuation(cur); ok { + prefix = cont + } + } else if e.opts.MarkdownLists && above { + if cont, ok := listContinuation(cur); ok { + prefix = cont + } + } + at := e.cursor.Line + if !above { + at = e.nextVisibleLine(e.cursor.Line) + if end, ok := e.folds[e.cursor.Line]; ok { + at = end + 1 + } + } + e.buf.InsertLines(at, []string{prefix}) + if !above && e.opts.MarkdownLists { + e.renumberList(at) + } + e.mode = ModeInsert + e.cursor = Pos{at, len([]rune(prefix))} + e.ins = insertState{start: e.cursor} + e.desiredCol = -1 +} + +var listMarkerRe = regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])(\s+)(\[[ xX/>-]\]\s+)?`) +var quoteMarkerRe = regexp.MustCompile(`^(\s*>\s?)+`) + +// listContinuation is the marker a new line below a list item should start +// with: the same bullet, the next number, a fresh unchecked box. +func listContinuation(line []rune) (string, bool) { + s := string(line) + m := listMarkerRe.FindStringSubmatch(s) + if m == nil { + if q := quoteMarkerRe.FindString(s); q != "" && strings.TrimSpace(s) != strings.TrimSpace(q) { + return q, true + } + return "", false + } + indent, marker, space, box := m[1], m[2], m[3], m[4] + if strings.TrimSpace(s[len(m[0]):]) == "" { + // Empty item: the continuation is nothing (the caller ends the list). + return "", false + } + if marker[len(marker)-1] == '.' || marker[len(marker)-1] == ')' { + n, _ := strconv.Atoi(marker[:len(marker)-1]) + marker = strconv.Itoa(n+1) + marker[len(marker)-1:] + } + if box != "" { + box = "[ ] " + } + return indent + marker + space + box, true +} + +// isEmptyListItem is true for a bare marker like `- ` or `1. [ ] `. +func isEmptyListItem(line []rune) bool { + s := string(line) + m := listMarkerRe.FindStringSubmatch(s) + if m == nil { + return false + } + return strings.TrimSpace(s[len(m[0]):]) == "" +} + +var orderedMarkerRe = regexp.MustCompile(`^(\s*)(\d+)([.)])(\s+)`) + +// renumberList renumbers an ordered list around line so numbers stay in +// sequence after an insertion. +func (e *Editor) renumberList(line int) { + m := orderedMarkerRe.FindStringSubmatch(e.buf.LineString(line)) + if m == nil { + return + } + indent := m[1] + start := line + for start > 0 { + pm := orderedMarkerRe.FindStringSubmatch(e.buf.LineString(start - 1)) + if pm == nil || pm[1] != indent { + break + } + start-- + } + num := 0 + if sm := orderedMarkerRe.FindStringSubmatch(e.buf.LineString(start)); sm != nil { + num, _ = strconv.Atoi(sm[2]) + } + for l := start; l < e.buf.LineCount(); l++ { + lm := orderedMarkerRe.FindStringSubmatch(e.buf.LineString(l)) + if lm == nil || lm[1] != indent { + break + } + want := strconv.Itoa(num) + if lm[2] != want { + s := e.buf.LineString(l) + e.buf.ReplaceLine(l, []rune(lm[1]+want+lm[3]+lm[4]+s[len(lm[0]):])) + } + num++ + } +} + +// --- reflow (gq / gw) --- + +var reflowSkipRe = regexp.MustCompile(`^\s*(#{1,6}\s|\||>|[-*+]\s|\d+[.)]\s|` + "```" + `|~~~|\$\$|---\s*$|\*\*\*\s*$)`) + +// reflowLines joins the hard-wrapped lines of every paragraph in the range +// into one line. Headings, lists, tables, quotes, code, math and explicit +// line breaks are left alone. +func (e *Editor) reflowLines(from, to int) { + to = min(to, e.buf.LineCount()-1) + l := from + for l <= to { + s := e.buf.LineString(l) + if strings.TrimSpace(s) == "" || reflowSkipRe.MatchString(s) { + l++ + continue + } + end := l + for end+1 <= to { + next := e.buf.LineString(end + 1) + cur := e.buf.LineString(end) + if strings.TrimSpace(next) == "" || reflowSkipRe.MatchString(next) { + break + } + if strings.HasSuffix(cur, " ") || strings.HasSuffix(cur, "\\") || strings.HasSuffix(strings.TrimSpace(cur), "
") { + break + } + end++ + } + if end > l { + parts := []string{strings.TrimRight(s, " \t")} + for k := l + 1; k <= end; k++ { + parts = append(parts, strings.TrimSpace(e.buf.LineString(k))) + } + e.buf.ReplaceLine(l, []rune(strings.Join(parts, " "))) + e.buf.DeleteLines(l+1, end) + to -= end - l + } + l++ + } +} + +// ReflowParagraph joins the paragraph under the cursor (Alt+Q). +func (e *Editor) ReflowParagraph() { + e.beginChange() + rng := e.paragraphObject(true, 1) + if rng.ok { + e.reflowLines(rng.start.Line, rng.end.Line) + } + e.cursor = e.buf.clampNormal(e.cursor) + if e.mode == ModeInsert { + e.cursor = e.buf.clampInsert(e.cursor) + } else { + e.commitChange() + } +} + +var checkboxLineRe = regexp.MustCompile(`^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+\[)( |x|X|/|>|-)(\].*)$`) +var bulletOnlyRe = regexp.MustCompile(`^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+)(.*)$`) + +// ToggleCheckboxAtCursor turns the current line into a checkbox and toggles +// it on repeat: plain text becomes `- [ ] text`, a bullet keeps its marker, +// `[ ]` flips to `[x]` and back, `[/]` checks off, `[>]` and `[-]` stay. +func (e *Editor) ToggleCheckboxAtCursor() { + e.beginChange() + lines := []int{e.cursor.Line} + if e.inSelect() { + start, end, _, _ := e.VisualRange() + lines = nil + for l := start.Line; l <= end.Line; l++ { + lines = append(lines, l) + } + } + for _, l := range lines { + s := e.buf.LineString(l) + switch { + case checkboxLineRe.MatchString(s): + m := checkboxLineRe.FindStringSubmatch(s) + switch m[2] { + case ">", "-": + continue + case "x", "X": + e.buf.ReplaceLine(l, []rune(m[1]+" "+m[3])) + default: + e.buf.ReplaceLine(l, []rune(m[1]+"x"+m[3])) + } + case bulletOnlyRe.MatchString(s): + m := bulletOnlyRe.FindStringSubmatch(s) + e.buf.ReplaceLine(l, []rune(m[1]+"[ ] "+m[2])) + default: + indent := leadingWhitespace([]rune(s)) + e.buf.ReplaceLine(l, []rune(string(indent)+"- [ ] "+strings.TrimLeft(s, " \t"))) + } + } + if e.inSelect() { + e.exitSelect() + } + if e.mode == ModeInsert { + e.cursor = e.buf.clampInsert(e.cursor) + } else { + e.cursor = e.buf.clampNormal(e.cursor) + e.commitChange() + } +} diff --git a/internal/helix/registers_test.go b/internal/helix/registers_test.go new file mode 100644 index 0000000..38638e9 --- /dev/null +++ b/internal/helix/registers_test.go @@ -0,0 +1,88 @@ +package helix + +import "testing" + +func TestSharedRegistersPreservePasteShape(t *testing.T) { + for _, tc := range []struct{ name, source, yank, target, want string }{ + {"characters", "alpha beta", "miwy", "123", "1alpha23"}, + {"lines", "alpha\nsecond", "xy", "target", "target\nalpha"}, + {"block", "abXY\ncdZW", "ljy", "12\n34", "1ab2\n3cd4"}, + } { + t.Run(tc.name, func(t *testing.T) { + var store RegisterStore + source := NewWithRegisters(tc.source, DefaultOptions(), Hooks{}, &store) + target := NewWithRegisters(tc.target, DefaultOptions(), Hooks{}, &store) + source.Feed(tc.yank) + target.Feed("p") + if got := target.Text(); got != tc.want { + t.Fatalf("paste: got %q, want %q", got, tc.want) + } + }) + } +} + +func TestSharedNamedAppendAndNumberedDeleteRegisters(t *testing.T) { + var store RegisterStore + first := NewWithRegisters("alpha\nkeep", DefaultOptions(), Hooks{}, &store) + second := NewWithRegisters("beta\nstay", DefaultOptions(), Hooks{}, &store) + first.Feed(`x`) + first.Feed(`"ay`) + second.Feed(`x`) + second.Feed(`"Ay`) + target := NewWithRegisters("target", DefaultOptions(), Hooks{}, &store) + target.Feed(`"ap`) + if got := target.Text(); got != "target\nalpha\nbeta" { + t.Fatalf("named append across notes: %q", got) + } + first.Feed("x") + first.Feed("d") + second.Feed("x") + second.Feed("d") + target.Feed(`gg"2p`) + if got := target.Text(); got != "target\nalpha\nalpha\nbeta" { + t.Fatalf("numbered delete history across notes: %q", got) + } +} + +func TestSharedRegistersRespectBlackHoleAndLocalUndo(t *testing.T) { + var store RegisterStore + first := NewWithRegisters("alpha", DefaultOptions(), Hooks{}, &store) + second := NewWithRegisters("beta\nstay", DefaultOptions(), Hooks{}, &store) + first.Feed("x") + first.Feed("y") + second.Feed("x") + second.Feed(`"_d`) + second.Feed("p") + if got := second.Text(); got != "stay\nalpha" { + t.Fatalf("black-hole delete replaced shared yank: %q", got) + } + first.Feed("u") + if first.Text() != "alpha" || second.Text() != "stay\nalpha" { + t.Fatal("an editor undid another editor's change") + } +} + +func TestSharedBlockYankRetainsItsShapeWithClipboardEnabled(t *testing.T) { + var store RegisterStore + clipboard := "" + hooks := Hooks{ReadClipboard: func() (string, bool) { return clipboard, true }, WriteClipboard: func(text string) bool { clipboard = text; return true }} + opts := DefaultOptions() + opts.YankToClipboard = true + first := NewWithRegisters("abXY\ncdZW", opts, hooks, &store) + second := NewWithRegisters("12\n34", opts, hooks, &store) + first.Feed("ljy") + second.Feed("p") + if got := second.Text(); got != "1ab2\n3cd4" { + t.Fatalf("clipboard lost shared block metadata: %q", got) + } +} + +func TestPrivateEditorsDoNotShareRegisters(t *testing.T) { + first := New("alpha", DefaultOptions(), Hooks{}) + second := New("beta", DefaultOptions(), Hooks{}) + first.Feed("yy") + second.Feed("p") + if second.Text() != "beta" { + t.Fatal("standalone editors unexpectedly shared registers") + } +} diff --git a/internal/helix/search.go b/internal/helix/search.go new file mode 100644 index 0000000..3ea2953 --- /dev/null +++ b/internal/helix/search.go @@ -0,0 +1,468 @@ +package helix + +import ( + "regexp" + "strconv" + "strings" + "unicode" +) + +// translatePattern turns a Vim (magic) pattern into a Go regexp source: +// `\<` and `\>` become word boundaries, `\(` `\|` `\{` `\+` `\?` `\=` are +// the grouping and repetition operators, and the bare forms of those +// characters are literals. `\c` and `\C` set case handling; `\v` switches to +// very magic (Go syntax as is) and `\V` to a literal pattern. +func translatePattern(pattern string) (source string, forceIgnore, forceMatch bool) { + var b strings.Builder + runes := []rune(pattern) + veryMagic, noMagic := false, false + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '\\' && i+1 < len(runes) { + next := runes[i+1] + i++ + switch next { + case '<', '>': + b.WriteString(`\b`) + case 'c': + forceIgnore = true + case 'C': + forceMatch = true + case 'v': + veryMagic = true + case 'V': + noMagic = true + case 'm', 'M': + case '(', ')', '|', '{', '}', '+', '?', '=': + if next == '=' { + b.WriteString("?") + } else { + b.WriteRune(next) + } + case 't': + b.WriteString(`\t`) + case 'n': + b.WriteString(`\n`) + case 's', 'S', 'd', 'D', 'w', 'W', 'b', 'B': + b.WriteString(`\` + string(next)) + case 'a': + b.WriteString(`[A-Za-z]`) + case 'l': + b.WriteString(`[a-z]`) + case 'u': + b.WriteString(`[A-Z]`) + default: + b.WriteString(regexp.QuoteMeta(string(next))) + } + continue + } + if noMagic { + b.WriteString(regexp.QuoteMeta(string(r))) + continue + } + if veryMagic { + b.WriteRune(r) + continue + } + switch r { + case '(', ')', '|', '{', '}', '+', '?', '=': + b.WriteString(regexp.QuoteMeta(string(r))) + default: + b.WriteRune(r) + } + } + return b.String(), forceIgnore, forceMatch +} + +func (e *Editor) compilePattern(pattern string) (*regexp.Regexp, error) { + source, forceIgnore, forceMatch := translatePattern(pattern) + ignore := e.opts.IgnoreCase + if e.opts.SmartCase && ignore { + for _, r := range pattern { + if unicode.IsUpper(r) { + ignore = false + break + } + } + } + if forceIgnore { + ignore = true + } + if forceMatch { + ignore = false + } + if ignore { + source = "(?i)" + source + } + return regexp.Compile(source) +} + +// searchNext finds the nth match of the last search from `from` in +// direction dir, wrapping when wrapscan is on. +func (e *Editor) searchNext(from Pos, dir, n int, report bool) (Pos, bool) { + p, _, ok := e.searchNextRange(from, dir, n, report) + return p, ok +} + +// searchNextRange is searchNext and also returns the exclusive end of the +// match, so `n` can select the whole match the way Helix does. +func (e *Editor) searchNextRange(from Pos, dir, n int, report bool) (Pos, Pos, bool) { + if e.lastSearch == "" { + e.setError("E35: No previous regular expression") + return from, from, false + } + re, err := e.compilePattern(e.lastSearch) + if err != nil { + e.setError("E486: Pattern not found: " + e.lastSearch) + return from, from, false + } + p := from + end := from + for i := 0; i < n; i++ { + next, e2, wrapped, ok := e.findMatch(re, p, dir) + if !ok { + e.setError("E486: Pattern not found: " + e.lastSearch) + return from, from, false + } + if wrapped && report { + if dir > 0 { + e.setMsg("search hit BOTTOM, continuing at TOP") + } else { + e.setMsg("search hit TOP, continuing at BOTTOM") + } + } + p, end = next, e2 + } + e.highlight = true + return p, end, true +} + +// findMatch is the next match strictly after (or before) from. It returns +// the match start, its exclusive end, whether it wrapped, and whether one +// was found. +func (e *Editor) findMatch(re *regexp.Regexp, from Pos, dir int) (Pos, Pos, bool, bool) { + lineCount := e.buf.LineCount() + matchesOn := func(line int) [][2]int { + return runeMatches(re, e.buf.LineString(line)) + } + if dir > 0 { + for offset := 0; offset <= lineCount; offset++ { + line := (from.Line + offset) % lineCount + wrapped := from.Line+offset >= lineCount + if wrapped && !e.opts.WrapScan { + return from, from, false, false + } + for _, m := range matchesOn(line) { + if offset == 0 && !wrapped && m[0] <= from.Col { + continue + } + if offset == lineCount && m[0] > from.Col { + continue + } + return Pos{line, m[0]}, Pos{line, m[1]}, wrapped, true + } + } + return from, from, false, false + } + for offset := 0; offset <= lineCount; offset++ { + line := ((from.Line-offset)%lineCount + lineCount) % lineCount + wrapped := from.Line-offset < 0 + if wrapped && !e.opts.WrapScan { + return from, from, false, false + } + ms := matchesOn(line) + for i := len(ms) - 1; i >= 0; i-- { + m := ms[i] + if offset == 0 && !wrapped && m[0] >= from.Col { + continue + } + if offset == lineCount && m[0] < from.Col { + continue + } + return Pos{line, m[0]}, Pos{line, m[1]}, wrapped, true + } + } + return from, from, false, false +} + +// runeMatches lists match spans on a line in rune columns. +func runeMatches(re *regexp.Regexp, s string) [][2]int { + locs := re.FindAllStringIndex(s, -1) + if len(locs) == 0 { + return nil + } + out := make([][2]int, 0, len(locs)) + byteToRune := map[int]int{} + ri := 0 + for bi := range s { + byteToRune[bi] = ri + ri++ + } + byteToRune[len(s)] = ri + for _, loc := range locs { + start, end := byteToRune[loc[0]], byteToRune[loc[1]] + if end == start { + end = start + 1 + } + out = append(out, [2]int{start, end}) + } + return out +} + +// SearchHighlights lists highlighted spans on a line while search +// highlighting is on. +func (e *Editor) SearchHighlights(line int) [][2]int { + if !e.highlight || e.lastSearch == "" { + return nil + } + re, err := e.compilePattern(e.lastSearch) + if err != nil { + return nil + } + return runeMatches(re, e.buf.LineString(line)) +} + +// HighlightOn reports whether search matches are shown. +func (e *Editor) HighlightOn() bool { return e.highlight && e.lastSearch != "" } + +// LastSearch is the last pattern. +func (e *Editor) LastSearch() string { return e.lastSearch } + +// runSearch is `/` or `?` submitted from the command line. +func (e *Editor) runSearch(pattern string, dir, count int) { + if pattern == "" { + pattern = e.lastSearch + } + if pattern == "" { + e.setError("E35: No previous regular expression") + return + } + if _, err := e.compilePattern(pattern); err != nil { + e.setError("E486: Pattern not found: " + pattern) + return + } + e.lastSearch = pattern + e.searchDir = dir + e.highlight = true + p, ok := e.searchNext(e.cmd.searchOrg, dir, max(1, count), true) + if !ok { + e.cursor = e.buf.clampNormal(e.cmd.searchOrg) + return + } + e.pushJump() + e.cursor = e.buf.clampNormal(p) + e.desiredCol = -1 + e.openFoldAt(e.cursor.Line) + e.ensureCursorVisible() +} + +// --- substitute --- + +type substitution struct { + pattern string + repl string + flags string +} + +// parseSubstitute reads `/pat/repl/flags` with any delimiter. +func parseSubstitute(args string) (pattern, repl, flags string, ok bool) { + if args == "" { + return "", "", "", false + } + delim := []rune(args)[0] + if unicode.IsLetter(delim) || unicode.IsDigit(delim) || delim == '\\' || delim == '"' || delim == '|' { + return "", "", "", false + } + rest := []rune(args)[1:] + parts := []string{} + var cur strings.Builder + for i := 0; i < len(rest); i++ { + r := rest[i] + if r == '\\' && i+1 < len(rest) { + if rest[i+1] == delim { + cur.WriteRune(delim) + i++ + continue + } + cur.WriteRune(r) + cur.WriteRune(rest[i+1]) + i++ + continue + } + if r == delim && len(parts) < 2 { + parts = append(parts, cur.String()) + cur.Reset() + continue + } + cur.WriteRune(r) + } + parts = append(parts, cur.String()) + for len(parts) < 3 { + parts = append(parts, "") + } + return parts[0], parts[1], strings.TrimSpace(parts[2]), true +} + +// expandReplacement applies `&`, `\0`..`\9`, `\n`, `\t`, `\\`, `\&`, and +// case modifiers `\u` `\l` `\U` `\L` `\E`. +func expandReplacement(repl string, match []string) string { + var b strings.Builder + runes := []rune(repl) + upperNext, lowerNext := false, false + upperAll, lowerAll := false, false + write := func(s string) { + for _, r := range s { + switch { + case upperNext: + r = unicode.ToUpper(r) + upperNext = false + case lowerNext: + r = unicode.ToLower(r) + lowerNext = false + case upperAll: + r = unicode.ToUpper(r) + case lowerAll: + r = unicode.ToLower(r) + } + b.WriteRune(r) + } + } + for i := 0; i < len(runes); i++ { + r := runes[i] + if r == '\\' && i+1 < len(runes) { + next := runes[i+1] + i++ + switch { + case next >= '0' && next <= '9': + idx := int(next - '0') + if idx < len(match) { + write(match[idx]) + } + case next == 'n', next == 'r': + b.WriteString("\n") + case next == 't': + b.WriteString("\t") + case next == '&': + b.WriteString("&") + case next == 'u': + upperNext = true + case next == 'l': + lowerNext = true + case next == 'U': + upperAll, lowerAll = true, false + case next == 'L': + lowerAll, upperAll = true, false + case next == 'E' || next == 'e': + upperAll, lowerAll = false, false + default: + b.WriteRune(next) + } + continue + } + if r == '&' { + write(match[0]) + continue + } + write(string(r)) + } + return b.String() +} + +// substitute runs :s over a line range and returns the replacement count. +func (e *Editor) substitute(start, end int, pattern, repl, flags string) (int, error) { + if pattern == "" { + pattern = e.lastSearch + } + if pattern == "" { + return 0, errString("E35: No previous regular expression") + } + re, err := e.compilePattern(pattern) + if err != nil { + return 0, errString("E486: Pattern not found: " + pattern) + } + if strings.Contains(flags, "i") { + re, _ = regexp.Compile("(?i)" + re.String()) + } + global := strings.Contains(flags, "g") + countOnly := strings.Contains(flags, "n") + e.lastSearch = pattern + e.lastSubst = &substitution{pattern: pattern, repl: repl, flags: flags} + e.highlight = true + total := 0 + lastLine := -1 + l := start + for l <= end && l < e.buf.LineCount() { + text := e.buf.LineString(l) + locs := re.FindAllStringSubmatchIndex(text, -1) + if len(locs) == 0 { + l++ + continue + } + if !global { + locs = locs[:1] + } + total += len(locs) + lastLine = l + if countOnly { + l++ + continue + } + var b strings.Builder + last := 0 + for _, loc := range locs { + groups := make([]string, len(loc)/2) + for g := 0; g < len(loc)/2; g++ { + if loc[2*g] >= 0 { + groups[g] = text[loc[2*g]:loc[2*g+1]] + } + } + b.WriteString(text[last:loc[0]]) + b.WriteString(expandReplacement(repl, groups)) + last = loc[1] + } + b.WriteString(text[last:]) + next := b.String() + if strings.Contains(next, "\n") { + parts := strings.Split(next, "\n") + e.buf.ReplaceLine(l, []rune(parts[0])) + e.buf.InsertLines(l+1, parts[1:]) + end += len(parts) - 1 + l += len(parts) - 1 + } else { + e.buf.ReplaceLine(l, []rune(next)) + } + l++ + } + if total == 0 { + return 0, errString("E486: Pattern not found: " + pattern) + } + if countOnly { + e.setMsg(strconv.Itoa(total) + " matches") + return total, nil + } + if lastLine >= 0 { + e.cursor = Pos{lastLine, firstNonBlank(e.buf.Line(lastLine))} + } + if total > 2 || lastLine != start { + e.setMsg(strconv.Itoa(total) + " substitutions") + } + return total, nil +} + +func (e *Editor) repeatSubstitute(keepFlags bool) { + if e.lastSubst == nil { + e.setError("E35: No previous regular expression") + return + } + flags := "" + if keepFlags { + flags = e.lastSubst.flags + } + if _, err := e.substitute(e.cursor.Line, e.cursor.Line, e.lastSubst.pattern, e.lastSubst.repl, flags); err != nil { + e.setError(err.Error()) + } +} + +type errString string + +func (s errString) Error() string { return string(s) } diff --git a/internal/helix/textobjects.go b/internal/helix/textobjects.go new file mode 100644 index 0000000..accf610 --- /dev/null +++ b/internal/helix/textobjects.go @@ -0,0 +1,389 @@ +package helix + +// textObject is a parsed `iw`, `a(`, `it` ... +type textObject struct { + inner bool + kind rune +} + +func parseTextObject(keys []Key) (textObject, parseStatus) { + if len(keys) == 0 { + return textObject{}, parseIncomplete + } + if !keys[0].Printable() || (keys[0].Rune != 'i' && keys[0].Rune != 'a') { + return textObject{}, parseInvalid + } + if len(keys) < 2 { + return textObject{}, parseIncomplete + } + if !keys[1].Printable() { + return textObject{}, parseInvalid + } + switch keys[1].Rune { + case 'w', 'W', 's', 'p', '(', ')', 'b', '[', ']', '{', '}', 'B', '<', '>', '"', '\'', '`', 't': + return textObject{inner: keys[0].Rune == 'i', kind: keys[1].Rune}, parseComplete + } + return textObject{}, parseInvalid +} + +// objectRange is a selected span: start..end inclusive, or whole lines. +type objectRange struct { + start, end Pos + linewise bool + ok bool +} + +// textObjectRange resolves a text object around the cursor (or the visual +// selection when one is active). +func (e *Editor) textObjectRange(obj textObject, count int) objectRange { + n := max(1, count) + switch obj.kind { + case 'w', 'W': + return e.wordObject(obj.inner, obj.kind == 'W', n) + case 's': + return e.sentenceObject(obj.inner, n) + case 'p': + return e.paragraphObject(obj.inner, n) + case '(', ')', 'b': + return e.bracketObject(obj.inner, '(', ')', n) + case '[', ']': + return e.bracketObject(obj.inner, '[', ']', n) + case '{', '}', 'B': + return e.bracketObject(obj.inner, '{', '}', n) + case '<', '>': + return e.bracketObject(obj.inner, '<', '>', n) + case '"', '\'', '`': + return e.quoteObject(obj.inner, obj.kind) + case 't': + return e.tagObject(obj.inner, n) + } + return objectRange{} +} + +func (e *Editor) wordObject(inner, big bool, n int) objectRange { + line := e.buf.Line(e.cursor.Line) + if len(line) == 0 { + return objectRange{start: e.cursor, end: e.cursor, ok: true} + } + col := min(e.cursor.Col, len(line)-1) + start, end := col, col + for i := 0; i < n; i++ { + if i > 0 { + if end+1 >= len(line) { + break + } + end++ + } + cls := charClass(line[end], big) + for end+1 < len(line) && charClass(line[end+1], big) == cls { + end++ + } + if i == 0 { + for start > 0 && charClass(line[start-1], big) == cls { + start-- + } + } + if !inner { + if cls == 0 { + // On whitespace: `aw` takes the whitespace plus the word after. + if end+1 < len(line) { + end++ + wc := charClass(line[end], big) + for end+1 < len(line) && charClass(line[end+1], big) == wc { + end++ + } + } + } else if end+1 < len(line) && isBlank(line[end+1]) { + for end+1 < len(line) && isBlank(line[end+1]) { + end++ + } + } else if i == 0 { + for start > 0 && isBlank(line[start-1]) { + start-- + } + } + } + } + return objectRange{start: Pos{e.cursor.Line, start}, end: Pos{e.cursor.Line, end}, ok: true} +} + +func (e *Editor) sentenceObject(inner bool, n int) objectRange { + starts := e.sentenceStarts() + if len(starts) == 0 { + return objectRange{} + } + idx := 0 + for i, s := range starts { + if !e.cursor.Less(s) { + idx = i + } + } + start := starts[idx] + endIdx := min(idx+n, len(starts)) + var end Pos + if endIdx < len(starts) { + end, _ = e.step(starts[endIdx], -1) + } else { + last := e.buf.LineCount() - 1 + end = Pos{last, max(0, e.buf.LineLen(last)-1)} + } + if inner { + for end.Line > start.Line || (end.Line == start.Line && end.Col > start.Col) { + r, ok := e.runeAt(end) + if ok && !isBlank(r) { + break + } + prev, ok := e.step(end, -1) + if !ok { + break + } + end = prev + } + } + return objectRange{start: start, end: end, ok: true} +} + +func (e *Editor) paragraphObject(inner bool, n int) objectRange { + l := e.cursor.Line + lineCount := e.buf.LineCount() + blank := isBlankLine(e.buf.Line(l)) + start := l + for start > 0 && isBlankLine(e.buf.Line(start-1)) == blank { + start-- + } + end := l + for i := 0; i < n; i++ { + if i > 0 { + if end+1 >= lineCount { + break + } + end++ + blank = isBlankLine(e.buf.Line(end)) + } + for end+1 < lineCount && isBlankLine(e.buf.Line(end+1)) == blank { + end++ + } + if !inner { + // `ap` takes the following blank lines too (or preceding when at end). + if end+1 < lineCount { + nextBlank := isBlankLine(e.buf.Line(end + 1)) + if nextBlank != blank { + end++ + for end+1 < lineCount && isBlankLine(e.buf.Line(end+1)) == nextBlank { + end++ + } + } + } else { + for start > 0 && isBlankLine(e.buf.Line(start-1)) != blank { + start-- + } + } + } + } + return objectRange{start: Pos{start, 0}, end: Pos{end, 0}, linewise: true, ok: true} +} + +func (e *Editor) bracketObject(inner bool, open, close rune, n int) objectRange { + // Find the enclosing open bracket, n levels out. + openPos := e.cursor + if r, ok := e.runeAt(openPos); ok && r == open { + n-- + if n == 0 { + goto haveOpen + } + } + if r, ok := e.runeAt(openPos); ok && r == close { + openPos, ok = e.findUnmatched(openPos, open, close, -1, 1) + if !ok { + return objectRange{} + } + n-- + } + if n > 0 { + p, ok := e.findUnmatched(openPos, open, close, -1, n) + if !ok { + return objectRange{} + } + openPos = p + } +haveOpen: + closePos, ok := e.findUnmatched(openPos, open, close, 1, 1) + if !ok { + return objectRange{} + } + if !inner { + return objectRange{start: openPos, end: closePos, ok: true} + } + start, ok1 := e.step(openPos, 1) + end, ok2 := e.step(closePos, -1) + if !ok1 || !ok2 || closePos.Less(start) || end.Less(openPos) { + return objectRange{start: closePos, end: closePos, ok: true, linewise: false} + } + if start == closePos { + // Empty brackets: nothing inside. + return objectRange{start: closePos, end: openPos, ok: true} + } + // Brackets on their own lines select whole inner lines. + if openPos.Col == e.buf.LineLen(openPos.Line)-1 && closePos.Col == firstNonBlankInsert(e.buf.Line(closePos.Line)) && closePos.Line > openPos.Line+1 { + return objectRange{start: Pos{openPos.Line + 1, 0}, end: Pos{closePos.Line - 1, 0}, linewise: true, ok: true} + } + return objectRange{start: start, end: end, ok: true} +} + +func (e *Editor) quoteObject(inner bool, q rune) objectRange { + line := e.buf.Line(e.cursor.Line) + col := e.cursor.Col + positions := []int{} + for i, r := range line { + if r == q && (i == 0 || line[i-1] != '\\') { + positions = append(positions, i) + } + } + if len(positions) < 2 { + return objectRange{} + } + start, end := -1, -1 + for i := 0; i+1 < len(positions); i += 2 { + if positions[i] <= col && col <= positions[i+1] { + start, end = positions[i], positions[i+1] + break + } + } + if start < 0 { + // Cursor before the first quote: the first pair after it. + for i := 0; i+1 < len(positions); i++ { + if positions[i] > col { + start, end = positions[i], positions[i+1] + break + } + } + } + if start < 0 { + return objectRange{} + } + if inner { + if end == start+1 { + return objectRange{start: Pos{e.cursor.Line, end}, end: Pos{e.cursor.Line, start}, ok: true} + } + return objectRange{start: Pos{e.cursor.Line, start + 1}, end: Pos{e.cursor.Line, end - 1}, ok: true} + } + s, en := start, end + if en+1 < len(line) && isBlank(line[en+1]) { + for en+1 < len(line) && isBlank(line[en+1]) { + en++ + } + } else { + for s > 0 && isBlank(line[s-1]) { + s-- + } + } + return objectRange{start: Pos{e.cursor.Line, s}, end: Pos{e.cursor.Line, en}, ok: true} +} + +// tagObject selects an `...` element around the cursor. +func (e *Editor) tagObject(inner bool, n int) objectRange { + text := []rune(e.buf.Text()) + offsets := e.lineOffsets() + cur := offsets[e.cursor.Line] + e.cursor.Col + type tag struct { + start, end int + name string + closing bool + } + tags := []tag{} + for i := 0; i < len(text); i++ { + if text[i] != '<' { + continue + } + j := i + 1 + closing := false + if j < len(text) && text[j] == '/' { + closing = true + j++ + } + nameStart := j + for j < len(text) && (isWordRune(text[j]) || text[j] == '-') { + j++ + } + if j == nameStart { + continue + } + name := string(text[nameStart:j]) + for j < len(text) && text[j] != '>' { + j++ + } + if j >= len(text) { + break + } + if text[j-1] == '/' { + i = j + continue + } + tags = append(tags, tag{start: i, end: j, name: name, closing: closing}) + i = j + } + // Match pairs with a stack, collect elements containing the cursor. + type element struct{ open, close tag } + elements := []element{} + stack := []tag{} + for _, t := range tags { + if !t.closing { + stack = append(stack, t) + continue + } + for k := len(stack) - 1; k >= 0; k-- { + if stack[k].name == t.name { + elements = append(elements, element{open: stack[k], close: t}) + stack = stack[:k] + break + } + } + } + enclosing := []element{} + for _, el := range elements { + if el.open.start <= cur && cur <= el.close.end { + enclosing = append(enclosing, el) + } + } + if len(enclosing) == 0 { + return objectRange{} + } + // Innermost first. + for i := 0; i < len(enclosing); i++ { + for j := i + 1; j < len(enclosing); j++ { + if enclosing[j].open.start > enclosing[i].open.start { + enclosing[i], enclosing[j] = enclosing[j], enclosing[i] + } + } + } + el := enclosing[min(n, len(enclosing))-1] + var s, en int + if inner { + s, en = el.open.end+1, el.close.start-1 + if en < s { + return objectRange{start: e.posFromOffset(offsets, el.close.start), end: e.posFromOffset(offsets, el.open.end), ok: true} + } + } else { + s, en = el.open.start, el.close.end + } + return objectRange{start: e.posFromOffset(offsets, s), end: e.posFromOffset(offsets, en), ok: true} +} + +func (e *Editor) lineOffsets() []int { + offsets := make([]int, e.buf.LineCount()+1) + total := 0 + for i := 0; i < e.buf.LineCount(); i++ { + offsets[i] = total + total += e.buf.LineLen(i) + 1 + } + offsets[e.buf.LineCount()] = total + return offsets +} + +func (e *Editor) posFromOffset(offsets []int, off int) Pos { + line := 0 + for line+1 < len(offsets)-1 && offsets[line+1] <= off { + line++ + } + return Pos{line, off - offsets[line]} +} diff --git a/internal/helix/visual.go b/internal/helix/visual.go new file mode 100644 index 0000000..c038bb1 --- /dev/null +++ b/internal/helix/visual.go @@ -0,0 +1,812 @@ +package helix + +import ( + "math" + "strings" +) + +// The selection model: in ModeSelect the selection runs from anchor to +// cursor (either order) and kind says what it covers — characters, whole +// lines, or a rectangular block (a ZenNotes extension; Helix has no block +// mode). In ModeNormal the selection is collapsed onto the cursor and acts +// as the one character under it, the way Helix models its block cursor. + +// selRange is the current selection as ordered endpoints. Outside select +// mode it is the collapsed selection on the cursor. +func (e *Editor) selRange() (from, to Pos) { + if e.mode != ModeSelect && e.anchor == e.cursor { + return e.cursor, e.cursor + } + from, to = e.anchor, e.cursor + if to.Less(from) { + from, to = to, from + } + return from, to +} + +// enterSelect enters select mode with the given kind; the same kind toggles +// back to normal mode, a different kind switches the selection shape. +func (e *Editor) enterSelect(kind selKind) { + if e.mode == ModeSelect { + if e.kind == kind { + e.exitSelect() + return + } + e.kind = kind + return + } + // A selector motion may already have left a spanning selection; keep it. + e.kind = kind + e.mode = ModeSelect + e.selExcl = false + e.pending = nil +} + +// exitSelect collapses to normal mode, remembering the selection for `gv`. +func (e *Editor) exitSelect() { + start, end := e.selRange() + e.lastSelect.start, e.lastSelect.end, e.lastSelect.kind, e.lastSelect.set = start, end, e.kind, true + e.marks['<'] = start + e.marks['>'] = end + e.anchor = e.cursor + e.selExcl = false + e.mode = ModeNormal + e.cursor = e.buf.clampNormal(e.cursor) + e.pending = nil +} + +// collapseToCursor shrinks the selection onto the cursor (Helix's `;`), +// keeping the mode. +func (e *Editor) collapseToCursor() { + e.anchor = e.cursor + e.selExcl = false + e.desiredCol = -1 +} + +// reselect restores the last selection (`gv`). +func (e *Editor) reselect() { + if !e.lastSelect.set { + return + } + e.mode = ModeSelect + e.kind = e.lastSelect.kind + e.selExcl = false + e.anchor = e.buf.clampNormal(e.lastSelect.start) + e.cursor = e.buf.clampNormal(e.lastSelect.end) +} + +// SelectionKind reports what the active selection covers: "v", "V" or +// "^V". Outside select mode it is "v" (the collapsed selection on the +// cursor). +func (e *Editor) SelectionKind() string { + if e.mode != ModeSelect { + return "v" + } + switch e.kind { + case selLine: + return "V" + case selBlock: + return "^V" + } + return "v" +} + +// VisualRange is the normalized selection: start..end inclusive, the mode, +// and ok when a selection is active. Selector motions leave spanning +// selections in normal mode, so any non-collapsed anchor counts as active — +// insert mode never renders one. A word-start selection's end is exclusive; +// it is reported one character short so the highlight stops before the next +// word's first char. For block mode the columns come from BlockColumns. +func (e *Editor) VisualRange() (start, end Pos, mode Mode, ok bool) { + if e.mode == ModeInsert || (e.mode != ModeSelect && e.anchor == e.cursor) { + return Pos{}, Pos{}, e.mode, false + } + start, end = e.selectionChars() + if e.kind == selLine { + start.Col = 0 + end.Col = max(0, e.buf.LineLen(end.Line)-1) + } + return start, end, e.mode, true +} + +// BlockCursor is where the block cursor is drawn. Helix keeps the cursor on +// the head side of the selection, inside it: a selection that stops short of +// its head, like the one a word-start motion leaves, draws the cursor on the +// character the selection ends on. +func (e *Editor) BlockCursor() Pos { + if !e.selExcl || e.mode == ModeInsert { + return e.cursor + } + from, to := e.selectionChars() + if e.cursor.Less(e.anchor) { + // A backward motion: the head is the lower bound, so the cursor + // sits on the first character of the selection. + return from + } + // A forward motion: the head is the upper bound, so the cursor sits + // on the last character of the selection. + return to +} + +// prevCharPos is the character before p, walking back over line breaks. +func (e *Editor) prevCharPos(p Pos) Pos { + if p.Col > 0 { + return Pos{p.Line, p.Col - 1} + } + if p.Line > 0 { + return Pos{p.Line - 1, max(0, e.buf.LineLen(p.Line-1)-1)} + } + return p +} + +// nextCharPos is the character after p, walking over line breaks. +func (e *Editor) nextCharPos(p Pos) Pos { + if p.Col+1 < e.buf.LineLen(p.Line) { + return Pos{p.Line, p.Col + 1} + } + if p.Line+1 < e.buf.LineCount() { + return Pos{p.Line + 1, 0} + } + return p +} + +// BlockColumns is the column span of a block selection, plus whether `$` +// extends every line to its end. +func (e *Editor) BlockColumns() (left, right int, toEnd bool) { + left = min(e.anchor.Col, e.cursor.Col) + right = max(e.anchor.Col, e.cursor.Col) + toEnd = e.desiredCol == math.MaxInt32 + return +} + +// selectionSpan converts the current selection into an operator span; the +// collapsed selection becomes the single character under the cursor. +func (e *Editor) selectionSpan() span { + if e.mode != ModeSelect && (e.anchor == e.cursor || e.mode == ModeInsert) { + return span{start: e.cursor, end: e.stepInsertPos(e.cursor)} + } + from, to := e.selRange() + switch e.kind { + case selLine: + return span{start: Pos{from.Line, 0}, end: Pos{to.Line, 0}, linewise: true} + case selBlock: + left, right, toEnd := e.BlockColumns() + return span{start: Pos{from.Line, left}, end: Pos{to.Line, right}, blockwise: true, blockLeft: left, blockRight: right, blockToEnd: toEnd} + } + // The characters the selection covers: a word-start motion leaves out + // the word it landed on, on either side of the selection. + from, to = e.selectionChars() + return span{start: from, end: e.stepInsertPos(to)} +} + +// --- select mode dispatch --- + +func (e *Editor) selectKey(k Key) { + e.ClearMessage() + e.pending = append(e.pending, k) + status := e.runSelect(e.pending) + if status != parseIncomplete { + e.pending = nil + } +} + +func (e *Editor) runSelect(keys []Key) parseStatus { + register, count, hasCount, rest, status := splitPrefix(keys) + if status != parseComplete { + return status + } + if len(rest) == 0 { + return parseIncomplete + } + k := rest[0] + n := max(1, count) + versionBefore := e.version + finish := func(entered bool) parseStatus { + if entered { + if !e.replaying { + e.recordingIns = true + e.changeKeys = append([]Key(nil), keys...) + } + return parseComplete + } + e.commitChange() + if e.version != versionBefore && !e.replaying { + e.last = lastChange{keys: stripCount(keys), count: count, register: register} + } + e.anchor = e.cursor + e.ensureCursorVisible() + return parseComplete + } + switch { + case k.Is("esc") || k.IsCtrl('c') || k.IsCtrl('['): + e.exitSelect() + return parseComplete + case k.IsRune('v'): + e.enterSelect(selChar) + return parseComplete + case k.IsRune('V'): + e.enterSelect(selLine) + return parseComplete + case k.IsCtrl('v'): + e.enterSelect(selBlock) + return parseComplete + case k.IsRune('o'): + e.anchor, e.cursor = e.cursor, e.anchor + e.desiredCol = -1 + return parseComplete + case k.IsRune('O'): + if e.kind == selBlock { + e.anchor.Col, e.cursor.Col = e.cursor.Col, e.anchor.Col + } else { + e.anchor, e.cursor = e.cursor, e.anchor + } + e.desiredCol = -1 + return parseComplete + case k.IsRune(';'): + e.collapseToCursor() + return parseComplete + case k.IsRune(':'): + e.exitSelect() + e.openCmdline(':', 0, false) + e.cmd.text = []rune("'<,'>") + e.cmd.pos = len(e.cmd.text) + return parseComplete + case k.IsRune('d') || k.Is("delete"): + e.beginChange() + sp := e.selectionSpan() + e.exitSelect() + e.deleteSpan(sp, register) + return finish(false) + case k.IsRune('y'): + sp := e.selectionSpan() + e.exitSelect() + e.yankSpan(sp, register, true) + e.cursor = e.buf.clampNormal(sp.start) + return finish(false) + case k.IsRune('c') || k.IsRune('s'): + e.beginChange() + sp := e.selectionSpan() + e.exitSelect() + entered := e.runOperator("c", sp, register, false) + return finish(entered) + case k.IsRune('D'): + e.beginChange() + sp := e.selectionSpan() + from, to := e.selRange() + if e.kind == selBlock { + sp.blockToEnd = true + e.exitSelect() + e.deleteSpan(sp, register) + return finish(false) + } + sp = span{start: from, end: Pos{to.Line, 0}, linewise: to.Line > from.Line} + if to.Line == from.Line { + sp = span{start: from, end: Pos{from.Line, e.buf.LineLen(from.Line)}} + } + e.exitSelect() + e.deleteSpan(sp, register) + return finish(false) + case k.IsRune('C'): + if e.kind == selBlock { + e.beginChange() + sp := e.selectionSpan() + left, right, _ := e.BlockColumns() + e.exitSelect() + sp.blockToEnd = true + e.deleteSpan(sp, register) + e.startBlockInsert(sp.start.Line, sp.end.Line, left, false) + _ = right + return finish(true) + } + e.beginChange() + sp := e.selectionSpan() + e.exitSelect() + entered := e.runOperator("c", span{start: Pos{sp.start.Line, 0}, end: Pos{sp.end.Line, 0}, linewise: true}, register, false) + return finish(entered) + case k.IsRune('p') || k.IsRune('P'): + e.beginChange() + e.putSelection(register, n, k.IsRune('p')) + return finish(false) + case k.IsRune('R'): + e.beginChange() + sp := e.selectionSpan() + e.putOverSpan(sp, register, n, false) + return finish(false) + case k.IsRune('J') || (k.IsRune('g') && len(rest) > 1 && rest[1].IsRune('J')): + e.beginChange() + start, end := e.selRange() + trim := k.IsRune('J') + e.exitSelect() + e.joinLines(start.Line, max(2, end.Line-start.Line+1), trim) + return finish(false) + case k.IsRune('<') || k.IsRune('>'): + e.beginChange() + start, end := e.selRange() + e.exitSelect() + e.shiftLines(start.Line, end.Line, k.IsRune('>'), n) + e.cursor = Pos{start.Line, firstNonBlank(e.buf.Line(start.Line))} + return finish(false) + case k.IsRune('='): + start, _ := e.selRange() + e.exitSelect() + e.cursor = Pos{start.Line, firstNonBlank(e.buf.Line(start.Line))} + return parseComplete + case k.IsRune('~') || k.IsRune('`'): + e.beginChange() + sp := e.selectionSpan() + e.exitSelect() + op := "g~" + if k.IsRune('`') { + op = "gu" + } + e.transformSpan(sp, op) + e.cursor = e.buf.clampNormal(sp.start) + return finish(false) + case k.IsRune('r'): + if len(rest) < 2 { + return parseIncomplete + } + if !rest[1].Printable() && !rest[1].Is("enter") { + e.exitSelect() + return parseComplete + } + e.beginChange() + sp := e.selectionSpan() + e.exitSelect() + e.replaceSpanWith(sp, rest[1]) + return finish(false) + case k.IsRune('I') || k.IsRune('A'): + if e.kind == selBlock { + e.beginChange() + sp := e.selectionSpan() + left, right, toEnd := e.BlockColumns() + e.exitSelect() + col := left + appendMode := k.IsRune('A') + if appendMode { + col = right + 1 + if toEnd { + col = -1 + } + } + e.startBlockInsert(sp.start.Line, sp.end.Line, col, appendMode) + return finish(true) + } + start, end := e.selRange() + e.exitSelect() + if k.IsRune('I') { + e.startInsert(Pos{start.Line, firstNonBlankInsert(e.buf.Line(start.Line))}) + } else { + e.startInsert(Pos{end.Line, e.buf.LineLen(end.Line)}) + } + return finish(true) + case k.IsRune('g'): + if len(rest) < 2 { + return parseIncomplete + } + second := rest[1] + if !second.Printable() { + return parseInvalid + } + switch second.Rune { + case 'u', 'U', '~', '?': + e.beginChange() + sp := e.selectionSpan() + e.exitSelect() + e.transformSpan(sp, "g"+string(second.Rune)) + e.cursor = e.buf.clampNormal(sp.start) + return finish(false) + case 'q', 'w': + e.beginChange() + sp := e.selectionSpan() + e.exitSelect() + e.reflowLines(sp.start.Line, sp.end.Line) + e.cursor = e.buf.clampNormal(Pos{sp.start.Line, 0}) + return finish(false) + case 'v': + e.reselect() + return parseComplete + case 'd': + e.exitSelect() + if e.hooks.FollowLink != nil { + e.hooks.FollowLink() + } + return parseComplete + } + m, st := parseMotion(rest) + if st != parseComplete { + return st + } + e.extendByMotion(m, count, hasCount) + return parseComplete + case k.IsRune('m'): + st, _ := e.matchMode(rest) + return st + case k.IsRune('x'): + e.extendLineBelow() + return parseComplete + case k.IsRune('X'): + e.extendToLineBounds() + return parseComplete + case k.IsRune('%'): + e.selectAll() + return parseComplete + case k.IsCtrl('d'): + e.scrollHalfPage(1, count, hasCount) + return parseComplete + case k.IsCtrl('u'): + e.scrollHalfPage(-1, count, hasCount) + return parseComplete + case k.IsCtrl('f'): + e.scrollPage(n) + return parseComplete + case k.IsCtrl('b'): + e.scrollPage(-n) + return parseComplete + case k.IsCtrl('e'): + e.scrollLines(n) + return parseComplete + case k.IsCtrl('y'): + e.scrollLines(-n) + return parseComplete + case k.IsCtrl('a') || k.IsCtrl('x'): + e.beginChange() + delta := n + if k.IsCtrl('x') { + delta = -n + } + start, end := e.selRange() + for l := start.Line; l <= end.Line; l++ { + saved := e.cursor + e.cursor = Pos{l, 0} + e.incrementNumber(delta) + e.cursor = saved + } + return finish(false) + case k.IsAlt('.'): + e.repeatLastMotion(n) + return parseComplete + case k.IsAlt('o') || (k.Alt && k.Name == "up"): + e.expandSelection() + return parseComplete + case k.IsAlt('i') || (k.Alt && k.Name == "down"): + e.shrinkSelection() + return parseComplete + case k.IsRune('z'): + if len(rest) < 2 { + return parseIncomplete + } + if rest[1].IsRune('f') { + start, end := e.selRange() + e.exitSelect() + e.folds[start.Line] = end.Line + e.cursor = Pos{start.Line, e.cursor.Col} + return parseComplete + } + return parseInvalid + } + m, st := parseMotion(rest) + if st != parseComplete { + return st + } + switch { + case m.kind == "n" || m.kind == "N": + e.selectMatch(m, count, hasCount) + case selectorMotion(m): + e.selectByMotion(m, count, hasCount) + default: + e.extendByMotion(m, count, hasCount) + } + return parseComplete +} + +// --- selection construction --- + +// extendLineBelow selects the cursor's line, or grows an existing line +// selection by one more line (Helix's x). +func (e *Editor) extendLineBelow() { + if e.mode != ModeSelect || e.kind != selLine { + e.mode = ModeSelect + e.kind = selLine + e.selExcl = false + e.anchor = Pos{e.cursor.Line, 0} + e.cursor = Pos{e.cursor.Line, max(0, e.buf.LineLen(e.cursor.Line)-1)} + e.desiredCol = -1 + e.pending = nil + return + } + last := max(e.anchor.Line, e.cursor.Line) + if last+1 < e.buf.LineCount() { + if e.anchor.Less(e.cursor) { + e.cursor = Pos{last + 1, max(0, e.buf.LineLen(last+1)-1)} + } else { + e.anchor = Pos{last + 1, 0} + } + } +} + +// extendToLineBounds extends the selection to whole lines (Helix's X). +func (e *Editor) extendToLineBounds() { + from, to := e.selRange() + e.mode = ModeSelect + e.selExcl = false + e.anchor = Pos{from.Line, 0} + e.cursor = Pos{to.Line, max(0, e.buf.LineLen(to.Line)-1)} + e.desiredCol = -1 + e.pending = nil +} + +// selectAll selects the whole buffer (Helix's %). +func (e *Editor) selectAll() { + last := e.buf.LineCount() - 1 + e.mode = ModeSelect + e.selExcl = false + e.anchor = Pos{0, 0} + e.cursor = Pos{last, max(0, e.buf.LineLen(last)-1)} + e.desiredCol = -1 + e.pending = nil +} + +func (e *Editor) extendByMotion(m motion, count int, hasCount bool) { + from := e.cursor + e.lastMotion = m + res, ok := e.applyMotion(m, count, hasCount, e.cursor, false) + if !ok { + return + } + e.selExcl = false + if res.jump { + e.pushJump() + } + e.cursor = e.buf.clampNormal(res.pos) + if m.kind == "$" || m.kind == "g$" { + e.desiredCol = math.MaxInt32 + } else if res.keepCol { + if e.desiredCol < 0 { + e.desiredCol = from.Col + } + } else { + e.desiredCol = -1 + } + e.ensureCursorVisible() +} + +func (e *Editor) extendByObject(obj textObject, count int) { + rng := e.textObjectRange(obj, count) + if !rng.ok { + return + } + e.selExcl = false + if rng.linewise && e.kind == selChar { + e.kind = selLine + } + // Extend rather than replace when the selection already spans more than + // the object. + if e.anchor.Less(rng.start) && e.anchor != e.cursor { + e.cursor = rng.end + return + } + e.anchor = rng.start + e.cursor = rng.end + e.desiredCol = -1 +} + +// --- selection operations --- + +// putSelection pastes after (p) or before (P) the selection. With a +// collapsed selection it behaves the Helix/Vim way: after the character +// under the cursor, or below its line for a linewise register. +func (e *Editor) putSelection(register rune, n int, after bool) { + if e.mode != ModeSelect { + e.put(register, n, after, false) + return + } + reg, ok := e.readRegister(register) + if !ok { + return + } + sp := e.selectionSpan() + if sp.linewise { + line := sp.end.Line + if !after { + line = sp.start.Line - 1 + } + text := strings.TrimSuffix(reg.Text, "\n") + lines := []string{} + for i := 0; i < n; i++ { + lines = append(lines, strings.Split(text, "\n")...) + } + e.buf.InsertLines(line+1, lines) + e.cursor = Pos{line + 1, firstNonBlank(e.buf.Line(line + 1))} + e.exitSelect() + return + } + at := sp.end + if !after { + at = sp.start + } + text := reg.Text + if !reg.Linewise { + text = strings.Repeat(strings.TrimSuffix(reg.Text, "\n"), n) + } + end := e.buf.InsertText(at, text) + e.cursor = e.buf.clampNormal(end) +} + +// indentSelection shifts the selected lines one stop in or out. +func (e *Editor) indentSelection(dir int) { + from, to := e.selRange() + e.shiftLines(from.Line, to.Line, dir > 0, 1) + e.cursor = Pos{from.Line, firstNonBlank(e.buf.Line(from.Line))} + e.anchor = e.cursor +} + +// transformSelection applies a case operator to the selection. +func (e *Editor) transformSelection(op string) { + sp := e.selectionSpan() + e.transformSpan(sp, op) + e.cursor = e.buf.clampNormal(sp.start) + e.anchor = e.cursor +} + +// replaceSelectionWith swaps every character of the selection for one rune. +func (e *Editor) replaceSelectionWith(r rune) { + e.replaceSpanWith(e.selectionSpan(), R(r)) + e.anchor = e.cursor +} + +// joinSelection joins the selection's lines; with a collapsed selection it +// joins the cursor line with the next ones. +func (e *Editor) joinSelection(n int) { + from, to := e.selRange() + e.joinLines(from.Line, max(2, to.Line-from.Line+1), true) + _ = n +} + +func (e *Editor) replaceSpanWith(sp span, k Key) { + if k.Is("enter") { + return + } + r := k.Rune + apply := func(l, from, to int) { + line := cloneRunes(e.buf.Line(l)) + to = min(to, len(line)) + for i := max(0, from); i < to; i++ { + line[i] = r + } + e.buf.ReplaceLine(l, line) + } + switch { + case sp.linewise: + for l := sp.start.Line; l <= sp.end.Line; l++ { + apply(l, 0, e.buf.LineLen(l)) + } + case sp.blockwise: + for l := sp.start.Line; l <= sp.end.Line; l++ { + right := sp.blockRight + 1 + if sp.blockToEnd { + right = e.buf.LineLen(l) + } + apply(l, sp.blockLeft, right) + } + default: + for l := sp.start.Line; l <= sp.end.Line; l++ { + from, to := 0, e.buf.LineLen(l) + if l == sp.start.Line { + from = sp.start.Col + } + if l == sp.end.Line { + to = sp.end.Col + } + apply(l, from, to) + } + } + e.cursor = e.buf.clampNormal(sp.start) +} + +// putOverSpan replaces the selection with a register; the replaced text +// lands in the unnamed register when swapRegister is set. +func (e *Editor) putOverSpan(sp span, register rune, n int, swapRegister bool) { + reg, ok := e.readRegister(register) + if !ok { + return + } + removed := e.spanText(sp) + switch { + case sp.linewise: + at := sp.start.Line + text := strings.TrimSuffix(reg.Text, "\n") + lines := []string{} + for i := 0; i < n; i++ { + lines = append(lines, strings.Split(text, "\n")...) + } + e.buf.ReplaceLines(sp.start.Line, sp.end.Line, lines) + e.cursor = Pos{at, firstNonBlank(e.buf.Line(at))} + case sp.blockwise: + e.deleteSpan(sp, '_') + saved := e.cursor + e.cursor = Pos{sp.start.Line, sp.blockLeft} + e.put(register, n, false, false) + _ = saved + default: + e.buf.DeleteRange(sp.start, sp.end) + text := strings.Repeat(strings.TrimSuffix(reg.Text, "\n"), n) + if reg.Linewise { + text = "\n" + strings.TrimSuffix(reg.Text, "\n") + "\n" + } + end := e.buf.InsertText(sp.start, text) + e.cursor = e.buf.clampNormal(Pos{end.Line, end.Col - 1}) + if reg.Linewise { + e.cursor = Pos{sp.start.Line + 1, 0} + } + } + e.anchor = e.cursor + if swapRegister { + e.storeRegister(0, removed, false) + } +} + +// startBlockInsert begins insert on the first line of a block; the typed +// text is replicated onto the other lines when insert ends. col -1 means +// each line's own end. +func (e *Editor) startBlockInsert(startLine, endLine, col int, appendMode bool) { + e.blockInsert = &blockInsertState{startLine: startLine, endLine: endLine, col: col, appendMode: appendMode} + first := Pos{startLine, col} + if col < 0 { + first.Col = e.buf.LineLen(startLine) + } else if appendMode { + line := cloneRunes(e.buf.Line(startLine)) + for len(line) < col { + line = append(line, ' ') + } + e.buf.ReplaceLine(startLine, line) + } + e.mode = ModeInsert + e.cursor = e.buf.clampInsert(first) + e.ins = insertState{start: e.cursor} + e.blockInsert.startCol = e.cursor.Col +} + +type blockInsertState struct { + startLine, endLine int + col int + startCol int + appendMode bool +} + +// finishBlockInsert copies what was typed on the first line onto the rest +// of the block. +func (e *Editor) finishBlockInsert() { + bi := e.blockInsert + e.blockInsert = nil + if bi == nil || e.cursor.Line != bi.startLine { + return + } + line := e.buf.Line(bi.startLine) + endCol := e.cursor.Col + if endCol <= bi.startCol { + return + } + typed := string(line[bi.startCol:endCol]) + if strings.Contains(typed, "\n") { + return + } + for l := bi.startLine + 1; l <= bi.endLine && l < e.buf.LineCount(); l++ { + cur := cloneRunes(e.buf.Line(l)) + col := bi.col + if col < 0 { + col = len(cur) + } + if col > len(cur) { + if !bi.appendMode { + continue + } + for len(cur) < col { + cur = append(cur, ' ') + } + } + next := append(cloneRunes(cur[:col]), []rune(typed)...) + next = append(next, cur[col:]...) + e.buf.ReplaceLine(l, next) + } +} diff --git a/internal/keymaps/keymaps.go b/internal/keymaps/keymaps.go index c067a88..2079d8e 100644 --- a/internal/keymaps/keymaps.go +++ b/internal/keymaps/keymaps.go @@ -50,48 +50,50 @@ var Catalog = []Entry{ {"tabs.select7", "global", "Alt+7", "Go to tab 7"}, {"tabs.select8", "global", "Alt+8", "Go to tab 8"}, {"tabs.select9", "global", "Alt+9", "Go to tab 9"}, - {"vim.leaderPrefix", "vim", "Space", "Leader key"}, - {"vim.leaderOpenBuffers", "vim", "o", "Leader: open buffers"}, - {"vim.leaderWorkflows", "vim", "a", "Leader: open workflows"}, - {"vim.leaderAtlas", "vim", "g", "Leader: open atlas"}, - {"vim.leaderSearchNotes", "vim", "f", "Leader: search notes"}, - {"vim.leaderSearchGroup", "vim", "s", "Leader: search…"}, - {"vim.leaderSearchVaultText", "vim", "t", "Leader search: vault text"}, - {"vim.leaderToggleSidebar", "vim", "e", "Leader: toggle sidebar"}, - {"vim.leaderNoteOutline", "vim", "p", "Leader: note outline"}, - {"vim.leaderSwitchVault", "vim", "v", "Leader: switch vault"}, - {"vim.leaderViewGroup", "vim", "z", "Leader: view (preview, split, editor, wrap, line numbers, zen, theme)"}, - {"vim.leaderKanban", "vim", "k", "Leader: Kanban board"}, - {"vim.leaderNoteActions", "vim", "l", "Leader: note actions"}, - {"vim.leaderFormatNote", "vim", "f", "Leader note action: format note"}, - {"vim.leaderCopyMarkdown", "vim", "y", "Leader note action: copy note as Markdown"}, - {"vim.leaderToggleFavorite", "vim", "s", "Leader note action: toggle favorite"}, - {"vim.leaderQuickCapture", "vim", "q", "Leader: open quick capture"}, - {"vim.leaderTemplatePicker", "vim", "t", "Leader: new from template"}, - {"vim.leaderInsertTemplate", "vim", "i", "Leader: insert template into note"}, - {"vim.leaderDailyNote", "vim", "d", "Leader: today's daily note"}, - {"vim.leaderWeeklyNote", "vim", "w", "Leader: this week's note"}, - {"vim.leaderMonthlyNote", "vim", "m", "Leader: this month's note"}, - {"vim.leaderCalendar", "vim", "c", "Leader: toggle calendar"}, - {"vim.hintMode", "vim", "h", "Leader: hint mode"}, - {"vim.panePrefix", "vim", "Ctrl+W", "Pane command prefix"}, - {"vim.paneFocusLeft", "vim", "h", "Pane: focus left"}, - {"vim.paneFocusDown", "vim", "j", "Pane: focus down"}, - {"vim.paneFocusUp", "vim", "k", "Pane: focus up"}, - {"vim.paneFocusRight", "vim", "l", "Pane: focus right"}, - {"vim.paneSplitRight", "vim", "v", "Pane: split right"}, - {"vim.paneSplitDown", "vim", "s", "Pane: split down"}, - {"vim.historyBack", "vim", "Ctrl+O", "Go back in note history"}, - {"vim.historyForward", "vim", "Ctrl+I", "Go forward in note history"}, - {"vim.bufferPrevious", "vim", "[ b", "Previous buffer"}, - {"vim.bufferNext", "vim", "] b", "Next buffer"}, - {"vim.tabPrevious", "vim", "g T", "Previous tab"}, - {"vim.tabNext", "vim", "g t", "Next tab"}, - {"vim.goToDefinition", "vim", "g d", "Follow link at cursor"}, - {"vim.foldCurrent", "vim", "z c", "Fold heading at cursor"}, - {"vim.unfoldCurrent", "vim", "z o", "Unfold heading at cursor"}, - {"vim.foldAll", "vim", "z M", "Fold all headings"}, - {"vim.unfoldAll", "vim", "z R", "Unfold all headings"}, + {"helix.leaderPrefix", "helix", "Space", "Leader key"}, + {"helix.leaderOpenBuffers", "helix", "o", "Leader: open buffers"}, + {"helix.leaderWorkflows", "helix", "a", "Leader: open workflows"}, + {"helix.leaderAtlas", "helix", "g", "Leader: open atlas"}, + {"helix.leaderSearchNotes", "helix", "f", "Leader: search notes"}, + {"helix.leaderSearchGroup", "helix", "s", "Leader: search…"}, + {"helix.leaderSearchVaultText", "helix", "t", "Leader search: vault text"}, + {"helix.leaderToggleSidebar", "helix", "e", "Leader: toggle sidebar"}, + {"helix.leaderNoteOutline", "helix", "p", "Leader: note outline"}, + {"helix.leaderSwitchVault", "helix", "v", "Leader: switch vault"}, + {"helix.leaderViewGroup", "helix", "z", "Leader: view (preview, split, editor, wrap, line numbers, zen, theme)"}, + {"helix.leaderKanban", "helix", "k", "Leader: Kanban board"}, + {"helix.leaderNoteActions", "helix", "l", "Leader: note actions"}, + {"helix.leaderFormatNote", "helix", "f", "Leader note action: format note"}, + {"helix.leaderCopyMarkdown", "helix", "y", "Leader note action: copy note as Markdown"}, + {"helix.leaderToggleFavorite", "helix", "s", "Leader note action: toggle favorite"}, + {"helix.leaderQuickCapture", "helix", "q", "Leader: open quick capture"}, + {"helix.leaderTemplatePicker", "helix", "t", "Leader: new from template"}, + {"helix.leaderInsertTemplate", "helix", "i", "Leader: insert template into note"}, + {"helix.leaderDailyNote", "helix", "d", "Leader: today's daily note"}, + {"helix.leaderWeeklyNote", "helix", "w", "Leader: this week's note"}, + {"helix.leaderMonthlyNote", "helix", "m", "Leader: this month's note"}, + {"helix.leaderCalendar", "helix", "c", "Leader: toggle calendar"}, + {"helix.hintMode", "helix", "h", "Leader: hint mode"}, + {"helix.panePrefix", "helix", "Ctrl+W", "Pane command prefix"}, + {"helix.paneFocusLeft", "helix", "h", "Pane: focus left"}, + {"helix.paneFocusDown", "helix", "j", "Pane: focus down"}, + {"helix.paneFocusUp", "helix", "k", "Pane: focus up"}, + {"helix.paneFocusRight", "helix", "l", "Pane: focus right"}, + {"helix.paneSplitRight", "helix", "v", "Pane: split right"}, + {"helix.paneSplitDown", "helix", "s", "Pane: split down"}, + {"helix.historyBack", "helix", "Ctrl+O", "Go back in note history"}, + {"helix.historyForward", "helix", "Ctrl+I", "Go forward in note history"}, + {"helix.bufferPrevious", "helix", "[ b", "Previous buffer"}, + {"helix.bufferNext", "helix", "] b", "Next buffer"}, + {"helix.tabPrevious", "helix", "g T", "Previous tab"}, + {"helix.tabNext", "helix", "g t", "Next tab"}, + {"helix.gotoTabPrevious", "helix", "g p", "Previous tab (gp)"}, + {"helix.gotoTabNext", "helix", "g n", "Next tab (gn)"}, + {"helix.goToDefinition", "helix", "g d", "Follow link at cursor"}, + {"helix.foldCurrent", "helix", "z c", "Fold heading at cursor"}, + {"helix.unfoldCurrent", "helix", "z o", "Unfold heading at cursor"}, + {"helix.foldAll", "helix", "z M", "Fold all headings"}, + {"helix.unfoldAll", "helix", "z R", "Unfold all headings"}, {"nav.moveDown", "navigation", "j", "Move selection down"}, {"nav.moveUp", "navigation", "k", "Move selection up"}, {"nav.moveLeft", "navigation", "h", "Move selection left"}, @@ -195,7 +197,7 @@ func (r *Resolver) Known(id string) bool { // LeaderKey is the single key that starts a leader sequence, "" when the // leader is unbound. func (r *Resolver) LeaderKey() string { - b := r.Binding("vim.leaderPrefix") + b := r.Binding("helix.leaderPrefix") if b == "Space" { return " " } diff --git a/internal/tui/app.go b/internal/tui/app.go index a06e6ae..583e3b9 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -17,10 +17,10 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/fswatch" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/keymaps" "github.com/ZenNotes/tui/internal/themes" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // Options configure a terminal session. @@ -85,7 +85,7 @@ type App struct { markedMoveDirs map[string]string boardOrder map[string][]string buffers map[string]*noteBuffer - registers vim.RegisterStore + registers helix.RegisterStore noteModes map[string]paneMode panes *paneNode activePane *pane @@ -106,7 +106,7 @@ type App struct { overlay overlay leader *leaderState - navKeys []vim.Key + navKeys []helix.Key paneKeys bool // tagMenu is the tag completion under the cursor; tagDismissed the token @@ -160,7 +160,7 @@ type App struct { type jumpEntry struct { path string - pos vim.Pos + pos helix.Pos } type toast struct { @@ -488,7 +488,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.Type == tea.KeyRunes && len(m.Runes) > 1 && !m.Alt { var cmds []tea.Cmd for _, r := range m.Runes { - if cmd := a.handleKey(vim.Key{Rune: r}); cmd != nil { + if cmd := a.handleKey(helix.Key{Rune: r}); cmd != nil { cmds = append(cmds, cmd) } } @@ -794,9 +794,9 @@ func (a *App) renderEmptyPane(w, h int) string { "", th.KeyHint.Render(padRight(a.keyOf("global.searchNotes"), 10)) + th.Base.Render("search notes"), th.KeyHint.Render(padRight(a.keyOf("global.newNoteHere"), 10)) + th.Base.Render("new note"), - th.KeyHint.Render(padRight(a.keyOf("vim.leaderPrefix")+" d", 10)) + th.Base.Render("today's daily note"), - th.KeyHint.Render(padRight(a.keyOf("vim.leaderPrefix")+" q", 10)) + th.Base.Render("quick capture"), - th.KeyHint.Render(padRight(a.keyOf("vim.leaderPrefix")+" x", 10)) + th.Base.Render("tasks"), + th.KeyHint.Render(padRight(a.keyOf("helix.leaderPrefix")+" d", 10)) + th.Base.Render("today's daily note"), + th.KeyHint.Render(padRight(a.keyOf("helix.leaderPrefix")+" q", 10)) + th.Base.Render("quick capture"), + th.KeyHint.Render(padRight(a.keyOf("helix.leaderPrefix")+" x", 10)) + th.Base.Render("tasks"), th.KeyHint.Render(padRight(":help", 10)) + th.Base.Render("the manual"), } if len(a.recent) > 0 { @@ -854,8 +854,8 @@ func (a *App) renderStatusBar() string { suffix := "" modeStyle := t.StatusMode if buf := a.activeBuffer(); buf != nil && a.focus == focusPane { - if a.prefs.VimMode { - mode = buf.ed.Mode().Label() + if a.prefs.HelixMode { + mode = buf.ed.ModeLabel() } else { mode = "EDIT" } @@ -901,7 +901,7 @@ func (a *App) renderStatusBar() string { if a.leader != nil { suffix = "SPC " + a.leader.pendingLabel() } else if len(a.navKeys) > 0 { - suffix = vim.KeysString(a.navKeys) + suffix = helix.KeysString(a.navKeys) } else if a.paneKeys { suffix = "^W" } diff --git a/internal/tui/authoring.go b/internal/tui/authoring.go index 78a8d56..bb9c70f 100644 --- a/internal/tui/authoring.go +++ b/internal/tui/authoring.go @@ -2,7 +2,7 @@ package tui import ( "fmt" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "regexp" "strings" "unicode" @@ -31,8 +31,8 @@ func inMarkdownFence(lines []string, line int) bool { var blockSnippetRe = regexp.MustCompile("^([\\t ]*(?:(?:[-+*]|[0-9]+[.)])[\\t ]+|>[\\t ]?)*)((?:`{3,}|~{3,}).*|\\$\\$)$") -func (a *App) markdownSnippet(buf *noteBuffer, k vim.Key) bool { - if !a.prefs.MarkdownSnippets || buf.ed.Mode() != vim.ModeInsert { +func (a *App) markdownSnippet(buf *noteBuffer, k helix.Key) bool { + if !a.prefs.MarkdownSnippets || buf.ed.Mode() != helix.ModeInsert { return false } cur := buf.ed.Cursor() @@ -64,7 +64,7 @@ func (a *App) markdownSnippet(buf *noteBuffer, k vim.Key) bool { return false } buf.ed.InsertAtCursor("\n" + indent + "\n" + indent + token) - buf.ed.SetCursor(vim.Pos{Line: cur.Line + 1, Col: len([]rune(indent))}) + buf.ed.SetCursor(helix.Pos{Line: cur.Line + 1, Col: len([]rune(indent))}) return true } } @@ -163,7 +163,7 @@ func (a *App) markdownCompletion() { } next := string(line[:start]) + it.id + suffix + rest buf.ed.ReplaceLineText(cur.Line, next) - buf.ed.SetCursor(vim.Pos{Line: cur.Line, Col: start + len([]rune(it.id+suffix))}) + buf.ed.SetCursor(helix.Pos{Line: cur.Line, Col: start + len([]rune(it.id+suffix))}) }} p.refilter(a) a.overlay = p diff --git a/internal/tui/authoring_test.go b/internal/tui/authoring_test.go index 61ba9ad..7f30214 100644 --- a/internal/tui/authoring_test.go +++ b/internal/tui/authoring_test.go @@ -1,6 +1,7 @@ package tui import ( + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vim" "strings" "testing" @@ -31,15 +32,15 @@ func TestSnippetRespectsPreferenceAndExistingPairs(t *testing.T) { a.openNote("note.md", true) buf := a.activeBuffer() buf.ed.ReplaceText("**") - buf.ed.HandleKey(vim.R('A')) - a.handleKey(vim.R(' ')) + buf.ed.HandleKey(helix.R('A')) + a.handleKey(helix.R(' ')) if buf.ed.Text() != "****" { t.Fatalf("snippet: %q", buf.ed.Text()) } a.prefs.MarkdownSnippets = false buf.ed.ReplaceText("**") - buf.ed.SetCursor(vim.Pos{Line: 0, Col: 2}) - a.handleKey(vim.R(' ')) + buf.ed.SetCursor(helix.Pos{Line: 0, Col: 2}) + a.handleKey(helix.R(' ')) if buf.ed.Text() != "** " { t.Fatalf("disabled snippet: %q", buf.ed.Text()) } @@ -52,7 +53,7 @@ func TestFrontmatterTagCompletionPreservesSequenceAndUndo(t *testing.T) { buf := a.activeBuffer() body := "---\ntags: [foo, ba]\n---\nText" buf.ed.ReplaceText(body) - buf.ed.SetCursor(vim.Pos{Line: 1, Col: 14}) + buf.ed.SetCursor(helix.Pos{Line: 1, Col: 14}) a.markdownCompletion() p, ok := a.overlay.(*palette) if !ok { @@ -62,7 +63,7 @@ func TestFrontmatterTagCompletionPreservesSequenceAndUndo(t *testing.T) { if buf.ed.Line(1) != "tags: [foo, bar]" { t.Fatalf("frontmatter damaged: %s", buf.ed.Text()) } - buf.ed.HandleKey(vim.R('u')) + buf.ed.HandleKey(helix.R('u')) if buf.ed.Text() != body { t.Fatalf("completion undo did not restore note: %s", buf.ed.Text()) } diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index cbad444..2bf05d5 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -10,8 +10,8 @@ import ( "github.com/atotto/clipboard" "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // noteBuffer is an open note: its Vim editor plus save state. One buffer @@ -20,7 +20,7 @@ type noteBuffer struct { // codeHL caches the syntax highlighting of fenced code per version. codeHL *codeHighlights path string - ed *vim.Editor + ed modalEditor meta vault.NoteMeta savedText string diskChanged bool @@ -46,18 +46,18 @@ type saveResultMsg struct { } // editorOptions derives the engine options from the preferences. -func (a *App) editorOptions() vim.Options { - opts := vim.DefaultOptions() - opts.WrappedLineMotions = a.prefs.VimWrappedLineMotions == "display" +func (a *App) editorOptions() helix.Options { + opts := helix.DefaultOptions() + opts.WrappedLineMotions = a.prefs.HelixWrappedLineMotions == "display" opts.TabSize = a.prefs.EditorTabSize opts.ScrollOff = a.prefs.EditorScrollOff - opts.InsertEscape = a.prefs.VimInsertEscape + opts.InsertEscape = a.prefs.HelixInsertEscape opts.AutoPairs = a.prefs.AutoPairs opts.AutoPairQuotes = a.prefs.AutoPairQuotesInProse opts.TextReplacements = a.prefs.TextReplacements opts.TextReplacementsEnabled = a.prefs.TextReplacementsEnabled - opts.YankToClipboard = a.prefs.VimYankToClipboard - opts.VimEnabled = a.prefs.VimMode + opts.YankToClipboard = a.prefs.HelixYankToClipboard + opts.HelixEnabled = a.prefs.HelixMode opts.MarkdownLists = true return opts } @@ -70,7 +70,7 @@ func (a *App) openBuffer(path string) (*noteBuffer, error) { if a.deferReads { meta, _ := a.noteMeta(path) buf := ¬eBuffer{path: path, meta: meta, loading: true} - buf.ed = vim.NewWithRegisters("", a.editorOptions(), a.editorHooks(buf), &a.registers) + buf.ed = a.newModalEditor("", a.editorHooks(buf), &a.registers) buf.ed.MarkSaved() a.buffers[path] = buf a.queue(a.readBufferCmd(buf)) @@ -81,15 +81,15 @@ func (a *App) openBuffer(path string) (*noteBuffer, error) { return nil, err } buf := ¬eBuffer{path: path, meta: content.NoteMeta, savedText: content.Body} - buf.ed = vim.NewWithRegisters(content.Body, a.editorOptions(), a.editorHooks(buf), &a.registers) + buf.ed = a.newModalEditor(content.Body, a.editorHooks(buf), &a.registers) buf.ed.MarkSaved() a.buffers[path] = buf return buf, nil } -func (a *App) editorHooks(buf *noteBuffer) vim.Hooks { - return vim.Hooks{ - DisplayRowBounds: func(p vim.Pos) (int, int) { +func (a *App) editorHooks(buf *noteBuffer) helix.Hooks { + return helix.Hooks{ + DisplayRowBounds: func(p helix.Pos) (int, int) { line := buf.ed.LineRunes(p.Line) if !a.prefs.WordWrap { return 0, len(line) @@ -101,7 +101,7 @@ func (a *App) editorHooks(buf *noteBuffer) vim.Hooks { } return 0, len(line) }, - ExCommand: func(cmd vim.ExCommand) (bool, error) { + ExCommand: func(cmd helix.ExCommand) (bool, error) { return a.runEx(buf, cmd) }, ExComplete: func(text string) (string, []string) { return a.completeEx(text) }, @@ -115,7 +115,7 @@ func (a *App) editorHooks(buf *noteBuffer) vim.Hooks { WriteClipboard: func(text string) bool { return clipboard.WriteAll(text) == nil }, - OnJump: func(from vim.Pos) { + OnJump: func(from helix.Pos) { a.pushJump(buf.path, from) }, FollowLink: func() { a.followLinkAtCursor(buf) }, diff --git a/internal/tui/bulk_notes.go b/internal/tui/bulk_notes.go index 38e0ae1..198d6a8 100644 --- a/internal/tui/bulk_notes.go +++ b/internal/tui/bulk_notes.go @@ -2,8 +2,8 @@ package tui import ( "fmt" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" "path" "sort" "strings" @@ -151,4 +151,4 @@ func (a *App) applyBulkNotes(paths []string, action, sub string) { a.notify(fmt.Sprintf("%s: %d notes", action, done)) } } -func isMarkKey(k vim.Key) bool { return k.IsCtrl(' ') || k.IsCtrl('@') || k.Is("ctrl+space") } +func isMarkKey(k helix.Key) bool { return k.IsCtrl(' ') || k.IsCtrl('@') || k.Is("ctrl+space") } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 881d17e..955be7f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -11,11 +11,11 @@ import ( "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/database" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/search" "github.com/ZenNotes/tui/internal/themes" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // command is one ex command the host owns, also listed in the palette. @@ -23,7 +23,7 @@ type command struct { names []string title string hint string - run func(a *App, buf *noteBuffer, cmd vim.ExCommand) error + run func(a *App, buf *noteBuffer, cmd helix.ExCommand) error // palette false keeps a command out of the palette (argument-only ones). palette bool } @@ -34,16 +34,16 @@ func (a *App) commandTable() []command { if a.commands != nil { return a.commands } - openView := func(path string) func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { - return func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.openVirtualByPath(path); return nil } + openView := func(path string) func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { + return func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openVirtualByPath(path); return nil } } a.commands = []command{ - {names: []string{"complete"}, title: "Complete Markdown token", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.markdownCompletion(); return nil }}, - {names: []string{"table"}, title: "Markdown table actions", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.tableMenu(); return nil }}, - {names: []string{"formatting"}, title: "Formatting menu", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.formatMenu(); return nil }}, - {names: []string{"templates"}, title: "Manage templates", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.openTemplates(); return nil }}, - {names: []string{"bulk"}, title: "Actions on marked notes", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.bulkMenu(); return nil }}, - {names: []string{"mark"}, title: "Mark active note", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"complete"}, title: "Complete Markdown token", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.markdownCompletion(); return nil }}, + {names: []string{"table"}, title: "Markdown table actions", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.tableMenu(); return nil }}, + {names: []string{"formatting"}, title: "Formatting menu", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.formatMenu(); return nil }}, + {names: []string{"templates"}, title: "Manage templates", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openTemplates(); return nil }}, + {names: []string{"bulk"}, title: "Actions on marked notes", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.bulkMenu(); return nil }}, + {names: []string{"mark"}, title: "Mark active note", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf == nil { buf = a.activeBuffer() } @@ -52,10 +52,10 @@ func (a *App) commandTable() []command { } return nil }}, - {names: []string{"comments"}, title: "Note comments", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.openComments(); return nil }}, - {names: []string{"filters"}, title: "Saved task filters", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.savedTaskFilters(); return nil }}, - {names: []string{"savefilter"}, title: "Save current task filter", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.saveTaskFilter(); return nil }}, - {names: []string{"order"}, title: "Order the task list (note | priority)", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"comments"}, title: "Note comments", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openComments(); return nil }}, + {names: []string{"filters"}, title: "Saved task filters", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.savedTaskFilters(); return nil }}, + {names: []string{"savefilter"}, title: "Save current task filter", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.saveTaskFilter(); return nil }}, + {names: []string{"order"}, title: "Order the task list (note | priority)", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { switch want := strings.ToLower(strings.TrimSpace(cmd.Args)); want { case "note", "priority": a.setTasksListOrder(want) @@ -70,14 +70,14 @@ func (a *App) commandTable() []command { } return nil }}, - {names: []string{"boardoptions"}, title: "Task board options", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"boardoptions"}, title: "Task board options", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { if v := a.activeTasksView(); v != nil { v.boardMenu(a) } return nil }}, - {names: []string{"conflict"}, title: "Resolve external note changes", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.conflictMenu(); return nil }}, - {names: []string{"w", "write", "save"}, title: "Save note", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"conflict"}, title: "Resolve external note changes", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.conflictMenu(); return nil }}, + {names: []string{"w", "write", "save"}, title: "Save note", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { if strings.TrimSpace(cmd.Args) != "" { return a.saveAs(buf, cmd.Args) } @@ -87,16 +87,16 @@ func (a *App) commandTable() []command { a.queueSave(buf) return nil }}, - {names: []string{"q", "quit", "close"}, title: "Close tab", hint: ":q", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"q", "quit", "close"}, title: "Close tab", hint: ":q", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { return a.closeActiveTabCommand(cmd.Bang) }}, - {names: []string{"wq", "x", "xit"}, title: "Save and close tab", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"wq", "x", "xit"}, title: "Save and close tab", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { if err := a.saveBufferReport(buf); err != nil { return err } return a.closeActiveTabCommand(true) }}, - {names: []string{"qa", "qall", "quitall"}, title: "Quit ZenNotes", hint: ":qa", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"qa", "qall", "quitall"}, title: "Quit ZenNotes", hint: ":qa", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { if cmd.Bang { a.quitting = true return nil @@ -104,21 +104,21 @@ func (a *App) commandTable() []command { a.quit() return nil }}, - {names: []string{"wa", "wall"}, title: "Save all notes", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"wa", "wall"}, title: "Save all notes", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { if err := a.saveAllBuffers(); err != nil { return err } a.notify("Saved all notes") return nil }}, - {names: []string{"wqa", "xa", "wqall", "xall"}, title: "Save all and quit", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"wqa", "xa", "wqall", "xall"}, title: "Save all and quit", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.quit() return nil }}, - {names: []string{"bd", "bdelete", "bc", "bclose"}, title: "Close buffer", run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"bd", "bdelete", "bc", "bclose"}, title: "Close buffer", run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.closeActiveTabCommand(cmd.Bang) }}, - {names: []string{"bn", "bnext", "tabnext", "tabn"}, title: "Next tab", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"bn", "bnext", "tabnext", "tabn"}, title: "Next tab", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { if n, err := strconv.Atoi(strings.TrimSpace(cmd.Args)); err == nil { a.selectTab(n) return nil @@ -126,15 +126,15 @@ func (a *App) commandTable() []command { a.cycleTab(1) return nil }}, - {names: []string{"bp", "bprev", "bprevious", "tabprev", "tabp", "tabprevious"}, title: "Previous tab", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"bp", "bprev", "bprevious", "tabprev", "tabp", "tabprevious"}, title: "Previous tab", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.cycleTab(-1) return nil }}, - {names: []string{"buffers", "ls"}, title: "List open buffers", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"buffers", "ls"}, title: "List open buffers", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openBufferPicker() return nil }}, - {names: []string{"tabnew", "new"}, title: "New note", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"tabnew", "new"}, title: "New note", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { title := strings.TrimSpace(cmd.Args) if title == "" { a.newNoteHere() @@ -147,18 +147,18 @@ func (a *App) commandTable() []command { a.createNote(folder, title, sub, nil, true) return nil }}, - {names: []string{"tabclose", "tabc"}, title: "Close tab", run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"tabclose", "tabc"}, title: "Close tab", run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.closeActiveTabCommand(cmd.Bang) }}, - {names: []string{"tabonly", "tabo"}, title: "Close other tabs", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"tabonly", "tabo"}, title: "Close other tabs", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.closeOtherTabs() return nil }}, - {names: []string{"pin", "tabpin", "unpin"}, title: "Pin or unpin the tab", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"pin", "tabpin", "unpin"}, title: "Pin or unpin the tab", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.togglePin(a.activePane, a.activePane.active) return nil }}, - {names: []string{"tabmove", "tabm"}, title: "Move the tab left or right (:tabmove -1 / +1)", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"tabmove", "tabm"}, title: "Move the tab left or right (:tabmove -1 / +1)", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { arg := strings.TrimSpace(cmd.Args) delta := 1 switch { @@ -193,30 +193,30 @@ func (a *App) commandTable() []command { } return nil }}, - {names: []string{"tabreopen", "reopen"}, title: "Reopen closed tab", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"tabreopen", "reopen"}, title: "Reopen closed tab", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.reopenClosedTab() return nil }}, - {names: []string{"only", "on"}, title: "Close other panes", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"only", "on"}, title: "Close other panes", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.onlyPane() return nil }}, - {names: []string{"sp", "split"}, title: "Split pane down", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"sp", "split"}, title: "Split pane down", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.splitPane(false) return a.openArgument(cmd.Args) }}, - {names: []string{"vs", "vsplit", "vsp"}, title: "Split pane right", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"vs", "vsplit", "vsp"}, title: "Split pane right", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.splitPane(true) return a.openArgument(cmd.Args) }}, - {names: []string{"e", "edit", "open", "o"}, title: "Open note", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"e", "edit", "open", "o"}, title: "Open note", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { if strings.TrimSpace(cmd.Args) == "" { a.openNoteSearch("") return nil } return a.openArgument(cmd.Args) }}, - {names: []string{"move", "mv"}, title: "Move note to folder", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"move", "mv"}, title: "Move note to folder", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } @@ -232,7 +232,7 @@ func (a *App) commandTable() []command { a.moveNote(buf.path, folder, sub) return nil }}, - {names: []string{"rename", "saveas"}, title: "Rename note", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"rename", "saveas"}, title: "Rename note", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } @@ -242,65 +242,65 @@ func (a *App) commandTable() []command { } return a.saveAs(buf, cmd.Args) }}, - {names: []string{"delete", "del", "trashnote"}, title: "Move note to trash", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"delete", "del", "trashnote"}, title: "Move note to trash", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { a.trashActiveNote() return nil }}, - {names: []string{"archive"}, title: "Archive or unarchive note", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"archive"}, title: "Archive or unarchive note", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.archiveActiveNote() return nil }}, - {names: []string{"restore"}, title: "Restore note from trash", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"restore"}, title: "Restore note from trash", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } a.restoreNote(buf.path) return nil }}, - {names: []string{"duplicate", "dup"}, title: "Duplicate note", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"duplicate", "dup"}, title: "Duplicate note", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } a.duplicateNote(buf.path) return nil }}, - {names: []string{"favorite", "fav"}, title: "Toggle favorite", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"favorite", "fav"}, title: "Toggle favorite", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.toggleActiveFavorite() return nil }}, - {names: []string{"copylink", "yanklink"}, title: "Copy note link", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"copylink", "yanklink"}, title: "Copy note link", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.copyActiveLink() return nil }}, - {names: []string{"copy", "yanknote"}, title: "Copy note as Markdown", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"copy", "yanknote"}, title: "Copy note as Markdown", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.copyActiveNote() return nil }}, - {names: []string{"format", "fmt"}, title: "Format note", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"format", "fmt"}, title: "Format note", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.formatActiveNote() return nil }}, - {names: []string{"desktop", "app"}, title: "Open in ZenNotes app", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"desktop", "app"}, title: "Open in ZenNotes app", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openActiveInDesktop() return nil }}, - {names: []string{"tasks"}, title: "Open Tasks", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"tasks"}, title: "Open Tasks", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.openTasksMode(strings.TrimSpace(cmd.Args)) }}, - {names: []string{"kanban", "board"}, title: "Open Tasks as a Kanban board", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"kanban", "board"}, title: "Open Tasks as a Kanban board", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return a.openTasksMode("kanban") }}, - {names: []string{"taskcalendar", "tcal", "agenda"}, title: "Open Tasks as a calendar", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"taskcalendar", "tcal", "agenda"}, title: "Open Tasks as a calendar", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return a.openTasksMode("calendar") }}, - {names: []string{"tasklist"}, title: "Open Tasks as a list", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"tasklist"}, title: "Open Tasks as a list", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return a.openTasksMode("list") }}, - {names: []string{"tags"}, title: "Open Tags", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"tags"}, title: "Open Tags", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.openTags(strings.TrimPrefix(strings.TrimSpace(cmd.Args), "#")) return nil }}, - {names: []string{"tag"}, title: "Filter by tag", run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"tag"}, title: "Filter by tag", run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.openTags(strings.TrimPrefix(strings.TrimSpace(cmd.Args), "#")) return nil }}, @@ -308,7 +308,7 @@ func (a *App) commandTable() []command { {names: []string{"quick", "quicknotes"}, title: "Open Quick Notes", palette: true, run: openView(tabQuickNotes)}, {names: []string{"archived", "archiveview"}, title: "Open Archive", palette: true, run: openView(tabArchive)}, {names: []string{"home"}, title: "Open Home", palette: true, run: openView(tabHome)}, - {names: []string{"help", "h", "manual"}, title: "Open the manual", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"help", "h", "manual"}, title: "Open the manual", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.openHelp() if q := strings.TrimSpace(cmd.Args); q != "" { if t := a.activeTab(); t != nil { @@ -321,7 +321,7 @@ func (a *App) commandTable() []command { return nil }}, {names: []string{"settings", "preferences"}, title: "Open Settings", palette: true, run: openView(tabSettings)}, - {names: []string{"view"}, title: "Open a view", run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"view"}, title: "Open a view", run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { switch strings.ToLower(strings.TrimSpace(cmd.Args)) { case "tasks": a.openTasks() @@ -351,7 +351,7 @@ func (a *App) commandTable() []command { } return nil }}, - {names: []string{"template", "tmpl"}, title: "New note from template", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"template", "tmpl"}, title: "New note from template", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { name := strings.TrimSpace(cmd.Args) if name == "" { a.openTemplatePicker(false) @@ -379,7 +379,7 @@ func (a *App) commandTable() []command { } return fmt.Errorf("no template named %q", name) }}, - {names: []string{"insert", "inserttemplate"}, title: "Insert template", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"insert", "inserttemplate"}, title: "Insert template", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { name := strings.TrimSpace(cmd.Args) if name == "" { a.openTemplatePicker(true) @@ -393,32 +393,32 @@ func (a *App) commandTable() []command { } return fmt.Errorf("no template named %q", name) }}, - {names: []string{"daily", "today"}, title: "Open today's daily note", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"daily", "today"}, title: "Open today's daily note", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.periodicCommand(periodic.Daily, cmd.Args) }}, - {names: []string{"weekly"}, title: "Open this week's note", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"weekly"}, title: "Open this week's note", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.periodicCommand(periodic.Weekly, cmd.Args) }}, - {names: []string{"monthly"}, title: "Open this month's note", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"monthly"}, title: "Open this month's note", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.periodicCommand(periodic.Monthly, cmd.Args) }}, - {names: []string{"outline"}, title: "Toggle outline panel", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"outline"}, title: "Toggle outline panel", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.toggleSidePanel(&a.outlineOpen, focusOutline) return nil }}, - {names: []string{"connections", "backlinks", "links"}, title: "Toggle connections panel", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"connections", "backlinks", "links"}, title: "Toggle connections panel", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.toggleSidePanel(&a.connectionsOpen, focusConnections) return nil }}, - {names: []string{"calendar", "cal"}, title: "Toggle calendar panel", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"calendar", "cal"}, title: "Toggle calendar panel", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.toggleSidePanel(&a.calendarOpen, focusCalendar) return nil }}, - {names: []string{"closepanel"}, title: "Close side panels", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"closepanel"}, title: "Close side panels", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.closeSidePanels() return nil }}, - {names: []string{"sidebar"}, title: "Toggle sidebar", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"sidebar"}, title: "Toggle sidebar", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { if strings.TrimSpace(cmd.Args) == "tags" { key := "section:tags" a.sidebar.collapsed[key] = !a.sidebar.collapsed[key] @@ -433,51 +433,51 @@ func (a *App) commandTable() []command { a.toggleSidebar() return nil }}, - {names: []string{"zen"}, title: "Toggle Zen mode", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"zen"}, title: "Toggle Zen mode", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.toggleZen() return nil }}, - {names: []string{"editmode", "edit-mode"}, title: "Editor mode", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"editmode", "edit-mode"}, title: "Editor mode", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.setPaneMode(modeEdit) return nil }}, - {names: []string{"splitmode", "split-mode"}, title: "Split mode (editor + preview)", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"splitmode", "split-mode"}, title: "Split mode (editor + preview)", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.setPaneMode(modeSplit) return nil }}, - {names: []string{"previewmode", "preview-mode", "preview"}, title: "Preview mode", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"previewmode", "preview-mode", "preview"}, title: "Preview mode", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.setPaneMode(modePreview) return nil }}, - {names: []string{"togglepreview", "tp"}, title: "Toggle editor/preview", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"togglepreview", "tp"}, title: "Toggle editor/preview", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.togglePreview() return nil }}, - {names: []string{"fold"}, title: "Fold heading at cursor", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"fold"}, title: "Fold heading at cursor", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf != nil { buf.ed.Feed("zc") } return nil }}, - {names: []string{"unfold"}, title: "Unfold heading at cursor", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"unfold"}, title: "Unfold heading at cursor", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf != nil { buf.ed.Feed("zo") } return nil }}, - {names: []string{"foldall"}, title: "Fold all headings", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"foldall"}, title: "Fold all headings", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf != nil { buf.ed.Feed("zM") } return nil }}, - {names: []string{"unfoldall"}, title: "Unfold all headings", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"unfoldall"}, title: "Unfold all headings", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf != nil { buf.ed.Feed("zR") } return nil }}, - {names: []string{"newtask", "task"}, title: "New task in today's daily note", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"newtask", "task"}, title: "New task in today's daily note", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { text := strings.TrimSpace(cmd.Args) if text == "" { a.promptFor("New task", "", "Task text, with due:YYYY-MM-DD or !high as needed", func(a *App, text string) { @@ -490,7 +490,7 @@ func (a *App) commandTable() []command { a.addTaskToDaily(text, time.Now()) return nil }}, - {names: []string{"filter"}, title: "Filter tasks", run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"filter"}, title: "Filter tasks", run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.openTasks() if t := a.activeTab(); t != nil { if tv, ok := t.view.(*tasksView); ok { @@ -500,19 +500,19 @@ func (a *App) commandTable() []command { } return nil }}, - {names: []string{"capture"}, title: "Quick capture", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"capture"}, title: "Quick capture", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openQuickCapture() return nil }}, - {names: []string{"search", "find"}, title: "Search vault text", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"search", "find"}, title: "Search vault text", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.openTextSearch(strings.TrimSpace(cmd.Args)) return nil }}, - {names: []string{"notes"}, title: "Search notes", run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"notes"}, title: "Search notes", run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { a.openNoteSearch(strings.TrimSpace(cmd.Args)) return nil }}, - {names: []string{"db", "database", "base"}, title: "Open database", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"db", "database", "base"}, title: "Open database", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { name := strings.TrimSpace(cmd.Args) if name == "" { a.openDatabasePicker() @@ -521,7 +521,7 @@ func (a *App) commandTable() []command { a.openDatabase(name) return nil }}, - {names: []string{"dbnew", "newdb"}, title: "New database", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"dbnew", "newdb"}, title: "New database", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { name := strings.TrimSpace(cmd.Args) if name == "" { a.promptFor("New database", "", "Name", func(a *App, name string) { @@ -534,7 +534,7 @@ func (a *App) commandTable() []command { a.createDatabase(name) return nil }}, - {names: []string{"dbconvert"}, title: "Convert the open .csv database to a .base folder", run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"dbconvert"}, title: "Convert the open .csv database to a .base folder", run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { t := a.activePane.activeTab() if t == nil { return fmt.Errorf("open a database first (:db)") @@ -550,34 +550,36 @@ func (a *App) commandTable() []command { dv.convertToFolder(a, nil) return nil }}, - {names: []string{"config"}, title: "Edit config.toml", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return a.editConfig() }}, - {names: []string{"reloadconfig"}, title: "Reload preferences", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return a.reloadPreferences() }}, - {names: []string{"unbind"}, title: "Unbind a keymap action", run: func(a *App, _ *noteBuffer, c vim.ExCommand) error { return a.saveKeymap(strings.TrimSpace(c.Args), "") }}, - {names: []string{"bind", "map"}, title: "Bind a keymap action", run: func(a *App, _ *noteBuffer, c vim.ExCommand) error { + {names: []string{"config"}, title: "Edit config.toml", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return a.editConfig() }}, + {names: []string{"reloadconfig"}, title: "Reload preferences", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return a.reloadPreferences() }}, + {names: []string{"unbind"}, title: "Unbind a keymap action", run: func(a *App, _ *noteBuffer, c helix.ExCommand) error { + return a.saveKeymap(strings.TrimSpace(c.Args), "") + }}, + {names: []string{"bind", "map"}, title: "Bind a keymap action", run: func(a *App, _ *noteBuffer, c helix.ExCommand) error { p := strings.Fields(c.Args) if len(p) < 2 { return fmt.Errorf("usage: :bind ") } return a.saveKeymap(p[0], strings.Join(p[1:], " ")) }}, - {names: []string{"keymaps", "keys"}, title: "Show keymap actions", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"keymaps", "keys"}, title: "Show keymap actions", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openKeymapList() return nil }}, - {names: []string{"commands", "cmd", "palette"}, title: "Command palette", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"commands", "cmd", "palette"}, title: "Command palette", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openCommandPalette() return nil }}, - {names: []string{"reload", "refresh"}, title: "Reload the vault", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"reload", "refresh"}, title: "Reload the vault", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.refreshIndex() a.notify("Reloading the vault") return nil }}, - {names: []string{"wrap"}, title: "Toggle word wrap", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"wrap"}, title: "Toggle word wrap", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.prefs.WordWrap = !a.prefs.WordWrap return nil }}, - {names: []string{"linenumbers", "number", "nu"}, title: "Cycle line numbers", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"linenumbers", "number", "nu"}, title: "Cycle line numbers", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { modes := []string{"off", "absolute", "relative", "hybrid"} arg := strings.TrimSpace(cmd.Args) if arg != "" { @@ -594,7 +596,7 @@ func (a *App) commandTable() []command { a.prefs.LineNumberMode = "absolute" return nil }}, - {names: []string{"theme"}, title: "Toggle dark/light theme", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"theme"}, title: "Toggle dark/light theme", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { name := strings.TrimSpace(cmd.Args) if name == "" { name = "dark" @@ -604,14 +606,14 @@ func (a *App) commandTable() []command { } return a.selectTheme(name) }}, - {names: []string{"themes", "colorscheme", "colo"}, title: "Pick a color scheme", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"themes", "colorscheme", "colo"}, title: "Pick a color scheme", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { if name := strings.TrimSpace(cmd.Args); name != "" { return a.selectTheme(name) } a.openThemePicker() return nil }}, - {names: []string{"previewstyle", "style"}, title: "Preview style (Glamour name, JSON path, or zen)", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"previewstyle", "style"}, title: "Preview style (Glamour name, JSON path, or zen)", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { name := strings.TrimSpace(cmd.Args) if name == "" { a.openPreviewStylePicker() @@ -623,22 +625,22 @@ func (a *App) commandTable() []command { a.notify("Preview style: " + name) return nil }}, - {names: []string{"link", "wikilink"}, title: "Insert a link to a note", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"link", "wikilink"}, title: "Insert a link to a note", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } a.openWikilinkPicker(buf) return nil }}, - {names: []string{"editor", "ed"}, title: "Edit note in $EDITOR", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"editor", "ed"}, title: "Edit note in $EDITOR", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.editActiveInEditor() return nil }}, - {names: []string{"suspend", "stop"}, title: "Suspend to the shell (fg to return)", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"suspend", "stop"}, title: "Suspend to the shell (fg to return)", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.queue(tea.Suspend) return nil }}, - {names: []string{"mouse"}, title: "Toggle mouse support", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"mouse"}, title: "Toggle mouse support", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { switch strings.ToLower(strings.TrimSpace(cmd.Args)) { case "on": a.mouseEnabled = true @@ -656,23 +658,23 @@ func (a *App) commandTable() []command { } return nil }}, - {names: []string{"keyhelp"}, title: "Keys for the focused surface", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"keyhelp"}, title: "Keys for the focused surface", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openKeyHelp() return nil }}, - {names: []string{"vim"}, title: "Toggle Vim mode (this session)", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { - a.prefs.VimMode = !a.prefs.VimMode + {names: []string{"vim"}, title: "Toggle Vim mode (this session)", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { + a.prefs.HelixMode = !a.prefs.HelixMode for _, buf := range a.buffers { buf.ed.SetOptions(a.editorOptions()) } - a.notify(map[bool]string{true: "Vim mode on", false: "Vim mode off: arrows to navigate, Enter to open, F2 for actions"}[a.prefs.VimMode]) + a.notify(map[bool]string{true: "Vim mode on", false: "Vim mode off: arrows to navigate, Enter to open, F2 for actions"}[a.prefs.HelixMode]) return nil }}, - {names: []string{"emptytrash"}, title: "Empty trash", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"emptytrash"}, title: "Empty trash", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.emptyTrash() return nil }}, - {names: []string{"newfolder", "mkdir"}, title: "New folder", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"newfolder", "mkdir"}, title: "New folder", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { folder, sub := a.currentFolderContext() if folder == vault.FolderTrash || folder == vault.FolderQuick { folder, sub = vault.FolderInbox, "" @@ -684,22 +686,22 @@ func (a *App) commandTable() []command { } return a.createFolderNamed(folder, sub, name) }}, - {names: []string{"version", "ve"}, title: "Version details / copy bug report info", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"version", "ve"}, title: "Version details / copy bug report info", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.showVersion(cmd) }}, - {names: []string{"harper", "spell"}, title: "Grammar check (desktop only)", run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return errNotInTerminal }}, - {names: []string{"imgwidth"}, title: "Image width (desktop only)", run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return errNotInTerminal }}, - {names: []string{"workflow", "workflows"}, title: "Workflows (desktop only)", run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return errNotInTerminal }}, - {names: []string{"atlas"}, title: "Atlas (desktop only)", run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return errNotInTerminal }}, - {names: []string{"share", "publish"}, title: "Share (desktop only)", run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return errNotInTerminal }}, - {names: []string{"sync"}, title: "Cloud sync (desktop only)", run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { return errNotInTerminal }}, + {names: []string{"harper", "spell"}, title: "Grammar check (desktop only)", run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return errNotInTerminal }}, + {names: []string{"imgwidth"}, title: "Image width (desktop only)", run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return errNotInTerminal }}, + {names: []string{"workflow", "workflows"}, title: "Workflows (desktop only)", run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return errNotInTerminal }}, + {names: []string{"atlas"}, title: "Atlas (desktop only)", run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return errNotInTerminal }}, + {names: []string{"share", "publish"}, title: "Share (desktop only)", run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return errNotInTerminal }}, + {names: []string{"sync"}, title: "Cloud sync (desktop only)", run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return errNotInTerminal }}, } a.commands = append(a.commands, a.parityCommands()...) return a.commands } // runEx is the engine's host hook for commands it does not own. -func (a *App) runEx(buf *noteBuffer, cmd vim.ExCommand) (bool, error) { +func (a *App) runEx(buf *noteBuffer, cmd helix.ExCommand) (bool, error) { name := strings.ToLower(cmd.Name) for _, c := range a.commandTable() { for _, n := range c.names { @@ -849,7 +851,7 @@ func (a *App) runCommandLine(line string) { name, args, _ := strings.Cut(line, " ") bang := strings.HasSuffix(name, "!") name = strings.TrimSuffix(name, "!") - handled, err := a.runEx(nil, vim.ExCommand{Name: name, Args: strings.TrimSpace(args), Bang: bang, Raw: line}) + handled, err := a.runEx(nil, helix.ExCommand{Name: name, Args: strings.TrimSpace(args), Bang: bang, Raw: line}) if err != nil { a.notifyError(err.Error()) return diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index f041189..6cb5ac8 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -13,11 +13,11 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/fswatch" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/remote" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // The desktop's command palette, ported. Everything here mirrors an entry @@ -37,8 +37,8 @@ const ( func (a *App) parityCommands() []command { return []command{ - {names: []string{"servers"}, title: "Manage local servers…", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { a.managedServers(); return nil }}, - {names: []string{"vault", "vaults"}, title: "Switch vault…", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"servers"}, title: "Manage local servers…", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.managedServers(); return nil }}, + {names: []string{"vault", "vaults"}, title: "Switch vault…", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { arg := strings.TrimSpace(cmd.Args) if arg == "" { a.openVaultSwitcher() @@ -47,7 +47,7 @@ func (a *App) parityCommands() []command { a.switchByText(arg) return nil }}, - {names: []string{"server", "connect"}, title: "Connect to remote vault…", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"server", "connect"}, title: "Connect to remote vault…", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { arg := strings.TrimSpace(cmd.Args) if arg == "" { a.openServerPicker() @@ -62,7 +62,7 @@ func (a *App) parityCommands() []command { a.connectServerFlow(arg) return nil }}, - {names: []string{"local"}, title: "Switch to local vault", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"local"}, title: "Switch to local vault", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { arg := strings.TrimSpace(cmd.Args) var target backend.Target var err error @@ -82,16 +82,16 @@ func (a *App) parityCommands() []command { a.switchTarget(target) return nil }}, - {names: []string{"rollover"}, title: "Roll over unfinished tasks to today", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"rollover"}, title: "Roll over unfinished tasks to today", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { return a.rolloverNow() }}, - {names: []string{"copypath", "yankpath"}, title: "Copy note path", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"copypath", "yankpath"}, title: "Copy note path", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { return a.copyPathCommand(buf, strings.TrimSpace(cmd.Args)) }}, - {names: []string{"reveal", "finder"}, title: "Reveal note in file manager", palette: true, run: func(a *App, buf *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"reveal", "finder"}, title: "Reveal note in file manager", palette: true, run: func(a *App, buf *noteBuffer, cmd helix.ExCommand) error { return a.revealCommand(buf, strings.TrimSpace(cmd.Args)) }}, - {names: []string{"forward", "fwd"}, title: "Forward task to today's note", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"forward", "fwd"}, title: "Forward task to today's note", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { t, err := a.taskAtCursor(buf) if err != nil { return err @@ -99,24 +99,24 @@ func (a *App) parityCommands() []command { a.forwardTask(t) return nil }}, - {names: []string{"inprogress", "start", "wip"}, title: "Mark task in progress", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"inprogress", "start", "wip"}, title: "Mark task in progress", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { return a.setTaskStateAtCursor(buf, "inprogress") }}, - {names: []string{"cancel", "cancelled"}, title: "Cancel task", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"cancel", "cancelled"}, title: "Cancel task", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { return a.setTaskStateAtCursor(buf, "cancel") }}, - {names: []string{"taskfile", "tasknote"}, title: "New task in folder…", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"taskfile", "tasknote"}, title: "New task in folder…", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { return a.newTaskFile(strings.TrimSpace(cmd.Args)) }}, - {names: []string{"tabcloseright", "tabcr"}, title: "Close tabs to the right", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"tabcloseright", "tabcr"}, title: "Close tabs to the right", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.closeTabsRight() return nil }}, - {names: []string{"tabmenu"}, title: "Open active tab menu", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"tabmenu"}, title: "Open active tab menu", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openActiveTabMenu() return nil }}, - {names: []string{"unarchive"}, title: "Unarchive note", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"unarchive"}, title: "Unarchive note", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } @@ -126,7 +126,7 @@ func (a *App) parityCommands() []command { a.unarchiveNote(buf.path) return nil }}, - {names: []string{"purge", "deleteforever"}, title: "Delete note permanently", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"purge", "deleteforever"}, title: "Delete note permanently", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } @@ -136,18 +136,18 @@ func (a *App) parityCommands() []command { a.deleteNoteForever(buf.path) return nil }}, - {names: []string{"assets", "files"}, title: "Go to files", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"assets", "files"}, title: "Go to files", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openAssetsPicker() return nil }}, - {names: []string{"ref", "reference"}, title: "Pin active note as reference", palette: true, run: func(a *App, buf *noteBuffer, _ vim.ExCommand) error { + {names: []string{"ref", "reference"}, title: "Pin active note as reference", palette: true, run: func(a *App, buf *noteBuffer, _ helix.ExCommand) error { if buf == nil { return fmt.Errorf("no note is active") } a.openReference(buf.path) return nil }}, - {names: []string{"notesort", "sortnotes"}, title: "Sort notes in the sidebar", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"notesort", "sortnotes"}, title: "Sort notes in the sidebar", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { arg := strings.TrimSpace(cmd.Args) if arg == "" { a.openNoteSortMenu() @@ -155,7 +155,7 @@ func (a *App) parityCommands() []command { } return a.setNoteSort(arg) }}, - {names: []string{"donestyle"}, title: "Completed tasks style", palette: true, run: func(a *App, _ *noteBuffer, cmd vim.ExCommand) error { + {names: []string{"donestyle"}, title: "Completed tasks style", palette: true, run: func(a *App, _ *noteBuffer, cmd helix.ExCommand) error { arg := strings.TrimSpace(cmd.Args) if arg == "" { a.openDoneStyleMenu() @@ -163,39 +163,39 @@ func (a *App) parityCommands() []command { } return a.setDoneStyle(arg) }}, - {names: []string{"whichkey"}, title: "Toggle leader key hints", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"whichkey"}, title: "Toggle leader key hints", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.prefs.WhichKeyHints = !a.prefs.WhichKeyHints - a.notify(onOff("Leader key hints", a.prefs.WhichKeyHints) + " (vim.which_key_hints in config.toml keeps it)") + a.notify(onOff("Leader key hints", a.prefs.WhichKeyHints) + " (helix.which_key_hints in config.toml keeps it)") return nil }}, - {names: []string{"tabs"}, title: "Toggle tabs", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"tabs"}, title: "Toggle tabs", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.prefs.TabsOff = !a.prefs.TabsOff a.layout() a.notify(onOff("Tab strip", !a.prefs.TabsOff)) return nil }}, - {names: []string{"quickdate"}, title: "Toggle quick note date titles", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"quickdate"}, title: "Toggle quick note date titles", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.prefs.QuickNoteDateTitle = !a.prefs.QuickNoteDateTitle a.notify(onOff("Quick note date titles", a.prefs.QuickNoteDateTitle) + "") return nil }}, - {names: []string{"website"}, title: "Open ZenNotes website", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"website"}, title: "Open ZenNotes website", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openExternal(appWebsiteURL) return nil }}, - {names: []string{"discord"}, title: "Join ZenNotes Discord", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"discord"}, title: "Join ZenNotes Discord", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openExternal(appDiscordURL) return nil }}, - {names: []string{"github", "repo"}, title: "Open GitHub repository", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"github", "repo"}, title: "Open GitHub repository", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openExternal(appRepoURL) return nil }}, - {names: []string{"releases", "release"}, title: "View latest release", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"releases", "release"}, title: "View latest release", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openExternal(appReleasesURL) return nil }}, - {names: []string{"issue", "bug"}, title: "Report an issue", palette: true, run: func(a *App, _ *noteBuffer, _ vim.ExCommand) error { + {names: []string{"issue", "bug"}, title: "Report an issue", palette: true, run: func(a *App, _ *noteBuffer, _ helix.ExCommand) error { a.openExternal(appIssuesURL) return nil }}, diff --git a/internal/tui/comments_panel.go b/internal/tui/comments_panel.go index e5f2f92..91a8923 100644 --- a/internal/tui/comments_panel.go +++ b/internal/tui/comments_panel.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" tea "github.com/charmbracelet/bubbletea" "strings" "time" @@ -112,7 +112,7 @@ func commentThreadText(t backend.CommentThreadView) string { } return strings.Join(lines, "\n") } -func (p *commentsPanel) handleKey(a *App, k vim.Key) { +func (p *commentsPanel) handleKey(a *App, k helix.Key) { if k.Is("esc") { a.focus = focusPane return diff --git a/internal/tui/context_actions.go b/internal/tui/context_actions.go index bd33d27..d9b1547 100644 --- a/internal/tui/context_actions.go +++ b/internal/tui/context_actions.go @@ -1,7 +1,7 @@ package tui import ( - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "time" ) @@ -52,11 +52,11 @@ func (a *App) contextActions() { a.noteContextMenu(n.Path) } case *filesView: - v.handleKey(a, vim.KeyEnter) + v.handleKey(a, helix.KeyEnter) case *templatesView: - v.handleKey(a, vim.KeyEnter) + v.handleKey(a, helix.KeyEnter) case *settingsView: - v.handleKey(a, vim.KeyEnter) + v.handleKey(a, helix.KeyEnter) default: if t.view == nil { a.noteContextMenu(t.path) diff --git a/internal/tui/database_test.go b/internal/tui/database_test.go index 012d801..20f41c5 100644 --- a/internal/tui/database_test.go +++ b/internal/tui/database_test.go @@ -10,15 +10,15 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/database" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/keymaps" - "github.com/ZenNotes/tui/internal/vim" ) -func enterKey() vim.Key { return vim.Key{Name: "enter"} } +func enterKey() helix.Key { return helix.Key{Name: "enter"} } func typeText(a *App, text string) { for _, r := range text { - a.overlay.handleKey(a, vim.R(r)) + a.overlay.handleKey(a, helix.R(r)) } } @@ -41,7 +41,7 @@ func newDatabaseTestApp(t *testing.T) (*App, string) { backend: b, keymap: keymaps.NewResolver(nil), theme: buildTheme(true), - prefs: prefsView{Prefs: config.Prefs{VimMode: true}}, + prefs: prefsView{Prefs: config.Prefs{HelixMode: true}}, panes: newPaneTree(), buffers: map[string]*noteBuffer{}, noteModes: map[string]paneMode{}, @@ -95,24 +95,24 @@ func TestHeaderRowEditsFields(t *testing.T) { a, _ := newDatabaseTestApp(t) v := openTestDatabase(t, a) v.render(a, 100, 20, true) - v.handleKey(a, vim.R('k')) + v.handleKey(a, helix.R('k')) if !v.header { t.Fatal("k on the first row enters the header") } - v.handleKey(a, vim.R('L')) + v.handleKey(a, helix.R('L')) if fieldNames(v) != "Status,Title,Done" || v.col != 1 { t.Fatalf("L moves the column right: %s col %d", fieldNames(v), v.col) } - v.handleKey(a, vim.R('t')) + v.handleKey(a, helix.R('t')) m, ok := a.overlay.(*menu) if !ok { t.Fatal("t opens the type menu") } - m.handleKey(a, vim.R('s')) + m.handleKey(a, helix.R('s')) if v.doc.FieldByID(v.fields[1].ID).Type != "select" { t.Fatal("s retypes the field to select") } - v.handleKey(a, vim.R('x')) + v.handleKey(a, helix.R('x')) if fieldNames(v) != "Status,Done" { t.Fatalf("x hides the column: %s", fieldNames(v)) } @@ -121,17 +121,17 @@ func TestHeaderRowEditsFields(t *testing.T) { if fieldNames(v2) != "Status,Done" || v2.doc.HiddenColumns(v2.viewID)[0].Name != "Title" { t.Fatalf("hidden column persists: %s", fieldNames(v2)) } - v.handleKey(a, vim.R('a')) + v.handleKey(a, helix.R('a')) typeText(a, "Pages") a.overlay.handleKey(a, enterKey()) - a.overlay.handleKey(a, vim.R('n')) + a.overlay.handleKey(a, helix.R('n')) if fieldNames(v) != "Status,Done,Pages" || v.fields[2].Type != "number" || v.col != 2 || v.header { t.Fatalf("a adds a typed field and lands on it: %s col %d header %v", fieldNames(v), v.col, v.header) } v.header = true - v.handleKey(a, vim.R('d')) - v.handleKey(a, vim.R('d')) - a.overlay.handleKey(a, vim.R('y')) + v.handleKey(a, helix.R('d')) + v.handleKey(a, helix.R('d')) + a.overlay.handleKey(a, helix.R('y')) if fieldNames(v) != "Status,Done" { t.Fatalf("dd deletes the field after confirming: %s", fieldNames(v)) } @@ -171,15 +171,15 @@ func TestSelectCellPickerAndSelection(t *testing.T) { t.Fatal("Enter on a checkbox toggles it") } v.row = 0 - v.handleKey(a, vim.R('x')) + v.handleKey(a, helix.R('x')) if !v.selected[v.rows[0].ID] || v.row != 1 { t.Fatal("x selects the row and moves down") } - v.handleKey(a, vim.Key{Name: "esc"}) + v.handleKey(a, helix.Key{Name: "esc"}) if len(v.selected) != 0 { t.Fatal("Esc clears the selection") } - v.handleKey(a, vim.R('D')) + v.handleKey(a, helix.R('D')) if len(v.rows) != 3 || v.rows[2].Cells[v.fields[0].ID] != "Neuromancer" || v.row != 2 { t.Fatalf("D duplicates the row after it: %d rows, cursor %d", len(v.rows), v.row) } @@ -222,25 +222,25 @@ func TestFiltersSortsAndBoard(t *testing.T) { v.render(a, 120, 30, true) v.board.col, v.board.card = 0, 0 dune := v.boardRow().ID - v.handleKey(a, vim.R('L')) + v.handleKey(a, helix.R('L')) if v.doc.RowByID(dune).Cells[status.ID] != "done" || v.board.col != 1 || v.boardRow().ID != dune { t.Fatalf("L moves the card to the next column and follows it: col %d", v.board.col) } - v.handleKey(a, vim.R('L')) + v.handleKey(a, helix.R('L')) if v.doc.RowByID(dune).Cells[status.ID] != "" { t.Fatal("moving into the empty column clears the cell") } - v.handleKey(a, vim.R('A')) + v.handleKey(a, helix.R('A')) typeText(a, "later") a.overlay.handleKey(a, enterKey()) if len(v.board.columns) != 4 || v.board.columns[v.board.col].Key != "later" { t.Fatalf("A adds a column and focuses it: %d", len(v.board.columns)) } - v.handleKey(a, vim.R('<')) + v.handleKey(a, helix.R('<')) if v.board.columns[1].Key != "later" { t.Fatalf("< moves the column left: %v", v.boardOrder()) } - v.handleKey(a, vim.R('v')) + v.handleKey(a, helix.R('v')) if v.isBoard() { t.Fatal("v cycles back to the table view") } diff --git a/internal/tui/editor.go b/internal/tui/editor.go new file mode 100644 index 0000000..0b79b69 --- /dev/null +++ b/internal/tui/editor.go @@ -0,0 +1,101 @@ +package tui + +import ( + "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vim" +) + +// modalEditor is the editing engine behind buffers. The [vim] motions +// option picks the implementation: "vim" (default) or "helix". Both engines +// share the helix key, position, register and hook types, so the host drives +// either one the same way; the vim adapter converts its engine-local Mode. +type modalEditor interface { + BlockColumns() (left, right int, toEnd bool) + BlockCursor() helix.Pos + ClearMessage() + Cmdline() (kind rune, text string, cursor int, active bool) + Completions() ([]string, int) + Cursor() helix.Pos + EnsureCursorVisible() + ExecuteEx(line string) + Feed(notation string) + GotoLine(line int) + HandleKey(k helix.Key) bool + Hidden(line int) bool + IncrementalHighlights(line int) [][2]int + InsertAtCursor(text string) + IsFoldStart(line int) (int, bool) + Line(i int) string + LineCount() int + LineRunes(i int) []rune + Lines() []string + MarkSaved() + Message() (string, bool) + Mode() helix.Mode + OpenCmdline(kind rune, initial string) + Pending() string + PendingCount() (int, bool) + ReflowParagraph() + ReplaceAtCursor(before, after int, text string) + ReplaceLineText(line int, text string) + ReplaceText(text string) + ResetPending() + ScrollTop() int + SearchHighlights(line int) [][2]int + SelectionKind() string + SetCursor(p helix.Pos) + SetHooks(h helix.Hooks) + SetOptions(opts helix.Options) + SetScrollTop(line int) + SetViewport(rows int) + StatusSuffix() string + Text() string + ToggleCheckboxAtCursor() + Version() int + VisualRange() (start, end helix.Pos, mode helix.Mode, ok bool) + ModeLabel() string +} + +// vimEditor adapts the vim engine to modalEditor. The embedded editor +// provides every method; the adapter converts the engine-local Mode. +type vimEditor struct { + *vim.Editor +} + +func (w vimEditor) Mode() helix.Mode { + switch w.Editor.Mode() { + case vim.ModeInsert: + return helix.ModeInsert + case vim.ModeCmdline: + return helix.ModeCmdline + } + return helix.ModeNormal +} + +// ModeLabel is the status-bar name of the current mode, including vim's +// visual and replace modes. +func (w vimEditor) ModeLabel() string { return w.Editor.Mode().Label() } + +// BlockCursor is where the block cursor is drawn. Vim keeps the cursor on +// the character under it, so the head position is the cursor. +func (w vimEditor) BlockCursor() helix.Pos { return w.Editor.Cursor() } + +// VisualRange reports the selection; the vim engine's mode maps to a plain +// helix selection, with the kind carried by SelectionKind. +func (w vimEditor) VisualRange() (helix.Pos, helix.Pos, helix.Mode, bool) { + start, end, _, ok := w.Editor.VisualRange() + return start, end, helix.ModeNormal, ok +} + +// newModalEditor builds the configured engine over text. +func (a *App) newModalEditor(text string, hooks helix.Hooks, store *helix.RegisterStore) modalEditor { + return a.newModalEditorWithOpts(text, a.editorOptions(), hooks, store) +} + +// newModalEditorWithOpts is newModalEditor with explicit options. +func (a *App) newModalEditorWithOpts(text string, opts helix.Options, hooks helix.Hooks, store *helix.RegisterStore) modalEditor { + if a.prefs.Motions == "helix" { + return helix.NewWithRegisters(text, opts, hooks, store) + } + return vimEditor{vim.NewWithRegisters(text, opts, hooks, store)} +} diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index 753a7e1..274846a 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -7,7 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // Style classes for editor runes. @@ -288,10 +288,11 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { textW := max(1, w-gutter) lines := ed.Lines() blocks := computeBlockState(lines) - cur := ed.Cursor() - vStart, vEnd, vMode, inVisual := ed.VisualRange() + cur := ed.BlockCursor() + vStart, vEnd, _, inVisual := ed.VisualRange() + vKind := ed.SelectionKind() blockLeft, blockRight, blockToEnd := 0, 0, false - if inVisual && vMode == vim.ModeVisualBlock { + if inVisual && vKind == "^V" { blockLeft, blockRight, blockToEnd = ed.BlockColumns() } out := make([]string, 0, h) @@ -368,7 +369,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { if dimDone && lineDone { key.dimDone = true } - if inVisual && inVisualRange(line, col, vStart, vEnd, vMode, blockLeft, blockRight, blockToEnd) { + if inVisual && inVisualRange(line, col, vStart, vEnd, vKind, blockLeft, blockRight, blockToEnd) { key.visual = true } for _, hit := range searchHits { @@ -389,7 +390,7 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { k2.fg, k2.bold, k2.italic = sp.fg, sp.bold, sp.italic } } - if inVisual && inVisualRange(line, j, vStart, vEnd, vMode, blockLeft, blockRight, blockToEnd) { + if inVisual && inVisualRange(line, j, vStart, vEnd, vKind, blockLeft, blockRight, blockToEnd) { k2.visual = true } for _, hit := range searchHits { @@ -476,14 +477,14 @@ func (a *App) renderGutter(buf *noteBuffer, line, curLine, rel int, first bool, } // inVisualRange reports whether a cell is inside the visual selection. -func inVisualRange(line, col int, start, end vim.Pos, mode vim.Mode, bl, br int, toEnd bool) bool { +func inVisualRange(line, col int, start, end helix.Pos, kind string, bl, br int, toEnd bool) bool { if line < start.Line || line > end.Line { return false } - switch mode { - case vim.ModeVisualLine: + switch kind { + case "V": return true - case vim.ModeVisualBlock: + case "^V": if toEnd { return col >= bl } @@ -502,7 +503,7 @@ func inVisualRange(line, col int, start, end vim.Pos, mode vim.Mode, bl, br int, } // renderPlainEditor draws a small editor (quick capture) without gutter. -func renderPlainEditor(a *App, ed *vim.Editor, w, h int, focused bool) string { +func renderPlainEditor(a *App, ed modalEditor, w, h int, focused bool) string { tmp := ¬eBuffer{ed: ed} saved := a.prefs.LineNumberMode a.prefs.LineNumberMode = "off" diff --git a/internal/tui/excomplete_test.go b/internal/tui/excomplete_test.go index eec00b6..a57dc9f 100644 --- a/internal/tui/excomplete_test.go +++ b/internal/tui/excomplete_test.go @@ -3,9 +3,9 @@ package tui import ( "testing" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/keymaps" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) func testAppWithNotes() *App { @@ -45,23 +45,23 @@ func TestCompleteExCommandsAndArguments(t *testing.T) { func TestExPromptTabCyclesLikeVim(t *testing.T) { a := testAppWithNotes() p := &exPrompt{input: newTextInput("e R")} - p.handleKey(a, vim.KeyTab) + p.handleKey(a, helix.KeyTab) if p.input.String() != "e Reading" { t.Fatalf("first tab: %q", p.input.String()) } - p.handleKey(a, vim.KeyTab) + p.handleKey(a, helix.KeyTab) if p.input.String() != "e Roadmap" { t.Fatalf("second tab: %q", p.input.String()) } - p.handleKey(a, vim.KeyTab) + p.handleKey(a, helix.KeyTab) if p.input.String() != "e R" { t.Fatalf("cycling should return to the typed text: %q", p.input.String()) } - p.handleKey(a, vim.KeyShiftTab) + p.handleKey(a, helix.KeyShiftTab) if p.input.String() != "e Roadmap" { t.Fatalf("shift+tab: %q", p.input.String()) } - p.handleKey(a, vim.R('x')) + p.handleKey(a, helix.R('x')) if p.completions != nil { t.Fatal("typing resets the completion cycle") } diff --git a/internal/tui/expand_test.go b/internal/tui/expand_test.go new file mode 100644 index 0000000..7001147 --- /dev/null +++ b/internal/tui/expand_test.go @@ -0,0 +1,38 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ZenNotes/tui/internal/helix" +) + +// TestExpandSelectionReachesTheEngine checks that Alt+o and Alt+i grow and +// shrink the selection in the editor: the app must pass the chords on to the +// helix engine instead of swallowing them. +func TestExpandSelectionReachesTheEngine(t *testing.T) { + a, root := newFeatureTestApp(t) + if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("one two three\n"), 0o600); err != nil { + t.Fatal(err) + } + a.openNote("note.md", true) + ed := a.activeBuffer().ed + a.handleKey(helix.Key{Rune: 'o', Alt: true}) + start, end, _, ok := ed.VisualRange() + if !ok || start != (helix.Pos{Line: 0, Col: 0}) || end != (helix.Pos{Line: 0, Col: 2}) { + t.Fatalf("selection after alt-o = %v..%v ok=%t, want the first word", start, end, ok) + } + // The arrow keys work the same way. + a.handleKey(helix.Key{Name: "up", Alt: true}) + start, end, _, ok = ed.VisualRange() + if !ok || start != (helix.Pos{Line: 0, Col: 0}) || end != (helix.Pos{Line: 0, Col: 12}) { + t.Fatalf("selection after alt-up = %v..%v ok=%t, want the paragraph", start, end, ok) + } + // Alt+i walks back down the ladder. + a.handleKey(helix.Key{Name: "down", Alt: true}) + start, end, _, ok = ed.VisualRange() + if !ok || start != (helix.Pos{Line: 0, Col: 0}) || end != (helix.Pos{Line: 0, Col: 2}) { + t.Fatalf("selection after alt-down = %v..%v ok=%t, want the first word", start, end, ok) + } +} diff --git a/internal/tui/featurehelpers_test.go b/internal/tui/featurehelpers_test.go index c250543..8e72f4f 100644 --- a/internal/tui/featurehelpers_test.go +++ b/internal/tui/featurehelpers_test.go @@ -23,6 +23,7 @@ func newFeatureTestApp(t *testing.T) (*App, string) { t.Fatal(err) } a := newApp(context.Background(), Options{Backend: b, Target: target}, config.DefaultPrefs(), true) + a.prefs.Motions = "helix" // these features test the helix engine; see motions_engine_test.go m := a.loadIndexCmd()().(indexLoadedMsg) if m.err != nil { t.Fatal(m.err) diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index bcaadc8..a40ada4 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -14,12 +14,12 @@ func configPathInfo() (string, string, error) { // rows, plain prose. A filter keeps sections with a matching line. func helpLines(a *App, filter string) []string { leader := "Space" - if a.keymap.Binding("vim.leaderPrefix") != "" { - leader = a.keymap.Binding("vim.leaderPrefix") + if a.keymap.Binding("helix.leaderPrefix") != "" { + leader = a.keymap.Binding("helix.leaderPrefix") } all := []string{ "# ZenNotes in the terminal", - "zn tui is ZenNotes without the window: the same vault, notes, tasks, tags, templates and Vim motions,", + "zn tui is ZenNotes without the window: the same vault, notes, tasks, tags, templates and Helix selections,", "driven from the keyboard. Every key below assumes Vim mode ([vim] enabled = true in config.toml).", "With Vim mode off, use arrows, Enter and Escape to navigate; F2 opens context actions and Ctrl+G opens commands.", "", @@ -61,14 +61,18 @@ func helpLines(a *App, filter string) []string { leader + " n\tNew note", leader + " ;\tCommand palette (every ex command, searchable)", leader + " ?\tThis manual", - "Leader hints appear immediately; timed expires the sequence, sticky keeps it open (vim.which_key_hint_mode).", + "Leader hints appear immediately; timed expires the sequence, sticky keeps it open (helix.which_key_hint_mode).", + "Motions engine: [vim] motions = vim (operator-pending, default) or helix (selection-first).", "", "## Editor: motions", - "h j k l w b e W B E 0 ^ $\tThe usual", - "gg G { } ( ) H M L\tFile, paragraph, sentence and screen motions", - "f t F T ; ,\tFind a character on the line", - "% * # n N / ?\tMatching bracket, word search, pattern search (with incremental highlight)", - "]] [[ ][ []\tNext and previous Markdown heading", + "h j k l 0 ^ $\tMoves; word and find motions select as they travel", + "w b e W B E\tSelect a whole word and the space after it; b takes the word it lands on", + "f t F T , ;\tSelect through a character; ; also collapses the selection", + "n N * # / ?\tSelect the next match, use the selection as the pattern, search", + "gg G { } H M L\tFile, paragraph and screen motions", + "] ] [[ ][ []\tNext and previous Markdown heading", + "Alt+O / Alt+Up\tGrow the selection: word, paragraph, code function, section, note", + "Alt+I / Alt+Down\tShrink the selection back to the smaller context", "Ctrl+D Ctrl+U Ctrl+F Ctrl+B Ctrl+E Ctrl+Y zt zz zb\tScrolling", "Ctrl+O Ctrl+I\tJump list, across notes too", "gj gk\tMove by display row when a line wraps", @@ -98,7 +102,7 @@ func helpLines(a *App, filter string) []string { "Ctrl+Q / gqip\tReflow the paragraph", "Enter in a list\tContinues the list or checkbox; Enter on an empty item ends it", "Tab / Shift+Tab in a list\tIndent or outdent the item", - "jk\tLeaves insert mode (vim.insert_escape in config.toml)", + "jk\tLeaves insert mode (helix.insert_escape in config.toml)", "Auto-pairs, `->` to → and other text replacements follow the editor preferences.", "", "## Ex commands", @@ -106,7 +110,7 @@ func helpLines(a *App, filter string) []string { ":q :bd\tClose the current tab; closing the last tab quits", ":w :wa :wq :x\tSave, save all, or save and close the current tab", ":e :new :vs :sp :only\tOpen, create, split", - ":bn :bp ]b [b gt gT\tNext and previous tab", + ":bn :bp ]b [b gt gT\tNext and previous tab (gn gp with helix motions)", ":pin :tabmove -1|+1|first|last\tPin or unpin the tab (pinned tabs sit first and survive :tabonly); reorder tabs", ":link, or [[ in insert mode\tPick a note and insert a wikilink to it", "# and a letter in insert mode\tOffers the vault's tags under the cursor, most used first, as the desktop app does; inside a frontmatter tags: value it completes bare tags. Ctrl+N / Ctrl+P or ↑↓ move, Enter / Tab / Ctrl+Y accept, Ctrl+E dismisses, typing narrows", diff --git a/internal/tui/hints.go b/internal/tui/hints.go index a288a39..a24a9fa 100644 --- a/internal/tui/hints.go +++ b/internal/tui/hints.go @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // hintPair is one entry of a help line: a catalog action id (or a literal @@ -89,9 +89,9 @@ var ( {"nav.filter", "search"}, {"key:Esc", "editor"}, {"key:?", "keys"}, } editorHints = []hintPair{ - {"vim.leaderPrefix", "leader"}, {"key::qa", "quit"}, {"key::help", "manual"}, {"global.searchNotes", "search"}, + {"helix.leaderPrefix", "leader"}, {"key::qa", "quit"}, {"key::help", "manual"}, {"global.searchNotes", "search"}, {"key:Space z p", "preview"}, {"key:Space z s", "split"}, - {"vim.panePrefix", "pane"}, {"global.newNoteHere", "new note"}, {"vim.goToDefinition", "follow link"}, + {"helix.panePrefix", "pane"}, {"global.newNoteHere", "new note"}, {"helix.goToDefinition", "follow link"}, {"editor.toggleCheckbox", "checkbox"}, } plainEditorHints = []hintPair{ @@ -106,7 +106,7 @@ var ( func (a *App) hintLine() string { var line string switch { - case !a.prefs.VimMode: + case !a.prefs.HelixMode: switch a.focus { case focusSidebar: line = a.keysHint(append(arrowHints, hintPair{"key:←/→", "collapse/expand"}, hintPair{"global.newNoteHere", "new note"})) @@ -143,7 +143,7 @@ type keyHelpOverlay struct { extra []hintPair } -func (h *keyHelpOverlay) handleKey(a *App, k vim.Key) { +func (h *keyHelpOverlay) handleKey(a *App, k helix.Key) { switch { case k.Is("esc") || k.IsRune('?') || k.IsRune('q') || k.IsCtrl('c'): a.overlay = nil @@ -202,7 +202,7 @@ func (a *App) openKeyHelp() { {"global.searchNotes", "search notes"}, {"global.newNoteHere", "new note"}, {"global.toggleSidebar", "toggle sidebar"}, {"key:Ctrl+W h/j/k/l", "focus panes (also Alt+H/J/K/L)"}, {"key:Space z e/s/p", "editor, split, preview mode (also Alt+E/S/P)"}, {"key:Esc", "in preview: back to the editor"}, - {"global.toggleZenMode", "zen mode"}, {"global.toggleWordWrap", "word wrap"}, {"vim.historyBack|vim.historyForward", "jump list"}, + {"global.toggleZenMode", "zen mode"}, {"global.toggleWordWrap", "word wrap"}, {"helix.historyBack|helix.historyForward", "jump list"}, {"key:Ctrl+S", "save"}, {"key:Ctrl+Z", "suspend"}, {"key:Ctrl+G", "command palette"}, } title := "Keys" @@ -224,8 +224,12 @@ func (a *App) openKeyHelp() { } } else { title = "Editor keys" + tabs := "helix.bufferNext|helix.bufferPrevious" + if a.prefs.Motions == "helix" { + tabs += "|helix.gotoTabNext|helix.gotoTabPrevious" + } pairs = append(editorHints, hintPair{"key:gx", "open URL"}, hintPair{"key:gy", "copy link"}, hintPair{"key:zc/zo/zM/zR", "folds"}, - hintPair{"vim.bufferNext|vim.bufferPrevious", "next/previous tab"}, hintPair{"key::w :q :qa", "save, close, quit"}) + hintPair{tabs, "next/previous tab"}, hintPair{"key::w :q :qa", "save, close, quit"}) } } a.overlay = &keyHelpOverlay{title: title, pairs: pairs, extra: global} diff --git a/internal/tui/input.go b/internal/tui/input.go index b07d287..d0ba394 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -6,7 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) type leaderHintMsg struct{ seq int } @@ -14,7 +14,7 @@ type leaderHintMsg struct{ seq int } // leaderState is a leader sequence in progress. type leaderState struct { seq int - keys []vim.Key + keys []helix.Key node *leaderNode showHints bool } @@ -29,7 +29,7 @@ func (l *leaderState) pendingLabel() string { // handleKey routes one key: overlay first, then a leader or pane prefix in // progress, then the focused surface. -func (a *App) handleKey(k vim.Key) tea.Cmd { +func (a *App) handleKey(k helix.Key) tea.Cmd { for _, ignored := range a.prefs.IgnoredKeys { keys := bindingKeys(ignored) if len(keys) == 1 && sameKey(k, keys[0]) { @@ -59,8 +59,8 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { return a.afterKey() } if k.IsCtrl('c') { - if buf := a.activeBuffer(); buf != nil && a.focus == focusPane && buf.ed.Mode() != vim.ModeNormal { - buf.ed.HandleKey(vim.KeyEsc) + if buf := a.activeBuffer(); buf != nil && a.focus == focusPane && buf.ed.Mode() != helix.ModeNormal { + buf.ed.HandleKey(helix.KeyEsc) return a.afterKey() } a.quit() @@ -70,7 +70,7 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { editing := false if buf != nil && a.focus == focusPane { m := buf.ed.Mode() - editing = m == vim.ModeInsert || m == vim.ModeReplace || m == vim.ModeCmdline || !a.prefs.VimMode + editing = m == helix.ModeInsert || m == helix.ModeCmdline || !a.prefs.HelixMode } if editing { if a.completionKey(buf, k) || a.chordKey(k, true) { @@ -79,7 +79,7 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { if buf.loading { return a.afterKey() } - if buf.ed.Mode() == vim.ModeInsert { + if buf.ed.Mode() == helix.ModeInsert { if a.markdownSnippet(buf, k) { return a.afterKey() } @@ -94,7 +94,7 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { if a.chordKey(k, false) { return a.afterKey() } - if a.prefs.VimMode && a.isLeaderKey(k) { + if a.prefs.HelixMode && a.isLeaderKey(k) { if buf == nil || a.focus != focusPane || buf.ed.Pending() == "" { a.startLeader() return a.afterKey() @@ -121,8 +121,8 @@ func (a *App) handleKey(k vim.Key) tea.Cmd { // drive an open tag menu, and Vim's Ctrl-X prefix, under which Ctrl-O (omni) // and Ctrl-] (tags) open that menu on demand. Ctrl-Space also completes // callout names and fence languages when the cursor is in those contexts. -func (a *App) completionKey(buf *noteBuffer, k vim.Key) bool { - if buf.ed.Mode() != vim.ModeInsert { +func (a *App) completionKey(buf *noteBuffer, k helix.Key) bool { + if buf.ed.Mode() != helix.ModeInsert { a.insertCtrlX = false return false } @@ -139,7 +139,7 @@ func (a *App) completionKey(buf *noteBuffer, k vim.Key) bool { return true } switch { - case k.IsCtrl('x') && a.prefs.VimMode: + case k.IsCtrl('x') && a.prefs.HelixMode: a.insertCtrlX = true a.message, a.messageErr = "-- ^X mode (^O tags)", false case k.IsCtrl('@'): @@ -197,8 +197,8 @@ func (a *App) quit() { a.quitting = true } -func (a *App) isLeaderKey(k vim.Key) bool { - leader := a.keymap.Binding("vim.leaderPrefix") +func (a *App) isLeaderKey(k helix.Key) bool { + leader := a.keymap.Binding("helix.leaderPrefix") if leader == "" { return false } @@ -207,14 +207,14 @@ func (a *App) isLeaderKey(k vim.Key) bool { } // bound reports whether k is the (single-chord) binding of an action. -func (a *App) bound(k vim.Key, id string) bool { +func (a *App) bound(k helix.Key, id string) bool { keys := bindingKeys(a.keymap.Binding(id)) return len(keys) == 1 && sameKey(k, keys[0]) } // chordKey handles bindings that hold everywhere: modifier chords. With // editing true only the chords safe inside insert mode apply. -func (a *App) chordKey(k vim.Key, editing bool) bool { +func (a *App) chordKey(k helix.Key, editing bool) bool { if !k.Ctrl && !k.Alt { return false } @@ -261,12 +261,12 @@ func (a *App) chordKey(k vim.Key, editing bool) bool { case k.IsCtrl('g'): a.openCommandPalette() case k.IsCtrl('z'): - if editing && !a.prefs.VimMode { + if editing && !a.prefs.HelixMode { return false } a.queue(tea.Suspend) case a.tabSelectChord(k): - case editing && a.prefs.VimMode: + case editing && a.prefs.HelixMode: // Insert mode keeps Ctrl+P/N (completion) and the rest for Vim. return false case a.bound(k, "global.searchNotes"): @@ -283,13 +283,13 @@ func (a *App) chordKey(k vim.Key, editing bool) bool { a.toggleRecentNote() case a.bound(k, "global.reopenClosedTab"): a.reopenClosedTab() - case !a.prefs.VimMode && a.bound(k, "global.closeActiveTab"): + case !a.prefs.HelixMode && a.bound(k, "global.closeActiveTab"): a.closeTab(a.activePane, a.activePane.active) - case a.prefs.VimMode && a.bound(k, "vim.panePrefix"): + case a.prefs.HelixMode && a.bound(k, "helix.panePrefix"): a.paneKeys = true - case a.prefs.VimMode && a.bound(k, "vim.historyBack"): + case a.prefs.HelixMode && a.bound(k, "helix.historyBack"): a.jumpBack() - case a.prefs.VimMode && a.bound(k, "vim.historyForward"): + case a.prefs.HelixMode && a.bound(k, "helix.historyForward"): a.jumpForward() default: return false @@ -297,7 +297,7 @@ func (a *App) chordKey(k vim.Key, editing bool) bool { return true } -func (a *App) tabSelectChord(k vim.Key) bool { +func (a *App) tabSelectChord(k helix.Key) bool { ids := []string{"tabs.select1", "tabs.select2", "tabs.select3", "tabs.select4", "tabs.select5", "tabs.select6", "tabs.select7", "tabs.select8", "tabs.select9"} for i, id := range ids { if a.bound(k, id) { @@ -309,7 +309,7 @@ func (a *App) tabSelectChord(k vim.Key) bool { } // paneKey is a key for the active pane's content. -func (a *App) paneKey(k vim.Key) { +func (a *App) paneKey(k helix.Key) { if buf := a.activeBuffer(); buf != nil && buf.loading { if k.IsRune(':') { a.openLocalEx() @@ -318,7 +318,7 @@ func (a *App) paneKey(k vim.Key) { } t := a.activeTab() if t == nil { - if a.prefs.VimMode && k.IsRune('?') { + if a.prefs.HelixMode && k.IsRune('?') { a.openHelp() return } @@ -339,7 +339,7 @@ func (a *App) paneKey(k vim.Key) { if buf == nil { return } - if t.mode != modePreview && buf.ed.Mode() == vim.ModeInsert { + if t.mode != modePreview && buf.ed.Mode() == helix.ModeInsert { if isMarkKey(k) { a.markdownCompletion() return @@ -357,14 +357,18 @@ func (a *App) paneKey(k vim.Key) { // editorKey feeds a normal or visual mode key, first checking the app's own // multi-key sequences (`gt`, `]b`) which the engine would otherwise own. -func (a *App) editorKey(buf *noteBuffer, k vim.Key) { - if a.prefs.VimMode && (k.Is("tab") && !k.Shift) && buf.ed.Pending() == "" && a.bound(vim.Key{Rune: 'i', Ctrl: true}, "vim.historyForward") { +func (a *App) editorKey(buf *noteBuffer, k helix.Key) { + if a.prefs.HelixMode && (k.Is("tab") && !k.Shift) && buf.ed.Pending() == "" && a.bound(helix.Key{Rune: 'i', Ctrl: true}, "helix.historyForward") { a.jumpForward() return } - seqIDs := []string{"vim.bufferPrevious", "vim.bufferNext", "vim.tabPrevious", "vim.tabNext"} + // gn/gp are helix-motion keys: the vim engine leaves them alone. + seqIDs := []string{"helix.bufferPrevious", "helix.bufferNext", "helix.tabPrevious", "helix.tabNext"} + if a.prefs.Motions == "helix" { + seqIDs = append(seqIDs, "helix.gotoTabPrevious", "helix.gotoTabNext") + } if len(a.navKeys) > 0 { - seq := append(append([]vim.Key{}, a.navKeys...), k) + seq := append(append([]helix.Key{}, a.navKeys...), k) a.navKeys = nil for _, id := range seqIDs { complete, _ := matchSequence(seq, bindingKeys(a.keymap.Binding(id))) @@ -384,7 +388,7 @@ func (a *App) editorKey(buf *noteBuffer, k vim.Key) { for _, id := range seqIDs { keys := bindingKeys(a.keymap.Binding(id)) if len(keys) > 1 && sameKey(k, keys[0]) { - a.navKeys = []vim.Key{k} + a.navKeys = []helix.Key{k} return } if len(keys) == 1 && sameKey(k, keys[0]) { @@ -403,31 +407,39 @@ func (a *App) runSequenceAction(id string, count int, hasCount bool) { count = 1 } switch id { - case "vim.bufferPrevious": + case "helix.bufferPrevious": for i := 0; i < count; i++ { a.cycleTab(-1) } - case "vim.bufferNext": + case "helix.bufferNext": for i := 0; i < count; i++ { a.cycleTab(1) } - case "vim.tabPrevious": + case "helix.tabPrevious": for i := 0; i < count; i++ { a.cycleTab(-1) } - case "vim.tabNext": + case "helix.tabNext": if hasCount { a.selectTab(count) return } a.cycleTab(1) + case "helix.gotoTabPrevious": + for i := 0; i < count; i++ { + a.cycleTab(-1) + } + case "helix.gotoTabNext": + for i := 0; i < count; i++ { + a.cycleTab(1) + } } } // resolveAction matches the typed keys against list bindings. A prefix // match parks the keys and reports pending. -func (a *App) resolveAction(k vim.Key, ids ...string) (string, bool) { - seq := append(append([]vim.Key{}, a.navKeys...), k) +func (a *App) resolveAction(k helix.Key, ids ...string) (string, bool) { + seq := append(append([]helix.Key{}, a.navKeys...), k) anyPrefix := false for _, id := range ids { b := a.keymap.Binding(id) @@ -453,8 +465,8 @@ func (a *App) resolveAction(k vim.Key, ids ...string) (string, bool) { // listKeyAllowed gates single-key list shortcuts on Vim mode: with Vim off // only arrows, Enter, Escape and modifier chords reach a list. -func (a *App) listKeyAllowed(k vim.Key) bool { - if a.prefs.VimMode { +func (a *App) listKeyAllowed(k helix.Key) bool { + if a.prefs.HelixMode { return true } return !k.Printable() @@ -623,7 +635,7 @@ func (a *App) saveNow() { // --- Ctrl+W pane commands --- -func (a *App) paneCommand(k vim.Key) { +func (a *App) paneCommand(k helix.Key) { switch { case k.IsRune('h') || k.Is("left") || k.IsCtrl('h'): a.focusDirection('h') @@ -763,7 +775,7 @@ func containsPane(n *paneNode, p *pane) bool { // --- jump list across notes --- -func (a *App) pushJump(path string, pos vim.Pos) { +func (a *App) pushJump(path string, pos helix.Pos) { if a.jumpIdx >= 0 && a.jumpIdx < len(a.jumps) { a.jumps = a.jumps[:a.jumpIdx] } @@ -820,7 +832,7 @@ func (a *App) startLeader() { } } -func (a *App) leaderKey(k vim.Key) { +func (a *App) leaderKey(k helix.Key) { l := a.leader if k.Is("esc") || k.IsCtrl('c') || (a.isLeaderKey(k) && a.prefs.WhichKeyHintMode == "sticky") { a.leader = nil diff --git a/internal/tui/io_async_test.go b/internal/tui/io_async_test.go index 2af861e..ebe434a 100644 --- a/internal/tui/io_async_test.go +++ b/internal/tui/io_async_test.go @@ -12,8 +12,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) type slowIOBackend struct { @@ -439,8 +439,8 @@ func TestCtrlSpaceRoutesToSidebarMarkAndInsertCompletion(t *testing.T) { a.openNote("Alpha.md", true) buf := a.activeBuffer() buf.ed.ReplaceText("> [!NO") - buf.ed.HandleKey(vim.R('A')) - if buf.ed.Mode() != vim.ModeInsert { + buf.ed.HandleKey(helix.R('A')) + if buf.ed.Mode() != helix.ModeInsert { t.Fatal("fixture did not enter Insert mode") } a.Update(tea.KeyMsg{Type: tea.KeyCtrlAt}) diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 0ee47b6..5bb5e91 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -6,71 +6,71 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // keyFromTea normalizes a Bubble Tea key into the engine's key type. -func keyFromTea(msg tea.KeyMsg) vim.Key { +func keyFromTea(msg tea.KeyMsg) helix.Key { switch msg.Type { case tea.KeyRunes: if len(msg.Runes) == 1 { - return vim.Key{Rune: msg.Runes[0], Alt: msg.Alt} + return helix.Key{Rune: msg.Runes[0], Alt: msg.Alt} } - return vim.Key{Rune: msg.Runes[0], Alt: msg.Alt} + return helix.Key{Rune: msg.Runes[0], Alt: msg.Alt} case tea.KeySpace: - return vim.Key{Rune: ' ', Alt: msg.Alt} + return helix.Key{Rune: ' ', Alt: msg.Alt} case tea.KeyF2: - return vim.Special("f2") + return helix.Special("f2") case tea.KeyEnter: - return vim.Key{Name: "enter", Alt: msg.Alt} + return helix.Key{Name: "enter", Alt: msg.Alt} case tea.KeyEsc: - return vim.Key{Name: "esc", Alt: msg.Alt} + return helix.Key{Name: "esc", Alt: msg.Alt} case tea.KeyTab: - return vim.Key{Name: "tab", Alt: msg.Alt} + return helix.Key{Name: "tab", Alt: msg.Alt} case tea.KeyShiftTab: - return vim.Key{Name: "tab", Shift: true, Alt: msg.Alt} + return helix.Key{Name: "tab", Shift: true, Alt: msg.Alt} case tea.KeyBackspace: - return vim.Key{Name: "backspace", Alt: msg.Alt} + return helix.Key{Name: "backspace", Alt: msg.Alt} case tea.KeyDelete: - return vim.Key{Name: "delete", Alt: msg.Alt} + return helix.Key{Name: "delete", Alt: msg.Alt} case tea.KeyUp: - return vim.Key{Name: "up", Alt: msg.Alt} + return helix.Key{Name: "up", Alt: msg.Alt} case tea.KeyDown: - return vim.Key{Name: "down", Alt: msg.Alt} + return helix.Key{Name: "down", Alt: msg.Alt} case tea.KeyLeft: - return vim.Key{Name: "left", Alt: msg.Alt} + return helix.Key{Name: "left", Alt: msg.Alt} case tea.KeyRight: - return vim.Key{Name: "right", Alt: msg.Alt} + return helix.Key{Name: "right", Alt: msg.Alt} case tea.KeyHome: - return vim.Key{Name: "home", Alt: msg.Alt} + return helix.Key{Name: "home", Alt: msg.Alt} case tea.KeyEnd: - return vim.Key{Name: "end", Alt: msg.Alt} + return helix.Key{Name: "end", Alt: msg.Alt} case tea.KeyPgUp: - return vim.Key{Name: "pgup", Alt: msg.Alt} + return helix.Key{Name: "pgup", Alt: msg.Alt} case tea.KeyPgDown: - return vim.Key{Name: "pgdn", Alt: msg.Alt} + return helix.Key{Name: "pgdn", Alt: msg.Alt} case tea.KeyInsert: - return vim.Key{Name: "insert", Alt: msg.Alt} + return helix.Key{Name: "insert", Alt: msg.Alt} case tea.KeyShiftUp: - return vim.Key{Name: "up", Shift: true} + return helix.Key{Name: "up", Shift: true} case tea.KeyShiftDown: - return vim.Key{Name: "down", Shift: true} + return helix.Key{Name: "down", Shift: true} case tea.KeyShiftLeft: - return vim.Key{Name: "left", Shift: true} + return helix.Key{Name: "left", Shift: true} case tea.KeyShiftRight: - return vim.Key{Name: "right", Shift: true} + return helix.Key{Name: "right", Shift: true} case tea.KeyCtrlUp: - return vim.Key{Name: "up", Ctrl: true} + return helix.Key{Name: "up", Ctrl: true} case tea.KeyCtrlDown: - return vim.Key{Name: "down", Ctrl: true} + return helix.Key{Name: "down", Ctrl: true} case tea.KeyCtrlLeft: - return vim.Key{Name: "left", Ctrl: true} + return helix.Key{Name: "left", Ctrl: true} case tea.KeyCtrlRight: - return vim.Key{Name: "right", Ctrl: true} + return helix.Key{Name: "right", Ctrl: true} case tea.KeyCtrlHome: - return vim.Key{Name: "home", Ctrl: true} + return helix.Key{Name: "home", Ctrl: true} case tea.KeyCtrlEnd: - return vim.Key{Name: "end", Ctrl: true} + return helix.Key{Name: "end", Ctrl: true} } name := msg.String() if strings.HasPrefix(name, "alt+") { @@ -79,39 +79,39 @@ func keyFromTea(msg tea.KeyMsg) vim.Key { if strings.HasPrefix(name, "ctrl+") { rest := name[5:] if r := []rune(rest); len(r) == 1 { - return vim.Key{Rune: r[0], Ctrl: true, Alt: msg.Alt} + return helix.Key{Rune: r[0], Ctrl: true, Alt: msg.Alt} } switch rest { case "@": - return vim.Key{Rune: '@', Ctrl: true} + return helix.Key{Rune: '@', Ctrl: true} case "[": - return vim.Key{Name: "esc"} + return helix.Key{Name: "esc"} case "]": - return vim.Key{Rune: ']', Ctrl: true} + return helix.Key{Rune: ']', Ctrl: true} case "\\": - return vim.Key{Rune: '\\', Ctrl: true} + return helix.Key{Rune: '\\', Ctrl: true} case "^": - return vim.Key{Rune: '^', Ctrl: true} + return helix.Key{Rune: '^', Ctrl: true} case "_": - return vim.Key{Rune: '_', Ctrl: true} + return helix.Key{Rune: '_', Ctrl: true} case "?": - return vim.Key{Name: "backspace"} + return helix.Key{Name: "backspace"} } } - return vim.Key{Name: name, Alt: msg.Alt} + return helix.Key{Name: name, Alt: msg.Alt} } // bindingKeys parses a catalog binding like `Alt+H`, `Ctrl+P`, `g t`, // `Space`, `Alt+ArrowLeft` into a key sequence. -func bindingKeys(binding string) []vim.Key { +func bindingKeys(binding string) []helix.Key { binding = strings.TrimSpace(binding) if binding == "" { return nil } - out := []vim.Key{} + out := []helix.Key{} for _, chord := range strings.Fields(binding) { parts := strings.Split(chord, "+") - k := vim.Key{} + k := helix.Key{} base := parts[len(parts)-1] for _, mod := range parts[:len(parts)-1] { switch strings.ToLower(mod) { @@ -171,7 +171,7 @@ func bindingKeys(binding string) []vim.Key { } // sameKey compares keys ignoring the shift flag on printable runes. -func sameKey(a, b vim.Key) bool { +func sameKey(a, b helix.Key) bool { if a.Name != "" || b.Name != "" { return a.Name == b.Name && a.Ctrl == b.Ctrl && a.Alt == b.Alt && a.Shift == b.Shift } @@ -180,7 +180,7 @@ func sameKey(a, b vim.Key) bool { // matchSequence reports whether typed keys complete or could still // complete a binding. -func matchSequence(typed, binding []vim.Key) (complete bool, prefix bool) { +func matchSequence(typed, binding []helix.Key) (complete bool, prefix bool) { if len(binding) == 0 || len(typed) > len(binding) { return false, false } @@ -193,7 +193,7 @@ func matchSequence(typed, binding []vim.Key) (complete bool, prefix bool) { } // keyLabel renders a key for hint overlays. -func keyLabel(k vim.Key) string { +func keyLabel(k helix.Key) string { s := k.String() s = strings.TrimPrefix(strings.TrimSuffix(s, ">"), "<") switch s { diff --git a/internal/tui/leader.go b/internal/tui/leader.go index b10e79a..96aa351 100644 --- a/internal/tui/leader.go +++ b/internal/tui/leader.go @@ -26,18 +26,18 @@ func (a *App) leaderTree() *leaderNode { parent.children = append(parent.children, n) return n } - add(root, "vim.leaderOpenBuffers", "Open buffers", func(a *App) { a.openBufferPicker() }) - add(root, "vim.leaderSearchNotes", "Search notes", func(a *App) { a.openNoteSearch("") }) - if s := add(root, "vim.leaderSearchGroup", "Search", nil); s != nil { - add(s, "vim.leaderSearchVaultText", "Vault text", func(a *App) { a.openTextSearch("") }) + add(root, "helix.leaderOpenBuffers", "Open buffers", func(a *App) { a.openBufferPicker() }) + add(root, "helix.leaderSearchNotes", "Search notes", func(a *App) { a.openNoteSearch("") }) + if s := add(root, "helix.leaderSearchGroup", "Search", nil); s != nil { + add(s, "helix.leaderSearchVaultText", "Vault text", func(a *App) { a.openTextSearch("") }) s.children = append(s.children, &leaderNode{key: "n", title: "Notes", run: func(a *App) { a.openNoteSearch("") }}) } - add(root, "vim.leaderToggleSidebar", "Toggle sidebar", func(a *App) { a.toggleSidebar() }) - add(root, "vim.leaderNoteOutline", "Note outline", func(a *App) { a.toggleSidePanel(&a.outlineOpen, focusOutline) }) + add(root, "helix.leaderToggleSidebar", "Toggle sidebar", func(a *App) { a.toggleSidebar() }) + add(root, "helix.leaderNoteOutline", "Note outline", func(a *App) { a.toggleSidePanel(&a.outlineOpen, focusOutline) }) // Space v is the desktop's "switch vault"; the view group lives on its // own key so nothing about vaults hides under "view". - add(root, "vim.leaderSwitchVault", "Switch vault", func(a *App) { a.openVaultSwitcher() }) - if v := add(root, "vim.leaderViewGroup", "View", nil); v != nil { + add(root, "helix.leaderSwitchVault", "Switch vault", func(a *App) { a.openVaultSwitcher() }) + if v := add(root, "helix.leaderViewGroup", "View", nil); v != nil { v.children = append(v.children, &leaderNode{key: "p", title: "Preview mode", run: func(a *App) { a.setPaneMode(modePreview) }}, &leaderNode{key: "s", title: "Split mode", run: func(a *App) { a.setPaneMode(modeSplit) }}, @@ -49,10 +49,10 @@ func (a *App) leaderTree() *leaderNode { &leaderNode{key: "t", title: "Theme", run: func(a *App) { a.runCommandLine("theme") }}, ) } - if l := add(root, "vim.leaderNoteActions", "Note actions", nil); l != nil { - add(l, "vim.leaderFormatNote", "Format note", func(a *App) { a.formatActiveNote() }) - add(l, "vim.leaderCopyMarkdown", "Copy note as Markdown", func(a *App) { a.copyActiveNote() }) - add(l, "vim.leaderToggleFavorite", "Toggle favorite", func(a *App) { a.toggleActiveFavorite() }) + if l := add(root, "helix.leaderNoteActions", "Note actions", nil); l != nil { + add(l, "helix.leaderFormatNote", "Format note", func(a *App) { a.formatActiveNote() }) + add(l, "helix.leaderCopyMarkdown", "Copy note as Markdown", func(a *App) { a.copyActiveNote() }) + add(l, "helix.leaderToggleFavorite", "Toggle favorite", func(a *App) { a.toggleActiveFavorite() }) l.children = append(l.children, &leaderNode{key: "r", title: "Rename note", run: func(a *App) { a.renameActiveNote() }}, &leaderNode{key: "m", title: "Move note", run: func(a *App) { a.moveActiveNotePrompt() }}, @@ -63,15 +63,15 @@ func (a *App) leaderTree() *leaderNode { &leaderNode{key: "e", title: "Edit in $EDITOR", run: func(a *App) { a.editActiveInEditor() }}, ) } - add(root, "vim.leaderQuickCapture", "Quick capture", func(a *App) { a.openQuickCapture() }) - add(root, "vim.leaderTemplatePicker", "New from template", func(a *App) { a.openTemplatePicker(false) }) - add(root, "vim.leaderInsertTemplate", "Insert template", func(a *App) { a.openTemplatePicker(true) }) - add(root, "vim.leaderDailyNote", "Daily note", func(a *App) { a.openPeriodic(periodic.Daily, 0) }) - add(root, "vim.leaderWeeklyNote", "Weekly note", func(a *App) { a.openPeriodic(periodic.Weekly, 0) }) - add(root, "vim.leaderMonthlyNote", "Monthly note", func(a *App) { a.openPeriodic(periodic.Monthly, 0) }) - add(root, "vim.leaderCalendar", "Calendar", func(a *App) { a.toggleSidePanel(&a.calendarOpen, focusCalendar) }) - add(root, "vim.hintMode", "Hint mode", func(a *App) { a.startHintMode() }) - add(root, "vim.leaderKanban", "Kanban board", func(a *App) { _ = a.openTasksMode("kanban") }) + add(root, "helix.leaderQuickCapture", "Quick capture", func(a *App) { a.openQuickCapture() }) + add(root, "helix.leaderTemplatePicker", "New from template", func(a *App) { a.openTemplatePicker(false) }) + add(root, "helix.leaderInsertTemplate", "Insert template", func(a *App) { a.openTemplatePicker(true) }) + add(root, "helix.leaderDailyNote", "Daily note", func(a *App) { a.openPeriodic(periodic.Daily, 0) }) + add(root, "helix.leaderWeeklyNote", "Weekly note", func(a *App) { a.openPeriodic(periodic.Weekly, 0) }) + add(root, "helix.leaderMonthlyNote", "Monthly note", func(a *App) { a.openPeriodic(periodic.Monthly, 0) }) + add(root, "helix.leaderCalendar", "Calendar", func(a *App) { a.toggleSidePanel(&a.calendarOpen, focusCalendar) }) + add(root, "helix.hintMode", "Hint mode", func(a *App) { a.startHintMode() }) + add(root, "helix.leaderKanban", "Kanban board", func(a *App) { _ = a.openTasksMode("kanban") }) root.children = append(root.children, &leaderNode{key: ";", title: "Command palette", run: func(a *App) { a.openCommandPalette() }}, &leaderNode{key: "x", title: "Tasks", run: func(a *App) { _ = a.openTasksMode("") }}, diff --git a/internal/tui/leader_test.go b/internal/tui/leader_test.go index 2a193d9..ffd96cb 100644 --- a/internal/tui/leader_test.go +++ b/internal/tui/leader_test.go @@ -1,13 +1,13 @@ package tui import ( - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "testing" ) func TestLeaderAppearsImmediatelyAndTimedSequenceExpires(t *testing.T) { a, _ := newFeatureTestApp(t) - a.handleKey(vim.R(' ')) + a.handleKey(helix.R(' ')) if a.leader == nil || !a.leader.showHints { t.Fatal("leader hints must appear on the first key") } @@ -20,9 +20,9 @@ func TestLeaderAppearsImmediatelyAndTimedSequenceExpires(t *testing.T) { func TestLeaderSubmenuIgnoresPreviousExpiry(t *testing.T) { a, _ := newFeatureTestApp(t) - a.handleKey(vim.R(' ')) + a.handleKey(helix.R(' ')) seq := a.leader.seq - a.handleKey(vim.R('l')) + a.handleKey(helix.R('l')) a.Update(leaderHintMsg{seq: seq}) if a.leader == nil || !a.leader.showHints { t.Fatal("old timeout closed the submenu") @@ -36,11 +36,11 @@ func TestLeaderSubmenuIgnoresPreviousExpiry(t *testing.T) { func TestStickyLeaderDismissesWithLeaderKey(t *testing.T) { a, _ := newFeatureTestApp(t) a.prefs.WhichKeyHintMode = "sticky" - a.handleKey(vim.R(' ')) + a.handleKey(helix.R(' ')) if a.leader == nil || !a.leader.showHints { t.Fatal("sticky hints must appear immediately") } - a.handleKey(vim.R(' ')) + a.handleKey(helix.R(' ')) if a.leader != nil || a.messageErr || a.message != "" { t.Fatal("leader should quietly dismiss sticky hints") } diff --git a/internal/tui/motions_engine_test.go b/internal/tui/motions_engine_test.go new file mode 100644 index 0000000..d4a3f92 --- /dev/null +++ b/internal/tui/motions_engine_test.go @@ -0,0 +1,91 @@ +package tui + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ZenNotes/tui/internal/helix" +) + +// TestMotionsOptionPicksTheEngine checks [helix] motions: "vim" restores the +// vim model (dw deletes a word, w just moves) while the default helix model +// selects as it moves (w leaves a spanning selection, wd deletes a word). +func TestMotionsOptionPicksTheEngine(t *testing.T) { + for _, tc := range []struct { + motions string + move string + delete string + want string + wantSel bool + }{ + {"vim", "w", "dw", "two three\n", false}, + {"helix", "w", "wd", "two three\n", true}, + } { + a, root := newFeatureTestApp(t) + a.prefs.Motions = tc.motions + if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("one two three\n"), 0o600); err != nil { + t.Fatal(err) + } + a.openNote("note.md", true) + a.activeBuffer().ed.Feed(tc.move) + _, _, _, active := a.activeBuffer().ed.VisualRange() + if active != tc.wantSel { + t.Fatalf("%s %q: selection active = %t, want %t", tc.motions, tc.move, active, tc.wantSel) + } + // the delete runs from a fresh cursor, the way the user would type it + fresh := a.newModalEditor("one two three\n", helix.Hooks{}, &a.registers) + fresh.Feed(tc.delete) + if got := fresh.Text(); got != tc.want { + t.Fatalf("%s %q: got %q, want %q", tc.motions, tc.delete, got, tc.want) + } + } +} + +// TestHelixWordMotionLeavesTheNextWordOut checks that the helix engine keeps +// the word a motion lands on out of the selection, and draws the block cursor +// inside the selection, the way Helix does. +func TestHelixWordMotionLeavesTheNextWordOut(t *testing.T) { + a, root := newFeatureTestApp(t) + a.prefs.Motions = "helix" + if err := os.WriteFile(filepath.Join(root, "note.md"), []byte("one two three\n"), 0o600); err != nil { + t.Fatal(err) + } + a.openNote("note.md", true) + ed := a.activeBuffer().ed + ed.Feed("w") + start, end, _, ok := ed.VisualRange() + if !ok || start != (helix.Pos{Line: 0, Col: 0}) || end != (helix.Pos{Line: 0, Col: 3}) { + t.Fatalf("selection after w = %v..%v ok=%t, want the text before the next word", start, end, ok) + } + if got, want := ed.BlockCursor(), (helix.Pos{Line: 0, Col: 3}); got != want { + t.Fatalf("block cursor after w = %v, want %v", got, want) + } +} + +// TestVimMotionsKeepAppKeys checks that with the vim engine the app-level +// sequences still work and visual mode reports its label. +func TestVimMotionsKeepAppKeys(t *testing.T) { + a, root := newFeatureTestApp(t) + a.prefs.Motions = "vim" + for name, text := range map[string]string{"a.md": "A\n", "b.md": "B\n"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(text), 0o600); err != nil { + t.Fatal(err) + } + } + a.openNote("a.md", true) + a.openNote("b.md", true) + // gn is a helix-motion key: with the vim engine it stays with the engine + // (a no-op here) and must not switch tabs. + a.handleKey(helix.R('g')) + a.handleKey(helix.R('n')) + if got := a.activeBuffer().path; got != "b.md" { + t.Fatalf("gn switched tabs with the vim engine, active %q", got) + } + // vim visual mode keeps its label through the interface + a.activeBuffer().ed.Feed("v") + if label := a.activeBuffer().ed.ModeLabel(); label != "VISUAL" { + t.Fatalf("vim visual label = %q", label) + } + a.activeBuffer().ed.Feed(helix.KeyEsc.String()) +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 7fda616..69b3133 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -5,7 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // Mouse support covers every surface: click to focus and select, double @@ -27,7 +27,7 @@ type mouseState struct { dragNode *paneNode dragPane *pane dragBuf *noteBuffer - dragStart vim.Pos + dragStart helix.Pos dragVisual bool } @@ -209,7 +209,7 @@ func (a *App) scrollAt(x, y, delta int) { return } mode := buf.ed.Mode() - if !a.prefs.VimMode || mode == vim.ModeInsert || mode == vim.ModeReplace || mode == vim.ModeCmdline { + if !a.prefs.HelixMode || mode == helix.ModeInsert || mode == helix.ModeCmdline { top := max(0, min(max(0, buf.ed.LineCount()-1), buf.ed.ScrollTop()+delta)) buf.ed.SetScrollTop(top) return @@ -312,19 +312,20 @@ func (a *App) paneClick(p *pane, m tea.MouseMsg, double bool) { return } } - if mode == vim.ModeInsert || mode == vim.ModeReplace || mode == vim.ModeCmdline { + if mode == helix.ModeInsert || mode == helix.ModeCmdline { buf.ed.SetCursor(pos) return } - if a.prefs.VimMode && mode != vim.ModeNormal { - buf.ed.HandleKey(vim.KeyEsc) + if a.prefs.HelixMode && mode != helix.ModeNormal { + buf.ed.HandleKey(helix.KeyEsc) } buf.ed.SetCursor(pos) - if double && a.prefs.VimMode { - buf.ed.Feed("viw") + if double && a.prefs.HelixMode { + // Select the word under the pointer (Helix match mode). + buf.ed.Feed("miw") return } - if a.prefs.VimMode { + if a.prefs.HelixMode { a.mouse.drag = "select" a.mouse.dragBuf = buf a.mouse.dragPane = p @@ -335,7 +336,7 @@ func (a *App) paneClick(p *pane, m tea.MouseMsg, double bool) { // editorPosAt maps a screen cell to a buffer position using the rows of // the last render. -func (a *App) editorPosAt(p *pane, buf *noteBuffer, x, y int) (vim.Pos, bool) { +func (a *App) editorPosAt(p *pane, buf *noteBuffer, x, y int) (helix.Pos, bool) { inner := a.contentRect(p) textY := y - inner.y textX := x - inner.x @@ -353,14 +354,14 @@ func (a *App) editorPosAt(p *pane, buf *noteBuffer, x, y int) (vim.Pos, bool) { if col > ri.segStart && col >= ri.segEnd && ri.segEnd < len(runes) { col = ri.segEnd - 1 } - return vim.Pos{Line: ri.line, Col: col}, true + return helix.Pos{Line: ri.line, Col: col}, true } // Below the last row: end of the buffer. if len(buf.rows) > 0 && textY > buf.rows[len(buf.rows)-1].y { last := buf.ed.LineCount() - 1 - return vim.Pos{Line: last, Col: max(0, len(buf.ed.LineRunes(last))-1)}, true + return helix.Pos{Line: last, Col: max(0, len(buf.ed.LineRunes(last))-1)}, true } - return vim.Pos{}, false + return helix.Pos{}, false } func (a *App) previewClick(t *tab, buf *noteBuffer, y int, m tea.MouseMsg, double bool) { @@ -433,7 +434,7 @@ func (a *App) sidePanelClick(m tea.MouseMsg, double bool, main rect) { p.month = time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, time.Local) } if double { - a.calendar.handleKey(a, vim.KeyEnter) + a.calendar.handleKey(a, helix.KeyEnter) } } } @@ -582,7 +583,7 @@ func (a *App) mouseDrag(m tea.MouseMsg) { } if !a.mouse.dragVisual { buf.ed.SetCursor(a.mouse.dragStart) - buf.ed.HandleKey(vim.R('v')) + buf.ed.HandleKey(helix.R('v')) a.mouse.dragVisual = true } buf.ed.SetCursor(pos) @@ -610,7 +611,7 @@ func (a *App) overlayMouse(m tea.MouseMsg) { idx := ov.scroll + by - 3 if idx >= 0 && idx < len(ov.filtered) { ov.cursor = idx - ov.handleKey(a, vim.KeyEnter) + ov.handleKey(a, helix.KeyEnter) } } case *menu: @@ -624,7 +625,7 @@ func (a *App) overlayMouse(m tea.MouseMsg) { idx := by - 1 if idx >= 0 && idx < len(ov.items) && !ov.items[idx].sep { ov.cursor = idx - ov.handleKey(a, vim.KeyEnter) + ov.handleKey(a, helix.KeyEnter) } case *confirmDialog: if m.Button != tea.MouseButtonLeft || !inside { @@ -632,9 +633,9 @@ func (a *App) overlayMouse(m tea.MouseMsg) { } if by == 2 { if bx < (r.w-4)/2 { - ov.handleKey(a, vim.R('y')) + ov.handleKey(a, helix.R('y')) } else { - ov.handleKey(a, vim.R('n')) + ov.handleKey(a, helix.R('n')) } } case *keyHelpOverlay, *hintOverlay: diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index f6fd4f2..662c911 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -11,11 +11,11 @@ import ( "github.com/atotto/clipboard" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/search" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // createNote makes a note and opens it. @@ -31,8 +31,8 @@ func (a *App) createNote(folder vault.NoteFolder, title, subpath string, body *s a.openNote(meta.Path, true) a.withLoadedBuffer(meta.Path, func(buf *noteBuffer) { buf.ed.GotoLine(buf.ed.LineCount()) - if a.prefs.VimMode { - buf.ed.HandleKey(vim.R('A')) + if a.prefs.HelixMode { + buf.ed.HandleKey(helix.R('A')) } }) } @@ -697,7 +697,7 @@ func (a *App) insertTemplate(t templates.Template) { } // posForOffset maps a rune offset to a position. -func posForOffset(text string, offset int) vim.Pos { +func posForOffset(text string, offset int) helix.Pos { line, col := 0, 0 for i, r := range []rune(text) { if i == offset { @@ -710,7 +710,7 @@ func posForOffset(text string, offset int) vim.Pos { col++ } } - return vim.Pos{Line: line, Col: col} + return helix.Pos{Line: line, Col: col} } // --- folders --- @@ -925,7 +925,7 @@ func (a *App) openNoteQuiet(path string) { // note inserts its title and steps past the closing brackets auto-pairs // added. func (a *App) maybeWikilinkCompletion(buf *noteBuffer) { - if buf.ed.Mode() != vim.ModeInsert { + if buf.ed.Mode() != helix.ModeInsert { return } cur := buf.ed.Cursor() @@ -949,7 +949,7 @@ func (a *App) openWikilinkPicker(buf *noteBuffer) { case opened && closed: buf.ed.InsertAtCursor(title) c := buf.ed.Cursor() - buf.ed.SetCursor(vim.Pos{Line: c.Line, Col: c.Col + 2}) + buf.ed.SetCursor(helix.Pos{Line: c.Line, Col: c.Col + 2}) case opened: buf.ed.InsertAtCursor(title + "]]") default: diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index a5f61fe..be60e60 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -8,14 +8,14 @@ import ( "github.com/charmbracelet/lipgloss" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/search" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // overlay is a modal surface: a palette, prompt, menu or dialog. type overlay interface { - handleKey(a *App, k vim.Key) + handleKey(a *App, k helix.Key) render(a *App, w, h int) string } @@ -50,7 +50,7 @@ func (t *textInput) endsWith(suffix string) bool { } // handle applies an editing key; false means the key was not for the input. -func (t *textInput) handle(k vim.Key) bool { +func (t *textInput) handle(k helix.Key) bool { switch { case k.Printable(): t.runes = append(t.runes[:t.cursor], append([]rune{k.Rune}, t.runes[t.cursor:]...)...) @@ -182,7 +182,7 @@ func (p *palette) refilter(a *App) { } } -func (p *palette) handleKey(a *App, k vim.Key) { +func (p *palette) handleKey(a *App, k helix.Key) { if p.onMove != nil { before := p.currentID() defer func() { @@ -341,7 +341,7 @@ func (a *App) promptSecret(title, help string, onSubmit func(a *App, text string a.overlay = &prompt{title: title, input: newTextInput(""), help: help, onSubmit: onSubmit, masked: true} } -func (p *prompt) handleKey(a *App, k vim.Key) { +func (p *prompt) handleKey(a *App, k helix.Key) { switch { case k.Is("esc") || k.IsCtrl('c'): a.overlay = nil @@ -385,7 +385,7 @@ func (a *App) confirm(message string, onYes func()) { a.overlay = &confirmDialog{message: message, onYes: onYes} } -func (c *confirmDialog) handleKey(a *App, k vim.Key) { +func (c *confirmDialog) handleKey(a *App, k helix.Key) { switch { case k.IsRune('y') || k.IsRune('Y') || k.Is("enter"): a.overlay = nil @@ -435,7 +435,7 @@ func (a *App) showMenu(title string, items []menuItem) { a.overlay = &menu{title: title, items: items, anchor: anchor} } -func (m *menu) handleKey(a *App, k vim.Key) { +func (m *menu) handleKey(a *App, k helix.Key) { switch { case k.Is("esc") || k.IsCtrl('c') || k.IsRune('q'): a.overlay = nil @@ -505,20 +505,20 @@ func (m *menu) render(a *App, w, h int) string { // --- quick capture --- type captureOverlay struct { - ed *vim.Editor + ed modalEditor } func (a *App) openQuickCapture() { opts := a.editorOptions() - ed := vim.New("", opts, vim.Hooks{}) + ed := a.newModalEditorWithOpts("", opts, helix.Hooks{}, nil) ed.SetViewport(8) - if a.prefs.VimMode { - ed.HandleKey(vim.R('i')) + if a.prefs.HelixMode { + ed.HandleKey(helix.R('i')) } a.overlay = &captureOverlay{ed: ed} } -func (c *captureOverlay) handleKey(a *App, k vim.Key) { +func (c *captureOverlay) handleKey(a *App, k helix.Key) { text := strings.TrimSpace(c.ed.Text()) switch { case k.IsCtrl('s'): @@ -528,7 +528,7 @@ func (c *captureOverlay) handleKey(a *App, k vim.Key) { case k.IsCtrl('c'): a.overlay = nil return - case k.Is("esc") && (!a.prefs.VimMode || c.ed.Mode() == vim.ModeNormal): + case k.Is("esc") && (!a.prefs.HelixMode || c.ed.Mode() == helix.ModeNormal): if text == "" { a.overlay = nil return @@ -548,8 +548,8 @@ func (c *captureOverlay) render(a *App, w, h int) string { c.ed.EnsureCursorVisible() body := renderPlainEditor(a, c.ed, inner, 8, true) lines = append(lines, strings.Split(body, "\n")...) - mode := c.ed.Mode().Label() - if !a.prefs.VimMode { + mode := c.ed.ModeLabel() + if !a.prefs.HelixMode { mode = "EDIT" } lines = append(lines, th.Muted.Render(mode)) @@ -679,7 +679,7 @@ func (a *App) startHintMode() { a.overlay = &hintOverlay{targets: targets} } -func (h *hintOverlay) handleKey(a *App, k vim.Key) { +func (h *hintOverlay) handleKey(a *App, k helix.Key) { if k.Is("esc") || k.IsCtrl('c') || !k.Printable() { a.overlay = nil return @@ -812,7 +812,7 @@ func (p *exPrompt) complete(a *App, backward bool) { p.input = newTextInput(p.compBase + p.completions[p.compIdx]) } -func (p *exPrompt) handleKey(a *App, k vim.Key) { +func (p *exPrompt) handleKey(a *App, k helix.Key) { switch { case k.Is("esc") || k.IsCtrl('c'): a.overlay = nil diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 65c048c..b53e0bc 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -8,9 +8,9 @@ import ( "github.com/charmbracelet/lipgloss" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // --- Outline --- @@ -67,7 +67,7 @@ func (p *outlinePanel) render(a *App, w, h int) string { return fitBlock(strings.Join(lines, "\n"), w, h) } -func (p *outlinePanel) handleKey(a *App, k vim.Key) { +func (p *outlinePanel) handleKey(a *App, k helix.Key) { if k.Is("esc") { a.focus = focusPane return @@ -200,7 +200,7 @@ func (p *connectionsPanel) render(a *App, w, h int) string { return fitBlock(strings.Join(lines, "\n"), w, h) } -func (p *connectionsPanel) handleKey(a *App, k vim.Key) { +func (p *connectionsPanel) handleKey(a *App, k helix.Key) { if k.Is("esc") { a.focus = focusPane return @@ -329,7 +329,7 @@ func (p *calendarPanel) render(a *App, w, h int) string { return fitBlock(strings.Join(lines, "\n"), w, h) } -func (p *calendarPanel) handleKey(a *App, k vim.Key) { +func (p *calendarPanel) handleKey(a *App, k helix.Key) { if k.Is("esc") { a.focus = focusPane return diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index e30a188..1bd4ff8 100644 --- a/internal/tui/pick_overlay.go +++ b/internal/tui/pick_overlay.go @@ -5,8 +5,8 @@ import ( "sort" "strings" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/search" - "github.com/ZenNotes/tui/internal/vim" ) // pickItem is one checkable entry of a pickOverlay. @@ -145,7 +145,7 @@ func (p *pickOverlay) toggleCurrent() { } } -func (p *pickOverlay) handleKey(a *App, k vim.Key) { +func (p *pickOverlay) handleKey(a *App, k helix.Key) { switch { case k.Is("esc") || k.IsCtrl('c') || k.IsCtrl('['): a.overlay = nil diff --git a/internal/tui/preview.go b/internal/tui/preview.go index b1be58b..f6dcd43 100644 --- a/internal/tui/preview.go +++ b/internal/tui/preview.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // previewState is the reading-mode state of a tab. @@ -550,10 +550,10 @@ func (a *App) renderPreview(t *tab, buf *noteBuffer, w, h int, focused bool) str // previewKey handles reading-mode keys. // previewHint is the help line for reading mode. func (a *App) previewHint() string { - return a.keysHint([]hintPair{{"key:Esc/i", "back to editor"}, {"nav.moveDown|nav.moveUp", "scroll"}, {"key:Enter", "follow link"}, {"nav.toggleTask", "toggle task"}, {"key:Space z s", "split"}, {"vim.leaderPrefix", "leader"}}) + return a.keysHint([]hintPair{{"key:Esc/i", "back to editor"}, {"nav.moveDown|nav.moveUp", "scroll"}, {"key:Enter", "follow link"}, {"nav.toggleTask", "toggle task"}, {"key:Space z s", "split"}, {"helix.leaderPrefix", "leader"}}) } -func (a *App) previewKey(t *tab, buf *noteBuffer, k vim.Key) { +func (a *App) previewKey(t *tab, buf *noteBuffer, k helix.Key) { pv := t.preview if pv == nil { pv = &previewState{} @@ -585,14 +585,14 @@ func (a *App) previewKey(t *tab, buf *noteBuffer, k vim.Key) { if !a.listKeyAllowed(k) { return } - id, pending := a.resolveAction(k, "nav.toggleTask", "nav.openResult", "nav.localEx", "nav.filter", "vim.goToDefinition") + id, pending := a.resolveAction(k, "nav.toggleTask", "nav.openResult", "nav.localEx", "nav.filter", "helix.goToDefinition") if pending { return } switch id { case "nav.toggleTask": a.previewToggleTask(t, buf) - case "nav.openResult", "vim.goToDefinition": + case "nav.openResult", "helix.goToDefinition": a.previewFollow(t, buf) case "nav.localEx": if buf != nil { @@ -655,7 +655,7 @@ func (a *App) previewToggleTask(t *tab, buf *noteBuffer) { return } cur := buf.ed.Cursor() - buf.ed.SetCursor(vim.Pos{Line: row.srcLine, Col: 0}) + buf.ed.SetCursor(helix.Pos{Line: row.srcLine, Col: 0}) buf.ed.ToggleCheckboxAtCursor() buf.ed.SetCursor(cur) } diff --git a/internal/tui/registers_test.go b/internal/tui/registers_test.go index 0a4b5a8..89573f8 100644 --- a/internal/tui/registers_test.go +++ b/internal/tui/registers_test.go @@ -6,30 +6,31 @@ import ( "strings" "testing" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) func TestYankFollowsTheSessionAcrossPreviouslyOpenedNotes(t *testing.T) { a, root := newFeatureTestApp(t) - a.prefs.VimYankToClipboard = false + a.prefs.HelixYankToClipboard = false for name, text := range map[string]string{"source.md": "source line\nanother line\n", "target.md": "destination line\n"} { if err := os.WriteFile(filepath.Join(root, name), []byte(text), 0o600); err != nil { t.Fatal(err) } } a.openNote("source.md", true) - a.activeBuffer().ed.Feed("yy") + a.activeBuffer().ed.Feed("xy") a.openNote("target.md", true) - a.activeBuffer().ed.Feed("yy") - a.handleKey(vim.R('[')) - a.handleKey(vim.R('b')) + a.activeBuffer().ed.Feed("xy") + a.handleKey(helix.R('[')) + a.handleKey(helix.R('b')) if a.activeBuffer().path != "source.md" { t.Fatal("[b did not switch to the source note") } - a.activeBuffer().ed.Feed("jyy") - a.handleKey(vim.R(']')) - a.handleKey(vim.R('b')) + a.activeBuffer().ed.Feed("j") + a.activeBuffer().ed.Feed("xy") + a.handleKey(helix.R(']')) + a.handleKey(helix.R('b')) target := a.activeBuffer() target.ed.Feed("p") if got := target.ed.Text(); got != "destination line\nanother line\n" { @@ -43,12 +44,12 @@ func TestYankFollowsTheSessionAcrossPreviouslyOpenedNotes(t *testing.T) { func TestYankSurvivesClosedSourceAndAsynchronousDestination(t *testing.T) { a, root := newFeatureTestApp(t) - a.prefs.VimYankToClipboard = false + a.prefs.HelixYankToClipboard = false source, err := a.openBuffer("note.md") if err != nil { t.Fatal(err) } - source.ed.Feed("yy") + source.ed.Feed("xy") a.closeBufferIfUnused("note.md") if _, ok := a.buffers["note.md"]; ok { t.Fatal("source buffer was not closed") @@ -70,11 +71,13 @@ func TestYankSurvivesClosedSourceAndAsynchronousDestination(t *testing.T) { func TestTemplateEditorSharesNamedRegistersWithNotes(t *testing.T) { a, _ := newFeatureTestApp(t) - a.prefs.VimYankToClipboard = false + a.prefs.HelixYankToClipboard = false a.openNote("note.md", true) - a.activeBuffer().ed.Feed(`"ayy`) + a.activeBuffer().ed.Feed("x") + a.activeBuffer().ed.Feed(`"ay`) file := vault.CustomTemplateFile{SourcePath: ".zennotes/templates/example.md", Raw: "# Template\n"} a.openTemplateFile(file) + a.activeBuffer().ed.Feed("x") a.activeBuffer().ed.Feed(`"ap`) if got := a.activeBuffer().ed.Text(); !strings.Contains(got, "# Template\n# Note\n") { t.Fatalf("template editor could not read the note register: %q", got) @@ -84,7 +87,7 @@ func TestTemplateEditorSharesNamedRegistersWithNotes(t *testing.T) { func TestSeparateTUISessionsDoNotShareRegisters(t *testing.T) { first, _ := newFeatureTestApp(t) second, _ := newFeatureTestApp(t) - first.prefs.VimYankToClipboard, second.prefs.VimYankToClipboard = false, false + first.prefs.HelixYankToClipboard, second.prefs.HelixYankToClipboard = false, false first.openNote("note.md", true) first.activeBuffer().ed.Feed("yy") second.openNote("note.md", true) diff --git a/internal/tui/session.go b/internal/tui/session.go index e069930..4e5218e 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/config" ) @@ -57,8 +57,8 @@ type sessionPane struct { } type sessionPosition struct { - Cursor vim.Pos `json:"cursor"` - Scroll int `json:"scroll"` + Cursor helix.Pos `json:"cursor"` + Scroll int `json:"scroll"` } func sessionPath() string { diff --git a/internal/tui/session_parity_test.go b/internal/tui/session_parity_test.go index 07083fe..734bd49 100644 --- a/internal/tui/session_parity_test.go +++ b/internal/tui/session_parity_test.go @@ -12,7 +12,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) func newSessionParityVault(t *testing.T, paths ...string) string { @@ -232,11 +232,11 @@ func TestSessionRestoresEachOpenNotesCursorAndScroll(t *testing.T) { } a := newSessionParityApp(t, root) a.openNote("Alpha.md", true) - a.activeBuffer().ed.SetCursor(vim.Pos{Line: 25, Col: 9}) + a.activeBuffer().ed.SetCursor(helix.Pos{Line: 25, Col: 9}) a.activeBuffer().ed.SetScrollTop(20) a.activePane = a.panes.split(a.activePane, true) a.openNote("Beta.md", true) - a.activeBuffer().ed.SetCursor(vim.Pos{Line: 54, Col: 15}) + a.activeBuffer().ed.SetCursor(helix.Pos{Line: 54, Col: 15}) a.activeBuffer().ed.SetScrollTop(48) a.saveSession() @@ -268,7 +268,7 @@ func TestSessionClampsSavedPositionAfterNoteIsShortened(t *testing.T) { } a := newSessionParityApp(t, root) a.openNote("Alpha.md", true) - a.activeBuffer().ed.SetCursor(vim.Pos{Line: 50, Col: 20}) + a.activeBuffer().ed.SetCursor(helix.Pos{Line: 50, Col: 20}) a.activeBuffer().ed.SetScrollTop(45) a.saveSession() if err := os.WriteFile(filepath.Join(root, "Alpha.md"), []byte("Short"), 0o644); err != nil { diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index 2fcf843..7fb6bef 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -3,8 +3,8 @@ package tui import ( "fmt" "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" "sort" "strconv" "strings" @@ -87,9 +87,9 @@ func (v *settingsView) render(a *App, w, h int, focused bool) string { } return fitBlock(strings.Join(lines, "\n"), w, h) } -func (v *settingsView) handleKey(a *App, k vim.Key) bool { +func (v *settingsView) handleKey(a *App, k helix.Key) bool { if k.IsRune('v') { - _ = a.showVersion(vim.ExCommand{Name: "version"}) + _ = a.showVersion(helix.ExCommand{Name: "version"}) return true } if a.listNav(&v.list, k, len(v.settings), v.rows) { @@ -113,7 +113,7 @@ func (v *settingsView) handleKey(a *App, k vim.Key) bool { } var settingChoices = map[string][]string{ - "which_key_hint_mode": {"timed", "sticky"}, "wrapped_line_motions": {"display", "logical"}, "default_view_mode": {"edit", "split", "preview"}, "line_number_mode": {"off", "absolute", "relative"}, "completed_task_style": {"none", "gray", "strikethrough", "gray-strikethrough"}, "time_format": {"12h", "24h"}, "theme_mode": {"dark", "light", "system"}, "note_sort_order": {"modified-desc", "created-desc", "title-asc", "title-desc"}, "calendar_week_start": {"monday", "sunday"}, "tasks_view_mode": {"list", "kanban", "calendar"}, "preview_style": {"auto", "dark", "light", "ascii", "notty"}, "images": {"auto", "kitty", "off"}, + "which_key_hint_mode": {"timed", "sticky"}, "wrapped_line_motions": {"display", "logical"}, "motions": {"helix", "vim"}, "default_view_mode": {"edit", "split", "preview"}, "line_number_mode": {"off", "absolute", "relative"}, "completed_task_style": {"none", "gray", "strikethrough", "gray-strikethrough"}, "time_format": {"12h", "24h"}, "theme_mode": {"dark", "light", "system"}, "note_sort_order": {"modified-desc", "created-desc", "title-asc", "title-desc"}, "calendar_week_start": {"monday", "sunday"}, "tasks_view_mode": {"list", "kanban", "calendar"}, "preview_style": {"auto", "dark", "light", "ascii", "notty"}, "images": {"auto", "kitty", "off"}, } func (v *settingsView) edit(a *App, row settingRow) { diff --git a/internal/tui/settings_live_test.go b/internal/tui/settings_live_test.go index 76a8835..919c237 100644 --- a/internal/tui/settings_live_test.go +++ b/internal/tui/settings_live_test.go @@ -2,14 +2,14 @@ package tui import ( "github.com/ZenNotes/tui/internal/config" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "os" "testing" ) func TestPreferenceCommandPersistsAndReloads(t *testing.T) { a, _ := newFeatureTestApp(t) - a.runEx(nil, vim.ExCommand{Name: "wrap"}) + a.runEx(nil, helix.ExCommand{Name: "wrap"}) a.afterKey() p, _, err := config.LoadPrefs() if err != nil { @@ -21,7 +21,7 @@ func TestPreferenceCommandPersistsAndReloads(t *testing.T) { if err := config.SetValue("editor", "tabs_enabled", false); err != nil { t.Fatal(err) } - a.runEx(nil, vim.ExCommand{Name: "reloadconfig"}) + a.runEx(nil, helix.ExCommand{Name: "reloadconfig"}) if a.prefs.TabsEnabled() { t.Fatal("reload did not apply tab preference") } @@ -31,7 +31,7 @@ func TestMalformedConfigIsNotOverwrittenByPreferenceCommand(t *testing.T) { if err := os.WriteFile(config.ConfigTomlPath(), []byte("broken=["), 0600); err != nil { t.Fatal(err) } - a.runEx(nil, vim.ExCommand{Name: "wrap"}) + a.runEx(nil, helix.ExCommand{Name: "wrap"}) a.afterKey() got, _ := os.ReadFile(config.ConfigTomlPath()) if string(got) != "broken=[" { @@ -54,7 +54,7 @@ func TestThemePreviewDoesNotChangePortablePreferences(t *testing.T) { if p.ThemeFamily != before.ThemeFamily || p.ThemeMode != before.ThemeMode || p.ThemeID != before.ThemeID { t.Fatalf("preview changed saved theme: %+v", p) } - a.handleKey(vim.KeyEsc) + a.handleKey(helix.KeyEsc) } func TestSessionThemeSurvivesUnrelatedPreferenceChange(t *testing.T) { diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index 141e91b..1632c9e 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -6,8 +6,8 @@ import ( "strings" "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // sidebarRow is one visible line of the sidebar tree. @@ -485,7 +485,7 @@ func sidebarGlyph(r sidebarRow) string { // --- keys --- -func (s *sidebarState) handleKey(a *App, k vim.Key) { +func (s *sidebarState) handleKey(a *App, k helix.Key) { if isMarkKey(k) { if s.cursor < len(s.rows) { a.markSidebarRow(s.rows[s.cursor]) diff --git a/internal/tui/tagcomplete.go b/internal/tui/tagcomplete.go index 992b59c..b08d68c 100644 --- a/internal/tui/tagcomplete.go +++ b/internal/tui/tagcomplete.go @@ -7,8 +7,8 @@ import ( "strings" "unicode" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // Tag completion. Typing `#` and a letter in a note offers the vault's @@ -42,7 +42,7 @@ type tagMenu struct { scroll int // at is where the cursor stood when the menu was last refreshed. A cursor // that moved by other means (the mouse, a reload) makes the menu stale. - at vim.Pos + at helix.Pos } func isTagRune(r rune) bool { @@ -123,7 +123,7 @@ func underTagsKey(lines []string, lineNo int) bool { // tagTokenAt is the token the cursor is completing, if the place allows // tags at all: not in code, not in a heading, not in a link. -func tagTokenAt(ed *vim.Editor) (tagToken, bool) { +func tagTokenAt(ed modalEditor) (tagToken, bool) { cur := ed.Cursor() lines := ed.Lines() if cur.Line >= len(lines) { @@ -230,7 +230,7 @@ func (a *App) liveTagMenu() *tagMenu { } buf := a.activeBuffer() if buf == nil || a.overlay != nil || a.focus != focusPane || buf.path != m.path || - buf.ed.Mode() != vim.ModeInsert || buf.ed.Cursor() != m.at { + buf.ed.Mode() != helix.ModeInsert || buf.ed.Cursor() != m.at { a.tagMenu = nil return nil } @@ -243,7 +243,7 @@ func (a *App) liveTagMenu() *tagMenu { func (a *App) refreshTagMenu(buf *noteBuffer) { prev := a.tagMenu a.tagMenu = nil - if buf.ed.Mode() != vim.ModeInsert { + if buf.ed.Mode() != helix.ModeInsert { a.tagDismissed = nil return } @@ -282,7 +282,7 @@ func (a *App) refreshTagMenu(buf *noteBuffer) { // wordTagToken is the run of tag characters from start to the cursor, for // a menu that was opened by hand where no '#' had been typed. -func wordTagToken(line []rune, cur vim.Pos, start int) (tagToken, bool) { +func wordTagToken(line []rune, cur helix.Pos, start int) (tagToken, bool) { if start > cur.Col || cur.Col > len(line) { return tagToken{}, false } @@ -304,7 +304,7 @@ type tagDismissal struct { // anywhere else the word before the cursor becomes the query and the pick // replaces it with `#tag`. func (a *App) openTagMenu(buf *noteBuffer) { - if buf.ed.Mode() != vim.ModeInsert { + if buf.ed.Mode() != helix.ModeInsert { return } cur := buf.ed.Cursor() @@ -329,7 +329,7 @@ func (a *App) openTagMenu(buf *noteBuffer) { // tagMenuKey takes the keys that drive an open menu. Everything else goes // on to the editor, which is how typing narrows the list. -func (a *App) tagMenuKey(buf *noteBuffer, k vim.Key) bool { +func (a *App) tagMenuKey(buf *noteBuffer, k helix.Key) bool { m := a.liveTagMenu() if m == nil { return false @@ -343,7 +343,7 @@ func (a *App) tagMenuKey(buf *noteBuffer, k vim.Key) bool { a.acceptTag(buf, m) case k.IsCtrl('e'): a.dismissTagMenu(m) - case k.Is("esc") && !a.prefs.VimMode: + case k.Is("esc") && !a.prefs.HelixMode: a.dismissTagMenu(m) default: // Esc in Vim mode closes the menu and leaves insert mode, as Vim's diff --git a/internal/tui/tagcomplete_test.go b/internal/tui/tagcomplete_test.go index c93f676..37c59ef 100644 --- a/internal/tui/tagcomplete_test.go +++ b/internal/tui/tagcomplete_test.go @@ -11,7 +11,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // newTagTestApp opens Draft.md in a vault whose other notes carry tags: @@ -54,7 +54,7 @@ func newTagTestApp(t *testing.T, prefs config.Prefs, draft string) (*App, *noteB return a, buf } -func feed(a *App, keys ...vim.Key) { +func feed(a *App, keys ...helix.Key) { for _, k := range keys { a.handleKey(k) } @@ -62,7 +62,7 @@ func feed(a *App, keys ...vim.Key) { func typeKeys(a *App, text string) { for _, r := range text { - a.handleKey(vim.R(r)) + a.handleKey(helix.R(r)) } } @@ -80,13 +80,13 @@ func menuTags(a *App) string { func vimPrefs() config.Prefs { p := config.DefaultPrefs() - p.VimInsertEscape = "" + p.HelixInsertEscape = "" return p } func TestTypingAHashOffersTheVaultsTags(t *testing.T) { a, buf := newTagTestApp(t, vimPrefs(), "# Draft\n\n") - feed(a, vim.R('G'), vim.R('o')) + feed(a, helix.R('G'), helix.R('o')) typeKeys(a, "see #") if got := menuTags(a); got != "" { t.Fatalf("a bare # must stay quiet (it may become a heading), got %q", got) @@ -100,7 +100,7 @@ func TestTypingAHashOffersTheVaultsTags(t *testing.T) { if !strings.Contains(a.View(), "#archived-project") { t.Fatal("the menu is not on screen") } - feed(a, vim.Key{Rune: 'n', Ctrl: true}, vim.Key{Name: "enter"}) + feed(a, helix.Key{Rune: 'n', Ctrl: true}, helix.Key{Name: "enter"}) if got := buf.ed.Line(3); got != "see #prose" { t.Fatalf("accepted line %q", got) } @@ -108,10 +108,10 @@ func TestTypingAHashOffersTheVaultsTags(t *testing.T) { t.Fatal("the menu should close after a pick") } // Still in insert mode, and the pick undoes together with the typing. - if buf.ed.Mode() != vim.ModeInsert { + if buf.ed.Mode() != helix.ModeInsert { t.Fatalf("mode after pick: %v", buf.ed.Mode()) } - feed(a, vim.KeyEsc, vim.R('u')) + feed(a, helix.KeyEsc, helix.R('u')) if buf.ed.LineCount() != 3 { t.Fatalf("undo left %q", buf.ed.Text()) } @@ -121,9 +121,9 @@ func TestTagMenuMatchesTheDesktopsRules(t *testing.T) { a, buf := newTagTestApp(t, vimPrefs(), "---\ntags: [x]\n---\n# Draft #\n\n```\n#\n```\nurl.com/#\n`#`\n\n") at := func(line int, typed string) string { t.Helper() - feed(a, vim.KeyEsc) - buf.ed.SetCursor(vim.Pos{Line: line, Col: 0}) - feed(a, vim.R('A')) + feed(a, helix.KeyEsc) + buf.ed.SetCursor(helix.Pos{Line: line, Col: 0}) + feed(a, helix.R('A')) typeKeys(a, typed) return menuTags(a) } @@ -132,9 +132,9 @@ func TestTagMenuMatchesTheDesktopsRules(t *testing.T) { t.Fatalf("%s offered %q", name, got) } } - feed(a, vim.KeyEsc) - buf.ed.SetCursor(vim.Pos{Line: 9, Col: 1}) - feed(a, vim.R('a')) + feed(a, helix.KeyEsc) + buf.ed.SetCursor(helix.Pos{Line: 9, Col: 1}) + feed(a, helix.R('a')) typeKeys(a, "pro") if got := menuTags(a); got != "" { t.Fatalf("inline code offered %q", got) @@ -155,32 +155,32 @@ func TestTagMenuMatchesTheDesktopsRules(t *testing.T) { func TestTagMenuKeysAndDismissal(t *testing.T) { a, buf := newTagTestApp(t, vimPrefs(), "\n") - feed(a, vim.R('i')) + feed(a, helix.R('i')) typeKeys(a, "#pro") // Ctrl-E closes the menu and keeps it closed while the token grows. - feed(a, vim.Key{Rune: 'e', Ctrl: true}) + feed(a, helix.Key{Rune: 'e', Ctrl: true}) typeKeys(a, "j") if a.liveTagMenu() != nil || buf.ed.Line(0) != "#proj" { t.Fatalf("after Ctrl-E: menu=%v line=%q", a.liveTagMenu() != nil, buf.ed.Line(0)) } // A new token opens it again; Tab accepts; Ctrl-P wraps to the end. typeKeys(a, " #pro") - feed(a, vim.Key{Rune: 'p', Ctrl: true}, vim.KeyTab) + feed(a, helix.Key{Rune: 'p', Ctrl: true}, helix.KeyTab) if got := buf.ed.Line(0); got != "#proj #archived-project" { t.Fatalf("Ctrl-P then Tab: %q", got) } // Esc leaves insert mode and takes the menu with it, as Vim's does. typeKeys(a, " #pro") - feed(a, vim.KeyEsc) - if a.liveTagMenu() != nil || buf.ed.Mode() != vim.ModeNormal { + feed(a, helix.KeyEsc) + if a.liveTagMenu() != nil || buf.ed.Mode() != helix.ModeNormal { t.Fatalf("after Esc: menu=%v mode=%v", a.liveTagMenu() != nil, buf.ed.Mode()) } } func TestCtrlXCtrlOOpensTheMenuOnDemand(t *testing.T) { a, buf := newTagTestApp(t, vimPrefs(), "\n") - ctrl := func(r rune) vim.Key { return vim.Key{Rune: r, Ctrl: true} } - feed(a, vim.R('i')) + ctrl := func(r rune) helix.Key { return helix.Key{Rune: r, Ctrl: true} } + feed(a, helix.R('i')) // Nothing typed: every tag, most used first. feed(a, ctrl('x'), ctrl('o')) if got := menuTags(a); got != "project,archived-project,Idea,prose,work/deep" { @@ -206,28 +206,28 @@ func TestCtrlXCtrlOOpensTheMenuOnDemand(t *testing.T) { func TestFrontmatterTagsCompleteWithoutTheHash(t *testing.T) { a, buf := newTagTestApp(t, vimPrefs(), "---\ntags: [idea, ]\nrelated:\n - x\ntags:\n - \n---\n") - buf.ed.SetCursor(vim.Pos{Line: 1, Col: 12}) - feed(a, vim.R('a')) + buf.ed.SetCursor(helix.Pos{Line: 1, Col: 12}) + feed(a, helix.R('a')) typeKeys(a, "pro") if got := menuTags(a); got != "project,prose,archived-project" { t.Fatalf("inline list: %q", got) } - feed(a, vim.Key{Name: "enter"}) + feed(a, helix.Key{Name: "enter"}) if got := buf.ed.Line(1); got != "tags: [idea, project]" { t.Fatalf("inline list accepted: %q", got) } - feed(a, vim.KeyEsc) - buf.ed.SetCursor(vim.Pos{Line: 5, Col: 3}) - feed(a, vim.R('a')) + feed(a, helix.KeyEsc) + buf.ed.SetCursor(helix.Pos{Line: 5, Col: 3}) + feed(a, helix.R('a')) typeKeys(a, "wo") - feed(a, vim.KeyTab) + feed(a, helix.KeyTab) if got := buf.ed.Line(5); got != " - work/deep" { t.Fatalf("block list accepted: %q", got) } // A list under another key is not a tag list. - feed(a, vim.KeyEsc) - buf.ed.SetCursor(vim.Pos{Line: 3, Col: 4}) - feed(a, vim.R('a')) + feed(a, helix.KeyEsc) + buf.ed.SetCursor(helix.Pos{Line: 3, Col: 4}) + feed(a, helix.R('a')) typeKeys(a, "pro") if got := menuTags(a); got != "" { t.Fatalf("list under related: offered %q", got) @@ -236,14 +236,14 @@ func TestFrontmatterTagsCompleteWithoutTheHash(t *testing.T) { func TestTagMenuWorksWithoutVimMode(t *testing.T) { prefs := vimPrefs() - prefs.VimMode = false + prefs.HelixMode = false a, buf := newTagTestApp(t, prefs, "\n") typeKeys(a, "#wor") if got := menuTags(a); got != "work/deep" { t.Fatalf("plain editing: %q", got) } // Esc only closes the menu here: there is no mode to leave. - feed(a, vim.KeyEsc) + feed(a, helix.KeyEsc) if a.liveTagMenu() != nil { t.Fatal("Esc should close the menu") } @@ -252,7 +252,7 @@ func TestTagMenuWorksWithoutVimMode(t *testing.T) { t.Fatal("a dismissed token stays quiet") } typeKeys(a, " #dee") - feed(a, vim.Key{Name: "enter"}) + feed(a, helix.Key{Name: "enter"}) if got := buf.ed.Line(0); got != "#work #work/deep" { t.Fatalf("accepted: %q", got) } diff --git a/internal/tui/text_reader.go b/internal/tui/text_reader.go index df93fba..c0e5ed0 100644 --- a/internal/tui/text_reader.go +++ b/internal/tui/text_reader.go @@ -1,7 +1,7 @@ package tui import ( - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "strings" ) @@ -11,7 +11,7 @@ type textReader struct { scroll, rows, total int } -func (r *textReader) handleKey(a *App, k vim.Key) { +func (r *textReader) handleKey(a *App, k helix.Key) { switch { case k.Is("esc"), k.IsCtrl('c'), k.IsRune('q'): a.overlay = nil diff --git a/internal/tui/theme_render_test.go b/internal/tui/theme_render_test.go index 0e4133e..4346346 100644 --- a/internal/tui/theme_render_test.go +++ b/internal/tui/theme_render_test.go @@ -22,7 +22,7 @@ func TestViewPaintsEveryCellIndependentlyOfTerminalColors(t *testing.T) { t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) for _, mode := range []string{"light", "dark"} { t.Run(mode, func(t *testing.T) { - a := newApp(context.Background(), Options{}, config.Prefs{ThemeMode: mode, VimMode: true}, true) + a := newApp(context.Background(), Options{}, config.Prefs{ThemeMode: mode, HelixMode: true}, true) a.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) for y, row := range strings.Split(a.View(), "\n") { // Bubble Tea can redraw any row on its own after a resize or edit. diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 2d83034..3e6736a 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -10,13 +10,13 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/ZenNotes/tui/internal/config" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/themes" - "github.com/ZenNotes/tui/internal/vim" ) func themedApp(t *testing.T, prefs config.Prefs) *App { t.Helper() - prefs.VimMode = true + prefs.HelixMode = true return newApp(context.Background(), Options{}, prefs, true) } @@ -25,7 +25,7 @@ func runEx(t *testing.T, a *App, name, args string) error { for _, c := range a.commandTable() { for _, n := range c.names { if n == name { - return c.run(a, nil, vim.ExCommand{Name: name, Args: args}) + return c.run(a, nil, helix.ExCommand{Name: name, Args: args}) } } } @@ -119,11 +119,11 @@ func TestThemePickerPreviewsAndRestores(t *testing.T) { if !ok || p.currentID() != "dark-hard" { t.Fatalf("the picker should open on the current theme: %v", a.overlay) } - a.overlay.handleKey(a, vim.KeyDown) + a.overlay.handleKey(a, helix.KeyDown) if a.theme.ID != "dark-medium" { t.Fatalf("moving should preview: %s", a.theme.ID) } - a.overlay.handleKey(a, vim.KeyEsc) + a.overlay.handleKey(a, helix.KeyEsc) if a.theme.ID != "dark-hard" || a.overlay != nil { t.Fatalf("Esc should restore: %s", a.theme.ID) } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 762212e..0dc2f8b 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1,14 +1,16 @@ package tui import ( + "os" + "path/filepath" "strings" "testing" "time" "github.com/charmbracelet/lipgloss" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) func TestWrapLineBreaksAtSpaces(t *testing.T) { @@ -35,11 +37,11 @@ func TestBindingKeys(t *testing.T) { if len(keys) != 1 || !keys[0].Ctrl || keys[0].Rune != 'p' { t.Fatalf("Ctrl+Shift+P parsed as %+v", keys) } - complete, prefix := matchSequence([]vim.Key{vim.R('g')}, keys) + complete, prefix := matchSequence([]helix.Key{helix.R('g')}, keys) if complete || prefix { t.Fatal("g should not match Ctrl+P") } - complete, prefix = matchSequence([]vim.Key{vim.R('g')}, bindingKeys("g t")) + complete, prefix = matchSequence([]helix.Key{helix.R('g')}, bindingKeys("g t")) if complete || !prefix { t.Fatal("g should be a prefix of g t") } @@ -239,3 +241,58 @@ func TestHumanSize(t *testing.T) { t.Fatalf("%s %s %s", humanSize(512), humanSize(2048), humanSize(3<<20)) } } + +func TestGotoNextPreviousTab(t *testing.T) { + a, root := newFeatureTestApp(t) + for name, text := range map[string]string{"a.md": "A\n", "b.md": "B\n"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(text), 0o600); err != nil { + t.Fatal(err) + } + } + a.openNote("a.md", true) + a.openNote("b.md", true) + if got := a.activeBuffer().path; got != "b.md" { + t.Fatalf("expected b.md active, got %q", got) + } + a.handleKey(helix.R('g')) + a.handleKey(helix.R('n')) + if got := a.activeBuffer().path; got != "a.md" { + t.Fatalf("gn did not switch to the next tab, active %q", got) + } + a.handleKey(helix.R('g')) + a.handleKey(helix.R('p')) + if got := a.activeBuffer().path; got != "b.md" { + t.Fatalf("gp did not switch to the previous tab, active %q", got) + } + // gd must still reach the engine now that g sequences are parked. + a.handleKey(helix.R('g')) + a.handleKey(helix.R('g')) + if cur := a.activeBuffer().ed.Cursor(); cur.Line != 0 { + t.Fatalf("gg moved the cursor unexpectedly: %+v", cur) + } +} + +func TestGotoNextPreviousTabFromHome(t *testing.T) { + a, root := newFeatureTestApp(t) + for name, text := range map[string]string{"a.md": "A\n", "b.md": "B\n"} { + if err := os.WriteFile(filepath.Join(root, name), []byte(text), 0o600); err != nil { + t.Fatal(err) + } + } + a.openNote("a.md", true) + a.openNote("b.md", true) + a.openHome() + if got := a.activeTab().path; got != tabHome { + t.Fatalf("expected Home active, got %q", got) + } + a.handleKey(helix.R('g')) + a.handleKey(helix.R('n')) + if got := a.activeTab().path; got != "a.md" { + t.Fatalf("gn did not leave Home for the next tab, active %q", got) + } + a.handleKey(helix.R('g')) + a.handleKey(helix.R('p')) + if got := a.activeTab().path; got != tabHome { + t.Fatalf("gp did not go back to the previous tab, active %q", got) + } +} diff --git a/internal/tui/version.go b/internal/tui/version.go index dddc77c..e2aa6a5 100644 --- a/internal/tui/version.go +++ b/internal/tui/version.go @@ -20,8 +20,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/selfupdate" - "github.com/ZenNotes/tui/internal/vim" ) type versionView struct { @@ -37,7 +37,7 @@ type versionDetailsMsg struct { text string } -func (a *App) showVersion(cmd vim.ExCommand) error { +func (a *App) showVersion(cmd helix.ExCommand) error { if arg := strings.TrimSpace(cmd.Args); arg != "" && arg != "copy" { return fmt.Errorf("usage: :version [copy] or :version!") } @@ -80,7 +80,7 @@ func (v *versionView) copy(a *App) { a.notify("Copied version details") } -func (v *versionView) handleKey(a *App, k vim.Key) { +func (v *versionView) handleKey(a *App, k helix.Key) { if k.IsRune('c') || k.IsRune('y') { v.copy(a) return diff --git a/internal/tui/version_test.go b/internal/tui/version_test.go index 3a45292..f3f2a17 100644 --- a/internal/tui/version_test.go +++ b/internal/tui/version_test.go @@ -12,14 +12,14 @@ import ( "time" "github.com/ZenNotes/tui/internal/backend" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) func TestVersionOpensPersistentReportForBugReports(t *testing.T) { a, root := newFeatureTestApp(t) a.opts.Version = "0.6.0-test" suppressAsyncTestTimers(a) - if handled, err := a.runEx(nil, vim.ExCommand{Name: "version"}); err != nil || !handled { + if handled, err := a.runEx(nil, helix.ExCommand{Name: "version"}); err != nil || !handled { t.Fatalf("version command: %t %v", handled, err) } if a.overlay == nil { @@ -37,7 +37,7 @@ func TestVersionOpensPersistentReportForBugReports(t *testing.T) { if strings.Contains(report, root) { t.Fatal("copyable report includes the user's private vault path") } - a.overlay.handleKey(a, vim.KeyEsc) + a.overlay.handleKey(a, helix.KeyEsc) if a.overlay != nil { t.Fatal("Esc did not dismiss the report") } @@ -57,7 +57,7 @@ func TestVersionReportsConnectedServerWithoutCredentials(t *testing.T) { u.User = url.UserPassword("private-user", "password-secret") a.opts.Target = backend.Target{Kind: backend.KindRemote, BaseURL: u.String(), AuthToken: token} suppressAsyncTestTimers(a) - a.runEx(nil, vim.ExCommand{Name: "version"}) + a.runEx(nil, helix.ExCommand{Name: "version"}) if a.overlay == nil { t.Fatal(":version did not open a report") } @@ -76,7 +76,7 @@ func TestVersionReportsConnectedServerWithoutCredentials(t *testing.T) { } func TestVersionCopyVariantsAndKeyboardCopy(t *testing.T) { - for _, command := range []vim.ExCommand{{Name: "version", Args: "copy"}, {Name: "version", Bang: true}, {Name: "ve", Args: "copy"}} { + for _, command := range []helix.ExCommand{{Name: "version", Args: "copy"}, {Name: "version", Bang: true}, {Name: "ve", Args: "copy"}} { t.Run(command.Name+command.Args+map[bool]string{true: "!"}[command.Bang], func(t *testing.T) { a, _ := newFeatureTestApp(t) suppressAsyncTestTimers(a) @@ -101,7 +101,7 @@ func TestVersionCopyVariantsAndKeyboardCopy(t *testing.T) { if len(copied) != 1 || copied[0] != view.body || strings.Contains(copied[0], "\x1b") { t.Fatalf("copy did not use the completed plain-text report: %v", copied) } - view.handleKey(a, vim.R('c')) + view.handleKey(a, helix.R('c')) if len(copied) != 2 || copied[1] != copied[0] { t.Fatal("c did not copy the report") } @@ -112,11 +112,11 @@ func TestVersionCopyVariantsAndKeyboardCopy(t *testing.T) { func TestClosingVersionCancelsCollectionAndDoesNotStealFocusOrClipboard(t *testing.T) { a, _ := newFeatureTestApp(t) suppressAsyncTestTimers(a) - a.runEx(nil, vim.ExCommand{Name: "version", Args: "copy"}) + a.runEx(nil, helix.ExCommand{Name: "version", Args: "copy"}) view := a.overlay.(*versionView) view.writeClipboard = func(string) error { t.Error("closed report wrote to clipboard"); return nil } commands := a.pendingCmds - view.handleKey(a, vim.KeyEsc) + view.handleKey(a, helix.KeyEsc) next := &textReader{title: "Another operation"} a.overlay = next for _, cmd := range commands { @@ -129,17 +129,17 @@ func TestClosingVersionCancelsCollectionAndDoesNotStealFocusOrClipboard(t *testi func TestSettingsOffersVersionDetailsWithoutVim(t *testing.T) { a, _ := newFeatureTestApp(t) - a.prefs.VimMode = false + a.prefs.HelixMode = false suppressAsyncTestTimers(a) v := &settingsView{} - if !v.handleKey(a, vim.R('v')) { + if !v.handleKey(a, helix.R('v')) { t.Fatal("settings did not handle v") } report, ok := a.overlay.(*versionView) if !ok { t.Fatal("settings did not expose version details") } - report.handleKey(a, vim.KeyEsc) + report.handleKey(a, helix.KeyEsc) } func TestConnectedServerVersionTimeoutAndErrorBodiesAreSafe(t *testing.T) { diff --git a/internal/tui/views.go b/internal/tui/views.go index d76863f..7e43e6a 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -3,7 +3,7 @@ package tui import ( "strings" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // view is a built-in tab: Tasks, Tags, Trash and friends. @@ -12,7 +12,7 @@ type view interface { hint(a *App) string refresh(a *App) render(a *App, w, h int, focused bool) string - handleKey(a *App, k vim.Key) bool + handleKey(a *App, k helix.Key) bool } // refreshViews tells every open view the index changed. @@ -145,7 +145,7 @@ func (c *listCursor) ensureVisible(rows int) { // listNav applies the shared movement keys to a cursor. It returns true // when the key was a movement. -func (a *App) listNav(c *listCursor, k vim.Key, n, pageRows int) bool { +func (a *App) listNav(c *listCursor, k helix.Key, n, pageRows int) bool { if k.Is("down") { c.move(1, n) return true @@ -175,7 +175,12 @@ func (a *App) listNav(c *listCursor, k vim.Key, n, pageRows int) bool { if !a.listKeyAllowed(k) { return false } - id, pending := a.resolveAction(k, "nav.moveDown", "nav.moveUp", "nav.jumpTop", "nav.jumpBottom", "nav.halfPageDown", "nav.halfPageUp") + ids := []string{"nav.moveDown", "nav.moveUp", "nav.jumpTop", "nav.jumpBottom", "nav.halfPageDown", "nav.halfPageUp"} + if a.prefs.Motions == "helix" { + // gn/gp are helix-motion keys; the vim engine leaves them alone. + ids = append(ids, "helix.gotoTabPrevious", "helix.gotoTabNext") + } + id, pending := a.resolveAction(k, ids...) if pending { return true } @@ -194,6 +199,10 @@ func (a *App) listNav(c *listCursor, k vim.Key, n, pageRows int) bool { c.move(max(1, pageRows/2), n) case "nav.halfPageUp": c.move(-max(1, pageRows/2), n) + case "helix.gotoTabPrevious": + a.cycleTab(-1) + case "helix.gotoTabNext": + a.cycleTab(1) default: return false } diff --git a/internal/tui/views_database.go b/internal/tui/views_database.go index 2d662a3..db13a28 100644 --- a/internal/tui/views_database.go +++ b/internal/tui/views_database.go @@ -8,8 +8,8 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/ZenNotes/tui/internal/database" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // databaseView is a `.base` folder or loose `.csv` opened as a grid. The @@ -31,7 +31,7 @@ type databaseView struct { row, col int scroll, hscroll int pageRows int - navKeys []vim.Key + navKeys []helix.Key colSpans [][2]int viewSpans []viewSpan widths []int @@ -638,7 +638,7 @@ func (v *databaseView) renderRaw(a *App, w, h int) []string { // --- keys --- -func (v *databaseView) handleKey(a *App, k vim.Key) bool { +func (v *databaseView) handleKey(a *App, k helix.Key) bool { if v.doc == nil { return false } @@ -654,7 +654,7 @@ func (v *databaseView) handleKey(a *App, k vim.Key) bool { } // commonKey handles the keys every layout shares. -func (v *databaseView) commonKey(a *App, k vim.Key) bool { +func (v *databaseView) commonKey(a *App, k helix.Key) bool { switch { case k.IsRune('v'): v.nextView(a) @@ -686,7 +686,7 @@ func (v *databaseView) moveCol(delta int) { v.col = clampInt(v.col+delta, 0, len(v.fields)-1) } -func (v *databaseView) tableKey(a *App, k vim.Key) bool { +func (v *databaseView) tableKey(a *App, k helix.Key) bool { switch { case k.Is("enter"): v.editCell(a) @@ -759,7 +759,7 @@ func (v *databaseView) tableKey(a *App, k vim.Key) bool { case k.IsCtrl('u'): v.moveRow(-max(1, v.pageRows/2)) case k.IsRune('d') || k.IsRune('g') || k.IsRune('y'): - v.navKeys = []vim.Key{k} + v.navKeys = []helix.Key{k} case k.IsRune('i') || k.IsRune('c') || k.IsRune('e'): v.editCell(a) case k.IsRune('x'): @@ -782,7 +782,7 @@ func (v *databaseView) tableKey(a *App, k vim.Key) bool { return true } -func (v *databaseView) headerKey(a *App, k vim.Key) bool { +func (v *databaseView) headerKey(a *App, k helix.Key) bool { f := v.currentField() switch { case k.Is("esc") || k.Is("down"): @@ -833,7 +833,7 @@ func (v *databaseView) headerKey(a *App, k vim.Key) bool { case k.IsRune('a'): v.addFieldFlow(a) case k.IsRune('d'): - v.navKeys = []vim.Key{k} + v.navKeys = []helix.Key{k} case k.IsRune('s'): v.cycleSort(a, f) case k.IsRune('f'): @@ -850,7 +850,7 @@ func (v *databaseView) headerKey(a *App, k vim.Key) bool { return true } -func (v *databaseView) rawKey(a *App, k vim.Key) bool { +func (v *databaseView) rawKey(a *App, k helix.Key) bool { switch { case k.Is("esc") || k.IsRune('q') || k.IsRune('R'): v.raw = false diff --git a/internal/tui/views_database_board.go b/internal/tui/views_database_board.go index f444838..e945522 100644 --- a/internal/tui/views_database_board.go +++ b/internal/tui/views_database_board.go @@ -8,7 +8,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/ZenNotes/tui/internal/database" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // boardState is the kanban layout of a board view: one column per option @@ -263,7 +263,7 @@ func (v *databaseView) boardCursorPos() (int, int) { return 1, 1 } -func (v *databaseView) boardKey(a *App, k vim.Key) bool { +func (v *databaseView) boardKey(a *App, k helix.Key) bool { b := &v.board rows := v.boardColumnRows() switch { @@ -340,7 +340,7 @@ func (v *databaseView) boardKey(a *App, k vim.Key) bool { case k.IsRune('>'): v.moveBoardColumn(a, 1) case k.IsRune('d') || k.IsRune('g') || k.IsRune('y'): - v.navKeys = []vim.Key{k} + v.navKeys = []helix.Key{k} case k.IsRune('i') || k.IsRune('c') || k.IsRune('e'): v.editCell(a) case k.IsRune('x'): diff --git a/internal/tui/views_files.go b/internal/tui/views_files.go index 89245fe..8ff1e6e 100644 --- a/internal/tui/views_files.go +++ b/internal/tui/views_files.go @@ -4,8 +4,8 @@ import ( "context" "fmt" "github.com/ZenNotes/tui/internal/backend" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" tea "github.com/charmbracelet/bubbletea" "os" "path/filepath" @@ -122,7 +122,7 @@ func (v *filesView) render(a *App, w, h int, focused bool) string { } return fitBlock(strings.Join(lines, "\n"), w, h) } -func (v *filesView) handleKey(a *App, k vim.Key) bool { +func (v *filesView) handleKey(a *App, k helix.Key) bool { list := v.filtered() if a.listNav(&v.list, k, len(list), v.rows) { return true diff --git a/internal/tui/views_home.go b/internal/tui/views_home.go index ae44e6d..b5edc85 100644 --- a/internal/tui/views_home.go +++ b/internal/tui/views_home.go @@ -8,9 +8,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // The Home view is a dashboard: a header with the day and the vault's @@ -61,7 +61,11 @@ type homeHit struct { func (v *homeView) title() string { return "Home" } func (v *homeView) hintPairs(a *App) []hintPair { - return []hintPair{{"nav.moveDown|nav.moveUp", "move"}, {"key:Enter", "open"}, {"nav.toggleTask", "toggle task"}, {"key:h/l", "week day"}, {"global.newNoteHere", "new note"}, {"global.searchNotes", "search"}, {"key:r", "refresh"}} + pairs := []hintPair{{"nav.moveDown|nav.moveUp", "move"}, {"key:Enter", "open"}, {"nav.toggleTask", "toggle task"}, {"key:h/l", "week day"}, {"global.newNoteHere", "new note"}, {"global.searchNotes", "search"}, {"key:r", "refresh"}} + if a.prefs.Motions == "helix" { + pairs = append(pairs, hintPair{"helix.gotoTabNext|helix.gotoTabPrevious", "next/previous tab"}) + } + return pairs } func (v *homeView) refresh(a *App) { @@ -216,11 +220,11 @@ func (v *homeView) render(a *App, w, h int, focused bool) string { right := strings.Join(stats, th.Muted.Render(" · ")) gap := max(1, inner-lipgloss.Width(left)-lipgloss.Width(right)) addLine(left + strings.Repeat(" ", gap) + right) - actions := []string{a.keyOf("global.newNoteHere") + " new note", a.keyOf("global.searchNotes") + " search", a.keyOf("vim.leaderPrefix") + " d daily", a.keyOf("vim.leaderPrefix") + " q capture", a.keyOf("vim.leaderPrefix") + " x tasks"} + actions := []string{a.keyOf("global.newNoteHere") + " new note", a.keyOf("global.searchNotes") + " search", a.keyOf("helix.leaderPrefix") + " d daily", a.keyOf("helix.leaderPrefix") + " q capture", a.keyOf("helix.leaderPrefix") + " x tasks"} styled := []string{} for _, act := range actions { key, verb, _ := strings.Cut(act, " ") - if strings.HasPrefix(act, a.keyOf("vim.leaderPrefix")+" ") && a.keyOf("vim.leaderPrefix") != "" { + if strings.HasPrefix(act, a.keyOf("helix.leaderPrefix")+" ") && a.keyOf("helix.leaderPrefix") != "" { parts := strings.SplitN(act, " ", 3) key, verb = parts[0]+" "+parts[1], parts[2] } @@ -619,7 +623,7 @@ func (v *homeView) itemKey(it homeItem) string { // --- keys --- -func (v *homeView) handleKey(a *App, k vim.Key) bool { +func (v *homeView) handleKey(a *App, k helix.Key) bool { n := len(v.items) cur := listCursor{cursor: v.cursor} if a.listNav(&cur, k, n, max(1, v.rows/2)) { diff --git a/internal/tui/views_lists.go b/internal/tui/views_lists.go index 34d9316..d60412c 100644 --- a/internal/tui/views_lists.go +++ b/internal/tui/views_lists.go @@ -10,8 +10,8 @@ import ( "github.com/atotto/clipboard" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // --- note list views: Quick Notes, Archive, Trash --- @@ -134,7 +134,7 @@ func formatAge(unixMs int64) string { return time.UnixMilli(unixMs).Format("2006-01-02") } -func (v *noteListView) handleKey(a *App, k vim.Key) bool { +func (v *noteListView) handleKey(a *App, k helix.Key) bool { if isMarkKey(k) { if n := v.selected(); n != nil { a.markPath(n.Path) @@ -378,7 +378,7 @@ func joinColumns(a *App, blocks []string, h int) string { return joinHorizontal(parts...) } -func (v *tagsView) handleKey(a *App, k vim.Key) bool { +func (v *tagsView) handleKey(a *App, k helix.Key) bool { if k.Is("tab") && !k.Shift { if v.tagList.cursor < len(v.tags) { t := v.tags[v.tagList.cursor].tag diff --git a/internal/tui/views_misc.go b/internal/tui/views_misc.go index 329e25d..5e3d85f 100644 --- a/internal/tui/views_misc.go +++ b/internal/tui/views_misc.go @@ -3,7 +3,7 @@ package tui import ( "strings" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // --- Help --- @@ -54,7 +54,7 @@ func (v *helpView) render(a *App, w, h int, focused bool) string { return fitBlock(strings.Join(lines, "\n"), w, h) } -func (v *helpView) handleKey(a *App, k vim.Key) bool { +func (v *helpView) handleKey(a *App, k helix.Key) bool { n := len(v.lines) cur := listCursor{cursor: v.scroll, scroll: v.scroll} if a.listNav(&cur, k, max(1, n-v.rows+1), v.rows) { diff --git a/internal/tui/views_mouse.go b/internal/tui/views_mouse.go index c48a085..5492f07 100644 --- a/internal/tui/views_mouse.go +++ b/internal/tui/views_mouse.go @@ -5,7 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // Click handling for the built-in views. Coordinates are relative to the @@ -47,7 +47,7 @@ func (v *tasksView) click(a *App, p *pane, x, y int, m tea.MouseMsg, double bool v.selectDay(a, day) v.dayFocus = false if double { - v.calendarKey(a, vim.KeyEnter) + v.calendarKey(a, helix.KeyEnter) } return } @@ -112,7 +112,7 @@ func (v *tagsView) click(a *App, p *pane, x, y int, m tea.MouseMsg, double bool) } } if m.Button == tea.MouseButtonRight { - v.handleKey(a, vim.R('m')) + v.handleKey(a, helix.R('m')) return } v.refresh(a) diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index b5fd016..5b6d221 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -9,9 +9,9 @@ import ( "github.com/charmbracelet/lipgloss" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) // tasksView is the Tasks tab: list, board and calendar modes. @@ -766,7 +766,7 @@ func sameDay(a, b time.Time) bool { // --- keys --- -func (v *tasksView) handleKey(a *App, k vim.Key) bool { +func (v *tasksView) handleKey(a *App, k helix.Key) bool { if k.IsRune('S') { a.savedTaskFilters() return true @@ -859,7 +859,7 @@ func (v *tasksView) selectedTask() *vault.Task { return nil } -func (v *tasksView) listKey(a *App, k vim.Key) bool { +func (v *tasksView) listKey(a *App, k helix.Key) bool { n := len(v.rows) before := v.list.cursor if a.listNav(&v.list, k, n, v.pageRows) { @@ -935,7 +935,7 @@ func (v *tasksView) listKey(a *App, k vim.Key) bool { } // taskEditKey handles the per-task edit keys shared by every mode. -func (v *tasksView) taskEditKey(a *App, k vim.Key, t *vault.Task) bool { +func (v *tasksView) taskEditKey(a *App, k helix.Key, t *vault.Task) bool { if t == nil { return false } @@ -966,7 +966,7 @@ func (v *tasksView) taskEditKey(a *App, k vim.Key, t *vault.Task) bool { return true } -func (v *tasksView) boardKey(a *App, k vim.Key) bool { +func (v *tasksView) boardKey(a *App, k helix.Key) bool { if !a.listKeyAllowed(k) && !k.Is("enter") && !k.Is("left") && !k.Is("right") && !k.Is("up") && !k.Is("down") { return false } @@ -1078,7 +1078,7 @@ func (v *tasksView) moveCard(a *App, t vault.Task, target int) { v.col = target } -func (v *tasksView) calendarKey(a *App, k vim.Key) bool { +func (v *tasksView) calendarKey(a *App, k helix.Key) bool { t := v.selectedTask() if v.dayFocus { switch { diff --git a/internal/tui/views_templates.go b/internal/tui/views_templates.go index 9a7dabf..29a0a77 100644 --- a/internal/tui/views_templates.go +++ b/internal/tui/views_templates.go @@ -2,9 +2,9 @@ package tui import ( "fmt" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" "strings" ) @@ -64,7 +64,7 @@ func (v *templatesView) render(a *App, w, h int, focused bool) string { } return fitBlock(strings.Join(lines, "\n"), w, h) } -func (v *templatesView) handleKey(a *App, k vim.Key) bool { +func (v *templatesView) handleKey(a *App, k helix.Key) bool { if a.listNav(&v.list, k, len(v.templates), v.rows) { return true } @@ -222,7 +222,7 @@ func (a *App) openTemplateFile(file vault.CustomTemplateFile) { if buf := a.buffers[file.SourcePath]; buf == nil { t := templates.ParseCustom(file) buf = ¬eBuffer{path: file.SourcePath, meta: vault.NoteMeta{Path: file.SourcePath, Title: t.Name}, savedText: file.Raw} - buf.ed = vim.NewWithRegisters(file.Raw, a.editorOptions(), a.editorHooks(buf), &a.registers) + buf.ed = a.newModalEditor(file.Raw, a.editorHooks(buf), &a.registers) buf.ed.MarkSaved() a.buffers[file.SourcePath] = buf } diff --git a/internal/tui/workflow_parity_test.go b/internal/tui/workflow_parity_test.go index 2f3b501..5a56179 100644 --- a/internal/tui/workflow_parity_test.go +++ b/internal/tui/workflow_parity_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" + "github.com/ZenNotes/tui/internal/helix" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" ) func TestInitialRemoteFailureStillRetriesPolling(t *testing.T) { @@ -35,13 +35,13 @@ func TestMarkCommandFromEditorAndPaletteSelectsNote(t *testing.T) { a, _ := newFeatureTestApp(t) a.openNote("note.md", true) for _, r := range ":mark" { - a.handleKey(vim.R(r)) + a.handleKey(helix.R(r)) } - a.handleKey(vim.KeyEnter) + a.handleKey(helix.KeyEnter) if !a.markedNotes["note.md"] { t.Fatal(":mark was swallowed by Vim's line-mark command") } - if _, err := a.runEx(nil, vim.ExCommand{Name: "mark"}); err != nil { + if _, err := a.runEx(nil, helix.ExCommand{Name: "mark"}); err != nil { t.Fatal(err) } if a.markedNotes["note.md"] { @@ -151,22 +151,22 @@ func TestCommentsWorkflowUnicodeAndLongThread(t *testing.T) { t.Fatalf("Unicode anchor at line %d", threads[0].Line) } p.menu(a, threads[0]) - a.overlay.handleKey(a, vim.R('r')) + a.overlay.handleKey(a, helix.R('r')) a.overlay.(*prompt).onSubmit(a, strings.Repeat("Reply text\n", 80)+"Last reply line") threads, err = backend.ListCommentThreads(a.ctx, a.backend, "note.md", true) if err != nil || len(threads[0].Replies) != 1 { t.Fatal("reply missing", err) } p.menu(a, threads[0]) - a.overlay.handleKey(a, vim.R('v')) + a.overlay.handleKey(a, helix.R('v')) reader := a.overlay.(*textReader) reader.render(a, 80, 24) - reader.handleKey(a, vim.Key{Name: "end"}) + reader.handleKey(a, helix.Key{Name: "end"}) if !strings.Contains(reader.render(a, 80, 24), "Last reply line") { t.Fatal("long reply tail unreachable") } p.menu(a, threads[0]) - a.overlay.handleKey(a, vim.R('x')) + a.overlay.handleKey(a, helix.R('x')) active, err := backend.ListCommentThreads(a.ctx, a.backend, "note.md", false) if err != nil || len(active) != 0 { t.Fatal("thread not resolved", err) @@ -188,8 +188,8 @@ func TestCommentsWorkflowUnicodeAndLongThread(t *testing.T) { func TestSidebarRangeAddsWithoutTogglingEarlierMarks(t *testing.T) { a, _ := newFeatureTestApp(t) s := &sidebarState{rows: []sidebarRow{{kind: "note", path: "a.md"}, {kind: "note", path: "b.md"}, {kind: "note", path: "c.md"}}} - s.handleKey(a, vim.Key{Name: "down", Shift: true}) - s.handleKey(a, vim.Key{Name: "down", Shift: true}) + s.handleKey(a, helix.Key{Name: "down", Shift: true}) + s.handleKey(a, helix.Key{Name: "down", Shift: true}) for _, p := range []string{"a.md", "b.md", "c.md"} { if !a.markedNotes[p] { t.Fatalf("range dropped %s: %v", p, a.markedNotes) diff --git a/internal/tui/workspace_source_test.go b/internal/tui/workspace_source_test.go index 77c565e..7e3f895 100644 --- a/internal/tui/workspace_source_test.go +++ b/internal/tui/workspace_source_test.go @@ -11,7 +11,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) func TestDesktopLauncherDoesNotChangeTUIVaultSwitches(t *testing.T) { @@ -52,7 +52,7 @@ func TestDesktopLauncherDoesNotChangeTUIVaultSwitches(t *testing.T) { }) for _, cmd := range a.parityCommands() { if cmd.names[0] == tc.command { - if err := cmd.run(a, nil, vim.ExCommand{Args: "work"}); err != nil { + if err := cmd.run(a, nil, helix.ExCommand{Args: "work"}); err != nil { t.Fatal(err) } break @@ -102,7 +102,7 @@ func TestTUIServerSwitchHonorsExplicitAppWorkspaceSource(t *testing.T) { if action == "server command" { for _, cmd := range a.parityCommands() { if cmd.names[0] == "server" { - if err := cmd.run(a, nil, vim.ExCommand{Args: "home"}); err != nil { + if err := cmd.run(a, nil, helix.ExCommand{Args: "home"}); err != nil { t.Fatal(err) } break @@ -149,7 +149,7 @@ func TestNewServerURLStillPromptsForToken(t *testing.T) { a := newApp(context.Background(), Options{Backend: b, Target: target, WorkspaceSource: source}, config.Prefs{}, true) for _, cmd := range a.parityCommands() { if cmd.names[0] == "server" { - if err := cmd.run(a, nil, vim.ExCommand{Args: server.URL}); err != nil { + if err := cmd.run(a, nil, helix.ExCommand{Args: server.URL}); err != nil { t.Fatal(err) } break diff --git a/internal/vim/buffer.go b/internal/vim/buffer.go index 7af69ce..97a0240 100644 --- a/internal/vim/buffer.go +++ b/internal/vim/buffer.go @@ -3,20 +3,13 @@ package vim import ( "strings" "unicode" + + "github.com/ZenNotes/tui/internal/helix" ) // Pos is a position in the buffer: a zero-based line and a rune column. -type Pos struct { - Line, Col int -} - -// Less orders positions. -func (p Pos) Less(q Pos) bool { - if p.Line != q.Line { - return p.Line < q.Line - } - return p.Col < q.Col -} +// It is the helix engine's Pos so both engines share one coordinate type. +type Pos = helix.Pos func minPos(a, b Pos) Pos { if b.Less(a) { diff --git a/internal/vim/editor.go b/internal/vim/editor.go index ca9a6ee..29dc185 100644 --- a/internal/vim/editor.go +++ b/internal/vim/editor.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" "unicode" + + "github.com/ZenNotes/tui/internal/helix" ) // Mode is the editor's state. @@ -41,93 +43,36 @@ func (m Mode) Label() string { return "NORMAL" } -// Options tune the engine. Zero values are filled by New. -type Options struct { - WrappedLineMotions bool - TabSize int - ScrollOff int - IgnoreCase bool - SmartCase bool - WrapScan bool - InsertEscape string - AutoPairs bool - AutoPairQuotes bool - TextReplacements map[string]string - TextReplacementsEnabled bool - MarkdownLists bool - YankToClipboard bool - // VimEnabled false runs the buffer as a plain editor: always inserting, - // arrows and the usual editing keys only. - VimEnabled bool -} +// Options tune the engine. Zero values are filled by New. It is the helix +// engine's Options so the host can configure either engine the same way; +// the vim engine reads the fields it knows and ignores the rest. +type Options = helix.Options // DefaultOptions are the shipped defaults. func DefaultOptions() Options { - return Options{ - TabSize: 4, - WrapScan: true, - AutoPairs: true, - TextReplacements: map[string]string{"->": "→"}, - TextReplacementsEnabled: true, - MarkdownLists: true, - VimEnabled: true, - } + o := helix.DefaultOptions() + o.HelixEnabled = true // modal editing on; the motions select vim keys + return o } -// Register is a yank or delete. -type Register struct { - Text string - Linewise bool - Blockwise bool -} +// Register is a yank or delete. It is the helix engine's Register so hosts +// share register stores across engines. +type Register = helix.Register // RegisterStore holds yank, delete, and macro registers shared by editors in // one host session. Its zero value is ready to use. Like Editor, it is owned by // the host's event loop rather than accessed concurrently. -type RegisterStore struct { - values map[rune]Register -} +type RegisterStore = helix.RegisterStore // Range is a line range for an ex command, zero-based and inclusive. -type Range struct { - Start, End int - Given bool -} +type Range = helix.Range // ExCommand is a command the editor hands to its host. -type ExCommand struct { - Name string - Args string - Bang bool - Range Range - Raw string -} - -// Hooks are the host's side of the engine. Every field is optional. -type Hooks struct { - DisplayRowBounds func(Pos) (int, int) - // ExCommand runs a command the editor does not own; handled false means - // unknown command. - ExCommand func(cmd ExCommand) (handled bool, err error) - // ExComplete proposes completions for the ex line. It receives the text - // before the cursor and returns the part to keep plus the candidates - // that replace the rest, so a host can complete note titles with spaces - // as one argument. Nil candidates fall back to the built-in command - // names. - ExComplete func(text string) (keep string, candidates []string) - ReadClipboard func() (string, bool) - WriteClipboard func(text string) bool - // OnJump is told before a jump motion moves the cursor. - OnJump func(from Pos) - // FollowLink is `gd`, CopyLink is `gy`, OpenURL is `gx`. - FollowLink func() - CopyLink func() - OpenURL func() - // LineRows is how many screen rows a line takes when soft-wrapped. - LineRows func(line int) int - // Changed is called after every committed change. - Changed func() -} +type ExCommand = helix.ExCommand + +// Hooks are the host's side of the engine. Every field is optional. It is +// the helix engine's Hooks so hosts wire either engine the same way. +type Hooks = helix.Hooks type snapshot struct { lines [][]rune @@ -269,21 +214,19 @@ func NewWithRegisters(text string, opts Options, hooks Hooks, store *RegisterSto if store == nil { store = &RegisterStore{} } - if store.values == nil { - store.values = make(map[rune]Register) - } + store.Init() e := &Editor{ buf: NewBuffer(text), opts: opts, hooks: hooks, - registers: store.values, + registers: store.Map(), marks: map[rune]Pos{}, desiredCol: -1, searchDir: 1, rows: 24, folds: map[int]int{}, } - if !opts.VimEnabled { + if !opts.HelixEnabled { e.mode = ModeInsert } return e @@ -308,12 +251,12 @@ func (e *Editor) SetOptions(opts Options) { if opts.TabSize <= 0 { opts.TabSize = 4 } - wasEnabled := e.opts.VimEnabled + wasEnabled := e.opts.HelixEnabled e.opts = opts - if wasEnabled && !opts.VimEnabled { + if wasEnabled && !opts.HelixEnabled { e.leaveToInsertOnly() } - if !wasEnabled && opts.VimEnabled && e.mode == ModeInsert { + if !wasEnabled && opts.HelixEnabled && e.mode == ModeInsert { e.mode = ModeNormal e.cursor = e.buf.clampNormal(e.cursor) } @@ -528,7 +471,7 @@ func (e *Editor) HandleKey(k Key) bool { } switch e.mode { case ModeInsert, ModeReplace: - if !e.opts.VimEnabled { + if !e.opts.HelixEnabled { return e.plainKey(k) } e.insertKey(k) diff --git a/internal/vim/editor_test.go b/internal/vim/editor_test.go index 4cc5b4e..78be8a1 100644 --- a/internal/vim/editor_test.go +++ b/internal/vim/editor_test.go @@ -381,7 +381,7 @@ func TestRegistersAndMarks(t *testing.T) { func TestPlainMode(t *testing.T) { opts := DefaultOptions() - opts.VimEnabled = false + opts.HelixEnabled = false e := New("ab", opts, Hooks{}) if e.Mode() != ModeInsert { t.Fatal("plain mode starts inserting") diff --git a/internal/vim/keys.go b/internal/vim/keys.go index 9a44ce1..cd50973 100644 --- a/internal/vim/keys.go +++ b/internal/vim/keys.go @@ -1,201 +1,38 @@ -// Package vim is a Vim editing engine over a plain text buffer: modes, -// counts, registers, motions, operators, text objects, visual selections, -// undo, dot-repeat, macros, marks, search and substitute, an ex command -// line, and heading folds. It knows nothing about terminals; the terminal -// UI feeds it keys and draws what it exposes. package vim -import ( - "strings" - "unicode" -) +import "github.com/ZenNotes/tui/internal/helix" + +// The vim engine shares the terminal-facing key model with the helix engine +// so the TUI can drive either through one interface. These aliases keep the +// two packages signature-compatible. -// Key is one keystroke, normalized. Printable keys carry a Rune; special -// keys carry a Name (esc, enter, tab, backspace, delete, up, down, left, -// right, home, end, pgup, pgdn, insert). -type Key struct { - Rune rune - Name string - Ctrl bool - Alt bool - Shift bool -} +type Key = helix.Key -// R is a plain rune key. -func R(r rune) Key { return Key{Rune: r} } +func R(r rune) Key { return helix.R(r) } -// Ctrl is a control chord like Ctrl+d. -func Ctrl(r rune) Key { return Key{Rune: unicode.ToLower(r), Ctrl: true} } +func Ctrl(r rune) Key { return helix.Ctrl(r) } -// Alt is an alt chord. -func Alt(r rune) Key { return Key{Rune: r, Alt: true} } +func Alt(r rune) Key { return helix.Alt(r) } -// Special is a named key. -func Special(name string) Key { return Key{Name: name} } +func Special(name string) Key { return helix.Special(name) } var ( - KeyEsc = Special("esc") - KeyEnter = Special("enter") - KeyTab = Special("tab") - KeyShiftTab = Key{Name: "tab", Shift: true} - KeyBackspace = Special("backspace") - KeyDelete = Special("delete") - KeyUp = Special("up") - KeyDown = Special("down") - KeyLeft = Special("left") - KeyRight = Special("right") - KeyHome = Special("home") - KeyEnd = Special("end") - KeyPgUp = Special("pgup") - KeyPgDn = Special("pgdn") + KeyEsc = helix.KeyEsc + KeyEnter = helix.KeyEnter + KeyTab = helix.KeyTab + KeyShiftTab = helix.KeyShiftTab + KeyBackspace = helix.KeyBackspace + KeyDelete = helix.KeyDelete + KeyUp = helix.KeyUp + KeyDown = helix.KeyDown + KeyLeft = helix.KeyLeft + KeyRight = helix.KeyRight + KeyHome = helix.KeyHome + KeyEnd = helix.KeyEnd + KeyPgUp = helix.KeyPgUp + KeyPgDn = helix.KeyPgDn ) -// IsRune is true for the plain printable rune r. -func (k Key) IsRune(r rune) bool { - return k.Name == "" && !k.Ctrl && !k.Alt && k.Rune == r -} - -// IsCtrl is true for the control chord Ctrl+r. -func (k Key) IsCtrl(r rune) bool { - return k.Ctrl && !k.Alt && k.Rune == unicode.ToLower(r) -} - -// Is is true for a special key by name (shift-insensitive unless the -// name is "shift+tab"). -func (k Key) Is(name string) bool { - if name == "shift+tab" { - return k.Name == "tab" && k.Shift - } - return k.Name == name && !k.Ctrl && !k.Alt && !(name == "tab" && k.Shift) -} - -// Printable is true for a plain rune the user could type into text. -func (k Key) Printable() bool { - return k.Name == "" && !k.Ctrl && !k.Alt && k.Rune != 0 -} - -var specialNotation = map[string]string{ - "esc": "Esc", "enter": "CR", "tab": "Tab", "backspace": "BS", "delete": "Del", - "up": "Up", "down": "Down", "left": "Left", "right": "Right", "home": "Home", - "end": "End", "pgup": "PageUp", "pgdn": "PageDown", "insert": "Insert", -} - -// String renders the key in Vim's notation: `a`, `<Esc>`, `<C-d>`, `<CR>`. -func (k Key) String() string { - var b strings.Builder - if k.Name != "" { - b.WriteString("<") - if k.Ctrl { - b.WriteString("C-") - } - if k.Alt { - b.WriteString("A-") - } - if k.Shift { - b.WriteString("S-") - } - if n, ok := specialNotation[k.Name]; ok { - b.WriteString(n) - } else { - b.WriteString(k.Name) - } - b.WriteString(">") - return b.String() - } - if k.Ctrl || k.Alt { - b.WriteString("<") - if k.Ctrl { - b.WriteString("C-") - } - if k.Alt { - b.WriteString("A-") - } - if k.Rune == ' ' { - b.WriteString("Space") - } else { - b.WriteRune(k.Rune) - } - b.WriteString(">") - return b.String() - } - if k.Rune == ' ' { - return "<Space>" - } - if k.Rune == '<' { - return "<lt>" - } - return string(k.Rune) -} - -var notationSpecial = map[string]Key{ - "esc": KeyEsc, "cr": KeyEnter, "enter": KeyEnter, "return": KeyEnter, "tab": KeyTab, - "s-tab": KeyShiftTab, "bs": KeyBackspace, "backspace": KeyBackspace, "del": KeyDelete, - "delete": KeyDelete, "up": KeyUp, "down": KeyDown, "left": KeyLeft, "right": KeyRight, - "home": KeyHome, "end": KeyEnd, "pageup": KeyPgUp, "pagedown": KeyPgDn, "space": R(' '), - "lt": R('<'), "bar": R('|'), "bslash": R('\\'), "nl": KeyEnter, -} - -// ParseKeys turns Vim notation into keys: `dw<Esc>ihello<CR>`, `<C-d>`. -// An unterminated `<` is a literal less-than. -func ParseKeys(s string) []Key { - out := []Key{} - runes := []rune(s) - for i := 0; i < len(runes); i++ { - r := runes[i] - if r == '<' { - end := -1 - for j := i + 1; j < len(runes) && j < i+24; j++ { - if runes[j] == '>' { - end = j - break - } - } - if end > i+1 { - name := strings.ToLower(string(runes[i+1 : end])) - if k, ok := parseNotation(name); ok { - out = append(out, k) - i = end - continue - } - } - } - out = append(out, R(r)) - } - return out -} - -func parseNotation(name string) (Key, bool) { - if k, ok := notationSpecial[name]; ok { - return k, true - } - if strings.HasPrefix(name, "c-") && len(name) > 2 { - rest := name[2:] - if k, ok := notationSpecial[rest]; ok { - k.Ctrl = true - return k, true - } - if r := []rune(rest); len(r) == 1 { - return Ctrl(r[0]), true - } - } - if strings.HasPrefix(name, "a-") || strings.HasPrefix(name, "m-") { - rest := name[2:] - if k, ok := notationSpecial[rest]; ok { - k.Alt = true - return k, true - } - if r := []rune(rest); len(r) == 1 { - return Alt(r[0]), true - } - } - return Key{}, false -} +func ParseKeys(s string) []Key { return helix.ParseKeys(s) } -// KeysString renders a key sequence in notation. -func KeysString(keys []Key) string { - var b strings.Builder - for _, k := range keys { - b.WriteString(k.String()) - } - return b.String() -} +func KeysString(keys []Key) string { return helix.KeysString(keys) } diff --git a/internal/vim/visual.go b/internal/vim/visual.go index fd8033d..250b40b 100644 --- a/internal/vim/visual.go +++ b/internal/vim/visual.go @@ -551,3 +551,14 @@ func (e *Editor) finishBlockInsert() { } var _ = unicode.IsUpper + +// SelectionKind reports what the selection covers: "v", "V" or "^V". +func (e *Editor) SelectionKind() string { + switch e.mode { + case ModeVisualLine: + return "V" + case ModeVisualBlock: + return "^V" + } + return "v" +}