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