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 let line_segment = &full_line_content
119 [visual_line.start_col..visual_line.end_col];
120
121 if let Some(syntax) = syntax_ref {
122 let mut highlighter =
123 HighlightLines::new(syntax, syntax_theme);
124
125 // Highlight the full line to get correct token colors
126 let full_line_ranges = highlighter
127 .highlight_line(full_line_content, &syntax_set)
128 .unwrap_or_else(|_| {
129 vec![(Style::default(), full_line_content)]
130 });
131
132 // Extract only the ranges that fall within our segment
133 let mut x_offset = GUTTER_WIDTH + 5.0;
134 let mut char_pos = 0;
135
136 for (style, text) in full_line_ranges {
137 let text_len = text.len();
138 let text_end = char_pos + text_len;
139
140 // Check if this token intersects with our segment
141 if text_end > visual_line.start_col
142 && char_pos < visual_line.end_col
143 {
144 // Calculate the intersection
145 let segment_start =
146 char_pos.max(visual_line.start_col);
147 let segment_end = text_end.min(visual_line.end_col);
148
149 let text_start_offset =
150 segment_start.saturating_sub(char_pos);
151 let text_end_offset = text_start_offset
152 + (segment_end - segment_start);
153
154 let segment_text =
155 &text[text_start_offset..text_end_offset];
156
157 let color = Color::from_rgb(
158 f32::from(style.foreground.r) / 255.0,
159 f32::from(style.foreground.g) / 255.0,
160 f32::from(style.foreground.b) / 255.0,
161 );
162
163 frame.fill_text(canvas::Text {
164 content: segment_text.to_string(),
165 position: Point::new(x_offset, y + 2.0),
166 color,
167 size: FONT_SIZE.into(),
168 font: iced::Font::MONOSPACE,
169 ..canvas::Text::default()
170 });
171
172 x_offset += segment_text.len() as f32 * CHAR_WIDTH;
173 }
174
175 char_pos = text_end;
176 }
177 } else {
178 // Fallback to plain text
179 frame.fill_text(canvas::Text {
180 content: line_segment.to_string(),
181 position: Point::new(GUTTER_WIDTH + 5.0, y + 2.0),
182 color: self.style.text_color,
183 size: FONT_SIZE.into(),
184 font: iced::Font::MONOSPACE,
185 ..canvas::Text::default()
186 });
187 }
188 }
189
190 // Draw search match highlights
191 if self.search_state.is_open && !self.search_state.query.is_empty()
192 {
193 let query_len = self.search_state.query.chars().count();
194
195 for (match_idx, search_match) in
196 self.search_state.matches.iter().enumerate()
197 {
198 // Determine if this is the current match
199 let is_current = self.search_state.current_match_index
200 == Some(match_idx);
201
202 let highlight_color = if is_current {
203 // Orange for current match
204 Color { r: 1.0, g: 0.6, b: 0.0, a: 0.4 }
205 } else {
206 // Yellow for other matches
207 Color { r: 1.0, g: 1.0, b: 0.0, a: 0.3 }
208 };
209
210 // Convert logical position to visual line
211 let start_visual = WrappingCalculator::logical_to_visual(
212 &visual_lines,
213 search_match.line,
214 search_match.col,
215 );
216 let end_visual = WrappingCalculator::logical_to_visual(
217 &visual_lines,
218 search_match.line,
219 search_match.col + query_len,
220 );
221
222 if let (Some(start_v), Some(end_v)) =
223 (start_visual, end_visual)
224 {
225 if start_v == end_v {
226 // Match within same visual line
227 let y = start_v as f32 * LINE_HEIGHT;
228 let x_start = GUTTER_WIDTH
229 + 5.0
230 + search_match.col as f32 * CHAR_WIDTH;
231 let x_end = GUTTER_WIDTH
232 + 5.0
233 + (search_match.col + query_len) as f32
234 * CHAR_WIDTH;
235
236 frame.fill_rectangle(
237 Point::new(x_start, y + 2.0),
238 Size::new(x_end - x_start, LINE_HEIGHT - 4.0),
239 highlight_color,
240 );
241 } else {
242 // Match spans multiple visual lines
243 for (v_idx, vl) in visual_lines
244 .iter()
245 .enumerate()
246 .skip(start_v)
247 .take(end_v - start_v + 1)
248 {
249 let y = v_idx as f32 * LINE_HEIGHT;
250
251 let match_start_col = search_match.col;
252 let match_end_col =
253 search_match.col + query_len;
254
255 let sel_start_col = if v_idx == start_v {
256 match_start_col
257 } else {
258 vl.start_col
259 };
260 let sel_end_col = if v_idx == end_v {
261 match_end_col
262 } else {
263 vl.end_col
264 };
265
266 let x_start = GUTTER_WIDTH
267 + 5.0
268 + (sel_start_col - vl.start_col) as f32
269 * CHAR_WIDTH;
270 let x_end = GUTTER_WIDTH
271 + 5.0
272 + (sel_end_col - vl.start_col) as f32
273 * CHAR_WIDTH;
274
275 frame.fill_rectangle(
276 Point::new(x_start, y + 2.0),
277 Size::new(
278 x_end - x_start,
279 LINE_HEIGHT - 4.0,
280 ),
281 highlight_color,
282 );
283 }
284 }
285 }
286 }
287 }
288
289 // Draw selection highlight
290 if let Some((start, end)) = self.get_selection_range()
291 && start != end
292 {
293 let selection_color = Color { r: 0.3, g: 0.5, b: 0.8, a: 0.3 };
294
295 if start.0 == end.0 {
296 // Single line selection - need to handle wrapped segments
297 let start_visual = WrappingCalculator::logical_to_visual(
298 &visual_lines,
299 start.0,
300 start.1,
301 );
302 let end_visual = WrappingCalculator::logical_to_visual(
303 &visual_lines,
304 end.0,
305 end.1,
306 );
307
308 if let (Some(start_v), Some(end_v)) =
309 (start_visual, end_visual)
310 {
311 if start_v == end_v {
312 // Selection within same visual line
313 let y = start_v as f32 * LINE_HEIGHT;
314 let x_start = GUTTER_WIDTH
315 + 5.0
316 + start.1 as f32 * CHAR_WIDTH;
317 let x_end =
318 GUTTER_WIDTH + 5.0 + end.1 as f32 * CHAR_WIDTH;
319
320 frame.fill_rectangle(
321 Point::new(x_start, y + 2.0),
322 Size::new(x_end - x_start, LINE_HEIGHT - 4.0),
323 selection_color,
324 );
325 } else {
326 // Selection spans multiple visual lines (same logical line)
327 for (v_idx, vl) in visual_lines
328 .iter()
329 .enumerate()
330 .skip(start_v)
331 .take(end_v - start_v + 1)
332 {
333 let y = v_idx as f32 * LINE_HEIGHT;
334
335 let sel_start_col = if v_idx == start_v {
336 start.1
337 } else {
338 vl.start_col
339 };
340 let sel_end_col = if v_idx == end_v {
341 end.1
342 } else {
343 vl.end_col
344 };
345
346 let x_start = GUTTER_WIDTH
347 + 5.0
348 + (sel_start_col - vl.start_col) as f32
349 * CHAR_WIDTH;
350 let x_end = GUTTER_WIDTH
351 + 5.0
352 + (sel_end_col - vl.start_col) as f32
353 * CHAR_WIDTH;
354
355 frame.fill_rectangle(
356 Point::new(x_start, y + 2.0),
357 Size::new(
358 x_end - x_start,
359 LINE_HEIGHT - 4.0,
360 ),
361 selection_color,
362 );
363 }
364 }
365 }
366 } else {
367 // Multi-line selection
368 let start_visual = WrappingCalculator::logical_to_visual(
369 &visual_lines,
370 start.0,
371 start.1,
372 );
373 let end_visual = WrappingCalculator::logical_to_visual(
374 &visual_lines,
375 end.0,
376 end.1,
377 );
378
379 if let (Some(start_v), Some(end_v)) =
380 (start_visual, end_visual)
381 {
382 for (v_idx, vl) in visual_lines
383 .iter()
384 .enumerate()
385 .skip(start_v)
386 .take(end_v - start_v + 1)
387 {
388 let y = v_idx as f32 * LINE_HEIGHT;
389
390 let sel_start_col = if vl.logical_line == start.0
391 && v_idx == start_v
392 {
393 start.1
394 } else {
395 vl.start_col
396 };
397
398 let sel_end_col =
399 if vl.logical_line == end.0 && v_idx == end_v {
400 end.1
401 } else {
402 vl.end_col
403 };
404
405 let x_start = GUTTER_WIDTH
406 + 5.0
407 + (sel_start_col - vl.start_col) as f32
408 * CHAR_WIDTH;
409 let x_end = GUTTER_WIDTH
410 + 5.0
411 + (sel_end_col - vl.start_col) as f32
412 * CHAR_WIDTH;
413
414 frame.fill_rectangle(
415 Point::new(x_start, y + 2.0),
416 Size::new(x_end - x_start, LINE_HEIGHT - 4.0),
417 selection_color,
418 );
419 }
420 }
421 }
422 }
423
424 // Draw cursor
425 if self.cursor_visible {
426 // Find the visual line containing the cursor
427 if let Some(cursor_visual) =
428 WrappingCalculator::logical_to_visual(
429 &visual_lines,
430 self.cursor.0,
431 self.cursor.1,
432 )
433 {
434 let vl = &visual_lines[cursor_visual];
435 let cursor_x = GUTTER_WIDTH
436 + 5.0
437 + (self.cursor.1 - vl.start_col) as f32 * CHAR_WIDTH;
438 let cursor_y = cursor_visual as f32 * LINE_HEIGHT;
439
440 frame.fill_rectangle(
441 Point::new(cursor_x, cursor_y + 2.0),
442 Size::new(2.0, LINE_HEIGHT - 4.0),
443 self.style.text_color,
444 );
445 }
446 }
447 });
448
449 vec![geometry]
450 }
451
452 fn update(
453 &self,
454 _state: &mut Self::State,
455 event: &Event,
456 bounds: Rectangle,
457 cursor: mouse::Cursor,
458 ) -> Option<Action<Message>> {
459 match event {
460 Event::Keyboard(keyboard::Event::KeyPressed {
461 key,
462 modifiers,
463 text,
464 ..
465 }) => {
466 // Handle Ctrl+C / Ctrl+Insert (copy)
467 if (modifiers.control()
468 && matches!(key, keyboard::Key::Character(c) if c.as_str() == "c"))
469 || (modifiers.control()
470 && matches!(
471 key,
472 keyboard::Key::Named(keyboard::key::Named::Insert)
473 ))
474 {
475 return Some(Action::publish(Message::Copy).and_capture());
476 }
477
478 // Handle Ctrl+Z (undo)
479 if modifiers.control()
480 && matches!(key, keyboard::Key::Character(z) if z.as_str() == "z")
481 {
482 return Some(Action::publish(Message::Undo).and_capture());
483 }
484
485 // Handle Ctrl+Y (redo)
486 if modifiers.control()
487 && matches!(key, keyboard::Key::Character(y) if y.as_str() == "y")
488 {
489 return Some(Action::publish(Message::Redo).and_capture());
490 }
491
492 // Handle Ctrl+F (open search)
493 if modifiers.control()
494 && matches!(key, keyboard::Key::Character(f) if f.as_str() == "f")
495 && self.search_replace_enabled
496 {
497 return Some(
498 Action::publish(Message::OpenSearch).and_capture(),
499 );
500 }
501
502 // Handle Ctrl+H (open search and replace)
503 if modifiers.control()
504 && matches!(key, keyboard::Key::Character(h) if h.as_str() == "h")
505 && self.search_replace_enabled
506 {
507 return Some(
508 Action::publish(Message::OpenSearchReplace)
509 .and_capture(),
510 );
511 }
512
513 // Handle Escape (close search dialog if open)
514 if matches!(
515 key,
516 keyboard::Key::Named(keyboard::key::Named::Escape)
517 ) {
518 return Some(
519 Action::publish(Message::CloseSearch).and_capture(),
520 );
521 }
522
523 // Handle Tab (cycle forward in search dialog if open)
524 if matches!(
525 key,
526 keyboard::Key::Named(keyboard::key::Named::Tab)
527 ) && self.search_state.is_open
528 {
529 if modifiers.shift() {
530 // Shift+Tab: cycle backward
531 return Some(
532 Action::publish(Message::SearchDialogShiftTab)
533 .and_capture(),
534 );
535 } else {
536 // Tab: cycle forward
537 return Some(
538 Action::publish(Message::SearchDialogTab)
539 .and_capture(),
540 );
541 }
542 }
543
544 // Handle F3 (find next) and Shift+F3 (find previous)
545 if matches!(key, keyboard::Key::Named(keyboard::key::Named::F3))
546 && self.search_replace_enabled
547 {
548 if modifiers.shift() {
549 return Some(
550 Action::publish(Message::FindPrevious)
551 .and_capture(),
552 );
553 } else {
554 return Some(
555 Action::publish(Message::FindNext).and_capture(),
556 );
557 }
558 }
559
560 // Handle Ctrl+V / Shift+Insert (paste) - read clipboard and send paste message
561 if (modifiers.control()
562 && matches!(key, keyboard::Key::Character(v) if v.as_str() == "v"))
563 || (modifiers.shift()
564 && matches!(
565 key,
566 keyboard::Key::Named(keyboard::key::Named::Insert)
567 ))
568 {
569 // Return an action that requests clipboard read
570 return Some(Action::publish(
571 Message::Paste(String::new()),
572 ));
573 }
574
575 // Handle Ctrl+Home (go to start of document)
576 if modifiers.control()
577 && matches!(
578 key,
579 keyboard::Key::Named(keyboard::key::Named::Home)
580 )
581 {
582 return Some(
583 Action::publish(Message::CtrlHome).and_capture(),
584 );
585 }
586
587 // Handle Ctrl+End (go to end of document)
588 if modifiers.control()
589 && matches!(
590 key,
591 keyboard::Key::Named(keyboard::key::Named::End)
592 )
593 {
594 return Some(
595 Action::publish(Message::CtrlEnd).and_capture(),
596 );
597 }
598
599 // Handle Shift+Delete (delete selection)
600 if modifiers.shift()
601 && matches!(
602 key,
603 keyboard::Key::Named(keyboard::key::Named::Delete)
604 )
605 {
606 return Some(
607 Action::publish(Message::DeleteSelection).and_capture(),
608 );
609 }
610
611 // PRIORITY 1: Check if 'text' field has valid printable character
612 // This handles:
613 // - Numpad keys with NumLock ON (key=Named(ArrowDown), text=Some("2"))
614 // - Regular typing with shift, accents, international layouts
615 if let Some(text_content) = text
616 && !text_content.is_empty()
617 && !modifiers.control()
618 && !modifiers.alt()
619 {
620 // Check if it's a printable character (not a control character)
621 // This filters out Enter (\n), Tab (\t), Delete (U+007F), etc.
622 if let Some(first_char) = text_content.chars().next()
623 && !first_char.is_control()
624 {
625 return Some(
626 Action::publish(Message::CharacterInput(
627 first_char,
628 ))
629 .and_capture(),
630 );
631 }
632 }
633
634 // PRIORITY 2: Handle special named keys (navigation, editing)
635 // These are only processed if text didn't contain a printable character
636 let message = match key {
637 keyboard::Key::Named(keyboard::key::Named::Backspace) => {
638 Some(Message::Backspace)
639 }
640 keyboard::Key::Named(keyboard::key::Named::Delete) => {
641 Some(Message::Delete)
642 }
643 keyboard::Key::Named(keyboard::key::Named::Enter) => {
644 Some(Message::Enter)
645 }
646 keyboard::Key::Named(keyboard::key::Named::Tab) => {
647 // Insert 4 spaces for Tab
648 Some(Message::Tab)
649 }
650 keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
651 Some(Message::ArrowKey(
652 ArrowDirection::Up,
653 modifiers.shift(),
654 ))
655 }
656 keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
657 Some(Message::ArrowKey(
658 ArrowDirection::Down,
659 modifiers.shift(),
660 ))
661 }
662 keyboard::Key::Named(keyboard::key::Named::ArrowLeft) => {
663 Some(Message::ArrowKey(
664 ArrowDirection::Left,
665 modifiers.shift(),
666 ))
667 }
668 keyboard::Key::Named(keyboard::key::Named::ArrowRight) => {
669 Some(Message::ArrowKey(
670 ArrowDirection::Right,
671 modifiers.shift(),
672 ))
673 }
674 keyboard::Key::Named(keyboard::key::Named::PageUp) => {
675 Some(Message::PageUp)
676 }
677 keyboard::Key::Named(keyboard::key::Named::PageDown) => {
678 Some(Message::PageDown)
679 }
680 keyboard::Key::Named(keyboard::key::Named::Home) => {
681 Some(Message::Home(modifiers.shift()))
682 }
683 keyboard::Key::Named(keyboard::key::Named::End) => {
684 Some(Message::End(modifiers.shift()))
685 }
686 // PRIORITY 3: Fallback to extracting from 'key' if text was empty/control char
687 // This handles edge cases where text field is not populated
688 _ => {
689 if !modifiers.control()
690 && !modifiers.alt()
691 && let keyboard::Key::Character(c) = key
692 && !c.is_empty()
693 {
694 return c
695 .chars()
696 .next()
697 .map(Message::CharacterInput)
698 .map(|msg| Action::publish(msg).and_capture());
699 }
700 None
701 }
702 };
703
704 message.map(|msg| Action::publish(msg).and_capture())
705 }
706 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
707 cursor.position_in(bounds).map(|position| {
708 // Don't capture the event so it can bubble up for focus management
709 Action::publish(Message::MouseClick(position))
710 })
711 }
712 Event::Mouse(mouse::Event::CursorMoved { .. }) => {
713 // Handle mouse drag for selection only when cursor is within bounds
714 cursor.position_in(bounds).map(|position| {
715 Action::publish(Message::MouseDrag(position)).and_capture()
716 })
717 }
718 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
719 // Only handle mouse release when cursor is within bounds
720 // This prevents capturing events meant for other widgets
721 if cursor.is_over(bounds) {
722 Some(Action::publish(Message::MouseRelease).and_capture())
723 } else {
724 None
725 }
726 }
727 _ => None,
728 }
729 }
730}