reovim_plugin_which_key/
lib.rs

1//! Which-key plugin for reovim
2//!
3//! Shows available keybindings in a popup panel when pressing `?` after a prefix key.
4//! For example, pressing `g?` shows all bindings starting with `g`.
5
6mod commands;
7mod filter;
8mod saturator;
9mod state;
10
11pub use commands::{WhichKeyBackspace, WhichKeyClose, WhichKeyOpen, WhichKeyTrigger};
12
13use std::sync::Arc;
14
15use {
16    arc_swap::ArcSwap,
17    reovim_core::{
18        bind::{CommandRef, KeymapScope, SubModeKind},
19        command::CommandId,
20        event::RuntimeEvent,
21        event_bus::{EventBus, EventResult, PluginBackspace, PluginTextInput, RequestModeChange},
22        frame::FrameBuffer,
23        highlight::Theme,
24        keys,
25        keystroke::Keystroke,
26        modd::{ComponentId, EditMode, ModeState, SubMode},
27        plugin::{
28            EditorContext, PanelPosition, Plugin, PluginContext, PluginId, PluginStateRegistry,
29            PluginWindow, Rect, WindowConfig,
30        },
31    },
32    tokio::sync::mpsc,
33};
34
35/// Component ID for the which-key plugin
36pub const COMPONENT_ID: ComponentId = ComponentId("which_key");
37
38use crate::{
39    saturator::spawn_whichkey_saturator,
40    state::{WhichKeyCacheHolder, WhichKeyState},
41};
42
43/// Which-key plugin for showing available keybindings
44pub struct WhichKeyPlugin;
45
46impl Plugin for WhichKeyPlugin {
47    fn id(&self) -> PluginId {
48        PluginId::new("reovim:which-key")
49    }
50
51    fn name(&self) -> &'static str {
52        "Which-Key"
53    }
54
55    fn description(&self) -> &'static str {
56        "Shows available keybindings after pressing a prefix key"
57    }
58
59    fn build(&self, ctx: &mut PluginContext) {
60        // Register commands
61        let _ = ctx.register_command(WhichKeyClose);
62        let _ = ctx.register_command(WhichKeyBackspace);
63
64        // Register ? keybindings for common prefixes in editor normal mode
65        // Each binding triggers which-key with the appropriate prefix
66        let editor_normal = KeymapScope::editor_normal();
67
68        // Standalone ? (shows all bindings)
69        ctx.keymap_mut().bind_scoped(
70            editor_normal.clone(),
71            keys!['?'],
72            CommandRef::Inline(WhichKeyTrigger::arc(keys![])),
73        );
74
75        // Common prefix + ? bindings
76        // These allow g?, z?, Space?, d?, y?, c? to show context-specific bindings
77        let common_prefixes = [
78            keys!['g'],       // g-prefix commands
79            keys!['z'],       // fold commands
80            keys![Space],     // leader key
81            keys![Space 'f'], // find prefix
82            keys![Space 'b'], // buffer prefix
83            keys![Space 'w'], // window prefix
84            keys!['d'],       // delete operator
85            keys!['y'],       // yank operator
86            keys!['c'],       // change operator
87        ];
88
89        for prefix in common_prefixes {
90            let mut full_keys = prefix.clone();
91            full_keys.push(Keystroke::char('?'));
92            ctx.keymap_mut().bind_scoped(
93                editor_normal.clone(),
94                full_keys,
95                CommandRef::Inline(WhichKeyTrigger::arc(prefix)),
96            );
97        }
98
99        // Define which-key interactor scope for keybindings
100        let which_key_interactor = KeymapScope::SubMode(SubModeKind::Interactor(COMPONENT_ID));
101
102        // Bind Escape to close the which-key panel (in interactor mode)
103        ctx.keymap_mut().bind_scoped(
104            which_key_interactor.clone(),
105            keys![Escape],
106            CommandRef::Registered(CommandId::new("which_key_close")),
107        );
108
109        // Bind Backspace to remove last filter key (in interactor mode)
110        ctx.keymap_mut().bind_scoped(
111            which_key_interactor,
112            keys![Backspace],
113            CommandRef::Registered(CommandId::new("which_key_backspace")),
114        );
115    }
116
117    fn init_state(&self, registry: &PluginStateRegistry) {
118        // Create ArcSwap cache (saturator spawned in boot() with event_tx)
119        let cache = Arc::new(ArcSwap::new(Arc::new(state::WhichKeyCache::default())));
120
121        // Register cache for boot() to access
122        registry.register(WhichKeyCacheHolder {
123            cache: cache.clone(),
124        });
125        registry.register_plugin_window(Arc::new(WhichKeyPluginWindow { cache }));
126    }
127
128    fn boot(
129        &self,
130        _bus: &EventBus,
131        state: Arc<PluginStateRegistry>,
132        event_tx: Option<mpsc::Sender<RuntimeEvent>>,
133    ) {
134        tracing::info!("WhichKeyPlugin: boot() called");
135        // Get cache from init_state
136        let cache = state
137            .with::<WhichKeyCacheHolder, _, _>(|h| h.cache.clone())
138            .expect("WhichKeyCacheHolder must be registered");
139
140        // Spawn background saturator with event_tx
141        let Some(tx) = event_tx else {
142            tracing::warn!("Which-key plugin requires event_tx but none was provided");
143            return;
144        };
145
146        // Get KeyMap and CommandRegistry from state
147        let Some(keymap) = state.keymap() else {
148            tracing::warn!("Which-key plugin requires KeyMap but none was registered");
149            return;
150        };
151        let Some(command_registry) = state.command_registry() else {
152            tracing::warn!("Which-key plugin requires CommandRegistry but none was registered");
153            return;
154        };
155
156        tracing::info!("WhichKeyPlugin: spawning saturator");
157        let saturator = spawn_whichkey_saturator(cache.clone(), tx, keymap, command_registry);
158
159        // Register full state with saturator handle
160        state.register(WhichKeyState { cache, saturator });
161        tracing::info!("WhichKeyPlugin: WhichKeyState registered");
162    }
163
164    #[allow(clippy::too_many_lines)]
165    fn subscribe(&self, bus: &EventBus, state: Arc<PluginStateRegistry>) {
166        // Subscribe to WhichKeyOpen
167        let state_clone = Arc::clone(&state);
168        bus.subscribe::<WhichKeyOpen, _>(100, move |event, ctx| {
169            tracing::info!(
170                "WhichKeyPlugin: received WhichKeyOpen event, prefix={:?}",
171                event.prefix
172            );
173            let prefix = event.prefix.clone();
174            let scope = KeymapScope::editor_normal();
175
176            // Update cache with open state
177            state_clone.with::<WhichKeyState, _, _>(|wk| {
178                let mut cache = (*wk.cache.load_full()).clone();
179                cache.open(prefix.clone(), scope.clone());
180                wk.cache.store(Arc::new(cache));
181
182                // Send initial filter request to saturator
183                tracing::info!(
184                    "WhichKeyPlugin: sending request to saturator with prefix={:?}",
185                    prefix
186                );
187                match wk.saturator.tx.try_send(saturator::WhichKeyRequest {
188                    pending_keys: prefix,
189                    scope,
190                }) {
191                    Ok(()) => tracing::info!("WhichKeyPlugin: request sent to saturator"),
192                    Err(e) => tracing::warn!("WhichKeyPlugin: failed to send to saturator: {}", e),
193                }
194            });
195
196            // Request mode change to which-key interactor sub-mode
197            // This ensures keys are routed to this plugin via PluginTextInput
198            let mode = ModeState::with_interactor_id_sub_mode(
199                ComponentId::EDITOR,
200                EditMode::Normal,
201                SubMode::Interactor(COMPONENT_ID),
202            );
203            ctx.emit(RequestModeChange { mode });
204
205            ctx.request_render();
206            EventResult::Handled
207        });
208
209        // Subscribe to PluginTextInput - handle keys typed while panel is visible
210        let state_clone = Arc::clone(&state);
211        bus.subscribe_targeted::<PluginTextInput, _>(COMPONENT_ID, 100, move |event, ctx| {
212            tracing::info!("WhichKeyPlugin: received PluginTextInput c={}", event.c);
213
214            // Convert char to keystroke and update filter
215            let keystroke = Keystroke::char(event.c);
216            state_clone.with::<WhichKeyState, _, _>(|wk| {
217                let mut cache = (*wk.cache.load_full()).clone();
218                cache.push_key(keystroke);
219
220                // Send updated filter to saturator
221                let filter = cache.current_filter.clone();
222                let scope = cache.scope.clone();
223                wk.cache.store(Arc::new(cache));
224
225                tracing::info!("WhichKeyPlugin: updated filter to {:?}", filter);
226                let _ = wk.saturator.tx.try_send(saturator::WhichKeyRequest {
227                    pending_keys: filter,
228                    scope,
229                });
230            });
231
232            ctx.request_render();
233            EventResult::Handled
234        });
235
236        // Subscribe to PluginBackspace - same as WhichKeyBackspace
237        let state_clone = Arc::clone(&state);
238        bus.subscribe_targeted::<PluginBackspace, _>(COMPONENT_ID, 100, move |_event, ctx| {
239            tracing::info!("WhichKeyPlugin: received PluginBackspace");
240
241            state_clone.with::<WhichKeyState, _, _>(|wk| {
242                let mut cache = (*wk.cache.load_full()).clone();
243                if cache.pop_key() {
244                    // Key was removed, update filter
245                    let filter = cache.current_filter.clone();
246                    let scope = cache.scope.clone();
247                    wk.cache.store(Arc::new(cache));
248
249                    tracing::info!("WhichKeyPlugin: updated filter to {:?}", filter);
250                    let _ = wk.saturator.tx.try_send(saturator::WhichKeyRequest {
251                        pending_keys: filter,
252                        scope,
253                    });
254                }
255            });
256
257            ctx.request_render();
258            EventResult::Handled
259        });
260
261        // Subscribe to WhichKeyBackspace
262        let state_clone = Arc::clone(&state);
263        bus.subscribe::<WhichKeyBackspace, _>(100, move |_event, ctx| {
264            tracing::info!("WhichKeyPlugin: received WhichKeyBackspace");
265
266            state_clone.with::<WhichKeyState, _, _>(|wk| {
267                let mut cache = (*wk.cache.load_full()).clone();
268                if cache.pop_key() {
269                    // Key was removed, update filter
270                    let filter = cache.current_filter.clone();
271                    let scope = cache.scope.clone();
272                    wk.cache.store(Arc::new(cache));
273
274                    tracing::info!("WhichKeyPlugin: updated filter to {:?}", filter);
275                    let _ = wk.saturator.tx.try_send(saturator::WhichKeyRequest {
276                        pending_keys: filter,
277                        scope,
278                    });
279                }
280            });
281
282            ctx.request_render();
283            EventResult::Handled
284        });
285
286        // Subscribe to WhichKeyClose
287        let state_clone = Arc::clone(&state);
288        bus.subscribe::<WhichKeyClose, _>(100, move |_event, ctx| {
289            tracing::info!("WhichKeyPlugin: received WhichKeyClose");
290
291            state_clone.with::<WhichKeyState, _, _>(|wk| {
292                let mut cache = (*wk.cache.load_full()).clone();
293                cache.close();
294                wk.cache.store(Arc::new(cache));
295            });
296
297            // Return to normal editor mode
298            let mode = ModeState::normal();
299            ctx.emit(RequestModeChange { mode });
300
301            ctx.request_render();
302            EventResult::Handled
303        });
304    }
305}
306
307/// Plugin window for rendering the which-key panel
308pub struct WhichKeyPluginWindow {
309    cache: Arc<ArcSwap<state::WhichKeyCache>>,
310}
311
312impl PluginWindow for WhichKeyPluginWindow {
313    fn window_config(
314        &self,
315        _state: &Arc<PluginStateRegistry>,
316        ctx: &EditorContext,
317    ) -> Option<WindowConfig> {
318        // Lock-free read from ArcSwap cache
319        let wk_cache = self.cache.load();
320        if !wk_cache.visible {
321            return None;
322        }
323
324        let panel_height = 6;
325        let (x, y, w, h) = ctx.side_panel(PanelPosition::Bottom, panel_height);
326
327        Some(WindowConfig {
328            bounds: Rect::new(x, y, w, h),
329            z_order: 600, // Panel level
330            visible: true,
331        })
332    }
333
334    fn render(
335        &self,
336        _state: &Arc<PluginStateRegistry>,
337        _ctx: &EditorContext,
338        buffer: &mut FrameBuffer,
339        bounds: Rect,
340        theme: &Theme,
341    ) {
342        // Lock-free read from ArcSwap cache
343        let wk_cache = self.cache.load();
344
345        // Render border
346        let border_style = &theme.popup.border;
347
348        // Top border
349        buffer.put_char(bounds.x, bounds.y, '╭', border_style);
350        for x in (bounds.x + 1)..(bounds.x + bounds.width - 1) {
351            buffer.put_char(x, bounds.y, '─', border_style);
352        }
353        buffer.put_char(bounds.x + bounds.width - 1, bounds.y, '╮', border_style);
354
355        // Title
356        let title = " Which Key ";
357        #[allow(clippy::cast_possible_truncation)] // title is short
358        let title_x = bounds.x + (bounds.width.saturating_sub(title.len() as u16)) / 2;
359        buffer.write_str(title_x, bounds.y, title, border_style);
360
361        // Side borders
362        for y in (bounds.y + 1)..(bounds.y + bounds.height - 1) {
363            buffer.put_char(bounds.x, y, '│', border_style);
364            buffer.put_char(bounds.x + bounds.width - 1, y, '│', border_style);
365        }
366
367        // Bottom border
368        buffer.put_char(bounds.x, bounds.y + bounds.height - 1, '╰', border_style);
369        for x in (bounds.x + 1)..(bounds.x + bounds.width - 1) {
370            buffer.put_char(x, bounds.y + bounds.height - 1, '─', border_style);
371        }
372        buffer.put_char(
373            bounds.x + bounds.width - 1,
374            bounds.y + bounds.height - 1,
375            '╯',
376            border_style,
377        );
378
379        // Render bindings
380        let content_x = bounds.x + 2;
381        let content_y = bounds.y + 1;
382        let content_width = bounds.width.saturating_sub(4);
383        let content_height = bounds.height.saturating_sub(2);
384
385        let normal_style = &theme.popup.normal;
386
387        // Fill content area with background to clear any artifacts
388        for y in content_y..(content_y + content_height) {
389            // Fill entire inner width (between borders)
390            for x in (bounds.x + 1)..(bounds.x + 1 + content_width + 2) {
391                buffer.put_char(x, y, ' ', normal_style);
392            }
393        }
394
395        // For now, just show a placeholder message if no bindings
396        if wk_cache.bindings.is_empty() {
397            let msg = "Press ? after a prefix key to see bindings";
398            buffer.write_str(content_x, content_y, msg, normal_style);
399        } else {
400            // Render bindings in columns
401            for (row, entry) in wk_cache.bindings.iter().enumerate() {
402                if row >= content_height as usize {
403                    break;
404                }
405
406                #[allow(clippy::cast_possible_truncation)] // row limited by content_height
407                let y = content_y + row as u16;
408                let key_str = entry
409                    .suffix
410                    .render(reovim_core::keystroke::KeyNotationFormat::Vim);
411                let desc = &entry.description;
412
413                // Key in highlight color
414                let key_width = buffer.write_str(content_x, y, &key_str, &theme.popup.selected);
415
416                // Description in normal color
417                buffer.write_str(content_x + key_width + 2, y, desc, normal_style);
418            }
419        }
420    }
421}