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