Skip to main content

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}