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