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