Skip to main content

iced_markdown_editor/widget/
view.rs

1//! The widget: page canvas (with its own scrolling), toolbar, link bar and
2//! status bar. All labels use the bundled fonts, so they render in the
3//! browser too.
4
5use super::edit::builtin_command;
6use super::toolbar::{self, fill};
7use super::{Action, Command, EditorState, Inner, Mode};
8use crate::fonts;
9use crate::i18n;
10use crate::render;
11use iced::keyboard::{self, Key, Modifiers, key::Named};
12use iced::mouse::{self, ScrollDelta};
13use iced::widget::canvas::{self, Canvas, Frame, Geometry};
14use iced::widget::{Text, button, column, container, row, text, text_input};
15use iced::{
16    Alignment, Background, Border, Color, Element, Length, Point, Rectangle, Renderer, Size, Theme,
17    Vector,
18};
19use std::time::Duration;
20use web_time::Instant;
21
22/// Width of the scrollbars.
23const BAR: f32 = 10.0;
24/// Pixels per wheel "line".
25const LINE: f32 = 48.0;
26
27/// A label in the bundled UI font.
28fn label<'a>(content: impl Into<String>, size: u32) -> Text<'a> {
29    let content: String = content.into();
30    text(content).size(size).font(fonts::SANS)
31}
32
33/// What the left mouse button is doing.
34#[derive(Debug, Clone, Copy, Default, PartialEq)]
35enum Drag {
36    #[default]
37    None,
38    /// Selecting text.
39    Select,
40    /// Dragging the vertical scrollbar; offset of the grab in the thumb.
41    Vertical(f32),
42    /// Dragging the horizontal scrollbar.
43    Horizontal(f32),
44}
45
46/// Interaction state kept by the canvas itself.
47#[derive(Default)]
48pub struct PageState {
49    drag: Drag,
50    last_click: Option<(Instant, Point)>,
51    clicks: u8,
52    modifiers: Modifiers,
53}
54
55/// Scrollbar geometry: track and thumb, in widget coordinates.
56#[derive(Debug, Clone, Copy)]
57struct Bar {
58    track: Rectangle,
59    thumb: Rectangle,
60    /// Largest scroll offset.
61    max: f32,
62}
63
64/// The vertical and horizontal scrollbar, when the content overflows.
65fn bars(editor: &EditorState, size: Size) -> (Option<Bar>, Option<Bar>) {
66    let (content_w, content_h) = {
67        let layout = editor.layout.borrow();
68        (layout.width, layout.height)
69    };
70    let (sx, sy) = editor.clamped_scroll();
71    let need_v = content_h > size.height + 0.5;
72    let need_h = content_w > size.width + 0.5;
73
74    let vertical = need_v.then(|| {
75        let length = size.height - if need_h { BAR } else { 0.0 };
76        let max = (content_h - size.height).max(1.0);
77        let thumb = (length * size.height / content_h).clamp(24.0, length);
78        let y = (sy / max) * (length - thumb);
79        Bar {
80            track: Rectangle::new(Point::new(size.width - BAR, 0.0), Size::new(BAR, length)),
81            thumb: Rectangle::new(Point::new(size.width - BAR, y), Size::new(BAR, thumb)),
82            max,
83        }
84    });
85    let horizontal = need_h.then(|| {
86        let length = size.width - if need_v { BAR } else { 0.0 };
87        let max = (content_w - size.width).max(1.0);
88        let thumb = (length * size.width / content_w).clamp(24.0, length);
89        let x = (sx / max) * (length - thumb);
90        Bar {
91            track: Rectangle::new(Point::new(0.0, size.height - BAR), Size::new(length, BAR)),
92            thumb: Rectangle::new(Point::new(x, size.height - BAR), Size::new(thumb, BAR)),
93            max,
94        }
95    });
96    (vertical, horizontal)
97}
98
99struct Page<'a> {
100    state: &'a EditorState,
101}
102
103fn publish(action: Inner) -> Option<canvas::Action<Action>> {
104    Some(canvas::Action::publish(Action(action)).and_capture())
105}
106
107impl<'a> canvas::Program<Action> for Page<'a> {
108    type State = PageState;
109
110    fn update(
111        &self,
112        page: &mut PageState,
113        event: &canvas::Event,
114        bounds: Rectangle,
115        cursor: mouse::Cursor,
116    ) -> Option<canvas::Action<Action>> {
117        let editor = self.state;
118        let size = bounds.size();
119        let (sx, sy) = editor.clamped_scroll();
120
121        match event {
122            canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
123                let Some(point) = cursor.position_in(bounds) else {
124                    return editor
125                        .focused
126                        .then(|| canvas::Action::publish(Action(Inner::PressOutside)));
127                };
128
129                let (vertical, horizontal) = bars(editor, size);
130                if let Some(bar) = vertical.filter(|bar| bar.track.contains(point)) {
131                    let grab = if bar.thumb.contains(point) {
132                        point.y - bar.thumb.y
133                    } else {
134                        bar.thumb.height / 2.0
135                    };
136                    page.drag = Drag::Vertical(grab);
137                    let y = scroll_for(
138                        point.y - grab,
139                        bar.track.y,
140                        bar.track.height,
141                        bar.thumb.height,
142                        bar.max,
143                    );
144                    return publish(Inner::ScrollTo {
145                        x: None,
146                        y: Some(y),
147                    });
148                }
149                if let Some(bar) = horizontal.filter(|bar| bar.track.contains(point)) {
150                    let grab = if bar.thumb.contains(point) {
151                        point.x - bar.thumb.x
152                    } else {
153                        bar.thumb.width / 2.0
154                    };
155                    page.drag = Drag::Horizontal(grab);
156                    let x = scroll_for(
157                        point.x - grab,
158                        bar.track.x,
159                        bar.track.width,
160                        bar.thumb.width,
161                        bar.max,
162                    );
163                    return publish(Inner::ScrollTo {
164                        x: Some(x),
165                        y: None,
166                    });
167                }
168
169                let now = Instant::now();
170                let clicks = match page.last_click {
171                    Some((time, previous))
172                        if now.duration_since(time) < Duration::from_millis(450)
173                            && (previous.x - point.x).abs() < 5.0
174                            && (previous.y - point.y).abs() < 5.0 =>
175                    {
176                        page.clicks % 3 + 1
177                    }
178                    _ => 1,
179                };
180                page.clicks = clicks;
181                page.last_click = Some((now, point));
182                let (x, y) = (point.x + sx, point.y + sy);
183                page.drag = if editor.layout.borrow().task_at(x, y).is_some() {
184                    Drag::None
185                } else {
186                    Drag::Select
187                };
188                publish(Inner::Press { x, y, clicks })
189            }
190            canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
191                let position = cursor.position()?;
192                let point = Point::new(position.x - bounds.x, position.y - bounds.y);
193                match page.drag {
194                    Drag::None => None,
195                    Drag::Select => publish(Inner::Drag {
196                        x: point.x + sx,
197                        y: point.y + sy,
198                    }),
199                    Drag::Vertical(grab) => {
200                        let bar = bars(editor, size).0?;
201                        let y = scroll_for(
202                            point.y - grab,
203                            bar.track.y,
204                            bar.track.height,
205                            bar.thumb.height,
206                            bar.max,
207                        );
208                        publish(Inner::ScrollTo {
209                            x: None,
210                            y: Some(y),
211                        })
212                    }
213                    Drag::Horizontal(grab) => {
214                        let bar = bars(editor, size).1?;
215                        let x = scroll_for(
216                            point.x - grab,
217                            bar.track.x,
218                            bar.track.width,
219                            bar.thumb.width,
220                            bar.max,
221                        );
222                        publish(Inner::ScrollTo {
223                            x: Some(x),
224                            y: None,
225                        })
226                    }
227                }
228            }
229            canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
230                if page.drag != Drag::None {
231                    page.drag = Drag::None;
232                    return Some(canvas::Action::capture());
233                }
234                None
235            }
236            canvas::Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
237                if !cursor.is_over(bounds) {
238                    return None;
239                }
240                let settings = &editor.zoom_settings;
241                if page.modifiers.command() && settings.enabled && settings.mouse_wheel {
242                    return publish(Inner::Wheel(*delta));
243                }
244                let (mut dx, mut dy) = match *delta {
245                    ScrollDelta::Lines { x, y } => (-x * LINE, -y * LINE),
246                    ScrollDelta::Pixels { x, y } => (-x, -y),
247                };
248                if page.modifiers.shift() && dx == 0.0 {
249                    std::mem::swap(&mut dx, &mut dy);
250                }
251                publish(Inner::ScrollBy { dx, dy })
252            }
253            canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => {
254                page.modifiers = *modifiers;
255                Some(canvas::Action::publish(Action(Inner::Modifiers(
256                    *modifiers,
257                ))))
258            }
259            canvas::Event::Keyboard(keyboard::Event::KeyPressed {
260                key,
261                modified_key,
262                modifiers,
263                text,
264                ..
265            }) => {
266                if editor.link_url.is_some() {
267                    return None;
268                }
269                let modifiers = *modifiers;
270
271                if editor.focused || cursor.is_over(bounds) {
272                    if let Some(command) =
273                        editor
274                            .zoom_settings
275                            .command_for(key, modified_key, modifiers)
276                    {
277                        return Some(canvas::Action::publish(Action::from(command)).and_capture());
278                    }
279                }
280
281                if !editor.focused {
282                    return None;
283                }
284
285                if modifiers.command() && !modifiers.alt() {
286                    if let Key::Character(c) = key.as_ref() {
287                        let c = c.to_lowercase();
288                        // Shortcuts of disabled features go to the application.
289                        builtin_command(&c, modifiers.shift())
290                            .filter(|command| editor.features.allows(command))?;
291                    }
292                }
293
294                let tab = editor.tab_width > 0 && !modifiers.command();
295                let editing_key = match key.as_ref() {
296                    Key::Named(Named::Tab) => tab,
297                    Key::Named(
298                        Named::ArrowLeft
299                        | Named::ArrowRight
300                        | Named::ArrowUp
301                        | Named::ArrowDown
302                        | Named::Home
303                        | Named::End
304                        | Named::PageUp
305                        | Named::PageDown
306                        | Named::Backspace
307                        | Named::Delete
308                        | Named::Enter
309                        | Named::Escape,
310                    ) => true,
311                    Key::Character(_) => true,
312                    _ => false,
313                };
314                let types_text = text
315                    .as_ref()
316                    .is_some_and(|text| !text.is_empty() && text.chars().all(|c| !c.is_control()));
317                if !editing_key && !types_text {
318                    return None;
319                }
320
321                let text = text.as_ref().map(|text| text.to_string());
322                publish(Inner::Key(key.clone(), modifiers, text))
323            }
324            _ => None,
325        }
326    }
327
328    fn draw(
329        &self,
330        page: &PageState,
331        renderer: &Renderer,
332        _theme: &Theme,
333        bounds: Rectangle,
334        cursor: mouse::Cursor,
335    ) -> Vec<Geometry> {
336        let editor = self.state;
337        let size = bounds.size();
338        editor.sync_viewport(size);
339        let (sx, sy) = editor.clamped_scroll();
340        let palette = editor.style.palette;
341        let code_colors = |index: usize| editor.code_colors(index);
342        let custom = |frame: &mut Frame, index: usize, bounds: Rectangle, available: Size| {
343            editor.draw_block(frame, index, bounds, available)
344        };
345
346        let content = {
347            let layout = editor.layout.borrow();
348            editor.cache.borrow().draw(renderer, size, |frame| {
349                render::draw_desk(frame, &layout, &palette, size);
350                frame.translate(Vector::new(-sx, -sy));
351                render::draw_pages(frame, &layout, &palette);
352                render::draw_document(
353                    frame,
354                    &editor.doc,
355                    &layout,
356                    &palette,
357                    &code_colors,
358                    &custom,
359                    (sy, sy + size.height),
360                );
361            })
362        };
363
364        let mut overlay = Frame::new(renderer, size);
365        {
366            let layout = editor.layout.borrow();
367            overlay.with_save(|frame| {
368                frame.translate(Vector::new(-sx, -sy));
369                render::draw_selection(frame, &layout, editor.sel, &palette);
370                if editor.focused && editor.caret_on && editor.link_url.is_none() {
371                    render::draw_caret(frame, &layout, editor.sel, &palette);
372                }
373            });
374        }
375
376        let hovered = cursor.position_in(bounds);
377        let (vertical, horizontal) = bars(editor, size);
378        for (bar, dragging) in [
379            (vertical, matches!(page.drag, Drag::Vertical(_))),
380            (horizontal, matches!(page.drag, Drag::Horizontal(_))),
381        ] {
382            let Some(bar) = bar else {
383                continue;
384            };
385            let active = dragging || hovered.is_some_and(|point| bar.track.contains(point));
386            let alpha = if active { 0.55 } else { 0.30 };
387            let thumb = Color {
388                a: alpha,
389                ..palette.muted
390            };
391            let inset = 2.0;
392            overlay.fill_rectangle(
393                Point::new(bar.thumb.x + inset, bar.thumb.y + inset),
394                Size::new(
395                    bar.thumb.width - 2.0 * inset,
396                    bar.thumb.height - 2.0 * inset,
397                ),
398                thumb,
399            );
400        }
401
402        vec![content, overlay.into_geometry()]
403    }
404
405    fn mouse_interaction(
406        &self,
407        page: &PageState,
408        bounds: Rectangle,
409        cursor: mouse::Cursor,
410    ) -> mouse::Interaction {
411        if matches!(page.drag, Drag::Vertical(_) | Drag::Horizontal(_)) {
412            return mouse::Interaction::Idle;
413        }
414        let Some(point) = cursor.position_in(bounds) else {
415            return mouse::Interaction::default();
416        };
417        let (vertical, horizontal) = bars(self.state, bounds.size());
418        if vertical.is_some_and(|bar| bar.track.contains(point))
419            || horizontal.is_some_and(|bar| bar.track.contains(point))
420        {
421            return mouse::Interaction::Idle;
422        }
423        let (sx, sy) = self.state.clamped_scroll();
424        if self
425            .state
426            .layout
427            .borrow()
428            .task_at(point.x + sx, point.y + sy)
429            .is_some()
430        {
431            mouse::Interaction::Pointer
432        } else {
433            mouse::Interaction::Text
434        }
435    }
436}
437
438/// Scroll offset for a thumb start position.
439fn scroll_for(thumb_start: f32, track_start: f32, track: f32, thumb: f32, max: f32) -> f32 {
440    let room = (track - thumb).max(1.0);
441    ((thumb_start - track_start) / room).clamp(0.0, 1.0) * max
442}
443
444/// Builder returned by [`markdown_editor`].
445pub struct MarkdownEditor<'a> {
446    state: &'a EditorState,
447    toolbar: bool,
448    status_bar: bool,
449    width: Length,
450    height: Length,
451}
452
453/// Creates the editor widget for the given state.
454pub fn markdown_editor(state: &EditorState) -> MarkdownEditor<'_> {
455    MarkdownEditor {
456        state,
457        toolbar: true,
458        status_bar: true,
459        width: Length::Fill,
460        height: Length::Fill,
461    }
462}
463
464impl<'a> MarkdownEditor<'a> {
465    /// Shows or hides the built-in toolbar (default: shown).
466    pub fn toolbar(mut self, show: bool) -> Self {
467        self.toolbar = show;
468        self
469    }
470
471    /// Shows or hides the status bar (default: shown).
472    pub fn status_bar(mut self, show: bool) -> Self {
473        self.status_bar = show;
474        self
475    }
476
477    pub fn width(mut self, width: impl Into<Length>) -> Self {
478        self.width = width.into();
479        self
480    }
481
482    pub fn height(mut self, height: impl Into<Length>) -> Self {
483        self.height = height.into();
484        self
485    }
486
487    /// Builds the widget element.
488    pub fn view(self) -> Element<'a, Action> {
489        let state = self.state;
490        let palette = state.style.palette;
491
492        let bar_style = move |_: &Theme| container::Style {
493            background: Some(Background::Color(palette.toolbar_background)),
494            text_color: Some(palette.toolbar_text),
495            border: Border {
496                color: palette.toolbar_border,
497                width: 1.0,
498                radius: 0.0.into(),
499            },
500            ..container::Style::default()
501        };
502
503        let language = state.language;
504        let tr = |id| i18n::text(language, id);
505        let mut content = column![];
506
507        if self.toolbar {
508            content = content.push(
509                container(toolbar::view(state))
510                    .padding([4, 8])
511                    .width(Length::Fill)
512                    .style(bar_style),
513            );
514        }
515
516        if let Some(url) = &state.link_url {
517            let link_bar = container(
518                row![
519                    label(tr("link-address"), 12).color(palette.muted),
520                    text_input("https://", url)
521                        .on_input(|url| Action(Inner::LinkUrl(url)))
522                        .on_submit(Action(Inner::LinkApply))
523                        .font(fonts::SANS)
524                        .padding(6)
525                        .size(13)
526                        .width(Length::Fixed(420.0)),
527                    button(label(tr("link-apply"), 12))
528                        .on_press(Action(Inner::LinkApply))
529                        .padding([5, 10])
530                        .style(toolbar::button_style(palette, true)),
531                    button(label(tr("link-remove"), 12))
532                        .on_press(Action(Inner::LinkRemove))
533                        .padding([5, 10])
534                        .style(toolbar::button_style(palette, false)),
535                    button(label(tr("link-cancel"), 12))
536                        .on_press(Action(Inner::LinkCancel))
537                        .padding([5, 10])
538                        .style(toolbar::button_style(palette, false)),
539                ]
540                .spacing(8)
541                .align_y(Alignment::Center),
542            )
543            .padding([6, 12])
544            .width(Length::Fill)
545            .style(bar_style);
546
547            content = content.push(link_bar);
548        }
549
550        let page = Canvas::new(Page { state })
551            .width(Length::Fill)
552            .height(Length::Fill);
553        content = content.push(page);
554
555        if self.status_bar {
556            let mode = match state.mode {
557                Mode::Wysiwyg => tr("status-mode-wysiwyg"),
558                Mode::Source => tr("status-mode-source"),
559            };
560
561            let mut status = row![].spacing(18).align_y(Alignment::Center);
562
563            if !state.paper.is_fill() {
564                let page = i18n::text_with(
565                    language,
566                    "status-page",
567                    &[
568                        ("current", state.current_page().into()),
569                        ("total", state.page_count().into()),
570                    ],
571                );
572                status = status.push(label(page, 12));
573            }
574
575            let words = i18n::text_with(
576                language,
577                "status-words",
578                &[("count", state.word_count().into())],
579            );
580            status = status.push(label(words, 12));
581
582            let (checked, total) = state.task_progress();
583            if total > 0 {
584                let tasks = i18n::text_with(
585                    language,
586                    "status-tasks",
587                    &[("checked", checked.into()), ("total", total.into())],
588                );
589                status = status.push(label(tasks, 12));
590            }
591
592            let pending = state.pending_images();
593            if pending > 0 {
594                let saving = i18n::text_with(
595                    language,
596                    "status-saving-images",
597                    &[("count", pending.into())],
598                );
599                status = status.push(label(saving, 12));
600            }
601
602            status = status.push(label(mode, 12));
603
604            if state.tab_width > 0 {
605                let tab = i18n::text_with(
606                    language,
607                    "status-tab-width",
608                    &[("count", state.tab_width.into())],
609                );
610                status = status.push(label(tab, 12));
611            }
612
613            status = status.push(fill()).push(label(
614                if state.dirty {
615                    tr("status-modified")
616                } else {
617                    String::new()
618                },
619                12,
620            ));
621
622            let zoom = &state.zoom_settings;
623            if zoom.enabled && zoom.controls {
624                let percent = format!("{:.0} %", state.zoom * 100.0);
625                let small = |text: String, command: Command| {
626                    button(label(text, 12))
627                        .on_press(Action::from(command))
628                        .padding([1, 8])
629                        .style(toolbar::button_style(palette, false))
630                };
631                status = status.push(
632                    row![
633                        small("\u{2212}".to_string(), Command::ZoomOut),
634                        small(percent, Command::ResetZoom),
635                        small("+".to_string(), Command::ZoomIn),
636                    ]
637                    .spacing(2)
638                    .align_y(Alignment::Center),
639                );
640            }
641
642            content = content.push(
643                container(status)
644                    .padding([3, 12])
645                    .width(Length::Fill)
646                    .style(move |_: &Theme| container::Style {
647                        background: Some(Background::Color(palette.toolbar_background)),
648                        text_color: Some(palette.muted),
649                        ..container::Style::default()
650                    }),
651            );
652        }
653
654        container(content)
655            .width(self.width)
656            .height(self.height)
657            .into()
658    }
659}
660
661impl<'a> From<MarkdownEditor<'a>> for Element<'a, Action> {
662    fn from(editor: MarkdownEditor<'a>) -> Self {
663        editor.view()
664    }
665}