Skip to main content

iced_code_editor/canvas_editor/
canvas_impl.rs

1//! Canvas rendering implementation using Iced's `canvas::Program`.
2
3use iced::advanced::input_method;
4use iced::mouse;
5use iced::widget::canvas::{self, Geometry};
6use iced::{Color, Event, Point, Rectangle, Size, Theme, keyboard};
7use syntect::easy::HighlightLines;
8use syntect::highlighting::{Style, ThemeSet};
9use syntect::parsing::SyntaxSet;
10
11fn is_cursor_in_bounds(cursor: &mouse::Cursor, bounds: Rectangle) -> bool {
12    match cursor {
13        mouse::Cursor::Available(point) => bounds.contains(*point),
14        mouse::Cursor::Levitating(point) => bounds.contains(*point),
15        mouse::Cursor::Unavailable => false,
16    }
17}
18
19/// Computes geometry (x start and width) for a text segment used in rendering or highlighting.
20///
21/// # Arguments
22///
23/// * `line_content`: full text content of the current line.
24/// * `visual_start_col`: start column index of the current visual line.
25/// * `segment_start_col`: start column index of the target segment (e.g. highlight).
26/// * `segment_end_col`: end column index of the target segment.
27/// * `base_offset`: base X offset (usually gutter_width + padding).
28///
29/// # Returns
30///
31/// x_start, width
32///
33/// # Remark
34///
35/// This function handles CJK character widths correctly to keep highlights accurate.
36fn calculate_segment_geometry(
37    line_content: &str,
38    visual_start_col: usize,
39    segment_start_col: usize,
40    segment_end_col: usize,
41    base_offset: f32,
42    full_char_width: f32,
43    char_width: f32,
44) -> (f32, f32) {
45    // Calculate prefix width relative to visual line start
46    let prefix_len = segment_start_col.saturating_sub(visual_start_col);
47    let prefix_text: String =
48        line_content.chars().skip(visual_start_col).take(prefix_len).collect();
49    let prefix_width =
50        measure_text_width(&prefix_text, full_char_width, char_width);
51
52    // Calculate segment width
53    let segment_len = segment_end_col.saturating_sub(segment_start_col);
54    let segment_text: String = line_content
55        .chars()
56        .skip(segment_start_col)
57        .take(segment_len)
58        .collect();
59    let segment_width =
60        measure_text_width(&segment_text, full_char_width, char_width);
61
62    (base_offset + prefix_width, segment_width)
63}
64
65use super::wrapping::WrappingCalculator;
66use super::{ArrowDirection, CodeEditor, Message, measure_text_width};
67use iced::widget::canvas::Action;
68
69impl canvas::Program<Message> for CodeEditor {
70    type State = ();
71
72    /// Renders the code editor's visual elements on the canvas, including text layout, syntax highlighting,
73    /// cursor positioning, and other graphical aspects.
74    ///
75    /// # Arguments
76    ///
77    /// * `state` - The current state of the canvas
78    /// * `renderer` - The renderer used for drawing
79    /// * `theme` - The theme for styling
80    /// * `bounds` - The rectangle bounds of the canvas
81    /// * `cursor` - The mouse cursor position
82    ///
83    /// # Returns
84    ///
85    /// A vector of `Geometry` objects representing the drawn elements
86    fn draw(
87        &self,
88        _state: &Self::State,
89        renderer: &iced::Renderer,
90        _theme: &Theme,
91        bounds: Rectangle,
92        _cursor: mouse::Cursor,
93    ) -> Vec<Geometry> {
94        let geometry = self.cache.draw(renderer, bounds.size(), |frame| {
95            // Initialize wrapping calculator
96            let wrapping_calc = WrappingCalculator::new(
97                self.wrap_enabled,
98                self.wrap_column,
99                self.full_char_width,
100                self.char_width,
101            );
102            let visual_lines = wrapping_calc.calculate_visual_lines(
103                &self.buffer,
104                bounds.width,
105                self.gutter_width(),
106            );
107
108            // Calculate visible line range based on viewport for optimized rendering
109            // Use bounds.height as fallback when viewport_height is not yet initialized
110            let effective_viewport_height = if self.viewport_height > 0.0 {
111                self.viewport_height
112            } else {
113                bounds.height
114            };
115            let first_visible_line =
116                (self.viewport_scroll / self.line_height).floor() as usize;
117            let visible_lines_count =
118                (effective_viewport_height / self.line_height).ceil() as usize
119                    + 2;
120            let last_visible_line = (first_visible_line + visible_lines_count)
121                .min(visual_lines.len());
122
123            // Load syntax highlighting
124            let syntax_set = SyntaxSet::load_defaults_newlines();
125            let theme_set = ThemeSet::load_defaults();
126            let syntax_theme = &theme_set.themes["base16-ocean.dark"];
127
128            // Note: Extension-based lookup below falls back to plain text via
129            // `.or(Some(syntax_set.find_syntax_plain_text()))`. If `self.syntax`
130            // is a language name that doesn't match a known file extension or an
131            // unsupported extension, `syntect` returns `None` and the fallback
132            // causes many inputs to be highlighted as plain text.
133            let syntax_ref = match self.syntax.as_str() {
134                "python" => syntax_set.find_syntax_by_extension("py"),
135                "rust" => syntax_set.find_syntax_by_extension("rs"),
136                "javascript" => syntax_set.find_syntax_by_extension("js"),
137                "htm" => syntax_set.find_syntax_by_extension("html"),
138                "svg" => syntax_set.find_syntax_by_extension("xml"),
139                "markdown" => syntax_set.find_syntax_by_extension("md"),
140                "text" => Some(syntax_set.find_syntax_plain_text()),
141                _ => syntax_set.find_syntax_by_extension(self.syntax.as_str()),
142            }
143            .or(Some(syntax_set.find_syntax_plain_text()));
144
145            // Choose the render range:
146            // - If a cache window is defined, render that larger window
147            //   to avoid frequent cache clears as the user scrolls.
148            // - Otherwise, render only the current visible range.
149            let (start_idx, end_idx) = if self.cache_window_end_line
150                > self.cache_window_start_line
151            {
152                let s = self.cache_window_start_line.min(visual_lines.len());
153                let e = self.cache_window_end_line.min(visual_lines.len());
154                (s, e)
155            } else {
156                (first_visible_line, last_visible_line)
157            };
158
159            for (idx, visual_line) in visual_lines
160                .iter()
161                .enumerate()
162                .skip(start_idx)
163                .take(end_idx.saturating_sub(start_idx))
164            {
165                let y = idx as f32 * self.line_height;
166
167                // Note: Gutter background is handled by a container in view.rs
168                // to ensure proper clipping when the pane is resized.
169
170                // Draw line number only for first segment
171                if self.line_numbers_enabled {
172                    if visual_line.is_first_segment() {
173                        let line_num = visual_line.logical_line + 1;
174                        let line_num_text = format!("{}", line_num);
175                        // Calculate actual text width and center in gutter
176                        let text_width = measure_text_width(
177                            &line_num_text,
178                            self.full_char_width,
179                            self.char_width,
180                        );
181                        let x_pos = (self.gutter_width() - text_width) / 2.0;
182                        frame.fill_text(canvas::Text {
183                            content: line_num_text,
184                            position: Point::new(x_pos, y + 2.0),
185                            color: self.style.line_number_color,
186                            size: self.font_size.into(),
187                            font: self.font,
188                            ..canvas::Text::default()
189                        });
190                    } else {
191                        // Draw wrap indicator for continuation lines
192                        frame.fill_text(canvas::Text {
193                            content: "↪".to_string(),
194                            position: Point::new(
195                                self.gutter_width() - 20.0,
196                                y + 2.0,
197                            ),
198                            color: self.style.line_number_color,
199                            size: self.font_size.into(),
200                            font: self.font,
201                            ..canvas::Text::default()
202                        });
203                    }
204                }
205
206                // Highlight current line (based on logical line)
207                if visual_line.logical_line == self.cursor.0 {
208                    frame.fill_rectangle(
209                        Point::new(self.gutter_width(), y),
210                        Size::new(
211                            bounds.width - self.gutter_width(),
212                            self.line_height,
213                        ),
214                        self.style.current_line_highlight,
215                    );
216                }
217
218                // Draw text content with syntax highlighting
219                let full_line_content =
220                    self.buffer.line(visual_line.logical_line);
221
222                // Convert character indices to byte indices for UTF-8 string slicing
223                let start_byte = full_line_content
224                    .char_indices()
225                    .nth(visual_line.start_col)
226                    .map_or(full_line_content.len(), |(idx, _)| idx);
227                let end_byte = full_line_content
228                    .char_indices()
229                    .nth(visual_line.end_col)
230                    .map_or(full_line_content.len(), |(idx, _)| idx);
231                let line_segment = &full_line_content[start_byte..end_byte];
232
233                if let Some(syntax) = syntax_ref {
234                    let mut highlighter =
235                        HighlightLines::new(syntax, syntax_theme);
236
237                    // Highlight the full line to get correct token colors
238                    let full_line_ranges = highlighter
239                        .highlight_line(full_line_content, &syntax_set)
240                        .unwrap_or_else(|_| {
241                            vec![(Style::default(), full_line_content)]
242                        });
243
244                    // Extract only the ranges that fall within our segment
245                    let mut x_offset = self.gutter_width() + 5.0;
246                    let mut char_pos = 0;
247
248                    for (style, text) in full_line_ranges {
249                        let text_len = text.chars().count();
250                        let text_end = char_pos + text_len;
251
252                        // Check if this token intersects with our segment
253                        if text_end > visual_line.start_col
254                            && char_pos < visual_line.end_col
255                        {
256                            // Calculate the intersection
257                            let segment_start =
258                                char_pos.max(visual_line.start_col);
259                            let segment_end = text_end.min(visual_line.end_col);
260
261                            let text_start_offset =
262                                segment_start.saturating_sub(char_pos);
263                            let text_end_offset = text_start_offset
264                                + (segment_end - segment_start);
265
266                            // Convert character offsets to byte offsets for UTF-8 slicing
267                            let start_byte = text
268                                .char_indices()
269                                .nth(text_start_offset)
270                                .map_or(text.len(), |(idx, _)| idx);
271                            let end_byte = text
272                                .char_indices()
273                                .nth(text_end_offset)
274                                .map_or(text.len(), |(idx, _)| idx);
275
276                            let segment_text = &text[start_byte..end_byte];
277
278                            let color = Color::from_rgb(
279                                f32::from(style.foreground.r) / 255.0,
280                                f32::from(style.foreground.g) / 255.0,
281                                f32::from(style.foreground.b) / 255.0,
282                            );
283
284                            frame.fill_text(canvas::Text {
285                                content: segment_text.to_string(),
286                                position: Point::new(x_offset, y + 2.0),
287                                color,
288                                size: self.font_size.into(),
289                                font: self.font,
290                                ..canvas::Text::default()
291                            });
292
293                            x_offset += measure_text_width(
294                                segment_text,
295                                self.full_char_width,
296                                self.char_width,
297                            );
298                        }
299
300                        char_pos = text_end;
301                    }
302                } else {
303                    // Fallback to plain text
304                    frame.fill_text(canvas::Text {
305                        content: line_segment.to_string(),
306                        position: Point::new(
307                            self.gutter_width() + 5.0,
308                            y + 2.0,
309                        ),
310                        color: self.style.text_color,
311                        size: self.font_size.into(),
312                        font: self.font,
313                        ..canvas::Text::default()
314                    });
315                }
316            }
317
318            // Draw search match highlights
319            if self.search_state.is_open && !self.search_state.query.is_empty()
320            {
321                let query_len = self.search_state.query.chars().count();
322
323                // Optimization: Only draw matches that are within the visible area
324                // Find the range of visible logical lines based on visible visual lines
325                let start_visual_idx = first_visible_line;
326                // last_visible_line is exclusive bound, so subtract 1 for last index
327                let end_visual_idx = last_visible_line
328                    .saturating_sub(1)
329                    .min(visual_lines.len().saturating_sub(1));
330
331                if let (Some(start_vl), Some(end_vl)) = (
332                    visual_lines.get(start_visual_idx),
333                    visual_lines.get(end_visual_idx),
334                ) {
335                    let min_logical_line = start_vl.logical_line;
336                    let max_logical_line = end_vl.logical_line;
337
338                    // Optimization: Use get_visible_match_range to find matches in view
339                    // This uses binary search + early termination for O(log N) performance
340                    let match_range = super::search::get_visible_match_range(
341                        &self.search_state.matches,
342                        min_logical_line,
343                        max_logical_line,
344                    );
345
346                    for (match_idx, search_match) in self
347                        .search_state
348                        .matches
349                        .iter()
350                        .enumerate()
351                        .skip(match_range.start)
352                        .take(match_range.len())
353                    {
354                        // Determine if this is the current match
355                        let is_current = self.search_state.current_match_index
356                            == Some(match_idx);
357
358                        let highlight_color = if is_current {
359                            // Orange for current match
360                            Color { r: 1.0, g: 0.6, b: 0.0, a: 0.4 }
361                        } else {
362                            // Yellow for other matches
363                            Color { r: 1.0, g: 1.0, b: 0.0, a: 0.3 }
364                        };
365
366                        // Convert logical position to visual line
367                        let start_visual =
368                            WrappingCalculator::logical_to_visual(
369                                &visual_lines,
370                                search_match.line,
371                                search_match.col,
372                            );
373                        let end_visual = WrappingCalculator::logical_to_visual(
374                            &visual_lines,
375                            search_match.line,
376                            search_match.col + query_len,
377                        );
378
379                        if let (Some(start_v), Some(end_v)) =
380                            (start_visual, end_visual)
381                        {
382                            if start_v == end_v {
383                                // Match within same visual line
384                                let y = start_v as f32 * self.line_height;
385                                let vl = &visual_lines[start_v];
386                                let line_content =
387                                    self.buffer.line(vl.logical_line);
388
389                                // Use calculate_segment_geometry to compute match position and width
390                                let (x_start, match_width) =
391                                    calculate_segment_geometry(
392                                        line_content,
393                                        vl.start_col,
394                                        search_match.col,
395                                        search_match.col + query_len,
396                                        self.gutter_width() + 5.0,
397                                        self.full_char_width,
398                                        self.char_width,
399                                    );
400                                let x_end = x_start + match_width;
401
402                                frame.fill_rectangle(
403                                    Point::new(x_start, y + 2.0),
404                                    Size::new(
405                                        x_end - x_start,
406                                        self.line_height - 4.0,
407                                    ),
408                                    highlight_color,
409                                );
410                            } else {
411                                // Match spans multiple visual lines
412                                for (v_idx, vl) in visual_lines
413                                    .iter()
414                                    .enumerate()
415                                    .skip(start_v)
416                                    .take(end_v - start_v + 1)
417                                {
418                                    let y = v_idx as f32 * self.line_height;
419
420                                    let match_start_col = search_match.col;
421                                    let match_end_col =
422                                        search_match.col + query_len;
423
424                                    let sel_start_col = if v_idx == start_v {
425                                        match_start_col
426                                    } else {
427                                        vl.start_col
428                                    };
429                                    let sel_end_col = if v_idx == end_v {
430                                        match_end_col
431                                    } else {
432                                        vl.end_col
433                                    };
434
435                                    let line_content =
436                                        self.buffer.line(vl.logical_line);
437
438                                    let (x_start, sel_width) =
439                                        calculate_segment_geometry(
440                                            line_content,
441                                            vl.start_col,
442                                            sel_start_col,
443                                            sel_end_col,
444                                            self.gutter_width() + 5.0,
445                                            self.full_char_width,
446                                            self.char_width,
447                                        );
448                                    let x_end = x_start + sel_width;
449
450                                    frame.fill_rectangle(
451                                        Point::new(x_start, y + 2.0),
452                                        Size::new(
453                                            x_end - x_start,
454                                            self.line_height - 4.0,
455                                        ),
456                                        highlight_color,
457                                    );
458                                }
459                            }
460                        }
461                    }
462                }
463            }
464
465            // Draw selection highlight
466            if let Some((start, end)) = self.get_selection_range()
467                && start != end
468            {
469                let selection_color = Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 };
470
471                if start.0 == end.0 {
472                    // Single line selection - need to handle wrapped segments
473                    let start_visual = WrappingCalculator::logical_to_visual(
474                        &visual_lines,
475                        start.0,
476                        start.1,
477                    );
478                    let end_visual = WrappingCalculator::logical_to_visual(
479                        &visual_lines,
480                        end.0,
481                        end.1,
482                    );
483
484                    if let (Some(start_v), Some(end_v)) =
485                        (start_visual, end_visual)
486                    {
487                        if start_v == end_v {
488                            // Selection within same visual line
489                            let y = start_v as f32 * self.line_height;
490                            let vl = &visual_lines[start_v];
491                            let line_content =
492                                self.buffer.line(vl.logical_line);
493
494                            let (x_start, sel_width) =
495                                calculate_segment_geometry(
496                                    line_content,
497                                    vl.start_col,
498                                    start.1,
499                                    end.1,
500                                    self.gutter_width() + 5.0,
501                                    self.full_char_width,
502                                    self.char_width,
503                                );
504                            let x_end = x_start + sel_width;
505
506                            frame.fill_rectangle(
507                                Point::new(x_start, y + 2.0),
508                                Size::new(
509                                    x_end - x_start,
510                                    self.line_height - 4.0,
511                                ),
512                                selection_color,
513                            );
514                        } else {
515                            // Selection spans multiple visual lines (same logical line)
516                            for (v_idx, vl) in visual_lines
517                                .iter()
518                                .enumerate()
519                                .skip(start_v)
520                                .take(end_v - start_v + 1)
521                            {
522                                let y = v_idx as f32 * self.line_height;
523
524                                let sel_start_col = if v_idx == start_v {
525                                    start.1
526                                } else {
527                                    vl.start_col
528                                };
529                                let sel_end_col = if v_idx == end_v {
530                                    end.1
531                                } else {
532                                    vl.end_col
533                                };
534
535                                let line_content =
536                                    self.buffer.line(vl.logical_line);
537
538                                let (x_start, sel_width) =
539                                    calculate_segment_geometry(
540                                        line_content,
541                                        vl.start_col,
542                                        sel_start_col,
543                                        sel_end_col,
544                                        self.gutter_width() + 5.0,
545                                        self.full_char_width,
546                                        self.char_width,
547                                    );
548                                let x_end = x_start + sel_width;
549
550                                frame.fill_rectangle(
551                                    Point::new(x_start, y + 2.0),
552                                    Size::new(
553                                        x_end - x_start,
554                                        self.line_height - 4.0,
555                                    ),
556                                    selection_color,
557                                );
558                            }
559                        }
560                    }
561                } else {
562                    // Multi-line selection
563                    let start_visual = WrappingCalculator::logical_to_visual(
564                        &visual_lines,
565                        start.0,
566                        start.1,
567                    );
568                    let end_visual = WrappingCalculator::logical_to_visual(
569                        &visual_lines,
570                        end.0,
571                        end.1,
572                    );
573
574                    if let (Some(start_v), Some(end_v)) =
575                        (start_visual, end_visual)
576                    {
577                        for (v_idx, vl) in visual_lines
578                            .iter()
579                            .enumerate()
580                            .skip(start_v)
581                            .take(end_v - start_v + 1)
582                        {
583                            let y = v_idx as f32 * self.line_height;
584
585                            let sel_start_col = if vl.logical_line == start.0
586                                && v_idx == start_v
587                            {
588                                start.1
589                            } else {
590                                vl.start_col
591                            };
592
593                            let sel_end_col =
594                                if vl.logical_line == end.0 && v_idx == end_v {
595                                    end.1
596                                } else {
597                                    vl.end_col
598                                };
599
600                            let line_content =
601                                self.buffer.line(vl.logical_line);
602
603                            let (x_start, sel_width) =
604                                calculate_segment_geometry(
605                                    line_content,
606                                    vl.start_col,
607                                    sel_start_col,
608                                    sel_end_col,
609                                    self.gutter_width() + 5.0,
610                                    self.full_char_width,
611                                    self.char_width,
612                                );
613                            let x_end = x_start + sel_width;
614
615                            frame.fill_rectangle(
616                                Point::new(x_start, y + 2.0),
617                                Size::new(
618                                    x_end - x_start,
619                                    self.line_height - 4.0,
620                                ),
621                                selection_color,
622                            );
623                        }
624                    }
625                }
626            }
627
628            // Cursor drawing logic (only when the editor has focus)
629            // -------------------------------------------------------------------------
630            // Core notes:
631            // 1. Choose the drawing path based on whether IME preedit is present.
632            // 2. Require both `is_focused()` (Iced focus) and `has_canvas_focus()` (internal focus)
633            //    so the cursor is drawn only in the active editor, avoiding multiple cursors.
634            // 3. Use `WrappingCalculator` to map logical (line, col) to visual (x, y)
635            //    for correct cursor positioning with line wrapping.
636            // -------------------------------------------------------------------------
637            if self.show_cursor
638                && self.cursor_visible
639                && self.is_focused()
640                && self.has_canvas_focus
641                && self.ime_preedit.is_some()
642            {
643                // [Branch A] IME preedit rendering mode
644                // ---------------------------------------------------------------------
645                // When the user is composing with an IME (e.g. pinyin before commit),
646                // draw a preedit region instead of the normal caret, including:
647                // - preedit background (highlighting the composing text)
648                // - preedit text content (preedit.content)
649                // - preedit selection (underline or selection background)
650                // - preedit caret
651                // ---------------------------------------------------------------------
652                if let Some(cursor_visual) =
653                    WrappingCalculator::logical_to_visual(
654                        &visual_lines,
655                        self.cursor.0,
656                        self.cursor.1,
657                    )
658                {
659                    let vl = &visual_lines[cursor_visual];
660                    let line_content = self.buffer.line(vl.logical_line);
661
662                    // Compute the preedit region start X
663                    // Use calculate_segment_geometry to ensure correct CJK width handling
664                    let (cursor_x, _) = calculate_segment_geometry(
665                        line_content,
666                        vl.start_col,
667                        self.cursor.1,
668                        self.cursor.1,
669                        self.gutter_width() + 5.0,
670                        self.full_char_width,
671                        self.char_width,
672                    );
673                    let cursor_y = cursor_visual as f32 * self.line_height;
674
675                    if let Some(preedit) = self.ime_preedit.as_ref() {
676                        let preedit_width = measure_text_width(
677                            &preedit.content,
678                            self.full_char_width,
679                            self.char_width,
680                        );
681
682                        // 1. Draw preedit background (light translucent)
683                        // This indicates the text is not committed yet
684                        frame.fill_rectangle(
685                            Point::new(cursor_x, cursor_y + 2.0),
686                            Size::new(preedit_width, self.line_height - 4.0),
687                            Color { r: 1.0, g: 1.0, b: 1.0, a: 0.08 },
688                        );
689
690                        // 2. Draw preedit selection (if any)
691                        // IME may mark a selection inside preedit text (e.g. segmentation)
692                        // The range uses UTF-8 byte indices, so slices must be safe
693                        if let Some(range) = preedit.selection.as_ref()
694                            && range.start != range.end
695                        {
696                            // Validate indices before slicing to prevent panic
697                            if let Some((start, end)) =
698                                validate_selection_indices(
699                                    &preedit.content,
700                                    range.start,
701                                    range.end,
702                                )
703                            {
704                                let selected_prefix = &preedit.content[..start];
705                                let selected_text =
706                                    &preedit.content[start..end];
707
708                                let selection_x = cursor_x
709                                    + measure_text_width(
710                                        selected_prefix,
711                                        self.full_char_width,
712                                        self.char_width,
713                                    );
714                                let selection_w = measure_text_width(
715                                    selected_text,
716                                    self.full_char_width,
717                                    self.char_width,
718                                );
719
720                                frame.fill_rectangle(
721                                    Point::new(selection_x, cursor_y + 2.0),
722                                    Size::new(
723                                        selection_w,
724                                        self.line_height - 4.0,
725                                    ),
726                                    Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 },
727                                );
728                            }
729                        }
730
731                        // 3. Draw preedit text itself
732                        frame.fill_text(canvas::Text {
733                            content: preedit.content.clone(),
734                            position: Point::new(cursor_x, cursor_y + 2.0),
735                            color: self.style.text_color,
736                            size: self.font_size.into(),
737                            font: self.font,
738                            ..canvas::Text::default()
739                        });
740
741                        // 4. Draw bottom underline (IME state indicator)
742                        frame.fill_rectangle(
743                            Point::new(
744                                cursor_x,
745                                cursor_y + self.line_height - 3.0,
746                            ),
747                            Size::new(preedit_width, 1.0),
748                            self.style.text_color,
749                        );
750
751                        // 5. Draw preedit caret
752                        // If IME provides a caret position (usually selection end), draw a thin bar
753                        if let Some(range) = preedit.selection.as_ref() {
754                            let caret_end =
755                                range.end.min(preedit.content.len());
756
757                            // Validate caret position to avoid panic on invalid UTF-8 boundary
758                            if caret_end <= preedit.content.len()
759                                && preedit.content.is_char_boundary(caret_end)
760                            {
761                                let caret_prefix =
762                                    &preedit.content[..caret_end];
763                                let caret_x = cursor_x
764                                    + measure_text_width(
765                                        caret_prefix,
766                                        self.full_char_width,
767                                        self.char_width,
768                                    );
769
770                                frame.fill_rectangle(
771                                    Point::new(caret_x, cursor_y + 2.0),
772                                    Size::new(2.0, self.line_height - 4.0),
773                                    self.style.text_color,
774                                );
775                            }
776                        }
777                    }
778                }
779            } else if self.show_cursor
780                && self.cursor_visible
781                && self.is_focused()
782                && self.has_canvas_focus
783            {
784                // [Branch B] Normal caret rendering mode
785                // ---------------------------------------------------------------------
786                // When there is no IME preedit, draw the standard editor caret.
787                // Key checks:
788                // - is_focused(): the widget has Iced focus
789                // - has_canvas_focus: internal focus state (mouse clicks, etc.)
790                // - draw only when both are true to avoid ghost cursors
791                // ---------------------------------------------------------------------
792
793                // Map logical cursor position (Line, Col) to visual line index
794                // to handle line wrapping changes
795                if let Some(cursor_visual) =
796                    WrappingCalculator::logical_to_visual(
797                        &visual_lines,
798                        self.cursor.0,
799                        self.cursor.1,
800                    )
801                {
802                    let vl = &visual_lines[cursor_visual];
803                    let line_content = self.buffer.line(vl.logical_line);
804
805                    // Compute exact caret X position
806                    // Account for gutter width, left padding, and rendered prefix width
807                    let (cursor_x, _) = calculate_segment_geometry(
808                        line_content,
809                        vl.start_col,
810                        self.cursor.1,
811                        self.cursor.1,
812                        self.gutter_width() + 5.0,
813                        self.full_char_width,
814                        self.char_width,
815                    );
816                    let cursor_y = cursor_visual as f32 * self.line_height;
817
818                    // Draw standard caret (2px vertical bar)
819                    frame.fill_rectangle(
820                        Point::new(cursor_x, cursor_y + 2.0),
821                        Size::new(2.0, self.line_height - 4.0),
822                        self.style.text_color,
823                    );
824                }
825            }
826        });
827
828        vec![geometry]
829    }
830
831    /// Handles Canvas trait events, specifically keyboard input events and focus management for the code editor widget.
832    ///
833    /// # Arguments
834    ///
835    /// * `_state` - The mutable state of the canvas (unused in this implementation)
836    /// * `event` - The input event to handle, such as keyboard presses
837    /// * `bounds` - The rectangle bounds of the canvas widget
838    /// * `cursor` - The current mouse cursor position and status
839    ///
840    /// # Returns
841    ///
842    /// An optional `Action<Message>` to perform, such as sending a message or redrawing the canvas
843    fn update(
844        &self,
845        _state: &mut Self::State,
846        event: &Event,
847        bounds: Rectangle,
848        cursor: mouse::Cursor,
849    ) -> Option<Action<Message>> {
850        match event {
851            Event::Keyboard(keyboard::Event::KeyPressed {
852                key,
853                modifiers,
854                text,
855                ..
856            }) => {
857                // Only process keyboard events if this editor has focus
858                let focused_id = super::FOCUSED_EDITOR_ID
859                    .load(std::sync::atomic::Ordering::Relaxed);
860                if focused_id != self.editor_id {
861                    return None;
862                }
863
864                // Cursor outside canvas bounds
865                if !is_cursor_in_bounds(&cursor, bounds) {
866                    return None;
867                }
868
869                // Only process keyboard events if canvas has focus
870                if !self.has_canvas_focus {
871                    return None;
872                }
873
874                if self.ime_preedit.is_some()
875                    && !(modifiers.control() || modifiers.command())
876                {
877                    return None;
878                }
879
880                // Handle Ctrl+C / Ctrl+Insert (copy)
881                if (modifiers.control()
882                    && matches!(key, keyboard::Key::Character(c) if c.as_str() == "c"))
883                    || (modifiers.control()
884                        && matches!(
885                            key,
886                            keyboard::Key::Named(keyboard::key::Named::Insert)
887                        ))
888                {
889                    return Some(Action::publish(Message::Copy).and_capture());
890                }
891
892                // Handle Ctrl+Z (undo)
893                if modifiers.control()
894                    && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")
895                {
896                    return Some(Action::publish(Message::Undo).and_capture());
897                }
898
899                // Handle Ctrl+Y (redo)
900                if modifiers.control()
901                    && matches!(key, keyboard::Key::Character(y) if y.as_str() == "y")
902                {
903                    return Some(Action::publish(Message::Redo).and_capture());
904                }
905
906                // Handle Ctrl+F (open search)
907                if modifiers.control()
908                    && matches!(key, keyboard::Key::Character(f) if f.as_str() == "f")
909                    && self.search_replace_enabled
910                {
911                    return Some(
912                        Action::publish(Message::OpenSearch).and_capture(),
913                    );
914                }
915
916                // Handle Ctrl+H (open search and replace)
917                if modifiers.control()
918                    && matches!(key, keyboard::Key::Character(h) if h.as_str() == "h")
919                    && self.search_replace_enabled
920                {
921                    return Some(
922                        Action::publish(Message::OpenSearchReplace)
923                            .and_capture(),
924                    );
925                }
926
927                // Handle Escape (close search dialog if open)
928                if matches!(
929                    key,
930                    keyboard::Key::Named(keyboard::key::Named::Escape)
931                ) {
932                    return Some(
933                        Action::publish(Message::CloseSearch).and_capture(),
934                    );
935                }
936
937                // Handle Tab (cycle forward in search dialog if open)
938                if matches!(
939                    key,
940                    keyboard::Key::Named(keyboard::key::Named::Tab)
941                ) && self.search_state.is_open
942                {
943                    if modifiers.shift() {
944                        // Shift+Tab: cycle backward
945                        return Some(
946                            Action::publish(Message::SearchDialogShiftTab)
947                                .and_capture(),
948                        );
949                    } else {
950                        // Tab: cycle forward
951                        return Some(
952                            Action::publish(Message::SearchDialogTab)
953                                .and_capture(),
954                        );
955                    }
956                }
957
958                // Handle F3 (find next) and Shift+F3 (find previous)
959                if matches!(key, keyboard::Key::Named(keyboard::key::Named::F3))
960                    && self.search_replace_enabled
961                {
962                    if modifiers.shift() {
963                        return Some(
964                            Action::publish(Message::FindPrevious)
965                                .and_capture(),
966                        );
967                    } else {
968                        return Some(
969                            Action::publish(Message::FindNext).and_capture(),
970                        );
971                    }
972                }
973
974                // Handle Ctrl+V / Shift+Insert (paste) - read clipboard and send paste message
975                if (modifiers.control()
976                    && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v"))
977                    || (modifiers.shift()
978                        && matches!(
979                            key,
980                            keyboard::Key::Named(keyboard::key::Named::Insert)
981                        ))
982                {
983                    // Return an action that requests clipboard read
984                    return Some(Action::publish(
985                        Message::Paste(String::new()),
986                    ));
987                }
988
989                // Handle Ctrl+Home (go to start of document)
990                if modifiers.control()
991                    && matches!(
992                        key,
993                        keyboard::Key::Named(keyboard::key::Named::Home)
994                    )
995                {
996                    return Some(
997                        Action::publish(Message::CtrlHome).and_capture(),
998                    );
999                }
1000
1001                // Handle Ctrl+End (go to end of document)
1002                if modifiers.control()
1003                    && matches!(
1004                        key,
1005                        keyboard::Key::Named(keyboard::key::Named::End)
1006                    )
1007                {
1008                    return Some(
1009                        Action::publish(Message::CtrlEnd).and_capture(),
1010                    );
1011                }
1012
1013                // Handle Shift+Delete (delete selection)
1014                if modifiers.shift()
1015                    && matches!(
1016                        key,
1017                        keyboard::Key::Named(keyboard::key::Named::Delete)
1018                    )
1019                {
1020                    return Some(
1021                        Action::publish(Message::DeleteSelection).and_capture(),
1022                    );
1023                }
1024
1025                // PRIORITY 1: Check if 'text' field has valid printable character
1026                // This handles:
1027                // - Numpad keys with NumLock ON (key=Named(ArrowDown), text=Some("2"))
1028                // - Regular typing with shift, accents, international layouts
1029                if let Some(text_content) = text
1030                    && !text_content.is_empty()
1031                    && !modifiers.control()
1032                    && !modifiers.alt()
1033                {
1034                    // Check if it's a printable character (not a control character)
1035                    // This filters out Enter (\n), Tab (\t), Delete (U+007F), etc.
1036                    if let Some(first_char) = text_content.chars().next()
1037                        && !first_char.is_control()
1038                    {
1039                        return Some(
1040                            Action::publish(Message::CharacterInput(
1041                                first_char,
1042                            ))
1043                            .and_capture(),
1044                        );
1045                    }
1046                }
1047
1048                // PRIORITY 2: Handle special named keys (navigation, editing)
1049                // These are only processed if text didn't contain a printable character
1050                let message = match key {
1051                    keyboard::Key::Named(keyboard::key::Named::Backspace) => {
1052                        Some(Message::Backspace)
1053                    }
1054                    keyboard::Key::Named(keyboard::key::Named::Delete) => {
1055                        Some(Message::Delete)
1056                    }
1057                    keyboard::Key::Named(keyboard::key::Named::Enter) => {
1058                        Some(Message::Enter)
1059                    }
1060                    keyboard::Key::Named(keyboard::key::Named::Tab) => {
1061                        // Insert 4 spaces for Tab
1062                        Some(Message::Tab)
1063                    }
1064                    keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
1065                        Some(Message::ArrowKey(
1066                            ArrowDirection::Up,
1067                            modifiers.shift(),
1068                        ))
1069                    }
1070                    keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
1071                        Some(Message::ArrowKey(
1072                            ArrowDirection::Down,
1073                            modifiers.shift(),
1074                        ))
1075                    }
1076                    keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
1077                        Some(Message::ArrowKey(
1078                            ArrowDirection::Left,
1079                            modifiers.shift(),
1080                        ))
1081                    }
1082                    keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
1083                        Some(Message::ArrowKey(
1084                            ArrowDirection::Right,
1085                            modifiers.shift(),
1086                        ))
1087                    }
1088                    keyboard::Key::Named(keyboard::key::Named::PageUp) => {
1089                        Some(Message::PageUp)
1090                    }
1091                    keyboard::Key::Named(keyboard::key::Named::PageDown) => {
1092                        Some(Message::PageDown)
1093                    }
1094                    keyboard::Key::Named(keyboard::key::Named::Home) => {
1095                        Some(Message::Home(modifiers.shift()))
1096                    }
1097                    keyboard::Key::Named(keyboard::key::Named::End) => {
1098                        Some(Message::End(modifiers.shift()))
1099                    }
1100                    // PRIORITY 3: Fallback to extracting from 'key' if text was empty/control char
1101                    // This handles edge cases where text field is not populated
1102                    _ => {
1103                        if !modifiers.control()
1104                            && !modifiers.alt()
1105                            && let keyboard::Key::Character(c) = key
1106                            && !c.is_empty()
1107                        {
1108                            return c
1109                                .chars()
1110                                .next()
1111                                .map(Message::CharacterInput)
1112                                .map(|msg| Action::publish(msg).and_capture());
1113                        }
1114                        None
1115                    }
1116                };
1117
1118                message.map(|msg| Action::publish(msg).and_capture())
1119            }
1120            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
1121                cursor.position_in(bounds).map(|position| {
1122                    // Don't capture the event so it can bubble up for focus management
1123                    Action::publish(Message::MouseClick(position))
1124                })
1125            }
1126            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
1127                // Handle mouse drag for selection only when cursor is within bounds
1128                cursor.position_in(bounds).map(|position| {
1129                    Action::publish(Message::MouseDrag(position)).and_capture()
1130                })
1131            }
1132            Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
1133                // Only handle mouse release when cursor is within bounds
1134                // This prevents capturing events meant for other widgets
1135                if cursor.is_over(bounds) {
1136                    Some(Action::publish(Message::MouseRelease).and_capture())
1137                } else {
1138                    None
1139                }
1140            }
1141            Event::InputMethod(event) => {
1142                let focused_id = super::FOCUSED_EDITOR_ID
1143                    .load(std::sync::atomic::Ordering::Relaxed);
1144                if focused_id != self.editor_id {
1145                    return None;
1146                }
1147
1148                if !is_cursor_in_bounds(&cursor, bounds) {
1149                    return None;
1150                }
1151
1152                if !self.has_canvas_focus {
1153                    return None;
1154                }
1155
1156                // IME event handling
1157                // ---------------------------------------------------------------------
1158                // Core mapping: convert Iced IME events into editor Messages
1159                //
1160                // Flow:
1161                // 1. Opened: IME activated (e.g. switching input method). Clear old preedit state.
1162                // 2. Preedit: User is composing (e.g. typing "nihao" before commit).
1163                //    - content: current candidate text
1164                //    - selection: selection range within the text, in bytes
1165                // 3. Commit: User confirms a candidate and commits text into the buffer.
1166                // 4. Closed: IME closed or lost focus.
1167                //
1168                // Safety checks:
1169                // - handle only when `focused_id` matches this editor ID
1170                // - handle only when `has_canvas_focus` is true
1171                // This ensures IME events are not delivered to the wrong widget.
1172                // ---------------------------------------------------------------------
1173                let message = match event {
1174                    input_method::Event::Opened => Message::ImeOpened,
1175                    input_method::Event::Preedit(content, selection) => {
1176                        Message::ImePreedit(content.clone(), selection.clone())
1177                    }
1178                    input_method::Event::Commit(content) => {
1179                        Message::ImeCommit(content.clone())
1180                    }
1181                    input_method::Event::Closed => Message::ImeClosed,
1182                };
1183
1184                Some(Action::publish(message).and_capture())
1185            }
1186            _ => None,
1187        }
1188    }
1189}
1190
1191/// Validates that the selection indices fall on valid UTF-8 character boundaries
1192/// to prevent panics during string slicing.
1193///
1194/// # Arguments
1195///
1196/// * `content` - The string content to check against
1197/// * `start` - The start byte index
1198/// * `end` - The end byte index
1199///
1200/// # Returns
1201///
1202/// `Some((start, end))` if indices are valid, `None` otherwise.
1203fn validate_selection_indices(
1204    content: &str,
1205    start: usize,
1206    end: usize,
1207) -> Option<(usize, usize)> {
1208    let len = content.len();
1209    // Clamp indices to content length
1210    let start = start.min(len);
1211    let end = end.min(len);
1212
1213    // Ensure start is not greater than end
1214    if start > end {
1215        return None;
1216    }
1217
1218    // Verify that indices fall on valid UTF-8 character boundaries
1219    if content.is_char_boundary(start) && content.is_char_boundary(end) {
1220        Some((start, end))
1221    } else {
1222        None
1223    }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228    use super::*;
1229    use crate::canvas_editor::{CHAR_WIDTH, FONT_SIZE, compare_floats};
1230    use std::cmp::Ordering;
1231
1232    #[test]
1233    fn test_calculate_segment_geometry_ascii() {
1234        // "Hello World"
1235        // "Hello " (6 chars) -> prefix
1236        // "World" (5 chars) -> segment
1237        // width("Hello ") = 6 * CHAR_WIDTH
1238        // width("World") = 5 * CHAR_WIDTH
1239        let content = "Hello World";
1240        let (x, w) = calculate_segment_geometry(
1241            content, 0, 6, 11, 0.0, FONT_SIZE, CHAR_WIDTH,
1242        );
1243
1244        let expected_x = CHAR_WIDTH * 6.0;
1245        let expected_w = CHAR_WIDTH * 5.0;
1246
1247        assert_eq!(
1248            compare_floats(x, expected_x),
1249            Ordering::Equal,
1250            "X position mismatch for ASCII"
1251        );
1252        assert_eq!(
1253            compare_floats(w, expected_w),
1254            Ordering::Equal,
1255            "Width mismatch for ASCII"
1256        );
1257    }
1258
1259    #[test]
1260    fn test_calculate_segment_geometry_cjk() {
1261        // "你好世界"
1262        // "你好" (2 chars) -> prefix
1263        // "世界" (2 chars) -> segment
1264        // width("你好") = 2 * FONT_SIZE
1265        // width("世界") = 2 * FONT_SIZE
1266        let content = "你好世界";
1267        let (x, w) = calculate_segment_geometry(
1268            content, 0, 2, 4, 10.0, FONT_SIZE, CHAR_WIDTH,
1269        );
1270
1271        let expected_x = 10.0 + FONT_SIZE * 2.0;
1272        let expected_w = FONT_SIZE * 2.0;
1273
1274        assert_eq!(
1275            compare_floats(x, expected_x),
1276            Ordering::Equal,
1277            "X position mismatch for CJK"
1278        );
1279        assert_eq!(
1280            compare_floats(w, expected_w),
1281            Ordering::Equal,
1282            "Width mismatch for CJK"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_calculate_segment_geometry_mixed() {
1288        // "Hi你好"
1289        // "Hi" (2 chars) -> prefix
1290        // "你好" (2 chars) -> segment
1291        // width("Hi") = 2 * CHAR_WIDTH
1292        // width("你好") = 2 * FONT_SIZE
1293        let content = "Hi你好";
1294        let (x, w) = calculate_segment_geometry(
1295            content, 0, 2, 4, 0.0, FONT_SIZE, CHAR_WIDTH,
1296        );
1297
1298        let expected_x = CHAR_WIDTH * 2.0;
1299        let expected_w = FONT_SIZE * 2.0;
1300
1301        assert_eq!(
1302            compare_floats(x, expected_x),
1303            Ordering::Equal,
1304            "X position mismatch for mixed content"
1305        );
1306        assert_eq!(
1307            compare_floats(w, expected_w),
1308            Ordering::Equal,
1309            "Width mismatch for mixed content"
1310        );
1311    }
1312
1313    #[test]
1314    fn test_calculate_segment_geometry_empty_range() {
1315        let content = "Hello";
1316        let (x, w) = calculate_segment_geometry(
1317            content, 0, 0, 0, 0.0, FONT_SIZE, CHAR_WIDTH,
1318        );
1319        assert!((x - 0.0).abs() < f32::EPSILON);
1320        assert!((w - 0.0).abs() < f32::EPSILON);
1321    }
1322
1323    #[test]
1324    fn test_calculate_segment_geometry_with_visual_offset() {
1325        // content: "0123456789"
1326        // visual_start_col: 2 (starts at '2')
1327        // segment: "34" (indices 3 to 5)
1328        // prefix: from visual start (2) to segment start (3) -> "2" (length 1)
1329        // prefix width: 1 * CHAR_WIDTH
1330        // segment width: 2 * CHAR_WIDTH
1331        let content = "0123456789";
1332        let (x, w) = calculate_segment_geometry(
1333            content, 2, 3, 5, 5.0, FONT_SIZE, CHAR_WIDTH,
1334        );
1335
1336        let expected_x = 5.0 + CHAR_WIDTH * 1.0;
1337        let expected_w = CHAR_WIDTH * 2.0;
1338
1339        assert_eq!(
1340            compare_floats(x, expected_x),
1341            Ordering::Equal,
1342            "X position mismatch with visual offset"
1343        );
1344        assert_eq!(
1345            compare_floats(w, expected_w),
1346            Ordering::Equal,
1347            "Width mismatch with visual offset"
1348        );
1349    }
1350
1351    #[test]
1352    fn test_calculate_segment_geometry_out_of_bounds() {
1353        // Content length is 5 ("Hello")
1354        // Request start at 10, end at 15
1355        // visual_start 0
1356        // Prefix should consume whole string ("Hello") and stop.
1357        // Segment should be empty.
1358        let content = "Hello";
1359        let (x, w) = calculate_segment_geometry(
1360            content, 0, 10, 15, 0.0, FONT_SIZE, CHAR_WIDTH,
1361        );
1362
1363        let expected_x = CHAR_WIDTH * 5.0; // Width of "Hello"
1364        let expected_w = 0.0;
1365
1366        assert_eq!(
1367            compare_floats(x, expected_x),
1368            Ordering::Equal,
1369            "X position mismatch for out of bounds start"
1370        );
1371        assert!(
1372            (w - expected_w).abs() < f32::EPSILON,
1373            "Width should be 0 for out of bounds segment"
1374        );
1375    }
1376
1377    #[test]
1378    fn test_calculate_segment_geometry_special_chars() {
1379        // Emoji "👋" (width > 1 => FONT_SIZE)
1380        // Tab "\t" (width None => 0.0)
1381        let content = "A👋\tB";
1382        // Measure "👋" (index 1 to 2)
1383        // Indices in chars: 'A' (0), '👋' (1), '\t' (2), 'B' (3)
1384
1385        // Segment covering Emoji
1386        let (x, w) = calculate_segment_geometry(
1387            content, 0, 1, 2, 0.0, FONT_SIZE, CHAR_WIDTH,
1388        );
1389        let expected_x_emoji = CHAR_WIDTH; // 'A'
1390        let expected_w_emoji = FONT_SIZE; // '👋'
1391
1392        assert_eq!(
1393            compare_floats(x, expected_x_emoji),
1394            Ordering::Equal,
1395            "X pos for emoji"
1396        );
1397        assert_eq!(
1398            compare_floats(w, expected_w_emoji),
1399            Ordering::Equal,
1400            "Width for emoji"
1401        );
1402
1403        // Segment covering Tab
1404        let (x_tab, w_tab) = calculate_segment_geometry(
1405            content, 0, 2, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
1406        );
1407        let expected_x_tab = CHAR_WIDTH + FONT_SIZE; // 'A' + '👋'
1408        let expected_w_tab = 0.0; // Tab width is 0 in this implementation
1409
1410        assert_eq!(
1411            compare_floats(x_tab, expected_x_tab),
1412            Ordering::Equal,
1413            "X pos for tab"
1414        );
1415        assert_eq!(
1416            compare_floats(w_tab, expected_w_tab),
1417            Ordering::Equal,
1418            "Width for tab"
1419        );
1420    }
1421
1422    #[test]
1423    fn test_calculate_segment_geometry_inverted_range() {
1424        // Start 5, End 3
1425        // Should result in empty segment at start 5
1426        let content = "0123456789";
1427        let (x, w) = calculate_segment_geometry(
1428            content, 0, 5, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
1429        );
1430
1431        let expected_x = CHAR_WIDTH * 5.0;
1432        let expected_w = 0.0;
1433
1434        assert_eq!(
1435            compare_floats(x, expected_x),
1436            Ordering::Equal,
1437            "X pos for inverted range"
1438        );
1439        assert!(
1440            (w - expected_w).abs() < f32::EPSILON,
1441            "Width for inverted range"
1442        );
1443    }
1444
1445    #[test]
1446    fn test_validate_selection_indices() {
1447        // Test valid ASCII indices
1448        let content = "Hello";
1449        assert_eq!(validate_selection_indices(content, 0, 5), Some((0, 5)));
1450        assert_eq!(validate_selection_indices(content, 1, 3), Some((1, 3)));
1451
1452        // Test valid multi-byte indices (Chinese "你好")
1453        // "你" is 3 bytes (0-3), "好" is 3 bytes (3-6)
1454        let content = "你好";
1455        assert_eq!(validate_selection_indices(content, 0, 6), Some((0, 6)));
1456        assert_eq!(validate_selection_indices(content, 0, 3), Some((0, 3)));
1457        assert_eq!(validate_selection_indices(content, 3, 6), Some((3, 6)));
1458
1459        // Test invalid indices (splitting multi-byte char)
1460        assert_eq!(validate_selection_indices(content, 1, 3), None); // Split first char
1461        assert_eq!(validate_selection_indices(content, 0, 4), None); // Split second char
1462
1463        // Test out of bounds (should be clamped if on boundary, but here len is 6)
1464        // If we pass start=0, end=100 -> clamped to 0, 6. 6 is boundary.
1465        assert_eq!(validate_selection_indices(content, 0, 100), Some((0, 6)));
1466
1467        // Test inverted range
1468        assert_eq!(validate_selection_indices(content, 3, 0), None);
1469    }
1470}