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::{Column, Row, Scrollable, Space, container, scrollable};
7use iced::{Background, Border, Color, Element, Length, Rectangle, Shadow};
8
9use super::ime_requester::ImeRequester;
10use super::search_dialog;
11use super::wrapping::{self, WrappingCalculator};
12use super::{CodeEditor, GUTTER_WIDTH, Message};
13use std::rc::Rc;
14
15impl CodeEditor {
16    /// Calculates visual lines and canvas height for the editor.
17    ///
18    /// Returns a tuple of (visual_lines, canvas_height) where:
19    /// - visual_lines: The visual line mapping with wrapping applied
20    /// - canvas_height: The total height needed for the canvas
21    fn calculate_canvas_height(&self) -> (Rc<Vec<wrapping::VisualLine>>, f32) {
22        // Reuse memoized visual lines so view layout (canvas height + IME cursor rect)
23        // does not trigger repeated wrapping computation.
24        let visual_lines = self.visual_lines_cached(self.viewport_width);
25        let total_visual_lines = visual_lines.len();
26        let content_height = total_visual_lines as f32 * self.line_height;
27
28        // Use max of content height and viewport height to ensure the canvas
29        // always covers the visible area (prevents visual artifacts when
30        // content is shorter than viewport after reset/file change)
31        let canvas_height = content_height.max(self.viewport_height);
32
33        (visual_lines, canvas_height)
34    }
35
36    /// Creates the scrollable style function with custom colors.
37    ///
38    /// Returns a style function that configures the scrollbar appearance.
39    fn create_scrollable_style(
40        &self,
41    ) -> impl Fn(&iced::Theme, scrollable::Status) -> scrollable::Style {
42        let scrollbar_bg = self.style.scrollbar_background;
43        let scroller_color = self.style.scroller_color;
44
45        move |_theme, _status| scrollable::Style {
46            container: container::Style {
47                background: Some(Background::Color(Color::TRANSPARENT)),
48                ..container::Style::default()
49            },
50            vertical_rail: scrollable::Rail {
51                background: Some(scrollbar_bg.into()),
52                border: Border {
53                    radius: 4.0.into(),
54                    width: 0.0,
55                    color: Color::TRANSPARENT,
56                },
57                scroller: scrollable::Scroller {
58                    background: scroller_color.into(),
59                    border: Border {
60                        radius: 4.0.into(),
61                        width: 0.0,
62                        color: Color::TRANSPARENT,
63                    },
64                },
65            },
66            horizontal_rail: scrollable::Rail {
67                background: Some(scrollbar_bg.into()),
68                border: Border {
69                    radius: 4.0.into(),
70                    width: 0.0,
71                    color: Color::TRANSPARENT,
72                },
73                scroller: scrollable::Scroller {
74                    background: scroller_color.into(),
75                    border: Border {
76                        radius: 4.0.into(),
77                        width: 0.0,
78                        color: Color::TRANSPARENT,
79                    },
80                },
81            },
82            gap: None,
83            auto_scroll: scrollable::AutoScroll {
84                background: Color::TRANSPARENT.into(),
85                border: Border::default(),
86                shadow: Shadow::default(),
87                icon: Color::TRANSPARENT,
88            },
89        }
90    }
91
92    /// Creates the canvas widget wrapped in a scrollable container.
93    ///
94    /// # Arguments
95    ///
96    /// * `canvas_height` - The total height of the canvas
97    ///
98    /// # Returns
99    ///
100    /// A configured scrollable widget containing the canvas
101    fn create_canvas_with_scrollable(
102        &self,
103        canvas_height: f32,
104    ) -> Scrollable<'_, Message> {
105        let canvas = Canvas::new(self)
106            .width(Length::Fill)
107            .height(Length::Fixed(canvas_height));
108
109        Scrollable::new(canvas)
110            .id(self.scrollable_id.clone())
111            .width(Length::Fill)
112            .height(Length::Fill)
113            .on_scroll(Message::Scrolled)
114            .style(self.create_scrollable_style())
115    }
116
117    /// Creates the horizontal scrollbar element when wrap is disabled and content overflows.
118    ///
119    /// # Arguments
120    ///
121    /// * `max_content_width` - The total pixel width of the widest line
122    ///
123    /// # Returns
124    ///
125    /// `Some(element)` if a horizontal scrollbar is needed, `None` otherwise
126    fn create_horizontal_scrollbar(
127        &self,
128        max_content_width: f32,
129    ) -> Option<Element<'_, Message>> {
130        if self.wrap_enabled || max_content_width <= self.viewport_width {
131            return None;
132        }
133
134        let scrollbar_bg = self.style.scrollbar_background;
135        let scroller_color = self.style.scroller_color;
136
137        let h_scrollable = Scrollable::new(
138            Space::new().width(Length::Fixed(max_content_width)).height(0.0),
139        )
140        .id(self.horizontal_scrollable_id.clone())
141        .width(Length::Fill)
142        .height(Length::Fixed(12.0))
143        .direction(scrollable::Direction::Horizontal(
144            scrollable::Scrollbar::new(),
145        ))
146        .on_scroll(Message::HorizontalScrolled)
147        .style(move |_theme, _status| scrollable::Style {
148            container: container::Style {
149                background: Some(Background::Color(Color::TRANSPARENT)),
150                ..container::Style::default()
151            },
152            vertical_rail: scrollable::Rail {
153                background: Some(scrollbar_bg.into()),
154                border: Border {
155                    radius: 4.0.into(),
156                    width: 0.0,
157                    color: Color::TRANSPARENT,
158                },
159                scroller: scrollable::Scroller {
160                    background: scroller_color.into(),
161                    border: Border {
162                        radius: 4.0.into(),
163                        width: 0.0,
164                        color: Color::TRANSPARENT,
165                    },
166                },
167            },
168            horizontal_rail: scrollable::Rail {
169                background: Some(scrollbar_bg.into()),
170                border: Border {
171                    radius: 4.0.into(),
172                    width: 0.0,
173                    color: Color::TRANSPARENT,
174                },
175                scroller: scrollable::Scroller {
176                    background: scroller_color.into(),
177                    border: Border {
178                        radius: 4.0.into(),
179                        width: 0.0,
180                        color: Color::TRANSPARENT,
181                    },
182                },
183            },
184            gap: None,
185            auto_scroll: scrollable::AutoScroll {
186                background: Color::TRANSPARENT.into(),
187                border: Border::default(),
188                shadow: Shadow::default(),
189                icon: Color::TRANSPARENT,
190            },
191        });
192
193        Some(h_scrollable.into())
194    }
195
196    /// Creates the gutter background container if line numbers are enabled.
197    ///
198    /// # Returns
199    ///
200    /// Some(container) if line numbers are enabled, None otherwise
201    fn create_gutter_container(
202        &self,
203    ) -> Option<container::Container<'_, Message>> {
204        if self.line_numbers_enabled {
205            let gutter_background = self.style.gutter_background;
206            Some(
207                container(
208                    Space::new().width(Length::Fill).height(Length::Fill),
209                )
210                .width(Length::Fixed(GUTTER_WIDTH))
211                .height(Length::Fill)
212                .style(move |_| container::Style {
213                    background: Some(Background::Color(gutter_background)),
214                    ..container::Style::default()
215                }),
216            )
217        } else {
218            None
219        }
220    }
221
222    /// Creates the code area background container.
223    ///
224    /// # Returns
225    ///
226    /// The code background container widget
227    fn create_code_background_container(
228        &self,
229    ) -> container::Container<'_, Message> {
230        let background_color = self.style.background;
231        container(Space::new().width(Length::Fill).height(Length::Fill))
232            .width(Length::Fill)
233            .height(Length::Fill)
234            .style(move |_| container::Style {
235                background: Some(Background::Color(background_color)),
236                ..container::Style::default()
237            })
238    }
239
240    /// Creates the background layer combining gutter and code backgrounds.
241    ///
242    /// # Returns
243    ///
244    /// A row containing the background elements
245    fn create_background_layer(&self) -> Row<'_, Message> {
246        let gutter_container = self.create_gutter_container();
247        let code_background_container = self.create_code_background_container();
248
249        if let Some(gutter) = gutter_container {
250            Row::new().push(gutter).push(code_background_container)
251        } else {
252            Row::new().push(code_background_container)
253        }
254    }
255
256    /// Calculates the IME cursor rectangle for the current cursor position.
257    ///
258    /// # Arguments
259    ///
260    /// * `visual_lines` - The visual line mapping
261    ///
262    /// # Returns
263    ///
264    /// A rectangle representing the cursor position for IME
265    fn calculate_ime_cursor_rect(
266        &self,
267        visual_lines: &[wrapping::VisualLine],
268    ) -> Rectangle {
269        let ime_enabled = self.is_focused() && self.has_canvas_focus;
270
271        if !ime_enabled {
272            return Rectangle::new(
273                iced::Point::new(0.0, 0.0),
274                Size::new(0.0, 0.0),
275            );
276        }
277
278        if let Some(cursor_visual) = WrappingCalculator::logical_to_visual(
279            visual_lines,
280            self.cursor.0,
281            self.cursor.1,
282        ) {
283            let vl = &visual_lines[cursor_visual];
284            let line_content = self.buffer.line(vl.logical_line);
285            let prefix_len = self.cursor.1.saturating_sub(vl.start_col);
286            let prefix_text: String = line_content
287                .chars()
288                .skip(vl.start_col)
289                .take(prefix_len)
290                .collect();
291            let cursor_x = self.gutter_width()
292                + 5.0
293                + super::measure_text_width(
294                    &prefix_text,
295                    self.full_char_width,
296                    self.char_width,
297                )
298                - self.horizontal_scroll_offset;
299
300            // Calculate visual Y position relative to the viewport
301            // We subtract viewport_scroll because the content is scrolled up/down
302            // but the cursor position sent to IME must be relative to the visible area
303            let cursor_y = (cursor_visual as f32 * self.line_height)
304                - self.viewport_scroll;
305
306            Rectangle::new(
307                iced::Point::new(cursor_x, cursor_y + 2.0),
308                Size::new(2.0, self.line_height - 4.0),
309            )
310        } else {
311            Rectangle::new(iced::Point::new(0.0, 0.0), Size::new(0.0, 0.0))
312        }
313    }
314
315    /// Creates the IME (Input Method Editor) layer widget.
316    ///
317    /// # Arguments
318    ///
319    /// * `cursor_rect` - The rectangle representing the cursor position
320    ///
321    /// # Returns
322    ///
323    /// An element containing the IME requester widget
324    fn create_ime_layer(&self, cursor_rect: Rectangle) -> Element<'_, Message> {
325        let ime_enabled = self.is_focused() && self.has_canvas_focus;
326
327        let preedit =
328            self.ime_preedit.as_ref().map(|p| input_method::Preedit {
329                content: p.content.clone(),
330                selection: p.selection.clone(),
331                text_size: None,
332            });
333
334        let ime_layer = ImeRequester::new(ime_enabled, cursor_rect, preedit);
335        iced::Element::new(ime_layer)
336    }
337
338    /// Creates the view element with scrollable wrapper.
339    ///
340    /// The backgrounds (editor and gutter) are handled by container styles
341    /// to ensure proper clipping when the pane is resized.
342    pub fn view(&self) -> Element<'_, Message> {
343        // Calculate canvas height and visual lines
344        let (visual_lines, canvas_height) = self.calculate_canvas_height();
345
346        // Create scrollable containing the canvas
347        let scrollable = self.create_canvas_with_scrollable(canvas_height);
348
349        // Create background layer with gutter and code backgrounds
350        let background_row = self.create_background_layer();
351
352        // Build editor stack: backgrounds + scrollable
353        let mut editor_stack =
354            iced::widget::Stack::new().push(background_row).push(scrollable);
355
356        // Add IME layer for input method support.
357        // The IME requester needs the cursor rect in viewport coordinates, which
358        // depends on the current logical↔visual mapping.
359        let cursor_rect = self.calculate_ime_cursor_rect(visual_lines.as_ref());
360        let ime_layer = self.create_ime_layer(cursor_rect);
361        editor_stack = editor_stack.push(ime_layer);
362
363        // Add search dialog overlay if open
364        if self.search_state.is_open {
365            let search_dialog =
366                search_dialog::view(&self.search_state, &self.translations);
367
368            // Position the dialog in top-right corner with 20px margin
369            let positioned_dialog = container(
370                Row::new()
371                    .push(Space::new().width(Length::Fill))
372                    .push(search_dialog),
373            )
374            .padding(20)
375            .width(Length::Fill)
376            .height(Length::Shrink);
377
378            editor_stack = editor_stack.push(positioned_dialog);
379        }
380
381        // Wrap the editor stack in a container with clip
382        let editor_container = container(editor_stack)
383            .width(Length::Fill)
384            .height(Length::Fill)
385            .clip(true);
386
387        // When wrap is disabled, add a horizontal scrollbar below the editor
388        let max_content_width = self.max_content_width();
389        if let Some(h_scrollbar) =
390            self.create_horizontal_scrollbar(max_content_width)
391        {
392            Column::new().push(editor_container).push(h_scrollbar).into()
393        } else {
394            editor_container.into()
395        }
396    }
397}