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}