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