Vim Motions

by Emile Bangma
5
4
3
2
1
Score: 55/100

Description

Enhances Obsidian's built-in Vim mode with Markdown-aware text objects, structural navigation, workspace keyboard control, and a polished Neovim-native experience.

Reviews

No reviews yet.

Stats

51
stars
3,771
downloads
4
forks
30
days
0
days
0
days
1
total PRs
0
open PRs
1
closed PRs
0
merged PRs
121
total issues
4
open issues
117
closed issues
594
commits

Latest Version

a day ago

Changelog

Fixed

  • Cursor shape dropdowns always disabled in Settings UI — the 5 cursor shape dropdowns (Normal, Insert, Visual, Replace, Operator-pending) on the Appearance page were permanently disabled even when Obsidian's built-in Vim mode was off. Root cause: Obsidian's addSettingTab() immediately calls getSettingDefinitions() and caches the result for rendering and search indexing. In onload(), addSettingTab() ran before createBundledVimExtension(), so the disabled callbacks closed over forkActive = false (a const captured at the top of getSettingDefinitions()). The callbacks always returned true (disabled) regardless of the actual fork activation state. Fixed by replacing the captured forkActive const in all 5 disabled callbacks with a direct isBundledVimActive() call, so Obsidian's refreshDomState() always evaluates the current state. Additionally, this.declarativeSettingTab.update() is now called after createBundledVimExtension() to refresh the cached getSettingDefinitions() result — this updates the static description text which cannot use a callback. (#128)
    • Plugin: src/settings.ts (5 cursor shape disabled callbacks — !forkActive!isBundledVimActive())
    • Plugin: src/main.ts (store setting tab reference as declarativeSettingTab; call declarativeSettingTab.update() after createBundledVimExtension())
  • Animated cursor suppression not synced on reloadFeatures()setCursorSuppressed(this.settings.animatedCursor) was only called during initial plugin load (onload()), not during reloadFeatures(). Any runtime setting change that called reloadFeatures() (settings UI toggle, vimrc set smoothcursor, Lua vim.opt.smoothcursor) did not update the global cursor suppression flag in the codemirror-vim fork. The animated cursor canvas would draw but the native CM6 block cursor was not suppressed, causing both cursors to render simultaneously. Also fixed the born-broken table-cursor-suppression.e2e.ts test (5 of 6 failures since commit 99e5fea) whose enableAnimatedCursor() helper set the setting and called reloadFeatures() but never triggered the global suppression. (#127)
    • Plugin: src/main.ts (reloadFeatures() — added setCursorSuppressed(this.settings.animatedCursor) call)
  • Doubled cursors when animated cursor is disabled — when animated cursor was disabled, the native CM6 text caret (thin blinking bar) appeared alongside the fork's vim cursor (block/hollow) in normal, operator-pending, and replace modes after entering and leaving insert mode. Root cause: the fork's BlockCursorPlugin.update() relied on a CSS baseTheme rule to hide native cursor layers, but mode transitions (insert removes .cm-vimMode, normal re-adds it) and CM6's drawSelection extension left native layers visible due to CSS specificity conflicts. Fixed in the fork by unconditionally hiding native CM6 cursor layers and setting caretColor to match the vim cursor color (var(--interactive-accent)) in insert mode. (#129)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (BlockCursorPlugin.update() — unconditional native layer hiding, mode-aware caretColor)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (updated setCursorSuppressed API section)
  • Doubled cursors in embedded editors (textarea vim overlay) — in the textarea vim overlay, caretColor was the accent color instead of transparent in normal mode, causing the native text caret to appear alongside the fork's block cursor. Root cause: BlockCursorPlugin.update() checked the .cm-vimMode DOM class to determine insert/normal mode, but CM6 ViewPlugin update ordering meant the class wasn't yet present when the block cursor plugin ran. Fixed in the fork by checking this.cm.state.vim.insertMode directly instead of the DOM class. Also uses setProperty("caret-color", ..., "important") for CSS specificity robustness. (#130)
    • Fork: ~/Repos/codemirror-vim/src/block-cursor.ts (BlockCursorPlugin.update() — vim-state-based caretColor instead of DOM class check)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added "Vim-state-based caretColor" subsection)
  • Escape does not close footnote popover — pressing Escape twice (insert→normal, then idle normal) in the footnote popover editor did not close the popover. The user had to click outside to dismiss it. Root cause: the fork's findKey consumed <Esc> unconditionally in idle normal mode, preventing the event from reaching Obsidian's popover close handler. Fixed with a two-part approach: (1) the fork now exposes setIdleEscapeCallback(fn) which fires when Escape is pressed in idle normal mode, and (2) the plugin registers a callback (installEscapeGuard) that dismisses the popover via HoverPopover.hide() for non-workspace-leaf editors while silently consuming Escape in workspace-leaf editors (preventing Obsidian hotkey interference). (#130)
    • Fork: ~/Repos/codemirror-vim/src/vim.js (setIdleEscapeCallback API, wasIdleNormal pre-capture in findKey)
    • Fork: ~/Repos/codemirror-vim/DIFFERENCES.md (added setIdleEscapeCallback API section)
    • Plugin: src/vim/escape-guard.ts (NEW — installEscapeGuard with HoverPopover.hide() dismissal)
    • Plugin: src/main.ts (installEscapeGuard(this.app) call in feature registration)
  • Invisible cursor in footnote popover with animated cursor enabled — the animated cursor canvas (z-index: 15) renders behind Obsidian's popover (z-index: 30). The fork's vim cursor was also suppressed (global setCursorSuppressed(true)), resulting in no visible cursor. Fixed by detecting editors inside .popover or .modal-container in the CursorController and un-suppressing the fork's vim cursor for those views (setCursorSuppressedForView(view, false)). The animated cursor tick() skips rendering for above-canvas editors. (#130)
    • Plugin: src/vim/animated-cursor/controller.ts (isAboveCanvas flag, per-view un-suppression for popover/modal editors, tick() early return)
  • Stale cursor suppression after animated cursor toggle — when animated cursor was disabled at runtime, CursorController.update() returned early without clearing the per-view suppression override set in the constructor, leaving the fork's vim cursor hidden. Also, the constructor unconditionally suppressed the cursor regardless of config.enabled. Fixed by gating constructor suppression on config.enabled and calling clearCursorSuppressedForView() in the disabled early-return path. (#130)
    • Plugin: src/vim/animated-cursor/controller.ts (constructor gates on config.enabled, update() clears per-view override when disabled)

Changed

  • Internal API type safety — obsidian-typings migration (round 2) — eliminated 23 additional as unknown as casts across 16 source files by leveraging @obsidian-typings/obsidian-public-latest v6.32.0 typed APIs. Total as unknown as count reduced from 90 → 67. The remaining 67 casts are inherent to plugin architecture (dynamic settings indexing, codemirror-vim fork adapter access, external plugin window globals, fengari Lua bridge, minAppVersion compatibility guards).
    • src/util/commands.ts: app.commands.executeCommandById() and app.commands.commands accessed directly via typed Commands interface; custom ObsidianCommand narrowed to Pick<Command, 'id' | 'name'>
    • src/util/leaf.ts: leaf.id and leaf.pinned used directly (required properties via WorkspaceItem/WorkspaceLeaf augmentation); getViewFilePath()/getViewFileBasename() use instanceof FileView guard instead of as unknown as { file? } cast
    • src/util/vault.ts: ConfigItem imported from @obsidian-typings/obsidian-public-latest replacing custom VaultConfigKey type inference
    • src/workspace/global-defaults.ts: mdView.getMode() called directly (typed as MarkdownViewModeType)
    • src/editors/embeddable-editor.ts: app.embedRegistry accessed directly; editorApp.scope accessed directly (official API); workspace.activeEditor assignment typed via MarkdownFileInfo
    • src/oil/keybindings.ts, src/oil/manager.ts: app.internalPlugins.getEnabledPluginById('file-explorer') returns typed FileExplorerPluginInstance with revealInFolder(item: TAbstractFile)
    • src/oil/oil-view.ts: this.leaf.updateHeader() called directly (typed on WorkspaceLeaf augmentation)
    • src/oil/manager.ts: app.openWithDefaultApp(path) called directly (typed on App augmentation)
    • src/vim/native-table-adapter.ts: EditMode type extends MarkdownEditView instead of Record<string, unknown>; view.editMode accessed directly; isInLivePreview() uses view.getMode() + editMode.sourceMode instead of getState() cast; getEditModeForView() uses instanceof MarkdownView guard
    • src/vim/table-cell-cursor-guard.ts: mdView.editor.cm accessed directly (typed as EditorView via Editor augmentation)
    • src/ui/global-ex-command.ts: this.inputEl accessed directly (official SuggestModal.inputEl)
    • src/lua/loader.ts: view.getViewType() called directly (official View API) — 3 instances
    • src/settings.ts: this.display() and this.refreshDomState() — reverted, casts retained to bypass obsidianmd/no-unsupported-api and @typescript-eslint/no-deprecated lint rules (plugin minAppVersion is 1.7.2; these APIs require/deprecate at 1.13.0)
    • src/picker/sources/tasks.ts: app.plugins.plugins['obsidian-tasks-plugin'] accessed directly via typed Plugins interface

Documentation

  • CHANGELOG.md
  • KNOWN_LIMITATIONS.md: Updated cursor shapes section with addSettingTab() caching fix (#128); added doubled cursors fix (#129); added #130 fixes (doubled cursors in embedded editors, Escape popover dismiss, invisible cursor in popovers, stale cursor suppression)
  • AGENTS.md: Updated codemirror-vim fork cursor suppression description (vim-state-based caretColor, setIdleEscapeCallback API)
  • CONTRIBUTING.md: Added escape-guard.ts to codebase structure; updated controller.ts description with isAboveCanvas flag and popover/modal fallback
  • docs/features/animated-cursor.md: Updated embeddable editors section with popover/modal fallback and z-index explanation
  • Fork DIFFERENCES.md: Added setIdleEscapeCallback API section; added "Vim-state-based caretColor" subsection; updated findKey Escape handling description

Full Changelog: https://github.com/saberzero1/motions/compare/0.111.0...0.112.0

README file from

Github

Vim Motions

A polished, Neovim-native experience inside Obsidian. Vim Motions adds what's missing from Obsidian's built-in Vim mode: Markdown-aware text objects, structural navigation, hard-wrap formatting, workspace keyboard control, EasyMotion, Lua configuration with vim.keymap.set / vim.opt / vim.fn / vim.api / vim.tbl_* / autocommands / timers / highlight groups, and a built-in .obsidian.vimrc loader.

Full documentation →

Features

  • Markdown text objects — operate on bold, italic, code, math, links, blockquotes, code blocks, callouts, tags, table cells, subwords, numbers, quotes, wikilinks, URLs, arguments, and indentation with d, c, y, v
  • Structural navigation — jump between headings, lists, links, and buffers with ]h, ]l, ]n, ]b
  • Lua configuration.obsidian.init.lua with conditional logic, function keymaps, vim.v predefined variables (count, count1, register, operator, searchforward, constants), { expr = true } expression mappings, vim.fn.*, vim.api.* (buffer APIs, nvim_set_hl), vim.tbl_*, vim.snippet.*, vim.json, vim.inspect, vim.regex (ECMAScript RegExp), vim.schedule/vim.defer_fn/vim.uv timers, autocommands (19 events, mode events fire per-view across all editors), vim.obsidian namespace (including vim.obsidian.im for input method control), buffer-local keymaps, async file reading (vim.ob.fs.read), multi-file configs via require(), collectgarbage() support, __gc userdata finalization, and Neovim-compatible syntax
  • Built-in vimrc.obsidian.vimrc loader with 100+ configurable settings and which-key support with Lucide icons
  • Flash motions — enhanced f/F/t/T with labels on all visible matches (flash.nvim-inspired). Auto-jumps on single match, count prefix honored (3f{char} jumps to 3rd match without labels). Operator-pending (df, cf, yf), visual mode, multi-line search. Incremental s jump mode (type multiple chars to narrow, labels update live), post-commit //? search labels, clever-f repetition, label conflict skipping, [3/15] search match counter. Dynamically sized match highlights and labels positioned after matched text (flash.nvim parity)
  • EasyMotion / Hop — jump to any visible position with two keystrokes, with operator-pending support
  • Workspace keyboard control — navigate panes, tabs, and sidebar without a mouse (<C-w>, gt/gT/Ngt, :sp/:vs). Built-in hotkey conflict detection with resolution wizard
  • Surround — add, change, or delete surrounding delimiters (vim-surround with Markdown support, including dsf/csf for function calls, dot-repeat for ys with text objects, insert-mode <C-G>s with both delimiters inserted up front and full dot-repeat support)
  • Hard-wrap formatting — Markdown-aware gq/gw operators with prefix preservation
  • Replace-with-registergr{motion} replaces text with register contents without clobbering the register (vim-ReplaceWithRegister parity)
  • Yank-ring paste cycling — cycle through numbered register history with <C-p>/<C-n> after pasting. Wraps around registers "1"9. Cancels on any non-cycling command. Dot-repeat (.) replays the final cycled text (yanky.nvim parity).
  • Table editing — cell navigation, text objects, manipulation commands, format-on-exit auto-alignment, and native table editor integration with vim-enabled per-cell editing, cross-cell h/j/k/l navigation, and optional table-nav overlay with direct table manipulation (o, dd, J/K, H/L, =). Three modes: native with nav overlay (default), native without overlay, or raw markdown
  • Oil exploreroil.nvim-inspired file manager: edit directories as buffers, create/rename/delete files with vim commands. Matching oil.nvim keybindings: <CR> opens in same leaf, <C-t> new tab, <C-s>/<C-h> vertical/horizontal split, <C-c>/q close, gx open in default app, g. toggle hidden files
  • Telescope-style picker — fuzzy finder with 14 built-in sources (files, buffers, commands, headings, outline, grep, live grep, marks, registers, tags, backlinks, recent, harpoon, snippets), preview pane, frecency scoring, bundled integrations for Omnisearch, Obsidian Tasks, and Dataview, and a provider API for external plugin integration
  • Snippets — VS Code-compatible snippet expansion with tabstop navigation, 37 variables (full VSCode spec + $VISUAL/$WORD vim aliases), choice nodes, context filtering. 60+ bundled Obsidian snippets. User-defined snippets via JSON files or LuaSnip-inspired Lua DSL with reactive f()/d() nodes
  • 100+ ex commands:sp, :vs, :e, :grep, :ob, :Oil, :sidebar, navigation/action aliases, and more
  • Vimium-style hints — navigate the entire Obsidian UI with keyboard hints (f, F, yf, df, gf for context menu)
  • Line numbers — configurable line number gutter with absolute, relative, and hybrid modes. Neovim-compatible statuscolumn API for custom gutter layouts (vim.opt.statuscolumn = "%s %l %r %C"). Cursor line highlight (cursorline/cursorlineopt), configurable number width, mobile-responsive gutter, and Obsidian's native line numbers suppressed when active
  • Marks — dedicated sign column gutter showing mark letters next to marked lines, configurable via signcolumn (auto/always/off), consistent font size regardless of content, gutter layout matching Neovim (sign column → line numbers → fold column), global mark persistence across files and sessions (AZ), and a grouped marks picker with cross-file navigation
  • Harpoon — pin files to numbered slots for instant switching (<leader>1<leader>9), cursor position tracking, persistence across sessions, auto-updating on file rename/delete
  • Fully remappable keybindings — every keybinding can be customized via Lua or vimrc across all contexts (editor, oil explorer, picker, workspace)
  • Folding — full Neovim-style fold commands: zf (create), zd (delete), zE (eliminate all), zm/zr (incremental level), custom fold providers for frontmatter and callouts, descriptive fold placeholder text, fold-aware navigation (auto-unfold on ]h), cross-session fold persistence, and optional fold column gutter (set foldcolumn) with click-to-fold
  • Input method switching — automatic IM switching for CJK users when entering/leaving insert mode. Supports macism, im-select, fcitx5-remote, ibus, and any external binary. Platform presets for one-click setup, per-view state across all editors (split panes, popovers, canvas cards) with session persistence, composition guard, :IMToggle/:IMStatus ex commands, Lua API (vim.obsidian.im). Desktop only.
  • Vim in text areas — focused <textarea> elements in modals and plugin UIs are replaced with a vim-enabled editor overlay. Starts in insert mode for transparent typing; press Escape for normal mode, second Escape returns to modal. Experimental, disabled by default. Desktop only.
  • Cross-note jump list<C-o> and <C-i> navigate backward/forward through jump history across notes. Jumps recorded on gd, picker selection, harpoon, oil, EasyMotion, and 100+ other navigation paths. Persists across sessions. :jumps displays the list. set jumplist/set jumplistsize for configuration
  • Undo treeundotree-style branching undo history visualization. g-/g+ navigate chronologically across all branches with buffer content restoration. :earlier/:later by count, time, or save point. :undolist modal. Sidebar view (:UndoTreeToggle) with tree rendering, keyboard nav, collapse/expand, diff preview. vim.fn.undotree() Lua API. Optional persistence (set undofile). 5 settings: enableUndoTree, undoTreeMaxNodes, undoTreePosition, undoTreeAutoOpen, undoFile
  • Animated cursor — canvas-based smooth cursor movement and smear-cursor.nvim-style spring-damper smear trail. Per-mode cursor shapes, configurable stiffness/damping/smoothness, prefers-reduced-motion support, cross-platform resilience (heartbeat safety net, error recovery, visibility-change wakeup, fractional DPI rounding), and full vimrc/Lua configuration (set smoothcursor / vim.opt.smoothcursor). Disabled by default. 8 settings: animatedCursor, smoothCursor, cursorSmoothness, smearTrail, smearStiffness, smearTrailingStiffness, smearDamping, smearMaxLength
  • Subword motions — spider.nvim-style w/b/e/ge override stopping at camelCase, snake_case, and kebab-case boundaries. Opt-in setting.
  • Enhanced increment/decrement — dial.nvim-style <C-a>/<C-x> cycling hex colors, booleans, dates, CSS values, and checkboxes
  • Custom text objects — define delimiter-pair text objects from Lua via vim.textobject.add() + vim.gen_spec.pair()
  • External grep — optional ripgrep or GNU grep binary for native-speed vault search in the picker. Desktop only with in-memory fallback.
  • Quality of life: Neovim defaults (Y/Q), yank highlight, smart list continuation, scrolloff, insert escape sequences, chord display, powerline status bar, and settings hot-reload

