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        // Reuse the memoized layout so that lines hidden by collapsed folds are
127        // skipped during vertical navigation, exactly like in rendering.
128        let visual_lines = self.visual_lines_cached(self.viewport_width);
129
130        for cursor in self.cursors.as_mut_slice() {
131            if let Some(new_pos) = compute_next_position(
132                cursor.position,
133                direction,
134                &self.buffer,
135                &visual_lines,
136            ) {
137                cursor.position = new_pos;
138            }
139        }
140
141        // Deduplicate cursors that landed on the same position after movement.
142        self.cursors.sort_and_merge();
143
144        // Cursor movement affects only overlay visuals (caret, current-line highlight),
145        // so avoid invalidating the expensive content cache.
146        self.overlay_cache.clear();
147    }
148
149    /// Computes the cursor logical position (line, col) from a screen point.
150    ///
151    /// This method considers:
152    /// 1. Whether the click is inside the gutter area.
153    /// 2. Visual line mapping after wrapping.
154    /// 3. CJK character widths (wide characters use FONT_SIZE, narrow use CHAR_WIDTH).
155    pub(crate) fn calculate_cursor_from_point(
156        &self,
157        point: Point,
158    ) -> Option<(usize, usize)> {
159        // Account for gutter width
160        if point.x < self.gutter_width() {
161            return None; // Clicked in gutter
162        }
163
164        // Calculate visual line number - point.y is already in canvas coordinates
165        let visual_line_idx = (point.y / self.line_height) as usize;
166
167        // Reuse memoized wrapping result for hit-testing. This avoids recomputing
168        // visual lines on every mouse move/drag.
169        let visual_lines = self.visual_lines_cached(self.viewport_width);
170
171        if visual_line_idx >= visual_lines.len() {
172            // Clicked beyond last line - move to end of document
173            let last_line = self.buffer.line_count().saturating_sub(1);
174            let last_col = self.buffer.line_len(last_line);
175            return Some((last_line, last_col));
176        }
177
178        let visual_line = &visual_lines[visual_line_idx];
179
180        // Calculate column within the segment, accounting for horizontal scroll
181        let x_in_text =
182            point.x - self.gutter_width() - 5.0 + self.horizontal_scroll_offset;
183
184        // Use correct width calculation for CJK support
185        let line_content = self.buffer.line(visual_line.logical_line);
186
187        let mut current_width = 0.0;
188        let mut col_offset = 0;
189
190        // Iterate the visual slice directly to avoid allocating a temporary String.
191        for c in line_content
192            .chars()
193            .skip(visual_line.start_col)
194            .take(visual_line.end_col - visual_line.start_col)
195        {
196            let char_width = super::measure_char_width(
197                c,
198                self.full_char_width,
199                self.char_width,
200            );
201
202            if current_width + char_width / 2.0 > x_in_text {
203                break;
204            }
205            current_width += char_width;
206            col_offset += 1;
207        }
208
209        let col = visual_line.start_col + col_offset;
210        Some((visual_line.logical_line, col))
211    }
212
213    /// Handles mouse clicks to position the cursor.
214    ///
215    /// Reuses `calculate_cursor_from_point` to compute the position and updates the cache.
216    pub(crate) fn handle_mouse_click(&mut self, point: Point) {
217        let before = self.cursors.primary_position();
218        if let Some(pos) = self.calculate_cursor_from_point(point) {
219            self.cursors.primary_mut().position = pos;
220            if self.cursors.primary_position() != before {
221                // Only clear overlay when the caret actually moved.
222                self.overlay_cache.clear();
223            }
224        }
225    }
226
227    /// Returns a scroll command to make the cursor visible.
228    pub(crate) fn scroll_to_cursor(&self) -> Task<Message> {
229        // Reuse memoized wrapping result so repeated scroll computations do not
230        // trigger repeated visual line calculation.
231        let visual_lines = self.visual_lines_cached(self.viewport_width);
232
233        let pos = self.cursors.primary_position();
234        let cursor_visual =
235            WrappingCalculator::logical_to_visual(&visual_lines, pos.0, pos.1);
236
237        let cursor_y = if let Some(visual_idx) = cursor_visual {
238            visual_idx as f32 * self.line_height
239        } else {
240            // Fallback to logical line if visual not found
241            pos.0 as f32 * self.line_height
242        };
243
244        let viewport_top = self.viewport_scroll;
245        let viewport_bottom = self.viewport_scroll + self.viewport_height;
246
247        // Add margins to avoid cursor being exactly at edge
248        let top_margin = self.line_height * 2.0;
249        let bottom_margin = self.line_height * 2.0;
250
251        // Calculate new vertical scroll position if cursor is outside visible area
252        let new_v_scroll = if cursor_y < viewport_top + top_margin {
253            // Cursor is above viewport - scroll up
254            Some((cursor_y - top_margin).max(0.0))
255        } else if cursor_y + self.line_height > viewport_bottom - bottom_margin
256        {
257            // Cursor is below viewport - scroll down
258            Some(
259                cursor_y + self.line_height + bottom_margin
260                    - self.viewport_height,
261            )
262        } else {
263            None
264        };
265
266        let vertical_task = if let Some(new_scroll) = new_v_scroll {
267            scroll_to(
268                self.scrollable_id.clone(),
269                scrollable::AbsoluteOffset { x: 0.0, y: new_scroll },
270            )
271        } else {
272            Task::none()
273        };
274
275        // Horizontal scroll: only when wrap is disabled
276        let h_task = if !self.wrap_enabled {
277            // Compute cursor content-space X position
278            let cursor_content_x = if let Some(visual_idx) = cursor_visual {
279                let vl = &visual_lines[visual_idx];
280                let line_content = self.buffer.line(vl.logical_line);
281                let prefix: String = line_content
282                    .chars()
283                    .skip(vl.start_col)
284                    .take(pos.1.saturating_sub(vl.start_col))
285                    .collect();
286                self.gutter_width()
287                    + 5.0
288                    + measure_text_width(
289                        &prefix,
290                        self.full_char_width,
291                        self.char_width,
292                    )
293            } else {
294                self.gutter_width() + 5.0
295            };
296
297            let left_boundary = self.gutter_width() + self.char_width;
298            let right_boundary = self.viewport_width - self.char_width * 2.0;
299            let cursor_viewport_x =
300                cursor_content_x - self.horizontal_scroll_offset;
301
302            let new_h_offset = if cursor_viewport_x < left_boundary {
303                (cursor_content_x - left_boundary).max(0.0)
304            } else if cursor_viewport_x > right_boundary {
305                cursor_content_x - right_boundary
306            } else {
307                self.horizontal_scroll_offset // no change
308            };
309
310            if (new_h_offset - self.horizontal_scroll_offset).abs() > 0.5 {
311                scroll_to(
312                    self.horizontal_scrollable_id.clone(),
313                    scrollable::AbsoluteOffset { x: new_h_offset, y: 0.0 },
314                )
315            } else {
316                Task::none()
317            }
318        } else {
319            Task::none()
320        };
321
322        Task::batch([vertical_task, h_task])
323    }
324
325    /// Moves all cursors up by one page (approximately viewport height).
326    pub(crate) fn page_up(&mut self) {
327        let lines_per_page = (self.viewport_height / self.line_height) as usize;
328        for cursor in self.cursors.as_mut_slice() {
329            let new_line = cursor.position.0.saturating_sub(lines_per_page);
330            let line_len = self.buffer.line_len(new_line);
331            cursor.position = (new_line, cursor.position.1.min(line_len));
332        }
333        self.cursors.sort_and_merge();
334        self.overlay_cache.clear();
335    }
336
337    /// Moves all cursors down by one page (approximately viewport height).
338    pub(crate) fn page_down(&mut self) {
339        let lines_per_page = (self.viewport_height / self.line_height) as usize;
340        let max_line = self.buffer.line_count().saturating_sub(1);
341        for cursor in self.cursors.as_mut_slice() {
342            let new_line = (cursor.position.0 + lines_per_page).min(max_line);
343            let line_len = self.buffer.line_len(new_line);
344            cursor.position = (new_line, cursor.position.1.min(line_len));
345        }
346        self.cursors.sort_and_merge();
347        self.overlay_cache.clear();
348    }
349
350    /// Handles mouse drag for text selection.
351    ///
352    /// Reuses `calculate_cursor_from_point` to compute the position and update selection end.
353    pub(crate) fn handle_mouse_drag(&mut self, point: Point) {
354        if let Some(pos) = self.calculate_cursor_from_point(point) {
355            self.cursors.primary_mut().position = pos;
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_cursor_movement() {
366        let mut editor = CodeEditor::new("line1\nline2", "py");
367        editor.move_cursor(ArrowDirection::Down);
368        assert_eq!(editor.cursors.primary_position().0, 1);
369        editor.move_cursor(ArrowDirection::Right);
370        assert_eq!(editor.cursors.primary_position().1, 1);
371    }
372
373    #[test]
374    fn test_page_down() {
375        // Create editor with many lines
376        let content = (0..100)
377            .map(|i| format!("line {i}"))
378            .collect::<Vec<_>>()
379            .join("\n");
380        let mut editor = CodeEditor::new(&content, "py");
381
382        editor.page_down();
383        // Should move approximately 30 lines (600px / 20px per line)
384        assert!(editor.cursors.primary_position().0 >= 25);
385        assert!(editor.cursors.primary_position().0 <= 35);
386    }
387
388    #[test]
389    fn test_page_up() {
390        // Create editor with many lines
391        let content = (0..100)
392            .map(|i| format!("line {i}"))
393            .collect::<Vec<_>>()
394            .join("\n");
395        let mut editor = CodeEditor::new(&content, "py");
396
397        // Move to line 50
398        editor.cursors.primary_mut().position = (50, 0);
399        editor.page_up();
400
401        // Should move approximately 30 lines up
402        assert!(editor.cursors.primary_position().0 >= 15);
403        assert!(editor.cursors.primary_position().0 <= 25);
404    }
405
406    #[test]
407    fn test_page_down_at_end() {
408        let content =
409            (0..10).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
410        let mut editor = CodeEditor::new(&content, "py");
411
412        editor.page_down();
413        // Should be at last line (line 9)
414        assert_eq!(editor.cursors.primary_position().0, 9);
415    }
416
417    #[test]
418    fn test_page_up_at_start() {
419        let content = (0..100)
420            .map(|i| format!("line {i}"))
421            .collect::<Vec<_>>()
422            .join("\n");
423        let mut editor = CodeEditor::new(&content, "py");
424
425        // Already at start
426        editor.cursors.primary_mut().position = (0, 0);
427        editor.page_up();
428        assert_eq!(editor.cursors.primary_position().0, 0);
429    }
430
431    #[test]
432    fn test_cursor_click_cjk() {
433        use iced::Point;
434        let mut editor = CodeEditor::new("你好", "txt");
435        editor.set_line_numbers_enabled(false);
436        // Disable folding so the gutter (line numbers + fold margin) is
437        // zero-width; otherwise the fold margin shifts click coordinates.
438        editor.set_folding_enabled(false);
439
440        let full_char_width = editor.full_char_width();
441        let half_width = full_char_width / 2.0;
442        let padding = 5.0;
443
444        // Assume each CJK character is `full_char_width` wide.
445        // "你" is 0..full_char_width. "好" is full_char_width..2*full_char_width.
446        //
447        // Case 1: Click inside "你", at less than half its width.
448        // Expect col 0
449        editor
450            .handle_mouse_click(Point::new((half_width - 2.0) + padding, 10.0));
451
452        assert_eq!(editor.cursors.primary_position(), (0, 0));
453
454        // Case 2: Click inside "你", at more than half its width.
455        // Expect col 1
456        editor
457            .handle_mouse_click(Point::new((half_width + 2.0) + padding, 10.0));
458        assert_eq!(editor.cursors.primary_position(), (0, 1));
459
460        // Case 3: Click inside "好", at less than half its width.
461        // "好" starts at full_char_width. Offset into "好" is < half_width.
462        // Expect col 1 (start of "好")
463        editor.handle_mouse_click(Point::new(
464            (full_char_width + half_width - 2.0) + padding,
465            10.0,
466        ));
467        assert_eq!(editor.cursors.primary_position(), (0, 1));
468
469        // Case 4: Click inside "好", at more than half its width.
470        // "好" starts at full_char_width. Offset into "好" is > half_width.
471        // Expect col 2 (end of "好")
472        editor.handle_mouse_click(Point::new(
473            (full_char_width + half_width + 2.0) + padding,
474            10.0,
475        ));
476        assert_eq!(editor.cursors.primary_position(), (0, 2));
477    }
478
479    #[test]
480    fn test_multi_cursor_move_left() {
481        let mut editor = CodeEditor::new("abc\ndef", "rs");
482        editor.cursors.primary_mut().position = (0, 2);
483        editor.cursors.add_cursor((1, 2));
484
485        editor.move_cursor(ArrowDirection::Left);
486
487        // Both cursors should have moved left by one
488        let positions: Vec<(usize, usize)> =
489            editor.cursors.iter().map(|c| c.position).collect();
490        assert!(positions.contains(&(0, 1)));
491        assert!(positions.contains(&(1, 1)));
492    }
493
494    #[test]
495    fn test_multi_cursor_move_right() {
496        let mut editor = CodeEditor::new("abc\ndef", "rs");
497        editor.cursors.primary_mut().position = (0, 1);
498        editor.cursors.add_cursor((1, 1));
499
500        editor.move_cursor(ArrowDirection::Right);
501
502        let positions: Vec<(usize, usize)> =
503            editor.cursors.iter().map(|c| c.position).collect();
504        assert!(positions.contains(&(0, 2)));
505        assert!(positions.contains(&(1, 2)));
506    }
507
508    #[test]
509    fn test_multi_cursor_move_deduplicates() {
510        let mut editor = CodeEditor::new("abc", "rs");
511        // Place two cursors adjacent, moving right will merge them
512        editor.cursors.primary_mut().position = (0, 0);
513        editor.cursors.add_cursor((0, 1));
514        assert_eq!(editor.cursors.len(), 2);
515
516        editor.move_cursor(ArrowDirection::Right);
517
518        // Both moved right: (0,1) and (0,2). Still 2 distinct positions.
519        assert_eq!(editor.cursors.len(), 2);
520    }
521}