lattice_picker/transient.rs
1//! Transient mode — grouped action menus within the picker.
2//!
3//! A transient presents groups of action items with single-key
4//! selection, toggleable flags, argument inputs, and a live command
5//! preview. Magit is the first consumer (dispatch menus, branch menus,
6//! stash menus), but which-key key hints, command palette drilldown,
7//! and future plugin transients all reuse the same mechanism.
8//!
9//! See `docs/dev/architecture/magit.md` §8.
10
11use std::collections::HashMap;
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16use lattice_grammar::Args;
17use lattice_protocol::ids::CommandId;
18
19/// Accumulated state for an open transient: flag values and argument
20/// values. Keyed by the `name` field from `TransientItemKind::Flag`
21/// and `TransientItemKind::Argument` items.
22pub type TransientState = HashMap<String, TransientValue>;
23
24/// A single value in the transient state (flag or argument).
25#[derive(Clone, Debug)]
26pub enum TransientValue {
27 Bool(bool),
28 String(String),
29}
30
31/// Build initial state from a spec — flags get their defaults,
32/// arguments get their defaults (or empty string).
33pub fn transient_initial_state(spec: &TransientSpec) -> TransientState {
34 let mut state = HashMap::new();
35 for group in &spec.groups {
36 for item in &group.items {
37 match &item.kind {
38 TransientItemKind::Flag { name, default } => {
39 state.insert(name.clone(), TransientValue::Bool(*default));
40 }
41 TransientItemKind::Argument { name, default, .. } => {
42 state.insert(
43 name.clone(),
44 TransientValue::String(default.clone().unwrap_or_default()),
45 );
46 }
47 _ => {}
48 }
49 }
50 }
51 state
52}
53
54/// Specification for a transient menu — the complete layout.
55/// Stored behind an `Arc` in the `Picker` struct so clone is a ref-count
56/// bump; the `preview` boxed closure stays alive across clones.
57pub struct TransientSpec {
58 pub title: String,
59 pub groups: Vec<TransientGroup>,
60 /// Optional live command preview. Called every time a flag
61 /// toggles or an argument changes; the returned string is
62 /// rendered in the picker's preview pane.
63 #[allow(clippy::type_complexity)]
64 pub preview: Option<Box<dyn Fn(&TransientState) -> String + Send + Sync>>,
65 pub footer: Option<String>,
66}
67
68impl TransientSpec {
69 /// How many items can be selected — every item in every group,
70 /// counted in group order.
71 ///
72 /// This is the number `<C-n>` / `<C-p>` wrap on, and the reason
73 /// they can no longer run off the end: it is derived from the spec
74 /// alone, so the host clamps on data it owns rather than on a
75 /// viewport height only the renderer knows. The previous shape
76 /// stored a raw scroll offset and grew it unbounded, leaving the
77 /// stored value tens of rows past anything renderable — pressing
78 /// `<C-p>` then did nothing visible until the overshoot was walked
79 /// back off.
80 pub fn selectable_count(&self) -> usize {
81 self.groups.iter().map(|g| g.items.len()).sum()
82 }
83
84 /// Rows the group+item list occupies — per group one header, one
85 /// row per item and one trailing blank separator. EXCLUDES
86 /// preview/footer, which only exist in the popup layout.
87 ///
88 /// Lives here rather than in each renderer because both peers need
89 /// it and both had their own copy: undercounting the separator in
90 /// both made every multi-group menu's box too short *and* capped
91 /// its scroll before the last rows.
92 pub fn row_count(&self) -> usize {
93 self.selectable_count() + self.groups.len() * 2
94 }
95
96 /// Which row the `index`-th selectable item is painted on, in the
97 /// same row stream [`Self::row_count`] measures. `None` when
98 /// `index` is past the last item.
99 ///
100 /// The mapping is what lets a renderer window on a *selection*: the
101 /// host moves an item index, and each peer turns it into the scroll
102 /// offset its own geometry needs.
103 pub fn row_of_item(&self, index: usize) -> Option<usize> {
104 let mut row = 0;
105 let mut seen = 0;
106 for group in &self.groups {
107 row += 1; // the group header
108 if index < seen + group.items.len() {
109 return Some(row + (index - seen));
110 }
111 row += group.items.len() + 1; // items + the separator
112 seen += group.items.len();
113 }
114 None
115 }
116
117 /// The `index`-th selectable item, in the same order
118 /// [`Self::selectable_count`] counts.
119 pub fn item_at(&self, index: usize) -> Option<&TransientItem> {
120 self.groups.iter().flat_map(|g| &g.items).nth(index)
121 }
122
123 /// What the keys typed so far mean at this level.
124 ///
125 /// Transient keys are **strings, not characters** — magit binds
126 /// multi-key rows (`, k` delete, `, r` rename, `= f` set target)
127 /// and lattice follows it. The host used to compare one typed
128 /// `char` against them, so every multi-key row was unreachable by
129 /// keypress: it rendered, `<C-n>` reached it, `<CR>` fired it, and
130 /// its own key did nothing at all.
131 ///
132 /// **An exact match wins over a prefix.** A key that both completes
133 /// one row and begins another is ambiguous, and vim resolves that
134 /// with `timeoutlen` — machinery this editor does not have (there
135 /// is no ambiguous-chord timeout anywhere; `AbsorbPartialChord`
136 /// waits indefinitely). Firing the exact match is the resolution
137 /// that never leaves a key hanging on a timer that does not exist.
138 /// No spec has such a pair today; this decides it if one appears.
139 pub fn resolve_key(&self, typed: &str) -> KeyResolution<'_> {
140 let mut prefix_seen = false;
141 for item in self.groups.iter().flat_map(|g| &g.items) {
142 for key in &item.key {
143 if key == typed {
144 return KeyResolution::Fire(item);
145 }
146 if key.starts_with(typed) {
147 prefix_seen = true;
148 }
149 }
150 }
151 if prefix_seen {
152 KeyResolution::Prefix
153 } else {
154 KeyResolution::NoMatch
155 }
156 }
157
158 /// True when `item` is still reachable by typing more after
159 /// `typed` — what the renderers dim on.
160 ///
161 /// An empty `typed` matches everything, so a menu with no prefix
162 /// pending renders exactly as it always did.
163 pub fn item_matches_prefix(item: &TransientItem, typed: &str) -> bool {
164 typed.is_empty() || item.key.iter().any(|k| k.starts_with(typed))
165 }
166
167 /// The scroll offset that keeps `selected`'s row inside a window
168 /// `visible` rows tall, clamped so the list never scrolls past its
169 /// own end.
170 ///
171 /// Derived fresh from the selection every frame rather than stored,
172 /// which is what makes the overshoot unrepresentable: there is no
173 /// scroll state left to drift out of range.
174 pub fn scroll_for(&self, selected: usize, visible: usize) -> usize {
175 let row = self.row_of_item(selected).unwrap_or(0);
176 let max = self.row_count().saturating_sub(visible.max(1));
177 (row + 1).saturating_sub(visible.max(1)).min(max)
178 }
179}
180
181impl std::fmt::Debug for TransientSpec {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("TransientSpec")
184 .field("title", &self.title)
185 .field("groups", &self.groups)
186 .field("preview", &self.preview.as_ref().map(|_| "<fn>"))
187 .field("footer", &self.footer)
188 .finish()
189 }
190}
191
192/// What [`TransientSpec::resolve_key`] made of the keys typed so far.
193#[derive(Debug)]
194pub enum KeyResolution<'a> {
195 /// Exactly one row's key — fire it.
196 Fire(&'a TransientItem),
197 /// No row's key yet, but at least one begins with what was typed.
198 /// Hold the keys and wait for more; the renderers dim everything
199 /// that can no longer match.
200 Prefix,
201 /// Nothing here begins with this. The accumulated keys are
202 /// discarded — holding them would leave the menu silently unable
203 /// to accept the next keystroke.
204 NoMatch,
205}
206
207/// A named group of transient items.
208#[derive(Clone, Debug)]
209pub struct TransientGroup {
210 pub label: String,
211 pub items: Vec<TransientItem>,
212}
213
214/// A single entry in a transient group.
215#[derive(Clone, Debug)]
216pub struct TransientItem {
217 pub key: Vec<String>,
218 pub label: String,
219 pub description: String,
220 pub kind: TransientItemKind,
221}
222
223/// MG.53.e: where a picker-backed [`TransientItemKind::Argument`] gets
224/// its candidate list.
225///
226/// The transient names a registered picker source rather than carrying
227/// the listing itself, which is what keeps the listing out of the
228/// feature crate: magit's "repo-relative file" argument declares
229/// `file-pick` and the walk stays in `lattice-picker`, reachable by
230/// every other provider — including WASM ones — through the same
231/// `PickerSourceSpec` surface.
232#[derive(Clone, Debug)]
233pub struct TransientArgSource {
234 /// Registered `PickerSourceSpec` id, e.g. `"file-pick"`.
235 pub id: String,
236 /// Positional arguments passed to the source's `init`, as `:picker
237 /// <id> <args...>` would supply them.
238 pub args: Vec<String>,
239}
240
241impl TransientArgSource {
242 pub fn new(id: impl Into<String>) -> Self {
243 Self {
244 id: id.into(),
245 args: Vec::new(),
246 }
247 }
248
249 pub fn with_args(mut self, args: Vec<String>) -> Self {
250 self.args = args;
251 self
252 }
253}
254
255/// The kind of a transient item — determines its interaction.
256#[derive(Clone, Debug)]
257pub enum TransientItemKind {
258 /// Fires an action via the action-handler registry and closes
259 /// the transient.
260 ///
261 /// `args` are the row's OWN arguments, and they are how a menu
262 /// whose rows differ only in a parameter is expressible at all.
263 /// Every native menu today leaves them [`Args::None`] and lets the
264 /// host project the menu's [`TransientState`] through the command's
265 /// `args_schema` instead — flags and arguments the user toggled
266 /// before pressing the key. That mechanism is per-MENU, so it
267 /// cannot say "this row means template `t`, that one means `n`",
268 /// which is exactly the shape a plugin-contributed menu has (org's
269 /// capture menu, one row per template). Hence a per-row slot.
270 ///
271 /// When both are present the row's args win, because they were
272 /// chosen when the row was built and the state was not.
273 Action { command: CommandId, args: Args },
274 /// Opens a nested transient (submenu). The parent transient is
275 /// pushed onto a stack; `BS`/`DEL` returns to it.
276 Submenu(std::sync::Arc<TransientSpec>),
277 /// A boolean flag that toggles in-place. The `name` is the key
278 /// in `TransientState`.
279 Flag { name: String, default: bool },
280 /// An argument that collects a value into `TransientState` under
281 /// `name`.
282 ///
283 /// By default it opens a minibuffer prompt. When `source` is set the
284 /// value is **picked from a list** instead — MG.53's rule is that
285 /// naming a thing which must already exist gets a picker, and an
286 /// argument is as much a naming site as a command is. A free-text
287 /// prompt for an existing path is a typo waiting to happen, and git
288 /// reports it long after the keystroke that caused it.
289 ///
290 /// Either way the menu is parked and re-seated with the collected
291 /// value (`PendingTransientArgument` → `resume_parked_transient`);
292 /// the picker and the prompt are both surfaces the menu cannot stay
293 /// seated underneath. `prompt` stays meaningful with a `source` set:
294 /// it titles the picker.
295 Argument {
296 name: String,
297 default: Option<String>,
298 prompt: String,
299 /// `None` = free-text prompt. `Some` = pick from this registered
300 /// picker source, whose accept supplies the value.
301 source: Option<TransientArgSource>,
302 },
303 /// Dismisses the transient picker without firing any action.
304 /// Used for 'n' / 'q' keys in confirmation dialogs.
305 Dismiss,
306 /// MG.43g: a value owned by something OUTSIDE the transient — a
307 /// git-config key, an editor option — shown inline and changed by
308 /// firing `action`.
309 ///
310 /// Distinct from [`Self::Flag`] and [`Self::Argument`], which hold
311 /// their value in `TransientState` for the duration of one menu.
312 /// A variable's value lives in the world and persists; the menu
313 /// only reports and edits it.
314 ///
315 /// `value` is `None` when the current value has **not been read
316 /// yet**, which renders differently from a value that is read and
317 /// unset. Collapsing the two would make the menu state a fact
318 /// about the user's configuration it has not actually checked —
319 /// and reporting the current value is the row's entire purpose.
320 Variable {
321 /// Display name of the underlying key, e.g. `pull.rebase`.
322 key: String,
323 /// Prefetched current value; `None` = not read yet.
324 value: Option<String>,
325 /// Fired to change it. Prompts for the new value itself.
326 action: CommandId,
327 },
328}
329
330impl TransientItemKind {
331 /// The overwhelmingly common action row: fires `command` with no
332 /// arguments of its own, leaving the menu's [`TransientState`]
333 /// projection to supply them. Every native menu builds rows this
334 /// way; the struct form exists for the per-row case.
335 pub fn action(command: CommandId) -> Self {
336 Self::Action {
337 command,
338 args: Args::None,
339 }
340 }
341
342 /// The row's OWN arguments, if its kind can carry any.
343 ///
344 /// `None` for every kind but [`Self::Action`] — including
345 /// [`Self::Variable`], which is an action leaf whose action prompts
346 /// for its new value rather than receiving one. Reported as an
347 /// `Option` rather than a `&Args::None` so the two are
348 /// distinguishable without giving every variant a field it would
349 /// never use.
350 pub fn row_args(&self) -> Option<&Args> {
351 match self {
352 Self::Action { args, .. } => Some(args),
353 _ => None,
354 }
355 }
356
357 /// How a [`Self::Variable`]'s current value reads in the menu.
358 ///
359 /// Three distinct states, deliberately: unread, read-and-unset,
360 /// and set. `…` is not `unset` — see the variant's doc.
361 pub fn variable_display(value: Option<&str>) -> &'static str {
362 match value {
363 None => "…",
364 Some("") => "unset",
365 Some(_) => "",
366 }
367 }
368}
369
370/// Registry of named transient builders, populated at boot by each
371/// owning mode crate (magit registers `magit-dispatch` /
372/// `magit-file-dispatch`; per the module doc, which-key / command
373/// palette / future plugin transients are meant to reuse the same
374/// mechanism). `Effect::OpenTransient { source }` carries only the
375/// name — resolving it to an actual `TransientSpec` (which can't
376/// cross into `lattice-grammar`'s `Effect` enum directly, since
377/// `TransientSpec` lives downstream of it) happens here, at the
378/// renderer's effect-handling site. Mirrors `PickerRegistry`'s
379/// named-source shape, simplified: transients have no candidate
380/// generator or arg-schema concept, just a name and a builder.
381///
382/// Read-only after boot in practice (each owning crate's `install`
383/// populates it once), so this stays a plain mutex-guarded map
384/// rather than an `ArcSwap` RCU registry like `PickerRegistry` —
385/// there's no runtime plugin-load use case for it yet.
386/// MG.23h: where a transient was opened from, so a builder can vary
387/// its rows.
388///
389/// A dispatch menu bound globally has to degrade: rows that act on the
390/// thing under the cursor are meaningless in a buffer that has no such
391/// thing, and a row whose only useful reading depends on which buffer
392/// you are in should say the useful thing. Emacs magit answers both
393/// with predicates on its prefix definitions — `:if-derived` for "any
394/// buffer of this family" and `:if-mode` for "exactly this major" —
395/// and this is the same question, asked of the two mode axes.
396///
397/// **Major and minors are separate fields on purpose.** A flat list of
398/// active mode ids can only answer one of those two questions; magit's
399/// dispatch asks both of them about the same key (`j` is "jump to
400/// section" in magit-status and "display status" everywhere else,
401/// while its whole "Applying changes" group is gated on the looser
402/// family test).
403///
404/// **What is deliberately absent:** the buffer id, the cursor, and the
405/// selection. A builder produces rows; it does not act. The row's
406/// action receives its own `ActionContext`, which already carries the
407/// underlying buffer, its cursor and any Visual region — resolved at
408/// fire time, when they are current. Duplicating them here would be
409/// speculative surface that could also go stale between build and fire.
410// No `Eq`: `Args` carries an `ArgValue::Invocation` whose recursive
411// `CommandInvocation` is only `PartialEq`. `PartialEq` is what the tests
412// compare on anyway.
413#[derive(Debug, Clone, Default, PartialEq)]
414pub struct TransientContext {
415 /// The active major mode's id, if the buffer has one. The
416 /// `:if-mode` question.
417 pub major_mode: Option<String>,
418 /// The active minor mode ids. The `:if-derived` question is asked
419 /// here: a magit buffer is one where `magit-core-mode` is active,
420 /// whatever its major happens to be.
421 pub minor_modes: Vec<String>,
422 /// MR.4: the buffer the menu was opened over.
423 ///
424 /// A builder whose rows depend on something the buffer *owns* —
425 /// magit's dispatch offers `rebase --continue` only while a rebase
426 /// is stopped, and which repository that is depends on the buffer —
427 /// cannot answer from the mode axes alone. `None` mid-boot, where
428 /// the builder degrades exactly as it does for the mode fields.
429 ///
430 /// Still not the cursor or the selection: a row's own action
431 /// receives those at fire time, when they are current.
432 pub buffer: Option<lattice_core::BufferId>,
433 /// TR.3a: the arguments the open carried
434 /// (`Effect::OpenTransient { args }`).
435 ///
436 /// The one field here that is not a fact about WHERE the menu was
437 /// opened — it is what it was opened FOR, and it is what lets a menu
438 /// drill down. Org's capture menu has a row per template; the fields
439 /// menu that row opens reads the template key from here rather than
440 /// from guest memory, which `<Esc>` never clears.
441 pub args: Args,
442}
443
444impl TransientContext {
445 /// True iff `id` is the active major — `:if-mode`.
446 pub fn is_major(&self, id: &str) -> bool {
447 self.major_mode.as_deref() == Some(id)
448 }
449
450 /// True iff `id` is an active minor — the family test a mode's
451 /// shared minor answers, standing in for `:if-derived`.
452 pub fn has_minor(&self, id: &str) -> bool {
453 self.minor_modes.iter().any(|m| m == id)
454 }
455}
456
457/// A menu build that could not answer synchronously — the shape a
458/// guest-backed builder returns. Mirrors
459/// [`PickerInitResult::Future`](crate::source::PickerInitResult::Future):
460/// `'static + Send`, so the host can move it onto its own runtime and
461/// seat the menu when it lands.
462pub type TransientBuildFuture =
463 Pin<Box<dyn Future<Output = Result<TransientSpec, String>> + Send + 'static>>;
464
465/// What [`TransientSourceRegistry::build`] answers with.
466///
467/// Native builders are pure functions of a [`TransientContext`] and
468/// answer [`Ready`](Self::Ready) — the menu seats in the same frame the
469/// chord was pressed, exactly as before this type existed. A WASM
470/// builder cannot: its `build` is a guest call on the plugin's own
471/// actor task, and blocking the editor actor on it would violate
472/// paramount #4. So it answers [`Future`](Self::Future) and the host
473/// parks, then seats on the async-landed wake.
474///
475/// Making that difference a value rather than two registries is what
476/// keeps `Effect::OpenTransient { source }` one code path: the effect
477/// still carries only a name, and neither the chord nor the ex-command
478/// that emits it knows or cares which kind of builder answers.
479pub enum TransientBuild {
480 /// Built synchronously — seat it now.
481 Ready(TransientSpec),
482 /// Building off-thread. `Err` from the future is echoed and the
483 /// menu does not open.
484 Future(TransientBuildFuture),
485}
486
487impl std::fmt::Debug for TransientBuild {
488 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
489 match self {
490 TransientBuild::Ready(spec) => f.debug_tuple("Ready").field(spec).finish(),
491 TransientBuild::Future(_) => f.debug_struct("Future").finish_non_exhaustive(),
492 }
493 }
494}
495
496#[derive(Default)]
497pub struct TransientSourceRegistry {
498 #[allow(clippy::type_complexity)]
499 sources: std::sync::Mutex<
500 HashMap<String, Arc<dyn Fn(&TransientContext) -> TransientBuild + Send + Sync>>,
501 >,
502}
503
504/// Service handle registered via `SubsystemBoot::register_service` —
505/// same `Arc<X>` register-and-lookup convention every other service
506/// handle in this codebase uses.
507pub type TransientSourceRegistryHandle = Arc<TransientSourceRegistry>;
508
509impl TransientSourceRegistry {
510 pub fn new() -> Self {
511 Self::default()
512 }
513
514 /// Register a named **synchronous** builder — a native menu, whose
515 /// rows are a pure function of the open context. Re-registering a
516 /// name overwrites the previous entry (last writer wins), matching
517 /// `PickerRegistry::register`'s semantics.
518 pub fn register(
519 &self,
520 name: impl Into<String>,
521 builder: impl Fn(&TransientContext) -> TransientSpec + Send + Sync + 'static,
522 ) {
523 self.register_build(name, move |ctx| TransientBuild::Ready(builder(ctx)));
524 }
525
526 /// TR.2: register a named builder that answers a
527 /// [`TransientBuildFuture`] — the guest-backed shape. The host
528 /// parks on the future and seats the menu when it lands.
529 pub fn register_async(
530 &self,
531 name: impl Into<String>,
532 builder: impl Fn(&TransientContext) -> TransientBuildFuture + Send + Sync + 'static,
533 ) {
534 self.register_build(name, move |ctx| TransientBuild::Future(builder(ctx)));
535 }
536
537 fn register_build(
538 &self,
539 name: impl Into<String>,
540 builder: impl Fn(&TransientContext) -> TransientBuild + Send + Sync + 'static,
541 ) {
542 if let Ok(mut sources) = self.sources.lock() {
543 sources.insert(name.into(), Arc::new(builder));
544 }
545 }
546
547 /// TR.2: drop the builder registered under `name`, reporting
548 /// whether one was there. The teardown half of
549 /// [`register_async`](Self::register_async) — an unloaded plugin's
550 /// menu name must stop resolving, or `Effect::OpenTransient` would
551 /// keep reaching a client whose actor is gone and report a host
552 /// error instead of "unknown source".
553 pub fn unregister(&self, name: &str) -> bool {
554 match self.sources.lock() {
555 Ok(mut sources) => sources.remove(name).is_some(),
556 Err(_) => false,
557 }
558 }
559
560 /// Build the named transient for the place it was opened from, or
561 /// `None` if no builder is registered under `name`.
562 ///
563 /// MG.23h: `ctx` is supplied by the renderer at open time rather
564 /// than by whatever emitted `Effect::OpenTransient`. That is what
565 /// makes every path uniformly context-aware — the chord, the
566 /// ex-command (whose `ExCommandContext` carries no buffer), and any
567 /// future plugin-emitted open. Resolving it at emit time instead
568 /// would leave all but the chord looking at nothing.
569 pub fn build(&self, name: &str, ctx: &TransientContext) -> Option<TransientBuild> {
570 let builder = self.sources.lock().ok()?.get(name)?.clone();
571 Some(builder(ctx))
572 }
573
574 /// [`build`](Self::build) for a caller that can only handle a
575 /// synchronous answer — tests and native call sites that predate
576 /// TR.2. A guest-backed name answers `None` here rather than
577 /// blocking.
578 pub fn build_ready(&self, name: &str, ctx: &TransientContext) -> Option<TransientSpec> {
579 match self.build(name, ctx)? {
580 TransientBuild::Ready(spec) => Some(spec),
581 TransientBuild::Future(_) => None,
582 }
583 }
584}
585
586/// Build a simple y/n confirmation transient spec.
587/// `prompt` is the title shown at the top; `yes_command_id`
588/// is the action fired when the user presses `y`.
589pub fn confirm_transient_spec(prompt: &str, yes_command_id: CommandId) -> TransientSpec {
590 TransientSpec {
591 title: prompt.to_string(),
592 groups: vec![TransientGroup {
593 label: String::new(),
594 items: vec![
595 TransientItem {
596 key: vec!["y".to_string(), "Y".to_string()],
597 label: "Yes".to_string(),
598 description: String::new(),
599 kind: TransientItemKind::action(yes_command_id),
600 },
601 TransientItem {
602 key: vec![
603 "n".to_string(),
604 "N".to_string(),
605 "q".to_string(),
606 "Q".to_string(),
607 ],
608 label: "No".to_string(),
609 description: String::new(),
610 kind: TransientItemKind::Dismiss,
611 },
612 ],
613 }],
614 preview: None,
615 footer: None,
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 fn spec_titled(title: &str) -> TransientSpec {
624 TransientSpec {
625 title: title.to_string(),
626 groups: Vec::new(),
627 preview: None,
628 footer: None,
629 }
630 }
631
632 #[test]
633 fn build_returns_none_for_an_unregistered_name() {
634 let registry = TransientSourceRegistry::new();
635 assert!(
636 registry
637 .build_ready("nope", &TransientContext::default())
638 .is_none()
639 );
640 }
641
642 #[test]
643 fn register_then_build_round_trips() {
644 let registry = TransientSourceRegistry::new();
645 registry.register("magit-dispatch", |_| spec_titled("Magit dispatch"));
646 let spec = registry
647 .build_ready("magit-dispatch", &TransientContext::default())
648 .expect("registered name builds");
649 assert_eq!(spec.title, "Magit dispatch");
650 }
651
652 #[test]
653 fn build_calls_the_builder_fresh_every_time() {
654 // The registry stores a builder closure, not a cached spec —
655 // each `build()` must re-invoke it. Regression guard: an
656 // Rc/Cell-backed call counter would only prove this if build()
657 // is actually called per-invocation rather than once at
658 // registration time.
659 let registry = TransientSourceRegistry::new();
660 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
661 let calls2 = calls.clone();
662 registry.register("counted", move |_| {
663 calls2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
664 spec_titled("counted")
665 });
666 registry.build_ready("counted", &TransientContext::default());
667 registry.build_ready("counted", &TransientContext::default());
668 registry.build_ready("counted", &TransientContext::default());
669 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 3);
670 }
671
672 #[test]
673 fn re_registering_a_name_overwrites_the_previous_builder() {
674 let registry = TransientSourceRegistry::new();
675 registry.register("magit-dispatch", |_| spec_titled("first"));
676 registry.register("magit-dispatch", |_| spec_titled("second"));
677 let spec = registry
678 .build_ready("magit-dispatch", &TransientContext::default())
679 .expect("still registered");
680 assert_eq!(
681 spec.title, "second",
682 "last writer wins, matching PickerRegistry"
683 );
684 }
685
686 /// A menu with a multi-key row, the shape magit's file dispatch
687 /// has (`, k` delete beside single-key `s` / `u`).
688 fn multi_key_spec() -> TransientSpec {
689 fn item(keys: &[&str]) -> TransientItem {
690 TransientItem {
691 key: keys.iter().map(|k| k.to_string()).collect(),
692 label: keys[0].to_string(),
693 description: String::new(),
694 kind: TransientItemKind::Dismiss,
695 }
696 }
697 TransientSpec {
698 title: "t".into(),
699 groups: vec![TransientGroup {
700 label: "g".into(),
701 items: vec![item(&["s"]), item(&[",k"]), item(&[",r"]), item(&["=f"])],
702 }],
703 preview: None,
704 footer: None,
705 }
706 }
707
708 /// The reported bug: `, k` rendered, `<C-n>` reached it and `<CR>`
709 /// fired it, but pressing `,` then `k` did nothing — the host
710 /// compared a single typed char against the whole key string.
711 #[test]
712 fn a_multi_key_row_resolves_one_keystroke_at_a_time() {
713 let spec = multi_key_spec();
714 assert!(
715 matches!(spec.resolve_key(","), KeyResolution::Prefix),
716 "`,` completes nothing but begins two rows — it must be held"
717 );
718 match spec.resolve_key(",k") {
719 KeyResolution::Fire(item) => assert_eq!(item.label, ",k"),
720 other => panic!("`,k` must fire the delete row, got {other:?}"),
721 }
722 match spec.resolve_key("=f") {
723 KeyResolution::Fire(item) => assert_eq!(item.label, "=f"),
724 other => panic!("`=f` must fire, got {other:?}"),
725 }
726 }
727
728 /// Single-key rows are unaffected — they fire on the first press,
729 /// never wait for a second.
730 #[test]
731 fn a_single_key_row_still_fires_immediately() {
732 match multi_key_spec().resolve_key("s") {
733 KeyResolution::Fire(item) => assert_eq!(item.label, "s"),
734 other => panic!("`s` must fire at once, got {other:?}"),
735 }
736 }
737
738 /// A key that begins nothing here is reported as such, so the host
739 /// can drop what it accumulated. Holding it would make every later
740 /// keystroke miss too — a menu that has gone quietly deaf.
741 #[test]
742 fn a_key_that_begins_nothing_is_a_miss_rather_than_a_prefix() {
743 let spec = multi_key_spec();
744 assert!(matches!(spec.resolve_key("q"), KeyResolution::NoMatch));
745 assert!(
746 matches!(spec.resolve_key(",z"), KeyResolution::NoMatch),
747 "a valid prefix followed by a wrong key is a miss, not a \
748 longer prefix"
749 );
750 }
751
752 /// What the renderers dim on: with `,` pending, only the `,`-rows
753 /// are still reachable; with nothing pending, everything is.
754 #[test]
755 fn the_prefix_filter_matches_exactly_the_rows_still_reachable() {
756 let spec = multi_key_spec();
757 let reachable = |typed: &str| {
758 spec.groups[0]
759 .items
760 .iter()
761 .filter(|i| TransientSpec::item_matches_prefix(i, typed))
762 .map(|i| i.label.clone())
763 .collect::<Vec<_>>()
764 };
765 assert_eq!(reachable(""), vec!["s", ",k", ",r", "=f"]);
766 assert_eq!(reachable(","), vec![",k", ",r"]);
767 assert_eq!(reachable(",k"), vec![",k"]);
768 assert!(reachable("q").is_empty());
769 }
770
771 /// MG.23h: the context reaches the builder, and reaches it on
772 /// every build rather than being captured once.
773 ///
774 /// Without this the signature could be satisfied by a builder that
775 /// ignores its argument and the whole mechanism would look wired
776 /// while gating nothing.
777 #[test]
778 fn the_open_context_reaches_the_builder() {
779 let registry = TransientSourceRegistry::new();
780 registry.register("ctx", |ctx: &TransientContext| {
781 spec_titled(ctx.major_mode.as_deref().unwrap_or("none"))
782 });
783 let in_status = TransientContext {
784 major_mode: Some("magit-status-mode".into()),
785 minor_modes: vec!["magit-core-mode".into()],
786 buffer: None,
787 args: Default::default(),
788 };
789 assert_eq!(
790 registry.build_ready("ctx", &in_status).unwrap().title,
791 "magit-status-mode"
792 );
793 assert_eq!(
794 registry
795 .build_ready("ctx", &TransientContext::default())
796 .unwrap()
797 .title,
798 "none",
799 "a second build with a different context must rebuild, not \
800 replay the first"
801 );
802 }
803
804 /// The two questions magit's prefixes ask are different questions,
805 /// which is why the two axes are separate fields: a flat list of
806 /// active mode ids could answer only one of them.
807 #[test]
808 fn the_major_and_minor_tests_are_independent() {
809 let ctx = TransientContext {
810 major_mode: Some("magit-status-mode".into()),
811 minor_modes: vec!["magit-core-mode".into()],
812 buffer: None,
813 args: Default::default(),
814 };
815 assert!(ctx.is_major("magit-status-mode"));
816 assert!(!ctx.is_major("magit-core-mode"), "a minor is not the major");
817 assert!(ctx.has_minor("magit-core-mode"));
818 assert!(
819 !ctx.has_minor("magit-status-mode"),
820 "the major is not among the minors"
821 );
822
823 // A magit buffer that is not the status buffer: the family
824 // test still passes, the exact-major test does not.
825 let in_log = TransientContext {
826 major_mode: Some("magit-log-mode".into()),
827 minor_modes: vec!["magit-core-mode".into()],
828 buffer: None,
829 args: Default::default(),
830 };
831 assert!(in_log.has_minor("magit-core-mode"));
832 assert!(!in_log.is_major("magit-status-mode"));
833
834 // And no magit at all.
835 assert!(!TransientContext::default().has_minor("magit-core-mode"));
836 }
837
838 #[test]
839 fn distinct_names_stay_independent() {
840 let registry = TransientSourceRegistry::new();
841 registry.register("magit-dispatch", |_| spec_titled("dispatch"));
842 registry.register("magit-file-dispatch", |_| spec_titled("file-dispatch"));
843 assert_eq!(
844 registry
845 .build_ready("magit-dispatch", &TransientContext::default())
846 .unwrap()
847 .title,
848 "dispatch"
849 );
850 assert_eq!(
851 registry
852 .build_ready("magit-file-dispatch", &TransientContext::default())
853 .unwrap()
854 .title,
855 "file-dispatch"
856 );
857 }
858
859 // ---- TR.2: async builders + unregister ----
860
861 /// The guest-backed shape: `build` hands back a future, and the
862 /// context reaches the builder before it is spawned (the projection
863 /// happens synchronously, so nothing borrows across the await).
864 #[test]
865 fn an_async_builder_answers_a_future_carrying_the_open_context() {
866 let registry = TransientSourceRegistry::new();
867 registry.register_async("org-capture", |ctx: &TransientContext| {
868 let major = ctx.major_mode.clone().unwrap_or_else(|| "none".into());
869 Box::pin(async move { Ok(spec_titled(&major)) })
870 });
871 let ctx = TransientContext {
872 major_mode: Some("org-mode".into()),
873 minor_modes: Vec::new(),
874 buffer: None,
875 args: Default::default(),
876 };
877 let TransientBuild::Future(fut) = registry.build("org-capture", &ctx).expect("registered")
878 else {
879 panic!("an async builder must answer Future, not Ready");
880 };
881 let spec = futures::executor::block_on(fut).expect("the future resolves");
882 assert_eq!(spec.title, "org-mode");
883 }
884
885 /// A native builder is unchanged by TR.2 — it still answers in the
886 /// same frame the chord was pressed.
887 #[test]
888 fn a_sync_builder_still_answers_ready() {
889 let registry = TransientSourceRegistry::new();
890 registry.register("magit-dispatch", |_| spec_titled("dispatch"));
891 assert!(matches!(
892 registry.build("magit-dispatch", &TransientContext::default()),
893 Some(TransientBuild::Ready(_))
894 ));
895 }
896
897 /// `build_ready` exists for callers that cannot park. It must
898 /// REFUSE a guest-backed name rather than silently blocking or
899 /// inventing an empty menu.
900 #[test]
901 fn build_ready_declines_an_async_builder() {
902 let registry = TransientSourceRegistry::new();
903 registry.register_async("org-capture", |_| {
904 Box::pin(async { Ok(spec_titled("capture")) })
905 });
906 assert!(
907 registry
908 .build_ready("org-capture", &TransientContext::default())
909 .is_none()
910 );
911 }
912
913 /// The teardown half: an unloaded plugin's menu name stops
914 /// resolving, so `Effect::OpenTransient` reports "unknown source"
915 /// rather than reaching a dead actor.
916 #[test]
917 fn unregister_drops_the_name_and_reports_whether_it_was_there() {
918 let registry = TransientSourceRegistry::new();
919 registry.register("magit-dispatch", |_| spec_titled("dispatch"));
920 registry.register_async("org-capture", |_| {
921 Box::pin(async { Ok(spec_titled("capture")) })
922 });
923
924 assert!(registry.unregister("org-capture"));
925 assert!(
926 registry
927 .build("org-capture", &TransientContext::default())
928 .is_none()
929 );
930 assert!(
931 !registry.unregister("org-capture"),
932 "a second unregister removes nothing"
933 );
934 assert!(
935 registry
936 .build("magit-dispatch", &TransientContext::default())
937 .is_some(),
938 "an unrelated name survives"
939 );
940 }
941
942 /// The per-row args slot the plugin seam needs, and the default
943 /// every native row keeps.
944 #[test]
945 fn an_action_row_defaults_to_no_args_of_its_own() {
946 let bare = TransientItemKind::action(CommandId::new(3));
947 assert!(matches!(
948 bare,
949 TransientItemKind::Action {
950 args: Args::None,
951 ..
952 }
953 ));
954 let keyed = TransientItemKind::Action {
955 command: CommandId::new(3),
956 args: Args::String("t".into()),
957 };
958 assert!(matches!(
959 keyed,
960 TransientItemKind::Action { ref args, .. } if matches!(args, Args::String(s) if s == "t")
961 ));
962 }
963
964 /// A three-group menu whose groups are different sizes, so an
965 /// off-by-one in the header/separator accounting cannot hide.
966 fn geometry_spec() -> TransientSpec {
967 fn item(key: &str) -> TransientItem {
968 TransientItem {
969 key: vec![key.to_string()],
970 label: key.to_string(),
971 description: String::new(),
972 kind: TransientItemKind::Dismiss,
973 }
974 }
975 TransientSpec {
976 title: "t".into(),
977 groups: vec![
978 TransientGroup {
979 label: "one".into(),
980 items: vec![item("a"), item("b")],
981 },
982 TransientGroup {
983 label: "two".into(),
984 items: vec![item("c")],
985 },
986 TransientGroup {
987 label: "three".into(),
988 items: vec![item("d"), item("e"), item("f")],
989 },
990 ],
991 preview: None,
992 footer: None,
993 }
994 }
995
996 #[test]
997 fn row_of_item_skips_the_headers_and_separators_between_groups() {
998 let spec = geometry_spec();
999 assert_eq!(spec.selectable_count(), 6);
1000 // header a b sep | header c sep | header d e f sep
1001 // 0 1 2 3 | 4 5 6 | 7 8 9 10 11
1002 assert_eq!(spec.row_count(), 12);
1003 assert_eq!(spec.row_of_item(0), Some(1));
1004 assert_eq!(spec.row_of_item(1), Some(2));
1005 assert_eq!(spec.row_of_item(2), Some(5));
1006 assert_eq!(spec.row_of_item(3), Some(8));
1007 assert_eq!(spec.row_of_item(5), Some(10));
1008 assert_eq!(spec.row_of_item(6), None, "past the last item");
1009 }
1010
1011 /// The property the whole redesign exists for: whatever the
1012 /// selection and however tall the window, the selected item's row
1013 /// is inside `[scroll, scroll + visible)`.
1014 #[test]
1015 fn scroll_for_always_keeps_the_selection_in_view() {
1016 let spec = geometry_spec();
1017 for visible in [1usize, 2, 4, 7, 12, 40] {
1018 for selected in 0..spec.selectable_count() {
1019 let scroll = spec.scroll_for(selected, visible);
1020 let row = spec.row_of_item(selected).expect("a row");
1021 assert!(
1022 row >= scroll && row < scroll + visible.max(1),
1023 "selection {selected} (row {row}) fell outside the \
1024 window [{scroll}, {}) at visible={visible}",
1025 scroll + visible.max(1)
1026 );
1027 assert!(
1028 scroll <= spec.row_count().saturating_sub(visible.max(1)),
1029 "the list must never scroll past its own end"
1030 );
1031 }
1032 }
1033 }
1034
1035 /// `<C-n>` past the last item wraps instead of running away.
1036 ///
1037 /// The bug this replaces: the host grew a raw scroll offset with
1038 /// `saturating_add`, so extra presses accumulated far past anything
1039 /// renderable and `<C-p>` had to walk every phantom step back
1040 /// before the view moved.
1041 #[test]
1042 fn walking_off_either_end_of_a_transient_wraps() {
1043 use crate::{Picker, PickerAction, PickerSource};
1044
1045 let mut picker = Picker::new("t", PickerSource::Files, PickerAction::OpenFile);
1046 picker.transient = Some(std::sync::Arc::new(geometry_spec()));
1047
1048 for expected in [1, 2, 3, 4, 5, 0, 1] {
1049 picker.transient_select_next();
1050 assert_eq!(picker.transient_selected, expected);
1051 }
1052 picker.transient_selected = 0;
1053 picker.transient_select_prev();
1054 assert_eq!(
1055 picker.transient_selected, 5,
1056 "backwards off the top wraps to the last item"
1057 );
1058 }
1059
1060 /// Ten extra `<C-n>`s must leave the selection where one `<C-p>`
1061 /// undoes them — the user-visible symptom, stated directly.
1062 #[test]
1063 fn overshooting_leaves_no_phantom_steps_to_walk_back() {
1064 use crate::{Picker, PickerAction, PickerSource};
1065
1066 let mut picker = Picker::new("t", PickerSource::Files, PickerAction::OpenFile);
1067 picker.transient = Some(std::sync::Arc::new(geometry_spec()));
1068 for _ in 0..6 {
1069 picker.transient_select_next();
1070 }
1071 assert_eq!(picker.transient_selected, 0, "six items, back to the top");
1072 picker.transient_select_prev();
1073 assert_eq!(
1074 picker.transient_selected, 5,
1075 "one press back moves one item — not one of N accumulated \
1076 out-of-range steps"
1077 );
1078 }
1079
1080 /// With no transient open the walkers are inert rather than
1081 /// scribbling on a field the picker is not using.
1082 #[test]
1083 fn the_transient_walkers_are_inert_without_a_transient() {
1084 use crate::{Picker, PickerAction, PickerSource};
1085
1086 let mut picker = Picker::new("t", PickerSource::Files, PickerAction::OpenFile);
1087 picker.transient_select_next();
1088 picker.transient_select_prev();
1089 assert_eq!(picker.transient_selected, 0);
1090 assert!(picker.transient_selected_item().is_none());
1091 }
1092
1093 /// `<CR>` fires the item the marker is on — the selection index and
1094 /// `item_at` must agree with `row_of_item`'s ordering, or the menu
1095 /// highlights one row and runs another.
1096 #[test]
1097 fn the_selected_item_is_the_one_the_index_names() {
1098 let spec = geometry_spec();
1099 for (index, key) in ["a", "b", "c", "d", "e", "f"].iter().enumerate() {
1100 assert_eq!(
1101 spec.item_at(index).map(|i| i.label.as_str()),
1102 Some(*key),
1103 "item {index}"
1104 );
1105 }
1106 assert!(spec.item_at(6).is_none());
1107 }
1108
1109 #[test]
1110 fn confirm_transient_spec_has_yes_action_and_dismiss_items() {
1111 let cmd_id = CommandId::new(42);
1112 let spec = confirm_transient_spec("Discard changes?", cmd_id);
1113 assert_eq!(spec.title, "Discard changes?");
1114 assert_eq!(spec.groups.len(), 1);
1115 let items = &spec.groups[0].items;
1116 assert_eq!(items.len(), 2);
1117 assert!(
1118 matches!(items[0].kind, TransientItemKind::Action { command, ref args }
1119 if command == cmd_id && matches!(args, Args::None))
1120 );
1121 assert!(matches!(items[1].kind, TransientItemKind::Dismiss));
1122 assert!(items[1].key.iter().any(|k| k == "q"), "q must dismiss");
1123 assert!(items[1].key.iter().any(|k| k == "n"), "n must dismiss");
1124 }
1125}