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}