Skip to main content

fresh/app/
clipboard.rs

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