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}