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.active_window_mut().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.active_window_mut().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.active_window_mut().status_message =
108                    Some(t!("clipboard.copied_line").to_string());
109            }
110        }
111    }
112
113    /// Extract text from block (rectangular) selection
114    ///
115    /// For block selection, we need to extract a rectangular region defined by:
116    /// - The block anchor (stored as Position2D with line and column)
117    /// - The current cursor position (byte offset, converted to 2D)
118    ///
119    /// This works for both small and large files by using line_iterator
120    /// for iteration and only using 2D positions for column extraction.
121    fn copy_block_selection_text(&mut self) -> String {
122        let estimated_line_length = 120;
123
124        // Collect block selection info from all cursors
125        let block_infos: Vec<_> = self
126            .active_cursors()
127            .iter()
128            .filter_map(|(_, cursor)| {
129                if !cursor.has_block_selection() {
130                    return None;
131                }
132                let block_anchor = cursor.block_anchor?;
133                let anchor_byte = cursor.anchor?; // byte offset of anchor
134                let cursor_byte = cursor.position;
135                Some((block_anchor, anchor_byte, cursor_byte))
136            })
137            .collect();
138
139        let mut result = String::new();
140
141        for (block_anchor, anchor_byte, cursor_byte) in block_infos {
142            // Get current cursor position as 2D
143            let cursor_2d = {
144                let state = self.active_state();
145                byte_to_2d(&state.buffer, cursor_byte)
146            };
147
148            // Calculate column bounds (min and max columns for the rectangle)
149            let min_col = block_anchor.column.min(cursor_2d.column);
150            let max_col = block_anchor.column.max(cursor_2d.column);
151
152            // Calculate line bounds using byte positions
153            let start_byte = anchor_byte.min(cursor_byte);
154            let end_byte = anchor_byte.max(cursor_byte);
155
156            // Use line_iterator to iterate through lines
157            let state = self.active_state_mut();
158            let mut iter = state
159                .buffer
160                .line_iterator(start_byte, estimated_line_length);
161
162            // Collect lines within the block selection range
163            let mut lines_text = Vec::new();
164            loop {
165                let line_start = iter.current_position();
166
167                // Stop if we've passed the end of the selection
168                if line_start > end_byte {
169                    break;
170                }
171
172                if let Some((_offset, line_content)) = iter.next_line() {
173                    // Extract the column range from this line
174                    // Remove trailing newline for column calculation
175                    let content_without_newline = line_content.trim_end_matches(&['\n', '\r'][..]);
176                    let chars: Vec<char> = content_without_newline.chars().collect();
177
178                    // Extract characters from min_col to max_col (exclusive)
179                    let extracted: String = chars
180                        .iter()
181                        .skip(min_col)
182                        .take(max_col.saturating_sub(min_col))
183                        .collect();
184
185                    lines_text.push(extracted);
186
187                    // If this line extends past end_byte, we're done
188                    if line_start + line_content.len() > end_byte {
189                        break;
190                    }
191                } else {
192                    break;
193                }
194            }
195
196            // Join the extracted text from each line
197            if !result.is_empty() && !lines_text.is_empty() {
198                result.push('\n');
199            }
200            result.push_str(&lines_text.join("\n"));
201        }
202
203        result
204    }
205
206    /// Copy selection with a specific theme's formatting
207    ///
208    /// If theme_name is empty, opens a prompt to select a theme.
209    /// Otherwise, copies the selected text as HTML with inline CSS styles.
210    pub fn copy_selection_with_theme(&mut self, theme_name: &str) {
211        // Check if there's a selection first
212        let has_selection = self
213            .active_cursors()
214            .iter()
215            .any(|(_, cursor)| cursor.selection_range().is_some());
216
217        if !has_selection {
218            self.active_window_mut().status_message =
219                Some(t!("clipboard.no_selection").to_string());
220            return;
221        }
222
223        // Empty theme = open theme picker prompt
224        if theme_name.is_empty() {
225            self.start_copy_with_formatting_prompt();
226            return;
227        }
228        use crate::services::styled_html::render_styled_html;
229
230        // Get the requested theme from registry
231        let theme = match self.theme_registry.get_cloned(theme_name) {
232            Some(t) => t,
233            None => {
234                self.active_window_mut().status_message =
235                    Some(format!("Theme '{}' not found", theme_name));
236                return;
237            }
238        };
239
240        // Collect ranges and their byte offsets
241        let ranges: Vec<_> = self
242            .active_cursors()
243            .iter()
244            .filter_map(|(_, cursor)| cursor.selection_range())
245            .collect();
246
247        if ranges.is_empty() {
248            self.active_window_mut().status_message =
249                Some(t!("clipboard.no_selection").to_string());
250            return;
251        }
252
253        // Get the overall range for highlighting
254        let min_offset = ranges.iter().map(|r| r.start).min().unwrap_or(0);
255        let max_offset = ranges.iter().map(|r| r.end).max().unwrap_or(0);
256
257        // Collect text and highlight spans from state
258        let (text, highlight_spans) = {
259            let state = self.active_state_mut();
260
261            // Collect text from all ranges
262            let mut text = String::new();
263            for range in &ranges {
264                if !text.is_empty() {
265                    text.push('\n');
266                }
267                let range_text = state.get_text_range(range.start, range.end);
268                text.push_str(&range_text);
269            }
270
271            if text.is_empty() {
272                (text, Vec::new())
273            } else {
274                // Get highlight spans for the selected region
275                let highlight_spans = state.highlighter.highlight_viewport(
276                    &state.buffer,
277                    min_offset,
278                    max_offset,
279                    &theme,
280                    0, // No context needed since we're copying exact selection
281                );
282                (text, highlight_spans)
283            }
284        };
285
286        if text.is_empty() {
287            self.active_window_mut().status_message = Some(t!("clipboard.no_text").to_string());
288            return;
289        }
290
291        // Adjust highlight spans to be relative to the copied text
292        let adjusted_spans: Vec<_> = if ranges.len() == 1 {
293            let base_offset = ranges[0].start;
294            highlight_spans
295                .into_iter()
296                .filter_map(|span| {
297                    if span.range.end <= base_offset || span.range.start >= ranges[0].end {
298                        return None;
299                    }
300                    let start = span.range.start.saturating_sub(base_offset);
301                    let end = (span.range.end - base_offset).min(text.len());
302                    if start < end {
303                        Some(crate::primitives::highlighter::HighlightSpan {
304                            range: start..end,
305                            color: span.color,
306                            category: span.category,
307                        })
308                    } else {
309                        None
310                    }
311                })
312                .collect()
313        } else {
314            Vec::new()
315        };
316
317        // Render the styled text to HTML
318        let html = render_styled_html(&text, &adjusted_spans, &theme);
319
320        // Copy the HTML to clipboard (with plain text fallback)
321        if self.clipboard.copy_html(&html, &text) {
322            self.active_window_mut().status_message =
323                Some(t!("clipboard.copied_with_theme", theme = theme_name).to_string());
324        } else {
325            self.clipboard.copy(text);
326            self.active_window_mut().status_message =
327                Some(t!("clipboard.copied_plain").to_string());
328        }
329    }
330
331    /// Start the theme selection prompt for copy with formatting
332    fn start_copy_with_formatting_prompt(&mut self) {
333        use crate::view::prompt::PromptType;
334
335        let available_themes = self.theme_registry.list();
336        // Resolve the config value (portable form) to a canonical registry
337        // key so the picker can pre-highlight the current theme.
338        let resolved_current = self
339            .theme_registry
340            .resolve_key(&self.config.theme.0)
341            .unwrap_or_else(|| self.config.theme.0.clone());
342        let current_theme_key = resolved_current.as_str();
343
344        // Find the index of the current theme (match by key first, then name)
345        let current_index = available_themes
346            .iter()
347            .position(|info| info.key == *current_theme_key)
348            .or_else(|| {
349                let normalized = crate::view::theme::normalize_theme_name(current_theme_key);
350                available_themes.iter().position(|info| {
351                    crate::view::theme::normalize_theme_name(&info.name) == normalized
352                })
353            })
354            .unwrap_or(0);
355
356        let suggestions: Vec<crate::input::commands::Suggestion> = available_themes
357            .iter()
358            .map(|info| {
359                let is_current = Some(info) == available_themes.get(current_index);
360                let description = if is_current {
361                    Some(format!("{} (current)", info.key))
362                } else {
363                    Some(info.key.clone())
364                };
365                crate::input::commands::Suggestion {
366                    text: info.name.clone(),
367                    description,
368                    value: Some(info.key.clone()),
369                    disabled: false,
370                    keybinding: None,
371                    source: None,
372                }
373            })
374            .collect();
375
376        self.active_window_mut().prompt = Some(crate::view::prompt::Prompt::with_suggestions(
377            "Copy with theme: ".to_string(),
378            PromptType::CopyWithFormattingTheme,
379            suggestions,
380        ));
381
382        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
383            if !prompt.suggestions.is_empty() {
384                prompt.selected_suggestion = Some(current_index);
385                prompt.input = current_theme_key.to_string();
386                prompt.cursor_pos = prompt.input.len();
387            }
388        }
389    }
390
391    /// Cut the current selection to clipboard
392    ///
393    /// If no selection exists, cuts the entire current line (like VSCode/Rider/Zed).
394    pub fn cut_selection(&mut self) {
395        // Check if any cursor has a selection
396        let has_selection = self
397            .active_cursors()
398            .iter()
399            .any(|(_, cursor)| cursor.selection_range().is_some());
400
401        // Copy first (this handles both selection and whole-line cases)
402        self.copy_selection();
403
404        if has_selection {
405            // Delete selected text from all cursors
406            // IMPORTANT: Sort deletions by position to ensure we process from end to start
407            let mut deletions: Vec<_> = self
408                .active_cursors()
409                .iter()
410                .filter_map(|(_, c)| c.selection_range())
411                .collect();
412            // Sort by start position so reverse iteration processes from end to start
413            deletions.sort_by_key(|r| r.start);
414
415            let primary_id = self.active_cursors().primary_id();
416            let state = self.active_state_mut();
417            let events: Vec<_> = deletions
418                .iter()
419                .rev()
420                .map(|range| {
421                    let deleted_text = state.get_text_range(range.start, range.end);
422                    Event::Delete {
423                        range: range.clone(),
424                        deleted_text,
425                        cursor_id: primary_id,
426                    }
427                })
428                .collect();
429
430            // Apply events with atomic undo using bulk edit for O(n) performance
431            if events.len() > 1 {
432                // Use optimized bulk edit for multi-cursor cut
433                if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Cut".to_string()) {
434                    self.active_event_log_mut().append(bulk_edit);
435                }
436            } else if let Some(event) = events.into_iter().next() {
437                self.log_and_apply_event(&event);
438            }
439
440            if !deletions.is_empty() {
441                self.active_window_mut().status_message = Some(t!("clipboard.cut").to_string());
442            }
443        } else {
444            // No selection: delete entire line(s) for each cursor
445            let estimated_line_length = 80;
446
447            // Collect line ranges for each cursor
448            // IMPORTANT: Sort deletions by position to ensure we process from end to start
449            let positions: Vec<_> = self
450                .active_cursors()
451                .iter()
452                .map(|(_, c)| c.position)
453                .collect();
454            let mut deletions: Vec<_> = {
455                let state = self.active_state_mut();
456                positions
457                    .into_iter()
458                    .filter_map(|pos| {
459                        let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
460                        let line_start = iter.current_position();
461                        iter.next_line().map(|(_start, content)| {
462                            let line_end = line_start + content.len();
463                            line_start..line_end
464                        })
465                    })
466                    .collect()
467            };
468            // Sort by start position so reverse iteration processes from end to start
469            deletions.sort_by_key(|r| r.start);
470
471            let primary_id = self.active_cursors().primary_id();
472            let state = self.active_state_mut();
473            let events: Vec<_> = deletions
474                .iter()
475                .rev()
476                .map(|range| {
477                    let deleted_text = state.get_text_range(range.start, range.end);
478                    Event::Delete {
479                        range: range.clone(),
480                        deleted_text,
481                        cursor_id: primary_id,
482                    }
483                })
484                .collect();
485
486            // Apply events with atomic undo using bulk edit for O(n) performance
487            if events.len() > 1 {
488                // Use optimized bulk edit for multi-cursor cut
489                if let Some(bulk_edit) =
490                    self.apply_events_as_bulk_edit(events, "Cut line".to_string())
491                {
492                    self.active_event_log_mut().append(bulk_edit);
493                }
494            } else if let Some(event) = events.into_iter().next() {
495                self.log_and_apply_event(&event);
496            }
497
498            if !deletions.is_empty() {
499                self.active_window_mut().status_message =
500                    Some(t!("clipboard.cut_line").to_string());
501            }
502        }
503    }
504
505    /// Paste the clipboard content at all cursor positions
506    ///
507    /// Handles:
508    /// - Single cursor paste
509    /// - Multi-cursor paste (pastes at each cursor)
510    /// - Selection replacement (deletes selection before inserting)
511    /// - Atomic undo (single undo step for entire operation)
512    pub fn paste(&mut self) {
513        // Get content from clipboard (tries system first, falls back to internal)
514        let text = match self.clipboard.paste() {
515            Some(text) => text,
516            None => return,
517        };
518
519        // Use paste_text which handles line ending normalization
520        self.paste_text(text);
521    }
522
523    /// Paste text directly into the editor
524    ///
525    /// Handles:
526    /// - Line ending normalization (CRLF/CR → buffer's format)
527    /// - Single cursor paste
528    /// - Multi-cursor paste (pastes at each cursor)
529    /// - Column-mode paste: when the cursor count equals the number of
530    ///   clipboard lines, each cursor receives a distinct line (matches
531    ///   VSCode/Notepad++ behavior, see issue #1057). This makes a
532    ///   block-selected copy/paste round-trip preserve its rectangular shape.
533    /// - Selection replacement (deletes selection before inserting)
534    /// - Atomic undo (single undo step for entire operation)
535    /// - Routing to prompt if one is open
536    pub fn paste_text(&mut self, paste_text: String) {
537        if paste_text.is_empty() {
538            return;
539        }
540
541        // Normalize line endings: first convert all to LF, then to buffer's format
542        // This handles Windows clipboard (CRLF), old Mac (CR), and Unix (LF)
543        let normalized = paste_text.replace("\r\n", "\n").replace('\r', "\n");
544
545        // If a prompt is open, paste into the prompt (prompts use LF internally)
546        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
547            prompt.insert_str(&normalized);
548            self.update_prompt_suggestions();
549            self.active_window_mut().status_message = Some(t!("clipboard.pasted").to_string());
550            return;
551        }
552
553        // If in terminal mode, send paste to the terminal PTY
554        if self.active_window().terminal_mode {
555            self.active_window_mut()
556                .send_terminal_input(normalized.as_bytes());
557            return;
558        }
559
560        // Collect cursor info sorted in reverse order by position
561        let mut cursor_data: Vec<_> = self
562            .active_cursors()
563            .iter()
564            .map(|(cursor_id, cursor)| {
565                let selection = cursor.selection_range();
566                let insert_position = selection
567                    .as_ref()
568                    .map(|r| r.start)
569                    .unwrap_or(cursor.position);
570                (cursor_id, selection, insert_position)
571            })
572            .collect();
573        cursor_data.sort_by_key(|(_, _, pos)| std::cmp::Reverse(*pos));
574
575        // Decide whether to distribute one clipboard line per cursor
576        // (column-mode paste). We split on LF (after normalization above) and
577        // ignore a single trailing empty entry from a trailing newline so that
578        // "a\nb\nc" and "a\nb\nc\n" both yield 3 lines.
579        let mut lines_for_distribution: Vec<&str> = normalized.split('\n').collect();
580        if lines_for_distribution.len() > 1 && lines_for_distribution.last() == Some(&"") {
581            lines_for_distribution.pop();
582        }
583        let use_column_paste = cursor_data.len() > 1
584            && lines_for_distribution.len() > 1
585            && lines_for_distribution.len() == cursor_data.len();
586
587        // Convert to buffer's line ending format (only used in non-column mode;
588        // a single column-paste line never contains an embedded newline).
589        let paste_text_full = match self.active_state().buffer.line_ending() {
590            crate::model::buffer::LineEnding::LF => normalized.clone(),
591            crate::model::buffer::LineEnding::CRLF => normalized.replace('\n', "\r\n"),
592            crate::model::buffer::LineEnding::CR => normalized.replace('\n', "\r"),
593        };
594
595        // Get deleted text for each selection
596        let cursor_data_with_text: Vec<_> = {
597            let state = self.active_state_mut();
598            cursor_data
599                .into_iter()
600                .map(|(cursor_id, selection, insert_position)| {
601                    let deleted_text = selection
602                        .as_ref()
603                        .map(|r| state.get_text_range(r.start, r.end));
604                    (cursor_id, selection, insert_position, deleted_text)
605                })
606                .collect()
607        };
608
609        // Build events for each cursor.
610        //
611        // cursor_data_with_text is sorted by position DESCENDING (so events
612        // applied in vector order don't invalidate earlier offsets). For column
613        // paste we want the topmost cursor (smallest position) to receive the
614        // first clipboard line, so we index into `lines_for_distribution` from
615        // the back when iterating.
616        let total = cursor_data_with_text.len();
617        let mut events = Vec::new();
618        for (i, (cursor_id, selection, insert_position, deleted_text)) in
619            cursor_data_with_text.into_iter().enumerate()
620        {
621            if let (Some(range), Some(text)) = (selection, deleted_text) {
622                events.push(Event::Delete {
623                    range,
624                    deleted_text: text,
625                    cursor_id,
626                });
627            }
628            let text = if use_column_paste {
629                lines_for_distribution[total - 1 - i].to_string()
630            } else {
631                paste_text_full.clone()
632            };
633            events.push(Event::Insert {
634                position: insert_position,
635                text,
636                cursor_id,
637            });
638        }
639
640        // Apply events with atomic undo using bulk edit for O(n) performance
641        if events.len() > 1 {
642            // Use optimized bulk edit for multi-cursor paste
643            if let Some(bulk_edit) = self.apply_events_as_bulk_edit(events, "Paste".to_string()) {
644                self.active_event_log_mut().append(bulk_edit);
645            }
646        } else if let Some(event) = events.into_iter().next() {
647            self.log_and_apply_event(&event);
648        }
649
650        self.active_window_mut().status_message = Some(t!("clipboard.pasted").to_string());
651    }
652
653    /// Set clipboard content for testing purposes
654    /// This sets the internal clipboard and enables internal-only mode to avoid
655    /// system clipboard interference between parallel tests
656    #[doc(hidden)]
657    pub fn set_clipboard_for_test(&mut self, text: String) {
658        self.clipboard.set_internal(text);
659        self.clipboard.set_internal_only(true);
660    }
661
662    /// Paste from internal clipboard only (for testing)
663    /// This bypasses the system clipboard to avoid interference from CI environments
664    #[doc(hidden)]
665    pub fn paste_for_test(&mut self) {
666        // Get content from internal clipboard only (ignores system clipboard)
667        let paste_text = match self.clipboard.paste_internal() {
668            Some(text) => text,
669            None => return,
670        };
671
672        // Use the same paste logic as the regular paste method
673        self.paste_text(paste_text);
674    }
675
676    /// Get clipboard content for testing purposes
677    /// Returns the internal clipboard content
678    #[doc(hidden)]
679    pub fn clipboard_content_for_test(&self) -> String {
680        self.clipboard.get_internal().to_string()
681    }
682
683    /// Copy a buffer's file path to the clipboard.
684    ///
685    /// When `relative` is true the path is made relative to the workspace root;
686    /// if the file lives outside the workspace the absolute path is used as a
687    /// safe fallback (the user still gets a usable path rather than nothing).
688    /// When `relative` is false the absolute path is always copied.
689    ///
690    /// If the buffer has no associated file (unsaved scratch buffer) or the
691    /// buffer id is unknown, a status message is shown and the clipboard is
692    /// left untouched.
693    pub fn copy_buffer_path(&mut self, buffer_id: crate::model::event::BufferId, relative: bool) {
694        let path = self
695            .buffers()
696            .get(&buffer_id)
697            .and_then(|state| state.buffer.file_path().map(|p| p.to_path_buf()));
698        let Some(path) = path else {
699            self.active_window_mut().status_message =
700                Some(t!("clipboard.no_file_path").to_string());
701            return;
702        };
703
704        let path_str = if relative {
705            path.strip_prefix(&self.working_dir)
706                .unwrap_or(&path)
707                .to_string_lossy()
708                .into_owned()
709        } else {
710            path.to_string_lossy().into_owned()
711        };
712
713        self.clipboard.copy(path_str.clone());
714        self.active_window_mut().status_message =
715            Some(t!("clipboard.copied_path", path = &path_str).to_string());
716    }
717
718    /// Copy the active buffer's file path. See [`Self::copy_buffer_path`].
719    pub fn copy_active_buffer_path(&mut self, relative: bool) {
720        let buffer_id = self.active_buffer();
721        self.copy_buffer_path(buffer_id, relative);
722    }
723
724    /// Add a cursor at the next occurrence of the selected text
725    /// If no selection, first selects the entire word at cursor position.
726    ///
727    /// When an active substring search has placed the cursor at a match
728    /// (cursor inside `search_state.matches[i]..matches[i] + match_lengths[i]`),
729    /// the search match is selected instead of the surrounding word.  This
730    /// way subsequent presses look for the search substring rather than the
731    /// whole word, which would skip other substring occurrences (issue #1697).
732    pub fn add_cursor_at_next_match(&mut self) {
733        if let Some(range) = self.active_window().search_match_at_primary_cursor() {
734            let primary_id = self.active_cursors().primary_id();
735            let primary = self.active_cursors().primary();
736            let event = Event::MoveCursor {
737                cursor_id: primary_id,
738                old_position: primary.position,
739                new_position: range.end,
740                old_anchor: primary.anchor,
741                new_anchor: Some(range.start),
742                old_sticky_column: primary.sticky_column,
743                new_sticky_column: 0,
744            };
745            self.active_event_log_mut().append(event.clone());
746            self.apply_event_to_active_buffer(&event);
747            return;
748        }
749
750        let cursors = self.active_cursors().clone();
751        let state = self.active_state_mut();
752        match add_cursor_at_next_match(state, &cursors) {
753            AddCursorResult::Success {
754                cursor,
755                total_cursors,
756            } => {
757                // Create AddCursor event with the next cursor ID
758                let next_id = CursorId(self.active_cursors().count());
759                let event = Event::AddCursor {
760                    cursor_id: next_id,
761                    position: cursor.position,
762                    anchor: cursor.anchor,
763                };
764
765                // Log and apply the event
766                self.active_event_log_mut().append(event.clone());
767                self.apply_event_to_active_buffer(&event);
768
769                self.active_window_mut().status_message =
770                    Some(t!("clipboard.added_cursor_match", count = total_cursors).to_string());
771            }
772            AddCursorResult::WordSelected {
773                word_start,
774                word_end,
775            } => {
776                // Select the word by updating the primary cursor
777                let primary_id = self.active_cursors().primary_id();
778                let primary = self.active_cursors().primary();
779                let event = Event::MoveCursor {
780                    cursor_id: primary_id,
781                    old_position: primary.position,
782                    new_position: word_end,
783                    old_anchor: primary.anchor,
784                    new_anchor: Some(word_start),
785                    old_sticky_column: primary.sticky_column,
786                    new_sticky_column: 0,
787                };
788
789                // Log and apply the event
790                self.active_event_log_mut().append(event.clone());
791                self.apply_event_to_active_buffer(&event);
792            }
793            AddCursorResult::Failed { message } => {
794                self.active_window_mut().status_message = Some(message);
795            }
796        }
797    }
798
799    /// Add a cursor above the primary cursor at the same column
800    pub fn add_cursor_above(&mut self) {
801        let cursors = self.active_cursors().clone();
802        let state = self.active_state_mut();
803        match add_cursor_above(state, &cursors) {
804            AddCursorResult::Success {
805                cursor,
806                total_cursors,
807            } => {
808                // Create AddCursor event with the next cursor ID
809                let next_id = CursorId(self.active_cursors().count());
810                let event = Event::AddCursor {
811                    cursor_id: next_id,
812                    position: cursor.position,
813                    anchor: cursor.anchor,
814                };
815
816                // Log and apply the event
817                self.active_event_log_mut().append(event.clone());
818                self.apply_event_to_active_buffer(&event);
819
820                self.active_window_mut().status_message =
821                    Some(t!("clipboard.added_cursor_above", count = total_cursors).to_string());
822            }
823            AddCursorResult::Failed { message } => {
824                self.active_window_mut().status_message = Some(message);
825            }
826            AddCursorResult::WordSelected { .. } => unreachable!(),
827        }
828    }
829
830    /// Add a cursor below the primary cursor at the same column
831    pub fn add_cursor_below(&mut self) {
832        let cursors = self.active_cursors().clone();
833        let state = self.active_state_mut();
834        match add_cursor_below(state, &cursors) {
835            AddCursorResult::Success {
836                cursor,
837                total_cursors,
838            } => {
839                // Create AddCursor event with the next cursor ID
840                let next_id = CursorId(self.active_cursors().count());
841                let event = Event::AddCursor {
842                    cursor_id: next_id,
843                    position: cursor.position,
844                    anchor: cursor.anchor,
845                };
846
847                // Log and apply the event
848                self.active_event_log_mut().append(event.clone());
849                self.apply_event_to_active_buffer(&event);
850
851                self.active_window_mut().status_message =
852                    Some(t!("clipboard.added_cursor_below", count = total_cursors).to_string());
853            }
854            AddCursorResult::Failed { message } => {
855                self.active_window_mut().status_message = Some(message);
856            }
857            AddCursorResult::WordSelected { .. } => unreachable!(),
858        }
859    }
860
861    // =========================================================================
862    // Vi-style yank operations (copy range without requiring selection)
863    // =========================================================================
864
865    /// Yank (copy) from cursor to next word start
866    pub fn yank_word_forward(&mut self) {
867        let cursor_positions: Vec<_> = self
868            .active_cursors()
869            .iter()
870            .map(|(_, c)| c.position)
871            .collect();
872        let ranges: Vec<_> = {
873            let state = self.active_state();
874            cursor_positions
875                .into_iter()
876                .filter_map(|start| {
877                    let end = find_word_start_right(&state.buffer, start);
878                    if end > start {
879                        Some(start..end)
880                    } else {
881                        None
882                    }
883                })
884                .collect()
885        };
886
887        if ranges.is_empty() {
888            return;
889        }
890
891        // Copy text from all ranges
892        let mut text = String::new();
893        let state = self.active_state_mut();
894        for range in ranges {
895            if !text.is_empty() {
896                text.push('\n');
897            }
898            let range_text = state.get_text_range(range.start, range.end);
899            text.push_str(&range_text);
900        }
901
902        if !text.is_empty() {
903            let len = text.len();
904            self.clipboard.copy(text);
905            self.active_window_mut().status_message =
906                Some(t!("clipboard.yanked", count = len).to_string());
907        }
908    }
909
910    /// Yank (copy) from cursor to vim word end (inclusive)
911    pub fn yank_vi_word_end(&mut self) {
912        let cursor_positions: Vec<_> = self
913            .active_cursors()
914            .iter()
915            .map(|(_, c)| c.position)
916            .collect();
917        let ranges: Vec<_> = {
918            let state = self.active_state();
919            cursor_positions
920                .into_iter()
921                .filter_map(|start| {
922                    let word_end = find_vi_word_end(&state.buffer, start);
923                    let end = (word_end + 1).min(state.buffer.len());
924                    if end > start {
925                        Some(start..end)
926                    } else {
927                        None
928                    }
929                })
930                .collect()
931        };
932
933        if ranges.is_empty() {
934            return;
935        }
936
937        let mut text = String::new();
938        let state = self.active_state_mut();
939        for range in ranges {
940            if !text.is_empty() {
941                text.push('\n');
942            }
943            let range_text = state.get_text_range(range.start, range.end);
944            text.push_str(&range_text);
945        }
946
947        if !text.is_empty() {
948            let len = text.len();
949            self.clipboard.copy(text);
950            self.active_window_mut().status_message =
951                Some(t!("clipboard.yanked", count = len).to_string());
952        }
953    }
954
955    /// Yank (copy) from previous word start to cursor
956    pub fn yank_word_backward(&mut self) {
957        let cursor_positions: Vec<_> = self
958            .active_cursors()
959            .iter()
960            .map(|(_, c)| c.position)
961            .collect();
962        let ranges: Vec<_> = {
963            let state = self.active_state();
964            cursor_positions
965                .into_iter()
966                .filter_map(|end| {
967                    let start = find_word_start_left(&state.buffer, end);
968                    if start < end {
969                        Some(start..end)
970                    } else {
971                        None
972                    }
973                })
974                .collect()
975        };
976
977        if ranges.is_empty() {
978            return;
979        }
980
981        let mut text = String::new();
982        let state = self.active_state_mut();
983        for range in ranges {
984            if !text.is_empty() {
985                text.push('\n');
986            }
987            let range_text = state.get_text_range(range.start, range.end);
988            text.push_str(&range_text);
989        }
990
991        if !text.is_empty() {
992            let len = text.len();
993            self.clipboard.copy(text);
994            self.active_window_mut().status_message =
995                Some(t!("clipboard.yanked", count = len).to_string());
996        }
997    }
998
999    /// Yank (copy) from cursor to end of line
1000    pub fn yank_to_line_end(&mut self) {
1001        let estimated_line_length = 80;
1002
1003        // First collect cursor positions with immutable borrow
1004        let cursor_positions: Vec<_> = self
1005            .active_cursors()
1006            .iter()
1007            .map(|(_, cursor)| cursor.position)
1008            .collect();
1009
1010        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
1011        let state = self.active_state_mut();
1012        let mut ranges = Vec::new();
1013        for pos in cursor_positions {
1014            let mut iter = state.buffer.line_iterator(pos, estimated_line_length);
1015            let line_start = iter.current_position();
1016            if let Some((_start, content)) = iter.next_line() {
1017                // Don't include the line ending in yank
1018                let content_len = content.trim_end_matches(&['\n', '\r'][..]).len();
1019                let line_end = line_start + content_len;
1020                if pos < line_end {
1021                    ranges.push(pos..line_end);
1022                }
1023            }
1024        }
1025
1026        if ranges.is_empty() {
1027            return;
1028        }
1029
1030        let mut text = String::new();
1031        for range in ranges {
1032            if !text.is_empty() {
1033                text.push('\n');
1034            }
1035            let range_text = state.get_text_range(range.start, range.end);
1036            text.push_str(&range_text);
1037        }
1038
1039        if !text.is_empty() {
1040            let len = text.len();
1041            self.clipboard.copy(text);
1042            self.active_window_mut().status_message =
1043                Some(t!("clipboard.yanked", count = len).to_string());
1044        }
1045    }
1046
1047    /// Yank (copy) from start of line to cursor
1048    pub fn yank_to_line_start(&mut self) {
1049        let estimated_line_length = 80;
1050
1051        // First collect cursor positions with immutable borrow
1052        let cursor_positions: Vec<_> = self
1053            .active_cursors()
1054            .iter()
1055            .map(|(_, cursor)| cursor.position)
1056            .collect();
1057
1058        // Now compute ranges with mutable borrow (line_iterator needs &mut self)
1059        let state = self.active_state_mut();
1060        let mut ranges = Vec::new();
1061        for pos in cursor_positions {
1062            let iter = state.buffer.line_iterator(pos, estimated_line_length);
1063            let line_start = iter.current_position();
1064            if pos > line_start {
1065                ranges.push(line_start..pos);
1066            }
1067        }
1068
1069        if ranges.is_empty() {
1070            return;
1071        }
1072
1073        let mut text = String::new();
1074        for range in ranges {
1075            if !text.is_empty() {
1076                text.push('\n');
1077            }
1078            let range_text = state.get_text_range(range.start, range.end);
1079            text.push_str(&range_text);
1080        }
1081
1082        if !text.is_empty() {
1083            let len = text.len();
1084            self.clipboard.copy(text);
1085            self.active_window_mut().status_message =
1086                Some(t!("clipboard.yanked", count = len).to_string());
1087        }
1088    }
1089}