1use 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
21const BAR: f32 = 10.0;
23const LINE: f32 = 48.0;
25
26fn 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#[derive(Debug, Clone, Copy, Default, PartialEq)]
34enum Drag {
35 #[default]
36 None,
37 Select,
39 Vertical(f32),
41 Horizontal(f32),
43}
44
45#[derive(Default)]
47pub struct PageState {
48 drag: Drag,
49 last_click: Option<(Instant, Point)>,
50 clicks: u8,
51 modifiers: Modifiers,
52}
53
54#[derive(Debug, Clone, Copy)]
56struct Bar {
57 track: Rectangle,
58 thumb: Rectangle,
59 max: f32,
61}
62
63fn 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
433fn 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
439pub struct MarkdownEditor<'a> {
441 state: &'a EditorState,
442 toolbar: bool,
443 status_bar: bool,
444 width: Length,
445 height: Length,
446}
447
448pub 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 pub fn toolbar(mut self, show: bool) -> Self {
462 self.toolbar = show;
463 self
464 }
465
466 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 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}