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