Enhance navigation with repeat and reverse functionality

Add two new commands for bracket navigation: repeat last and reverse
last navigation. This reduces repetitive typing and improves user
experience when navigating through diagnostics, spelling, and
quickfix items.
This commit is contained in:
Ray Elliott 2026-01-18 13:38:42 +00:00
parent 8e2b71c7f1
commit 2a516e353b
1 changed files with 434 additions and 131 deletions

View File

@ -1,7 +1,163 @@
# Navigation Mode Feature Plan
# Navigation Enhancement Plans
## Concept
A custom navigation mode where keypresses are automatically prefixed with `[` or `]`, making it easier to navigate using Neovim's bracket-based navigation pairs without repeatedly typing brackets.
**Status**: Two complementary approaches being considered (not mutually exclusive)
Both approaches aim to reduce repetitive typing when navigating with Neovim's bracket-based pairs (`[c`, `]d`, etc.).
---
## Option 1: Repeat/Reverse Last Bracket Navigation
### Concept
Two simple commands that remember and replay the last `[x` or `]x` navigation:
- **Repeat**: Execute the same navigation again (e.g., `]d``.``.``.`)
- **Reverse**: Execute the opposite direction (e.g., after `]d`, press `,``[d`)
Similar to `;` and `,` for repeating/reversing `f/F/t/T` motions.
### User Experience
```vim
" Example 1: Scanning diagnostics
]d " Next diagnostic
. " Next diagnostic (repeat)
. " Next diagnostic (repeat)
. " Next diagnostic (repeat)
, " Previous diagnostic (reverse, oops went too far)
" Example 2: Checking spelling
]s " Next misspelling
. " Next misspelling
. " Next misspelling
" Example 3: Quickfix workflow
]q " Next quickfix item
. " Next quickfix
. " Next quickfix
```
### When This Works Best
- Repeatedly navigating **same type**: diagnostics, spelling, quickfix, location list
- Linear scanning through items of one kind
- Quick corrections when you overshoot (reverse)
### Implementation
**Module**: `lua/bracket-repeat.lua`
```lua
local M = {}
-- Track last bracket navigation
local last_nav = {
prefix = nil, -- '[' or ']'
key = nil, -- 'c', 'd', 'm', 's', 'q', etc.
}
--- Setup wrapper mappings to track bracket navigation
function M.setup()
-- Common bracket pairs to track
local pairs = {
'c', -- Git hunks (gitsigns)
'd', -- Diagnostics
's', -- Spelling
'q', -- Quickfix
'l', -- Location list
't', -- Tags
'm', -- Methods (treesitter)
'f', -- Functions (treesitter)
'p', -- Parameters (treesitter)
}
for _, key in ipairs(pairs) do
-- Wrap [x to track
vim.keymap.set('n', '[' .. key, function()
last_nav.prefix = '['
last_nav.key = key
return '[' .. key
end, { expr = true, silent = true })
-- Wrap ]x to track
vim.keymap.set('n', ']' .. key, function()
last_nav.prefix = ']'
last_nav.key = key
return ']' .. key
end, { expr = true, silent = true })
end
end
--- Repeat last bracket navigation
function M.repeat_last()
if not last_nav.prefix or not last_nav.key then
vim.notify('No bracket navigation to repeat', vim.log.levels.WARN)
return
end
vim.cmd('normal! ' .. last_nav.prefix .. last_nav.key)
end
--- Reverse last bracket navigation (flip direction)
function M.reverse_last()
if not last_nav.prefix or not last_nav.key then
vim.notify('No bracket navigation to reverse', vim.log.levels.WARN)
return
end
local opposite = last_nav.prefix == '[' and ']' or '['
vim.cmd('normal! ' .. opposite .. last_nav.key)
-- Update tracking to reflect the reversal
last_nav.prefix = opposite
end
return M
```
**Integration**: `init.lua` or `lua/keymaps.lua`
```lua
-- Setup tracking
require('bracket-repeat').setup()
-- Keybindings (choose one option)
-- Option A: Override ; and , (loses f/F/t/T repeat, but very ergonomic)
vim.keymap.set('n', ';', function() require('bracket-repeat').repeat_last() end,
{ desc = 'Repeat bracket navigation' })
vim.keymap.set('n', ',', function() require('bracket-repeat').reverse_last() end,
{ desc = 'Reverse bracket navigation' })
-- Option B: Use z prefix (keeps ; and , for f/t motions)
vim.keymap.set('n', 'z.', function() require('bracket-repeat').repeat_last() end,
{ desc = 'Repeat bracket navigation' })
vim.keymap.set('n', 'z,', function() require('bracket-repeat').reverse_last() end,
{ desc = 'Reverse bracket navigation' })
-- Option C: Use leader
vim.keymap.set('n', '<leader>.', function() require('bracket-repeat').repeat_last() end,
{ desc = 'Repeat bracket navigation' })
vim.keymap.set('n', '<leader>,', function() require('bracket-repeat').reverse_last() end,
{ desc = 'Reverse bracket navigation' })
```
### Pros & Cons
**Pros:**
- ✅ Very simple (~40 lines)
- ✅ No mode switching, stays in normal mode
- ✅ Familiar pattern (like `;`/`,` for `f`/`t`)
- ✅ Works with natural workflow
- ✅ Easy to add more tracked pairs
**Cons:**
- ⚠️ Only handles one "type" at a time (can't easily switch between `]d` and `]c`)
- ⚠️ Requires calling `.setup()` to track pairs
- ⚠️ Might want `;`/`,` for `f`/`t` repeat (depends on keybinding choice)
### Estimated Complexity
- **Total**: ~40 lines
- **Time**: 15-30 minutes
---
## Option 2: Navigation Mode (getchar Loop)
## User Experience
@ -24,28 +180,43 @@ A custom navigation mode where keypresses are automatically prefixed with `[` or
- `]` - Switch to `]` prefix (forward navigation)
### Exit
- `<Esc>` - Exit navigation mode and restore normal mappings
- `<Esc>` - Exit navigation mode and return to normal editing
### When This Works Best
- Switching between **different types** of navigation (`]d` → `]c``]m`)
- Want visual feedback showing current direction
- Prefer a dedicated "navigation state"
- Mixed workflow: `]q` to quickfix, then multiple `]c` for hunks (stay in mode, switch keys)
### Example Workflow
```vim
" Mixed navigation scenario
z] " Enter forward navigation mode (shows: NAV ] →:)
q " Execute ]q (next quickfix)
c " Execute ]c (next hunk)
c " Execute ]c (next hunk)
[ " Toggle to backward (shows: NAV [ ←:)
c " Execute [c (previous hunk)
d " Execute [d (previous diagnostic)
<Esc> " Exit mode
```
## Technical Implementation
### State Management
```lua
local NavMode = {
active = false,
prefix = '[', -- '[' or ']'
}
```
### Approach: getchar() Loop (No Remapping)
### Key Remapping Strategy
1. Store original mappings for letters a-z, A-Z
2. On mode entry, create new mappings: `key → prefix .. key`
3. On mode exit, restore original mappings
4. On prefix toggle, update all mappings with new prefix
Instead of remapping keys, use a `while` loop with `vim.fn.getchar()` to read keypresses and manually execute the prefixed commands.
### Keys to Remap
- Lowercase: `a-z` (26 keys)
- Uppercase: `A-Z` (26 keys)
- Total: 52 keys dynamically remapped
**Why this approach:**
- ✅ Zero mapping conflicts (never touches existing keymaps)
- ✅ Simpler implementation (~50-80 lines vs ~150-250)
- ✅ Built-in visual feedback via `vim.api.nvim_echo()`
- ✅ Impossible to leak state (no cleanup needed if crashed)
- ✅ Predictable behavior (each keypress isolated)
- ✅ Naturally handles special keys
**The "blocking" behavior is actually desired** - you're in a focused navigation mode, press `<Esc>` to exit anytime.
### Common Bracket Navigation Pairs
- `[c`/`]c` - Previous/next git hunk (gitsigns)
@ -66,74 +237,53 @@ Create `lua/navigation-mode.lua`:
```lua
local M = {}
local state = {
active = false,
prefix = '[',
stored_mappings = {},
}
local LETTERS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
--- Enter navigation mode with getchar() loop
--- @param prefix string Either '[' or ']'
function M.enter(prefix)
if state.active then return end
prefix = prefix or '['
state.prefix = prefix or '['
state.active = true
-- Store original mappings and create prefixed ones
for i = 1, #LETTERS do
local key = LETTERS:sub(i, i)
-- Store original mapping (if exists)
-- Create new mapping: key → prefix .. key
vim.keymap.set('n', key, state.prefix .. key, { noremap = true, silent = true })
while true do
-- Display prompt
local hl = prefix == '[' and 'DiagnosticInfo' or 'DiagnosticHint'
local arrow = prefix == '[' and '←' or '→'
vim.api.nvim_echo({{string.format('NAV %s %s: ', prefix, arrow), hl}}, false, {})
-- Get next keypress
local ok, char = pcall(vim.fn.getchar)
if not ok then break end -- Handle <C-c> gracefully
-- Convert to string
local key = type(char) == 'number' and vim.fn.nr2char(char) or char
-- Handle special keys
if key == '\27' then -- ESC
break
elseif key == '[' then
prefix = '['
elseif key == ']' then
prefix = ']'
else
-- Execute prefix + key as normal mode command
local cmd = prefix .. key
vim.cmd('normal! ' .. vim.api.nvim_replace_termcodes(cmd, true, false, true))
end
end
-- Special mappings for [ and ] to toggle prefix
vim.keymap.set('n', '[', function() M.set_prefix('[') end, { noremap = true, silent = true })
vim.keymap.set('n', ']', function() M.set_prefix(']') end, { noremap = true, silent = true })
-- Exit mapping
vim.keymap.set('n', '<Esc>', function() M.exit() end, { noremap = true, silent = true })
-- TODO: Set visual feedback (statusline, notification, etc.)
end
function M.set_prefix(new_prefix)
if not state.active then return end
state.prefix = new_prefix
-- Remap all letters with new prefix
for i = 1, #LETTERS do
local key = LETTERS:sub(i, i)
vim.keymap.set('n', key, state.prefix .. key, { noremap = true, silent = true })
end
-- TODO: Update visual feedback
end
function M.exit()
if not state.active then return end
-- Restore original mappings
for i = 1, #LETTERS do
local key = LETTERS:sub(i, i)
vim.keymap.del('n', key)
-- Restore stored mapping if it existed
end
-- Clean up special mappings
vim.keymap.del('n', '[')
vim.keymap.del('n', ']')
vim.keymap.del('n', '<Esc>')
state.active = false
-- TODO: Clear visual feedback
-- Clear prompt
vim.api.nvim_echo({{'', 'Normal'}}, false, {})
end
return M
```
**Key Points:**
- No state management needed (self-contained loop)
- `pcall(vim.fn.getchar)` handles `<C-c>` interrupts gracefully
- `vim.api.nvim_replace_termcodes()` ensures special key sequences work
- Visual feedback built into the loop (shows `NAV [ ←:` or `NAV ] →:`)
- Press `[` or `]` to toggle prefix without exiting
- Press `<Esc>` (or `<C-c>`) to exit
### Integration in keymaps.lua
```lua
-- Navigation mode
@ -146,71 +296,224 @@ vim.keymap.set('n', 'z]', function()
end, { desc = 'Enter navigation mode (forward)' })
```
## Visual Feedback Options (TODO)
## Visual Feedback
Need to decide on one or more:
Built into the getchar() loop:
- Shows `NAV [ ←:` (in blue) when in backward mode
- Shows `NAV ] →:` (in teal) when in forward mode
- Prompt updates immediately when toggling with `[` or `]`
- Clears when exiting with `<Esc>`
1. **Statusline indicator**: Show `[NAV ←]` or `[NAV →]` in statusline
2. **Notification**: Brief message on mode entry/exit
3. **Command line**: `echo` message showing current prefix
4. **Cursor highlight**: Change cursor color/shape
5. **Virtual text**: Floating indicator in corner of window
No additional statusline integration needed - the command line prompt is clear and non-intrusive.
## Open Questions
## Design Decisions
1. **Mapping conflicts**: How to handle if a letter already has a mapping?
- Overwrite temporarily?
- Skip that letter?
- Warn user?
### Mapping Conflicts: Solved
No mapping conflicts possible - getchar() reads raw input without touching keymaps.
2. **Buffer-local mappings**: Should mode respect buffer-local mappings?
- Store and restore per-buffer?
- Global mode only?
### Buffer-local Mappings: Not Applicable
Loop executes `normal! [key` which uses whatever mappings exist naturally.
3. **Visual feedback**: Which approach is clearest without being intrusive?
### Visual Feedback: Built-in
Command line prompt is sufficient and non-intrusive.
4. **Number keys**: Should `0-9` also be prefixed?
- Useful for some navigation pairs
- But might conflict with counts
### Number Keys
Currently not prefixed - this allows using counts if a prefixed command accepts them.
Example: `3c` → executes `[3c` or `]3c` (may not be useful, but won't break anything)
5. **Operators**: Should `d`, `c`, `y` still work as operators or only as navigation?
- Current plan: They become navigation only while in mode
- Trade-off: Can't delete/change while navigating
Could add special handling if needed:
```lua
if key:match('^%d$') then
-- Handle numbers specially
end
```
## Implementation Phases
### Operators (d, c, y, etc.)
In navigation mode, `d` executes `[d` (diagnostic navigation), not delete operator.
This is desired behavior - use `<Esc>` to exit and edit normally.
1. **Core functionality** (essential)
- Mode enter/exit
- Key remapping for a-z
- Prefix toggle
- Basic state management
### Error Handling
If a command doesn't exist (e.g., `[z`), Vim will show an error but mode continues.
User can press `<Esc>` to exit or try another key.
2. **Visual feedback** (important)
- Choose and implement feedback mechanism
- Ensure clarity when mode is active
### Pros & Cons
3. **Edge cases** (polish)
- Handle existing mappings gracefully
- Buffer-local mapping preservation
- Mode timeout/auto-exit?
**Pros:**
- ✅ Switch between different navigation types easily (`c` → `d``m`)
- ✅ Visual feedback (command line prompt)
- ✅ No mapping conflicts
- ✅ Clean state (nothing to cleanup if interrupted)
- ✅ Natural for rapid mixed navigation
4. **Documentation** (completion)
- Update README.md with keymaps
- Add examples of useful navigation pairs
- Consider adding to help docs
**Cons:**
- ⚠️ Requires mode switching (mental overhead)
- ⚠️ Blocking loop (though this is by design)
- ⚠️ Need to remember entry/exit keys
## Estimated Complexity
- **Core**: ~100-150 lines of Lua
- **Visual feedback**: +20-50 lines depending on approach
- **Edge case handling**: +30-50 lines
- **Total**: 150-250 lines
### Estimated Complexity
- **Total**: ~50-80 lines
- **Time**: 30-60 minutes
## Testing Checklist
- [ ] Enter mode with `z[` and `z]`
- [ ] Verify letter keys are prefixed correctly
- [ ] Toggle between `[` and `]` prefix
- [ ] Exit cleanly with `<Esc>`
- [ ] No residual mappings after exit
---
## Comparison & Recommendation
| Aspect | Repeat/Reverse | Navigation Mode |
|--------|---------------|----------------|
| **Best for** | Same-type scanning | Mixed navigation |
| **Complexity** | ~40 lines | ~50-80 lines |
| **Mental model** | Like `;`/`,` | New mode |
| **Typing (4× same nav)** | `]d ...` (4 keys) | `z] dddd <Esc>` (9 keys) |
| **Typing (mixed nav)** | `]d ]d ]c ]c` (8 keys) | `z] ddcc <Esc>` (9 keys) |
| **Mode switching** | No | Yes |
| **Visual feedback** | No (unless added) | Yes (built-in) |
### Use Cases
**Repeat/Reverse excels at:**
- Scanning diagnostics: `]d . . . .`
- Spell checking: `]s . . . .`
- Reviewing quickfix: `]q . . . .`
- Going back: `. . . , , ,`
**Navigation Mode excels at:**
- Mixed workflow: `]q` → multiple `]c` hunks → check `]d` diagnostic
- When you want visual confirmation of direction
- Exploring unfamiliar code (trying different navigation types)
### Implementation Strategy
**Both can coexist!** They solve slightly different problems:
1. **Start with Repeat/Reverse** (simpler, covers 80% of cases)
- Use for linear scanning (diagnostics, spelling, quickfix)
- Bind to `z.` and `z,` (or `;`/`,` if you don't use `f`/`t` repeat often)
2. **Add Navigation Mode later** (optional, for mixed navigation)
- Use when you need to rapidly switch types
- Bind to `z[` and `z]`
You'll naturally reach for whichever fits the situation better.
---
## Common Bracket Pairs Reference
- `[c`/`]c` - Previous/next git hunk (gitsigns)
- `[d`/`]d` - Previous/next diagnostic
- `[s`/`]s` - Previous/next misspelling
- `[q`/`]q` - Previous/next quickfix item
- `[l`/`]l` - Previous/next location list item
- `[m`/`]m` - Previous/next method (treesitter textobjects)
- `[f`/`]f` - Previous/next function (treesitter textobjects)
- `[p`/`]p` - Previous/next parameter (treesitter textobjects)
- `[t`/`]t` - Previous/next tag
- `[b`/`]b` - Previous/next buffer (if custom mapping exists)
---
## Testing Checklists
### Repeat/Reverse Testing
- [ ] Setup completes without errors
- [ ] `]d` followed by `.` repeats diagnostic navigation
- [ ] `]s` followed by `.` repeats spelling navigation
- [ ] `]q` followed by `.` repeats quickfix navigation
- [ ] `,` reverses last navigation direction
- [ ] Warning shown when no navigation to repeat/reverse
- [ ] Works across different buffers
- [ ] Visual feedback is clear
- [ ] Common navigation pairs work (c, d, m, f, q)
- [ ] Multiple reverses work: `. . . , , ,`
### Navigation Mode Testing
- [ ] Enter mode with `z[` and `z]`
- [ ] Verify visual prompt shows `NAV [ ←:` or `NAV ] →:`
- [ ] Press `c` → navigates to previous/next git hunk
- [ ] Press `d` → navigates to previous/next diagnostic
- [ ] Press `m` → navigates to previous/next method
- [ ] Press `f` → navigates to previous/next function
- [ ] Toggle with `[` → prompt updates to `NAV [ ←:`
- [ ] Toggle with `]` → prompt updates to `NAV ] →:`
- [ ] Exit with `<Esc>` → prompt clears, back to normal mode
- [ ] Exit with `<C-c>` → handles gracefully
- [ ] Works across different buffers
- [ ] Invalid keys (e.g., `z`) show error but don't crash
- [ ] Existing keymaps still work after exit
---
## Implementation Priority
**Recommended order:**
1. **Phase 1: Repeat/Reverse** (start here)
- Simpler, faster to implement
- Covers most common use cases
- Get immediate value
2. **Phase 2: Navigation Mode** (optional, evaluate need)
- Implement only if you find yourself wanting mixed navigation
- Can be added anytime without conflicts
- Both features work together
**Time estimate:**
- Phase 1: 15-30 minutes
- Phase 2: 30-60 minutes
- Total: 45-90 minutes if both implemented
---
## Implementation Steps
### For Repeat/Reverse (Start Here)
1. **Create `lua/bracket-repeat.lua`** (~40 lines)
- Implement tracking state
- Implement `setup()`, `repeat_last()`, `reverse_last()`
2. **Add to `init.lua`**
```lua
require('bracket-repeat').setup()
```
3. **Add keymaps** (`lua/keymaps.lua`)
```lua
vim.keymap.set('n', 'z.', function() require('bracket-repeat').repeat_last() end)
vim.keymap.set('n', 'z,', function() require('bracket-repeat').reverse_last() end)
```
4. **Test** with diagnostics, spelling, quickfix
5. **Document** in README.md
### For Navigation Mode (Optional Later)
1. **Create `lua/navigation-mode.lua`** (~50 lines)
- Implement `M.enter(prefix)` with getchar() loop
- Handle ESC, `[`, `]`, and general keys
- Add visual feedback via `nvim_echo`
2. **Add keymaps** (`lua/keymaps.lua`)
```lua
vim.keymap.set('n', 'z[', function()
require('navigation-mode').enter('[')
end, { desc = 'Navigation mode (backward)' })
vim.keymap.set('n', 'z]', function()
require('navigation-mode').enter(']')
end, { desc = 'Navigation mode (forward)' })
```
3. **Test basic functionality**
- Enter with `z[` / `z]`
- Navigate with `c`, `d`, `m`, `f`, `q`
- Toggle prefix with `[` / `]`
- Exit with `<Esc>`
4. **Polish** (optional)
- Add help text on first use
- Handle `<C-c>` gracefully (already done with `pcall`)
- Consider adding common navigation cheatsheet
5. **Document**
- Update `README.md` with new keymaps
- Add navigation pairs reference