1use 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
23const BAR: f32 = 10.0;
25const LINE: f32 = 48.0;
27
28fn 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#[derive(Debug, Clone, Copy, Default, PartialEq)]
36enum Drag {
37 #[default]
38 None,
39 Select,
41 Vertical(f32),
43 Horizontal(f32),
45}
46
47#[derive(Default)]
49pub struct PageState {
50 drag: Drag,
51 last_click: Option<(Instant, Point)>,
52 clicks: u8,
53 modifiers: Modifiers,
54}
55
56#[derive(Debug, Clone, Copy)]
58struct Bar {
59 track: Rectangle,
60 thumb: Rectangle,
61 max: f32,
63}
64
65fn 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 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 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
457fn 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
463pub 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
473pub 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 pub fn toolbar(mut self, show: bool) -> Self {
488 self.toolbar = show;
489 self
490 }
491
492 pub fn status_bar(mut self, show: bool) -> Self {
494 self.status_bar = show;
495 self
496 }
497
498 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 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 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 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}