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