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::{Action, Command, EditorState, Inner, Mode};
7use crate::fonts;
8use crate::toolbar::fill;
9use crate::{render, toolbar};
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                        if builtin_command(&c, modifiers.shift()).is_none() {
288                            return None;
289                        }
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
342        let content = {
343            let layout = editor.layout.borrow();
344            editor.cache.borrow().draw(renderer, size, |frame| {
345                render::draw_desk(frame, &layout, &palette, size);
346                frame.translate(Vector::new(-sx, -sy));
347                render::draw_pages(frame, &layout, &palette);
348                render::draw_document(
349                    frame,
350                    &editor.doc,
351                    &layout,
352                    &palette,
353                    &code_colors,
354                    (sy, sy + size.height),
355                );
356            })
357        };
358
359        let mut overlay = Frame::new(renderer, size);
360        {
361            let layout = editor.layout.borrow();
362            overlay.with_save(|frame| {
363                frame.translate(Vector::new(-sx, -sy));
364                render::draw_selection(frame, &layout, editor.sel, &palette);
365                if editor.focused && editor.caret_on && editor.link_url.is_none() {
366                    render::draw_caret(frame, &layout, editor.sel, &palette);
367                }
368            });
369        }
370
371        let hovered = cursor.position_in(bounds);
372        let (vertical, horizontal) = bars(editor, size);
373        for (bar, dragging) in [
374            (vertical, matches!(page.drag, Drag::Vertical(_))),
375            (horizontal, matches!(page.drag, Drag::Horizontal(_))),
376        ] {
377            let Some(bar) = bar else {
378                continue;
379            };
380            let active = dragging || hovered.is_some_and(|point| bar.track.contains(point));
381            let alpha = if active { 0.55 } else { 0.30 };
382            let thumb = Color {
383                a: alpha,
384                ..palette.muted
385            };
386            let inset = 2.0;
387            overlay.fill_rectangle(
388                Point::new(bar.thumb.x + inset, bar.thumb.y + inset),
389                Size::new(
390                    bar.thumb.width - 2.0 * inset,
391                    bar.thumb.height - 2.0 * inset,
392                ),
393                thumb,
394            );
395        }
396
397        vec![content, overlay.into_geometry()]
398    }
399
400    fn mouse_interaction(
401        &self,
402        page: &PageState,
403        bounds: Rectangle,
404        cursor: mouse::Cursor,
405    ) -> mouse::Interaction {
406        if matches!(page.drag, Drag::Vertical(_) | Drag::Horizontal(_)) {
407            return mouse::Interaction::Idle;
408        }
409        let Some(point) = cursor.position_in(bounds) else {
410            return mouse::Interaction::default();
411        };
412        let (vertical, horizontal) = bars(self.state, bounds.size());
413        if vertical.is_some_and(|bar| bar.track.contains(point))
414            || horizontal.is_some_and(|bar| bar.track.contains(point))
415        {
416            return mouse::Interaction::Idle;
417        }
418        let (sx, sy) = self.state.clamped_scroll();
419        if self
420            .state
421            .layout
422            .borrow()
423            .task_at(point.x + sx, point.y + sy)
424            .is_some()
425        {
426            mouse::Interaction::Pointer
427        } else {
428            mouse::Interaction::Text
429        }
430    }
431}
432
433/// Scroll offset for a thumb start position.
434fn scroll_for(thumb_start: f32, track_start: f32, track: f32, thumb: f32, max: f32) -> f32 {
435    let room = (track - thumb).max(1.0);
436    ((thumb_start - track_start) / room).clamp(0.0, 1.0) * max
437}
438
439/// Builder returned by [`markdown_editor`].
440pub struct MarkdownEditor<'a> {
441    state: &'a EditorState,
442    toolbar: bool,
443    status_bar: bool,
444    width: Length,
445    height: Length,
446}
447
448/// Creates the editor widget for the given state.
449pub fn markdown_editor(state: &EditorState) -> MarkdownEditor<'_> {
450    MarkdownEditor {
451        state,
452        toolbar: true,
453        status_bar: true,
454        width: Length::Fill,
455        height: Length::Fill,
456    }
457}
458
459impl<'a> MarkdownEditor<'a> {
460    /// Shows or hides the built-in toolbar (default: shown).
461    pub fn toolbar(mut self, show: bool) -> Self {
462        self.toolbar = show;
463        self
464    }
465
466    /// Shows or hides the status bar (default: shown).
467    pub fn status_bar(mut self, show: bool) -> Self {
468        self.status_bar = show;
469        self
470    }
471
472    pub fn width(mut self, width: impl Into<Length>) -> Self {
473        self.width = width.into();
474        self
475    }
476
477    pub fn height(mut self, height: impl Into<Length>) -> Self {
478        self.height = height.into();
479        self
480    }
481
482    /// Builds the widget element.
483    pub fn view(self) -> Element<'a, Action> {
484        let state = self.state;
485        let palette = state.style.palette;
486
487        let bar_style = move |_: &Theme| container::Style {
488            background: Some(Background::Color(palette.toolbar_background)),
489            text_color: Some(palette.toolbar_text),
490            border: Border {
491                color: palette.toolbar_border,
492                width: 1.0,
493                radius: 0.0.into(),
494            },
495            ..container::Style::default()
496        };
497
498        let mut content = column![];
499
500        if self.toolbar {
501            content = content.push(
502                container(toolbar::view(
503                    palette,
504                    &state.formatting(),
505                    state.mode,
506                    state.can_undo(),
507                    state.can_redo(),
508                    state.code_languages(),
509                    state.image_settings.enabled(),
510                ))
511                .padding([4, 8])
512                .width(Length::Fill)
513                .style(bar_style),
514            );
515        }
516
517        if let Some(url) = &state.link_url {
518            let link_bar = container(
519                row![
520                    label("Link address", 12).color(palette.muted),
521                    text_input("https://", url)
522                        .on_input(|url| Action(Inner::LinkUrl(url)))
523                        .on_submit(Action(Inner::LinkApply))
524                        .font(fonts::SANS)
525                        .padding(6)
526                        .size(13)
527                        .width(Length::Fixed(420.0)),
528                    button(label("Apply", 12))
529                        .on_press(Action(Inner::LinkApply))
530                        .padding([5, 10])
531                        .style(toolbar::button_style(palette, true)),
532                    button(label("Remove link", 12))
533                        .on_press(Action(Inner::LinkRemove))
534                        .padding([5, 10])
535                        .style(toolbar::button_style(palette, false)),
536                    button(label("Cancel", 12))
537                        .on_press(Action(Inner::LinkCancel))
538                        .padding([5, 10])
539                        .style(toolbar::button_style(palette, false)),
540                ]
541                .spacing(8)
542                .align_y(Alignment::Center),
543            )
544            .padding([6, 12])
545            .width(Length::Fill)
546            .style(bar_style);
547
548            content = content.push(link_bar);
549        }
550
551        let page = Canvas::new(Page { state })
552            .width(Length::Fill)
553            .height(Length::Fill);
554        content = content.push(page);
555
556        if self.status_bar {
557            let mode = match state.mode {
558                Mode::Wysiwyg => "WYSIWYG",
559                Mode::Source => "Markdown source",
560            };
561
562            let mut status = row![].spacing(18).align_y(Alignment::Center);
563
564            if !state.paper.is_fill() {
565                status = status.push(label(
566                    format!("Page {} of {}", state.current_page(), state.page_count()),
567                    12,
568                ));
569            }
570
571            status = status.push(label(format!("{} words", state.word_count()), 12));
572
573            let (checked, total) = state.task_progress();
574            if total > 0 {
575                status = status.push(label(format!("{checked}/{total} tasks done"), 12));
576            }
577
578            let pending = state.pending_images();
579            if pending > 0 {
580                status = status.push(label(format!("Saving {pending} image(s)\u{2026}"), 12));
581            }
582
583            status = status.push(label(mode, 12));
584
585            if state.tab_width > 0 {
586                status = status.push(label(format!("Tab: {} spaces", state.tab_width), 12));
587            }
588
589            status = status
590                .push(fill())
591                .push(label(if state.dirty { "Modified" } else { "" }, 12));
592
593            let zoom = &state.zoom_settings;
594            if zoom.enabled && zoom.controls {
595                let percent = format!("{:.0} %", state.zoom * 100.0);
596                let small = |text: String, command: Command| {
597                    button(label(text, 12))
598                        .on_press(Action::from(command))
599                        .padding([1, 8])
600                        .style(toolbar::button_style(palette, false))
601                };
602                status = status.push(
603                    row![
604                        small("\u{2212}".to_string(), Command::ZoomOut),
605                        small(percent, Command::ResetZoom),
606                        small("+".to_string(), Command::ZoomIn),
607                    ]
608                    .spacing(2)
609                    .align_y(Alignment::Center),
610                );
611            }
612
613            content = content.push(
614                container(status)
615                    .padding([3, 12])
616                    .width(Length::Fill)
617                    .style(move |_: &Theme| container::Style {
618                        background: Some(Background::Color(palette.toolbar_background)),
619                        text_color: Some(palette.muted),
620                        ..container::Style::default()
621                    }),
622            );
623        }
624
625        container(content)
626            .width(self.width)
627            .height(self.height)
628            .into()
629    }
630}
631
632impl<'a> From<MarkdownEditor<'a>> for Element<'a, Action> {
633    fn from(editor: MarkdownEditor<'a>) -> Self {
634        editor.view()
635    }
636}