From b27d95c9e41633a1b4ca8c040a151a22cd47db06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= Date: Tue, 20 Oct 2026 15:23:39 +0000 Subject: [PATCH 01/19] Add helix bindings --- internal/config/prefs.go | 40 +- internal/config/prefs_validate.go | 1 + internal/config/prefs_values.go | 2 +- internal/{vim => helix}/buffer.go | 2 +- .../{vim => helix}/display_motion_test.go | 2 +- internal/{vim => helix}/editor.go | 651 ++++++++------- internal/{vim => helix}/editor_test.go | 367 ++++---- internal/{vim => helix}/ex.go | 67 +- internal/{vim => helix}/folds.go | 2 +- internal/{vim => helix}/insert.go | 41 +- internal/{vim => helix}/keys.go | 17 +- internal/helix/matchmode.go | 334 ++++++++ internal/{vim => helix}/motions.go | 323 ++++---- internal/{vim => helix}/operators.go | 8 +- internal/{vim => helix}/registers_test.go | 25 +- internal/{vim => helix}/search.go | 40 +- internal/{vim => helix}/textobjects.go | 2 +- internal/helix/visual.go | 781 ++++++++++++++++++ internal/keymaps/keymaps.go | 88 +- internal/tui/app.go | 20 +- internal/tui/authoring.go | 10 +- internal/tui/authoring_test.go | 14 +- internal/tui/buffers.go | 30 +- internal/tui/bulk_notes.go | 4 +- internal/tui/commands.go | 224 ++--- internal/tui/commands_parity.go | 58 +- internal/tui/comments_panel.go | 4 +- internal/tui/context_actions.go | 8 +- internal/tui/database_test.go | 44 +- internal/tui/editorview.go | 21 +- internal/tui/excomplete_test.go | 12 +- internal/tui/helpdata.go | 23 +- internal/tui/hints.go | 14 +- internal/tui/input.go | 91 +- internal/tui/io_async_test.go | 6 +- internal/tui/keys.go | 92 +-- internal/tui/leader.go | 42 +- internal/tui/leader_test.go | 12 +- internal/tui/mouse.go | 39 +- internal/tui/noteops.go | 14 +- internal/tui/overlays.go | 32 +- internal/tui/panels.go | 8 +- internal/tui/pick_overlay.go | 4 +- internal/tui/preview.go | 12 +- internal/tui/registers_test.go | 31 +- internal/tui/session.go | 4 +- internal/tui/session_parity_test.go | 8 +- internal/tui/settings_controls.go | 8 +- internal/tui/settings_live_test.go | 10 +- internal/tui/sidebar.go | 4 +- internal/tui/tagcomplete.go | 18 +- internal/tui/tagcomplete_test.go | 68 +- internal/tui/text_reader.go | 4 +- internal/tui/theme_render_test.go | 2 +- internal/tui/theme_select_test.go | 10 +- internal/tui/tui_test.go | 63 +- internal/tui/version.go | 6 +- internal/tui/version_test.go | 22 +- internal/tui/views.go | 13 +- internal/tui/views_database.go | 18 +- internal/tui/views_database_board.go | 6 +- internal/tui/views_files.go | 4 +- internal/tui/views_home.go | 10 +- internal/tui/views_lists.go | 6 +- internal/tui/views_misc.go | 4 +- internal/tui/views_mouse.go | 6 +- internal/tui/views_tasks.go | 12 +- internal/tui/views_templates.go | 6 +- internal/tui/workflow_parity_test.go | 20 +- internal/tui/workspace_source_test.go | 8 +- internal/vim/visual.go | 553 ------------- 71 files changed, 2643 insertions(+), 1912 deletions(-) rename internal/{vim => helix}/buffer.go (99%) rename internal/{vim => helix}/display_motion_test.go (98%) rename internal/{vim => helix}/editor.go (76%) rename internal/{vim => helix}/editor_test.go (56%) rename internal/{vim => helix}/ex.go (93%) rename internal/{vim => helix}/folds.go (99%) rename internal/{vim => helix}/insert.go (93%) rename internal/{vim => helix}/keys.go (91%) create mode 100644 internal/helix/matchmode.go rename internal/{vim => helix}/motions.go (79%) rename internal/{vim => helix}/operators.go (99%) rename internal/{vim => helix}/registers_test.go (87%) rename internal/{vim => helix}/search.go (91%) rename internal/{vim => helix}/textobjects.go (99%) create mode 100644 internal/helix/visual.go delete mode 100644 internal/vim/visual.go diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 3c3c8c2..aa72875 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -11,11 +11,11 @@ import ( // honor. Defaults match the desktop's PORTABLE_DEFAULTS; a missing file or // key keeps the default. type Prefs struct { - VimWrappedLineMotions string + HelixWrappedLineMotions string IgnoredKeys []string - VimMode bool - VimInsertEscape string - VimYankToClipboard bool + HelixMode bool + HelixInsertEscape string + HelixYankToClipboard bool WhichKeyHints bool WhichKeyHintMode string WhichKeyHintTimeoutMs int @@ -81,8 +81,8 @@ type Prefs struct { // DefaultPrefs are the shipped defaults. func DefaultPrefs() Prefs { return Prefs{ - VimMode: true, - VimWrappedLineMotions: "display", + HelixMode: true, + HelixWrappedLineMotions: "display", TabsEnabled: true, SavedTaskFilters: map[string]string{}, WhichKeyHints: true, @@ -151,14 +151,24 @@ 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) + // The editor section was [vim] before Helix: a key set in [helix] wins, + // otherwise the legacy table still applies. + modal := section("vim") + if helix := section("helix"); helix != nil { + if modal == nil { + modal = map[string]any{} + } + for k, v := range helix { + modal[k] = v + } + } + setBool(modal, "enabled", &p.HelixMode) + 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) @@ -285,7 +295,7 @@ func setInt(m map[string]any, key string, dst *int) { // no config.toml exists yet. Every key matches the desktop app's names. const DefaultConfigTOML = `# ZenNotes portable preferences. Shared by the desktop app and zn. -[vim] +[helix] enabled = true insert_escape = "jk" yank_to_clipboard = true diff --git a/internal/config/prefs_validate.go b/internal/config/prefs_validate.go index 3ab8ff9..5c06aaf 100644 --- a/internal/config/prefs_validate.go +++ b/internal/config/prefs_validate.go @@ -57,6 +57,7 @@ func validateKnownValues(doc map[string]any) error { } } enums := map[string]map[string][]string{ + "helix": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}}, "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"}}, diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index e33ea1c..8df9af6 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}, + "helix": {"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/vim/buffer.go b/internal/helix/buffer.go similarity index 99% rename from internal/vim/buffer.go rename to internal/helix/buffer.go index 7af69ce..ae204b6 100644 --- a/internal/vim/buffer.go +++ b/internal/helix/buffer.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "strings" diff --git a/internal/vim/display_motion_test.go b/internal/helix/display_motion_test.go similarity index 98% rename from internal/vim/display_motion_test.go rename to internal/helix/display_motion_test.go index 07b2bcd..dc7f1ac 100644 --- a/internal/vim/display_motion_test.go +++ b/internal/helix/display_motion_test.go @@ -1,4 +1,4 @@ -package vim +package helix import "testing" diff --git a/internal/vim/editor.go b/internal/helix/editor.go similarity index 76% rename from internal/vim/editor.go rename to internal/helix/editor.go index ca9a6ee..c38c985 100644 --- a/internal/vim/editor.go +++ b/internal/helix/editor.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "fmt" @@ -15,10 +15,7 @@ type Mode int const ( ModeNormal Mode = iota ModeInsert - ModeReplace - ModeVisual - ModeVisualLine - ModeVisualBlock + ModeSelect ModeCmdline ) @@ -27,20 +24,24 @@ func (m Mode) Label() string { switch m { case ModeInsert: return "INSERT" - case ModeReplace: - return "REPLACE" - case ModeVisual: - return "VISUAL" - case ModeVisualLine: - return "V-LINE" - case ModeVisualBlock: - return "V-BLOCK" + 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. type Options struct { WrappedLineMotions bool @@ -56,9 +57,9 @@ type Options struct { TextReplacementsEnabled bool MarkdownLists bool YankToClipboard bool - // VimEnabled false runs the buffer as a plain editor: always inserting, + // HelixEnabled false runs the buffer as a plain editor: always inserting, // arrows and the usual editing keys only. - VimEnabled bool + HelixEnabled bool } // DefaultOptions are the shipped defaults. @@ -70,7 +71,7 @@ func DefaultOptions() Options { TextReplacements: map[string]string{"->": "→"}, TextReplacementsEnabled: true, MarkdownLists: true, - VimEnabled: true, + HelixEnabled: true, } } @@ -162,17 +163,9 @@ type cmdlineState struct { // pendingOpSearch is an operator waiting on a `/` or `?` motion typed on // the command line, like `d/foo`. -type pendingOpSearch struct { - op string - count int - register rune - from Pos - keys []Key -} type insertState struct { start Pos - replaced []rune escPending bool ctrlO bool ctrlR bool @@ -198,10 +191,15 @@ type Editor struct { cursor Pos desiredCol int - visualAnchor Pos - lastVisual struct { + // 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 - mode Mode + kind selKind set bool } @@ -237,12 +235,24 @@ type Editor struct { 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 whose upper-bound character is not part of + // it: word-start motions select up to the next word's first character + // without including it (Helix keeps that char as the exclusive head). + selExcl bool + lastMotionArgs struct { + count int + hasCount bool + } cmd cmdlineState ins insertState histories map[rune]*[]string incremental *regexp.Regexp blockInsert *blockInsertState - opSearch *pendingOpSearch rows int scrollTop int @@ -283,7 +293,7 @@ func NewWithRegisters(text string, opts Options, hooks Hooks, store *RegisterSto rows: 24, folds: map[int]int{}, } - if !opts.VimEnabled { + if !opts.HelixEnabled { e.mode = ModeInsert } return e @@ -308,12 +318,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) } @@ -346,7 +356,7 @@ 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 == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock { + if e.mode == ModeNormal || e.mode == ModeSelect { return KeysString(e.pending) } return "" @@ -370,11 +380,15 @@ func (e *Editor) SetScrollTop(line int) { // SetCursor moves the cursor, clamped for the current mode. func (e *Editor) SetCursor(p Pos) { - if e.mode == ModeInsert || e.mode == ModeReplace { + 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) } @@ -387,7 +401,7 @@ func (e *Editor) SetText(text string) { e.cursor = e.buf.clampNormal(Pos{}) e.scrollTop = 0 e.folds = map[int]int{} - if e.mode == ModeInsert || e.mode == ModeReplace { + if e.mode == ModeInsert { e.cursor = e.buf.clampInsert(e.cursor) } } @@ -398,7 +412,7 @@ func (e *Editor) ReplaceText(text string) { e.beginChange() e.buf.SetText(text) e.cursor = e.buf.clampNormal(e.cursor) - if e.mode == ModeInsert || e.mode == ModeReplace { + if e.mode == ModeInsert { e.cursor = e.buf.clampInsert(e.cursor) } e.commitChange() @@ -527,8 +541,8 @@ func (e *Editor) HandleKey(k Key) bool { e.changeKeys = append(e.changeKeys, k) } switch e.mode { - case ModeInsert, ModeReplace: - if !e.opts.VimEnabled { + case ModeInsert: + if !e.opts.HelixEnabled { return e.plainKey(k) } e.insertKey(k) @@ -536,8 +550,8 @@ func (e *Editor) HandleKey(k Key) bool { case ModeCmdline: e.cmdlineKey(k) return true - case ModeVisual, ModeVisualLine, ModeVisualBlock: - e.visualKey(k) + case ModeSelect: + e.selectKey(k) return true default: e.normalKey(k) @@ -601,36 +615,11 @@ func validRegister(r rune) bool { (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') } -var operatorKeys = map[string]bool{"d": true, "c": true, "y": true, "<": true, ">": true, "=": true, - "gu": true, "gU": true, "g~": true, "gq": true, "gw": true, "g?": true} - -// readOperator reads an operator off the front of keys. -func readOperator(keys []Key) (op string, consumed int, status parseStatus) { - if len(keys) == 0 { - return "", 0, parseIncomplete - } - k := keys[0] - if !k.Printable() { - return "", 0, parseInvalid - } - switch k.Rune { - case 'd', 'c', 'y', '<', '>', '=': - return string(k.Rune), 1, parseComplete - case 'g': - if len(keys) < 2 { - return "", 0, parseIncomplete - } - if keys[1].Printable() { - switch keys[1].Rune { - case 'u', 'U', '~', 'q', 'w', '?': - return "g" + string(keys[1].Rune), 2, parseComplete - } - } - } - return "", 0, parseInvalid -} - +// 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 @@ -638,117 +627,15 @@ func (e *Editor) runNormal(keys []Key) parseStatus { versionBefore := e.version enteredInsert := false - // Operators. - if op, consumed, st := readOperator(rest); st == parseComplete { - afterOp := rest[consumed:] - _, count2, hasCount2, rest2, st2 := splitPrefix(afterOp) - if st2 == parseIncomplete && len(afterOp) > 0 && allDigits(afterOp) { - return parseIncomplete - } - if st2 == parseInvalid { - return parseInvalid - } - if len(afterOp) == 0 { - return parseIncomplete - } - total := effectiveCount(count, hasCount, count2, hasCount2) - hasTotal := hasCount || hasCount2 - opKeys := rest[:consumed] - // The doubled operator works on lines: dd, yy, >>, guu, gUgU ... - if doubled, n := isDoubledOperator(op, opKeys, rest2); doubled { - if n < 0 { - return parseIncomplete - } - e.beginChange() - enteredInsert = e.applyLinewiseOperator(op, total, register) - e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) - return parseComplete - } - // Text object or motion. - if len(rest2) == 0 { - return parseIncomplete - } - if rest2[0].IsRune('/') || rest2[0].IsRune('?') { - e.opSearch = &pendingOpSearch{op: op, count: total, register: register, from: e.cursor, keys: append([]Key(nil), keys...)} - e.openCmdline(rest2[0].Rune, 0, false) - return parseComplete - } - if obj, st3 := parseTextObject(rest2); st3 != parseInvalid { - if st3 == parseIncomplete { - return parseIncomplete - } - e.beginChange() - enteredInsert = e.applyOperatorToObject(op, obj, total, register) - e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) - return parseComplete - } - m, st3 := parseMotion(rest2) - if st3 != parseComplete { - return st3 - } - e.beginChange() - enteredInsert = e.applyOperatorToMotion(op, m, total, hasTotal, register) - e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) - return parseComplete - } - - // Simple commands. - st, entered := e.simpleCommand(rest, count, hasCount, register) + st, entered := e.normalCommand(rest, count, hasCount, register) if st != parseComplete { return st } - e.finishNormal(keys, versionBefore, entered, register, count, hasCount) + enteredInsert = entered + e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) return parseComplete } -func allDigits(keys []Key) bool { - for _, k := range keys { - if !k.Printable() || k.Rune < '0' || k.Rune > '9' { - return false - } - } - return len(keys) > 0 -} - -func effectiveCount(c1 int, has1 bool, c2 int, has2 bool) int { - switch { - case has1 && has2: - return c1 * c2 - case has1: - return c1 - case has2: - return c2 - } - return 1 -} - -// isDoubledOperator recognizes `dd`, `guu`, `gUgU`: n is -1 when more keys -// are needed. -func isDoubledOperator(op string, opKeys []Key, rest []Key) (bool, int) { - if len(rest) == 0 { - return false, 0 - } - if len(op) == 1 { - if rest[0].Printable() && string(rest[0].Rune) == op { - return true, 1 - } - return false, 0 - } - last := rune(op[1]) - if rest[0].Printable() && rest[0].Rune == last { - return true, 1 - } - if rest[0].Printable() && rest[0].Rune == 'g' { - if len(rest) < 2 { - return true, -1 - } - if rest[1].Printable() && rest[1].Rune == last { - return true, 2 - } - } - return false, 0 -} - // 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 { @@ -766,6 +653,13 @@ func (e *Editor) finishNormal(keys []Key, versionBefore int, enteredInsert bool, } } 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() } @@ -792,9 +686,14 @@ func stripCount(keys []Key) []Key { return append([]Key(nil), keys[i:]...) } -// --- simple commands --- +// --- normal mode commands --- -func (e *Editor) simpleCommand(rest []Key, count int, hasCount bool, register rune) (parseStatus, bool) { +// 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 } @@ -802,15 +701,16 @@ func (e *Editor) simpleCommand(rest []Key, count int, hasCount bool, register ru 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': - e.startInsert(e.cursor) + start, _ := e.selRange() + e.startInsert(e.buf.clampNormal(start)) return parseComplete, true case 'a': - p := e.cursor - if e.buf.LineLen(p.Line) > 0 { - p.Col++ - } - e.startInsert(p) + _, 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))} @@ -839,104 +739,118 @@ func (e *Editor) simpleCommand(rest []Key, count int, hasCount bool, register ru e.beginChange() e.openLine(true) return parseComplete, true - case 'R': - e.beginChange() - e.mode = ModeReplace - e.ins = insertState{start: e.cursor} - e.cursor = e.buf.clampInsert(e.cursor) - return parseComplete, true - case 's': - e.beginChange() - e.deleteChars(n, register) - e.startInsert(e.cursor) - return parseComplete, true - case 'S': - e.beginChange() - e.changeLines(n, register) - return parseComplete, true - case 'C': - e.beginChange() - e.deleteToEnd(n, register) - e.startInsert(Pos{e.cursor.Line, e.buf.LineLen(e.cursor.Line)}) - return parseComplete, true + // --- the selection --- + case 'v': + e.enterSelect(selChar) + return parseComplete, false + case 'V': + e.enterSelect(selLine) + return parseComplete, false case 'x': - e.beginChange() - e.deleteChars(n, register) + e.extendLineBelow() return parseComplete, false case 'X': - e.beginChange() - e.deleteCharsBack(n, register) + e.extendToLineBounds() return parseComplete, false - case 'D': + case ';': + e.collapseToCursor() + return parseComplete, false + case '%': + e.selectAll() + return parseComplete, false + // --- selection operations --- + case 'd': e.beginChange() - e.deleteToEnd(n, register) - e.cursor = e.buf.clampNormal(e.cursor) + e.runOperator("d", e.selectionSpan(), register, false) return parseComplete, false - case 'Y': - e.yankLines(e.cursor.Line, min(e.buf.LineCount()-1, e.cursor.Line+n-1), register) + 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.put(register, n, true, false) + e.putSelection(register, n, true) return parseComplete, false case 'P': e.beginChange() - e.put(register, n, false, false) + e.putSelection(register, n, false) return parseComplete, false - case 'J': + case 'R': e.beginChange() - e.joinLines(e.cursor.Line, max(2, n), true) + e.putOverSpan(e.selectionSpan(), register, n, false) return parseComplete, false - case 'u': - for i := 0; i < n; i++ { - e.Undo() - } - return parseComplete, false - case 'U': - e.Undo() + case '>': + e.beginChange() + e.indentSelection(1) return parseComplete, false - case '.': - e.repeatLastChange(count, hasCount) + case '<': + e.beginChange() + e.indentSelection(-1) return parseComplete, false case '~': e.beginChange() - e.toggleCaseChars(n) - return parseComplete, false - case 'v': - e.enterVisual(ModeVisual) + e.transformSelection("g~") return parseComplete, false - case 'V': - e.enterVisual(ModeVisualLine) + 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.replaceChars(rest[1], n) + e.replaceSelectionWith(rest[1].Rune) return parseComplete, false - case 'm': - if len(rest) < 2 { - return parseIncomplete, false - } - if rest[1].Printable() { - e.marks[rest[1].Rune] = e.cursor - } + case 'J': + e.beginChange() + e.joinSelection(n) return parseComplete, false - case 'q': + // --- 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 } - if len(rest) < 2 { - return parseIncomplete, false + reg := register + if reg == 0 { + reg = 'q' } - if rest[1].Printable() && validRegister(rest[1].Rune) { - e.macroReg = unicode.ToLower(rest[1].Rune) + 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 @@ -945,19 +859,38 @@ func (e *Editor) simpleCommand(rest []Key, count int, hasCount bool, register ru e.runMacro(rest[1].Rune, n) } return parseComplete, false - case ':': - e.openCmdline(':', count, hasCount) + // --- undo, redo, repeat --- + case 'u': + for i := 0; i < n; i++ { + e.Undo() + } return parseComplete, false - case '/': - e.openCmdline('/', count, hasCount) + case 'U': + for i := 0; i < n; i++ { + e.Redo() + } return parseComplete, false - case '?': - e.openCmdline('?', count, hasCount) + 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 @@ -968,10 +901,13 @@ func (e *Editor) simpleCommand(rest []Key, count int, hasCount bool, register ru e.hostEx(ExCommand{Name: "q", Bang: true, Raw: "q!"}) } return parseComplete, false + // --- minor modes --- case 'g': - return e.gCommand(rest, n, count, hasCount, register) + return e.gotoMode(rest, count, hasCount) case 'z': - return e.zCommand(rest, n) + return e.viewMode(rest, n) + case 'm': + return e.matchMode(rest) } } if k.IsCtrl('r') { @@ -990,7 +926,11 @@ func (e *Editor) simpleCommand(rest []Key, count int, hasCount bool, register ru return parseComplete, false } if k.IsCtrl('v') { - e.enterVisual(ModeVisualBlock) + e.enterSelect(selBlock) + return parseComplete, false + } + if k.IsAlt('.') { + e.repeatLastMotion(n) return parseComplete, false } if k.IsCtrl('d') { @@ -1031,21 +971,33 @@ func (e *Editor) simpleCommand(rest []Key, count int, hasCount bool, register ru } if k.Is("delete") { e.beginChange() - e.deleteChars(n, register) + e.runOperator("d", e.selectionSpan(), register, false) return parseComplete, false } - // Motions move the cursor. + // 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 } - e.moveByMotion(m, count, hasCount) + 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 } -func (e *Editor) highlightOffOnEsc() {} - -func (e *Editor) gCommand(rest []Key, n, count int, hasCount bool, register rune) (parseStatus, bool) { +// 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 } @@ -1054,30 +1006,37 @@ func (e *Editor) gCommand(rest []Key, n, count int, hasCount bool, register rune return parseInvalid, false } switch k.Rune { - case 'i': - p, ok := e.marks['^'] - if !ok { - p = e.cursor + 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) } - e.startInsert(p) - return parseComplete, true - case 'I': - e.startInsert(Pos{e.cursor.Line, 0}) - return parseComplete, true - case 'v': - e.reselectVisual() return parseComplete, false - case 'p': - e.beginChange() - e.put(register, n, true, true) + case 'e': + e.moveByMotion(motion{kind: "G"}, 1, false) return parseComplete, false - case 'P': - e.beginChange() - e.put(register, n, false, true) + case 'j', 'k': + e.moveByMotion(motion{kind: "g" + string(k.Rune)}, 1, false) return parseComplete, false - case 'J': - e.beginChange() - e.joinLines(e.cursor.Line, max(2, n), 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 { @@ -1094,28 +1053,27 @@ func (e *Editor) gCommand(rest []Key, n, count int, hasCount bool, register rune e.hooks.OpenURL() } return parseComplete, false + case 'v': + e.reselect() + return parseComplete, false case '&': e.beginChange() e.repeatSubstitute(true) return parseComplete, false - case 'n': - return parseComplete, false - } - m, st := parseMotion(rest) - if st != parseComplete { - return st, false } - e.moveByMotion(m, count, hasCount) - return parseComplete, false + return parseInvalid, false } -func (e *Editor) zCommand(rest []Key, n int) (parseStatus, bool) { +// 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('.'): + 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)) @@ -1130,7 +1088,11 @@ func (e *Editor) zCommand(rest []Key, n int) (parseStatus, bool) { if k.IsRune('-') { e.cursor.Col = firstNonBlank(e.buf.Line(e.cursor.Line)) } - case k.IsRune('c'): + 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() @@ -1140,18 +1102,18 @@ func (e *Editor) zCommand(rest []Key, n int) (parseStatus, bool) { e.foldAll() case k.IsRune('R'): e.unfoldAll() - case k.IsRune('j'): + case k.IsRune('J'): e.moveToFold(1, n) - case k.IsRune('k'): + case k.IsRune('K'): e.moveToFold(-1, n) - case k.IsRune('=') || k.IsRune('g') || k.IsRune('G'): - // Harper suggestions are the host's; nothing to do in the buffer. default: return parseInvalid, false } return parseComplete, false } +func (e *Editor) highlightOffOnEsc() {} + // --- registers --- func (e *Editor) readRegister(r rune) (Register, bool) { @@ -1275,7 +1237,7 @@ func (e *Editor) repeatLastChange(count int, hasCount bool) { for _, k := range append(prefix, keys...) { e.HandleKey(k) } - if e.mode == ModeInsert || e.mode == ModeReplace { + if e.mode == ModeInsert { e.HandleKey(KeyEsc) } e.replaying = false @@ -1321,8 +1283,72 @@ func (e *Editor) runMacro(r rune, n int) { // --- 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 stop at the next word's first character without + // selecting it; every other selector includes its target. + e.selExcl = m.kind == "w" || m.kind == "W" || m.kind == "b" || m.kind == "B" + 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 @@ -1344,6 +1370,33 @@ func (e *Editor) moveByMotion(m motion, count int, hasCount bool) { 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 @@ -1613,12 +1666,12 @@ func (e *Editor) WordUnderCursor() string { func (e *Editor) InsertAtCursor(text string) { e.beginChange() p := e.cursor - if e.mode != ModeInsert && e.mode != ModeReplace && e.buf.LineLen(p.Line) > 0 && p.Col < e.buf.LineLen(p.Line) { + 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.mode != ModeReplace { + if e.mode != ModeInsert { e.cursor = e.buf.clampNormal(e.cursor) e.commitChange() } @@ -1635,7 +1688,7 @@ func (e *Editor) ReplaceAtCursor(before, after int, text string) { 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.mode != ModeReplace { + if e.mode != ModeInsert { e.cursor = e.buf.clampNormal(e.cursor) e.commitChange() } @@ -1646,7 +1699,7 @@ 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.mode == ModeReplace { + if e.mode == ModeInsert { e.cursor = e.buf.clampInsert(e.cursor) } else { e.commitChange() @@ -1665,6 +1718,8 @@ func (e *Editor) ReplaceLines(from, to int, lines []string) { 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) diff --git a/internal/vim/editor_test.go b/internal/helix/editor_test.go similarity index 56% rename from internal/vim/editor_test.go rename to internal/helix/editor_test.go index 05571f0..1b7d1ae 100644 --- a/internal/vim/editor_test.go +++ b/internal/helix/editor_test.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "strings" @@ -63,26 +63,29 @@ func TestMotions(t *testing.T) { {"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}, - {"ge", text, Pos{0, 5}, "ge", text, Pos{0, 2}, 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}, - {";", text, Pos{0, 0}, "ft;", text, Pos{0, 8}, ModeNormal, nil}, - {",", text, Pos{0, 0}, "ft;,", text, Pos{0, 4}, 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}, - {"%", text, Pos{3, 4}, "%", text, Pos{3, 10}, ModeNormal, nil}, - {"% back", text, Pos{3, 10}, "%", text, Pos{3, 4}, ModeNormal, nil}, - {"% from before", text, Pos{3, 0}, "%", text, Pos{3, 10}, 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}, @@ -91,81 +94,81 @@ func TestMotions(t *testing.T) { {"]] 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}, - {"marks", text, Pos{0, 4}, "majj`a", text, Pos{0, 4}, ModeNormal, nil}, - {"mark line", text, Pos{0, 4}, "majj'a", text, Pos{0, 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 TestOperators(t *testing.T) { +func TestSelectionOperations(t *testing.T) { run(t, []keyCase{ - {"x", "abc", Pos{0, 1}, "x", "ac", Pos{0, 1}, ModeNormal, nil}, - {"3x", "abcdef", Pos{0, 1}, "3x", "aef", Pos{0, 1}, ModeNormal, nil}, - {"x at end clamps", "abc", Pos{0, 2}, "x", "ab", Pos{0, 1}, ModeNormal, nil}, - {"X", "abc", Pos{0, 2}, "X", "ac", Pos{0, 1}, ModeNormal, nil}, - {"dw", "one two three", Pos{0, 0}, "dw", "two three", Pos{0, 0}, ModeNormal, nil}, - {"dw last word", "one two", Pos{0, 4}, "dw", "one ", Pos{0, 3}, ModeNormal, nil}, - {"dw end of line keeps next line", "one two\nthree", Pos{0, 4}, "dw", "one \nthree", Pos{0, 3}, ModeNormal, nil}, - {"d2w", "one two three four", Pos{0, 0}, "d2w", "three four", Pos{0, 0}, ModeNormal, nil}, - {"2dw", "one two three four", Pos{0, 0}, "2dw", "three four", Pos{0, 0}, ModeNormal, nil}, - {"de", "one two", Pos{0, 0}, "de", " two", Pos{0, 0}, ModeNormal, nil}, - {"db", "one two", Pos{0, 4}, "db", "two", Pos{0, 0}, ModeNormal, nil}, - {"dd", "a\nb\nc", Pos{1, 0}, "dd", "a\nc", Pos{1, 0}, ModeNormal, nil}, - {"2dd", "a\nb\nc\nd", Pos{1, 0}, "2dd", "a\nd", Pos{1, 0}, ModeNormal, nil}, - {"dd last line", "a\nb", Pos{1, 0}, "dd", "a", Pos{0, 0}, ModeNormal, nil}, - {"dd only line", "abc", Pos{0, 1}, "dd", "", Pos{0, 0}, ModeNormal, nil}, - {"dj", "a\nb\nc", Pos{0, 0}, "dj", "c", Pos{0, 0}, ModeNormal, nil}, - {"dk", "a\nb\nc", Pos{2, 0}, "dk", "a", Pos{0, 0}, ModeNormal, nil}, - {"dG", "a\nb\nc", Pos{1, 0}, "dG", "a", Pos{0, 0}, ModeNormal, nil}, - {"dgg", "a\nb\nc", Pos{1, 0}, "dgg", "c", Pos{0, 0}, ModeNormal, nil}, - {"d$", "one two", Pos{0, 3}, "d$", "one", Pos{0, 2}, ModeNormal, nil}, - {"D", "one two", Pos{0, 3}, "D", "one", Pos{0, 2}, ModeNormal, nil}, - {"d0", "one two", Pos{0, 4}, "d0", "two", Pos{0, 0}, ModeNormal, nil}, - {"d^", " one two", Pos{0, 6}, "d^", " two", Pos{0, 2}, ModeNormal, nil}, - {"dfx", "abcxdef", Pos{0, 0}, "dfx", "def", Pos{0, 0}, ModeNormal, nil}, - {"dtx", "abcxdef", Pos{0, 0}, "dtx", "xdef", Pos{0, 0}, ModeNormal, nil}, - {"d}", "a\nb\n\nc", Pos{0, 0}, "d}", "\nc", Pos{0, 0}, ModeNormal, nil}, - {"d} mid line charwise", "one two\nb\n\nc", Pos{0, 4}, "d}", "one \n\nc", Pos{0, 3}, ModeNormal, nil}, - {"diw", "one two three", Pos{0, 5}, "diw", "one three", Pos{0, 4}, ModeNormal, nil}, - {"daw", "one two three", Pos{0, 5}, "daw", "one three", Pos{0, 4}, ModeNormal, nil}, - {"daw end", "one two", Pos{0, 5}, "daw", "one", Pos{0, 2}, ModeNormal, nil}, - {"ciw", "one two three", Pos{0, 5}, "ciwX", "one X three", Pos{0, 4}, ModeNormal, nil}, - {"di(", "f(a, b) c", Pos{0, 3}, "di(", "f() c", Pos{0, 2}, ModeNormal, nil}, - {"da(", "f(a, b) c", Pos{0, 3}, "da(", "f c", Pos{0, 1}, ModeNormal, nil}, - {"di( on bracket", "f(a, b) c", Pos{0, 1}, "di(", "f() c", Pos{0, 2}, ModeNormal, nil}, - {"di[ nested", "[a [b] c]", Pos{0, 4}, "di[", "[a [] c]", Pos{0, 4}, ModeNormal, nil}, - {"2di[", "[a [b] c]", Pos{0, 4}, "2di[", "[]", Pos{0, 1}, ModeNormal, nil}, - {"di{ multiline", "{\n a\n b\n}", Pos{1, 1}, "di{", "{\n}", Pos{1, 0}, ModeNormal, nil}, - {"di\"", `say "hello world" now`, Pos{0, 7}, `di"`, `say "" now`, Pos{0, 5}, ModeNormal, nil}, - {"da\"", `say "hello world" now`, Pos{0, 7}, `da"`, `say now`, Pos{0, 4}, ModeNormal, nil}, - {"ci\" before quotes", `say "hello" now`, Pos{0, 0}, `ci"x`, `say "x" now`, Pos{0, 5}, ModeNormal, nil}, - {"dit", "bold x", Pos{0, 4}, "dit", " x", Pos{0, 3}, ModeNormal, nil}, - {"dat", "bold x", Pos{0, 4}, "dat", " x", Pos{0, 0}, ModeNormal, nil}, - {"dip", "a\nb\n\nc", Pos{0, 0}, "dip", "\nc", Pos{0, 0}, ModeNormal, nil}, - {"dap", "a\nb\n\nc", Pos{0, 0}, "dap", "c", Pos{0, 0}, ModeNormal, nil}, - {"cc keeps indent", " abc", Pos{0, 3}, "ccx", " x", Pos{0, 2}, ModeNormal, nil}, - {"S", "abc\ndef", Pos{1, 1}, "Sq", "abc\nq", Pos{1, 0}, ModeNormal, nil}, - {"cw is ce", "one two", Pos{0, 0}, "cwX", "X two", Pos{0, 0}, ModeNormal, nil}, - {"cw on space", "one two", Pos{0, 3}, "cw-", "one-two", Pos{0, 3}, ModeNormal, nil}, - {"C", "one two", Pos{0, 3}, "C!", "one!", Pos{0, 3}, ModeNormal, nil}, - {"s", "abc", Pos{0, 1}, "sXY", "aXYc", Pos{0, 2}, ModeNormal, nil}, - {"r", "abc", Pos{0, 1}, "rx", "axc", Pos{0, 1}, ModeNormal, nil}, - {"3r", "abcd", Pos{0, 1}, "3rx", "axxx", Pos{0, 3}, ModeNormal, nil}, - {"r too many", "abc", Pos{0, 1}, "5rx", "abc", Pos{0, 1}, ModeNormal, nil}, - {"r enter", "ab cd", Pos{0, 2}, "r", "ab\ncd", Pos{1, 0}, ModeNormal, nil}, - {"~", "abc", Pos{0, 0}, "~", "Abc", Pos{0, 1}, ModeNormal, nil}, - {"3~", "abc", Pos{0, 0}, "3~", "ABC", Pos{0, 2}, ModeNormal, nil}, - {"gUw", "one two", Pos{0, 0}, "gUw", "ONE two", Pos{0, 0}, ModeNormal, nil}, - {"guu", "ONE TWO", Pos{0, 3}, "guu", "one two", Pos{0, 0}, ModeNormal, nil}, - {"g~~", "One", Pos{0, 0}, "g~~", "oNE", Pos{0, 0}, ModeNormal, nil}, - {"J", "a\n b\nc", Pos{0, 0}, "J", "a b\nc", Pos{0, 1}, ModeNormal, nil}, - {"3J", "a\nb\nc\nd", Pos{0, 0}, "3J", "a b c\nd", Pos{0, 3}, ModeNormal, nil}, - {"gJ", "a\n b", Pos{0, 0}, "gJ", "a b", Pos{0, 1}, ModeNormal, nil}, - {">>", "a\nb", Pos{0, 0}, ">>", " a\nb", Pos{0, 4}, ModeNormal, nil}, - {"2>>", "a\nb", Pos{0, 0}, "2>>", " a\n b", Pos{0, 4}, ModeNormal, nil}, - {"<<", " a", Pos{0, 4}, "<<", "a", Pos{0, 0}, ModeNormal, nil}, - {">j", "a\nb\nc", Pos{0, 0}, ">j", " a\n b\nc", Pos{0, 4}, ModeNormal, nil}, + // 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}, @@ -173,8 +176,23 @@ func TestOperators(t *testing.T) { {"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}, - {"gq joins paragraph", "one\ntwo\n\nthree", Pos{0, 0}, "gqip", "one two\n\nthree", Pos{0, 0}, ModeNormal, nil}, - {"gq keeps headings", "# H\none\ntwo", Pos{1, 0}, "gqG", "# H\none two", Pos{1, 0}, 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}, }) } @@ -202,11 +220,8 @@ func TestInsertMode(t *testing.T) { {"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 }}, - {"R replace", "abcd", Pos{0, 1}, "Rxy", "axyd", Pos{0, 2}, ModeNormal, nil}, - {"R replace past end", "ab", Pos{0, 1}, "Rxyz", "axyz", Pos{0, 3}, ModeNormal, nil}, - {"R backspace restores", "abcd", Pos{0, 1}, "Rxy", "axcd", Pos{0, 1}, ModeNormal, nil}, {"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}, "yiwA \"", "abc abc", Pos{0, 6}, 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}, @@ -217,89 +232,47 @@ func TestInsertMode(t *testing.T) { }) } -func TestYankPut(t *testing.T) { - run(t, []keyCase{ - {"yyp", "a\nb", Pos{0, 0}, "yyp", "a\na\nb", Pos{1, 0}, ModeNormal, nil}, - {"yyP", "a\nb", Pos{1, 0}, "yyP", "a\nb\nb", Pos{1, 0}, ModeNormal, nil}, - {"3yyP count", "a\nb\nc", Pos{0, 0}, "yy2P", "a\na\na\nb\nc", Pos{0, 0}, ModeNormal, nil}, - {"yw p", "one two", Pos{0, 0}, "ywP", "one one two", Pos{0, 3}, ModeNormal, nil}, - {"yiw p after", "one two", Pos{0, 0}, "yiwp", "oonene two", Pos{0, 3}, ModeNormal, nil}, - {"dd p", "a\nb\nc", Pos{0, 0}, "ddp", "b\na\nc", Pos{1, 0}, ModeNormal, nil}, - {"xp swap", "ab", Pos{0, 0}, "xp", "ba", Pos{0, 1}, ModeNormal, nil}, - {"named register", "one two", Pos{0, 0}, "\"ayiww\"aP", "one onetwo", Pos{0, 6}, ModeNormal, nil}, - {"append register", "one two", Pos{0, 0}, "\"ayiww\"Ayiw$\"ap", "one twoonetwo", Pos{0, 12}, ModeNormal, nil}, - {"yank 0 register survives delete", "one two", Pos{0, 0}, "yiwwdiw\"0P", "oneone ", Pos{0, 5}, ModeNormal, nil}, - {"blackhole", "one two", Pos{0, 0}, "yiww\"_dwP", "oneone ", Pos{0, 5}, ModeNormal, nil}, - {"gp cursor after", "a\nb", Pos{0, 0}, "yygp", "a\na\nb", Pos{2, 0}, ModeNormal, nil}, - {"Y is yy", "ab\ncd", Pos{0, 1}, "Yp", "ab\nab\ncd", Pos{1, 0}, ModeNormal, nil}, - {"p multi-line charwise", "ab\ncd", Pos{0, 0}, "vjy$p", "abab\nc\ncd", Pos{0, 2}, ModeNormal, nil}, - }) -} - -func TestVisual(t *testing.T) { +func TestSurround(t *testing.T) { run(t, []keyCase{ - {"vd", "abcdef", Pos{0, 1}, "vlld", "aef", Pos{0, 1}, ModeNormal, nil}, - {"vy", "abcdef", Pos{0, 1}, "vllyP", "abcdbcdef", Pos{0, 3}, ModeNormal, nil}, - {"Vd", "a\nb\nc", Pos{0, 0}, "Vjd", "c", Pos{0, 0}, ModeNormal, nil}, - {"vc", "one two", Pos{0, 0}, "vecX", "X two", Pos{0, 0}, ModeNormal, nil}, - {"viw", "one two three", Pos{0, 5}, "viwd", "one three", Pos{0, 4}, ModeNormal, nil}, - {"v o swap", "abcdef", Pos{0, 2}, "vlohd", "aef", Pos{0, 1}, ModeNormal, nil}, - {"v$", "abc\ndef", Pos{0, 1}, "v$d", "a\ndef", Pos{0, 0}, ModeNormal, nil}, - {"vU", "abc", Pos{0, 0}, "vlU", "ABc", Pos{0, 0}, ModeNormal, nil}, - {"vr", "abc", Pos{0, 0}, "vlrx", "xxc", Pos{0, 0}, ModeNormal, nil}, - {"vJ", "a\nb\nc", Pos{0, 0}, "VjJ", "a b\nc", Pos{0, 1}, ModeNormal, nil}, - {"v>", "a\nb", Pos{0, 0}, "Vj>", " a\n b", Pos{0, 4}, ModeNormal, nil}, - {"vp replaces", "one two", Pos{0, 0}, "yiwwviwp", "one one", Pos{0, 6}, ModeNormal, nil}, - {"gv", "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}, - {"v esc", "abc", Pos{0, 0}, "vl", "abc", Pos{0, 1}, ModeNormal, nil}, - {"V then v", "abc", Pos{0, 1}, "Vvd", "ac", Pos{0, 1}, ModeNormal, nil}, - {"visual ip", "a\nb\n\nc", Pos{0, 0}, "vipd", "\nc", Pos{0, 0}, ModeNormal, nil}, - {"visual toggle checkbox", "a\nb", Pos{0, 0}, "Vj", "a\nb", Pos{1, 0}, ModeVisualLine, nil}, + {"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}, "xu", "abc", Pos{0, 0}, ModeNormal, nil}, + {"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}, "xu", "bc", Pos{0, 0}, ModeNormal, nil}, - {"2u", "abcd", Pos{0, 0}, "xx2u", "abcd", Pos{0, 0}, ModeNormal, nil}, - {"dot x", "abcdef", Pos{0, 0}, "x.", "cdef", Pos{0, 0}, ModeNormal, nil}, - {"dot dw", "a b c d", Pos{0, 0}, "dw.", "c d", 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}, "x3.", "ef", Pos{0, 0}, ModeNormal, nil}, - {"dot ciw", "one two", Pos{0, 0}, "ciwXw.", "X X", Pos{0, 2}, 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}, - {"dot dd count", "a\nb\nc\nd\ne", Pos{0, 0}, "2dd.", "e", Pos{0, 0}, ModeNormal, nil}, - {"macro", "a\nb\nc", Pos{0, 0}, "qaA!jq@a", "a!\nb!\nc", Pos{2, 0}, ModeNormal, nil}, - {"macro count", "a\nb\nc\nd", Pos{0, 0}, "qaA!jq2@a", "a!\nb!\nc!\nd", Pos{3, 0}, ModeNormal, nil}, - {"macro @@", "a\nb\nc\nd", Pos{0, 0}, "qaA!jq@a@@", "a!\nb!\nc!\nd", Pos{3, 0}, ModeNormal, nil}, - {"U undo", "abc", Pos{0, 0}, "xU", "abc", Pos{0, 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, 6}, ModeNormal, nil}, - {"search wraps", "one two\nthree two", Pos{0, 0}, "/twonn", "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, 6}, ModeNormal, nil}, - {"star", "foo bar foo", Pos{0, 0}, "*", "foo bar foo", Pos{0, 8}, ModeNormal, nil}, - {"star word boundary", "foo foobar foo", Pos{0, 0}, "*", "foo foobar foo", Pos{0, 11}, ModeNormal, nil}, - {"hash", "foo bar foo", Pos{0, 8}, "#", "foo bar foo", Pos{0, 0}, ModeNormal, nil}, - {"d/", "one two three", Pos{0, 0}, "d/three", "three", Pos{0, 0}, ModeNormal, nil}, - {"c/", "one two three", Pos{0, 0}, "c/threeX ", "X three", Pos{0, 1}, ModeNormal, nil}, - {"y?", "one two three", Pos{0, 8}, "y?twoP", "one two two three", Pos{0, 7}, ModeNormal, nil}, - {"d/ dot", "a x b x c x", Pos{0, 0}, "d/x..", "x", Pos{0, 0}, ModeNormal, nil}, - {"search vim word boundary", "cat concat cat", Pos{0, 0}, "/\\", "cat concat cat", Pos{0, 11}, 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}, @@ -321,16 +294,26 @@ func TestSearchAndEx(t *testing.T) { {":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}, "yy:pu", "a\na\nb", Pos{1, 0}, ModeNormal, nil}, - {"ctrl-r on cmdline", "word", Pos{0, 0}, "yiw:s/\"/x/", "x", Pos{0, 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("jzc") + e.Feed("jzf") if end, ok := e.IsFoldStart(0); !ok || end != 2 { t.Fatalf("expected fold 0..2, got %d %v", end, ok) } @@ -361,24 +344,24 @@ func TestFolds(t *testing.T) { func TestRegistersAndMarks(t *testing.T) { e := New("hello world", DefaultOptions(), Hooks{}) - e.Feed("yiw") + e.Feed("miwy") if reg, ok := e.RegisterText('0'); !ok || reg.Text != "hello" { t.Fatalf("yank register = %+v", reg) } - e.Feed("dd") + 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("dw") - if reg, _ := e.RegisterText('-'); reg.Text != "hello " { + 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.VimEnabled = false + opts.HelixEnabled = false e := New("ab", opts, Hooks{}) if e.Mode() != ModeInsert { t.Fatal("plain mode starts inserting") @@ -446,6 +429,11 @@ func TestScrolling(t *testing.T) { 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) { @@ -468,6 +456,25 @@ func TestHostExCommand(t *testing.T) { } } +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 @@ -490,3 +497,53 @@ func TestReplaceAtCursorJoinsTheInsert(t *testing.T) { 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}, + {"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 backwards", "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()) + } +} diff --git a/internal/vim/ex.go b/internal/helix/ex.go similarity index 93% rename from internal/vim/ex.go rename to internal/helix/ex.go index 3b7c9d1..c1bfdaf 100644 --- a/internal/vim/ex.go +++ b/internal/helix/ex.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "fmt" @@ -24,8 +24,8 @@ func (e *Editor) OpenCmdline(kind rune, initial string) { } func (e *Editor) openCmdline(kind rune, count int, hasCount bool) { - if e.inVisual() { - e.exitVisual() + if e.inSelect() { + e.exitSelect() } e.cmd = cmdlineState{kind: kind, histIdx: -1, searchOrg: e.cursor} if kind == ':' && hasCount { @@ -59,9 +59,6 @@ func (e *Editor) history(kind rune) *[]string { func (e *Editor) cmdlineKey(k Key) { c := &e.cmd - if e.opSearch != nil { - e.opSearch.keys = append(e.opSearch.keys, k) - } switch { case k.Is("esc") || k.IsCtrl('c') || k.IsCtrl('['): e.cancelCmdline() @@ -256,7 +253,7 @@ func (e *Editor) incrementalSearch() { if e.cmd.kind == '?' { dir = -1 } - p, _, ok := e.findMatch(re, e.cmd.searchOrg, dir) + p, _, _, ok := e.findMatch(re, e.cmd.searchOrg, dir) if !ok { e.cursor = e.buf.clampNormal(e.cmd.searchOrg) e.incremental = nil @@ -280,7 +277,6 @@ func (e *Editor) cancelCmdline() { e.cursor = e.buf.clampNormal(e.cmd.searchOrg) } e.incremental = nil - e.opSearch = nil e.mode = ModeNormal e.cmd = cmdlineState{} e.ensureCursorVisible() @@ -308,11 +304,6 @@ func (e *Editor) submitCmdline() { if kind == '?' { dir = -1 } - if pending := e.opSearch; pending != nil { - e.opSearch = nil - e.runOperatorSearch(pending, text, dir) - break - } e.runSearch(text, dir, count) default: if text != "" { @@ -431,7 +422,7 @@ func (e *Editor) parseAddress(s string) (int, string, bool, error) { if dir > 0 { from = Pos{e.cursor.Line, e.buf.LineLen(e.cursor.Line)} } - p, _, ok := e.findMatch(re, from, dir) + p, _, _, ok := e.findMatch(re, from, dir) if !ok { return 0, s, false, fmt.Errorf("E486: Pattern not found: %s", pattern) } @@ -467,6 +458,12 @@ func (e *Editor) executeExLine(line string) { 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()) @@ -630,11 +627,11 @@ func (e *Editor) builtinEx(cmd ExCommand) bool { for _, k := range keys { e.HandleKey(k) } - if e.mode == ModeInsert || e.mode == ModeReplace { + if e.mode == ModeInsert { e.HandleKey(KeyEsc) } - if e.inVisual() { - e.exitVisual() + if e.inSelect() { + e.exitSelect() } if e.mode == ModeCmdline { e.cancelCmdline() @@ -966,39 +963,3 @@ func (e *Editor) ExecuteEx(line string) { e.cursor = e.buf.clampNormal(e.cursor) e.ensureCursorVisible() } - -// runOperatorSearch finishes `d/pat`: the search target is the motion. -func (e *Editor) runOperatorSearch(pending *pendingOpSearch, pattern string, dir 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.cursor = e.buf.clampNormal(pending.from) - target, ok := e.searchNext(pending.from, dir, max(1, pending.count), false) - if !ok { - return - } - versionBefore := e.version - e.beginChange() - entered := e.applyOperatorResult(pending.op, pending.from, motionResult{pos: target, jump: true}, pending.register, false) - if entered { - if !e.replaying { - e.recordingIns = true - e.changeKeys = append([]Key(nil), pending.keys...) - } - return - } - e.commitChange() - if e.version != versionBefore && !e.replaying { - e.last = lastChange{keys: stripCount(pending.keys), register: pending.register} - } -} diff --git a/internal/vim/folds.go b/internal/helix/folds.go similarity index 99% rename from internal/vim/folds.go rename to internal/helix/folds.go index 32d4caa..adca2c0 100644 --- a/internal/vim/folds.go +++ b/internal/helix/folds.go @@ -1,4 +1,4 @@ -package vim +package helix import "strings" diff --git a/internal/vim/insert.go b/internal/helix/insert.go similarity index 93% rename from internal/vim/insert.go rename to internal/helix/insert.go index 55ac99f..55a74a1 100644 --- a/internal/vim/insert.go +++ b/internal/helix/insert.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "strings" @@ -170,6 +170,8 @@ func (e *Editor) finishInsert() { 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() @@ -207,19 +209,6 @@ func (e *Editor) deleteBefore(n int) { } func (e *Editor) insertPrintable(r rune) { - if e.mode == ModeReplace { - line := cloneRunes(e.buf.Line(e.cursor.Line)) - if e.cursor.Col < len(line) { - e.ins.replaced = append(e.ins.replaced, line[e.cursor.Col]) - line[e.cursor.Col] = r - e.buf.ReplaceLine(e.cursor.Line, line) - } else { - e.ins.replaced = append(e.ins.replaced, -1) - e.buf.ReplaceLine(e.cursor.Line, append(line, r)) - } - e.cursor.Col++ - return - } line := e.buf.Line(e.cursor.Line) next := rune(0) if e.cursor.Col < len(line) { @@ -278,10 +267,6 @@ func (e *Editor) applyTextReplacement() { func (e *Editor) insertNewline() { line := e.buf.Line(e.cursor.Line) - if e.mode == ModeReplace { - e.insertRaw("\n") - return - } 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)) @@ -311,26 +296,6 @@ func (e *Editor) insertNewline() { } func (e *Editor) insertBackspace() { - if e.mode == ModeReplace { - if e.cursor.Col == 0 { - return - } - if n := len(e.ins.replaced); n > 0 { - orig := e.ins.replaced[n-1] - e.ins.replaced = e.ins.replaced[:n-1] - line := cloneRunes(e.buf.Line(e.cursor.Line)) - e.cursor.Col-- - if orig < 0 { - line = line[:e.cursor.Col] - } else if e.cursor.Col < len(line) { - line[e.cursor.Col] = orig - } - e.buf.ReplaceLine(e.cursor.Line, line) - return - } - e.cursor.Col-- - return - } if e.cursor.Col == 0 { if e.cursor.Line == 0 { return diff --git a/internal/vim/keys.go b/internal/helix/keys.go similarity index 91% rename from internal/vim/keys.go rename to internal/helix/keys.go index 9a44ce1..4443592 100644 --- a/internal/vim/keys.go +++ b/internal/helix/keys.go @@ -1,9 +1,9 @@ -// 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 +// 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" @@ -60,6 +60,11 @@ 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 { 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/vim/motions.go b/internal/helix/motions.go similarity index 79% rename from internal/vim/motions.go rename to internal/helix/motions.go index d443d05..ca5cac3 100644 --- a/internal/vim/motions.go +++ b/internal/helix/motions.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "math" @@ -21,13 +21,17 @@ type motionResult struct { 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 // 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, '`': true, '\'': true} +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) { @@ -56,16 +60,13 @@ func parseMotion(keys []Key) (motion, parseStatus) { } switch k.Rune { case 'h', 'l', 'j', 'k', '0', '^', '$', '|', 'w', 'W', 'b', 'B', 'e', 'E', 'G', 'H', 'M', 'L', - '{', '}', '(', ')', ';', ',', '%', 'n', 'N', '*', '#', '_', '-', '+': + '{', '}', '(', ')', 'n', 'N', '_', '-', '+': return motion{kind: string(k.Rune)}, parseComplete - case 'f', 'F', 't', 'T', '`', '\'': + case 'f', 'F', 't', 'T': if len(keys) < 2 { return motion{}, parseIncomplete } if !keys[1].Printable() { - if keys[1].Is("esc") { - return motion{}, parseInvalid - } return motion{}, parseInvalid } return motion{kind: string(k.Rune), arg: keys[1].Rune}, parseComplete @@ -77,7 +78,7 @@ func parseMotion(keys []Key) (motion, parseStatus) { return motion{}, parseInvalid } switch keys[1].Rune { - case 'g', 'e', 'E', 'j', 'k', '0', '^', '$', 'm', '_', '*', '#': + case 'g', 'j', 'k': return motion{kind: "g" + string(keys[1].Rune)}, parseComplete } return motion{}, parseInvalid @@ -130,7 +131,7 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp res.pos.Col = max(0, from.Col-n) case "l": limit := len(line) - 1 - if forOperator || e.inVisual() { + if forOperator || e.inSelect() { limit = len(line) } if from.Col >= limit && !(forOperator && from.Col < len(line)) { @@ -139,7 +140,7 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp } } res.pos.Col = min(from.Col+n, max(0, limit)) - if !forOperator && !e.inVisual() && res.pos.Col == from.Col { + if !forOperator && !e.inSelect() && res.pos.Col == from.Col { return res, false } case "j": @@ -344,71 +345,16 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp 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 ";", ",": - if !e.lastFind.set { - return res, false - } - kind := e.lastFind.kind - if m.kind == "," { - kind = map[rune]rune{'f': 'F', 'F': 'f', 't': 'T', 'T': 't'}[kind] - } - p, ok := e.findChar(from, byte(kind), e.lastFind.ch, n) - if !ok { - return res, false - } - res.pos = p - res.inclusive = kind == 'f' || kind == 't' - case "%": - p, ok := e.matchPair(from) - if !ok { - return res, false - } - res.pos = p - res.inclusive, res.jump = true, true case "n", "N": dir := e.searchDir if m.kind == "N" { dir = -dir } - p, ok := e.searchNext(from, dir, n, true) + p, end, ok := e.searchNextRange(from, dir, n, true) if !ok { return res, false } - res.pos, res.jump = p, true - case "*", "#", "g*", "g#": - word := e.WordUnderCursor() - if word == "" { - e.setError("E348: No string under cursor") - return res, false - } - pattern := regexp.QuoteMeta(word) - if !strings.HasPrefix(m.kind, "g") { - pattern = `\b` + pattern + `\b` - } - e.lastSearch = pattern - e.highlight = true - dir := 1 - if strings.HasSuffix(m.kind, "#") { - dir = -1 - } - e.searchDir = dir - p, ok := e.searchNext(from, dir, n, true) - if !ok { - return res, false - } - res.pos, res.jump = p, true - case "`", "'": - p, ok := e.markPos(m.arg) - if !ok { - e.setError("E20: Mark not set") - return res, false - } - res.pos = e.buf.clampNormal(p) - if m.kind == "'" { - res.pos.Col = firstNonBlank(e.buf.Line(res.pos.Line)) - res.linewise = true - } - res.jump = true + res.pos, res.matchEnd, res.matched, res.jump = p, end, true, true case "]]", "[[": dir := 1 if m.kind == "[[" { @@ -441,8 +387,8 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp return res, true } -func (e *Editor) inVisual() bool { - return e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock +func (e *Editor) inSelect() bool { + return e.mode == ModeSelect } func (e *Editor) colForDesired(line int) int { @@ -451,7 +397,7 @@ func (e *Editor) colForDesired(line int) int { if want < 0 { want = e.cursor.Col } - if e.mode == ModeInsert || e.mode == ModeReplace { + if e.mode == ModeInsert { return min(want, len(l)) } if want == math.MaxInt32 { @@ -755,105 +701,99 @@ func (e *Editor) headingMove(from Pos, dir, n int) Pos { // --- 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) { - line := e.buf.Line(from.Line) - col := from.Col - switch kind { - case 'f', 't': - for i := 0; i < n; i++ { - start := col + 1 - if kind == 't' && i == 0 && start < len(line) && line[start] == ch && n == 1 { - // `t` on an adjacent char still needs to find the NEXT one - // when repeated with ; in Vim; plain t stays put. Vim's cpo - // default: t does not move. Match that by searching from - // col+2 only for repeats. - } - found := -1 - for c := start; c < len(line); c++ { - if line[c] == ch { - found = c - break - } - } - if found < 0 { - return from, false - } - col = found + p := from + for i := 0; i < n; i++ { + next, ok := e.findCharStep(p, kind, ch, i == 0) + if !ok { + return from, false } - if kind == 't' { - col-- - if col <= from.Col && n == 1 { - if col < from.Col { - 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 } } - } - return Pos{from.Line, col}, true - default: - for i := 0; i < n; i++ { - found := -1 - for c := col - 1; c >= 0; c-- { + } else { + for c := min(p.Col, len(line)-1); c >= 0; c-- { if line[c] == ch { - found = c - break + if kind == 'T' { + return Pos{p.Line, c + 1}, true + } + return Pos{p.Line, c}, true } } - if found < 0 { - return from, false - } - col = found } - if kind == 'T' { - col++ + 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{from.Line, col}, true } + return Pos{}, false } -// --- brackets --- - -var pairs = map[rune]rune{'(': ')', '[': ']', '{': '}', ')': '(', ']': '[', '}': '{'} - -func (e *Editor) matchPair(from Pos) (Pos, bool) { - line := e.buf.Line(from.Line) - col := from.Col - for col < len(line) { - if _, ok := pairs[line[col]]; ok { - break - } - col++ +// 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 } - if col >= len(line) { - return from, false + kind := e.lastFind.kind + if dir < 0 { + kind = map[rune]rune{'f': 'F', 'F': 'f', 't': 'T', 'T': 't'}[kind] } - open := line[col] - close := pairs[open] - dir := 1 - if open == ')' || open == ']' || open == '}' { - dir = -1 - } - depth := 0 - p := Pos{from.Line, col} - for { - r, ok := e.runeAt(p) - if ok { - if r == open { - depth++ - } else if r == close { - depth-- - if depth == 0 { - return p, true - } - } - } - next, ok := e.step(p, dir) - if !ok { - return from, false - } - p = next + 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) { @@ -939,3 +879,82 @@ func (e *Editor) markPos(r rune) (Pos, bool) { } 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.selRange() + at := e.stepInsertPos(to) + if e.selExcl { + at = to + } + word := e.buf.Range(from, at) + 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/vim/operators.go b/internal/helix/operators.go similarity index 99% rename from internal/vim/operators.go rename to internal/helix/operators.go index 64169e0..a2d7575 100644 --- a/internal/vim/operators.go +++ b/internal/helix/operators.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "regexp" @@ -738,7 +738,7 @@ var bulletOnlyRe = regexp.MustCompile(`^(\s*(?:>\s*)*(?:[-+*]|\d+[.)])\s+)(.*)$` func (e *Editor) ToggleCheckboxAtCursor() { e.beginChange() lines := []int{e.cursor.Line} - if e.inVisual() { + if e.inSelect() { start, end, _, _ := e.VisualRange() lines = nil for l := start.Line; l <= end.Line; l++ { @@ -766,8 +766,8 @@ func (e *Editor) ToggleCheckboxAtCursor() { e.buf.ReplaceLine(l, []rune(string(indent)+"- [ ] "+strings.TrimLeft(s, " \t"))) } } - if e.inVisual() { - e.exitVisual() + if e.inSelect() { + e.exitSelect() } if e.mode == ModeInsert { e.cursor = e.buf.clampInsert(e.cursor) diff --git a/internal/vim/registers_test.go b/internal/helix/registers_test.go similarity index 87% rename from internal/vim/registers_test.go rename to internal/helix/registers_test.go index 4a100bf..38638e9 100644 --- a/internal/vim/registers_test.go +++ b/internal/helix/registers_test.go @@ -1,11 +1,11 @@ -package vim +package helix import "testing" func TestSharedRegistersPreservePasteShape(t *testing.T) { for _, tc := range []struct{ name, source, yank, target, want string }{ - {"characters", "alpha beta", "yiw", "123", "1alpha23"}, - {"lines", "alpha\nsecond", "yy", "target", "target\nalpha"}, + {"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) { @@ -25,15 +25,19 @@ func TestSharedNamedAppendAndNumberedDeleteRegisters(t *testing.T) { var store RegisterStore first := NewWithRegisters("alpha\nkeep", DefaultOptions(), Hooks{}, &store) second := NewWithRegisters("beta\nstay", DefaultOptions(), Hooks{}, &store) - first.Feed(`"ayy`) - second.Feed(`"Ayy`) + 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("dd") - second.Feed("dd") + 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) @@ -44,8 +48,11 @@ func TestSharedRegistersRespectBlackHoleAndLocalUndo(t *testing.T) { var store RegisterStore first := NewWithRegisters("alpha", DefaultOptions(), Hooks{}, &store) second := NewWithRegisters("beta\nstay", DefaultOptions(), Hooks{}, &store) - first.Feed("yy") - second.Feed(`"_ddp`) + 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) } diff --git a/internal/vim/search.go b/internal/helix/search.go similarity index 91% rename from internal/vim/search.go rename to internal/helix/search.go index 4a05e36..3ea2953 100644 --- a/internal/vim/search.go +++ b/internal/helix/search.go @@ -1,4 +1,4 @@ -package vim +package helix import ( "regexp" @@ -100,21 +100,29 @@ func (e *Editor) compilePattern(pattern string) (*regexp.Regexp, error) { // 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, false + return from, from, false } re, err := e.compilePattern(e.lastSearch) if err != nil { e.setError("E486: Pattern not found: " + e.lastSearch) - return from, false + return from, from, false } p := from + end := from for i := 0; i < n; i++ { - next, wrapped, ok := e.findMatch(re, p, dir) + next, e2, wrapped, ok := e.findMatch(re, p, dir) if !ok { e.setError("E486: Pattern not found: " + e.lastSearch) - return from, false + return from, from, false } if wrapped && report { if dir > 0 { @@ -123,14 +131,16 @@ func (e *Editor) searchNext(from Pos, dir, n int, report bool) (Pos, bool) { e.setMsg("search hit TOP, continuing at BOTTOM") } } - p = next + p, end = next, e2 } e.highlight = true - return p, true + return p, end, true } -// findMatch is the next match strictly after (or before) from. -func (e *Editor) findMatch(re *regexp.Regexp, from Pos, dir int) (Pos, bool, bool) { +// 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)) @@ -140,7 +150,7 @@ func (e *Editor) findMatch(re *regexp.Regexp, from Pos, dir int) (Pos, bool, boo line := (from.Line + offset) % lineCount wrapped := from.Line+offset >= lineCount if wrapped && !e.opts.WrapScan { - return from, false, false + return from, from, false, false } for _, m := range matchesOn(line) { if offset == 0 && !wrapped && m[0] <= from.Col { @@ -149,16 +159,16 @@ func (e *Editor) findMatch(re *regexp.Regexp, from Pos, dir int) (Pos, bool, boo if offset == lineCount && m[0] > from.Col { continue } - return Pos{line, m[0]}, wrapped, true + return Pos{line, m[0]}, Pos{line, m[1]}, wrapped, true } } - return from, false, false + 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, false, false + return from, from, false, false } ms := matchesOn(line) for i := len(ms) - 1; i >= 0; i-- { @@ -169,10 +179,10 @@ func (e *Editor) findMatch(re *regexp.Regexp, from Pos, dir int) (Pos, bool, boo if offset == lineCount && m[0] < from.Col { continue } - return Pos{line, m[0]}, wrapped, true + return Pos{line, m[0]}, Pos{line, m[1]}, wrapped, true } } - return from, false, false + return from, from, false, false } // runeMatches lists match spans on a line in rune columns. diff --git a/internal/vim/textobjects.go b/internal/helix/textobjects.go similarity index 99% rename from internal/vim/textobjects.go rename to internal/helix/textobjects.go index d1f81a9..accf610 100644 --- a/internal/vim/textobjects.go +++ b/internal/helix/textobjects.go @@ -1,4 +1,4 @@ -package vim +package helix // textObject is a parsed `iw`, `a(`, `it` ... type textObject struct { diff --git a/internal/helix/visual.go b/internal/helix/visual.go new file mode 100644 index 0000000..78b81ea --- /dev/null +++ b/internal/helix/visual.go @@ -0,0 +1,781 @@ +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. +// 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 != ModeSelect && e.anchor == e.cursor { + return Pos{}, Pos{}, e.mode, false + } + start, end = e.selRange() + if e.selExcl && start.Less(end) { + end = e.prevCharPos(end) + } + if e.kind == selLine { + start.Col = 0 + end.Col = max(0, e.buf.LineLen(end.Line)-1) + } + return start, end, e.mode, true +} + +// 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 +} + +// 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 { + 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} + } + if e.selExcl { + // The upper-bound character sits past the selection: `w` stops on + // the next word's first char without selecting it. + return span{start: from, end: to} + } + 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.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..7f8a709 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -20,7 +20,7 @@ import ( "github.com/ZenNotes/tui/internal/keymaps" "github.com/ZenNotes/tui/internal/themes" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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,7 +854,7 @@ func (a *App) renderStatusBar() string { suffix := "" modeStyle := t.StatusMode if buf := a.activeBuffer(); buf != nil && a.focus == focusPane { - if a.prefs.VimMode { + if a.prefs.HelixMode { mode = buf.ed.Mode().Label() } 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 7557820..f99d9b7 100644 --- a/internal/tui/authoring_test.go +++ b/internal/tui/authoring_test.go @@ -1,7 +1,7 @@ package tui import ( - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "strings" "testing" ) @@ -31,15 +31,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 +52,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 +62,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..b4dd581 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -11,7 +11,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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 *helix.Editor 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 = helix.NewWithRegisters("", a.editorOptions(), 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 = helix.NewWithRegisters(content.Body, a.editorOptions(), 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..4f75781 100644 --- a/internal/tui/bulk_notes.go +++ b/internal/tui/bulk_notes.go @@ -3,7 +3,7 @@ package tui import ( "fmt" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "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 9678017..e7aaac8 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -15,7 +15,7 @@ import ( "github.com/ZenNotes/tui/internal/search" "github.com/ZenNotes/tui/internal/themes" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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,17 +52,17 @@ 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{"boardoptions"}, title: "Task board options", palette: true, run: func(a *App, _ *noteBuffer, _ 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{"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) } @@ -72,16 +72,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 @@ -89,21 +89,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 @@ -111,15 +111,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() @@ -132,18 +132,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 { @@ -178,30 +178,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") } @@ -217,7 +217,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") } @@ -227,65 +227,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 }}, @@ -293,7 +293,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 { @@ -306,7 +306,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() @@ -336,7 +336,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) @@ -364,7 +364,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) @@ -378,32 +378,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] @@ -418,51 +418,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) { @@ -475,7 +475,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 { @@ -485,19 +485,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() @@ -506,7 +506,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) { @@ -519,7 +519,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)") @@ -535,34 +535,34 @@ 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 != "" { @@ -579,7 +579,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" @@ -589,14 +589,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() @@ -608,22 +608,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 @@ -641,23 +641,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, "" @@ -669,22 +669,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 { @@ -834,7 +834,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..d040567 100644 --- a/internal/tui/commands_parity.go +++ b/internal/tui/commands_parity.go @@ -17,7 +17,7 @@ import ( "github.com/ZenNotes/tui/internal/remote" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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..bd8b369 100644 --- a/internal/tui/comments_panel.go +++ b/internal/tui/comments_panel.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" 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 ef6ba17..5beecb9 100644 --- a/internal/tui/database_test.go +++ b/internal/tui/database_test.go @@ -11,14 +11,14 @@ import ( "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/database" "github.com/ZenNotes/tui/internal/keymaps" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) -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/editorview.go b/internal/tui/editorview.go index 753a7e1..5a18499 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. @@ -289,9 +289,10 @@ func (a *App) renderEditor(buf *noteBuffer, w, h int, focused bool) string { lines := ed.Lines() blocks := computeBlockState(lines) cur := ed.Cursor() - vStart, vEnd, vMode, inVisual := ed.VisualRange() + 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 *helix.Editor, 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..778aaca 100644 --- a/internal/tui/excomplete_test.go +++ b/internal/tui/excomplete_test.go @@ -5,7 +5,7 @@ import ( "github.com/ZenNotes/tui/internal/keymaps" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) 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/helpdata.go b/internal/tui/helpdata.go index 87630de..5149e77 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,15 @@ 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).", "", "## 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 to the next or previous word, so wd deletes a word", + "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", "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", @@ -97,7 +98,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", @@ -105,7 +106,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 gn gp\tNext and previous tab", ":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..83f7296 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" @@ -225,7 +225,7 @@ func (a *App) openKeyHelp() { } else { title = "Editor keys" 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{"helix.bufferNext|helix.bufferPrevious|helix.gotoTabNext|helix.gotoTabPrevious", "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..549d3c0 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,15 @@ 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"} + seqIDs := []string{"helix.bufferPrevious", "helix.bufferNext", "helix.tabPrevious", "helix.tabNext", + "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 +385,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 +404,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 +462,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 +632,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 +772,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 +829,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..6028354 100644 --- a/internal/tui/io_async_test.go +++ b/internal/tui/io_async_test.go @@ -13,7 +13,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) 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/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..1f93cc0 100644 --- a/internal/tui/noteops.go +++ b/internal/tui/noteops.go @@ -15,7 +15,7 @@ import ( "github.com/ZenNotes/tui/internal/search" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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..1e1f34b 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -10,12 +10,12 @@ import ( "github.com/ZenNotes/tui/internal/search" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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 *helix.Editor } func (a *App) openQuickCapture() { opts := a.editorOptions() - ed := vim.New("", opts, vim.Hooks{}) + ed := helix.New("", opts, helix.Hooks{}) 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 @@ -549,7 +549,7 @@ func (c *captureOverlay) render(a *App, w, h int) string { body := renderPlainEditor(a, c.ed, inner, 8, true) lines = append(lines, strings.Split(body, "\n")...) mode := c.ed.Mode().Label() - if !a.prefs.VimMode { + 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..8c516bc 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -10,7 +10,7 @@ import ( "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // --- 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..301c7bf 100644 --- a/internal/tui/pick_overlay.go +++ b/internal/tui/pick_overlay.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/ZenNotes/tui/internal/search" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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..55a6828 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,7 +57,7 @@ type sessionPane struct { } type sessionPosition struct { - Cursor vim.Pos `json:"cursor"` + Cursor helix.Pos `json:"cursor"` Scroll int `json:"scroll"` } 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..bf8bf29 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "sort" "strconv" "strings" @@ -40,7 +40,7 @@ func (v *settingsView) refresh(a *App) { } } tables := config.ValueTables(a.prefs.Prefs) - for _, section := range []string{"vim", "editor", "appearance", "view", "terminal"} { + for _, section := range []string{"helix", "editor", "appearance", "view", "terminal"} { keys := []string{} for k := range tables[section] { keys = append(keys, k) @@ -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) { 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..fad8ec3 100644 --- a/internal/tui/sidebar.go +++ b/internal/tui/sidebar.go @@ -7,7 +7,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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..d8afeda 100644 --- a/internal/tui/tagcomplete.go +++ b/internal/tui/tagcomplete.go @@ -8,7 +8,7 @@ import ( "unicode" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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 *helix.Editor) (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..11e2036 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -11,12 +11,12 @@ import ( "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/themes" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) 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..559d842 100644 --- a/internal/tui/version.go +++ b/internal/tui/version.go @@ -21,7 +21,7 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/selfupdate" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) 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..4e5445c 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,8 @@ 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") + id, pending := a.resolveAction(k, "nav.moveDown", "nav.moveUp", "nav.jumpTop", "nav.jumpBottom", "nav.halfPageDown", "nav.halfPageUp", + "helix.gotoTabPrevious", "helix.gotoTabNext") if pending { return true } @@ -194,6 +195,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 4e97d19..eb3defb 100644 --- a/internal/tui/views_database.go +++ b/internal/tui/views_database.go @@ -9,7 +9,7 @@ import ( "github.com/ZenNotes/tui/internal/database" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // 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..5881ab9 100644 --- a/internal/tui/views_files.go +++ b/internal/tui/views_files.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" 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..738598a 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,7 @@ 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"}} + 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"}, {"helix.gotoTabNext|helix.gotoTabPrevious", "next/previous tab"}} } func (v *homeView) refresh(a *App) { @@ -216,11 +216,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 +619,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..d93eb57 100644 --- a/internal/tui/views_lists.go +++ b/internal/tui/views_lists.go @@ -11,7 +11,7 @@ import ( "github.com/atotto/clipboard" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // --- 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 0c11ff5..3b48c13 100644 --- a/internal/tui/views_tasks.go +++ b/internal/tui/views_tasks.go @@ -11,7 +11,7 @@ import ( "github.com/ZenNotes/tui/internal/periodic" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) // tasksView is the Tasks tab: list, board and calendar modes. @@ -763,7 +763,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 @@ -848,7 +848,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) { @@ -919,7 +919,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 } @@ -950,7 +950,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 } @@ -1058,7 +1058,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..6201a10 100644 --- a/internal/tui/views_templates.go +++ b/internal/tui/views_templates.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/ZenNotes/tui/internal/templates" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" "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 = helix.NewWithRegisters(file.Raw, a.editorOptions(), 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..d6a9938 100644 --- a/internal/tui/workflow_parity_test.go +++ b/internal/tui/workflow_parity_test.go @@ -11,7 +11,7 @@ import ( "testing" "github.com/ZenNotes/tui/internal/vault" - "github.com/ZenNotes/tui/internal/vim" + "github.com/ZenNotes/tui/internal/helix" ) 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/visual.go b/internal/vim/visual.go deleted file mode 100644 index fd8033d..0000000 --- a/internal/vim/visual.go +++ /dev/null @@ -1,553 +0,0 @@ -package vim - -import ( - "math" - "strings" - "unicode" -) - -func (e *Editor) enterVisual(mode Mode) { - if e.inVisual() { - if e.mode == mode { - e.exitVisual() - return - } - e.mode = mode - return - } - e.visualAnchor = e.cursor - e.mode = mode - e.pending = nil -} - -func (e *Editor) exitVisual() { - start, end, mode, _ := e.VisualRange() - e.lastVisual.start, e.lastVisual.end, e.lastVisual.mode, e.lastVisual.set = start, end, mode, true - e.marks['<'] = start - e.marks['>'] = end - e.mode = ModeNormal - e.cursor = e.buf.clampNormal(e.cursor) - e.pending = nil -} - -func (e *Editor) reselectVisual() { - if !e.lastVisual.set { - return - } - e.mode = e.lastVisual.mode - e.visualAnchor = e.buf.clampNormal(e.lastVisual.start) - e.cursor = e.buf.clampNormal(e.lastVisual.end) -} - -// VisualRange is the normalized selection: start..end inclusive, the mode, -// and ok when a selection is active. For block mode the columns come from -// BlockColumns. -func (e *Editor) VisualRange() (start, end Pos, mode Mode, ok bool) { - if !e.inVisual() { - return Pos{}, Pos{}, e.mode, false - } - start, end = e.visualAnchor, e.cursor - if end.Less(start) { - start, end = end, start - } - if e.mode == ModeVisualLine { - start.Col = 0 - end.Col = max(0, e.buf.LineLen(end.Line)-1) - } - return start, end, e.mode, true -} - -// 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.visualAnchor.Col, e.cursor.Col) - right = max(e.visualAnchor.Col, e.cursor.Col) - toEnd = e.desiredCol == math.MaxInt32 - return -} - -// visualSpan converts the selection into an operator span. -func (e *Editor) visualSpan() span { - start, end, mode, _ := e.VisualRange() - switch mode { - case ModeVisualLine: - return span{start: Pos{start.Line, 0}, end: Pos{end.Line, 0}, linewise: true} - case ModeVisualBlock: - left, right, toEnd := e.BlockColumns() - return span{start: Pos{start.Line, left}, end: Pos{end.Line, right}, blockwise: true, blockLeft: left, blockRight: right, blockToEnd: toEnd} - } - return span{start: start, end: e.stepInsertPos(end)} -} - -func (e *Editor) visualKey(k Key) { - e.ClearMessage() - e.pending = append(e.pending, k) - status := e.runVisual(e.pending) - if status != parseIncomplete { - e.pending = nil - } -} - -func (e *Editor) runVisual(keys []Key) parseStatus { - register, count, hasCount, rest, status := splitPrefix(keys) - if status != parseComplete { - return status - } - 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.ensureCursorVisible() - return parseComplete - } - switch { - case k.Is("esc") || k.IsCtrl('c') || k.IsCtrl('['): - e.exitVisual() - return parseComplete - case k.IsRune('v'): - e.enterVisual(ModeVisual) - return parseComplete - case k.IsRune('V'): - e.enterVisual(ModeVisualLine) - return parseComplete - case k.IsCtrl('v'): - e.enterVisual(ModeVisualBlock) - return parseComplete - case k.IsRune('o'): - e.visualAnchor, e.cursor = e.cursor, e.visualAnchor - e.desiredCol = -1 - return parseComplete - case k.IsRune('O'): - if e.mode == ModeVisualBlock { - e.visualAnchor.Col, e.cursor.Col = e.cursor.Col, e.visualAnchor.Col - } else { - e.visualAnchor, e.cursor = e.cursor, e.visualAnchor - } - e.desiredCol = -1 - return parseComplete - case k.IsRune(':'): - e.exitVisual() - e.openCmdline(':', 0, false) - e.cmd.text = []rune("'<,'>") - e.cmd.pos = len(e.cmd.text) - return parseComplete - case k.IsRune('d') || k.IsRune('x') || k.Is("delete"): - e.beginChange() - sp := e.visualSpan() - e.exitVisual() - e.deleteSpan(sp, register) - return finish(false) - case k.IsRune('X') || k.IsRune('D'): - e.beginChange() - sp := e.visualSpan() - if e.mode == ModeVisualBlock { - sp.blockToEnd = true - } else { - sp = span{start: Pos{sp.start.Line, 0}, end: Pos{sp.end.Line, 0}, linewise: true} - } - e.exitVisual() - e.deleteSpan(sp, register) - return finish(false) - case k.IsRune('y'): - sp := e.visualSpan() - e.exitVisual() - e.yankSpan(sp, register, true) - e.cursor = e.buf.clampNormal(sp.start) - return finish(false) - case k.IsRune('Y'): - sp := e.visualSpan() - sp = span{start: Pos{sp.start.Line, 0}, end: Pos{sp.end.Line, 0}, linewise: true} - e.exitVisual() - e.yankSpan(sp, register, true) - e.cursor = e.buf.clampNormal(Pos{sp.start.Line, e.cursor.Col}) - return finish(false) - case k.IsRune('c') || k.IsRune('s'): - e.beginChange() - sp := e.visualSpan() - e.exitVisual() - entered := e.runOperator("c", sp, register, false) - return finish(entered) - case k.IsRune('C') || k.IsRune('S') || k.IsRune('R'): - e.beginChange() - sp := e.visualSpan() - if e.mode == ModeVisualBlock && k.IsRune('C') { - sp.blockToEnd = true - e.exitVisual() - e.deleteSpan(sp, register) - e.startBlockInsert(sp.start.Line, sp.end.Line, sp.blockLeft, false) - return finish(true) - } - sp = span{start: Pos{sp.start.Line, 0}, end: Pos{sp.end.Line, 0}, linewise: true} - e.exitVisual() - entered := e.runOperator("c", sp, register, false) - return finish(entered) - case k.IsRune('J') || (k.IsRune('g') && len(rest) > 1 && rest[1].IsRune('J')): - e.beginChange() - start, end, _, _ := e.VisualRange() - e.exitVisual() - e.joinLines(start.Line, max(2, end.Line-start.Line+1), k.IsRune('J')) - return finish(false) - case k.IsRune('<') || k.IsRune('>'): - e.beginChange() - start, end, _, _ := e.VisualRange() - e.exitVisual() - 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.VisualRange() - e.exitVisual() - e.cursor = Pos{start.Line, firstNonBlank(e.buf.Line(start.Line))} - return parseComplete - case k.IsRune('~') || k.IsRune('u') || k.IsRune('U'): - e.beginChange() - sp := e.visualSpan() - e.exitVisual() - op := map[rune]string{'~': "g~", 'u': "gu", 'U': "gU"}[k.Rune] - 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.exitVisual() - return parseComplete - } - e.beginChange() - sp := e.visualSpan() - e.exitVisual() - e.replaceSpanWith(sp, rest[1]) - return finish(false) - case k.IsRune('p') || k.IsRune('P'): - e.beginChange() - sp := e.visualSpan() - e.exitVisual() - e.putOverSpan(sp, register, n, k.IsRune('p')) - return finish(false) - case k.IsRune('I') || k.IsRune('A'): - if e.mode == ModeVisualBlock { - e.beginChange() - sp := e.visualSpan() - e.exitVisual() - col := sp.blockLeft - appendMode := k.IsRune('A') - if appendMode { - col = sp.blockRight + 1 - if sp.blockToEnd { - col = -1 - } - } - e.startBlockInsert(sp.start.Line, sp.end.Line, col, appendMode) - return finish(true) - } - start, end, _, _ := e.VisualRange() - e.exitVisual() - 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.visualSpan() - e.exitVisual() - e.transformSpan(sp, "g"+string(second.Rune)) - e.cursor = e.buf.clampNormal(sp.start) - return finish(false) - case 'q', 'w': - e.beginChange() - sp := e.visualSpan() - e.exitVisual() - e.reflowLines(sp.start.Line, sp.end.Line) - e.cursor = e.buf.clampNormal(Pos{sp.start.Line, 0}) - return finish(false) - case 'v': - e.reselectVisual() - return parseComplete - case 'd': - e.exitVisual() - 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('i') || k.IsRune('a'): - obj, st := parseTextObject(rest) - if st != parseComplete { - return st - } - e.extendByObject(obj, n) - 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() - start, end, _, _ := e.VisualRange() - e.exitVisual() - delta := n - if k.IsCtrl('x') { - delta = -n - } - for l := start.Line; l <= end.Line; l++ { - saved := e.cursor - e.cursor = Pos{l, 0} - e.incrementNumber(delta) - e.cursor = saved - } - e.cursor = e.buf.clampNormal(start) - return finish(false) - case k.IsRune('z'): - if len(rest) < 2 { - return parseIncomplete - } - if rest[1].IsRune('f') { - start, end, _, _ := e.VisualRange() - e.exitVisual() - 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 - } - e.extendByMotion(m, count, hasCount) - return parseComplete -} - -func (e *Editor) extendByMotion(m motion, count int, hasCount bool) { - from := e.cursor - res, ok := e.applyMotion(m, count, hasCount, e.cursor, false) - if !ok { - return - } - 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 - } - if rng.linewise && e.mode == ModeVisual { - e.mode = ModeVisualLine - } - // Extend rather than replace when the selection already spans more than - // the object. - if e.visualAnchor.Less(rng.start) && e.visualAnchor != e.cursor { - e.cursor = rng.end - return - } - e.visualAnchor = rng.start - e.cursor = rng.end - e.desiredCol = -1 -} - -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 unless P is used. -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} - } - } - 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) - } -} - -var _ = unicode.IsUpper From 1d6032a61c83453e2d5c0726061bcf5d05c31748 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Tue, 20 Oct 2026 16:00:59 +0000 Subject: [PATCH 02/19] Add helix alongside vim bindings --- README.md | 17 +- internal/config/prefs.go | 15 +- internal/config/prefs_validate.go | 2 +- internal/config/prefs_values.go | 2 +- internal/helix/editor.go | 18 + internal/tui/app.go | 2 +- internal/tui/buffers.go | 6 +- internal/tui/editor.go | 96 ++ internal/tui/editorview.go | 2 +- internal/tui/featurehelpers_test.go | 1 + internal/tui/helpdata.go | 1 + internal/tui/motions_engine_test.go | 68 ++ internal/tui/overlays.go | 6 +- internal/tui/settings_controls.go | 2 +- internal/tui/tagcomplete.go | 2 +- internal/tui/views_templates.go | 2 +- internal/vim/buffer.go | 332 ++++++ internal/vim/display_motion_test.go | 46 + internal/vim/editor.go | 1648 +++++++++++++++++++++++++++ internal/vim/editor_test.go | 492 ++++++++ internal/vim/ex.go | 1004 ++++++++++++++++ internal/vim/folds.go | 200 ++++ internal/vim/insert.go | 574 ++++++++++ internal/vim/keys.go | 38 + internal/vim/motions.go | 941 +++++++++++++++ internal/vim/operators.go | 778 +++++++++++++ internal/vim/registers_test.go | 81 ++ internal/vim/search.go | 458 ++++++++ internal/vim/textobjects.go | 389 +++++++ internal/vim/visual.go | 564 +++++++++ 30 files changed, 7764 insertions(+), 23 deletions(-) create mode 100644 internal/tui/editor.go create mode 100644 internal/tui/motions_engine_test.go create mode 100644 internal/vim/buffer.go create mode 100644 internal/vim/display_motion_test.go create mode 100644 internal/vim/editor.go create mode 100644 internal/vim/editor_test.go create mode 100644 internal/vim/ex.go create mode 100644 internal/vim/folds.go create mode 100644 internal/vim/insert.go create mode 100644 internal/vim/keys.go create mode 100644 internal/vim/motions.go create mode 100644 internal/vim/operators.go create mode 100644 internal/vim/registers_test.go create mode 100644 internal/vim/search.go create mode 100644 internal/vim/textobjects.go create mode 100644 internal/vim/visual.go diff --git a/README.md b/README.md index 06d59a7..62ce59f 100644 --- a/README.md +++ b/README.md @@ -524,9 +524,18 @@ 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 +`[helix] 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 (`gn` `gp`, `gt` `gT`, `]b` `[b`), the leader, registers and +macros work in either engine. + +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 @@ -539,7 +548,7 @@ 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 `[helix] 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. diff --git a/internal/config/prefs.go b/internal/config/prefs.go index aa72875..5ff3402 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -12,13 +12,14 @@ import ( // key keeps the default. type Prefs struct { HelixWrappedLineMotions string - IgnoredKeys []string + IgnoredKeys []string HelixMode bool + Motions string HelixInsertEscape string HelixYankToClipboard bool - WhichKeyHints bool - WhichKeyHintMode string - WhichKeyHintTimeoutMs int + WhichKeyHints bool + WhichKeyHintMode string + WhichKeyHintTimeoutMs int SyncTitleHeadingOnRename bool EditorTabSize int @@ -81,8 +82,9 @@ type Prefs struct { // DefaultPrefs are the shipped defaults. func DefaultPrefs() Prefs { return Prefs{ - HelixMode: true, - HelixWrappedLineMotions: "display", + HelixMode: true, + Motions: "vim", + HelixWrappedLineMotions: "display", TabsEnabled: true, SavedTaskFilters: map[string]string{}, WhichKeyHints: true, @@ -163,6 +165,7 @@ func applyPrefs(p *Prefs, doc map[string]any) { } } 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) diff --git a/internal/config/prefs_validate.go b/internal/config/prefs_validate.go index 5c06aaf..ac816d1 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{ - "helix": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}}, + "helix": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}, "motions": {"helix", "vim"}}, "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"}}, diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index 8df9af6..0250c77 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{ - "helix": {"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}, + "helix": {"enabled": p.HelixMode, "motions": p.Motions, "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/editor.go b/internal/helix/editor.go index c38c985..05f7636 100644 --- a/internal/helix/editor.go +++ b/internal/helix/editor.go @@ -89,6 +89,21 @@ 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 @@ -1728,6 +1743,9 @@ func (e *Editor) GotoLine(line int) { // 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 != "" { diff --git a/internal/tui/app.go b/internal/tui/app.go index 7f8a709..bb367f1 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -855,7 +855,7 @@ func (a *App) renderStatusBar() string { modeStyle := t.StatusMode if buf := a.activeBuffer(); buf != nil && a.focus == focusPane { if a.prefs.HelixMode { - mode = buf.ed.Mode().Label() + mode = buf.ed.ModeLabel() } else { mode = "EDIT" } diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index b4dd581..2cef2ad 100644 --- a/internal/tui/buffers.go +++ b/internal/tui/buffers.go @@ -20,7 +20,7 @@ type noteBuffer struct { // codeHL caches the syntax highlighting of fenced code per version. codeHL *codeHighlights path string - ed *helix.Editor + ed modalEditor meta vault.NoteMeta savedText string diskChanged bool @@ -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 = helix.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,7 +81,7 @@ func (a *App) openBuffer(path string) (*noteBuffer, error) { return nil, err } buf := ¬eBuffer{path: path, meta: content.NoteMeta, savedText: content.Body} - buf.ed = helix.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 diff --git a/internal/tui/editor.go b/internal/tui/editor.go new file mode 100644 index 0000000..1d45e16 --- /dev/null +++ b/internal/tui/editor.go @@ -0,0 +1,96 @@ +package tui + +import ( + "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vim" +) + +// modalEditor is the editing engine behind buffers. The [helix] 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) + 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() } + +// 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 5a18499..46f49f0 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -503,7 +503,7 @@ func inVisualRange(line, col int, start, end helix.Pos, kind string, bl, br int, } // renderPlainEditor draws a small editor (quick capture) without gutter. -func renderPlainEditor(a *App, ed *helix.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/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 5149e77..595ad58 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -62,6 +62,7 @@ func helpLines(a *App, filter string) []string { leader + " ;\tCommand palette (every ex command, searchable)", leader + " ?\tThis manual", "Leader hints appear immediately; timed expires the sequence, sticky keeps it open (helix.which_key_hint_mode).", + "Motions engine: [helix] motions = vim (operator-pending, default) or helix (selection-first).", "", "## Editor: motions", "h j k l 0 ^ $\tMoves; word and find motions select as they travel", diff --git a/internal/tui/motions_engine_test.go b/internal/tui/motions_engine_test.go new file mode 100644 index 0000000..0de5e58 --- /dev/null +++ b/internal/tui/motions_engine_test.go @@ -0,0 +1,68 @@ +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) + } + } +} + +// 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) + a.handleKey(helix.R('g')) + a.handleKey(helix.R('n')) + if got := a.activeBuffer().path; got != "a.md" { + t.Fatalf("gn did not switch 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/overlays.go b/internal/tui/overlays.go index 1e1f34b..dccdf80 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -505,12 +505,12 @@ func (m *menu) render(a *App, w, h int) string { // --- quick capture --- type captureOverlay struct { - ed *helix.Editor + ed modalEditor } func (a *App) openQuickCapture() { opts := a.editorOptions() - ed := helix.New("", opts, helix.Hooks{}) + ed := a.newModalEditorWithOpts("", opts, helix.Hooks{}, nil) ed.SetViewport(8) if a.prefs.HelixMode { ed.HandleKey(helix.R('i')) @@ -548,7 +548,7 @@ 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() + mode := c.ed.ModeLabel() if !a.prefs.HelixMode { mode = "EDIT" } diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index bf8bf29..e4aec29 100644 --- a/internal/tui/settings_controls.go +++ b/internal/tui/settings_controls.go @@ -113,7 +113,7 @@ func (v *settingsView) handleKey(a *App, k helix.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/tagcomplete.go b/internal/tui/tagcomplete.go index d8afeda..2e31533 100644 --- a/internal/tui/tagcomplete.go +++ b/internal/tui/tagcomplete.go @@ -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 *helix.Editor) (tagToken, bool) { +func tagTokenAt(ed modalEditor) (tagToken, bool) { cur := ed.Cursor() lines := ed.Lines() if cur.Line >= len(lines) { diff --git a/internal/tui/views_templates.go b/internal/tui/views_templates.go index 6201a10..cd0e64d 100644 --- a/internal/tui/views_templates.go +++ b/internal/tui/views_templates.go @@ -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 = helix.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/vim/buffer.go b/internal/vim/buffer.go new file mode 100644 index 0000000..97a0240 --- /dev/null +++ b/internal/vim/buffer.go @@ -0,0 +1,332 @@ +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. +// 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) { + 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/vim/display_motion_test.go b/internal/vim/display_motion_test.go new file mode 100644 index 0000000..07b2bcd --- /dev/null +++ b/internal/vim/display_motion_test.go @@ -0,0 +1,46 @@ +package vim + +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/vim/editor.go b/internal/vim/editor.go new file mode 100644 index 0000000..29dc185 --- /dev/null +++ b/internal/vim/editor.go @@ -0,0 +1,1648 @@ +package vim + +import ( + "fmt" + "math" + "regexp" + "strconv" + "strings" + "unicode" + + "github.com/ZenNotes/tui/internal/helix" +) + +// Mode is the editor's state. +type Mode int + +const ( + ModeNormal Mode = iota + ModeInsert + ModeReplace + ModeVisual + ModeVisualLine + ModeVisualBlock + ModeCmdline +) + +// Label is the mode name for a status bar. +func (m Mode) Label() string { + switch m { + case ModeInsert: + return "INSERT" + case ModeReplace: + return "REPLACE" + case ModeVisual: + return "VISUAL" + case ModeVisualLine: + return "V-LINE" + case ModeVisualBlock: + return "V-BLOCK" + case ModeCmdline: + return "COMMAND" + } + return "NORMAL" +} + +// 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 { + o := helix.DefaultOptions() + o.HelixEnabled = true // modal editing on; the motions select vim keys + return o +} + +// 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 = helix.RegisterStore + +// Range is a line range for an ex command, zero-based and inclusive. +type Range = helix.Range + +// ExCommand is a command the editor hands to its host. +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 + 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<CR>`. +type pendingOpSearch struct { + op string + count int + register rune + from Pos + keys []Key +} + +type insertState struct { + start Pos + replaced []rune + 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 + + visualAnchor Pos + lastVisual struct { + start, end Pos + mode Mode + 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 + } + cmd cmdlineState + ins insertState + histories map[rune]*[]string + incremental *regexp.Regexp + blockInsert *blockInsertState + opSearch *pendingOpSearch + + 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{} + } + store.Init() + e := &Editor{ + buf: NewBuffer(text), + opts: opts, + hooks: hooks, + registers: store.Map(), + 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 == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock { + 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.mode == ModeReplace { + e.cursor = e.buf.clampInsert(p) + } else { + e.cursor = e.buf.clampNormal(p) + } + 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.mode == ModeReplace { + 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.mode == ModeReplace { + 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() { + 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, ModeReplace: + if !e.opts.HelixEnabled { + return e.plainKey(k) + } + e.insertKey(k) + return true + case ModeCmdline: + e.cmdlineKey(k) + return true + case ModeVisual, ModeVisualLine, ModeVisualBlock: + e.visualKey(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') +} + +var operatorKeys = map[string]bool{"d": true, "c": true, "y": true, "<": true, ">": true, "=": true, + "gu": true, "gU": true, "g~": true, "gq": true, "gw": true, "g?": true} + +// readOperator reads an operator off the front of keys. +func readOperator(keys []Key) (op string, consumed int, status parseStatus) { + if len(keys) == 0 { + return "", 0, parseIncomplete + } + k := keys[0] + if !k.Printable() { + return "", 0, parseInvalid + } + switch k.Rune { + case 'd', 'c', 'y', '<', '>', '=': + return string(k.Rune), 1, parseComplete + case 'g': + if len(keys) < 2 { + return "", 0, parseIncomplete + } + if keys[1].Printable() { + switch keys[1].Rune { + case 'u', 'U', '~', 'q', 'w', '?': + return "g" + string(keys[1].Rune), 2, parseComplete + } + } + } + return "", 0, parseInvalid +} + +func (e *Editor) runNormal(keys []Key) parseStatus { + register, count, hasCount, rest, status := splitPrefix(keys) + if status != parseComplete { + return status + } + versionBefore := e.version + enteredInsert := false + + // Operators. + if op, consumed, st := readOperator(rest); st == parseComplete { + afterOp := rest[consumed:] + _, count2, hasCount2, rest2, st2 := splitPrefix(afterOp) + if st2 == parseIncomplete && len(afterOp) > 0 && allDigits(afterOp) { + return parseIncomplete + } + if st2 == parseInvalid { + return parseInvalid + } + if len(afterOp) == 0 { + return parseIncomplete + } + total := effectiveCount(count, hasCount, count2, hasCount2) + hasTotal := hasCount || hasCount2 + opKeys := rest[:consumed] + // The doubled operator works on lines: dd, yy, >>, guu, gUgU ... + if doubled, n := isDoubledOperator(op, opKeys, rest2); doubled { + if n < 0 { + return parseIncomplete + } + e.beginChange() + enteredInsert = e.applyLinewiseOperator(op, total, register) + e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) + return parseComplete + } + // Text object or motion. + if len(rest2) == 0 { + return parseIncomplete + } + if rest2[0].IsRune('/') || rest2[0].IsRune('?') { + e.opSearch = &pendingOpSearch{op: op, count: total, register: register, from: e.cursor, keys: append([]Key(nil), keys...)} + e.openCmdline(rest2[0].Rune, 0, false) + return parseComplete + } + if obj, st3 := parseTextObject(rest2); st3 != parseInvalid { + if st3 == parseIncomplete { + return parseIncomplete + } + e.beginChange() + enteredInsert = e.applyOperatorToObject(op, obj, total, register) + e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) + return parseComplete + } + m, st3 := parseMotion(rest2) + if st3 != parseComplete { + return st3 + } + e.beginChange() + enteredInsert = e.applyOperatorToMotion(op, m, total, hasTotal, register) + e.finishNormal(keys, versionBefore, enteredInsert, register, count, hasCount) + return parseComplete + } + + // Simple commands. + st, entered := e.simpleCommand(rest, count, hasCount, register) + if st != parseComplete { + return st + } + e.finishNormal(keys, versionBefore, entered, register, count, hasCount) + return parseComplete +} + +func allDigits(keys []Key) bool { + for _, k := range keys { + if !k.Printable() || k.Rune < '0' || k.Rune > '9' { + return false + } + } + return len(keys) > 0 +} + +func effectiveCount(c1 int, has1 bool, c2 int, has2 bool) int { + switch { + case has1 && has2: + return c1 * c2 + case has1: + return c1 + case has2: + return c2 + } + return 1 +} + +// isDoubledOperator recognizes `dd`, `guu`, `gUgU`: n is -1 when more keys +// are needed. +func isDoubledOperator(op string, opKeys []Key, rest []Key) (bool, int) { + if len(rest) == 0 { + return false, 0 + } + if len(op) == 1 { + if rest[0].Printable() && string(rest[0].Rune) == op { + return true, 1 + } + return false, 0 + } + last := rune(op[1]) + if rest[0].Printable() && rest[0].Rune == last { + return true, 1 + } + if rest[0].Printable() && rest[0].Rune == 'g' { + if len(rest) < 2 { + return true, -1 + } + if rest[1].Printable() && rest[1].Rune == last { + return true, 2 + } + } + return false, 0 +} + +// 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 + 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:]...) +} + +// --- simple commands --- + +func (e *Editor) simpleCommand(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 { + case 'i': + e.startInsert(e.cursor) + return parseComplete, true + case 'a': + p := e.cursor + if e.buf.LineLen(p.Line) > 0 { + p.Col++ + } + e.startInsert(p) + 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 + case 'R': + e.beginChange() + e.mode = ModeReplace + e.ins = insertState{start: e.cursor} + e.cursor = e.buf.clampInsert(e.cursor) + return parseComplete, true + case 's': + e.beginChange() + e.deleteChars(n, register) + e.startInsert(e.cursor) + return parseComplete, true + case 'S': + e.beginChange() + e.changeLines(n, register) + return parseComplete, true + case 'C': + e.beginChange() + e.deleteToEnd(n, register) + e.startInsert(Pos{e.cursor.Line, e.buf.LineLen(e.cursor.Line)}) + return parseComplete, true + case 'x': + e.beginChange() + e.deleteChars(n, register) + return parseComplete, false + case 'X': + e.beginChange() + e.deleteCharsBack(n, register) + return parseComplete, false + case 'D': + e.beginChange() + e.deleteToEnd(n, register) + e.cursor = e.buf.clampNormal(e.cursor) + return parseComplete, false + case 'Y': + e.yankLines(e.cursor.Line, min(e.buf.LineCount()-1, e.cursor.Line+n-1), register) + return parseComplete, false + case 'p': + e.beginChange() + e.put(register, n, true, false) + return parseComplete, false + case 'P': + e.beginChange() + e.put(register, n, false, false) + return parseComplete, false + case 'J': + e.beginChange() + e.joinLines(e.cursor.Line, max(2, n), true) + return parseComplete, false + case 'u': + for i := 0; i < n; i++ { + e.Undo() + } + return parseComplete, false + case 'U': + e.Undo() + return parseComplete, false + case '.': + e.repeatLastChange(count, hasCount) + return parseComplete, false + case '~': + e.beginChange() + e.toggleCaseChars(n) + return parseComplete, false + case 'v': + e.enterVisual(ModeVisual) + return parseComplete, false + case 'V': + e.enterVisual(ModeVisualLine) + return parseComplete, false + case 'r': + if len(rest) < 2 { + return parseIncomplete, false + } + e.beginChange() + e.replaceChars(rest[1], n) + return parseComplete, false + case 'm': + if len(rest) < 2 { + return parseIncomplete, false + } + if rest[1].Printable() { + e.marks[rest[1].Rune] = e.cursor + } + return parseComplete, false + case 'q': + if e.macroReg != 0 { + e.stopMacro() + return parseComplete, false + } + if len(rest) < 2 { + return parseIncomplete, false + } + if rest[1].Printable() && validRegister(rest[1].Rune) { + e.macroReg = unicode.ToLower(rest[1].Rune) + e.macroKeys = nil + e.setMsg("recording @" + string(e.macroReg)) + } + return parseComplete, false + case '@': + if len(rest) < 2 { + return parseIncomplete, false + } + if rest[1].Printable() { + e.runMacro(rest[1].Rune, n) + } + 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 + case '&': + e.beginChange() + e.repeatSubstitute(false) + 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 + case 'g': + return e.gCommand(rest, n, count, hasCount, register) + case 'z': + return e.zCommand(rest, n) + } + } + 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.enterVisual(ModeVisualBlock) + 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.deleteChars(n, register) + return parseComplete, false + } + // Motions move the cursor. + m, st := parseMotion(rest) + if st != parseComplete { + return st, false + } + e.moveByMotion(m, count, hasCount) + return parseComplete, false +} + +func (e *Editor) highlightOffOnEsc() {} + +func (e *Editor) gCommand(rest []Key, n, count int, hasCount bool, register rune) (parseStatus, bool) { + if len(rest) < 2 { + return parseIncomplete, false + } + k := rest[1] + if !k.Printable() { + return parseInvalid, false + } + switch k.Rune { + case 'i': + p, ok := e.marks['^'] + if !ok { + p = e.cursor + } + e.startInsert(p) + return parseComplete, true + case 'I': + e.startInsert(Pos{e.cursor.Line, 0}) + return parseComplete, true + case 'v': + e.reselectVisual() + return parseComplete, false + case 'p': + e.beginChange() + e.put(register, n, true, true) + return parseComplete, false + case 'P': + e.beginChange() + e.put(register, n, false, true) + return parseComplete, false + case 'J': + e.beginChange() + e.joinLines(e.cursor.Line, max(2, n), 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 '&': + e.beginChange() + e.repeatSubstitute(true) + return parseComplete, false + case 'n': + return parseComplete, false + } + m, st := parseMotion(rest) + if st != parseComplete { + return st, false + } + e.moveByMotion(m, count, hasCount) + return parseComplete, false +} + +func (e *Editor) zCommand(rest []Key, n int) (parseStatus, bool) { + if len(rest) < 2 { + return parseIncomplete, false + } + k := rest[1] + switch { + case k.IsRune('z') || 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('c'): + 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) + case k.IsRune('=') || k.IsRune('g') || k.IsRune('G'): + // Harper suggestions are the host's; nothing to do in the buffer. + default: + return parseInvalid, false + } + return parseComplete, false +} + +// --- 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.mode == ModeReplace { + 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 --- + +func (e *Editor) moveByMotion(m motion, count int, hasCount bool) { + from := e.cursor + 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() +} + +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.mode != ModeReplace && 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.mode != ModeReplace { + 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.mode != ModeReplace { + 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.mode == ModeReplace { + 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.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. +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/vim/editor_test.go b/internal/vim/editor_test.go new file mode 100644 index 0000000..08536e7 --- /dev/null +++ b/internal/vim/editor_test.go @@ -0,0 +1,492 @@ +package vim + +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 crosses lines", text, Pos{0, 8}, "w", text, Pos{1, 0}, ModeNormal, nil}, + {"w to blank line", text, Pos{1, 5}, "w", text, Pos{2, 0}, ModeNormal, nil}, + {"W", "a.b c", Pos{0, 0}, "W", "a.b c", Pos{0, 4}, 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}, + {"ge", text, Pos{0, 5}, "ge", text, Pos{0, 2}, 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}, + {"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}, + {"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}, + {";", text, Pos{0, 0}, "ft;", text, Pos{0, 8}, ModeNormal, nil}, + {",", text, Pos{0, 0}, "ft;,", text, Pos{0, 4}, 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}, + {"%", text, Pos{3, 4}, "%", text, Pos{3, 10}, ModeNormal, nil}, + {"% back", text, Pos{3, 10}, "%", text, Pos{3, 4}, ModeNormal, nil}, + {"% from before", text, Pos{3, 0}, "%", 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}, "<CR>", "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}, + {"marks", text, Pos{0, 4}, "majj`a", text, Pos{0, 4}, ModeNormal, nil}, + {"mark line", text, Pos{0, 4}, "majj'a", text, Pos{0, 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 TestOperators(t *testing.T) { + run(t, []keyCase{ + {"x", "abc", Pos{0, 1}, "x", "ac", Pos{0, 1}, ModeNormal, nil}, + {"3x", "abcdef", Pos{0, 1}, "3x", "aef", Pos{0, 1}, ModeNormal, nil}, + {"x at end clamps", "abc", Pos{0, 2}, "x", "ab", Pos{0, 1}, ModeNormal, nil}, + {"X", "abc", Pos{0, 2}, "X", "ac", Pos{0, 1}, ModeNormal, nil}, + {"dw", "one two three", Pos{0, 0}, "dw", "two three", Pos{0, 0}, ModeNormal, nil}, + {"dw last word", "one two", Pos{0, 4}, "dw", "one ", Pos{0, 3}, ModeNormal, nil}, + {"dw end of line keeps next line", "one two\nthree", Pos{0, 4}, "dw", "one \nthree", Pos{0, 3}, ModeNormal, nil}, + {"d2w", "one two three four", Pos{0, 0}, "d2w", "three four", Pos{0, 0}, ModeNormal, nil}, + {"2dw", "one two three four", Pos{0, 0}, "2dw", "three four", Pos{0, 0}, ModeNormal, nil}, + {"de", "one two", Pos{0, 0}, "de", " two", Pos{0, 0}, ModeNormal, nil}, + {"db", "one two", Pos{0, 4}, "db", "two", Pos{0, 0}, ModeNormal, nil}, + {"dd", "a\nb\nc", Pos{1, 0}, "dd", "a\nc", Pos{1, 0}, ModeNormal, nil}, + {"2dd", "a\nb\nc\nd", Pos{1, 0}, "2dd", "a\nd", Pos{1, 0}, ModeNormal, nil}, + {"dd last line", "a\nb", Pos{1, 0}, "dd", "a", Pos{0, 0}, ModeNormal, nil}, + {"dd only line", "abc", Pos{0, 1}, "dd", "", Pos{0, 0}, ModeNormal, nil}, + {"dj", "a\nb\nc", Pos{0, 0}, "dj", "c", Pos{0, 0}, ModeNormal, nil}, + {"dk", "a\nb\nc", Pos{2, 0}, "dk", "a", Pos{0, 0}, ModeNormal, nil}, + {"dG", "a\nb\nc", Pos{1, 0}, "dG", "a", Pos{0, 0}, ModeNormal, nil}, + {"dgg", "a\nb\nc", Pos{1, 0}, "dgg", "c", Pos{0, 0}, ModeNormal, nil}, + {"d$", "one two", Pos{0, 3}, "d$", "one", Pos{0, 2}, ModeNormal, nil}, + {"D", "one two", Pos{0, 3}, "D", "one", Pos{0, 2}, ModeNormal, nil}, + {"d0", "one two", Pos{0, 4}, "d0", "two", Pos{0, 0}, ModeNormal, nil}, + {"d^", " one two", Pos{0, 6}, "d^", " two", Pos{0, 2}, ModeNormal, nil}, + {"dfx", "abcxdef", Pos{0, 0}, "dfx", "def", Pos{0, 0}, ModeNormal, nil}, + {"dtx", "abcxdef", Pos{0, 0}, "dtx", "xdef", Pos{0, 0}, ModeNormal, nil}, + {"d}", "a\nb\n\nc", Pos{0, 0}, "d}", "\nc", Pos{0, 0}, ModeNormal, nil}, + {"d} mid line charwise", "one two\nb\n\nc", Pos{0, 4}, "d}", "one \n\nc", Pos{0, 3}, ModeNormal, nil}, + {"diw", "one two three", Pos{0, 5}, "diw", "one three", Pos{0, 4}, ModeNormal, nil}, + {"daw", "one two three", Pos{0, 5}, "daw", "one three", Pos{0, 4}, ModeNormal, nil}, + {"daw end", "one two", Pos{0, 5}, "daw", "one", Pos{0, 2}, ModeNormal, nil}, + {"ciw", "one two three", Pos{0, 5}, "ciwX<Esc>", "one X three", Pos{0, 4}, ModeNormal, nil}, + {"di(", "f(a, b) c", Pos{0, 3}, "di(", "f() c", Pos{0, 2}, ModeNormal, nil}, + {"da(", "f(a, b) c", Pos{0, 3}, "da(", "f c", Pos{0, 1}, ModeNormal, nil}, + {"di( on bracket", "f(a, b) c", Pos{0, 1}, "di(", "f() c", Pos{0, 2}, ModeNormal, nil}, + {"di[ nested", "[a [b] c]", Pos{0, 4}, "di[", "[a [] c]", Pos{0, 4}, ModeNormal, nil}, + {"2di[", "[a [b] c]", Pos{0, 4}, "2di[", "[]", Pos{0, 1}, ModeNormal, nil}, + {"di{ multiline", "{\n a\n b\n}", Pos{1, 1}, "di{", "{\n}", Pos{1, 0}, ModeNormal, nil}, + {"di\"", `say "hello world" now`, Pos{0, 7}, `di"`, `say "" now`, Pos{0, 5}, ModeNormal, nil}, + {"da\"", `say "hello world" now`, Pos{0, 7}, `da"`, `say now`, Pos{0, 4}, ModeNormal, nil}, + {"ci\" before quotes", `say "hello" now`, Pos{0, 0}, `ci"x<Esc>`, `say "x" now`, Pos{0, 5}, ModeNormal, nil}, + {"dit", "<b>bold</b> x", Pos{0, 4}, "dit", "<b></b> x", Pos{0, 3}, ModeNormal, nil}, + {"dat", "<b>bold</b> x", Pos{0, 4}, "dat", " x", Pos{0, 0}, ModeNormal, nil}, + {"dip", "a\nb\n\nc", Pos{0, 0}, "dip", "\nc", Pos{0, 0}, ModeNormal, nil}, + {"dap", "a\nb\n\nc", Pos{0, 0}, "dap", "c", Pos{0, 0}, ModeNormal, nil}, + {"cc keeps indent", " abc", Pos{0, 3}, "ccx<Esc>", " x", Pos{0, 2}, ModeNormal, nil}, + {"S", "abc\ndef", Pos{1, 1}, "Sq<Esc>", "abc\nq", Pos{1, 0}, ModeNormal, nil}, + {"cw is ce", "one two", Pos{0, 0}, "cwX<Esc>", "X two", Pos{0, 0}, ModeNormal, nil}, + {"cw on space", "one two", Pos{0, 3}, "cw-<Esc>", "one-two", Pos{0, 3}, ModeNormal, nil}, + {"C", "one two", Pos{0, 3}, "C!<Esc>", "one!", Pos{0, 3}, ModeNormal, nil}, + {"s", "abc", Pos{0, 1}, "sXY<Esc>", "aXYc", Pos{0, 2}, ModeNormal, nil}, + {"r", "abc", Pos{0, 1}, "rx", "axc", Pos{0, 1}, ModeNormal, nil}, + {"3r", "abcd", Pos{0, 1}, "3rx", "axxx", Pos{0, 3}, ModeNormal, nil}, + {"r too many", "abc", Pos{0, 1}, "5rx", "abc", Pos{0, 1}, ModeNormal, nil}, + {"r enter", "ab cd", Pos{0, 2}, "r<CR>", "ab\ncd", Pos{1, 0}, ModeNormal, nil}, + {"~", "abc", Pos{0, 0}, "~", "Abc", Pos{0, 1}, ModeNormal, nil}, + {"3~", "abc", Pos{0, 0}, "3~", "ABC", Pos{0, 2}, ModeNormal, nil}, + {"gUw", "one two", Pos{0, 0}, "gUw", "ONE two", Pos{0, 0}, ModeNormal, nil}, + {"guu", "ONE TWO", Pos{0, 3}, "guu", "one two", Pos{0, 0}, ModeNormal, nil}, + {"g~~", "One", Pos{0, 0}, "g~~", "oNE", Pos{0, 0}, ModeNormal, nil}, + {"J", "a\n b\nc", Pos{0, 0}, "J", "a b\nc", Pos{0, 1}, ModeNormal, nil}, + {"3J", "a\nb\nc\nd", Pos{0, 0}, "3J", "a b c\nd", Pos{0, 3}, ModeNormal, nil}, + {"gJ", "a\n b", Pos{0, 0}, "gJ", "a b", Pos{0, 1}, ModeNormal, nil}, + {">>", "a\nb", Pos{0, 0}, ">>", " a\nb", Pos{0, 4}, ModeNormal, nil}, + {"2>>", "a\nb", Pos{0, 0}, "2>>", " a\n b", Pos{0, 4}, ModeNormal, nil}, + {"<<", " a", Pos{0, 4}, "<<", "a", Pos{0, 0}, ModeNormal, nil}, + {">j", "a\nb\nc", Pos{0, 0}, ">j", " a\n b\nc", Pos{0, 4}, ModeNormal, nil}, + {"ctrl-a", "x 41 y", Pos{0, 0}, "<C-a>", "x 42 y", Pos{0, 3}, ModeNormal, nil}, + {"5 ctrl-x", "x 41 y", Pos{0, 0}, "5<C-x>", "x 36 y", Pos{0, 3}, ModeNormal, nil}, + {"ctrl-a negative", "x -1 y", Pos{0, 0}, "2<C-a>", "x 1 y", Pos{0, 2}, ModeNormal, nil}, + {"o list continues", "- item", Pos{0, 3}, "onext<Esc>", "- item\n- next", Pos{1, 5}, ModeNormal, nil}, + {"o numbered continues", "1. item", Pos{0, 3}, "ox<Esc>", "1. item\n2. x", Pos{1, 3}, ModeNormal, nil}, + {"o checkbox continues", "- [x] done", Pos{0, 3}, "ox<Esc>", "- [x] done\n- [ ] x", Pos{1, 6}, ModeNormal, nil}, + {"O above", " abc", Pos{0, 3}, "Ox<Esc>", " x\n abc", Pos{0, 2}, ModeNormal, nil}, + {"gq joins paragraph", "one\ntwo\n\nthree", Pos{0, 0}, "gqip", "one two\n\nthree", Pos{0, 0}, ModeNormal, nil}, + {"gq keeps headings", "# H\none\ntwo", Pos{1, 0}, "gqG", "# H\none two", Pos{1, 0}, ModeNormal, nil}, + }) +} + +func TestInsertMode(t *testing.T) { + run(t, []keyCase{ + {"i", "abc", Pos{0, 1}, "iX<Esc>", "aXbc", Pos{0, 1}, ModeNormal, nil}, + {"a", "abc", Pos{0, 1}, "aX<Esc>", "abXc", Pos{0, 2}, ModeNormal, nil}, + {"A", "abc", Pos{0, 0}, "AX<Esc>", "abcX", Pos{0, 3}, ModeNormal, nil}, + {"I", " abc", Pos{0, 4}, "IX<Esc>", " Xabc", Pos{0, 2}, ModeNormal, nil}, + {"esc at col 0 stays", "abc", Pos{0, 0}, "i<Esc>", "abc", Pos{0, 0}, ModeNormal, nil}, + {"enter splits", "abcd", Pos{0, 2}, "i<CR><Esc>", "ab\ncd", Pos{1, 0}, ModeNormal, nil}, + {"enter autoindent", " ab", Pos{0, 3}, "a<CR>x<Esc>", " ab\n x", Pos{1, 2}, ModeNormal, nil}, + {"enter continues list", "- ab", Pos{0, 3}, "a<CR>x<Esc>", "- ab\n- x", Pos{1, 2}, ModeNormal, nil}, + {"enter on empty item ends list", "- ab\n- ", Pos{1, 1}, "a<CR>x<Esc>", "- ab\nx", Pos{1, 0}, ModeNormal, nil}, + {"enter renumbers", "1. a\n2. b", Pos{0, 3}, "a<CR>c<Esc>", "1. a\n2. c\n3. b", Pos{1, 3}, ModeNormal, nil}, + {"backspace", "abc", Pos{0, 2}, "i<BS><Esc>", "ac", Pos{0, 0}, ModeNormal, nil}, + {"backspace joins", "ab\ncd", Pos{1, 0}, "i<BS><Esc>", "abcd", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-w", "one two", Pos{0, 6}, "a<C-w><Esc>", "one ", Pos{0, 3}, ModeNormal, nil}, + {"ctrl-u", "one two", Pos{0, 6}, "a<C-u><Esc>", "", Pos{0, 0}, ModeNormal, nil}, + {"tab spaces", "ab", Pos{0, 1}, "i<Tab><Esc>", "a b", Pos{0, 3}, ModeNormal, nil}, + {"autopair", "", Pos{0, 0}, "i(<Esc>", "()", Pos{0, 0}, ModeNormal, nil}, + {"autopair skip", "", Pos{0, 0}, "i(a)<Esc>", "(a)", Pos{0, 2}, ModeNormal, nil}, + {"autopair backspace", "", Pos{0, 0}, "i(<BS><Esc>", "", Pos{0, 0}, ModeNormal, nil}, + {"no autopair before word", "x", Pos{0, 0}, "i(<Esc>", "(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<Esc>", "aj", Pos{0, 1}, ModeNormal, func(o *Options) { o.InsertEscape = "jk" }}, + {"text replacement", "", Pos{0, 0}, "ia->b<Esc>", "a→b", Pos{0, 2}, ModeNormal, func(o *Options) { o.TextReplacementsEnabled = true }}, + {"R replace", "abcd", Pos{0, 1}, "Rxy<Esc>", "axyd", Pos{0, 2}, ModeNormal, nil}, + {"R replace past end", "ab", Pos{0, 1}, "Rxyz<Esc>", "axyz", Pos{0, 3}, ModeNormal, nil}, + {"R backspace restores", "abcd", Pos{0, 1}, "Rxy<BS><Esc>", "axcd", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-o one command", "abc def", Pos{0, 0}, "i<C-o>$X<Esc>", "abc defX", Pos{0, 7}, ModeNormal, nil}, + {"ctrl-r register", "abc", Pos{0, 0}, "yiwA <C-r>\"<Esc>", "abc abc", Pos{0, 6}, ModeNormal, nil}, + {"ctrl-n completion", "hello help\nhe", Pos{1, 1}, "A<C-n><Esc>", "hello help\nhello", Pos{1, 4}, ModeNormal, nil}, + {"ctrl-n twice", "hello help\nhe", Pos{1, 1}, "A<C-n><C-n><Esc>", "hello help\nhelp", Pos{1, 3}, ModeNormal, nil}, + {"ctrl-t indents", "ab", Pos{0, 1}, "i<C-t><Esc>", " ab", Pos{0, 4}, ModeNormal, nil}, + {"shift-tab outdents", " ab", Pos{0, 5}, "i<S-Tab><Esc>", "ab", Pos{0, 0}, ModeNormal, nil}, + {"tab indents list item", "- ab", Pos{0, 2}, "i<Tab><Esc>", " - ab", Pos{0, 5}, ModeNormal, nil}, + {"arrows in insert", "abc", Pos{0, 0}, "i<Right><Right>X<Esc>", "abXc", Pos{0, 2}, ModeNormal, nil}, + {"3ix", "", Pos{0, 0}, "3ix<Esc>", "x", Pos{0, 0}, ModeNormal, nil}, + }) +} + +func TestYankPut(t *testing.T) { + run(t, []keyCase{ + {"yyp", "a\nb", Pos{0, 0}, "yyp", "a\na\nb", Pos{1, 0}, ModeNormal, nil}, + {"yyP", "a\nb", Pos{1, 0}, "yyP", "a\nb\nb", Pos{1, 0}, ModeNormal, nil}, + {"3yyP count", "a\nb\nc", Pos{0, 0}, "yy2P", "a\na\na\nb\nc", Pos{0, 0}, ModeNormal, nil}, + {"yw p", "one two", Pos{0, 0}, "ywP", "one one two", Pos{0, 3}, ModeNormal, nil}, + {"yiw p after", "one two", Pos{0, 0}, "yiwp", "oonene two", Pos{0, 3}, ModeNormal, nil}, + {"dd p", "a\nb\nc", Pos{0, 0}, "ddp", "b\na\nc", Pos{1, 0}, ModeNormal, nil}, + {"xp swap", "ab", Pos{0, 0}, "xp", "ba", Pos{0, 1}, ModeNormal, nil}, + {"named register", "one two", Pos{0, 0}, "\"ayiww\"aP", "one onetwo", Pos{0, 6}, ModeNormal, nil}, + {"append register", "one two", Pos{0, 0}, "\"ayiww\"Ayiw$\"ap", "one twoonetwo", Pos{0, 12}, ModeNormal, nil}, + {"yank 0 register survives delete", "one two", Pos{0, 0}, "yiwwdiw\"0P", "oneone ", Pos{0, 5}, ModeNormal, nil}, + {"blackhole", "one two", Pos{0, 0}, "yiww\"_dwP", "oneone ", Pos{0, 5}, ModeNormal, nil}, + {"gp cursor after", "a\nb", Pos{0, 0}, "yygp", "a\na\nb", Pos{2, 0}, ModeNormal, nil}, + {"Y is yy", "ab\ncd", Pos{0, 1}, "Yp", "ab\nab\ncd", Pos{1, 0}, ModeNormal, nil}, + {"p multi-line charwise", "ab\ncd", Pos{0, 0}, "vjy$p", "abab\nc\ncd", Pos{0, 2}, ModeNormal, nil}, + }) +} + +func TestVisual(t *testing.T) { + run(t, []keyCase{ + {"vd", "abcdef", Pos{0, 1}, "vlld", "aef", Pos{0, 1}, ModeNormal, nil}, + {"vy", "abcdef", Pos{0, 1}, "vllyP", "abcdbcdef", Pos{0, 3}, ModeNormal, nil}, + {"Vd", "a\nb\nc", Pos{0, 0}, "Vjd", "c", Pos{0, 0}, ModeNormal, nil}, + {"vc", "one two", Pos{0, 0}, "vecX<Esc>", "X two", Pos{0, 0}, ModeNormal, nil}, + {"viw", "one two three", Pos{0, 5}, "viwd", "one three", Pos{0, 4}, ModeNormal, nil}, + {"v o swap", "abcdef", Pos{0, 2}, "vlohd", "aef", Pos{0, 1}, ModeNormal, nil}, + {"v$", "abc\ndef", Pos{0, 1}, "v$d", "a\ndef", Pos{0, 0}, ModeNormal, nil}, + {"vU", "abc", Pos{0, 0}, "vlU", "ABc", Pos{0, 0}, ModeNormal, nil}, + {"vr", "abc", Pos{0, 0}, "vlrx", "xxc", Pos{0, 0}, ModeNormal, nil}, + {"vJ", "a\nb\nc", Pos{0, 0}, "VjJ", "a b\nc", Pos{0, 1}, ModeNormal, nil}, + {"v>", "a\nb", Pos{0, 0}, "Vj>", " a\n b", Pos{0, 4}, ModeNormal, nil}, + {"vp replaces", "one two", Pos{0, 0}, "yiwwviwp", "one one", Pos{0, 6}, ModeNormal, nil}, + {"gv", "abcdef", Pos{0, 1}, "vl<Esc>0gvd", "adef", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-v block delete", "abc\ndef\nghi", Pos{0, 1}, "<C-v>jld", "a\nd\nghi", Pos{0, 0}, ModeNormal, nil}, + {"ctrl-v block I", "abc\ndef", Pos{0, 1}, "<C-v>jIX<Esc>", "aXbc\ndXef", Pos{0, 1}, ModeNormal, nil}, + {"ctrl-v block A", "abc\ndef", Pos{0, 1}, "<C-v>jAX<Esc>", "abXc\ndeXf", Pos{0, 2}, ModeNormal, nil}, + {"ctrl-v $A", "ab\ndefg", Pos{0, 0}, "<C-v>j$AX<Esc>", "abX\ndefgX", Pos{0, 2}, ModeNormal, nil}, + {"ctrl-v block y p", "abc\ndef", Pos{0, 0}, "<C-v>jly$p", "abcab\ndefde", Pos{0, 3}, ModeNormal, nil}, + {"v esc", "abc", Pos{0, 0}, "vl<Esc>", "abc", Pos{0, 1}, ModeNormal, nil}, + {"V then v", "abc", Pos{0, 1}, "Vvd", "ac", Pos{0, 1}, ModeNormal, nil}, + {"visual ip", "a\nb\n\nc", Pos{0, 0}, "vipd", "\nc", Pos{0, 0}, ModeNormal, nil}, + {"visual toggle checkbox", "a\nb", Pos{0, 0}, "Vj", "a\nb", Pos{1, 0}, ModeVisualLine, nil}, + }) +} + +func TestUndoRedoDot(t *testing.T) { + run(t, []keyCase{ + {"u", "abc", Pos{0, 0}, "xu", "abc", Pos{0, 0}, ModeNormal, nil}, + {"u insert session", "abc", Pos{0, 0}, "ixyz<Esc>u", "abc", Pos{0, 0}, ModeNormal, nil}, + {"redo", "abc", Pos{0, 0}, "xu<C-r>", "bc", Pos{0, 0}, ModeNormal, nil}, + {"2u", "abcd", Pos{0, 0}, "xx2u", "abcd", Pos{0, 0}, ModeNormal, nil}, + {"dot x", "abcdef", Pos{0, 0}, "x.", "cdef", Pos{0, 0}, ModeNormal, nil}, + {"dot dw", "a b c d", Pos{0, 0}, "dw.", "c d", Pos{0, 0}, ModeNormal, nil}, + {"dot insert", "", Pos{0, 0}, "ix<Esc>.", "xx", Pos{0, 0}, ModeNormal, nil}, + {"dot A", "a\nb", Pos{0, 0}, "A!<Esc>j.", "a!\nb!", Pos{1, 1}, ModeNormal, nil}, + {"dot with count", "abcdef", Pos{0, 0}, "x3.", "ef", Pos{0, 0}, ModeNormal, nil}, + {"dot ciw", "one two", Pos{0, 0}, "ciwX<Esc>w.", "X X", Pos{0, 2}, ModeNormal, nil}, + {"dot o", "a", Pos{0, 0}, "ob<Esc>.", "a\nb\nb", Pos{2, 0}, ModeNormal, nil}, + {"dot dd count", "a\nb\nc\nd\ne", Pos{0, 0}, "2dd.", "e", Pos{0, 0}, ModeNormal, nil}, + {"macro", "a\nb\nc", Pos{0, 0}, "qaA!<Esc>jq@a", "a!\nb!\nc", Pos{2, 0}, ModeNormal, nil}, + {"macro count", "a\nb\nc\nd", Pos{0, 0}, "qaA!<Esc>jq2@a", "a!\nb!\nc!\nd", Pos{3, 0}, ModeNormal, nil}, + {"macro @@", "a\nb\nc\nd", Pos{0, 0}, "qaA!<Esc>jq@a@@", "a!\nb!\nc!\nd", Pos{3, 0}, ModeNormal, nil}, + {"U undo", "abc", Pos{0, 0}, "xU", "abc", Pos{0, 0}, ModeNormal, nil}, + }) +} + +func TestSearchAndEx(t *testing.T) { + run(t, []keyCase{ + {"search", "one two\nthree two", Pos{0, 0}, "/two<CR>", "one two\nthree two", Pos{0, 4}, ModeNormal, nil}, + {"search n", "one two\nthree two", Pos{0, 0}, "/two<CR>n", "one two\nthree two", Pos{1, 6}, ModeNormal, nil}, + {"search wraps", "one two\nthree two", Pos{0, 0}, "/two<CR>nn", "one two\nthree two", Pos{0, 4}, ModeNormal, nil}, + {"search backward", "one two\nthree two", Pos{0, 0}, "?two<CR>", "one two\nthree two", Pos{1, 6}, ModeNormal, nil}, + {"search N", "one two\nthree two", Pos{0, 0}, "/two<CR>N", "one two\nthree two", Pos{1, 6}, ModeNormal, nil}, + {"star", "foo bar foo", Pos{0, 0}, "*", "foo bar foo", Pos{0, 8}, ModeNormal, nil}, + {"star word boundary", "foo foobar foo", Pos{0, 0}, "*", "foo foobar foo", Pos{0, 11}, ModeNormal, nil}, + {"hash", "foo bar foo", Pos{0, 8}, "#", "foo bar foo", Pos{0, 0}, ModeNormal, nil}, + {"d/", "one two three", Pos{0, 0}, "d/three<CR>", "three", Pos{0, 0}, ModeNormal, nil}, + {"c/", "one two three", Pos{0, 0}, "c/three<CR>X <Esc>", "X three", Pos{0, 1}, ModeNormal, nil}, + {"y?", "one two three", Pos{0, 8}, "y?two<CR>P", "one two two three", Pos{0, 7}, ModeNormal, nil}, + {"d/ dot", "a x b x c x", Pos{0, 0}, "d/x<CR>..", "x", Pos{0, 0}, ModeNormal, nil}, + {"search vim word boundary", "cat concat cat", Pos{0, 0}, "/\\<cat\\><CR>", "cat concat cat", Pos{0, 11}, ModeNormal, nil}, + {"search esc restores", "one two", Pos{0, 0}, "/two<Esc>", "one two", Pos{0, 0}, ModeNormal, nil}, + {":s", "one two one", Pos{0, 0}, ":s/one/1/<CR>", "1 two one", Pos{0, 0}, ModeNormal, nil}, + {":s g", "one two one", Pos{0, 0}, ":s/one/1/g<CR>", "1 two 1", Pos{0, 0}, ModeNormal, nil}, + {":%s", "a\nb\na", Pos{0, 0}, ":%s/a/x/<CR>", "x\nb\nx", Pos{2, 0}, ModeNormal, nil}, + {":s groups", "john smith", Pos{0, 0}, ":s/\\(\\w\\+\\) \\(\\w\\+\\)/\\2 \\1/<CR>", "smith john", Pos{0, 0}, ModeNormal, nil}, + {":s ampersand", "abc", Pos{0, 0}, ":s/b/[&]/<CR>", "a[b]c", Pos{0, 0}, ModeNormal, nil}, + {":s case", "Abc abc", Pos{0, 0}, ":s/abc/x/gi<CR>", "x x", Pos{0, 0}, ModeNormal, nil}, + {":s newline", "a,b", Pos{0, 0}, ":s/,/\\r/<CR>", "a\nb", Pos{0, 0}, ModeNormal, nil}, + {":g/d", "keep\ndrop\nkeep2\ndrop", Pos{0, 0}, ":g/drop/d<CR>", "keep\nkeep2", Pos{1, 0}, ModeNormal, nil}, + {":v/d", "keep\ndrop\nkeep2", Pos{0, 0}, ":v/keep/d<CR>", "keep\nkeep2", Pos{1, 0}, ModeNormal, nil}, + {":d range", "a\nb\nc\nd", Pos{0, 0}, ":2,3d<CR>", "a\nd", Pos{1, 0}, ModeNormal, nil}, + {":N goto", "a\nb\nc", Pos{0, 0}, ":3<CR>", "a\nb\nc", Pos{2, 0}, ModeNormal, nil}, + {":m 0", "a\nb\nc", Pos{2, 0}, ":m 0<CR>", "c\na\nb", Pos{0, 0}, ModeNormal, nil}, + {":m $", "a\nb\nc", Pos{0, 0}, ":m $<CR>", "b\nc\na", Pos{2, 0}, ModeNormal, nil}, + {":t", "a\nb", Pos{0, 0}, ":t.<CR>", "a\na\nb", Pos{1, 0}, ModeNormal, nil}, + {":sort", "c\na\nb", Pos{0, 0}, ":sort<CR>", "a\nb\nc", Pos{0, 0}, ModeNormal, nil}, + {":sort!", "c\na\nb", Pos{0, 0}, ":sort!<CR>", "c\nb\na", Pos{0, 0}, ModeNormal, nil}, + {":normal", "a\nb", Pos{0, 0}, ":%norm A;<CR>", "a;\nb;", Pos{1, 1}, ModeNormal, nil}, + {":>", "a", Pos{0, 0}, ":><CR>", " a", Pos{0, 4}, ModeNormal, nil}, + {":j", "a\nb\nc", Pos{0, 0}, ":j<CR>", "a b\nc", Pos{0, 1}, ModeNormal, nil}, + {":'<,'>", "a\nb\nc", Pos{0, 0}, "Vj:d<CR>", "c", Pos{0, 0}, ModeNormal, nil}, + {"& repeat", "aa\naa", Pos{0, 0}, ":s/a/x/<CR>j&", "xa\nxa", Pos{1, 0}, ModeNormal, nil}, + {":pu", "a\nb", Pos{0, 0}, "yy:pu<CR>", "a\na\nb", Pos{1, 0}, ModeNormal, nil}, + {"ctrl-r on cmdline", "word", Pos{0, 0}, "yiw:s/<C-r>\"/x/<CR>", "x", Pos{0, 0}, ModeNormal, nil}, + }) +} + +func TestFolds(t *testing.T) { + text := "# A\na1\na2\n# B\nb1" + e := New(text, DefaultOptions(), Hooks{}) + e.SetViewport(10) + e.Feed("jzc") + 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("yiw") + if reg, ok := e.RegisterText('0'); !ok || reg.Text != "hello" { + t.Fatalf("yank register = %+v", reg) + } + e.Feed("dd") + if reg, _ := e.RegisterText('1'); !reg.Linewise || reg.Text != "hello world\n" { + t.Fatalf("delete register = %+v", reg) + } + e.Feed("u") + e.Feed("dw") + if reg, _ := e.RegisterText('-'); 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("<C-d>") + 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<C-f>") + 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) + } +} + +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!<CR>") + if got.Name != "wq" || !got.Bang { + t.Fatalf("host got %+v", got) + } + e.Feed(":move inbox/Work<CR>") + 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 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("<Esc>u") + if got := e.Text(); got != "see end" { + t.Fatalf("after undo: %q", got) + } +} diff --git a/internal/vim/ex.go b/internal/vim/ex.go new file mode 100644 index 0000000..3b7c9d1 --- /dev/null +++ b/internal/vim/ex.go @@ -0,0 +1,1004 @@ +package vim + +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.inVisual() { + e.exitVisual() + } + 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 + if e.opSearch != nil { + e.opSearch.keys = append(e.opSearch.keys, k) + } + 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.opSearch = 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 + } + if pending := e.opSearch; pending != nil { + e.opSearch = nil + e.runOperatorSearch(pending, text, dir) + break + } + 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 + } + 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.mode == ModeReplace { + e.HandleKey(KeyEsc) + } + if e.inVisual() { + e.exitVisual() + } + 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() +} + +// runOperatorSearch finishes `d/pat<CR>`: the search target is the motion. +func (e *Editor) runOperatorSearch(pending *pendingOpSearch, pattern string, dir 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.cursor = e.buf.clampNormal(pending.from) + target, ok := e.searchNext(pending.from, dir, max(1, pending.count), false) + if !ok { + return + } + versionBefore := e.version + e.beginChange() + entered := e.applyOperatorResult(pending.op, pending.from, motionResult{pos: target, jump: true}, pending.register, false) + if entered { + if !e.replaying { + e.recordingIns = true + e.changeKeys = append([]Key(nil), pending.keys...) + } + return + } + e.commitChange() + if e.version != versionBefore && !e.replaying { + e.last = lastChange{keys: stripCount(pending.keys), register: pending.register} + } +} diff --git a/internal/vim/folds.go b/internal/vim/folds.go new file mode 100644 index 0000000..32d4caa --- /dev/null +++ b/internal/vim/folds.go @@ -0,0 +1,200 @@ +package vim + +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/vim/insert.go b/internal/vim/insert.go new file mode 100644 index 0000000..55ac99f --- /dev/null +++ b/internal/vim/insert.go @@ -0,0 +1,574 @@ +package vim + +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 + // `<C-o>$` 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.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) { + if e.mode == ModeReplace { + line := cloneRunes(e.buf.Line(e.cursor.Line)) + if e.cursor.Col < len(line) { + e.ins.replaced = append(e.ins.replaced, line[e.cursor.Col]) + line[e.cursor.Col] = r + e.buf.ReplaceLine(e.cursor.Line, line) + } else { + e.ins.replaced = append(e.ins.replaced, -1) + e.buf.ReplaceLine(e.cursor.Line, append(line, r)) + } + e.cursor.Col++ + return + } + 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.mode == ModeReplace { + e.insertRaw("\n") + return + } + 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.mode == ModeReplace { + if e.cursor.Col == 0 { + return + } + if n := len(e.ins.replaced); n > 0 { + orig := e.ins.replaced[n-1] + e.ins.replaced = e.ins.replaced[:n-1] + line := cloneRunes(e.buf.Line(e.cursor.Line)) + e.cursor.Col-- + if orig < 0 { + line = line[:e.cursor.Col] + } else if e.cursor.Col < len(line) { + line[e.cursor.Col] = orig + } + e.buf.ReplaceLine(e.cursor.Line, line) + return + } + e.cursor.Col-- + return + } + 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/vim/keys.go b/internal/vim/keys.go new file mode 100644 index 0000000..cd50973 --- /dev/null +++ b/internal/vim/keys.go @@ -0,0 +1,38 @@ +package vim + +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. + +type Key = helix.Key + +func R(r rune) Key { return helix.R(r) } + +func Ctrl(r rune) Key { return helix.Ctrl(r) } + +func Alt(r rune) Key { return helix.Alt(r) } + +func Special(name string) Key { return helix.Special(name) } + +var ( + 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 +) + +func ParseKeys(s string) []Key { return helix.ParseKeys(s) } + +func KeysString(keys []Key) string { return helix.KeysString(keys) } diff --git a/internal/vim/motions.go b/internal/vim/motions.go new file mode 100644 index 0000000..d443d05 --- /dev/null +++ b/internal/vim/motions.go @@ -0,0 +1,941 @@ +package vim + +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 bool + keepCol bool + stickyEnd bool + jump 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, '`': true, '\'': 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() { + if keys[1].Is("esc") { + return motion{}, parseInvalid + } + 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', 'e', 'E', 'j', 'k', '0', '^', '$', 'm', '_', '*', '#': + 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.inVisual() { + 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.inVisual() && 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 + for i := 0; i < n; i++ { + next, ok := e.wordForward(p, big, forOperator && i == n-1) + if !ok { + break + } + p = next + } + res.pos = p + 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 + } + res.pos = p + 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 ";", ",": + if !e.lastFind.set { + return res, false + } + kind := e.lastFind.kind + if m.kind == "," { + kind = map[rune]rune{'f': 'F', 'F': 'f', 't': 'T', 'T': 't'}[kind] + } + p, ok := e.findChar(from, byte(kind), e.lastFind.ch, n) + if !ok { + return res, false + } + res.pos = p + res.inclusive = kind == 'f' || kind == 't' + case "%": + p, ok := e.matchPair(from) + if !ok { + return res, false + } + res.pos = p + res.inclusive, res.jump = true, true + case "n", "N": + dir := e.searchDir + if m.kind == "N" { + dir = -dir + } + p, ok := e.searchNext(from, dir, n, true) + if !ok { + return res, false + } + res.pos, res.jump = p, true + case "*", "#", "g*", "g#": + word := e.WordUnderCursor() + if word == "" { + e.setError("E348: No string under cursor") + return res, false + } + pattern := regexp.QuoteMeta(word) + if !strings.HasPrefix(m.kind, "g") { + pattern = `\b` + pattern + `\b` + } + e.lastSearch = pattern + e.highlight = true + dir := 1 + if strings.HasSuffix(m.kind, "#") { + dir = -1 + } + e.searchDir = dir + p, ok := e.searchNext(from, dir, n, true) + if !ok { + return res, false + } + res.pos, res.jump = p, true + case "`", "'": + p, ok := e.markPos(m.arg) + if !ok { + e.setError("E20: Mark not set") + return res, false + } + res.pos = e.buf.clampNormal(p) + if m.kind == "'" { + res.pos.Col = firstNonBlank(e.buf.Line(res.pos.Line)) + res.linewise = true + } + res.jump = 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) inVisual() bool { + return e.mode == ModeVisual || e.mode == ModeVisualLine || e.mode == ModeVisualBlock +} + +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 || e.mode == ModeReplace { + 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 +} + +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 --- + +func (e *Editor) findChar(from Pos, kind byte, ch rune, n int) (Pos, bool) { + line := e.buf.Line(from.Line) + col := from.Col + switch kind { + case 'f', 't': + for i := 0; i < n; i++ { + start := col + 1 + if kind == 't' && i == 0 && start < len(line) && line[start] == ch && n == 1 { + // `t` on an adjacent char still needs to find the NEXT one + // when repeated with ; in Vim; plain t stays put. Vim's cpo + // default: t does not move. Match that by searching from + // col+2 only for repeats. + } + found := -1 + for c := start; c < len(line); c++ { + if line[c] == ch { + found = c + break + } + } + if found < 0 { + return from, false + } + col = found + } + if kind == 't' { + col-- + if col <= from.Col && n == 1 { + if col < from.Col { + return from, false + } + } + } + return Pos{from.Line, col}, true + default: + for i := 0; i < n; i++ { + found := -1 + for c := col - 1; c >= 0; c-- { + if line[c] == ch { + found = c + break + } + } + if found < 0 { + return from, false + } + col = found + } + if kind == 'T' { + col++ + } + return Pos{from.Line, col}, true + } +} + +// --- brackets --- + +var pairs = map[rune]rune{'(': ')', '[': ']', '{': '}', ')': '(', ']': '[', '}': '{'} + +func (e *Editor) matchPair(from Pos) (Pos, bool) { + line := e.buf.Line(from.Line) + col := from.Col + for col < len(line) { + if _, ok := pairs[line[col]]; ok { + break + } + col++ + } + if col >= len(line) { + return from, false + } + open := line[col] + close := pairs[open] + dir := 1 + if open == ')' || open == ']' || open == '}' { + dir = -1 + } + depth := 0 + p := Pos{from.Line, col} + for { + r, ok := e.runeAt(p) + if ok { + if r == open { + depth++ + } else if r == close { + depth-- + if depth == 0 { + return p, true + } + } + } + next, ok := e.step(p, dir) + if !ok { + return from, false + } + p = next + } +} + +// 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 diff --git a/internal/vim/operators.go b/internal/vim/operators.go new file mode 100644 index 0000000..64169e0 --- /dev/null +++ b/internal/vim/operators.go @@ -0,0 +1,778 @@ +package vim + +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 + 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), "<br>") { + 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.inVisual() { + 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.inVisual() { + e.exitVisual() + } + 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/vim/registers_test.go b/internal/vim/registers_test.go new file mode 100644 index 0000000..4a100bf --- /dev/null +++ b/internal/vim/registers_test.go @@ -0,0 +1,81 @@ +package vim + +import "testing" + +func TestSharedRegistersPreservePasteShape(t *testing.T) { + for _, tc := range []struct{ name, source, yank, target, want string }{ + {"characters", "alpha beta", "yiw", "123", "1alpha23"}, + {"lines", "alpha\nsecond", "yy", "target", "target\nalpha"}, + {"block", "abXY\ncdZW", "<C-v>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(`"ayy`) + second.Feed(`"Ayy`) + 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("dd") + second.Feed("dd") + 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("yy") + second.Feed(`"_ddp`) + 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("<C-v>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/vim/search.go b/internal/vim/search.go new file mode 100644 index 0000000..4a05e36 --- /dev/null +++ b/internal/vim/search.go @@ -0,0 +1,458 @@ +package vim + +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) { + if e.lastSearch == "" { + e.setError("E35: No previous regular expression") + return from, false + } + re, err := e.compilePattern(e.lastSearch) + if err != nil { + e.setError("E486: Pattern not found: " + e.lastSearch) + return from, false + } + p := from + for i := 0; i < n; i++ { + next, wrapped, ok := e.findMatch(re, p, dir) + if !ok { + e.setError("E486: Pattern not found: " + e.lastSearch) + return 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 = next + } + e.highlight = true + return p, true +} + +// findMatch is the next match strictly after (or before) from. +func (e *Editor) findMatch(re *regexp.Regexp, from Pos, dir int) (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, 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]}, wrapped, true + } + } + return 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, 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]}, wrapped, true + } + } + return 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/vim/textobjects.go b/internal/vim/textobjects.go new file mode 100644 index 0000000..d1f81a9 --- /dev/null +++ b/internal/vim/textobjects.go @@ -0,0 +1,389 @@ +package vim + +// 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 `<x>...</x>` 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/vim/visual.go b/internal/vim/visual.go new file mode 100644 index 0000000..250b40b --- /dev/null +++ b/internal/vim/visual.go @@ -0,0 +1,564 @@ +package vim + +import ( + "math" + "strings" + "unicode" +) + +func (e *Editor) enterVisual(mode Mode) { + if e.inVisual() { + if e.mode == mode { + e.exitVisual() + return + } + e.mode = mode + return + } + e.visualAnchor = e.cursor + e.mode = mode + e.pending = nil +} + +func (e *Editor) exitVisual() { + start, end, mode, _ := e.VisualRange() + e.lastVisual.start, e.lastVisual.end, e.lastVisual.mode, e.lastVisual.set = start, end, mode, true + e.marks['<'] = start + e.marks['>'] = end + e.mode = ModeNormal + e.cursor = e.buf.clampNormal(e.cursor) + e.pending = nil +} + +func (e *Editor) reselectVisual() { + if !e.lastVisual.set { + return + } + e.mode = e.lastVisual.mode + e.visualAnchor = e.buf.clampNormal(e.lastVisual.start) + e.cursor = e.buf.clampNormal(e.lastVisual.end) +} + +// VisualRange is the normalized selection: start..end inclusive, the mode, +// and ok when a selection is active. For block mode the columns come from +// BlockColumns. +func (e *Editor) VisualRange() (start, end Pos, mode Mode, ok bool) { + if !e.inVisual() { + return Pos{}, Pos{}, e.mode, false + } + start, end = e.visualAnchor, e.cursor + if end.Less(start) { + start, end = end, start + } + if e.mode == ModeVisualLine { + start.Col = 0 + end.Col = max(0, e.buf.LineLen(end.Line)-1) + } + return start, end, e.mode, true +} + +// 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.visualAnchor.Col, e.cursor.Col) + right = max(e.visualAnchor.Col, e.cursor.Col) + toEnd = e.desiredCol == math.MaxInt32 + return +} + +// visualSpan converts the selection into an operator span. +func (e *Editor) visualSpan() span { + start, end, mode, _ := e.VisualRange() + switch mode { + case ModeVisualLine: + return span{start: Pos{start.Line, 0}, end: Pos{end.Line, 0}, linewise: true} + case ModeVisualBlock: + left, right, toEnd := e.BlockColumns() + return span{start: Pos{start.Line, left}, end: Pos{end.Line, right}, blockwise: true, blockLeft: left, blockRight: right, blockToEnd: toEnd} + } + return span{start: start, end: e.stepInsertPos(end)} +} + +func (e *Editor) visualKey(k Key) { + e.ClearMessage() + e.pending = append(e.pending, k) + status := e.runVisual(e.pending) + if status != parseIncomplete { + e.pending = nil + } +} + +func (e *Editor) runVisual(keys []Key) parseStatus { + register, count, hasCount, rest, status := splitPrefix(keys) + if status != parseComplete { + return status + } + 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.ensureCursorVisible() + return parseComplete + } + switch { + case k.Is("esc") || k.IsCtrl('c') || k.IsCtrl('['): + e.exitVisual() + return parseComplete + case k.IsRune('v'): + e.enterVisual(ModeVisual) + return parseComplete + case k.IsRune('V'): + e.enterVisual(ModeVisualLine) + return parseComplete + case k.IsCtrl('v'): + e.enterVisual(ModeVisualBlock) + return parseComplete + case k.IsRune('o'): + e.visualAnchor, e.cursor = e.cursor, e.visualAnchor + e.desiredCol = -1 + return parseComplete + case k.IsRune('O'): + if e.mode == ModeVisualBlock { + e.visualAnchor.Col, e.cursor.Col = e.cursor.Col, e.visualAnchor.Col + } else { + e.visualAnchor, e.cursor = e.cursor, e.visualAnchor + } + e.desiredCol = -1 + return parseComplete + case k.IsRune(':'): + e.exitVisual() + e.openCmdline(':', 0, false) + e.cmd.text = []rune("'<,'>") + e.cmd.pos = len(e.cmd.text) + return parseComplete + case k.IsRune('d') || k.IsRune('x') || k.Is("delete"): + e.beginChange() + sp := e.visualSpan() + e.exitVisual() + e.deleteSpan(sp, register) + return finish(false) + case k.IsRune('X') || k.IsRune('D'): + e.beginChange() + sp := e.visualSpan() + if e.mode == ModeVisualBlock { + sp.blockToEnd = true + } else { + sp = span{start: Pos{sp.start.Line, 0}, end: Pos{sp.end.Line, 0}, linewise: true} + } + e.exitVisual() + e.deleteSpan(sp, register) + return finish(false) + case k.IsRune('y'): + sp := e.visualSpan() + e.exitVisual() + e.yankSpan(sp, register, true) + e.cursor = e.buf.clampNormal(sp.start) + return finish(false) + case k.IsRune('Y'): + sp := e.visualSpan() + sp = span{start: Pos{sp.start.Line, 0}, end: Pos{sp.end.Line, 0}, linewise: true} + e.exitVisual() + e.yankSpan(sp, register, true) + e.cursor = e.buf.clampNormal(Pos{sp.start.Line, e.cursor.Col}) + return finish(false) + case k.IsRune('c') || k.IsRune('s'): + e.beginChange() + sp := e.visualSpan() + e.exitVisual() + entered := e.runOperator("c", sp, register, false) + return finish(entered) + case k.IsRune('C') || k.IsRune('S') || k.IsRune('R'): + e.beginChange() + sp := e.visualSpan() + if e.mode == ModeVisualBlock && k.IsRune('C') { + sp.blockToEnd = true + e.exitVisual() + e.deleteSpan(sp, register) + e.startBlockInsert(sp.start.Line, sp.end.Line, sp.blockLeft, false) + return finish(true) + } + sp = span{start: Pos{sp.start.Line, 0}, end: Pos{sp.end.Line, 0}, linewise: true} + e.exitVisual() + entered := e.runOperator("c", sp, register, false) + return finish(entered) + case k.IsRune('J') || (k.IsRune('g') && len(rest) > 1 && rest[1].IsRune('J')): + e.beginChange() + start, end, _, _ := e.VisualRange() + e.exitVisual() + e.joinLines(start.Line, max(2, end.Line-start.Line+1), k.IsRune('J')) + return finish(false) + case k.IsRune('<') || k.IsRune('>'): + e.beginChange() + start, end, _, _ := e.VisualRange() + e.exitVisual() + 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.VisualRange() + e.exitVisual() + e.cursor = Pos{start.Line, firstNonBlank(e.buf.Line(start.Line))} + return parseComplete + case k.IsRune('~') || k.IsRune('u') || k.IsRune('U'): + e.beginChange() + sp := e.visualSpan() + e.exitVisual() + op := map[rune]string{'~': "g~", 'u': "gu", 'U': "gU"}[k.Rune] + 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.exitVisual() + return parseComplete + } + e.beginChange() + sp := e.visualSpan() + e.exitVisual() + e.replaceSpanWith(sp, rest[1]) + return finish(false) + case k.IsRune('p') || k.IsRune('P'): + e.beginChange() + sp := e.visualSpan() + e.exitVisual() + e.putOverSpan(sp, register, n, k.IsRune('p')) + return finish(false) + case k.IsRune('I') || k.IsRune('A'): + if e.mode == ModeVisualBlock { + e.beginChange() + sp := e.visualSpan() + e.exitVisual() + col := sp.blockLeft + appendMode := k.IsRune('A') + if appendMode { + col = sp.blockRight + 1 + if sp.blockToEnd { + col = -1 + } + } + e.startBlockInsert(sp.start.Line, sp.end.Line, col, appendMode) + return finish(true) + } + start, end, _, _ := e.VisualRange() + e.exitVisual() + 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.visualSpan() + e.exitVisual() + e.transformSpan(sp, "g"+string(second.Rune)) + e.cursor = e.buf.clampNormal(sp.start) + return finish(false) + case 'q', 'w': + e.beginChange() + sp := e.visualSpan() + e.exitVisual() + e.reflowLines(sp.start.Line, sp.end.Line) + e.cursor = e.buf.clampNormal(Pos{sp.start.Line, 0}) + return finish(false) + case 'v': + e.reselectVisual() + return parseComplete + case 'd': + e.exitVisual() + 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('i') || k.IsRune('a'): + obj, st := parseTextObject(rest) + if st != parseComplete { + return st + } + e.extendByObject(obj, n) + 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() + start, end, _, _ := e.VisualRange() + e.exitVisual() + delta := n + if k.IsCtrl('x') { + delta = -n + } + for l := start.Line; l <= end.Line; l++ { + saved := e.cursor + e.cursor = Pos{l, 0} + e.incrementNumber(delta) + e.cursor = saved + } + e.cursor = e.buf.clampNormal(start) + return finish(false) + case k.IsRune('z'): + if len(rest) < 2 { + return parseIncomplete + } + if rest[1].IsRune('f') { + start, end, _, _ := e.VisualRange() + e.exitVisual() + 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 + } + e.extendByMotion(m, count, hasCount) + return parseComplete +} + +func (e *Editor) extendByMotion(m motion, count int, hasCount bool) { + from := e.cursor + res, ok := e.applyMotion(m, count, hasCount, e.cursor, false) + if !ok { + return + } + 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 + } + if rng.linewise && e.mode == ModeVisual { + e.mode = ModeVisualLine + } + // Extend rather than replace when the selection already spans more than + // the object. + if e.visualAnchor.Less(rng.start) && e.visualAnchor != e.cursor { + e.cursor = rng.end + return + } + e.visualAnchor = rng.start + e.cursor = rng.end + e.desiredCol = -1 +} + +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 unless P is used. +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} + } + } + 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) + } +} + +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" +} From 9a2aaac04bfa4462a58face13f3443462a20ad2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Tue, 20 Oct 2026 16:09:02 +0000 Subject: [PATCH 03/19] Cleaup configuration motions --- README.md | 2 +- internal/config/prefs.go | 16 +++++++++------- internal/config/prefs_validate.go | 4 ++-- internal/config/prefs_values.go | 4 ++-- internal/tui/editor.go | 2 +- internal/tui/helpdata.go | 2 +- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 62ce59f..35ea97e 100644 --- a/README.md +++ b/README.md @@ -525,7 +525,7 @@ and preview, `Alt+.` for zen) work where the terminal sends Option as Alt ### Editing The editor ships two motion engines: Vim (default) and Helix. Pick one with -`[helix] motions = "vim" | "helix"` in `config.toml` (or Settings). The Vim +`[editor] 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` diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 5ff3402..708630f 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -14,12 +14,14 @@ type Prefs struct { HelixWrappedLineMotions string IgnoredKeys []string HelixMode bool - Motions string - HelixInsertEscape string - HelixYankToClipboard bool - WhichKeyHints bool - WhichKeyHintMode string - WhichKeyHintTimeoutMs int + // Motions picks the modal engine: "vim" (default) or "helix". It is + // [editor] motions in config.toml. + Motions string + HelixInsertEscape string + HelixYankToClipboard bool + WhichKeyHints bool + WhichKeyHintMode string + WhichKeyHintTimeoutMs int SyncTitleHeadingOnRename bool EditorTabSize int @@ -165,7 +167,6 @@ func applyPrefs(p *Prefs, doc map[string]any) { } } 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) @@ -174,6 +175,7 @@ func applyPrefs(p *Prefs, doc map[string]any) { setInt(modal, "which_key_hint_timeout_ms", &p.WhichKeyHintTimeoutMs) editor := section("editor") + setString(editor, "motions", &p.Motions) setBool(editor, "sync_title_heading_on_rename", &p.SyncTitleHeadingOnRename) setInt(editor, "tab_size", &p.EditorTabSize) setInt(editor, "scroll_off", &p.EditorScrollOff) diff --git a/internal/config/prefs_validate.go b/internal/config/prefs_validate.go index ac816d1..259ab4e 100644 --- a/internal/config/prefs_validate.go +++ b/internal/config/prefs_validate.go @@ -57,9 +57,9 @@ func validateKnownValues(doc map[string]any) error { } } enums := map[string]map[string][]string{ - "helix": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}, "motions": {"helix", "vim"}}, + "helix": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}}, "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"}}, + "editor": {"motions": {"helix", "vim"}, "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"}}, "terminal": {"images": {"auto", "kitty", "off"}}, diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index 0250c77..89552bd 100644 --- a/internal/config/prefs_values.go +++ b/internal/config/prefs_values.go @@ -9,8 +9,8 @@ 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{ - "helix": {"enabled": p.HelixMode, "motions": p.Motions, "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}, + "helix": {"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": {"motions": p.Motions, "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}, "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme}, diff --git a/internal/tui/editor.go b/internal/tui/editor.go index 1d45e16..db768f4 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -5,7 +5,7 @@ import ( "github.com/ZenNotes/tui/internal/vim" ) -// modalEditor is the editing engine behind buffers. The [helix] motions +// modalEditor is the editing engine behind buffers. The [editor] 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. diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index 595ad58..f8c1c0b 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -62,7 +62,7 @@ func helpLines(a *App, filter string) []string { leader + " ;\tCommand palette (every ex command, searchable)", leader + " ?\tThis manual", "Leader hints appear immediately; timed expires the sequence, sticky keeps it open (helix.which_key_hint_mode).", - "Motions engine: [helix] motions = vim (operator-pending, default) or helix (selection-first).", + "Motions engine: [editor] motions = vim (operator-pending, default) or helix (selection-first).", "", "## Editor: motions", "h j k l 0 ^ $\tMoves; word and find motions select as they travel", From 31c90e5c19ba27a9c67cc115a60ee60779717fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Tue, 20 Oct 2026 16:19:05 +0000 Subject: [PATCH 04/19] Fix(keys): gn and gp only switch tabs with helix motions The vim engine leaves gn/gp with the engine (a no-op) instead of switching tabs; hints only advertise them under helix motions. --- README.md | 5 +++-- internal/tui/helpdata.go | 2 +- internal/tui/hints.go | 6 +++++- internal/tui/input.go | 7 +++++-- internal/tui/motions_engine_test.go | 6 ++++-- internal/tui/views.go | 8 ++++++-- internal/tui/views_home.go | 6 +++++- 7 files changed, 29 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 35ea97e..ea44414 100644 --- a/README.md +++ b/README.md @@ -530,8 +530,9 @@ 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 (`gn` `gp`, `gt` `gT`, `]b` `[b`), the leader, registers and -macros work in either engine. +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 editor also has counts, registers, marks, macros, dot-repeat, undo and redo, text objects, search, `:s` `:g` `:sort` `:norm` with ranges, `gq`, and diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index f8c1c0b..ec41e33 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -107,7 +107,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 <note> :new <title> :vs :sp :only\tOpen, create, split", - ":bn :bp ]b [b gt gT gn gp\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 83f7296..a24a9fa 100644 --- a/internal/tui/hints.go +++ b/internal/tui/hints.go @@ -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{"helix.bufferNext|helix.bufferPrevious|helix.gotoTabNext|helix.gotoTabPrevious", "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 549d3c0..d0ba394 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -362,8 +362,11 @@ func (a *App) editorKey(buf *noteBuffer, k helix.Key) { a.jumpForward() return } - seqIDs := []string{"helix.bufferPrevious", "helix.bufferNext", "helix.tabPrevious", "helix.tabNext", - "helix.gotoTabPrevious", "helix.gotoTabNext"} + // 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([]helix.Key{}, a.navKeys...), k) a.navKeys = nil diff --git a/internal/tui/motions_engine_test.go b/internal/tui/motions_engine_test.go index 0de5e58..7720009 100644 --- a/internal/tui/motions_engine_test.go +++ b/internal/tui/motions_engine_test.go @@ -54,10 +54,12 @@ func TestVimMotionsKeepAppKeys(t *testing.T) { } 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 != "a.md" { - t.Fatalf("gn did not switch tabs with the vim engine, active %q", got) + 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") diff --git a/internal/tui/views.go b/internal/tui/views.go index 4e5445c..7e43e6a 100644 --- a/internal/tui/views.go +++ b/internal/tui/views.go @@ -175,8 +175,12 @@ func (a *App) listNav(c *listCursor, k helix.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", - "helix.gotoTabPrevious", "helix.gotoTabNext") + 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 } diff --git a/internal/tui/views_home.go b/internal/tui/views_home.go index 738598a..b5edc85 100644 --- a/internal/tui/views_home.go +++ b/internal/tui/views_home.go @@ -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"}, {"helix.gotoTabNext|helix.gotoTabPrevious", "next/previous tab"}} + 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) { From a19271d67e16d618837aba9905e3e6943d997f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Tue, 20 Oct 2026 16:26:44 +0000 Subject: [PATCH 05/19] Docs(readme): consolidate the engine configuration reference Document every [helix] key with a sample block, point [editor] motions at the engine choice, and refresh the config table ([helix] replaces the legacy [vim] row, which is still honored). --- README.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ea44414..be4d3fe 100644 --- a/README.md +++ b/README.md @@ -487,7 +487,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 | @@ -553,13 +553,31 @@ Modal editing follows `[helix] enabled` in `config.toml`. With it off, the edito 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 `[helix]` (the legacy `[vim]` section +is still read; a `[helix]` key wins): + +```toml +[helix] +enabled = true # modal editing; false = plain editor +motions = "vim" # ...set per engine: see [editor] motions +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 +``` + +`[editor] motions = "vim" | "helix"` picks the engine for new buffers; +individual keys are overridden under `[keymaps]` and listed by `:keymaps`. + `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; Enter after a code fence creates its closing fence. `:table` adds/removes rows or columns and sets alignment; `:formatting` opens formatting actions. These edits participate in undo. `:format` still formats the whole note. -`vim.wrapped_line_motions = "display"` makes `$`, `I` and `A` use the +`helix.wrapped_line_motions = "display"` makes `$`, `I` and `A` use the visible wrapped row; `"logical"` uses the source line. `Space l e` opens the note in `$VISUAL` or `$EDITOR` at the cursor line and @@ -714,8 +732,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` | +| `[helix]` | `enabled`, `insert_escape`, `yank_to_clipboard`, `which_key_hints`, `which_key_hint_mode`, `wrapped_line_motions` (legacy `[vim]` still read) | +| `[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 | From dbb242883046d14def4e1e044e681cd9548d1a20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Tue, 20 Oct 2026 16:29:12 +0000 Subject: [PATCH 06/19] Docs(config): keep the [vim] section as the canonical engine config motions moves under [vim] next to the keys it governs, the settings view edits [vim] again, and the starter config from zn config shows the vim-shaped keys. A [helix] section from earlier helix-branch builds is still honored when [vim] is absent. --- README.md | 18 +++++++++--------- internal/config/prefs.go | 22 ++++++++++------------ internal/config/prefs_validate.go | 5 ++--- internal/config/prefs_values.go | 4 ++-- internal/tui/app.go | 2 +- internal/tui/buffers.go | 2 +- internal/tui/bulk_notes.go | 2 +- internal/tui/commands.go | 6 ++++-- internal/tui/commands_parity.go | 2 +- internal/tui/comments_panel.go | 2 +- internal/tui/database_test.go | 2 +- internal/tui/editor.go | 2 +- internal/tui/excomplete_test.go | 2 +- internal/tui/helpdata.go | 2 +- internal/tui/io_async_test.go | 2 +- internal/tui/noteops.go | 2 +- internal/tui/overlays.go | 2 +- internal/tui/panels.go | 2 +- internal/tui/pick_overlay.go | 2 +- internal/tui/session.go | 2 +- internal/tui/settings_controls.go | 4 ++-- internal/tui/sidebar.go | 2 +- internal/tui/tagcomplete.go | 2 +- internal/tui/theme_select_test.go | 2 +- internal/tui/version.go | 2 +- internal/tui/views_database.go | 2 +- internal/tui/views_files.go | 2 +- internal/tui/views_lists.go | 2 +- internal/tui/views_tasks.go | 2 +- internal/tui/views_templates.go | 2 +- internal/tui/workflow_parity_test.go | 2 +- 31 files changed, 54 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index be4d3fe..5d1db9c 100644 --- a/README.md +++ b/README.md @@ -525,7 +525,7 @@ and preview, `Alt+.` for zen) work where the terminal sends Option as Alt ### Editing The editor ships two motion engines: Vim (default) and Helix. Pick one with -`[editor] motions = "vim" | "helix"` in `config.toml` (or Settings). The Vim +`[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` @@ -549,17 +549,16 @@ 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. -Modal editing follows `[helix] 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 `[helix]` (the legacy `[vim]` section -is still read; a `[helix]` key wins): +All editor engine settings live under `[vim]`: ```toml -[helix] +[vim] enabled = true # modal editing; false = plain editor -motions = "vim" # ...set per engine: see [editor] motions +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 @@ -568,8 +567,9 @@ which_key_hint_timeout_ms = 2500 wrapped_line_motions = "display" # $, I, A follow the wrapped row ``` -`[editor] motions = "vim" | "helix"` picks the engine for new buffers; -individual keys are overridden under `[keymaps]` and listed by `:keymaps`. +`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` @@ -732,7 +732,7 @@ layout choices do not change these terminal layouts. | Section | Examples | | --- | --- | -| `[helix]` | `enabled`, `insert_escape`, `yank_to_clipboard`, `which_key_hints`, `which_key_hint_mode`, `wrapped_line_motions` (legacy `[vim]` still read) | +| `[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` | diff --git a/internal/config/prefs.go b/internal/config/prefs.go index 708630f..ede0ca8 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -15,7 +15,7 @@ type Prefs struct { IgnoredKeys []string HelixMode bool // Motions picks the modal engine: "vim" (default) or "helix". It is - // [editor] motions in config.toml. + // [vim] motions in config.toml. Motions string HelixInsertEscape string HelixYankToClipboard bool @@ -155,18 +155,16 @@ func applyPrefs(p *Prefs, doc map[string]any) { m, _ := doc[name].(map[string]any) return m } - // The editor section was [vim] before Helix: a key set in [helix] wins, - // otherwise the legacy table still applies. + // The modal engine section stays [vim], the name it has always had; the + // motions key picks which engine (vim or helix) runs behind it. A + // [helix] section written by older helix-branch builds is still honored + // when [vim] is absent. modal := section("vim") - if helix := section("helix"); helix != nil { - if modal == nil { - modal = map[string]any{} - } - for k, v := range helix { - modal[k] = v - } + 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) @@ -175,7 +173,6 @@ func applyPrefs(p *Prefs, doc map[string]any) { setInt(modal, "which_key_hint_timeout_ms", &p.WhichKeyHintTimeoutMs) editor := section("editor") - setString(editor, "motions", &p.Motions) setBool(editor, "sync_title_heading_on_rename", &p.SyncTitleHeadingOnRename) setInt(editor, "tab_size", &p.EditorTabSize) setInt(editor, "scroll_off", &p.EditorScrollOff) @@ -300,8 +297,9 @@ func setInt(m map[string]any, key string, dst *int) { // no config.toml exists yet. Every key matches the desktop app's names. const DefaultConfigTOML = `# ZenNotes portable preferences. Shared by the desktop app and zn. -[helix] +[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 259ab4e..515ab10 100644 --- a/internal/config/prefs_validate.go +++ b/internal/config/prefs_validate.go @@ -57,9 +57,8 @@ func validateKnownValues(doc map[string]any) error { } } enums := map[string]map[string][]string{ - "helix": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}}, - "vim": {"which_key_hint_mode": {"timed", "sticky", "instant"}, "wrapped_line_motions": {"display", "logical"}}, - "editor": {"motions": {"helix", "vim"}, "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"}}, + "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"}}, "terminal": {"images": {"auto", "kitty", "off"}}, diff --git a/internal/config/prefs_values.go b/internal/config/prefs_values.go index 89552bd..82c40f9 100644 --- a/internal/config/prefs_values.go +++ b/internal/config/prefs_values.go @@ -9,8 +9,8 @@ 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{ - "helix": {"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": {"motions": p.Motions, "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}, + "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}, "terminal": {"mouse": p.TerminalMouse, "preview_style": p.PreviewStyle, "images": p.TerminalImages, "theme": p.TerminalTheme}, diff --git a/internal/tui/app.go b/internal/tui/app.go index bb367f1..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/helix" ) // Options configure a terminal session. diff --git a/internal/tui/buffers.go b/internal/tui/buffers.go index 2cef2ad..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" ) // noteBuffer is an open note: its Vim editor plus save state. One buffer diff --git a/internal/tui/bulk_notes.go b/internal/tui/bulk_notes.go index 4f75781..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" "path" "sort" "strings" diff --git a/internal/tui/commands.go b/internal/tui/commands.go index e7aaac8..f455855 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/helix" ) // command is one ex command the host owns, also listed in the palette. @@ -537,7 +537,9 @@ func (a *App) commandTable() []command { }}, {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{"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 { diff --git a/internal/tui/commands_parity.go b/internal/tui/commands_parity.go index d040567..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/helix" ) // The desktop's command palette, ported. Everything here mirrors an entry diff --git a/internal/tui/comments_panel.go b/internal/tui/comments_panel.go index bd8b369..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" tea "github.com/charmbracelet/bubbletea" "strings" "time" diff --git a/internal/tui/database_test.go b/internal/tui/database_test.go index 5beecb9..e8e4d14 100644 --- a/internal/tui/database_test.go +++ b/internal/tui/database_test.go @@ -10,8 +10,8 @@ import ( "github.com/ZenNotes/tui/internal/backend" "github.com/ZenNotes/tui/internal/config" "github.com/ZenNotes/tui/internal/database" - "github.com/ZenNotes/tui/internal/keymaps" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/keymaps" ) func enterKey() helix.Key { return helix.Key{Name: "enter"} } diff --git a/internal/tui/editor.go b/internal/tui/editor.go index db768f4..bad639e 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -5,7 +5,7 @@ import ( "github.com/ZenNotes/tui/internal/vim" ) -// modalEditor is the editing engine behind buffers. The [editor] motions +// 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. diff --git a/internal/tui/excomplete_test.go b/internal/tui/excomplete_test.go index 778aaca..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/helix" ) func testAppWithNotes() *App { diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index ec41e33..e21fc69 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -62,7 +62,7 @@ func helpLines(a *App, filter string) []string { leader + " ;\tCommand palette (every ex command, searchable)", leader + " ?\tThis manual", "Leader hints appear immediately; timed expires the sequence, sticky keeps it open (helix.which_key_hint_mode).", - "Motions engine: [editor] motions = vim (operator-pending, default) or helix (selection-first).", + "Motions engine: [vim] motions = vim (operator-pending, default) or helix (selection-first).", "", "## Editor: motions", "h j k l 0 ^ $\tMoves; word and find motions select as they travel", diff --git a/internal/tui/io_async_test.go b/internal/tui/io_async_test.go index 6028354..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" ) type slowIOBackend struct { diff --git a/internal/tui/noteops.go b/internal/tui/noteops.go index 1f93cc0..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/helix" ) // createNote makes a note and opens it. diff --git a/internal/tui/overlays.go b/internal/tui/overlays.go index dccdf80..be60e60 100644 --- a/internal/tui/overlays.go +++ b/internal/tui/overlays.go @@ -8,9 +8,9 @@ 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/helix" ) // overlay is a modal surface: a palette, prompt, menu or dialog. diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 8c516bc..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/helix" ) // --- Outline --- diff --git a/internal/tui/pick_overlay.go b/internal/tui/pick_overlay.go index 301c7bf..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/search" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/search" ) // pickItem is one checkable entry of a pickOverlay. diff --git a/internal/tui/session.go b/internal/tui/session.go index 55a6828..4e5218e 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -58,7 +58,7 @@ type sessionPane struct { type sessionPosition struct { Cursor helix.Pos `json:"cursor"` - Scroll int `json:"scroll"` + Scroll int `json:"scroll"` } func sessionPath() string { diff --git a/internal/tui/settings_controls.go b/internal/tui/settings_controls.go index e4aec29..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" "sort" "strconv" "strings" @@ -40,7 +40,7 @@ func (v *settingsView) refresh(a *App) { } } tables := config.ValueTables(a.prefs.Prefs) - for _, section := range []string{"helix", "editor", "appearance", "view", "terminal"} { + for _, section := range []string{"vim", "editor", "appearance", "view", "terminal"} { keys := []string{} for k := range tables[section] { keys = append(keys, k) diff --git a/internal/tui/sidebar.go b/internal/tui/sidebar.go index fad8ec3..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" ) // sidebarRow is one visible line of the sidebar tree. diff --git a/internal/tui/tagcomplete.go b/internal/tui/tagcomplete.go index 2e31533..b08d68c 100644 --- a/internal/tui/tagcomplete.go +++ b/internal/tui/tagcomplete.go @@ -7,8 +7,8 @@ import ( "strings" "unicode" - "github.com/ZenNotes/tui/internal/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" ) // Tag completion. Typing `#` and a letter in a note offers the vault's diff --git a/internal/tui/theme_select_test.go b/internal/tui/theme_select_test.go index 11e2036..3e6736a 100644 --- a/internal/tui/theme_select_test.go +++ b/internal/tui/theme_select_test.go @@ -10,8 +10,8 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/ZenNotes/tui/internal/config" - "github.com/ZenNotes/tui/internal/themes" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/themes" ) func themedApp(t *testing.T, prefs config.Prefs) *App { diff --git a/internal/tui/version.go b/internal/tui/version.go index 559d842..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/selfupdate" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/selfupdate" ) type versionView struct { diff --git a/internal/tui/views_database.go b/internal/tui/views_database.go index eb3defb..336bfe4 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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" ) // databaseView is a `.base` folder or loose `.csv` opened as a grid. The diff --git a/internal/tui/views_files.go b/internal/tui/views_files.go index 5881ab9..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" tea "github.com/charmbracelet/bubbletea" "os" "path/filepath" diff --git a/internal/tui/views_lists.go b/internal/tui/views_lists.go index d93eb57..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" ) // --- note list views: Quick Notes, Archive, Trash --- diff --git a/internal/tui/views_tasks.go b/internal/tui/views_tasks.go index 3b48c13..ac6b711 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/helix" ) // tasksView is the Tasks tab: list, board and calendar modes. diff --git a/internal/tui/views_templates.go b/internal/tui/views_templates.go index cd0e64d..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/helix" "strings" ) diff --git a/internal/tui/workflow_parity_test.go b/internal/tui/workflow_parity_test.go index d6a9938..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/vault" "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vault" ) func TestInitialRemoteFailureStillRetriesPolling(t *testing.T) { From fd4252ac90f469aae3ed5a6031537e0f175a75da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Tue, 20 Oct 2026 16:55:36 +0000 Subject: [PATCH 07/19] Fix(helix): typing in insert mode must not grow a selection startInsert now collapses the anchor, and VisualRange never reports a selection in insert mode: the anchor stayed where insert began while the cursor advanced with every typed character, so the host drew a growing highlight around normal typing. --- internal/helix/editor_test.go | 34 ++++++++++++++++++++++++++++++++++ internal/helix/operators.go | 3 +++ internal/helix/visual.go | 12 ++++++------ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/internal/helix/editor_test.go b/internal/helix/editor_test.go index 1b7d1ae..cde514e 100644 --- a/internal/helix/editor_test.go +++ b/internal/helix/editor_test.go @@ -547,3 +547,37 @@ func TestSelectorMotions(t *testing.T) { 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("<Esc>") + 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<Esc> 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/operators.go b/internal/helix/operators.go index a2d7575..9a517d0 100644 --- a/internal/helix/operators.go +++ b/internal/helix/operators.go @@ -563,6 +563,9 @@ func (e *Editor) put(register rune, n int, after bool, cursorAfter bool) { 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 diff --git a/internal/helix/visual.go b/internal/helix/visual.go index 78b81ea..6c669ed 100644 --- a/internal/helix/visual.go +++ b/internal/helix/visual.go @@ -93,12 +93,12 @@ func (e *Editor) SelectionKind() string { // 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. -// 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. +// 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 != ModeSelect && e.anchor == e.cursor { + if e.mode == ModeInsert || (e.mode != ModeSelect && e.anchor == e.cursor) { return Pos{}, Pos{}, e.mode, false } start, end = e.selRange() @@ -135,7 +135,7 @@ func (e *Editor) BlockColumns() (left, right int, toEnd bool) { // 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 { + 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() From bdf6e64f6b340e6d125eb09d9a9e008e4d0b3a65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Wed, 21 Oct 2026 13:08:37 +0000 Subject: [PATCH 08/19] Fix some selection issues with helix bindings --- README.md | 11 + internal/helix/editor.go | 49 ++- internal/helix/editor_test.go | 9 +- internal/helix/expand.go | 404 +++++++++++++++++++++++++ internal/helix/expand_test.go | 443 ++++++++++++++++++++++++++++ internal/helix/motions.go | 195 +++++++++++- internal/helix/visual.go | 49 ++- internal/tui/editor.go | 5 + internal/tui/editorview.go | 2 +- internal/tui/expand_test.go | 38 +++ internal/tui/helpdata.go | 4 +- internal/tui/motions_engine_test.go | 21 ++ 12 files changed, 1199 insertions(+), 31 deletions(-) create mode 100644 internal/helix/expand.go create mode 100644 internal/helix/expand_test.go create mode 100644 internal/tui/expand_test.go diff --git a/README.md b/README.md index 5d1db9c..d7b41b0 100644 --- a/README.md +++ b/README.md @@ -534,6 +534,17 @@ 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 `]]` `[[`. diff --git a/internal/helix/editor.go b/internal/helix/editor.go index 05f7636..4d3ac6e 100644 --- a/internal/helix/editor.go +++ b/internal/helix/editor.go @@ -255,10 +255,13 @@ type Editor struct { // behind (the selector motions); finishNormal collapses stale anchors // after every other normal-mode command. motionSelected bool - // selExcl marks a selection whose upper-bound character is not part of - // it: word-start motions select up to the next word's first character - // without including it (Helix keeps that char as the exclusive head). - selExcl 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 @@ -451,6 +454,9 @@ func (e *Editor) RegisterText(r rune) (Register, bool) { // --- 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 } @@ -948,6 +954,14 @@ func (e *Editor) normalCommand(rest []Key, count int, hasCount bool, register ru 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 @@ -1323,10 +1337,29 @@ func (e *Editor) selectByMotion(m motion, count int, hasCount bool) { e.pushJump() } e.motionSelected = true - // Word-start motions stop at the next word's first character without - // selecting it; every other selector includes its target. - e.selExcl = m.kind == "w" || m.kind == "W" || m.kind == "b" || m.kind == "B" - e.anchor = e.buf.clampNormal(from) + // 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) diff --git a/internal/helix/editor_test.go b/internal/helix/editor_test.go index cde514e..7a9932b 100644 --- a/internal/helix/editor_test.go +++ b/internal/helix/editor_test.go @@ -55,9 +55,11 @@ func TestMotions(t *testing.T) { {"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 crosses lines", text, Pos{0, 8}, "w", text, Pos{1, 0}, ModeNormal, nil}, - {"w to blank line", text, Pos{1, 5}, "w", text, Pos{2, 0}, 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}, @@ -505,9 +507,10 @@ func TestSelectorMotions(t *testing.T) { // 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 backwards", "one two", Pos{0, 4}, "bd", "two", Pos{0, 0}, 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}, 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("<a-o>") + 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("<a-o>") + 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("<a-o><a-o>") + // 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("<a-i>") + 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("<a-i>") + 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("<a-i>") + 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("<a-i>") + 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("<a-o>") + 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("<a-i>") + 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("<a-o>") + 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("<a-up>") + 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("<a-down>") + 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("<a-o><a-o>") + 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("<a-o>") + 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("<a-o>") + 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<a-i>") + 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/motions.go b/internal/helix/motions.go index ca5cac3..adbca19 100644 --- a/internal/helix/motions.go +++ b/internal/helix/motions.go @@ -15,8 +15,10 @@ type motion struct { // motionResult is where a motion lands and how an operator treats it. type motionResult struct { - pos Pos - linewise bool + 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 @@ -25,6 +27,19 @@ type motionResult struct { // 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. @@ -219,14 +234,47 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp case "w", "W": big := m.kind == "W" p := from + inclusive := false for i := 0; i < n; i++ { - next, ok := e.wordForward(p, big, forOperator && i == n-1) + 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 @@ -245,7 +293,19 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp } 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 @@ -462,6 +522,127 @@ func (e *Editor) wordForward(p Pos, big bool, lastForOperator bool) (Pos, bool) 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 { @@ -886,12 +1067,8 @@ var _ = unicode.IsLetter // *). 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.selRange() - at := e.stepInsertPos(to) - if e.selExcl { - at = to - } - word := e.buf.Range(from, at) + from, to := e.selectionChars() + word := e.buf.Range(from, e.stepInsertPos(to)) if word == "" { return } diff --git a/internal/helix/visual.go b/internal/helix/visual.go index 6c669ed..c038bb1 100644 --- a/internal/helix/visual.go +++ b/internal/helix/visual.go @@ -101,10 +101,7 @@ 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.selRange() - if e.selExcl && start.Less(end) { - end = e.prevCharPos(end) - } + start, end = e.selectionChars() if e.kind == selLine { start.Col = 0 end.Col = max(0, e.buf.LineLen(end.Line)-1) @@ -112,6 +109,25 @@ func (e *Editor) VisualRange() (start, end Pos, mode Mode, ok bool) { 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 { @@ -123,6 +139,17 @@ func (e *Editor) prevCharPos(p Pos) Pos { 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) { @@ -146,11 +173,9 @@ func (e *Editor) selectionSpan() span { 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} } - if e.selExcl { - // The upper-bound character sits past the selection: `w` stops on - // the next word's first char without selecting it. - return span{start: from, end: to} - } + // 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)} } @@ -441,6 +466,12 @@ func (e *Editor) runSelect(keys []Key) parseStatus { 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 diff --git a/internal/tui/editor.go b/internal/tui/editor.go index bad639e..0b79b69 100644 --- a/internal/tui/editor.go +++ b/internal/tui/editor.go @@ -11,6 +11,7 @@ import ( // 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) @@ -75,6 +76,10 @@ func (w vimEditor) Mode() helix.Mode { // 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) { diff --git a/internal/tui/editorview.go b/internal/tui/editorview.go index 46f49f0..274846a 100644 --- a/internal/tui/editorview.go +++ b/internal/tui/editorview.go @@ -288,7 +288,7 @@ 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() + cur := ed.BlockCursor() vStart, vEnd, _, inVisual := ed.VisualRange() vKind := ed.SelectionKind() blockLeft, blockRight, blockToEnd := 0, 0, false 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/helpdata.go b/internal/tui/helpdata.go index e21fc69..d602f56 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -66,11 +66,13 @@ func helpLines(a *App, filter string) []string { "", "## Editor: motions", "h j k l 0 ^ $\tMoves; word and find motions select as they travel", - "w b e W B E\tSelect to the next or previous word, so wd deletes a word", + "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", diff --git a/internal/tui/motions_engine_test.go b/internal/tui/motions_engine_test.go index 7720009..d4a3f92 100644 --- a/internal/tui/motions_engine_test.go +++ b/internal/tui/motions_engine_test.go @@ -42,6 +42,27 @@ func TestMotionsOptionPicksTheEngine(t *testing.T) { } } +// 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) { From 7ca1429a1a1e171b5f89d8e38b72aabe8e026fda Mon Sep 17 00:00:00 2001 From: leon <leon@mac> Date: Sun, 18 Oct 2026 11:29:55 -0400 Subject: [PATCH 09/19] Fix(tui): preview keeps multi-word wikilinks from scrolling the whole screen Glamour styles each word, so a token like [[double brackets]] reaches styleInline with escape codes inside it. The link style renders rune by rune, splitting those codes into visible text; the row grew wider than the pane, the terminal wrapped it and the whole display scrolled. Strip the codes from the token before styling it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- internal/tui/glamour_test.go | 26 ++++++++++++++++++++++++++ internal/tui/previewglamour.go | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/tui/glamour_test.go b/internal/tui/glamour_test.go index de3315f..88a7eab 100644 --- a/internal/tui/glamour_test.go +++ b/internal/tui/glamour_test.go @@ -4,6 +4,10 @@ import ( "strings" "testing" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" + "github.com/ZenNotes/tui/internal/keymaps" "github.com/ZenNotes/tui/internal/search" ) @@ -117,3 +121,25 @@ func TestKeysHintFollowsRebinding(t *testing.T) { t.Fatalf("fit: %q", fitHint("a b · c d · e f", 9)) } } + +// Glamour styles each word, so a multi-word wikilink token carries escape +// codes; styling the token must not split them into visible text that +// widens the row and makes the terminal wrap it. +func TestGlamourMultiWordWikilinkKeepsRowWidth(t *testing.T) { + profile := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(profile) }) + a := &App{prefs: prefsView{}, theme: buildTheme(true)} + a.prefs.PreviewStyle = "dark" + lines, err := a.renderGlamourLines("- jump between notes with [[double brackets]]\n", 60) + if err != nil { + t.Fatal(err) + } + row := lines[0].text + if w := ansi.StringWidth(row); w > 60 { + t.Fatalf("row is %d cells wide, want <= 60: %q", w, row) + } + if plain := ansi.Strip(row); !strings.Contains(plain, "double brackets") || strings.Contains(plain, "[0m") { + t.Fatalf("link text mangled: %q", plain) + } +} diff --git a/internal/tui/previewglamour.go b/internal/tui/previewglamour.go index 906a506..9e6495b 100644 --- a/internal/tui/previewglamour.go +++ b/internal/tui/previewglamour.go @@ -321,7 +321,9 @@ func styleInline(row string, re *regexp.Regexp, group int, style lipgloss.Style, // Drop the token brackets, keep the inner text. text = row[m[2*group+2]:m[2*group+3]] } - out.WriteString("\x1b[0m" + style.Render(text) + activeSGR(row[:s])) + // Glamour styles word by word, so a multi-word token carries escape + // codes; the style renders rune by rune and would split them. + out.WriteString("\x1b[0m" + style.Render(xansi.Strip(text)) + activeSGR(row[:s])) last = e } out.WriteString(row[last:]) From fea68046275d31a3e7b725525306a3e9d754a66a Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Tue, 20 Oct 2026 09:07:47 -0500 Subject: [PATCH 10/19] Chore(homebrew): point the zn formula at v0.9.1 --- packaging/homebrew/Formula/zn.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packaging/homebrew/Formula/zn.rb b/packaging/homebrew/Formula/zn.rb index e4c001d..e93626e 100644 --- a/packaging/homebrew/Formula/zn.rb +++ b/packaging/homebrew/Formula/zn.rb @@ -5,23 +5,23 @@ class Zn < Formula on_macos do on_arm do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_darwin_arm64.tar.gz" - sha256 "1f97fcbf307ccfba03496311bdc04e9708c10e822ac2abdf03ffa7131197f92e" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_darwin_arm64.tar.gz" + sha256 "6ee4b0d9ed60417453bae5aed10c1cb92cd2d1504be79afb5e6b8fb31caed99a" end on_intel do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_darwin_amd64.tar.gz" - sha256 "2f95711520fda368cff318d744b87b3afa98cccefcb34b35d86cce967c4c0bb8" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_darwin_amd64.tar.gz" + sha256 "58c990a91900df44684f942d11907fec79c2712ca1a513278f957328779478c6" end end on_linux do on_arm do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_linux_arm64.tar.gz" - sha256 "b0a08f2575fec89e87f76fcb5dc9a7410c9b8e3327922e587605ccdd3588f2dc" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_linux_arm64.tar.gz" + sha256 "b8a3dd4a3f160c2d5d9184a0a4ca941822d410623ef3c609aa841638dc866a54" end on_intel do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.0/zn_0.9.0_linux_amd64.tar.gz" - sha256 "6ca45da7e8adcbed0037888961239fe4dbb6b960ec87844a8b6fb2439484f523" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_linux_amd64.tar.gz" + sha256 "8064c1282f63ce520ba252c09be4d867c8b8b4417d88c547cd5ea4e74a2a84da" end end From 9e0b3dc32294df22afc3b1d1189fe9ab4318379c Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 22 Oct 2026 08:28:22 -0500 Subject: [PATCH 11/19] Fix(vim): a second $ on a wrapped line reaches the end of the line With soft wrap on and vim.wrapped_line_motions = "display" (the default), a bare `$` stops at the end of the row the cursor is on, as the desktop editor does (#536 there). That part is deliberate. But pressing `$` again on that row found the same row and stayed put, so the true end of a long line was out of reach without `A<Esc>` or switching to logical motions. Reported on Discord by Scotty, who expected nvim's `$`, or at least a second press that gets there. Now a bare `$` that starts on the last character of a row that is not the line's last goes on to the end of the logical line. The first press still ends the visible row; `g$` stays the row motion and never runs past it, and operators keep the row too, so `d$` still deletes only what the user can see. A counted `$` and the "logical" setting behave as before. Engine tests cover the first, second and third press, `g$`, `d$` and `v$$`. The README's wrapped-line sentence and the manual's motion list say how to reach the end. Live in zn tui on a 168-column line: `$` landed on 3:88, `$` again on 3:168, and `g$` on 3:88. --- README.md | 5 ++-- internal/tui/helpdata.go | 1 + internal/vim/display_motion_test.go | 45 +++++++++++++++++++++++++++++ internal/vim/motions.go | 16 +++++++++- 4 files changed, 64 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d7b41b0..f30283c 100644 --- a/README.md +++ b/README.md @@ -588,8 +588,9 @@ enabled, Space after an opening Markdown delimiter adds its closer; Enter after a code fence creates its closing fence. `:table` adds/removes rows or columns and sets alignment; `:formatting` opens formatting actions. These edits participate in undo. `:format` still formats the whole note. -`helix.wrapped_line_motions = "display"` makes `$`, `I` and `A` use the -visible wrapped row; `"logical"` uses the source line. +`vim.wrapped_line_motions = "display"` makes `$`, `I` and `A` use the +visible wrapped row (press `$` again at the end of a row to reach the end of +the line); `"logical"` uses the source line. `Space l e` opens the note in `$VISUAL` or `$EDITOR` at the cursor line and reloads it afterwards. In Vim mode, `Ctrl+Z` suspends to the shell. diff --git a/internal/tui/helpdata.go b/internal/tui/helpdata.go index d602f56..2f40a0a 100644 --- a/internal/tui/helpdata.go +++ b/internal/tui/helpdata.go @@ -76,6 +76,7 @@ func helpLines(a *App, filter string) []string { "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", + "$ $\tOn a wrapped line, $ ends the visible row and a second $ reaches the line end", "", "## Editor: operators and text objects", "d c y > < gu gU g~ = J gq\tOperators; double them for a line (dd, cc, yy, >>)", diff --git a/internal/vim/display_motion_test.go b/internal/vim/display_motion_test.go index 07b2bcd..7b0f8b1 100644 --- a/internal/vim/display_motion_test.go +++ b/internal/vim/display_motion_test.go @@ -44,3 +44,48 @@ func TestDisplayRowBoundariesRespectCountsAndLogicalMode(t *testing.T) { t.Fatal(ed.Cursor()) } } + +func TestSecondDollarOnAWrappedRowReachesTheLineEnd(t *testing.T) { + opts := DefaultOptions() + opts.WrappedLineMotions = true + rows := Hooks{DisplayRowBounds: func(p Pos) (int, int) { + if p.Col < 5 { + return 0, 5 + } + if p.Col < 10 { + return 5, 10 + } + return 10, 14 + }} + ed := New("abcdefghijklmn", opts, rows) + ed.HandleKey(R('$')) + if ed.Cursor().Col != 4 { + t.Fatalf("first $ should stop at the row end: %v", ed.Cursor()) + } + ed.HandleKey(R('$')) + if ed.Cursor().Col != 13 { + t.Fatalf("second $ should reach the line end: %v", ed.Cursor()) + } + ed.HandleKey(R('$')) + if ed.Cursor().Col != 13 { + t.Fatalf("$ at the line end should stay: %v", ed.Cursor()) + } + // g$ is the row motion, so it never runs on past the row. + ed.SetCursor(Pos{0, 4}) + ed.Feed("g$") + if ed.Cursor().Col != 4 { + t.Fatalf("g$ at the row end left the row: %v", ed.Cursor()) + } + // Operators keep the visible row, even from its last character. + ed.SetCursor(Pos{0, 4}) + ed.Feed("d$") + if ed.Text() != "abcdfghijklmn" { + t.Fatalf("d$ at the row end: %q", ed.Text()) + } + // Visual mode extends to the line end the same way. + ed = New("abcdefghijklmn", opts, rows) + ed.Feed("v$$") + if ed.Cursor().Col != 13 || ed.Mode() != ModeVisual { + t.Fatalf("v$$: %v %v", ed.Cursor(), ed.Mode()) + } +} diff --git a/internal/vim/motions.go b/internal/vim/motions.go index d443d05..5bf860d 100644 --- a/internal/vim/motions.go +++ b/internal/vim/motions.go @@ -98,6 +98,20 @@ func parseMotion(keys []Key) (motion, parseStatus) { return motion{}, parseInvalid } +// atWrappedRowEnd reports a cursor already on the last character of a +// soft-wrapped row that is not the line's last. A bare `$` stops at the end +// of the visible row, so pressing it again there goes on to the end of the +// line, where Vim's `$` lands; otherwise the second press did nothing and +// the true end was out of reach. Operators keep the row (`d$` deletes what +// the user can see). +func (e *Editor) atWrappedRowEnd(from Pos, forOperator bool) bool { + if forOperator { + return false + } + start, end := e.hooks.DisplayRowBounds(from) + return end < e.buf.LineLen(from.Line) && from.Col >= max(start, end-1) +} + // 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) { @@ -105,7 +119,7 @@ func (e *Editor) applyMotion(m motion, count int, hasCount bool, from Pos, forOp 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^") { + if e.hooks.DisplayRowBounds != nil && ((m.kind == "$" && n == 1 && e.opts.WrappedLineMotions && !e.atWrappedRowEnd(from, forOperator)) || 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$" { From 18d3ba9cd0691c443206a03b58ee5a178ea60de3 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 22 Oct 2026 08:28:26 -0500 Subject: [PATCH 12/19] Fix(editor): typing ``` makes a code fence instead of four backticks With auto pairs on, the editor gave every backtick a closing partner. The first backtick paired, the second stepped over its partner, and the third paired again, so ``` became ```` with the cursor before the fourth. Enter then split that line into ``` and a stray backtick, and the fence snippet that closes a code block on Enter never got its chance. Reported on Discord by Scotty. A backtick typed right after another backtick now grows the run instead of opening a pair, so ``` (and ```go) stays as typed, and Enter adds the closing fence below with the cursor inside the block, as in the desktop editor. A single backtick still pairs, so `ls` types as before. The desktop never pairs backticks; the terminal keeps its inline pairing and only stops pairing inside a run. Tests: engine cases for ```, ```go and an inline span, and a TUI test that types ```go and Enter through the App's key path. Both failed before this change (```go` and ````). Live in zn tui: ```go then Enter opened the block with ``` below it, and `ls` still paired. --- internal/tui/authoring_test.go | 16 ++++++++++++++++ internal/vim/editor_test.go | 3 +++ internal/vim/insert.go | 7 ++++++- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/internal/tui/authoring_test.go b/internal/tui/authoring_test.go index f99d9b7..07d390a 100644 --- a/internal/tui/authoring_test.go +++ b/internal/tui/authoring_test.go @@ -77,3 +77,19 @@ func TestHelpersRespectLongCodeFences(t *testing.T) { t.Fatal("closing fence left helper disabled") } } + +func TestTypedBacktickFenceClosesOnEnter(t *testing.T) { + a, buf := newTagTestApp(t, vimPrefs(), "# Draft") + feed(a, vim.R('o')) + typeKeys(a, "```go") + if got := buf.ed.Line(1); got != "```go" { + t.Fatalf("typed fence %q", got) + } + feed(a, vim.KeyEnter) + if got := buf.ed.Text(); got != "# Draft\n```go\n\n```" { + t.Fatalf("Enter after the fence: %q", got) + } + if got := buf.ed.Cursor(); got != (vim.Pos{Line: 2, Col: 0}) { + t.Fatalf("cursor should sit inside the block: %v", got) + } +} diff --git a/internal/vim/editor_test.go b/internal/vim/editor_test.go index 08536e7..78be8a1 100644 --- a/internal/vim/editor_test.go +++ b/internal/vim/editor_test.go @@ -199,6 +199,9 @@ func TestInsertMode(t *testing.T) { {"autopair skip", "", Pos{0, 0}, "i(a)<Esc>", "(a)", Pos{0, 2}, ModeNormal, nil}, {"autopair backspace", "", Pos{0, 0}, "i(<BS><Esc>", "", Pos{0, 0}, ModeNormal, nil}, {"no autopair before word", "x", Pos{0, 0}, "i(<Esc>", "(x", Pos{0, 0}, ModeNormal, nil}, + {"autopair backtick span", "", Pos{0, 0}, "i`a`<Esc>", "`a`", Pos{0, 2}, ModeNormal, nil}, + {"backtick fence stays three", "", Pos{0, 0}, "i```<Esc>", "```", Pos{0, 2}, ModeNormal, nil}, + {"backtick fence with language", "", Pos{0, 0}, "i```go<Esc>", "```go", Pos{0, 4}, 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<Esc>", "aj", Pos{0, 1}, ModeNormal, func(o *Options) { o.InsertEscape = "jk" }}, {"text replacement", "", Pos{0, 0}, "ia->b<Esc>", "a→b", Pos{0, 2}, ModeNormal, func(o *Options) { o.TextReplacementsEnabled = true }}, diff --git a/internal/vim/insert.go b/internal/vim/insert.go index 55ac99f..c442b46 100644 --- a/internal/vim/insert.go +++ b/internal/vim/insert.go @@ -234,9 +234,14 @@ func (e *Editor) insertPrintable(r rune) { e.cursor.Col++ return } + // A backtick after a backtick grows a run (a ``` fence, a `` span) + // instead of opening a pair: pairing every one turned ``` into ```` with + // the cursor before the fourth, so Enter split the fence instead of + // closing it. + afterBacktick := e.cursor.Col > 0 && line[e.cursor.Col-1] == '`' 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) { + if r != '`' || ((next == 0 || isBlank(next)) && !afterBacktick) { e.buf.InsertText(e.cursor, string(closer)) e.ins.pairedInsert = e.cursor } From b23584f40819d8b2e13decef3f978dd483463d21 Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 22 Oct 2026 08:50:25 -0500 Subject: [PATCH 13/19] Chore(homebrew): point the zn formula at v0.9.2 --- packaging/homebrew/Formula/zn.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packaging/homebrew/Formula/zn.rb b/packaging/homebrew/Formula/zn.rb index e93626e..b46f723 100644 --- a/packaging/homebrew/Formula/zn.rb +++ b/packaging/homebrew/Formula/zn.rb @@ -5,23 +5,23 @@ class Zn < Formula on_macos do on_arm do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_darwin_arm64.tar.gz" - sha256 "6ee4b0d9ed60417453bae5aed10c1cb92cd2d1504be79afb5e6b8fb31caed99a" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.2/zn_0.9.2_darwin_arm64.tar.gz" + sha256 "0eb85dc50c28855d0929b54928a86797dc1aa545901340ce7052468840d35d2c" end on_intel do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_darwin_amd64.tar.gz" - sha256 "58c990a91900df44684f942d11907fec79c2712ca1a513278f957328779478c6" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.2/zn_0.9.2_darwin_amd64.tar.gz" + sha256 "d6cf4e7007dbb94ec720605dbe3369e20b006226a3ccbe22aa9a85e669493d68" end end on_linux do on_arm do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_linux_arm64.tar.gz" - sha256 "b8a3dd4a3f160c2d5d9184a0a4ca941822d410623ef3c609aa841638dc866a54" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.2/zn_0.9.2_linux_arm64.tar.gz" + sha256 "bb0c21763fe0a5a292fbe4d5a3e905aa17fea5ed7c1c65b5c9a019358a41cbf1" end on_intel do - url "https://github.com/ZenNotes/tui/releases/download/v0.9.1/zn_0.9.1_linux_amd64.tar.gz" - sha256 "8064c1282f63ce520ba252c09be4d867c8b8b4417d88c547cd5ea4e74a2a84da" + url "https://github.com/ZenNotes/tui/releases/download/v0.9.2/zn_0.9.2_linux_amd64.tar.gz" + sha256 "57f0e231f29df65ac4c06e0052e80dbc36f1459a1844af25256cb6e869990b2c" end end From 6f7b2a42b93c19ca750c48bd0ce3b40124368dea Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 22 Oct 2026 11:43:10 -0500 Subject: [PATCH 14/19] Feat(base): zn base pages gives every record its record page A database record is a note links can reach only once it has a page, and `zn base add --page` made one only for a new row; an existing record got its page when someone opened it, one at a time. A database converted from a CSV had none, so none of its records could be linked. `zn base pages <base>` gives every record that has no page its record page, the desktop's "Create pages for all records" (ZenNotes 2.74.0): the row's properties as frontmatter and its title as the heading, as `zn base add --page` writes them. Doc.PlanRecordPages follows the app's rules (planRecordPages in its shared-domain database-records.ts): a record whose page is mapped and still there keeps it, one whose page was deleted gets a new one, and one with an empty title is left out. Ops.RecordPageExists returns a read error as an error rather than as a missing page, so a failing server read stops the run instead of minting a second page for a record that has one. The schema is written once, and kept when a page fails partway, so the pages already written stay linked to their rows. `--dry-run` lists what it would create, `--json` reports the pages, and a loose .csv is refused with a pointer to `zn base convert`. The help row also registers the command and its flags for parsing and completion. On a vault with Books.base holding only its data.csv: --dry-run listed Dune, Kindred and Emma and wrote nothing; the run created the three pages and mapped them in schema.json, relative to the folder as the app keeps them; a second run found all three. The desktop app opened that vault with links to the records live and counted every page as there. Tests: the planner, a read error stopping it, the command's pages and schema, a second run writing nothing, --dry-run, and a loose .csv. go vet and go test ./... clean. --- README.md | 5 +- internal/cli/args.go | 1 + internal/cli/base_pages_test.go | 145 +++++++++++++++++++++++++ internal/cli/commands_base.go | 95 ++++++++++++++++ internal/cli/help.go | 1 + internal/cli/main.go | 1 + internal/database/doc.go | 40 +++++++ internal/database/ops.go | 11 ++ internal/database/record_pages_test.go | 42 +++++++ 9 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 internal/cli/base_pages_test.go create mode 100644 internal/database/record_pages_test.go diff --git a/README.md b/README.md index f30283c..b37ad1b 100644 --- a/README.md +++ b/README.md @@ -234,13 +234,16 @@ zn comment add "Project plan.md" "Can we simplify this?" zn comment list "Project plan.md" --all --json zn base create "Reading list" zn base rows "Reading list" --json +zn base pages "Reading list" ``` Use the stable ID returned by `task list` with `task toggle <id>`. Comment replies and resolve/reopen operations take a thread ID. `base add` and `base set` accept repeated `--set Field=Value` flags for existing fields; `base add --body` or `--page` also creates a record page. `base convert` -turns a loose CSV into a `.base` folder. +turns a loose CSV into a `.base` folder, and `base pages` gives every +record that has no page its record page, so a database converted from a +CSV can be linked right away (`--dry-run` lists what it would create). ### Watching task status diff --git a/internal/cli/args.go b/internal/cli/args.go index d0ce40c..f8a6635 100644 --- a/internal/cli/args.go +++ b/internal/cli/args.go @@ -51,6 +51,7 @@ var valuelessFlags = map[string]bool{ "no-default": true, "no-start": true, "check": true, + "dry-run": true, "rollback": true, "no-edit": true, "page": true, diff --git a/internal/cli/base_pages_test.go b/internal/cli/base_pages_test.go new file mode 100644 index 0000000..b52b567 --- /dev/null +++ b/internal/cli/base_pages_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + "testing" +) + +// `zn base pages` gives every record of a database converted from a CSV its +// record page, so each one can be linked; the desktop's "Create pages for +// all records". +func basePagesVault(t *testing.T) (root, base string) { + t.Helper() + isolatedCLI(t) + root = t.TempDir() + base = filepath.Join(root, "inbox", "Books.base") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + csv := "id,Title,Author\n1,Dune,Herbert\n2,Kindred,Butler\n3,Emma,Austen\n4,,Nobody\n" + if err := os.WriteFile(filepath.Join(base, "data.csv"), []byte(csv), 0o644); err != nil { + t.Fatal(err) + } + return root, base +} + +type basePagesResult struct { + OK bool `json:"ok"` + DryRun bool `json:"dryRun"` + Pages []struct { + ID string `json:"id"` + Title string `json:"title"` + Page string `json:"page"` + } `json:"pages"` + Existing int `json:"existing"` + Untitled int `json:"untitled"` +} + +func runBasePages(t *testing.T, root string, flags ...string) basePagesResult { + t.Helper() + args := append([]string{"base", "pages", "Books", "--json", "--vault", root}, flags...) + out := captureOutput(t, func() { + if code := Main(args); code != 0 { + t.Errorf("zn %v exited %d", args, code) + } + }) + var result basePagesResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("zn %v: %v\n%s", args, err, out) + } + return result +} + +func mdFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + out := []string{} + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".md") { + out = append(out, e.Name()) + } + } + sort.Strings(out) + return out +} + +func TestBasePagesCreatesAPageForEveryTitledRecord(t *testing.T) { + root, base := basePagesVault(t) + result := runBasePages(t, root) + titles := []string{} + for _, p := range result.Pages { + titles = append(titles, p.Title) + } + if !result.OK || result.DryRun || !reflect.DeepEqual(titles, []string{"Dune", "Kindred", "Emma"}) || result.Untitled != 1 || result.Existing != 0 { + t.Fatalf("result: %+v", result) + } + if got := mdFiles(t, base); !reflect.DeepEqual(got, []string{"Dune.md", "Emma.md", "Kindred.md"}) { + t.Fatalf("pages on disk: %v", got) + } + // The page the desktop's own "open" makes: properties as frontmatter, the title as its heading. + page, err := os.ReadFile(filepath.Join(base, "Kindred.md")) + if err != nil || string(page) != "---\nAuthor: Butler\n---\n# Kindred\n\n" { + t.Fatalf("Kindred.md: %q %v", page, err) + } + // schema.json maps each row to its page, relative to the folder as the app keeps it. + raw, err := os.ReadFile(filepath.Join(base, "schema.json")) + if err != nil { + t.Fatal(err) + } + var schema struct { + Pages map[string]string `json:"pages"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatal(err) + } + if len(schema.Pages) != 3 || schema.Pages[result.Pages[1].ID] != "Kindred.md" { + t.Fatalf("schema pages: %v", schema.Pages) + } + + // A second run finds every page and writes nothing. + again := runBasePages(t, root) + if len(again.Pages) != 0 || again.Existing != 3 || again.Untitled != 1 { + t.Fatalf("second run: %+v", again) + } + if got := mdFiles(t, base); len(got) != 3 { + t.Fatalf("second run wrote pages: %v", got) + } +} + +func TestBasePagesDryRunWritesNothing(t *testing.T) { + root, base := basePagesVault(t) + result := runBasePages(t, root, "--dry-run") + if !result.DryRun || len(result.Pages) != 3 || result.Pages[0].Page != "" { + t.Fatalf("dry run: %+v", result) + } + if got := mdFiles(t, base); len(got) != 0 { + t.Fatalf("dry run wrote pages: %v", got) + } +} + +func TestBasePagesRefusesALooseCSV(t *testing.T) { + isolatedCLI(t) + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "inbox"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "inbox", "Books.csv"), []byte("id,Title\n1,Dune\n"), 0o644); err != nil { + t.Fatal(err) + } + var code int + captureOutput(t, func() { code = Main([]string{"base", "pages", "Books", "--vault", root}) }) + if code == 0 { + t.Fatal("a loose .csv has no record pages; zn base pages should refuse it") + } + if entries, _ := os.ReadDir(filepath.Join(root, "inbox")); len(entries) != 1 { + t.Fatalf("refusal wrote files: %v", entries) + } +} diff --git a/internal/cli/commands_base.go b/internal/cli/commands_base.go index dafa86b..173bd9e 100644 --- a/internal/cli/commands_base.go +++ b/internal/cli/commands_base.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "github.com/ZenNotes/tui/internal/backend" @@ -463,3 +464,97 @@ func cmdBaseConvert(_ context.Context, b backend.Backend, args Args) error { emitOK(fmt.Sprintf("Converted %s to %s", summary.Path, database.FormDirFromCSVPath(next))) return nil } + +// cmdBasePages gives every record without a page its record page: the +// desktop's "Create pages for all records". A record is a note links can +// reach only once it has a page, and pages were made one record at a time, +// so a database converted from a CSV had none. --dry-run lists what it +// would create and writes nothing. +func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { + ops := b.DatabaseOps() + summary, err := resolveDatabase(ops, args.Positional(0)) + if err != nil { + return err + } + if !database.HasRecordPages(summary.Path) { + return fmt.Errorf("%s is a loose .csv, which has no record pages. Run `zn base convert %q` first.", summary.Path, summary.Title) + } + doc, err := ops.OpenDatabase(summary.Path) + if err != nil { + return err + } + plan, err := doc.PlanRecordPages(ops.RecordPageExists) + if err != nil { + return err + } + type pageView struct { + ID string `json:"id"` + Title string `json:"title"` + Page string `json:"page,omitempty"` + } + dryRun := args.Bool("dry-run") + pages := []pageView{} + var failure error + for _, row := range plan.Missing { + title := doc.RecordTitle(row) + if dryRun { + pages = append(pages, pageView{ID: row.ID, Title: title}) + continue + } + page, err := ops.CreateRecordPage(summary.Path, title, doc.ComposePageBody(row, "# "+title+"\n\n")) + if err != nil { + failure = err + break + } + if doc.Pages == nil { + doc.Pages = map[string]string{} + } + doc.Pages[row.ID] = page + pages = append(pages, pageView{ID: row.ID, Title: title, Page: page}) + } + // One schema write for the run, kept when a page fails partway so the + // pages already written stay linked to their rows. + if !dryRun && len(pages) > 0 { + if _, err := ops.WriteSchema(summary.Path, doc); err != nil { + return err + } + } + if failure != nil { + return fmt.Errorf("Created %d of %d record pages in %s, then: %w", len(pages), len(plan.Missing), summary.Title, failure) + } + if args.Bool("json") { + emitJSON(map[string]any{ + "ok": true, + "path": summary.Path, + "dryRun": dryRun, + "pages": pages, + "existing": plan.Existing, + "untitled": plan.Untitled, + }) + return nil + } + noun := func(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return strconv.Itoa(n) + " " + many + } + switch { + case len(pages) == 0: + emitOK("Every titled record in " + summary.Title + " already has a page") + case dryRun: + emitLine("Would create " + noun(len(pages), "record page", "record pages") + " in " + summary.Title + ":") + for _, p := range pages { + emitLine(" " + p.Title) + } + default: + emitOK("Created " + noun(len(pages), "record page", "record pages") + " in " + summary.Title) + } + if plan.Existing > 0 { + emitLine(noun(plan.Existing, "record already had its page", "records already had their pages")) + } + if plan.Untitled > 0 { + emitLine(noun(plan.Untitled, "record has no title and was left out", "records have no title and were left out")) + } + return nil +} diff --git a/internal/cli/help.go b/internal/cli/help.go index 9ae9fa9..7817f3c 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -94,6 +94,7 @@ var helpSections = []helpSection{ {"base add <base>", "Add a row; --body (or --body - for stdin) also creates its record page", "--set Field=Value … --body <text|-> --page --json"}, {"base set <base> <row>", "Set fields; select options mint, the page re-mirrors", "--set Field=Value … --json"}, {"base convert <base>", "Turn a loose .csv into a .base folder so rows can have record pages", "--json"}, + {"base pages <base>", "Give every record without one its record page, so each can be linked", "--dry-run --json"}, }}, {"CAPTURE", []helpRow{ {"capture \"...\"", "Quick add. Pipes stdin if no positional", "--folder <f> --tag <t> --title <t> --json"}, diff --git a/internal/cli/main.go b/internal/cli/main.go index abf2ebe..e435b44 100644 --- a/internal/cli/main.go +++ b/internal/cli/main.go @@ -58,6 +58,7 @@ func dispatchTable() map[string]Handler { "base add": cmdBaseAdd, "base set": cmdBaseSet, "base convert": cmdBaseConvert, + "base pages": cmdBasePages, "capture": cmdCapture, } } diff --git a/internal/database/doc.go b/internal/database/doc.go index bceabdf..d8aac26 100644 --- a/internal/database/doc.go +++ b/internal/database/doc.go @@ -268,6 +268,46 @@ func (d *Doc) ComposePageBody(row Row, body string) string { return strings.Join(lines, "\n") + "\n" + strings.TrimLeft(body, "\n") } +// RecordPagePlan is what giving every record its page would do. +type RecordPagePlan struct { + // Missing are the records with no page, or whose page is gone, in row order. + Missing []Row + // Existing counts the records whose page is mapped and still there. + Existing int + // Untitled counts the records with an empty title field, left without a page. + Untitled int +} + +// PlanRecordPages plans `zn base pages` with the desktop app's rules for +// "Create pages for all records" (planRecordPages in the app's +// shared-domain database-records.ts). A record is a note links can reach +// only once it has a page. One whose page is mapped and still exists keeps +// it; one whose page was deleted gets a new one, as opening it would; one +// with an empty title is left out, since its page would only be "Untitled" +// and nothing could link to it by name. +func (d *Doc) PlanRecordPages(exists func(notePath string) (bool, error)) (RecordPagePlan, error) { + plan := RecordPagePlan{} + titleID := d.TitleFieldID() + for _, row := range d.Rows { + if page := d.Pages[row.ID]; page != "" { + ok, err := exists(page) + if err != nil { + return plan, err + } + if ok { + plan.Existing++ + continue + } + } + if titleID == "" || strings.TrimSpace(row.Cells[titleID]) == "" { + plan.Untitled++ + continue + } + plan.Missing = append(plan.Missing, row) + } + return plan, nil +} + // SetCell writes one cell. func (d *Doc) SetCell(rowID, fieldID, value string) { for i := range d.Rows { diff --git a/internal/database/ops.go b/internal/database/ops.go index 09bd33b..5645c9d 100644 --- a/internal/database/ops.go +++ b/internal/database/ops.go @@ -670,6 +670,17 @@ func (o *Ops) CreateRecordPage(csvPath, title, body string) (string, error) { return noteRel, nil } +// RecordPageExists reports whether a record page note is there. A read error +// comes back as an error, never as a missing page, so a failing read cannot +// mint a second page for a record that has one. +func (o *Ops) RecordPageExists(notePath string) (bool, error) { + text, err := o.io.ReadFileTextOrNull(toPosix(notePath)) + if err != nil { + return false, err + } + return text != nil, nil +} + // RenameDatabase renames the `.base` folder and returns the new csv path. func (o *Ops) RenameDatabase(csvPath, newTitle string) (string, error) { oldFormDir := FormDirFromCSVPath(toPosix(csvPath)) diff --git a/internal/database/record_pages_test.go b/internal/database/record_pages_test.go new file mode 100644 index 0000000..5b18432 --- /dev/null +++ b/internal/database/record_pages_test.go @@ -0,0 +1,42 @@ +package database + +import ( + "errors" + "reflect" + "testing" +) + +// The same rules as the desktop's "Create pages for all records". +func TestPlanRecordPages(t *testing.T) { + d := testDoc(t) + d.Rows = []Row{ + {ID: "r1", Cells: map[string]string{"f0": "r1", "f1": "Dune"}}, + {ID: "r2", Cells: map[string]string{"f0": "r2", "f1": "Emma"}}, + {ID: "r3", Cells: map[string]string{"f0": "r3", "f1": "Kindred"}}, + {ID: "r4", Cells: map[string]string{"f0": "r4", "f1": " "}}, + } + d.Pages = map[string]string{"r1": "Books.base/Dune.md", "r2": "Books.base/Emma.md"} + plan, err := d.PlanRecordPages(func(p string) (bool, error) { return p == "Books.base/Dune.md", nil }) + if err != nil { + t.Fatal(err) + } + ids := []string{} + for _, row := range plan.Missing { + ids = append(ids, row.ID) + } + // Emma's page was deleted, so she gets a new one; the blank title is left out. + if !reflect.DeepEqual(ids, []string{"r2", "r3"}) || plan.Existing != 1 || plan.Untitled != 1 { + t.Fatalf("plan: missing %v, existing %d, untitled %d", ids, plan.Existing, plan.Untitled) + } +} + +// A read that fails is not a missing page: planning stops instead of +// minting a second page for a record that has one. +func TestPlanRecordPagesStopsOnAReadError(t *testing.T) { + d := testDoc(t) + d.Pages = map[string]string{"r1": "Books.base/Dune.md"} + boom := errors.New("server unreachable") + if _, err := d.PlanRecordPages(func(string) (bool, error) { return false, boom }); !errors.Is(err, boom) { + t.Fatalf("want the read error, got %v", err) + } +} From a98fe3bd6e69e7705315572dd4cc72a830f5509e Mon Sep 17 00:00:00 2001 From: Adib Hanna <adibhanna@gmail.com> Date: Thu, 22 Oct 2026 11:53:20 -0500 Subject: [PATCH 15/19] Fix(base): a record's page file already in the folder gets linked, not copied A Discord user's workaround script wrote a page file per record into the database folder without mapping it in schema.json. `zn base pages` took those records for pageless and wrote `Dune 2.md` beside the file, and opening a record in the TUI did the same, as it always had for an unmapped file. A record with no mapped page is now linked to a page file already there under its name, in the database folder or its `pages/` subfolder, under each name a page gets: the app's note-title rules, the slash-only rule zn and the server path use, and the title as it is. Doc.RecordPageCandidates gives the same names as the app's recordPageCandidates (a test pins them to the app's case), and Doc.RecordPageToAdopt picks the first that exists and no row maps. PlanRecordPages lists those records under Adopt, `zn base pages` maps them without writing and reports them as `linked` (and under --dry-run), and the TUI's open links the file before it would create a page. A file another row maps, or an earlier record with the same title took, is never taken twice. Tests: the candidate names, files in the folder and in pages/ linked, one mapped by another row left alone, twins sharing no file, the command linking a script's page as it is, and the TUI open linking instead of writing `Dune 2.md` (which fails without this change). go vet and go test ./... clean. --- README.md | 4 +- internal/cli/base_pages_test.go | 36 +++++++++ internal/cli/commands_base.go | 37 +++++++-- internal/database/doc.go | 107 ++++++++++++++++++++++++- internal/database/record_pages_test.go | 52 ++++++++++++ internal/tui/database_test.go | 35 ++++++++ internal/tui/views_database.go | 15 ++++ 7 files changed, 278 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b37ad1b..dfb24a8 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,9 @@ replies and resolve/reopen operations take a thread ID. `base add` and `base add --body` or `--page` also creates a record page. `base convert` turns a loose CSV into a `.base` folder, and `base pages` gives every record that has no page its record page, so a database converted from a -CSV can be linked right away (`--dry-run` lists what it would create). +CSV can be linked right away. A page file already in the database folder +under a record's name is linked instead of copied, and `--dry-run` lists +what it would do. ### Watching task status diff --git a/internal/cli/base_pages_test.go b/internal/cli/base_pages_test.go index b52b567..b391a27 100644 --- a/internal/cli/base_pages_test.go +++ b/internal/cli/base_pages_test.go @@ -143,3 +143,39 @@ func TestBasePagesRefusesALooseCSV(t *testing.T) { t.Fatalf("refusal wrote files: %v", entries) } } + +// A workaround script wrote a page file per record but no schema.json entry: +// those files are linked as they are, never shadowed by a `Title 2.md`. +func TestBasePagesLinksPageFilesAScriptWrote(t *testing.T) { + root, base := basePagesVault(t) + notes := "---\nAuthor: Herbert\n---\n# Dune\n\nMy notes on Dune.\n" + if err := os.WriteFile(filepath.Join(base, "Dune.md"), []byte(notes), 0o644); err != nil { + t.Fatal(err) + } + args := []string{"base", "pages", "Books", "--json", "--vault", root} + out := captureOutput(t, func() { + if code := Main(args); code != 0 { + t.Errorf("zn %v exited %d", args, code) + } + }) + var result struct { + Pages []struct{ Title, Page string } `json:"pages"` + Linked []struct{ Title, Page string } `json:"linked"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("%v\n%s", err, out) + } + if len(result.Linked) != 1 || result.Linked[0].Title != "Dune" || len(result.Pages) != 2 { + t.Fatalf("result: %+v", result) + } + if got := mdFiles(t, base); !reflect.DeepEqual(got, []string{"Dune.md", "Emma.md", "Kindred.md"}) { + t.Fatalf("pages on disk: %v", got) + } + if kept, _ := os.ReadFile(filepath.Join(base, "Dune.md")); string(kept) != notes { + t.Fatalf("the script's page changed: %q", kept) + } + raw, _ := os.ReadFile(filepath.Join(base, "schema.json")) + if !strings.Contains(string(raw), `"Dune.md"`) { + t.Fatalf("schema does not map Dune.md: %s", raw) + } +} diff --git a/internal/cli/commands_base.go b/internal/cli/commands_base.go index 173bd9e..3e91208 100644 --- a/internal/cli/commands_base.go +++ b/internal/cli/commands_base.go @@ -494,6 +494,17 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { } dryRun := args.Bool("dry-run") pages := []pageView{} + linked := []pageView{} + for _, adopted := range plan.Adopt { + linked = append(linked, pageView{ID: adopted.Row.ID, Title: doc.RecordTitle(adopted.Row), Page: adopted.Page}) + if dryRun { + continue + } + if doc.Pages == nil { + doc.Pages = map[string]string{} + } + doc.Pages[adopted.Row.ID] = adopted.Page + } var failure error for _, row := range plan.Missing { title := doc.RecordTitle(row) @@ -514,7 +525,7 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { } // One schema write for the run, kept when a page fails partway so the // pages already written stay linked to their rows. - if !dryRun && len(pages) > 0 { + if !dryRun && len(pages)+len(linked) > 0 { if _, err := ops.WriteSchema(summary.Path, doc); err != nil { return err } @@ -528,6 +539,7 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { "path": summary.Path, "dryRun": dryRun, "pages": pages, + "linked": linked, "existing": plan.Existing, "untitled": plan.Untitled, }) @@ -540,15 +552,28 @@ func cmdBasePages(_ context.Context, b backend.Backend, args Args) error { return strconv.Itoa(n) + " " + many } switch { - case len(pages) == 0: + case len(pages) == 0 && len(linked) == 0: emitOK("Every titled record in " + summary.Title + " already has a page") case dryRun: - emitLine("Would create " + noun(len(pages), "record page", "record pages") + " in " + summary.Title + ":") - for _, p := range pages { - emitLine(" " + p.Title) + if len(pages) > 0 { + emitLine("Would create " + noun(len(pages), "record page", "record pages") + " in " + summary.Title + ":") + for _, p := range pages { + emitLine(" " + p.Title) + } + } + if len(linked) > 0 { + emitLine("Would link " + noun(len(linked), "record to the page file already there", "records to the page files already there") + ":") + for _, p := range linked { + emitLine(" " + p.Title + " -> " + p.Page) + } } default: - emitOK("Created " + noun(len(pages), "record page", "record pages") + " in " + summary.Title) + if len(pages) > 0 { + emitOK("Created " + noun(len(pages), "record page", "record pages") + " in " + summary.Title) + } + if len(linked) > 0 { + emitOK("Linked " + noun(len(linked), "record to the page file already there", "records to the page files already there")) + } } if plan.Existing > 0 { emitLine(noun(plan.Existing, "record already had its page", "records already had their pages")) diff --git a/internal/database/doc.go b/internal/database/doc.go index d8aac26..b7a46a7 100644 --- a/internal/database/doc.go +++ b/internal/database/doc.go @@ -1,6 +1,7 @@ package database import ( + "regexp" "strings" "github.com/ZenNotes/tui/internal/vault" @@ -268,10 +269,97 @@ func (d *Doc) ComposePageBody(row Row, body string) string { return strings.Join(lines, "\n") + "\n" + strings.TrimLeft(body, "\n") } +var ( + pageNameBad = regexp.MustCompile(`[\\/:\x00-\x1f*?"<>|]`) + spaceRun = regexp.MustCompile(`\s+`) +) + +// RecordPageCandidates lists where a record's page may already be without +// being mapped to its row: a note named after the record in the database +// folder, or in its `pages/` subfolder, under each name a page gets. The +// app names a local page by its note-title rules, the server path and zn +// only replace slashes, and a script someone wrote to make the pages likely +// used the title as it is. Mirrors recordPageCandidates in the app's +// shared-domain database-records.ts, name for name. +func (d *Doc) RecordPageCandidates(title string) []string { + formDir := FormDirFromCSVPath(toPosix(d.Path)) + raw := strings.TrimSpace(title) + if formDir == "" || raw == "" { + return nil + } + names := []string{} + add := func(name string) { + if name == "" { + return + } + for _, existing := range names { + if existing == name { + return + } + } + names = append(names, name) + } + app := strings.TrimSpace(spaceRun.ReplaceAllString(pageNameBad.ReplaceAllString(raw, "-"), " ")) + if runes := []rune(app); len(runes) > 200 { + app = string(runes[:200]) + } + add(app) + add(strings.NewReplacer("\\", "-", "/", "-").Replace(raw)) + if !strings.ContainsAny(raw, `\/`) { + add(raw) + } + out := []string{} + for _, dir := range []string{formDir, formDir + "/pages"} { + for _, name := range names { + out = append(out, dir+"/"+name+".md") + } + } + return out +} + +// RecordPageToAdopt is the unmapped page file a record should be linked to, +// or "": the first of its candidates that exists and no row maps. Before +// this, opening a record whose page file was there but unmapped wrote a +// `Title 2.md` beside it. A record with an empty title has no name to match. +func (d *Doc) RecordPageToAdopt(row Row, exists func(notePath string) (bool, error), taken map[string]bool) (string, error) { + titleID := d.TitleFieldID() + if titleID == "" { + return "", nil + } + if taken == nil { + taken = map[string]bool{} + for _, page := range d.Pages { + taken[page] = true + } + } + for _, candidate := range d.RecordPageCandidates(row.Cells[titleID]) { + if taken[candidate] { + continue + } + ok, err := exists(candidate) + if err != nil { + return "", err + } + if ok { + return candidate, nil + } + } + return "", nil +} + +// AdoptedPage is a record linked to a page file that was already there. +type AdoptedPage struct { + Row Row + Page string +} + // RecordPagePlan is what giving every record its page would do. type RecordPagePlan struct { // Missing are the records with no page, or whose page is gone, in row order. Missing []Row + // Adopt are the records whose page file is already there, unmapped: + // linked, not written. + Adopt []AdoptedPage // Existing counts the records whose page is mapped and still there. Existing int // Untitled counts the records with an empty title field, left without a page. @@ -284,10 +372,18 @@ type RecordPagePlan struct { // only once it has a page. One whose page is mapped and still exists keeps // it; one whose page was deleted gets a new one, as opening it would; one // with an empty title is left out, since its page would only be "Untitled" -// and nothing could link to it by name. +// and nothing could link to it by name. A record whose page file is already +// in the folder but not mapped (written by a script, or left by an older +// map) is linked to that file instead of getting a `Title 2.md` beside it; +// a file another row maps, or an earlier record took, is never taken twice. func (d *Doc) PlanRecordPages(exists func(notePath string) (bool, error)) (RecordPagePlan, error) { plan := RecordPagePlan{} titleID := d.TitleFieldID() + // Every mapped page belongs to its row, even one that is gone for now. + taken := map[string]bool{} + for _, page := range d.Pages { + taken[page] = true + } for _, row := range d.Rows { if page := d.Pages[row.ID]; page != "" { ok, err := exists(page) @@ -303,6 +399,15 @@ func (d *Doc) PlanRecordPages(exists func(notePath string) (bool, error)) (Recor plan.Untitled++ continue } + found, err := d.RecordPageToAdopt(row, exists, taken) + if err != nil { + return plan, err + } + if found != "" { + taken[found] = true + plan.Adopt = append(plan.Adopt, AdoptedPage{Row: row, Page: found}) + continue + } plan.Missing = append(plan.Missing, row) } return plan, nil diff --git a/internal/database/record_pages_test.go b/internal/database/record_pages_test.go index 5b18432..662d9c6 100644 --- a/internal/database/record_pages_test.go +++ b/internal/database/record_pages_test.go @@ -40,3 +40,55 @@ func TestPlanRecordPagesStopsOnAReadError(t *testing.T) { t.Fatalf("want the read error, got %v", err) } } + +// The same names the app's recordPageCandidates gives, so the two link the +// same files. +func TestRecordPageCandidatesMatchTheApp(t *testing.T) { + d := testDoc(t) + want := []string{ + "Books.base/What- a -guide--.md", + `Books.base/What: a "guide"?.md`, + "Books.base/pages/What- a -guide--.md", + `Books.base/pages/What: a "guide"?.md`, + } + if got := d.RecordPageCandidates(`What: a "guide"?`); !reflect.DeepEqual(got, want) { + t.Fatalf("candidates:\n got %q\nwant %q", got, want) + } + // A slash in a title never makes a path of it. + if got := d.RecordPageCandidates("A/B"); !reflect.DeepEqual(got, []string{"Books.base/A-B.md", "Books.base/pages/A-B.md"}) { + t.Fatalf("slash: %q", got) + } +} + +// A page file already in the folder, unmapped, is linked rather than copied; +// one another row maps is never taken, and twins share no file. +func TestPlanRecordPagesLinksPageFilesAlreadyThere(t *testing.T) { + d := testDoc(t) + d.Rows = []Row{ + {ID: "r1", Cells: map[string]string{"f0": "r1", "f1": "Dune"}}, + {ID: "r2", Cells: map[string]string{"f0": "r2", "f1": "Kindred"}}, + {ID: "r3", Cells: map[string]string{"f0": "r3", "f1": "Emma"}}, + {ID: "r4", Cells: map[string]string{"f0": "r4", "f1": "Dune"}}, + } + d.Pages = map[string]string{"r9": "Books.base/Emma.md"} + files := map[string]bool{"Books.base/Dune.md": true, "Books.base/pages/Kindred.md": true, "Books.base/Emma.md": true} + plan, err := d.PlanRecordPages(func(p string) (bool, error) { return files[p], nil }) + if err != nil { + t.Fatal(err) + } + adopted := [][2]string{} + for _, a := range plan.Adopt { + adopted = append(adopted, [2]string{a.Row.ID, a.Page}) + } + if !reflect.DeepEqual(adopted, [][2]string{{"r1", "Books.base/Dune.md"}, {"r2", "Books.base/pages/Kindred.md"}}) { + t.Fatalf("adopted: %v", adopted) + } + missing := []string{} + for _, row := range plan.Missing { + missing = append(missing, row.ID) + } + // Emma's file belongs to another row; the second Dune finds its file taken. + if !reflect.DeepEqual(missing, []string{"r3", "r4"}) { + t.Fatalf("missing: %v", missing) + } +} diff --git a/internal/tui/database_test.go b/internal/tui/database_test.go index e8e4d14..20f41c5 100644 --- a/internal/tui/database_test.go +++ b/internal/tui/database_test.go @@ -288,3 +288,38 @@ func TestParseDateInput(t *testing.T) { t.Fatal("nonsense is rejected") } } + +// Opening a record whose page file is already in the folder but not mapped +// (a workaround script wrote it) links that file instead of writing a +// `Dune 2.md` beside it. +func TestOpenRecordPageLinksAPageFileAlreadyThere(t *testing.T) { + a, root := newDatabaseTestApp(t) + v := openTestDatabase(t, a) + next, err := a.backend.DatabaseOps().ConvertToFolder(v.csvPath) + if err != nil { + t.Fatal(err) + } + notes := "# Dune\n\nMy notes on Dune.\n" + if err := os.WriteFile(filepath.Join(root, "Books.base", "Dune.md"), []byte(notes), 0o644); err != nil { + t.Fatal(err) + } + v.csvPath = next + v.refresh(a) + var dune string + for _, row := range v.doc.Rows { + if v.doc.RecordTitle(row) == "Dune" { + dune = row.ID + } + } + v.focusRow(dune) + v.openRecordPage(a) + if _, err := os.Stat(filepath.Join(root, "Books.base", "Dune 2.md")); !os.IsNotExist(err) { + t.Fatal("opening wrote a second page beside the one already there") + } + if got := v.doc.Pages[dune]; got != "Books.base/Dune.md" { + t.Fatalf("Dune maps to %q", got) + } + if kept, _ := os.ReadFile(filepath.Join(root, "Books.base", "Dune.md")); string(kept) != notes { + t.Fatalf("the page changed: %q", kept) + } +} diff --git a/internal/tui/views_database.go b/internal/tui/views_database.go index 336bfe4..db13a28 100644 --- a/internal/tui/views_database.go +++ b/internal/tui/views_database.go @@ -1026,6 +1026,21 @@ func (v *databaseView) openRecordPage(a *App) { return } ops := a.backend.DatabaseOps() + // A page file already there under the record's name but not mapped (a + // script wrote it, say) is linked, not shadowed by a `Title 2.md`. + if page, err := v.doc.RecordPageToAdopt(*r, ops.RecordPageExists, nil); err == nil && page != "" { + if v.doc.Pages == nil { + v.doc.Pages = map[string]string{} + } + v.doc.Pages[r.ID] = page + if _, err := ops.WriteSchema(v.csvPath, v.doc); err != nil { + a.notifyError(err.Error()) + } + a.refreshIndex() + a.notify("Linked record page " + page) + a.openNote(page, true) + return + } title := v.doc.RecordTitle(*r) body := v.doc.ComposePageBody(*r, "") path, err := ops.CreateRecordPage(v.csvPath, title, body) From 448dce535e56c273031cbe094760f1eabfd0bfa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Fri, 23 Oct 2026 10:09:59 +0000 Subject: [PATCH 16/19] Cleanup --- internal/config/prefs.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/config/prefs.go b/internal/config/prefs.go index ede0ca8..b34f926 100644 --- a/internal/config/prefs.go +++ b/internal/config/prefs.go @@ -14,8 +14,6 @@ type Prefs struct { HelixWrappedLineMotions string IgnoredKeys []string HelixMode bool - // Motions picks the modal engine: "vim" (default) or "helix". It is - // [vim] motions in config.toml. Motions string HelixInsertEscape string HelixYankToClipboard bool @@ -155,10 +153,6 @@ func applyPrefs(p *Prefs, doc map[string]any) { m, _ := doc[name].(map[string]any) return m } - // The modal engine section stays [vim], the name it has always had; the - // motions key picks which engine (vim or helix) runs behind it. A - // [helix] section written by older helix-branch builds is still honored - // when [vim] is absent. modal := section("vim") if modal == nil { modal = section("helix") From 055a77b1ee83c19c9ac548e6f06e5c1f24c9666d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Fri, 23 Oct 2026 10:12:19 +0000 Subject: [PATCH 17/19] Fix(tui): import the vim engine in authoring_test The rebase left TestTypedBacktickFenceClosesOnEnter using vim.R, vim.KeyEnter and vim.Pos while the file only imported the helix package, so go test failed to build the tui tests. --- internal/tui/authoring_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/tui/authoring_test.go b/internal/tui/authoring_test.go index 07d390a..7f30214 100644 --- a/internal/tui/authoring_test.go +++ b/internal/tui/authoring_test.go @@ -2,6 +2,7 @@ package tui import ( "github.com/ZenNotes/tui/internal/helix" + "github.com/ZenNotes/tui/internal/vim" "strings" "testing" ) From 88fdf39ecf673d87e8997ce517604061b43a1380 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Fri, 23 Oct 2026 10:18:02 +0000 Subject: [PATCH 18/19] Chore(helix): mention the vim engine on the shared type comments The vim engine aliases Pos, Options, Register, Hooks, Key and the key constructors out of this package, and its alias comments say so. The helix definitions now carry the matching note from their side. --- internal/helix/buffer.go | 3 ++- internal/helix/editor.go | 10 +++++++--- internal/helix/keys.go | 4 +++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/internal/helix/buffer.go b/internal/helix/buffer.go index ae204b6..185aeb0 100644 --- a/internal/helix/buffer.go +++ b/internal/helix/buffer.go @@ -5,7 +5,8 @@ import ( "unicode" ) -// Pos is a position in the buffer: a zero-based line and a rune column. +// 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 } diff --git a/internal/helix/editor.go b/internal/helix/editor.go index 4d3ac6e..b4efef7 100644 --- a/internal/helix/editor.go +++ b/internal/helix/editor.go @@ -42,7 +42,9 @@ const ( selBlock ) -// Options tune the engine. Zero values are filled by New. +// 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 @@ -75,7 +77,8 @@ func DefaultOptions() Options { } } -// Register is a yank or delete. +// 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 @@ -119,7 +122,8 @@ type ExCommand struct { Raw string } -// Hooks are the host's side of the engine. Every field is optional. +// 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 diff --git a/internal/helix/keys.go b/internal/helix/keys.go index 4443592..300dac9 100644 --- a/internal/helix/keys.go +++ b/internal/helix/keys.go @@ -12,7 +12,9 @@ import ( // 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). +// 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 From 54b8e42cb93aef0c18498c45295d0e3299742194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20S=C3=A1?= <enkodr@pm.me> Date: Tue, 27 Oct 2026 09:43:02 +0000 Subject: [PATCH 19/19] Fix compile errors --- internal/tui/commands.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 0ae47b2..955be7f 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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,7 +70,7 @@ 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) }