diff --git a/docs/adrs/004.tabs-vim.keybinding-cleanup.md b/docs/adrs/004.tabs-vim.keybinding-cleanup.md new file mode 100644 index 0000000..5501d24 --- /dev/null +++ b/docs/adrs/004.tabs-vim.keybinding-cleanup.md @@ -0,0 +1,56 @@ +# 004. Keybinding Cleanup — Expose Functions, Minimal OOTB Bindings + +**SPEC:** tabs.vim / key-binding +**Status:** Accepted +**Last Updated:** 2026-04-04 + +--- + +## Decision + +Refactor `plugin/tabs.vim` to expose public `TabsVim_*` functions for all non-trivial operations, and reduce out-of-the-box bindings to only what cannot be cleanly delegated to the user's vimrc. + +**OOTB behavior retained:** +- Mouse drag-and-drop file opening (bracketed-paste infrastructure) + +**All keybindings removed from the plugin.** Users wire them via their own vimrc using the exposed functions. Vim's native `gt` / `gT` / `gt` cover tab navigation without any plugin binding. + +--- + +## Context + +The current plugin ships ~20 hard-coded keybindings covering terminals, window splits, buffer rename, redo, file path copy, fzf integration, and tab jumps. This creates several problems: + +1. **Conflicts**: Bindings like `` (native: jump to tag), `gF` (native: open file under cursor), `r` (commonly user-assigned), and terminal mode maps override keys that users or other plugins rely on. +2. **Scope creep**: Some bindings (`r` for redo, `fy` for clipboard) are general editor utilities with no tab-management justification. +3. **Opaque defaults**: Users cannot audit what the plugin has bound without reading source code. +4. **No opt-out granularity**: No per-binding disable flag exists; users must shadow every conflicting map. + +Vim already provides `gt`, `gT`, and `gt` for tab navigation — there is no need for the plugin to bind ``/`` either. + +Mouse DnD is retained OOTB because it requires bracketed-paste terminal infrastructure (`t_BE`/`t_BD` sequences and ``/`` key aliases) that cannot meaningfully be expressed as a user-side vimrc binding. It also has zero keymap conflict risk. + +The plugin's declared principles are *Locality*, *Discoverability*, *Speed*, and *Integration* — none of which require owning the user's keymap. + +--- + +## Considered Options + +| Option | Pros | Cons | +|--------|------|------| +| **Expose functions, only mouse DnD OOTB** *(chosen)* | Zero keymap conflicts; users own their keymap; functions are stable API | Users must add vimrc bindings | +| Keep ``/`` OOTB | Convenient defaults | Redundant with native `gt`/`gT`; `` conflicts with completion plugins (e.g. copilot.vim) | +| Keep all bindings, add `g:tabs_vim_no_mappings` flag | Easy single opt-out | All-or-nothing; doesn't help with partial conflicts | +| Keep all bindings, add per-binding `g:tabs_vim_map_*` flags | Fine-grained opt-out | Dozens of config variables; documentation burden | + +Terminal mode escape mappings (`tnoremap `, `tnoremap `) are removed — especially collision-prone and purely a user preference. The `` recommendation (use it to enter terminal insert mode, overriding its native tag-jump role) is documented in the key-binding SPEC as an opt-in with the trade-off explained. + +--- + +## Consequences + +- Only functions with real logic are promoted to `TabsVim_*` public API: `TabsVim_ToggleHorizTerm`, `TabsVim_ToggleVertTerm`, `TabsVim_NewTabTerm`, `TabsVim_CloseOrHide`, `TabsVim_RenameBuffer`, `TabsVim_FzfOpenInTab`. Simple one-liner native commands (`:tabnew`, `:sp`, `:tabonly`, `:tabedit `, etc.) are documented as direct vimrc mappings — no wrapper is exposed. +- A new SPEC `key-binding.md` documents the public function API, direct native-command wiring patterns, and trade-off notes (`g`, ``). +- The existing `tabs.vim` SPEC is updated to reference the key-binding SPEC and mark the refactor feature. +- Existing users who relied on the removed bindings must add them to their vimrc — the key-binding SPEC provides a ready-made example block. +- No behavior changes: the logic inside each function is unchanged; only the binding wiring moves to the user. diff --git a/docs/specs/key-binding.md b/docs/specs/key-binding.md new file mode 100644 index 0000000..1ff5c1b --- /dev/null +++ b/docs/specs/key-binding.md @@ -0,0 +1,129 @@ +# SPEC: Key Binding + +**Last Updated:** 2026-04-04 + +--- + +## Description + +Defines the keybinding contract for `tabs.vim`: what the plugin installs out-of-the-box (OOTB), what public functions it exposes for users to bind themselves, and the recommended vimrc wiring pattern. + +The plugin intentionally keeps OOTB bindings minimal to avoid conflicts with user keymaps and other plugins. All operations are exposed as stable `TabsVim_*` public functions. Users adopt the ones they want and bind them to their preferred keys. + +**Persona:** Vim/Neovim users who want tab-management features without the plugin imposing a full keymap. + +--- + +## OOTB Behavior + +The plugin installs **no user-facing keybindings** by default. Tab navigation is already covered by Vim natively (`gt` / `gT` / `gt`). + +The one OOTB behavior is **mouse drag-and-drop file opening**: dropping a file from a file manager into the terminal opens it in a new tab. This is implemented via bracketed-paste terminal sequences (`t_BE`/`t_BD`) and internal synthetic key aliases (``/``) — these are not user-facing mappings. It activates only in terminal Vim (not GVim) on Vim 8.0.0210+. + +--- + +## Public Function API + +Only operations with real logic are exposed as `TabsVim_*` functions. Simple one-liner native commands (`:tabnew`, `:sp`, `:tabonly`, `:only`, `:tabedit `, `let @+ = expand("%:p")`) are documented as direct vimrc mappings below — no wrapper needed. + +### Terminal Operations + +| Function | Description | +|----------|-------------| +| `TabsVim_ToggleHorizTerm()` | Toggle a persistent horizontal split terminal (below, 15 rows) | +| `TabsVim_ToggleVertTerm()` | Toggle a persistent vertical split terminal (right, 80 cols) | +| `TabsVim_NewTabTerm()` | Open a new terminal in its own tab | + +### Window / Buffer Operations + +| Function | Description | +|----------|-------------| +| `TabsVim_CloseOrHide()` | Close the current window; if last window, prompt to quit Vim; if a terminal buffer, toggle-hide it instead | +| `TabsVim_RenameBuffer()` | Prompt to rename the current buffer | + +### FZF Integration + +| Function | Description | +|----------|-------------| +| `TabsVim_FzfOpenInTab()` | Open fzf file picker with `tabedit` as the sink (requires fzf.vim) | + +--- + +## Recommended vimrc Wiring + +Copy and adapt the block below. Remove any line you don't use. + +```vim +" ── Tab navigation ──────────────────────────────────────────────────────────── +" Native: gt / gT / gt — no plugin binding needed. +nnoremap wt :tabnew +nnoremap x :call TabsVim_CloseOrHide() +nnoremap X :tabonly + +" Direct tab jumps: 1 … 9 (native gt) +for s:i in range(1, 9) + execute 'nnoremap ' . s:i . ' ' . s:i . 'gt' +endfor + +" ── Window / split — native commands ───────────────────────────────────────── +nnoremap ws :sp +nnoremap wv :vsp +nnoremap wm :only +nnoremap wr :call TabsVim_RenameBuffer() + +" ── Terminal ────────────────────────────────────────────────────────────────── +nnoremap ts :call TabsVim_ToggleHorizTerm() +nnoremap tv :call TabsVim_ToggleVertTerm() +nnoremap tt :call TabsVim_NewTabTerm() + +" ── File operations — native commands ──────────────────────────────────────── +nnoremap gF :tabedit +nnoremap fy :let @+ = expand("%:p") +nnoremap ft :call TabsVim_FzfOpenInTab() +``` + +--- + +## Keybinding Notes + +### `g` for direct tab jumps + +`gt` (e.g. `3gt`) is native Vim and requires no plugin function. If you want a shorter shorthand, `g1`–`g9` are unbound in stock Vim and generally free — but `g0` is taken (go to first character of the screen line), so never bind `g0`. + +```vim +for s:i in range(1, 9) + execute 'nnoremap g' . s:i . ' ' . s:i . 'gt' +endfor +``` + +This is a pure vimrc concern — no plugin function is needed. + +### `` for terminal navigation + +The primary use case is quickly exiting terminal mode to normal mode so you can scroll, copy, and perform window operations (splits, focus changes, etc.). Vim's native way to do this is ``, which is an awkward two-key chord. `` is a natural single-key replacement: + +```vim +tnoremap " exit terminal mode → normal mode (scroll, copy, splits) +``` + +This mapping is **safe** — `` has no meaningful default binding in terminal mode. + +**Why `` and not ``?** `` is the ASCII equivalent of `` and most terminals send an identical byte sequence for both — Vim cannot distinguish them. Using `` as a terminal-exit binding would conflict with any `` mapping and produce unreliable behavior across terminal emulators. `` sends a distinct byte (`0x1D`) that terminals reliably deliver as-is. + +The companion normal-mode mapping is optional and carries a trade-off: + +```vim +nnoremap i " re-enter terminal insert mode from normal mode +``` + +In normal mode, `` is the native "jump to tag under cursor" (ctags / cscope / LSP). Override it only if you do not rely on tag jumping. If you do use tags, leave this one out and use `i` or `a` to re-enter terminal insert mode manually. + +--- + +## Features + +| Feature | Description | ADR | Done? | +|---------|-------------|-----|-------| +| **No OOTB keybindings** | Plugin installs zero keymaps; mouse DnD infrastructure only | ADR-004 | ✅ | +| **Public function API** | All operations promoted to `TabsVim_*` public functions | ADR-004 | ✅ | +| **Example vimrc block** | Ready-made mapping block for users to copy into vimrc | — | ✅ | diff --git a/docs/specs/tabs.vim.md b/docs/specs/tabs.vim.md index 99bbf06..a72fbba 100644 --- a/docs/specs/tabs.vim.md +++ b/docs/specs/tabs.vim.md @@ -257,7 +257,7 @@ let g:tabs_vim_colors = { ## Related Specs -None yet. +- [key-binding.md](key-binding.md) — public `TabsVim_*` function API, OOTB behavior contract, and recommended vimrc wiring --- diff --git a/plugin/tabs.vim b/plugin/tabs.vim index e6949e8..e98c249 100644 --- a/plugin/tabs.vim +++ b/plugin/tabs.vim @@ -18,8 +18,6 @@ let s:vterm_bufnr = -1 " TERMINAL: Split terminals & new tab terminal """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -" Toggle split terminal: below/horizontal (h, ts) -" or vertical (tv) function! s:ToggleTerm(bufvar, open_cmd, resize_cmd) abort let bufnr = eval(a:bufvar) for w in range(1, winnr('$')) @@ -37,22 +35,18 @@ function! s:ToggleTerm(bufvar, open_cmd, resize_cmd) abort execute a:resize_cmd endfunction -nnoremap h :call ToggleTerm('s:term_bufnr', 'below', 'resize 15') -nnoremap ts :call ToggleTerm('s:term_bufnr', 'below', 'resize 15') -nnoremap tv :call ToggleTerm('s:vterm_bufnr', 'vertical', 'vertical resize 80') +function! TabsVim_ToggleHorizTerm() abort + call s:ToggleTerm('s:term_bufnr', 'below', 'resize 15') +endfunction + +function! TabsVim_ToggleVertTerm() abort + call s:ToggleTerm('s:vterm_bufnr', 'vertical', 'vertical resize 80') +endfunction -" New tab terminal (tt) -function! s:NewTabTerm() abort +function! TabsVim_NewTabTerm() abort tab term setlocal nobuflisted endfunction -nnoremap tt :call NewTabTerm() - -" Terminal mode mappings -tnoremap -tnoremap -nnoremap i -inoremap " Terminal settings augroup TermSettings @@ -73,30 +67,12 @@ function! s:ResizeTerminals() abort endfunction """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -" WINDOWS & BUFFERS: Tab navigation, creation, closing +" WINDOWS & BUFFERS """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -" Tab navigation: next, prev -nnoremap :tabnext -nnoremap :tabprev - -" Window/split operations -nnoremap ws :sp -nnoremap wv :vsp -nnoremap wm :only - -" Buffer operations -nnoremap wr :call RenameBuffer() -nnoremap wb :Buffers - -" Tab operations -nnoremap wt :tabnew -nnoremap x :call CloseOrHideSplit() -nnoremap X :tabonly - -" Close window or terminal, with prompt to quit if it's the last window -function! s:CloseOrHideSplit() abort - if winnr('$') == 1 +" Close window or terminal, with prompt to quit if it's the last window in the last tab +function! TabsVim_CloseOrHide() abort + if tabpagenr('$') == 1 && winnr('$') == 1 if confirm('Quit Vim?', "&Yes\n&No", 2) == 1 qall! endif @@ -106,8 +82,10 @@ function! s:CloseOrHideSplit() abort if &buftype ==# 'terminal' if bufnr('%') == s:vterm_bufnr call s:ToggleTerm('s:vterm_bufnr', 'vertical', 'vertical resize 80') - else + elseif bufnr('%') == s:term_bufnr call s:ToggleTerm('s:term_bufnr', 'below', 'resize 15') + else + close endif else close @@ -115,7 +93,7 @@ function! s:CloseOrHideSplit() abort endfunction " Rename current buffer -function! s:RenameBuffer() abort +function! TabsVim_RenameBuffer() abort let l:new_name = input('Rename buffer: ', expand('%:t')) if empty(l:new_name) return @@ -123,14 +101,18 @@ function! s:RenameBuffer() abort execute 'file ' . fnameescape(l:new_name) endfunction -" Copy file path to clipboard -nnoremap fy :let @+ = expand("%:p") - -" gF: open file-under-cursor in a new tab (extends built-in gf) -nnoremap gF :tabedit - -" r: redo -nnoremap r redo +""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" +" FZF Integration: Open files in tabs +""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" +function! TabsVim_FzfOpenInTab() abort + if !exists('*fzf#vim#files') || !exists('*fzf#vim#with_preview') + echohl WarningMsg + echo 'tabs.vim: TabsVim_FzfOpenInTab requires fzf.vim (fzf#vim#files not available)' + echohl None + return + endif + call fzf#vim#files('', fzf#vim#with_preview({'sink': 'tabedit'}), 0) +endfunction """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" " DRAG-AND-DROP: Drop a file path onto the terminal → open in new tab @@ -289,22 +271,3 @@ if exists('##ModeChanged') autocmd ModeChanged * redrawtabline augroup END endif - -""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -" Direct Tab Jump: [1-9] to jump to tab N -""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -nnoremap 1 :1tabnext -nnoremap 2 :2tabnext -nnoremap 3 :3tabnext -nnoremap 4 :4tabnext -nnoremap 5 :5tabnext -nnoremap 6 :6tabnext -nnoremap 7 :7tabnext -nnoremap 8 :8tabnext -nnoremap 9 :9tabnext - -""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -" FZF Integration: Open files in tabs -""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -" ft: Open file in tab via fzf -nnoremap ft :call fzf#vim#files('', fzf#vim#with_preview({'sink': 'tabedit'}), 0)