1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
//! Clipboard abstraction with vim-style named registers.
//!
//! - The default (unnamed) register doubles as the system clipboard via
//! `arboard`, and also auto-mirrors into `"0` on every yank (vim convention:
//! `"0` is "last yank"). The "last yank" mirror happens only when the op
//! was a yank — `EditOp::YankLine`/`YankSelection`/`YankBlock` flag that
//! via `set_last_yank` rather than via `set()`. Other ops (delete, cut)
//! still write to the unnamed register but skip `"0`.
//! - `"a`-`"z` named registers (lowercase only — vim's uppercase-append form
//! is a follow-up); writes go via `pending_register`, set by the input
//! handler before the op runs.
//! - `"+` mirrors the system clipboard (same as the default — explicit form).
//! - `"_` blackhole — `set` is a no-op; `text` returns `""`.
//! - Any non-recognized `pending_register` char ⇒ unnamed register (safe
//! fallback so a stray `"X` doesn't surprise the user).
use std::collections::HashMap;
pub struct Clipboard {
register: String,
register_linewise: bool,
/// Linewise-ness of whatever `text()` last returned (kept in sync so
/// `is_linewise()` is meaningful right after a `text()` call).
effective_linewise: bool,
/// Lazily-created system clipboard handle. `None` ⇒ unavailable; we just use the register.
sys: Option<arboard::Clipboard>,
/// Vim named registers — `a`-`z` (lowercase). Each entry is
/// `(text, linewise)`. `'0'` is also stored here on each yank.
named: HashMap<char, (String, bool)>,
/// Register hint for the *next* clipboard op (set / text). Set by
/// `EditOp::SetRegisterHint` which the vim handler emits before
/// yank/paste/delete. Consumed (reset) on the first set/text call.
pending_register: Option<char>,
/// True once the unnamed register has been written by an in-mnml
/// op (yank / delete / set / push_delete). Before that, `p` may
/// fall back to the OS clipboard (cold-start paste-from-browser).
/// After that, mnml's register is authoritative — an explicit `Y`
/// on an empty line yields empty, not stale browser text. R10
/// nvchad-user SEV-3 (2026-08-22).
register_owned: bool,
/// Exactly what mnml last pushed to the OS clipboard, so a later
/// read can distinguish "nobody has touched it since" from "another
/// app copied something". `None` means we never pushed, or the push
/// failed — in which case we cannot detect an external change.
last_pushed_to_os: Option<String>,
}
impl Default for Clipboard {
fn default() -> Self {
Self::new()
}
}
impl Clipboard {
pub fn new() -> Self {
Clipboard {
register: String::new(),
register_linewise: false,
effective_linewise: false,
sys: arboard::Clipboard::new().ok(),
named: HashMap::new(),
pending_register: None,
register_owned: false,
last_pushed_to_os: None,
}
}
/// A register-only clipboard with no OS bridge — used in tests so they don't
/// touch (or depend on) the real system clipboard.
pub fn detached() -> Self {
Clipboard {
register: String::new(),
register_linewise: false,
effective_linewise: false,
sys: None,
named: HashMap::new(),
pending_register: None,
register_owned: false,
last_pushed_to_os: None,
}
}
/// Vim `"<reg>` prefix — the next set/text call routes through this
/// register. Consumed on the next op. `None` clears any prior hint.
pub fn set_pending_register(&mut self, reg: Option<char>) {
self.pending_register = reg;
}
/// Delete-flavored set: writes to the unnamed register (and system
/// clipboard) AND pushes onto vim's `"1`-`"9` delete-history ring
/// (most-recent-first; `"1` is shifted to `"2`, etc., dropping the
/// oldest beyond `"9`). When a named register is pending, the delete
/// only goes to that register and the history is unchanged (vim
/// convention — explicit named-register deletes don't pollute "1-"9).
pub fn push_delete(&mut self, text: impl Into<String>, linewise: bool) {
let text: String = text.into();
let reg = self.pending_register;
// Set goes through the normal pipeline (honors pending_register).
self.set(text.clone(), linewise);
if matches!(reg, None | Some('+')) {
// Shift "1..="8 → "2..="9, drop "9, write text → "1.
for i in (1..=8).rev() {
let from = char::from_digit(i as u32, 10).unwrap();
let to = char::from_digit((i + 1) as u32, 10).unwrap();
if let Some(v) = self.named.remove(&from) {
self.named.insert(to, v);
}
}
self.named.insert('1', (text, linewise));
}
}
/// Set the clipboard. Writes the register *and* (best-effort) the OS
/// clipboard. Honors `pending_register` if set:
/// - `'_'` ⇒ blackhole (no-op, but resets pending)
/// - `'+'` ⇒ system clipboard (same as unnamed)
/// - `'a'..='z'` ⇒ named register; system clipboard *not* touched
/// - other ⇒ unnamed (safe fallback)
pub fn set(&mut self, text: impl Into<String>, linewise: bool) {
let text: String = text.into();
let reg = self.pending_register.take();
match reg {
Some('_') => { /* blackhole — drop */ }
Some(c) if c.is_ascii_alphabetic() && c.is_ascii_lowercase() => {
self.named.insert(c, (text, linewise));
}
// nvchad-user SEV-2 2026-07-11: uppercase register letter
// `"A`..`"Z` = same slot as `"a`..`"z` but APPEND on write
// instead of overwrite. Vim canonical for accumulating a
// chain of yanks under one register handle. Linewise flag
// follows the LAST written yank (vim convention).
Some(c) if c.is_ascii_alphabetic() && c.is_ascii_uppercase() => {
let slot = c.to_ascii_lowercase();
let existing = self.named.remove(&slot);
let merged = match existing {
Some((prev, _)) => format!("{prev}{text}"),
None => text,
};
self.named.insert(slot, (merged, linewise));
}
Some('0') => {
self.named.insert('0', (text, linewise));
}
// '+' and None ⇒ unnamed + system clipboard
_ => {
self.register = text;
self.register_linewise = linewise;
self.effective_linewise = linewise;
self.register_owned = true;
let pushed = self.register.clone();
self.last_pushed_to_os = match self.sys.as_mut() {
Some(sys) => sys.set_text(pushed.clone()).ok().map(|()| pushed),
None => None,
};
}
}
}
/// Yank-flavored set: writes the same way `set` does AND mirrors into
/// `"0` (vim's "last yank" register) when the op went to the unnamed
/// register. Called by the editor's yank ops.
pub fn set_yank(&mut self, text: impl Into<String>, linewise: bool) {
let text: String = text.into();
let reg = self.pending_register;
self.set(text.clone(), linewise);
// Mirror into "0 only when the explicit register wasn't named —
// i.e., when the yank went to the unnamed register.
if matches!(reg, None | Some('+')) {
self.named.insert('0', (text, linewise));
}
}
/// Current clipboard text. Prefers the OS clipboard when it differs from
/// our register (something else copied); that case is treated as charwise.
/// Honors `pending_register` if set.
pub fn text(&mut self) -> String {
let reg = self.pending_register.take();
match reg {
Some('_') => {
self.effective_linewise = false;
String::new()
}
// `"a`-`"z` AND `"A`-`"Z` — both address the same slot for
// reads (uppercase-append only affects writes). Normalize
// to lowercase before the map lookup.
Some(c) if c.is_ascii_alphabetic() => {
let slot = c.to_ascii_lowercase();
if let Some((t, linewise)) = self.named.get(&slot) {
self.effective_linewise = *linewise;
return t.clone();
}
self.effective_linewise = false;
String::new()
}
Some('0') => {
if let Some((t, linewise)) = self.named.get(&'0') {
self.effective_linewise = *linewise;
return t.clone();
}
self.effective_linewise = false;
String::new()
}
Some(c) if c.is_ascii_digit() && c != '0' => {
// "1-"9 — delete history.
if let Some((t, linewise)) = self.named.get(&c) {
self.effective_linewise = *linewise;
return t.clone();
}
self.effective_linewise = false;
String::new()
}
// '+' — explicit system-clipboard read. Always defers
// to the OS, ignoring the in-mnml register.
Some('+') => {
if let Some(sys) = self.sys.as_mut()
&& let Ok(t) = sys.get_text()
{
self.effective_linewise = false;
return t;
}
self.effective_linewise = false;
String::new()
}
// None ⇒ unnamed. Two paths:
// (a) `register_owned == false` — no in-mnml op has
// written the register yet. Cold-start paste-
// from-browser: fall back to the OS clipboard.
// (b) `register_owned == true` — mnml owns the
// register. Return it as-is. R10 nvchad-user
// SEV-3 (2026-08-22) — was: "OS clipboard wins
// when it differs", which silently pasted stale
// browser text after an explicit `Y` on an
// empty line cleared the register to "".
_ => {
let os_text = self.sys.as_mut().and_then(|s| s.get_text().ok());
if let Some(t) = os_text
&& Self::os_clipboard_wins(
self.register_owned,
self.last_pushed_to_os.as_deref(),
&t,
)
{
self.effective_linewise = false;
return t;
}
self.effective_linewise = self.register_linewise;
self.register.clone()
}
}
}
/// Does the OS clipboard win over mnml's unnamed register?
///
/// Latching on "mnml has written the register" alone is not enough:
/// it makes the register authoritative for the rest of the session,
/// so copying in another app never reaches mnml again. Comparing the
/// OS text against what we ourselves last pushed distinguishes the
/// two cases the latch conflated.
fn os_clipboard_wins(register_owned: bool, last_pushed: Option<&str>, os_text: &str) -> bool {
match (register_owned, last_pushed) {
// Cold start — no in-mnml op has written the register, so a
// paste should pick up whatever the user copied elsewhere.
(false, _) => true,
// We pushed, and the OS still holds exactly that: nothing
// external happened, so our register is authoritative. This
// is the R10 case — `Y` on an empty line yields empty, not
// stale browser text.
(true, Some(ours)) => ours != os_text,
// We own the register but could not push (no OS bridge, or
// the push failed), so we cannot detect an external change.
// Trust mnml — the user just yanked here.
(true, None) => false,
}
}
/// Linewise-ness of the most recent `text()` (or `set()`).
pub fn is_linewise(&self) -> bool {
self.effective_linewise
}
/// Read-only snapshot of the named registers (`a`-`z`, `0`). Used by
/// `:reg` / `:registers` for the display dump.
pub fn named_registers(&self) -> &HashMap<char, (String, bool)> {
&self.named
}
}
#[cfg(test)]
mod tests {
use super::*;
// These exercise the decision in isolation, so nothing here touches
// (or depends on) the real system clipboard.
/// Cold start: no in-mnml op has written the register yet, so a
/// paste picks up whatever the user copied in another app.
#[test]
fn os_wins_before_mnml_has_written_the_register() {
assert!(Clipboard::os_clipboard_wins(false, None, "from browser"));
assert!(Clipboard::os_clipboard_wins(
false,
Some("stale"),
"from browser"
));
}
/// The R10 case that motivated the original latch: `Y` on an empty
/// line sets the register to "", we push "", and the OS still holds
/// "". Our register must win, or the user pastes stale browser text.
#[test]
fn register_wins_when_os_still_holds_what_we_pushed() {
assert!(!Clipboard::os_clipboard_wins(true, Some(""), ""));
assert!(!Clipboard::os_clipboard_wins(
true,
Some("yanked"),
"yanked"
));
}
/// The regression the latch introduced: once mnml owned the
/// register, copying in another app was ignored for the rest of the
/// session. A differing OS clipboard means someone else wrote it.
#[test]
fn os_wins_after_an_external_copy() {
assert!(Clipboard::os_clipboard_wins(
true,
Some("yanked in mnml"),
"copied in browser"
));
}
/// No OS bridge, or the push failed — we cannot detect an external
/// change, so trust the register the user just yanked into.
#[test]
fn register_wins_when_we_could_not_push() {
assert!(!Clipboard::os_clipboard_wins(true, None, "anything"));
}
/// End-to-end on a detached clipboard: with no OS bridge, set/text
/// round-trips the register and never consults the OS.
#[test]
fn detached_clipboard_round_trips_the_register() {
let mut c = Clipboard::detached();
c.set("hello".to_string(), false);
assert_eq!(c.text(), "hello");
c.set(String::new(), false);
assert_eq!(c.text(), "", "an explicit empty yank must yield empty");
}
}