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    /// - Selection replacement (deletes selection before inserting)
524    /// - Atomic undo (single undo step for entire operation)
525    /// - Routing to prompt if one is open
526    pub fn paste_text(&mut self, paste_text: String) {
527        if paste_text.is_empty() {
528            return;
529        }
530
531        // Normalize line endings: first convert all to LF, then to buffer's format
532        // This handles Windows clipboard (CRLF), old Mac (CR), and Unix (LF)
533        let normalized = paste_text.replace("\r\n", "\n").replace('\r', "\n");
534
535        // If a prompt is open, paste into the prompt (prompts use LF internally)
536        if let Some(prompt) = self.prompt.as_mut() {
537            prompt.insert_str(&normalized);
538            self.update_prompt_suggestions();
539            self.status_message = Some(t!("clipboard.pasted").to_string());
540            return;
541        }
542
543        // If in terminal mode, send paste to the terminal PTY
544        if self.terminal_mode {
545            self.send_terminal_input(normalized.as_bytes());
546            return;
547        }
548
549        // Convert to buffer's line ending format
550        let buffer_line_ending = self.active_state().buffer.line_ending();
551        let paste_text = match buffer_line_ending {
552            crate::model::buffer::LineEnding::LF => normalized,
553            crate::model::buffer::LineEnding::CRLF => normalized.replace('\n', "\r\n"),
554            crate::model::buffer::LineEnding::CR => normalized.replace('\n', "\r"),
555        };
556
557        let mut events = Vec::new();
558
559        // Collect cursor info sorted in reverse order by position
560        let mut cursor_data: Vec<_> = self
561            .active_cursors()
562            .iter()
563            .map(|(cursor_id, cursor)| {
564                let selection = cursor.selection_range();
565                let insert_position = selection
566                    .as_ref()
567                    .map(|r| r.start)
568                    .unwrap_or(cursor.position);
569                (cursor_id, selection, insert_position)
570            })
571            .collect();
572        cursor_data.sort_by_key(|(_, _, pos)| std::cmp::Reverse(*pos));
573
574        // Get deleted text for each selection
575        let cursor_data_with_text: Vec<_> = {
576            let state = self.active_state_mut();
577            cursor_data
578                .into_iter()
579                .map(|(cursor_id, selection, insert_position)| {
580                    let deleted_text = selection
581                        .as_ref()
582                        .map(|r| state.get_text_range(r.start, r.end));
583                    (cursor_id, selection, insert_position, deleted_text)
584                })
585                .collect()
586        };
587
588        // Build events for each cursor
589        for (cursor_id, selection, insert_position, deleted_text) in cursor_data_with_text {
590            if let (Some(range), Some(text)) = (selection, deleted_text) {
591                events.push(Event::Delete {
592                    range,
593                    deleted_text: text,
594                    cursor_id,
595                });
596            }
597            events.push(Event::Insert {
598                position: insert_position,
599                text: paste_text.clone(),
600                cursor_id,
601            });
602        }
603
604        // Apply events with atomic undo using bulk edit for O(n) performance
605        if events.len() > 1 {
606            // Use optimized bulk edit for multi-cursor paste
607            if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Paste".to_string()) {
608                self.active_event_log_mut().append(bulk_edit);
609            }
610        } else if let Some(event) = events.into_iter().next() {
611            self.log_and_apply_event(&event);
612        }
613
614        self.status_message = Some(t!("clipboard.pasted").to_string());
615    }
616
617    /// Set clipboard content for testing purposes
618    /// This sets the internal clipboard and enables internal-only mode to avoid
619    /// system clipboard interference between parallel tests
620    #[doc(hidden)]
621    pub fn set_clipboard_for_test(&mut self, text: String) {
622        self.clipboard.set_internal(text);
623        self.clipboard.set_internal_only(true);
624    }
625
626    /// Paste from internal clipboard only (for testing)
627    /// This bypasses the system clipboard to avoid interference from CI environments
628    #[doc(hidden)]
629    pub fn paste_for_test(&mut self) {
630        // Get content from internal clipboard only (ignores system clipboard)
631        let paste_text = match self.clipboard.paste_internal() {
632            Some(text) => text,
633            None => return,
634        };
635
636        // Use the same paste logic as the regular paste method
637        self.paste_text(paste_text);
638    }
639
640    /// Get clipboard content for testing purposes
641    /// Returns the internal clipboard content
642    #[doc(hidden)]
643    pub fn clipboard_content_for_test(&self) -> String {
644        self.clipboard.get_internal().to_string()
645    }
646
647    /// Copy a buffer's file path to the clipboard.
648    ///
649    /// When `relative` is true the path is made relative to the workspace root;
650    /// if the file lives outside the workspace the absolute path is used as a
651    /// safe fallback (the user still gets a usable path rather than nothing).
652    /// When `relative` is false the absolute path is always copied.
653    ///
654    /// If the buffer has no associated file (unsaved scratch buffer) or the
655    /// buffer id is unknown, a status message is shown and the clipboard is
656    /// left untouched.
657    pub fn copy_buffer_path(&mut self, buffer_id: crate::model::event::BufferId, relative: bool) {
658        let path = self
659            .buffers
660            .get(&buffer_id)
661            .and_then(|state| state.buffer.file_path().map(|p| p.to_path_buf()));
662        let Some(path) = path else {
663            self.status_message = Some(t!("clipboard.no_file_path").to_string());
664            return;
665        };
666
667        let path_str = if relative {
668            path.strip_prefix(&self.working_dir)
669                .unwrap_or(&path)
670                .to_string_lossy()
671                .into_owned()
672        } else {
673            path.to_string_lossy().into_owned()
674        };
675
676        self.clipboard.copy(path_str.clone());
677        self.status_message = Some(t!("clipboard.copied_path", path = &path_str).to_string());
678    }
679
680    /// Copy the active buffer's file path. See [`Self::copy_buffer_path`].
681    pub fn copy_active_buffer_path(&mut self, relative: bool) {
682        let buffer_id = self.active_buffer();
683        self.copy_buffer_path(buffer_id, relative);
684    }
685
686    /// Add a cursor at the next occurrence of the selected text
687    /// If no selection, first selects the entire word at cursor position
688    pub fn add_cursor_at_next_match(&mut self) {
689        let cursors = self.active_cursors().clone();
690        let state = self.active_state_mut();
691        match add_cursor_at_next_match(state, &cursors) {
692            AddCursorResult::Success {
693                cursor,
694                total_cursors,
695            } => {
696                // Create AddCursor event with the next cursor ID
697                let next_id = CursorId(self.active_cursors().count());
698                let event = Event::AddCursor {
699                    cursor_id: next_id,
700                    position: cursor.position,
701                    anchor: cursor.anchor,
702                };
703
704                // Log and apply the event
705                self.active_event_log_mut().append(event.clone());
706                self.apply_event_to_active_buffer(&event);
707
708                self.status_message =
709                    Some(t!("clipboard.added_cursor_match", count = total_cursors).to_string());
710            }
711            AddCursorResult::WordSelected {
712                word_start,
713                word_end,
714            } => {
715                // Select the word by updating the primary cursor
716                let primary_id = self.active_cursors().primary_id();
717                let primary = self.active_cursors().primary();
718                let event = Event::MoveCursor {
719                    cursor_id: primary_id,
720                    old_position: primary.position,
721                    new_position: word_end,
722                    old_anchor: primary.anchor,
723                    new_anchor: Some(word_start),
724                    old_sticky_column: primary.sticky_column,
725                    new_sticky_column: 0,
726                };
727
728                // Log and apply the event
729                self.active_event_log_mut().append(event.clone());
730                self.apply_event_to_active_buffer(&event);
731            }
732            AddCursorResult::Failed { message } => {
733                self.status_message = Some(message);
734            }
735        }
736    }
737
738    /// Add a cursor above the primary cursor at the same column
739    pub fn add_cursor_above(&mut self) {
740        let cursors = self.active_cursors().clone();
741        let state = self.active_state_mut();
742        match add_cursor_above(state, &cursors) {
743            AddCursorResult::Success {
744                cursor,
745                total_cursors,
746            } => {
747                // Create AddCursor event with the next cursor ID
748                let next_id = CursorId(self.active_cursors().count());
749                let event = Event::AddCursor {
750                    cursor_id: next_id,
751                    position: cursor.position,
752                    anchor: cursor.anchor,
753                };
754
755                // Log and apply the event
756                self.active_event_log_mut().append(event.clone());
757                self.apply_event_to_active_buffer(&event);
758
759                self.status_message =
760                    Some(t!("clipboard.added_cursor_above", count = total_cursors).to_string());
761            }
762            AddCursorResult::Failed { message } => {
763                self.status_message = Some(message);
764            }
765            AddCursorResult::WordSelected { .. } => unreachable!(),
766        }
767    }
768
769    /// Add a cursor below the primary cursor at the same column
770    pub fn add_cursor_below(&mut self) {
771        let cursors = self.active_cursors().clone();
772        let state = self.active_state_mut();
773        match add_cursor_below(state, &cursors) {
774            AddCursorResult::Success {
775                cursor,
776                total_cursors,
777            } => {
778                // Create AddCursor event with the next cursor ID
779                let next_id = CursorId(self.active_cursors().count());
780                let event = Event::AddCursor {
781                    cursor_id: next_id,
782                    position: cursor.position,
783                    anchor: cursor.anchor,
784                };
785
786                // Log and apply the event
787                self.active_event_log_mut().append(event.clone());
788                self.apply_event_to_active_buffer(&event);
789
790                self.status_message =
791                    Some(t!("clipboard.added_cursor_below", count = total_cursors).to_string());
792            }
793            AddCursorResult::Failed { message } => {
794                self.status_message = Some(message);
795            }
796            AddCursorResult::WordSelected { .. } => unreachable!(),
797        }
798    }
799
800    // =========================================================================
801    // Vi-style yank operations (copy range without requiring selection)
802    // =========================================================================
803
804    /// Yank (copy) from cursor to next word start
805    pub fn yank_word_forward(&mut self) {
806        let cursor_positions: Vec<_> = self
807            .active_cursors()
808            .iter()
809            .map(|(_, c)| c.position)
810            .collect();
811        let ranges: Vec<_> = {
812            let state = self.active_state();
813            cursor_positions
814                .into_iter()
815                .filter_map(|start| {
816                    let end = find_word_start_right(&state.buffer, start);
817                    if end > start {
818                        Some(start..end)
819                    } else {
820                        None
821                    }
822                })
823                .collect()
824        };
825
826        if ranges.is_empty() {
827            return;
828        }
829
830        // Copy text from all ranges
831        let mut text = String::new();
832        let state = self.active_state_mut();
833        for range in ranges {
834            if !text.is_empty() {
835                text.push('\n');
836            }
837            let range_text = state.get_text_range(range.start, range.end);
838            text.push_str(&range_text);
839        }
840
841        if !text.is_empty() {
842            let len = text.len();
843            self.clipboard.copy(text);
844            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
845        }
846    }
847
848    /// Yank (copy) from cursor to vim word end (inclusive)
849    pub fn yank_vi_word_end(&mut self) {
850        let cursor_positions: Vec<_> = self
851            .active_cursors()
852            .iter()
853            .map(|(_, c)| c.position)
854            .collect();
855        let ranges: Vec<_> = {
856            let state = self.active_state();
857            cursor_positions
858                .into_iter()
859                .filter_map(|start| {
860                    let word_end = find_vi_word_end(&state.buffer, start);
861                    let end = (word_end + 1).min(state.buffer.len());
862                    if end > start {
863                        Some(start..end)
864                    } else {
865                        None
866                    }
867                })
868                .collect()
869        };
870
871        if ranges.is_empty() {
872            return;
873        }
874
875        let mut text = String::new();
876        let state = self.active_state_mut();
877        for range in ranges {
878            if !text.is_empty() {
879                text.push('\n');
880            }
881            let range_text = state.get_text_range(range.start, range.end);
882            text.push_str(&range_text);
883        }
884
885        if !text.is_empty() {
886            let len = text.len();
887            self.clipboard.copy(text);
888            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
889        }
890    }
891
892    /// Yank (copy) from previous word start to cursor
893    pub fn yank_word_backward(&mut self) {
894        let cursor_positions: Vec<_> = self
895            .active_cursors()
896            .iter()
897            .map(|(_, c)| c.position)
898            .collect();
899        let ranges: Vec<_> = {
900            let state = self.active_state();
901            cursor_positions
902                .into_iter()
903                .filter_map(|end| {
904                    let start = find_word_start_left(&state.buffer, end);
905                    if start < end {
906                        Some(start..end)
907                    } else {
908                        None
909                    }
910                })
911                .collect()
912        };
913
914        if ranges.is_empty() {
915            return;
916        }
917
918        let mut text = String::new();
919        let state = self.active_state_mut();
920        for range in ranges {
921            if !text.is_empty() {
922                text.push('\n');
923            }
924            let range_text = state.get_text_range(range.start, range.end);
925            text.push_str(&range_text);
926        }
927
928        if !text.is_empty() {
929            let len = text.len();
930            self.clipboard.copy(text);
931            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
932        }
933    }
934
935    /// Yank (copy) from cursor to end of line
936    pub fn yank_to_line_end(&mut self) {
937        let estimated_line_length = 80;
938
939        // First collect cursor positions with immutable borrow
940        let cursor_positions: Vec<_> = self
941            .active_cursors()
942            .iter()
943            .map(|(_, cursor)| cursor.position)
944            .collect();
945
946        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
947        let state = self.active_state_mut();
948        let mut ranges = Vec::new();
949        for pos in cursor_positions {
950            let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
951            let line_start = iter.current_position();
952            if let Some((_start, content)) = iter.next_line() {
953                // Don't include the line ending in yank
954                let content_len = content.trim_end_matches(&['\n', '\r'][..]).len();
955                let line_end = line_start + content_len;
956                if pos < line_end {
957                    ranges.push(pos..line_end);
958                }
959            }
960        }
961
962        if ranges.is_empty() {
963            return;
964        }
965
966        let mut text = String::new();
967        for range in ranges {
968            if !text.is_empty() {
969                text.push('\n');
970            }
971            let range_text = state.get_text_range(range.start, range.end);
972            text.push_str(&range_text);
973        }
974
975        if !text.is_empty() {
976            let len = text.len();
977            self.clipboard.copy(text);
978            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
979        }
980    }
981
982    /// Yank (copy) from start of line to cursor
983    pub fn yank_to_line_start(&mut self) {
984        let estimated_line_length = 80;
985
986        // First collect cursor positions with immutable borrow
987        let cursor_positions: Vec<_> = self
988            .active_cursors()
989            .iter()
990            .map(|(_, cursor)| cursor.position)
991            .collect();
992
993        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
994        let state = self.active_state_mut();
995        let mut ranges = Vec::new();
996        for pos in cursor_positions {
997            let iter = state.buffer.line_iterator(pos, estimated_line_length);
998            let line_start = iter.current_position();
999            if pos > line_start {
1000                ranges.push(line_start..pos);
1001            }
1002        }
1003
1004        if ranges.is_empty() {
1005            return;
1006        }
1007
1008        let mut text = String::new();
1009        for range in ranges {
1010            if !text.is_empty() {
1011                text.push('\n');
1012            }
1013            let range_text = state.get_text_range(range.start, range.end);
1014            text.push_str(&range_text);
1015        }
1016
1017        if !text.is_empty() {
1018            let len = text.len();
1019            self.clipboard.copy(text);
1020            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
1021        }
1022    }
1023}