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