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    /// Add a cursor at the next occurrence of the selected text
648    /// If no selection, first selects the entire word at cursor position
649    pub fn add_cursor_at_next_match(&mut self) {
650        let cursors = self.active_cursors().clone();
651        let state = self.active_state_mut();
652        match add_cursor_at_next_match(state, &cursors) {
653            AddCursorResult::Success {
654                cursor,
655                total_cursors,
656            } => {
657                // Create AddCursor event with the next cursor ID
658                let next_id = CursorId(self.active_cursors().count());
659                let event = Event::AddCursor {
660                    cursor_id: next_id,
661                    position: cursor.position,
662                    anchor: cursor.anchor,
663                };
664
665                // Log and apply the event
666                self.active_event_log_mut().append(event.clone());
667                self.apply_event_to_active_buffer(&event);
668
669                self.status_message =
670                    Some(t!("clipboard.added_cursor_match", count = total_cursors).to_string());
671            }
672            AddCursorResult::WordSelected {
673                word_start,
674                word_end,
675            } => {
676                // Select the word by updating the primary cursor
677                let primary_id = self.active_cursors().primary_id();
678                let primary = self.active_cursors().primary();
679                let event = Event::MoveCursor {
680                    cursor_id: primary_id,
681                    old_position: primary.position,
682                    new_position: word_end,
683                    old_anchor: primary.anchor,
684                    new_anchor: Some(word_start),
685                    old_sticky_column: primary.sticky_column,
686                    new_sticky_column: 0,
687                };
688
689                // Log and apply the event
690                self.active_event_log_mut().append(event.clone());
691                self.apply_event_to_active_buffer(&event);
692            }
693            AddCursorResult::Failed { message } => {
694                self.status_message = Some(message);
695            }
696        }
697    }
698
699    /// Add a cursor above the primary cursor at the same column
700    pub fn add_cursor_above(&mut self) {
701        let cursors = self.active_cursors().clone();
702        let state = self.active_state_mut();
703        match add_cursor_above(state, &cursors) {
704            AddCursorResult::Success {
705                cursor,
706                total_cursors,
707            } => {
708                // Create AddCursor event with the next cursor ID
709                let next_id = CursorId(self.active_cursors().count());
710                let event = Event::AddCursor {
711                    cursor_id: next_id,
712                    position: cursor.position,
713                    anchor: cursor.anchor,
714                };
715
716                // Log and apply the event
717                self.active_event_log_mut().append(event.clone());
718                self.apply_event_to_active_buffer(&event);
719
720                self.status_message =
721                    Some(t!("clipboard.added_cursor_above", count = total_cursors).to_string());
722            }
723            AddCursorResult::Failed { message } => {
724                self.status_message = Some(message);
725            }
726            AddCursorResult::WordSelected { .. } => unreachable!(),
727        }
728    }
729
730    /// Add a cursor below the primary cursor at the same column
731    pub fn add_cursor_below(&mut self) {
732        let cursors = self.active_cursors().clone();
733        let state = self.active_state_mut();
734        match add_cursor_below(state, &cursors) {
735            AddCursorResult::Success {
736                cursor,
737                total_cursors,
738            } => {
739                // Create AddCursor event with the next cursor ID
740                let next_id = CursorId(self.active_cursors().count());
741                let event = Event::AddCursor {
742                    cursor_id: next_id,
743                    position: cursor.position,
744                    anchor: cursor.anchor,
745                };
746
747                // Log and apply the event
748                self.active_event_log_mut().append(event.clone());
749                self.apply_event_to_active_buffer(&event);
750
751                self.status_message =
752                    Some(t!("clipboard.added_cursor_below", count = total_cursors).to_string());
753            }
754            AddCursorResult::Failed { message } => {
755                self.status_message = Some(message);
756            }
757            AddCursorResult::WordSelected { .. } => unreachable!(),
758        }
759    }
760
761    // =========================================================================
762    // Vi-style yank operations (copy range without requiring selection)
763    // =========================================================================
764
765    /// Yank (copy) from cursor to next word start
766    pub fn yank_word_forward(&mut self) {
767        let cursor_positions: Vec<_> = self
768            .active_cursors()
769            .iter()
770            .map(|(_, c)| c.position)
771            .collect();
772        let ranges: Vec<_> = {
773            let state = self.active_state();
774            cursor_positions
775                .into_iter()
776                .filter_map(|start| {
777                    let end = find_word_start_right(&state.buffer, start);
778                    if end > start {
779                        Some(start..end)
780                    } else {
781                        None
782                    }
783                })
784                .collect()
785        };
786
787        if ranges.is_empty() {
788            return;
789        }
790
791        // Copy text from all ranges
792        let mut text = String::new();
793        let state = self.active_state_mut();
794        for range in ranges {
795            if !text.is_empty() {
796                text.push('\n');
797            }
798            let range_text = state.get_text_range(range.start, range.end);
799            text.push_str(&range_text);
800        }
801
802        if !text.is_empty() {
803            let len = text.len();
804            self.clipboard.copy(text);
805            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
806        }
807    }
808
809    /// Yank (copy) from cursor to vim word end (inclusive)
810    pub fn yank_vi_word_end(&mut self) {
811        let cursor_positions: Vec<_> = self
812            .active_cursors()
813            .iter()
814            .map(|(_, c)| c.position)
815            .collect();
816        let ranges: Vec<_> = {
817            let state = self.active_state();
818            cursor_positions
819                .into_iter()
820                .filter_map(|start| {
821                    let word_end = find_vi_word_end(&state.buffer, start);
822                    let end = (word_end + 1).min(state.buffer.len());
823                    if end > start {
824                        Some(start..end)
825                    } else {
826                        None
827                    }
828                })
829                .collect()
830        };
831
832        if ranges.is_empty() {
833            return;
834        }
835
836        let mut text = String::new();
837        let state = self.active_state_mut();
838        for range in ranges {
839            if !text.is_empty() {
840                text.push('\n');
841            }
842            let range_text = state.get_text_range(range.start, range.end);
843            text.push_str(&range_text);
844        }
845
846        if !text.is_empty() {
847            let len = text.len();
848            self.clipboard.copy(text);
849            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
850        }
851    }
852
853    /// Yank (copy) from previous word start to cursor
854    pub fn yank_word_backward(&mut self) {
855        let cursor_positions: Vec<_> = self
856            .active_cursors()
857            .iter()
858            .map(|(_, c)| c.position)
859            .collect();
860        let ranges: Vec<_> = {
861            let state = self.active_state();
862            cursor_positions
863                .into_iter()
864                .filter_map(|end| {
865                    let start = find_word_start_left(&state.buffer, end);
866                    if start < end {
867                        Some(start..end)
868                    } else {
869                        None
870                    }
871                })
872                .collect()
873        };
874
875        if ranges.is_empty() {
876            return;
877        }
878
879        let mut text = String::new();
880        let state = self.active_state_mut();
881        for range in ranges {
882            if !text.is_empty() {
883                text.push('\n');
884            }
885            let range_text = state.get_text_range(range.start, range.end);
886            text.push_str(&range_text);
887        }
888
889        if !text.is_empty() {
890            let len = text.len();
891            self.clipboard.copy(text);
892            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
893        }
894    }
895
896    /// Yank (copy) from cursor to end of line
897    pub fn yank_to_line_end(&mut self) {
898        let estimated_line_length = 80;
899
900        // First collect cursor positions with immutable borrow
901        let cursor_positions: Vec<_> = self
902            .active_cursors()
903            .iter()
904            .map(|(_, cursor)| cursor.position)
905            .collect();
906
907        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
908        let state = self.active_state_mut();
909        let mut ranges = Vec::new();
910        for pos in cursor_positions {
911            let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
912            let line_start = iter.current_position();
913            if let Some((_start, content)) = iter.next_line() {
914                // Don't include the line ending in yank
915                let content_len = content.trim_end_matches(&['\n', '\r'][..]).len();
916                let line_end = line_start + content_len;
917                if pos < line_end {
918                    ranges.push(pos..line_end);
919                }
920            }
921        }
922
923        if ranges.is_empty() {
924            return;
925        }
926
927        let mut text = String::new();
928        for range in ranges {
929            if !text.is_empty() {
930                text.push('\n');
931            }
932            let range_text = state.get_text_range(range.start, range.end);
933            text.push_str(&range_text);
934        }
935
936        if !text.is_empty() {
937            let len = text.len();
938            self.clipboard.copy(text);
939            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
940        }
941    }
942
943    /// Yank (copy) from start of line to cursor
944    pub fn yank_to_line_start(&mut self) {
945        let estimated_line_length = 80;
946
947        // First collect cursor positions with immutable borrow
948        let cursor_positions: Vec<_> = self
949            .active_cursors()
950            .iter()
951            .map(|(_, cursor)| cursor.position)
952            .collect();
953
954        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
955        let state = self.active_state_mut();
956        let mut ranges = Vec::new();
957        for pos in cursor_positions {
958            let iter = state.buffer.line_iterator(pos, estimated_line_length);
959            let line_start = iter.current_position();
960            if pos > line_start {
961                ranges.push(line_start..pos);
962            }
963        }
964
965        if ranges.is_empty() {
966            return;
967        }
968
969        let mut text = String::new();
970        for range in ranges {
971            if !text.is_empty() {
972                text.push('\n');
973            }
974            let range_text = state.get_text_range(range.start, range.end);
975            text.push_str(&range_text);
976        }
977
978        if !text.is_empty() {
979            let len = text.len();
980            self.clipboard.copy(text);
981            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
982        }
983    }
984}