Installation

From community directory

Search for "Vim Motions" in Settings → Community plugins → Browse.

Manual installation

  1. Download main.js, manifest.json, and styles.css from the latest release.
  2. Create a folder vim-motions in <your-vault>/.obsidian/plugins/.
  3. Copy the downloaded files into that folder.
  4. Restart Obsidian and enable the plugin in Settings → Community plugins.

Disable Obsidian's built-in Vim mode (Settings → Editor → Vim key bindings → off). Vim Motions provides its own enhanced vim engine — a fork of codemirror-vim — with Neovim-correct behavior, async motion support, correct cursor positioning in Live Preview, and theme-aligned styling.

The plugin also works with built-in vim mode enabled, but the fork provides a more accurate Vim experience. See the recommended setup guide for details.

Documentation

Full documentation: https://saberzero1.github.io/motions

Requirements

  • Obsidian v1.7.2 or later
  • Desktop or mobile (physical keyboard recommended on mobile)

Development

npm install       # Install dependencies
npm run dev       # Development build (watch mode)
npm run build:dev # Development build (one-shot, with __DEV__ assertions)
npm run build     # Production build
npm run lint      # Lint
npm run test:unit # Unit tests (Vitest)
npm run test:e2e  # E2E tests (requires nix develop)

See CONTRIBUTING.md for the full development guide, testing strategy, and contribution guidelines.

License

MIT — Emile Bangma