Skip to main content

strop_engine/editor/
registers.rs

1//! Registers and paste (vim's " machinery) plus the system clipboard
2//! (`+` via OSC52/wl-paste, helix's playbook). Reads run on workers;
3//! results land on the drain — never a subprocess on the input path.
4//!
5//! Register CONTENT is a typed domain (R13): [`Register`] carries its
6//! text plus a [`RegisterShape`] — the `(String, bool)` tuple could
7//! not say blockwise, and a block needs its rectangle width in
8//! display cells to land straight under configured tabs and wide
9//! clusters. No bare `bool` crosses the register or paste boundary.
10
11use strop_core::id::DisplayColumn;
12use strop_core::layout::LineLayout;
13use strop_core::worker::{self, Completion, FailureKind, Outcome, Ticket};
14
15use super::trace;
16use super::Editor;
17
18/// The document that asked for a clipboard read (R9): the terminal
19/// result owns this ticket — a paste lands only in the buffer that
20/// requested it, and failures surface instead of collapsing into
21/// "empty clipboard".
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub struct ClipboardKey {
24    pub document: strop_core::id::DocumentId,
25}
26
27/// The owned terminal clipboard-read result.
28pub type ClipboardResult = Completion<ClipboardKey, String>;
29
30/// How register text sits in the document when it lands — vim's
31/// three register shapes. Blockwise carries the rectangle's cell
32/// width, measured with the configured tab size at yank time, so
33/// paste replays the same rectangle on any tab/wide-char mix.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum RegisterShape {
36    Characterwise,
37    Linewise,
38    /// Rows joined by `\n` in [`Register::text`]; `width` is the
39    /// rectangle's width in display cells.
40    Blockwise {
41        width: DisplayColumn,
42    },
43}
44
45/// One register cell: text + shape (vim's unnamed register is `"`).
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Register {
48    pub text: String,
49    pub shape: RegisterShape,
50}
51
52impl Register {
53    pub fn characterwise(text: impl Into<String>) -> Self {
54        Self {
55            text: text.into(),
56            shape: RegisterShape::Characterwise,
57        }
58    }
59
60    pub fn linewise(text: impl Into<String>) -> Self {
61        Self {
62            text: text.into(),
63            shape: RegisterShape::Linewise,
64        }
65    }
66
67    /// `width` is the rectangle's width in display cells (the same
68    /// streamed layout that measured it at yank).
69    pub fn blockwise(text: impl Into<String>, width: DisplayColumn) -> Self {
70        Self {
71            text: text.into(),
72            shape: RegisterShape::Blockwise { width },
73        }
74    }
75
76    pub fn is_empty(&self) -> bool {
77        self.text.is_empty()
78    }
79}
80
81impl Editor {
82    pub fn register(&self, name: Option<char>) -> &Register {
83        static EMPTY: Register = Register {
84            text: String::new(),
85            shape: RegisterShape::Characterwise,
86        };
87        self.registers.get(&name.unwrap_or('"')).unwrap_or(&EMPTY)
88    }
89
90    pub(crate) fn set_register(&mut self, name: Option<char>, register: Register) {
91        // the `+` register is the system clipboard: yank/delete into it
92        // stages an OSC52 payload for the TUI to emit
93        if name == Some('+') {
94            self.osc52 = Some(register.text.clone());
95        }
96        self.registers.insert(name.unwrap_or('"'), register);
97    }
98
99    /// `p`/`P` (vim `2p`): the register lands per its shape —
100    /// charwise/linewise text repeats, a block repeats its rectangle
101    /// horizontally; always one undo unit.
102    pub(crate) fn paste(&mut self, name: Option<char>, count: usize, before: bool) {
103        if self.buf().readonly {
104            self.message = "readonly buffer".into();
105            return;
106        }
107        // `"+p`: the system clipboard is read by a provider job — never
108        // a subprocess on the input path (0001 §3)
109        if name == Some('+') {
110            self.clipboard_paste(before);
111            return;
112        }
113        let register = self.register(name).clone();
114        if register.is_empty() {
115            return;
116        }
117        self.paste_register(&register, count, before);
118    }
119
120    /// Paste one register's content without storing it (bracketed
121    /// paste and clipboard results never touch the register file —
122    /// vim's rule).
123    fn paste_register(&mut self, register: &Register, count: usize, before: bool) {
124        match register.shape {
125            RegisterShape::Blockwise { width } => {
126                self.paste_blockwise(register, width, count, before)
127            }
128            shape @ (RegisterShape::Characterwise | RegisterShape::Linewise) => {
129                self.paste_text(register.text.repeat(count), shape, before)
130            }
131        }
132    }
133
134    /// `Space p` / `"+p`: spawn a clipboard read; the result lands in
135    /// drain_clipboard on a later tick.
136    pub(crate) fn clipboard_paste(&mut self, before: bool) {
137        if self.buf().readonly {
138            self.message = "readonly buffer".into();
139            return;
140        }
141        if self.clip_paste_pending.is_some() {
142            return; // one read in flight
143        }
144        let request = match self.worker_ids.allocate() {
145            Ok(request) => request,
146            Err(error) => {
147                self.message = error.message;
148                return;
149            }
150        };
151        let ticket = Ticket {
152            request,
153            key: ClipboardKey {
154                document: self.current(),
155            },
156        };
157        self.clip_paste_pending = Some((before, ticket.clone()));
158        strop_trace::record_with(strop_trace::EventKind::JobStarted, || {
159            serde_json::json!({"service":"clipboard","request":request.get(),
160                "document":{"slot":self.current().index(),"generation":self.current().generation()}})
161        });
162        match self.tape.request("clipboard.read", &ticket) {
163            Ok(false) => return,
164            Ok(true) => {}
165            Err(error) => {
166                self.handle_clipboard(Completion {
167                    ticket,
168                    outcome: Outcome::failed(FailureKind::Protocol, error.to_string()),
169                });
170                return;
171            }
172        }
173        let tx = self.clip_tx.clone();
174        let handle = worker::spawn(
175            "strop-clipboard",
176            move |outcome| {
177                let _ = tx.send(Completion { ticket, outcome });
178            },
179            |_| read_system_clipboard(),
180        );
181        self.worker_handles.insert(request, handle);
182    }
183
184    /// One owned clipboard-read result.
185    pub(crate) fn handle_clipboard(&mut self, result: ClipboardResult) {
186        trace::services::clipboard(&result);
187        let Some((before, ticket)) = self.clip_paste_pending.as_ref() else {
188            return;
189        };
190        if *ticket != result.ticket {
191            trace::services::rejected("clipboard", "clipboard request superseded");
192            return;
193        }
194        let before = *before;
195        self.clip_paste_pending = None;
196        self.worker_handles.remove(&result.ticket.request);
197        // the read answers the document that asked (0023 §4): a switch
198        // mid-read must not paste into the newly active buffer
199        if self.docs.is_empty() || self.current() != result.ticket.key.document {
200            self.message = "clipboard: destination changed — paste dropped".into();
201            trace::services::rejected("clipboard", &self.message);
202            return;
203        }
204        match result.outcome {
205            // a trailing newline is vim's linewise paste convention;
206            // Success("") is a real empty clipboard, not a failure
207            Outcome::Success(text) if !text.is_empty() => {
208                let register = if text.ends_with('\n') {
209                    Register::linewise(text)
210                } else {
211                    Register::characterwise(text)
212                };
213                self.paste_register(&register, 1, before);
214            }
215            Outcome::Success(_) => self.message = "clipboard: empty".into(),
216            Outcome::Failed { failure, .. } => {
217                self.message = format!("clipboard: {}", failure.message)
218            }
219            // the owning paste left (cancelled/superseded): nothing to do
220            Outcome::Cancelled(_) => {}
221        }
222    }
223
224    /// Insertion point, landing byte and exact bytes for one cursor's
225    /// charwise/linewise paste. A linewise paste below a final row
226    /// without its separator grows one (vim's `p` at EOF); the cursor
227    /// lands on the first non-blank of the first pasted row.
228    fn paste_points(&self, cursor: usize, text: &str, shape: RegisterShape, before: bool) -> Spot {
229        match shape {
230            RegisterShape::Linewise => {
231                let line = self.buf().line_of(cursor);
232                let (at, prefix) = if before {
233                    (self.buf().line_start(line), 0usize)
234                } else if line + 1 < self.buf().len_lines() {
235                    (self.buf().line_start(line + 1), 0usize)
236                } else {
237                    // below the last row: the buffer gains the break it
238                    // lacks, then the pasted rows (vim appends the
239                    // newline at EOF; an empty buffer keeps vim's
240                    // leading empty row)
241                    let ends_break = self.buf().len_bytes() > 0
242                        && self.buf().byte(self.buf().len_bytes() - 1) == b'\n';
243                    let prefix = if ends_break {
244                        0
245                    } else {
246                        self.newline_str().len()
247                    };
248                    (self.buf().len_bytes(), prefix)
249                };
250                // vim: first non-blank of the first pasted row
251                let row = &text[prefix.min(text.len())..];
252                let blank = row
253                    .chars()
254                    .take_while(|c| *c == ' ' || *c == '\t')
255                    .map(char::len_utf8)
256                    .sum::<usize>()
257                    .min(row.len());
258                Spot {
259                    at,
260                    land: at + prefix + blank,
261                    text: if prefix == 0 {
262                        text.to_string()
263                    } else {
264                        format!("{}{}", self.newline_str(), text)
265                    },
266                }
267            }
268            RegisterShape::Characterwise => {
269                let at = if before {
270                    cursor
271                } else {
272                    // 0020 §10: after the cursor means after the CHAR — a
273                    // byte step from a multibyte lead inserted before it
274                    self.buf()
275                        .ceil_boundary(cursor + 1)
276                        .min(self.buf().len_bytes())
277                };
278                // vim: the cursor lands on the LAST pasted char, both p
279                // and P — the whole char, never a mid-cluster byte
280                let last = text.chars().next_back().map_or(0, char::len_utf8);
281                Spot {
282                    at,
283                    land: at + text.len().saturating_sub(last),
284                    text: text.to_string(),
285                }
286            }
287            RegisterShape::Blockwise { .. } => unreachable!("blockwise pastes its own way"),
288        }
289    }
290
291    fn paste_text(&mut self, text: String, shape: RegisterShape, before: bool) {
292        // nvim rule: every command is one undo unit — a lone paste must
293        // commit its own revision (it used to ride the *next* command's)
294        self.tx_begin();
295        let cursors = self.all_cursors();
296        if cursors.len() == 1 {
297            let spot = self.paste_points(self.head(), &text, shape, before);
298            self.buf_mut().insert(spot.at, &spot.text);
299            self.set_head(spot.land);
300            self.clamp_cursor();
301            self.tx_commit();
302            return;
303        }
304        // multicursor paste (0013 §3): the same register at every
305        // cursor, bottom-up so insertion points stay valid mid-batch
306        // (a spot below a final row without its break carries the
307        // break it grew — bytes can differ per cursor)
308        let primary = self.head();
309        let mut jobs: Vec<(usize, usize, bool, String)> = cursors
310            .into_iter()
311            .map(|c| {
312                let spot = self.paste_points(c, &text, shape, before);
313                (spot.at, spot.land, c == primary, spot.text)
314            })
315            .collect();
316        jobs.sort_by_key(|j| j.0);
317        jobs.dedup_by_key(|j| j.0); // stacked cursors paste once
318                                    // each landing shifts by what lower insertions already added
319        let mut shift = 0usize;
320        for j in &mut jobs {
321            j.1 += shift;
322            shift += j.3.len();
323        }
324        for (at, _, _, insert) in jobs.iter().rev() {
325            self.buf_mut().insert(*at, insert);
326        }
327        self.sels_mut()
328            .set_extras(jobs.iter().filter(|j| !j.2).map(|j| j.1));
329        self.set_head(jobs.iter().find(|j| j.2).map(|j| j.1).unwrap_or(primary));
330        self.normalize_cursors();
331        self.clamp_cursor();
332        self.tx_commit();
333    }
334
335    /// Blockwise put (vim's counted block paste): every register row
336    /// lands at one DISPLAY column on consecutive rows — the same
337    /// streamed layout with the configured tab stops measures the
338    /// column on every row, so tabs and wide clusters cannot skew it.
339    /// Short rows grow spaces to the column; rows past the end create
340    /// terminated rows; `count` repeats the rectangle horizontally;
341    /// the whole put is one undo unit and the cursor lands on the
342    /// first pasted cluster.
343    fn paste_blockwise(
344        &mut self,
345        register: &Register,
346        width: DisplayColumn,
347        count: usize,
348        before: bool,
349    ) {
350        let tab = self.config.tab_size.max(1);
351        // rectangles are primary-only (vim has no multicursor block):
352        // extras collapse, the head anchors the column
353        self.sels_mut().collapse_extras();
354        let cursor = self.head();
355        let line = self.buf().line_of(cursor);
356        let head_row = self.buf().line_text(line);
357        let layout = LineLayout::build(&head_row, tab);
358        let col = self.buf().col_of(cursor);
359        let column = if before || head_row.is_empty() {
360            // `P` lands at the cursor's cell; `p` after an empty row's
361            // cursor lands at cell 0 — there is no cluster to be after
362            layout.cell_at_byte(col)
363        } else {
364            // after the cursor means after its whole cluster (0020 §10)
365            let cluster = layout.spans().iter().rev().find(|s| s.byte <= col);
366            layout.cell_at_byte(col) + cluster.map_or(0, |s| s.width)
367        };
368        let rows: Vec<&str> = register.text.split('\n').collect();
369        let sep = self.newline_str();
370        let len = self.buf().len_bytes();
371        let ends_break = len > 0 && self.buf().byte(len - 1) == b'\n';
372        // rows at or past the buffer's phantom final row merge into ONE
373        // tail edit at the end: a terminated buffer's phantom row keeps
374        // the break that already precedes it, later rows grow their own
375        let tail_from = if ends_break {
376            self.buf().len_lines().saturating_sub(1)
377        } else {
378            self.buf().len_lines()
379        };
380        let mut edits: Vec<(usize, String)> = Vec::with_capacity(rows.len() + 1);
381        let mut tail = String::new();
382        let mut row0_land = cursor;
383        for (i, row) in rows.iter().enumerate() {
384            let target = line + i;
385            if target < tail_from {
386                let start = self.buf().line_start(target);
387                let end = self.buf().line_end(target);
388                let text = self.buf().line_text(target);
389                let row_layout = LineLayout::build(&text, tab);
390                // mid-row inserts right-pad the row to the rectangle so
391                // following text cannot creep into the block (vim keeps
392                // the block rectangular); at the row's end nothing
393                // follows and no pad grows
394                let mid_row = row_layout.width.get() > column.get();
395                let content = block_row(row, width, count, mid_row, tab);
396                let (at, content) = if row_layout.width.get() < column.get() {
397                    // short row: spaces grow the row out to the column
398                    let pad = " ".repeat(column.get() - row_layout.width.get());
399                    (end, format!("{pad}{content}"))
400                } else {
401                    // a cell inside a wide cluster or a tab lands the row
402                    // at that cluster's start — clusters never split
403                    (start + row_layout.byte_at_cell(column), content)
404                };
405                if i == 0 {
406                    row0_land = at;
407                }
408                edits.push((at, content));
409            } else {
410                if !(tail.is_empty() && ends_break && target == tail_from) {
411                    // the phantom row keeps its existing break; every
412                    // created row grows one
413                    tail.push_str(sep);
414                }
415                if column.get() > 0 {
416                    tail.push_str(&" ".repeat(column.get()));
417                }
418                tail.push_str(&block_row(row, width, count, false, tab));
419                if i == 0 {
420                    row0_land = len
421                        + if ends_break && target == tail_from {
422                            0
423                        } else {
424                            sep.len()
425                        };
426                }
427            }
428        }
429        if !tail.is_empty() {
430            // created rows are terminated rows (vim's line model — the
431            // buffer gains the final break it lacks)
432            tail.push_str(sep);
433            edits.push((len, tail));
434        }
435        self.tx_begin();
436        for (at, text) in edits.iter().rev() {
437            self.buf_mut().insert(*at, text);
438        }
439        self.set_head(row0_land);
440        self.clamp_cursor();
441        self.tx_commit();
442    }
443}
444
445/// One cursor's paste: insertion byte, landing byte, and the exact
446/// bytes that land (a paste below a final row without its break grows
447/// one first — vim's `p` at EOF).
448struct Spot {
449    at: usize,
450    land: usize,
451    text: String,
452}
453
454/// One pasted block row's bytes. Mid-row puts right-pad the row to the
455/// rectangle before repeating it `count` times (vim's counted block
456/// put repeats the PADDED row horizontally); puts at a row's end or on
457/// created rows repeat the bare row.
458fn block_row(row: &str, width: DisplayColumn, count: usize, mid_row: bool, tab: usize) -> String {
459    let mut unit = row.to_string();
460    if mid_row {
461        let cells = LineLayout::build(row, tab).width.get();
462        let pad = width.get().saturating_sub(cells);
463        unit.push_str(&" ".repeat(pad));
464    }
465    unit.repeat(count)
466}
467
468/// Read the system clipboard via the first working provider (helix's
469/// playbook: wl-paste, xclip, xsel, pbpaste). Runs on a worker; every
470/// failure is terminal and typed — "not installed" tries the next
471/// provider, anything else (spawn error, nonzero exit, non-UTF-8
472/// data) reports itself instead of masquerading as an empty
473/// clipboard.
474fn read_system_clipboard() -> Outcome<String> {
475    let providers: [(&str, &[&str]); 4] = [
476        ("wl-paste", &[]),
477        ("xclip", &["-selection", "clipboard", "-o"]),
478        ("xsel", &["--clipboard", "--output"]),
479        ("pbpaste", &[]),
480    ];
481    let mut failed = Vec::new();
482    for (cmd, args) in providers {
483        let output = match std::process::Command::new(cmd)
484            .args(args)
485            .stdin(std::process::Stdio::null())
486            .stdout(std::process::Stdio::piped())
487            .stderr(std::process::Stdio::null())
488            .output()
489        {
490            Ok(output) => output,
491            // not installed: the next provider gets its chance
492            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
493            Err(error) => {
494                return Outcome::failed(FailureKind::Spawn, format!("{cmd}: {error}"));
495            }
496        };
497        if !output.status.success() {
498            failed.push(format!("{cmd}: exited {}", output.status));
499            continue;
500        }
501        return match String::from_utf8(output.stdout) {
502            Ok(text) => Outcome::Success(text),
503            Err(error) => Outcome::failed(
504                FailureKind::Io,
505                format!("{cmd}: clipboard is not UTF-8: {error}"),
506            ),
507        };
508    }
509    let mut message = String::from("no clipboard provider succeeded");
510    if !failed.is_empty() {
511        message.push_str(&format!(" ({})", failed.join("; ")));
512    }
513    Outcome::failed(FailureKind::Unavailable, message)
514}
515
516impl Editor {
517    /// Table shims (0008 stage 2): `Space y` arms the `+` register for
518    /// the next yank; `Space p/P` paste from the system clipboard.
519    pub(crate) fn clipboard_yank_pub(&mut self) {
520        self.walker
521            .begin_operator(strop_grammar::Op::Yank, Some('+'), None);
522    }
523    pub(crate) fn clipboard_paste_pub(&mut self, before: bool) {
524        self.clipboard_paste(before);
525    }
526    /// Bracketed paste (0017): one undo unit, no key interpretation —
527    /// the payload is text, not keystrokes. A live prompt consumes it
528    /// through the pending reducer; an open picker pastes into its
529    /// focused field; in normal mode it behaves like p; a trailing
530    /// newline pastes linewise (vim's paste plugin convention).
531    pub fn paste_bracketed(&mut self, text: &str) {
532        if text.is_empty() {
533            return;
534        }
535        if self.pending.is_active() {
536            self.feed_pending_event(super::pending::PendingEvent::Paste(text.to_owned()));
537            return;
538        }
539        if self.picker_open() {
540            self.paste_picker(text);
541            return;
542        }
543        if self.mode == super::Mode::Insert {
544            let pos = self.head();
545            self.tx_begin();
546            self.buf_mut().insert(pos, text);
547            self.tx_commit();
548            self.set_head(pos + text.len());
549            self.clamp_cursor();
550        } else {
551            let register = if text.ends_with('\n') {
552                Register::linewise(text)
553            } else {
554                Register::characterwise(text)
555            };
556            self.paste_register(&register, 1, false);
557        }
558    }
559}