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