Skip to main content

fresh/app/
clipboard.rs

1//! Clipboard and multi-cursor operations for the Editor.
2//!
3//! This module contains clipboard operations and multi-cursor actions:
4//! - Copy/cut/paste operations
5//! - Copy with formatting (HTML with syntax highlighting)
6//! - Multi-cursor add above/below/at next match
7
8use rust_i18n::t;
9
10use crate::input::multi_cursor::{
11    add_cursor_above, add_cursor_at_next_match, add_cursor_below, AddCursorResult,
12};
13use crate::model::buffer_position::byte_to_2d;
14use crate::model::event::{CursorId, Event};
15use crate::primitives::word_navigation::{
16    find_vi_word_end, find_word_start_left, find_word_start_right,
17};
18
19use super::Editor;
20
21// These are the clipboard and multi-cursor operations on Editor.
22//
23// MOTIVATION FOR SEPARATION:
24// - Buffer operations need: multi-cursor, selections, event sourcing, undo/redo
25// - Prompt operations need: simple string manipulation, no selection tracking
26// - Sharing code would force prompts to use Buffer (expensive) or buffers to
27//   lose features (selections, multi-cursor, undo)
28//
29// Both use the same clipboard storage (self.clipboard) ensuring copy/paste
30// works across buffer editing and prompt input.
31
32impl Editor {
33    /// Copy the current selection to clipboard
34    ///
35    /// If no selection exists, copies the entire current line (like VSCode/Rider/Zed).
36    /// For block selections, copies only the rectangular region.
37    pub fn copy_selection(&mut self) {
38        // Check if any cursor has a block selection (takes priority)
39        let has_block_selection = self
40            .active_cursors()
41            .iter()
42            .any(|(_, cursor)| cursor.has_block_selection());
43
44        if has_block_selection {
45            // Block selection: copy rectangular region
46            let text = self.copy_block_selection_text();
47            if !text.is_empty() {
48                self.clipboard.copy(text);
49                self.status_message = Some(t!("clipboard.copied").to_string());
50            }
51            return;
52        }
53
54        // Check if any cursor has a normal selection
55        let has_selection = self
56            .active_cursors()
57            .iter()
58            .any(|(_, cursor)| cursor.selection_range().is_some());
59
60        if has_selection {
61            // Original behavior: copy selected text
62            let ranges: Vec<_> = self
63                .active_cursors()
64                .iter()
65                .filter_map(|(_, cursor)| cursor.selection_range())
66                .collect();
67
68            let mut text = String::new();
69            let state = self.active_state_mut();
70            for range in ranges {
71                if !text.is_empty() {
72                    text.push('\n');
73                }
74                let range_text = state.get_text_range(range.start, range.end);
75                text.push_str(&range_text);
76            }
77
78            if !text.is_empty() {
79                self.clipboard.copy(text);
80                self.status_message = Some(t!("clipboard.copied").to_string());
81            }
82        } else {
83            // No selection: copy entire line(s) for each cursor
84            let estimated_line_length = 80;
85            let mut text = String::new();
86
87            // Collect cursor positions first
88            let positions: Vec<_> = self
89                .active_cursors()
90                .iter()
91                .map(|(_, c)| c.position)
92                .collect();
93            let state = self.active_state_mut();
94
95            for pos in positions {
96                let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
97                if let Some((_start, content)) = iter.next_line() {
98                    if !text.is_empty() {
99                        text.push('\n');
100                    }
101                    text.push_str(&content);
102                }
103            }
104
105            if !text.is_empty() {
106                self.clipboard.copy(text);
107                self.status_message = Some(t!("clipboard.copied_line").to_string());
108            }
109        }
110    }
111
112    /// Extract text from block (rectangular) selection
113    ///
114    /// For block selection, we need to extract a rectangular region defined by:
115    /// - The block anchor (stored as Position2D with line and column)
116    /// - The current cursor position (byte offset, converted to 2D)
117    ///
118    /// This works for both small and large files by using line_iterator
119    /// for iteration and only using 2D positions for column extraction.
120    fn copy_block_selection_text(&mut self) -> String {
121        let estimated_line_length = 120;
122
123        // Collect block selection info from all cursors
124        let block_infos: Vec<_> = self
125            .active_cursors()
126            .iter()
127            .filter_map(|(_, cursor)| {
128                if !cursor.has_block_selection() {
129                    return None;
130                }
131                let block_anchor = cursor.block_anchor?;
132                let anchor_byte = cursor.anchor?; // byte offset of anchor
133                let cursor_byte = cursor.position;
134                Some((block_anchor, anchor_byte, cursor_byte))
135            })
136            .collect();
137
138        let mut result = String::new();
139
140        for (block_anchor, anchor_byte, cursor_byte) in block_infos {
141            // Get current cursor position as 2D
142            let cursor_2d = {
143                let state = self.active_state();
144                byte_to_2d(&state.buffer, cursor_byte)
145            };
146
147            // Calculate column bounds (min and max columns for the rectangle)
148            let min_col = block_anchor.column.min(cursor_2d.column);
149            let max_col = block_anchor.column.max(cursor_2d.column);
150
151            // Calculate line bounds using byte positions
152            let start_byte = anchor_byte.min(cursor_byte);
153            let end_byte = anchor_byte.max(cursor_byte);
154
155            // Use line_iterator to iterate through lines
156            let state = self.active_state_mut();
157            let mut iter = state
158                .buffer
159                .line_iterator(start_byte, estimated_line_length);
160
161            // Collect lines within the block selection range
162            let mut lines_text = Vec::new();
163            loop {
164                let line_start = iter.current_position();
165
166                // Stop if we've passed the end of the selection
167                if line_start > end_byte {
168                    break;
169                }
170
171                if let Some((_offset, line_content)) = iter.next_line() {
172                    // Extract the column range from this line
173                    // Remove trailing newline for column calculation
174                    let content_without_newline = line_content.trim_end_matches(&['\n', '\r'][..]);
175                    let chars: Vec<char> = content_without_newline.chars().collect();
176
177                    // Extract characters from min_col to max_col (exclusive)
178                    let extracted: String = chars
179                        .iter()
180                        .skip(min_col)
181                        .take(max_col.saturating_sub(min_col))
182                        .collect();
183
184                    lines_text.push(extracted);
185
186                    // If this line extends past end_byte, we're done
187                    if line_start + line_content.len() > end_byte {
188                        break;
189                    }
190                } else {
191                    break;
192                }
193            }
194
195            // Join the extracted text from each line
196            if !result.is_empty() && !lines_text.is_empty() {
197                result.push('\n');
198            }
199            result.push_str(&lines_text.join("\n"));
200        }
201
202        result
203    }
204
205    /// Copy selection with a specific theme's formatting
206    ///
207    /// If theme_name is empty, opens a prompt to select a theme.
208    /// Otherwise, copies the selected text as HTML with inline CSS styles.
209    pub fn copy_selection_with_theme(&mut self, theme_name: &str) {
210        // Check if there's a selection first
211        let has_selection = self
212            .active_cursors()
213            .iter()
214            .any(|(_, cursor)| cursor.selection_range().is_some());
215
216        if !has_selection {
217            self.status_message = Some(t!("clipboard.no_selection").to_string());
218            return;
219        }
220
221        // Empty theme = open theme picker prompt
222        if theme_name.is_empty() {
223            self.start_copy_with_formatting_prompt();
224            return;
225        }
226        use crate::services::styled_html::render_styled_html;
227
228        // Get the requested theme from registry
229        let theme = match self.theme_registry.get_cloned(theme_name) {
230            Some(t) => t,
231            None => {
232                self.status_message = Some(format!("Theme '{}' not found", theme_name));
233                return;
234            }
235        };
236
237        // Collect ranges and their byte offsets
238        let ranges: Vec<_> = self
239            .active_cursors()
240            .iter()
241            .filter_map(|(_, cursor)| cursor.selection_range())
242            .collect();
243
244        if ranges.is_empty() {
245            self.status_message = Some(t!("clipboard.no_selection").to_string());
246            return;
247        }
248
249        // Get the overall range for highlighting
250        let min_offset = ranges.iter().map(|r| r.start).min().unwrap_or(0);
251        let max_offset = ranges.iter().map(|r| r.end).max().unwrap_or(0);
252
253        // Collect text and highlight spans from state
254        let (text, highlight_spans) = {
255            let state = self.active_state_mut();
256
257            // Collect text from all ranges
258            let mut text = String::new();
259            for range in &ranges {
260                if !text.is_empty() {
261                    text.push('\n');
262                }
263                let range_text = state.get_text_range(range.start, range.end);
264                text.push_str(&range_text);
265            }
266
267            if text.is_empty() {
268                (text, Vec::new())
269            } else {
270                // Get highlight spans for the selected region
271                let highlight_spans = state.highlighter.highlight_viewport(
272                    &state.buffer,
273                    min_offset,
274                    max_offset,
275                    &theme,
276                    0, // No context needed since we're copying exact selection
277                );
278                (text, highlight_spans)
279            }
280        };
281
282        if text.is_empty() {
283            self.status_message = Some(t!("clipboard.no_text").to_string());
284            return;
285        }
286
287        // Adjust highlight spans to be relative to the copied text
288        let adjusted_spans: Vec<_> = if ranges.len() == 1 {
289            let base_offset = ranges[0].start;
290            highlight_spans
291                .into_iter()
292                .filter_map(|span| {
293                    if span.range.end <= base_offset || span.range.start >= ranges[0].end {
294                        return None;
295                    }
296                    let start = span.range.start.saturating_sub(base_offset);
297                    let end = (span.range.end - base_offset).min(text.len());
298                    if start < end {
299                        Some(crate::primitives::highlighter::HighlightSpan {
300                            range: start..end,
301                            color: span.color,
302                            category: span.category,
303                        })
304                    } else {
305                        None
306                    }
307                })
308                .collect()
309        } else {
310            Vec::new()
311        };
312
313        // Render the styled text to HTML
314        let html = render_styled_html(&text, &adjusted_spans, &theme);
315
316        // Copy the HTML to clipboard (with plain text fallback)
317        if self.clipboard.copy_html(&html, &text) {
318            self.status_message =
319                Some(t!("clipboard.copied_with_theme", theme = theme_name).to_string());
320        } else {
321            self.clipboard.copy(text);
322            self.status_message = Some(t!("clipboard.copied_plain").to_string());
323        }
324    }
325
326    /// Start the theme selection prompt for copy with formatting
327    fn start_copy_with_formatting_prompt(&mut self) {
328        use crate::view::prompt::PromptType;
329
330        let available_themes = self.theme_registry.list();
331        // Resolve the config value (portable form) to a canonical registry
332        // key so the picker can pre-highlight the current theme.
333        let resolved_current = self
334            .theme_registry
335            .resolve_key(&self.config.theme.0)
336            .unwrap_or_else(|| self.config.theme.0.clone());
337        let current_theme_key = resolved_current.as_str();
338
339        // Find the index of the current theme (match by key first, then name)
340        let current_index = available_themes
341            .iter()
342            .position(|info| info.key == *current_theme_key)
343            .or_else(|| {
344                let normalized = crate::view::theme::normalize_theme_name(current_theme_key);
345                available_themes.iter().position(|info| {
346                    crate::view::theme::normalize_theme_name(&info.name) == normalized
347                })
348            })
349            .unwrap_or(0);
350
351        let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
352            .iter()
353            .map(|info| {
354                let is_current = Some(info) == available_themes.get(current_index);
355                let description = if is_current {
356                    Some(format!("{} (current)", info.key))
357                } else {
358                    Some(info.key.clone())
359                };
360                crate::input::commands::Suggestion {
361                    text: info.name.clone(),
362                    description,
363                    value: Some(info.key.clone()),
364                    disabled: false,
365                    keybinding: None,
366                    source: None,
367                }
368            })
369            .collect();
370
371        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
372            "Copy with theme: ".to_string(),
373            PromptType::CopyWithFormattingTheme,
374            suggestions,
375        ));
376
377        if let Some(prompt) = self.prompt.as_mut() {
378            if !prompt.suggestions.is_empty() {
379                prompt.selected_suggestion = Some(current_index);
380                prompt.input = current_theme_key.to_string();
381                prompt.cursor_pos = prompt.input.len();
382            }
383        }
384    }
385
386    /// Cut the current selection to clipboard
387    ///
388    /// If no selection exists, cuts the entire current line (like VSCode/Rider/Zed).
389    pub fn cut_selection(&mut self) {
390        // Check if any cursor has a selection
391        let has_selection = self
392            .active_cursors()
393            .iter()
394            .any(|(_, cursor)| cursor.selection_range().is_some());
395
396        // Copy first (this handles both selection and whole-line cases)
397        self.copy_selection();
398
399        if has_selection {
400            // Delete selected text from all cursors
401            // IMPORTANT: Sort deletions by position to ensure we process from end to start
402            let mut deletions: Vec<_> = self
403                .active_cursors()
404                .iter()
405                .filter_map(|(_, c)| c.selection_range())
406                .collect();
407            // Sort by start position so reverse iteration processes from end to start
408            deletions.sort_by_key(|r| r.start);
409
410            let primary_id = self.active_cursors().primary_id();
411            let state = self.active_state_mut();
412            let events: Vec<_> = deletions
413                .iter()
414                .rev()
415                .map(|range| {
416                    let deleted_text = state.get_text_range(range.start, range.end);
417                    Event::Delete {
418                        range: range.clone(),
419                        deleted_text,
420                        cursor_id: primary_id,
421                    }
422                })
423                .collect();
424
425            // Apply events with atomic undo using bulk edit for O(n) performance
426            if events.len() > 1 {
427                // Use optimized bulk edit for multi-cursor cut
428                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Cut".to_string()) {
429                    self.active_event_log_mut().append(bulk_edit);
430                }
431            } else if let Some(event) = events.into_iter().next() {
432                self.log_and_apply_event(&event);
433            }
434
435            if !deletions.is_empty() {
436                self.status_message = Some(t!("clipboard.cut").to_string());
437            }
438        } else {
439            // No selection: delete entire line(s) for each cursor
440            let estimated_line_length = 80;
441
442            // Collect line ranges for each cursor
443            // IMPORTANT: Sort deletions by position to ensure we process from end to start
444            let positions: Vec<_> = self
445                .active_cursors()
446                .iter()
447                .map(|(_, c)| c.position)
448                .collect();
449            let mut deletions: Vec<_> = {
450                let state = self.active_state_mut();
451                positions
452                    .into_iter()
453                    .filter_map(|pos| {
454                        let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
455                        let line_start = iter.current_position();
456                        iter.next_line().map(|(_start, content)| {
457                            let line_end = line_start + content.len();
458                            line_start..line_end
459                        })
460                    })
461                    .collect()
462            };
463            // Sort by start position so reverse iteration processes from end to start
464            deletions.sort_by_key(|r| r.start);
465
466            let primary_id = self.active_cursors().primary_id();
467            let state = self.active_state_mut();
468            let events: Vec<_> = deletions
469                .iter()
470                .rev()
471                .map(|range| {
472                    let deleted_text = state.get_text_range(range.start, range.end);
473                    Event::Delete {
474                        range: range.clone(),
475                        deleted_text,
476                        cursor_id: primary_id,
477                    }
478                })
479                .collect();
480
481            // Apply events with atomic undo using bulk edit for O(n) performance
482            if events.len() > 1 {
483                // Use optimized bulk edit for multi-cursor cut
484                if let Some(bulk_edit) =
485                    self.apply_events_as_bulk_edit(events, "Cut line".to_string())
486                {
487                    self.active_event_log_mut().append(bulk_edit);
488                }
489            } else if let Some(event) = events.into_iter().next() {
490                self.log_and_apply_event(&event);
491            }
492
493            if !deletions.is_empty() {
494                self.status_message = Some(t!("clipboard.cut_line").to_string());
495            }
496        }
497    }
498
499    /// Paste the clipboard content at all cursor positions
500    ///
501    /// Handles:
502    /// - Single cursor paste
503    /// - Multi-cursor paste (pastes at each cursor)
504    /// - Selection replacement (deletes selection before inserting)
505    /// - Atomic undo (single undo step for entire operation)
506    pub fn paste(&mut self) {
507        // Get content from clipboard (tries system first, falls back to internal)
508        let text = match self.clipboard.paste() {
509            Some(text) => text,
510            None => return,
511        };
512
513        // Use paste_text which handles line ending normalization
514        self.paste_text(text);
515    }
516
517    /// Paste text directly into the editor
518    ///
519    /// Handles:
520    /// - Line ending normalization (CRLF/CR → buffer's format)
521    /// - Single cursor paste
522    /// - Multi-cursor paste (pastes at each cursor)
523    /// - Column-mode paste: when the cursor count equals the number of
524    ///   clipboard lines, each cursor receives a distinct line (matches
525    ///   VSCode/Notepad++ behavior, see issue #1057). This makes a
526    ///   block-selected copy/paste round-trip preserve its rectangular shape.
527    /// - Selection replacement (deletes selection before inserting)
528    /// - Atomic undo (single undo step for entire operation)
529    /// - Routing to prompt if one is open
530    pub fn paste_text(&mut self, paste_text: String) {
531        if paste_text.is_empty() {
532            return;
533        }
534
535        // Normalize line endings: first convert all to LF, then to buffer's format
536        // This handles Windows clipboard (CRLF), old Mac (CR), and Unix (LF)
537        let normalized = paste_text.replace("\r\n", "\n").replace('\r', "\n");
538
539        // If a prompt is open, paste into the prompt (prompts use LF internally)
540        if let Some(prompt) = self.prompt.as_mut() {
541            prompt.insert_str(&normalized);
542            self.update_prompt_suggestions();
543            self.status_message = Some(t!("clipboard.pasted").to_string());
544            return;
545        }
546
547        // If in terminal mode, send paste to the terminal PTY
548        if self.terminal_mode {
549            self.send_terminal_input(normalized.as_bytes());
550            return;
551        }
552
553        // Collect cursor info sorted in reverse order by position
554        let mut cursor_data: Vec<_> = self
555            .active_cursors()
556            .iter()
557            .map(|(cursor_id, cursor)| {
558                let selection = cursor.selection_range();
559                let insert_position = selection
560                    .as_ref()
561                    .map(|r| r.start)
562                    .unwrap_or(cursor.position);
563                (cursor_id, selection, insert_position)
564            })
565            .collect();
566        cursor_data.sort_by_key(|(_, _, pos)| std::cmp::Reverse(*pos));
567
568        // Decide whether to distribute one clipboard line per cursor
569        // (column-mode paste). We split on LF (after normalization above) and
570        // ignore a single trailing empty entry from a trailing newline so that
571        // "a\nb\nc" and "a\nb\nc\n" both yield 3 lines.
572        let mut lines_for_distribution: Vec<&str> = normalized.split('\n').collect();
573        if lines_for_distribution.len() > 1 && lines_for_distribution.last() == Some(&"") {
574            lines_for_distribution.pop();
575        }
576        let use_column_paste = cursor_data.len() > 1
577            && lines_for_distribution.len() > 1
578            && lines_for_distribution.len() == cursor_data.len();
579
580        // Convert to buffer's line ending format (only used in non-column mode;
581        // a single column-paste line never contains an embedded newline).
582        let paste_text_full = match self.active_state().buffer.line_ending() {
583            crate::model::buffer::LineEnding::LF => normalized.clone(),
584            crate::model::buffer::LineEnding::CRLF => normalized.replace('\n', "\r\n"),
585            crate::model::buffer::LineEnding::CR => normalized.replace('\n', "\r"),
586        };
587
588        // Get deleted text for each selection
589        let cursor_data_with_text: Vec<_> = {
590            let state = self.active_state_mut();
591            cursor_data
592                .into_iter()
593                .map(|(cursor_id, selection, insert_position)| {
594                    let deleted_text = selection
595                        .as_ref()
596                        .map(|r| state.get_text_range(r.start, r.end));
597                    (cursor_id, selection, insert_position, deleted_text)
598                })
599                .collect()
600        };
601
602        // Build events for each cursor.
603        //
604        // cursor_data_with_text is sorted by position DESCENDING (so events
605        // applied in vector order don't invalidate earlier offsets). For column
606        // paste we want the topmost cursor (smallest position) to receive the
607        // first clipboard line, so we index into `lines_for_distribution` from
608        // the back when iterating.
609        let total = cursor_data_with_text.len();
610        let mut events = Vec::new();
611        for (i, (cursor_id, selection, insert_position, deleted_text)) in
612            cursor_data_with_text.into_iter().enumerate()
613        {
614            if let (Some(range), Some(text)) = (selection, deleted_text) {
615                events.push(Event::Delete {
616                    range,
617                    deleted_text: text,
618                    cursor_id,
619                });
620            }
621            let text = if use_column_paste {
622                lines_for_distribution[total - 1 - i].to_string()
623            } else {
624                paste_text_full.clone()
625            };
626            events.push(Event::Insert {
627                position: insert_position,
628                text,
629                cursor_id,
630            });
631        }
632
633        // Apply events with atomic undo using bulk edit for O(n) performance
634        if events.len() > 1 {
635            // Use optimized bulk edit for multi-cursor paste
636            if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Paste".to_string()) {
637                self.active_event_log_mut().append(bulk_edit);
638            }
639        } else if let Some(event) = events.into_iter().next() {
640            self.log_and_apply_event(&event);
641        }
642
643        self.status_message = Some(t!("clipboard.pasted").to_string());
644    }
645
646    /// Set clipboard content for testing purposes
647    /// This sets the internal clipboard and enables internal-only mode to avoid
648    /// system clipboard interference between parallel tests
649    #[doc(hidden)]
650    pub fn set_clipboard_for_test(&mut self, text: String) {
651        self.clipboard.set_internal(text);
652        self.clipboard.set_internal_only(true);
653    }
654
655    /// Paste from internal clipboard only (for testing)
656    /// This bypasses the system clipboard to avoid interference from CI environments
657    #[doc(hidden)]
658    pub fn paste_for_test(&mut self) {
659        // Get content from internal clipboard only (ignores system clipboard)
660        let paste_text = match self.clipboard.paste_internal() {
661            Some(text) => text,
662            None => return,
663        };
664
665        // Use the same paste logic as the regular paste method
666        self.paste_text(paste_text);
667    }
668
669    /// Get clipboard content for testing purposes
670    /// Returns the internal clipboard content
671    #[doc(hidden)]
672    pub fn clipboard_content_for_test(&self) -> String {
673        self.clipboard.get_internal().to_string()
674    }
675
676    /// Copy a buffer's file path to the clipboard.
677    ///
678    /// When `relative` is true the path is made relative to the workspace root;
679    /// if the file lives outside the workspace the absolute path is used as a
680    /// safe fallback (the user still gets a usable path rather than nothing).
681    /// When `relative` is false the absolute path is always copied.
682    ///
683    /// If the buffer has no associated file (unsaved scratch buffer) or the
684    /// buffer id is unknown, a status message is shown and the clipboard is
685    /// left untouched.
686    pub fn copy_buffer_path(&mut self, buffer_id: crate::model::event::BufferId, relative: bool) {
687        let path = self
688            .buffers
689            .get(&buffer_id)
690            .and_then(|state| state.buffer.file_path().map(|p| p.to_path_buf()));
691        let Some(path) = path else {
692            self.status_message = Some(t!("clipboard.no_file_path").to_string());
693            return;
694        };
695
696        let path_str = if relative {
697            path.strip_prefix(&self.working_dir)
698                .unwrap_or(&path)
699                .to_string_lossy()
700                .into_owned()
701        } else {
702            path.to_string_lossy().into_owned()
703        };
704
705        self.clipboard.copy(path_str.clone());
706        self.status_message = Some(t!("clipboard.copied_path", path = &path_str).to_string());
707    }
708
709    /// Copy the active buffer's file path. See [`Self::copy_buffer_path`].
710    pub fn copy_active_buffer_path(&mut self, relative: bool) {
711        let buffer_id = self.active_buffer();
712        self.copy_buffer_path(buffer_id, relative);
713    }
714
715    /// Add a cursor at the next occurrence of the selected text
716    /// If no selection, first selects the entire word at cursor position.
717    ///
718    /// When an active substring search has placed the cursor at a match
719    /// (cursor inside `search_state.matches[i]..matches[i] + match_lengths[i]`),
720    /// the search match is selected instead of the surrounding word.  This
721    /// way subsequent presses look for the search substring rather than the
722    /// whole word, which would skip other substring occurrences (issue #1697).
723    pub fn add_cursor_at_next_match(&mut self) {
724        if let Some(range) = self.search_match_at_primary_cursor() {
725            let primary_id = self.active_cursors().primary_id();
726            let primary = self.active_cursors().primary();
727            let event = Event::MoveCursor {
728                cursor_id: primary_id,
729                old_position: primary.position,
730                new_position: range.end,
731                old_anchor: primary.anchor,
732                new_anchor: Some(range.start),
733                old_sticky_column: primary.sticky_column,
734                new_sticky_column: 0,
735            };
736            self.active_event_log_mut().append(event.clone());
737            self.apply_event_to_active_buffer(&event);
738            return;
739        }
740
741        let cursors = self.active_cursors().clone();
742        let state = self.active_state_mut();
743        match add_cursor_at_next_match(state, &cursors) {
744            AddCursorResult::Success {
745                cursor,
746                total_cursors,
747            } => {
748                // Create AddCursor event with the next cursor ID
749                let next_id = CursorId(self.active_cursors().count());
750                let event = Event::AddCursor {
751                    cursor_id: next_id,
752                    position: cursor.position,
753                    anchor: cursor.anchor,
754                };
755
756                // Log and apply the event
757                self.active_event_log_mut().append(event.clone());
758                self.apply_event_to_active_buffer(&event);
759
760                self.status_message =
761                    Some(t!("clipboard.added_cursor_match", count = total_cursors).to_string());
762            }
763            AddCursorResult::WordSelected {
764                word_start,
765                word_end,
766            } => {
767                // Select the word by updating the primary cursor
768                let primary_id = self.active_cursors().primary_id();
769                let primary = self.active_cursors().primary();
770                let event = Event::MoveCursor {
771                    cursor_id: primary_id,
772                    old_position: primary.position,
773                    new_position: word_end,
774                    old_anchor: primary.anchor,
775                    new_anchor: Some(word_start),
776                    old_sticky_column: primary.sticky_column,
777                    new_sticky_column: 0,
778                };
779
780                // Log and apply the event
781                self.active_event_log_mut().append(event.clone());
782                self.apply_event_to_active_buffer(&event);
783            }
784            AddCursorResult::Failed { message } => {
785                self.status_message = Some(message);
786            }
787        }
788    }
789
790    /// Add a cursor above the primary cursor at the same column
791    pub fn add_cursor_above(&mut self) {
792        let cursors = self.active_cursors().clone();
793        let state = self.active_state_mut();
794        match add_cursor_above(state, &cursors) {
795            AddCursorResult::Success {
796                cursor,
797                total_cursors,
798            } => {
799                // Create AddCursor event with the next cursor ID
800                let next_id = CursorId(self.active_cursors().count());
801                let event = Event::AddCursor {
802                    cursor_id: next_id,
803                    position: cursor.position,
804                    anchor: cursor.anchor,
805                };
806
807                // Log and apply the event
808                self.active_event_log_mut().append(event.clone());
809                self.apply_event_to_active_buffer(&event);
810
811                self.status_message =
812                    Some(t!("clipboard.added_cursor_above", count = total_cursors).to_string());
813            }
814            AddCursorResult::Failed { message } => {
815                self.status_message = Some(message);
816            }
817            AddCursorResult::WordSelected { .. } => unreachable!(),
818        }
819    }
820
821    /// Add a cursor below the primary cursor at the same column
822    pub fn add_cursor_below(&mut self) {
823        let cursors = self.active_cursors().clone();
824        let state = self.active_state_mut();
825        match add_cursor_below(state, &cursors) {
826            AddCursorResult::Success {
827                cursor,
828                total_cursors,
829            } => {
830                // Create AddCursor event with the next cursor ID
831                let next_id = CursorId(self.active_cursors().count());
832                let event = Event::AddCursor {
833                    cursor_id: next_id,
834                    position: cursor.position,
835                    anchor: cursor.anchor,
836                };
837
838                // Log and apply the event
839                self.active_event_log_mut().append(event.clone());
840                self.apply_event_to_active_buffer(&event);
841
842                self.status_message =
843                    Some(t!("clipboard.added_cursor_below", count = total_cursors).to_string());
844            }
845            AddCursorResult::Failed { message } => {
846                self.status_message = Some(message);
847            }
848            AddCursorResult::WordSelected { .. } => unreachable!(),
849        }
850    }
851
852    // =========================================================================
853    // Vi-style yank operations (copy range without requiring selection)
854    // =========================================================================
855
856    /// Yank (copy) from cursor to next word start
857    pub fn yank_word_forward(&mut self) {
858        let cursor_positions: Vec<_> = self
859            .active_cursors()
860            .iter()
861            .map(|(_, c)| c.position)
862            .collect();
863        let ranges: Vec<_> = {
864            let state = self.active_state();
865            cursor_positions
866                .into_iter()
867                .filter_map(|start| {
868                    let end = find_word_start_right(&state.buffer, start);
869                    if end > start {
870                        Some(start..end)
871                    } else {
872                        None
873                    }
874                })
875                .collect()
876        };
877
878        if ranges.is_empty() {
879            return;
880        }
881
882        // Copy text from all ranges
883        let mut text = String::new();
884        let state = self.active_state_mut();
885        for range in ranges {
886            if !text.is_empty() {
887                text.push('\n');
888            }
889            let range_text = state.get_text_range(range.start, range.end);
890            text.push_str(&range_text);
891        }
892
893        if !text.is_empty() {
894            let len = text.len();
895            self.clipboard.copy(text);
896            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
897        }
898    }
899
900    /// Yank (copy) from cursor to vim word end (inclusive)
901    pub fn yank_vi_word_end(&mut self) {
902        let cursor_positions: Vec<_> = self
903            .active_cursors()
904            .iter()
905            .map(|(_, c)| c.position)
906            .collect();
907        let ranges: Vec<_> = {
908            let state = self.active_state();
909            cursor_positions
910                .into_iter()
911                .filter_map(|start| {
912                    let word_end = find_vi_word_end(&state.buffer, start);
913                    let end = (word_end + 1).min(state.buffer.len());
914                    if end > start {
915                        Some(start..end)
916                    } else {
917                        None
918                    }
919                })
920                .collect()
921        };
922
923        if ranges.is_empty() {
924            return;
925        }
926
927        let mut text = String::new();
928        let state = self.active_state_mut();
929        for range in ranges {
930            if !text.is_empty() {
931                text.push('\n');
932            }
933            let range_text = state.get_text_range(range.start, range.end);
934            text.push_str(&range_text);
935        }
936
937        if !text.is_empty() {
938            let len = text.len();
939            self.clipboard.copy(text);
940            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
941        }
942    }
943
944    /// Yank (copy) from previous word start to cursor
945    pub fn yank_word_backward(&mut self) {
946        let cursor_positions: Vec<_> = self
947            .active_cursors()
948            .iter()
949            .map(|(_, c)| c.position)
950            .collect();
951        let ranges: Vec<_> = {
952            let state = self.active_state();
953            cursor_positions
954                .into_iter()
955                .filter_map(|end| {
956                    let start = find_word_start_left(&state.buffer, end);
957                    if start < end {
958                        Some(start..end)
959                    } else {
960                        None
961                    }
962                })
963                .collect()
964        };
965
966        if ranges.is_empty() {
967            return;
968        }
969
970        let mut text = String::new();
971        let state = self.active_state_mut();
972        for range in ranges {
973            if !text.is_empty() {
974                text.push('\n');
975            }
976            let range_text = state.get_text_range(range.start, range.end);
977            text.push_str(&range_text);
978        }
979
980        if !text.is_empty() {
981            let len = text.len();
982            self.clipboard.copy(text);
983            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
984        }
985    }
986
987    /// Yank (copy) from cursor to end of line
988    pub fn yank_to_line_end(&mut self) {
989        let estimated_line_length = 80;
990
991        // First collect cursor positions with immutable borrow
992        let cursor_positions: Vec<_> = self
993            .active_cursors()
994            .iter()
995            .map(|(_, cursor)| cursor.position)
996            .collect();
997
998        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
999        let state = self.active_state_mut();
1000        let mut ranges = Vec::new();
1001        for pos in cursor_positions {
1002            let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
1003            let line_start = iter.current_position();
1004            if let Some((_start, content)) = iter.next_line() {
1005                // Don't include the line ending in yank
1006                let content_len = content.trim_end_matches(&['\n', '\r'][..]).len();
1007                let line_end = line_start + content_len;
1008                if pos < line_end {
1009                    ranges.push(pos..line_end);
1010                }
1011            }
1012        }
1013
1014        if ranges.is_empty() {
1015            return;
1016        }
1017
1018        let mut text = String::new();
1019        for range in ranges {
1020            if !text.is_empty() {
1021                text.push('\n');
1022            }
1023            let range_text = state.get_text_range(range.start, range.end);
1024            text.push_str(&range_text);
1025        }
1026
1027        if !text.is_empty() {
1028            let len = text.len();
1029            self.clipboard.copy(text);
1030            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
1031        }
1032    }
1033
1034    /// Yank (copy) from start of line to cursor
1035    pub fn yank_to_line_start(&mut self) {
1036        let estimated_line_length = 80;
1037
1038        // First collect cursor positions with immutable borrow
1039        let cursor_positions: Vec<_> = self
1040            .active_cursors()
1041            .iter()
1042            .map(|(_, cursor)| cursor.position)
1043            .collect();
1044
1045        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
1046        let state = self.active_state_mut();
1047        let mut ranges = Vec::new();
1048        for pos in cursor_positions {
1049            let iter = state.buffer.line_iterator(pos, estimated_line_length);
1050            let line_start = iter.current_position();
1051            if pos > line_start {
1052                ranges.push(line_start..pos);
1053            }
1054        }
1055
1056        if ranges.is_empty() {
1057            return;
1058        }
1059
1060        let mut text = String::new();
1061        for range in ranges {
1062            if !text.is_empty() {
1063                text.push('\n');
1064            }
1065            let range_text = state.get_text_range(range.start, range.end);
1066            text.push_str(&range_text);
1067        }
1068
1069        if !text.is_empty() {
1070            let len = text.len();
1071            self.clipboard.copy(text);
1072            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
1073        }
1074    }
1075}