Skip to content

Commit 217bf13

Browse files
authored
Merge pull request #127 from FluxxField/dev
Selection handlers & pipeline-modifying support
2 parents 3bf6265 + 0ec9294 commit 217bf13

11 files changed

Lines changed: 511 additions & 0 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,12 @@ SmartMotion wouldn't exist without these plugins. See [Why SmartMotion](https://
424424
search_idle_timeout_ms = 2000, -- exit search with no input
425425
yank_highlight_duration = 150, -- yank flash duration (ms)
426426
history_max_age_days = 30, -- prune history entries older than this
427+
selection_keys = { -- key-action map during label selection
428+
["<CR>"] = "select_first", -- Enter picks the first target
429+
["<M-d>"] = "toggle_direction", -- Flip search direction
430+
["<M-w>"] = "toggle_multi_window", -- Toggle single/multi-window
431+
["<M-e>"] = "expand_search_scope", -- Double the search scope
432+
},
427433
}
428434
```
429435

docs/Advanced-Features.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,118 @@ ds + ... = delete via search df + .. = delete via 2-char find
8484

8585
---
8686

87+
## Selection Handlers
88+
89+
During label selection, special keys can trigger **selection handlers** — registered actions that modify the selection flow. Three built-in handlers ship with SmartMotion.
90+
91+
### Select First / Select Last
92+
93+
Press `<CR>` during label selection to instantly select the first target. This bridges the gap between SmartMotion's label-based selection and vanilla Vim behavior.
94+
95+
Search motions like `f`, `t`, and `s` always require reading and pressing a label, even when you want the closest match. Unlike `dww` (repeat the motion key) or flow state (`jj`), there's no quick shortcut for "just give me the first one."
96+
97+
```
98+
fa<CR> jump to the first "a" (vanilla f behavior)
99+
ta<CR> jump to just before the first "a" (vanilla t behavior)
100+
sa<CR> jump to the first "a" search match
101+
dw<CR> delete to the first word target
102+
```
103+
104+
`select_last` does the inverse — selects the furthest target. Not mapped by default; add it to your config:
105+
106+
```lua
107+
selection_keys = {
108+
["<CR>"] = "select_first",
109+
["<S-CR>"] = "select_last",
110+
}
111+
```
112+
113+
### Toggle Hint Position
114+
115+
Flips hint labels between the start and end of each target. Useful when a hint overlaps text you're trying to read. Not mapped by default:
116+
117+
```lua
118+
selection_keys = {
119+
["<CR>"] = "select_first",
120+
["<M-h>"] = "toggle_hint_position",
121+
}
122+
```
123+
124+
Press the toggle key during label selection — hints re-render at the opposite end of each target. Press it again to flip back. Then pick your label as usual.
125+
126+
### Pipeline-Modifying Handlers
127+
128+
Some selection handlers can re-run the entire motion pipeline during label selection.
129+
This lets you change the search context without cancelling and re-triggering the motion.
130+
131+
**Built-in pipeline-modifying handlers:**
132+
133+
- `toggle_direction` (`<M-d>`) — Flip between forward and backward search. Press during
134+
`w` labels to switch to backward words, or during `s` results to search the other direction.
135+
136+
- `toggle_multi_window` (`<M-w>`) — Toggle between single-window and multi-window target
137+
collection. Expand a word motion to show targets across all visible windows.
138+
139+
- `expand_search_scope` (`<M-e>`) — Double the search scope (max_lines). Press multiple
140+
times to progressively expand. Useful when the target you want is just outside the
141+
initial scope.
142+
143+
**How it works:** When these handlers are triggered, the plugin:
144+
1. Resets targets and labels
145+
2. Re-runs the pipeline with the modified `motion_state`
146+
3. Regenerates and renders new hint labels
147+
4. Returns to the selection loop for your next keypress
148+
149+
If the re-run produces no targets (e.g., toggling direction when nothing is behind the
150+
cursor), the motion cancels gracefully.
151+
152+
### Relationship to Other Shortcuts
153+
154+
SmartMotion has several ways to "skip" or modify label selection:
155+
156+
| Method | How | Best for |
157+
|--------|-----|----------|
158+
| **Flow state** | Press motion key quickly after a previous motion | Chaining motions (`w``w``j`) |
159+
| **Repeat motion key** | Press motion key during labels (`dww`) | Acting on cursor target |
160+
| **Select first target** | Press `<CR>` during labels | Picking the closest match |
161+
| **Toggle hint position** | Press configured key during labels | Reading obscured text |
162+
| **Toggle direction** | Press `<M-d>` during labels | Flipping search direction |
163+
| **Toggle multi-window** | Press `<M-w>` during labels | Expanding to all windows |
164+
| **Expand search scope** | Press `<M-e>` during labels | Reaching further targets |
165+
166+
All coexist. Selection handlers are checked after the motion-key-repeat check, so `dww` still works as expected.
167+
168+
### Configuration
169+
170+
Enabled by default with four mappings. The `selection_keys` config maps keys to registered handlers:
171+
172+
```lua
173+
-- Default
174+
selection_keys = {
175+
["<CR>"] = "select_first",
176+
["<M-d>"] = "toggle_direction",
177+
["<M-w>"] = "toggle_multi_window",
178+
["<M-e>"] = "expand_search_scope",
179+
}
180+
181+
-- Full example
182+
selection_keys = {
183+
["<CR>"] = "select_first",
184+
["<S-CR>"] = "select_last",
185+
["<M-h>"] = "toggle_hint_position",
186+
["<M-d>"] = "toggle_direction",
187+
["<M-w>"] = "toggle_multi_window",
188+
["<M-e>"] = "expand_search_scope",
189+
}
190+
191+
-- Disable
192+
selection_keys = false
193+
```
194+
195+
See **[Configuration: Selection Keys](Configuration.md#selection-keys)** for details.
196+
197+
---
198+
87199
## Operator-Pending Mode
88200

89201
SmartMotion motions work with **any vim operator**.

docs/Configuration.md

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ Complete guide to configuring SmartMotion.
6767

6868
-- Prune history entries older than this many days
6969
history_max_age_days = 30,
70+
71+
-- Key-action map for label selection (press key to trigger action)
72+
selection_keys = {
73+
["<CR>"] = "select_first", -- Enter selects the first target
74+
["<M-d>"] = "toggle_direction", -- Flip search direction
75+
["<M-w>"] = "toggle_multi_window", -- Toggle single/multi-window
76+
["<M-e>"] = "expand_search_scope", -- Double the search scope
77+
},
7078
}
7179
```
7280

