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 std::borrow::Cow;
8use std::rc::Rc;
9use std::sync::OnceLock;
10use syntect::easy::HighlightLines;
11use syntect::highlighting::{Style, ThemeSet};
12use syntect::parsing::SyntaxSet;
13
14/// Computes geometry (x start and width) for a text segment used in rendering or highlighting.
15///
16/// # Arguments
17///
18/// * `line_content`: full text content of the current line.
19/// * `visual_start_col`: start column index of the current visual line.
20/// * `segment_start_col`: start column index of the target segment (e.g. highlight).
21/// * `segment_end_col`: end column index of the target segment.
22/// * `base_offset`: base X offset (usually gutter_width + padding).
23///
24/// # Returns
25///
26/// x_start, width
27///
28/// # Remark
29///
30/// This function handles CJK character widths correctly to keep highlights accurate.
31fn calculate_segment_geometry(
32    line_content: &str,
33    visual_start_col: usize,
34    segment_start_col: usize,
35    segment_end_col: usize,
36    base_offset: f32,
37    full_char_width: f32,
38    char_width: f32,
39) -> (f32, f32) {
40    // Clamp the segment to the current visual line so callers can safely pass
41    // logical selection/match columns without worrying about wrapping boundaries.
42    let segment_start_col = segment_start_col.max(visual_start_col);
43    let segment_end_col = segment_end_col.max(segment_start_col);
44
45    let mut prefix_width = 0.0;
46    let mut segment_width = 0.0;
47
48    // Compute widths directly from the source string to avoid allocating
49    // intermediate `String` slices for prefix/segment.
50    for (i, c) in line_content.chars().enumerate() {
51        if i >= segment_end_col {
52            break;
53        }
54
55        let w = super::measure_char_width(c, full_char_width, char_width);
56
57        if i >= visual_start_col && i < segment_start_col {
58            prefix_width += w;
59        } else if i >= segment_start_col {
60            segment_width += w;
61        }
62    }
63
64    (base_offset + prefix_width, segment_width)
65}
66
67fn expand_tabs(text: &str, tab_width: usize) -> Cow<'_, str> {
68    if !text.contains('\t') {
69        return Cow::Borrowed(text);
70    }
71
72    let mut expanded = String::with_capacity(text.len());
73    for ch in text.chars() {
74        if ch == '\t' {
75            for _ in 0..tab_width {
76                expanded.push(' ');
77            }
78        } else {
79            expanded.push(ch);
80        }
81    }
82
83    Cow::Owned(expanded)
84}
85
86use super::folding;
87use super::wrapping::{VisualLine, WrappingCalculator};
88use super::{ArrowDirection, CodeEditor, Message, measure_text_width};
89use iced::widget::canvas::Action;
90
91static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
92static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
93
94/// Context for canvas rendering operations.
95///
96/// This struct packages commonly used rendering parameters to reduce
97/// method signature complexity and improve code maintainability.
98struct RenderContext<'a> {
99    /// Visual lines calculated from wrapping
100    visual_lines: &'a [VisualLine],
101    /// Width of the canvas bounds
102    bounds_width: f32,
103    /// Width of the line number gutter
104    gutter_width: f32,
105    /// Height of each line in pixels
106    line_height: f32,
107    /// Font size in pixels
108    font_size: f32,
109    /// Full character width for wide characters (e.g., CJK)
110    full_char_width: f32,
111    /// Character width for narrow characters
112    char_width: f32,
113    /// Font to use for rendering text
114    font: iced::Font,
115    /// Horizontal scroll offset in pixels (subtracted from text X positions)
116    horizontal_scroll_offset: f32,
117}
118
119impl CodeEditor {
120    /// Draws line numbers and wrap indicators in the gutter area.
121    ///
122    /// # Arguments
123    ///
124    /// * `frame` - The canvas frame to draw on
125    /// * `ctx` - Rendering context containing visual lines and metrics
126    /// * `visual_line` - The visual line to render
127    /// * `y` - Y position for rendering
128    fn draw_line_numbers(
129        &self,
130        frame: &mut canvas::Frame,
131        ctx: &RenderContext,
132        visual_line: &VisualLine,
133        y: f32,
134    ) {
135        // The line-number area is the left part of the gutter; the fold margin
136        // (when folding is enabled) is the right strip adjacent to the text.
137        let number_area_width = self.line_number_gutter_width();
138
139        if self.line_numbers_enabled {
140            if visual_line.is_first_segment() {
141                // Draw line number for first segment, centered in the number area.
142                let line_num = visual_line.logical_line + 1;
143                let line_num_text = format!("{}", line_num);
144                let text_width = measure_text_width(
145                    &line_num_text,
146                    ctx.full_char_width,
147                    ctx.char_width,
148                );
149                let x_pos = (number_area_width - text_width) / 2.0;
150                frame.fill_text(canvas::Text {
151                    content: line_num_text,
152                    position: Point::new(x_pos, y + 2.0),
153                    color: self.style.line_number_color,
154                    size: ctx.font_size.into(),
155                    font: ctx.font,
156                    ..canvas::Text::default()
157                });
158            } else {
159                // Draw wrap indicator for continuation lines.
160                frame.fill_text(canvas::Text {
161                    content: "↪".to_string(),
162                    position: Point::new(number_area_width - 20.0, y + 2.0),
163                    color: self.style.line_number_color,
164                    size: ctx.font_size.into(),
165                    font: ctx.font,
166                    ..canvas::Text::default()
167                });
168            }
169        }
170
171        self.draw_fold_chevron(frame, ctx, visual_line, y, number_area_width);
172    }
173
174    /// Draws the fold chevron in the fold margin for a foldable header line.
175    ///
176    /// Draws nothing when folding is disabled, on continuation (wrapped)
177    /// segments, or on lines that are not fold headers.
178    ///
179    /// # Arguments
180    ///
181    /// * `frame` - The canvas frame to draw on
182    /// * `ctx` - Rendering context containing metrics
183    /// * `visual_line` - The visual line to render
184    /// * `y` - Y position for rendering
185    /// * `number_area_width` - Width of the line-number area (start of the fold margin)
186    fn draw_fold_chevron(
187        &self,
188        frame: &mut canvas::Frame,
189        ctx: &RenderContext,
190        visual_line: &VisualLine,
191        y: f32,
192        number_area_width: f32,
193    ) {
194        if !self.folding_enabled || !visual_line.is_first_segment() {
195            return;
196        }
197
198        let regions = self.foldable_regions();
199        if !folding::is_fold_header(&regions, visual_line.logical_line) {
200            return;
201        }
202
203        // `▶` when collapsed, `▼` when expanded.
204        let chevron = if self.is_folded(visual_line.logical_line) {
205            "▶"
206        } else {
207            "▼"
208        };
209        frame.fill_text(canvas::Text {
210            content: chevron.to_string(),
211            position: Point::new(number_area_width + 1.0, y + 2.0),
212            color: self.style.line_number_color,
213            size: ctx.font_size.into(),
214            font: ctx.font,
215            ..canvas::Text::default()
216        });
217    }
218
219    /// Draws a `⋯` marker after the text of a collapsed fold header, signalling
220    /// that lines are hidden below it (VS Code-style cue).
221    ///
222    /// Draws nothing unless folding is enabled and `visual_line` is the header
223    /// of a currently collapsed region. Intended to be called inside the clipped
224    /// code area so the marker cannot bleed into the gutter.
225    fn draw_fold_collapsed_marker(
226        &self,
227        frame: &mut canvas::Frame,
228        ctx: &RenderContext,
229        visual_line: &VisualLine,
230        y: f32,
231    ) {
232        if !self.folding_enabled
233            || !visual_line.is_first_segment()
234            || !self.is_folded(visual_line.logical_line)
235        {
236            return;
237        }
238
239        let line_content = self.buffer.line(visual_line.logical_line);
240        let line_width = measure_text_width(
241            line_content,
242            ctx.full_char_width,
243            ctx.char_width,
244        );
245        let x = ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset
246            + line_width
247            + 6.0;
248        frame.fill_text(canvas::Text {
249            content: "⋯".to_string(),
250            position: Point::new(x, y + 2.0),
251            color: self.style.line_number_color,
252            size: ctx.font_size.into(),
253            font: ctx.font,
254            ..canvas::Text::default()
255        });
256    }
257
258    /// Draws the background highlight for the current line.
259    ///
260    /// # Arguments
261    ///
262    /// * `frame` - The canvas frame to draw on
263    /// * `ctx` - Rendering context containing visual lines and metrics
264    /// * `visual_line` - The visual line to check
265    /// * `y` - Y position for rendering
266    fn draw_current_line_highlight(
267        &self,
268        frame: &mut canvas::Frame,
269        ctx: &RenderContext,
270        visual_line: &VisualLine,
271        y: f32,
272    ) {
273        if self.cursors.iter().any(|c| c.position.0 == visual_line.logical_line)
274        {
275            frame.fill_rectangle(
276                Point::new(ctx.gutter_width, y),
277                Size::new(ctx.bounds_width - ctx.gutter_width, ctx.line_height),
278                self.style.current_line_highlight,
279            );
280        }
281    }
282
283    /// Draws text content with syntax highlighting or plain text fallback.
284    ///
285    /// # Arguments
286    ///
287    /// * `frame` - The canvas frame to draw on
288    /// * `ctx` - Rendering context containing visual lines and metrics
289    /// * `visual_line` - The visual line to render
290    /// * `y` - Y position for rendering
291    /// * `syntax_ref` - Optional syntax reference for highlighting
292    /// * `syntax_set` - Syntax set for highlighting
293    /// * `syntax_theme` - Theme for syntax highlighting
294    #[allow(clippy::too_many_arguments)]
295    fn draw_text_with_syntax_highlighting(
296        &self,
297        frame: &mut canvas::Frame,
298        ctx: &RenderContext,
299        visual_line: &VisualLine,
300        y: f32,
301        syntax_ref: Option<&syntect::parsing::SyntaxReference>,
302        syntax_set: &SyntaxSet,
303        syntax_theme: Option<&syntect::highlighting::Theme>,
304    ) {
305        let full_line_content = self.buffer.line(visual_line.logical_line);
306
307        // Convert character indices to byte indices for UTF-8 string slicing
308        let start_byte = full_line_content
309            .char_indices()
310            .nth(visual_line.start_col)
311            .map_or(full_line_content.len(), |(idx, _)| idx);
312        let end_byte = full_line_content
313            .char_indices()
314            .nth(visual_line.end_col)
315            .map_or(full_line_content.len(), |(idx, _)| idx);
316        let line_segment = &full_line_content[start_byte..end_byte];
317
318        if let (Some(syntax), Some(syntax_theme)) = (syntax_ref, syntax_theme) {
319            let mut highlighter = HighlightLines::new(syntax, syntax_theme);
320
321            // Highlight the full line to get correct token colors
322            let full_line_ranges = highlighter
323                .highlight_line(full_line_content, syntax_set)
324                .unwrap_or_else(|_| {
325                    vec![(Style::default(), full_line_content)]
326                });
327
328            // Extract only the ranges that fall within our segment
329            let mut x_offset =
330                ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset;
331            let mut char_pos = 0;
332
333            for (style, text) in full_line_ranges {
334                let text_len = text.chars().count();
335                let text_end = char_pos + text_len;
336
337                // Check if this token intersects with our segment
338                if text_end > visual_line.start_col
339                    && char_pos < visual_line.end_col
340                {
341                    // Calculate the intersection
342                    let segment_start = char_pos.max(visual_line.start_col);
343                    let segment_end = text_end.min(visual_line.end_col);
344
345                    let text_start_offset =
346                        segment_start.saturating_sub(char_pos);
347                    let text_end_offset =
348                        text_start_offset + (segment_end - segment_start);
349
350                    // Convert character offsets to byte offsets for UTF-8 slicing
351                    let start_byte = text
352                        .char_indices()
353                        .nth(text_start_offset)
354                        .map_or(text.len(), |(idx, _)| idx);
355                    let end_byte = text
356                        .char_indices()
357                        .nth(text_end_offset)
358                        .map_or(text.len(), |(idx, _)| idx);
359
360                    let segment_text = &text[start_byte..end_byte];
361                    let display_text =
362                        expand_tabs(segment_text, super::TAB_WIDTH)
363                            .into_owned();
364                    let display_width = measure_text_width(
365                        &display_text,
366                        ctx.full_char_width,
367                        ctx.char_width,
368                    );
369
370                    let color = Color::from_rgb(
371                        f32::from(style.foreground.r) / 255.0,
372                        f32::from(style.foreground.g) / 255.0,
373                        f32::from(style.foreground.b) / 255.0,
374                    );
375
376                    frame.fill_text(canvas::Text {
377                        content: display_text,
378                        position: Point::new(x_offset, y + 2.0),
379                        color,
380                        size: ctx.font_size.into(),
381                        font: ctx.font,
382                        ..canvas::Text::default()
383                    });
384
385                    x_offset += display_width;
386                }
387
388                char_pos = text_end;
389            }
390        } else {
391            // Fallback to plain text
392            let display_text =
393                expand_tabs(line_segment, super::TAB_WIDTH).into_owned();
394            frame.fill_text(canvas::Text {
395                content: display_text,
396                position: Point::new(
397                    ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset,
398                    y + 2.0,
399                ),
400                color: self.style.text_color,
401                size: ctx.font_size.into(),
402                font: ctx.font,
403                ..canvas::Text::default()
404            });
405        }
406    }
407
408    /// Draws search match highlights for all visible matches.
409    ///
410    /// # Arguments
411    ///
412    /// * `frame` - The canvas frame to draw on
413    /// * `ctx` - Rendering context containing visual lines and metrics
414    /// * `first_visible_line` - First visible visual line index
415    /// * `last_visible_line` - Last visible visual line index
416    fn draw_search_highlights(
417        &self,
418        frame: &mut canvas::Frame,
419        ctx: &RenderContext,
420        start_visual_idx: usize,
421        end_visual_idx: usize,
422    ) {
423        if !self.search_state.is_open || self.search_state.query.is_empty() {
424            return;
425        }
426
427        let query_len = self.search_state.query.chars().count();
428
429        let start_visual_idx = start_visual_idx.min(ctx.visual_lines.len());
430        let end_visual_idx = end_visual_idx.min(ctx.visual_lines.len());
431
432        let end_visual_inclusive = end_visual_idx
433            .saturating_sub(1)
434            .min(ctx.visual_lines.len().saturating_sub(1));
435
436        if let (Some(start_vl), Some(end_vl)) = (
437            ctx.visual_lines.get(start_visual_idx),
438            ctx.visual_lines.get(end_visual_inclusive),
439        ) {
440            let min_logical_line = start_vl.logical_line;
441            let max_logical_line = end_vl.logical_line;
442
443            // Optimization: Use get_visible_match_range to find matches in view
444            // This uses binary search + early termination for O(log N) performance
445            let match_range = super::search::get_visible_match_range(
446                &self.search_state.matches,
447                min_logical_line,
448                max_logical_line,
449            );
450
451            for (match_idx, search_match) in self
452                .search_state
453                .matches
454                .iter()
455                .enumerate()
456                .skip(match_range.start)
457                .take(match_range.len())
458            {
459                // Determine if this is the current match
460                let is_current =
461                    self.search_state.current_match_index == Some(match_idx);
462
463                let highlight_color = if is_current {
464                    // Orange for current match
465                    Color { r: 1.0, g: 0.6, b: 0.0, a: 0.4 }
466                } else {
467                    // Yellow for other matches
468                    Color { r: 1.0, g: 1.0, b: 0.0, a: 0.3 }
469                };
470
471                // Convert logical position to visual line
472                let start_visual = WrappingCalculator::logical_to_visual(
473                    ctx.visual_lines,
474                    search_match.line,
475                    search_match.col,
476                );
477                let end_visual = WrappingCalculator::logical_to_visual(
478                    ctx.visual_lines,
479                    search_match.line,
480                    search_match.col + query_len,
481                );
482
483                if let (Some(start_v), Some(end_v)) = (start_visual, end_visual)
484                {
485                    if start_v == end_v {
486                        // Match within same visual line
487                        let y = start_v as f32 * ctx.line_height;
488                        let vl = &ctx.visual_lines[start_v];
489                        let line_content = self.buffer.line(vl.logical_line);
490
491                        // Use calculate_segment_geometry to compute match position and width
492                        let (x_start, match_width) = calculate_segment_geometry(
493                            line_content,
494                            vl.start_col,
495                            search_match.col,
496                            search_match.col + query_len,
497                            ctx.gutter_width + 5.0,
498                            ctx.full_char_width,
499                            ctx.char_width,
500                        );
501                        let x_start = x_start - ctx.horizontal_scroll_offset;
502                        let x_end = x_start + match_width;
503
504                        frame.fill_rectangle(
505                            Point::new(x_start, y + 2.0),
506                            Size::new(x_end - x_start, ctx.line_height - 4.0),
507                            highlight_color,
508                        );
509                    } else {
510                        // Match spans multiple visual lines
511                        for (v_idx, vl) in ctx
512                            .visual_lines
513                            .iter()
514                            .enumerate()
515                            .skip(start_v)
516                            .take(end_v - start_v + 1)
517                        {
518                            let y = v_idx as f32 * ctx.line_height;
519
520                            let match_start_col = search_match.col;
521                            let match_end_col = search_match.col + query_len;
522
523                            let sel_start_col = if v_idx == start_v {
524                                match_start_col
525                            } else {
526                                vl.start_col
527                            };
528                            let sel_end_col = if v_idx == end_v {
529                                match_end_col
530                            } else {
531                                vl.end_col
532                            };
533
534                            let line_content =
535                                self.buffer.line(vl.logical_line);
536
537                            let (x_start, sel_width) =
538                                calculate_segment_geometry(
539                                    line_content,
540                                    vl.start_col,
541                                    sel_start_col,
542                                    sel_end_col,
543                                    ctx.gutter_width + 5.0,
544                                    ctx.full_char_width,
545                                    ctx.char_width,
546                                );
547                            let x_start =
548                                x_start - ctx.horizontal_scroll_offset;
549                            let x_end = x_start + sel_width;
550
551                            frame.fill_rectangle(
552                                Point::new(x_start, y + 2.0),
553                                Size::new(
554                                    x_end - x_start,
555                                    ctx.line_height - 4.0,
556                                ),
557                                highlight_color,
558                            );
559                        }
560                    }
561                }
562            }
563        }
564    }
565
566    /// Draws the selection highlight for a single cursor range.
567    ///
568    /// # Arguments
569    ///
570    /// * `frame` - The canvas frame to draw on
571    /// * `ctx` - Rendering context containing visual lines and metrics
572    /// * `start` - Selection start (line, col)
573    /// * `end` - Selection end (line, col), must be >= start
574    fn draw_single_selection(
575        &self,
576        frame: &mut canvas::Frame,
577        ctx: &RenderContext,
578        start: (usize, usize),
579        end: (usize, usize),
580    ) {
581        let selection_color = Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 };
582
583        if start.0 == end.0 {
584            // Single line selection - need to handle wrapped segments
585            let start_visual = WrappingCalculator::logical_to_visual(
586                ctx.visual_lines,
587                start.0,
588                start.1,
589            );
590            let end_visual = WrappingCalculator::logical_to_visual(
591                ctx.visual_lines,
592                end.0,
593                end.1,
594            );
595
596            if let (Some(start_v), Some(end_v)) = (start_visual, end_visual) {
597                if start_v == end_v {
598                    // Selection within same visual line
599                    let y = start_v as f32 * ctx.line_height;
600                    let vl = &ctx.visual_lines[start_v];
601                    let line_content = self.buffer.line(vl.logical_line);
602
603                    let (x_start, sel_width) = calculate_segment_geometry(
604                        line_content,
605                        vl.start_col,
606                        start.1,
607                        end.1,
608                        ctx.gutter_width + 5.0,
609                        ctx.full_char_width,
610                        ctx.char_width,
611                    );
612                    let x_start = x_start - ctx.horizontal_scroll_offset;
613                    let x_end = x_start + sel_width;
614
615                    frame.fill_rectangle(
616                        Point::new(x_start, y + 2.0),
617                        Size::new(x_end - x_start, ctx.line_height - 4.0),
618                        selection_color,
619                    );
620                } else {
621                    // Selection spans multiple visual lines (same logical line)
622                    for (v_idx, vl) in ctx
623                        .visual_lines
624                        .iter()
625                        .enumerate()
626                        .skip(start_v)
627                        .take(end_v - start_v + 1)
628                    {
629                        let y = v_idx as f32 * ctx.line_height;
630
631                        let sel_start_col = if v_idx == start_v {
632                            start.1
633                        } else {
634                            vl.start_col
635                        };
636                        let sel_end_col =
637                            if v_idx == end_v { end.1 } else { vl.end_col };
638
639                        let line_content = self.buffer.line(vl.logical_line);
640
641                        let (x_start, sel_width) = calculate_segment_geometry(
642                            line_content,
643                            vl.start_col,
644                            sel_start_col,
645                            sel_end_col,
646                            ctx.gutter_width + 5.0,
647                            ctx.full_char_width,
648                            ctx.char_width,
649                        );
650                        let x_start = x_start - ctx.horizontal_scroll_offset;
651                        let x_end = x_start + sel_width;
652
653                        frame.fill_rectangle(
654                            Point::new(x_start, y + 2.0),
655                            Size::new(x_end - x_start, ctx.line_height - 4.0),
656                            selection_color,
657                        );
658                    }
659                }
660            }
661        } else {
662            // Multi-line selection
663            let start_visual = WrappingCalculator::logical_to_visual(
664                ctx.visual_lines,
665                start.0,
666                start.1,
667            );
668            let end_visual = WrappingCalculator::logical_to_visual(
669                ctx.visual_lines,
670                end.0,
671                end.1,
672            );
673
674            if let (Some(start_v), Some(end_v)) = (start_visual, end_visual) {
675                for (v_idx, vl) in ctx
676                    .visual_lines
677                    .iter()
678                    .enumerate()
679                    .skip(start_v)
680                    .take(end_v - start_v + 1)
681                {
682                    let y = v_idx as f32 * ctx.line_height;
683
684                    let sel_start_col =
685                        if vl.logical_line == start.0 && v_idx == start_v {
686                            start.1
687                        } else {
688                            vl.start_col
689                        };
690
691                    let sel_end_col =
692                        if vl.logical_line == end.0 && v_idx == end_v {
693                            end.1
694                        } else {
695                            vl.end_col
696                        };
697
698                    let line_content = self.buffer.line(vl.logical_line);
699
700                    let (x_start, sel_width) = calculate_segment_geometry(
701                        line_content,
702                        vl.start_col,
703                        sel_start_col,
704                        sel_end_col,
705                        ctx.gutter_width + 5.0,
706                        ctx.full_char_width,
707                        ctx.char_width,
708                    );
709                    let x_start = x_start - ctx.horizontal_scroll_offset;
710                    let x_end = x_start + sel_width;
711
712                    frame.fill_rectangle(
713                        Point::new(x_start, y + 2.0),
714                        Size::new(x_end - x_start, ctx.line_height - 4.0),
715                        selection_color,
716                    );
717                }
718            }
719        }
720    }
721
722    /// Draws text selection highlights for all cursors.
723    ///
724    /// # Arguments
725    ///
726    /// * `frame` - The canvas frame to draw on
727    /// * `ctx` - Rendering context containing visual lines and metrics
728    fn draw_selection_highlight(
729        &self,
730        frame: &mut canvas::Frame,
731        ctx: &RenderContext,
732    ) {
733        for cursor in self.cursors.iter() {
734            if let Some((start, end)) = cursor.selection_range()
735                && start != end
736            {
737                self.draw_single_selection(frame, ctx, start, end);
738            }
739        }
740    }
741
742    /// Draws the cursor (normal caret or IME preedit cursor).
743    ///
744    /// # Arguments
745    ///
746    /// * `frame` - The canvas frame to draw on
747    /// * `ctx` - Rendering context containing visual lines and metrics
748    fn draw_cursor(&self, frame: &mut canvas::Frame, ctx: &RenderContext) {
749        // Cursor drawing logic (only when the editor has focus)
750        // -------------------------------------------------------------------------
751        // Core notes:
752        // 1. Choose the drawing path based on whether IME preedit is present.
753        // 2. Require both `is_focused()` (Iced focus) and `has_canvas_focus()` (internal focus)
754        //    so the cursor is drawn only in the active editor, avoiding multiple cursors.
755        // 3. Use `WrappingCalculator` to map logical (line, col) to visual (x, y)
756        //    for correct cursor positioning with line wrapping.
757        // -------------------------------------------------------------------------
758        if self.show_cursor
759            && self.cursor_visible
760            && self.has_focus()
761            && self.ime_preedit.is_some()
762        {
763            // [Branch A] IME preedit rendering mode
764            // ---------------------------------------------------------------------
765            // When the user is composing with an IME (e.g. pinyin before commit),
766            // draw a preedit region instead of the normal caret, including:
767            // - preedit background (highlighting the composing text)
768            // - preedit text content (preedit.content)
769            // - preedit selection (underline or selection background)
770            // - preedit caret
771            // ---------------------------------------------------------------------
772            if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
773                ctx.visual_lines,
774                self.cursors.primary_position().0,
775                self.cursors.primary_position().1,
776            ) {
777                let vl = &ctx.visual_lines[cursor_visual];
778                let line_content = self.buffer.line(vl.logical_line);
779
780                // Compute the preedit region start X
781                // Use calculate_segment_geometry to ensure correct CJK width handling
782                let (cursor_x_content, _) = calculate_segment_geometry(
783                    line_content,
784                    vl.start_col,
785                    self.cursors.primary_position().1,
786                    self.cursors.primary_position().1,
787                    ctx.gutter_width + 5.0,
788                    ctx.full_char_width,
789                    ctx.char_width,
790                );
791                let cursor_x = cursor_x_content - ctx.horizontal_scroll_offset;
792                let cursor_y = cursor_visual as f32 * ctx.line_height;
793
794                if let Some(preedit) = self.ime_preedit.as_ref() {
795                    let preedit_width = measure_text_width(
796                        &preedit.content,
797                        ctx.full_char_width,
798                        ctx.char_width,
799                    );
800
801                    // 1. Draw preedit background (light translucent)
802                    // This indicates the text is not committed yet
803                    frame.fill_rectangle(
804                        Point::new(cursor_x, cursor_y + 2.0),
805                        Size::new(preedit_width, ctx.line_height - 4.0),
806                        Color { r: 1.0, g: 1.0, b: 1.0, a: 0.08 },
807                    );
808
809                    // 2. Draw preedit selection (if any)
810                    // IME may mark a selection inside preedit text (e.g. segmentation)
811                    // The range uses UTF-8 byte indices, so slices must be safe
812                    if let Some(range) = preedit.selection.as_ref()
813                        && range.start != range.end
814                    {
815                        // Validate indices before slicing to prevent panic
816                        if let Some((start, end)) = validate_selection_indices(
817                            &preedit.content,
818                            range.start,
819                            range.end,
820                        ) {
821                            let selected_prefix = &preedit.content[..start];
822                            let selected_text = &preedit.content[start..end];
823
824                            let selection_x = cursor_x
825                                + measure_text_width(
826                                    selected_prefix,
827                                    ctx.full_char_width,
828                                    ctx.char_width,
829                                );
830                            let selection_w = measure_text_width(
831                                selected_text,
832                                ctx.full_char_width,
833                                ctx.char_width,
834                            );
835
836                            frame.fill_rectangle(
837                                Point::new(selection_x, cursor_y + 2.0),
838                                Size::new(selection_w, ctx.line_height - 4.0),
839                                Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 },
840                            );
841                        }
842                    }
843
844                    // 3. Draw preedit text itself
845                    frame.fill_text(canvas::Text {
846                        content: preedit.content.clone(),
847                        position: Point::new(cursor_x, cursor_y + 2.0),
848                        color: self.style.text_color,
849                        size: ctx.font_size.into(),
850                        font: ctx.font,
851                        ..canvas::Text::default()
852                    });
853
854                    // 4. Draw bottom underline (IME state indicator)
855                    frame.fill_rectangle(
856                        Point::new(cursor_x, cursor_y + ctx.line_height - 3.0),
857                        Size::new(preedit_width, 1.0),
858                        self.style.text_color,
859                    );
860
861                    // 5. Draw preedit caret
862                    // If IME provides a caret position (usually selection end), draw a thin bar
863                    if let Some(range) = preedit.selection.as_ref() {
864                        let caret_end = range.end.min(preedit.content.len());
865
866                        // Validate caret position to avoid panic on invalid UTF-8 boundary
867                        if caret_end <= preedit.content.len()
868                            && preedit.content.is_char_boundary(caret_end)
869                        {
870                            let caret_prefix = &preedit.content[..caret_end];
871                            let caret_x = cursor_x
872                                + measure_text_width(
873                                    caret_prefix,
874                                    ctx.full_char_width,
875                                    ctx.char_width,
876                                );
877
878                            frame.fill_rectangle(
879                                Point::new(caret_x, cursor_y + 2.0),
880                                Size::new(2.0, ctx.line_height - 4.0),
881                                self.style.text_color,
882                            );
883                        }
884                    }
885                }
886            }
887        } else if self.show_cursor && self.cursor_visible && self.has_focus() {
888            // [Branch B] Normal caret rendering mode
889            // ---------------------------------------------------------------------
890            // Draw a caret for every cursor in the set.
891            // The primary cursor is drawn exactly like secondary ones — the viewport
892            // follows the primary, but visually all carets look the same.
893            // ---------------------------------------------------------------------
894            for cursor in self.cursors.iter() {
895                self.draw_single_caret(frame, ctx, cursor.position);
896            }
897        }
898    }
899
900    /// Draws a single 2px vertical caret at the given logical (line, col) position.
901    ///
902    /// # Arguments
903    ///
904    /// * `frame` - The canvas frame to draw on
905    /// * `ctx` - Rendering context containing visual lines and metrics
906    /// * `position` - Logical cursor position (line, col)
907    fn draw_single_caret(
908        &self,
909        frame: &mut canvas::Frame,
910        ctx: &RenderContext,
911        position: (usize, usize),
912    ) {
913        // Map logical cursor position (line, col) to visual line index
914        if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
915            ctx.visual_lines,
916            position.0,
917            position.1,
918        ) {
919            let vl = &ctx.visual_lines[cursor_visual];
920            let line_content = self.buffer.line(vl.logical_line);
921
922            // Compute exact caret X position
923            let (cursor_x_content, _) = calculate_segment_geometry(
924                line_content,
925                vl.start_col,
926                position.1,
927                position.1,
928                ctx.gutter_width + 5.0,
929                ctx.full_char_width,
930                ctx.char_width,
931            );
932            let cursor_x = cursor_x_content - ctx.horizontal_scroll_offset;
933            let cursor_y = cursor_visual as f32 * ctx.line_height;
934
935            // Draw standard caret (2px vertical bar)
936            frame.fill_rectangle(
937                Point::new(cursor_x, cursor_y + 2.0),
938                Size::new(2.0, ctx.line_height - 4.0),
939                self.style.text_color,
940            );
941        }
942    }
943
944    /// Checks if the editor has focus (both Iced focus and internal canvas focus).
945    ///
946    /// # Returns
947    ///
948    /// `true` if the editor has both Iced focus and internal canvas focus and is not focus-locked; `false` otherwise
949    pub(crate) fn has_focus(&self) -> bool {
950        // Check if this editor has Iced focus
951        let focused_id =
952            super::FOCUSED_EDITOR_ID.load(std::sync::atomic::Ordering::Relaxed);
953        focused_id == self.editor_id
954            && self.has_canvas_focus
955            && !self.focus_locked
956    }
957
958    /// Handles keyboard shortcut combinations (Ctrl+C, Ctrl+Z, etc.).
959    ///
960    /// This implementation includes focus chain management for Tab and Shift+Tab
961    /// navigation between editors.
962    ///
963    /// # Arguments
964    ///
965    /// * `key` - The keyboard key that was pressed
966    /// * `modifiers` - The keyboard modifiers (Ctrl, Shift, Alt, etc.)
967    ///
968    /// # Returns
969    ///
970    /// `Some(Action<Message>)` if a shortcut was matched, `None` otherwise
971    fn handle_keyboard_shortcuts(
972        &self,
973        key: &keyboard::Key,
974        modifiers: &keyboard::Modifiers,
975    ) -> Option<Action<Message>> {
976        // Shift+Tab: focus navigation backward (Tab alone inserts indentation)
977        if matches!(key, keyboard::Key::Named(keyboard::key::Named::Tab))
978            && modifiers.shift()
979            && !self.search_state.is_open
980        {
981            return Some(
982                Action::publish(Message::FocusNavigationShiftTab).and_capture(),
983            );
984        }
985
986        // Handle Ctrl+C / Ctrl+Insert (copy)
987        if (modifiers.control()
988            && matches!(key, keyboard::Key::Character(c) if c.as_str() == "c"))
989            || (modifiers.control()
990                && matches!(
991                    key,
992                    keyboard::Key::Named(keyboard::key::Named::Insert)
993                ))
994        {
995            return Some(Action::publish(Message::Copy).and_capture());
996        }
997
998        // Handle Ctrl+Z (undo)
999        if modifiers.control()
1000            && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")
1001        {
1002            return Some(Action::publish(Message::Undo).and_capture());
1003        }
1004
1005        // Handle Ctrl+Y (redo)
1006        if modifiers.control()
1007            && matches!(key, keyboard::Key::Character(y) if y.as_str() == "y")
1008        {
1009            return Some(Action::publish(Message::Redo).and_capture());
1010        }
1011
1012        // Handle Ctrl+F (open search)
1013        if modifiers.control()
1014            && matches!(key, keyboard::Key::Character(f) if f.as_str() == "f")
1015            && self.search_replace_enabled
1016        {
1017            return Some(Action::publish(Message::OpenSearch).and_capture());
1018        }
1019
1020        // Handle Ctrl+H (open search and replace)
1021        if modifiers.control()
1022            && matches!(key, keyboard::Key::Character(h) if h.as_str() == "h")
1023            && self.search_replace_enabled
1024        {
1025            return Some(
1026                Action::publish(Message::OpenSearchReplace).and_capture(),
1027            );
1028        }
1029
1030        // Handle Escape — handled by CloseSearch message, which also collapses multi-cursor
1031        if matches!(key, keyboard::Key::Named(keyboard::key::Named::Escape)) {
1032            return Some(Action::publish(Message::CloseSearch).and_capture());
1033        }
1034
1035        // Handle Ctrl+D (select next occurrence)
1036        if modifiers.control()
1037            && matches!(key, keyboard::Key::Character(d) if d.as_str() == "d")
1038        {
1039            return Some(
1040                Action::publish(Message::SelectNextOccurrence).and_capture(),
1041            );
1042        }
1043
1044        // Handle Ctrl+Alt+Up (add cursor above)
1045        if modifiers.control()
1046            && modifiers.alt()
1047            && matches!(
1048                key,
1049                keyboard::Key::Named(keyboard::key::Named::ArrowUp)
1050            )
1051        {
1052            return Some(
1053                Action::publish(Message::AddCursorAbove).and_capture(),
1054            );
1055        }
1056
1057        // Handle Ctrl+Alt+Down (add cursor below)
1058        if modifiers.control()
1059            && modifiers.alt()
1060            && matches!(
1061                key,
1062                keyboard::Key::Named(keyboard::key::Named::ArrowDown)
1063            )
1064        {
1065            return Some(
1066                Action::publish(Message::AddCursorBelow).and_capture(),
1067            );
1068        }
1069
1070        // Handle Tab (cycle forward in search dialog if open)
1071        if matches!(key, keyboard::Key::Named(keyboard::key::Named::Tab))
1072            && self.search_state.is_open
1073        {
1074            if modifiers.shift() {
1075                // Shift+Tab: cycle backward
1076                return Some(
1077                    Action::publish(Message::SearchDialogShiftTab)
1078                        .and_capture(),
1079                );
1080            } else {
1081                // Tab: cycle forward
1082                return Some(
1083                    Action::publish(Message::SearchDialogTab).and_capture(),
1084                );
1085            }
1086        }
1087
1088        // Handle F3 (find next) and Shift+F3 (find previous)
1089        if matches!(key, keyboard::Key::Named(keyboard::key::Named::F3))
1090            && self.search_replace_enabled
1091        {
1092            if modifiers.shift() {
1093                return Some(
1094                    Action::publish(Message::FindPrevious).and_capture(),
1095                );
1096            } else {
1097                return Some(Action::publish(Message::FindNext).and_capture());
1098            }
1099        }
1100
1101        // Handle Ctrl+V / Shift+Insert (paste) - read clipboard and send paste message
1102        if (modifiers.control()
1103            && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v"))
1104            || (modifiers.shift()
1105                && matches!(
1106                    key,
1107                    keyboard::Key::Named(keyboard::key::Named::Insert)
1108                ))
1109        {
1110            // Return an action that requests clipboard read
1111            return Some(Action::publish(Message::Paste(String::new())));
1112        }
1113
1114        // Handle Ctrl+Home (go to start of document)
1115        if modifiers.control()
1116            && matches!(key, keyboard::Key::Named(keyboard::key::Named::Home))
1117        {
1118            return Some(Action::publish(Message::CtrlHome).and_capture());
1119        }
1120
1121        // Handle Ctrl+End (go to end of document)
1122        if modifiers.control()
1123            && matches!(key, keyboard::Key::Named(keyboard::key::Named::End))
1124        {
1125            return Some(Action::publish(Message::CtrlEnd).and_capture());
1126        }
1127
1128        // Handle Shift+Delete (delete selection)
1129        if modifiers.shift()
1130            && matches!(key, keyboard::Key::Named(keyboard::key::Named::Delete))
1131        {
1132            return Some(
1133                Action::publish(Message::DeleteSelection).and_capture(),
1134            );
1135        }
1136
1137        // Code folding shortcuts (only when folding is enabled).
1138        if self.folding_enabled {
1139            // Ctrl+. : toggle the fold of the block at the cursor.
1140            if modifiers.control()
1141                && matches!(key, keyboard::Key::Character(c) if c.as_str() == ".")
1142            {
1143                return Some(
1144                    Action::publish(Message::ToggleFoldAtCursor).and_capture(),
1145                );
1146            }
1147
1148            // Ctrl+K : fold all blocks.
1149            if modifiers.control()
1150                && !modifiers.shift()
1151                && matches!(key, keyboard::Key::Character(c) if c.as_str() == "k")
1152            {
1153                return Some(Action::publish(Message::FoldAll).and_capture());
1154            }
1155
1156            // Ctrl+J : unfold all blocks.
1157            if modifiers.control()
1158                && !modifiers.shift()
1159                && matches!(key, keyboard::Key::Character(c) if c.as_str() == "j")
1160            {
1161                return Some(Action::publish(Message::UnfoldAll).and_capture());
1162            }
1163        }
1164
1165        None
1166    }
1167
1168    /// Handles character input and special navigation keys.
1169    ///
1170    /// This implementation includes focus event propagation and focus chain management
1171    /// for proper focus handling without mouse bounds checking.
1172    ///
1173    /// # Arguments
1174    ///
1175    /// * `key` - The keyboard key that was pressed
1176    /// * `modifiers` - The keyboard modifiers (Ctrl, Shift, Alt, etc.)
1177    /// * `text` - Optional text content from the keyboard event
1178    ///
1179    /// # Returns
1180    ///
1181    /// `Some(Action<Message>)` if input should be processed, `None` otherwise
1182    #[allow(clippy::unused_self)]
1183    fn handle_character_input(
1184        &self,
1185        key: &keyboard::Key,
1186        modifiers: &keyboard::Modifiers,
1187        text: Option<&str>,
1188    ) -> Option<Action<Message>> {
1189        // Early exit: Only process character input when editor has focus
1190        // This prevents focus stealing where characters typed in other input fields
1191        // appear in the editor
1192        if !self.has_focus() {
1193            return None;
1194        }
1195
1196        // PRIORITY 1: Check if 'text' field has valid printable character
1197        // This handles:
1198        // - Numpad keys with NumLock ON (key=Named(ArrowDown), text=Some("2"))
1199        // - Regular typing with shift, accents, international layouts
1200        if let Some(text_content) = text
1201            && !text_content.is_empty()
1202            && !modifiers.control()
1203            && !modifiers.alt()
1204        {
1205            // Check if it's a printable character (not a control character)
1206            // This filters out Enter (\n), Tab (\t), Delete (U+007F), etc.
1207            if let Some(first_char) = text_content.chars().next()
1208                && !first_char.is_control()
1209            {
1210                return Some(
1211                    Action::publish(Message::CharacterInput(first_char))
1212                        .and_capture(),
1213                );
1214            }
1215        }
1216
1217        // PRIORITY 2: Handle special named keys (navigation, editing)
1218        // These are only processed if text didn't contain a printable character
1219        let message = match key {
1220            keyboard::Key::Named(keyboard::key::Named::Backspace) => {
1221                Some(Message::Backspace)
1222            }
1223            keyboard::Key::Named(keyboard::key::Named::Delete) => {
1224                Some(Message::Delete)
1225            }
1226            keyboard::Key::Named(keyboard::key::Named::Enter) => {
1227                Some(Message::Enter)
1228            }
1229            keyboard::Key::Named(keyboard::key::Named::Tab) => {
1230                // Handle Tab for focus navigation or text insertion
1231                // This implements focus event propagation and focus chain management
1232                if modifiers.shift() {
1233                    // Shift+Tab: focus navigation backward through widget hierarchy
1234                    Some(Message::FocusNavigationShiftTab)
1235                } else {
1236                    // Regular Tab: check if search dialog is open
1237                    if self.search_state.is_open {
1238                        Some(Message::SearchDialogTab)
1239                    } else {
1240                        // Insert 4 spaces for Tab when not in search dialog
1241                        Some(Message::Tab)
1242                    }
1243                }
1244            }
1245            keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
1246                Some(Message::ArrowKey(ArrowDirection::Up, modifiers.shift()))
1247            }
1248            keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
1249                Some(Message::ArrowKey(ArrowDirection::Down, modifiers.shift()))
1250            }
1251            keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
1252                Some(Message::ArrowKey(ArrowDirection::Left, modifiers.shift()))
1253            }
1254            keyboard::Key::Named(keyboard::key::Named::ArrowRight) => Some(
1255                Message::ArrowKey(ArrowDirection::Right, modifiers.shift()),
1256            ),
1257            keyboard::Key::Named(keyboard::key::Named::PageUp) => {
1258                Some(Message::PageUp)
1259            }
1260            keyboard::Key::Named(keyboard::key::Named::PageDown) => {
1261                Some(Message::PageDown)
1262            }
1263            keyboard::Key::Named(keyboard::key::Named::Home) => {
1264                Some(Message::Home(modifiers.shift()))
1265            }
1266            keyboard::Key::Named(keyboard::key::Named::End) => {
1267                Some(Message::End(modifiers.shift()))
1268            }
1269            // PRIORITY 3: Fallback to extracting from 'key' if text was empty/control char
1270            // This handles edge cases where text field is not populated
1271            _ => {
1272                if !modifiers.control()
1273                    && !modifiers.alt()
1274                    && let keyboard::Key::Character(c) = key
1275                    && !c.is_empty()
1276                {
1277                    return c
1278                        .chars()
1279                        .next()
1280                        .map(Message::CharacterInput)
1281                        .map(|msg| Action::publish(msg).and_capture());
1282                }
1283                None
1284            }
1285        };
1286
1287        message.map(|msg| Action::publish(msg).and_capture())
1288    }
1289
1290    /// Handles keyboard events with focus event propagation through widget hierarchy.
1291    ///
1292    /// This implementation completes focus handling without mouse bounds checking
1293    /// and ensures proper focus chain management.
1294    ///
1295    /// # Arguments
1296    ///
1297    /// * `key` - The keyboard key that was pressed
1298    /// * `modifiers` - The keyboard modifiers (Ctrl, Shift, Alt, etc.)
1299    /// * `text` - Optional text content from the keyboard event
1300    /// * `bounds` - The rectangle bounds of the canvas widget (unused in this implementation)
1301    /// * `cursor` - The current mouse cursor position and status (unused in this implementation)
1302    ///
1303    /// # Returns
1304    ///
1305    /// `Some(Action<Message>)` if the event was handled, `None` otherwise
1306    fn handle_keyboard_event(
1307        &self,
1308        key: &keyboard::Key,
1309        modifiers: &keyboard::Modifiers,
1310        text: &Option<iced::advanced::graphics::core::SmolStr>,
1311        _bounds: Rectangle,
1312        _cursor: &mouse::Cursor,
1313    ) -> Option<Action<Message>> {
1314        // Early exit: Check if editor has focus and is not focus-locked
1315        // This prevents focus stealing where keyboard input meant for other widgets
1316        // is incorrectly processed by this editor during focus transitions
1317        if !self.has_focus() || self.focus_locked {
1318            return None;
1319        }
1320
1321        // Skip if IME is active (unless Ctrl/Command is pressed)
1322        if self.ime_preedit.is_some()
1323            && !(modifiers.control() || modifiers.command())
1324        {
1325            return None;
1326        }
1327
1328        // Try keyboard shortcuts first
1329        if let Some(action) = self.handle_keyboard_shortcuts(key, modifiers) {
1330            return Some(action);
1331        }
1332
1333        // Handle character input and special keys
1334        // Convert Option<SmolStr> to Option<&str>
1335        let text_str = text.as_ref().map(|s| s.as_str());
1336        self.handle_character_input(key, modifiers, text_str)
1337    }
1338
1339    /// Handles mouse events (button presses, movement, releases).
1340    ///
1341    /// # Arguments
1342    ///
1343    /// * `event` - The mouse event to handle
1344    /// * `bounds` - The rectangle bounds of the canvas widget
1345    /// * `cursor` - The current mouse cursor position and status
1346    ///
1347    /// # Returns
1348    ///
1349    /// `Some(Action<Message>)` if the event was handled, `None` otherwise
1350    #[allow(clippy::unused_self)]
1351    /// Returns the logical line of the fold header whose chevron is at `point`,
1352    /// if any.
1353    ///
1354    /// Returns `None` when folding is disabled, when the point is outside the
1355    /// fold margin, or when the targeted line is not a fold header.
1356    ///
1357    /// # Arguments
1358    ///
1359    /// * `point` - The click position in canvas coordinates
1360    pub(crate) fn fold_header_at_point(&self, point: Point) -> Option<usize> {
1361        if !self.folding_enabled {
1362            return None;
1363        }
1364
1365        // The fold margin is the strip between the line-number area and the text.
1366        let margin_start = self.line_number_gutter_width();
1367        if point.x < margin_start || point.x >= self.gutter_width() {
1368            return None;
1369        }
1370
1371        let visual_line_idx = (point.y / self.line_height) as usize;
1372        let visual_lines = self.visual_lines_cached(self.viewport_width);
1373        let visual_line = visual_lines.get(visual_line_idx)?;
1374        if !visual_line.is_first_segment() {
1375            return None;
1376        }
1377
1378        let regions = self.foldable_regions();
1379        folding::is_fold_header(&regions, visual_line.logical_line)
1380            .then_some(visual_line.logical_line)
1381    }
1382
1383    fn handle_mouse_event(
1384        &self,
1385        event: &mouse::Event,
1386        bounds: Rectangle,
1387        cursor: &mouse::Cursor,
1388    ) -> Option<Action<Message>> {
1389        match event {
1390            mouse::Event::ButtonPressed(mouse::Button::Left) => {
1391                cursor.position_in(bounds).map(|position| {
1392                    // Clicking a fold chevron toggles the block instead of
1393                    // moving the caret.
1394                    if let Some(header) = self.fold_header_at_point(position) {
1395                        return Action::publish(Message::ToggleFold(header))
1396                            .and_capture();
1397                    }
1398
1399                    // Check for Ctrl (or Command on macOS) + Click
1400                    #[cfg(target_os = "macos")]
1401                    let is_jump_click = self.modifiers.get().command();
1402                    #[cfg(not(target_os = "macos"))]
1403                    let is_jump_click = self.modifiers.get().control();
1404
1405                    if is_jump_click {
1406                        return Action::publish(Message::JumpClick(position));
1407                    }
1408
1409                    // Alt+Click: add a new cursor at the clicked position
1410                    if self.modifiers.get().alt() {
1411                        return Action::publish(Message::AltClick(position))
1412                            .and_capture();
1413                    }
1414
1415                    // Don't capture the event so it can bubble up for focus management
1416                    // This implements focus event propagation through the widget hierarchy
1417                    Action::publish(Message::MouseClick(position))
1418                })
1419            }
1420            mouse::Event::CursorMoved { .. } => {
1421                cursor.position_in(bounds).map(|position| {
1422                    if self.is_dragging {
1423                        // Handle mouse drag for selection only when cursor is within bounds
1424                        Action::publish(Message::MouseDrag(position))
1425                            .and_capture()
1426                    } else {
1427                        // Forward hover events when not dragging to enable LSP hover.
1428                        Action::publish(Message::MouseHover(position))
1429                    }
1430                })
1431            }
1432            mouse::Event::ButtonReleased(mouse::Button::Left) => {
1433                // Only handle mouse release when cursor is within bounds
1434                // This prevents capturing events meant for other widgets
1435                if cursor.is_over(bounds) {
1436                    Some(Action::publish(Message::MouseRelease).and_capture())
1437                } else {
1438                    None
1439                }
1440            }
1441            _ => None,
1442        }
1443    }
1444
1445    /// Handles IME (Input Method Editor) events for complex text input.
1446    ///
1447    /// # Arguments
1448    ///
1449    /// * `event` - The IME event to handle
1450    /// * `bounds` - The rectangle bounds of the canvas widget
1451    /// * `cursor` - The current mouse cursor position and status
1452    ///
1453    /// # Returns
1454    ///
1455    /// `Some(Action<Message>)` if the event was handled, `None` otherwise
1456    fn handle_ime_event(
1457        &self,
1458        event: &input_method::Event,
1459        _bounds: Rectangle,
1460        _cursor: &mouse::Cursor,
1461    ) -> Option<Action<Message>> {
1462        // Early exit: Check if editor has focus and is not focus-locked
1463        // This prevents focus stealing where IME events meant for other widgets
1464        // are incorrectly processed by this editor during focus transitions
1465        if !self.has_focus() || self.focus_locked {
1466            return None;
1467        }
1468
1469        // IME event handling
1470        // ---------------------------------------------------------------------
1471        // Core mapping: convert Iced IME events into editor Messages
1472        //
1473        // Flow:
1474        // 1. Opened: IME activated (e.g. switching input method). Clear old preedit state.
1475        // 2. Preedit: User is composing (e.g. typing "nihao" before commit).
1476        //    - content: current candidate text
1477        //    - selection: selection range within the text, in bytes
1478        // 3. Commit: User confirms a candidate and commits text into the buffer.
1479        // 4. Closed: IME closed or lost focus.
1480        //
1481        // Safety checks:
1482        // - handle only when `focused_id` matches this editor ID
1483        // - handle only when `has_canvas_focus` is true
1484        // This ensures IME events are not delivered to the wrong widget.
1485        // ---------------------------------------------------------------------
1486        let message = match event {
1487            input_method::Event::Opened => Message::ImeOpened,
1488            input_method::Event::Preedit(content, selection) => {
1489                Message::ImePreedit(content.clone(), selection.clone())
1490            }
1491            input_method::Event::Commit(content) => {
1492                Message::ImeCommit(content.clone())
1493            }
1494            input_method::Event::Closed => Message::ImeClosed,
1495        };
1496
1497        Some(Action::publish(message).and_capture())
1498    }
1499}
1500
1501impl CodeEditor {
1502    /// Draws underlines for jumpable links when modifier is held.
1503    fn draw_jump_link_highlight(
1504        &self,
1505        frame: &mut canvas::Frame,
1506        ctx: &RenderContext,
1507        bounds: Rectangle,
1508        cursor: mouse::Cursor,
1509    ) {
1510        #[cfg(target_os = "macos")]
1511        let modifier_active = self.modifiers.get().command();
1512        #[cfg(not(target_os = "macos"))]
1513        let modifier_active = self.modifiers.get().control();
1514
1515        if !modifier_active {
1516            return;
1517        }
1518
1519        let Some(point) = cursor.position_in(bounds) else {
1520            return;
1521        };
1522
1523        if let Some((line, col)) = self.calculate_cursor_from_point(point) {
1524            let line_content = self.buffer.line(line);
1525
1526            let start_col = Self::word_start_in_line(line_content, col);
1527            let end_col = Self::word_end_in_line(line_content, col);
1528
1529            if start_col >= end_col {
1530                return;
1531            }
1532
1533            // Find the first visual line for this logical line
1534            if let Some(mut idx) =
1535                WrappingCalculator::logical_to_visual(ctx.visual_lines, line, 0)
1536            {
1537                // Iterate all visual lines belonging to this logical line
1538                while idx < ctx.visual_lines.len() {
1539                    let visual_line = &ctx.visual_lines[idx];
1540                    if visual_line.logical_line != line {
1541                        break;
1542                    }
1543
1544                    // Check intersection
1545                    let seg_start = visual_line.start_col.max(start_col);
1546                    let seg_end = visual_line.end_col.min(end_col);
1547
1548                    if seg_start < seg_end {
1549                        let (x, width) = calculate_segment_geometry(
1550                            line_content,
1551                            visual_line.start_col,
1552                            seg_start,
1553                            seg_end,
1554                            ctx.gutter_width + 5.0
1555                                - ctx.horizontal_scroll_offset,
1556                            ctx.full_char_width,
1557                            ctx.char_width,
1558                        );
1559
1560                        let y = idx as f32 * ctx.line_height + ctx.line_height; // Underline at bottom
1561
1562                        // Draw underline
1563                        let path = canvas::Path::line(
1564                            Point::new(x, y),
1565                            Point::new(x + width, y),
1566                        );
1567
1568                        frame.stroke(
1569                            &path,
1570                            canvas::Stroke::default()
1571                                .with_color(self.style.text_color) // Use text color or link color
1572                                .with_width(1.0),
1573                        );
1574                    }
1575
1576                    idx += 1;
1577                }
1578            }
1579        }
1580    }
1581}
1582
1583impl canvas::Program<Message> for CodeEditor {
1584    type State = ();
1585
1586    /// Renders the code editor's visual elements on the canvas, including text layout, syntax highlighting,
1587    /// cursor positioning, and other graphical aspects.
1588    ///
1589    /// # Arguments
1590    ///
1591    /// * `state` - The current state of the canvas
1592    /// * `renderer` - The renderer used for drawing
1593    /// * `theme` - The theme for styling
1594    /// * `bounds` - The rectangle bounds of the canvas
1595    /// * `cursor` - The mouse cursor position
1596    ///
1597    /// # Returns
1598    ///
1599    /// A vector of `Geometry` objects representing the drawn elements
1600    fn draw(
1601        &self,
1602        _state: &Self::State,
1603        renderer: &iced::Renderer,
1604        _theme: &Theme,
1605        bounds: Rectangle,
1606        _cursor: mouse::Cursor,
1607    ) -> Vec<Geometry> {
1608        let visual_lines: Rc<Vec<VisualLine>> =
1609            self.visual_lines_cached(bounds.width);
1610
1611        // Prefer the tracked viewport height when available, but fall back to
1612        // the current bounds during initial layout when viewport metrics have
1613        // not been populated yet.
1614        let effective_viewport_height = if self.viewport_height > 0.0 {
1615            self.viewport_height
1616        } else {
1617            bounds.height
1618        };
1619        let first_visible_line =
1620            (self.viewport_scroll / self.line_height).floor() as usize;
1621        let visible_lines_count =
1622            (effective_viewport_height / self.line_height).ceil() as usize + 2;
1623        let last_visible_line =
1624            (first_visible_line + visible_lines_count).min(visual_lines.len());
1625
1626        let (start_idx, end_idx) =
1627            if self.cache_window_end_line > self.cache_window_start_line {
1628                let s = self.cache_window_start_line.min(visual_lines.len());
1629                let e = self.cache_window_end_line.min(visual_lines.len());
1630                (s, e)
1631            } else {
1632                (first_visible_line, last_visible_line)
1633            };
1634
1635        // Split rendering into two cached layers:
1636        // - content: expensive, mostly static text/gutter rendering
1637        // - overlay: frequently changing highlights/cursor/IME
1638        //
1639        // This keeps selection dragging and cursor blinking smooth by avoiding
1640        // invalidation of the text layer on every overlay update.
1641        let visual_lines_for_content = visual_lines.clone();
1642        let content_geometry =
1643            self.content_cache.draw(renderer, bounds.size(), |frame| {
1644                // syntect initialization is relatively expensive; keep it global.
1645                let syntax_set = SYNTAX_SET.get_or_init(|| {
1646                    #[cfg(feature = "two-face")]
1647                    {
1648                        two_face::syntax::extra_newlines()
1649                    }
1650                    #[cfg(not(feature = "two-face"))]
1651                    {
1652                        SyntaxSet::load_defaults_newlines()
1653                    }
1654                });
1655                let theme_set = THEME_SET.get_or_init(ThemeSet::load_defaults);
1656                let syntax_theme = theme_set
1657                    .themes
1658                    .get("base16-ocean.dark")
1659                    .or_else(|| theme_set.themes.values().next());
1660
1661                // Normalize common language aliases/extensions used by consumers.
1662                let syntax_ref = match self.syntax.as_str() {
1663                    "python" => syntax_set.find_syntax_by_extension("py"),
1664                    "rust" => syntax_set.find_syntax_by_extension("rs"),
1665                    "javascript" => syntax_set.find_syntax_by_extension("js"),
1666                    "htm" => syntax_set.find_syntax_by_extension("html"),
1667                    "svg" => syntax_set.find_syntax_by_extension("xml"),
1668                    "markdown" => syntax_set.find_syntax_by_extension("md"),
1669                    "text" => Some(syntax_set.find_syntax_plain_text()),
1670                    _ => syntax_set
1671                        .find_syntax_by_extension(self.syntax.as_str()),
1672                }
1673                .or(Some(syntax_set.find_syntax_plain_text()));
1674
1675                let ctx = RenderContext {
1676                    visual_lines: visual_lines_for_content.as_ref(),
1677                    bounds_width: bounds.width,
1678                    gutter_width: self.gutter_width(),
1679                    line_height: self.line_height,
1680                    font_size: self.font_size,
1681                    full_char_width: self.full_char_width,
1682                    char_width: self.char_width,
1683                    font: self.font,
1684                    horizontal_scroll_offset: self.horizontal_scroll_offset,
1685                };
1686
1687                // Clip code text to the code area (right of gutter) so that
1688                // horizontal scrolling cannot cause text to bleed into the gutter.
1689                // Note: iced renders ALL text on top of ALL geometry, so a
1690                // fill_rectangle cannot mask text bleed — with_clip is required.
1691                let code_clip = Rectangle {
1692                    x: ctx.gutter_width,
1693                    y: 0.0,
1694                    width: (bounds.width - ctx.gutter_width).max(0.0),
1695                    height: bounds.height,
1696                };
1697                frame.with_clip(code_clip, |f| {
1698                    for (idx, visual_line) in visual_lines_for_content
1699                        .iter()
1700                        .enumerate()
1701                        .skip(start_idx)
1702                        .take(end_idx.saturating_sub(start_idx))
1703                    {
1704                        let y = idx as f32 * self.line_height;
1705                        self.draw_text_with_syntax_highlighting(
1706                            f,
1707                            &ctx,
1708                            visual_line,
1709                            y,
1710                            syntax_ref,
1711                            syntax_set,
1712                            syntax_theme,
1713                        );
1714                        self.draw_fold_collapsed_marker(
1715                            f,
1716                            &ctx,
1717                            visual_line,
1718                            y,
1719                        );
1720                    }
1721                });
1722
1723                // Draw line numbers in the gutter (no clip — fixed position)
1724                for (idx, visual_line) in visual_lines_for_content
1725                    .iter()
1726                    .enumerate()
1727                    .skip(start_idx)
1728                    .take(end_idx.saturating_sub(start_idx))
1729                {
1730                    let y = idx as f32 * self.line_height;
1731                    self.draw_line_numbers(frame, &ctx, visual_line, y);
1732                }
1733            });
1734
1735        let visual_lines_for_overlay = visual_lines;
1736        let overlay_geometry =
1737            self.overlay_cache.draw(renderer, bounds.size(), |frame| {
1738                // The overlay layer shares the same visual lines, but draws only
1739                // elements that change without modifying the buffer content.
1740                let ctx = RenderContext {
1741                    visual_lines: visual_lines_for_overlay.as_ref(),
1742                    bounds_width: bounds.width,
1743                    gutter_width: self.gutter_width(),
1744                    line_height: self.line_height,
1745                    font_size: self.font_size,
1746                    full_char_width: self.full_char_width,
1747                    char_width: self.char_width,
1748                    font: self.font,
1749                    horizontal_scroll_offset: self.horizontal_scroll_offset,
1750                };
1751
1752                for (idx, visual_line) in visual_lines_for_overlay
1753                    .iter()
1754                    .enumerate()
1755                    .skip(start_idx)
1756                    .take(end_idx.saturating_sub(start_idx))
1757                {
1758                    let y = idx as f32 * self.line_height;
1759                    self.draw_current_line_highlight(
1760                        frame,
1761                        &ctx,
1762                        visual_line,
1763                        y,
1764                    );
1765                }
1766
1767                self.draw_search_highlights(frame, &ctx, start_idx, end_idx);
1768                self.draw_selection_highlight(frame, &ctx);
1769                self.draw_jump_link_highlight(frame, &ctx, bounds, _cursor);
1770                self.draw_cursor(frame, &ctx);
1771            });
1772
1773        vec![content_geometry, overlay_geometry]
1774    }
1775
1776    /// Handles Canvas trait events, specifically keyboard input events and focus management for the code editor widget.
1777    ///
1778    /// # Arguments
1779    ///
1780    /// * `_state` - The mutable state of the canvas (unused in this implementation)
1781    /// * `event` - The input event to handle, such as keyboard presses
1782    /// * `bounds` - The rectangle bounds of the canvas widget
1783    /// * `cursor` - The current mouse cursor position and status
1784    ///
1785    /// # Returns
1786    ///
1787    /// An optional `Action<Message>` to perform, such as sending a message or redrawing the canvas
1788    fn update(
1789        &self,
1790        _state: &mut Self::State,
1791        event: &Event,
1792        bounds: Rectangle,
1793        cursor: mouse::Cursor,
1794    ) -> Option<Action<Message>> {
1795        match event {
1796            Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
1797                self.modifiers.set(*modifiers);
1798                None
1799            }
1800            Event::Keyboard(keyboard::Event::KeyPressed {
1801                key,
1802                modifiers,
1803                text,
1804                ..
1805            }) => {
1806                self.modifiers.set(*modifiers);
1807                self.handle_keyboard_event(
1808                    key, modifiers, text, bounds, &cursor,
1809                )
1810            }
1811            Event::Keyboard(keyboard::Event::KeyReleased {
1812                modifiers, ..
1813            }) => {
1814                self.modifiers.set(*modifiers);
1815                None
1816            }
1817            Event::Mouse(mouse_event) => {
1818                self.handle_mouse_event(mouse_event, bounds, &cursor)
1819            }
1820            Event::InputMethod(ime_event) => {
1821                self.handle_ime_event(ime_event, bounds, &cursor)
1822            }
1823            _ => None,
1824        }
1825    }
1826}
1827
1828/// Validates that the selection indices fall on valid UTF-8 character boundaries
1829/// to prevent panics during string slicing.
1830///
1831/// # Arguments
1832///
1833/// * `content` - The string content to check against
1834/// * `start` - The start byte index
1835/// * `end` - The end byte index
1836///
1837/// # Returns
1838///
1839/// `Some((start, end))` if indices are valid, `None` otherwise.
1840fn validate_selection_indices(
1841    content: &str,
1842    start: usize,
1843    end: usize,
1844) -> Option<(usize, usize)> {
1845    let len = content.len();
1846    // Clamp indices to content length
1847    let start = start.min(len);
1848    let end = end.min(len);
1849
1850    // Ensure start is not greater than end
1851    if start > end {
1852        return None;
1853    }
1854
1855    // Verify that indices fall on valid UTF-8 character boundaries
1856    if content.is_char_boundary(start) && content.is_char_boundary(end) {
1857        Some((start, end))
1858    } else {
1859        None
1860    }
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    use super::*;
1866    use crate::canvas_editor::{CHAR_WIDTH, FONT_SIZE, compare_floats};
1867    use std::cmp::Ordering;
1868
1869    #[test]
1870    fn test_calculate_segment_geometry_ascii() {
1871        // "Hello World"
1872        // "Hello " (6 chars) -> prefix
1873        // "World" (5 chars) -> segment
1874        // width("Hello ") = 6 * CHAR_WIDTH
1875        // width("World") = 5 * CHAR_WIDTH
1876        let content = "Hello World";
1877        let (x, w) = calculate_segment_geometry(
1878            content, 0, 6, 11, 0.0, FONT_SIZE, CHAR_WIDTH,
1879        );
1880
1881        let expected_x = CHAR_WIDTH * 6.0;
1882        let expected_w = CHAR_WIDTH * 5.0;
1883
1884        assert_eq!(
1885            compare_floats(x, expected_x),
1886            Ordering::Equal,
1887            "X position mismatch for ASCII"
1888        );
1889        assert_eq!(
1890            compare_floats(w, expected_w),
1891            Ordering::Equal,
1892            "Width mismatch for ASCII"
1893        );
1894    }
1895
1896    #[test]
1897    fn test_calculate_segment_geometry_cjk() {
1898        // "你好世界"
1899        // "你好" (2 chars) -> prefix
1900        // "世界" (2 chars) -> segment
1901        // width("你好") = 2 * FONT_SIZE
1902        // width("世界") = 2 * FONT_SIZE
1903        let content = "你好世界";
1904        let (x, w) = calculate_segment_geometry(
1905            content, 0, 2, 4, 10.0, FONT_SIZE, CHAR_WIDTH,
1906        );
1907
1908        let expected_x = 10.0 + FONT_SIZE * 2.0;
1909        let expected_w = FONT_SIZE * 2.0;
1910
1911        assert_eq!(
1912            compare_floats(x, expected_x),
1913            Ordering::Equal,
1914            "X position mismatch for CJK"
1915        );
1916        assert_eq!(
1917            compare_floats(w, expected_w),
1918            Ordering::Equal,
1919            "Width mismatch for CJK"
1920        );
1921    }
1922
1923    #[test]
1924    fn test_calculate_segment_geometry_mixed() {
1925        // "Hi你好"
1926        // "Hi" (2 chars) -> prefix
1927        // "你好" (2 chars) -> segment
1928        // width("Hi") = 2 * CHAR_WIDTH
1929        // width("你好") = 2 * FONT_SIZE
1930        let content = "Hi你好";
1931        let (x, w) = calculate_segment_geometry(
1932            content, 0, 2, 4, 0.0, FONT_SIZE, CHAR_WIDTH,
1933        );
1934
1935        let expected_x = CHAR_WIDTH * 2.0;
1936        let expected_w = FONT_SIZE * 2.0;
1937
1938        assert_eq!(
1939            compare_floats(x, expected_x),
1940            Ordering::Equal,
1941            "X position mismatch for mixed content"
1942        );
1943        assert_eq!(
1944            compare_floats(w, expected_w),
1945            Ordering::Equal,
1946            "Width mismatch for mixed content"
1947        );
1948    }
1949
1950    #[test]
1951    fn test_calculate_segment_geometry_empty_range() {
1952        let content = "Hello";
1953        let (x, w) = calculate_segment_geometry(
1954            content, 0, 0, 0, 0.0, FONT_SIZE, CHAR_WIDTH,
1955        );
1956        assert!((x - 0.0).abs() < f32::EPSILON);
1957        assert!((w - 0.0).abs() < f32::EPSILON);
1958    }
1959
1960    #[test]
1961    fn test_calculate_segment_geometry_with_visual_offset() {
1962        // content: "0123456789"
1963        // visual_start_col: 2 (starts at '2')
1964        // segment: "34" (indices 3 to 5)
1965        // prefix: from visual start (2) to segment start (3) -> "2" (length 1)
1966        // prefix width: 1 * CHAR_WIDTH
1967        // segment width: 2 * CHAR_WIDTH
1968        let content = "0123456789";
1969        let (x, w) = calculate_segment_geometry(
1970            content, 2, 3, 5, 5.0, FONT_SIZE, CHAR_WIDTH,
1971        );
1972
1973        let expected_x = 5.0 + CHAR_WIDTH * 1.0;
1974        let expected_w = CHAR_WIDTH * 2.0;
1975
1976        assert_eq!(
1977            compare_floats(x, expected_x),
1978            Ordering::Equal,
1979            "X position mismatch with visual offset"
1980        );
1981        assert_eq!(
1982            compare_floats(w, expected_w),
1983            Ordering::Equal,
1984            "Width mismatch with visual offset"
1985        );
1986    }
1987
1988    #[test]
1989    fn test_calculate_segment_geometry_out_of_bounds() {
1990        // Content length is 5 ("Hello")
1991        // Request start at 10, end at 15
1992        // visual_start 0
1993        // Prefix should consume whole string ("Hello") and stop.
1994        // Segment should be empty.
1995        let content = "Hello";
1996        let (x, w) = calculate_segment_geometry(
1997            content, 0, 10, 15, 0.0, FONT_SIZE, CHAR_WIDTH,
1998        );
1999
2000        let expected_x = CHAR_WIDTH * 5.0; // Width of "Hello"
2001        let expected_w = 0.0;
2002
2003        assert_eq!(
2004            compare_floats(x, expected_x),
2005            Ordering::Equal,
2006            "X position mismatch for out of bounds start"
2007        );
2008        assert!(
2009            (w - expected_w).abs() < f32::EPSILON,
2010            "Width should be 0 for out of bounds segment"
2011        );
2012    }
2013
2014    #[test]
2015    fn test_calculate_segment_geometry_special_chars() {
2016        // Emoji "👋" (width > 1 => FONT_SIZE)
2017        // Tab "\t" (width = 4 * CHAR_WIDTH)
2018        let content = "A👋\tB";
2019        // Measure "👋" (index 1 to 2)
2020        // Indices in chars: 'A' (0), '👋' (1), '\t' (2), 'B' (3)
2021
2022        // Segment covering Emoji
2023        let (x, w) = calculate_segment_geometry(
2024            content, 0, 1, 2, 0.0, FONT_SIZE, CHAR_WIDTH,
2025        );
2026        let expected_x_emoji = CHAR_WIDTH; // 'A'
2027        let expected_w_emoji = FONT_SIZE; // '👋'
2028
2029        assert_eq!(
2030            compare_floats(x, expected_x_emoji),
2031            Ordering::Equal,
2032            "X pos for emoji"
2033        );
2034        assert_eq!(
2035            compare_floats(w, expected_w_emoji),
2036            Ordering::Equal,
2037            "Width for emoji"
2038        );
2039
2040        // Segment covering Tab
2041        let (x_tab, w_tab) = calculate_segment_geometry(
2042            content, 0, 2, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
2043        );
2044        let expected_x_tab = CHAR_WIDTH + FONT_SIZE; // 'A' + '👋'
2045        let expected_w_tab =
2046            CHAR_WIDTH * crate::canvas_editor::TAB_WIDTH as f32;
2047
2048        assert_eq!(
2049            compare_floats(x_tab, expected_x_tab),
2050            Ordering::Equal,
2051            "X pos for tab"
2052        );
2053        assert_eq!(
2054            compare_floats(w_tab, expected_w_tab),
2055            Ordering::Equal,
2056            "Width for tab"
2057        );
2058    }
2059
2060    #[test]
2061    fn test_calculate_segment_geometry_inverted_range() {
2062        // Start 5, End 3
2063        // Should result in empty segment at start 5
2064        let content = "0123456789";
2065        let (x, w) = calculate_segment_geometry(
2066            content, 0, 5, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
2067        );
2068
2069        let expected_x = CHAR_WIDTH * 5.0;
2070        let expected_w = 0.0;
2071
2072        assert_eq!(
2073            compare_floats(x, expected_x),
2074            Ordering::Equal,
2075            "X pos for inverted range"
2076        );
2077        assert!(
2078            (w - expected_w).abs() < f32::EPSILON,
2079            "Width for inverted range"
2080        );
2081    }
2082
2083    #[test]
2084    fn test_validate_selection_indices() {
2085        // Test valid ASCII indices
2086        let content = "Hello";
2087        assert_eq!(validate_selection_indices(content, 0, 5), Some((0, 5)));
2088        assert_eq!(validate_selection_indices(content, 1, 3), Some((1, 3)));
2089
2090        // Test valid multi-byte indices (Chinese "你好")
2091        // "你" is 3 bytes (0-3), "好" is 3 bytes (3-6)
2092        let content = "你好";
2093        assert_eq!(validate_selection_indices(content, 0, 6), Some((0, 6)));
2094        assert_eq!(validate_selection_indices(content, 0, 3), Some((0, 3)));
2095        assert_eq!(validate_selection_indices(content, 3, 6), Some((3, 6)));
2096
2097        // Test invalid indices (splitting multi-byte char)
2098        assert_eq!(validate_selection_indices(content, 1, 3), None); // Split first char
2099        assert_eq!(validate_selection_indices(content, 0, 4), None); // Split second char
2100
2101        // Test out of bounds (should be clamped if on boundary, but here len is 6)
2102        // If we pass start=0, end=100 -> clamped to 0, 6. 6 is boundary.
2103        assert_eq!(validate_selection_indices(content, 0, 100), Some((0, 6)));
2104
2105        // Test inverted range
2106        assert_eq!(validate_selection_indices(content, 3, 0), None);
2107    }
2108}