1use std::path::{Path, PathBuf};
9
10use unicode_segmentation::UnicodeSegmentation;
11
12use crate::model::{
13 Block, MAX_CATEGORY_DESC_LINE_LEN, MAX_CATEGORY_DESC_LINES, MAX_DESCRIPTION_LINES,
14 MAX_NOTES_LINE_LEN,
15};
16use crate::text_input::TextInput;
17
18pub const IMAGE_ROWS: u16 = 10;
20pub const TODO_INDENT: usize = 4;
22pub const BULLET_INDENT: usize = 2;
24pub const LINK_INDENT: usize = 2;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Command {
29 Todo,
30 Bullet,
31 Number,
32 Link,
33 Paste,
35 Copy,
37 CopyImage,
39 CopyAll,
41}
42
43#[derive(Debug, Clone)]
45pub enum CopyLine {
46 Text(String),
47 Link(String),
48 Image(PathBuf),
49}
50
51#[derive(Debug, Clone)]
53pub enum CopyPayload {
54 Text(String),
55 Image(PathBuf),
56 All(Vec<CopyLine>),
58}
59
60#[derive(Debug, Clone)]
63pub enum CommandRequest {
64 Copy(CopyPayload),
65 Paste,
66}
67
68impl Command {
69 pub const ALL: [Self; 8] = [
70 Self::Todo,
71 Self::Bullet,
72 Self::Number,
73 Self::Link,
74 Self::Paste,
75 Self::Copy,
76 Self::CopyImage,
77 Self::CopyAll,
78 ];
79 pub const PLAIN: [Self; 5] = [
81 Self::Bullet,
82 Self::Number,
83 Self::Link,
84 Self::Paste,
85 Self::Copy,
86 ];
87
88 pub fn label(self) -> &'static str {
89 match self {
90 Self::Todo => "To-do list",
91 Self::Bullet => "Bullet point",
92 Self::Number => "Numbered list",
93 Self::Link => "Link",
94 Self::Paste => "Paste",
95 Self::Copy => "Copy text",
96 Self::CopyImage => "Copy image",
97 Self::CopyAll => "Copy all",
98 }
99 }
100
101 fn hint(self, plain: bool) -> &'static str {
102 match (self, plain) {
103 (Self::Paste, true) => "text from clipboard",
104 (Self::Copy, true) => "prose, lists, and links",
105 (command, _) => match command {
106 Self::Todo => "tick it off with Ctrl+D",
107 Self::Bullet => "or type - and a space",
108 Self::Number => "or type 1. and a space",
109 Self::Link => "click or ⌘↵ to open",
110 Self::Paste => "text and images from clipboard",
111 Self::Copy => "prose, bullets, to-dos",
112 Self::CopyImage => "nearest picture in the description",
113 Self::CopyAll => "text and pictures together",
114 },
115 }
116 }
117
118 fn keywords(self) -> &'static [&'static str] {
119 match self {
120 Self::Todo => &["todo", "to-do", "task", "check", "box", "list"],
121 Self::Bullet => &["bullet", "point", "dash", "item", "list"],
122 Self::Number => &["number", "numbered", "ordered", "ol", "1"],
123 Self::Link => &["link", "url", "href", "http", "https", "www"],
124 Self::Paste => &["paste", "insert"],
125 Self::Copy => &["copy", "clipboard", "text"],
126 Self::CopyImage => &["image", "picture", "pic", "img", "photo"],
127 Self::CopyAll => &["copyall", "all", "everything", "rich"],
128 }
129 }
130
131 fn matches(self, query: &str) -> bool {
132 let query = query.to_lowercase();
133 query.is_empty() || self.keywords().iter().any(|k| k.starts_with(&query))
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct SlashMenu {
140 start: usize,
142 pub query: String,
143 pub index: usize,
144}
145
146impl SlashMenu {
147 pub fn matches_in(&self, allowed: &[Command]) -> Vec<Command> {
148 allowed
149 .iter()
150 .copied()
151 .filter(|c| c.matches(&self.query))
152 .collect()
153 }
154
155 pub fn selected_in(&self, allowed: &[Command]) -> Option<Command> {
156 let matches = self.matches_in(allowed);
157 matches
158 .get(self.index.min(matches.len().saturating_sub(1)))
159 .copied()
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164enum Line {
165 Text(TextInput),
166 Todo { text: TextInput, done: bool },
167 Bullet(TextInput),
168 Number(TextInput),
169 Link(TextInput),
170 Image { path: String },
171}
172
173impl Line {
174 fn input(&mut self) -> Option<&mut TextInput> {
175 match self {
176 Self::Text(text)
177 | Self::Todo { text, .. }
178 | Self::Bullet(text)
179 | Self::Number(text)
180 | Self::Link(text) => Some(text),
181 Self::Image { .. } => None,
182 }
183 }
184
185 fn input_ref(&self) -> Option<&TextInput> {
186 match self {
187 Self::Text(text)
188 | Self::Todo { text, .. }
189 | Self::Bullet(text)
190 | Self::Number(text)
191 | Self::Link(text) => Some(text),
192 Self::Image { .. } => None,
193 }
194 }
195
196 fn indent(&self) -> usize {
199 match self {
200 Self::Todo { .. } => TODO_INDENT,
201 Self::Bullet(_) => BULLET_INDENT,
202 Self::Link(_) => LINK_INDENT,
203 Self::Text(_) | Self::Number(_) | Self::Image { .. } => 0,
204 }
205 }
206
207 fn height(&self, width: usize, number: Option<usize>) -> usize {
208 match self {
209 Self::Image { .. } => usize::from(IMAGE_ROWS),
210 line => {
211 let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
212 let field = width.saturating_sub(indent).max(1);
213 line.input_ref()
214 .map(|t| t.wrap_height(field))
215 .unwrap_or(1)
216 .max(1)
217 }
218 }
219 }
220}
221
222fn flatten_if_image_path(text: &str, images_root: &std::path::Path) -> Option<String> {
225 if !text.contains('\n') && !text.contains('\r') {
226 return None;
227 }
228 let flat: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
229 let flat = flat.trim();
230 crate::image::path_if_image_in(flat, images_root).map(|_| flat.to_string())
231}
232
233fn link_url_from_line(s: &str) -> String {
235 let s = s.trim();
236 if let Some(open) = s.find("](")
237 && s.starts_with('[')
238 && s.ends_with(')')
239 && open + 2 < s.len()
240 {
241 let url = &s[open + 2..s.len() - 1];
242 if !url.is_empty() {
243 return url.to_string();
244 }
245 }
246 s.to_string()
247}
248
249fn plain_link_value(url: &str) -> String {
250 if crate::open::has_supported_scheme(url) {
251 url.to_string()
252 } else {
253 format!("[link]({url})")
254 }
255}
256
257fn number_indent(n: usize) -> usize {
259 let n = n.max(1);
260 n.ilog10() as usize + 3
261}
262
263fn number_at(lines: &[Line], at: usize) -> usize {
265 let mut n = 0;
266 for i in (0..=at).rev() {
267 if matches!(lines[i], Line::Number(_)) {
268 n += 1;
269 } else {
270 break;
271 }
272 }
273 n
274}
275
276fn number_runs(lines: &[Line]) -> Vec<Option<usize>> {
278 let mut out = Vec::with_capacity(lines.len());
279 let mut run = 0usize;
280 for line in lines {
281 if matches!(line, Line::Number(_)) {
282 run += 1;
283 out.push(Some(run));
284 } else {
285 run = 0;
286 out.push(None);
287 }
288 }
289 out
290}
291
292fn line_from_block(block: &Block, line_max_len: usize) -> Line {
293 match block {
294 Block::Text { text } => Line::Text(TextInput::new(text, line_max_len)),
295 Block::Todo { text, done } => Line::Todo {
296 text: TextInput::new(text, line_max_len),
297 done: *done,
298 },
299 Block::Bullet { text } => Line::Bullet(TextInput::new(text, line_max_len)),
300 Block::Number { text } => Line::Number(TextInput::new(text, line_max_len)),
301 Block::Link { url } => Line::Link(TextInput::new(url, line_max_len)),
302 Block::Image { attachment_id } => Line::Image {
303 path: attachment_id.clone(),
304 },
305 }
306}
307
308fn plain_line_to_block(line: &str) -> Block {
309 if let Some(rest) = line.strip_prefix("- ") {
310 return Block::bullet(rest);
311 }
312 let digits = line.bytes().take_while(u8::is_ascii_digit).count();
313 if digits > 0
314 && let Some(rest) = line.get(digits..).and_then(|rest| rest.strip_prefix(". "))
315 {
316 return Block::number(rest);
317 }
318 let link = link_url_from_line(line);
319 if link != line || crate::open::has_supported_scheme(&link) {
320 return Block::link(&link);
321 }
322 Block::text(line)
323}
324
325fn block_from_input(input: &TextInput, make: impl FnOnce(&str) -> Block) -> Option<Block> {
326 let value = input.value();
327 let value = value.trim_end();
328 (!value.trim().is_empty()).then(|| make(value))
329}
330
331fn resolve_image_reference(
332 reference: &str,
333 image_root: &Path,
334 attachments: &crate::image::AttachmentCatalog,
335) -> PathBuf {
336 attachments.resolve(reference, image_root)
337}
338
339fn visible_band(
343 start: usize,
344 rows: usize,
345 scroll: usize,
346 height: u16,
347) -> Option<(u16, u16, usize)> {
348 if rows == 0 || height == 0 {
349 return None;
350 }
351 let height = usize::from(height);
352 let end = start.saturating_add(rows);
353 let viewport_end = scroll.saturating_add(height);
354 if end <= scroll || start >= viewport_end {
355 return None;
356 }
357 let vis_start = start.max(scroll);
358 let vis_end = end.min(viewport_end);
359 let y = (vis_start - scroll) as u16;
360 let vis_rows = (vis_end - vis_start) as u16;
361 let skip = vis_start - start;
362 (vis_rows > 0).then_some((y, vis_rows, skip))
363}
364
365#[derive(Debug, Clone)]
367pub struct WrappedRow {
368 pub text: String,
369 pub sel: Option<(u16, u16)>,
370}
371
372pub enum Painted {
374 Text {
377 rows: Vec<WrappedRow>,
378 kind: TextKind,
379 },
380 Image(PathBuf),
381}
382
383#[derive(Debug, Clone, Copy)]
385pub enum TextKind {
386 Plain,
387 Todo { done: bool },
388 Bullet,
389 Number(usize),
390 Link,
391}
392
393impl TextKind {
394 pub fn indent(self) -> usize {
395 match self {
396 Self::Plain => 0,
397 Self::Todo { .. } => TODO_INDENT,
398 Self::Bullet => BULLET_INDENT,
399 Self::Number(n) => number_indent(n),
400 Self::Link => LINK_INDENT,
401 }
402 }
403}
404
405pub struct Placed {
406 pub block: Painted,
407 pub line: usize,
409 pub y: u16,
411 pub rows: u16,
412 pub selected: bool,
415}
416
417struct LineLayout {
418 number: Option<usize>,
419 wraps: Vec<(usize, usize)>,
420 start: usize,
421 rows: usize,
422 selection: Option<(usize, usize)>,
423 selected: bool,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct DescriptionEditor {
428 lines: Vec<Line>,
429 cursor: usize,
430 scroll: usize,
431 follow_cursor: bool,
434 pub menu: Option<SlashMenu>,
435 plain: bool,
437 max_lines: usize,
439 line_max_len: usize,
441 layout_width: usize,
443 content_height: usize,
445 prefer_col: u16,
447 sel_anchor: Option<(usize, usize)>,
450 image_root: PathBuf,
451 attachments: crate::image::AttachmentCatalog,
452}
453
454impl DescriptionEditor {
455 pub fn new(blocks: &[Block]) -> Self {
456 Self::new_with_images(blocks, crate::image::default_images_root(), &[])
457 }
458
459 pub fn new_with_images(
460 blocks: &[Block],
461 image_root: PathBuf,
462 attachments: &[crate::store::Attachment],
463 ) -> Self {
464 let mut catalog = crate::image::AttachmentCatalog::default();
465 catalog.set(attachments);
466 Self::from_blocks(
467 blocks,
468 MAX_DESCRIPTION_LINES,
469 MAX_NOTES_LINE_LEN,
470 false,
471 image_root,
472 catalog,
473 )
474 }
475
476 pub fn plain(text: &str) -> Self {
479 let blocks: Vec<Block> = text.split('\n').map(plain_line_to_block).collect();
480 Self::from_blocks(
481 &blocks,
482 MAX_CATEGORY_DESC_LINES,
483 MAX_CATEGORY_DESC_LINE_LEN,
484 true,
485 crate::image::default_images_root(),
486 crate::image::AttachmentCatalog::default(),
487 )
488 }
489
490 fn from_blocks(
491 blocks: &[Block],
492 max_lines: usize,
493 line_max_len: usize,
494 plain: bool,
495 image_root: PathBuf,
496 attachments: crate::image::AttachmentCatalog,
497 ) -> Self {
498 let mut lines: Vec<Line> = blocks
499 .iter()
500 .map(|b| line_from_block(b, line_max_len))
501 .collect();
502 if lines.is_empty() {
503 lines.push(Line::Text(TextInput::new("", line_max_len)));
504 }
505 let mut editor = Self {
506 lines,
507 cursor: 0,
508 scroll: 0,
509 follow_cursor: true,
510 menu: None,
511 plain,
512 max_lines,
513 line_max_len,
514 layout_width: 40,
515 content_height: 0,
516 prefer_col: u16::MAX,
517 sel_anchor: None,
518 image_root,
519 attachments,
520 };
521 if !plain {
522 editor.adopt_pasted_paths();
524 }
525 editor
526 }
527
528 fn can_add_lines(&self, n: usize) -> bool {
529 self.lines.len().saturating_add(n) <= self.max_lines
530 }
531
532 fn line_text_fits(&self, text: &str) -> bool {
533 text.len() <= crate::model::text_byte_limit(self.line_max_len)
534 && text.graphemes(true).count() <= self.line_max_len
535 }
536
537 pub fn set_image_root(&mut self, image_root: PathBuf) {
538 self.image_root = image_root;
539 if !self.plain {
540 self.adopt_pasted_paths();
541 }
542 }
543
544 pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
545 self.attachments.set(attachments);
546 if !self.plain {
547 self.adopt_pasted_paths();
548 }
549 }
550
551 pub fn image_root(&self) -> &std::path::Path {
552 &self.image_root
553 }
554
555 fn empty_line(&self) -> Line {
556 Line::Text(TextInput::new("", self.line_max_len))
557 }
558
559 pub fn allowed_commands(&self) -> &'static [Command] {
561 if self.plain {
562 &Command::PLAIN
563 } else {
564 &Command::ALL
565 }
566 }
567
568 pub fn menu_commands(&self) -> Vec<Command> {
570 self.menu
571 .as_ref()
572 .map(|m| m.matches_in(self.allowed_commands()))
573 .unwrap_or_default()
574 }
575
576 pub fn command_hint(&self, command: Command) -> &'static str {
577 command.hint(self.plain)
578 }
579
580 pub fn plain_value(&self) -> String {
582 let mut n = 0usize;
583 self.value()
584 .iter()
585 .filter_map(|b| match b {
586 Block::Text { text } => {
587 n = 0;
588 Some(text.clone())
589 }
590 Block::Bullet { text } => {
591 n = 0;
592 Some(format!("- {text}"))
593 }
594 Block::Number { text } => {
595 n += 1;
596 Some(format!("{n}. {text}"))
597 }
598 Block::Link { url } => {
599 n = 0;
600 Some(plain_link_value(url))
601 }
602 _ => {
603 n = 0;
604 None
605 }
606 })
607 .collect::<Vec<_>>()
608 .join("\n")
609 }
610
611 pub fn value(&self) -> Vec<Block> {
614 if self.is_empty() {
615 return Vec::new();
616 }
617 self.lines
618 .iter()
619 .filter_map(|line| match line {
620 Line::Text(text) => Some(Block::text(text.value().trim_end())),
621 Line::Todo { text, done } => {
622 block_from_input(text, |value| Block::todo(value, *done))
623 }
624 Line::Bullet(text) => block_from_input(text, Block::bullet),
625 Line::Number(text) => block_from_input(text, Block::number),
626 Line::Link(text) => block_from_input(text, Block::link),
627 Line::Image { path } => Some(Block::image(path)),
628 })
629 .collect()
630 }
631
632 pub fn is_empty(&self) -> bool {
633 !self.lines.iter().any(|l| match l {
634 Line::Image { .. } => true,
635 Line::Text(t)
636 | Line::Bullet(t)
637 | Line::Number(t)
638 | Line::Link(t)
639 | Line::Todo { text: t, .. } => !t.value().trim().is_empty(),
640 })
641 }
642
643 pub fn progress(&self) -> (usize, usize) {
644 let mut done = 0usize;
645 let mut total = 0usize;
646 for l in &self.lines {
647 if let Line::Todo { text, done: d } = l
648 && !text.value().trim().is_empty()
649 {
650 total += 1;
651 if *d {
652 done += 1;
653 }
654 }
655 }
656 (done, total)
657 }
658
659 pub fn cursor_line(&self) -> usize {
661 self.cursor
662 }
663
664 pub fn has_selection(&self) -> bool {
666 if self.ordered_selection().is_some() {
667 return true;
668 }
669 self.lines[self.cursor]
670 .input_ref()
671 .is_some_and(|t| t.has_selection())
672 }
673
674 pub fn selected_text(&self) -> Option<String> {
677 if let Some(payload) = self.selected_payload() {
678 return match payload {
679 CopyPayload::Text(s) => Some(s),
680 CopyPayload::All(lines) => {
681 let s = lines
682 .into_iter()
683 .map(|l| match l {
684 CopyLine::Text(t) | CopyLine::Link(t) => t,
685 CopyLine::Image(p) => format!("[image: {}]", p.display()),
686 })
687 .collect::<Vec<_>>()
688 .join("\n");
689 (!s.is_empty()).then_some(s)
690 }
691 CopyPayload::Image(p) => Some(format!("[image: {}]", p.display())),
692 };
693 }
694 None
695 }
696
697 pub fn selected_payload(&self) -> Option<CopyPayload> {
699 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
700 let lines = self.copy_lines_between(al, ac, bl, bc);
701 if lines.is_empty() {
702 return None;
703 }
704 if lines.iter().any(|l| matches!(l, CopyLine::Image(_))) {
705 return Some(CopyPayload::All(lines));
706 }
707 let text = lines
708 .into_iter()
709 .filter_map(|l| match l {
710 CopyLine::Text(t) | CopyLine::Link(t) => Some(t),
711 CopyLine::Image(_) => None,
712 })
713 .collect::<Vec<_>>()
714 .join("\n");
715 return (!text.is_empty()).then_some(CopyPayload::Text(text));
716 }
717 match &self.lines[self.cursor] {
718 Line::Text(t)
719 | Line::Todo { text: t, .. }
720 | Line::Bullet(t)
721 | Line::Number(t)
722 | Line::Link(t) => t.selected_text().map(CopyPayload::Text),
723 Line::Image { path } => Some(CopyPayload::Image(resolve_image_reference(
724 path,
725 &self.image_root,
726 &self.attachments,
727 ))),
728 }
729 }
730
731 fn caret(&self) -> (usize, usize) {
732 let col = self.lines[self.cursor]
733 .input_ref()
734 .map(|i| i.cursor())
735 .unwrap_or(0);
736 (self.cursor, col)
737 }
738
739 fn ordered_selection(&self) -> Option<((usize, usize), (usize, usize))> {
740 let a = self.sel_anchor?;
741 let b = self.caret();
742 if a == b {
743 return None;
744 }
745 Some(if (a.0, a.1) <= (b.0, b.1) {
746 (a, b)
747 } else {
748 (b, a)
749 })
750 }
751
752 fn line_char_len(&self, i: usize) -> usize {
753 self.lines[i].input_ref().map(|t| t.len()).unwrap_or(0)
754 }
755
756 fn is_image_line(&self, i: usize) -> bool {
757 matches!(self.lines.get(i), Some(Line::Image { .. }))
758 }
759
760 pub fn line_in_selection(&self, i: usize) -> bool {
764 let Some(((al, _), (bl, _))) = self.ordered_selection() else {
765 return false;
766 };
767 if i < al || i > bl {
768 return false;
769 }
770 if self.is_image_line(i) {
771 return al < bl;
773 }
774 self.char_sel_on_line(i).is_some()
775 }
776
777 fn copy_lines_between(&self, al: usize, ac: usize, bl: usize, bc: usize) -> Vec<CopyLine> {
778 let mut out = Vec::new();
779 if al == bl {
780 let Some(input) = self.lines[al].input_ref() else {
781 return out;
782 };
783 let lo = ac.min(input.len());
784 let hi = bc.min(input.len());
785 if lo < hi {
786 out.push(CopyLine::Text(input.slice(lo, hi)));
787 }
788 return out;
789 }
790 if let Some(line) = self.copy_line_slice(al, Some(ac), None) {
792 out.push(line);
793 }
794 for i in (al + 1)..bl {
795 if let Some(line) = self.copy_line_slice(i, None, None) {
796 out.push(line);
797 }
798 }
799 if let Some(line) = self.copy_line_slice(bl, None, Some(bc)) {
801 out.push(line);
802 }
803 out
804 }
805
806 fn copy_line_slice(
809 &self,
810 i: usize,
811 from: Option<usize>,
812 to: Option<usize>,
813 ) -> Option<CopyLine> {
814 let (input, link) = match &self.lines[i] {
815 Line::Image { path } => {
816 return Some(CopyLine::Image(resolve_image_reference(
817 path,
818 &self.image_root,
819 &self.attachments,
820 )));
821 }
822 Line::Link(input) => (input, true),
823 Line::Text(input)
824 | Line::Bullet(input)
825 | Line::Number(input)
826 | Line::Todo { text: input, .. } => (input, false),
827 };
828 let lo = from.unwrap_or(0).min(input.len());
829 let hi = to.unwrap_or(input.len()).min(input.len());
830 (lo < hi).then(|| {
831 let text = input.slice(lo, hi);
832 if link {
833 CopyLine::Link(text)
834 } else {
835 CopyLine::Text(text)
836 }
837 })
838 }
839
840 fn char_sel_on_line(&self, i: usize) -> Option<(usize, usize)> {
842 let ((al, ac), (bl, bc)) = self.ordered_selection()?;
843 if i < al || i > bl || self.is_image_line(i) {
844 return None;
845 }
846 let len = self.line_char_len(i);
847 if al == bl {
848 let lo = ac.min(bc).min(len);
849 let hi = ac.max(bc).min(len);
850 return (lo < hi).then_some((lo, hi));
851 }
852 if i == al {
853 let lo = ac.min(len);
854 return (lo < len || len == 0).then_some((lo, len));
855 }
856 if i == bl {
857 let hi = bc.min(len);
858 return (hi > 0).then_some((0, hi));
859 }
860 Some((0, len))
862 }
863
864 fn ensure_sel_anchor(&mut self) {
865 if self.sel_anchor.is_none() {
866 self.sel_anchor = Some(self.caret());
867 }
868 }
869
870 fn clear_description_selection(&mut self) {
871 self.sel_anchor = None;
872 for line in &mut self.lines {
873 if let Some(input) = line.input() {
874 input.clear_selection();
875 }
876 }
877 }
878
879 pub fn delete_description_selection(&mut self) -> bool {
882 self.follow_cursor = true;
883 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
884 if al == bl {
885 if let Some(input) = self.lines[al].input_ref() {
888 let len = input.len();
889 let lo = ac.min(bc).min(len);
890 let hi = ac.max(bc).min(len);
891 let text = format!("{}{}", input.slice(0, lo), input.slice(hi, len));
892 let len = self.line_max_len;
893 self.lines[al] = self.line_with_text(al, &text, len);
894 if let Some(input) = self.lines[al].input() {
895 input.place_cursor(lo);
896 }
897 }
898 } else {
899 let (prefix, ac) = if let Some(input) = self.lines[al].input_ref() {
902 let ac = ac.min(input.len());
903 (input.slice(0, ac), ac)
904 } else {
905 (String::new(), 0)
906 };
907 let suffix = if let Some(input) = self.lines[bl].input_ref() {
908 input.slice(bc.min(input.len()), input.len())
909 } else {
910 String::new()
911 };
912 let merged = prefix + &suffix;
913 if !self.line_text_fits(&merged) {
914 return false;
915 }
916 let len = self.line_max_len;
917 self.lines[al] = self.line_with_text(al, &merged, len);
918 self.lines.drain(al + 1..=bl);
919 self.cursor = al;
920 if let Some(input) = self.lines[al].input() {
921 input.place_cursor(ac.min(input.len()));
922 }
923 }
924 self.sel_anchor = None;
925 return true;
926 }
927 if let Some(input) = self.input() {
928 return input.delete_selection();
929 }
930 false
931 }
932
933 fn line_with_text(&self, index: usize, text: &str, len: usize) -> Line {
934 match &self.lines[index] {
935 Line::Todo { done, .. } => Line::Todo {
936 text: TextInput::new(text, len),
937 done: *done,
938 },
939 Line::Bullet(_) => Line::Bullet(TextInput::new(text, len)),
940 Line::Number(_) => Line::Number(TextInput::new(text, len)),
941 Line::Link(_) => Line::Link(TextInput::new(text, len)),
942 Line::Text(_) | Line::Image { .. } => Line::Text(TextInput::new(text, len)),
943 }
944 }
945
946 pub fn link_url_at_cursor(&self) -> Option<String> {
948 match &self.lines[self.cursor] {
949 Line::Link(t) => {
950 let u = t.value();
951 let u = u.trim();
952 (!u.is_empty()).then(|| u.to_string())
953 }
954 _ => None,
955 }
956 }
957
958 pub fn link_url_at_position(&self, row: u16, col: usize) -> Option<String> {
961 use unicode_width::UnicodeWidthStr;
962
963 let width = self.layout_width.max(1);
964 let numbers = number_runs(&self.lines);
965 let target = self.scroll.saturating_add(usize::from(row));
966 let mut at = 0usize;
967 for (index, line) in self.lines.iter().enumerate() {
968 let height = line.height(width, numbers[index]);
969 if target >= at.saturating_add(height) {
970 at = at.saturating_add(height);
971 continue;
972 }
973 let Line::Link(input) = line else {
974 return None;
975 };
976 let row_in = target.saturating_sub(at);
977 let indent = line.indent();
978 let field = width.saturating_sub(indent).max(1);
979 let breaks = input.wrap_breaks(field);
980 let &(start, end) = breaks.get(row_in)?;
981 let text = input.slice(start, end);
982 let text_width = text.width();
983 let on_marker = row_in == 0 && col < indent;
984 let on_text = col >= indent && col < indent.saturating_add(text_width);
985 if !on_marker && !on_text {
986 return None;
987 }
988 let url = input.value();
989 let url = url.trim();
990 return (!url.is_empty()).then(|| url.to_string());
991 }
992 None
993 }
994
995 pub fn selected_image(&self) -> Option<PathBuf> {
996 match &self.lines[self.cursor] {
997 Line::Image { path } => Some(resolve_image_reference(
998 path,
999 &self.image_root,
1000 &self.attachments,
1001 )),
1002 _ => None,
1003 }
1004 }
1005
1006 pub fn images(&self) -> Vec<PathBuf> {
1008 self.lines
1009 .iter()
1010 .filter_map(|l| match l {
1011 Line::Image { path } => Some(resolve_image_reference(
1012 path,
1013 &self.image_root,
1014 &self.attachments,
1015 )),
1016 _ => None,
1017 })
1018 .collect()
1019 }
1020
1021 pub fn abandon_image_selection(&mut self) {
1026 self.follow_cursor = true;
1027 if !matches!(self.lines.get(self.cursor), Some(Line::Image { .. })) {
1028 return;
1029 }
1030 if let Some(next) = self.next_editable(self.cursor) {
1031 self.cursor = next;
1032 if let Some(input) = self.input() {
1033 input.home();
1034 }
1035 return;
1036 }
1037 if let Some(prev) = self.prev_editable(self.cursor) {
1038 self.cursor = prev;
1039 if let Some(input) = self.input() {
1040 input.end();
1041 }
1042 }
1043 }
1044
1045 fn line(&mut self) -> &mut Line {
1046 &mut self.lines[self.cursor]
1047 }
1048
1049 fn input(&mut self) -> Option<&mut TextInput> {
1050 self.lines[self.cursor].input()
1051 }
1052
1053 pub fn insert(&mut self, c: char) {
1056 self.follow_cursor = true;
1057 if self.has_selection() && !self.delete_description_selection() {
1059 return;
1060 }
1061 if self.input().is_none() {
1062 self.insert_block(Block::text(""));
1064 }
1065 if let Some(input) = self.input() {
1066 input.insert(c);
1067 }
1068 if c == ' '
1070 && let Line::Text(text) = &self.lines[self.cursor]
1071 {
1072 let v = text.value();
1073 if text.cursor() == v.graphemes(true).count() {
1074 if matches!(v.as_str(), "- " | "* ") {
1075 self.lines[self.cursor] = Line::Bullet(TextInput::new("", self.line_max_len));
1076 return;
1077 }
1078 if let Some(rest) = v.strip_suffix(". ")
1079 && !rest.is_empty()
1080 && rest.chars().all(|ch| ch.is_ascii_digit())
1081 {
1082 self.lines[self.cursor] = Line::Number(TextInput::new("", self.line_max_len));
1083 return;
1084 }
1085 }
1086 }
1087 if c == '/' {
1088 let start = self.input().map(|i| i.cursor()).unwrap_or(0);
1089 self.menu = Some(SlashMenu {
1090 start,
1091 query: String::new(),
1092 index: 0,
1093 });
1094 } else if self.menu.is_some() {
1095 if let Some(menu) = &mut self.menu {
1096 menu.query.push(c);
1097 menu.index = 0;
1098 }
1099 if self.menu_commands().is_empty() {
1101 self.close_menu();
1102 }
1103 }
1104 self.try_adopt_line(self.cursor);
1106 }
1107
1108 pub fn insert_str(&mut self, text: &str) {
1109 self.follow_cursor = true;
1110 self.close_menu();
1111 if self.has_selection() && !self.delete_description_selection() {
1112 return;
1113 }
1114 let text = match flatten_if_image_path(text, &self.image_root) {
1116 Some(flat) if self.line_text_fits(&flat) => flat,
1117 _ => text.to_string(),
1118 };
1119 for (i, part) in text.split('\n').enumerate() {
1120 if i > 0 && !self.newline() {
1121 break;
1122 }
1123 if self.input().is_none() {
1124 self.insert_block(Block::text(""));
1125 }
1126 if let Some(input) = self.input() {
1127 input.insert_str(part.trim_end_matches('\r'));
1128 }
1129 }
1130 self.adopt_pasted_paths();
1133 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1134 if matches!(self.lines.get(self.cursor + 1), Some(Line::Text(_))) {
1135 self.cursor += 1;
1136 } else if self.can_add_lines(1) {
1137 self.lines.insert(self.cursor + 1, self.empty_line());
1138 self.cursor += 1;
1139 }
1140 }
1141 }
1142
1143 pub fn newline(&mut self) -> bool {
1147 self.follow_cursor = true;
1148 self.close_menu();
1149
1150 let exits_list = match &self.lines[self.cursor] {
1151 Line::Todo { text, .. } | Line::Bullet(text) | Line::Number(text) => {
1152 text.value().trim().is_empty()
1153 }
1154 Line::Text(_) | Line::Link(_) | Line::Image { .. } => false,
1155 };
1156 if exits_list {
1157 self.lines[self.cursor] = self.empty_line();
1158 return true;
1159 }
1160
1161 if !self.can_add_lines(1) {
1162 return false;
1163 }
1164 let line_max_len = self.line_max_len;
1165 let next = match self.line() {
1166 Line::Text(text) | Line::Link(text) => Line::Text(text.split_off_at_cursor()),
1167 Line::Todo { text, .. } => Line::Todo {
1168 text: text.split_off_at_cursor(),
1169 done: false,
1170 },
1171 Line::Bullet(text) => Line::Bullet(text.split_off_at_cursor()),
1172 Line::Number(text) => Line::Number(text.split_off_at_cursor()),
1173 Line::Image { .. } => Line::Text(TextInput::new("", line_max_len)),
1174 };
1175 self.lines.insert(self.cursor + 1, next);
1176 self.cursor += 1;
1177 self.adopt_pasted_paths();
1179 true
1180 }
1181
1182 pub fn backspace(&mut self) {
1183 self.follow_cursor = true;
1184 if let Some(start) = self.menu.as_ref().map(|m| m.start) {
1185 let at = self.input().map(|i| i.cursor()).unwrap_or(0);
1186 if at <= start {
1187 self.close_menu();
1188 } else if let Some(menu) = &mut self.menu {
1189 menu.query.pop();
1190 menu.index = 0;
1191 }
1192 }
1193 let had_selection = self.has_selection();
1194 if self.delete_description_selection() {
1195 return;
1196 }
1197 if had_selection {
1198 return;
1199 }
1200 match self.line() {
1201 Line::Todo { text, .. }
1204 | Line::Bullet(text)
1205 | Line::Number(text)
1206 | Line::Link(text)
1207 if text.at_start() =>
1208 {
1209 let text = text.clone();
1210 self.lines[self.cursor] = Line::Text(text);
1211 return;
1212 }
1213 Line::Text(text) if text.at_start() => {}
1214 Line::Image { .. } => {
1215 self.remove_block();
1216 return;
1217 }
1218 _ => {
1219 if let Some(input) = self.input() {
1220 input.backspace();
1221 }
1222 return;
1223 }
1224 }
1225 if self.cursor == 0 {
1227 return;
1228 }
1229 let current = self.lines.remove(self.cursor);
1230 self.cursor -= 1;
1231 match current {
1232 Line::Text(text) => {
1233 let merged = match self.lines[self.cursor].input() {
1234 Some(previous) => previous.append(&text),
1235 None => text.is_empty(),
1237 };
1238 if !merged {
1239 self.cursor += 1;
1240 self.lines.insert(self.cursor, Line::Text(text));
1241 }
1242 }
1243 line => {
1244 self.cursor += 1;
1245 self.lines.insert(self.cursor, line);
1246 }
1247 }
1248 }
1249
1250 pub fn delete(&mut self) {
1251 self.follow_cursor = true;
1252 self.close_menu();
1253 let had_selection = self.has_selection();
1254 if self.delete_description_selection() {
1255 return;
1256 }
1257 if had_selection {
1258 return;
1259 }
1260 if matches!(self.line(), Line::Image { .. }) {
1261 self.remove_block();
1262 return;
1263 }
1264 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1265 if !at_end {
1266 if let Some(input) = self.input() {
1267 input.delete();
1268 }
1269 return;
1270 }
1271 if self.cursor + 1 >= self.lines.len() {
1272 return;
1273 }
1274 let next = self.lines.remove(self.cursor + 1);
1275 let merged = match next.input_ref() {
1276 Some(text) => self.lines[self.cursor]
1277 .input()
1278 .is_some_and(|current| current.append(text)),
1279 None => false,
1280 };
1281 if !merged {
1282 self.lines.insert(self.cursor + 1, next);
1283 }
1284 }
1285
1286 pub fn remove_block(&mut self) {
1288 self.follow_cursor = true;
1289 if self.lines.len() == 1 {
1290 self.lines[0] = self.empty_line();
1291 return;
1292 }
1293 self.lines.remove(self.cursor);
1294 self.cursor = self.cursor.min(self.lines.len() - 1);
1295 }
1296
1297 pub fn toggle(&mut self) {
1298 self.follow_cursor = true;
1299 if let Line::Todo { done, .. } = self.line() {
1300 *done = !*done;
1301 }
1302 }
1303
1304 fn field_width_for(&self, index: usize) -> usize {
1307 let width = self.layout_width.max(1);
1308 let n = match &self.lines[index] {
1309 Line::Number(_) => Some(number_at(&self.lines, index)),
1310 _ => None,
1311 };
1312 let indent = n
1313 .map(number_indent)
1314 .unwrap_or_else(|| self.lines[index].indent());
1315 width.saturating_sub(indent).max(1)
1316 }
1317
1318 pub fn up(&mut self) {
1319 self.follow_cursor = true;
1320 self.close_menu();
1321 self.clear_description_selection();
1322 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1324 self.leave_image_backward();
1325 return;
1326 }
1327 let width = self.field_width_for(self.cursor);
1328 let prefer = self.prefer_col;
1329 let moved = self
1330 .input()
1331 .is_some_and(|input| input.wrap_up(width, prefer));
1332 if moved {
1333 if self.prefer_col == u16::MAX
1334 && let Some(input) = self.input()
1335 {
1336 self.prefer_col = input.wrap_cursor(width).1;
1337 }
1338 return;
1339 }
1340 if self.cursor > 0 {
1341 let left = self.cursor;
1342 self.cursor -= 1;
1343 self.try_adopt_line(left);
1344 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1346 return;
1347 }
1348 let width = self.field_width_for(self.cursor);
1349 let prefer = if self.prefer_col == u16::MAX {
1350 0
1351 } else {
1352 self.prefer_col
1353 };
1354 if let Some(input) = self.input() {
1355 let last = input.wrap_height(width).saturating_sub(1);
1356 input.set_cursor_from_wrap(width, last, prefer as usize);
1357 }
1358 }
1359 }
1360
1361 pub fn down(&mut self) {
1362 self.follow_cursor = true;
1363 self.close_menu();
1364 self.clear_description_selection();
1365 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1366 self.leave_image_forward();
1367 return;
1368 }
1369 let width = self.field_width_for(self.cursor);
1370 let prefer = self.prefer_col;
1371 let moved = self
1372 .input()
1373 .is_some_and(|input| input.wrap_down(width, prefer));
1374 if moved {
1375 if self.prefer_col == u16::MAX
1376 && let Some(input) = self.input()
1377 {
1378 self.prefer_col = input.wrap_cursor(width).1;
1379 }
1380 return;
1381 }
1382 if self.cursor + 1 < self.lines.len() {
1383 let left = self.cursor;
1384 self.cursor += 1;
1385 self.try_adopt_line(left);
1386 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1388 return;
1389 }
1390 let width = self.field_width_for(self.cursor);
1391 let prefer = if self.prefer_col == u16::MAX {
1392 0
1393 } else {
1394 self.prefer_col
1395 };
1396 if let Some(input) = self.input() {
1397 input.set_cursor_from_wrap(width, 0, prefer as usize);
1398 }
1399 }
1400 }
1401
1402 pub fn left(&mut self) {
1403 self.follow_cursor = true;
1404 self.close_menu();
1405 self.prefer_col = u16::MAX;
1406 self.clear_description_selection();
1407 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1410 self.leave_image_backward();
1411 return;
1412 }
1413 match self.input() {
1414 Some(input) if !input.at_start() => input.left(),
1415 _ => {
1416 if self.cursor > 0 {
1417 self.cursor -= 1;
1418 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1420 return;
1421 }
1422 if let Some(input) = self.input() {
1423 input.end();
1424 }
1425 }
1426 }
1427 }
1428 }
1429
1430 pub fn right(&mut self) {
1431 self.follow_cursor = true;
1432 self.close_menu();
1433 self.prefer_col = u16::MAX;
1434 self.clear_description_selection();
1435 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1438 self.leave_image_forward();
1439 return;
1440 }
1441 match self.input() {
1442 Some(input) if !input.at_end() => input.right(),
1443 _ => {
1444 if self.cursor + 1 < self.lines.len() {
1445 self.cursor += 1;
1446 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1448 return;
1449 }
1450 if let Some(input) = self.input() {
1451 input.home();
1452 }
1453 }
1454 }
1455 }
1456 }
1457
1458 fn leave_image_forward(&mut self) {
1462 let next = self.cursor + 1;
1463 if next < self.lines.len() && self.lines[next].input_ref().is_some() {
1464 self.cursor = next;
1465 if let Some(input) = self.input() {
1466 input.home();
1467 }
1468 return;
1469 }
1470 if !self.can_add_lines(1) {
1471 return;
1472 }
1473 self.lines.insert(next, self.empty_line());
1476 self.cursor = next;
1477 }
1478
1479 fn leave_image_backward(&mut self) {
1482 if self.cursor > 0 && self.lines[self.cursor - 1].input_ref().is_some() {
1483 self.cursor -= 1;
1484 if let Some(input) = self.input() {
1485 input.end();
1486 }
1487 return;
1488 }
1489 if !self.can_add_lines(1) {
1490 return;
1491 }
1492 self.lines.insert(self.cursor, self.empty_line());
1493 if let Some(input) = self.input() {
1495 input.home();
1496 }
1497 }
1498
1499 pub fn home(&mut self) {
1500 self.follow_cursor = true;
1501 self.close_menu();
1502 self.clear_description_selection();
1503 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1504 self.leave_image_backward();
1505 return;
1506 }
1507 if let Some(input) = self.input() {
1508 input.home();
1509 }
1510 }
1511
1512 pub fn end(&mut self) {
1513 self.follow_cursor = true;
1514 self.close_menu();
1515 self.clear_description_selection();
1516 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1517 self.leave_image_forward();
1518 return;
1519 }
1520 if let Some(input) = self.input() {
1521 input.end();
1522 }
1523 }
1524
1525 pub fn word_left(&mut self) {
1526 self.follow_cursor = true;
1527 self.close_menu();
1528 self.clear_description_selection();
1529 self.prefer_col = u16::MAX;
1530 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1531 self.leave_image_backward();
1532 return;
1533 }
1534 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1535 if !at_start {
1536 if let Some(input) = self.input() {
1537 input.word_left();
1538 }
1539 return;
1540 }
1541 if let Some(prev) = self.prev_editable(self.cursor) {
1543 self.cursor = prev;
1544 if let Some(input) = self.input() {
1545 input.end();
1546 input.word_left();
1547 }
1548 }
1549 }
1550
1551 pub fn word_right(&mut self) {
1552 self.follow_cursor = true;
1553 self.close_menu();
1554 self.clear_description_selection();
1555 self.prefer_col = u16::MAX;
1556 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1557 self.leave_image_forward();
1558 return;
1559 }
1560 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1561 if !at_end {
1562 if let Some(input) = self.input() {
1563 input.word_right();
1564 }
1565 return;
1566 }
1567 if let Some(next) = self.next_editable(self.cursor) {
1568 self.cursor = next;
1569 if let Some(input) = self.input() {
1570 input.home();
1571 input.word_right();
1572 }
1573 }
1574 }
1575
1576 fn prev_editable(&self, from: usize) -> Option<usize> {
1577 (0..from)
1578 .rev()
1579 .find(|&i| self.lines[i].input_ref().is_some())
1580 }
1581
1582 fn next_editable(&self, from: usize) -> Option<usize> {
1583 ((from + 1)..self.lines.len()).find(|&i| self.lines[i].input_ref().is_some())
1584 }
1585
1586 pub fn select_word(&mut self) {
1587 self.follow_cursor = true;
1588 self.close_menu();
1589 self.sel_anchor = None;
1590 if let Some(input) = self.input() {
1591 input.select_word();
1592 }
1593 }
1594
1595 pub fn select_left(&mut self) {
1596 self.follow_cursor = true;
1597 self.close_menu();
1598 self.prefer_col = u16::MAX;
1599 self.ensure_sel_anchor();
1600 if let Some(input) = self.input() {
1601 input.clear_selection();
1602 }
1603 if self.is_image_line(self.cursor) {
1605 self.step_sel_prev_line();
1606 return;
1607 }
1608 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1609 if !at_start {
1610 if let Some(input) = self.input() {
1611 let c = input.cursor().saturating_sub(1);
1612 input.place_cursor(c);
1613 }
1614 } else {
1615 self.step_sel_prev_line();
1616 }
1617 }
1618
1619 pub fn select_right(&mut self) {
1620 self.follow_cursor = true;
1621 self.close_menu();
1622 self.prefer_col = u16::MAX;
1623 self.ensure_sel_anchor();
1624 if let Some(input) = self.input() {
1625 input.clear_selection();
1626 }
1627 if self.is_image_line(self.cursor) {
1628 self.step_sel_next_line();
1629 return;
1630 }
1631 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1632 if !at_end {
1633 if let Some(input) = self.input() {
1634 let c = (input.cursor() + 1).min(input.len());
1635 input.place_cursor(c);
1636 }
1637 } else {
1638 self.step_sel_next_line();
1639 }
1640 }
1641
1642 pub fn select_word_left(&mut self) {
1644 self.follow_cursor = true;
1645 self.close_menu();
1646 self.prefer_col = u16::MAX;
1647 self.ensure_sel_anchor();
1648 if let Some(input) = self.input() {
1649 input.clear_selection();
1650 }
1651 if self.is_image_line(self.cursor) {
1653 self.step_sel_prev_line();
1654 if !self.is_image_line(self.cursor)
1655 && let Some(input) = self.input()
1656 {
1657 input.end();
1658 let target = input.word_left_index();
1659 input.place_cursor(target);
1660 }
1661 return;
1662 }
1663 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1664 if !at_start {
1665 if let Some(input) = self.input() {
1666 let target = input.word_left_index();
1667 input.place_cursor(target);
1668 }
1669 return;
1670 }
1671 self.step_sel_prev_line();
1672 if self.is_image_line(self.cursor) {
1673 return;
1674 }
1675 if let Some(input) = self.input() {
1676 input.end();
1677 let target = input.word_left_index();
1678 input.place_cursor(target);
1679 }
1680 }
1681
1682 pub fn select_word_right(&mut self) {
1684 self.follow_cursor = true;
1685 self.close_menu();
1686 self.prefer_col = u16::MAX;
1687 self.ensure_sel_anchor();
1688 if let Some(input) = self.input() {
1689 input.clear_selection();
1690 }
1691 if self.is_image_line(self.cursor) {
1692 self.step_sel_next_line();
1693 if !self.is_image_line(self.cursor)
1694 && let Some(input) = self.input()
1695 {
1696 input.home();
1697 let target = input.word_right_index();
1698 input.place_cursor(target);
1699 }
1700 return;
1701 }
1702 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1703 if !at_end {
1704 if let Some(input) = self.input() {
1705 let target = input.word_right_index();
1706 input.place_cursor(target);
1707 }
1708 return;
1709 }
1710 self.step_sel_next_line();
1711 if self.is_image_line(self.cursor) {
1712 return;
1713 }
1714 if let Some(input) = self.input() {
1715 input.home();
1716 let target = input.word_right_index();
1717 input.place_cursor(target);
1718 }
1719 }
1720
1721 fn step_sel_prev_line(&mut self) {
1724 if self.cursor == 0 {
1725 return;
1726 }
1727 self.cursor -= 1;
1728 if let Some(input) = self.input() {
1729 input.end();
1730 }
1731 }
1732
1733 fn step_sel_next_line(&mut self) {
1735 if self.cursor + 1 >= self.lines.len() {
1736 return;
1737 }
1738 self.cursor += 1;
1739 if let Some(input) = self.input() {
1740 input.home();
1741 }
1742 }
1743
1744 pub fn select_home(&mut self) {
1745 self.follow_cursor = true;
1746 self.close_menu();
1747 self.ensure_sel_anchor();
1748 if let Some(input) = self.input() {
1749 input.clear_selection();
1750 input.place_cursor(0);
1751 }
1752 }
1753
1754 pub fn select_end(&mut self) {
1755 self.follow_cursor = true;
1756 self.close_menu();
1757 self.ensure_sel_anchor();
1758 if let Some(input) = self.input() {
1759 input.clear_selection();
1760 let n = input.len();
1761 input.place_cursor(n);
1762 }
1763 }
1764
1765 pub fn delete_to_start(&mut self) {
1766 self.follow_cursor = true;
1767 if let Some(input) = self.input() {
1768 input.delete_to_start();
1769 }
1770 }
1771
1772 pub fn delete_to_end(&mut self) {
1773 self.follow_cursor = true;
1774 if let Some(input) = self.input() {
1775 input.delete_to_end();
1776 }
1777 }
1778
1779 pub fn delete_word_left(&mut self) {
1780 self.follow_cursor = true;
1781 if let Some(input) = self.input() {
1782 input.delete_word_left();
1783 }
1784 }
1785
1786 pub fn menu_next(&mut self) {
1789 let count = self.menu_commands().len();
1790 if let Some(menu) = &mut self.menu
1791 && count > 0
1792 {
1793 menu.index = (menu.index + 1) % count;
1794 }
1795 }
1796
1797 pub fn menu_prev(&mut self) {
1798 let count = self.menu_commands().len();
1799 if let Some(menu) = &mut self.menu
1800 && count > 0
1801 {
1802 menu.index = (menu.index + count - 1) % count;
1803 }
1804 }
1805
1806 pub fn close_menu(&mut self) {
1807 self.menu = None;
1808 }
1809
1810 pub fn menu_selected(&self) -> Option<Command> {
1812 self.menu
1813 .as_ref()
1814 .and_then(|m| m.selected_in(self.allowed_commands()))
1815 }
1816
1817 pub fn text_for_copy(&self) -> String {
1819 self.lines_for_copy_all()
1820 .into_iter()
1821 .filter_map(|line| match line {
1822 CopyLine::Text(s) | CopyLine::Link(s) => Some(s),
1823 CopyLine::Image(_) => None,
1824 })
1825 .collect::<Vec<_>>()
1826 .join("\n")
1827 }
1828
1829 pub fn lines_for_copy_all(&self) -> Vec<CopyLine> {
1831 let numbers = number_runs(&self.lines);
1832 self.lines
1833 .iter()
1834 .zip(numbers)
1835 .filter_map(|(line, number)| match line {
1836 Line::Text(text) => {
1837 let s = text.value();
1838 (!s.trim().is_empty()).then_some(CopyLine::Text(s))
1839 }
1840 Line::Bullet(text) => Some(CopyLine::Text(format!("- {}", text.value()))),
1841 Line::Number(text) => {
1842 let n = number.expect("numbered lines have a run index");
1843 Some(CopyLine::Text(format!("{n}. {}", text.value())))
1844 }
1845 Line::Todo { text, done } => {
1846 let mark = if *done { "[✓]" } else { "[ ]" };
1847 Some(CopyLine::Text(format!("{mark} {}", text.value())))
1848 }
1849 Line::Link(text) => {
1850 let s = text.value();
1851 (!s.trim().is_empty()).then_some(CopyLine::Link(s))
1852 }
1853 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
1854 path,
1855 &self.image_root,
1856 &self.attachments,
1857 ))),
1858 })
1859 .collect()
1860 }
1861
1862 pub fn image_for_copy(&self) -> Option<PathBuf> {
1864 (0..=self.cursor)
1865 .rev()
1866 .chain(self.cursor + 1..self.lines.len())
1867 .find_map(|i| match &self.lines[i] {
1868 Line::Image { path } => Some(resolve_image_reference(
1869 path,
1870 &self.image_root,
1871 &self.attachments,
1872 )),
1873 _ => None,
1874 })
1875 }
1876
1877 pub fn apply(&mut self, command: Command) -> Option<CommandRequest> {
1880 self.follow_cursor = true;
1881 if !self.allowed_commands().contains(&command) {
1882 self.close_menu();
1883 return None;
1884 }
1885 let menu = self.menu.take()?;
1886 if let Some(input) = self.input() {
1887 let end = input.cursor();
1889 input.set_cursor(end);
1890 for _ in menu.start.saturating_sub(1)..end {
1891 input.backspace();
1892 }
1893 }
1894 let request = match command {
1895 Command::Paste => Some(CommandRequest::Paste),
1896 Command::Copy => Some(CommandRequest::Copy(CopyPayload::Text(
1897 self.text_for_copy(),
1898 ))),
1899 Command::CopyImage => self
1900 .image_for_copy()
1901 .map(CopyPayload::Image)
1902 .map(CommandRequest::Copy),
1903 Command::CopyAll => Some(CommandRequest::Copy(CopyPayload::All(
1904 self.lines_for_copy_all(),
1905 ))),
1906 Command::Todo | Command::Bullet | Command::Number | Command::Link => {
1907 let text = match self.line() {
1908 Line::Text(text)
1909 | Line::Todo { text, .. }
1910 | Line::Bullet(text)
1911 | Line::Number(text)
1912 | Line::Link(text) => text.clone(),
1913 Line::Image { .. } => return None,
1914 };
1915 self.lines[self.cursor] = match command {
1916 Command::Todo => Line::Todo { text, done: false },
1917 Command::Bullet => Line::Bullet(text),
1918 Command::Number => Line::Number(text),
1919 Command::Link => {
1920 let url = link_url_from_line(&text.value());
1921 Line::Link(TextInput::new(&url, self.line_max_len))
1922 }
1923 Command::Paste | Command::Copy | Command::CopyImage | Command::CopyAll => {
1924 return None;
1925 }
1926 };
1927 None
1928 }
1929 };
1930 if matches!(
1931 command,
1932 Command::Copy | Command::CopyImage | Command::CopyAll
1933 ) && self.input().is_some_and(|input| input.is_empty())
1934 {
1935 self.remove_block();
1936 }
1937 request
1938 }
1939
1940 pub fn insert_block(&mut self, block: Block) -> bool {
1943 self.follow_cursor = true;
1944 if self.plain && matches!(block, Block::Todo { .. } | Block::Image { .. }) {
1945 return false;
1946 }
1947 self.close_menu();
1948 let is_image = matches!(block, Block::Image { .. });
1949 let replace = match &self.lines[self.cursor] {
1950 Line::Text(text) => text.is_empty(),
1951 _ => false,
1952 };
1953 let extra = match (replace, is_image) {
1954 (true, true) => 1, (true, false) => 0,
1956 (false, true) => 2, (false, false) => 1,
1958 };
1959 if extra > 0 && !self.can_add_lines(extra) {
1960 return false;
1961 }
1962 let line = line_from_block(&block, self.line_max_len);
1963 if replace {
1964 self.lines[self.cursor] = line;
1965 } else {
1966 self.lines.insert(self.cursor + 1, line);
1967 self.cursor += 1;
1968 }
1969 if is_image {
1971 self.lines.insert(self.cursor + 1, self.empty_line());
1972 self.cursor += 1;
1973 }
1974 true
1975 }
1976
1977 fn adopt_pasted_paths(&mut self) {
1979 if self.plain {
1980 return;
1981 }
1982 self.merge_broken_image_paths();
1983 for i in 0..self.lines.len() {
1984 self.try_adopt_line(i);
1985 }
1986 }
1987
1988 fn try_adopt_line(&mut self, i: usize) {
1989 let Line::Text(text) = &self.lines[i] else {
1990 return;
1991 };
1992 let value = text.value();
1993 let reference = crate::image::reference_path(&value);
1994 let managed = reference.filter(|reference| self.attachments.contains(reference));
1995 if !crate::image::looks_like_image(&value) && managed.is_none() {
1996 return;
1997 }
1998 if let Some(reference) = managed {
1999 self.lines[i] = Line::Image {
2000 path: reference.to_string(),
2001 };
2002 } else if let Some(path) = crate::image::path_if_image_in(&value, &self.image_root) {
2003 self.lines[i] = Line::Image {
2004 path: crate::image::short_in(&path, &self.image_root),
2005 };
2006 }
2007 }
2008
2009 fn merge_broken_image_paths(&mut self) {
2012 let mut i = 0;
2013 while i + 1 < self.lines.len() {
2014 let joined = match (&self.lines[i], &self.lines[i + 1]) {
2015 (Line::Text(a), Line::Text(b)) => {
2016 let left = a.value();
2017 let right = b.value();
2018 let joined = format!("{left}{right}");
2019 if left.contains('/')
2022 && !crate::image::looks_like_image(&left)
2023 && self.line_text_fits(&joined)
2024 && crate::image::path_if_image_in(&joined, &self.image_root).is_some()
2025 {
2026 Some(joined)
2027 } else {
2028 None
2029 }
2030 }
2031 _ => None,
2032 };
2033 if let Some(path) = joined {
2034 let len = self.line_max_len;
2035 self.lines[i] = Line::Text(TextInput::new(&path, len));
2036 self.lines.remove(i + 1);
2037 if self.cursor > i {
2038 self.cursor -= 1;
2039 }
2040 } else {
2042 i += 1;
2043 }
2044 }
2045 }
2046
2047 pub fn layout(&mut self, width: usize, height: u16) -> (Vec<Placed>, Option<(u16, u16)>) {
2053 if height == 0 || width == 0 {
2054 return (Vec::new(), None);
2055 }
2056 self.layout_width = width;
2057
2058 let numbers = number_runs(&self.lines);
2059 let mut total = 0usize;
2060 let layouts: Vec<LineLayout> = self
2061 .lines
2062 .iter()
2063 .enumerate()
2064 .map(|(i, line)| {
2065 let number = numbers[i];
2066 let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
2067 let field = width.saturating_sub(indent).max(1);
2068 let wraps = line
2069 .input_ref()
2070 .map(|text| text.wrap_breaks(field))
2071 .unwrap_or_default();
2072 let rows = if matches!(line, Line::Image { .. }) {
2073 usize::from(IMAGE_ROWS)
2074 } else {
2075 wraps.len().max(1)
2076 };
2077 let layout = LineLayout {
2078 number,
2079 wraps,
2080 start: total,
2081 rows,
2082 selection: self
2083 .char_sel_on_line(i)
2084 .or_else(|| line.input_ref().and_then(TextInput::selection_range)),
2085 selected: i == self.cursor || self.line_in_selection(i),
2086 };
2087 total = total.saturating_add(rows);
2088 layout
2089 })
2090 .collect();
2091 self.content_height = total;
2092
2093 let cursor_visual = {
2095 let layout = &layouts[self.cursor];
2096 let row_in_block = self.lines[self.cursor]
2097 .input_ref()
2098 .map(|text| text.wrap_cursor_from_breaks(&layout.wraps).0)
2099 .unwrap_or(0);
2100 layout.start.saturating_add(row_in_block)
2101 };
2102 if self.follow_cursor {
2103 if cursor_visual < self.scroll {
2104 self.scroll = cursor_visual;
2105 } else if cursor_visual >= self.scroll.saturating_add(usize::from(height)) {
2106 self.scroll = cursor_visual + 1 - usize::from(height);
2107 }
2108 }
2109 self.scroll = self.scroll.min(total.saturating_sub(usize::from(height)));
2110
2111 let mut placed = Vec::new();
2112 let mut cursor_at = None;
2113 for (i, (line, layout)) in self.lines.iter_mut().zip(&layouts).enumerate() {
2114 let Some((y, vis_rows, skip)) =
2119 visible_band(layout.start, layout.rows, self.scroll, height)
2120 else {
2121 continue;
2122 };
2123 let (text, kind) = match line {
2124 Line::Todo { text, done } => (text, TextKind::Todo { done: *done }),
2125 Line::Bullet(text) => (text, TextKind::Bullet),
2126 Line::Number(text) => (text, TextKind::Number(layout.number.unwrap_or(1))),
2127 Line::Link(text) => (text, TextKind::Link),
2128 Line::Text(text) => (text, TextKind::Plain),
2129 Line::Image { path } => {
2130 placed.push(Placed {
2131 block: Painted::Image(resolve_image_reference(
2132 path,
2133 &self.image_root,
2134 &self.attachments,
2135 )),
2136 line: i,
2137 y,
2138 rows: vis_rows,
2139 selected: layout.selected,
2140 });
2141 continue;
2142 }
2143 };
2144 let indent = kind.indent();
2145 let view = text.wrapped_from_breaks(&layout.wraps, layout.selection);
2146 if i == self.cursor {
2147 let row = usize::from(view.cursor_row).saturating_sub(skip) as u16;
2148 cursor_at = Some((y.saturating_add(row), view.cursor_col + indent as u16));
2149 }
2150 let wrap_rows: Vec<WrappedRow> = view
2151 .lines
2152 .into_iter()
2153 .skip(skip)
2154 .take(vis_rows as usize)
2155 .map(|l| WrappedRow {
2156 text: l.text,
2157 sel: l.sel_cols,
2158 })
2159 .collect();
2160 placed.push(Placed {
2161 block: Painted::Text {
2162 rows: wrap_rows,
2163 kind,
2164 },
2165 line: i,
2166 y,
2167 rows: vis_rows,
2168 selected: layout.selected,
2169 });
2170 }
2171 (placed, cursor_at)
2172 }
2173
2174 pub fn scroll(&self) -> usize {
2176 self.scroll
2177 }
2178
2179 pub fn content_height(&self) -> usize {
2181 self.content_height
2182 }
2183
2184 pub fn scroll_by(&mut self, rows: isize, viewport_height: usize) -> bool {
2188 if rows == 0 || viewport_height == 0 {
2189 return false;
2190 }
2191 let max_scroll = self.content_height.saturating_sub(viewport_height);
2192 let next = if rows.is_negative() {
2193 self.scroll.saturating_sub(rows.unsigned_abs())
2194 } else {
2195 self.scroll.saturating_add(rows as usize).min(max_scroll)
2196 };
2197 if next == self.scroll {
2198 return false;
2199 }
2200 self.scroll = next;
2201 self.follow_cursor = false;
2202 true
2203 }
2204
2205 pub fn click(&mut self, row: u16, col: usize) -> bool {
2209 self.follow_cursor = true;
2210 let width = self.layout_width.max(1);
2211 let numbers = number_runs(&self.lines);
2212 let target = self.scroll.saturating_add(usize::from(row));
2213 let mut at = 0usize;
2214 let mut hit = false;
2215 for (i, line) in self.lines.iter().enumerate() {
2216 let h = line.height(width, numbers[i]);
2217 if target < at.saturating_add(h) {
2218 self.cursor = i;
2219 let row_in = target.saturating_sub(at);
2220 let indent = numbers[i]
2221 .map(number_indent)
2222 .unwrap_or_else(|| line.indent());
2223 let field = width.saturating_sub(indent).max(1);
2224 if let Some(input) = self.lines[i].input() {
2225 input.set_cursor_from_wrap(field, row_in, col.saturating_sub(indent));
2226 }
2227 hit = true;
2228 break;
2229 }
2230 at = at.saturating_add(h);
2231 }
2232 self.close_menu();
2233 self.prefer_col = u16::MAX;
2234 self.clear_description_selection();
2235 hit
2236 }
2237}
2238
2239#[cfg(test)]
2240mod tests {
2241 use super::*;
2242
2243 fn editor(blocks: &[Block]) -> DescriptionEditor {
2244 DescriptionEditor::new(blocks)
2245 }
2246
2247 fn type_in(editor: &mut DescriptionEditor, text: &str) {
2248 for c in text.chars() {
2249 editor.insert(c);
2250 }
2251 }
2252
2253 #[test]
2254 fn types_prose_and_splits_lines() {
2255 let mut e = editor(&[]);
2256 type_in(&mut e, "first");
2257 e.newline();
2258 type_in(&mut e, "second");
2259 assert_eq!(e.value(), vec![Block::text("first"), Block::text("second")]);
2260 }
2261
2262 #[test]
2263 fn description_value_round_trips_blank_rows_around_content() {
2264 let mut e = editor(&[]);
2265 assert!(e.newline());
2266 type_in(&mut e, "first");
2267 assert!(e.newline());
2268 assert!(e.newline());
2269 type_in(&mut e, "second");
2270 assert!(e.newline());
2271
2272 let expected = vec![
2273 Block::text(""),
2274 Block::text("first"),
2275 Block::text(""),
2276 Block::text("second"),
2277 Block::text(""),
2278 ];
2279 assert_eq!(e.value(), expected);
2280 assert_eq!(editor(&e.value()).value(), expected);
2281 assert!(editor(&[]).value().is_empty());
2282 }
2283
2284 #[test]
2285 fn refuses_more_lines_than_the_cap() {
2286 let mut e = DescriptionEditor::from_blocks(
2287 &[],
2288 2,
2289 32,
2290 false,
2291 crate::image::default_images_root(),
2292 crate::image::AttachmentCatalog::default(),
2293 );
2294 type_in(&mut e, "a");
2295 assert!(e.newline());
2296 type_in(&mut e, "b");
2297 assert!(!e.newline(), "third line blocked");
2298 assert_eq!(e.value().len(), 2);
2299 }
2300
2301 #[test]
2302 fn plain_description_uses_shorter_line_cap() {
2303 let mut e = DescriptionEditor::plain("");
2304 type_in(&mut e, &"x".repeat(MAX_CATEGORY_DESC_LINE_LEN + 10));
2305 assert_eq!(
2306 e.value()[0],
2307 Block::text(&"x".repeat(MAX_CATEGORY_DESC_LINE_LEN))
2308 );
2309 }
2310
2311 #[test]
2312 fn slash_bullet_turns_the_line_into_a_point() {
2313 let mut e = editor(&[]);
2314 type_in(&mut e, "/bul");
2315 e.apply(Command::Bullet);
2316 type_in(&mut e, "a point");
2317 assert_eq!(e.value(), vec![Block::bullet("a point")]);
2318 }
2319
2320 #[test]
2321 fn slash_number_turns_the_line_into_a_list_item() {
2322 let mut e = editor(&[]);
2323 type_in(&mut e, "/num");
2324 assert_eq!(e.menu_selected(), Some(Command::Number));
2325 e.apply(Command::Number);
2326 type_in(&mut e, "first");
2327 e.newline();
2328 type_in(&mut e, "second");
2329 assert_eq!(
2330 e.value(),
2331 vec![Block::number("first"), Block::number("second")]
2332 );
2333 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2334 }
2335
2336 #[test]
2337 fn typing_1_dot_space_makes_a_numbered_item() {
2338 let mut e = editor(&[]);
2339 type_in(&mut e, "1. ");
2340 type_in(&mut e, "alpha");
2341 assert_eq!(e.value(), vec![Block::number("alpha")]);
2342 }
2343
2344 #[test]
2345 fn slash_link_turns_the_line_into_a_url() {
2346 let mut e = editor(&[Block::text("https://example.com")]);
2347 e.end();
2348 type_in(&mut e, "/link");
2349 assert_eq!(e.menu_selected(), Some(Command::Link));
2350 e.apply(Command::Link);
2351 assert_eq!(e.value(), vec![Block::link("https://example.com")]);
2352 }
2353
2354 #[test]
2355 fn slash_link_unwraps_markdown() {
2356 let mut e = editor(&[Block::text("[docs](https://example.com/docs)")]);
2357 e.end();
2358 type_in(&mut e, "/link");
2359 e.apply(Command::Link);
2360 assert_eq!(e.value(), vec![Block::link("https://example.com/docs")]);
2361 }
2362
2363 #[test]
2364 fn slash_link_on_empty_line_is_ready_for_a_url() {
2365 let mut e = editor(&[]);
2366 type_in(&mut e, "/link");
2367 e.apply(Command::Link);
2368 type_in(&mut e, "https://x.ai");
2369 assert_eq!(e.value(), vec![Block::link("https://x.ai")]);
2370 }
2371
2372 #[test]
2373 fn slash_todo_turns_the_line_into_a_task() {
2374 let mut e = editor(&[]);
2375 type_in(&mut e, "/todo");
2376 let menu = e.menu.as_ref().expect("menu is open");
2377 assert_eq!(menu.query, "todo");
2378 assert_eq!(e.menu_selected(), Some(Command::Todo));
2379 e.apply(Command::Todo);
2380 type_in(&mut e, "buy milk");
2381 assert_eq!(e.value(), vec![Block::todo("buy milk", false)]);
2382 assert!(e.menu.is_none());
2383 }
2384
2385 #[test]
2386 fn enter_in_a_todo_starts_an_unchecked_todo() {
2387 let mut e = editor(&[Block::todo("one", true)]);
2388 e.end();
2389 e.newline();
2390 type_in(&mut e, "two");
2391 assert_eq!(
2392 e.value(),
2393 vec![Block::todo("one", true), Block::todo("two", false)]
2394 );
2395 }
2396
2397 #[test]
2398 fn enter_in_a_bullet_continues_the_list() {
2399 let mut e = editor(&[Block::bullet("first")]);
2400 e.end();
2401 e.newline();
2402 type_in(&mut e, "second");
2403
2404 assert_eq!(
2405 e.value(),
2406 vec![Block::bullet("first"), Block::bullet("second")]
2407 );
2408 }
2409
2410 #[test]
2411 fn enter_splits_a_numbered_item_into_the_continued_list() {
2412 let mut e = editor(&[Block::number("firstsecond")]);
2413 e.home();
2414 for _ in 0..5 {
2415 e.right();
2416 }
2417 e.newline();
2418
2419 assert_eq!(
2420 e.value(),
2421 vec![Block::number("first"), Block::number("second")]
2422 );
2423 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2424 }
2425
2426 #[test]
2427 fn enter_on_an_empty_list_item_returns_to_plain_text() {
2428 let mut e = editor(&[]);
2429 type_in(&mut e, "- first");
2430 e.newline();
2431 e.newline();
2432 type_in(&mut e, "plain");
2433
2434 assert_eq!(
2435 e.value(),
2436 vec![Block::bullet("first"), Block::text("plain")]
2437 );
2438 }
2439
2440 #[test]
2441 fn backspace_at_the_start_unmakes_a_todo() {
2442 let mut e = editor(&[Block::todo("one", false)]);
2443 e.home();
2444 e.backspace();
2445 assert_eq!(e.value(), vec![Block::text("one")]);
2446 }
2447
2448 #[test]
2449 fn moving_the_cursor_closes_the_description_command_menu() {
2450 let mut editor = DescriptionEditor::new(&[]);
2451 for c in "/todo".chars() {
2452 editor.insert(c);
2453 }
2454 assert!(editor.menu.is_some());
2455
2456 editor.left();
2457
2458 assert!(
2459 editor.menu.is_none(),
2460 "the cached query must never outlive its caret range"
2461 );
2462 assert_eq!(editor.value(), vec![Block::text("/todo")]);
2463 }
2464
2465 #[test]
2466 fn link_hit_testing_excludes_blank_row_padding() {
2467 let mut editor = DescriptionEditor::new(&[Block::link("https://example.com")]);
2468 let _ = editor.layout(40, 4);
2469
2470 assert_eq!(
2471 editor.link_url_at_position(0, 3).as_deref(),
2472 Some("https://example.com")
2473 );
2474 assert_eq!(editor.link_url_at_position(0, 39), None);
2475 }
2476
2477 #[test]
2478 fn toggling_counts_towards_progress() {
2479 let mut e = editor(&[Block::todo("a", false), Block::todo("b", false)]);
2480 assert_eq!(e.progress(), (0, 2));
2481 e.toggle();
2482 assert_eq!(e.progress(), (1, 2));
2483 }
2484
2485 #[test]
2486 fn an_image_lands_between_the_lines_with_room_to_type() {
2487 let mut e = editor(&[]);
2488 type_in(&mut e, "before");
2489 e.newline();
2490 e.insert_block(Block::image("/tmp/a.png"));
2491 type_in(&mut e, "after");
2492 assert_eq!(
2493 e.value(),
2494 vec![
2495 Block::text("before"),
2496 Block::image("/tmp/a.png"),
2497 Block::text("after")
2498 ]
2499 );
2500 assert_eq!(e.images().len(), 1);
2501 }
2502
2503 #[test]
2504 fn a_path_in_the_description_becomes_a_picture() {
2505 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2507 let e = editor(&[Block::text(path), Block::text("below")]);
2508 assert!(matches!(e.value()[0], Block::Image { .. }));
2509 assert_eq!(e.value()[1], Block::text("below"));
2510 }
2511
2512 #[test]
2513 fn a_path_to_nothing_stays_text() {
2514 let e = editor(&[Block::text("/tmp/not-here-at-all.png")]);
2515 assert!(matches!(e.value()[0], Block::Text { .. }));
2516 }
2517
2518 #[test]
2519 fn a_pasted_path_is_a_picture_at_once() {
2520 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2521 let mut e = editor(&[]);
2522 e.insert_str(path);
2523 assert!(
2524 matches!(e.value()[0], Block::Image { .. }),
2525 "no need to move the cursor off it first"
2526 );
2527 type_in(&mut e, "and on we go");
2528 assert_eq!(e.value()[1], Block::text("and on we go"));
2529 }
2530
2531 #[test]
2532 fn a_complete_path_becomes_a_picture_even_under_the_cursor() {
2533 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2534 let mut e = editor(&[]);
2535 type_in(&mut e, path);
2536 e.layout(40, 20);
2537 assert!(
2538 matches!(e.value()[0], Block::Image { .. }),
2539 "complete existing path converts without leaving the line"
2540 );
2541 }
2542
2543 #[test]
2544 fn shift_option_left_selects_across_lines() {
2545 let mut e = editor(&[Block::text("one two"), Block::text("three four")]);
2546 e.down();
2547 e.end(); e.select_word_left(); assert_eq!(e.selected_text().as_deref(), Some("four"));
2550 e.select_word_left(); e.select_word_left(); let sel = e.selected_text().expect("cross-line selection");
2553 assert!(
2554 sel.contains("two") && sel.contains("three"),
2555 "expected multi-line sel, got {sel:?}"
2556 );
2557 }
2558
2559 #[test]
2560 fn shift_selection_includes_pictures_with_text() {
2561 let path = "/tmp/shot.png";
2562 let mut e = editor(&[
2563 Block::text("above"),
2564 Block::image(path),
2565 Block::text("below here"),
2566 ]);
2567 e.down();
2569 e.down();
2570 e.home();
2571 e.select_word_left(); assert!(e.line_in_selection(1), "picture covered by selection");
2573 assert!(
2574 matches!(e.selected_payload(), Some(CopyPayload::All(_))),
2575 "mixed selection is rich copy"
2576 );
2577 e.select_word_left(); let sel = e.selected_text().expect("text+image selection");
2579 assert!(
2580 sel.contains("above") && sel.contains("[image:") && !sel.contains("below"),
2581 "got {sel:?}"
2582 );
2583 let (placed, _) = e.layout(40, 40);
2585 let img = placed
2586 .iter()
2587 .find(|p| matches!(p.block, Painted::Image(_)))
2588 .expect("image placed");
2589 assert!(img.selected, "outer frame while selection covers image");
2590 }
2591
2592 #[test]
2593 fn a_pasted_path_broken_by_newlines_still_becomes_a_picture() {
2594 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2595 let mid = path.len() / 2;
2597 let broken = format!("{}\n{}", &path[..mid], &path[mid..]);
2598 let mut e = editor(&[]);
2599 e.insert_str(&broken);
2600 assert!(
2601 matches!(e.value()[0], Block::Image { .. }),
2602 "flattened paste: {broken:?} → {:?}",
2603 e.value()
2604 );
2605 }
2606
2607 #[test]
2608 fn two_text_lines_that_form_a_path_are_merged() {
2609 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2610 let mid = path.len() / 2;
2611 let e = editor(&[Block::text(&path[..mid]), Block::text(&path[mid..])]);
2612 assert!(
2613 matches!(e.value()[0], Block::Image { .. }),
2614 "split lines rejoin: {:?}",
2615 e.value()
2616 );
2617 }
2618
2619 #[test]
2620 fn dash_and_a_space_make_a_bullet() {
2621 let mut e = editor(&[]);
2622 type_in(&mut e, "- buy milk");
2623 assert_eq!(e.value(), vec![Block::bullet("buy milk")]);
2624 }
2625
2626 #[test]
2627 fn a_dash_mid_line_is_just_a_dash() {
2628 let mut e = editor(&[]);
2629 type_in(&mut e, "a - b");
2630 assert_eq!(e.value(), vec![Block::text("a - b")]);
2631 }
2632
2633 #[test]
2634 fn backspace_at_the_start_unmakes_a_bullet() {
2635 let mut e = editor(&[Block::bullet("one")]);
2636 e.home();
2637 e.backspace();
2638 assert_eq!(e.value(), vec![Block::text("one")]);
2639 }
2640
2641 #[test]
2642 fn plain_text_round_trips_its_bullets() {
2643 let e = DescriptionEditor::plain("intro\n- first\n- second");
2644 assert_eq!(e.value().len(), 3);
2645 assert_eq!(e.plain_value(), "intro\n- first\n- second");
2646 }
2647
2648 #[test]
2649 fn plain_description_round_trips_blank_rows() {
2650 let description = "\nfirst\n\nsecond\n";
2651 let e = DescriptionEditor::plain(description);
2652
2653 assert_eq!(e.plain_value(), description);
2654 assert_eq!(
2655 DescriptionEditor::plain(&e.plain_value()).plain_value(),
2656 description
2657 );
2658 }
2659
2660 #[test]
2661 fn plain_mode_rejects_todo_slash_command() {
2662 let mut e = DescriptionEditor::plain("");
2663 type_in(&mut e, "/todo");
2664 assert!(e.menu.is_none(), "no to-do command in plain mode");
2665 assert_eq!(e.value(), vec![Block::text("/todo")]);
2666 }
2667
2668 #[test]
2669 fn plain_mode_slash_makes_a_bullet() {
2670 let mut e = DescriptionEditor::plain("");
2671 type_in(&mut e, "/bul");
2672 assert_eq!(e.menu_selected(), Some(Command::Bullet));
2673 e.apply(Command::Bullet);
2674 type_in(&mut e, "a point");
2675 assert_eq!(e.plain_value(), "- a point");
2676 }
2677
2678 #[test]
2679 fn plain_mode_supports_numbered_lists_and_links_but_not_images() {
2680 let mut numbered = DescriptionEditor::plain("");
2681 type_in(&mut numbered, "/num");
2682 assert_eq!(numbered.menu_selected(), Some(Command::Number));
2683 numbered.apply(Command::Number);
2684 type_in(&mut numbered, "first");
2685 assert_eq!(numbered.plain_value(), "1. first");
2686 assert!(matches!(
2687 DescriptionEditor::plain(&numbered.plain_value()).value().as_slice(),
2688 [Block::Number { text }] if text == "first"
2689 ));
2690
2691 let mut link = DescriptionEditor::plain("https://example.com");
2692 link.end();
2693 type_in(&mut link, "/link");
2694 assert_eq!(link.menu_selected(), Some(Command::Link));
2695 link.apply(Command::Link);
2696 assert!(matches!(link.value().as_slice(), [Block::Link { .. }]));
2697 assert!(matches!(
2698 DescriptionEditor::plain(&link.plain_value()).value().as_slice(),
2699 [Block::Link { url }] if url == "https://example.com"
2700 ));
2701
2702 let mut bare_link = DescriptionEditor::plain("example.com");
2703 bare_link.end();
2704 type_in(&mut bare_link, "/link");
2705 bare_link.apply(Command::Link);
2706 assert_eq!(bare_link.plain_value(), "[link](example.com)");
2707 assert!(matches!(
2708 DescriptionEditor::plain(&bare_link.plain_value())
2709 .value()
2710 .as_slice(),
2711 [Block::Link { url }] if url == "example.com"
2712 ));
2713
2714 assert!(
2715 !DescriptionEditor::plain("")
2716 .allowed_commands()
2717 .contains(&Command::CopyImage)
2718 );
2719 assert!(
2720 !DescriptionEditor::plain("")
2721 .allowed_commands()
2722 .contains(&Command::CopyAll)
2723 );
2724 }
2725
2726 #[test]
2727 fn plain_description_does_not_adopt_image_files() {
2728 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2729 let mut editor = DescriptionEditor::plain(path);
2730
2731 assert_eq!(editor.value(), vec![Block::text(path)]);
2732 assert!(!editor.insert_block(Block::image(path)));
2733 assert_eq!(editor.value(), vec![Block::text(path)]);
2734 }
2735
2736 #[test]
2737 fn plain_description_slash_paste_requests_clipboard_content() {
2738 let mut e = DescriptionEditor::plain("before");
2739 e.end();
2740 e.newline();
2741 type_in(&mut e, "/paste");
2742
2743 assert_eq!(e.menu_selected(), Some(Command::Paste));
2744 assert!(matches!(
2745 e.apply(Command::Paste),
2746 Some(CommandRequest::Paste)
2747 ));
2748 e.insert_str("clipboard\n- item");
2749 assert_eq!(e.plain_value(), "before\nclipboard\n- item");
2750 }
2751
2752 #[test]
2753 fn copy_exports_text_and_skips_images() {
2754 let e = editor(&[
2755 Block::text("hello"),
2756 Block::todo("one", true),
2757 Block::image("/tmp/a.png"),
2758 Block::bullet("point"),
2759 ]);
2760 assert_eq!(e.text_for_copy(), "hello\n[✓] one\n- point");
2761 }
2762
2763 #[test]
2764 fn copy_all_keeps_text_and_images_in_order() {
2765 let e = editor(&[
2766 Block::text("hello"),
2767 Block::image("/tmp/a.png"),
2768 Block::bullet("point"),
2769 ]);
2770 let lines = e.lines_for_copy_all();
2771 assert_eq!(lines.len(), 3);
2772 match &lines[0] {
2773 CopyLine::Text(t) => assert_eq!(t, "hello"),
2774 _ => panic!("text first"),
2775 }
2776 match &lines[1] {
2777 CopyLine::Image(p) => assert!(p.ends_with("a.png")),
2778 _ => panic!("image second"),
2779 }
2780 match &lines[2] {
2781 CopyLine::Text(t) => assert_eq!(t, "- point"),
2782 _ => panic!("bullet third"),
2783 }
2784 }
2785
2786 #[test]
2787 fn slash_copy_strips_the_query_and_returns_text() {
2788 let mut e = editor(&[Block::text("keep me")]);
2789 e.end();
2790 e.newline();
2791 type_in(&mut e, "/copy");
2792 assert_eq!(e.menu_selected(), Some(Command::Copy));
2793 match e.apply(Command::Copy).expect("copy yields request") {
2794 CommandRequest::Copy(CopyPayload::Text(text)) => assert_eq!(text, "keep me"),
2795 CommandRequest::Copy(CopyPayload::Image(_) | CopyPayload::All(_))
2796 | CommandRequest::Paste => panic!("expected text copy"),
2797 }
2798 assert!(e.menu.is_none());
2799 assert_eq!(e.value(), vec![Block::text("keep me")]);
2801 }
2802
2803 #[test]
2804 fn description_slash_paste_requests_clipboard_content() {
2805 let mut e = editor(&[Block::text("before")]);
2806 e.end();
2807 e.newline();
2808 type_in(&mut e, "/pas");
2809
2810 assert_eq!(e.menu_selected(), Some(Command::Paste));
2811 assert!(matches!(
2812 e.apply(Command::Paste),
2813 Some(CommandRequest::Paste)
2814 ));
2815 e.insert_str("clipboard\n- item");
2816 assert_eq!(
2817 e.value(),
2818 vec![
2819 Block::text("before"),
2820 Block::text("clipboard"),
2821 Block::text("- item")
2822 ]
2823 );
2824 }
2825
2826 #[test]
2827 fn image_for_copy_picks_nearest_above_the_cursor() {
2828 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2829 let mut e = editor(&[
2830 Block::text("above"),
2831 Block::image(path),
2832 Block::text("below"),
2833 ]);
2834 e.down();
2836 e.down();
2837 let got = e.image_for_copy().expect("finds the image above");
2838 assert!(got.ends_with("screenshot.png"));
2839 }
2840
2841 #[test]
2842 fn slash_image_returns_the_picture_path() {
2843 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2844 let mut e = editor(&[Block::image(path), Block::text("")]);
2845 e.down();
2846 type_in(&mut e, "/img");
2847 assert_eq!(e.menu_selected(), Some(Command::CopyImage));
2848 match e.apply(Command::CopyImage).expect("image request") {
2849 CommandRequest::Copy(CopyPayload::Image(p)) => {
2850 assert!(p.ends_with("screenshot.png"))
2851 }
2852 CommandRequest::Copy(CopyPayload::Text(_) | CopyPayload::All(_))
2853 | CommandRequest::Paste => panic!("expected image copy"),
2854 }
2855 }
2856
2857 #[test]
2858 fn a_picture_is_deleted_by_backspace() {
2859 let mut e = editor(&[Block::text("a"), Block::image("/tmp/a.png")]);
2860 e.down();
2861 e.backspace();
2862 assert_eq!(e.value(), vec![Block::text("a")]);
2863 }
2864
2865 #[test]
2866 fn the_menu_filters_by_abbreviation() {
2867 let mut e = editor(&[]);
2868 type_in(&mut e, "/che");
2869 assert_eq!(e.menu_selected(), Some(Command::Todo));
2870 }
2871
2872 #[test]
2873 fn the_menu_closes_when_nothing_matches() {
2874 let mut e = editor(&[]);
2875 type_in(&mut e, "/zz");
2876 assert!(e.menu.is_none());
2877 assert_eq!(e.value(), vec![Block::text("/zz")], "the text is kept");
2878 }
2879
2880 #[test]
2881 fn the_menu_closes_when_the_slash_is_removed() {
2882 let mut e = editor(&[]);
2883 type_in(&mut e, "/to");
2884 assert!(e.menu.is_some());
2885 e.backspace();
2886 e.backspace();
2887 assert!(e.menu.is_some());
2888 e.backspace();
2889 assert!(e.menu.is_none(), "removing the slash closes the menu");
2890 }
2891
2892 #[test]
2893 fn right_on_sole_image_inserts_text_line() {
2894 let blocks = [Block::image("/tmp/x.png")];
2895 let mut e = editor(&blocks);
2896 assert!(matches!(e.lines[0], Line::Image { .. }));
2897 assert_eq!(e.cursor, 0);
2898 e.right();
2899 assert_eq!(e.lines.len(), 2, "should insert a text line");
2900 assert_eq!(e.cursor, 1);
2901 assert!(matches!(e.lines[1], Line::Text(_)));
2902 assert!(e.input().is_some());
2903 }
2904
2905 #[test]
2906 fn left_on_first_image_inserts_text_line_above() {
2907 let blocks = [Block::image("/tmp/x.png")];
2908 let mut e = editor(&blocks);
2909 e.left();
2910 assert_eq!(e.lines.len(), 2);
2911 assert_eq!(e.cursor, 0);
2912 assert!(matches!(e.lines[0], Line::Text(_)));
2913 assert!(matches!(e.lines[1], Line::Image { .. }));
2914 }
2915
2916 #[test]
2917 fn visible_band_clips_top_like_bottom() {
2918 assert_eq!(visible_band(5, 10, 8, 10), Some((0, 7, 3)));
2921 assert_eq!(visible_band(0, 5, 8, 10), None);
2923 assert_eq!(visible_band(20, 5, 8, 10), None);
2924 assert_eq!(visible_band(8, 10, 0, 12), Some((8, 4, 0)));
2926 }
2927
2928 #[test]
2929 fn consecutive_images_shrink_when_scrolled_off_the_top() {
2930 let path = "/tmp/a.png";
2931 let mut e = editor(&[Block::image(path), Block::image(path), Block::text("tail")]);
2932 e.layout(40, 30);
2934 assert!(e.click(20, 0));
2935 assert_eq!(e.cursor_line(), 2);
2936
2937 let (placed, _) = e.layout(40, 12);
2938 assert_eq!(e.scroll(), 9);
2940 let imgs: Vec<_> = placed
2941 .iter()
2942 .filter(|p| matches!(p.block, Painted::Image(_)))
2943 .collect();
2944 assert_eq!(imgs.len(), 2);
2945 assert_eq!((imgs[0].y, imgs[0].rows), (0, 1));
2947 assert_eq!((imgs[1].y, imgs[1].rows), (1, 10));
2949 assert_eq!(imgs[0].y + imgs[0].rows, imgs[1].y);
2951 }
2952
2953 #[test]
2954 fn manual_scroll_is_bounded_without_moving_the_caret() {
2955 let blocks = (0..10)
2956 .map(|index| Block::text(&format!("line {index}")))
2957 .collect::<Vec<_>>();
2958 let mut e = editor(&blocks);
2959 let (_, cursor) = e.layout(20, 3);
2960 assert_eq!(cursor.map(|(row, _)| row), Some(0));
2961
2962 assert!(e.scroll_by(isize::MAX, 3));
2963 let (_, cursor) = e.layout(20, 3);
2964 assert_eq!(e.scroll(), 7, "wheel scrolling clamps at the last page");
2965 assert_eq!(
2966 e.cursor_line(),
2967 0,
2968 "wheel scrolling does not move the caret"
2969 );
2970 assert_eq!(cursor, None, "an off-screen caret is hidden");
2971
2972 assert!(e.scroll_by(-6, 3));
2973 assert_eq!(e.scroll(), 1);
2974 e.down();
2975 let (_, cursor) = e.layout(20, 3);
2976 assert_eq!(e.scroll(), 1);
2977 assert_eq!(
2978 cursor.map(|(row, _)| row),
2979 Some(0),
2980 "keyboard navigation resumes caret follow"
2981 );
2982 }
2983
2984 #[test]
2985 fn narrow_maximum_description_keeps_the_last_row_addressable() {
2986 let line = "x".repeat(MAX_NOTES_LINE_LEN);
2987 let blocks = vec![Block::text(&line); MAX_DESCRIPTION_LINES];
2988 let mut e = editor(&blocks);
2989 e.cursor = e.lines.len() - 1;
2990 e.input().unwrap().end();
2991
2992 let (_, cursor) = e.layout(1, 10);
2993
2994 let expected_height = MAX_DESCRIPTION_LINES * MAX_NOTES_LINE_LEN;
2995 assert_eq!(e.content_height() as usize, expected_height);
2996 assert_eq!(e.scroll() as usize, expected_height - 10);
2997 assert_eq!(cursor, Some((9, 1)));
2998 assert!(e.click(0, 0));
2999 assert_eq!(e.cursor_line(), MAX_DESCRIPTION_LINES - 1);
3000 }
3001
3002 #[test]
3003 fn line_join_at_the_length_limit_never_discards_the_next_line() {
3004 let full = "a".repeat(MAX_NOTES_LINE_LEN);
3005
3006 let mut backward = editor(&[Block::text(&full), Block::text("tail")]);
3007 backward.cursor = 1;
3008 backward.input().unwrap().home();
3009 backward.backspace();
3010 assert_eq!(
3011 backward.value(),
3012 vec![Block::text(&full), Block::text("tail")]
3013 );
3014
3015 let mut forward = editor(&[Block::text(&full), Block::text("tail")]);
3016 forward.input().unwrap().end();
3017 forward.delete();
3018 assert_eq!(
3019 forward.value(),
3020 vec![Block::text(&full), Block::text("tail")]
3021 );
3022 }
3023
3024 #[test]
3025 fn oversized_cross_line_selection_replacement_is_rejected_without_data_loss() {
3026 let full = "a".repeat(MAX_NOTES_LINE_LEN);
3027 let mut editor = editor(&[Block::text(&full), Block::text("tail")]);
3028 editor.sel_anchor = Some((0, MAX_NOTES_LINE_LEN));
3029 editor.cursor = 1;
3030 editor.input().unwrap().home();
3031
3032 editor.insert('x');
3033
3034 assert_eq!(
3035 editor.value(),
3036 vec![Block::text(&full), Block::text("tail")]
3037 );
3038 }
3039
3040 #[test]
3041 fn oversized_image_path_detection_never_discards_a_source_line() {
3042 let root = std::env::temp_dir().join(format!(
3043 "mach-long-image-path-test-{}",
3044 uuid::Uuid::new_v4()
3045 ));
3046 let first_dir = "a".repeat(240);
3047 let second_dir = "b".repeat(240);
3048 let file = format!("{}.png", "c".repeat(40));
3049 let relative = format!("{first_dir}/{second_dir}/{file}");
3050 assert!(relative.graphemes(true).count() > MAX_NOTES_LINE_LEN);
3051 std::fs::create_dir_all(root.join(&first_dir).join(&second_dir)).unwrap();
3052 std::fs::write(root.join(&relative), []).unwrap();
3053
3054 let split = relative.len() / 2;
3055 let (left, right) = relative.split_at(split);
3056 let expected = vec![Block::text(left), Block::text(right)];
3057 let mut loaded = editor(&expected);
3058 loaded.set_image_root(root.clone());
3059 assert_eq!(loaded.value(), expected);
3060
3061 let mut pasted = editor(&[]);
3062 pasted.set_image_root(root.clone());
3063 pasted.insert_str(&format!("{left}\n{right}"));
3064 assert_eq!(pasted.value(), expected);
3065
3066 std::fs::remove_dir_all(root).unwrap();
3067 }
3068}