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