Skip to main content

iced_code_editor/canvas_editor/
update.rs

1//! Message handling and update logic.
2
3use iced::Task;
4use iced::widget::operation::{focus, select_all};
5
6use super::command::{
7    Command, CompositeCommand, DeleteCharCommand, DeleteForwardCommand,
8    InsertCharCommand, InsertNewlineCommand, ReplaceTextCommand,
9};
10use super::{CURSOR_BLINK_INTERVAL, CodeEditor, ImePreedit, Message};
11
12impl CodeEditor {
13    /// Updates the editor state based on messages and returns scroll commands.
14    ///
15    /// # Arguments
16    ///
17    /// * `message` - The message to process for updating the editor state
18    ///
19    /// # Returns
20    /// A `Task<Message>` for any asynchronous operations, such as scrolling to keep the cursor visible after state updates
21    pub fn update(&mut self, message: &Message) -> Task<Message> {
22        match message {
23            Message::CharacterInput(ch) => {
24                // Start grouping if not already grouping (for smart undo)
25                if !self.is_grouping {
26                    self.history.begin_group("Typing");
27                    self.is_grouping = true;
28                }
29
30                let (line, col) = self.cursor;
31                let mut cmd =
32                    InsertCharCommand::new(line, col, *ch, self.cursor);
33                cmd.execute(&mut self.buffer, &mut self.cursor);
34                self.history.push(Box::new(cmd));
35
36                self.reset_cursor_blink();
37                self.refresh_search_matches_if_needed();
38                self.cache.clear();
39                Task::none()
40            }
41            Message::Backspace => {
42                // End grouping on backspace (separate from typing)
43                if self.is_grouping {
44                    self.history.end_group();
45                    self.is_grouping = false;
46                }
47
48                // Check if there's a selection - if so, delete it instead
49                if self.selection_start.is_some()
50                    && self.selection_end.is_some()
51                {
52                    self.delete_selection();
53                    self.reset_cursor_blink();
54                    self.refresh_search_matches_if_needed();
55                    self.cache.clear();
56                    return self.scroll_to_cursor();
57                }
58
59                // No selection - perform normal backspace
60                let (line, col) = self.cursor;
61                let mut cmd = DeleteCharCommand::new(
62                    &self.buffer,
63                    line,
64                    col,
65                    self.cursor,
66                );
67                cmd.execute(&mut self.buffer, &mut self.cursor);
68                self.history.push(Box::new(cmd));
69
70                self.reset_cursor_blink();
71                self.refresh_search_matches_if_needed();
72                self.cache.clear();
73                self.scroll_to_cursor()
74            }
75            Message::Delete => {
76                // End grouping on delete
77                if self.is_grouping {
78                    self.history.end_group();
79                    self.is_grouping = false;
80                }
81
82                // Check if there's a selection - if so, delete it instead
83                if self.selection_start.is_some()
84                    && self.selection_end.is_some()
85                {
86                    self.delete_selection();
87                    self.reset_cursor_blink();
88                    self.refresh_search_matches_if_needed();
89                    self.cache.clear();
90                    return self.scroll_to_cursor();
91                }
92
93                // No selection - perform normal forward delete
94                let (line, col) = self.cursor;
95                let mut cmd = DeleteForwardCommand::new(
96                    &self.buffer,
97                    line,
98                    col,
99                    self.cursor,
100                );
101                cmd.execute(&mut self.buffer, &mut self.cursor);
102                self.history.push(Box::new(cmd));
103
104                self.reset_cursor_blink();
105                self.refresh_search_matches_if_needed();
106                self.cache.clear();
107                Task::none()
108            }
109            Message::Enter => {
110                // End grouping on enter
111                if self.is_grouping {
112                    self.history.end_group();
113                    self.is_grouping = false;
114                }
115
116                let (line, col) = self.cursor;
117                let mut cmd = InsertNewlineCommand::new(line, col, self.cursor);
118                cmd.execute(&mut self.buffer, &mut self.cursor);
119                self.history.push(Box::new(cmd));
120
121                self.reset_cursor_blink();
122                self.refresh_search_matches_if_needed();
123                self.cache.clear();
124                self.scroll_to_cursor()
125            }
126            Message::Tab => {
127                // Insert 4 spaces for Tab
128                // Start grouping if not already grouping
129                if !self.is_grouping {
130                    self.history.begin_group("Tab");
131                    self.is_grouping = true;
132                }
133
134                let (line, col) = self.cursor;
135                // Insert 4 spaces
136                for i in 0..4 {
137                    let current_col = col + i;
138                    let mut cmd = InsertCharCommand::new(
139                        line,
140                        current_col,
141                        ' ',
142                        (line, current_col),
143                    );
144                    cmd.execute(&mut self.buffer, &mut self.cursor);
145                    self.history.push(Box::new(cmd));
146                }
147
148                self.reset_cursor_blink();
149                self.cache.clear();
150                Task::none()
151            }
152            Message::ArrowKey(direction, shift_pressed) => {
153                // End grouping on navigation
154                if self.is_grouping {
155                    self.history.end_group();
156                    self.is_grouping = false;
157                }
158
159                if *shift_pressed {
160                    // Start selection if not already started
161                    if self.selection_start.is_none() {
162                        self.selection_start = Some(self.cursor);
163                    }
164                    self.move_cursor(*direction);
165                    self.selection_end = Some(self.cursor);
166                } else {
167                    // Clear selection and move cursor
168                    self.clear_selection();
169                    self.move_cursor(*direction);
170                }
171                self.reset_cursor_blink();
172                self.cache.clear();
173                self.scroll_to_cursor()
174            }
175            Message::MouseClick(point) => {
176                // Capture focus when clicked
177                super::FOCUSED_EDITOR_ID.store(
178                    self.editor_id,
179                    std::sync::atomic::Ordering::Relaxed,
180                );
181
182                // End grouping on mouse click
183                if self.is_grouping {
184                    self.history.end_group();
185                    self.is_grouping = false;
186                }
187
188                self.handle_mouse_click(*point);
189                self.reset_cursor_blink();
190                // Clear selection on click
191                self.clear_selection();
192                self.is_dragging = true;
193                self.selection_start = Some(self.cursor);
194
195                // Gain canvas focus
196                self.has_canvas_focus = true;
197                self.show_cursor = true;
198
199                Task::none()
200            }
201            Message::MouseDrag(point) => {
202                if self.is_dragging {
203                    self.handle_mouse_drag(*point);
204                    self.cache.clear();
205                }
206                Task::none()
207            }
208            Message::MouseRelease => {
209                self.is_dragging = false;
210                Task::none()
211            }
212            Message::Copy => self.copy_selection(),
213            Message::Paste(text) => {
214                // End grouping on paste
215                if self.is_grouping {
216                    self.history.end_group();
217                    self.is_grouping = false;
218                }
219
220                // If text is empty, we need to read from clipboard
221                if text.is_empty() {
222                    // Return a task that reads clipboard and chains to paste
223                    iced::clipboard::read().and_then(|clipboard_text| {
224                        Task::done(Message::Paste(clipboard_text))
225                    })
226                } else {
227                    // We have the text, paste it
228                    self.paste_text(text);
229                    self.refresh_search_matches_if_needed();
230                    self.cache.clear();
231                    self.scroll_to_cursor()
232                }
233            }
234            Message::DeleteSelection => {
235                // End grouping on delete selection
236                if self.is_grouping {
237                    self.history.end_group();
238                    self.is_grouping = false;
239                }
240
241                // Delete selected text
242                self.delete_selection();
243                self.reset_cursor_blink();
244                self.cache.clear();
245                self.scroll_to_cursor()
246            }
247            Message::Tick => {
248                // Handle cursor blinking only if editor has focus
249                if self.is_focused()
250                    && self.has_canvas_focus
251                    && self.last_blink.elapsed() >= CURSOR_BLINK_INTERVAL
252                {
253                    self.cursor_visible = !self.cursor_visible;
254                    self.last_blink = std::time::Instant::now();
255                    self.cache.clear();
256                }
257
258                // Hide cursor if canvas doesn't have focus
259                if !self.has_canvas_focus {
260                    self.show_cursor = false;
261                }
262
263                Task::none()
264            }
265            Message::PageUp => {
266                self.page_up();
267                self.reset_cursor_blink();
268                self.scroll_to_cursor()
269            }
270            Message::PageDown => {
271                self.page_down();
272                self.reset_cursor_blink();
273                self.scroll_to_cursor()
274            }
275            Message::Home(shift_pressed) => {
276                if *shift_pressed {
277                    // Start selection if not already started
278                    if self.selection_start.is_none() {
279                        self.selection_start = Some(self.cursor);
280                    }
281                    self.cursor.1 = 0; // Move to start of line
282                    self.selection_end = Some(self.cursor);
283                } else {
284                    // Clear selection and move cursor
285                    self.clear_selection();
286                    self.cursor.1 = 0;
287                }
288                self.reset_cursor_blink();
289                self.cache.clear();
290                Task::none()
291            }
292            Message::End(shift_pressed) => {
293                let line = self.cursor.0;
294                let line_len = self.buffer.line_len(line);
295
296                if *shift_pressed {
297                    // Start selection if not already started
298                    if self.selection_start.is_none() {
299                        self.selection_start = Some(self.cursor);
300                    }
301                    self.cursor.1 = line_len; // Move to end of line
302                    self.selection_end = Some(self.cursor);
303                } else {
304                    // Clear selection and move cursor
305                    self.clear_selection();
306                    self.cursor.1 = line_len;
307                }
308                self.reset_cursor_blink();
309                self.cache.clear();
310                Task::none()
311            }
312            Message::CtrlHome => {
313                // Move cursor to the beginning of the document
314                self.clear_selection();
315                self.cursor = (0, 0);
316                self.reset_cursor_blink();
317                self.cache.clear();
318                self.scroll_to_cursor()
319            }
320            Message::CtrlEnd => {
321                // Move cursor to the end of the document
322                self.clear_selection();
323                let last_line = self.buffer.line_count().saturating_sub(1);
324                let last_col = self.buffer.line_len(last_line);
325                self.cursor = (last_line, last_col);
326                self.reset_cursor_blink();
327                self.cache.clear();
328                self.scroll_to_cursor()
329            }
330            Message::Scrolled(viewport) => {
331                // Virtual-scrolling cache window:
332                // Instead of clearing the canvas cache for every small scroll,
333                // we maintain a larger "render window" of visual lines around
334                // the visible range. We only clear the cache and re-window
335                // when the scroll crosses the window boundary or the viewport
336                // size changes significantly. This prevents frequent re-highlighting
337                // and layout recomputation for very large files while ensuring
338                // the first scroll renders correctly without requiring a click.
339                let new_scroll = viewport.absolute_offset().y;
340                let new_height = viewport.bounds().height;
341                let new_width = viewport.bounds().width;
342                let scroll_changed =
343                    (self.viewport_scroll - new_scroll).abs() > 0.1;
344                let visible_lines_count =
345                    (new_height / self.line_height).ceil() as usize + 2;
346                let first_visible_line =
347                    (new_scroll / self.line_height).floor() as usize;
348                let last_visible_line =
349                    first_visible_line + visible_lines_count;
350                let margin = visible_lines_count
351                    * crate::canvas_editor::CACHE_WINDOW_MARGIN_MULTIPLIER;
352                let window_start = first_visible_line.saturating_sub(margin);
353                let window_end = last_visible_line + margin;
354                // Decide whether we need to re-window the cache.
355                // Special-case top-of-file: when window_start == 0, allow small forward scrolls
356                // without forcing a rewindow, to avoid thrashing when the visible range is near 0.
357                let need_rewindow = if self.cache_window_end_line
358                    > self.cache_window_start_line
359                {
360                    let lower_boundary_trigger = self.cache_window_start_line
361                        > 0
362                        && first_visible_line
363                            < self
364                                .cache_window_start_line
365                                .saturating_add(visible_lines_count / 2);
366                    let upper_boundary_trigger = last_visible_line
367                        > self
368                            .cache_window_end_line
369                            .saturating_sub(visible_lines_count / 2);
370                    lower_boundary_trigger || upper_boundary_trigger
371                } else {
372                    true
373                };
374                // Clear cache when viewport dimensions change significantly
375                // to ensure proper redraw (e.g., window resize)
376                if (self.viewport_height - new_height).abs() > 1.0
377                    || (self.viewport_width - new_width).abs() > 1.0
378                    || (scroll_changed && need_rewindow)
379                {
380                    self.cache_window_start_line = window_start;
381                    self.cache_window_end_line = window_end;
382                    self.last_first_visible_line = first_visible_line;
383                    self.cache.clear();
384                }
385                self.viewport_scroll = new_scroll;
386                self.viewport_height = new_height;
387                self.viewport_width = new_width;
388                Task::none()
389            }
390            Message::Undo => {
391                // End any current grouping before undoing
392                if self.is_grouping {
393                    self.history.end_group();
394                    self.is_grouping = false;
395                }
396
397                if self.history.undo(&mut self.buffer, &mut self.cursor) {
398                    self.clear_selection();
399                    self.reset_cursor_blink();
400                    self.refresh_search_matches_if_needed();
401                    self.cache.clear();
402                    self.scroll_to_cursor()
403                } else {
404                    Task::none()
405                }
406            }
407            Message::Redo => {
408                if self.history.redo(&mut self.buffer, &mut self.cursor) {
409                    self.clear_selection();
410                    self.reset_cursor_blink();
411                    self.refresh_search_matches_if_needed();
412                    self.cache.clear();
413                    self.scroll_to_cursor()
414                } else {
415                    Task::none()
416                }
417            }
418            Message::OpenSearch => {
419                self.search_state.open_search();
420                self.cache.clear();
421
422                // Focus the search input and select all text if any
423                Task::batch([
424                    focus(self.search_state.search_input_id.clone()),
425                    select_all(self.search_state.search_input_id.clone()),
426                ])
427            }
428            Message::OpenSearchReplace => {
429                self.search_state.open_replace();
430                self.cache.clear();
431
432                // Focus the search input and select all text if any
433                Task::batch([
434                    focus(self.search_state.search_input_id.clone()),
435                    select_all(self.search_state.search_input_id.clone()),
436                ])
437            }
438            Message::CloseSearch => {
439                self.search_state.close();
440                self.cache.clear();
441                Task::none()
442            }
443            Message::SearchQueryChanged(query) => {
444                self.search_state.set_query(query.clone(), &self.buffer);
445                self.cache.clear();
446
447                // Move cursor to first match if any
448                if let Some(match_pos) = self.search_state.current_match() {
449                    self.cursor = (match_pos.line, match_pos.col);
450                    self.clear_selection();
451                    return self.scroll_to_cursor();
452                }
453                Task::none()
454            }
455            Message::ReplaceQueryChanged(replace_text) => {
456                self.search_state.set_replace_with(replace_text.clone());
457                Task::none()
458            }
459            Message::ToggleCaseSensitive => {
460                self.search_state.toggle_case_sensitive(&self.buffer);
461                self.cache.clear();
462
463                // Move cursor to first match if any
464                if let Some(match_pos) = self.search_state.current_match() {
465                    self.cursor = (match_pos.line, match_pos.col);
466                    self.clear_selection();
467                    return self.scroll_to_cursor();
468                }
469                Task::none()
470            }
471            Message::FindNext => {
472                if !self.search_state.matches.is_empty() {
473                    self.search_state.next_match();
474                    if let Some(match_pos) = self.search_state.current_match() {
475                        self.cursor = (match_pos.line, match_pos.col);
476                        self.clear_selection();
477                        self.cache.clear();
478                        return self.scroll_to_cursor();
479                    }
480                }
481                Task::none()
482            }
483            Message::FindPrevious => {
484                if !self.search_state.matches.is_empty() {
485                    self.search_state.previous_match();
486                    if let Some(match_pos) = self.search_state.current_match() {
487                        self.cursor = (match_pos.line, match_pos.col);
488                        self.clear_selection();
489                        self.cache.clear();
490                        return self.scroll_to_cursor();
491                    }
492                }
493                Task::none()
494            }
495            Message::ReplaceNext => {
496                // Replace current match and move to next
497                if let Some(match_pos) = self.search_state.current_match() {
498                    let query_len = self.search_state.query.chars().count();
499                    let replace_text = self.search_state.replace_with.clone();
500
501                    // Create and execute replace command
502                    let mut cmd = ReplaceTextCommand::new(
503                        &self.buffer,
504                        (match_pos.line, match_pos.col),
505                        query_len,
506                        replace_text,
507                        self.cursor,
508                    );
509                    cmd.execute(&mut self.buffer, &mut self.cursor);
510                    self.history.push(Box::new(cmd));
511
512                    // Update matches after replacement
513                    self.search_state.update_matches(&self.buffer);
514
515                    // Move to next match if available
516                    if !self.search_state.matches.is_empty()
517                        && let Some(next_match) =
518                            self.search_state.current_match()
519                    {
520                        self.cursor = (next_match.line, next_match.col);
521                    }
522
523                    self.clear_selection();
524                    self.cache.clear();
525                    return self.scroll_to_cursor();
526                }
527                Task::none()
528            }
529            Message::ReplaceAll => {
530                // Perform a fresh search to find ALL matches (ignoring the display limit)
531                let all_matches = super::search::find_matches(
532                    &self.buffer,
533                    &self.search_state.query,
534                    self.search_state.case_sensitive,
535                    None, // No limit for Replace All
536                );
537
538                if !all_matches.is_empty() {
539                    let query_len = self.search_state.query.chars().count();
540                    let replace_text = self.search_state.replace_with.clone();
541
542                    // Create composite command for undo
543                    let mut composite =
544                        CompositeCommand::new("Replace All".to_string());
545
546                    // Process matches in reverse order (to preserve positions)
547                    for match_pos in all_matches.iter().rev() {
548                        let cmd = ReplaceTextCommand::new(
549                            &self.buffer,
550                            (match_pos.line, match_pos.col),
551                            query_len,
552                            replace_text.clone(),
553                            self.cursor,
554                        );
555                        composite.add(Box::new(cmd));
556                    }
557
558                    // Execute all replacements
559                    composite.execute(&mut self.buffer, &mut self.cursor);
560                    self.history.push(Box::new(composite));
561
562                    // Update matches (should be empty now)
563                    self.search_state.update_matches(&self.buffer);
564                    self.clear_selection();
565                    self.cache.clear();
566                    self.scroll_to_cursor()
567                } else {
568                    Task::none()
569                }
570            }
571            Message::SearchDialogTab => {
572                // Cycle focus forward (Search → Replace → Search)
573                self.search_state.focus_next_field();
574
575                // Focus the appropriate input based on new focused_field
576                match self.search_state.focused_field {
577                    crate::canvas_editor::search::SearchFocusedField::Search => {
578                        focus(self.search_state.search_input_id.clone())
579                    }
580                    crate::canvas_editor::search::SearchFocusedField::Replace => {
581                        focus(self.search_state.replace_input_id.clone())
582                    }
583                }
584            }
585            Message::SearchDialogShiftTab => {
586                // Cycle focus backward (Replace → Search → Replace)
587                self.search_state.focus_previous_field();
588
589                // Focus the appropriate input based on new focused_field
590                match self.search_state.focused_field {
591                    crate::canvas_editor::search::SearchFocusedField::Search => {
592                        focus(self.search_state.search_input_id.clone())
593                    }
594                    crate::canvas_editor::search::SearchFocusedField::Replace => {
595                        focus(self.search_state.replace_input_id.clone())
596                    }
597                }
598            }
599            Message::CanvasFocusGained => {
600                self.has_canvas_focus = true;
601                self.show_cursor = true;
602                self.reset_cursor_blink();
603                self.cache.clear();
604                Task::none()
605            }
606            Message::CanvasFocusLost => {
607                self.has_canvas_focus = false;
608                self.show_cursor = false;
609                self.ime_preedit = None;
610                self.cache.clear();
611                Task::none()
612            }
613            Message::ImeOpened => {
614                // IME opened event
615                // -------------------------------------------------------------
616                // Triggered when the user activates the input method.
617                // Action: clear current preedit content (ime_preedit) to accept new input.
618                // This avoids carrying over the previous composition state.
619                // -------------------------------------------------------------
620                self.ime_preedit = None;
621                self.cache.clear();
622                Task::none()
623            }
624            Message::ImePreedit(content, selection) => {
625                // IME preedit event
626                // -------------------------------------------------------------
627                // Triggered while the user is composing text but not committed yet.
628                // Params:
629                // - content: current preedit text (e.g. "ni h").
630                // - selection: caret/selection inside the preedit text.
631                //
632                // Note: Iced provides selection as a byte index range, not character indices.
633                // When rendering or slicing, use UTF-8 byte offsets to avoid panics.
634                // -------------------------------------------------------------
635                if content.is_empty() {
636                    self.ime_preedit = None;
637                } else {
638                    self.ime_preedit = Some(ImePreedit {
639                        content: content.clone(),
640                        selection: selection.clone(),
641                    });
642                }
643
644                self.cache.clear();
645                Task::none()
646            }
647            Message::ImeCommit(text) => {
648                // IME commit event
649                // -------------------------------------------------------------
650                // Triggered when the user confirms a candidate and commits text.
651                // Actions:
652                // 1. Clear preedit state (ime_preedit = None).
653                // 2. If text is not empty, insert it at the current cursor position.
654                // 3. Begin a "Typing" undo group so consecutive IME commits undo together.
655                // -------------------------------------------------------------
656                self.ime_preedit = None;
657
658                if text.is_empty() {
659                    self.cache.clear();
660                    return Task::none();
661                }
662
663                if !self.is_grouping {
664                    self.history.begin_group("Typing");
665                    self.is_grouping = true;
666                }
667
668                self.paste_text(text);
669                self.reset_cursor_blink();
670                self.refresh_search_matches_if_needed();
671                self.cache.clear();
672                self.scroll_to_cursor()
673            }
674            Message::ImeClosed => {
675                // IME closed event
676                // -------------------------------------------------------------
677                // Triggered when the input method is closed or loses focus.
678                // Action: clear preedit state to return to normal input mode.
679                // -------------------------------------------------------------
680                self.ime_preedit = None;
681                self.cache.clear();
682                Task::none()
683            }
684        }
685    }
686}
687
688#[cfg(test)]
689mod tests {
690    use super::*;
691    use crate::canvas_editor::ArrowDirection;
692
693    #[test]
694    fn test_new_canvas_editor() {
695        let editor = CodeEditor::new("line1\nline2", "py");
696        assert_eq!(editor.cursor, (0, 0));
697    }
698
699    #[test]
700    fn test_home_key() {
701        let mut editor = CodeEditor::new("hello world", "py");
702        editor.cursor = (0, 5); // Move to middle of line
703        let _ = editor.update(&Message::Home(false));
704        assert_eq!(editor.cursor, (0, 0));
705    }
706
707    #[test]
708    fn test_end_key() {
709        let mut editor = CodeEditor::new("hello world", "py");
710        editor.cursor = (0, 0);
711        let _ = editor.update(&Message::End(false));
712        assert_eq!(editor.cursor, (0, 11)); // Length of "hello world"
713    }
714
715    #[test]
716    fn test_arrow_key_with_shift_creates_selection() {
717        let mut editor = CodeEditor::new("hello world", "py");
718        editor.cursor = (0, 0);
719
720        // Shift+Right should start selection
721        let _ = editor.update(&Message::ArrowKey(ArrowDirection::Right, true));
722        assert!(editor.selection_start.is_some());
723        assert!(editor.selection_end.is_some());
724    }
725
726    #[test]
727    fn test_arrow_key_without_shift_clears_selection() {
728        let mut editor = CodeEditor::new("hello world", "py");
729        editor.selection_start = Some((0, 0));
730        editor.selection_end = Some((0, 5));
731
732        // Regular arrow key should clear selection
733        let _ = editor.update(&Message::ArrowKey(ArrowDirection::Right, false));
734        assert_eq!(editor.selection_start, None);
735        assert_eq!(editor.selection_end, None);
736    }
737
738    #[test]
739    fn test_typing_with_selection() {
740        let mut editor = CodeEditor::new("hello world", "py");
741        editor.selection_start = Some((0, 0));
742        editor.selection_end = Some((0, 5));
743
744        let _ = editor.update(&Message::CharacterInput('X'));
745        // Current behavior: character is inserted at cursor, selection is NOT automatically deleted
746        // This is expected behavior - user must delete selection first (Backspace/Delete) or use Paste
747        assert_eq!(editor.buffer.line(0), "Xhello world");
748    }
749
750    #[test]
751    fn test_ctrl_home() {
752        let mut editor = CodeEditor::new("line1\nline2\nline3", "py");
753        editor.cursor = (2, 5); // Start at line 3, column 5
754        let _ = editor.update(&Message::CtrlHome);
755        assert_eq!(editor.cursor, (0, 0)); // Should move to beginning of document
756    }
757
758    #[test]
759    fn test_ctrl_end() {
760        let mut editor = CodeEditor::new("line1\nline2\nline3", "py");
761        editor.cursor = (0, 0); // Start at beginning
762        let _ = editor.update(&Message::CtrlEnd);
763        assert_eq!(editor.cursor, (2, 5)); // Should move to end of last line (line3 has 5 chars)
764    }
765
766    #[test]
767    fn test_ctrl_home_clears_selection() {
768        let mut editor = CodeEditor::new("line1\nline2\nline3", "py");
769        editor.cursor = (2, 5);
770        editor.selection_start = Some((0, 0));
771        editor.selection_end = Some((2, 5));
772
773        let _ = editor.update(&Message::CtrlHome);
774        assert_eq!(editor.cursor, (0, 0));
775        assert_eq!(editor.selection_start, None);
776        assert_eq!(editor.selection_end, None);
777    }
778
779    #[test]
780    fn test_ctrl_end_clears_selection() {
781        let mut editor = CodeEditor::new("line1\nline2\nline3", "py");
782        editor.cursor = (0, 0);
783        editor.selection_start = Some((0, 0));
784        editor.selection_end = Some((1, 3));
785
786        let _ = editor.update(&Message::CtrlEnd);
787        assert_eq!(editor.cursor, (2, 5));
788        assert_eq!(editor.selection_start, None);
789        assert_eq!(editor.selection_end, None);
790    }
791
792    #[test]
793    fn test_scroll_sets_initial_cache_window() {
794        let content =
795            (0..200).map(|i| format!("line{}\n", i)).collect::<String>();
796        let mut editor = CodeEditor::new(&content, "py");
797
798        // Simulate initial viewport
799        let height = 400.0;
800        let width = 800.0;
801        let scroll = 0.0;
802
803        // Expected derived ranges
804        let visible_lines_count =
805            (height / editor.line_height).ceil() as usize + 2;
806        let first_visible_line = (scroll / editor.line_height).floor() as usize;
807        let last_visible_line = first_visible_line + visible_lines_count;
808        let margin = visible_lines_count * 2;
809        let window_start = first_visible_line.saturating_sub(margin);
810        let window_end = last_visible_line + margin;
811
812        // Apply logic similar to Message::Scrolled branch
813        editor.viewport_height = height;
814        editor.viewport_width = width;
815        editor.viewport_scroll = -1.0;
816        let scroll_changed = (editor.viewport_scroll - scroll).abs() > 0.1;
817        let need_rewindow = true;
818        if (editor.viewport_height - height).abs() > 1.0
819            || (editor.viewport_width - width).abs() > 1.0
820            || (scroll_changed && need_rewindow)
821        {
822            editor.cache_window_start_line = window_start;
823            editor.cache_window_end_line = window_end;
824            editor.last_first_visible_line = first_visible_line;
825        }
826        editor.viewport_scroll = scroll;
827
828        assert_eq!(editor.last_first_visible_line, first_visible_line);
829        assert!(editor.cache_window_end_line > editor.cache_window_start_line);
830        assert_eq!(editor.cache_window_start_line, window_start);
831        assert_eq!(editor.cache_window_end_line, window_end);
832    }
833
834    #[test]
835    fn test_small_scroll_keeps_window() {
836        let content =
837            (0..200).map(|i| format!("line{}\n", i)).collect::<String>();
838        let mut editor = CodeEditor::new(&content, "py");
839        let height = 400.0;
840        let width = 800.0;
841        let initial_scroll = 0.0;
842        let visible_lines_count =
843            (height / editor.line_height).ceil() as usize + 2;
844        let first_visible_line =
845            (initial_scroll / editor.line_height).floor() as usize;
846        let last_visible_line = first_visible_line + visible_lines_count;
847        let margin = visible_lines_count * 2;
848        let window_start = first_visible_line.saturating_sub(margin);
849        let window_end = last_visible_line + margin;
850        editor.cache_window_start_line = window_start;
851        editor.cache_window_end_line = window_end;
852        editor.viewport_height = height;
853        editor.viewport_width = width;
854        editor.viewport_scroll = initial_scroll;
855
856        // Small scroll inside window
857        let small_scroll =
858            editor.line_height * (visible_lines_count as f32 / 4.0);
859        let first_visible_line2 =
860            (small_scroll / editor.line_height).floor() as usize;
861        let last_visible_line2 = first_visible_line2 + visible_lines_count;
862        let lower_boundary_trigger = editor.cache_window_start_line > 0
863            && first_visible_line2
864                < editor
865                    .cache_window_start_line
866                    .saturating_add(visible_lines_count / 2);
867        let upper_boundary_trigger = last_visible_line2
868            > editor
869                .cache_window_end_line
870                .saturating_sub(visible_lines_count / 2);
871        let need_rewindow = lower_boundary_trigger || upper_boundary_trigger;
872
873        assert!(!need_rewindow, "Small scroll should be inside the window");
874        // Window remains unchanged
875        assert_eq!(editor.cache_window_start_line, window_start);
876        assert_eq!(editor.cache_window_end_line, window_end);
877    }
878
879    #[test]
880    fn test_large_scroll_rewindows() {
881        let content =
882            (0..1000).map(|i| format!("line{}\n", i)).collect::<String>();
883        let mut editor = CodeEditor::new(&content, "py");
884        let height = 400.0;
885        let width = 800.0;
886        let initial_scroll = 0.0;
887        let visible_lines_count =
888            (height / editor.line_height).ceil() as usize + 2;
889        let first_visible_line =
890            (initial_scroll / editor.line_height).floor() as usize;
891        let last_visible_line = first_visible_line + visible_lines_count;
892        let margin = visible_lines_count * 2;
893        editor.cache_window_start_line =
894            first_visible_line.saturating_sub(margin);
895        editor.cache_window_end_line = last_visible_line + margin;
896        editor.viewport_height = height;
897        editor.viewport_width = width;
898        editor.viewport_scroll = initial_scroll;
899
900        // Large scroll beyond window boundary
901        let large_scroll =
902            editor.line_height * ((visible_lines_count * 4) as f32);
903        let first_visible_line2 =
904            (large_scroll / editor.line_height).floor() as usize;
905        let last_visible_line2 = first_visible_line2 + visible_lines_count;
906        let window_start2 = first_visible_line2.saturating_sub(margin);
907        let window_end2 = last_visible_line2 + margin;
908        let need_rewindow = first_visible_line2
909            < editor
910                .cache_window_start_line
911                .saturating_add(visible_lines_count / 2)
912            || last_visible_line2
913                > editor
914                    .cache_window_end_line
915                    .saturating_sub(visible_lines_count / 2);
916        assert!(need_rewindow, "Large scroll should trigger window update");
917
918        // Apply rewindow
919        editor.cache_window_start_line = window_start2;
920        editor.cache_window_end_line = window_end2;
921        editor.last_first_visible_line = first_visible_line2;
922
923        assert_eq!(editor.cache_window_start_line, window_start2);
924        assert_eq!(editor.cache_window_end_line, window_end2);
925        assert_eq!(editor.last_first_visible_line, first_visible_line2);
926    }
927
928    #[test]
929    fn test_delete_selection_message() {
930        let mut editor = CodeEditor::new("hello world", "py");
931        editor.cursor = (0, 0);
932        editor.selection_start = Some((0, 0));
933        editor.selection_end = Some((0, 5));
934
935        let _ = editor.update(&Message::DeleteSelection);
936        assert_eq!(editor.buffer.line(0), " world");
937        assert_eq!(editor.cursor, (0, 0));
938        assert_eq!(editor.selection_start, None);
939        assert_eq!(editor.selection_end, None);
940    }
941
942    #[test]
943    fn test_delete_selection_multiline() {
944        let mut editor = CodeEditor::new("line1\nline2\nline3", "py");
945        editor.cursor = (0, 2);
946        editor.selection_start = Some((0, 2));
947        editor.selection_end = Some((2, 2));
948
949        let _ = editor.update(&Message::DeleteSelection);
950        assert_eq!(editor.buffer.line(0), "line3");
951        assert_eq!(editor.cursor, (0, 2));
952        assert_eq!(editor.selection_start, None);
953    }
954
955    #[test]
956    fn test_delete_selection_no_selection() {
957        let mut editor = CodeEditor::new("hello world", "py");
958        editor.cursor = (0, 5);
959
960        let _ = editor.update(&Message::DeleteSelection);
961        // Should do nothing if there's no selection
962        assert_eq!(editor.buffer.line(0), "hello world");
963        assert_eq!(editor.cursor, (0, 5));
964    }
965
966    #[test]
967    #[allow(clippy::unwrap_used)]
968    fn test_ime_preedit_and_commit_chinese() {
969        let mut editor = CodeEditor::new("", "py");
970        // Simulate IME opened
971        let _ = editor.update(&Message::ImeOpened);
972        assert!(editor.ime_preedit.is_none());
973
974        // Preedit with Chinese content and a selection range
975        let content = "安全与合规".to_string();
976        let selection = Some(0..3); // range aligned to UTF-8 character boundary
977        let _ = editor
978            .update(&Message::ImePreedit(content.clone(), selection.clone()));
979
980        assert!(editor.ime_preedit.is_some());
981        assert_eq!(
982            editor.ime_preedit.as_ref().unwrap().content.clone(),
983            content
984        );
985        assert_eq!(
986            editor.ime_preedit.as_ref().unwrap().selection.clone(),
987            selection
988        );
989
990        // Commit should insert the text and clear preedit
991        let _ = editor.update(&Message::ImeCommit("安全与合规".to_string()));
992        assert!(editor.ime_preedit.is_none());
993        assert_eq!(editor.buffer.line(0), "安全与合规");
994        assert_eq!(editor.cursor, (0, "安全与合规".chars().count()));
995    }
996
997    #[test]
998    fn test_undo_char_insert() {
999        let mut editor = CodeEditor::new("hello", "py");
1000        editor.cursor = (0, 5);
1001
1002        // Type a character
1003        let _ = editor.update(&Message::CharacterInput('!'));
1004        assert_eq!(editor.buffer.line(0), "hello!");
1005        assert_eq!(editor.cursor, (0, 6));
1006
1007        // Undo should remove it (but first end the grouping)
1008        editor.history.end_group();
1009        let _ = editor.update(&Message::Undo);
1010        assert_eq!(editor.buffer.line(0), "hello");
1011        assert_eq!(editor.cursor, (0, 5));
1012    }
1013
1014    #[test]
1015    fn test_undo_redo_char_insert() {
1016        let mut editor = CodeEditor::new("hello", "py");
1017        editor.cursor = (0, 5);
1018
1019        // Type a character
1020        let _ = editor.update(&Message::CharacterInput('!'));
1021        editor.history.end_group();
1022
1023        // Undo
1024        let _ = editor.update(&Message::Undo);
1025        assert_eq!(editor.buffer.line(0), "hello");
1026
1027        // Redo
1028        let _ = editor.update(&Message::Redo);
1029        assert_eq!(editor.buffer.line(0), "hello!");
1030        assert_eq!(editor.cursor, (0, 6));
1031    }
1032
1033    #[test]
1034    fn test_undo_backspace() {
1035        let mut editor = CodeEditor::new("hello", "py");
1036        editor.cursor = (0, 5);
1037
1038        // Backspace
1039        let _ = editor.update(&Message::Backspace);
1040        assert_eq!(editor.buffer.line(0), "hell");
1041        assert_eq!(editor.cursor, (0, 4));
1042
1043        // Undo
1044        let _ = editor.update(&Message::Undo);
1045        assert_eq!(editor.buffer.line(0), "hello");
1046        assert_eq!(editor.cursor, (0, 5));
1047    }
1048
1049    #[test]
1050    fn test_undo_newline() {
1051        let mut editor = CodeEditor::new("hello world", "py");
1052        editor.cursor = (0, 5);
1053
1054        // Insert newline
1055        let _ = editor.update(&Message::Enter);
1056        assert_eq!(editor.buffer.line(0), "hello");
1057        assert_eq!(editor.buffer.line(1), " world");
1058        assert_eq!(editor.cursor, (1, 0));
1059
1060        // Undo
1061        let _ = editor.update(&Message::Undo);
1062        assert_eq!(editor.buffer.line(0), "hello world");
1063        assert_eq!(editor.cursor, (0, 5));
1064    }
1065
1066    #[test]
1067    fn test_undo_grouped_typing() {
1068        let mut editor = CodeEditor::new("hello", "py");
1069        editor.cursor = (0, 5);
1070
1071        // Type multiple characters (they should be grouped)
1072        let _ = editor.update(&Message::CharacterInput(' '));
1073        let _ = editor.update(&Message::CharacterInput('w'));
1074        let _ = editor.update(&Message::CharacterInput('o'));
1075        let _ = editor.update(&Message::CharacterInput('r'));
1076        let _ = editor.update(&Message::CharacterInput('l'));
1077        let _ = editor.update(&Message::CharacterInput('d'));
1078
1079        assert_eq!(editor.buffer.line(0), "hello world");
1080
1081        // End the group
1082        editor.history.end_group();
1083
1084        // Single undo should remove all grouped characters
1085        let _ = editor.update(&Message::Undo);
1086        assert_eq!(editor.buffer.line(0), "hello");
1087        assert_eq!(editor.cursor, (0, 5));
1088    }
1089
1090    #[test]
1091    fn test_navigation_ends_grouping() {
1092        let mut editor = CodeEditor::new("hello", "py");
1093        editor.cursor = (0, 5);
1094
1095        // Type a character (starts grouping)
1096        let _ = editor.update(&Message::CharacterInput('!'));
1097        assert!(editor.is_grouping);
1098
1099        // Move cursor (ends grouping)
1100        let _ = editor.update(&Message::ArrowKey(ArrowDirection::Left, false));
1101        assert!(!editor.is_grouping);
1102
1103        // Type another character (starts new group)
1104        let _ = editor.update(&Message::CharacterInput('?'));
1105        assert!(editor.is_grouping);
1106
1107        editor.history.end_group();
1108
1109        // Two separate undo operations
1110        let _ = editor.update(&Message::Undo);
1111        assert_eq!(editor.buffer.line(0), "hello!");
1112
1113        let _ = editor.update(&Message::Undo);
1114        assert_eq!(editor.buffer.line(0), "hello");
1115    }
1116
1117    #[test]
1118    fn test_multiple_undo_redo() {
1119        let mut editor = CodeEditor::new("a", "py");
1120        editor.cursor = (0, 1);
1121
1122        // Make several changes
1123        let _ = editor.update(&Message::CharacterInput('b'));
1124        editor.history.end_group();
1125
1126        let _ = editor.update(&Message::CharacterInput('c'));
1127        editor.history.end_group();
1128
1129        let _ = editor.update(&Message::CharacterInput('d'));
1130        editor.history.end_group();
1131
1132        assert_eq!(editor.buffer.line(0), "abcd");
1133
1134        // Undo all
1135        let _ = editor.update(&Message::Undo);
1136        assert_eq!(editor.buffer.line(0), "abc");
1137
1138        let _ = editor.update(&Message::Undo);
1139        assert_eq!(editor.buffer.line(0), "ab");
1140
1141        let _ = editor.update(&Message::Undo);
1142        assert_eq!(editor.buffer.line(0), "a");
1143
1144        // Redo all
1145        let _ = editor.update(&Message::Redo);
1146        assert_eq!(editor.buffer.line(0), "ab");
1147
1148        let _ = editor.update(&Message::Redo);
1149        assert_eq!(editor.buffer.line(0), "abc");
1150
1151        let _ = editor.update(&Message::Redo);
1152        assert_eq!(editor.buffer.line(0), "abcd");
1153    }
1154
1155    #[test]
1156    fn test_delete_key_with_selection() {
1157        let mut editor = CodeEditor::new("hello world", "py");
1158        editor.selection_start = Some((0, 0));
1159        editor.selection_end = Some((0, 5));
1160        editor.cursor = (0, 5);
1161
1162        let _ = editor.update(&Message::Delete);
1163
1164        assert_eq!(editor.buffer.line(0), " world");
1165        assert_eq!(editor.cursor, (0, 0));
1166        assert_eq!(editor.selection_start, None);
1167        assert_eq!(editor.selection_end, None);
1168    }
1169
1170    #[test]
1171    fn test_delete_key_without_selection() {
1172        let mut editor = CodeEditor::new("hello", "py");
1173        editor.cursor = (0, 0);
1174
1175        let _ = editor.update(&Message::Delete);
1176
1177        // Should delete the 'h'
1178        assert_eq!(editor.buffer.line(0), "ello");
1179        assert_eq!(editor.cursor, (0, 0));
1180    }
1181
1182    #[test]
1183    fn test_backspace_with_selection() {
1184        let mut editor = CodeEditor::new("hello world", "py");
1185        editor.selection_start = Some((0, 6));
1186        editor.selection_end = Some((0, 11));
1187        editor.cursor = (0, 11);
1188
1189        let _ = editor.update(&Message::Backspace);
1190
1191        assert_eq!(editor.buffer.line(0), "hello ");
1192        assert_eq!(editor.cursor, (0, 6));
1193        assert_eq!(editor.selection_start, None);
1194        assert_eq!(editor.selection_end, None);
1195    }
1196
1197    #[test]
1198    fn test_backspace_without_selection() {
1199        let mut editor = CodeEditor::new("hello", "py");
1200        editor.cursor = (0, 5);
1201
1202        let _ = editor.update(&Message::Backspace);
1203
1204        // Should delete the 'o'
1205        assert_eq!(editor.buffer.line(0), "hell");
1206        assert_eq!(editor.cursor, (0, 4));
1207    }
1208
1209    #[test]
1210    fn test_delete_multiline_selection() {
1211        let mut editor = CodeEditor::new("line1\nline2\nline3", "py");
1212        editor.selection_start = Some((0, 2));
1213        editor.selection_end = Some((2, 2));
1214        editor.cursor = (2, 2);
1215
1216        let _ = editor.update(&Message::Delete);
1217
1218        assert_eq!(editor.buffer.line(0), "line3");
1219        assert_eq!(editor.cursor, (0, 2));
1220        assert_eq!(editor.selection_start, None);
1221    }
1222
1223    #[test]
1224    fn test_canvas_focus_gained() {
1225        let mut editor = CodeEditor::new("hello world", "py");
1226        assert!(!editor.has_canvas_focus);
1227        assert!(!editor.show_cursor);
1228
1229        let _ = editor.update(&Message::CanvasFocusGained);
1230
1231        assert!(editor.has_canvas_focus);
1232        assert!(editor.show_cursor);
1233    }
1234
1235    #[test]
1236    fn test_canvas_focus_lost() {
1237        let mut editor = CodeEditor::new("hello world", "py");
1238        editor.has_canvas_focus = true;
1239        editor.show_cursor = true;
1240
1241        let _ = editor.update(&Message::CanvasFocusLost);
1242
1243        assert!(!editor.has_canvas_focus);
1244        assert!(!editor.show_cursor);
1245    }
1246
1247    #[test]
1248    fn test_mouse_click_gains_focus() {
1249        let mut editor = CodeEditor::new("hello world", "py");
1250        editor.has_canvas_focus = false;
1251        editor.show_cursor = false;
1252
1253        let _ =
1254            editor.update(&Message::MouseClick(iced::Point::new(100.0, 10.0)));
1255
1256        assert!(editor.has_canvas_focus);
1257        assert!(editor.show_cursor);
1258    }
1259}