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        let current_theme_key = &self.config.theme.0;
332
333        // Find the index of the current theme (match by key first, then name)
334        let current_index = available_themes
335            .iter()
336            .position(|info| info.key == *current_theme_key)
337            .or_else(|| {
338                let normalized = crate::view::theme::normalize_theme_name(current_theme_key);
339                available_themes.iter().position(|info| {
340                    crate::view::theme::normalize_theme_name(&info.name) == normalized
341                })
342            })
343            .unwrap_or(0);
344
345        let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
346            .iter()
347            .map(|info| {
348                let is_current = Some(info) == available_themes.get(current_index);
349                let description = if is_current {
350                    Some(format!("{} (current)", info.key))
351                } else {
352                    Some(info.key.clone())
353                };
354                crate::input::commands::Suggestion {
355                    text: info.name.clone(),
356                    description,
357                    value: Some(info.key.clone()),
358                    disabled: false,
359                    keybinding: None,
360                    source: None,
361                }
362            })
363            .collect();
364
365        self.prompt = Some(crate::view::prompt::Prompt::with_suggestions(
366            "Copy with theme: ".to_string(),
367            PromptType::CopyWithFormattingTheme,
368            suggestions,
369        ));
370
371        if let Some(prompt) = self.prompt.as_mut() {
372            if !prompt.suggestions.is_empty() {
373                prompt.selected_suggestion = Some(current_index);
374                prompt.input = current_theme_key.to_string();
375                prompt.cursor_pos = prompt.input.len();
376            }
377        }
378    }
379
380    /// Cut the current selection to clipboard
381    ///
382    /// If no selection exists, cuts the entire current line (like VSCode/Rider/Zed).
383    pub fn cut_selection(&mut self) {
384        // Check if any cursor has a selection
385        let has_selection = self
386            .active_cursors()
387            .iter()
388            .any(|(_, cursor)| cursor.selection_range().is_some());
389
390        // Copy first (this handles both selection and whole-line cases)
391        self.copy_selection();
392
393        if has_selection {
394            // Delete selected text from all cursors
395            // IMPORTANT: Sort deletions by position to ensure we process from end to start
396            let mut deletions: Vec<_> = self
397                .active_cursors()
398                .iter()
399                .filter_map(|(_, c)| c.selection_range())
400                .collect();
401            // Sort by start position so reverse iteration processes from end to start
402            deletions.sort_by_key(|r| r.start);
403
404            let primary_id = self.active_cursors().primary_id();
405            let state = self.active_state_mut();
406            let events: Vec<_> = deletions
407                .iter()
408                .rev()
409                .map(|range| {
410                    let deleted_text = state.get_text_range(range.start, range.end);
411                    Event::Delete {
412                        range: range.clone(),
413                        deleted_text,
414                        cursor_id: primary_id,
415                    }
416                })
417                .collect();
418
419            // Apply events with atomic undo using bulk edit for O(n) performance
420            if events.len() > 1 {
421                // Use optimized bulk edit for multi-cursor cut
422                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Cut".to_string()) {
423                    self.active_event_log_mut().append(bulk_edit);
424                }
425            } else if let Some(event) = events.into_iter().next() {
426                self.log_and_apply_event(&event);
427            }
428
429            if !deletions.is_empty() {
430                self.status_message = Some(t!("clipboard.cut").to_string());
431            }
432        } else {
433            // No selection: delete entire line(s) for each cursor
434            let estimated_line_length = 80;
435
436            // Collect line ranges for each cursor
437            // IMPORTANT: Sort deletions by position to ensure we process from end to start
438            let positions: Vec<_> = self
439                .active_cursors()
440                .iter()
441                .map(|(_, c)| c.position)
442                .collect();
443            let mut deletions: Vec<_> = {
444                let state = self.active_state_mut();
445                positions
446                    .into_iter()
447                    .filter_map(|pos| {
448                        let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
449                        let line_start = iter.current_position();
450                        iter.next_line().map(|(_start, content)| {
451                            let line_end = line_start + content.len();
452                            line_start..line_end
453                        })
454                    })
455                    .collect()
456            };
457            // Sort by start position so reverse iteration processes from end to start
458            deletions.sort_by_key(|r| r.start);
459
460            let primary_id = self.active_cursors().primary_id();
461            let state = self.active_state_mut();
462            let events: Vec<_> = deletions
463                .iter()
464                .rev()
465                .map(|range| {
466                    let deleted_text = state.get_text_range(range.start, range.end);
467                    Event::Delete {
468                        range: range.clone(),
469                        deleted_text,
470                        cursor_id: primary_id,
471                    }
472                })
473                .collect();
474
475            // Apply events with atomic undo using bulk edit for O(n) performance
476            if events.len() > 1 {
477                // Use optimized bulk edit for multi-cursor cut
478                if let Some(bulk_edit) =
479                    self.apply_events_as_bulk_edit(events, "Cut line".to_string())
480                {
481                    self.active_event_log_mut().append(bulk_edit);
482                }
483            } else if let Some(event) = events.into_iter().next() {
484                self.log_and_apply_event(&event);
485            }
486
487            if !deletions.is_empty() {
488                self.status_message = Some(t!("clipboard.cut_line").to_string());
489            }
490        }
491    }
492
493    /// Paste the clipboard content at all cursor positions
494    ///
495    /// Handles:
496    /// - Single cursor paste
497    /// - Multi-cursor paste (pastes at each cursor)
498    /// - Selection replacement (deletes selection before inserting)
499    /// - Atomic undo (single undo step for entire operation)
500    pub fn paste(&mut self) {
501        // Get content from clipboard (tries system first, falls back to internal)
502        let text = match self.clipboard.paste() {
503            Some(text) => text,
504            None => return,
505        };
506
507        // Use paste_text which handles line ending normalization
508        self.paste_text(text);
509    }
510
511    /// Paste text directly into the editor
512    ///
513    /// Handles:
514    /// - Line ending normalization (CRLF/CR → buffer's format)
515    /// - Single cursor paste
516    /// - Multi-cursor paste (pastes at each cursor)
517    /// - Selection replacement (deletes selection before inserting)
518    /// - Atomic undo (single undo step for entire operation)
519    /// - Routing to prompt if one is open
520    pub fn paste_text(&mut self, paste_text: String) {
521        if paste_text.is_empty() {
522            return;
523        }
524
525        // Normalize line endings: first convert all to LF, then to buffer's format
526        // This handles Windows clipboard (CRLF), old Mac (CR), and Unix (LF)
527        let normalized = paste_text.replace("\r\n", "\n").replace('\r', "\n");
528
529        // If a prompt is open, paste into the prompt (prompts use LF internally)
530        if let Some(prompt) = self.prompt.as_mut() {
531            prompt.insert_str(&normalized);
532            self.update_prompt_suggestions();
533            self.status_message = Some(t!("clipboard.pasted").to_string());
534            return;
535        }
536
537        // If in terminal mode, send paste to the terminal PTY
538        if self.terminal_mode {
539            self.send_terminal_input(normalized.as_bytes());
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 mut cursor_data: Vec<_> = self
555            .active_cursors()
556            .iter()
557            .map(|(cursor_id, cursor)| {
558                let selection = cursor.selection_range();
559                let insert_position = selection
560                    .as_ref()
561                    .map(|r| r.start)
562                    .unwrap_or(cursor.position);
563                (cursor_id, selection, insert_position)
564            })
565            .collect();
566        cursor_data.sort_by_key(|(_, _, pos)| std::cmp::Reverse(*pos));
567
568        // Get deleted text for each selection
569        let cursor_data_with_text: Vec<_> = {
570            let state = self.active_state_mut();
571            cursor_data
572                .into_iter()
573                .map(|(cursor_id, selection, insert_position)| {
574                    let deleted_text = selection
575                        .as_ref()
576                        .map(|r| state.get_text_range(r.start, r.end));
577                    (cursor_id, selection, insert_position, deleted_text)
578                })
579                .collect()
580        };
581
582        // Build events for each cursor
583        for (cursor_id, selection, insert_position, deleted_text) in cursor_data_with_text {
584            if let (Some(range), Some(text)) = (selection, deleted_text) {
585                events.push(Event::Delete {
586                    range,
587                    deleted_text: text,
588                    cursor_id,
589                });
590            }
591            events.push(Event::Insert {
592                position: insert_position,
593                text: paste_text.clone(),
594                cursor_id,
595            });
596        }
597
598        // Apply events with atomic undo using bulk edit for O(n) performance
599        if events.len() > 1 {
600            // Use optimized bulk edit for multi-cursor paste
601            if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Paste".to_string()) {
602                self.active_event_log_mut().append(bulk_edit);
603            }
604        } else if let Some(event) = events.into_iter().next() {
605            self.log_and_apply_event(&event);
606        }
607
608        self.status_message = Some(t!("clipboard.pasted").to_string());
609    }
610
611    /// Set clipboard content for testing purposes
612    /// This sets the internal clipboard and enables internal-only mode to avoid
613    /// system clipboard interference between parallel tests
614    #[doc(hidden)]
615    pub fn set_clipboard_for_test(&mut self, text: String) {
616        self.clipboard.set_internal(text);
617        self.clipboard.set_internal_only(true);
618    }
619
620    /// Paste from internal clipboard only (for testing)
621    /// This bypasses the system clipboard to avoid interference from CI environments
622    #[doc(hidden)]
623    pub fn paste_for_test(&mut self) {
624        // Get content from internal clipboard only (ignores system clipboard)
625        let paste_text = match self.clipboard.paste_internal() {
626            Some(text) => text,
627            None => return,
628        };
629
630        // Use the same paste logic as the regular paste method
631        self.paste_text(paste_text);
632    }
633
634    /// Get clipboard content for testing purposes
635    /// Returns the internal clipboard content
636    #[doc(hidden)]
637    pub fn clipboard_content_for_test(&self) -> String {
638        self.clipboard.get_internal().to_string()
639    }
640
641    /// Add a cursor at the next occurrence of the selected text
642    /// If no selection, first selects the entire word at cursor position
643    pub fn add_cursor_at_next_match(&mut self) {
644        let cursors = self.active_cursors().clone();
645        let state = self.active_state_mut();
646        match add_cursor_at_next_match(state, &cursors) {
647            AddCursorResult::Success {
648                cursor,
649                total_cursors,
650            } => {
651                // Create AddCursor event with the next cursor ID
652                let next_id = CursorId(self.active_cursors().count());
653                let event = Event::AddCursor {
654                    cursor_id: next_id,
655                    position: cursor.position,
656                    anchor: cursor.anchor,
657                };
658
659                // Log and apply the event
660                self.active_event_log_mut().append(event.clone());
661                self.apply_event_to_active_buffer(&event);
662
663                self.status_message =
664                    Some(t!("clipboard.added_cursor_match", count = total_cursors).to_string());
665            }
666            AddCursorResult::WordSelected {
667                word_start,
668                word_end,
669            } => {
670                // Select the word by updating the primary cursor
671                let primary_id = self.active_cursors().primary_id();
672                let primary = self.active_cursors().primary();
673                let event = Event::MoveCursor {
674                    cursor_id: primary_id,
675                    old_position: primary.position,
676                    new_position: word_end,
677                    old_anchor: primary.anchor,
678                    new_anchor: Some(word_start),
679                    old_sticky_column: primary.sticky_column,
680                    new_sticky_column: 0,
681                };
682
683                // Log and apply the event
684                self.active_event_log_mut().append(event.clone());
685                self.apply_event_to_active_buffer(&event);
686            }
687            AddCursorResult::Failed { message } => {
688                self.status_message = Some(message);
689            }
690        }
691    }
692
693    /// Add a cursor above the primary cursor at the same column
694    pub fn add_cursor_above(&mut self) {
695        let cursors = self.active_cursors().clone();
696        let state = self.active_state_mut();
697        match add_cursor_above(state, &cursors) {
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_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 cursors = self.active_cursors().clone();
727        let state = self.active_state_mut();
728        match add_cursor_below(state, &cursors) {
729            AddCursorResult::Success {
730                cursor,
731                total_cursors,
732            } => {
733                // Create AddCursor event with the next cursor ID
734                let next_id = CursorId(self.active_cursors().count());
735                let event = Event::AddCursor {
736                    cursor_id: next_id,
737                    position: cursor.position,
738                    anchor: cursor.anchor,
739                };
740
741                // Log and apply the event
742                self.active_event_log_mut().append(event.clone());
743                self.apply_event_to_active_buffer(&event);
744
745                self.status_message =
746                    Some(t!("clipboard.added_cursor_below", count = total_cursors).to_string());
747            }
748            AddCursorResult::Failed { message } => {
749                self.status_message = Some(message);
750            }
751            AddCursorResult::WordSelected { .. } => unreachable!(),
752        }
753    }
754
755    // =========================================================================
756    // Vi-style yank operations (copy range without requiring selection)
757    // =========================================================================
758
759    /// Yank (copy) from cursor to next word start
760    pub fn yank_word_forward(&mut self) {
761        let cursor_positions: Vec<_> = self
762            .active_cursors()
763            .iter()
764            .map(|(_, c)| c.position)
765            .collect();
766        let ranges: Vec<_> = {
767            let state = self.active_state();
768            cursor_positions
769                .into_iter()
770                .filter_map(|start| {
771                    let end = find_word_start_right(&state.buffer, start);
772                    if end > start {
773                        Some(start..end)
774                    } else {
775                        None
776                    }
777                })
778                .collect()
779        };
780
781        if ranges.is_empty() {
782            return;
783        }
784
785        // Copy text from all ranges
786        let mut text = String::new();
787        let state = self.active_state_mut();
788        for range in ranges {
789            if !text.is_empty() {
790                text.push('\n');
791            }
792            let range_text = state.get_text_range(range.start, range.end);
793            text.push_str(&range_text);
794        }
795
796        if !text.is_empty() {
797            let len = text.len();
798            self.clipboard.copy(text);
799            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
800        }
801    }
802
803    /// Yank (copy) from cursor to vim word end (inclusive)
804    pub fn yank_vi_word_end(&mut self) {
805        let cursor_positions: Vec<_> = self
806            .active_cursors()
807            .iter()
808            .map(|(_, c)| c.position)
809            .collect();
810        let ranges: Vec<_> = {
811            let state = self.active_state();
812            cursor_positions
813                .into_iter()
814                .filter_map(|start| {
815                    let word_end = find_vi_word_end(&state.buffer, start);
816                    let end = (word_end + 1).min(state.buffer.len());
817                    if end > start {
818                        Some(start..end)
819                    } else {
820                        None
821                    }
822                })
823                .collect()
824        };
825
826        if ranges.is_empty() {
827            return;
828        }
829
830        let mut text = String::new();
831        let state = self.active_state_mut();
832        for range in ranges {
833            if !text.is_empty() {
834                text.push('\n');
835            }
836            let range_text = state.get_text_range(range.start, range.end);
837            text.push_str(&range_text);
838        }
839
840        if !text.is_empty() {
841            let len = text.len();
842            self.clipboard.copy(text);
843            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
844        }
845    }
846
847    /// Yank (copy) from previous word start to cursor
848    pub fn yank_word_backward(&mut self) {
849        let cursor_positions: Vec<_> = self
850            .active_cursors()
851            .iter()
852            .map(|(_, c)| c.position)
853            .collect();
854        let ranges: Vec<_> = {
855            let state = self.active_state();
856            cursor_positions
857                .into_iter()
858                .filter_map(|end| {
859                    let start = find_word_start_left(&state.buffer, end);
860                    if start < end {
861                        Some(start..end)
862                    } else {
863                        None
864                    }
865                })
866                .collect()
867        };
868
869        if ranges.is_empty() {
870            return;
871        }
872
873        let mut text = String::new();
874        let state = self.active_state_mut();
875        for range in ranges {
876            if !text.is_empty() {
877                text.push('\n');
878            }
879            let range_text = state.get_text_range(range.start, range.end);
880            text.push_str(&range_text);
881        }
882
883        if !text.is_empty() {
884            let len = text.len();
885            self.clipboard.copy(text);
886            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
887        }
888    }
889
890    /// Yank (copy) from cursor to end of line
891    pub fn yank_to_line_end(&mut self) {
892        let estimated_line_length = 80;
893
894        // First collect cursor positions with immutable borrow
895        let cursor_positions: Vec<_> = self
896            .active_cursors()
897            .iter()
898            .map(|(_, cursor)| cursor.position)
899            .collect();
900
901        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
902        let state = self.active_state_mut();
903        let mut ranges = Vec::new();
904        for pos in cursor_positions {
905            let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
906            let line_start = iter.current_position();
907            if let Some((_start, content)) = iter.next_line() {
908                // Don't include the line ending in yank
909                let content_len = content.trim_end_matches(&['\n', '\r'][..]).len();
910                let line_end = line_start + content_len;
911                if pos < line_end {
912                    ranges.push(pos..line_end);
913                }
914            }
915        }
916
917        if ranges.is_empty() {
918            return;
919        }
920
921        let mut text = String::new();
922        for range in ranges {
923            if !text.is_empty() {
924                text.push('\n');
925            }
926            let range_text = state.get_text_range(range.start, range.end);
927            text.push_str(&range_text);
928        }
929
930        if !text.is_empty() {
931            let len = text.len();
932            self.clipboard.copy(text);
933            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
934        }
935    }
936
937    /// Yank (copy) from start of line to cursor
938    pub fn yank_to_line_start(&mut self) {
939        let estimated_line_length = 80;
940
941        // First collect cursor positions with immutable borrow
942        let cursor_positions: Vec<_> = self
943            .active_cursors()
944            .iter()
945            .map(|(_, cursor)| cursor.position)
946            .collect();
947
948        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
949        let state = self.active_state_mut();
950        let mut ranges = Vec::new();
951        for pos in cursor_positions {
952            let iter = state.buffer.line_iterator(pos, estimated_line_length);
953            let line_start = iter.current_position();
954            if pos > line_start {
955                ranges.push(line_start..pos);
956            }
957        }
958
959        if ranges.is_empty() {
960            return;
961        }
962
963        let mut text = String::new();
964        for range in ranges {
965            if !text.is_empty() {
966                text.push('\n');
967            }
968            let range_text = state.get_text_range(range.start, range.end);
969            text.push_str(&range_text);
970        }
971
972        if !text.is_empty() {
973            let len = text.len();
974            self.clipboard.copy(text);
975            self.status_message = Some(t!("clipboard.yanked", count = len).to_string());
976        }
977    }
978}