Skip to main content

iced_markdown_editor/widget/
view.rs

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