Skip to main content

iced_code_editor/canvas_editor/
cursor.rs

1//! Cursor movement and positioning logic.
2
3use iced::widget::operation::scroll_to;
4use iced::widget::scrollable;
5use iced::{Point, Task};
6#[cfg(not(target_arch = "wasm32"))]
7use std::time::Instant;
8
9#[cfg(target_arch = "wasm32")]
10use web_time::Instant;
11
12use super::measure_text_width;
13
14use super::vim::VimMotion;
15use super::wrapping::{VisualLine, WrappingCalculator};
16use super::{ArrowDirection, CodeEditor, Message, cursor_set};
17use crate::text_buffer::TextBuffer;
18
19/// Computes the next logical `(line, col)` position for a cursor at `pos` moving in `direction`.
20///
21/// Returns `None` if the cursor is already at the boundary and cannot move further.
22fn compute_next_position(
23    pos: (usize, usize),
24    direction: ArrowDirection,
25    buffer: &TextBuffer,
26    visual_lines: &[VisualLine],
27) -> Option<(usize, usize)> {
28    let (line, col) = pos;
29    match direction {
30        ArrowDirection::Up | ArrowDirection::Down => {
31            let current_visual =
32                WrappingCalculator::logical_to_visual(visual_lines, line, col)?;
33
34            let target_visual = match direction {
35                ArrowDirection::Up => current_visual.checked_sub(1)?,
36                ArrowDirection::Down => {
37                    let next = current_visual + 1;
38                    if next < visual_lines.len() {
39                        next
40                    } else {
41                        return None;
42                    }
43                }
44                _ => return None,
45            };
46
47            let target_vl = &visual_lines[target_visual];
48            let current_vl = &visual_lines[current_visual];
49
50            let new_col = if target_vl.logical_line == line {
51                let offset_in_current =
52                    col.saturating_sub(current_vl.start_col);
53                let target_col = target_vl.start_col + offset_in_current;
54                if target_col >= target_vl.end_col {
55                    target_vl.end_col.saturating_sub(1).max(target_vl.start_col)
56                } else {
57                    target_col
58                }
59            } else {
60                let target_line_len = buffer.line_len(target_vl.logical_line);
61                (target_vl.start_col + col.min(target_vl.len()))
62                    .min(target_line_len)
63            };
64
65            Some((target_vl.logical_line, new_col))
66        }
67        ArrowDirection::Left => {
68            if col > 0 {
69                Some((line, col - 1))
70            } else if line > 0 {
71                Some((line - 1, buffer.line_len(line - 1)))
72            } else {
73                None
74            }
75        }
76        ArrowDirection::Right => {
77            let line_len = buffer.line_len(line);
78            if col < line_len {
79                Some((line, col + 1))
80            } else if line + 1 < buffer.line_count() {
81                Some((line + 1, 0))
82            } else {
83                None
84            }
85        }
86    }
87}
88
89impl CodeEditor {
90    /// Clamps a logical position to a character on which Normal/Visual mode can
91    /// land. Non-empty lines use their final character as the maximum column;
92    /// empty lines retain column zero.
93    pub(crate) fn vim_normal_position(
94        &self,
95        position: (usize, usize),
96    ) -> (usize, usize) {
97        let line = position.0.min(self.buffer.line_count().saturating_sub(1));
98        let line_len = self.buffer.line_len(line);
99        let max_col = line_len.saturating_sub(1);
100        (line, position.1.min(max_col))
101    }
102
103    fn vim_position_after(&self, position: (usize, usize)) -> (usize, usize) {
104        let line_len = self.buffer.line_len(position.0);
105        if line_len == 0 {
106            if position.0 + 1 < self.buffer.line_count() {
107                (position.0 + 1, 0)
108            } else {
109                position
110            }
111        } else {
112            (position.0, (position.1 + 1).min(line_len))
113        }
114    }
115
116    /// Projects an inclusive Vim Visual selection into the editor's half-open
117    /// cursor selection representation.
118    pub(crate) fn apply_vim_visual_selection(
119        &mut self,
120        anchor: (usize, usize),
121        active: (usize, usize),
122        linewise: bool,
123    ) {
124        let anchor = self.vim_normal_position(anchor);
125        let active = self.vim_normal_position(active);
126        let cursor = if linewise {
127            let start_line = anchor.0.min(active.0);
128            let end_line = anchor.0.max(active.0);
129            let end = if end_line + 1 < self.buffer.line_count() {
130                (end_line + 1, 0)
131            } else {
132                (end_line, self.buffer.line_len(end_line))
133            };
134            cursor_set::Cursor { position: end, anchor: Some((start_line, 0)) }
135        } else if active >= anchor {
136            cursor_set::Cursor {
137                position: self.vim_position_after(active),
138                anchor: Some(anchor),
139            }
140        } else {
141            cursor_set::Cursor {
142                position: active,
143                anchor: Some(self.vim_position_after(anchor)),
144            }
145        };
146        self.cursors.set_single(cursor.position);
147        self.cursors.primary_mut().anchor = cursor.anchor;
148        self.overlay_cache.clear();
149    }
150
151    /// Resolves one Vim motion from a character position, including counted
152    /// visible-line movement and Unicode-aware word boundaries.
153    pub(crate) fn vim_motion_target(
154        &self,
155        start: (usize, usize),
156        motion: VimMotion,
157        count: usize,
158        explicit_count: bool,
159    ) -> (usize, usize) {
160        let mut position = self.vim_normal_position(start);
161        let count = count.max(1);
162
163        match motion {
164            VimMotion::Left => {
165                position.1 = position.1.saturating_sub(count);
166            }
167            VimMotion::Right => {
168                let max_col =
169                    self.buffer.line_len(position.0).saturating_sub(1);
170                position.1 = position.1.saturating_add(count).min(max_col);
171            }
172            VimMotion::Up | VimMotion::Down => {
173                let direction = if motion == VimMotion::Up {
174                    ArrowDirection::Up
175                } else {
176                    ArrowDirection::Down
177                };
178                let visual_lines =
179                    self.visual_lines_cached(self.viewport_width);
180                for _ in 0..count {
181                    let Some(next) = compute_next_position(
182                        position,
183                        direction,
184                        &self.buffer,
185                        &visual_lines,
186                    ) else {
187                        break;
188                    };
189                    position = self.vim_normal_position(next);
190                }
191            }
192            VimMotion::WordForward
193            | VimMotion::WordBackward
194            | VimMotion::WordEnd => {
195                let chars = self.vim_char_index();
196                for _ in 0..count {
197                    position = Self::vim_word_motion(&chars, position, motion);
198                }
199            }
200            VimMotion::LineStart => position.1 = 0,
201            VimMotion::FirstNonBlank => {
202                position.1 = self
203                    .buffer
204                    .line(position.0)
205                    .chars()
206                    .position(|ch| !ch.is_whitespace())
207                    .unwrap_or(0);
208            }
209            VimMotion::LineEnd => {
210                position.1 = self.buffer.line_len(position.0).saturating_sub(1);
211            }
212            VimMotion::DocumentStart => {
213                let line = count
214                    .saturating_sub(1)
215                    .min(self.buffer.line_count().saturating_sub(1));
216                position = (line, 0);
217            }
218            VimMotion::DocumentEnd => {
219                let line = if explicit_count {
220                    count
221                        .saturating_sub(1)
222                        .min(self.buffer.line_count().saturating_sub(1))
223                } else {
224                    self.buffer.line_count().saturating_sub(1)
225                };
226                position = (line, 0);
227            }
228        }
229
230        self.vim_normal_position(position)
231    }
232
233    /// Builds a flat, position-tagged character index across the whole
234    /// buffer, inserting a synthetic `'\n'` at each line boundary so that
235    /// word motions can cross line breaks. Rebuilding this once per keystroke
236    /// (rather than once per counted repetition) keeps counted motions
237    /// (`5w`) linear in document size instead of `O(document_size * count)`.
238    fn vim_char_index(&self) -> Vec<((usize, usize), char)> {
239        let mut chars = Vec::new();
240        for line in 0..self.buffer.line_count() {
241            chars.extend(
242                self.buffer
243                    .line(line)
244                    .chars()
245                    .enumerate()
246                    .map(|(col, ch)| ((line, col), ch)),
247            );
248            if line + 1 < self.buffer.line_count() {
249                chars.push(((line, self.buffer.line_len(line)), '\n'));
250            }
251        }
252        chars
253    }
254
255    /// Resolves a single word-wise Vim motion (`w`/`b`/`e`) against a
256    /// prebuilt character index (see [`Self::vim_char_index`]).
257    fn vim_word_motion(
258        chars: &[((usize, usize), char)],
259        start: (usize, usize),
260        motion: VimMotion,
261    ) -> (usize, usize) {
262        #[derive(Clone, Copy, PartialEq, Eq)]
263        enum Class {
264            Space,
265            Word,
266            Punctuation,
267        }
268
269        fn class(ch: char) -> Class {
270            if ch.is_whitespace() {
271                Class::Space
272            } else if ch.is_alphanumeric() || ch == '_' {
273                Class::Word
274            } else {
275                Class::Punctuation
276            }
277        }
278
279        if chars.is_empty() {
280            return (0, 0);
281        }
282
283        let insertion =
284            chars.partition_point(|(position, _)| *position < start);
285        let exact = insertion < chars.len() && chars[insertion].0 == start;
286
287        match motion {
288            VimMotion::WordForward => {
289                let mut index = insertion;
290                if exact {
291                    let current_class = class(chars[index].1);
292                    if current_class == Class::Space {
293                        while index < chars.len()
294                            && class(chars[index].1) == Class::Space
295                        {
296                            index += 1;
297                        }
298                    } else {
299                        while index < chars.len()
300                            && class(chars[index].1) == current_class
301                        {
302                            index += 1;
303                        }
304                        while index < chars.len()
305                            && class(chars[index].1) == Class::Space
306                        {
307                            index += 1;
308                        }
309                    }
310                }
311                chars[index.min(chars.len() - 1)].0
312            }
313            VimMotion::WordBackward => {
314                let mut index = insertion.saturating_sub(1);
315                while index > 0 && class(chars[index].1) == Class::Space {
316                    index -= 1;
317                }
318                let target_class = class(chars[index].1);
319                while index > 0 && class(chars[index - 1].1) == target_class {
320                    index -= 1;
321                }
322                chars[index].0
323            }
324            VimMotion::WordEnd => {
325                let mut index = insertion.min(chars.len() - 1);
326                if exact {
327                    let current_class = class(chars[index].1);
328                    if current_class != Class::Space
329                        && index + 1 < chars.len()
330                        && class(chars[index + 1].1) == current_class
331                    {
332                        while index + 1 < chars.len()
333                            && class(chars[index + 1].1) == current_class
334                        {
335                            index += 1;
336                        }
337                        return chars[index].0;
338                    }
339                    index = (index + 1).min(chars.len() - 1);
340                }
341                while index + 1 < chars.len()
342                    && class(chars[index].1) == Class::Space
343                {
344                    index += 1;
345                }
346                let target_class = class(chars[index].1);
347                while index + 1 < chars.len()
348                    && class(chars[index + 1].1) == target_class
349                {
350                    index += 1;
351                }
352                chars[index].0
353            }
354            _ => start,
355        }
356    }
357
358    /// Sets the cursor position to the specified line and column.
359    ///
360    /// This method ensures the new position is within the bounds of the text buffer.
361    /// It also resets the blinking animation, clears the overlay cache (to redraw
362    /// the cursor immediately), and scrolls the view to make the cursor visible.
363    ///
364    /// # Arguments
365    ///
366    /// * `line` - The target line index (0-based).
367    /// * `col` - The target column index (0-based).
368    ///
369    /// # Returns
370    ///
371    /// A `Task` that may produce a `Message` (e.g., if scrolling is needed).
372    pub fn set_cursor(&mut self, line: usize, col: usize) -> Task<Message> {
373        let line = line.min(self.buffer.line_count().saturating_sub(1));
374        let line_len = self.buffer.line(line).chars().count();
375        let col = col.min(line_len);
376
377        self.cursors.set_single((line, col));
378        // Programmatic jumps should end any drag gesture. Otherwise, a stale
379        // drag state may let subsequent hover events move the caret away.
380        self.is_dragging = false;
381
382        // Reset blink
383        self.last_blink = Instant::now();
384
385        self.overlay_cache.clear();
386        self.scroll_to_cursor()
387    }
388
389    /// Moves all cursors one step in `direction`.
390    ///
391    /// Visual lines are computed once and shared across all cursor movements.
392    /// After moving, overlapping cursors are merged via `sort_and_merge`.
393    pub(crate) fn move_cursor(&mut self, direction: ArrowDirection) {
394        // Compute visual lines once — used by Up/Down movement for all cursors.
395        // Reuse the memoized layout so that lines hidden by collapsed folds are
396        // skipped during vertical navigation, exactly like in rendering.
397        let visual_lines = self.visual_lines_cached(self.viewport_width);
398
399        for cursor in self.cursors.as_mut_slice() {
400            if let Some(new_pos) = compute_next_position(
401                cursor.position,
402                direction,
403                &self.buffer,
404                &visual_lines,
405            ) {
406                cursor.position = new_pos;
407            }
408        }
409
410        // Deduplicate cursors that landed on the same position after movement.
411        self.cursors.sort_and_merge();
412
413        // Cursor movement affects only overlay visuals (caret, current-line highlight),
414        // so avoid invalidating the expensive content cache.
415        self.overlay_cache.clear();
416    }
417
418    /// Computes the cursor logical position (line, col) from a screen point.
419    ///
420    /// This method considers:
421    /// 1. Whether the click is inside the gutter area.
422    /// 2. Visual line mapping after wrapping.
423    /// 3. CJK character widths (wide characters use FONT_SIZE, narrow use CHAR_WIDTH).
424    pub(crate) fn calculate_cursor_from_point(
425        &self,
426        point: Point,
427    ) -> Option<(usize, usize)> {
428        // Account for gutter width
429        if point.x < self.gutter_width() {
430            return None; // Clicked in gutter
431        }
432
433        // Calculate visual line number - point.y is already in canvas coordinates
434        let visual_line_idx = (point.y / self.line_height) as usize;
435
436        // Reuse memoized wrapping result for hit-testing. This avoids recomputing
437        // visual lines on every mouse move/drag.
438        let visual_lines = self.visual_lines_cached(self.viewport_width);
439
440        if visual_line_idx >= visual_lines.len() {
441            // Clicked beyond last line - move to end of document
442            let last_line = self.buffer.line_count().saturating_sub(1);
443            let last_col = self.buffer.line_len(last_line);
444            return Some((last_line, last_col));
445        }
446
447        let visual_line = &visual_lines[visual_line_idx];
448
449        // Calculate column within the segment, accounting for horizontal scroll
450        let x_in_text =
451            point.x - self.gutter_width() - 5.0 + self.horizontal_scroll_offset;
452
453        // Use correct width calculation for CJK support
454        let line_content = self.buffer.line(visual_line.logical_line);
455
456        let mut current_width = 0.0;
457        let mut col_offset = 0;
458
459        // Iterate the visual slice directly to avoid allocating a temporary String.
460        for c in line_content
461            .chars()
462            .skip(visual_line.start_col)
463            .take(visual_line.end_col - visual_line.start_col)
464        {
465            let char_width = super::measure_char_width(
466                c,
467                self.full_char_width,
468                self.char_width,
469            );
470
471            if current_width + char_width / 2.0 > x_in_text {
472                break;
473            }
474            current_width += char_width;
475            col_offset += 1;
476        }
477
478        let col = visual_line.start_col + col_offset;
479        Some((visual_line.logical_line, col))
480    }
481
482    /// Handles mouse clicks to position the cursor.
483    ///
484    /// Reuses `calculate_cursor_from_point` to compute the position and updates the cache.
485    pub(crate) fn handle_mouse_click(&mut self, point: Point) {
486        let before = self.cursors.primary_position();
487        if let Some(pos) = self.calculate_cursor_from_point(point) {
488            self.cursors.primary_mut().position = pos;
489            if self.cursors.primary_position() != before {
490                // Only clear overlay when the caret actually moved.
491                self.overlay_cache.clear();
492            }
493        }
494    }
495
496    /// Classifies a left-button press as a single/double/triple click.
497    ///
498    /// Consecutive presses count up as long as each one lands within 400ms
499    /// otherwise the count resets to 1. Counts
500    /// wrap back to 1 after 3, so a fourth rapid click starts a fresh
501    /// single/double/triple cycle rather than being silently ignored.
502    pub(crate) fn classify_click(&self, position: Point) -> u8 {
503        let now = Instant::now();
504        let count = match self.last_click.get() {
505            Some((time, pos, count))
506                if now.duration_since(time)
507                    < std::time::Duration::from_millis(400)
508                    && pos.distance(position) < 6.0 =>
509            {
510                if count >= 3 {
511                    1
512                } else {
513                    count + 1
514                }
515            }
516            _ => 1,
517        };
518        self.last_click.set(Some((now, position, count)));
519        count
520    }
521
522    /// Returns a scroll command to make the cursor visible.
523    pub(crate) fn scroll_to_cursor(&self) -> Task<Message> {
524        // Reuse memoized wrapping result so repeated scroll computations do not
525        // trigger repeated visual line calculation.
526        let visual_lines = self.visual_lines_cached(self.viewport_width);
527
528        let pos = self.cursors.primary_position();
529        let cursor_visual =
530            WrappingCalculator::logical_to_visual(&visual_lines, pos.0, pos.1);
531
532        let cursor_y = if let Some(visual_idx) = cursor_visual {
533            visual_idx as f32 * self.line_height
534        } else {
535            // Fallback to logical line if visual not found
536            pos.0 as f32 * self.line_height
537        };
538
539        let viewport_top = self.viewport_scroll;
540        let viewport_bottom = self.viewport_scroll + self.viewport_height;
541
542        // Add margins to avoid cursor being exactly at edge
543        let top_margin = self.line_height * 2.0;
544        let bottom_margin = self.line_height * 2.0;
545
546        // Calculate new vertical scroll position if cursor is outside visible area
547        let new_v_scroll = if cursor_y < viewport_top + top_margin {
548            // Cursor is above viewport - scroll up
549            Some((cursor_y - top_margin).max(0.0))
550        } else if cursor_y + self.line_height > viewport_bottom - bottom_margin
551        {
552            // Cursor is below viewport - scroll down
553            Some(
554                cursor_y + self.line_height + bottom_margin
555                    - self.viewport_height,
556            )
557        } else {
558            None
559        };
560
561        let vertical_task = if let Some(new_scroll) = new_v_scroll {
562            scroll_to(
563                self.scrollable_id.clone(),
564                scrollable::AbsoluteOffset { x: 0.0, y: new_scroll },
565            )
566        } else {
567            Task::none()
568        };
569
570        // Horizontal scroll: only when wrap is disabled
571        let h_task = if !self.wrap_enabled {
572            // Compute cursor content-space X position
573            let cursor_content_x = if let Some(visual_idx) = cursor_visual {
574                let vl = &visual_lines[visual_idx];
575                let line_content = self.buffer.line(vl.logical_line);
576                let prefix: String = line_content
577                    .chars()
578                    .skip(vl.start_col)
579                    .take(pos.1.saturating_sub(vl.start_col))
580                    .collect();
581                self.gutter_width()
582                    + 5.0
583                    + measure_text_width(
584                        &prefix,
585                        self.full_char_width,
586                        self.char_width,
587                    )
588            } else {
589                self.gutter_width() + 5.0
590            };
591
592            let left_boundary = self.gutter_width() + self.char_width;
593            let right_boundary = self.viewport_width - self.char_width * 2.0;
594            let cursor_viewport_x =
595                cursor_content_x - self.horizontal_scroll_offset;
596
597            let new_h_offset = if cursor_viewport_x < left_boundary {
598                (cursor_content_x - left_boundary).max(0.0)
599            } else if cursor_viewport_x > right_boundary {
600                cursor_content_x - right_boundary
601            } else {
602                self.horizontal_scroll_offset // no change
603            };
604
605            if (new_h_offset - self.horizontal_scroll_offset).abs() > 0.5 {
606                scroll_to(
607                    self.horizontal_scrollable_id.clone(),
608                    scrollable::AbsoluteOffset { x: new_h_offset, y: 0.0 },
609                )
610            } else {
611                Task::none()
612            }
613        } else {
614            Task::none()
615        };
616
617        Task::batch([vertical_task, h_task])
618    }
619
620    /// Moves every cursor to a new line computed by `map_line`, clamping each
621    /// cursor's column to the new line's length, then merges overlapping
622    /// cursors and invalidates the overlay cache.
623    ///
624    /// Shared by [`page_up`](Self::page_up) and [`page_down`](Self::page_down).
625    ///
626    /// # Arguments
627    ///
628    /// * `map_line` - Maps a cursor's current line to its target line.
629    fn move_cursors_by_line(&mut self, map_line: impl Fn(usize) -> usize) {
630        for cursor in self.cursors.as_mut_slice() {
631            let new_line = map_line(cursor.position.0);
632            let line_len = self.buffer.line_len(new_line);
633            cursor.position = (new_line, cursor.position.1.min(line_len));
634        }
635        self.cursors.sort_and_merge();
636        self.overlay_cache.clear();
637    }
638
639    /// Moves all cursors up by one page (approximately viewport height).
640    pub(crate) fn page_up(&mut self) {
641        let lines_per_page = (self.viewport_height / self.line_height) as usize;
642        self.move_cursors_by_line(|line| line.saturating_sub(lines_per_page));
643    }
644
645    /// Moves all cursors down by one page (approximately viewport height).
646    pub(crate) fn page_down(&mut self) {
647        let lines_per_page = (self.viewport_height / self.line_height) as usize;
648        let max_line = self.buffer.line_count().saturating_sub(1);
649        self.move_cursors_by_line(|line| (line + lines_per_page).min(max_line));
650    }
651
652    /// Handles mouse drag for text selection.
653    ///
654    /// Reuses `calculate_cursor_from_point` to compute the position and update selection end.
655    pub(crate) fn handle_mouse_drag(&mut self, point: Point) {
656        if let Some(pos) = self.calculate_cursor_from_point(point) {
657            self.cursors.primary_mut().position = pos;
658        }
659    }
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn vim_word_motion_crosses_line_boundary_via_prebuilt_index() {
668        let editor = CodeEditor::new("one two\nthree four", "txt");
669        let chars = editor.vim_char_index();
670
671        assert_eq!(
672            CodeEditor::vim_word_motion(&chars, (0, 4), VimMotion::WordForward),
673            (1, 0)
674        );
675        assert_eq!(
676            CodeEditor::vim_word_motion(
677                &chars,
678                (1, 0),
679                VimMotion::WordBackward
680            ),
681            (0, 4)
682        );
683        assert_eq!(
684            CodeEditor::vim_word_motion(&chars, (0, 4), VimMotion::WordEnd),
685            (0, 6)
686        );
687    }
688
689    #[test]
690    fn test_cursor_movement() {
691        let mut editor = CodeEditor::new("line1\nline2", "py");
692        editor.move_cursor(ArrowDirection::Down);
693        assert_eq!(editor.cursors.primary_position().0, 1);
694        editor.move_cursor(ArrowDirection::Right);
695        assert_eq!(editor.cursors.primary_position().1, 1);
696    }
697
698    #[test]
699    fn test_page_down() {
700        // Create editor with many lines
701        let content = (0..100)
702            .map(|i| format!("line {i}"))
703            .collect::<Vec<_>>()
704            .join("\n");
705        let mut editor = CodeEditor::new(&content, "py");
706
707        editor.page_down();
708        // Should move approximately 30 lines (600px / 20px per line)
709        assert!(editor.cursors.primary_position().0 >= 25);
710        assert!(editor.cursors.primary_position().0 <= 35);
711    }
712
713    #[test]
714    fn test_page_up() {
715        // Create editor with many lines
716        let content = (0..100)
717            .map(|i| format!("line {i}"))
718            .collect::<Vec<_>>()
719            .join("\n");
720        let mut editor = CodeEditor::new(&content, "py");
721
722        // Move to line 50
723        editor.cursors.primary_mut().position = (50, 0);
724        editor.page_up();
725
726        // Should move approximately 30 lines up
727        assert!(editor.cursors.primary_position().0 >= 15);
728        assert!(editor.cursors.primary_position().0 <= 25);
729    }
730
731    #[test]
732    fn test_page_down_at_end() {
733        let content =
734            (0..10).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
735        let mut editor = CodeEditor::new(&content, "py");
736
737        editor.page_down();
738        // Should be at last line (line 9)
739        assert_eq!(editor.cursors.primary_position().0, 9);
740    }
741
742    #[test]
743    fn test_page_up_at_start() {
744        let content = (0..100)
745            .map(|i| format!("line {i}"))
746            .collect::<Vec<_>>()
747            .join("\n");
748        let mut editor = CodeEditor::new(&content, "py");
749
750        // Already at start
751        editor.cursors.primary_mut().position = (0, 0);
752        editor.page_up();
753        assert_eq!(editor.cursors.primary_position().0, 0);
754    }
755
756    #[test]
757    fn test_cursor_click_cjk() {
758        use iced::Point;
759        let mut editor = CodeEditor::new("你好", "txt");
760        editor.set_line_numbers_enabled(false);
761        // Disable folding so the gutter (line numbers + fold margin) is
762        // zero-width; otherwise the fold margin shifts click coordinates.
763        editor.set_folding_enabled(false);
764
765        let full_char_width = editor.full_char_width();
766        let half_width = full_char_width / 2.0;
767        let padding = 5.0;
768
769        // Assume each CJK character is `full_char_width` wide.
770        // "你" is 0..full_char_width. "好" is full_char_width..2*full_char_width.
771        //
772        // Case 1: Click inside "你", at less than half its width.
773        // Expect col 0
774        editor
775            .handle_mouse_click(Point::new((half_width - 2.0) + padding, 10.0));
776
777        assert_eq!(editor.cursors.primary_position(), (0, 0));
778
779        // Case 2: Click inside "你", at more than half its width.
780        // Expect col 1
781        editor
782            .handle_mouse_click(Point::new((half_width + 2.0) + padding, 10.0));
783        assert_eq!(editor.cursors.primary_position(), (0, 1));
784
785        // Case 3: Click inside "好", at less than half its width.
786        // "好" starts at full_char_width. Offset into "好" is < half_width.
787        // Expect col 1 (start of "好")
788        editor.handle_mouse_click(Point::new(
789            (full_char_width + half_width - 2.0) + padding,
790            10.0,
791        ));
792        assert_eq!(editor.cursors.primary_position(), (0, 1));
793
794        // Case 4: Click inside "好", at more than half its width.
795        // "好" starts at full_char_width. Offset into "好" is > half_width.
796        // Expect col 2 (end of "好")
797        editor.handle_mouse_click(Point::new(
798            (full_char_width + half_width + 2.0) + padding,
799            10.0,
800        ));
801        assert_eq!(editor.cursors.primary_position(), (0, 2));
802    }
803
804    #[test]
805    fn test_multi_cursor_move_left() {
806        let mut editor = CodeEditor::new("abc\ndef", "rs");
807        editor.cursors.primary_mut().position = (0, 2);
808        editor.cursors.add_cursor((1, 2));
809
810        editor.move_cursor(ArrowDirection::Left);
811
812        // Both cursors should have moved left by one
813        let positions: Vec<(usize, usize)> =
814            editor.cursors.iter().map(|c| c.position).collect();
815        assert!(positions.contains(&(0, 1)));
816        assert!(positions.contains(&(1, 1)));
817    }
818
819    #[test]
820    fn test_multi_cursor_move_right() {
821        let mut editor = CodeEditor::new("abc\ndef", "rs");
822        editor.cursors.primary_mut().position = (0, 1);
823        editor.cursors.add_cursor((1, 1));
824
825        editor.move_cursor(ArrowDirection::Right);
826
827        let positions: Vec<(usize, usize)> =
828            editor.cursors.iter().map(|c| c.position).collect();
829        assert!(positions.contains(&(0, 2)));
830        assert!(positions.contains(&(1, 2)));
831    }
832
833    #[test]
834    fn test_multi_cursor_move_deduplicates() {
835        let mut editor = CodeEditor::new("abc", "rs");
836        // Place two cursors adjacent, moving right will merge them
837        editor.cursors.primary_mut().position = (0, 0);
838        editor.cursors.add_cursor((0, 1));
839        assert_eq!(editor.cursors.len(), 2);
840
841        editor.move_cursor(ArrowDirection::Right);
842
843        // Both moved right: (0,1) and (0,2). Still 2 distinct positions.
844        assert_eq!(editor.cursors.len(), 2);
845    }
846}