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::wrapping::{VisualLine, WrappingCalculator};
15use super::{ArrowDirection, CodeEditor, Message};
16use crate::text_buffer::TextBuffer;
17
18/// Computes the next logical `(line, col)` position for a cursor at `pos` moving in `direction`.
19///
20/// Returns `None` if the cursor is already at the boundary and cannot move further.
21fn compute_next_position(
22    pos: (usize, usize),
23    direction: ArrowDirection,
24    buffer: &TextBuffer,
25    visual_lines: &[VisualLine],
26) -> Option<(usize, usize)> {
27    let (line, col) = pos;
28    match direction {
29        ArrowDirection::Up | ArrowDirection::Down => {
30            let current_visual =
31                WrappingCalculator::logical_to_visual(visual_lines, line, col)?;
32
33            let target_visual = match direction {
34                ArrowDirection::Up => current_visual.checked_sub(1)?,
35                ArrowDirection::Down => {
36                    let next = current_visual + 1;
37                    if next < visual_lines.len() {
38                        next
39                    } else {
40                        return None;
41                    }
42                }
43                _ => return None,
44            };
45
46            let target_vl = &visual_lines[target_visual];
47            let current_vl = &visual_lines[current_visual];
48
49            let new_col = if target_vl.logical_line == line {
50                let offset_in_current =
51                    col.saturating_sub(current_vl.start_col);
52                let target_col = target_vl.start_col + offset_in_current;
53                if target_col >= target_vl.end_col {
54                    target_vl.end_col.saturating_sub(1).max(target_vl.start_col)
55                } else {
56                    target_col
57                }
58            } else {
59                let target_line_len = buffer.line_len(target_vl.logical_line);
60                (target_vl.start_col + col.min(target_vl.len()))
61                    .min(target_line_len)
62            };
63
64            Some((target_vl.logical_line, new_col))
65        }
66        ArrowDirection::Left => {
67            if col > 0 {
68                Some((line, col - 1))
69            } else if line > 0 {
70                Some((line - 1, buffer.line_len(line - 1)))
71            } else {
72                None
73            }
74        }
75        ArrowDirection::Right => {
76            let line_len = buffer.line_len(line);
77            if col < line_len {
78                Some((line, col + 1))
79            } else if line + 1 < buffer.line_count() {
80                Some((line + 1, 0))
81            } else {
82                None
83            }
84        }
85    }
86}
87
88impl CodeEditor {
89    /// Sets the cursor position to the specified line and column.
90    ///
91    /// This method ensures the new position is within the bounds of the text buffer.
92    /// It also resets the blinking animation, clears the overlay cache (to redraw
93    /// the cursor immediately), and scrolls the view to make the cursor visible.
94    ///
95    /// # Arguments
96    ///
97    /// * `line` - The target line index (0-based).
98    /// * `col` - The target column index (0-based).
99    ///
100    /// # Returns
101    ///
102    /// A `Task` that may produce a `Message` (e.g., if scrolling is needed).
103    pub fn set_cursor(&mut self, line: usize, col: usize) -> Task<Message> {
104        let line = line.min(self.buffer.line_count().saturating_sub(1));
105        let line_len = self.buffer.line(line).chars().count();
106        let col = col.min(line_len);
107
108        self.cursors.set_single((line, col));
109        // Programmatic jumps should end any drag gesture. Otherwise, a stale
110        // drag state may let subsequent hover events move the caret away.
111        self.is_dragging = false;
112
113        // Reset blink
114        self.last_blink = Instant::now();
115
116        self.overlay_cache.clear();
117        self.scroll_to_cursor()
118    }
119
120    /// Moves all cursors one step in `direction`.
121    ///
122    /// Visual lines are computed once and shared across all cursor movements.
123    /// After moving, overlapping cursors are merged via `sort_and_merge`.
124    pub(crate) fn move_cursor(&mut self, direction: ArrowDirection) {
125        // Compute visual lines once — used by Up/Down movement for all cursors.
126        let wrapping_calc = WrappingCalculator::new(
127            self.wrap_enabled,
128            self.wrap_column,
129            self.full_char_width,
130            self.char_width,
131        );
132        let visual_lines = wrapping_calc.calculate_visual_lines(
133            &self.buffer,
134            self.viewport_width,
135            self.gutter_width(),
136        );
137
138        for cursor in self.cursors.as_mut_slice() {
139            if let Some(new_pos) = compute_next_position(
140                cursor.position,
141                direction,
142                &self.buffer,
143                &visual_lines,
144            ) {
145                cursor.position = new_pos;
146            }
147        }
148
149        // Deduplicate cursors that landed on the same position after movement.
150        self.cursors.sort_and_merge();
151
152        // Cursor movement affects only overlay visuals (caret, current-line highlight),
153        // so avoid invalidating the expensive content cache.
154        self.overlay_cache.clear();
155    }
156
157    /// Computes the cursor logical position (line, col) from a screen point.
158    ///
159    /// This method considers:
160    /// 1. Whether the click is inside the gutter area.
161    /// 2. Visual line mapping after wrapping.
162    /// 3. CJK character widths (wide characters use FONT_SIZE, narrow use CHAR_WIDTH).
163    pub(crate) fn calculate_cursor_from_point(
164        &self,
165        point: Point,
166    ) -> Option<(usize, usize)> {
167        // Account for gutter width
168        if point.x < self.gutter_width() {
169            return None; // Clicked in gutter
170        }
171
172        // Calculate visual line number - point.y is already in canvas coordinates
173        let visual_line_idx = (point.y / self.line_height) as usize;
174
175        // Reuse memoized wrapping result for hit-testing. This avoids recomputing
176        // visual lines on every mouse move/drag.
177        let visual_lines = self.visual_lines_cached(self.viewport_width);
178
179        if visual_line_idx >= visual_lines.len() {
180            // Clicked beyond last line - move to end of document
181            let last_line = self.buffer.line_count().saturating_sub(1);
182            let last_col = self.buffer.line_len(last_line);
183            return Some((last_line, last_col));
184        }
185
186        let visual_line = &visual_lines[visual_line_idx];
187
188        // Calculate column within the segment, accounting for horizontal scroll
189        let x_in_text =
190            point.x - self.gutter_width() - 5.0 + self.horizontal_scroll_offset;
191
192        // Use correct width calculation for CJK support
193        let line_content = self.buffer.line(visual_line.logical_line);
194
195        let mut current_width = 0.0;
196        let mut col_offset = 0;
197
198        // Iterate the visual slice directly to avoid allocating a temporary String.
199        for c in line_content
200            .chars()
201            .skip(visual_line.start_col)
202            .take(visual_line.end_col - visual_line.start_col)
203        {
204            let char_width = super::measure_char_width(
205                c,
206                self.full_char_width,
207                self.char_width,
208            );
209
210            if current_width + char_width / 2.0 > x_in_text {
211                break;
212            }
213            current_width += char_width;
214            col_offset += 1;
215        }
216
217        let col = visual_line.start_col + col_offset;
218        Some((visual_line.logical_line, col))
219    }
220
221    /// Handles mouse clicks to position the cursor.
222    ///
223    /// Reuses `calculate_cursor_from_point` to compute the position and updates the cache.
224    pub(crate) fn handle_mouse_click(&mut self, point: Point) {
225        let before = self.cursors.primary_position();
226        if let Some(pos) = self.calculate_cursor_from_point(point) {
227            self.cursors.primary_mut().position = pos;
228            if self.cursors.primary_position() != before {
229                // Only clear overlay when the caret actually moved.
230                self.overlay_cache.clear();
231            }
232        }
233    }
234
235    /// Returns a scroll command to make the cursor visible.
236    pub(crate) fn scroll_to_cursor(&self) -> Task<Message> {
237        // Reuse memoized wrapping result so repeated scroll computations do not
238        // trigger repeated visual line calculation.
239        let visual_lines = self.visual_lines_cached(self.viewport_width);
240
241        let pos = self.cursors.primary_position();
242        let cursor_visual =
243            WrappingCalculator::logical_to_visual(&visual_lines, pos.0, pos.1);
244
245        let cursor_y = if let Some(visual_idx) = cursor_visual {
246            visual_idx as f32 * self.line_height
247        } else {
248            // Fallback to logical line if visual not found
249            pos.0 as f32 * self.line_height
250        };
251
252        let viewport_top = self.viewport_scroll;
253        let viewport_bottom = self.viewport_scroll + self.viewport_height;
254
255        // Add margins to avoid cursor being exactly at edge
256        let top_margin = self.line_height * 2.0;
257        let bottom_margin = self.line_height * 2.0;
258
259        // Calculate new vertical scroll position if cursor is outside visible area
260        let new_v_scroll = if cursor_y < viewport_top + top_margin {
261            // Cursor is above viewport - scroll up
262            Some((cursor_y - top_margin).max(0.0))
263        } else if cursor_y + self.line_height > viewport_bottom - bottom_margin
264        {
265            // Cursor is below viewport - scroll down
266            Some(
267                cursor_y + self.line_height + bottom_margin
268                    - self.viewport_height,
269            )
270        } else {
271            None
272        };
273
274        let vertical_task = if let Some(new_scroll) = new_v_scroll {
275            scroll_to(
276                self.scrollable_id.clone(),
277                scrollable::AbsoluteOffset { x: 0.0, y: new_scroll },
278            )
279        } else {
280            Task::none()
281        };
282
283        // Horizontal scroll: only when wrap is disabled
284        let h_task = if !self.wrap_enabled {
285            // Compute cursor content-space X position
286            let cursor_content_x = if let Some(visual_idx) = cursor_visual {
287                let vl = &visual_lines[visual_idx];
288                let line_content = self.buffer.line(vl.logical_line);
289                let prefix: String = line_content
290                    .chars()
291                    .skip(vl.start_col)
292                    .take(pos.1.saturating_sub(vl.start_col))
293                    .collect();
294                self.gutter_width()
295                    + 5.0
296                    + measure_text_width(
297                        &prefix,
298                        self.full_char_width,
299                        self.char_width,
300                    )
301            } else {
302                self.gutter_width() + 5.0
303            };
304
305            let left_boundary = self.gutter_width() + self.char_width;
306            let right_boundary = self.viewport_width - self.char_width * 2.0;
307            let cursor_viewport_x =
308                cursor_content_x - self.horizontal_scroll_offset;
309
310            let new_h_offset = if cursor_viewport_x < left_boundary {
311                (cursor_content_x - left_boundary).max(0.0)
312            } else if cursor_viewport_x > right_boundary {
313                cursor_content_x - right_boundary
314            } else {
315                self.horizontal_scroll_offset // no change
316            };
317
318            if (new_h_offset - self.horizontal_scroll_offset).abs() > 0.5 {
319                scroll_to(
320                    self.horizontal_scrollable_id.clone(),
321                    scrollable::AbsoluteOffset { x: new_h_offset, y: 0.0 },
322                )
323            } else {
324                Task::none()
325            }
326        } else {
327            Task::none()
328        };
329
330        Task::batch([vertical_task, h_task])
331    }
332
333    /// Moves all cursors up by one page (approximately viewport height).
334    pub(crate) fn page_up(&mut self) {
335        let lines_per_page = (self.viewport_height / self.line_height) as usize;
336        for cursor in self.cursors.as_mut_slice() {
337            let new_line = cursor.position.0.saturating_sub(lines_per_page);
338            let line_len = self.buffer.line_len(new_line);
339            cursor.position = (new_line, cursor.position.1.min(line_len));
340        }
341        self.cursors.sort_and_merge();
342        self.overlay_cache.clear();
343    }
344
345    /// Moves all cursors down by one page (approximately viewport height).
346    pub(crate) fn page_down(&mut self) {
347        let lines_per_page = (self.viewport_height / self.line_height) as usize;
348        let max_line = self.buffer.line_count().saturating_sub(1);
349        for cursor in self.cursors.as_mut_slice() {
350            let new_line = (cursor.position.0 + lines_per_page).min(max_line);
351            let line_len = self.buffer.line_len(new_line);
352            cursor.position = (new_line, cursor.position.1.min(line_len));
353        }
354        self.cursors.sort_and_merge();
355        self.overlay_cache.clear();
356    }
357
358    /// Handles mouse drag for text selection.
359    ///
360    /// Reuses `calculate_cursor_from_point` to compute the position and update selection end.
361    pub(crate) fn handle_mouse_drag(&mut self, point: Point) {
362        if let Some(pos) = self.calculate_cursor_from_point(point) {
363            self.cursors.primary_mut().position = pos;
364        }
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_cursor_movement() {
374        let mut editor = CodeEditor::new("line1\nline2", "py");
375        editor.move_cursor(ArrowDirection::Down);
376        assert_eq!(editor.cursors.primary_position().0, 1);
377        editor.move_cursor(ArrowDirection::Right);
378        assert_eq!(editor.cursors.primary_position().1, 1);
379    }
380
381    #[test]
382    fn test_page_down() {
383        // Create editor with many lines
384        let content = (0..100)
385            .map(|i| format!("line {i}"))
386            .collect::<Vec<_>>()
387            .join("\n");
388        let mut editor = CodeEditor::new(&content, "py");
389
390        editor.page_down();
391        // Should move approximately 30 lines (600px / 20px per line)
392        assert!(editor.cursors.primary_position().0 >= 25);
393        assert!(editor.cursors.primary_position().0 <= 35);
394    }
395
396    #[test]
397    fn test_page_up() {
398        // Create editor with many lines
399        let content = (0..100)
400            .map(|i| format!("line {i}"))
401            .collect::<Vec<_>>()
402            .join("\n");
403        let mut editor = CodeEditor::new(&content, "py");
404
405        // Move to line 50
406        editor.cursors.primary_mut().position = (50, 0);
407        editor.page_up();
408
409        // Should move approximately 30 lines up
410        assert!(editor.cursors.primary_position().0 >= 15);
411        assert!(editor.cursors.primary_position().0 <= 25);
412    }
413
414    #[test]
415    fn test_page_down_at_end() {
416        let content =
417            (0..10).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
418        let mut editor = CodeEditor::new(&content, "py");
419
420        editor.page_down();
421        // Should be at last line (line 9)
422        assert_eq!(editor.cursors.primary_position().0, 9);
423    }
424
425    #[test]
426    fn test_page_up_at_start() {
427        let content = (0..100)
428            .map(|i| format!("line {i}"))
429            .collect::<Vec<_>>()
430            .join("\n");
431        let mut editor = CodeEditor::new(&content, "py");
432
433        // Already at start
434        editor.cursors.primary_mut().position = (0, 0);
435        editor.page_up();
436        assert_eq!(editor.cursors.primary_position().0, 0);
437    }
438
439    #[test]
440    fn test_cursor_click_cjk() {
441        use iced::Point;
442        let mut editor = CodeEditor::new("你好", "txt");
443        editor.set_line_numbers_enabled(false);
444
445        let full_char_width = editor.full_char_width();
446        let half_width = full_char_width / 2.0;
447        let padding = 5.0;
448
449        // Assume each CJK character is `full_char_width` wide.
450        // "你" is 0..full_char_width. "好" is full_char_width..2*full_char_width.
451        //
452        // Case 1: Click inside "你", at less than half its width.
453        // Expect col 0
454        editor
455            .handle_mouse_click(Point::new((half_width - 2.0) + padding, 10.0));
456
457        assert_eq!(editor.cursors.primary_position(), (0, 0));
458
459        // Case 2: Click inside "你", at more than half its width.
460        // Expect col 1
461        editor
462            .handle_mouse_click(Point::new((half_width + 2.0) + padding, 10.0));
463        assert_eq!(editor.cursors.primary_position(), (0, 1));
464
465        // Case 3: Click inside "好", at less than half its width.
466        // "好" starts at full_char_width. Offset into "好" is < half_width.
467        // Expect col 1 (start of "好")
468        editor.handle_mouse_click(Point::new(
469            (full_char_width + half_width - 2.0) + padding,
470            10.0,
471        ));
472        assert_eq!(editor.cursors.primary_position(), (0, 1));
473
474        // Case 4: Click inside "好", at more than half its width.
475        // "好" starts at full_char_width. Offset into "好" is > half_width.
476        // Expect col 2 (end of "好")
477        editor.handle_mouse_click(Point::new(
478            (full_char_width + half_width + 2.0) + padding,
479            10.0,
480        ));
481        assert_eq!(editor.cursors.primary_position(), (0, 2));
482    }
483
484    #[test]
485    fn test_multi_cursor_move_left() {
486        let mut editor = CodeEditor::new("abc\ndef", "rs");
487        editor.cursors.primary_mut().position = (0, 2);
488        editor.cursors.add_cursor((1, 2));
489
490        editor.move_cursor(ArrowDirection::Left);
491
492        // Both cursors should have moved left by one
493        let positions: Vec<(usize, usize)> =
494            editor.cursors.iter().map(|c| c.position).collect();
495        assert!(positions.contains(&(0, 1)));
496        assert!(positions.contains(&(1, 1)));
497    }
498
499    #[test]
500    fn test_multi_cursor_move_right() {
501        let mut editor = CodeEditor::new("abc\ndef", "rs");
502        editor.cursors.primary_mut().position = (0, 1);
503        editor.cursors.add_cursor((1, 1));
504
505        editor.move_cursor(ArrowDirection::Right);
506
507        let positions: Vec<(usize, usize)> =
508            editor.cursors.iter().map(|c| c.position).collect();
509        assert!(positions.contains(&(0, 2)));
510        assert!(positions.contains(&(1, 2)));
511    }
512
513    #[test]
514    fn test_multi_cursor_move_deduplicates() {
515        let mut editor = CodeEditor::new("abc", "rs");
516        // Place two cursors adjacent, moving right will merge them
517        editor.cursors.primary_mut().position = (0, 0);
518        editor.cursors.add_cursor((0, 1));
519        assert_eq!(editor.cursors.len(), 2);
520
521        editor.move_cursor(ArrowDirection::Right);
522
523        // Both moved right: (0,1) and (0,2). Still 2 distinct positions.
524        assert_eq!(editor.cursors.len(), 2);
525    }
526}