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