1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23pub struct ClipboardKey {
24 pub document: strop_core::id::DocumentId,
25}
26
27pub type ClipboardResult = Completion<ClipboardKey, String>;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum RegisterShape {
36 Characterwise,
37 Linewise,
38 Blockwise {
41 width: DisplayColumn,
42 },
43}
44
45#[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 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 if name == Some('+') {
99 self.osc52 = Some(register.text.clone());
100 }
101 self.registers.insert(name.unwrap_or('"'), register);
102 }
103
104 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 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(®ister, count, before);
123 }
124
125 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 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; }
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 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 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 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(®ister, 1, before);
227 }
228 Outcome::Success(_) => self.message = "clipboard: empty".into(),
229 Outcome::Failed { failure, .. } => {
230 self.message = format!("clipboard: {}", failure.message)
231 }
232 Outcome::Cancelled(_) => {}
234 }
235 }
236
237 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 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 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 self.buf()
288 .ceil_boundary(cursor + 1)
289 .min(self.buf().len_bytes())
290 };
291 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 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 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); 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 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 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 layout.cell_at_byte(col)
399 } else {
400 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 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 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 let pad = " ".repeat(column.get() - row_layout.width.get());
435 (end, format!("{pad}{content}"))
436 } else {
437 (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 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 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
481struct Spot {
485 at: usize,
486 land: usize,
487 text: String,
488}
489
490fn 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
504fn 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 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 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 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(®ister, 1, false);
593 }
594 }
595}