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