@@ -452,6 +460,98 @@ See **[Advanced Features: Motion History](Advanced-Features.md#motion-history)**
452460

453461
---
454462

463+
## Selection Keys
464+
465+
Configure special keys that trigger actions during label selection:
466+
467+
```lua
468+
selection_keys = {
469+
["<CR>"] = "select_first",
470+
["<M-d>"] = "toggle_direction",
471+
["<M-w>"] = "toggle_multi_window",
472+
["<M-e>"] = "expand_search_scope",
473+
}
474+
```
475+
476+
**How it works:**
477+
1. A motion triggers and labels appear
478+
2. Instead of pressing a label, press a configured key
479+
3. The corresponding handler runs
480+
481+
### Built-in Handlers
482+
483+
| Handler Name | Default Key | Return | Description |
484+
|---|---|---|---|
485+
| `select_first` | `<CR>` | exits | Selects the first (closest) target |
486+
| `select_last` | (none) | exits | Selects the last (furthest) target |
487+
| `toggle_hint_position` | (none) | stays | Toggles hint labels between start/end of targets |
488+
| `toggle_direction` | `<M-d>` | re-runs | Flips search direction (forward/backward) |
489+
| `toggle_multi_window` | `<M-w>` | re-runs | Toggles single/multi-window target collection |
490+
| `expand_search_scope` | `<M-e>` | re-runs | Doubles the search scope (max_lines) |
491+
492+
Add others as needed:
493+
494+
```lua
495+
selection_keys = {
496+
["<CR>"] = "select_first",
497+
["<S-CR>"] = "select_last",
498+
["<M-h>"] = "toggle_hint_position",
499+
["<M-d>"] = "toggle_direction",
500+
["<M-w>"] = "toggle_multi_window",
501+
["<M-e>"] = "expand_search_scope",
502+
}
503+
```
504+
505+
### Examples
506+
507+
```
508+
fa<CR> jump to the first "a" (same as vanilla f)
509+
sa<CR> jump to the first "a" match
510+
dw<CR> delete to the first word target
511+
w<M-h> toggle hints to end-of-word, then pick a label
512+
w<M-d> flip to backward words, then pick a label
513+
s<M-w> toggle multi-window search, then pick a label
514+
w<M-e> double the search scope, then pick a label
515+
```
516+
517+
### Handler Return Values
518+
519+
Handlers return a value that controls the selection flow:
520+
521+
- **`true`** — accept the selection and exit (like `select_first`, `select_last`)
522+
- **`false`** — stay in the selection loop and wait for the next keypress (like `toggle_hint_position`)
523+
- **`"rerun"`** — re-run the full pipeline with modified state, then return to selection (like `toggle_direction`, `toggle_multi_window`, `expand_search_scope`)
524+
525+
### Disable
526+
527+
```lua
528+
selection_keys = false
529+
```
530+
531+
### Custom Handlers
532+
533+
Selection handlers are registered modules, just like collectors, extractors, and actions:
534+
535+
```lua
536+
require("smart-motion").selection_handlers.register("my_handler", {
537+
run = function(ctx, cfg, motion_state)
538+
-- Modify motion_state, re-render hints, etc.
539+
-- Return true to exit selection, false to stay
540+
return true
541+
end,
542+
})
543+
544+
-- Then reference it in config:
545+
selection_keys = {
546+
["<CR>"] = "select_first",
547+
["<Tab>"] = "my_handler",
548+
}
549+
```
550+
551+
Keys are normalized via Vim's key notation, so `<CR>`, `<Tab>`, `<M-h>` all work.
552+
553+
---
554+
455555
## Complete Example
456556

457557
```lua
@@ -522,6 +622,9 @@ See **[Advanced Features: Motion History](Advanced-Features.md#motion-history)**
522622

523623
-- Keep history for 90 days
524624
history_max_age_days = 90,
625+
626+
-- Disable select-first-target (prefer explicit label selection)
627+
-- selection_keys = false,
525628
},
526629
}
527630
```

lua/smart-motion/config.lua

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ M.defaults = {
3737
search_idle_timeout_ms = 2000,
3838
yank_highlight_duration = 150,
3939
history_max_age_days = 30,
40+
selection_keys = {
41+
["<CR>"] = "select_first",
42+
["<M-d>"] = "toggle_direction",
43+
["<M-w>"] = "toggle_multi_window",
44+
["<M-e>"] = "expand_search_scope",
45+
},
4046
}
4147

4248
---@type SmartMotionConfig
@@ -214,6 +220,27 @@ function M.validate(user_config)
214220
config.history_max_age_days = 30
215221
end
216222

223+
--
224+
-- Validate selection_keys
225+
--
226+
if config.selection_keys ~= false then
227+
if config.selection_keys ~= nil and type(config.selection_keys) ~= "table" then
228+
config.selection_keys = M.defaults.selection_keys
229+
end
230+
231+
if type(config.selection_keys) == "table" then
232+
local normalized = {}
233+
for key, action in pairs(config.selection_keys) do
234+
if type(key) == "string" and type(action) == "string" then
235+
normalized[vim.fn.keytrans(key)] = action
236+
else
237+
log.warn("selection_keys: skipping invalid entry (key=" .. tostring(key) .. ", action=" .. tostring(action) .. ")")
238+
end
239+
end
240+
config.selection_keys = normalized
241+
end
242+
end
243+
217244
M.validated = config
218245

219246
return config

lua/smart-motion/core/registries.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ local log = require("smart-motion.core.log")
88
--- @field visualizers boolean
99
--- @field actions boolean
1010
--- @field pipeline_wrappers boolean
11+
--- @field selection_handlers boolean
1112
--- @field motions boolean
1213

1314
--- @type RegistryConstructors
@@ -19,6 +20,7 @@ local default_registry_constructors = {
1920
visualizers = true,
2021
actions = true,
2122
pipeline_wrappers = true,
23+
selection_handlers = true,
2224
motions = true,
2325
}
2426

lua/smart-motion/core/selection.lua

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,52 @@ local highlight = require("smart-motion.core.highlight")
33
local consts = require("smart-motion.consts")
44
local flow_state = require("smart-motion.core.flow_state")
55
local targets = require("smart-motion.core.targets")
6+
local exit_event = require("smart-motion.core.events.exit")
7+
local pipeline = require("smart-motion.core.engine.pipeline")
8+
local module_loader = require("smart-motion.utils.module_loader")
69

710
local M = {}
811

12+
--- Re-runs the full pipeline after a selection handler modified motion_state.
13+
--- Resets state, clears highlights, runs pipeline + visualizer, then recurses to selection.
14+
--- Mirrors the search-mode re-run pattern in loop.lua.
15+
---@param ctx SmartMotionContext
16+
---@param cfg SmartMotionConfig
17+
---@param motion_state SmartMotionMotionState
18+
function M._rerun_pipeline(ctx, cfg, motion_state)
19+
local state = require("smart-motion.core.state")
20+
21+
-- Reset selection state (targets, labels, counts)
22+
state.reset(motion_state)
23+
highlight.clear(ctx, cfg, motion_state)
24+
25+
-- Re-run pipeline (wrapped to catch exit events like EARLY_EXIT)
26+
local exit_type = exit_event.wrap(function()
27+
pipeline.run(ctx, cfg, motion_state)
28+
end)
29+
30+
-- If pipeline threw an exit event (e.g., no targets), cancel gracefully
31+
if exit_type then
32+
log.debug("Pipeline re-run exited with: " .. tostring(exit_type))
33+
motion_state.selected_jump_target = nil
34+
return
35+
end
36+
37+
local targets_list = motion_state.jump_targets or {}
38+
if #targets_list == 0 then
39+
log.debug("Pipeline re-run produced no targets")
40+
motion_state.selected_jump_target = nil
41+
return
42+
end
43+
44+
-- Re-run visualizer to regenerate and render hint labels
45+
local visualizer = module_loader.get_module(ctx, cfg, motion_state, "visualizer")
46+
visualizer.run(ctx, cfg, motion_state)
47+
48+
-- Recurse back into selection
49+
return M.wait_for_hint_selection(ctx, cfg, motion_state)
50+
end
51+
952
--- Waits for the user to press a hint key and handles both single and double character hints.
1053
---@param ctx SmartMotionContext
1154
---@param cfg SmartMotionConfig
@@ -50,6 +93,31 @@ function M.wait_for_hint_selection(ctx, cfg, motion_state)
5093
end
5194
end
5295

96+
-- Selection action keys (e.g., <CR> = select_first, <M-h> = toggle_hint_position)
97+
-- Handlers are registered in the selection_handlers registry.
98+
-- Return true = accept selection and exit. Return false = stay in selection loop.
99+
if cfg.selection_keys then
100+
local key_name = vim.fn.keytrans(char)
101+
local handler_name = cfg.selection_keys[key_name]
102+
if handler_name then
103+
local registries = require("smart-motion.core.registries"):get()
104+
local handler = registries.selection_handlers.get_by_name(handler_name)
105+
if handler then
106+
local result = handler.run(ctx, cfg, motion_state)
107+
log.debug("Selection handler '" .. handler_name .. "' triggered via " .. key_name)
108+
109+
if result == "rerun" then
110+
return M._rerun_pipeline(ctx, cfg, motion_state)
111+
elseif result then
112+
return
113+
end
114+
115+
-- Handler returned false: stay in selection, wait for next key
116+
return M.wait_for_hint_selection(ctx, cfg, motion_state)
117+
end
118+
end
119+
end
120+
53121
if flow_state.evaluate_flow_at_selection() and motion_state.selection_mode ~= consts.SELECTION_MODE.SECOND then
54122
log.debug("Selection is jumping early")
55123
return

0 commit comments

Comments
 (0)