Skip to main content

iced_code_editor/canvas_editor/
view.rs

1//! Iced UI view and rendering logic.
2
3use iced::Size;
4use iced::advanced::input_method;
5use iced::widget::canvas::Canvas;
6use iced::widget::{
7    Column, Row, Scrollable, Space, container, scrollable, text,
8};
9use iced::{Background, Border, Color, Element, Length, Rectangle, Shadow};
10use iced_aw::ContextMenu;
11
12use super::context_menu;
13use super::goto_line_dialog;
14use super::ime_requester::ImeRequester;
15use super::search_dialog;
16use super::wrapping::{self, WrappingCalculator};
17use super::{CodeEditor, GUTTER_WIDTH, Message};
18use std::rc::Rc;
19
20impl CodeEditor {
21    /// Calculates visual lines and canvas height for the editor.
22    ///
23    /// Returns a tuple of (visual_lines, canvas_height) where:
24    /// - visual_lines: The visual line mapping with wrapping applied
25    /// - canvas_height: The total height needed for the canvas
26    fn calculate_canvas_height(&self) -> (Rc<Vec<wrapping::VisualLine>>, f32) {
27        // Reuse memoized visual lines so view layout (canvas height + IME cursor rect)
28        // does not trigger repeated wrapping computation.
29        let visual_lines = self.visual_lines_cached(self.viewport_width);
30        let total_visual_lines = visual_lines.len();
31        let content_height = total_visual_lines as f32 * self.line_height;
32
33        // Use max of content height and viewport height to ensure the canvas
34        // always covers the visible area (prevents visual artifacts when
35        // content is shorter than viewport after reset/file change)
36        let canvas_height = content_height.max(self.viewport_height);
37
38        (visual_lines, canvas_height)
39    }
40
41    /// Creates the scrollable style function with custom colors.
42    ///
43    /// Returns a style function that configures the scrollbar appearance.
44    fn create_scrollable_style(
45        &self,
46    ) -> impl Fn(&iced::Theme, scrollable::Status) -> scrollable::Style {
47        let scrollbar_bg = self.style.scrollbar_background;
48        let scroller_color = self.style.scroller_color;
49
50        move |_theme, _status| scrollable::Style {
51            container: container::Style {
52                background: Some(Background::Color(Color::TRANSPARENT)),
53                ..container::Style::default()
54            },
55            vertical_rail: scrollable::Rail {
56                background: Some(scrollbar_bg.into()),
57                border: Border {
58                    radius: 4.0.into(),
59                    width: 0.0,
60                    color: Color::TRANSPARENT,
61                },
62                scroller: scrollable::Scroller {
63                    background: scroller_color.into(),
64                    border: Border {
65                        radius: 4.0.into(),
66                        width: 0.0,
67                        color: Color::TRANSPARENT,
68                    },
69                },
70            },
71            horizontal_rail: scrollable::Rail {
72                background: Some(scrollbar_bg.into()),
73                border: Border {
74                    radius: 4.0.into(),
75                    width: 0.0,
76                    color: Color::TRANSPARENT,
77                },
78                scroller: scrollable::Scroller {
79                    background: scroller_color.into(),
80                    border: Border {
81                        radius: 4.0.into(),
82                        width: 0.0,
83                        color: Color::TRANSPARENT,
84                    },
85                },
86            },
87            gap: None,
88            auto_scroll: scrollable::AutoScroll {
89                background: Color::TRANSPARENT.into(),
90                border: Border::default(),
91                shadow: Shadow::default(),
92                icon: Color::TRANSPARENT,
93            },
94        }
95    }
96
97    /// Creates the canvas widget wrapped in a scrollable container.
98    ///
99    /// # Arguments
100    ///
101    /// * `canvas_height` - The total height of the canvas
102    ///
103    /// # Returns
104    ///
105    /// A configured scrollable widget containing the canvas
106    fn create_canvas_with_scrollable(
107        &self,
108        canvas_height: f32,
109    ) -> Scrollable<'_, Message> {
110        let canvas = Canvas::new(self)
111            .width(Length::Fill)
112            .height(Length::Fixed(canvas_height));
113
114        Scrollable::new(canvas)
115            .id(self.scrollable_id.clone())
116            .width(Length::Fill)
117            .height(Length::Fill)
118            .on_scroll(Message::Scrolled)
119            .style(self.create_scrollable_style())
120    }
121
122    /// Creates the horizontal scrollbar element when wrap is disabled and content overflows.
123    ///
124    /// # Arguments
125    ///
126    /// * `max_content_width` - The total pixel width of the widest line
127    ///
128    /// # Returns
129    ///
130    /// `Some(element)` if a horizontal scrollbar is needed, `None` otherwise
131    fn create_horizontal_scrollbar(
132        &self,
133        max_content_width: f32,
134    ) -> Option<Element<'_, Message>> {
135        if self.wrap_enabled || max_content_width <= self.viewport_width {
136            return None;
137        }
138
139        let scrollbar_bg = self.style.scrollbar_background;
140        let scroller_color = self.style.scroller_color;
141
142        let h_scrollable = Scrollable::new(
143            Space::new().width(Length::Fixed(max_content_width)).height(0.0),
144        )
145        .id(self.horizontal_scrollable_id.clone())
146        .width(Length::Fill)
147        .height(Length::Fixed(12.0))
148        .direction(scrollable::Direction::Horizontal(
149            scrollable::Scrollbar::new(),
150        ))
151        .on_scroll(Message::HorizontalScrolled)
152        .style(move |_theme, _status| scrollable::Style {
153            container: container::Style {
154                background: Some(Background::Color(Color::TRANSPARENT)),
155                ..container::Style::default()
156            },
157            vertical_rail: scrollable::Rail {
158                background: Some(scrollbar_bg.into()),
159                border: Border {
160                    radius: 4.0.into(),
161                    width: 0.0,
162                    color: Color::TRANSPARENT,
163                },
164                scroller: scrollable::Scroller {
165                    background: scroller_color.into(),
166                    border: Border {
167                        radius: 4.0.into(),
168                        width: 0.0,
169                        color: Color::TRANSPARENT,
170                    },
171                },
172            },
173            horizontal_rail: scrollable::Rail {
174                background: Some(scrollbar_bg.into()),
175                border: Border {
176                    radius: 4.0.into(),
177                    width: 0.0,
178                    color: Color::TRANSPARENT,
179                },
180                scroller: scrollable::Scroller {
181                    background: scroller_color.into(),
182                    border: Border {
183                        radius: 4.0.into(),
184                        width: 0.0,
185                        color: Color::TRANSPARENT,
186                    },
187                },
188            },
189            gap: None,
190            auto_scroll: scrollable::AutoScroll {
191                background: Color::TRANSPARENT.into(),
192                border: Border::default(),
193                shadow: Shadow::default(),
194                icon: Color::TRANSPARENT,
195            },
196        });
197
198        Some(h_scrollable.into())
199    }
200
201    /// Creates the gutter background container if line numbers are enabled.
202    ///
203    /// # Returns
204    ///
205    /// Some(container) if line numbers are enabled, None otherwise
206    fn create_gutter_container(
207        &self,
208    ) -> Option<container::Container<'_, Message>> {
209        if self.line_numbers_enabled {
210            let gutter_background = self.style.gutter_background;
211            Some(
212                container(
213                    Space::new().width(Length::Fill).height(Length::Fill),
214                )
215                .width(Length::Fixed(GUTTER_WIDTH))
216                .height(Length::Fill)
217                .style(move |_| container::Style {
218                    background: Some(Background::Color(gutter_background)),
219                    ..container::Style::default()
220                }),
221            )
222        } else {
223            None
224        }
225    }
226
227    /// Creates the code area background container.
228    ///
229    /// # Returns
230    ///
231    /// The code background container widget
232    fn create_code_background_container(
233        &self,
234    ) -> container::Container<'_, Message> {
235        let background_color = self.style.background;
236        container(Space::new().width(Length::Fill).height(Length::Fill))
237            .width(Length::Fill)
238            .height(Length::Fill)
239            .style(move |_| container::Style {
240                background: Some(Background::Color(background_color)),
241                ..container::Style::default()
242            })
243    }
244
245    /// Creates the fixed Vim status and command line shown below the editor.
246    fn create_vim_status_bar(&self) -> Element<'_, Message> {
247        let (left_text, right_text) = self.vim_state.status_line_text();
248        let background = self.style.gutter_background;
249        let text_color = self.style.text_color;
250
251        container(
252            Row::new()
253                .push(
254                    text(left_text).size(self.font_size).style(move |_| {
255                        text::Style { color: Some(text_color) }
256                    }),
257                )
258                .push(Space::new().width(Length::Fill))
259                .push(
260                    text(right_text).size(self.font_size).style(move |_| {
261                        text::Style { color: Some(text_color) }
262                    }),
263                ),
264        )
265        .padding([2, 8])
266        .width(Length::Fill)
267        .height(Length::Fixed(self.line_height.max(20.0)))
268        .style(move |_| container::Style {
269            background: Some(Background::Color(background)),
270            ..container::Style::default()
271        })
272        .into()
273    }
274
275    /// Creates the background layer combining gutter and code backgrounds.
276    ///
277    /// # Returns
278    ///
279    /// A row containing the background elements
280    fn create_background_layer(&self) -> Row<'_, Message> {
281        let gutter_container = self.create_gutter_container();
282        let code_background_container = self.create_code_background_container();
283
284        if let Some(gutter) = gutter_container {
285            Row::new().push(gutter).push(code_background_container)
286        } else {
287            Row::new().push(code_background_container)
288        }
289    }
290
291    /// Calculates the IME cursor rectangle for the current cursor position.
292    ///
293    /// # Arguments
294    ///
295    /// * `visual_lines` - The visual line mapping
296    ///
297    /// # Returns
298    ///
299    /// A rectangle representing the cursor position for IME
300    fn calculate_ime_cursor_rect(
301        &self,
302        visual_lines: &[wrapping::VisualLine],
303    ) -> Rectangle {
304        let ime_enabled = self.is_focused() && self.has_canvas_focus;
305
306        if !ime_enabled {
307            return Rectangle::new(
308                iced::Point::new(0.0, 0.0),
309                Size::new(0.0, 0.0),
310            );
311        }
312
313        if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
314            visual_lines,
315            self.cursors.primary_position().0,
316            self.cursors.primary_position().1,
317        ) {
318            let vl = &visual_lines[cursor_visual];
319            let line_content = self.buffer.line(vl.logical_line);
320            let prefix_len =
321                self.cursors.primary_position().1.saturating_sub(vl.start_col);
322            let prefix_text: String = line_content
323                .chars()
324                .skip(vl.start_col)
325                .take(prefix_len)
326                .collect();
327            let cursor_x = self.gutter_width()
328                + 5.0
329                + super::measure_text_width(
330                    &prefix_text,
331                    self.full_char_width,
332                    self.char_width,
333                )
334                - self.horizontal_scroll_offset;
335
336            // Calculate visual Y position relative to the viewport
337            // We subtract viewport_scroll because the content is scrolled up/down
338            // but the cursor position sent to IME must be relative to the visible area
339            let cursor_y = (cursor_visual as f32 * self.line_height)
340                - self.viewport_scroll;
341
342            Rectangle::new(
343                iced::Point::new(cursor_x, cursor_y + 2.0),
344                Size::new(2.0, self.line_height - 4.0),
345            )
346        } else {
347            Rectangle::new(iced::Point::new(0.0, 0.0), Size::new(0.0, 0.0))
348        }
349    }
350
351    /// Creates the IME (Input Method Editor) layer widget.
352    ///
353    /// # Arguments
354    ///
355    /// * `cursor_rect` - The rectangle representing the cursor position
356    ///
357    /// # Returns
358    ///
359    /// An element containing the IME requester widget
360    fn create_ime_layer(&self, cursor_rect: Rectangle) -> Element<'_, Message> {
361        let ime_enabled = self.is_focused() && self.has_canvas_focus;
362
363        let preedit =
364            self.ime_preedit.as_ref().map(|p| input_method::Preedit {
365                content: p.content.clone(),
366                selection: p.selection.clone(),
367                text_size: None,
368            });
369
370        let ime_layer = ImeRequester::new(ime_enabled, cursor_rect, preedit);
371        iced::Element::new(ime_layer)
372    }
373
374    /// Creates the view element with scrollable wrapper.
375    ///
376    /// The backgrounds (editor and gutter) are handled by container styles
377    /// to ensure proper clipping when the pane is resized.
378    pub fn view(&self) -> Element<'_, Message> {
379        // Calculate canvas height and visual lines
380        let (visual_lines, canvas_height) = self.calculate_canvas_height();
381
382        // Create scrollable containing the canvas
383        let scrollable = self.create_canvas_with_scrollable(canvas_height);
384
385        // Create background layer with gutter and code backgrounds
386        let background_row = self.create_background_layer();
387
388        // Build editor stack: backgrounds + scrollable
389        let mut editor_stack =
390            iced::widget::Stack::new().push(background_row).push(scrollable);
391
392        // Add IME layer for input method support.
393        // The IME requester needs the cursor rect in viewport coordinates, which
394        // depends on the current logical↔visual mapping.
395        let cursor_rect = self.calculate_ime_cursor_rect(visual_lines.as_ref());
396        let ime_layer = self.create_ime_layer(cursor_rect);
397        editor_stack = editor_stack.push(ime_layer);
398
399        // Add search dialog overlay if open
400        if self.search_state.is_open {
401            let search_dialog =
402                search_dialog::view(&self.search_state, &self.translations);
403
404            // Position the dialog in top-right corner with 20px margin
405            let positioned_dialog = container(
406                Row::new()
407                    .push(Space::new().width(Length::Fill))
408                    .push(search_dialog),
409            )
410            .padding(20)
411            .width(Length::Fill)
412            .height(Length::Shrink);
413
414            editor_stack = editor_stack.push(positioned_dialog);
415        }
416
417        // Add the compact go-to-line dialog in the top center.
418        if self.goto_line_state.is_open {
419            let goto_line_dialog = goto_line_dialog::view(
420                &self.goto_line_state,
421                self.buffer.line_count(),
422            );
423            let positioned_dialog = container(
424                Row::new()
425                    .push(Space::new().width(Length::Fill))
426                    .push(goto_line_dialog)
427                    .push(Space::new().width(Length::Fill)),
428            )
429            .padding(20)
430            .width(Length::Fill)
431            .height(Length::Shrink);
432
433            editor_stack = editor_stack.push(positioned_dialog);
434        }
435
436        // Wrap the editor stack in a container with clip
437        let editor_container = container(editor_stack)
438            .width(Length::Fill)
439            .height(Length::Fill)
440            .clip(true);
441
442        // The context menu owns its transient open/close state and positions
443        // itself at the right-click location. The canvas still receives the
444        // right-click event so it can preserve or reposition the selection.
445        let can_undo = self.history.can_undo();
446        let can_redo = self.history.can_redo();
447        let has_selection =
448            self.cursors.iter().any(|cursor| cursor.has_selection());
449        let has_content =
450            self.buffer.line_count() > 1 || self.buffer.line_len(0) > 0;
451        let custom_context_menu_entries =
452            self.custom_context_menu_entries().to_vec();
453        let default_context_menu_enabled = self.default_context_menu_enabled();
454        let reveal_in_file_manager_enabled =
455            self.reveal_in_file_manager_enabled();
456        let translations = self.translations;
457        let editor_container = ContextMenu::new(editor_container, move || {
458            context_menu::view(
459                &custom_context_menu_entries,
460                default_context_menu_enabled,
461                context_menu::MenuState {
462                    can_undo,
463                    can_redo,
464                    has_selection,
465                    has_content,
466                    reveal_in_file_manager_enabled,
467                },
468                translations,
469            )
470        });
471
472        // When wrap is disabled, add a horizontal scrollbar below the editor.
473        let editor_body: Element<'_, Message> = if self.wrap_enabled {
474            editor_container.into()
475        } else {
476            // Measuring the widest line scans the entire buffer. It is only
477            // needed for the horizontal scrollbar, so never do that work while
478            // wrapping is enabled (the default), especially after every edit in
479            // a large file.
480            let max_content_width = self.max_content_width();
481            if let Some(h_scrollbar) =
482                self.create_horizontal_scrollbar(max_content_width)
483            {
484                Column::new().push(editor_container).push(h_scrollbar).into()
485            } else {
486                editor_container.into()
487            }
488        };
489
490        if self.vim_enabled {
491            Column::new()
492                .push(editor_body)
493                .push(self.create_vim_status_bar())
494                .width(Length::Fill)
495                .height(Length::Fill)
496                .into()
497        } else {
498            editor_body
499        }
500    }
501}