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
//! 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>,
}
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,
}
}
/// 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,
}
}
/// 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;
if let Some(sys) = self.sys.as_mut() {
let _ = sys.set_text(self.register.clone());
}
}
}
}
/// 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()
}
// '+' and None ⇒ system / unnamed
_ => {
if let Some(sys) = self.sys.as_mut()
&& let Ok(t) = sys.get_text()
&& t != self.register
{
self.effective_linewise = false;
return t;
}
self.effective_linewise = self.register_linewise;
self.register.clone()
}
}
}
/// 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
}
}