iced_code_editor/canvas_editor/canvas_impl.rs
1//! Canvas rendering implementation using Iced's `canvas::Program`.
2
3use iced::mouse;
4use iced::widget::canvas::{self, Geometry};
5use iced::{Color, Event, Point, Rectangle, Size, Theme, keyboard};
6use syntect::easy::HighlightLines;
7use syntect::highlighting::{Style, ThemeSet};
8use syntect::parsing::SyntaxSet;
9
10use super::{
11 ArrowDirection, CHAR_WIDTH, CodeEditor, FONT_SIZE, GUTTER_WIDTH,
12 LINE_HEIGHT, Message, wrapping::WrappingCalculator,
13};
14use iced::widget::canvas::Action;
15
16impl canvas::Program<Message> for CodeEditor {
17 type State = ();
18
19 fn draw(
20 &self,
21 _state: &Self::State,
22 renderer: &iced::Renderer,
23 _theme: &Theme,
24 bounds: Rectangle,
25 _cursor: mouse::Cursor,
26 ) -> Vec<Geometry> {
27 let geometry = self.cache.draw(renderer, bounds.size(), |frame| {
28 // Initialize wrapping calculator
29 let wrapping_calc =
30 WrappingCalculator::new(self.wrap_enabled, self.wrap_column);
31
32 // Calculate visual lines
33 let visual_lines = wrapping_calc
34 .calculate_visual_lines(&self.buffer, bounds.width);
35
36 // Calculate visible line range based on viewport for optimized rendering
37 // Use bounds.height as fallback when viewport_height is not yet initialized
38 let effective_viewport_height = if self.viewport_height > 0.0 {
39 self.viewport_height
40 } else {
41 bounds.height
42 };
43 let first_visible_line =
44 (self.viewport_scroll / LINE_HEIGHT).floor() as usize;
45 let visible_lines_count =
46 (effective_viewport_height / LINE_HEIGHT).ceil() as usize + 2;
47 let last_visible_line = (first_visible_line + visible_lines_count)
48 .min(visual_lines.len());
49
50 // Load syntax highlighting
51 let syntax_set = SyntaxSet::load_defaults_newlines();
52 let theme_set = ThemeSet::load_defaults();
53 let syntax_theme = &theme_set.themes["base16-ocean.dark"];
54
55 let syntax_ref = match self.syntax.as_str() {
56 "py" | "python" => syntax_set.find_syntax_by_extension("py"),
57 "lua" => syntax_set.find_syntax_by_extension("lua"),
58 "rs" | "rust" => syntax_set.find_syntax_by_extension("rs"),
59 "js" | "javascript" => {
60 syntax_set.find_syntax_by_extension("js")
61 }
62 "html" | "htm" => syntax_set.find_syntax_by_extension("html"),
63 "xml" | "svg" => syntax_set.find_syntax_by_extension("xml"),
64 "css" => syntax_set.find_syntax_by_extension("css"),
65 "json" => syntax_set.find_syntax_by_extension("json"),
66 "md" | "markdown" => syntax_set.find_syntax_by_extension("md"),
67 _ => Some(syntax_set.find_syntax_plain_text()),
68 };
69
70 // Draw only visible lines (virtual scrolling optimization)
71 for (idx, visual_line) in visual_lines
72 .iter()
73 .enumerate()
74 .skip(first_visible_line)
75 .take(last_visible_line - first_visible_line)
76 {
77 let y = idx as f32 * LINE_HEIGHT;
78
79 // Note: Gutter background is handled by a container in view.rs
80 // to ensure proper clipping when the pane is resized.
81
82 // Draw line number only for first segment
83 if visual_line.is_first_segment() {
84 let line_num_text =
85 format!("{:>4}", visual_line.logical_line + 1);
86 frame.fill_text(canvas::Text {
87 content: line_num_text,
88 position: Point::new(5.0, y + 2.0),
89 color: self.style.line_number_color,
90 size: FONT_SIZE.into(),
91 font: iced::Font::MONOSPACE,
92 ..canvas::Text::default()
93 });
94 } else {
95 // Draw wrap indicator for continuation lines
96 frame.fill_text(canvas::Text {
97 content: "↪".to_string(),
98 position: Point::new(GUTTER_WIDTH - 20.0, y + 2.0),
99 color: self.style.line_number_color,
100 size: FONT_SIZE.into(),
101 font: iced::Font::MONOSPACE,
102 ..canvas::Text::default()
103 });
104 }
105
106 // Highlight current line (based on logical line)
107 if visual_line.logical_line == self.cursor.0 {
108 frame.fill_rectangle(
109 Point::new(GUTTER_WIDTH, y),
110 Size::new(bounds.width - GUTTER_WIDTH, LINE_HEIGHT),
111 self.style.current_line_highlight,
112 );
113 }
114
115 // Draw text content with syntax highlighting
116 let full_line_content =
117 self.buffer.line(visual_line.logical_line);
118
119 // Convert character indices to byte indices for UTF-8 string slicing
120 let start_byte = full_line_content
121 .char_indices()
122 .nth(visual_line.start_col)
123 .map_or(full_line_content.len(), |(idx, _)| idx);
124 let end_byte = full_line_content
125 .char_indices()
126 .nth(visual_line.end_col)
127 .map_or(full_line_content.len(), |(idx, _)| idx);
128 let line_segment = &full_line_content[start_byte..end_byte];
129
130 if let Some(syntax) = syntax_ref {
131 let mut highlighter =
132 HighlightLines::new(syntax, syntax_theme);
133
134 // Highlight the full line to get correct token colors
135 let full_line_ranges = highlighter
136 .highlight_line(full_line_content, &syntax_set)
137 .unwrap_or_else(|_| {
138 vec![(Style::default(), full_line_content)]
139 });
140
141 // Extract only the ranges that fall within our segment
142 let mut x_offset = GUTTER_WIDTH + 5.0;
143 let mut char_pos = 0;
144
145 for (style, text) in full_line_ranges {
146 let text_len = text.chars().count();
147 let text_end = char_pos + text_len;
148
149 // Check if this token intersects with our segment
150 if text_end > visual_line.start_col
151 && char_pos < visual_line.end_col
152 {
153 // Calculate the intersection
154 let segment_start =
155 char_pos.max(visual_line.start_col);
156 let segment_end = text_end.min(visual_line.end_col);
157
158 let text_start_offset =
159 segment_start.saturating_sub(char_pos);
160 let text_end_offset = text_start_offset
161 + (segment_end - segment_start);
162
163 // Convert character offsets to byte offsets for UTF-8 slicing
164 let start_byte = text
165 .char_indices()
166 .nth(text_start_offset)
167 .map_or(text.len(), |(idx, _)| idx);
168 let end_byte = text
169 .char_indices()
170 .nth(text_end_offset)
171 .map_or(text.len(), |(idx, _)| idx);
172
173 let segment_text = &text[start_byte..end_byte];
174
175 let color = Color::from_rgb(
176 f32::from(style.foreground.r) / 255.0,
177 f32::from(style.foreground.g) / 255.0,
178 f32::from(style.foreground.b) / 255.0,
179 );
180
181 frame.fill_text(canvas::Text {
182 content: segment_text.to_string(),
183 position: Point::new(x_offset, y + 2.0),
184 color,
185 size: FONT_SIZE.into(),
186 font: iced::Font::MONOSPACE,
187 ..canvas::Text::default()
188 });
189
190 x_offset += segment_text.chars().count() as f32
191 * CHAR_WIDTH;
192 }
193
194 char_pos = text_end;
195 }
196 } else {
197 // Fallback to plain text
198 frame.fill_text(canvas::Text {
199 content: line_segment.to_string(),
200 position: Point::new(GUTTER_WIDTH + 5.0, y + 2.0),
201 color: self.style.text_color,
202 size: FONT_SIZE.into(),
203 font: iced::Font::MONOSPACE,
204 ..canvas::Text::default()
205 });
206 }
207 }
208
209 // Draw search match highlights
210 if self.search_state.is_open && !self.search_state.query.is_empty()
211 {
212 let query_len = self.search_state.query.chars().count();
213
214 for (match_idx, search_match) in
215 self.search_state.matches.iter().enumerate()
216 {
217 // Determine if this is the current match
218 let is_current = self.search_state.current_match_index
219 == Some(match_idx);
220
221 let highlight_color = if is_current {
222 // Orange for current match
223 Color { r: 1.0, g: 0.6, b: 0.0, a: 0.4 }
224 } else {
225 // Yellow for other matches
226 Color { r: 1.0, g: 1.0, b: 0.0, a: 0.3 }
227 };
228
229 // Convert logical position to visual line
230 let start_visual = WrappingCalculator::logical_to_visual(
231 &visual_lines,
232 search_match.line,
233 search_match.col,
234 );
235 let end_visual = WrappingCalculator::logical_to_visual(
236 &visual_lines,
237 search_match.line,
238 search_match.col + query_len,
239 );
240
241 if let (Some(start_v), Some(end_v)) =
242 (start_visual, end_visual)
243 {
244 if start_v == end_v {
245 // Match within same visual line
246 let y = start_v as f32 * LINE_HEIGHT;
247 let x_start = GUTTER_WIDTH
248 + 5.0
249 + search_match.col as f32 * CHAR_WIDTH;
250 let x_end = GUTTER_WIDTH
251 + 5.0
252 + (search_match.col + query_len) as f32
253 * CHAR_WIDTH;
254
255 frame.fill_rectangle(
256 Point::new(x_start, y + 2.0),
257 Size::new(x_end - x_start, LINE_HEIGHT - 4.0),
258 highlight_color,
259 );
260 } else {
261 // Match spans multiple visual lines
262 for (v_idx, vl) in visual_lines
263 .iter()
264 .enumerate()
265 .skip(start_v)
266 .take(end_v - start_v + 1)
267 {
268 let y = v_idx as f32 * LINE_HEIGHT;
269
270 let match_start_col = search_match.col;
271 let match_end_col =
272 search_match.col + query_len;
273
274 let sel_start_col = if v_idx == start_v {
275 match_start_col
276 } else {
277 vl.start_col
278 };
279 let sel_end_col = if v_idx == end_v {
280 match_end_col
281 } else {
282 vl.end_col
283 };
284
285 let x_start = GUTTER_WIDTH
286 + 5.0
287 + (sel_start_col - vl.start_col) as f32
288 * CHAR_WIDTH;
289 let x_end = GUTTER_WIDTH
290 + 5.0
291 + (sel_end_col - vl.start_col) as f32
292 * CHAR_WIDTH;
293
294 frame.fill_rectangle(
295 Point::new(x_start, y + 2.0),
296 Size::new(
297 x_end - x_start,
298 LINE_HEIGHT - 4.0,
299 ),
300 highlight_color,
301 );
302 }
303 }
304 }
305 }
306 }
307
308 // Draw selection highlight
309 if let Some((start, end)) = self.get_selection_range()
310 && start != end
311 {
312 let selection_color = Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 };
313
314 if start.0 == end.0 {
315 // Single line selection - need to handle wrapped segments
316 let start_visual = WrappingCalculator::logical_to_visual(
317 &visual_lines,
318 start.0,
319 start.1,
320 );
321 let end_visual = WrappingCalculator::logical_to_visual(
322 &visual_lines,
323 end.0,
324 end.1,
325 );
326
327 if let (Some(start_v), Some(end_v)) =
328 (start_visual, end_visual)
329 {
330 if start_v == end_v {
331 // Selection within same visual line
332 let y = start_v as f32 * LINE_HEIGHT;
333 let x_start = GUTTER_WIDTH
334 + 5.0
335 + start.1 as f32 * CHAR_WIDTH;
336 let x_end =
337 GUTTER_WIDTH + 5.0 + end.1 as f32 * CHAR_WIDTH;
338
339 frame.fill_rectangle(
340 Point::new(x_start, y + 2.0),
341 Size::new(x_end - x_start, LINE_HEIGHT - 4.0),
342 selection_color,
343 );
344 } else {
345 // Selection spans multiple visual lines (same logical line)
346 for (v_idx, vl) in visual_lines
347 .iter()
348 .enumerate()
349 .skip(start_v)
350 .take(end_v - start_v + 1)
351 {
352 let y = v_idx as f32 * LINE_HEIGHT;
353
354 let sel_start_col = if v_idx == start_v {
355 start.1
356 } else {
357 vl.start_col
358 };
359 let sel_end_col = if v_idx == end_v {
360 end.1
361 } else {
362 vl.end_col
363 };
364
365 let x_start = GUTTER_WIDTH
366 + 5.0
367 + (sel_start_col - vl.start_col) as f32
368 * CHAR_WIDTH;
369 let x_end = GUTTER_WIDTH
370 + 5.0
371 + (sel_end_col - vl.start_col) as f32
372 * CHAR_WIDTH;
373
374 frame.fill_rectangle(
375 Point::new(x_start, y + 2.0),
376 Size::new(
377 x_end - x_start,
378 LINE_HEIGHT - 4.0,
379 ),
380 selection_color,
381 );
382 }
383 }
384 }
385 } else {
386 // Multi-line selection
387 let start_visual = WrappingCalculator::logical_to_visual(
388 &visual_lines,
389 start.0,
390 start.1,
391 );
392 let end_visual = WrappingCalculator::logical_to_visual(
393 &visual_lines,
394 end.0,
395 end.1,
396 );
397
398 if let (Some(start_v), Some(end_v)) =
399 (start_visual, end_visual)
400 {
401 for (v_idx, vl) in visual_lines
402 .iter()
403 .enumerate()
404 .skip(start_v)
405 .take(end_v - start_v + 1)
406 {
407 let y = v_idx as f32 * LINE_HEIGHT;
408
409 let sel_start_col = if vl.logical_line == start.0
410 && v_idx == start_v
411 {
412 start.1
413 } else {
414 vl.start_col
415 };
416
417 let sel_end_col =
418 if vl.logical_line == end.0 && v_idx == end_v {
419 end.1
420 } else {
421 vl.end_col
422 };
423
424 let x_start = GUTTER_WIDTH
425 + 5.0
426 + (sel_start_col - vl.start_col) as f32
427 * CHAR_WIDTH;
428 let x_end = GUTTER_WIDTH
429 + 5.0
430 + (sel_end_col - vl.start_col) as f32
431 * CHAR_WIDTH;
432
433 frame.fill_rectangle(
434 Point::new(x_start, y + 2.0),
435 Size::new(x_end - x_start, LINE_HEIGHT - 4.0),
436 selection_color,
437 );
438 }
439 }
440 }
441 }
442
443 // Draw cursor
444 if self.cursor_visible {
445 // Find the visual line containing the cursor
446 if let Some(cursor_visual) =
447 WrappingCalculator::logical_to_visual(
448 &visual_lines,
449 self.cursor.0,
450 self.cursor.1,
451 )
452 {
453 let vl = &visual_lines[cursor_visual];
454 let cursor_x = GUTTER_WIDTH
455 + 5.0
456 + (self.cursor.1 - vl.start_col) as f32 * CHAR_WIDTH;
457 let cursor_y = cursor_visual as f32 * LINE_HEIGHT;
458
459 frame.fill_rectangle(
460 Point::new(cursor_x, cursor_y + 2.0),
461 Size::new(2.0, LINE_HEIGHT - 4.0),
462 self.style.text_color,
463 );
464 }
465 }
466 });
467
468 vec![geometry]
469 }
470
471 fn update(
472 &self,
473 _state: &mut Self::State,
474 event: &Event,
475 bounds: Rectangle,
476 cursor: mouse::Cursor,
477 ) -> Option<Action<Message>> {
478 match event {
479 Event::Keyboard(keyboard::Event::KeyPressed {
480 key,
481 modifiers,
482 text,
483 ..
484 }) => {
485 // Only process keyboard events if this editor has focus
486 let focused_id = super::FOCUSED_EDITOR_ID
487 .load(std::sync::atomic::Ordering::Relaxed);
488 if focused_id != self.editor_id {
489 return None;
490 }
491
492 // Handle Ctrl+C / Ctrl+Insert (copy)
493 if (modifiers.control()
494 && matches!(key, keyboard::Key::Character(c) if c.as_str() == "c"))
495 || (modifiers.control()
496 && matches!(
497 key,
498 keyboard::Key::Named(keyboard::key::Named::Insert)
499 ))
500 {
501 return Some(Action::publish(Message::Copy).and_capture());
502 }
503
504 // Handle Ctrl+Z (undo)
505 if modifiers.control()
506 && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")
507 {
508 return Some(Action::publish(Message::Undo).and_capture());
509 }
510
511 // Handle Ctrl+Y (redo)
512 if modifiers.control()
513 && matches!(key, keyboard::Key::Character(y) if y.as_str() == "y")
514 {
515 return Some(Action::publish(Message::Redo).and_capture());
516 }
517
518 // Handle Ctrl+F (open search)
519 if modifiers.control()
520 && matches!(key, keyboard::Key::Character(f) if f.as_str() == "f")
521 && self.search_replace_enabled
522 {
523 return Some(
524 Action::publish(Message::OpenSearch).and_capture(),
525 );
526 }
527
528 // Handle Ctrl+H (open search and replace)
529 if modifiers.control()
530 && matches!(key, keyboard::Key::Character(h) if h.as_str() == "h")
531 && self.search_replace_enabled
532 {
533 return Some(
534 Action::publish(Message::OpenSearchReplace)
535 .and_capture(),
536 );
537 }
538
539 // Handle Escape (close search dialog if open)
540 if matches!(
541 key,
542 keyboard::Key::Named(keyboard::key::Named::Escape)
543 ) {
544 return Some(
545 Action::publish(Message::CloseSearch).and_capture(),
546 );
547 }
548
549 // Handle Tab (cycle forward in search dialog if open)
550 if matches!(
551 key,
552 keyboard::Key::Named(keyboard::key::Named::Tab)
553 ) && self.search_state.is_open
554 {
555 if modifiers.shift() {
556 // Shift+Tab: cycle backward
557 return Some(
558 Action::publish(Message::SearchDialogShiftTab)
559 .and_capture(),
560 );
561 } else {
562 // Tab: cycle forward
563 return Some(
564 Action::publish(Message::SearchDialogTab)
565 .and_capture(),
566 );
567 }
568 }
569
570 // Handle F3 (find next) and Shift+F3 (find previous)
571 if matches!(key, keyboard::Key::Named(keyboard::key::Named::F3))
572 && self.search_replace_enabled
573 {
574 if modifiers.shift() {
575 return Some(
576 Action::publish(Message::FindPrevious)
577 .and_capture(),
578 );
579 } else {
580 return Some(
581 Action::publish(Message::FindNext).and_capture(),
582 );
583 }
584 }
585
586 // Handle Ctrl+V / Shift+Insert (paste) - read clipboard and send paste message
587 if (modifiers.control()
588 && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v"))
589 || (modifiers.shift()
590 && matches!(
591 key,
592 keyboard::Key::Named(keyboard::key::Named::Insert)
593 ))
594 {
595 // Return an action that requests clipboard read
596 return Some(Action::publish(
597 Message::Paste(String::new()),
598 ));
599 }
600
601 // Handle Ctrl+Home (go to start of document)
602 if modifiers.control()
603 && matches!(
604 key,
605 keyboard::Key::Named(keyboard::key::Named::Home)
606 )
607 {
608 return Some(
609 Action::publish(Message::CtrlHome).and_capture(),
610 );
611 }
612
613 // Handle Ctrl+End (go to end of document)
614 if modifiers.control()
615 && matches!(
616 key,
617 keyboard::Key::Named(keyboard::key::Named::End)
618 )
619 {
620 return Some(
621 Action::publish(Message::CtrlEnd).and_capture(),
622 );
623 }
624
625 // Handle Shift+Delete (delete selection)
626 if modifiers.shift()
627 && matches!(
628 key,
629 keyboard::Key::Named(keyboard::key::Named::Delete)
630 )
631 {
632 return Some(
633 Action::publish(Message::DeleteSelection).and_capture(),
634 );
635 }
636
637 // PRIORITY 1: Check if 'text' field has valid printable character
638 // This handles:
639 // - Numpad keys with NumLock ON (key=Named(ArrowDown), text=Some("2"))
640 // - Regular typing with shift, accents, international layouts
641 if let Some(text_content) = text
642 && !text_content.is_empty()
643 && !modifiers.control()
644 && !modifiers.alt()
645 {
646 // Check if it's a printable character (not a control character)
647 // This filters out Enter (\n), Tab (\t), Delete (U+007F), etc.
648 if let Some(first_char) = text_content.chars().next()
649 && !first_char.is_control()
650 {
651 return Some(
652 Action::publish(Message::CharacterInput(
653 first_char,
654 ))
655 .and_capture(),
656 );
657 }
658 }
659
660 // PRIORITY 2: Handle special named keys (navigation, editing)
661 // These are only processed if text didn't contain a printable character
662 let message = match key {
663 keyboard::Key::Named(keyboard::key::Named::Backspace) => {
664 Some(Message::Backspace)
665 }
666 keyboard::Key::Named(keyboard::key::Named::Delete) => {
667 Some(Message::Delete)
668 }
669 keyboard::Key::Named(keyboard::key::Named::Enter) => {
670 Some(Message::Enter)
671 }
672 keyboard::Key::Named(keyboard::key::Named::Tab) => {
673 // Insert 4 spaces for Tab
674 Some(Message::Tab)
675 }
676 keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
677 Some(Message::ArrowKey(
678 ArrowDirection::Up,
679 modifiers.shift(),
680 ))
681 }
682 keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
683 Some(Message::ArrowKey(
684 ArrowDirection::Down,
685 modifiers.shift(),
686 ))
687 }
688 keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
689 Some(Message::ArrowKey(
690 ArrowDirection::Left,
691 modifiers.shift(),
692 ))
693 }
694 keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
695 Some(Message::ArrowKey(
696 ArrowDirection::Right,
697 modifiers.shift(),
698 ))
699 }
700 keyboard::Key::Named(keyboard::key::Named::PageUp) => {
701 Some(Message::PageUp)
702 }
703 keyboard::Key::Named(keyboard::key::Named::PageDown) => {
704 Some(Message::PageDown)
705 }
706 keyboard::Key::Named(keyboard::key::Named::Home) => {
707 Some(Message::Home(modifiers.shift()))
708 }
709 keyboard::Key::Named(keyboard::key::Named::End) => {
710 Some(Message::End(modifiers.shift()))
711 }
712 // PRIORITY 3: Fallback to extracting from 'key' if text was empty/control char
713 // This handles edge cases where text field is not populated
714 _ => {
715 if !modifiers.control()
716 && !modifiers.alt()
717 && let keyboard::Key::Character(c) = key
718 && !c.is_empty()
719 {
720 return c
721 .chars()
722 .next()
723 .map(Message::CharacterInput)
724 .map(|msg| Action::publish(msg).and_capture());
725 }
726 None
727 }
728 };
729
730 message.map(|msg| Action::publish(msg).and_capture())
731 }
732 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
733 cursor.position_in(bounds).map(|position| {
734 // Don't capture the event so it can bubble up for focus management
735 Action::publish(Message::MouseClick(position))
736 })
737 }
738 Event::Mouse(mouse::Event::CursorMoved { .. }) => {
739 // Handle mouse drag for selection only when cursor is within bounds
740 cursor.position_in(bounds).map(|position| {
741 Action::publish(Message::MouseDrag(position)).and_capture()
742 })
743 }
744 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
745 // Only handle mouse release when cursor is within bounds
746 // This prevents capturing events meant for other widgets
747 if cursor.is_over(bounds) {
748 Some(Action::publish(Message::MouseRelease).and_capture())
749 } else {
750 None
751 }
752 }
753 _ => None,
754 }
755 }
756}