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/// Expands tabs and replaces whitespace with visible symbols: `\t` → `→` +
90/// `·` fill, ` ` → `·`. The output has the same logical width as the
91/// `expand_tabs` output, so existing width measurements remain valid.
92fn expand_tabs_visible(text: &str, tab_width: usize) -> String {
93    let mut result = String::with_capacity(text.len() * 2);
94    for ch in text.chars() {
95        match ch {
96            '\t' => {
97                result.push('→');
98                for _ in 1..tab_width {
99                    result.push('·');
100                }
101            }
102            ' ' => result.push('·'),
103            other => result.push(other),
104        }
105    }
106    result
107}
108
109/// Splits a string (already processed by [`expand_tabs_visible`]) into
110/// alternating `(is_whitespace, segment)` pairs, where whitespace segments
111/// consist exclusively of `·` and `→` characters.
112fn split_whitespace_segments(text: &str) -> Vec<(bool, &str)> {
113    if text.is_empty() {
114        return vec![];
115    }
116
117    let mut result = Vec::new();
118    let mut seg_start = 0usize;
119    let mut chars = text.char_indices().peekable();
120
121    let is_ws_char = |c: char| c == '·' || c == '→';
122
123    let first_ch = chars.peek().map(|(_, c)| *c).unwrap_or(' ');
124    let mut current_is_ws = is_ws_char(first_ch);
125
126    for (byte_idx, ch) in chars {
127        let ch_is_ws = is_ws_char(ch);
128        if ch_is_ws != current_is_ws {
129            result.push((current_is_ws, &text[seg_start..byte_idx]));
130            seg_start = byte_idx;
131            current_is_ws = ch_is_ws;
132        }
133    }
134    result.push((current_is_ws, &text[seg_start..]));
135    result
136}
137
138/// Converts a syntect highlight [`Style`] into an iced [`Color`].
139///
140/// Only the foreground color is used; alpha is left fully opaque.
141///
142/// # Arguments
143///
144/// * `style` - The syntect style whose foreground color is converted.
145fn color_from_style(style: Style) -> Color {
146    Color::from_rgb(
147        f32::from(style.foreground.r) / 255.0,
148        f32::from(style.foreground.g) / 255.0,
149        f32::from(style.foreground.b) / 255.0,
150    )
151}
152
153/// Tokenizes a full logical line into colored spans using syntect.
154///
155/// The returned spans cover the entire line in order, each pairing an iced
156/// [`Color`] with the owned token text. Each call highlights the line
157/// independently from the syntax's initial state, so it does not handle
158/// multi-line constructs; it is used for tests and benchmarks. Rendering uses
159/// the sequential [`CodeEditor::highlighted_line_cached`] instead.
160///
161/// # Arguments
162///
163/// * `line` - The full logical line content (without trailing newline).
164/// * `syntax` - The syntect syntax definition to tokenize with.
165/// * `theme` - The syntect highlighting theme providing token colors.
166/// * `syntax_set` - The syntax set the `syntax` belongs to.
167///
168/// # Returns
169///
170/// The ordered colored spans covering the entire line.
171pub fn highlight_line_spans(
172    line: &str,
173    syntax: &syntect::parsing::SyntaxReference,
174    theme: &syntect::highlighting::Theme,
175    syntax_set: &SyntaxSet,
176) -> Vec<(Color, String)> {
177    let mut highlighter = HighlightLines::new(syntax, theme);
178    let ranges = highlighter
179        .highlight_line(line, syntax_set)
180        .unwrap_or_else(|_| vec![(Style::default(), line)]);
181
182    ranges
183        .into_iter()
184        .map(|(style, text)| (color_from_style(style), text.to_string()))
185        .collect()
186}
187
188use super::folding;
189use super::wrapping::{VisualLine, WrappingCalculator};
190use super::{
191    ArrowDirection, CodeEditor, Message, measure_char_width, measure_text_width,
192};
193use iced::widget::canvas::Action;
194
195static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
196static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
197
198/// Context for canvas rendering operations.
199///
200/// This struct packages commonly used rendering parameters to reduce
201/// method signature complexity and improve code maintainability.
202struct RenderContext<'a> {
203    /// Visual lines calculated from wrapping
204    visual_lines: &'a [VisualLine],
205    /// Width of the canvas bounds
206    bounds_width: f32,
207    /// Width of the line number gutter
208    gutter_width: f32,
209    /// Height of each line in pixels
210    line_height: f32,
211    /// Font size in pixels
212    font_size: f32,
213    /// Full character width for wide characters (e.g., CJK)
214    full_char_width: f32,
215    /// Character width for narrow characters
216    char_width: f32,
217    /// Font to use for rendering text
218    font: iced::Font,
219    /// Horizontal scroll offset in pixels (subtracted from text X positions)
220    horizontal_scroll_offset: f32,
221}
222
223impl CodeEditor {
224    /// Draws line numbers and wrap indicators in the gutter area.
225    ///
226    /// # Arguments
227    ///
228    /// * `frame` - The canvas frame to draw on
229    /// * `ctx` - Rendering context containing visual lines and metrics
230    /// * `visual_line` - The visual line to render
231    /// * `y` - Y position for rendering
232    fn draw_line_numbers(
233        &self,
234        frame: &mut canvas::Frame,
235        ctx: &RenderContext,
236        visual_line: &VisualLine,
237        y: f32,
238    ) {
239        // The line-number area is the left part of the gutter; the fold margin
240        // (when folding is enabled) is the right strip adjacent to the text.
241        let number_area_width = self.line_number_gutter_width();
242
243        if self.line_numbers_enabled {
244            if visual_line.is_first_segment() {
245                // Draw line number for first segment, centered in the number area.
246                let line_num = visual_line.logical_line + 1;
247                let line_num_text = format!("{}", line_num);
248                let text_width = measure_text_width(
249                    &line_num_text,
250                    ctx.full_char_width,
251                    ctx.char_width,
252                );
253                let x_pos = (number_area_width - text_width) / 2.0;
254                frame.fill_text(canvas::Text {
255                    content: line_num_text,
256                    position: Point::new(x_pos, y + 2.0),
257                    color: self.style.line_number_color,
258                    size: ctx.font_size.into(),
259                    font: ctx.font,
260                    ..canvas::Text::default()
261                });
262            } else {
263                // Draw wrap indicator for continuation lines.
264                frame.fill_text(canvas::Text {
265                    content: "↪".to_string(),
266                    position: Point::new(number_area_width - 20.0, y + 2.0),
267                    color: self.style.line_number_color,
268                    size: ctx.font_size.into(),
269                    font: ctx.font,
270                    ..canvas::Text::default()
271                });
272            }
273        }
274
275        self.draw_fold_chevron(frame, ctx, visual_line, y, number_area_width);
276    }
277
278    /// Draws the fold chevron in the fold margin for a foldable header line.
279    ///
280    /// Draws nothing when folding is disabled, on continuation (wrapped)
281    /// segments, or on lines that are not fold headers.
282    ///
283    /// # Arguments
284    ///
285    /// * `frame` - The canvas frame to draw on
286    /// * `ctx` - Rendering context containing metrics
287    /// * `visual_line` - The visual line to render
288    /// * `y` - Y position for rendering
289    /// * `number_area_width` - Width of the line-number area (start of the fold margin)
290    fn draw_fold_chevron(
291        &self,
292        frame: &mut canvas::Frame,
293        ctx: &RenderContext,
294        visual_line: &VisualLine,
295        y: f32,
296        number_area_width: f32,
297    ) {
298        if !self.folding_enabled || !visual_line.is_first_segment() {
299            return;
300        }
301
302        if !folding::is_line_fold_header(&self.buffer, visual_line.logical_line)
303        {
304            return;
305        }
306
307        // `▶` when collapsed, `▼` when expanded.
308        let chevron = if self.is_folded(visual_line.logical_line) {
309            "▶"
310        } else {
311            "▼"
312        };
313        frame.fill_text(canvas::Text {
314            content: chevron.to_string(),
315            position: Point::new(number_area_width + 1.0, y + 2.0),
316            color: self.style.line_number_color,
317            size: ctx.font_size.into(),
318            font: ctx.font,
319            ..canvas::Text::default()
320        });
321    }
322
323    /// Draws a `⋯` marker after the text of a collapsed fold header, signalling
324    /// that lines are hidden below it (VS Code-style cue).
325    ///
326    /// Draws nothing unless folding is enabled and `visual_line` is the header
327    /// of a currently collapsed region. Intended to be called inside the clipped
328    /// code area so the marker cannot bleed into the gutter.
329    fn draw_fold_collapsed_marker(
330        &self,
331        frame: &mut canvas::Frame,
332        ctx: &RenderContext,
333        visual_line: &VisualLine,
334        y: f32,
335    ) {
336        if !self.folding_enabled
337            || !visual_line.is_first_segment()
338            || !self.is_folded(visual_line.logical_line)
339        {
340            return;
341        }
342
343        let line_content = self.buffer.line(visual_line.logical_line);
344        let line_width = measure_text_width(
345            line_content,
346            ctx.full_char_width,
347            ctx.char_width,
348        );
349        let x = ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset
350            + line_width
351            + 6.0;
352        frame.fill_text(canvas::Text {
353            content: "⋯".to_string(),
354            position: Point::new(x, y + 2.0),
355            color: self.style.line_number_color,
356            size: ctx.font_size.into(),
357            font: ctx.font,
358            ..canvas::Text::default()
359        });
360    }
361
362    /// Draws the background highlight for the current line.
363    ///
364    /// # Arguments
365    ///
366    /// * `frame` - The canvas frame to draw on
367    /// * `ctx` - Rendering context containing visual lines and metrics
368    /// * `visual_line` - The visual line to check
369    /// * `y` - Y position for rendering
370    fn draw_current_line_highlight(
371        &self,
372        frame: &mut canvas::Frame,
373        ctx: &RenderContext,
374        visual_line: &VisualLine,
375        y: f32,
376    ) {
377        if self.cursors.iter().any(|c| c.position.0 == visual_line.logical_line)
378        {
379            frame.fill_rectangle(
380                Point::new(ctx.gutter_width, y),
381                Size::new(ctx.bounds_width - ctx.gutter_width, ctx.line_height),
382                self.style.current_line_highlight,
383            );
384        }
385    }
386
387    /// Returns the memoized syntax-highlighted spans for a logical line.
388    ///
389    /// Highlighting is performed sequentially: lines `0..=logical_line` are
390    /// tokenized in order, each resuming from the syntect state left by the
391    /// previous line, so multi-line constructs (block comments, multi-line
392    /// strings) are colored correctly. The result is stored as a dense valid
393    /// prefix in [`HighlightCache`] and reused across wrapped visual segments
394    /// and across renders; an edit truncates the prefix from the changed line
395    /// (see [`CodeEditor::invalidate_highlight_from`]) instead of clearing it,
396    /// so deep lines are not re-parsed from the top on every keystroke. The
397    /// cache is reset only when the active syntax changes.
398    ///
399    /// # Arguments
400    ///
401    /// * `logical_line` - Index of the logical line in the buffer.
402    /// * `syntax` - The syntect syntax definition to tokenize with.
403    /// * `theme` - The syntect highlighting theme providing token colors.
404    /// * `syntax_set` - The syntax set the `syntax` belongs to.
405    ///
406    /// # Returns
407    ///
408    /// A shared handle to the line's colored token spans.
409    fn highlighted_line_cached(
410        &self,
411        logical_line: usize,
412        syntax: &syntect::parsing::SyntaxReference,
413        theme: &syntect::highlighting::Theme,
414        syntax_set: &SyntaxSet,
415    ) -> Rc<Vec<(Color, String)>> {
416        let mut guard = self.highlight_cache.borrow_mut();
417
418        // Reset the whole cache only when the active syntax changes.
419        let needs_reset =
420            guard.as_ref().is_none_or(|cache| cache.syntax() != self.syntax);
421        if needs_reset {
422            *guard = Some(super::HighlightCache::new(self.syntax.clone()));
423        }
424
425        let Some(cache) = guard.as_mut() else {
426            // Unreachable: populated just above. `unwrap`/`panic` are denied,
427            // so fall back to a single independent highlight without caching.
428            return Rc::new(highlight_line_spans(
429                self.buffer.line(logical_line),
430                syntax,
431                theme,
432                syntax_set,
433            ));
434        };
435
436        if let Some(spans) = cache.spans(logical_line) {
437            return spans;
438        }
439
440        // Extend the valid prefix sequentially up to `logical_line`, carrying
441        // the parser/highlight state forward across lines.
442        let highlighter = Highlighter::new(theme);
443        let (mut parse_state, mut highlight_state) =
444            cache.resume_state().unwrap_or_else(|| {
445                (
446                    ParseState::new(syntax),
447                    HighlightState::new(&highlighter, ScopeStack::new()),
448                )
449            });
450
451        let line_count = self.buffer.line_count();
452        let target = logical_line.min(line_count.saturating_sub(1));
453        let missing_lines =
454            target.saturating_add(1).saturating_sub(cache.valid_len());
455        let lines_to_parse =
456            missing_lines.min(self.highlight_lines_remaining.get());
457        let parse_end = cache
458            .valid_len()
459            .saturating_add(lines_to_parse)
460            .saturating_sub(1)
461            .min(target);
462        let mut result = None;
463        if lines_to_parse > 0 {
464            for index in cache.valid_len()..=parse_end {
465                // syntect's `_newlines` syntaxes expect a trailing '\n' for correct
466                // end-of-line context handling; the stored buffer line has none.
467                let mut line = self.buffer.line(index).to_string();
468                line.push('\n');
469
470                let ops = parse_state
471                    .parse_line(&line, syntax_set)
472                    .unwrap_or_default();
473                let spans: Vec<(Color, String)> = HighlightIterator::new(
474                    &mut highlight_state,
475                    &ops,
476                    &line,
477                    &highlighter,
478                )
479                .filter_map(|(style, text)| {
480                    let text = text.strip_suffix('\n').unwrap_or(text);
481                    if text.is_empty() {
482                        None
483                    } else {
484                        Some((color_from_style(style), text.to_string()))
485                    }
486                })
487                .collect();
488
489                let spans = Rc::new(spans);
490                cache.push_line(
491                    Rc::clone(&spans),
492                    parse_state.clone(),
493                    highlight_state.clone(),
494                );
495                if index == logical_line {
496                    result = Some(spans);
497                }
498            }
499        }
500        self.highlight_lines_remaining.set(
501            self.highlight_lines_remaining.get().saturating_sub(lines_to_parse),
502        );
503
504        result.or_else(|| cache.spans(logical_line)).unwrap_or_else(|| {
505            Rc::new(vec![(
506                self.style.text_color,
507                self.buffer.line(logical_line).to_string(),
508            )])
509        })
510    }
511
512    /// Draws text content with syntax highlighting or plain text fallback.
513    ///
514    /// # Arguments
515    ///
516    /// * `frame` - The canvas frame to draw on
517    /// * `ctx` - Rendering context containing visual lines and metrics
518    /// * `visual_line` - The visual line to render
519    /// * `y` - Y position for rendering
520    /// * `syntax_ref` - Optional syntax reference for highlighting
521    /// * `syntax_set` - Syntax set for highlighting
522    /// * `syntax_theme` - Theme for syntax highlighting
523    #[allow(clippy::too_many_arguments)]
524    fn draw_text_with_syntax_highlighting(
525        &self,
526        frame: &mut canvas::Frame,
527        ctx: &RenderContext,
528        visual_line: &VisualLine,
529        y: f32,
530        syntax_ref: Option<&syntect::parsing::SyntaxReference>,
531        syntax_set: &SyntaxSet,
532        syntax_theme: Option<&syntect::highlighting::Theme>,
533    ) {
534        if let (Some(syntax), Some(syntax_theme)) = (syntax_ref, syntax_theme) {
535            // Reuse the memoized full-line spans; only the visible segment of
536            // the (possibly wrapped) line is positioned and drawn here.
537            let spans = self.highlighted_line_cached(
538                visual_line.logical_line,
539                syntax,
540                syntax_theme,
541                syntax_set,
542            );
543
544            let mut x_offset =
545                ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset;
546            let mut char_pos = 0;
547
548            for (color, text) in spans.iter() {
549                let text_len = text.chars().count();
550                let text_end = char_pos + text_len;
551
552                // Check if this token intersects with our segment
553                if text_end > visual_line.start_col
554                    && char_pos < visual_line.end_col
555                {
556                    // Calculate the intersection
557                    let segment_start = char_pos.max(visual_line.start_col);
558                    let segment_end = text_end.min(visual_line.end_col);
559
560                    let text_start_offset =
561                        segment_start.saturating_sub(char_pos);
562                    let text_end_offset =
563                        text_start_offset + (segment_end - segment_start);
564
565                    let (start_byte, end_byte) = char_range_to_byte_range(
566                        text,
567                        text_start_offset,
568                        text_end_offset,
569                    );
570
571                    let segment_text = &text[start_byte..end_byte];
572                    let display_text = if self.show_whitespace {
573                        expand_tabs_visible(segment_text, super::TAB_WIDTH)
574                    } else {
575                        expand_tabs(segment_text, super::TAB_WIDTH).into_owned()
576                    };
577                    let display_width = measure_text_width(
578                        &display_text,
579                        ctx.full_char_width,
580                        ctx.char_width,
581                    );
582
583                    if self.show_whitespace {
584                        let ws_color = self.style.whitespace_color;
585                        let mut seg_x = x_offset;
586                        for (is_ws, seg) in
587                            split_whitespace_segments(&display_text)
588                        {
589                            let seg_color =
590                                if is_ws { ws_color } else { *color };
591                            let seg_width = measure_text_width(
592                                seg,
593                                ctx.full_char_width,
594                                ctx.char_width,
595                            );
596                            frame.fill_text(canvas::Text {
597                                content: seg.to_string(),
598                                position: Point::new(seg_x, y + 2.0),
599                                color: seg_color,
600                                size: ctx.font_size.into(),
601                                font: ctx.font,
602                                ..canvas::Text::default()
603                            });
604                            seg_x += seg_width;
605                        }
606                    } else {
607                        frame.fill_text(canvas::Text {
608                            content: display_text,
609                            position: Point::new(x_offset, y + 2.0),
610                            color: *color,
611                            size: ctx.font_size.into(),
612                            font: ctx.font,
613                            ..canvas::Text::default()
614                        });
615                    }
616
617                    x_offset += display_width;
618                }
619
620                char_pos = text_end;
621            }
622        } else {
623            // Fallback to plain text
624            let full_line_content = self.buffer.line(visual_line.logical_line);
625            let (start_byte, end_byte) = char_range_to_byte_range(
626                full_line_content,
627                visual_line.start_col,
628                visual_line.end_col,
629            );
630            let line_segment = &full_line_content[start_byte..end_byte];
631            let display_text = if self.show_whitespace {
632                expand_tabs_visible(line_segment, super::TAB_WIDTH)
633            } else {
634                expand_tabs(line_segment, super::TAB_WIDTH).into_owned()
635            };
636            let base_x = ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset;
637            if self.show_whitespace {
638                let ws_color = self.style.whitespace_color;
639                let text_color = self.style.text_color;
640                let mut seg_x = base_x;
641                for (is_ws, seg) in split_whitespace_segments(&display_text) {
642                    let seg_color = if is_ws { ws_color } else { text_color };
643                    let seg_width = measure_text_width(
644                        seg,
645                        ctx.full_char_width,
646                        ctx.char_width,
647                    );
648                    frame.fill_text(canvas::Text {
649                        content: seg.to_string(),
650                        position: Point::new(seg_x, y + 2.0),
651                        color: seg_color,
652                        size: ctx.font_size.into(),
653                        font: ctx.font,
654                        ..canvas::Text::default()
655                    });
656                    seg_x += seg_width;
657                }
658            } else {
659                frame.fill_text(canvas::Text {
660                    content: display_text,
661                    position: Point::new(base_x, y + 2.0),
662                    color: self.style.text_color,
663                    size: ctx.font_size.into(),
664                    font: ctx.font,
665                    ..canvas::Text::default()
666                });
667            }
668        }
669    }
670
671    /// Fills a single highlight rectangle for a column range within one visual
672    /// line.
673    ///
674    /// Computes the CJK-aware segment geometry, applies the horizontal scroll
675    /// offset, and draws the rectangle inset vertically to match the editor's
676    /// highlight styling. Shared by selection and search-match rendering.
677    ///
678    /// # Arguments
679    ///
680    /// * `frame` - The canvas frame to draw on
681    /// * `ctx` - Rendering context containing visual lines and metrics
682    /// * `visual_idx` - Index of the visual line being drawn (drives the Y position)
683    /// * `vl` - The visual line whose segment is highlighted
684    /// * `cols` - Inclusive start and exclusive end columns of the segment
685    /// * `color` - Fill color of the highlight rectangle
686    fn fill_highlight_segment(
687        &self,
688        frame: &mut canvas::Frame,
689        ctx: &RenderContext,
690        visual_idx: usize,
691        vl: &VisualLine,
692        cols: (usize, usize),
693        color: Color,
694    ) {
695        let y = visual_idx as f32 * ctx.line_height;
696        let line_content = self.buffer.line(vl.logical_line);
697        let (x_start, width) = calculate_segment_geometry(
698            line_content,
699            vl.start_col,
700            cols.0,
701            cols.1,
702            ctx.gutter_width + 5.0,
703            ctx.full_char_width,
704            ctx.char_width,
705        );
706        let x_start = x_start - ctx.horizontal_scroll_offset;
707        frame.fill_rectangle(
708            Point::new(x_start, y + 2.0),
709            Size::new(width, ctx.line_height - 4.0),
710            color,
711        );
712    }
713
714    /// Draws search match highlights for all visible matches.
715    ///
716    /// # Arguments
717    ///
718    /// * `frame` - The canvas frame to draw on
719    /// * `ctx` - Rendering context containing visual lines and metrics
720    /// * `first_visible_line` - First visible visual line index
721    /// * `last_visible_line` - Last visible visual line index
722    fn draw_search_highlights(
723        &self,
724        frame: &mut canvas::Frame,
725        ctx: &RenderContext,
726        start_visual_idx: usize,
727        end_visual_idx: usize,
728    ) {
729        if !self.search_matches_visible() || self.search_state.query.is_empty()
730        {
731            return;
732        }
733
734        let query_len = self.search_state.query.chars().count();
735
736        let start_visual_idx = start_visual_idx.min(ctx.visual_lines.len());
737        let end_visual_idx = end_visual_idx.min(ctx.visual_lines.len());
738
739        let end_visual_inclusive = end_visual_idx
740            .saturating_sub(1)
741            .min(ctx.visual_lines.len().saturating_sub(1));
742
743        if let (Some(start_vl), Some(end_vl)) = (
744            ctx.visual_lines.get(start_visual_idx),
745            ctx.visual_lines.get(end_visual_inclusive),
746        ) {
747            let min_logical_line = start_vl.logical_line;
748            let max_logical_line = end_vl.logical_line;
749
750            // Optimization: Use get_visible_match_range to find matches in view
751            // This uses binary search + early termination for O(log N) performance
752            let match_range = super::search::get_visible_match_range(
753                &self.search_state.matches,
754                min_logical_line,
755                max_logical_line,
756            );
757
758            for (match_idx, search_match) in self
759                .search_state
760                .matches
761                .iter()
762                .enumerate()
763                .skip(match_range.start)
764                .take(match_range.len())
765            {
766                // Determine if this is the current match
767                let is_current =
768                    self.search_state.current_match_index == Some(match_idx);
769
770                let highlight_color = if is_current {
771                    // Orange for current match
772                    Color { r: 1.0, g: 0.6, b: 0.0, a: 0.4 }
773                } else {
774                    // Yellow for other matches
775                    Color { r: 1.0, g: 1.0, b: 0.0, a: 0.3 }
776                };
777
778                // Convert logical position to visual line
779                let start_visual = WrappingCalculator::logical_to_visual(
780                    ctx.visual_lines,
781                    search_match.line,
782                    search_match.col,
783                );
784                let end_visual = WrappingCalculator::logical_to_visual(
785                    ctx.visual_lines,
786                    search_match.line,
787                    search_match.col + query_len,
788                );
789
790                if let (Some(start_v), Some(end_v)) = (start_visual, end_visual)
791                {
792                    if start_v == end_v {
793                        // Match within same visual line
794                        let vl = &ctx.visual_lines[start_v];
795                        self.fill_highlight_segment(
796                            frame,
797                            ctx,
798                            start_v,
799                            vl,
800                            (search_match.col, search_match.col + query_len),
801                            highlight_color,
802                        );
803                    } else {
804                        // Match spans multiple visual lines
805                        for (v_idx, vl) in ctx
806                            .visual_lines
807                            .iter()
808                            .enumerate()
809                            .skip(start_v)
810                            .take(end_v - start_v + 1)
811                        {
812                            let sel_start_col = if v_idx == start_v {
813                                search_match.col
814                            } else {
815                                vl.start_col
816                            };
817                            let sel_end_col = if v_idx == end_v {
818                                search_match.col + query_len
819                            } else {
820                                vl.end_col
821                            };
822
823                            self.fill_highlight_segment(
824                                frame,
825                                ctx,
826                                v_idx,
827                                vl,
828                                (sel_start_col, sel_end_col),
829                                highlight_color,
830                            );
831                        }
832                    }
833                }
834            }
835        }
836    }
837
838    /// Draws the selection highlight for a single cursor range.
839    ///
840    /// # Arguments
841    ///
842    /// * `frame` - The canvas frame to draw on
843    /// * `ctx` - Rendering context containing visual lines and metrics
844    /// * `start` - Selection start (line, col)
845    /// * `end` - Selection end (line, col), must be >= start
846    fn draw_single_selection(
847        &self,
848        frame: &mut canvas::Frame,
849        ctx: &RenderContext,
850        start: (usize, usize),
851        end: (usize, usize),
852    ) {
853        let selection_color = Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 };
854
855        if start.0 == end.0 {
856            // Single line selection - need to handle wrapped segments
857            let start_visual = WrappingCalculator::logical_to_visual(
858                ctx.visual_lines,
859                start.0,
860                start.1,
861            );
862            let end_visual = WrappingCalculator::logical_to_visual(
863                ctx.visual_lines,
864                end.0,
865                end.1,
866            );
867
868            if let (Some(start_v), Some(end_v)) = (start_visual, end_visual) {
869                if start_v == end_v {
870                    // Selection within same visual line
871                    let vl = &ctx.visual_lines[start_v];
872                    self.fill_highlight_segment(
873                        frame,
874                        ctx,
875                        start_v,
876                        vl,
877                        (start.1, end.1),
878                        selection_color,
879                    );
880                } else {
881                    // Selection spans multiple visual lines (same logical line)
882                    for (v_idx, vl) in ctx
883                        .visual_lines
884                        .iter()
885                        .enumerate()
886                        .skip(start_v)
887                        .take(end_v - start_v + 1)
888                    {
889                        let sel_start_col = if v_idx == start_v {
890                            start.1
891                        } else {
892                            vl.start_col
893                        };
894                        let sel_end_col =
895                            if v_idx == end_v { end.1 } else { vl.end_col };
896
897                        self.fill_highlight_segment(
898                            frame,
899                            ctx,
900                            v_idx,
901                            vl,
902                            (sel_start_col, sel_end_col),
903                            selection_color,
904                        );
905                    }
906                }
907            }
908        } else {
909            // Multi-line selection
910            let start_visual = WrappingCalculator::logical_to_visual(
911                ctx.visual_lines,
912                start.0,
913                start.1,
914            );
915            let end_visual = WrappingCalculator::logical_to_visual(
916                ctx.visual_lines,
917                end.0,
918                end.1,
919            );
920
921            if let (Some(start_v), Some(end_v)) = (start_visual, end_visual) {
922                for (v_idx, vl) in ctx
923                    .visual_lines
924                    .iter()
925                    .enumerate()
926                    .skip(start_v)
927                    .take(end_v - start_v + 1)
928                {
929                    let sel_start_col =
930                        if vl.logical_line == start.0 && v_idx == start_v {
931                            start.1
932                        } else {
933                            vl.start_col
934                        };
935
936                    let sel_end_col =
937                        if vl.logical_line == end.0 && v_idx == end_v {
938                            end.1
939                        } else {
940                            vl.end_col
941                        };
942
943                    self.fill_highlight_segment(
944                        frame,
945                        ctx,
946                        v_idx,
947                        vl,
948                        (sel_start_col, sel_end_col),
949                        selection_color,
950                    );
951                }
952            }
953        }
954    }
955
956    /// Draws text selection highlights for all cursors.
957    ///
958    /// # Arguments
959    ///
960    /// * `frame` - The canvas frame to draw on
961    /// * `ctx` - Rendering context containing visual lines and metrics
962    fn draw_selection_highlight(
963        &self,
964        frame: &mut canvas::Frame,
965        ctx: &RenderContext,
966    ) {
967        for cursor in self.cursors.iter() {
968            if let Some((start, end)) = cursor.selection_range()
969                && start != end
970            {
971                self.draw_single_selection(frame, ctx, start, end);
972            }
973        }
974    }
975
976    /// Draws the cursor (normal caret or IME preedit cursor).
977    ///
978    /// # Arguments
979    ///
980    /// * `frame` - The canvas frame to draw on
981    /// * `ctx` - Rendering context containing visual lines and metrics
982    fn draw_cursor(&self, frame: &mut canvas::Frame, ctx: &RenderContext) {
983        // Cursor drawing logic (only when the editor has focus)
984        // -------------------------------------------------------------------------
985        // Core notes:
986        // 1. Choose the drawing path based on whether IME preedit is present.
987        // 2. Require both `is_focused()` (Iced focus) and `has_canvas_focus()` (internal focus)
988        //    so the cursor is drawn only in the active editor, avoiding multiple cursors.
989        // 3. Use `WrappingCalculator` to map logical (line, col) to visual (x, y)
990        //    for correct cursor positioning with line wrapping.
991        // -------------------------------------------------------------------------
992        if self.show_cursor
993            && self.cursor_visible
994            && self.has_focus()
995            && self.ime_preedit.is_some()
996        {
997            // [Branch A] IME preedit rendering mode
998            // ---------------------------------------------------------------------
999            // When the user is composing with an IME (e.g. pinyin before commit),
1000            // draw a preedit region instead of the normal caret, including:
1001            // - preedit background (highlighting the composing text)
1002            // - preedit text content (preedit.content)
1003            // - preedit selection (underline or selection background)
1004            // - preedit caret
1005            // ---------------------------------------------------------------------
1006            if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
1007                ctx.visual_lines,
1008                self.cursors.primary_position().0,
1009                self.cursors.primary_position().1,
1010            ) {
1011                let vl = &ctx.visual_lines[cursor_visual];
1012                let line_content = self.buffer.line(vl.logical_line);
1013
1014                // Compute the preedit region start X
1015                // Use calculate_segment_geometry to ensure correct CJK width handling
1016                let (cursor_x_content, _) = calculate_segment_geometry(
1017                    line_content,
1018                    vl.start_col,
1019                    self.cursors.primary_position().1,
1020                    self.cursors.primary_position().1,
1021                    ctx.gutter_width + 5.0,
1022                    ctx.full_char_width,
1023                    ctx.char_width,
1024                );
1025                let cursor_x = cursor_x_content - ctx.horizontal_scroll_offset;
1026                let cursor_y = cursor_visual as f32 * ctx.line_height;
1027
1028                if let Some(preedit) = self.ime_preedit.as_ref() {
1029                    let preedit_width = measure_text_width(
1030                        &preedit.content,
1031                        ctx.full_char_width,
1032                        ctx.char_width,
1033                    );
1034
1035                    // 1. Draw preedit background (light translucent)
1036                    // This indicates the text is not committed yet
1037                    frame.fill_rectangle(
1038                        Point::new(cursor_x, cursor_y + 2.0),
1039                        Size::new(preedit_width, ctx.line_height - 4.0),
1040                        Color { r: 1.0, g: 1.0, b: 1.0, a: 0.08 },
1041                    );
1042
1043                    // 2. Draw preedit selection (if any)
1044                    // IME may mark a selection inside preedit text (e.g. segmentation)
1045                    // The range uses UTF-8 byte indices, so slices must be safe
1046                    if let Some(range) = preedit.selection.as_ref()
1047                        && range.start != range.end
1048                    {
1049                        // Validate indices before slicing to prevent panic
1050                        if let Some((start, end)) = validate_selection_indices(
1051                            &preedit.content,
1052                            range.start,
1053                            range.end,
1054                        ) {
1055                            let selected_prefix = &preedit.content[..start];
1056                            let selected_text = &preedit.content[start..end];
1057
1058                            let selection_x = cursor_x
1059                                + measure_text_width(
1060                                    selected_prefix,
1061                                    ctx.full_char_width,
1062                                    ctx.char_width,
1063                                );
1064                            let selection_w = measure_text_width(
1065                                selected_text,
1066                                ctx.full_char_width,
1067                                ctx.char_width,
1068                            );
1069
1070                            frame.fill_rectangle(
1071                                Point::new(selection_x, cursor_y + 2.0),
1072                                Size::new(selection_w, ctx.line_height - 4.0),
1073                                Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 },
1074                            );
1075                        }
1076                    }
1077
1078                    // 3. Draw preedit text itself
1079                    frame.fill_text(canvas::Text {
1080                        content: preedit.content.clone(),
1081                        position: Point::new(cursor_x, cursor_y + 2.0),
1082                        color: self.style.text_color,
1083                        size: ctx.font_size.into(),
1084                        font: ctx.font,
1085                        ..canvas::Text::default()
1086                    });
1087
1088                    // 4. Draw bottom underline (IME state indicator)
1089                    frame.fill_rectangle(
1090                        Point::new(cursor_x, cursor_y + ctx.line_height - 3.0),
1091                        Size::new(preedit_width, 1.0),
1092                        self.style.text_color,
1093                    );
1094
1095                    // 5. Draw preedit caret
1096                    // If IME provides a caret position (usually selection end), draw a thin bar
1097                    if let Some(range) = preedit.selection.as_ref() {
1098                        let caret_end = range.end.min(preedit.content.len());
1099
1100                        // Validate caret position to avoid panic on invalid UTF-8 boundary
1101                        if caret_end <= preedit.content.len()
1102                            && preedit.content.is_char_boundary(caret_end)
1103                        {
1104                            let caret_prefix = &preedit.content[..caret_end];
1105                            let caret_x = cursor_x
1106                                + measure_text_width(
1107                                    caret_prefix,
1108                                    ctx.full_char_width,
1109                                    ctx.char_width,
1110                                );
1111
1112                            frame.fill_rectangle(
1113                                Point::new(caret_x, cursor_y + 2.0),
1114                                Size::new(2.0, ctx.line_height - 4.0),
1115                                self.style.text_color,
1116                            );
1117                        }
1118                    }
1119                }
1120            }
1121        } else if self.show_cursor && self.cursor_visible && self.has_focus() {
1122            // [Branch B] Normal caret rendering mode
1123            // ---------------------------------------------------------------------
1124            // Vim mode is single-cursor and Visual selections use an inclusive
1125            // active position that differs from the editor's half-open cursor.
1126            // Standard editing continues to render every cursor in the set.
1127            // ---------------------------------------------------------------------
1128            if self.vim_enabled {
1129                let position = self
1130                    .vim_state
1131                    .visual_positions()
1132                    .map(|(_, active)| active)
1133                    .unwrap_or_else(|| self.cursors.primary_position());
1134                self.draw_single_caret(frame, ctx, position);
1135            } else {
1136                for cursor in self.cursors.iter() {
1137                    self.draw_single_caret(frame, ctx, cursor.position);
1138                }
1139            }
1140        }
1141    }
1142
1143    /// Returns the cursor size for a logical position using current font metrics.
1144    ///
1145    /// Standard editing and Vim Insert mode use the existing 2px bar. Vim
1146    /// Normal and Visual modes use the width of the character under the cursor;
1147    /// an empty line or end-of-line position uses one narrow character width.
1148    fn cursor_size_for_position(&self, position: (usize, usize)) -> Size {
1149        let uses_block =
1150            self.vim_enabled && self.vim_state.mode() != super::VimMode::Insert;
1151        let width = if uses_block {
1152            self.buffer
1153                .line(position.0)
1154                .chars()
1155                .nth(position.1)
1156                .map(|ch| {
1157                    measure_char_width(
1158                        ch,
1159                        self.full_char_width,
1160                        self.char_width,
1161                    )
1162                })
1163                .filter(|width| *width > 0.0)
1164                .unwrap_or(self.char_width)
1165        } else {
1166            2.0
1167        };
1168
1169        Size::new(width, (self.line_height - 4.0).max(1.0))
1170    }
1171
1172    /// Draws one cursor at the given logical (line, col) position.
1173    ///
1174    /// # Arguments
1175    ///
1176    /// * `frame` - The canvas frame to draw on
1177    /// * `ctx` - Rendering context containing visual lines and metrics
1178    /// * `position` - Logical cursor position (line, col)
1179    fn draw_single_caret(
1180        &self,
1181        frame: &mut canvas::Frame,
1182        ctx: &RenderContext,
1183        position: (usize, usize),
1184    ) {
1185        // Map logical cursor position (line, col) to visual line index
1186        if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
1187            ctx.visual_lines,
1188            position.0,
1189            position.1,
1190        ) {
1191            let vl = &ctx.visual_lines[cursor_visual];
1192            let line_content = self.buffer.line(vl.logical_line);
1193
1194            // Compute exact caret X position
1195            let (cursor_x_content, _) = calculate_segment_geometry(
1196                line_content,
1197                vl.start_col,
1198                position.1,
1199                position.1,
1200                ctx.gutter_width + 5.0,
1201                ctx.full_char_width,
1202                ctx.char_width,
1203            );
1204            let cursor_x = cursor_x_content - ctx.horizontal_scroll_offset;
1205            let cursor_y = cursor_visual as f32 * ctx.line_height;
1206
1207            let cursor_size = self.cursor_size_for_position(position);
1208            let mut cursor_color = self.style.text_color;
1209            if cursor_size.width > 2.0 {
1210                cursor_color.a *= 0.55;
1211            }
1212
1213            frame.fill_rectangle(
1214                Point::new(cursor_x, cursor_y + 2.0),
1215                cursor_size,
1216                cursor_color,
1217            );
1218        }
1219    }
1220
1221    /// Checks if the editor has focus (both Iced focus and internal canvas focus).
1222    ///
1223    /// # Returns
1224    ///
1225    /// `true` if the editor has both Iced focus and internal canvas focus and is not focus-locked; `false` otherwise
1226    pub(crate) fn has_focus(&self) -> bool {
1227        // Check if this editor has Iced focus
1228        let focused_id =
1229            super::FOCUSED_EDITOR_ID.load(std::sync::atomic::Ordering::Relaxed);
1230        focused_id == self.editor_id
1231            && self.has_canvas_focus
1232            && !self.focus_locked
1233    }
1234
1235    /// Handles keyboard shortcut combinations (Ctrl+C, Ctrl+Z, etc.).
1236    ///
1237    /// This implementation includes focus chain management for Tab and Shift+Tab
1238    /// navigation between editors.
1239    ///
1240    /// # Arguments
1241    ///
1242    /// * `key` - The keyboard key that was pressed
1243    /// * `modifiers` - The keyboard modifiers (Ctrl, Shift, Alt, etc.)
1244    ///
1245    /// # Returns
1246    ///
1247    /// `Some(Action<Message>)` if a shortcut was matched, `None` otherwise
1248    fn handle_keyboard_shortcuts(
1249        &self,
1250        key: &keyboard::Key,
1251        modified_key: &keyboard::Key,
1252        modifiers: &keyboard::Modifiers,
1253    ) -> Option<Action<Message>> {
1254        // `command()` maps to Command on macOS and Control elsewhere. Keep
1255        // accepting Control on macOS for backwards compatibility.
1256        let command_pressed = modifiers.command() || modifiers.control();
1257
1258        // Toggle Vim behavior without conflicting with the platform paste
1259        // shortcut (Ctrl/Cmd+V).
1260        if command_pressed
1261            && modifiers.alt()
1262            && !modifiers.shift()
1263            && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v")
1264        {
1265            return Some(Action::publish(Message::ToggleVimMode).and_capture());
1266        }
1267
1268        // Handle Ctrl/Cmd+S through the same host-owned save request as Vim
1269        // `:w`.
1270        if command_pressed
1271            && !modifiers.alt()
1272            && !modifiers.shift()
1273            && matches!(key, keyboard::Key::Character(s) if s.as_str() == "s")
1274        {
1275            return Some(
1276                Action::publish(Message::WriteRequested).and_capture(),
1277            );
1278        }
1279
1280        // Shift+Tab: focus navigation backward (Tab alone inserts indentation)
1281        if matches!(key, keyboard::Key::Named(keyboard::key::Named::Tab))
1282            && modifiers.shift()
1283            && !self.search_state.is_open
1284        {
1285            return Some(
1286                Action::publish(Message::FocusNavigationShiftTab).and_capture(),
1287            );
1288        }
1289
1290        // Handle Ctrl+C / Ctrl+Insert (copy)
1291        if (command_pressed
1292            && matches!(key, keyboard::Key::Character(c) if c.as_str() == "c"))
1293            || (modifiers.control()
1294                && matches!(
1295                    key,
1296                    keyboard::Key::Named(keyboard::key::Named::Insert)
1297                ))
1298        {
1299            return Some(Action::publish(Message::Copy).and_capture());
1300        }
1301
1302        // Handle Ctrl/Cmd+X (cut)
1303        if command_pressed
1304            && matches!(key, keyboard::Key::Character(x) if x.as_str() == "x")
1305        {
1306            return Some(Action::publish(Message::Cut).and_capture());
1307        }
1308
1309        // Handle Ctrl/Cmd+A (select all)
1310        if command_pressed
1311            && matches!(key, keyboard::Key::Character(a) if a.as_str() == "a")
1312        {
1313            return Some(Action::publish(Message::SelectAll).and_capture());
1314        }
1315
1316        // Handle Ctrl/Cmd+Z (undo). Shift+Cmd+Z is redo on macOS.
1317        if command_pressed
1318            && !modifiers.shift()
1319            && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")
1320        {
1321            return Some(Action::publish(Message::Undo).and_capture());
1322        }
1323
1324        // Handle Ctrl/Cmd+Y and Shift+Cmd+Z (redo)
1325        if command_pressed
1326            && (matches!(key, keyboard::Key::Character(y) if y.as_str() == "y")
1327                || (modifiers.shift()
1328                    && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")))
1329        {
1330            return Some(Action::publish(Message::Redo).and_capture());
1331        }
1332
1333        // Vim's redo binding is Ctrl+R in Normal mode. Keep the existing
1334        // platform redo shortcuts above available in every editor mode.
1335        if self.vim_enabled
1336            && self.vim_state.mode() == super::VimMode::Normal
1337            && modifiers.control()
1338            && !modifiers.shift()
1339            && matches!(key, keyboard::Key::Character(r) if r.as_str() == "r")
1340        {
1341            return Some(Action::publish(Message::Redo).and_capture());
1342        }
1343
1344        // Handle Ctrl+F (open search)
1345        if command_pressed
1346            && matches!(key, keyboard::Key::Character(f) if f.as_str() == "f")
1347            && self.search_replace_enabled
1348        {
1349            return Some(Action::publish(Message::OpenSearch).and_capture());
1350        }
1351
1352        // Handle Ctrl+H (open search and replace)
1353        if command_pressed
1354            && matches!(key, keyboard::Key::Character(h) if h.as_str() == "h")
1355            && self.search_replace_enabled
1356        {
1357            return Some(
1358                Action::publish(Message::OpenSearchReplace).and_capture(),
1359            );
1360        }
1361
1362        // Handle Cmd/Ctrl+G (open go-to-line input)
1363        if command_pressed
1364            && matches!(key, keyboard::Key::Character(g) if g.as_str() == "g")
1365        {
1366            return Some(Action::publish(Message::OpenGotoLine).and_capture());
1367        }
1368
1369        // Handle Escape — close the active overlay, or collapse multi-cursor.
1370        if matches!(key, keyboard::Key::Named(keyboard::key::Named::Escape)) {
1371            let message = if self.goto_line_state.is_open {
1372                Message::CloseGotoLine
1373            } else if self.search_state.is_open {
1374                Message::CloseSearch
1375            } else if self.vim_enabled {
1376                Message::VimKey('\u{1b}')
1377            } else {
1378                Message::CloseSearch
1379            };
1380            return Some(Action::publish(message).and_capture());
1381        }
1382
1383        // Handle Ctrl+D (select next occurrence)
1384        if command_pressed
1385            && matches!(key, keyboard::Key::Character(d) if d.as_str() == "d")
1386        {
1387            return Some(
1388                Action::publish(Message::SelectNextOccurrence).and_capture(),
1389            );
1390        }
1391
1392        // Handle Ctrl+/ (toggle line comment).
1393        //
1394        // Match against both the base key and `modified_key` so the shortcut
1395        // works regardless of layout: on US/QWERTY `/` is unshifted (in `key`),
1396        // while on French AZERTY it is Shift+`:` and only appears in
1397        // `modified_key`.
1398        if command_pressed
1399            && (matches!(key, keyboard::Key::Character(c) if c.as_str() == "/")
1400                || matches!(modified_key, keyboard::Key::Character(c) if c.as_str() == "/"))
1401        {
1402            return Some(Action::publish(Message::ToggleComment).and_capture());
1403        }
1404
1405        // Handle Ctrl+Alt+Up (add cursor above)
1406        if modifiers.control()
1407            && modifiers.alt()
1408            && matches!(
1409                key,
1410                keyboard::Key::Named(keyboard::key::Named::ArrowUp)
1411            )
1412        {
1413            return Some(
1414                Action::publish(Message::AddCursorAbove).and_capture(),
1415            );
1416        }
1417
1418        // Handle Ctrl+Alt+Down (add cursor below)
1419        if modifiers.control()
1420            && modifiers.alt()
1421            && matches!(
1422                key,
1423                keyboard::Key::Named(keyboard::key::Named::ArrowDown)
1424            )
1425        {
1426            return Some(
1427                Action::publish(Message::AddCursorBelow).and_capture(),
1428            );
1429        }
1430
1431        // Handle Alt+Up / Alt+Down (move line) and Shift+Alt+Up / Shift+Alt+Down
1432        // (duplicate line). Exclude Control to avoid clashing with the
1433        // Ctrl+Alt+Up/Down multi-cursor shortcuts above.
1434        if modifiers.alt() && !modifiers.control() {
1435            if matches!(
1436                key,
1437                keyboard::Key::Named(keyboard::key::Named::ArrowUp)
1438            ) {
1439                let message = if modifiers.shift() {
1440                    Message::DuplicateLineUp
1441                } else {
1442                    Message::MoveLineUp
1443                };
1444                return Some(Action::publish(message).and_capture());
1445            }
1446            if matches!(
1447                key,
1448                keyboard::Key::Named(keyboard::key::Named::ArrowDown)
1449            ) {
1450                let message = if modifiers.shift() {
1451                    Message::DuplicateLineDown
1452                } else {
1453                    Message::MoveLineDown
1454                };
1455                return Some(Action::publish(message).and_capture());
1456            }
1457        }
1458
1459        // Handle Tab (cycle forward in search dialog if open)
1460        if matches!(key, keyboard::Key::Named(keyboard::key::Named::Tab))
1461            && self.search_state.is_open
1462        {
1463            if modifiers.shift() {
1464                // Shift+Tab: cycle backward
1465                return Some(
1466                    Action::publish(Message::SearchDialogShiftTab)
1467                        .and_capture(),
1468                );
1469            } else {
1470                // Tab: cycle forward
1471                return Some(
1472                    Action::publish(Message::SearchDialogTab).and_capture(),
1473                );
1474            }
1475        }
1476
1477        // Handle F3 (find next) and Shift+F3 (find previous)
1478        if matches!(key, keyboard::Key::Named(keyboard::key::Named::F3))
1479            && self.search_replace_enabled
1480        {
1481            if modifiers.shift() {
1482                return Some(
1483                    Action::publish(Message::FindPrevious).and_capture(),
1484                );
1485            } else {
1486                return Some(Action::publish(Message::FindNext).and_capture());
1487            }
1488        }
1489
1490        // Handle Ctrl+V / Shift+Insert (paste) - read clipboard and send paste message
1491        if (command_pressed
1492            && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v"))
1493            || (modifiers.shift()
1494                && matches!(
1495                    key,
1496                    keyboard::Key::Named(keyboard::key::Named::Insert)
1497                ))
1498        {
1499            // Return an action that requests clipboard read
1500            return Some(Action::publish(Message::Paste(String::new())));
1501        }
1502
1503        // Handle Ctrl+Home (go to start of document)
1504        if command_pressed
1505            && matches!(key, keyboard::Key::Named(keyboard::key::Named::Home))
1506        {
1507            return Some(Action::publish(Message::CtrlHome).and_capture());
1508        }
1509
1510        // Handle Ctrl+End (go to end of document)
1511        if command_pressed
1512            && matches!(key, keyboard::Key::Named(keyboard::key::Named::End))
1513        {
1514            return Some(Action::publish(Message::CtrlEnd).and_capture());
1515        }
1516
1517        // Handle Shift+Delete (delete selection)
1518        if modifiers.shift()
1519            && matches!(key, keyboard::Key::Named(keyboard::key::Named::Delete))
1520        {
1521            return Some(
1522                Action::publish(Message::DeleteSelection).and_capture(),
1523            );
1524        }
1525
1526        // Code folding shortcuts (only when folding is enabled).
1527        if self.folding_enabled {
1528            // Ctrl+. : toggle the fold of the block at the cursor.
1529            if modifiers.control()
1530                && matches!(key, keyboard::Key::Character(c) if c.as_str() == ".")
1531            {
1532                return Some(
1533                    Action::publish(Message::ToggleFoldAtCursor).and_capture(),
1534                );
1535            }
1536
1537            // Ctrl+K : fold all blocks.
1538            if modifiers.control()
1539                && !modifiers.shift()
1540                && matches!(key, keyboard::Key::Character(c) if c.as_str() == "k")
1541            {
1542                return Some(Action::publish(Message::FoldAll).and_capture());
1543            }
1544
1545            // Ctrl+J : unfold all blocks.
1546            if modifiers.control()
1547                && !modifiers.shift()
1548                && matches!(key, keyboard::Key::Character(c) if c.as_str() == "j")
1549            {
1550                return Some(Action::publish(Message::UnfoldAll).and_capture());
1551            }
1552        }
1553
1554        None
1555    }
1556
1557    fn printable_input_message(&self, ch: char) -> Message {
1558        if self.vim_enabled && self.vim_state.mode() != super::VimMode::Insert {
1559            Message::VimKey(ch)
1560        } else {
1561            Message::CharacterInput(ch)
1562        }
1563    }
1564
1565    /// Handles character input and special navigation keys.
1566    ///
1567    /// This implementation includes focus event propagation and focus chain management
1568    /// for proper focus handling without mouse bounds checking.
1569    ///
1570    /// # Arguments
1571    ///
1572    /// * `key` - The keyboard key that was pressed
1573    /// * `modifiers` - The keyboard modifiers (Ctrl, Shift, Alt, etc.)
1574    /// * `text` - Optional text content from the keyboard event
1575    ///
1576    /// # Returns
1577    ///
1578    /// `Some(Action<Message>)` if input should be processed, `None` otherwise
1579    #[allow(clippy::unused_self)]
1580    fn handle_character_input(
1581        &self,
1582        key: &keyboard::Key,
1583        modifiers: &keyboard::Modifiers,
1584        text: Option<&str>,
1585    ) -> Option<Action<Message>> {
1586        // Early exit: Only process character input when editor has focus
1587        // This prevents focus stealing where characters typed in other input fields
1588        // appear in the editor
1589        if !self.has_focus() {
1590            return None;
1591        }
1592
1593        // PRIORITY 1: Check if 'text' field has valid printable character
1594        // This handles:
1595        // - Numpad keys with NumLock ON (key=Named(ArrowDown), text=Some("2"))
1596        // - Regular typing with shift, accents, international layouts
1597        if let Some(text_content) = text
1598            && !text_content.is_empty()
1599            && !modifiers.control()
1600            && !modifiers.alt()
1601        {
1602            // Check if it's a printable character (not a control character)
1603            // This filters out Enter (\n), Tab (\t), Delete (U+007F), etc.
1604            if let Some(first_char) = text_content.chars().next()
1605                && !first_char.is_control()
1606            {
1607                return Some(
1608                    Action::publish(self.printable_input_message(first_char))
1609                        .and_capture(),
1610                );
1611            }
1612        }
1613
1614        // PRIORITY 2: Handle special named keys (navigation, editing)
1615        // These are only processed if text didn't contain a printable character
1616        let message = match key {
1617            keyboard::Key::Named(keyboard::key::Named::Backspace)
1618                if !self.vim_enabled
1619                    || self.vim_state.mode() == super::VimMode::Insert
1620                    || self.vim_state.command_line_active() =>
1621            {
1622                if self.vim_state.command_line_active() {
1623                    Some(Message::VimKey('\u{8}'))
1624                } else {
1625                    Some(Message::Backspace)
1626                }
1627            }
1628            keyboard::Key::Named(keyboard::key::Named::Delete)
1629                if !self.vim_enabled
1630                    || self.vim_state.mode() == super::VimMode::Insert =>
1631            {
1632                Some(Message::Delete)
1633            }
1634            keyboard::Key::Named(keyboard::key::Named::Enter)
1635                if !self.vim_enabled
1636                    || self.vim_state.mode() == super::VimMode::Insert
1637                    || self.vim_state.command_line_active() =>
1638            {
1639                if self.vim_state.command_line_active() {
1640                    Some(Message::VimKey('\n'))
1641                } else {
1642                    Some(Message::Enter)
1643                }
1644            }
1645            keyboard::Key::Named(keyboard::key::Named::Tab)
1646                if !self.vim_enabled
1647                    || self.vim_state.mode() == super::VimMode::Insert =>
1648            {
1649                // Handle Tab for focus navigation or text insertion
1650                // This implements focus event propagation and focus chain management
1651                if modifiers.shift() {
1652                    // Shift+Tab: focus navigation backward through widget hierarchy
1653                    Some(Message::FocusNavigationShiftTab)
1654                } else {
1655                    // Regular Tab: check if search dialog is open
1656                    if self.search_state.is_open {
1657                        Some(Message::SearchDialogTab)
1658                    } else {
1659                        // Insert 4 spaces for Tab when not in search dialog
1660                        Some(Message::Tab)
1661                    }
1662                }
1663            }
1664            keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
1665                Some(Message::ArrowKey(ArrowDirection::Up, modifiers.shift()))
1666            }
1667            keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
1668                Some(Message::ArrowKey(ArrowDirection::Down, modifiers.shift()))
1669            }
1670            keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
1671                Some(Message::ArrowKey(ArrowDirection::Left, modifiers.shift()))
1672            }
1673            keyboard::Key::Named(keyboard::key::Named::ArrowRight) => Some(
1674                Message::ArrowKey(ArrowDirection::Right, modifiers.shift()),
1675            ),
1676            keyboard::Key::Named(keyboard::key::Named::PageUp) => {
1677                Some(Message::PageUp)
1678            }
1679            keyboard::Key::Named(keyboard::key::Named::PageDown) => {
1680                Some(Message::PageDown)
1681            }
1682            keyboard::Key::Named(keyboard::key::Named::Home) => {
1683                Some(Message::Home(modifiers.shift()))
1684            }
1685            keyboard::Key::Named(keyboard::key::Named::End) => {
1686                Some(Message::End(modifiers.shift()))
1687            }
1688            // PRIORITY 3: Fallback to extracting from 'key' if text was empty/control char
1689            // This handles edge cases where text field is not populated
1690            _ => {
1691                if !modifiers.control()
1692                    && !modifiers.alt()
1693                    && let keyboard::Key::Character(c) = key
1694                    && !c.is_empty()
1695                {
1696                    return c
1697                        .chars()
1698                        .next()
1699                        .map(|ch| self.printable_input_message(ch))
1700                        .map(|msg| Action::publish(msg).and_capture());
1701                }
1702                None
1703            }
1704        };
1705
1706        message.map(|msg| Action::publish(msg).and_capture())
1707    }
1708
1709    /// Handles keyboard events with focus event propagation through widget hierarchy.
1710    ///
1711    /// This implementation completes focus handling without mouse bounds checking
1712    /// and ensures proper focus chain management.
1713    ///
1714    /// # Arguments
1715    ///
1716    /// * `key` - The keyboard key that was pressed (base key, no modifiers applied)
1717    /// * `modified_key` - The key with all modifiers applied except Ctrl; used
1718    ///   for character shortcuts so they work on layouts where the glyph needs
1719    ///   Shift (e.g. `/` on French AZERTY)
1720    /// * `modifiers` - The keyboard modifiers (Ctrl, Shift, Alt, etc.)
1721    /// * `text` - Optional text content from the keyboard event
1722    /// * `bounds` - The rectangle bounds of the canvas widget (unused in this implementation)
1723    /// * `cursor` - The current mouse cursor position and status (unused in this implementation)
1724    ///
1725    /// # Returns
1726    ///
1727    /// `Some(Action<Message>)` if the event was handled, `None` otherwise
1728    fn handle_keyboard_event(
1729        &self,
1730        key: &keyboard::Key,
1731        modified_key: &keyboard::Key,
1732        modifiers: &keyboard::Modifiers,
1733        text: &Option<iced::advanced::graphics::core::SmolStr>,
1734        _bounds: Rectangle,
1735        _cursor: &mouse::Cursor,
1736    ) -> Option<Action<Message>> {
1737        // Early exit: Check if editor has focus and is not focus-locked
1738        // This prevents focus stealing where keyboard input meant for other widgets
1739        // is incorrectly processed by this editor during focus transitions
1740        if !self.has_focus() || self.focus_locked {
1741            return None;
1742        }
1743
1744        // Skip if IME is active (unless Ctrl/Command is pressed)
1745        if self.ime_preedit.is_some()
1746            && !(modifiers.control() || modifiers.command())
1747        {
1748            return None;
1749        }
1750
1751        // Try keyboard shortcuts first
1752        if let Some(action) =
1753            self.handle_keyboard_shortcuts(key, modified_key, modifiers)
1754        {
1755            return Some(action);
1756        }
1757
1758        // Handle character input and special keys
1759        // Convert Option<SmolStr> to Option<&str>
1760        let text_str = text.as_ref().map(|s| s.as_str());
1761        self.handle_character_input(key, modifiers, text_str)
1762    }
1763
1764    /// Handles mouse events (button presses, movement, releases).
1765    ///
1766    /// # Arguments
1767    ///
1768    /// * `event` - The mouse event to handle
1769    /// * `bounds` - The rectangle bounds of the canvas widget
1770    /// * `cursor` - The current mouse cursor position and status
1771    ///
1772    /// # Returns
1773    ///
1774    /// `Some(Action<Message>)` if the event was handled, `None` otherwise
1775    #[allow(clippy::unused_self)]
1776    /// Returns the logical line of the fold header whose chevron is at `point`,
1777    /// if any.
1778    ///
1779    /// Returns `None` when folding is disabled, when the point is outside the
1780    /// fold margin, or when the targeted line is not a fold header.
1781    ///
1782    /// # Arguments
1783    ///
1784    /// * `point` - The click position in canvas coordinates
1785    pub(crate) fn fold_header_at_point(&self, point: Point) -> Option<usize> {
1786        if !self.folding_enabled {
1787            return None;
1788        }
1789
1790        // The fold margin is the strip between the line-number area and the text.
1791        let margin_start = self.line_number_gutter_width();
1792        if point.x < margin_start || point.x >= self.gutter_width() {
1793            return None;
1794        }
1795
1796        let visual_line_idx = (point.y / self.line_height) as usize;
1797        let visual_lines = self.visual_lines_cached(self.viewport_width);
1798        let visual_line = visual_lines.get(visual_line_idx)?;
1799        if !visual_line.is_first_segment() {
1800            return None;
1801        }
1802
1803        folding::is_line_fold_header(&self.buffer, visual_line.logical_line)
1804            .then_some(visual_line.logical_line)
1805    }
1806
1807    fn handle_mouse_event(
1808        &self,
1809        event: &mouse::Event,
1810        bounds: Rectangle,
1811        cursor: &mouse::Cursor,
1812    ) -> Option<Action<Message>> {
1813        match event {
1814            mouse::Event::ButtonPressed(mouse::Button::Left) => {
1815                cursor.position_in(bounds).map(|position| {
1816                    // Clicking a fold chevron toggles the block instead of
1817                    // moving the caret.
1818                    if let Some(header) = self.fold_header_at_point(position) {
1819                        return Action::publish(Message::ToggleFold(header))
1820                            .and_capture();
1821                    }
1822
1823                    // Check for Ctrl (or Command on macOS) + Click
1824                    #[cfg(target_os = "macos")]
1825                    let is_jump_click = self.modifiers.get().command();
1826                    #[cfg(not(target_os = "macos"))]
1827                    let is_jump_click = self.modifiers.get().control();
1828
1829                    if is_jump_click {
1830                        return Action::publish(Message::JumpClick(position));
1831                    }
1832
1833                    // Alt+Click: add a new cursor at the clicked position
1834                    if self.modifiers.get().alt() {
1835                        let message = if self.vim_enabled {
1836                            Message::MouseClick(position)
1837                        } else {
1838                            Message::AltClick(position)
1839                        };
1840                        return Action::publish(message).and_capture();
1841                    }
1842
1843                    let click_count = self.classify_click(position);
1844                    match click_count {
1845                        2 => Action::publish(Message::DoubleClick(position))
1846                            .and_capture(),
1847                        3 => Action::publish(Message::TripleClick(position))
1848                            .and_capture(),
1849                        // Don't capture the event so it can bubble up for focus management
1850                        // This implements focus event propagation through the widget hierarchy
1851                        _ => Action::publish(Message::MouseClick(position)),
1852                    }
1853                })
1854            }
1855            mouse::Event::ButtonPressed(mouse::Button::Right) => {
1856                cursor.position_in(bounds).map(|position| {
1857                    Action::publish(Message::ContextMenuRequested(position))
1858                        .and_capture()
1859                })
1860            }
1861            mouse::Event::CursorMoved { .. } => {
1862                cursor.position_in(bounds).map(|position| {
1863                    if self.is_dragging {
1864                        // Handle mouse drag for selection only when cursor is within bounds
1865                        Action::publish(Message::MouseDrag(position))
1866                            .and_capture()
1867                    } else {
1868                        // Forward hover events when not dragging to enable LSP hover.
1869                        Action::publish(Message::MouseHover(position))
1870                    }
1871                })
1872            }
1873            mouse::Event::ButtonReleased(mouse::Button::Left) => {
1874                // Only handle mouse release when cursor is within bounds
1875                // This prevents capturing events meant for other widgets
1876                if cursor.is_over(bounds) {
1877                    Some(Action::publish(Message::MouseRelease).and_capture())
1878                } else {
1879                    None
1880                }
1881            }
1882            _ => None,
1883        }
1884    }
1885
1886    /// Handles IME (Input Method Editor) events for complex text input.
1887    ///
1888    /// # Arguments
1889    ///
1890    /// * `event` - The IME event to handle
1891    /// * `bounds` - The rectangle bounds of the canvas widget
1892    /// * `cursor` - The current mouse cursor position and status
1893    ///
1894    /// # Returns
1895    ///
1896    /// `Some(Action<Message>)` if the event was handled, `None` otherwise
1897    fn handle_ime_event(
1898        &self,
1899        event: &input_method::Event,
1900        _bounds: Rectangle,
1901        _cursor: &mouse::Cursor,
1902    ) -> Option<Action<Message>> {
1903        // Early exit: Check if editor has focus and is not focus-locked
1904        // This prevents focus stealing where IME events meant for other widgets
1905        // are incorrectly processed by this editor during focus transitions
1906        if !self.has_focus() || self.focus_locked {
1907            return None;
1908        }
1909        if self.vim_enabled && self.vim_state.mode() != super::VimMode::Insert {
1910            return None;
1911        }
1912
1913        // IME event handling
1914        // ---------------------------------------------------------------------
1915        // Core mapping: convert Iced IME events into editor Messages
1916        //
1917        // Flow:
1918        // 1. Opened: IME activated (e.g. switching input method). Clear old preedit state.
1919        // 2. Preedit: User is composing (e.g. typing "nihao" before commit).
1920        //    - content: current candidate text
1921        //    - selection: selection range within the text, in bytes
1922        // 3. Commit: User confirms a candidate and commits text into the buffer.
1923        // 4. Closed: IME closed or lost focus.
1924        //
1925        // Safety checks:
1926        // - handle only when `focused_id` matches this editor ID
1927        // - handle only when `has_canvas_focus` is true
1928        // This ensures IME events are not delivered to the wrong widget.
1929        // ---------------------------------------------------------------------
1930        let message = match event {
1931            input_method::Event::Opened => Message::ImeOpened,
1932            input_method::Event::Preedit(content, selection) => {
1933                Message::ImePreedit(content.clone(), selection.clone())
1934            }
1935            input_method::Event::Commit(content) => {
1936                Message::ImeCommit(content.clone())
1937            }
1938            input_method::Event::Closed => Message::ImeClosed,
1939        };
1940
1941        Some(Action::publish(message).and_capture())
1942    }
1943}
1944
1945impl CodeEditor {
1946    /// Draws underlines for jumpable links when modifier is held.
1947    fn draw_jump_link_highlight(
1948        &self,
1949        frame: &mut canvas::Frame,
1950        ctx: &RenderContext,
1951        bounds: Rectangle,
1952        cursor: mouse::Cursor,
1953    ) {
1954        #[cfg(target_os = "macos")]
1955        let modifier_active = self.modifiers.get().command();
1956        #[cfg(not(target_os = "macos"))]
1957        let modifier_active = self.modifiers.get().control();
1958
1959        if !modifier_active {
1960            return;
1961        }
1962
1963        let Some(point) = cursor.position_in(bounds) else {
1964            return;
1965        };
1966
1967        if let Some((line, col)) = self.calculate_cursor_from_point(point) {
1968            let line_content = self.buffer.line(line);
1969
1970            let start_col = Self::word_start_in_line(line_content, col);
1971            let end_col = Self::word_end_in_line(line_content, col);
1972
1973            if start_col >= end_col {
1974                return;
1975            }
1976
1977            // Find the first visual line for this logical line
1978            if let Some(mut idx) =
1979                WrappingCalculator::logical_to_visual(ctx.visual_lines, line, 0)
1980            {
1981                // Iterate all visual lines belonging to this logical line
1982                while idx < ctx.visual_lines.len() {
1983                    let visual_line = &ctx.visual_lines[idx];
1984                    if visual_line.logical_line != line {
1985                        break;
1986                    }
1987
1988                    // Check intersection
1989                    let seg_start = visual_line.start_col.max(start_col);
1990                    let seg_end = visual_line.end_col.min(end_col);
1991
1992                    if seg_start < seg_end {
1993                        let (x, width) = calculate_segment_geometry(
1994                            line_content,
1995                            visual_line.start_col,
1996                            seg_start,
1997                            seg_end,
1998                            ctx.gutter_width + 5.0
1999                                - ctx.horizontal_scroll_offset,
2000                            ctx.full_char_width,
2001                            ctx.char_width,
2002                        );
2003
2004                        let y = idx as f32 * ctx.line_height + ctx.line_height; // Underline at bottom
2005
2006                        // Draw underline
2007                        let path = canvas::Path::line(
2008                            Point::new(x, y),
2009                            Point::new(x + width, y),
2010                        );
2011
2012                        frame.stroke(
2013                            &path,
2014                            canvas::Stroke::default()
2015                                .with_color(self.style.text_color) // Use text color or link color
2016                                .with_width(1.0),
2017                        );
2018                    }
2019
2020                    idx += 1;
2021                }
2022            }
2023        }
2024    }
2025}
2026
2027impl canvas::Program<Message> for CodeEditor {
2028    type State = ();
2029
2030    /// Renders the code editor's visual elements on the canvas, including text layout, syntax highlighting,
2031    /// cursor positioning, and other graphical aspects.
2032    ///
2033    /// # Arguments
2034    ///
2035    /// * `state` - The current state of the canvas
2036    /// * `renderer` - The renderer used for drawing
2037    /// * `theme` - The theme for styling
2038    /// * `bounds` - The rectangle bounds of the canvas
2039    /// * `cursor` - The mouse cursor position
2040    ///
2041    /// # Returns
2042    ///
2043    /// A vector of `Geometry` objects representing the drawn elements
2044    fn draw(
2045        &self,
2046        _state: &Self::State,
2047        renderer: &iced::Renderer,
2048        _theme: &Theme,
2049        bounds: Rectangle,
2050        _cursor: mouse::Cursor,
2051    ) -> Vec<Geometry> {
2052        let visual_lines: Rc<Vec<VisualLine>> =
2053            self.visual_lines_cached(bounds.width);
2054
2055        // Prefer the tracked viewport height when available, but fall back to
2056        // the current bounds during initial layout when viewport metrics have
2057        // not been populated yet.
2058        let effective_viewport_height = if self.viewport_height > 0.0 {
2059            self.viewport_height
2060        } else {
2061            bounds.height
2062        };
2063        let first_visible_line =
2064            (self.viewport_scroll / self.line_height).floor() as usize;
2065        let visible_lines_count =
2066            (effective_viewport_height / self.line_height).ceil() as usize + 2;
2067        let last_visible_line =
2068            (first_visible_line + visible_lines_count).min(visual_lines.len());
2069
2070        let (start_idx, end_idx) =
2071            if self.cache_window_end_line > self.cache_window_start_line {
2072                let s = self.cache_window_start_line.min(visual_lines.len());
2073                let e = self.cache_window_end_line.min(visual_lines.len());
2074                (s, e)
2075            } else {
2076                (first_visible_line, last_visible_line)
2077            };
2078
2079        // Split rendering into two cached layers:
2080        // - content: expensive, mostly static text/gutter rendering
2081        // - overlay: frequently changing highlights/cursor/IME
2082        //
2083        // This keeps selection dragging and cursor blinking smooth by avoiding
2084        // invalidation of the text layer on every overlay update.
2085        let visual_lines_for_content = visual_lines.clone();
2086        let content_geometry =
2087            self.content_cache.draw(renderer, bounds.size(), |frame| {
2088                // Bound sequential syntect catch-up work for this frame. This
2089                // keeps a deep jump or a cache truncation in a huge file from
2090                // blocking the UI while parsing every preceding line.
2091                self.highlight_lines_remaining
2092                    .set(super::HIGHLIGHT_LINES_PER_FRAME);
2093
2094                // syntect initialization is relatively expensive; keep it global.
2095                let syntax_set = SYNTAX_SET.get_or_init(|| {
2096                    #[cfg(feature = "two-face")]
2097                    {
2098                        two_face::syntax::extra_newlines()
2099                    }
2100                    #[cfg(not(feature = "two-face"))]
2101                    {
2102                        SyntaxSet::load_defaults_newlines()
2103                    }
2104                });
2105                let theme_set = THEME_SET.get_or_init(ThemeSet::load_defaults);
2106                let syntax_theme = theme_set
2107                    .themes
2108                    .get("base16-ocean.dark")
2109                    .or_else(|| theme_set.themes.values().next());
2110
2111                // Normalize common language aliases/extensions used by consumers.
2112                let syntax_ref = match self.syntax.as_str() {
2113                    "python" => syntax_set.find_syntax_by_extension("py"),
2114                    "rust" => syntax_set.find_syntax_by_extension("rs"),
2115                    "javascript" => syntax_set.find_syntax_by_extension("js"),
2116                    "htm" => syntax_set.find_syntax_by_extension("html"),
2117                    "svg" => syntax_set.find_syntax_by_extension("xml"),
2118                    "markdown" => syntax_set.find_syntax_by_extension("md"),
2119                    "text" => Some(syntax_set.find_syntax_plain_text()),
2120                    _ => syntax_set
2121                        .find_syntax_by_extension(self.syntax.as_str()),
2122                }
2123                .or(Some(syntax_set.find_syntax_plain_text()));
2124
2125                let ctx = RenderContext {
2126                    visual_lines: visual_lines_for_content.as_ref(),
2127                    bounds_width: bounds.width,
2128                    gutter_width: self.gutter_width(),
2129                    line_height: self.line_height,
2130                    font_size: self.font_size,
2131                    full_char_width: self.full_char_width,
2132                    char_width: self.char_width,
2133                    font: self.font,
2134                    horizontal_scroll_offset: self.horizontal_scroll_offset,
2135                };
2136
2137                // Clip code text to the code area (right of gutter) so that
2138                // horizontal scrolling cannot cause text to bleed into the gutter.
2139                // Note: iced renders ALL text on top of ALL geometry, so a
2140                // fill_rectangle cannot mask text bleed — with_clip is required.
2141                let code_clip = Rectangle {
2142                    x: ctx.gutter_width,
2143                    y: 0.0,
2144                    width: (bounds.width - ctx.gutter_width).max(0.0),
2145                    height: bounds.height,
2146                };
2147                frame.with_clip(code_clip, |f| {
2148                    for (idx, visual_line) in visual_lines_for_content
2149                        .iter()
2150                        .enumerate()
2151                        .skip(start_idx)
2152                        .take(end_idx.saturating_sub(start_idx))
2153                    {
2154                        let y = idx as f32 * self.line_height;
2155                        self.draw_text_with_syntax_highlighting(
2156                            f,
2157                            &ctx,
2158                            visual_line,
2159                            y,
2160                            syntax_ref,
2161                            syntax_set,
2162                            syntax_theme,
2163                        );
2164                        self.draw_fold_collapsed_marker(
2165                            f,
2166                            &ctx,
2167                            visual_line,
2168                            y,
2169                        );
2170                    }
2171                });
2172
2173                // Draw line numbers in the gutter (no clip — fixed position)
2174                for (idx, visual_line) in visual_lines_for_content
2175                    .iter()
2176                    .enumerate()
2177                    .skip(start_idx)
2178                    .take(end_idx.saturating_sub(start_idx))
2179                {
2180                    let y = idx as f32 * self.line_height;
2181                    self.draw_line_numbers(frame, &ctx, visual_line, y);
2182                }
2183            });
2184
2185        let visual_lines_for_overlay = visual_lines;
2186        let overlay_geometry =
2187            self.overlay_cache.draw(renderer, bounds.size(), |frame| {
2188                // The overlay layer shares the same visual lines, but draws only
2189                // elements that change without modifying the buffer content.
2190                let ctx = RenderContext {
2191                    visual_lines: visual_lines_for_overlay.as_ref(),
2192                    bounds_width: bounds.width,
2193                    gutter_width: self.gutter_width(),
2194                    line_height: self.line_height,
2195                    font_size: self.font_size,
2196                    full_char_width: self.full_char_width,
2197                    char_width: self.char_width,
2198                    font: self.font,
2199                    horizontal_scroll_offset: self.horizontal_scroll_offset,
2200                };
2201
2202                for (idx, visual_line) in visual_lines_for_overlay
2203                    .iter()
2204                    .enumerate()
2205                    .skip(start_idx)
2206                    .take(end_idx.saturating_sub(start_idx))
2207                {
2208                    let y = idx as f32 * self.line_height;
2209                    self.draw_current_line_highlight(
2210                        frame,
2211                        &ctx,
2212                        visual_line,
2213                        y,
2214                    );
2215                }
2216
2217                self.draw_search_highlights(frame, &ctx, start_idx, end_idx);
2218                self.draw_selection_highlight(frame, &ctx);
2219                self.draw_jump_link_highlight(frame, &ctx, bounds, _cursor);
2220                self.draw_cursor(frame, &ctx);
2221            });
2222
2223        vec![content_geometry, overlay_geometry]
2224    }
2225
2226    /// Handles Canvas trait events, specifically keyboard input events and focus management for the code editor widget.
2227    ///
2228    /// # Arguments
2229    ///
2230    /// * `_state` - The mutable state of the canvas (unused in this implementation)
2231    /// * `event` - The input event to handle, such as keyboard presses
2232    /// * `bounds` - The rectangle bounds of the canvas widget
2233    /// * `cursor` - The current mouse cursor position and status
2234    ///
2235    /// # Returns
2236    ///
2237    /// An optional `Action<Message>` to perform, such as sending a message or redrawing the canvas
2238    fn update(
2239        &self,
2240        _state: &mut Self::State,
2241        event: &Event,
2242        bounds: Rectangle,
2243        cursor: mouse::Cursor,
2244    ) -> Option<Action<Message>> {
2245        match event {
2246            Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
2247                self.modifiers.set(*modifiers);
2248                None
2249            }
2250            Event::Keyboard(keyboard::Event::KeyPressed {
2251                key,
2252                modified_key,
2253                modifiers,
2254                text,
2255                ..
2256            }) => {
2257                self.modifiers.set(*modifiers);
2258                self.handle_keyboard_event(
2259                    key,
2260                    modified_key,
2261                    modifiers,
2262                    text,
2263                    bounds,
2264                    &cursor,
2265                )
2266            }
2267            Event::Keyboard(keyboard::Event::KeyReleased {
2268                modifiers, ..
2269            }) => {
2270                self.modifiers.set(*modifiers);
2271                None
2272            }
2273            Event::Mouse(mouse_event) => {
2274                self.handle_mouse_event(mouse_event, bounds, &cursor)
2275            }
2276            Event::InputMethod(ime_event) => {
2277                self.handle_ime_event(ime_event, bounds, &cursor)
2278            }
2279            _ => None,
2280        }
2281    }
2282
2283    /// Uses the text-selection cursor over the editable code area.
2284    ///
2285    /// The gutter keeps the default cursor, except for an interactive fold
2286    /// chevron. Checking the cursor against `bounds` is important because a
2287    /// canvas program's interaction can otherwise remain active out of bounds.
2288    fn mouse_interaction(
2289        &self,
2290        _state: &Self::State,
2291        bounds: Rectangle,
2292        cursor: mouse::Cursor,
2293    ) -> mouse::Interaction {
2294        let Some(position) = cursor.position_in(bounds) else {
2295            return mouse::Interaction::default();
2296        };
2297
2298        if self.fold_header_at_point(position).is_some() {
2299            mouse::Interaction::Pointer
2300        } else if position.x >= self.gutter_width() {
2301            mouse::Interaction::Text
2302        } else {
2303            mouse::Interaction::default()
2304        }
2305    }
2306}
2307
2308/// Validates that the selection indices fall on valid UTF-8 character boundaries
2309/// to prevent panics during string slicing.
2310///
2311/// # Arguments
2312///
2313/// * `content` - The string content to check against
2314/// * `start` - The start byte index
2315/// * `end` - The end byte index
2316///
2317/// # Returns
2318///
2319/// `Some((start, end))` if indices are valid, `None` otherwise.
2320fn validate_selection_indices(
2321    content: &str,
2322    start: usize,
2323    end: usize,
2324) -> Option<(usize, usize)> {
2325    let len = content.len();
2326    // Clamp indices to content length
2327    let start = start.min(len);
2328    let end = end.min(len);
2329
2330    // Ensure start is not greater than end
2331    if start > end {
2332        return None;
2333    }
2334
2335    // Verify that indices fall on valid UTF-8 character boundaries
2336    if content.is_char_boundary(start) && content.is_char_boundary(end) {
2337        Some((start, end))
2338    } else {
2339        None
2340    }
2341}
2342
2343#[cfg(test)]
2344mod tests {
2345    use super::*;
2346    use crate::canvas_editor::{CHAR_WIDTH, FONT_SIZE, compare_floats};
2347    use std::cmp::Ordering;
2348
2349    fn editor_mouse_interaction(
2350        editor: &CodeEditor,
2351        bounds: Rectangle,
2352        cursor: mouse::Cursor,
2353    ) -> mouse::Interaction {
2354        canvas::Program::<Message>::mouse_interaction(
2355            editor,
2356            &(),
2357            bounds,
2358            cursor,
2359        )
2360    }
2361
2362    #[test]
2363    fn test_vim_navigation_keyboard_route_uses_dedicated_message() {
2364        let mut editor = CodeEditor::new("abc", "txt").with_vim_enabled(true);
2365
2366        assert!(matches!(
2367            editor.printable_input_message('l'),
2368            Message::VimKey('l')
2369        ));
2370
2371        let _ = editor.vim_state.parse_key('i');
2372        assert!(matches!(
2373            editor.printable_input_message('x'),
2374            Message::CharacterInput('x')
2375        ));
2376
2377        editor.set_vim_enabled(false);
2378        assert!(matches!(
2379            editor.printable_input_message('x'),
2380            Message::CharacterInput('x')
2381        ));
2382
2383        editor.set_vim_enabled(true);
2384        let key = keyboard::Key::Character("r".into());
2385        let message = editor
2386            .handle_keyboard_shortcuts(&key, &key, &keyboard::Modifiers::CTRL)
2387            .map(|action| action.into_inner().0);
2388        assert!(matches!(message, Some(Some(Message::Redo))));
2389    }
2390
2391    #[test]
2392    fn test_vim_command_line_routes_enter_and_backspace() {
2393        let mut editor = CodeEditor::new("abc", "txt").with_vim_enabled(true);
2394        editor.request_focus();
2395        editor.has_canvas_focus = true;
2396        editor.focus_locked = false;
2397        let _ = editor.vim_state.parse_key('/');
2398
2399        let backspace = editor
2400            .handle_character_input(
2401                &keyboard::Key::Named(keyboard::key::Named::Backspace),
2402                &keyboard::Modifiers::NONE,
2403                None,
2404            )
2405            .map(|action| action.into_inner().0);
2406        assert!(matches!(backspace, Some(Some(Message::VimKey('\u{8}')))));
2407
2408        let enter = editor
2409            .handle_character_input(
2410                &keyboard::Key::Named(keyboard::key::Named::Enter),
2411                &keyboard::Modifiers::NONE,
2412                None,
2413            )
2414            .map(|action| action.into_inner().0);
2415        assert!(matches!(enter, Some(Some(Message::VimKey('\n')))));
2416    }
2417
2418    #[test]
2419    fn test_vim_cursor_rendering_insert_uses_bar() {
2420        let mut editor = CodeEditor::new("a", "txt").with_vim_enabled(true);
2421        let _ = editor.vim_state.parse_key('i');
2422
2423        let size = editor.cursor_size_for_position((0, 0));
2424
2425        assert_eq!(compare_floats(size.width, 2.0), Ordering::Equal);
2426        assert_eq!(
2427            compare_floats(size.height, editor.line_height() - 4.0),
2428            Ordering::Equal
2429        );
2430    }
2431
2432    #[test]
2433    fn test_vim_cursor_rendering_normal_uses_ascii_block() {
2434        let editor = CodeEditor::new("a", "txt").with_vim_enabled(true);
2435
2436        let size = editor.cursor_size_for_position((0, 0));
2437
2438        assert_eq!(
2439            compare_floats(size.width, editor.char_width()),
2440            Ordering::Equal
2441        );
2442    }
2443
2444    #[test]
2445    fn test_vim_cursor_rendering_normal_uses_cjk_width() {
2446        let editor = CodeEditor::new("汉", "txt").with_vim_enabled(true);
2447
2448        let size = editor.cursor_size_for_position((0, 0));
2449
2450        assert_eq!(
2451            compare_floats(size.width, editor.full_char_width()),
2452            Ordering::Equal
2453        );
2454    }
2455
2456    #[test]
2457    fn test_vim_cursor_rendering_empty_line_has_visible_block() {
2458        let editor = CodeEditor::new("", "txt").with_vim_enabled(true);
2459
2460        let size = editor.cursor_size_for_position((0, 0));
2461
2462        assert_eq!(
2463            compare_floats(size.width, editor.char_width()),
2464            Ordering::Equal
2465        );
2466        assert!(size.width > 2.0);
2467    }
2468
2469    #[test]
2470    fn test_mouse_interaction_uses_text_cursor_in_editable_area() {
2471        let editor = CodeEditor::new("fn main() {}", "rs");
2472        let bounds = Rectangle::new(Point::ORIGIN, Size::new(800.0, 600.0));
2473        let cursor = mouse::Cursor::Available(Point::new(
2474            editor.gutter_width() + 10.0,
2475            10.0,
2476        ));
2477
2478        assert_eq!(
2479            editor_mouse_interaction(&editor, bounds, cursor),
2480            mouse::Interaction::Text
2481        );
2482    }
2483
2484    #[test]
2485    fn test_mouse_interaction_keeps_default_cursor_in_gutter_and_outside() {
2486        let editor = CodeEditor::new("fn main() {}", "rs");
2487        let bounds = Rectangle::new(Point::ORIGIN, Size::new(800.0, 600.0));
2488
2489        assert_eq!(
2490            editor_mouse_interaction(
2491                &editor,
2492                bounds,
2493                mouse::Cursor::Available(Point::new(5.0, 10.0)),
2494            ),
2495            mouse::Interaction::default()
2496        );
2497        assert_eq!(
2498            editor_mouse_interaction(
2499                &editor,
2500                bounds,
2501                mouse::Cursor::Available(Point::new(900.0, 10.0)),
2502            ),
2503            mouse::Interaction::default()
2504        );
2505    }
2506
2507    #[test]
2508    fn test_calculate_segment_geometry_ascii() {
2509        // "Hello World"
2510        // "Hello " (6 chars) -> prefix
2511        // "World" (5 chars) -> segment
2512        // width("Hello ") = 6 * CHAR_WIDTH
2513        // width("World") = 5 * CHAR_WIDTH
2514        let content = "Hello World";
2515        let (x, w) = calculate_segment_geometry(
2516            content, 0, 6, 11, 0.0, FONT_SIZE, CHAR_WIDTH,
2517        );
2518
2519        let expected_x = CHAR_WIDTH * 6.0;
2520        let expected_w = CHAR_WIDTH * 5.0;
2521
2522        assert_eq!(
2523            compare_floats(x, expected_x),
2524            Ordering::Equal,
2525            "X position mismatch for ASCII"
2526        );
2527        assert_eq!(
2528            compare_floats(w, expected_w),
2529            Ordering::Equal,
2530            "Width mismatch for ASCII"
2531        );
2532    }
2533
2534    #[test]
2535    fn test_calculate_segment_geometry_cjk() {
2536        // "你好世界"
2537        // "你好" (2 chars) -> prefix
2538        // "世界" (2 chars) -> segment
2539        // width("你好") = 2 * FONT_SIZE
2540        // width("世界") = 2 * FONT_SIZE
2541        let content = "你好世界";
2542        let (x, w) = calculate_segment_geometry(
2543            content, 0, 2, 4, 10.0, FONT_SIZE, CHAR_WIDTH,
2544        );
2545
2546        let expected_x = 10.0 + FONT_SIZE * 2.0;
2547        let expected_w = FONT_SIZE * 2.0;
2548
2549        assert_eq!(
2550            compare_floats(x, expected_x),
2551            Ordering::Equal,
2552            "X position mismatch for CJK"
2553        );
2554        assert_eq!(
2555            compare_floats(w, expected_w),
2556            Ordering::Equal,
2557            "Width mismatch for CJK"
2558        );
2559    }
2560
2561    #[test]
2562    fn test_calculate_segment_geometry_mixed() {
2563        // "Hi你好"
2564        // "Hi" (2 chars) -> prefix
2565        // "你好" (2 chars) -> segment
2566        // width("Hi") = 2 * CHAR_WIDTH
2567        // width("你好") = 2 * FONT_SIZE
2568        let content = "Hi你好";
2569        let (x, w) = calculate_segment_geometry(
2570            content, 0, 2, 4, 0.0, FONT_SIZE, CHAR_WIDTH,
2571        );
2572
2573        let expected_x = CHAR_WIDTH * 2.0;
2574        let expected_w = FONT_SIZE * 2.0;
2575
2576        assert_eq!(
2577            compare_floats(x, expected_x),
2578            Ordering::Equal,
2579            "X position mismatch for mixed content"
2580        );
2581        assert_eq!(
2582            compare_floats(w, expected_w),
2583            Ordering::Equal,
2584            "Width mismatch for mixed content"
2585        );
2586    }
2587
2588    #[test]
2589    fn test_calculate_segment_geometry_empty_range() {
2590        let content = "Hello";
2591        let (x, w) = calculate_segment_geometry(
2592            content, 0, 0, 0, 0.0, FONT_SIZE, CHAR_WIDTH,
2593        );
2594        assert!((x - 0.0).abs() < f32::EPSILON);
2595        assert!((w - 0.0).abs() < f32::EPSILON);
2596    }
2597
2598    #[test]
2599    fn test_calculate_segment_geometry_with_visual_offset() {
2600        // content: "0123456789"
2601        // visual_start_col: 2 (starts at '2')
2602        // segment: "34" (indices 3 to 5)
2603        // prefix: from visual start (2) to segment start (3) -> "2" (length 1)
2604        // prefix width: 1 * CHAR_WIDTH
2605        // segment width: 2 * CHAR_WIDTH
2606        let content = "0123456789";
2607        let (x, w) = calculate_segment_geometry(
2608            content, 2, 3, 5, 5.0, FONT_SIZE, CHAR_WIDTH,
2609        );
2610
2611        let expected_x = 5.0 + CHAR_WIDTH * 1.0;
2612        let expected_w = CHAR_WIDTH * 2.0;
2613
2614        assert_eq!(
2615            compare_floats(x, expected_x),
2616            Ordering::Equal,
2617            "X position mismatch with visual offset"
2618        );
2619        assert_eq!(
2620            compare_floats(w, expected_w),
2621            Ordering::Equal,
2622            "Width mismatch with visual offset"
2623        );
2624    }
2625
2626    #[test]
2627    fn test_calculate_segment_geometry_out_of_bounds() {
2628        // Content length is 5 ("Hello")
2629        // Request start at 10, end at 15
2630        // visual_start 0
2631        // Prefix should consume whole string ("Hello") and stop.
2632        // Segment should be empty.
2633        let content = "Hello";
2634        let (x, w) = calculate_segment_geometry(
2635            content, 0, 10, 15, 0.0, FONT_SIZE, CHAR_WIDTH,
2636        );
2637
2638        let expected_x = CHAR_WIDTH * 5.0; // Width of "Hello"
2639        let expected_w = 0.0;
2640
2641        assert_eq!(
2642            compare_floats(x, expected_x),
2643            Ordering::Equal,
2644            "X position mismatch for out of bounds start"
2645        );
2646        assert!(
2647            (w - expected_w).abs() < f32::EPSILON,
2648            "Width should be 0 for out of bounds segment"
2649        );
2650    }
2651
2652    #[test]
2653    fn test_calculate_segment_geometry_special_chars() {
2654        // Emoji "👋" (width > 1 => FONT_SIZE)
2655        // Tab "\t" (width = 4 * CHAR_WIDTH)
2656        let content = "A👋\tB";
2657        // Measure "👋" (index 1 to 2)
2658        // Indices in chars: 'A' (0), '👋' (1), '\t' (2), 'B' (3)
2659
2660        // Segment covering Emoji
2661        let (x, w) = calculate_segment_geometry(
2662            content, 0, 1, 2, 0.0, FONT_SIZE, CHAR_WIDTH,
2663        );
2664        let expected_x_emoji = CHAR_WIDTH; // 'A'
2665        let expected_w_emoji = FONT_SIZE; // '👋'
2666
2667        assert_eq!(
2668            compare_floats(x, expected_x_emoji),
2669            Ordering::Equal,
2670            "X pos for emoji"
2671        );
2672        assert_eq!(
2673            compare_floats(w, expected_w_emoji),
2674            Ordering::Equal,
2675            "Width for emoji"
2676        );
2677
2678        // Segment covering Tab
2679        let (x_tab, w_tab) = calculate_segment_geometry(
2680            content, 0, 2, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
2681        );
2682        let expected_x_tab = CHAR_WIDTH + FONT_SIZE; // 'A' + '👋'
2683        let expected_w_tab =
2684            CHAR_WIDTH * crate::canvas_editor::TAB_WIDTH as f32;
2685
2686        assert_eq!(
2687            compare_floats(x_tab, expected_x_tab),
2688            Ordering::Equal,
2689            "X pos for tab"
2690        );
2691        assert_eq!(
2692            compare_floats(w_tab, expected_w_tab),
2693            Ordering::Equal,
2694            "Width for tab"
2695        );
2696    }
2697
2698    #[test]
2699    fn test_calculate_segment_geometry_inverted_range() {
2700        // Start 5, End 3
2701        // Should result in empty segment at start 5
2702        let content = "0123456789";
2703        let (x, w) = calculate_segment_geometry(
2704            content, 0, 5, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
2705        );
2706
2707        let expected_x = CHAR_WIDTH * 5.0;
2708        let expected_w = 0.0;
2709
2710        assert_eq!(
2711            compare_floats(x, expected_x),
2712            Ordering::Equal,
2713            "X pos for inverted range"
2714        );
2715        assert!(
2716            (w - expected_w).abs() < f32::EPSILON,
2717            "Width for inverted range"
2718        );
2719    }
2720
2721    #[test]
2722    fn test_validate_selection_indices() {
2723        // Test valid ASCII indices
2724        let content = "Hello";
2725        assert_eq!(validate_selection_indices(content, 0, 5), Some((0, 5)));
2726        assert_eq!(validate_selection_indices(content, 1, 3), Some((1, 3)));
2727
2728        // Test valid multi-byte indices (Chinese "你好")
2729        // "你" is 3 bytes (0-3), "好" is 3 bytes (3-6)
2730        let content = "你好";
2731        assert_eq!(validate_selection_indices(content, 0, 6), Some((0, 6)));
2732        assert_eq!(validate_selection_indices(content, 0, 3), Some((0, 3)));
2733        assert_eq!(validate_selection_indices(content, 3, 6), Some((3, 6)));
2734
2735        // Test invalid indices (splitting multi-byte char)
2736        assert_eq!(validate_selection_indices(content, 1, 3), None); // Split first char
2737        assert_eq!(validate_selection_indices(content, 0, 4), None); // Split second char
2738
2739        // Test out of bounds (should be clamped if on boundary, but here len is 6)
2740        // If we pass start=0, end=100 -> clamped to 0, 6. 6 is boundary.
2741        assert_eq!(validate_selection_indices(content, 0, 100), Some((0, 6)));
2742
2743        // Test inverted range
2744        assert_eq!(validate_selection_indices(content, 3, 0), None);
2745    }
2746
2747    #[test]
2748    fn test_highlight_line_spans_covers_full_line() {
2749        let syntax_set = SyntaxSet::load_defaults_newlines();
2750        let syntax = syntax_set.find_syntax_plain_text();
2751        let theme = syntect::highlighting::Theme::default();
2752
2753        let line = "fn main() {}";
2754        let spans = highlight_line_spans(line, syntax, &theme, &syntax_set);
2755
2756        assert!(!spans.is_empty(), "expected at least one span");
2757        let combined: String =
2758            spans.iter().map(|(_, text)| text.as_str()).collect();
2759        assert_eq!(combined, line, "spans must cover the entire line");
2760    }
2761
2762    #[test]
2763    fn test_highlighted_line_cached_reuses_until_invalidated() {
2764        let editor = CodeEditor::new("fn main() {}\nlet x = 1;", "rs");
2765        let syntax_set = SyntaxSet::load_defaults_newlines();
2766        let syntax = syntax_set.find_syntax_plain_text();
2767        let theme = syntect::highlighting::Theme::default();
2768
2769        let first =
2770            editor.highlighted_line_cached(0, syntax, &theme, &syntax_set);
2771        let second =
2772            editor.highlighted_line_cached(0, syntax, &theme, &syntax_set);
2773        assert!(
2774            Rc::ptr_eq(&first, &second),
2775            "a cached line should be reused as the same Rc"
2776        );
2777
2778        editor.invalidate_highlight_from(0);
2779        let third =
2780            editor.highlighted_line_cached(0, syntax, &theme, &syntax_set);
2781        assert!(
2782            !Rc::ptr_eq(&first, &third),
2783            "invalidation should force the line to be recomputed"
2784        );
2785    }
2786
2787    #[test]
2788    fn test_highlight_budget_uses_plain_fallback_without_scanning_to_target() {
2789        let editor = CodeEditor::new("zero\none\ntwo\nthree\nfour", "txt");
2790        let syntax_set = SyntaxSet::load_defaults_newlines();
2791        let syntax = syntax_set.find_syntax_plain_text();
2792        let theme = syntect::highlighting::Theme::default();
2793        editor.highlight_lines_remaining.set(2);
2794
2795        let spans =
2796            editor.highlighted_line_cached(4, syntax, &theme, &syntax_set);
2797        let combined: String =
2798            spans.iter().map(|(_, text)| text.as_str()).collect();
2799
2800        assert_eq!(combined, "four");
2801        assert_eq!(
2802            editor
2803                .highlight_cache
2804                .borrow()
2805                .as_ref()
2806                .map(super::super::HighlightCache::valid_len),
2807            Some(2)
2808        );
2809        assert_eq!(editor.highlight_lines_remaining.get(), 0);
2810    }
2811
2812    #[test]
2813    fn test_highlighted_line_cached_handles_multiline_comments() {
2814        let syntax_set = SyntaxSet::load_defaults_newlines();
2815        let syntax = syntax_set
2816            .find_syntax_by_extension("rs")
2817            .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
2818        let theme = ThemeSet::load_defaults()
2819            .themes
2820            .get("base16-ocean.dark")
2821            .cloned()
2822            .unwrap_or_default();
2823
2824        // Line index 2 ("still inside") sits within a `/* ... */` block.
2825        let code = "let a = 1;\n/* open\nstill inside\n*/\nlet b = 2;";
2826        let editor = CodeEditor::new(code, "rs");
2827
2828        // Sequential highlighting resumes inside the block comment.
2829        let sequential =
2830            editor.highlighted_line_cached(2, syntax, &theme, &syntax_set);
2831        // Independent highlighting wrongly treats the line as ordinary code.
2832        let independent = highlight_line_spans(
2833            editor.buffer.line(2),
2834            syntax,
2835            &theme,
2836            &syntax_set,
2837        );
2838
2839        let sequential_color = sequential.first().map(|(color, _)| *color);
2840        let independent_color = independent.first().map(|(color, _)| *color);
2841        assert!(sequential_color.is_some());
2842        assert!(independent_color.is_some());
2843        assert_ne!(
2844            sequential_color, independent_color,
2845            "a line inside a block comment must be colored as a comment"
2846        );
2847    }
2848
2849    #[test]
2850    fn test_expand_tabs_visible_spaces() {
2851        assert_eq!(expand_tabs_visible("a b", 4), "a·b");
2852        assert_eq!(expand_tabs_visible("  x  ", 4), "··x··");
2853    }
2854
2855    #[test]
2856    fn test_expand_tabs_visible_tabs() {
2857        // tab_width = 4: '\t' → '→' + 3 × '·'
2858        assert_eq!(expand_tabs_visible("\t", 4), "→···");
2859        assert_eq!(expand_tabs_visible("a\tb", 4), "a→···b");
2860    }
2861
2862    #[test]
2863    fn test_expand_tabs_visible_no_whitespace() {
2864        assert_eq!(expand_tabs_visible("hello", 4), "hello");
2865    }
2866
2867    #[test]
2868    fn test_split_whitespace_segments_mixed() {
2869        let segs = split_whitespace_segments("a·b");
2870        assert_eq!(segs, vec![(false, "a"), (true, "·"), (false, "b")]);
2871    }
2872
2873    #[test]
2874    fn test_split_whitespace_segments_leading_ws() {
2875        let segs = split_whitespace_segments("··x");
2876        assert_eq!(segs, vec![(true, "··"), (false, "x")]);
2877    }
2878
2879    #[test]
2880    fn test_split_whitespace_segments_all_ws() {
2881        let segs = split_whitespace_segments("···");
2882        assert_eq!(segs, vec![(true, "···")]);
2883    }
2884
2885    #[test]
2886    fn test_split_whitespace_segments_empty() {
2887        let segs = split_whitespace_segments("");
2888        assert!(segs.is_empty());
2889    }
2890
2891    #[test]
2892    fn test_command_g_opens_goto_line_dialog() {
2893        let editor = CodeEditor::new("one\ntwo", "rs");
2894        let key = keyboard::Key::Character("g".into());
2895
2896        let message = editor
2897            .handle_keyboard_shortcuts(
2898                &key,
2899                &key,
2900                &keyboard::Modifiers::COMMAND,
2901            )
2902            .map(|action| action.into_inner().0);
2903
2904        assert!(matches!(message, Some(Some(Message::OpenGotoLine))));
2905    }
2906}