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 syntect::easy::HighlightLines;
8use syntect::highlighting::{Style, ThemeSet};
9use syntect::parsing::SyntaxSet;
10
11fn is_cursor_in_bounds(cursor: &mouse::Cursor, bounds: Rectangle) -> bool {
12 match cursor {
13 mouse::Cursor::Available(point) => bounds.contains(*point),
14 mouse::Cursor::Levitating(point) => bounds.contains(*point),
15 mouse::Cursor::Unavailable => false,
16 }
17}
18
19/// Computes geometry (x start and width) for a text segment used in rendering or highlighting.
20///
21/// # Arguments
22///
23/// * `line_content`: full text content of the current line.
24/// * `visual_start_col`: start column index of the current visual line.
25/// * `segment_start_col`: start column index of the target segment (e.g. highlight).
26/// * `segment_end_col`: end column index of the target segment.
27/// * `base_offset`: base X offset (usually gutter_width + padding).
28///
29/// # Returns
30///
31/// x_start, width
32///
33/// # Remark
34///
35/// This function handles CJK character widths correctly to keep highlights accurate.
36fn calculate_segment_geometry(
37 line_content: &str,
38 visual_start_col: usize,
39 segment_start_col: usize,
40 segment_end_col: usize,
41 base_offset: f32,
42 full_char_width: f32,
43 char_width: f32,
44) -> (f32, f32) {
45 // Calculate prefix width relative to visual line start
46 let prefix_len = segment_start_col.saturating_sub(visual_start_col);
47 let prefix_text: String =
48 line_content.chars().skip(visual_start_col).take(prefix_len).collect();
49 let prefix_width =
50 measure_text_width(&prefix_text, full_char_width, char_width);
51
52 // Calculate segment width
53 let segment_len = segment_end_col.saturating_sub(segment_start_col);
54 let segment_text: String = line_content
55 .chars()
56 .skip(segment_start_col)
57 .take(segment_len)
58 .collect();
59 let segment_width =
60 measure_text_width(&segment_text, full_char_width, char_width);
61
62 (base_offset + prefix_width, segment_width)
63}
64
65use super::wrapping::WrappingCalculator;
66use super::{ArrowDirection, CodeEditor, Message, measure_text_width};
67use iced::widget::canvas::Action;
68
69impl canvas::Program<Message> for CodeEditor {
70 type State = ();
71
72 /// Renders the code editor's visual elements on the canvas, including text layout, syntax highlighting,
73 /// cursor positioning, and other graphical aspects.
74 ///
75 /// # Arguments
76 ///
77 /// * `state` - The current state of the canvas
78 /// * `renderer` - The renderer used for drawing
79 /// * `theme` - The theme for styling
80 /// * `bounds` - The rectangle bounds of the canvas
81 /// * `cursor` - The mouse cursor position
82 ///
83 /// # Returns
84 ///
85 /// A vector of `Geometry` objects representing the drawn elements
86 fn draw(
87 &self,
88 _state: &Self::State,
89 renderer: &iced::Renderer,
90 _theme: &Theme,
91 bounds: Rectangle,
92 _cursor: mouse::Cursor,
93 ) -> Vec<Geometry> {
94 let geometry = self.cache.draw(renderer, bounds.size(), |frame| {
95 // Initialize wrapping calculator
96 let wrapping_calc = WrappingCalculator::new(
97 self.wrap_enabled,
98 self.wrap_column,
99 self.full_char_width,
100 self.char_width,
101 );
102 let visual_lines = wrapping_calc.calculate_visual_lines(
103 &self.buffer,
104 bounds.width,
105 self.gutter_width(),
106 );
107
108 // Calculate visible line range based on viewport for optimized rendering
109 // Use bounds.height as fallback when viewport_height is not yet initialized
110 let effective_viewport_height = if self.viewport_height > 0.0 {
111 self.viewport_height
112 } else {
113 bounds.height
114 };
115 let first_visible_line =
116 (self.viewport_scroll / self.line_height).floor() as usize;
117 let visible_lines_count =
118 (effective_viewport_height / self.line_height).ceil() as usize
119 + 2;
120 let last_visible_line = (first_visible_line + visible_lines_count)
121 .min(visual_lines.len());
122
123 // Load syntax highlighting
124 let syntax_set = SyntaxSet::load_defaults_newlines();
125 let theme_set = ThemeSet::load_defaults();
126 let syntax_theme = &theme_set.themes["base16-ocean.dark"];
127
128 // Note: Extension-based lookup below falls back to plain text via
129 // `.or(Some(syntax_set.find_syntax_plain_text()))`. If `self.syntax`
130 // is a language name that doesn't match a known file extension or an
131 // unsupported extension, `syntect` returns `None` and the fallback
132 // causes many inputs to be highlighted as plain text.
133 let syntax_ref = match self.syntax.as_str() {
134 "python" => syntax_set.find_syntax_by_extension("py"),
135 "rust" => syntax_set.find_syntax_by_extension("rs"),
136 "javascript" => syntax_set.find_syntax_by_extension("js"),
137 "htm" => syntax_set.find_syntax_by_extension("html"),
138 "svg" => syntax_set.find_syntax_by_extension("xml"),
139 "markdown" => syntax_set.find_syntax_by_extension("md"),
140 "text" => Some(syntax_set.find_syntax_plain_text()),
141 _ => syntax_set.find_syntax_by_extension(self.syntax.as_str()),
142 }
143 .or(Some(syntax_set.find_syntax_plain_text()));
144
145 // Choose the render range:
146 // - If a cache window is defined, render that larger window
147 // to avoid frequent cache clears as the user scrolls.
148 // - Otherwise, render only the current visible range.
149 let (start_idx, end_idx) = if self.cache_window_end_line
150 > self.cache_window_start_line
151 {
152 let s = self.cache_window_start_line.min(visual_lines.len());
153 let e = self.cache_window_end_line.min(visual_lines.len());
154 (s, e)
155 } else {
156 (first_visible_line, last_visible_line)
157 };
158
159 for (idx, visual_line) in visual_lines
160 .iter()
161 .enumerate()
162 .skip(start_idx)
163 .take(end_idx.saturating_sub(start_idx))
164 {
165 let y = idx as f32 * self.line_height;
166
167 // Note: Gutter background is handled by a container in view.rs
168 // to ensure proper clipping when the pane is resized.
169
170 // Draw line number only for first segment
171 if self.line_numbers_enabled {
172 if visual_line.is_first_segment() {
173 let line_num = visual_line.logical_line + 1;
174 let line_num_text = format!("{}", line_num);
175 // Calculate actual text width and center in gutter
176 let text_width = measure_text_width(
177 &line_num_text,
178 self.full_char_width,
179 self.char_width,
180 );
181 let x_pos = (self.gutter_width() - text_width) / 2.0;
182 frame.fill_text(canvas::Text {
183 content: line_num_text,
184 position: Point::new(x_pos, y + 2.0),
185 color: self.style.line_number_color,
186 size: self.font_size.into(),
187 font: self.font,
188 ..canvas::Text::default()
189 });
190 } else {
191 // Draw wrap indicator for continuation lines
192 frame.fill_text(canvas::Text {
193 content: "↪".to_string(),
194 position: Point::new(
195 self.gutter_width() - 20.0,
196 y + 2.0,
197 ),
198 color: self.style.line_number_color,
199 size: self.font_size.into(),
200 font: self.font,
201 ..canvas::Text::default()
202 });
203 }
204 }
205
206 // Highlight current line (based on logical line)
207 if visual_line.logical_line == self.cursor.0 {
208 frame.fill_rectangle(
209 Point::new(self.gutter_width(), y),
210 Size::new(
211 bounds.width - self.gutter_width(),
212 self.line_height,
213 ),
214 self.style.current_line_highlight,
215 );
216 }
217
218 // Draw text content with syntax highlighting
219 let full_line_content =
220 self.buffer.line(visual_line.logical_line);
221
222 // Convert character indices to byte indices for UTF-8 string slicing
223 let start_byte = full_line_content
224 .char_indices()
225 .nth(visual_line.start_col)
226 .map_or(full_line_content.len(), |(idx, _)| idx);
227 let end_byte = full_line_content
228 .char_indices()
229 .nth(visual_line.end_col)
230 .map_or(full_line_content.len(), |(idx, _)| idx);
231 let line_segment = &full_line_content[start_byte..end_byte];
232
233 if let Some(syntax) = syntax_ref {
234 let mut highlighter =
235 HighlightLines::new(syntax, syntax_theme);
236
237 // Highlight the full line to get correct token colors
238 let full_line_ranges = highlighter
239 .highlight_line(full_line_content, &syntax_set)
240 .unwrap_or_else(|_| {
241 vec![(Style::default(), full_line_content)]
242 });
243
244 // Extract only the ranges that fall within our segment
245 let mut x_offset = self.gutter_width() + 5.0;
246 let mut char_pos = 0;
247
248 for (style, text) in full_line_ranges {
249 let text_len = text.chars().count();
250 let text_end = char_pos + text_len;
251
252 // Check if this token intersects with our segment
253 if text_end > visual_line.start_col
254 && char_pos < visual_line.end_col
255 {
256 // Calculate the intersection
257 let segment_start =
258 char_pos.max(visual_line.start_col);
259 let segment_end = text_end.min(visual_line.end_col);
260
261 let text_start_offset =
262 segment_start.saturating_sub(char_pos);
263 let text_end_offset = text_start_offset
264 + (segment_end - segment_start);
265
266 // Convert character offsets to byte offsets for UTF-8 slicing
267 let start_byte = text
268 .char_indices()
269 .nth(text_start_offset)
270 .map_or(text.len(), |(idx, _)| idx);
271 let end_byte = text
272 .char_indices()
273 .nth(text_end_offset)
274 .map_or(text.len(), |(idx, _)| idx);
275
276 let segment_text = &text[start_byte..end_byte];
277
278 let color = Color::from_rgb(
279 f32::from(style.foreground.r) / 255.0,
280 f32::from(style.foreground.g) / 255.0,
281 f32::from(style.foreground.b) / 255.0,
282 );
283
284 frame.fill_text(canvas::Text {
285 content: segment_text.to_string(),
286 position: Point::new(x_offset, y + 2.0),
287 color,
288 size: self.font_size.into(),
289 font: self.font,
290 ..canvas::Text::default()
291 });
292
293 x_offset += measure_text_width(
294 segment_text,
295 self.full_char_width,
296 self.char_width,
297 );
298 }
299
300 char_pos = text_end;
301 }
302 } else {
303 // Fallback to plain text
304 frame.fill_text(canvas::Text {
305 content: line_segment.to_string(),
306 position: Point::new(
307 self.gutter_width() + 5.0,
308 y + 2.0,
309 ),
310 color: self.style.text_color,
311 size: self.font_size.into(),
312 font: self.font,
313 ..canvas::Text::default()
314 });
315 }
316 }
317
318 // Draw search match highlights
319 if self.search_state.is_open && !self.search_state.query.is_empty()
320 {
321 let query_len = self.search_state.query.chars().count();
322
323 // Optimization: Only draw matches that are within the visible area
324 // Find the range of visible logical lines based on visible visual lines
325 let start_visual_idx = first_visible_line;
326 // last_visible_line is exclusive bound, so subtract 1 for last index
327 let end_visual_idx = last_visible_line
328 .saturating_sub(1)
329 .min(visual_lines.len().saturating_sub(1));
330
331 if let (Some(start_vl), Some(end_vl)) = (
332 visual_lines.get(start_visual_idx),
333 visual_lines.get(end_visual_idx),
334 ) {
335 let min_logical_line = start_vl.logical_line;
336 let max_logical_line = end_vl.logical_line;
337
338 // Optimization: Use get_visible_match_range to find matches in view
339 // This uses binary search + early termination for O(log N) performance
340 let match_range = super::search::get_visible_match_range(
341 &self.search_state.matches,
342 min_logical_line,
343 max_logical_line,
344 );
345
346 for (match_idx, search_match) in self
347 .search_state
348 .matches
349 .iter()
350 .enumerate()
351 .skip(match_range.start)
352 .take(match_range.len())
353 {
354 // Determine if this is the current match
355 let is_current = self.search_state.current_match_index
356 == Some(match_idx);
357
358 let highlight_color = if is_current {
359 // Orange for current match
360 Color { r: 1.0, g: 0.6, b: 0.0, a: 0.4 }
361 } else {
362 // Yellow for other matches
363 Color { r: 1.0, g: 1.0, b: 0.0, a: 0.3 }
364 };
365
366 // Convert logical position to visual line
367 let start_visual =
368 WrappingCalculator::logical_to_visual(
369 &visual_lines,
370 search_match.line,
371 search_match.col,
372 );
373 let end_visual = WrappingCalculator::logical_to_visual(
374 &visual_lines,
375 search_match.line,
376 search_match.col + query_len,
377 );
378
379 if let (Some(start_v), Some(end_v)) =
380 (start_visual, end_visual)
381 {
382 if start_v == end_v {
383 // Match within same visual line
384 let y = start_v as f32 * self.line_height;
385 let vl = &visual_lines[start_v];
386 let line_content =
387 self.buffer.line(vl.logical_line);
388
389 // Use calculate_segment_geometry to compute match position and width
390 let (x_start, match_width) =
391 calculate_segment_geometry(
392 line_content,
393 vl.start_col,
394 search_match.col,
395 search_match.col + query_len,
396 self.gutter_width() + 5.0,
397 self.full_char_width,
398 self.char_width,
399 );
400 let x_end = x_start + match_width;
401
402 frame.fill_rectangle(
403 Point::new(x_start, y + 2.0),
404 Size::new(
405 x_end - x_start,
406 self.line_height - 4.0,
407 ),
408 highlight_color,
409 );
410 } else {
411 // Match spans multiple visual lines
412 for (v_idx, vl) in visual_lines
413 .iter()
414 .enumerate()
415 .skip(start_v)
416 .take(end_v - start_v + 1)
417 {
418 let y = v_idx as f32 * self.line_height;
419
420 let match_start_col = search_match.col;
421 let match_end_col =
422 search_match.col + query_len;
423
424 let sel_start_col = if v_idx == start_v {
425 match_start_col
426 } else {
427 vl.start_col
428 };
429 let sel_end_col = if v_idx == end_v {
430 match_end_col
431 } else {
432 vl.end_col
433 };
434
435 let line_content =
436 self.buffer.line(vl.logical_line);
437
438 let (x_start, sel_width) =
439 calculate_segment_geometry(
440 line_content,
441 vl.start_col,
442 sel_start_col,
443 sel_end_col,
444 self.gutter_width() + 5.0,
445 self.full_char_width,
446 self.char_width,
447 );
448 let x_end = x_start + sel_width;
449
450 frame.fill_rectangle(
451 Point::new(x_start, y + 2.0),
452 Size::new(
453 x_end - x_start,
454 self.line_height - 4.0,
455 ),
456 highlight_color,
457 );
458 }
459 }
460 }
461 }
462 }
463 }
464
465 // Draw selection highlight
466 if let Some((start, end)) = self.get_selection_range()
467 && start != end
468 {
469 let selection_color = Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 };
470
471 if start.0 == end.0 {
472 // Single line selection - need to handle wrapped segments
473 let start_visual = WrappingCalculator::logical_to_visual(
474 &visual_lines,
475 start.0,
476 start.1,
477 );
478 let end_visual = WrappingCalculator::logical_to_visual(
479 &visual_lines,
480 end.0,
481 end.1,
482 );
483
484 if let (Some(start_v), Some(end_v)) =
485 (start_visual, end_visual)
486 {
487 if start_v == end_v {
488 // Selection within same visual line
489 let y = start_v as f32 * self.line_height;
490 let vl = &visual_lines[start_v];
491 let line_content =
492 self.buffer.line(vl.logical_line);
493
494 let (x_start, sel_width) =
495 calculate_segment_geometry(
496 line_content,
497 vl.start_col,
498 start.1,
499 end.1,
500 self.gutter_width() + 5.0,
501 self.full_char_width,
502 self.char_width,
503 );
504 let x_end = x_start + sel_width;
505
506 frame.fill_rectangle(
507 Point::new(x_start, y + 2.0),
508 Size::new(
509 x_end - x_start,
510 self.line_height - 4.0,
511 ),
512 selection_color,
513 );
514 } else {
515 // Selection spans multiple visual lines (same logical line)
516 for (v_idx, vl) in visual_lines
517 .iter()
518 .enumerate()
519 .skip(start_v)
520 .take(end_v - start_v + 1)
521 {
522 let y = v_idx as f32 * self.line_height;
523
524 let sel_start_col = if v_idx == start_v {
525 start.1
526 } else {
527 vl.start_col
528 };
529 let sel_end_col = if v_idx == end_v {
530 end.1
531 } else {
532 vl.end_col
533 };
534
535 let line_content =
536 self.buffer.line(vl.logical_line);
537
538 let (x_start, sel_width) =
539 calculate_segment_geometry(
540 line_content,
541 vl.start_col,
542 sel_start_col,
543 sel_end_col,
544 self.gutter_width() + 5.0,
545 self.full_char_width,
546 self.char_width,
547 );
548 let x_end = x_start + sel_width;
549
550 frame.fill_rectangle(
551 Point::new(x_start, y + 2.0),
552 Size::new(
553 x_end - x_start,
554 self.line_height - 4.0,
555 ),
556 selection_color,
557 );
558 }
559 }
560 }
561 } else {
562 // Multi-line selection
563 let start_visual = WrappingCalculator::logical_to_visual(
564 &visual_lines,
565 start.0,
566 start.1,
567 );
568 let end_visual = WrappingCalculator::logical_to_visual(
569 &visual_lines,
570 end.0,
571 end.1,
572 );
573
574 if let (Some(start_v), Some(end_v)) =
575 (start_visual, end_visual)
576 {
577 for (v_idx, vl) in visual_lines
578 .iter()
579 .enumerate()
580 .skip(start_v)
581 .take(end_v - start_v + 1)
582 {
583 let y = v_idx as f32 * self.line_height;
584
585 let sel_start_col = if vl.logical_line == start.0
586 && v_idx == start_v
587 {
588 start.1
589 } else {
590 vl.start_col
591 };
592
593 let sel_end_col =
594 if vl.logical_line == end.0 && v_idx == end_v {
595 end.1
596 } else {
597 vl.end_col
598 };
599
600 let line_content =
601 self.buffer.line(vl.logical_line);
602
603 let (x_start, sel_width) =
604 calculate_segment_geometry(
605 line_content,
606 vl.start_col,
607 sel_start_col,
608 sel_end_col,
609 self.gutter_width() + 5.0,
610 self.full_char_width,
611 self.char_width,
612 );
613 let x_end = x_start + sel_width;
614
615 frame.fill_rectangle(
616 Point::new(x_start, y + 2.0),
617 Size::new(
618 x_end - x_start,
619 self.line_height - 4.0,
620 ),
621 selection_color,
622 );
623 }
624 }
625 }
626 }
627
628 // Cursor drawing logic (only when the editor has focus)
629 // -------------------------------------------------------------------------
630 // Core notes:
631 // 1. Choose the drawing path based on whether IME preedit is present.
632 // 2. Require both `is_focused()` (Iced focus) and `has_canvas_focus()` (internal focus)
633 // so the cursor is drawn only in the active editor, avoiding multiple cursors.
634 // 3. Use `WrappingCalculator` to map logical (line, col) to visual (x, y)
635 // for correct cursor positioning with line wrapping.
636 // -------------------------------------------------------------------------
637 if self.show_cursor
638 && self.cursor_visible
639 && self.is_focused()
640 && self.has_canvas_focus
641 && self.ime_preedit.is_some()
642 {
643 // [Branch A] IME preedit rendering mode
644 // ---------------------------------------------------------------------
645 // When the user is composing with an IME (e.g. pinyin before commit),
646 // draw a preedit region instead of the normal caret, including:
647 // - preedit background (highlighting the composing text)
648 // - preedit text content (preedit.content)
649 // - preedit selection (underline or selection background)
650 // - preedit caret
651 // ---------------------------------------------------------------------
652 if let Some(cursor_visual) =
653 WrappingCalculator::logical_to_visual(
654 &visual_lines,
655 self.cursor.0,
656 self.cursor.1,
657 )
658 {
659 let vl = &visual_lines[cursor_visual];
660 let line_content = self.buffer.line(vl.logical_line);
661
662 // Compute the preedit region start X
663 // Use calculate_segment_geometry to ensure correct CJK width handling
664 let (cursor_x, _) = calculate_segment_geometry(
665 line_content,
666 vl.start_col,
667 self.cursor.1,
668 self.cursor.1,
669 self.gutter_width() + 5.0,
670 self.full_char_width,
671 self.char_width,
672 );
673 let cursor_y = cursor_visual as f32 * self.line_height;
674
675 if let Some(preedit) = self.ime_preedit.as_ref() {
676 let preedit_width = measure_text_width(
677 &preedit.content,
678 self.full_char_width,
679 self.char_width,
680 );
681
682 // 1. Draw preedit background (light translucent)
683 // This indicates the text is not committed yet
684 frame.fill_rectangle(
685 Point::new(cursor_x, cursor_y + 2.0),
686 Size::new(preedit_width, self.line_height - 4.0),
687 Color { r: 1.0, g: 1.0, b: 1.0, a: 0.08 },
688 );
689
690 // 2. Draw preedit selection (if any)
691 // IME may mark a selection inside preedit text (e.g. segmentation)
692 // The range uses UTF-8 byte indices, so slices must be safe
693 if let Some(range) = preedit.selection.as_ref()
694 && range.start != range.end
695 {
696 // Validate indices before slicing to prevent panic
697 if let Some((start, end)) =
698 validate_selection_indices(
699 &preedit.content,
700 range.start,
701 range.end,
702 )
703 {
704 let selected_prefix = &preedit.content[..start];
705 let selected_text =
706 &preedit.content[start..end];
707
708 let selection_x = cursor_x
709 + measure_text_width(
710 selected_prefix,
711 self.full_char_width,
712 self.char_width,
713 );
714 let selection_w = measure_text_width(
715 selected_text,
716 self.full_char_width,
717 self.char_width,
718 );
719
720 frame.fill_rectangle(
721 Point::new(selection_x, cursor_y + 2.0),
722 Size::new(
723 selection_w,
724 self.line_height - 4.0,
725 ),
726 Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 },
727 );
728 }
729 }
730
731 // 3. Draw preedit text itself
732 frame.fill_text(canvas::Text {
733 content: preedit.content.clone(),
734 position: Point::new(cursor_x, cursor_y + 2.0),
735 color: self.style.text_color,
736 size: self.font_size.into(),
737 font: self.font,
738 ..canvas::Text::default()
739 });
740
741 // 4. Draw bottom underline (IME state indicator)
742 frame.fill_rectangle(
743 Point::new(
744 cursor_x,
745 cursor_y + self.line_height - 3.0,
746 ),
747 Size::new(preedit_width, 1.0),
748 self.style.text_color,
749 );
750
751 // 5. Draw preedit caret
752 // If IME provides a caret position (usually selection end), draw a thin bar
753 if let Some(range) = preedit.selection.as_ref() {
754 let caret_end =
755 range.end.min(preedit.content.len());
756
757 // Validate caret position to avoid panic on invalid UTF-8 boundary
758 if caret_end <= preedit.content.len()
759 && preedit.content.is_char_boundary(caret_end)
760 {
761 let caret_prefix =
762 &preedit.content[..caret_end];
763 let caret_x = cursor_x
764 + measure_text_width(
765 caret_prefix,
766 self.full_char_width,
767 self.char_width,
768 );
769
770 frame.fill_rectangle(
771 Point::new(caret_x, cursor_y + 2.0),
772 Size::new(2.0, self.line_height - 4.0),
773 self.style.text_color,
774 );
775 }
776 }
777 }
778 }
779 } else if self.show_cursor
780 && self.cursor_visible
781 && self.is_focused()
782 && self.has_canvas_focus
783 {
784 // [Branch B] Normal caret rendering mode
785 // ---------------------------------------------------------------------
786 // When there is no IME preedit, draw the standard editor caret.
787 // Key checks:
788 // - is_focused(): the widget has Iced focus
789 // - has_canvas_focus: internal focus state (mouse clicks, etc.)
790 // - draw only when both are true to avoid ghost cursors
791 // ---------------------------------------------------------------------
792
793 // Map logical cursor position (Line, Col) to visual line index
794 // to handle line wrapping changes
795 if let Some(cursor_visual) =
796 WrappingCalculator::logical_to_visual(
797 &visual_lines,
798 self.cursor.0,
799 self.cursor.1,
800 )
801 {
802 let vl = &visual_lines[cursor_visual];
803 let line_content = self.buffer.line(vl.logical_line);
804
805 // Compute exact caret X position
806 // Account for gutter width, left padding, and rendered prefix width
807 let (cursor_x, _) = calculate_segment_geometry(
808 line_content,
809 vl.start_col,
810 self.cursor.1,
811 self.cursor.1,
812 self.gutter_width() + 5.0,
813 self.full_char_width,
814 self.char_width,
815 );
816 let cursor_y = cursor_visual as f32 * self.line_height;
817
818 // Draw standard caret (2px vertical bar)
819 frame.fill_rectangle(
820 Point::new(cursor_x, cursor_y + 2.0),
821 Size::new(2.0, self.line_height - 4.0),
822 self.style.text_color,
823 );
824 }
825 }
826 });
827
828 vec![geometry]
829 }
830
831 /// Handles Canvas trait events, specifically keyboard input events and focus management for the code editor widget.
832 ///
833 /// # Arguments
834 ///
835 /// * `_state` - The mutable state of the canvas (unused in this implementation)
836 /// * `event` - The input event to handle, such as keyboard presses
837 /// * `bounds` - The rectangle bounds of the canvas widget
838 /// * `cursor` - The current mouse cursor position and status
839 ///
840 /// # Returns
841 ///
842 /// An optional `Action<Message>` to perform, such as sending a message or redrawing the canvas
843 fn update(
844 &self,
845 _state: &mut Self::State,
846 event: &Event,
847 bounds: Rectangle,
848 cursor: mouse::Cursor,
849 ) -> Option<Action<Message>> {
850 match event {
851 Event::Keyboard(keyboard::Event::KeyPressed {
852 key,
853 modifiers,
854 text,
855 ..
856 }) => {
857 // Only process keyboard events if this editor has focus
858 let focused_id = super::FOCUSED_EDITOR_ID
859 .load(std::sync::atomic::Ordering::Relaxed);
860 if focused_id != self.editor_id {
861 return None;
862 }
863
864 // Cursor outside canvas bounds
865 if !is_cursor_in_bounds(&cursor, bounds) {
866 return None;
867 }
868
869 // Only process keyboard events if canvas has focus
870 if !self.has_canvas_focus {
871 return None;
872 }
873
874 if self.ime_preedit.is_some()
875 && !(modifiers.control() || modifiers.command())
876 {
877 return None;
878 }
879
880 // Handle Ctrl+C / Ctrl+Insert (copy)
881 if (modifiers.control()
882 && matches!(key, keyboard::Key::Character(c) if c.as_str() == "c"))
883 || (modifiers.control()
884 && matches!(
885 key,
886 keyboard::Key::Named(keyboard::key::Named::Insert)
887 ))
888 {
889 return Some(Action::publish(Message::Copy).and_capture());
890 }
891
892 // Handle Ctrl+Z (undo)
893 if modifiers.control()
894 && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")
895 {
896 return Some(Action::publish(Message::Undo).and_capture());
897 }
898
899 // Handle Ctrl+Y (redo)
900 if modifiers.control()
901 && matches!(key, keyboard::Key::Character(y) if y.as_str() == "y")
902 {
903 return Some(Action::publish(Message::Redo).and_capture());
904 }
905
906 // Handle Ctrl+F (open search)
907 if modifiers.control()
908 && matches!(key, keyboard::Key::Character(f) if f.as_str() == "f")
909 && self.search_replace_enabled
910 {
911 return Some(
912 Action::publish(Message::OpenSearch).and_capture(),
913 );
914 }
915
916 // Handle Ctrl+H (open search and replace)
917 if modifiers.control()
918 && matches!(key, keyboard::Key::Character(h) if h.as_str() == "h")
919 && self.search_replace_enabled
920 {
921 return Some(
922 Action::publish(Message::OpenSearchReplace)
923 .and_capture(),
924 );
925 }
926
927 // Handle Escape (close search dialog if open)
928 if matches!(
929 key,
930 keyboard::Key::Named(keyboard::key::Named::Escape)
931 ) {
932 return Some(
933 Action::publish(Message::CloseSearch).and_capture(),
934 );
935 }
936
937 // Handle Tab (cycle forward in search dialog if open)
938 if matches!(
939 key,
940 keyboard::Key::Named(keyboard::key::Named::Tab)
941 ) && self.search_state.is_open
942 {
943 if modifiers.shift() {
944 // Shift+Tab: cycle backward
945 return Some(
946 Action::publish(Message::SearchDialogShiftTab)
947 .and_capture(),
948 );
949 } else {
950 // Tab: cycle forward
951 return Some(
952 Action::publish(Message::SearchDialogTab)
953 .and_capture(),
954 );
955 }
956 }
957
958 // Handle F3 (find next) and Shift+F3 (find previous)
959 if matches!(key, keyboard::Key::Named(keyboard::key::Named::F3))
960 && self.search_replace_enabled
961 {
962 if modifiers.shift() {
963 return Some(
964 Action::publish(Message::FindPrevious)
965 .and_capture(),
966 );
967 } else {
968 return Some(
969 Action::publish(Message::FindNext).and_capture(),
970 );
971 }
972 }
973
974 // Handle Ctrl+V / Shift+Insert (paste) - read clipboard and send paste message
975 if (modifiers.control()
976 && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v"))
977 || (modifiers.shift()
978 && matches!(
979 key,
980 keyboard::Key::Named(keyboard::key::Named::Insert)
981 ))
982 {
983 // Return an action that requests clipboard read
984 return Some(Action::publish(
985 Message::Paste(String::new()),
986 ));
987 }
988
989 // Handle Ctrl+Home (go to start of document)
990 if modifiers.control()
991 && matches!(
992 key,
993 keyboard::Key::Named(keyboard::key::Named::Home)
994 )
995 {
996 return Some(
997 Action::publish(Message::CtrlHome).and_capture(),
998 );
999 }
1000
1001 // Handle Ctrl+End (go to end of document)
1002 if modifiers.control()
1003 && matches!(
1004 key,
1005 keyboard::Key::Named(keyboard::key::Named::End)
1006 )
1007 {
1008 return Some(
1009 Action::publish(Message::CtrlEnd).and_capture(),
1010 );
1011 }
1012
1013 // Handle Shift+Delete (delete selection)
1014 if modifiers.shift()
1015 && matches!(
1016 key,
1017 keyboard::Key::Named(keyboard::key::Named::Delete)
1018 )
1019 {
1020 return Some(
1021 Action::publish(Message::DeleteSelection).and_capture(),
1022 );
1023 }
1024
1025 // PRIORITY 1: Check if 'text' field has valid printable character
1026 // This handles:
1027 // - Numpad keys with NumLock ON (key=Named(ArrowDown), text=Some("2"))
1028 // - Regular typing with shift, accents, international layouts
1029 if let Some(text_content) = text
1030 && !text_content.is_empty()
1031 && !modifiers.control()
1032 && !modifiers.alt()
1033 {
1034 // Check if it's a printable character (not a control character)
1035 // This filters out Enter (\n), Tab (\t), Delete (U+007F), etc.
1036 if let Some(first_char) = text_content.chars().next()
1037 && !first_char.is_control()
1038 {
1039 return Some(
1040 Action::publish(Message::CharacterInput(
1041 first_char,
1042 ))
1043 .and_capture(),
1044 );
1045 }
1046 }
1047
1048 // PRIORITY 2: Handle special named keys (navigation, editing)
1049 // These are only processed if text didn't contain a printable character
1050 let message = match key {
1051 keyboard::Key::Named(keyboard::key::Named::Backspace) => {
1052 Some(Message::Backspace)
1053 }
1054 keyboard::Key::Named(keyboard::key::Named::Delete) => {
1055 Some(Message::Delete)
1056 }
1057 keyboard::Key::Named(keyboard::key::Named::Enter) => {
1058 Some(Message::Enter)
1059 }
1060 keyboard::Key::Named(keyboard::key::Named::Tab) => {
1061 // Insert 4 spaces for Tab
1062 Some(Message::Tab)
1063 }
1064 keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
1065 Some(Message::ArrowKey(
1066 ArrowDirection::Up,
1067 modifiers.shift(),
1068 ))
1069 }
1070 keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
1071 Some(Message::ArrowKey(
1072 ArrowDirection::Down,
1073 modifiers.shift(),
1074 ))
1075 }
1076 keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
1077 Some(Message::ArrowKey(
1078 ArrowDirection::Left,
1079 modifiers.shift(),
1080 ))
1081 }
1082 keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
1083 Some(Message::ArrowKey(
1084 ArrowDirection::Right,
1085 modifiers.shift(),
1086 ))
1087 }
1088 keyboard::Key::Named(keyboard::key::Named::PageUp) => {
1089 Some(Message::PageUp)
1090 }
1091 keyboard::Key::Named(keyboard::key::Named::PageDown) => {
1092 Some(Message::PageDown)
1093 }
1094 keyboard::Key::Named(keyboard::key::Named::Home) => {
1095 Some(Message::Home(modifiers.shift()))
1096 }
1097 keyboard::Key::Named(keyboard::key::Named::End) => {
1098 Some(Message::End(modifiers.shift()))
1099 }
1100 // PRIORITY 3: Fallback to extracting from 'key' if text was empty/control char
1101 // This handles edge cases where text field is not populated
1102 _ => {
1103 if !modifiers.control()
1104 && !modifiers.alt()
1105 && let keyboard::Key::Character(c) = key
1106 && !c.is_empty()
1107 {
1108 return c
1109 .chars()
1110 .next()
1111 .map(Message::CharacterInput)
1112 .map(|msg| Action::publish(msg).and_capture());
1113 }
1114 None
1115 }
1116 };
1117
1118 message.map(|msg| Action::publish(msg).and_capture())
1119 }
1120 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
1121 cursor.position_in(bounds).map(|position| {
1122 // Don't capture the event so it can bubble up for focus management
1123 Action::publish(Message::MouseClick(position))
1124 })
1125 }
1126 Event::Mouse(mouse::Event::CursorMoved { .. }) => {
1127 // Handle mouse drag for selection only when cursor is within bounds
1128 cursor.position_in(bounds).map(|position| {
1129 Action::publish(Message::MouseDrag(position)).and_capture()
1130 })
1131 }
1132 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
1133 // Only handle mouse release when cursor is within bounds
1134 // This prevents capturing events meant for other widgets
1135 if cursor.is_over(bounds) {
1136 Some(Action::publish(Message::MouseRelease).and_capture())
1137 } else {
1138 None
1139 }
1140 }
1141 Event::InputMethod(event) => {
1142 let focused_id = super::FOCUSED_EDITOR_ID
1143 .load(std::sync::atomic::Ordering::Relaxed);
1144 if focused_id != self.editor_id {
1145 return None;
1146 }
1147
1148 if !is_cursor_in_bounds(&cursor, bounds) {
1149 return None;
1150 }
1151
1152 if !self.has_canvas_focus {
1153 return None;
1154 }
1155
1156 // IME event handling
1157 // ---------------------------------------------------------------------
1158 // Core mapping: convert Iced IME events into editor Messages
1159 //
1160 // Flow:
1161 // 1. Opened: IME activated (e.g. switching input method). Clear old preedit state.
1162 // 2. Preedit: User is composing (e.g. typing "nihao" before commit).
1163 // - content: current candidate text
1164 // - selection: selection range within the text, in bytes
1165 // 3. Commit: User confirms a candidate and commits text into the buffer.
1166 // 4. Closed: IME closed or lost focus.
1167 //
1168 // Safety checks:
1169 // - handle only when `focused_id` matches this editor ID
1170 // - handle only when `has_canvas_focus` is true
1171 // This ensures IME events are not delivered to the wrong widget.
1172 // ---------------------------------------------------------------------
1173 let message = match event {
1174 input_method::Event::Opened => Message::ImeOpened,
1175 input_method::Event::Preedit(content, selection) => {
1176 Message::ImePreedit(content.clone(), selection.clone())
1177 }
1178 input_method::Event::Commit(content) => {
1179 Message::ImeCommit(content.clone())
1180 }
1181 input_method::Event::Closed => Message::ImeClosed,
1182 };
1183
1184 Some(Action::publish(message).and_capture())
1185 }
1186 _ => None,
1187 }
1188 }
1189}
1190
1191/// Validates that the selection indices fall on valid UTF-8 character boundaries
1192/// to prevent panics during string slicing.
1193///
1194/// # Arguments
1195///
1196/// * `content` - The string content to check against
1197/// * `start` - The start byte index
1198/// * `end` - The end byte index
1199///
1200/// # Returns
1201///
1202/// `Some((start, end))` if indices are valid, `None` otherwise.
1203fn validate_selection_indices(
1204 content: &str,
1205 start: usize,
1206 end: usize,
1207) -> Option<(usize, usize)> {
1208 let len = content.len();
1209 // Clamp indices to content length
1210 let start = start.min(len);
1211 let end = end.min(len);
1212
1213 // Ensure start is not greater than end
1214 if start > end {
1215 return None;
1216 }
1217
1218 // Verify that indices fall on valid UTF-8 character boundaries
1219 if content.is_char_boundary(start) && content.is_char_boundary(end) {
1220 Some((start, end))
1221 } else {
1222 None
1223 }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228 use super::*;
1229 use crate::canvas_editor::{CHAR_WIDTH, FONT_SIZE, compare_floats};
1230 use std::cmp::Ordering;
1231
1232 #[test]
1233 fn test_calculate_segment_geometry_ascii() {
1234 // "Hello World"
1235 // "Hello " (6 chars) -> prefix
1236 // "World" (5 chars) -> segment
1237 // width("Hello ") = 6 * CHAR_WIDTH
1238 // width("World") = 5 * CHAR_WIDTH
1239 let content = "Hello World";
1240 let (x, w) = calculate_segment_geometry(
1241 content, 0, 6, 11, 0.0, FONT_SIZE, CHAR_WIDTH,
1242 );
1243
1244 let expected_x = CHAR_WIDTH * 6.0;
1245 let expected_w = CHAR_WIDTH * 5.0;
1246
1247 assert_eq!(
1248 compare_floats(x, expected_x),
1249 Ordering::Equal,
1250 "X position mismatch for ASCII"
1251 );
1252 assert_eq!(
1253 compare_floats(w, expected_w),
1254 Ordering::Equal,
1255 "Width mismatch for ASCII"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_calculate_segment_geometry_cjk() {
1261 // "你好世界"
1262 // "你好" (2 chars) -> prefix
1263 // "世界" (2 chars) -> segment
1264 // width("你好") = 2 * FONT_SIZE
1265 // width("世界") = 2 * FONT_SIZE
1266 let content = "你好世界";
1267 let (x, w) = calculate_segment_geometry(
1268 content, 0, 2, 4, 10.0, FONT_SIZE, CHAR_WIDTH,
1269 );
1270
1271 let expected_x = 10.0 + FONT_SIZE * 2.0;
1272 let expected_w = FONT_SIZE * 2.0;
1273
1274 assert_eq!(
1275 compare_floats(x, expected_x),
1276 Ordering::Equal,
1277 "X position mismatch for CJK"
1278 );
1279 assert_eq!(
1280 compare_floats(w, expected_w),
1281 Ordering::Equal,
1282 "Width mismatch for CJK"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_calculate_segment_geometry_mixed() {
1288 // "Hi你好"
1289 // "Hi" (2 chars) -> prefix
1290 // "你好" (2 chars) -> segment
1291 // width("Hi") = 2 * CHAR_WIDTH
1292 // width("你好") = 2 * FONT_SIZE
1293 let content = "Hi你好";
1294 let (x, w) = calculate_segment_geometry(
1295 content, 0, 2, 4, 0.0, FONT_SIZE, CHAR_WIDTH,
1296 );
1297
1298 let expected_x = CHAR_WIDTH * 2.0;
1299 let expected_w = FONT_SIZE * 2.0;
1300
1301 assert_eq!(
1302 compare_floats(x, expected_x),
1303 Ordering::Equal,
1304 "X position mismatch for mixed content"
1305 );
1306 assert_eq!(
1307 compare_floats(w, expected_w),
1308 Ordering::Equal,
1309 "Width mismatch for mixed content"
1310 );
1311 }
1312
1313 #[test]
1314 fn test_calculate_segment_geometry_empty_range() {
1315 let content = "Hello";
1316 let (x, w) = calculate_segment_geometry(
1317 content, 0, 0, 0, 0.0, FONT_SIZE, CHAR_WIDTH,
1318 );
1319 assert!((x - 0.0).abs() < f32::EPSILON);
1320 assert!((w - 0.0).abs() < f32::EPSILON);
1321 }
1322
1323 #[test]
1324 fn test_calculate_segment_geometry_with_visual_offset() {
1325 // content: "0123456789"
1326 // visual_start_col: 2 (starts at '2')
1327 // segment: "34" (indices 3 to 5)
1328 // prefix: from visual start (2) to segment start (3) -> "2" (length 1)
1329 // prefix width: 1 * CHAR_WIDTH
1330 // segment width: 2 * CHAR_WIDTH
1331 let content = "0123456789";
1332 let (x, w) = calculate_segment_geometry(
1333 content, 2, 3, 5, 5.0, FONT_SIZE, CHAR_WIDTH,
1334 );
1335
1336 let expected_x = 5.0 + CHAR_WIDTH * 1.0;
1337 let expected_w = CHAR_WIDTH * 2.0;
1338
1339 assert_eq!(
1340 compare_floats(x, expected_x),
1341 Ordering::Equal,
1342 "X position mismatch with visual offset"
1343 );
1344 assert_eq!(
1345 compare_floats(w, expected_w),
1346 Ordering::Equal,
1347 "Width mismatch with visual offset"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_calculate_segment_geometry_out_of_bounds() {
1353 // Content length is 5 ("Hello")
1354 // Request start at 10, end at 15
1355 // visual_start 0
1356 // Prefix should consume whole string ("Hello") and stop.
1357 // Segment should be empty.
1358 let content = "Hello";
1359 let (x, w) = calculate_segment_geometry(
1360 content, 0, 10, 15, 0.0, FONT_SIZE, CHAR_WIDTH,
1361 );
1362
1363 let expected_x = CHAR_WIDTH * 5.0; // Width of "Hello"
1364 let expected_w = 0.0;
1365
1366 assert_eq!(
1367 compare_floats(x, expected_x),
1368 Ordering::Equal,
1369 "X position mismatch for out of bounds start"
1370 );
1371 assert!(
1372 (w - expected_w).abs() < f32::EPSILON,
1373 "Width should be 0 for out of bounds segment"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_calculate_segment_geometry_special_chars() {
1379 // Emoji "👋" (width > 1 => FONT_SIZE)
1380 // Tab "\t" (width None => 0.0)
1381 let content = "A👋\tB";
1382 // Measure "👋" (index 1 to 2)
1383 // Indices in chars: 'A' (0), '👋' (1), '\t' (2), 'B' (3)
1384
1385 // Segment covering Emoji
1386 let (x, w) = calculate_segment_geometry(
1387 content, 0, 1, 2, 0.0, FONT_SIZE, CHAR_WIDTH,
1388 );
1389 let expected_x_emoji = CHAR_WIDTH; // 'A'
1390 let expected_w_emoji = FONT_SIZE; // '👋'
1391
1392 assert_eq!(
1393 compare_floats(x, expected_x_emoji),
1394 Ordering::Equal,
1395 "X pos for emoji"
1396 );
1397 assert_eq!(
1398 compare_floats(w, expected_w_emoji),
1399 Ordering::Equal,
1400 "Width for emoji"
1401 );
1402
1403 // Segment covering Tab
1404 let (x_tab, w_tab) = calculate_segment_geometry(
1405 content, 0, 2, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
1406 );
1407 let expected_x_tab = CHAR_WIDTH + FONT_SIZE; // 'A' + '👋'
1408 let expected_w_tab = 0.0; // Tab width is 0 in this implementation
1409
1410 assert_eq!(
1411 compare_floats(x_tab, expected_x_tab),
1412 Ordering::Equal,
1413 "X pos for tab"
1414 );
1415 assert_eq!(
1416 compare_floats(w_tab, expected_w_tab),
1417 Ordering::Equal,
1418 "Width for tab"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_calculate_segment_geometry_inverted_range() {
1424 // Start 5, End 3
1425 // Should result in empty segment at start 5
1426 let content = "0123456789";
1427 let (x, w) = calculate_segment_geometry(
1428 content, 0, 5, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
1429 );
1430
1431 let expected_x = CHAR_WIDTH * 5.0;
1432 let expected_w = 0.0;
1433
1434 assert_eq!(
1435 compare_floats(x, expected_x),
1436 Ordering::Equal,
1437 "X pos for inverted range"
1438 );
1439 assert!(
1440 (w - expected_w).abs() < f32::EPSILON,
1441 "Width for inverted range"
1442 );
1443 }
1444
1445 #[test]
1446 fn test_validate_selection_indices() {
1447 // Test valid ASCII indices
1448 let content = "Hello";
1449 assert_eq!(validate_selection_indices(content, 0, 5), Some((0, 5)));
1450 assert_eq!(validate_selection_indices(content, 1, 3), Some((1, 3)));
1451
1452 // Test valid multi-byte indices (Chinese "你好")
1453 // "你" is 3 bytes (0-3), "好" is 3 bytes (3-6)
1454 let content = "你好";
1455 assert_eq!(validate_selection_indices(content, 0, 6), Some((0, 6)));
1456 assert_eq!(validate_selection_indices(content, 0, 3), Some((0, 3)));
1457 assert_eq!(validate_selection_indices(content, 3, 6), Some((3, 6)));
1458
1459 // Test invalid indices (splitting multi-byte char)
1460 assert_eq!(validate_selection_indices(content, 1, 3), None); // Split first char
1461 assert_eq!(validate_selection_indices(content, 0, 4), None); // Split second char
1462
1463 // Test out of bounds (should be clamped if on boundary, but here len is 6)
1464 // If we pass start=0, end=100 -> clamped to 0, 6. 6 is boundary.
1465 assert_eq!(validate_selection_indices(content, 0, 100), Some((0, 6)));
1466
1467 // Test inverted range
1468 assert_eq!(validate_selection_indices(content, 3, 0), None);
1469 }
1470}