Skip to main content

lattice_picker/
outcome.rs

1//! Picker accept outcomes -- typed effects the source
2//! generator emits on `<CR>` for the host to apply.
3//!
4//! Bounded enum, scoped to "what a picker can ask the host
5//! to do." Source generators emit one outcome; the host's
6//! translator (`App::apply_picker_outcome`) pattern-matches
7//! into the appropriate `Effect` / App-state mutation.
8//! Plugin sources (Phase 7) emit the same enum over WIT --
9//! the variant set is the trait surface plugin authors
10//! design against.
11
12use std::path::PathBuf;
13
14use lattice_grammar::args::Args;
15
16/// YR.3: where a [`PickerAcceptOutcome::FillCaller`] puts its text.
17///
18/// **Captured when the picker is opened, never resolved when it
19/// accepts.** By accept time the picker has been dismissed and the
20/// modal state that identified the caller is gone; resolving then reads
21/// whatever context is current. In the single-level case that is
22/// usually the right answer, which is precisely the trap — it passes a
23/// naive test and fails in the picker-inside-a-prompt case the feature
24/// exists for. `Effect::CursorMoveIn` (name the buffer the position was
25/// computed in) and MG.32's `<CR>` (ask the view before resolving the
26/// path) are the same shape, arrived at the same way.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum FillTarget {
29    /// Insert at the cursor in the buffer that was focused at open.
30    Document,
31    /// The `:` line.
32    CommandLine,
33    /// The `/` or `?` line.
34    SearchLine,
35    /// A one-line minibuffer prompt, named so a later prompt cannot
36    /// receive text meant for the one that opened this picker.
37    Prompt { buffer: u32 },
38    /// The query of the picker that was showing when this one opened —
39    /// the `M-y`-inside-a-picker case.
40    PickerQuery,
41    /// A transient menu's argument, parked while this picker is up.
42    /// MG.53.e's case, folded in here rather than kept as a second
43    /// mechanism: "which argument" is already recorded in the parked
44    /// `PendingTransientArgument`, so this variant carries nothing.
45    TransientArgument,
46    /// PC.11: an ex-command, which receives the value as its first
47    /// argument.
48    ///
49    /// **The variant a plugin can name.** Every target above is a host
50    /// surface — a buffer, a line, a prompt, a menu, another picker — and a
51    /// guest owns none of them. It owns an ex-command, so that is what it
52    /// gets to point at. `Effect::OpenPrompt`'s `on_submit_action` is the
53    /// same idea for prompts, and this closes the gap where a guest could be
54    /// handed a prompt's answer but not a picker's.
55    ///
56    /// Carries the command NAME rather than a resolved `CommandId`: the
57    /// target is captured at open (see this type's own doc), and resolving
58    /// then would pin an id the registry could have re-issued by accept
59    /// time. Resolution happens where the value is delivered, which is also
60    /// where "no such command" can be reported to someone who can act on it.
61    Action { command: String },
62}
63
64/// Issue #32 (2026-05-22): where a picker's file-opening outcome should
65/// land. `<CR>` uses `Default`; `<C-s>` / `<C-v>` / `<C-t>` override to a
66/// split / vsplit / new tab.
67///
68/// Defined in `lattice_core::ui::pane` (LM.0) so `Effect::OpenInTarget`
69/// can name the same canonical type; re-exported here so the picker's
70/// accept vocabulary and every `lattice_picker::OpenTarget` call site are
71/// unchanged.
72///
73/// Only the file-targeting outcome arms (`OpenFile`, `SwitchBuffer`,
74/// `JumpInBuffer`, `JumpToLocation`) honor this. Non-file outcomes
75/// (commands, registers, snippets, LSP code actions) ignore it.
76pub use lattice_core::ui::pane::OpenTarget;
77
78/// MG.54: result of [`PickerSourceGenerator::preview`], the hook that
79/// fires as the SELECTION moves rather than on `<CR>`.
80///
81/// **Deliberately not [`PickerAcceptOutcome`]**, which it used to be.
82/// That enum answers "what does accepting this candidate do", and it
83/// worked as a preview vocabulary only by the accident that
84/// `ApplyColorscheme` happens to mean the same thing in both contexts.
85/// Showing text in a pane does not: it is a projection the host tears
86/// down on `<Esc>`, never something a `<CR>` performs. Adding it to the
87/// accept enum would have put a variant there that no accept path can
88/// honour — and the next preview-only payload would have compounded it.
89///
90/// A plugin source implementing `preview` therefore gets a type whose
91/// every variant is valid where it is returned (paramount #2).
92///
93/// [`PickerSourceGenerator::preview`]: crate::source::PickerSourceGenerator::preview
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum PickerPreviewOutcome {
96    /// T.12: apply the named theme while the selection sits on it. The
97    /// host snapshots the pre-open theme on the first preview and
98    /// restores it on `<Esc>`. Swaps the GLOBAL theme, not a buffer, so
99    /// it is orthogonal to the buffer projection below.
100    Colorscheme { name: String },
101    /// MG.54: show `text` in the active pane as a read-only projection
102    /// — the pane's committed buffer is untouched and snaps back when
103    /// the picker closes.
104    ///
105    /// For content that has no file to read: a git blob at a revision,
106    /// a generated listing, a plugin's rendering. `syntax_path` is the
107    /// path the content *would* have (`src/main.rs` for
108    /// `git show HEAD:src/main.rs`) and drives language detection only;
109    /// nothing reads it from disk. `None` previews as plain text.
110    ///
111    /// The source hands over text it has ALREADY fetched. Whatever it
112    /// costs to produce is the source's problem, and a source whose
113    /// production is expensive says so via
114    /// [`PickerSourceGenerator::preview_debounce`] so the host only
115    /// asks once the selection settles.
116    ///
117    /// [`PickerSourceGenerator::preview_debounce`]: crate::source::PickerSourceGenerator::preview_debounce
118    Buffer {
119        /// Synthetic buffer name, shown wherever a buffer's name is
120        /// (`*magit:file:HEAD:src/main.rs*`).
121        name: String,
122        text: String,
123        syntax_path: Option<PathBuf>,
124    },
125}
126
127/// Result of `PickerSourceGenerator::accept`. The host
128/// pattern-matches and runs the corresponding mutation.
129///
130/// New variants are added when (and only when) a concrete
131/// picker source needs an action the existing set can't
132/// express. Resist re-using `Effect` directly: a tighter
133/// outcome set is easier to audit, easier to mirror in WIT,
134/// and stops source generators from emitting arbitrary
135/// grammar effects that bypass picker conventions.
136#[derive(Debug, Clone)]
137pub enum PickerAcceptOutcome {
138    /// Edit the file at `path`. Host routes through
139    /// `App::do_edit`, which handles the "already-open" +
140    /// new-file branches uniformly.
141    OpenFile { path: PathBuf },
142    /// Switch the active pane to an existing buffer by id.
143    SwitchBuffer { buffer_id: u32 },
144    /// Move the cursor within `buffer_id`. If `buffer_id`
145    /// is the active pane's buffer, only the cursor moves;
146    /// otherwise the host activates that buffer first.
147    /// Picker sources use this for in-buffer jumps where
148    /// the buffer is already loaded (`:picker lines`,
149    /// `:picker marks` against the active doc).
150    JumpInBuffer { buffer_id: u32, line: u32, col: u32 },
151    /// Jump to a named mark. The host resolves the mark's
152    /// position itself -- the source doesn't need to.
153    JumpToMark { name: char },
154    /// Jump to `path:line:col`. If `path` isn't the active
155    /// buffer, the host opens it first. Used by LSP
156    /// locations, grep hits, outline jumps -- anywhere the
157    /// destination might or might not already be open.
158    JumpToLocation { path: PathBuf, line: u32, col: u32 },
159    /// Dispatch an ex-command by id with the provided
160    /// args. The command palette (`:picker commands`) uses
161    /// this; future plugin sources may emit it for chained
162    /// behaviors ("pick a thing, then run a command on it").
163    InvokeCommand { id: String, args: Args },
164    /// Paste a register's contents at the current cursor.
165    /// `name` is the single-char register identifier
166    /// (`a`-`z`, `0`-`9`, `"`, `+`, etc.).
167    PasteRegister { name: char },
168    /// Expand a snippet by id at the current cursor.
169    ExpandSnippet { id: String },
170    /// Open the per-server LSP log buffer.
171    OpenLspLog { server_id: String },
172    /// Open the per-server LSP trace-log buffer (distinct
173    /// from the regular log; carries the protocol trace).
174    OpenLspTraceLog { server_id: String },
175    /// Apply a resolved LSP code action by index into the
176    /// host's `pending_code_action_items` snapshot.
177    /// `handle` is the cancellation token / request handle
178    /// the action was registered against (carried by the
179    /// host for resolve-then-apply correlation).
180    ApplyLspCodeAction { handle: u64, index: u32 },
181    /// Apply an LSP completion item by index into the
182    /// host's `pending_completion_items` snapshot.
183    ApplyLspCompletion { index: u32 },
184    /// T.12: apply the named theme via the ThemeRegistry catalog;
185    /// host calls apply_theme + signals ThemeChanged. Emitted by the
186    /// colorscheme picker on both accept and live preview.
187    ApplyColorscheme { name: String },
188    /// MB.3: load `text` into the editable `:` command line WITHOUT
189    /// executing it. Host routes through `Editor::open_command_line`,
190    /// exactly as if the user had typed `:` and the text by hand —
191    /// they then tweak (or `<C-x><C-e>` expand) and `<CR>` to run.
192    /// Emitted by the `history` picker source (`q:` / `:history`).
193    LoadCommandLine { text: String },
194    /// MB.5: load `text` into the editable `/` search line WITHOUT
195    /// executing it. Host routes through `Editor::open_search_line`
196    /// (Forward direction) + `set_search_line_text` — the user tweaks
197    /// and `<CR>` to execute. Emitted by the `search-history` picker
198    /// source (`q/` / `q?` / `:history search`).
199    LoadSearchLine { text: String },
200    /// PBH.5: walk the active pane's buffer trail to `index` — a MOVE of
201    /// the walk position, not a new visit, so the forward tail stays
202    /// reachable. Host routes through `Editor::do_pane_history_jump`.
203    /// Emitted by the `pane-buffer-history` source; host-internal (a
204    /// plugin has no pane trail to name).
205    ///
206    /// Its own variant rather than `NoOp`-plus-host-fallback: the source
207    /// used to return `NoOp` expecting the legacy routing arm to do the
208    /// walk, but a registered generator's outcome is applied INSTEAD of
209    /// that arm, so `<CR>` closed the picker and went nowhere.
210    WalkPaneHistory { index: u32 },
211    /// Open a generic one-line minibuffer text prompt —
212    /// picker-accept's peer of `Effect::OpenPrompt` (same fields, same
213    /// name-based `on_submit_action` lookup, no closures). Lets a
214    /// source chain "pick an item, then type a value" (e.g. magit's
215    /// branch-create: pick the base branch via this picker, then
216    /// prompt for the new branch's name) without inventing bespoke
217    /// per-source plumbing. `buffer_name`, when set, stashes context
218    /// (e.g. the picked base branch) in the prompt buffer's synthetic
219    /// name for the submit handler to read back.
220    OpenPrompt {
221        prompt: String,
222        initial: String,
223        on_submit_action: String,
224        buffer_name: Option<String>,
225    },
226    /// YR.3 / MG.53.e: the picked item's **text**, for whatever opened
227    /// the picker.
228    ///
229    /// The outcome for a source that answers a question rather than
230    /// performing an action. Where the text lands is not the source's
231    /// business and not encoded here — the host consumes the
232    /// [`FillTarget`] it captured when the picker was opened. The same
233    /// `file-pick` list fills a magit argument; the same `yank-ring`
234    /// list fills the document, the `:` line, a prompt, or another
235    /// picker's query.
236    ///
237    /// Deliberately not `InvokeCommand`. A source that supplies text
238    /// does not know, and must not decide, what the text is for. Baking
239    /// a command into the source would mean one registered source per
240    /// consumer, which is the duplication this exists to avoid.
241    ///
242    /// The host echoes and drops it when no target was captured; text
243    /// arriving with nowhere to go is a wiring bug, not a user error,
244    /// and silently discarding it would present as a picker that does
245    /// nothing.
246    FillCaller { text: String },
247    /// Picker dismissed without action -- source-side
248    /// abort, accept-on-empty filter, etc. Host applies no
249    /// mutation. Distinct from `Err` returned from `accept`
250    /// (which echoes an error message); `NoOp` is the
251    /// silent "nothing to do here" path.
252    NoOp,
253}
254
255#[cfg(test)]
256mod tests {
257    #![allow(clippy::unwrap_used, clippy::panic)]
258
259    use super::*;
260
261    #[test]
262    fn open_file_clone_preserves_path() {
263        let o = PickerAcceptOutcome::OpenFile {
264            path: "/tmp/x.rs".into(),
265        };
266        let clone = o.clone();
267        match clone {
268            PickerAcceptOutcome::OpenFile { path } => assert_eq!(path, PathBuf::from("/tmp/x.rs")),
269            other => panic!("expected OpenFile, got {other:?}"),
270        }
271    }
272
273    #[test]
274    fn jump_in_buffer_carries_coordinates() {
275        let o = PickerAcceptOutcome::JumpInBuffer {
276            buffer_id: 7,
277            line: 41,
278            col: 3,
279        };
280        match o {
281            PickerAcceptOutcome::JumpInBuffer {
282                buffer_id,
283                line,
284                col,
285            } => {
286                assert_eq!(buffer_id, 7);
287                assert_eq!(line, 41);
288                assert_eq!(col, 3);
289            }
290            other => panic!("expected JumpInBuffer, got {other:?}"),
291        }
292    }
293
294    #[test]
295    fn noop_compares_equal_via_format() {
296        // `PickerAcceptOutcome` is intentionally not `PartialEq`
297        // (Args isn't), so use Debug shape as a sanity check.
298        let o = PickerAcceptOutcome::NoOp;
299        assert_eq!(format!("{o:?}"), "NoOp");
300    }
301}