1mod 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
35pub const COMPONENT_ID: ComponentId = ComponentId("which_key");
37
38use crate::{
39 saturator::spawn_whichkey_saturator,
40 state::{WhichKeyCacheHolder, WhichKeyState},
41};
42
43pub 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 let _ = ctx.register_command(WhichKeyClose);
62 let _ = ctx.register_command(WhichKeyBackspace);
63
64 let editor_normal = KeymapScope::editor_normal();
67
68 ctx.keymap_mut().bind_scoped(
70 editor_normal.clone(),
71 keys!['?'],
72 CommandRef::Inline(WhichKeyTrigger::arc(keys![])),
73 );
74
75 let common_prefixes = [
78 keys!['g'], keys!['z'], keys![Space], keys![Space 'f'], keys![Space 'b'], keys![Space 'w'], keys!['d'], keys!['y'], keys!['c'], ];
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 let which_key_interactor = KeymapScope::SubMode(SubModeKind::Interactor(COMPONENT_ID));
101
102 ctx.keymap_mut().bind_scoped(
104 which_key_interactor.clone(),
105 keys![Escape],
106 CommandRef::Registered(CommandId::new("which_key_close")),
107 );
108
109 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 let cache = Arc::new(ArcSwap::new(Arc::new(state::WhichKeyCache::default())));
120
121 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 let cache = state
137 .with::<WhichKeyCacheHolder, _, _>(|h| h.cache.clone())
138 .expect("WhichKeyCacheHolder must be registered");
139
140 let Some(tx) = event_tx else {
142 tracing::warn!("Which-key plugin requires event_tx but none was provided");
143 return;
144 };
145
146 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let mode = ModeState::normal();
299 ctx.emit(RequestModeChange { mode });
300
301 ctx.request_render();
302 EventResult::Handled
303 });
304 }
305}
306
307pub 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 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, 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 let wk_cache = self.cache.load();
344
345 let border_style = &theme.popup.border;
347
348 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 let title = " Which Key ";
357 #[allow(clippy::cast_possible_truncation)] 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 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 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 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 for y in content_y..(content_y + content_height) {
389 for x in (bounds.x + 1)..(bounds.x + 1 + content_width + 2) {
391 buffer.put_char(x, y, ' ', normal_style);
392 }
393 }
394
395 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 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)] 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 let key_width = buffer.write_str(content_x, y, &key_str, &theme.popup.selected);
415
416 buffer.write_str(content_x + key_width + 2, y, desc, normal_style);
418 }
419 }
420 }
421}