Skip to main content

lattice_host/
state.rs

1//! Small App-helper state types -- pure data, no renderer
2//! coupling.
3//!
4//! Phase 5.2: extracted from `lattice-ui-tui::app` so the
5//! eventual App migration carries fewer in-line type
6//! definitions. Each struct here is a piece of state App holds
7//! in a field (search line in progress, last search, unnamed
8//! register, prev-pane snapshot). Renderer-agnostic by
9//! construction.
10
11use lattice_core::{BufferId, BufferKind, FoldMethod};
12
13use lattice_grammar::{ModalState, SearchDirection, VisualKind, YankKind};
14
15use lattice_protocol::CancellationToken;
16use lattice_protocol::position::{Position, Range as ProtoRange};
17
18use crate::action::Action;
19
20/// In-progress `/` or `?` search state (MB.5a). The pattern text is
21/// NOT stored here — it lives in the focused `*search-line*` buffer
22/// (single source of truth, read via `Editor::search_pattern`), the
23/// same way the `:` line reads `*command-line*`. This struct is the
24/// "search is active" marker + the metadata the buffer can't carry:
25/// the direction (`/` vs `?`) and the `origin` cursor preserved so
26/// `<Esc>` restores it and incremental preview anchors its search.
27#[derive(Debug, Clone)]
28pub struct SearchLine {
29    pub direction: SearchDirection,
30    pub origin: Position,
31}
32
33/// Last completed search -- consulted by `n` and `N`. VM.3d-2: defined in
34/// `lattice-grammar` now that `n` is a motion; re-exported so no call site
35/// moved.
36pub use lattice_grammar::LastSearch;
37
38/// The unnamed register's payload. v1 uses a single global slot;
39/// the full vim register zoo (`"a-z`, `"+`, `"*`, etc.) lands
40/// later.
41#[derive(Debug, Clone)]
42pub struct UnnamedRegister {
43    pub content: String,
44    pub kind: YankKind,
45}
46
47/// Snapshot of the active pane's state captured just before help
48/// took it over. Used by `dismiss_popup` to restore the user to
49/// the buffer + cursor + scroll they came from. The same struct
50/// serves both display modes (in-pane and popup-overlay).
51#[derive(Debug, Clone, Copy)]
52pub struct PrevPaneState {
53    pub buffer: BufferKind,
54    pub buffer_id: BufferId,
55    pub cursor: Position,
56    pub scroll: u32,
57    /// PU-A.1b: the modal state at capture. A focus-stealing popup
58    /// (`PopupFocus::Steal`) is a Normal-mode surface — its major mode
59    /// receives keys — so open sets `ModalState::Normal` and dismiss
60    /// restores this, returning a user who was mid-Insert to their
61    /// prompt in Insert (popup-api.md §5). Passive floats never flip
62    /// modal, so they never capture a `PrevPaneState`. In-pane help
63    /// captures this but is torn down by `do_close_pane` (which ignores
64    /// it), so the value is inert there.
65    pub modal: ModalState,
66}
67
68/// MB.1 / MB.5a (rich minibuffer): the editing state suspended while a
69/// **minibuffer prompt** (`:` command line or `/`·`?` search line) owns
70/// `self.document`. When a prompt opens,
71/// [`Editor::focus_editing_buffer`] swaps `self.document` /
72/// `document_buffer_id` / `active_buffer` to the synthetic
73/// `*command-line*` / `*search-line*` buffer and stashes the prior
74/// *editing* focus here **without** touching the pane tree (the active
75/// pane keeps rendering its own buffer). [`Editor::restore_editing_buffer`]
76/// pops it back on submit / cancel. `Some(_)` is the "a prompt is
77/// focused" flag; whether it's the command line vs the search line is
78/// decided by `Editor::search_line` (`command_line_active` /
79/// `search_line_active`). Prompt-agnostic by design so `git-commit-line`
80/// / `repl-input` reuse it unchanged (design §6).
81#[derive(Debug, Clone)]
82pub struct MinibufferFocus {
83    /// FS.1b: the buffer this frame FOCUSED — the surface itself, not what
84    /// it took focus from.
85    ///
86    /// It is what lets a focus tell "I am nesting inside the thing that has
87    /// focus" from "I am REPLACING it". A prompt opening while a prompt is
88    /// focused is the second: two frames would mean two restores, and the
89    /// first `<CR>` would land the user back in the previous prompt instead
90    /// of their file.
91    pub focused_buffer: BufferId,
92    /// The document buffer that was focused for editing before the
93    /// `:` line took over. Restored (re-fetched from the registry by
94    /// id) when the command line closes.
95    pub prior_buffer_id: BufferId,
96    /// Kind of the prior active buffer (`Document` / `Messages` / …).
97    pub prior_active_buffer: BufferKind,
98    /// Cursor in the prior buffer at focus time.
99    pub prior_cursor: Position,
100    /// Scroll (first visible line) of the prior buffer at focus time.
101    pub prior_scroll: u32,
102    /// Horizontal scroll of the prior buffer at focus time.
103    pub prior_leftcol: u32,
104    /// Modal state at focus time, restored on close.
105    pub prior_modal: ModalState,
106    /// MB.2: whether the `:` line is **expanded** into the full-modal
107    /// mini-buffer band (`<C-x><C-e>`). Tier 1 (`false`): the one-row
108    /// readline `:` line under `ModalState::Command`. Tier 2 (`true`):
109    /// the same `*command-line*` surface grown in place with the full vim
110    /// grammar (real Normal / Insert / Visual). Collapsing returns the
111    /// edited text to the one-row line for review.
112    pub expanded: bool,
113}
114
115/// Hot-path option cache. Mirrors the typed-options registry's
116/// resolved values for the active buffer; reads on this struct
117/// fire on every render tick, so the cache exists to skip a
118/// HashMap lookup per option. Repopulated by
119/// `App::rebuild_option_cache` after every `:set`.
120#[derive(Debug, Clone, Copy)]
121pub struct OptionCache {
122    pub show_line_numbers: bool,
123    pub relative_line_numbers: bool,
124    pub wrap_lines: bool,
125    /// PU.1b-1a (`signcolumn`): whether the renderer reserves the
126    /// gutter sign columns (diagnostics severity + diff sign). `true`
127    /// (default) always reserves them so layout never shifts when a
128    /// sign appears; `false` (help / synthetic buffers) renders
129    /// gutterless. Resolved from the `SignColumnOption` typed option —
130    /// the renderer reads only this flag, never the buffer kind.
131    pub sign_column: bool,
132    pub ignorecase: bool,
133    pub tabstop: u32,
134    pub foldenable: bool,
135    pub foldmethod: FoldMethod,
136    pub scrolloff: u32,
137    /// VM.3j-2 (`:set scroll`): lines `<C-d>` / `<C-u>` move. `0` means
138    /// half the window.
139    pub scroll_lines: u32,
140    /// VM.3f (`:set startofline`): `H` / `M` / `L` land on the first
141    /// non-blank; off keeps the cursor's column.
142    pub startofline: bool,
143    /// Horizontal scroll step (`:set sidescroll`). `0` jump-scrolls
144    /// the cursor to the window centre; positive scrolls N columns.
145    pub sidescroll: u32,
146    /// Horizontal scroll-off margin (`:set sidescrolloff`).
147    pub sidescrolloff: u32,
148    pub completion_auto_insert_single: bool,
149    pub show_whitespace: bool,
150    pub current_line_highlight: bool,
151    pub whitespace_tab: Option<char>,
152    pub whitespace_trailing: Option<char>,
153    pub whitespace_leading: Option<char>,
154    pub whitespace_space: Option<char>,
155    pub whitespace_eol: Option<char>,
156    /// IG.3: `display.indent-guides.char`, the glyph the TUI substitutes
157    /// into a guide column. `None` (empty string) ⇒ the TUI draws no
158    /// guides. The GPU peer paints a rule and ignores this.
159    pub indent_guide_char: Option<char>,
160    /// IG.3: `display.indent-guides.active` — draw the block enclosing
161    /// the cursor in its own style. Whether guides exist at all is
162    /// `display.indent-guides`, which is resolved by the worker (it
163    /// changes what gets built, not how it is painted).
164    pub indent_guide_active: bool,
165    /// Terminal-mode T2.b.0 (2026-05-25): cached
166    /// `terminal.esc-exits`. Default `true` so test fixtures
167    /// using `Editor::default()` (no `init_from_linkme`) get
168    /// the production semantics without panicking on an
169    /// unregistered option lookup.
170    pub terminal_esc_exits: bool,
171    /// D.0b: cached `scrollbind`. Triggers
172    /// `rebuild_scrollbind_group` via `apply_option_cascade`
173    /// when toggled.
174    pub scrollbind: bool,
175    /// DB.4: extra leading gutter cells to horizontally centre the active
176    /// buffer's content (dashboard). `(viewport_width - content_block_width)/2`,
177    /// recomputed on activation + resize. `0` = not centred (the default for
178    /// every buffer). The renderer adds this to the gutter width so content +
179    /// cursor shift right with no text mutation.
180    pub content_left_pad: u32,
181}
182
183impl Default for OptionCache {
184    fn default() -> Self {
185        Self {
186            show_line_numbers: true,
187            relative_line_numbers: false,
188            wrap_lines: false,
189            sign_column: true,
190            ignorecase: false,
191            tabstop: 4,
192            foldenable: true,
193            foldmethod: FoldMethod::Manual,
194            scrolloff: 0,
195            scroll_lines: 0,
196            startofline: true,
197            sidescroll: 0,
198            sidescrolloff: 0,
199            completion_auto_insert_single: true,
200            show_whitespace: false,
201            current_line_highlight: false,
202            whitespace_tab: Some('→'),
203            whitespace_trailing: Some('·'),
204            whitespace_leading: Some('·'),
205            whitespace_space: None,
206            whitespace_eol: None,
207            indent_guide_char: Some('\u{2502}'),
208            indent_guide_active: true,
209            terminal_esc_exits: true,
210            scrollbind: false,
211            content_left_pad: 0,
212        }
213    }
214}
215
216/// Capture of the most recent find/till for `;` / `,` repeat.
217///
218/// VM.3c: moved down to `lattice-grammar` alongside [`FindKind`], because the
219/// `;` motion reads it out of `MotionContext` and the grammar cannot depend on
220/// the host. Re-exported here so existing call sites keep their import.
221pub use lattice_grammar::LastFind;
222
223/// In-progress macro recording. `q<reg>` starts; `q` again
224/// stops and persists into the register table.
225#[derive(Debug, Clone)]
226pub struct MacroRecording {
227    pub register: char,
228    pub actions: Vec<Action>,
229}
230
231/// One entry on the vim-style tag stack. Pushed by `gd` (and
232/// the goto-* family) at the pre-jump cursor; popped by `<C-t>`
233/// to walk back. Distinct from the jump list because the user's
234/// mental model for `<C-t>` is "undo the drill-down chain", not
235/// "step through every cursor jump in chronological order".
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct TagStackEntry {
238    pub buffer: BufferKind,
239    pub buffer_id: BufferId,
240    pub position: Position,
241    pub label: String,
242}
243
244/// One entry in the unified position history (DESIGN.md §5.1.1).
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub struct PositionEntry {
247    pub position: Position,
248    pub source: PositionSource,
249    pub buffer: BufferKind,
250    pub buffer_id: BufferId,
251    /// T3.b.3 (2026-05-25): scrollback row the user was viewing
252    /// when the entry was pushed. Only meaningful for
253    /// `BufferKind::Terminal` entries — Document jumps ignore
254    /// it. `0` = live edge. Restored by `<C-o>` / `<C-i>` when
255    /// landing back on a Terminal so the user returns to the
256    /// row they were studying.
257    pub terminal_scroll_offset: u32,
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Eq)]
261pub enum PositionSource {
262    /// Pushed by "big motions" -- gg, G, search, *, #, %, mark jump.
263    AutoJump,
264    /// Reserved: `g<C-o>` style "I explicitly want to remember here"
265    /// pushes (emacs `set-mark`). Not yet wired to a key.
266    ExplicitMark,
267    /// Reserved: pushed by plugins (LSP go-to-definition, fuzzy-finder
268    /// hop, etc.). Treated like AutoJump for navigation.
269    PluginPush,
270    /// `mX` named mark. Walks via `g;` / `g,`.
271    NamedMark(char),
272}
273
274impl PositionEntry {
275    /// True for entries that the standard Ctrl-O / Ctrl-I jump-list
276    /// walks consume.
277    pub fn is_jump(&self) -> bool {
278        matches!(
279            self.source,
280            PositionSource::AutoJump | PositionSource::PluginPush
281        )
282    }
283
284    /// True for entries the `g;` / `g,` mark-history walks consume.
285    pub fn is_named_mark(&self) -> bool {
286        matches!(self.source, PositionSource::NamedMark(_))
287    }
288}
289
290/// One replace-mode entry -- the byte that was at `at` before the
291/// overwrite, so `<BS>` can restore it. `original = None` means
292/// the overwrite extended the line (the position was past EOL);
293/// `<BS>` deletes the inserted char rather than restoring a byte.
294#[derive(Debug, Clone)]
295pub struct ReplaceEntry {
296    pub at: Position,
297    pub original: Option<String>,
298}
299
300/// Cmdline completion popup state: candidates and selection.
301/// Renderer-agnostic; the renderer reads this via &App.
302#[derive(Debug, Clone)]
303pub struct CompletionState {
304    pub candidates: Vec<lattice_completion::RenderedCandidate>,
305    pub selected: usize,
306    /// Byte offset within the command line where the completed
307    /// prefix starts.
308    pub replace_start: usize,
309    /// Snapshot of the command line at popup-open time.
310    pub original_line: String,
311}
312/// Most-recently-completed visual selection. Used by `gv` to
313/// reselect.
314#[derive(Debug, Clone, Copy)]
315pub struct LastVisual {
316    pub anchor: Position,
317    pub head: Position,
318    pub kind: VisualKind,
319}
320
321/// Snapshot of an in-progress `:s/pat/repl/...` preview.
322/// Refreshed on every cmdline keystroke while the input parses as
323/// a substitute; consumed by the renderer to overlay match ranges
324/// (and the typed replacement, when present) on the target buffer.
325#[derive(Debug, Clone)]
326pub struct SubstitutePreview {
327    /// Match ranges in the target line(s).
328    pub matches: Vec<ProtoRange>,
329    /// The user-typed replacement template, once the second `/`
330    /// has been entered. None while the user is still inside the
331    /// pattern field.
332    pub replacement: Option<String>,
333    /// Whether the user has explicitly typed flags including 'g'.
334    pub global: bool,
335}
336
337/// 5.5.G.23.cmdline: result of resolving a missing required first
338/// arg on `:`-submit. Built by `Editor::try_resolve_missing_arg_prompt`
339/// and consumed by `do_command_line_submit`.
340#[derive(Debug, Clone)]
341pub struct MissingArgPrompt {
342    /// New value for `command_line`. Already contains the command
343    /// word + bang + a trailing space; the cursor lands at end-of-
344    /// line, in the first arg slot.
345    pub prefill: String,
346    /// Kind of the first arg. Drives whether the host arms the
347    /// chord-capture overlay (kind == Chord) or just leaves the
348    /// cmdline open for typed input.
349    pub kind: lattice_grammar::ArgKind,
350    /// Prompt text for the echo area, taken from the schema's
351    /// `prompt` field (or `"<name>:"` when empty).
352    pub prompt: String,
353}
354
355/// In-flight blockwise-visual insert (`I` or `A`).
356///
357/// When the user enters `I` from blockwise visual, the typed
358/// prefix is replicated to every line in the block at the same
359/// column on Esc. We capture the rectangle's lines and the
360/// per-line insert column at entry time, then replay the
361/// recorded text to all lines except the top one (the top row
362/// was edited live during the Insert session).
363#[derive(Debug, Clone, Copy)]
364pub struct PendingBlockInsert {
365    pub start_line: u32,
366    pub end_line: u32,
367    pub insert_col: u32,
368    pub live_edits: u32,
369}
370
371/// In-flight async picker init. The future from
372/// `PickerSourceGenerator::init` is spawned on the LSP
373/// runtime; its resolved batch lands here via `rx`. The
374/// `cancel` token lets a subsequent `:picker <source>` drop
375/// the predecessor before it completes.
376pub struct PendingPickerInit {
377    pub source_id: String,
378    pub generator: std::sync::Arc<dyn lattice_picker::PickerSourceGenerator>,
379    pub rx: tokio::sync::mpsc::UnboundedReceiver<
380        lattice_picker::SourceResult<lattice_picker::CandidateBatch>,
381    >,
382    pub cancel: CancellationToken,
383}
384
385impl std::fmt::Debug for PendingPickerInit {
386    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
387        f.debug_struct("PendingPickerInit")
388            .field("source_id", &self.source_id)
389            .finish_non_exhaustive()
390    }
391}
392
393/// TR.2: in-flight async **transient build**. A guest-backed
394/// menu's builder answers a `TransientBuildFuture` rather than a
395/// spec; it is spawned on the LSP runtime and its resolved spec
396/// lands here via `rx`, seated by
397/// `drain_pending_transient_build` on the async-landed wake.
398///
399/// `source` is kept for the error echo: a build that fails must
400/// name the menu that failed, or the user sees a chord that did
401/// nothing.
402///
403/// Single-slot like [`PendingPickerInit`], and for the same
404/// reason — a second `Effect::OpenTransient` before the first
405/// lands supersedes it, so the older future is cancelled rather
406/// than racing to seat a menu the user has moved on from.
407pub struct PendingTransientBuild {
408    pub source: String,
409    pub rx: tokio::sync::mpsc::UnboundedReceiver<Result<lattice_picker::TransientSpec, String>>,
410    pub cancel: CancellationToken,
411}
412
413impl std::fmt::Debug for PendingTransientBuild {
414    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415        f.debug_struct("PendingTransientBuild")
416            .field("source", &self.source)
417            .finish_non_exhaustive()
418    }
419}
420
421/// In-flight async picker *accept*. The future from
422/// `PickerSourceGenerator::accept_async` is spawned on the LSP
423/// runtime (a plugin source's accept is an async guest call
424/// bound to its actor task, so it must not block the actor
425/// thread); its resolved outcome lands here via `rx` and is
426/// applied by `drain_pending_picker_accept`. `target` is the
427/// open-target override consumed at accept time and re-applied
428/// when the outcome commits. Mirrors [`PendingPickerInit`]; the
429/// `cancel` token drops a superseded accept (a rapid second
430/// accept before this one drains).
431pub struct PendingPickerAccept {
432    pub source_id: String,
433    pub target: lattice_picker::OpenTarget,
434    pub rx: tokio::sync::mpsc::UnboundedReceiver<
435        lattice_picker::SourceResult<lattice_picker::outcome::PickerAcceptOutcome>,
436    >,
437    pub cancel: CancellationToken,
438}
439
440impl std::fmt::Debug for PendingPickerAccept {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        f.debug_struct("PendingPickerAccept")
443            .field("source_id", &self.source_id)
444            .finish_non_exhaustive()
445    }
446}
447
448/// Live-picker query state. Installed when `open_picker`
449/// resolves a source whose `spec().live` is true; survives
450/// until the picker is dismissed.
451///
452/// Two phases:
453///
454/// 1. **Debouncing.** `debounce_until = Some(deadline)` while
455///    a keystroke is pending. Every fresh keystroke
456///    reschedules the deadline forward. Once `Instant::now()
457///    >= deadline`, the main-loop drain fires
458///    `PickerSourceGenerator::on_query_changed` and clears
459///    `debounce_until` to None.
460/// 2. **In-flight.** When `on_query_changed` returns a
461///    `Future` or a `Stream`, the spawned task lands its
462///    result on `inflight.rx`. The drain seats new raw
463///    candidates (if the result is still relevant) or drops
464///    the result (if the user has typed past the launched
465///    query).
466pub struct LivePickerQueryState {
467    pub source_id: String,
468    pub generator: std::sync::Arc<dyn lattice_picker::PickerSourceGenerator>,
469    pub debounce_until: Option<std::time::Instant>,
470    pub inflight: Option<InFlightLiveQuery>,
471    /// Set by `open_picker` from the first positional arg
472    /// when the source is live; consumed (taken) by
473    /// `seat_picker_from_pairs` on the first seat so the
474    /// picker prompt opens pre-populated with the user's
475    /// `:picker grep <pattern>` argument. None for live
476    /// pickers opened without an initial pattern.
477    pub initial_query: Option<String>,
478}
479
480impl std::fmt::Debug for LivePickerQueryState {
481    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
482        f.debug_struct("LivePickerQueryState")
483            .field("source_id", &self.source_id)
484            .field("debounce_until", &self.debounce_until)
485            .field("inflight", &self.inflight)
486            .field("initial_query", &self.initial_query)
487            .finish_non_exhaustive()
488    }
489}
490
491/// Spawned `on_query_changed` future / stream paired with
492/// the query it was launched against. The drain compares
493/// `launched_for_query` against the picker's live query;
494/// if they differ, the user has kept typing and a newer fire
495/// is already in flight (or coming via debounce) -- discard
496/// the stale result.
497pub struct InFlightLiveQuery {
498    pub cancel: CancellationToken,
499    pub rx: tokio::sync::mpsc::UnboundedReceiver<
500        lattice_picker::SourceResult<lattice_picker::PickerInitResult>,
501    >,
502    pub launched_for_query: String,
503}
504
505impl std::fmt::Debug for InFlightLiveQuery {
506    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507        f.debug_struct("InFlightLiveQuery")
508            .field("launched_for_query", &self.launched_for_query)
509            .finish_non_exhaustive()
510    }
511}
512
513/// Debounce window before a live picker's query change
514/// fires `on_query_changed`. Telescope uses ~150ms; chosen so
515/// that burst keystrokes coalesce into one source call
516/// without feeling laggy. Constant lives here (not in
517/// `lattice-picker`) because debounce is host policy -- the
518/// picker primitive is renderer- and timer-agnostic.
519pub const LIVE_PICKER_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(150);
520
521/// Sidecar metadata for snippet candidates in the active
522/// insert-completion popup. Indexed by the candidate's
523/// `CandidateData::Extension { payload }` (u32 LE) -- same
524/// shape as the LSP source's sidecar. The host renders the
525/// snippet body on accept and starts an `ActiveSnippet`; this
526/// struct carries the parsed body plus the display fields the
527/// popup row uses.
528#[derive(Debug, Clone)]
529pub struct SnippetCandidateMeta {
530    pub name: String,
531    pub prefix: String,
532    pub description: Option<String>,
533    pub body: lattice_snippet::SnippetBody,
534}
535
536// ─────────────────────────────────────────────────────────────
537// YR.1 — the yank ring
538// ─────────────────────────────────────────────────────────────
539
540/// A bounded history of everything that has been yanked or deleted.
541///
542/// Beside [`UnnamedRegister`] rather than in `lattice-grammar` (where the
543/// slice plan proposed it) because that is where the entry type it holds
544/// already lives; the grammar does not read the ring.
545///
546/// **Deletes push too, and the system clipboard still does not take
547/// them.** That looks like a contradiction of `clipboard.md` §5, which
548/// keeps deletes out of the clipboard on purpose — vim's `unnamedplus`
549/// wart, where an incidental `x` clobbers what you copied from a browser.
550/// The two stores have different blast radii. The clipboard is shared
551/// with every other application, so a stray write there destroys
552/// something the editor never owned; the ring is internal, bounded and
553/// additive, so an `x` landing in it costs one slot and destroys
554/// nothing. "Get back the line I just deleted" is also among the most
555/// common reasons to open the picker at all, and a ring holding only
556/// yanks would decline the question users most want to ask it.
557#[derive(Debug, Clone, Default)]
558pub struct YankRing {
559    /// Newest first. Front is the most recent entry, which is what the
560    /// `"0`–`"9` projection (YR.2) and the picker both read from.
561    entries: std::collections::VecDeque<RingEntry>,
562}
563
564/// YR.2: a ring slot — the register plus how it got here.
565///
566/// The yank/delete flag lives HERE rather than on [`UnnamedRegister`]
567/// because the ring is its only consumer: `"0` projects the newest
568/// *yank*, `"1`–`"9` the newest *deletes*. Putting it on the register
569/// would add a field to every named register and the unnamed one, none
570/// of which can answer a question about provenance — and it would have
571/// to be kept truthful at every construction site rather than at the
572/// one seam (`store_yank`) that actually knows.
573#[derive(Debug, Clone)]
574pub struct RingEntry {
575    pub register: UnnamedRegister,
576    /// True when this came from an explicit yank, false from a delete.
577    pub yanked: bool,
578}
579
580impl YankRing {
581    pub fn new() -> Self {
582        Self::default()
583    }
584
585    /// Number of entries currently held.
586    pub fn len(&self) -> usize {
587        self.entries.len()
588    }
589
590    pub fn is_empty(&self) -> bool {
591        self.entries.is_empty()
592    }
593
594    /// Entries newest-first. The picker shows yanks and deletes alike,
595    /// so this does not filter.
596    pub fn iter(&self) -> impl Iterator<Item = &UnnamedRegister> {
597        self.entries.iter().map(|e| &e.register)
598    }
599
600    /// The `n`th newest entry, 0-based. `nth(0)` is the most recent.
601    pub fn nth(&self, n: usize) -> Option<&UnnamedRegister> {
602        self.entries.get(n).map(|e| &e.register)
603    }
604
605    /// YR.2: `"0` — the newest **yank**.
606    ///
607    /// Not `nth(0)`: an intervening delete must not shadow the last
608    /// thing you deliberately copied, which is the whole reason vim
609    /// keeps `"0` distinct from `""`.
610    pub fn newest_yank(&self) -> Option<&UnnamedRegister> {
611        self.entries.iter().find(|e| e.yanked).map(|e| &e.register)
612    }
613
614    /// YR.2: `"1`–`"9` — the `n`th newest **delete**, 0-based.
615    ///
616    /// `nth_delete(0)` is `"1`. Yanks are skipped rather than counted,
617    /// so `"1` is always "the last thing I deleted" no matter how many
618    /// yanks happened in between.
619    pub fn nth_delete(&self, n: usize) -> Option<&UnnamedRegister> {
620        self.entries
621            .iter()
622            .filter(|e| !e.yanked)
623            .nth(n)
624            .map(|e| &e.register)
625    }
626
627    /// Record a yank or a delete, trimming to `capacity`.
628    ///
629    /// Two duplicate rules, and they are deliberately different:
630    ///
631    /// - **A consecutive repeat collapses.** `yy` pressed twice, or a
632    ///   re-yank of an unchanged line, otherwise produces two identical
633    ///   rows the picker cannot help you tell apart. The existing front
634    ///   entry is left in place rather than removed and re-pushed, so
635    ///   the ring does not churn on a held key.
636    /// - **A non-consecutive repeat is promoted.** Re-yanking something
637    ///   from an hour ago is a real event, and moving it to the top is
638    ///   the useful answer — it is what you are about to paste. Adding a
639    ///   second row for it would not be.
640    ///
641    /// Eviction is oldest-first, which is what lets YR.2's `"0`–`"9`
642    /// projection be sound: the numbered registers read the newest
643    /// entries, so dropping from the back can never change what `"9`
644    /// means.
645    ///
646    /// `capacity` is passed in rather than held on the ring because it
647    /// is a live option (`yank.ring.size`) — reading it at push time is
648    /// what makes lowering it take effect on the next yank instead of at
649    /// the next restart. A capacity of 0 disables the ring.
650    pub fn push(&mut self, entry: UnnamedRegister, yanked: bool, capacity: usize) {
651        if capacity == 0 {
652            self.entries.clear();
653            return;
654        }
655        match self.entries.front() {
656            // Consecutive duplicate: already at the top, nothing to do.
657            Some(front)
658                if front.register.content == entry.content && front.register.kind == entry.kind =>
659            {
660                return;
661            }
662            _ => {}
663        }
664        // Non-consecutive repeat: promote rather than duplicate.
665        //
666        // YR.2: the promoted slot takes the NEW provenance. Deleting
667        // something you yanked an hour ago makes it the newest delete,
668        // and `"1` should find it — keeping the stale `yanked` flag
669        // would leave it addressable only as `"0`.
670        if let Some(pos) = self
671            .entries
672            .iter()
673            .position(|e| e.register.content == entry.content && e.register.kind == entry.kind)
674        {
675            self.entries.remove(pos);
676        }
677        self.entries.push_front(RingEntry {
678            register: entry,
679            yanked,
680        });
681        while self.entries.len() > capacity {
682            self.entries.pop_back();
683        }
684    }
685}
686
687#[cfg(test)]
688mod yr2_projection_tests {
689    #![allow(clippy::unwrap_used)]
690    use super::*;
691
692    fn reg(s: &str) -> UnnamedRegister {
693        UnnamedRegister {
694            content: s.to_string(),
695            kind: YankKind::Charwise,
696        }
697    }
698
699    /// `"0` is the newest YANK, not the newest entry. An intervening
700    /// delete must not shadow the last thing you deliberately copied —
701    /// that is the whole reason vim keeps `"0` distinct from `""`.
702    #[test]
703    fn register_zero_skips_deletes() {
704        let mut ring = YankRing::new();
705        ring.push(reg("yanked"), true, 10);
706        ring.push(reg("deleted"), false, 10);
707
708        assert_eq!(ring.nth(0).unwrap().content, "deleted", "newest overall");
709        assert_eq!(
710            ring.newest_yank().unwrap().content,
711            "yanked",
712            "`\"0` must survive an intervening delete"
713        );
714    }
715
716    /// `"1`–`"9` count deletes only. Yanks in between are skipped rather
717    /// than counted, so `"1` is always "the last thing I deleted".
718    #[test]
719    fn numbered_registers_count_deletes_only() {
720        let mut ring = YankRing::new();
721        ring.push(reg("d3"), false, 10);
722        ring.push(reg("y"), true, 10);
723        ring.push(reg("d2"), false, 10);
724        ring.push(reg("d1"), false, 10);
725
726        assert_eq!(ring.nth_delete(0).unwrap().content, "d1", "\"1");
727        assert_eq!(ring.nth_delete(1).unwrap().content, "d2", "\"2");
728        assert_eq!(
729            ring.nth_delete(2).unwrap().content,
730            "d3",
731            "\"3 — the yank between d2 and d3 is skipped, not counted"
732        );
733        assert!(ring.nth_delete(3).is_none());
734    }
735
736    /// An empty ring projects to nothing rather than to a wrong answer.
737    #[test]
738    fn an_empty_ring_projects_to_none() {
739        let ring = YankRing::new();
740        assert!(ring.newest_yank().is_none());
741        assert!(ring.nth_delete(0).is_none());
742    }
743
744    /// A promoted repeat takes the NEW provenance. Deleting something you
745    /// yanked earlier makes it the newest delete, and `"1` must find it;
746    /// keeping the stale flag would leave it addressable only as `"0`.
747    #[test]
748    fn a_promoted_entry_takes_its_new_provenance() {
749        let mut ring = YankRing::new();
750        ring.push(reg("shared"), true, 10);
751        ring.push(reg("other"), false, 10);
752        // Same content, now arriving as a delete → promoted to front.
753        ring.push(reg("shared"), false, 10);
754
755        assert_eq!(ring.nth_delete(0).unwrap().content, "shared");
756        assert!(
757            ring.newest_yank().is_none(),
758            "the only yank was re-classified when it was promoted"
759        );
760    }
761
762    /// The picker sees everything; only the numbered projection filters.
763    #[test]
764    fn iteration_is_unfiltered() {
765        let mut ring = YankRing::new();
766        ring.push(reg("y"), true, 10);
767        ring.push(reg("d"), false, 10);
768        let all: Vec<_> = ring.iter().map(|r| r.content.as_str()).collect();
769        assert_eq!(all, vec!["d", "y"]);
770    }
771}