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