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_name = &self.theme.name;
341
342        // Find the index of the current theme
343        let current_index = available_themes
344            .iter()
345            .position(|info| info.name == *current_theme_name)
346            .unwrap_or(0);
347
348        let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
349            .iter()
350            .map(|info| {
351                let is_current = info.name == *current_theme_name;
352                let description = match (is_current, info.pack.is_empty()) {
353                    (true, true) => Some("(current)".to_string()),
354                    (true, false) => Some(format!("{} (current)", info.pack)),
355                    (false, true) => None,
356                    (false, false) => Some(info.pack.clone()),
357                };
358                crate::input::commands::Suggestion {
359                    text: info.name.clone(),
360                    description,
361                    value: Some(info.name.clone()),
362                    disabled: false,
363                    keybinding: None,
364                    source: None,
365                }
366            })
367            .collect();
368
369        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
370            "Copy with theme: ".to_string(),
371            PromptType::CopyWithFormattingTheme,
372            suggestions,
373        ));
374
375        if let Some(prompt) = self.prompt.as_mut() {
376            if !prompt.suggestions.is_empty() {
377                prompt.selected_suggestion = Some(current_index);
378                prompt.input = current_theme_name.to_string();
379                prompt.cursor_pos = prompt.input.len();
380            }
381        }
382    }
383
384    /// Cut the current selection to clipboard
385    ///
386    /// If no selection exists, cuts the entire current line (like VSCode/Rider/Zed).
387    pub fn cut_selection(&mut self) {
388        // Check if any cursor has a selection
389        let has_selection = self
390            .active_cursors()
391            .iter()
392            .any(|(_, cursor)| cursor.selection_range().is_some());
393
394        // Copy first (this handles both selection and whole-line cases)
395        self.copy_selection();
396
397        if has_selection {
398            // Delete selected text from all cursors
399            // IMPORTANT: Sort deletions by position to ensure we process from end to start
400            let mut deletions: Vec<_> = self
401                .active_cursors()
402                .iter()
403                .filter_map(|(_, c)| c.selection_range())
404                .collect();
405            // Sort by start position so reverse iteration processes from end to start
406            deletions.sort_by_key(|r| r.start);
407
408            let primary_id = self.active_cursors().primary_id();
409            let state = self.active_state_mut();
410            let events: Vec<_> = deletions
411                .iter()
412                .rev()
413                .map(|range| {
414                    let deleted_text = state.get_text_range(range.start, range.end);
415                    Event::Delete {
416                        range: range.clone(),
417                        deleted_text,
418                        cursor_id: primary_id,
419                    }
420                })
421                .collect();
422
423            // Apply events with atomic undo using bulk edit for O(n) performance
424            if events.len() > 1 {
425                // Use optimized bulk edit for multi-cursor cut
426                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Cut".to_string()) {
427                    self.active_event_log_mut().append(bulk_edit);
428                }
429            } else if let Some(event) = events.into_iter().next() {
430                self.log_and_apply_event(&event);
431            }
432
433            if !deletions.is_empty() {
434                self.status_message = Some(t!("clipboard.cut").to_string());
435            }
436        } else {
437            // No selection: delete entire line(s) for each cursor
438            let estimated_line_length = 80;
439
440            // Collect line ranges for each cursor
441            // IMPORTANT: Sort deletions by position to ensure we process from end to start
442            let positions: Vec<_> = self
443                .active_cursors()
444                .iter()
445                .map(|(_, c)| c.position)
446                .collect();
447            let mut deletions: Vec<_> = {
448                let state = self.active_state_mut();
449                positions
450                    .into_iter()
451                    .filter_map(|pos| {
452                        let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
453                        let line_start = iter.current_position();
454                        iter.next_line().map(|(_start, content)| {
455                            let line_end = line_start + content.len();
456                            line_start..line_end
457                        })
458                    })
459                    .collect()
460            };
461            // Sort by start position so reverse iteration processes from end to start
462            deletions.sort_by_key(|r| r.start);
463
464            let primary_id = self.active_cursors().primary_id();
465            let state = self.active_state_mut();
466            let events: Vec<_> = deletions
467                .iter()
468                .rev()
469                .map(|range| {
470                    let deleted_text = state.get_text_range(range.start, range.end);
471                    Event::Delete {
472                        range: range.clone(),
473                        deleted_text,
474                        cursor_id: primary_id,
475                    }
476                })
477                .collect();
478
479            // Apply events with atomic undo using bulk edit for O(n) performance
480            if events.len() > 1 {
481                // Use optimized bulk edit for multi-cursor cut
482                if let Some(bulk_edit) =
483                    self.apply_events_as_bulk_edit(events, "Cut line".to_string())
484                {
485                    self.active_event_log_mut().append(bulk_edit);
486                }
487            } else if let Some(event) = events.into_iter().next() {
488                self.log_and_apply_event(&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.log_and_apply_event(&event);
610        }
611
612        self.status_message = Some(t!("clipboard.pasted").to_string());
613    }
614
615    /// Set clipboard content for testing purposes
616    /// This sets the internal clipboard and enables internal-only mode to avoid
617    /// system clipboard interference between parallel tests
618    #[doc(hidden)]
619    pub fn set_clipboard_for_test(&mut self, text: String) {
620        self.clipboard.set_internal(text);
621        self.clipboard.set_internal_only(true);
622    }
623
624    /// Paste from internal clipboard only (for testing)
625    /// This bypasses the system clipboard to avoid interference from CI environments
626    #[doc(hidden)]
627    pub fn paste_for_test(&mut self) {
628        // Get content from internal clipboard only (ignores system clipboard)
629        let paste_text = match self.clipboard.paste_internal() {
630            Some(text) => text,
631            None => return,
632        };
633
634        // Use the same paste logic as the regular paste method
635        self.paste_text(paste_text);
636    }
637
638    /// Get clipboard content for testing purposes
639    /// Returns the internal clipboard content
640    #[doc(hidden)]
641    pub fn clipboard_content_for_test(&self) -> String {
642        self.clipboard.get_internal().to_string()
643    }
644
645    /// Add a cursor at the next occurrence of the selected text
646    /// If no selection, first selects the entire word at cursor position
647    pub fn add_cursor_at_next_match(&mut self) {
648        let cursors = self.active_cursors().clone();
649        let state = self.active_state_mut();
650        match add_cursor_at_next_match(state, &cursors) {
651            AddCursorResult::Success {
652                cursor,
653                total_cursors,
654            } => {
655                // Create AddCursor event with the next cursor ID
656                let next_id = CursorId(self.active_cursors().count());
657                let event = Event::AddCursor {
658                    cursor_id: next_id,
659                    position: cursor.position,
660                    anchor: cursor.anchor,
661                };
662
663                // Log and apply the event
664                self.active_event_log_mut().append(event.clone());
665                self.apply_event_to_active_buffer(&event);
666
667                self.status_message =
668                    Some(t!("clipboard.added_cursor_match", count = total_cursors).to_string());
669            }
670            AddCursorResult::WordSelected {
671                word_start,
672                word_end,
673            } => {
674                // Select the word by updating the primary cursor
675                let primary_id = self.active_cursors().primary_id();
676                let primary = self.active_cursors().primary();
677                let event = Event::MoveCursor {
678                    cursor_id: primary_id,
679                    old_position: primary.position,
680                    new_position: word_end,
681                    old_anchor: primary.anchor,
682                    new_anchor: Some(word_start),
683                    old_sticky_column: primary.sticky_column,
684                    new_sticky_column: 0,
685                };
686
687                // Log and apply the event
688                self.active_event_log_mut().append(event.clone());
689                self.apply_event_to_active_buffer(&event);
690            }
691            AddCursorResult::Failed { message } => {
692                self.status_message = Some(message);
693            }
694        }
695    }
696
697    /// Add a cursor above the primary cursor at the same column
698    pub fn add_cursor_above(&mut self) {
699        let cursors = self.active_cursors().clone();
700        let state = self.active_state_mut();
701        match add_cursor_above(state, &cursors) {
702            AddCursorResult::Success {
703                cursor,
704                total_cursors,
705            } => {
706                // Create AddCursor event with the next cursor ID
707                let next_id = CursorId(self.active_cursors().count());
708                let event = Event::AddCursor {
709                    cursor_id: next_id,
710                    position: cursor.position,
711                    anchor: cursor.anchor,
712                };
713
714                // Log and apply the event
715                self.active_event_log_mut().append(event.clone());
716                self.apply_event_to_active_buffer(&event);
717
718                self.status_message =
719                    Some(t!("clipboard.added_cursor_above", count = total_cursors).to_string());
720            }
721            AddCursorResult::Failed { message } => {
722                self.status_message = Some(message);
723            }
724            AddCursorResult::WordSelected { .. } => unreachable!(),
725        }
726    }
727
728    /// Add a cursor below the primary cursor at the same column
729    pub fn add_cursor_below(&mut self) {
730        let cursors = self.active_cursors().clone();
731        let state = self.active_state_mut();
732        match add_cursor_below(state, &cursors) {
733            AddCursorResult::Success {
734                cursor,
735                total_cursors,
736            } => {
737                // Create AddCursor event with the next cursor ID
738                let next_id = CursorId(self.active_cursors().count());
739                let event = Event::AddCursor {
740                    cursor_id: next_id,
741                    position: cursor.position,
742                    anchor: cursor.anchor,
743                };
744
745                // Log and apply the event
746                self.active_event_log_mut().append(event.clone());
747                self.apply_event_to_active_buffer(&event);
748
749                self.status_message =
750                    Some(t!("clipboard.added_cursor_below", count = total_cursors).to_string());
751            }
752            AddCursorResult::Failed { message } => {
753                self.status_message = Some(message);
754            }
755            AddCursorResult::WordSelected { .. } => unreachable!(),
756        }
757    }
758
759    // =========================================================================
760    // Vi-style yank operations (copy range without requiring selection)
761    // =========================================================================
762
763    /// Yank (copy) from cursor to next word start
764    pub fn yank_word_forward(&mut self) {
765        let cursor_positions: Vec<_> = self
766            .active_cursors()
767            .iter()
768            .map(|(_, c)| c.position)
769            .collect();
770        let ranges: Vec<_> = {
771            let state = self.active_state();
772            cursor_positions
773                .into_iter()
774                .filter_map(|start| {
775                    let end = find_word_start_right(&state.buffer, start);
776                    if end > start {
777                        Some(start..end)
778                    } else {
779                        None
780                    }
781                })
782                .collect()
783        };
784
785        if ranges.is_empty() {
786            return;
787        }
788
789        // Copy text from all ranges
790        let mut text = String::new();
791        let state = self.active_state_mut();
792        for range in ranges {
793            if !text.is_empty() {
794                text.push('\n');
795            }
796            let range_text = state.get_text_range(range.start, range.end);
797            text.push_str(&range_text);
798        }
799
800        if !text.is_empty() {
801            let len = text.len();
802            self.clipboard.copy(text);
803            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
804        }
805    }
806
807    /// Yank (copy) from cursor to vim word end (inclusive)
808    pub fn yank_vi_word_end(&mut self) {
809        let cursor_positions: Vec<_> = self
810            .active_cursors()
811            .iter()
812            .map(|(_, c)| c.position)
813            .collect();
814        let ranges: Vec<_> = {
815            let state = self.active_state();
816            cursor_positions
817                .into_iter()
818                .filter_map(|start| {
819                    let word_end = find_vi_word_end(&state.buffer, start);
820                    let end = (word_end + 1).min(state.buffer.len());
821                    if end > start {
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 previous word start to cursor
852    pub fn yank_word_backward(&mut self) {
853        let cursor_positions: Vec<_> = self
854            .active_cursors()
855            .iter()
856            .map(|(_, c)| c.position)
857            .collect();
858        let ranges: Vec<_> = {
859            let state = self.active_state();
860            cursor_positions
861                .into_iter()
862                .filter_map(|end| {
863                    let start = find_word_start_left(&state.buffer, end);
864                    if start < end {
865                        Some(start..end)
866                    } else {
867                        None
868                    }
869                })
870                .collect()
871        };
872
873        if ranges.is_empty() {
874            return;
875        }
876
877        let mut text = String::new();
878        let state = self.active_state_mut();
879        for range in ranges {
880            if !text.is_empty() {
881                text.push('\n');
882            }
883            let range_text = state.get_text_range(range.start, range.end);
884            text.push_str(&range_text);
885        }
886
887        if !text.is_empty() {
888            let len = text.len();
889            self.clipboard.copy(text);
890            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
891        }
892    }
893
894    /// Yank (copy) from cursor to end of line
895    pub fn yank_to_line_end(&mut self) {
896        let estimated_line_length = 80;
897
898        // First collect cursor positions with immutable borrow
899        let cursor_positions: Vec<_> = self
900            .active_cursors()
901            .iter()
902            .map(|(_, cursor)| cursor.position)
903            .collect();
904
905        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
906        let state = self.active_state_mut();
907        let mut ranges = Vec::new();
908        for pos in cursor_positions {
909            let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
910            let line_start = iter.current_position();
911            if let Some((_start, content)) = iter.next_line() {
912                // Don't include the line ending in yank
913                let content_len = content.trim_end_matches(&['\n', '\r'][..]).len();
914                let line_end = line_start + content_len;
915                if pos < line_end {
916                    ranges.push(pos..line_end);
917                }
918            }
919        }
920
921        if ranges.is_empty() {
922            return;
923        }
924
925        let mut text = String::new();
926        for range in ranges {
927            if !text.is_empty() {
928                text.push('\n');
929            }
930            let range_text = state.get_text_range(range.start, range.end);
931            text.push_str(&range_text);
932        }
933
934        if !text.is_empty() {
935            let len = text.len();
936            self.clipboard.copy(text);
937            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
938        }
939    }
940
941    /// Yank (copy) from start of line to cursor
942    pub fn yank_to_line_start(&mut self) {
943        let estimated_line_length = 80;
944
945        // First collect cursor positions with immutable borrow
946        let cursor_positions: Vec<_> = self
947            .active_cursors()
948            .iter()
949            .map(|(_, cursor)| cursor.position)
950            .collect();
951
952        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
953        let state = self.active_state_mut();
954        let mut ranges = Vec::new();
955        for pos in cursor_positions {
956            let iter = state.buffer.line_iterator(pos, estimated_line_length);
957            let line_start = iter.current_position();
958            if pos > line_start {
959                ranges.push(line_start..pos);
960            }
961        }
962
963        if ranges.is_empty() {
964            return;
965        }
966
967        let mut text = String::new();
968        for range in ranges {
969            if !text.is_empty() {
970                text.push('\n');
971            }
972            let range_text = state.get_text_range(range.start, range.end);
973            text.push_str(&range_text);
974        }
975
976        if !text.is_empty() {
977            let len = text.len();
978            self.clipboard.copy(text);
979            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
980        }
981    }
982}