1use crate::text_utils::char_range_to_byte_range;
4use iced::advanced::input_method;
5use iced::mouse;
6use iced::widget::canvas::{self, Geometry};
7use iced::{Color, Event, Point, Rectangle, Size, Theme, keyboard};
8use std::borrow::Cow;
9use std::rc::Rc;
10use std::sync::OnceLock;
11use syntect::easy::HighlightLines;
12use syntect::highlighting::{
13 HighlightIterator, HighlightState, Highlighter, Style, ThemeSet,
14};
15use syntect::parsing::{ParseState, ScopeStack, SyntaxSet};
16
17fn calculate_segment_geometry(
35 line_content: &str,
36 visual_start_col: usize,
37 segment_start_col: usize,
38 segment_end_col: usize,
39 base_offset: f32,
40 full_char_width: f32,
41 char_width: f32,
42) -> (f32, f32) {
43 let segment_start_col = segment_start_col.max(visual_start_col);
46 let segment_end_col = segment_end_col.max(segment_start_col);
47
48 let mut prefix_width = 0.0;
49 let mut segment_width = 0.0;
50
51 for (i, c) in line_content.chars().enumerate() {
54 if i >= segment_end_col {
55 break;
56 }
57
58 let w = super::measure_char_width(c, full_char_width, char_width);
59
60 if i >= visual_start_col && i < segment_start_col {
61 prefix_width += w;
62 } else if i >= segment_start_col {
63 segment_width += w;
64 }
65 }
66
67 (base_offset + prefix_width, segment_width)
68}
69
70fn expand_tabs(text: &str, tab_width: usize) -> Cow<'_, str> {
71 if !text.contains('\t') {
72 return Cow::Borrowed(text);
73 }
74
75 let mut expanded = String::with_capacity(text.len());
76 for ch in text.chars() {
77 if ch == '\t' {
78 for _ in 0..tab_width {
79 expanded.push(' ');
80 }
81 } else {
82 expanded.push(ch);
83 }
84 }
85
86 Cow::Owned(expanded)
87}
88
89fn expand_tabs_visible(text: &str, tab_width: usize) -> String {
93 let mut result = String::with_capacity(text.len() * 2);
94 for ch in text.chars() {
95 match ch {
96 '\t' => {
97 result.push('→');
98 for _ in 1..tab_width {
99 result.push('·');
100 }
101 }
102 ' ' => result.push('·'),
103 other => result.push(other),
104 }
105 }
106 result
107}
108
109fn split_whitespace_segments(text: &str) -> Vec<(bool, &str)> {
113 if text.is_empty() {
114 return vec![];
115 }
116
117 let mut result = Vec::new();
118 let mut seg_start = 0usize;
119 let mut chars = text.char_indices().peekable();
120
121 let is_ws_char = |c: char| c == '·' || c == '→';
122
123 let first_ch = chars.peek().map(|(_, c)| *c).unwrap_or(' ');
124 let mut current_is_ws = is_ws_char(first_ch);
125
126 for (byte_idx, ch) in chars {
127 let ch_is_ws = is_ws_char(ch);
128 if ch_is_ws != current_is_ws {
129 result.push((current_is_ws, &text[seg_start..byte_idx]));
130 seg_start = byte_idx;
131 current_is_ws = ch_is_ws;
132 }
133 }
134 result.push((current_is_ws, &text[seg_start..]));
135 result
136}
137
138fn color_from_style(style: Style) -> Color {
146 Color::from_rgb(
147 f32::from(style.foreground.r) / 255.0,
148 f32::from(style.foreground.g) / 255.0,
149 f32::from(style.foreground.b) / 255.0,
150 )
151}
152
153pub fn highlight_line_spans(
172 line: &str,
173 syntax: &syntect::parsing::SyntaxReference,
174 theme: &syntect::highlighting::Theme,
175 syntax_set: &SyntaxSet,
176) -> Vec<(Color, String)> {
177 let mut highlighter = HighlightLines::new(syntax, theme);
178 let ranges = highlighter
179 .highlight_line(line, syntax_set)
180 .unwrap_or_else(|_| vec![(Style::default(), line)]);
181
182 ranges
183 .into_iter()
184 .map(|(style, text)| (color_from_style(style), text.to_string()))
185 .collect()
186}
187
188use super::folding;
189use super::wrapping::{VisualLine, WrappingCalculator};
190use super::{
191 ArrowDirection, CodeEditor, Message, measure_char_width, measure_text_width,
192};
193use iced::widget::canvas::Action;
194
195static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
196static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
197
198struct RenderContext<'a> {
203 visual_lines: &'a [VisualLine],
205 bounds_width: f32,
207 gutter_width: f32,
209 line_height: f32,
211 font_size: f32,
213 full_char_width: f32,
215 char_width: f32,
217 font: iced::Font,
219 horizontal_scroll_offset: f32,
221}
222
223impl CodeEditor {
224 fn draw_line_numbers(
233 &self,
234 frame: &mut canvas::Frame,
235 ctx: &RenderContext,
236 visual_line: &VisualLine,
237 y: f32,
238 ) {
239 let number_area_width = self.line_number_gutter_width();
242
243 if self.line_numbers_enabled {
244 if visual_line.is_first_segment() {
245 let line_num = visual_line.logical_line + 1;
247 let line_num_text = format!("{}", line_num);
248 let text_width = measure_text_width(
249 &line_num_text,
250 ctx.full_char_width,
251 ctx.char_width,
252 );
253 let x_pos = (number_area_width - text_width) / 2.0;
254 frame.fill_text(canvas::Text {
255 content: line_num_text,
256 position: Point::new(x_pos, y + 2.0),
257 color: self.style.line_number_color,
258 size: ctx.font_size.into(),
259 font: ctx.font,
260 ..canvas::Text::default()
261 });
262 } else {
263 frame.fill_text(canvas::Text {
265 content: "↪".to_string(),
266 position: Point::new(number_area_width - 20.0, y + 2.0),
267 color: self.style.line_number_color,
268 size: ctx.font_size.into(),
269 font: ctx.font,
270 ..canvas::Text::default()
271 });
272 }
273 }
274
275 self.draw_fold_chevron(frame, ctx, visual_line, y, number_area_width);
276 }
277
278 fn draw_fold_chevron(
291 &self,
292 frame: &mut canvas::Frame,
293 ctx: &RenderContext,
294 visual_line: &VisualLine,
295 y: f32,
296 number_area_width: f32,
297 ) {
298 if !self.folding_enabled || !visual_line.is_first_segment() {
299 return;
300 }
301
302 if !folding::is_line_fold_header(&self.buffer, visual_line.logical_line)
303 {
304 return;
305 }
306
307 let chevron = if self.is_folded(visual_line.logical_line) {
309 "▶"
310 } else {
311 "▼"
312 };
313 frame.fill_text(canvas::Text {
314 content: chevron.to_string(),
315 position: Point::new(number_area_width + 1.0, y + 2.0),
316 color: self.style.line_number_color,
317 size: ctx.font_size.into(),
318 font: ctx.font,
319 ..canvas::Text::default()
320 });
321 }
322
323 fn draw_fold_collapsed_marker(
330 &self,
331 frame: &mut canvas::Frame,
332 ctx: &RenderContext,
333 visual_line: &VisualLine,
334 y: f32,
335 ) {
336 if !self.folding_enabled
337 || !visual_line.is_first_segment()
338 || !self.is_folded(visual_line.logical_line)
339 {
340 return;
341 }
342
343 let line_content = self.buffer.line(visual_line.logical_line);
344 let line_width = measure_text_width(
345 line_content,
346 ctx.full_char_width,
347 ctx.char_width,
348 );
349 let x = ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset
350 + line_width
351 + 6.0;
352 frame.fill_text(canvas::Text {
353 content: "⋯".to_string(),
354 position: Point::new(x, y + 2.0),
355 color: self.style.line_number_color,
356 size: ctx.font_size.into(),
357 font: ctx.font,
358 ..canvas::Text::default()
359 });
360 }
361
362 fn draw_current_line_highlight(
371 &self,
372 frame: &mut canvas::Frame,
373 ctx: &RenderContext,
374 visual_line: &VisualLine,
375 y: f32,
376 ) {
377 if self.cursors.iter().any(|c| c.position.0 == visual_line.logical_line)
378 {
379 frame.fill_rectangle(
380 Point::new(ctx.gutter_width, y),
381 Size::new(ctx.bounds_width - ctx.gutter_width, ctx.line_height),
382 self.style.current_line_highlight,
383 );
384 }
385 }
386
387 fn highlighted_line_cached(
410 &self,
411 logical_line: usize,
412 syntax: &syntect::parsing::SyntaxReference,
413 theme: &syntect::highlighting::Theme,
414 syntax_set: &SyntaxSet,
415 ) -> Rc<Vec<(Color, String)>> {
416 let mut guard = self.highlight_cache.borrow_mut();
417
418 let needs_reset =
420 guard.as_ref().is_none_or(|cache| cache.syntax() != self.syntax);
421 if needs_reset {
422 *guard = Some(super::HighlightCache::new(self.syntax.clone()));
423 }
424
425 let Some(cache) = guard.as_mut() else {
426 return Rc::new(highlight_line_spans(
429 self.buffer.line(logical_line),
430 syntax,
431 theme,
432 syntax_set,
433 ));
434 };
435
436 if let Some(spans) = cache.spans(logical_line) {
437 return spans;
438 }
439
440 let highlighter = Highlighter::new(theme);
443 let (mut parse_state, mut highlight_state) =
444 cache.resume_state().unwrap_or_else(|| {
445 (
446 ParseState::new(syntax),
447 HighlightState::new(&highlighter, ScopeStack::new()),
448 )
449 });
450
451 let line_count = self.buffer.line_count();
452 let target = logical_line.min(line_count.saturating_sub(1));
453 let missing_lines =
454 target.saturating_add(1).saturating_sub(cache.valid_len());
455 let lines_to_parse =
456 missing_lines.min(self.highlight_lines_remaining.get());
457 let parse_end = cache
458 .valid_len()
459 .saturating_add(lines_to_parse)
460 .saturating_sub(1)
461 .min(target);
462 let mut result = None;
463 if lines_to_parse > 0 {
464 for index in cache.valid_len()..=parse_end {
465 let mut line = self.buffer.line(index).to_string();
468 line.push('\n');
469
470 let ops = parse_state
471 .parse_line(&line, syntax_set)
472 .unwrap_or_default();
473 let spans: Vec<(Color, String)> = HighlightIterator::new(
474 &mut highlight_state,
475 &ops,
476 &line,
477 &highlighter,
478 )
479 .filter_map(|(style, text)| {
480 let text = text.strip_suffix('\n').unwrap_or(text);
481 if text.is_empty() {
482 None
483 } else {
484 Some((color_from_style(style), text.to_string()))
485 }
486 })
487 .collect();
488
489 let spans = Rc::new(spans);
490 cache.push_line(
491 Rc::clone(&spans),
492 parse_state.clone(),
493 highlight_state.clone(),
494 );
495 if index == logical_line {
496 result = Some(spans);
497 }
498 }
499 }
500 self.highlight_lines_remaining.set(
501 self.highlight_lines_remaining.get().saturating_sub(lines_to_parse),
502 );
503
504 result.or_else(|| cache.spans(logical_line)).unwrap_or_else(|| {
505 Rc::new(vec![(
506 self.style.text_color,
507 self.buffer.line(logical_line).to_string(),
508 )])
509 })
510 }
511
512 #[allow(clippy::too_many_arguments)]
524 fn draw_text_with_syntax_highlighting(
525 &self,
526 frame: &mut canvas::Frame,
527 ctx: &RenderContext,
528 visual_line: &VisualLine,
529 y: f32,
530 syntax_ref: Option<&syntect::parsing::SyntaxReference>,
531 syntax_set: &SyntaxSet,
532 syntax_theme: Option<&syntect::highlighting::Theme>,
533 ) {
534 if let (Some(syntax), Some(syntax_theme)) = (syntax_ref, syntax_theme) {
535 let spans = self.highlighted_line_cached(
538 visual_line.logical_line,
539 syntax,
540 syntax_theme,
541 syntax_set,
542 );
543
544 let mut x_offset =
545 ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset;
546 let mut char_pos = 0;
547
548 for (color, text) in spans.iter() {
549 let text_len = text.chars().count();
550 let text_end = char_pos + text_len;
551
552 if text_end > visual_line.start_col
554 && char_pos < visual_line.end_col
555 {
556 let segment_start = char_pos.max(visual_line.start_col);
558 let segment_end = text_end.min(visual_line.end_col);
559
560 let text_start_offset =
561 segment_start.saturating_sub(char_pos);
562 let text_end_offset =
563 text_start_offset + (segment_end - segment_start);
564
565 let (start_byte, end_byte) = char_range_to_byte_range(
566 text,
567 text_start_offset,
568 text_end_offset,
569 );
570
571 let segment_text = &text[start_byte..end_byte];
572 let display_text = if self.show_whitespace {
573 expand_tabs_visible(segment_text, super::TAB_WIDTH)
574 } else {
575 expand_tabs(segment_text, super::TAB_WIDTH).into_owned()
576 };
577 let display_width = measure_text_width(
578 &display_text,
579 ctx.full_char_width,
580 ctx.char_width,
581 );
582
583 if self.show_whitespace {
584 let ws_color = self.style.whitespace_color;
585 let mut seg_x = x_offset;
586 for (is_ws, seg) in
587 split_whitespace_segments(&display_text)
588 {
589 let seg_color =
590 if is_ws { ws_color } else { *color };
591 let seg_width = measure_text_width(
592 seg,
593 ctx.full_char_width,
594 ctx.char_width,
595 );
596 frame.fill_text(canvas::Text {
597 content: seg.to_string(),
598 position: Point::new(seg_x, y + 2.0),
599 color: seg_color,
600 size: ctx.font_size.into(),
601 font: ctx.font,
602 ..canvas::Text::default()
603 });
604 seg_x += seg_width;
605 }
606 } else {
607 frame.fill_text(canvas::Text {
608 content: display_text,
609 position: Point::new(x_offset, y + 2.0),
610 color: *color,
611 size: ctx.font_size.into(),
612 font: ctx.font,
613 ..canvas::Text::default()
614 });
615 }
616
617 x_offset += display_width;
618 }
619
620 char_pos = text_end;
621 }
622 } else {
623 let full_line_content = self.buffer.line(visual_line.logical_line);
625 let (start_byte, end_byte) = char_range_to_byte_range(
626 full_line_content,
627 visual_line.start_col,
628 visual_line.end_col,
629 );
630 let line_segment = &full_line_content[start_byte..end_byte];
631 let display_text = if self.show_whitespace {
632 expand_tabs_visible(line_segment, super::TAB_WIDTH)
633 } else {
634 expand_tabs(line_segment, super::TAB_WIDTH).into_owned()
635 };
636 let base_x = ctx.gutter_width + 5.0 - ctx.horizontal_scroll_offset;
637 if self.show_whitespace {
638 let ws_color = self.style.whitespace_color;
639 let text_color = self.style.text_color;
640 let mut seg_x = base_x;
641 for (is_ws, seg) in split_whitespace_segments(&display_text) {
642 let seg_color = if is_ws { ws_color } else { text_color };
643 let seg_width = measure_text_width(
644 seg,
645 ctx.full_char_width,
646 ctx.char_width,
647 );
648 frame.fill_text(canvas::Text {
649 content: seg.to_string(),
650 position: Point::new(seg_x, y + 2.0),
651 color: seg_color,
652 size: ctx.font_size.into(),
653 font: ctx.font,
654 ..canvas::Text::default()
655 });
656 seg_x += seg_width;
657 }
658 } else {
659 frame.fill_text(canvas::Text {
660 content: display_text,
661 position: Point::new(base_x, y + 2.0),
662 color: self.style.text_color,
663 size: ctx.font_size.into(),
664 font: ctx.font,
665 ..canvas::Text::default()
666 });
667 }
668 }
669 }
670
671 fn fill_highlight_segment(
687 &self,
688 frame: &mut canvas::Frame,
689 ctx: &RenderContext,
690 visual_idx: usize,
691 vl: &VisualLine,
692 cols: (usize, usize),
693 color: Color,
694 ) {
695 let y = visual_idx as f32 * ctx.line_height;
696 let line_content = self.buffer.line(vl.logical_line);
697 let (x_start, width) = calculate_segment_geometry(
698 line_content,
699 vl.start_col,
700 cols.0,
701 cols.1,
702 ctx.gutter_width + 5.0,
703 ctx.full_char_width,
704 ctx.char_width,
705 );
706 let x_start = x_start - ctx.horizontal_scroll_offset;
707 frame.fill_rectangle(
708 Point::new(x_start, y + 2.0),
709 Size::new(width, ctx.line_height - 4.0),
710 color,
711 );
712 }
713
714 fn draw_search_highlights(
723 &self,
724 frame: &mut canvas::Frame,
725 ctx: &RenderContext,
726 start_visual_idx: usize,
727 end_visual_idx: usize,
728 ) {
729 if !self.search_matches_visible() || self.search_state.query.is_empty()
730 {
731 return;
732 }
733
734 let query_len = self.search_state.query.chars().count();
735
736 let start_visual_idx = start_visual_idx.min(ctx.visual_lines.len());
737 let end_visual_idx = end_visual_idx.min(ctx.visual_lines.len());
738
739 let end_visual_inclusive = end_visual_idx
740 .saturating_sub(1)
741 .min(ctx.visual_lines.len().saturating_sub(1));
742
743 if let (Some(start_vl), Some(end_vl)) = (
744 ctx.visual_lines.get(start_visual_idx),
745 ctx.visual_lines.get(end_visual_inclusive),
746 ) {
747 let min_logical_line = start_vl.logical_line;
748 let max_logical_line = end_vl.logical_line;
749
750 let match_range = super::search::get_visible_match_range(
753 &self.search_state.matches,
754 min_logical_line,
755 max_logical_line,
756 );
757
758 for (match_idx, search_match) in self
759 .search_state
760 .matches
761 .iter()
762 .enumerate()
763 .skip(match_range.start)
764 .take(match_range.len())
765 {
766 let is_current =
768 self.search_state.current_match_index == Some(match_idx);
769
770 let highlight_color = if is_current {
771 Color { r: 1.0, g: 0.6, b: 0.0, a: 0.4 }
773 } else {
774 Color { r: 1.0, g: 1.0, b: 0.0, a: 0.3 }
776 };
777
778 let start_visual = WrappingCalculator::logical_to_visual(
780 ctx.visual_lines,
781 search_match.line,
782 search_match.col,
783 );
784 let end_visual = WrappingCalculator::logical_to_visual(
785 ctx.visual_lines,
786 search_match.line,
787 search_match.col + query_len,
788 );
789
790 if let (Some(start_v), Some(end_v)) = (start_visual, end_visual)
791 {
792 if start_v == end_v {
793 let vl = &ctx.visual_lines[start_v];
795 self.fill_highlight_segment(
796 frame,
797 ctx,
798 start_v,
799 vl,
800 (search_match.col, search_match.col + query_len),
801 highlight_color,
802 );
803 } else {
804 for (v_idx, vl) in ctx
806 .visual_lines
807 .iter()
808 .enumerate()
809 .skip(start_v)
810 .take(end_v - start_v + 1)
811 {
812 let sel_start_col = if v_idx == start_v {
813 search_match.col
814 } else {
815 vl.start_col
816 };
817 let sel_end_col = if v_idx == end_v {
818 search_match.col + query_len
819 } else {
820 vl.end_col
821 };
822
823 self.fill_highlight_segment(
824 frame,
825 ctx,
826 v_idx,
827 vl,
828 (sel_start_col, sel_end_col),
829 highlight_color,
830 );
831 }
832 }
833 }
834 }
835 }
836 }
837
838 fn draw_single_selection(
847 &self,
848 frame: &mut canvas::Frame,
849 ctx: &RenderContext,
850 start: (usize, usize),
851 end: (usize, usize),
852 ) {
853 let selection_color = Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 };
854
855 if start.0 == end.0 {
856 let start_visual = WrappingCalculator::logical_to_visual(
858 ctx.visual_lines,
859 start.0,
860 start.1,
861 );
862 let end_visual = WrappingCalculator::logical_to_visual(
863 ctx.visual_lines,
864 end.0,
865 end.1,
866 );
867
868 if let (Some(start_v), Some(end_v)) = (start_visual, end_visual) {
869 if start_v == end_v {
870 let vl = &ctx.visual_lines[start_v];
872 self.fill_highlight_segment(
873 frame,
874 ctx,
875 start_v,
876 vl,
877 (start.1, end.1),
878 selection_color,
879 );
880 } else {
881 for (v_idx, vl) in ctx
883 .visual_lines
884 .iter()
885 .enumerate()
886 .skip(start_v)
887 .take(end_v - start_v + 1)
888 {
889 let sel_start_col = if v_idx == start_v {
890 start.1
891 } else {
892 vl.start_col
893 };
894 let sel_end_col =
895 if v_idx == end_v { end.1 } else { vl.end_col };
896
897 self.fill_highlight_segment(
898 frame,
899 ctx,
900 v_idx,
901 vl,
902 (sel_start_col, sel_end_col),
903 selection_color,
904 );
905 }
906 }
907 }
908 } else {
909 let start_visual = WrappingCalculator::logical_to_visual(
911 ctx.visual_lines,
912 start.0,
913 start.1,
914 );
915 let end_visual = WrappingCalculator::logical_to_visual(
916 ctx.visual_lines,
917 end.0,
918 end.1,
919 );
920
921 if let (Some(start_v), Some(end_v)) = (start_visual, end_visual) {
922 for (v_idx, vl) in ctx
923 .visual_lines
924 .iter()
925 .enumerate()
926 .skip(start_v)
927 .take(end_v - start_v + 1)
928 {
929 let sel_start_col =
930 if vl.logical_line == start.0 && v_idx == start_v {
931 start.1
932 } else {
933 vl.start_col
934 };
935
936 let sel_end_col =
937 if vl.logical_line == end.0 && v_idx == end_v {
938 end.1
939 } else {
940 vl.end_col
941 };
942
943 self.fill_highlight_segment(
944 frame,
945 ctx,
946 v_idx,
947 vl,
948 (sel_start_col, sel_end_col),
949 selection_color,
950 );
951 }
952 }
953 }
954 }
955
956 fn draw_selection_highlight(
963 &self,
964 frame: &mut canvas::Frame,
965 ctx: &RenderContext,
966 ) {
967 for cursor in self.cursors.iter() {
968 if let Some((start, end)) = cursor.selection_range()
969 && start != end
970 {
971 self.draw_single_selection(frame, ctx, start, end);
972 }
973 }
974 }
975
976 fn draw_cursor(&self, frame: &mut canvas::Frame, ctx: &RenderContext) {
983 if self.show_cursor
993 && self.cursor_visible
994 && self.has_focus()
995 && self.ime_preedit.is_some()
996 {
997 if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
1007 ctx.visual_lines,
1008 self.cursors.primary_position().0,
1009 self.cursors.primary_position().1,
1010 ) {
1011 let vl = &ctx.visual_lines[cursor_visual];
1012 let line_content = self.buffer.line(vl.logical_line);
1013
1014 let (cursor_x_content, _) = calculate_segment_geometry(
1017 line_content,
1018 vl.start_col,
1019 self.cursors.primary_position().1,
1020 self.cursors.primary_position().1,
1021 ctx.gutter_width + 5.0,
1022 ctx.full_char_width,
1023 ctx.char_width,
1024 );
1025 let cursor_x = cursor_x_content - ctx.horizontal_scroll_offset;
1026 let cursor_y = cursor_visual as f32 * ctx.line_height;
1027
1028 if let Some(preedit) = self.ime_preedit.as_ref() {
1029 let preedit_width = measure_text_width(
1030 &preedit.content,
1031 ctx.full_char_width,
1032 ctx.char_width,
1033 );
1034
1035 frame.fill_rectangle(
1038 Point::new(cursor_x, cursor_y + 2.0),
1039 Size::new(preedit_width, ctx.line_height - 4.0),
1040 Color { r: 1.0, g: 1.0, b: 1.0, a: 0.08 },
1041 );
1042
1043 if let Some(range) = preedit.selection.as_ref()
1047 && range.start != range.end
1048 {
1049 if let Some((start, end)) = validate_selection_indices(
1051 &preedit.content,
1052 range.start,
1053 range.end,
1054 ) {
1055 let selected_prefix = &preedit.content[..start];
1056 let selected_text = &preedit.content[start..end];
1057
1058 let selection_x = cursor_x
1059 + measure_text_width(
1060 selected_prefix,
1061 ctx.full_char_width,
1062 ctx.char_width,
1063 );
1064 let selection_w = measure_text_width(
1065 selected_text,
1066 ctx.full_char_width,
1067 ctx.char_width,
1068 );
1069
1070 frame.fill_rectangle(
1071 Point::new(selection_x, cursor_y + 2.0),
1072 Size::new(selection_w, ctx.line_height - 4.0),
1073 Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 },
1074 );
1075 }
1076 }
1077
1078 frame.fill_text(canvas::Text {
1080 content: preedit.content.clone(),
1081 position: Point::new(cursor_x, cursor_y + 2.0),
1082 color: self.style.text_color,
1083 size: ctx.font_size.into(),
1084 font: ctx.font,
1085 ..canvas::Text::default()
1086 });
1087
1088 frame.fill_rectangle(
1090 Point::new(cursor_x, cursor_y + ctx.line_height - 3.0),
1091 Size::new(preedit_width, 1.0),
1092 self.style.text_color,
1093 );
1094
1095 if let Some(range) = preedit.selection.as_ref() {
1098 let caret_end = range.end.min(preedit.content.len());
1099
1100 if caret_end <= preedit.content.len()
1102 && preedit.content.is_char_boundary(caret_end)
1103 {
1104 let caret_prefix = &preedit.content[..caret_end];
1105 let caret_x = cursor_x
1106 + measure_text_width(
1107 caret_prefix,
1108 ctx.full_char_width,
1109 ctx.char_width,
1110 );
1111
1112 frame.fill_rectangle(
1113 Point::new(caret_x, cursor_y + 2.0),
1114 Size::new(2.0, ctx.line_height - 4.0),
1115 self.style.text_color,
1116 );
1117 }
1118 }
1119 }
1120 }
1121 } else if self.show_cursor && self.cursor_visible && self.has_focus() {
1122 if self.vim_enabled {
1129 let position = self
1130 .vim_state
1131 .visual_positions()
1132 .map(|(_, active)| active)
1133 .unwrap_or_else(|| self.cursors.primary_position());
1134 self.draw_single_caret(frame, ctx, position);
1135 } else {
1136 for cursor in self.cursors.iter() {
1137 self.draw_single_caret(frame, ctx, cursor.position);
1138 }
1139 }
1140 }
1141 }
1142
1143 fn cursor_size_for_position(&self, position: (usize, usize)) -> Size {
1149 let uses_block =
1150 self.vim_enabled && self.vim_state.mode() != super::VimMode::Insert;
1151 let width = if uses_block {
1152 self.buffer
1153 .line(position.0)
1154 .chars()
1155 .nth(position.1)
1156 .map(|ch| {
1157 measure_char_width(
1158 ch,
1159 self.full_char_width,
1160 self.char_width,
1161 )
1162 })
1163 .filter(|width| *width > 0.0)
1164 .unwrap_or(self.char_width)
1165 } else {
1166 2.0
1167 };
1168
1169 Size::new(width, (self.line_height - 4.0).max(1.0))
1170 }
1171
1172 fn draw_single_caret(
1180 &self,
1181 frame: &mut canvas::Frame,
1182 ctx: &RenderContext,
1183 position: (usize, usize),
1184 ) {
1185 if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
1187 ctx.visual_lines,
1188 position.0,
1189 position.1,
1190 ) {
1191 let vl = &ctx.visual_lines[cursor_visual];
1192 let line_content = self.buffer.line(vl.logical_line);
1193
1194 let (cursor_x_content, _) = calculate_segment_geometry(
1196 line_content,
1197 vl.start_col,
1198 position.1,
1199 position.1,
1200 ctx.gutter_width + 5.0,
1201 ctx.full_char_width,
1202 ctx.char_width,
1203 );
1204 let cursor_x = cursor_x_content - ctx.horizontal_scroll_offset;
1205 let cursor_y = cursor_visual as f32 * ctx.line_height;
1206
1207 let cursor_size = self.cursor_size_for_position(position);
1208 let mut cursor_color = self.style.text_color;
1209 if cursor_size.width > 2.0 {
1210 cursor_color.a *= 0.55;
1211 }
1212
1213 frame.fill_rectangle(
1214 Point::new(cursor_x, cursor_y + 2.0),
1215 cursor_size,
1216 cursor_color,
1217 );
1218 }
1219 }
1220
1221 pub(crate) fn has_focus(&self) -> bool {
1227 let focused_id =
1229 super::FOCUSED_EDITOR_ID.load(std::sync::atomic::Ordering::Relaxed);
1230 focused_id == self.editor_id
1231 && self.has_canvas_focus
1232 && !self.focus_locked
1233 }
1234
1235 fn handle_keyboard_shortcuts(
1249 &self,
1250 key: &keyboard::Key,
1251 modified_key: &keyboard::Key,
1252 modifiers: &keyboard::Modifiers,
1253 ) -> Option<Action<Message>> {
1254 let command_pressed = modifiers.command() || modifiers.control();
1257
1258 if command_pressed
1261 && modifiers.alt()
1262 && !modifiers.shift()
1263 && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v")
1264 {
1265 return Some(Action::publish(Message::ToggleVimMode).and_capture());
1266 }
1267
1268 if command_pressed
1271 && !modifiers.alt()
1272 && !modifiers.shift()
1273 && matches!(key, keyboard::Key::Character(s) if s.as_str() == "s")
1274 {
1275 return Some(
1276 Action::publish(Message::WriteRequested).and_capture(),
1277 );
1278 }
1279
1280 if matches!(key, keyboard::Key::Named(keyboard::key::Named::Tab))
1282 && modifiers.shift()
1283 && !self.search_state.is_open
1284 {
1285 return Some(
1286 Action::publish(Message::FocusNavigationShiftTab).and_capture(),
1287 );
1288 }
1289
1290 if (command_pressed
1292 && matches!(key, keyboard::Key::Character(c) if c.as_str() == "c"))
1293 || (modifiers.control()
1294 && matches!(
1295 key,
1296 keyboard::Key::Named(keyboard::key::Named::Insert)
1297 ))
1298 {
1299 return Some(Action::publish(Message::Copy).and_capture());
1300 }
1301
1302 if command_pressed
1304 && matches!(key, keyboard::Key::Character(x) if x.as_str() == "x")
1305 {
1306 return Some(Action::publish(Message::Cut).and_capture());
1307 }
1308
1309 if command_pressed
1311 && matches!(key, keyboard::Key::Character(a) if a.as_str() == "a")
1312 {
1313 return Some(Action::publish(Message::SelectAll).and_capture());
1314 }
1315
1316 if command_pressed
1318 && !modifiers.shift()
1319 && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")
1320 {
1321 return Some(Action::publish(Message::Undo).and_capture());
1322 }
1323
1324 if command_pressed
1326 && (matches!(key, keyboard::Key::Character(y) if y.as_str() == "y")
1327 || (modifiers.shift()
1328 && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")))
1329 {
1330 return Some(Action::publish(Message::Redo).and_capture());
1331 }
1332
1333 if self.vim_enabled
1336 && self.vim_state.mode() == super::VimMode::Normal
1337 && modifiers.control()
1338 && !modifiers.shift()
1339 && matches!(key, keyboard::Key::Character(r) if r.as_str() == "r")
1340 {
1341 return Some(Action::publish(Message::Redo).and_capture());
1342 }
1343
1344 if command_pressed
1346 && matches!(key, keyboard::Key::Character(f) if f.as_str() == "f")
1347 && self.search_replace_enabled
1348 {
1349 return Some(Action::publish(Message::OpenSearch).and_capture());
1350 }
1351
1352 if command_pressed
1354 && matches!(key, keyboard::Key::Character(h) if h.as_str() == "h")
1355 && self.search_replace_enabled
1356 {
1357 return Some(
1358 Action::publish(Message::OpenSearchReplace).and_capture(),
1359 );
1360 }
1361
1362 if command_pressed
1364 && matches!(key, keyboard::Key::Character(g) if g.as_str() == "g")
1365 {
1366 return Some(Action::publish(Message::OpenGotoLine).and_capture());
1367 }
1368
1369 if matches!(key, keyboard::Key::Named(keyboard::key::Named::Escape)) {
1371 let message = if self.goto_line_state.is_open {
1372 Message::CloseGotoLine
1373 } else if self.search_state.is_open {
1374 Message::CloseSearch
1375 } else if self.vim_enabled {
1376 Message::VimKey('\u{1b}')
1377 } else {
1378 Message::CloseSearch
1379 };
1380 return Some(Action::publish(message).and_capture());
1381 }
1382
1383 if command_pressed
1385 && matches!(key, keyboard::Key::Character(d) if d.as_str() == "d")
1386 {
1387 return Some(
1388 Action::publish(Message::SelectNextOccurrence).and_capture(),
1389 );
1390 }
1391
1392 if command_pressed
1399 && (matches!(key, keyboard::Key::Character(c) if c.as_str() == "/")
1400 || matches!(modified_key, keyboard::Key::Character(c) if c.as_str() == "/"))
1401 {
1402 return Some(Action::publish(Message::ToggleComment).and_capture());
1403 }
1404
1405 if modifiers.control()
1407 && modifiers.alt()
1408 && matches!(
1409 key,
1410 keyboard::Key::Named(keyboard::key::Named::ArrowUp)
1411 )
1412 {
1413 return Some(
1414 Action::publish(Message::AddCursorAbove).and_capture(),
1415 );
1416 }
1417
1418 if modifiers.control()
1420 && modifiers.alt()
1421 && matches!(
1422 key,
1423 keyboard::Key::Named(keyboard::key::Named::ArrowDown)
1424 )
1425 {
1426 return Some(
1427 Action::publish(Message::AddCursorBelow).and_capture(),
1428 );
1429 }
1430
1431 if modifiers.alt() && !modifiers.control() {
1435 if matches!(
1436 key,
1437 keyboard::Key::Named(keyboard::key::Named::ArrowUp)
1438 ) {
1439 let message = if modifiers.shift() {
1440 Message::DuplicateLineUp
1441 } else {
1442 Message::MoveLineUp
1443 };
1444 return Some(Action::publish(message).and_capture());
1445 }
1446 if matches!(
1447 key,
1448 keyboard::Key::Named(keyboard::key::Named::ArrowDown)
1449 ) {
1450 let message = if modifiers.shift() {
1451 Message::DuplicateLineDown
1452 } else {
1453 Message::MoveLineDown
1454 };
1455 return Some(Action::publish(message).and_capture());
1456 }
1457 }
1458
1459 if matches!(key, keyboard::Key::Named(keyboard::key::Named::Tab))
1461 && self.search_state.is_open
1462 {
1463 if modifiers.shift() {
1464 return Some(
1466 Action::publish(Message::SearchDialogShiftTab)
1467 .and_capture(),
1468 );
1469 } else {
1470 return Some(
1472 Action::publish(Message::SearchDialogTab).and_capture(),
1473 );
1474 }
1475 }
1476
1477 if matches!(key, keyboard::Key::Named(keyboard::key::Named::F3))
1479 && self.search_replace_enabled
1480 {
1481 if modifiers.shift() {
1482 return Some(
1483 Action::publish(Message::FindPrevious).and_capture(),
1484 );
1485 } else {
1486 return Some(Action::publish(Message::FindNext).and_capture());
1487 }
1488 }
1489
1490 if (command_pressed
1492 && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v"))
1493 || (modifiers.shift()
1494 && matches!(
1495 key,
1496 keyboard::Key::Named(keyboard::key::Named::Insert)
1497 ))
1498 {
1499 return Some(Action::publish(Message::Paste(String::new())));
1501 }
1502
1503 if command_pressed
1505 && matches!(key, keyboard::Key::Named(keyboard::key::Named::Home))
1506 {
1507 return Some(Action::publish(Message::CtrlHome).and_capture());
1508 }
1509
1510 if command_pressed
1512 && matches!(key, keyboard::Key::Named(keyboard::key::Named::End))
1513 {
1514 return Some(Action::publish(Message::CtrlEnd).and_capture());
1515 }
1516
1517 if modifiers.shift()
1519 && matches!(key, keyboard::Key::Named(keyboard::key::Named::Delete))
1520 {
1521 return Some(
1522 Action::publish(Message::DeleteSelection).and_capture(),
1523 );
1524 }
1525
1526 if self.folding_enabled {
1528 if modifiers.control()
1530 && matches!(key, keyboard::Key::Character(c) if c.as_str() == ".")
1531 {
1532 return Some(
1533 Action::publish(Message::ToggleFoldAtCursor).and_capture(),
1534 );
1535 }
1536
1537 if modifiers.control()
1539 && !modifiers.shift()
1540 && matches!(key, keyboard::Key::Character(c) if c.as_str() == "k")
1541 {
1542 return Some(Action::publish(Message::FoldAll).and_capture());
1543 }
1544
1545 if modifiers.control()
1547 && !modifiers.shift()
1548 && matches!(key, keyboard::Key::Character(c) if c.as_str() == "j")
1549 {
1550 return Some(Action::publish(Message::UnfoldAll).and_capture());
1551 }
1552 }
1553
1554 None
1555 }
1556
1557 fn printable_input_message(&self, ch: char) -> Message {
1558 if self.vim_enabled && self.vim_state.mode() != super::VimMode::Insert {
1559 Message::VimKey(ch)
1560 } else {
1561 Message::CharacterInput(ch)
1562 }
1563 }
1564
1565 #[allow(clippy::unused_self)]
1580 fn handle_character_input(
1581 &self,
1582 key: &keyboard::Key,
1583 modifiers: &keyboard::Modifiers,
1584 text: Option<&str>,
1585 ) -> Option<Action<Message>> {
1586 if !self.has_focus() {
1590 return None;
1591 }
1592
1593 if let Some(text_content) = text
1598 && !text_content.is_empty()
1599 && !modifiers.control()
1600 && !modifiers.alt()
1601 {
1602 if let Some(first_char) = text_content.chars().next()
1605 && !first_char.is_control()
1606 {
1607 return Some(
1608 Action::publish(self.printable_input_message(first_char))
1609 .and_capture(),
1610 );
1611 }
1612 }
1613
1614 let message = match key {
1617 keyboard::Key::Named(keyboard::key::Named::Backspace)
1618 if !self.vim_enabled
1619 || self.vim_state.mode() == super::VimMode::Insert
1620 || self.vim_state.command_line_active() =>
1621 {
1622 if self.vim_state.command_line_active() {
1623 Some(Message::VimKey('\u{8}'))
1624 } else {
1625 Some(Message::Backspace)
1626 }
1627 }
1628 keyboard::Key::Named(keyboard::key::Named::Delete)
1629 if !self.vim_enabled
1630 || self.vim_state.mode() == super::VimMode::Insert =>
1631 {
1632 Some(Message::Delete)
1633 }
1634 keyboard::Key::Named(keyboard::key::Named::Enter)
1635 if !self.vim_enabled
1636 || self.vim_state.mode() == super::VimMode::Insert
1637 || self.vim_state.command_line_active() =>
1638 {
1639 if self.vim_state.command_line_active() {
1640 Some(Message::VimKey('\n'))
1641 } else {
1642 Some(Message::Enter)
1643 }
1644 }
1645 keyboard::Key::Named(keyboard::key::Named::Tab)
1646 if !self.vim_enabled
1647 || self.vim_state.mode() == super::VimMode::Insert =>
1648 {
1649 if modifiers.shift() {
1652 Some(Message::FocusNavigationShiftTab)
1654 } else {
1655 if self.search_state.is_open {
1657 Some(Message::SearchDialogTab)
1658 } else {
1659 Some(Message::Tab)
1661 }
1662 }
1663 }
1664 keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
1665 Some(Message::ArrowKey(ArrowDirection::Up, modifiers.shift()))
1666 }
1667 keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
1668 Some(Message::ArrowKey(ArrowDirection::Down, modifiers.shift()))
1669 }
1670 keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
1671 Some(Message::ArrowKey(ArrowDirection::Left, modifiers.shift()))
1672 }
1673 keyboard::Key::Named(keyboard::key::Named::ArrowRight) => Some(
1674 Message::ArrowKey(ArrowDirection::Right, modifiers.shift()),
1675 ),
1676 keyboard::Key::Named(keyboard::key::Named::PageUp) => {
1677 Some(Message::PageUp)
1678 }
1679 keyboard::Key::Named(keyboard::key::Named::PageDown) => {
1680 Some(Message::PageDown)
1681 }
1682 keyboard::Key::Named(keyboard::key::Named::Home) => {
1683 Some(Message::Home(modifiers.shift()))
1684 }
1685 keyboard::Key::Named(keyboard::key::Named::End) => {
1686 Some(Message::End(modifiers.shift()))
1687 }
1688 _ => {
1691 if !modifiers.control()
1692 && !modifiers.alt()
1693 && let keyboard::Key::Character(c) = key
1694 && !c.is_empty()
1695 {
1696 return c
1697 .chars()
1698 .next()
1699 .map(|ch| self.printable_input_message(ch))
1700 .map(|msg| Action::publish(msg).and_capture());
1701 }
1702 None
1703 }
1704 };
1705
1706 message.map(|msg| Action::publish(msg).and_capture())
1707 }
1708
1709 fn handle_keyboard_event(
1729 &self,
1730 key: &keyboard::Key,
1731 modified_key: &keyboard::Key,
1732 modifiers: &keyboard::Modifiers,
1733 text: &Option<iced::advanced::graphics::core::SmolStr>,
1734 _bounds: Rectangle,
1735 _cursor: &mouse::Cursor,
1736 ) -> Option<Action<Message>> {
1737 if !self.has_focus() || self.focus_locked {
1741 return None;
1742 }
1743
1744 if self.ime_preedit.is_some()
1746 && !(modifiers.control() || modifiers.command())
1747 {
1748 return None;
1749 }
1750
1751 if let Some(action) =
1753 self.handle_keyboard_shortcuts(key, modified_key, modifiers)
1754 {
1755 return Some(action);
1756 }
1757
1758 let text_str = text.as_ref().map(|s| s.as_str());
1761 self.handle_character_input(key, modifiers, text_str)
1762 }
1763
1764 #[allow(clippy::unused_self)]
1776 pub(crate) fn fold_header_at_point(&self, point: Point) -> Option<usize> {
1786 if !self.folding_enabled {
1787 return None;
1788 }
1789
1790 let margin_start = self.line_number_gutter_width();
1792 if point.x < margin_start || point.x >= self.gutter_width() {
1793 return None;
1794 }
1795
1796 let visual_line_idx = (point.y / self.line_height) as usize;
1797 let visual_lines = self.visual_lines_cached(self.viewport_width);
1798 let visual_line = visual_lines.get(visual_line_idx)?;
1799 if !visual_line.is_first_segment() {
1800 return None;
1801 }
1802
1803 folding::is_line_fold_header(&self.buffer, visual_line.logical_line)
1804 .then_some(visual_line.logical_line)
1805 }
1806
1807 fn handle_mouse_event(
1808 &self,
1809 event: &mouse::Event,
1810 bounds: Rectangle,
1811 cursor: &mouse::Cursor,
1812 ) -> Option<Action<Message>> {
1813 match event {
1814 mouse::Event::ButtonPressed(mouse::Button::Left) => {
1815 cursor.position_in(bounds).map(|position| {
1816 if let Some(header) = self.fold_header_at_point(position) {
1819 return Action::publish(Message::ToggleFold(header))
1820 .and_capture();
1821 }
1822
1823 #[cfg(target_os = "macos")]
1825 let is_jump_click = self.modifiers.get().command();
1826 #[cfg(not(target_os = "macos"))]
1827 let is_jump_click = self.modifiers.get().control();
1828
1829 if is_jump_click {
1830 return Action::publish(Message::JumpClick(position));
1831 }
1832
1833 if self.modifiers.get().alt() {
1835 let message = if self.vim_enabled {
1836 Message::MouseClick(position)
1837 } else {
1838 Message::AltClick(position)
1839 };
1840 return Action::publish(message).and_capture();
1841 }
1842
1843 let click_count = self.classify_click(position);
1844 match click_count {
1845 2 => Action::publish(Message::DoubleClick(position))
1846 .and_capture(),
1847 3 => Action::publish(Message::TripleClick(position))
1848 .and_capture(),
1849 _ => Action::publish(Message::MouseClick(position)),
1852 }
1853 })
1854 }
1855 mouse::Event::ButtonPressed(mouse::Button::Right) => {
1856 cursor.position_in(bounds).map(|position| {
1857 Action::publish(Message::ContextMenuRequested(position))
1858 .and_capture()
1859 })
1860 }
1861 mouse::Event::CursorMoved { .. } => {
1862 cursor.position_in(bounds).map(|position| {
1863 if self.is_dragging {
1864 Action::publish(Message::MouseDrag(position))
1866 .and_capture()
1867 } else {
1868 Action::publish(Message::MouseHover(position))
1870 }
1871 })
1872 }
1873 mouse::Event::ButtonReleased(mouse::Button::Left) => {
1874 if cursor.is_over(bounds) {
1877 Some(Action::publish(Message::MouseRelease).and_capture())
1878 } else {
1879 None
1880 }
1881 }
1882 _ => None,
1883 }
1884 }
1885
1886 fn handle_ime_event(
1898 &self,
1899 event: &input_method::Event,
1900 _bounds: Rectangle,
1901 _cursor: &mouse::Cursor,
1902 ) -> Option<Action<Message>> {
1903 if !self.has_focus() || self.focus_locked {
1907 return None;
1908 }
1909 if self.vim_enabled && self.vim_state.mode() != super::VimMode::Insert {
1910 return None;
1911 }
1912
1913 let message = match event {
1931 input_method::Event::Opened => Message::ImeOpened,
1932 input_method::Event::Preedit(content, selection) => {
1933 Message::ImePreedit(content.clone(), selection.clone())
1934 }
1935 input_method::Event::Commit(content) => {
1936 Message::ImeCommit(content.clone())
1937 }
1938 input_method::Event::Closed => Message::ImeClosed,
1939 };
1940
1941 Some(Action::publish(message).and_capture())
1942 }
1943}
1944
1945impl CodeEditor {
1946 fn draw_jump_link_highlight(
1948 &self,
1949 frame: &mut canvas::Frame,
1950 ctx: &RenderContext,
1951 bounds: Rectangle,
1952 cursor: mouse::Cursor,
1953 ) {
1954 #[cfg(target_os = "macos")]
1955 let modifier_active = self.modifiers.get().command();
1956 #[cfg(not(target_os = "macos"))]
1957 let modifier_active = self.modifiers.get().control();
1958
1959 if !modifier_active {
1960 return;
1961 }
1962
1963 let Some(point) = cursor.position_in(bounds) else {
1964 return;
1965 };
1966
1967 if let Some((line, col)) = self.calculate_cursor_from_point(point) {
1968 let line_content = self.buffer.line(line);
1969
1970 let start_col = Self::word_start_in_line(line_content, col);
1971 let end_col = Self::word_end_in_line(line_content, col);
1972
1973 if start_col >= end_col {
1974 return;
1975 }
1976
1977 if let Some(mut idx) =
1979 WrappingCalculator::logical_to_visual(ctx.visual_lines, line, 0)
1980 {
1981 while idx < ctx.visual_lines.len() {
1983 let visual_line = &ctx.visual_lines[idx];
1984 if visual_line.logical_line != line {
1985 break;
1986 }
1987
1988 let seg_start = visual_line.start_col.max(start_col);
1990 let seg_end = visual_line.end_col.min(end_col);
1991
1992 if seg_start < seg_end {
1993 let (x, width) = calculate_segment_geometry(
1994 line_content,
1995 visual_line.start_col,
1996 seg_start,
1997 seg_end,
1998 ctx.gutter_width + 5.0
1999 - ctx.horizontal_scroll_offset,
2000 ctx.full_char_width,
2001 ctx.char_width,
2002 );
2003
2004 let y = idx as f32 * ctx.line_height + ctx.line_height; let path = canvas::Path::line(
2008 Point::new(x, y),
2009 Point::new(x + width, y),
2010 );
2011
2012 frame.stroke(
2013 &path,
2014 canvas::Stroke::default()
2015 .with_color(self.style.text_color) .with_width(1.0),
2017 );
2018 }
2019
2020 idx += 1;
2021 }
2022 }
2023 }
2024 }
2025}
2026
2027impl canvas::Program<Message> for CodeEditor {
2028 type State = ();
2029
2030 fn draw(
2045 &self,
2046 _state: &Self::State,
2047 renderer: &iced::Renderer,
2048 _theme: &Theme,
2049 bounds: Rectangle,
2050 _cursor: mouse::Cursor,
2051 ) -> Vec<Geometry> {
2052 let visual_lines: Rc<Vec<VisualLine>> =
2053 self.visual_lines_cached(bounds.width);
2054
2055 let effective_viewport_height = if self.viewport_height > 0.0 {
2059 self.viewport_height
2060 } else {
2061 bounds.height
2062 };
2063 let first_visible_line =
2064 (self.viewport_scroll / self.line_height).floor() as usize;
2065 let visible_lines_count =
2066 (effective_viewport_height / self.line_height).ceil() as usize + 2;
2067 let last_visible_line =
2068 (first_visible_line + visible_lines_count).min(visual_lines.len());
2069
2070 let (start_idx, end_idx) =
2071 if self.cache_window_end_line > self.cache_window_start_line {
2072 let s = self.cache_window_start_line.min(visual_lines.len());
2073 let e = self.cache_window_end_line.min(visual_lines.len());
2074 (s, e)
2075 } else {
2076 (first_visible_line, last_visible_line)
2077 };
2078
2079 let visual_lines_for_content = visual_lines.clone();
2086 let content_geometry =
2087 self.content_cache.draw(renderer, bounds.size(), |frame| {
2088 self.highlight_lines_remaining
2092 .set(super::HIGHLIGHT_LINES_PER_FRAME);
2093
2094 let syntax_set = SYNTAX_SET.get_or_init(|| {
2096 #[cfg(feature = "two-face")]
2097 {
2098 two_face::syntax::extra_newlines()
2099 }
2100 #[cfg(not(feature = "two-face"))]
2101 {
2102 SyntaxSet::load_defaults_newlines()
2103 }
2104 });
2105 let theme_set = THEME_SET.get_or_init(ThemeSet::load_defaults);
2106 let syntax_theme = theme_set
2107 .themes
2108 .get("base16-ocean.dark")
2109 .or_else(|| theme_set.themes.values().next());
2110
2111 let syntax_ref = match self.syntax.as_str() {
2113 "python" => syntax_set.find_syntax_by_extension("py"),
2114 "rust" => syntax_set.find_syntax_by_extension("rs"),
2115 "javascript" => syntax_set.find_syntax_by_extension("js"),
2116 "htm" => syntax_set.find_syntax_by_extension("html"),
2117 "svg" => syntax_set.find_syntax_by_extension("xml"),
2118 "markdown" => syntax_set.find_syntax_by_extension("md"),
2119 "text" => Some(syntax_set.find_syntax_plain_text()),
2120 _ => syntax_set
2121 .find_syntax_by_extension(self.syntax.as_str()),
2122 }
2123 .or(Some(syntax_set.find_syntax_plain_text()));
2124
2125 let ctx = RenderContext {
2126 visual_lines: visual_lines_for_content.as_ref(),
2127 bounds_width: bounds.width,
2128 gutter_width: self.gutter_width(),
2129 line_height: self.line_height,
2130 font_size: self.font_size,
2131 full_char_width: self.full_char_width,
2132 char_width: self.char_width,
2133 font: self.font,
2134 horizontal_scroll_offset: self.horizontal_scroll_offset,
2135 };
2136
2137 let code_clip = Rectangle {
2142 x: ctx.gutter_width,
2143 y: 0.0,
2144 width: (bounds.width - ctx.gutter_width).max(0.0),
2145 height: bounds.height,
2146 };
2147 frame.with_clip(code_clip, |f| {
2148 for (idx, visual_line) in visual_lines_for_content
2149 .iter()
2150 .enumerate()
2151 .skip(start_idx)
2152 .take(end_idx.saturating_sub(start_idx))
2153 {
2154 let y = idx as f32 * self.line_height;
2155 self.draw_text_with_syntax_highlighting(
2156 f,
2157 &ctx,
2158 visual_line,
2159 y,
2160 syntax_ref,
2161 syntax_set,
2162 syntax_theme,
2163 );
2164 self.draw_fold_collapsed_marker(
2165 f,
2166 &ctx,
2167 visual_line,
2168 y,
2169 );
2170 }
2171 });
2172
2173 for (idx, visual_line) in visual_lines_for_content
2175 .iter()
2176 .enumerate()
2177 .skip(start_idx)
2178 .take(end_idx.saturating_sub(start_idx))
2179 {
2180 let y = idx as f32 * self.line_height;
2181 self.draw_line_numbers(frame, &ctx, visual_line, y);
2182 }
2183 });
2184
2185 let visual_lines_for_overlay = visual_lines;
2186 let overlay_geometry =
2187 self.overlay_cache.draw(renderer, bounds.size(), |frame| {
2188 let ctx = RenderContext {
2191 visual_lines: visual_lines_for_overlay.as_ref(),
2192 bounds_width: bounds.width,
2193 gutter_width: self.gutter_width(),
2194 line_height: self.line_height,
2195 font_size: self.font_size,
2196 full_char_width: self.full_char_width,
2197 char_width: self.char_width,
2198 font: self.font,
2199 horizontal_scroll_offset: self.horizontal_scroll_offset,
2200 };
2201
2202 for (idx, visual_line) in visual_lines_for_overlay
2203 .iter()
2204 .enumerate()
2205 .skip(start_idx)
2206 .take(end_idx.saturating_sub(start_idx))
2207 {
2208 let y = idx as f32 * self.line_height;
2209 self.draw_current_line_highlight(
2210 frame,
2211 &ctx,
2212 visual_line,
2213 y,
2214 );
2215 }
2216
2217 self.draw_search_highlights(frame, &ctx, start_idx, end_idx);
2218 self.draw_selection_highlight(frame, &ctx);
2219 self.draw_jump_link_highlight(frame, &ctx, bounds, _cursor);
2220 self.draw_cursor(frame, &ctx);
2221 });
2222
2223 vec![content_geometry, overlay_geometry]
2224 }
2225
2226 fn update(
2239 &self,
2240 _state: &mut Self::State,
2241 event: &Event,
2242 bounds: Rectangle,
2243 cursor: mouse::Cursor,
2244 ) -> Option<Action<Message>> {
2245 match event {
2246 Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
2247 self.modifiers.set(*modifiers);
2248 None
2249 }
2250 Event::Keyboard(keyboard::Event::KeyPressed {
2251 key,
2252 modified_key,
2253 modifiers,
2254 text,
2255 ..
2256 }) => {
2257 self.modifiers.set(*modifiers);
2258 self.handle_keyboard_event(
2259 key,
2260 modified_key,
2261 modifiers,
2262 text,
2263 bounds,
2264 &cursor,
2265 )
2266 }
2267 Event::Keyboard(keyboard::Event::KeyReleased {
2268 modifiers, ..
2269 }) => {
2270 self.modifiers.set(*modifiers);
2271 None
2272 }
2273 Event::Mouse(mouse_event) => {
2274 self.handle_mouse_event(mouse_event, bounds, &cursor)
2275 }
2276 Event::InputMethod(ime_event) => {
2277 self.handle_ime_event(ime_event, bounds, &cursor)
2278 }
2279 _ => None,
2280 }
2281 }
2282
2283 fn mouse_interaction(
2289 &self,
2290 _state: &Self::State,
2291 bounds: Rectangle,
2292 cursor: mouse::Cursor,
2293 ) -> mouse::Interaction {
2294 let Some(position) = cursor.position_in(bounds) else {
2295 return mouse::Interaction::default();
2296 };
2297
2298 if self.fold_header_at_point(position).is_some() {
2299 mouse::Interaction::Pointer
2300 } else if position.x >= self.gutter_width() {
2301 mouse::Interaction::Text
2302 } else {
2303 mouse::Interaction::default()
2304 }
2305 }
2306}
2307
2308fn validate_selection_indices(
2321 content: &str,
2322 start: usize,
2323 end: usize,
2324) -> Option<(usize, usize)> {
2325 let len = content.len();
2326 let start = start.min(len);
2328 let end = end.min(len);
2329
2330 if start > end {
2332 return None;
2333 }
2334
2335 if content.is_char_boundary(start) && content.is_char_boundary(end) {
2337 Some((start, end))
2338 } else {
2339 None
2340 }
2341}
2342
2343#[cfg(test)]
2344mod tests {
2345 use super::*;
2346 use crate::canvas_editor::{CHAR_WIDTH, FONT_SIZE, compare_floats};
2347 use std::cmp::Ordering;
2348
2349 fn editor_mouse_interaction(
2350 editor: &CodeEditor,
2351 bounds: Rectangle,
2352 cursor: mouse::Cursor,
2353 ) -> mouse::Interaction {
2354 canvas::Program::<Message>::mouse_interaction(
2355 editor,
2356 &(),
2357 bounds,
2358 cursor,
2359 )
2360 }
2361
2362 #[test]
2363 fn test_vim_navigation_keyboard_route_uses_dedicated_message() {
2364 let mut editor = CodeEditor::new("abc", "txt").with_vim_enabled(true);
2365
2366 assert!(matches!(
2367 editor.printable_input_message('l'),
2368 Message::VimKey('l')
2369 ));
2370
2371 let _ = editor.vim_state.parse_key('i');
2372 assert!(matches!(
2373 editor.printable_input_message('x'),
2374 Message::CharacterInput('x')
2375 ));
2376
2377 editor.set_vim_enabled(false);
2378 assert!(matches!(
2379 editor.printable_input_message('x'),
2380 Message::CharacterInput('x')
2381 ));
2382
2383 editor.set_vim_enabled(true);
2384 let key = keyboard::Key::Character("r".into());
2385 let message = editor
2386 .handle_keyboard_shortcuts(&key, &key, &keyboard::Modifiers::CTRL)
2387 .map(|action| action.into_inner().0);
2388 assert!(matches!(message, Some(Some(Message::Redo))));
2389 }
2390
2391 #[test]
2392 fn test_vim_command_line_routes_enter_and_backspace() {
2393 let mut editor = CodeEditor::new("abc", "txt").with_vim_enabled(true);
2394 editor.request_focus();
2395 editor.has_canvas_focus = true;
2396 editor.focus_locked = false;
2397 let _ = editor.vim_state.parse_key('/');
2398
2399 let backspace = editor
2400 .handle_character_input(
2401 &keyboard::Key::Named(keyboard::key::Named::Backspace),
2402 &keyboard::Modifiers::NONE,
2403 None,
2404 )
2405 .map(|action| action.into_inner().0);
2406 assert!(matches!(backspace, Some(Some(Message::VimKey('\u{8}')))));
2407
2408 let enter = editor
2409 .handle_character_input(
2410 &keyboard::Key::Named(keyboard::key::Named::Enter),
2411 &keyboard::Modifiers::NONE,
2412 None,
2413 )
2414 .map(|action| action.into_inner().0);
2415 assert!(matches!(enter, Some(Some(Message::VimKey('\n')))));
2416 }
2417
2418 #[test]
2419 fn test_vim_cursor_rendering_insert_uses_bar() {
2420 let mut editor = CodeEditor::new("a", "txt").with_vim_enabled(true);
2421 let _ = editor.vim_state.parse_key('i');
2422
2423 let size = editor.cursor_size_for_position((0, 0));
2424
2425 assert_eq!(compare_floats(size.width, 2.0), Ordering::Equal);
2426 assert_eq!(
2427 compare_floats(size.height, editor.line_height() - 4.0),
2428 Ordering::Equal
2429 );
2430 }
2431
2432 #[test]
2433 fn test_vim_cursor_rendering_normal_uses_ascii_block() {
2434 let editor = CodeEditor::new("a", "txt").with_vim_enabled(true);
2435
2436 let size = editor.cursor_size_for_position((0, 0));
2437
2438 assert_eq!(
2439 compare_floats(size.width, editor.char_width()),
2440 Ordering::Equal
2441 );
2442 }
2443
2444 #[test]
2445 fn test_vim_cursor_rendering_normal_uses_cjk_width() {
2446 let editor = CodeEditor::new("汉", "txt").with_vim_enabled(true);
2447
2448 let size = editor.cursor_size_for_position((0, 0));
2449
2450 assert_eq!(
2451 compare_floats(size.width, editor.full_char_width()),
2452 Ordering::Equal
2453 );
2454 }
2455
2456 #[test]
2457 fn test_vim_cursor_rendering_empty_line_has_visible_block() {
2458 let editor = CodeEditor::new("", "txt").with_vim_enabled(true);
2459
2460 let size = editor.cursor_size_for_position((0, 0));
2461
2462 assert_eq!(
2463 compare_floats(size.width, editor.char_width()),
2464 Ordering::Equal
2465 );
2466 assert!(size.width > 2.0);
2467 }
2468
2469 #[test]
2470 fn test_mouse_interaction_uses_text_cursor_in_editable_area() {
2471 let editor = CodeEditor::new("fn main() {}", "rs");
2472 let bounds = Rectangle::new(Point::ORIGIN, Size::new(800.0, 600.0));
2473 let cursor = mouse::Cursor::Available(Point::new(
2474 editor.gutter_width() + 10.0,
2475 10.0,
2476 ));
2477
2478 assert_eq!(
2479 editor_mouse_interaction(&editor, bounds, cursor),
2480 mouse::Interaction::Text
2481 );
2482 }
2483
2484 #[test]
2485 fn test_mouse_interaction_keeps_default_cursor_in_gutter_and_outside() {
2486 let editor = CodeEditor::new("fn main() {}", "rs");
2487 let bounds = Rectangle::new(Point::ORIGIN, Size::new(800.0, 600.0));
2488
2489 assert_eq!(
2490 editor_mouse_interaction(
2491 &editor,
2492 bounds,
2493 mouse::Cursor::Available(Point::new(5.0, 10.0)),
2494 ),
2495 mouse::Interaction::default()
2496 );
2497 assert_eq!(
2498 editor_mouse_interaction(
2499 &editor,
2500 bounds,
2501 mouse::Cursor::Available(Point::new(900.0, 10.0)),
2502 ),
2503 mouse::Interaction::default()
2504 );
2505 }
2506
2507 #[test]
2508 fn test_calculate_segment_geometry_ascii() {
2509 let content = "Hello World";
2515 let (x, w) = calculate_segment_geometry(
2516 content, 0, 6, 11, 0.0, FONT_SIZE, CHAR_WIDTH,
2517 );
2518
2519 let expected_x = CHAR_WIDTH * 6.0;
2520 let expected_w = CHAR_WIDTH * 5.0;
2521
2522 assert_eq!(
2523 compare_floats(x, expected_x),
2524 Ordering::Equal,
2525 "X position mismatch for ASCII"
2526 );
2527 assert_eq!(
2528 compare_floats(w, expected_w),
2529 Ordering::Equal,
2530 "Width mismatch for ASCII"
2531 );
2532 }
2533
2534 #[test]
2535 fn test_calculate_segment_geometry_cjk() {
2536 let content = "你好世界";
2542 let (x, w) = calculate_segment_geometry(
2543 content, 0, 2, 4, 10.0, FONT_SIZE, CHAR_WIDTH,
2544 );
2545
2546 let expected_x = 10.0 + FONT_SIZE * 2.0;
2547 let expected_w = FONT_SIZE * 2.0;
2548
2549 assert_eq!(
2550 compare_floats(x, expected_x),
2551 Ordering::Equal,
2552 "X position mismatch for CJK"
2553 );
2554 assert_eq!(
2555 compare_floats(w, expected_w),
2556 Ordering::Equal,
2557 "Width mismatch for CJK"
2558 );
2559 }
2560
2561 #[test]
2562 fn test_calculate_segment_geometry_mixed() {
2563 let content = "Hi你好";
2569 let (x, w) = calculate_segment_geometry(
2570 content, 0, 2, 4, 0.0, FONT_SIZE, CHAR_WIDTH,
2571 );
2572
2573 let expected_x = CHAR_WIDTH * 2.0;
2574 let expected_w = FONT_SIZE * 2.0;
2575
2576 assert_eq!(
2577 compare_floats(x, expected_x),
2578 Ordering::Equal,
2579 "X position mismatch for mixed content"
2580 );
2581 assert_eq!(
2582 compare_floats(w, expected_w),
2583 Ordering::Equal,
2584 "Width mismatch for mixed content"
2585 );
2586 }
2587
2588 #[test]
2589 fn test_calculate_segment_geometry_empty_range() {
2590 let content = "Hello";
2591 let (x, w) = calculate_segment_geometry(
2592 content, 0, 0, 0, 0.0, FONT_SIZE, CHAR_WIDTH,
2593 );
2594 assert!((x - 0.0).abs() < f32::EPSILON);
2595 assert!((w - 0.0).abs() < f32::EPSILON);
2596 }
2597
2598 #[test]
2599 fn test_calculate_segment_geometry_with_visual_offset() {
2600 let content = "0123456789";
2607 let (x, w) = calculate_segment_geometry(
2608 content, 2, 3, 5, 5.0, FONT_SIZE, CHAR_WIDTH,
2609 );
2610
2611 let expected_x = 5.0 + CHAR_WIDTH * 1.0;
2612 let expected_w = CHAR_WIDTH * 2.0;
2613
2614 assert_eq!(
2615 compare_floats(x, expected_x),
2616 Ordering::Equal,
2617 "X position mismatch with visual offset"
2618 );
2619 assert_eq!(
2620 compare_floats(w, expected_w),
2621 Ordering::Equal,
2622 "Width mismatch with visual offset"
2623 );
2624 }
2625
2626 #[test]
2627 fn test_calculate_segment_geometry_out_of_bounds() {
2628 let content = "Hello";
2634 let (x, w) = calculate_segment_geometry(
2635 content, 0, 10, 15, 0.0, FONT_SIZE, CHAR_WIDTH,
2636 );
2637
2638 let expected_x = CHAR_WIDTH * 5.0; let expected_w = 0.0;
2640
2641 assert_eq!(
2642 compare_floats(x, expected_x),
2643 Ordering::Equal,
2644 "X position mismatch for out of bounds start"
2645 );
2646 assert!(
2647 (w - expected_w).abs() < f32::EPSILON,
2648 "Width should be 0 for out of bounds segment"
2649 );
2650 }
2651
2652 #[test]
2653 fn test_calculate_segment_geometry_special_chars() {
2654 let content = "A👋\tB";
2657 let (x, w) = calculate_segment_geometry(
2662 content, 0, 1, 2, 0.0, FONT_SIZE, CHAR_WIDTH,
2663 );
2664 let expected_x_emoji = CHAR_WIDTH; let expected_w_emoji = FONT_SIZE; assert_eq!(
2668 compare_floats(x, expected_x_emoji),
2669 Ordering::Equal,
2670 "X pos for emoji"
2671 );
2672 assert_eq!(
2673 compare_floats(w, expected_w_emoji),
2674 Ordering::Equal,
2675 "Width for emoji"
2676 );
2677
2678 let (x_tab, w_tab) = calculate_segment_geometry(
2680 content, 0, 2, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
2681 );
2682 let expected_x_tab = CHAR_WIDTH + FONT_SIZE; let expected_w_tab =
2684 CHAR_WIDTH * crate::canvas_editor::TAB_WIDTH as f32;
2685
2686 assert_eq!(
2687 compare_floats(x_tab, expected_x_tab),
2688 Ordering::Equal,
2689 "X pos for tab"
2690 );
2691 assert_eq!(
2692 compare_floats(w_tab, expected_w_tab),
2693 Ordering::Equal,
2694 "Width for tab"
2695 );
2696 }
2697
2698 #[test]
2699 fn test_calculate_segment_geometry_inverted_range() {
2700 let content = "0123456789";
2703 let (x, w) = calculate_segment_geometry(
2704 content, 0, 5, 3, 0.0, FONT_SIZE, CHAR_WIDTH,
2705 );
2706
2707 let expected_x = CHAR_WIDTH * 5.0;
2708 let expected_w = 0.0;
2709
2710 assert_eq!(
2711 compare_floats(x, expected_x),
2712 Ordering::Equal,
2713 "X pos for inverted range"
2714 );
2715 assert!(
2716 (w - expected_w).abs() < f32::EPSILON,
2717 "Width for inverted range"
2718 );
2719 }
2720
2721 #[test]
2722 fn test_validate_selection_indices() {
2723 let content = "Hello";
2725 assert_eq!(validate_selection_indices(content, 0, 5), Some((0, 5)));
2726 assert_eq!(validate_selection_indices(content, 1, 3), Some((1, 3)));
2727
2728 let content = "你好";
2731 assert_eq!(validate_selection_indices(content, 0, 6), Some((0, 6)));
2732 assert_eq!(validate_selection_indices(content, 0, 3), Some((0, 3)));
2733 assert_eq!(validate_selection_indices(content, 3, 6), Some((3, 6)));
2734
2735 assert_eq!(validate_selection_indices(content, 1, 3), None); assert_eq!(validate_selection_indices(content, 0, 4), None); assert_eq!(validate_selection_indices(content, 0, 100), Some((0, 6)));
2742
2743 assert_eq!(validate_selection_indices(content, 3, 0), None);
2745 }
2746
2747 #[test]
2748 fn test_highlight_line_spans_covers_full_line() {
2749 let syntax_set = SyntaxSet::load_defaults_newlines();
2750 let syntax = syntax_set.find_syntax_plain_text();
2751 let theme = syntect::highlighting::Theme::default();
2752
2753 let line = "fn main() {}";
2754 let spans = highlight_line_spans(line, syntax, &theme, &syntax_set);
2755
2756 assert!(!spans.is_empty(), "expected at least one span");
2757 let combined: String =
2758 spans.iter().map(|(_, text)| text.as_str()).collect();
2759 assert_eq!(combined, line, "spans must cover the entire line");
2760 }
2761
2762 #[test]
2763 fn test_highlighted_line_cached_reuses_until_invalidated() {
2764 let editor = CodeEditor::new("fn main() {}\nlet x = 1;", "rs");
2765 let syntax_set = SyntaxSet::load_defaults_newlines();
2766 let syntax = syntax_set.find_syntax_plain_text();
2767 let theme = syntect::highlighting::Theme::default();
2768
2769 let first =
2770 editor.highlighted_line_cached(0, syntax, &theme, &syntax_set);
2771 let second =
2772 editor.highlighted_line_cached(0, syntax, &theme, &syntax_set);
2773 assert!(
2774 Rc::ptr_eq(&first, &second),
2775 "a cached line should be reused as the same Rc"
2776 );
2777
2778 editor.invalidate_highlight_from(0);
2779 let third =
2780 editor.highlighted_line_cached(0, syntax, &theme, &syntax_set);
2781 assert!(
2782 !Rc::ptr_eq(&first, &third),
2783 "invalidation should force the line to be recomputed"
2784 );
2785 }
2786
2787 #[test]
2788 fn test_highlight_budget_uses_plain_fallback_without_scanning_to_target() {
2789 let editor = CodeEditor::new("zero\none\ntwo\nthree\nfour", "txt");
2790 let syntax_set = SyntaxSet::load_defaults_newlines();
2791 let syntax = syntax_set.find_syntax_plain_text();
2792 let theme = syntect::highlighting::Theme::default();
2793 editor.highlight_lines_remaining.set(2);
2794
2795 let spans =
2796 editor.highlighted_line_cached(4, syntax, &theme, &syntax_set);
2797 let combined: String =
2798 spans.iter().map(|(_, text)| text.as_str()).collect();
2799
2800 assert_eq!(combined, "four");
2801 assert_eq!(
2802 editor
2803 .highlight_cache
2804 .borrow()
2805 .as_ref()
2806 .map(super::super::HighlightCache::valid_len),
2807 Some(2)
2808 );
2809 assert_eq!(editor.highlight_lines_remaining.get(), 0);
2810 }
2811
2812 #[test]
2813 fn test_highlighted_line_cached_handles_multiline_comments() {
2814 let syntax_set = SyntaxSet::load_defaults_newlines();
2815 let syntax = syntax_set
2816 .find_syntax_by_extension("rs")
2817 .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
2818 let theme = ThemeSet::load_defaults()
2819 .themes
2820 .get("base16-ocean.dark")
2821 .cloned()
2822 .unwrap_or_default();
2823
2824 let code = "let a = 1;\n/* open\nstill inside\n*/\nlet b = 2;";
2826 let editor = CodeEditor::new(code, "rs");
2827
2828 let sequential =
2830 editor.highlighted_line_cached(2, syntax, &theme, &syntax_set);
2831 let independent = highlight_line_spans(
2833 editor.buffer.line(2),
2834 syntax,
2835 &theme,
2836 &syntax_set,
2837 );
2838
2839 let sequential_color = sequential.first().map(|(color, _)| *color);
2840 let independent_color = independent.first().map(|(color, _)| *color);
2841 assert!(sequential_color.is_some());
2842 assert!(independent_color.is_some());
2843 assert_ne!(
2844 sequential_color, independent_color,
2845 "a line inside a block comment must be colored as a comment"
2846 );
2847 }
2848
2849 #[test]
2850 fn test_expand_tabs_visible_spaces() {
2851 assert_eq!(expand_tabs_visible("a b", 4), "a·b");
2852 assert_eq!(expand_tabs_visible(" x ", 4), "··x··");
2853 }
2854
2855 #[test]
2856 fn test_expand_tabs_visible_tabs() {
2857 assert_eq!(expand_tabs_visible("\t", 4), "→···");
2859 assert_eq!(expand_tabs_visible("a\tb", 4), "a→···b");
2860 }
2861
2862 #[test]
2863 fn test_expand_tabs_visible_no_whitespace() {
2864 assert_eq!(expand_tabs_visible("hello", 4), "hello");
2865 }
2866
2867 #[test]
2868 fn test_split_whitespace_segments_mixed() {
2869 let segs = split_whitespace_segments("a·b");
2870 assert_eq!(segs, vec![(false, "a"), (true, "·"), (false, "b")]);
2871 }
2872
2873 #[test]
2874 fn test_split_whitespace_segments_leading_ws() {
2875 let segs = split_whitespace_segments("··x");
2876 assert_eq!(segs, vec![(true, "··"), (false, "x")]);
2877 }
2878
2879 #[test]
2880 fn test_split_whitespace_segments_all_ws() {
2881 let segs = split_whitespace_segments("···");
2882 assert_eq!(segs, vec![(true, "···")]);
2883 }
2884
2885 #[test]
2886 fn test_split_whitespace_segments_empty() {
2887 let segs = split_whitespace_segments("");
2888 assert!(segs.is_empty());
2889 }
2890
2891 #[test]
2892 fn test_command_g_opens_goto_line_dialog() {
2893 let editor = CodeEditor::new("one\ntwo", "rs");
2894 let key = keyboard::Key::Character("g".into());
2895
2896 let message = editor
2897 .handle_keyboard_shortcuts(
2898 &key,
2899 &key,
2900 &keyboard::Modifiers::COMMAND,
2901 )
2902 .map(|action| action.into_inner().0);
2903
2904 assert!(matches!(message, Some(Some(Message::OpenGotoLine))));
2905 }
2906}