Skip to main content

iced_code_editor/canvas_editor/
canvas_impl.rs

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