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 pub menu: Option<SlashMenu>,
432 plain: bool,
434 max_lines: usize,
436 line_max_len: usize,
438 layout_width: usize,
440 content_height: usize,
442 prefer_col: u16,
444 sel_anchor: Option<(usize, usize)>,
447 image_root: PathBuf,
448 attachments: crate::image::AttachmentCatalog,
449}
450
451impl DescriptionEditor {
452 pub fn new(blocks: &[Block]) -> Self {
453 Self::new_with_images(blocks, crate::image::default_images_root(), &[])
454 }
455
456 pub fn new_with_images(
457 blocks: &[Block],
458 image_root: PathBuf,
459 attachments: &[crate::store::Attachment],
460 ) -> Self {
461 let mut catalog = crate::image::AttachmentCatalog::default();
462 catalog.set(attachments);
463 Self::from_blocks(
464 blocks,
465 MAX_DESCRIPTION_LINES,
466 MAX_NOTES_LINE_LEN,
467 false,
468 image_root,
469 catalog,
470 )
471 }
472
473 pub fn plain(text: &str) -> Self {
476 let blocks: Vec<Block> = text.split('\n').map(plain_line_to_block).collect();
477 Self::from_blocks(
478 &blocks,
479 MAX_CATEGORY_DESC_LINES,
480 MAX_CATEGORY_DESC_LINE_LEN,
481 true,
482 crate::image::default_images_root(),
483 crate::image::AttachmentCatalog::default(),
484 )
485 }
486
487 fn from_blocks(
488 blocks: &[Block],
489 max_lines: usize,
490 line_max_len: usize,
491 plain: bool,
492 image_root: PathBuf,
493 attachments: crate::image::AttachmentCatalog,
494 ) -> Self {
495 let mut lines: Vec<Line> = blocks
496 .iter()
497 .map(|b| line_from_block(b, line_max_len))
498 .collect();
499 if lines.is_empty() {
500 lines.push(Line::Text(TextInput::new("", line_max_len)));
501 }
502 let mut editor = Self {
503 lines,
504 cursor: 0,
505 scroll: 0,
506 menu: None,
507 plain,
508 max_lines,
509 line_max_len,
510 layout_width: 40,
511 content_height: 0,
512 prefer_col: u16::MAX,
513 sel_anchor: None,
514 image_root,
515 attachments,
516 };
517 if !plain {
518 editor.adopt_pasted_paths();
520 }
521 editor
522 }
523
524 fn can_add_lines(&self, n: usize) -> bool {
525 self.lines.len().saturating_add(n) <= self.max_lines
526 }
527
528 fn line_text_fits(&self, text: &str) -> bool {
529 text.len() <= crate::model::text_byte_limit(self.line_max_len)
530 && text.graphemes(true).count() <= self.line_max_len
531 }
532
533 pub fn set_image_root(&mut self, image_root: PathBuf) {
534 self.image_root = image_root;
535 if !self.plain {
536 self.adopt_pasted_paths();
537 }
538 }
539
540 pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
541 self.attachments.set(attachments);
542 if !self.plain {
543 self.adopt_pasted_paths();
544 }
545 }
546
547 pub fn image_root(&self) -> &std::path::Path {
548 &self.image_root
549 }
550
551 fn empty_line(&self) -> Line {
552 Line::Text(TextInput::new("", self.line_max_len))
553 }
554
555 pub fn allowed_commands(&self) -> &'static [Command] {
557 if self.plain {
558 &Command::PLAIN
559 } else {
560 &Command::ALL
561 }
562 }
563
564 pub fn menu_commands(&self) -> Vec<Command> {
566 self.menu
567 .as_ref()
568 .map(|m| m.matches_in(self.allowed_commands()))
569 .unwrap_or_default()
570 }
571
572 pub fn command_hint(&self, command: Command) -> &'static str {
573 command.hint(self.plain)
574 }
575
576 pub fn plain_value(&self) -> String {
578 let mut n = 0usize;
579 self.value()
580 .iter()
581 .filter_map(|b| match b {
582 Block::Text { text } => {
583 n = 0;
584 Some(text.clone())
585 }
586 Block::Bullet { text } => {
587 n = 0;
588 Some(format!("- {text}"))
589 }
590 Block::Number { text } => {
591 n += 1;
592 Some(format!("{n}. {text}"))
593 }
594 Block::Link { url } => {
595 n = 0;
596 Some(plain_link_value(url))
597 }
598 _ => {
599 n = 0;
600 None
601 }
602 })
603 .collect::<Vec<_>>()
604 .join("\n")
605 }
606
607 pub fn value(&self) -> Vec<Block> {
610 if self.is_empty() {
611 return Vec::new();
612 }
613 self.lines
614 .iter()
615 .filter_map(|line| match line {
616 Line::Text(text) => Some(Block::text(text.value().trim_end())),
617 Line::Todo { text, done } => {
618 block_from_input(text, |value| Block::todo(value, *done))
619 }
620 Line::Bullet(text) => block_from_input(text, Block::bullet),
621 Line::Number(text) => block_from_input(text, Block::number),
622 Line::Link(text) => block_from_input(text, Block::link),
623 Line::Image { path } => Some(Block::image(path)),
624 })
625 .collect()
626 }
627
628 pub fn is_empty(&self) -> bool {
629 !self.lines.iter().any(|l| match l {
630 Line::Image { .. } => true,
631 Line::Text(t)
632 | Line::Bullet(t)
633 | Line::Number(t)
634 | Line::Link(t)
635 | Line::Todo { text: t, .. } => !t.value().trim().is_empty(),
636 })
637 }
638
639 pub fn progress(&self) -> (usize, usize) {
640 let mut done = 0usize;
641 let mut total = 0usize;
642 for l in &self.lines {
643 if let Line::Todo { text, done: d } = l
644 && !text.value().trim().is_empty()
645 {
646 total += 1;
647 if *d {
648 done += 1;
649 }
650 }
651 }
652 (done, total)
653 }
654
655 pub fn cursor_line(&self) -> usize {
657 self.cursor
658 }
659
660 pub fn has_selection(&self) -> bool {
662 if self.ordered_selection().is_some() {
663 return true;
664 }
665 self.lines[self.cursor]
666 .input_ref()
667 .is_some_and(|t| t.has_selection())
668 }
669
670 pub fn selected_text(&self) -> Option<String> {
673 if let Some(payload) = self.selected_payload() {
674 return match payload {
675 CopyPayload::Text(s) => Some(s),
676 CopyPayload::All(lines) => {
677 let s = lines
678 .into_iter()
679 .map(|l| match l {
680 CopyLine::Text(t) | CopyLine::Link(t) => t,
681 CopyLine::Image(p) => format!("[image: {}]", p.display()),
682 })
683 .collect::<Vec<_>>()
684 .join("\n");
685 (!s.is_empty()).then_some(s)
686 }
687 CopyPayload::Image(p) => Some(format!("[image: {}]", p.display())),
688 };
689 }
690 None
691 }
692
693 pub fn selected_payload(&self) -> Option<CopyPayload> {
695 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
696 let lines = self.copy_lines_between(al, ac, bl, bc);
697 if lines.is_empty() {
698 return None;
699 }
700 if lines.iter().any(|l| matches!(l, CopyLine::Image(_))) {
701 return Some(CopyPayload::All(lines));
702 }
703 let text = lines
704 .into_iter()
705 .filter_map(|l| match l {
706 CopyLine::Text(t) | CopyLine::Link(t) => Some(t),
707 CopyLine::Image(_) => None,
708 })
709 .collect::<Vec<_>>()
710 .join("\n");
711 return (!text.is_empty()).then_some(CopyPayload::Text(text));
712 }
713 match &self.lines[self.cursor] {
714 Line::Text(t)
715 | Line::Todo { text: t, .. }
716 | Line::Bullet(t)
717 | Line::Number(t)
718 | Line::Link(t) => t.selected_text().map(CopyPayload::Text),
719 Line::Image { path } => Some(CopyPayload::Image(resolve_image_reference(
720 path,
721 &self.image_root,
722 &self.attachments,
723 ))),
724 }
725 }
726
727 fn caret(&self) -> (usize, usize) {
728 let col = self.lines[self.cursor]
729 .input_ref()
730 .map(|i| i.cursor())
731 .unwrap_or(0);
732 (self.cursor, col)
733 }
734
735 fn ordered_selection(&self) -> Option<((usize, usize), (usize, usize))> {
736 let a = self.sel_anchor?;
737 let b = self.caret();
738 if a == b {
739 return None;
740 }
741 Some(if (a.0, a.1) <= (b.0, b.1) {
742 (a, b)
743 } else {
744 (b, a)
745 })
746 }
747
748 fn line_char_len(&self, i: usize) -> usize {
749 self.lines[i].input_ref().map(|t| t.len()).unwrap_or(0)
750 }
751
752 fn line_text_value(&self, i: usize) -> String {
753 self.lines[i]
754 .input_ref()
755 .map(|t| t.value())
756 .unwrap_or_default()
757 }
758
759 fn is_image_line(&self, i: usize) -> bool {
760 matches!(self.lines.get(i), Some(Line::Image { .. }))
761 }
762
763 pub fn line_in_selection(&self, i: usize) -> bool {
767 let Some(((al, _), (bl, _))) = self.ordered_selection() else {
768 return false;
769 };
770 if i < al || i > bl {
771 return false;
772 }
773 if self.is_image_line(i) {
774 return al < bl;
776 }
777 self.char_sel_on_line(i).is_some()
778 }
779
780 fn copy_lines_between(&self, al: usize, ac: usize, bl: usize, bc: usize) -> Vec<CopyLine> {
781 let mut out = Vec::new();
782 if al == bl {
783 let s = self.line_text_value(al);
784 let chars: Vec<&str> = s.graphemes(true).collect();
785 let lo = ac.min(chars.len());
786 let hi = bc.min(chars.len());
787 if lo < hi {
788 out.push(CopyLine::Text(chars[lo..hi].concat()));
789 }
790 return out;
791 }
792 if let Some(line) = self.copy_line_slice(al, Some(ac), None) {
794 out.push(line);
795 }
796 for i in (al + 1)..bl {
797 if let Some(line) = self.copy_line_slice(i, None, None) {
798 out.push(line);
799 }
800 }
801 if let Some(line) = self.copy_line_slice(bl, None, Some(bc)) {
803 out.push(line);
804 }
805 out
806 }
807
808 fn copy_line_slice(
811 &self,
812 i: usize,
813 from: Option<usize>,
814 to: Option<usize>,
815 ) -> Option<CopyLine> {
816 match &self.lines[i] {
817 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
818 path,
819 &self.image_root,
820 &self.attachments,
821 ))),
822 Line::Link(t) => {
823 let s = t.value();
824 let chars: Vec<&str> = s.graphemes(true).collect();
825 let lo = from.unwrap_or(0).min(chars.len());
826 let hi = to.unwrap_or(chars.len()).min(chars.len());
827 (lo < hi).then(|| CopyLine::Link(chars[lo..hi].concat()))
828 }
829 Line::Text(t) | Line::Bullet(t) | Line::Number(t) | Line::Todo { text: t, .. } => {
830 let s = t.value();
831 let chars: Vec<&str> = s.graphemes(true).collect();
832 let lo = from.unwrap_or(0).min(chars.len());
833 let hi = to.unwrap_or(chars.len()).min(chars.len());
834 (lo < hi).then(|| CopyLine::Text(chars[lo..hi].concat()))
835 }
836 }
837 }
838
839 fn char_sel_on_line(&self, i: usize) -> Option<(usize, usize)> {
841 let ((al, ac), (bl, bc)) = self.ordered_selection()?;
842 if i < al || i > bl || self.is_image_line(i) {
843 return None;
844 }
845 let len = self.line_char_len(i);
846 if al == bl {
847 let lo = ac.min(bc).min(len);
848 let hi = ac.max(bc).min(len);
849 return (lo < hi).then_some((lo, hi));
850 }
851 if i == al {
852 let lo = ac.min(len);
853 return (lo < len || len == 0).then_some((lo, len));
854 }
855 if i == bl {
856 let hi = bc.min(len);
857 return (hi > 0).then_some((0, hi));
858 }
859 Some((0, len))
861 }
862
863 fn ensure_sel_anchor(&mut self) {
864 if self.sel_anchor.is_none() {
865 self.sel_anchor = Some(self.caret());
866 }
867 }
868
869 fn clear_description_selection(&mut self) {
870 self.sel_anchor = None;
871 for line in &mut self.lines {
872 if let Some(input) = line.input() {
873 input.clear_selection();
874 }
875 }
876 }
877
878 pub fn delete_description_selection(&mut self) -> bool {
881 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
882 if al == bl {
883 if let Some(input) = self.lines[al].input() {
886 let value = input.value();
887 let mut chars: Vec<&str> = value.graphemes(true).collect();
888 let lo = ac.min(bc).min(chars.len());
889 let hi = ac.max(bc).min(chars.len());
890 if lo < hi {
891 chars.drain(lo..hi);
892 }
893 let text = chars.concat();
894 let len = self.line_max_len;
895 self.lines[al] = self.line_with_text(al, &text, len);
896 if let Some(input) = self.lines[al].input() {
897 input.place_cursor(lo);
898 }
899 }
900 } else {
901 let start_s = if self.is_image_line(al) {
904 String::new()
905 } else {
906 self.line_text_value(al)
907 };
908 let end_s = if self.is_image_line(bl) {
909 String::new()
910 } else {
911 self.line_text_value(bl)
912 };
913 let sc: Vec<&str> = start_s.graphemes(true).collect();
914 let ec: Vec<&str> = end_s.graphemes(true).collect();
915 let ac = if self.is_image_line(al) {
916 0
917 } else {
918 ac.min(sc.len())
919 };
920 let bc = if self.is_image_line(bl) {
921 0
922 } else {
923 bc.min(ec.len())
924 };
925 let merged = format!("{}{}", sc[..ac].concat(), ec[bc..].concat());
926 if !self.line_text_fits(&merged) {
927 return false;
928 }
929 let len = self.line_max_len;
930 self.lines[al] = self.line_with_text(al, &merged, len);
931 self.lines.drain(al + 1..=bl);
932 self.cursor = al;
933 if let Some(input) = self.lines[al].input() {
934 input.place_cursor(ac.min(input.len()));
935 }
936 }
937 self.sel_anchor = None;
938 return true;
939 }
940 if let Some(input) = self.input() {
941 return input.delete_selection();
942 }
943 false
944 }
945
946 fn line_with_text(&self, index: usize, text: &str, len: usize) -> Line {
947 match &self.lines[index] {
948 Line::Todo { done, .. } => Line::Todo {
949 text: TextInput::new(text, len),
950 done: *done,
951 },
952 Line::Bullet(_) => Line::Bullet(TextInput::new(text, len)),
953 Line::Number(_) => Line::Number(TextInput::new(text, len)),
954 Line::Link(_) => Line::Link(TextInput::new(text, len)),
955 Line::Text(_) | Line::Image { .. } => Line::Text(TextInput::new(text, len)),
956 }
957 }
958
959 pub fn link_url_at_cursor(&self) -> Option<String> {
961 match &self.lines[self.cursor] {
962 Line::Link(t) => {
963 let u = t.value();
964 let u = u.trim();
965 (!u.is_empty()).then(|| u.to_string())
966 }
967 _ => None,
968 }
969 }
970
971 pub fn link_url_at_position(&self, row: u16, col: usize) -> Option<String> {
974 use unicode_width::UnicodeWidthStr;
975
976 let width = self.layout_width.max(1);
977 let numbers = number_runs(&self.lines);
978 let target = self.scroll.saturating_add(usize::from(row));
979 let mut at = 0usize;
980 for (index, line) in self.lines.iter().enumerate() {
981 let height = line.height(width, numbers[index]);
982 if target >= at.saturating_add(height) {
983 at = at.saturating_add(height);
984 continue;
985 }
986 let Line::Link(input) = line else {
987 return None;
988 };
989 let row_in = target.saturating_sub(at);
990 let indent = line.indent();
991 let field = width.saturating_sub(indent).max(1);
992 let breaks = input.wrap_breaks(field);
993 let &(start, end) = breaks.get(row_in)?;
994 let text = input.slice(start, end);
995 let text_width = text.width();
996 let on_marker = row_in == 0 && col < indent;
997 let on_text = col >= indent && col < indent.saturating_add(text_width);
998 if !on_marker && !on_text {
999 return None;
1000 }
1001 let url = input.value();
1002 let url = url.trim();
1003 return (!url.is_empty()).then(|| url.to_string());
1004 }
1005 None
1006 }
1007
1008 pub fn selected_image(&self) -> Option<PathBuf> {
1009 match &self.lines[self.cursor] {
1010 Line::Image { path } => Some(resolve_image_reference(
1011 path,
1012 &self.image_root,
1013 &self.attachments,
1014 )),
1015 _ => None,
1016 }
1017 }
1018
1019 pub fn images(&self) -> Vec<PathBuf> {
1021 self.lines
1022 .iter()
1023 .filter_map(|l| match l {
1024 Line::Image { path } => Some(resolve_image_reference(
1025 path,
1026 &self.image_root,
1027 &self.attachments,
1028 )),
1029 _ => None,
1030 })
1031 .collect()
1032 }
1033
1034 pub fn abandon_image_selection(&mut self) {
1039 if !matches!(self.lines.get(self.cursor), Some(Line::Image { .. })) {
1040 return;
1041 }
1042 if let Some(next) = self.next_editable(self.cursor) {
1043 self.cursor = next;
1044 if let Some(input) = self.input() {
1045 input.home();
1046 }
1047 return;
1048 }
1049 if let Some(prev) = self.prev_editable(self.cursor) {
1050 self.cursor = prev;
1051 if let Some(input) = self.input() {
1052 input.end();
1053 }
1054 }
1055 }
1056
1057 fn line(&mut self) -> &mut Line {
1058 &mut self.lines[self.cursor]
1059 }
1060
1061 fn input(&mut self) -> Option<&mut TextInput> {
1062 self.lines[self.cursor].input()
1063 }
1064
1065 pub fn insert(&mut self, c: char) {
1068 if self.has_selection() && !self.delete_description_selection() {
1070 return;
1071 }
1072 if self.input().is_none() {
1073 self.insert_block(Block::text(""));
1075 }
1076 if let Some(input) = self.input() {
1077 input.insert(c);
1078 }
1079 if c == ' '
1081 && let Line::Text(text) = &self.lines[self.cursor]
1082 {
1083 let v = text.value();
1084 if text.cursor() == v.graphemes(true).count() {
1085 if matches!(v.as_str(), "- " | "* ") {
1086 self.lines[self.cursor] = Line::Bullet(TextInput::new("", self.line_max_len));
1087 return;
1088 }
1089 if let Some(rest) = v.strip_suffix(". ")
1090 && !rest.is_empty()
1091 && rest.chars().all(|ch| ch.is_ascii_digit())
1092 {
1093 self.lines[self.cursor] = Line::Number(TextInput::new("", self.line_max_len));
1094 return;
1095 }
1096 }
1097 }
1098 if c == '/' {
1099 let start = self.input().map(|i| i.cursor()).unwrap_or(0);
1100 self.menu = Some(SlashMenu {
1101 start,
1102 query: String::new(),
1103 index: 0,
1104 });
1105 } else if self.menu.is_some() {
1106 if let Some(menu) = &mut self.menu {
1107 menu.query.push(c);
1108 menu.index = 0;
1109 }
1110 if self.menu_commands().is_empty() {
1112 self.close_menu();
1113 }
1114 }
1115 self.try_adopt_line(self.cursor);
1117 }
1118
1119 pub fn insert_str(&mut self, text: &str) {
1120 self.close_menu();
1121 if self.has_selection() && !self.delete_description_selection() {
1122 return;
1123 }
1124 let text = match flatten_if_image_path(text, &self.image_root) {
1126 Some(flat) if self.line_text_fits(&flat) => flat,
1127 _ => text.to_string(),
1128 };
1129 for (i, part) in text.split('\n').enumerate() {
1130 if i > 0 && !self.newline() {
1131 break;
1132 }
1133 if self.input().is_none() {
1134 self.insert_block(Block::text(""));
1135 }
1136 if let Some(input) = self.input() {
1137 input.insert_str(part.trim_end_matches('\r'));
1138 }
1139 }
1140 self.adopt_pasted_paths();
1143 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1144 if matches!(self.lines.get(self.cursor + 1), Some(Line::Text(_))) {
1145 self.cursor += 1;
1146 } else if self.can_add_lines(1) {
1147 self.lines.insert(self.cursor + 1, self.empty_line());
1148 self.cursor += 1;
1149 }
1150 }
1151 }
1152
1153 pub fn newline(&mut self) -> bool {
1157 self.close_menu();
1158
1159 let exits_list = match &self.lines[self.cursor] {
1160 Line::Todo { text, .. } | Line::Bullet(text) | Line::Number(text) => {
1161 text.value().trim().is_empty()
1162 }
1163 Line::Text(_) | Line::Link(_) | Line::Image { .. } => false,
1164 };
1165 if exits_list {
1166 self.lines[self.cursor] = self.empty_line();
1167 return true;
1168 }
1169
1170 if !self.can_add_lines(1) {
1171 return false;
1172 }
1173 let line_max_len = self.line_max_len;
1174 let next = match self.line() {
1175 Line::Text(text) | Line::Link(text) => Line::Text(text.split_off_at_cursor()),
1176 Line::Todo { text, .. } => Line::Todo {
1177 text: text.split_off_at_cursor(),
1178 done: false,
1179 },
1180 Line::Bullet(text) => Line::Bullet(text.split_off_at_cursor()),
1181 Line::Number(text) => Line::Number(text.split_off_at_cursor()),
1182 Line::Image { .. } => Line::Text(TextInput::new("", line_max_len)),
1183 };
1184 self.lines.insert(self.cursor + 1, next);
1185 self.cursor += 1;
1186 self.adopt_pasted_paths();
1188 true
1189 }
1190
1191 pub fn backspace(&mut self) {
1192 if let Some(start) = self.menu.as_ref().map(|m| m.start) {
1193 let at = self.input().map(|i| i.cursor()).unwrap_or(0);
1194 if at <= start {
1195 self.close_menu();
1196 } else if let Some(menu) = &mut self.menu {
1197 menu.query.pop();
1198 menu.index = 0;
1199 }
1200 }
1201 let had_selection = self.has_selection();
1202 if self.delete_description_selection() {
1203 return;
1204 }
1205 if had_selection {
1206 return;
1207 }
1208 match self.line() {
1209 Line::Todo { text, .. }
1212 | Line::Bullet(text)
1213 | Line::Number(text)
1214 | Line::Link(text)
1215 if text.at_start() =>
1216 {
1217 let text = text.clone();
1218 self.lines[self.cursor] = Line::Text(text);
1219 return;
1220 }
1221 Line::Text(text) if text.at_start() => {}
1222 Line::Image { .. } => {
1223 self.remove_block();
1224 return;
1225 }
1226 _ => {
1227 if let Some(input) = self.input() {
1228 input.backspace();
1229 }
1230 return;
1231 }
1232 }
1233 if self.cursor == 0 {
1235 return;
1236 }
1237 let current = self.lines.remove(self.cursor);
1238 self.cursor -= 1;
1239 match current {
1240 Line::Text(text) => {
1241 let merged = match self.lines[self.cursor].input() {
1242 Some(previous) => previous.append(&text),
1243 None => text.is_empty(),
1245 };
1246 if !merged {
1247 self.cursor += 1;
1248 self.lines.insert(self.cursor, Line::Text(text));
1249 }
1250 }
1251 line => {
1252 self.cursor += 1;
1253 self.lines.insert(self.cursor, line);
1254 }
1255 }
1256 }
1257
1258 pub fn delete(&mut self) {
1259 self.close_menu();
1260 let had_selection = self.has_selection();
1261 if self.delete_description_selection() {
1262 return;
1263 }
1264 if had_selection {
1265 return;
1266 }
1267 if matches!(self.line(), Line::Image { .. }) {
1268 self.remove_block();
1269 return;
1270 }
1271 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1272 if !at_end {
1273 if let Some(input) = self.input() {
1274 input.delete();
1275 }
1276 return;
1277 }
1278 if self.cursor + 1 >= self.lines.len() {
1279 return;
1280 }
1281 let next = self.lines.remove(self.cursor + 1);
1282 let merged = match next.input_ref() {
1283 Some(text) => self.lines[self.cursor]
1284 .input()
1285 .is_some_and(|current| current.append(text)),
1286 None => false,
1287 };
1288 if !merged {
1289 self.lines.insert(self.cursor + 1, next);
1290 }
1291 }
1292
1293 pub fn remove_block(&mut self) {
1295 if self.lines.len() == 1 {
1296 self.lines[0] = self.empty_line();
1297 return;
1298 }
1299 self.lines.remove(self.cursor);
1300 self.cursor = self.cursor.min(self.lines.len() - 1);
1301 }
1302
1303 pub fn toggle(&mut self) {
1304 if let Line::Todo { done, .. } = self.line() {
1305 *done = !*done;
1306 }
1307 }
1308
1309 fn field_width_for(&self, index: usize) -> usize {
1312 let width = self.layout_width.max(1);
1313 let n = match &self.lines[index] {
1314 Line::Number(_) => Some(number_at(&self.lines, index)),
1315 _ => None,
1316 };
1317 let indent = n
1318 .map(number_indent)
1319 .unwrap_or_else(|| self.lines[index].indent());
1320 width.saturating_sub(indent).max(1)
1321 }
1322
1323 pub fn up(&mut self) {
1324 self.close_menu();
1325 self.clear_description_selection();
1326 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1328 self.leave_image_backward();
1329 return;
1330 }
1331 let width = self.field_width_for(self.cursor);
1332 let prefer = self.prefer_col;
1333 let moved = self
1334 .input()
1335 .is_some_and(|input| input.wrap_up(width, prefer));
1336 if moved {
1337 if self.prefer_col == u16::MAX
1338 && let Some(input) = self.input()
1339 {
1340 self.prefer_col = input.wrap_cursor(width).1;
1341 }
1342 return;
1343 }
1344 if self.cursor > 0 {
1345 let left = self.cursor;
1346 self.cursor -= 1;
1347 self.try_adopt_line(left);
1348 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1350 return;
1351 }
1352 let width = self.field_width_for(self.cursor);
1353 let prefer = if self.prefer_col == u16::MAX {
1354 0
1355 } else {
1356 self.prefer_col
1357 };
1358 if let Some(input) = self.input() {
1359 let last = input.wrap_height(width).saturating_sub(1);
1360 input.set_cursor_from_wrap(width, last, prefer as usize);
1361 }
1362 }
1363 }
1364
1365 pub fn down(&mut self) {
1366 self.close_menu();
1367 self.clear_description_selection();
1368 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1369 self.leave_image_forward();
1370 return;
1371 }
1372 let width = self.field_width_for(self.cursor);
1373 let prefer = self.prefer_col;
1374 let moved = self
1375 .input()
1376 .is_some_and(|input| input.wrap_down(width, prefer));
1377 if moved {
1378 if self.prefer_col == u16::MAX
1379 && let Some(input) = self.input()
1380 {
1381 self.prefer_col = input.wrap_cursor(width).1;
1382 }
1383 return;
1384 }
1385 if self.cursor + 1 < self.lines.len() {
1386 let left = self.cursor;
1387 self.cursor += 1;
1388 self.try_adopt_line(left);
1389 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1391 return;
1392 }
1393 let width = self.field_width_for(self.cursor);
1394 let prefer = if self.prefer_col == u16::MAX {
1395 0
1396 } else {
1397 self.prefer_col
1398 };
1399 if let Some(input) = self.input() {
1400 input.set_cursor_from_wrap(width, 0, prefer as usize);
1401 }
1402 }
1403 }
1404
1405 pub fn left(&mut self) {
1406 self.close_menu();
1407 self.prefer_col = u16::MAX;
1408 self.clear_description_selection();
1409 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1412 self.leave_image_backward();
1413 return;
1414 }
1415 match self.input() {
1416 Some(input) if !input.at_start() => input.left(),
1417 _ => {
1418 if self.cursor > 0 {
1419 self.cursor -= 1;
1420 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1422 return;
1423 }
1424 if let Some(input) = self.input() {
1425 input.end();
1426 }
1427 }
1428 }
1429 }
1430 }
1431
1432 pub fn right(&mut self) {
1433 self.close_menu();
1434 self.prefer_col = u16::MAX;
1435 self.clear_description_selection();
1436 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1439 self.leave_image_forward();
1440 return;
1441 }
1442 match self.input() {
1443 Some(input) if !input.at_end() => input.right(),
1444 _ => {
1445 if self.cursor + 1 < self.lines.len() {
1446 self.cursor += 1;
1447 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1449 return;
1450 }
1451 if let Some(input) = self.input() {
1452 input.home();
1453 }
1454 }
1455 }
1456 }
1457 }
1458
1459 fn leave_image_forward(&mut self) {
1463 let next = self.cursor + 1;
1464 if next < self.lines.len() && self.lines[next].input_ref().is_some() {
1465 self.cursor = next;
1466 if let Some(input) = self.input() {
1467 input.home();
1468 }
1469 return;
1470 }
1471 if !self.can_add_lines(1) {
1472 return;
1473 }
1474 self.lines.insert(next, self.empty_line());
1477 self.cursor = next;
1478 }
1479
1480 fn leave_image_backward(&mut self) {
1483 if self.cursor > 0 && self.lines[self.cursor - 1].input_ref().is_some() {
1484 self.cursor -= 1;
1485 if let Some(input) = self.input() {
1486 input.end();
1487 }
1488 return;
1489 }
1490 if !self.can_add_lines(1) {
1491 return;
1492 }
1493 self.lines.insert(self.cursor, self.empty_line());
1494 if let Some(input) = self.input() {
1496 input.home();
1497 }
1498 }
1499
1500 pub fn home(&mut self) {
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.close_menu();
1514 self.clear_description_selection();
1515 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1516 self.leave_image_forward();
1517 return;
1518 }
1519 if let Some(input) = self.input() {
1520 input.end();
1521 }
1522 }
1523
1524 pub fn word_left(&mut self) {
1525 self.close_menu();
1526 self.clear_description_selection();
1527 self.prefer_col = u16::MAX;
1528 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1529 self.leave_image_backward();
1530 return;
1531 }
1532 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1533 if !at_start {
1534 if let Some(input) = self.input() {
1535 input.word_left();
1536 }
1537 return;
1538 }
1539 if let Some(prev) = self.prev_editable(self.cursor) {
1541 self.cursor = prev;
1542 if let Some(input) = self.input() {
1543 input.end();
1544 input.word_left();
1545 }
1546 }
1547 }
1548
1549 pub fn word_right(&mut self) {
1550 self.close_menu();
1551 self.clear_description_selection();
1552 self.prefer_col = u16::MAX;
1553 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1554 self.leave_image_forward();
1555 return;
1556 }
1557 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1558 if !at_end {
1559 if let Some(input) = self.input() {
1560 input.word_right();
1561 }
1562 return;
1563 }
1564 if let Some(next) = self.next_editable(self.cursor) {
1565 self.cursor = next;
1566 if let Some(input) = self.input() {
1567 input.home();
1568 input.word_right();
1569 }
1570 }
1571 }
1572
1573 fn prev_editable(&self, from: usize) -> Option<usize> {
1574 (0..from)
1575 .rev()
1576 .find(|&i| self.lines[i].input_ref().is_some())
1577 }
1578
1579 fn next_editable(&self, from: usize) -> Option<usize> {
1580 ((from + 1)..self.lines.len()).find(|&i| self.lines[i].input_ref().is_some())
1581 }
1582
1583 pub fn select_word(&mut self) {
1584 self.close_menu();
1585 self.sel_anchor = None;
1586 if let Some(input) = self.input() {
1587 input.select_word();
1588 }
1589 }
1590
1591 pub fn select_left(&mut self) {
1592 self.close_menu();
1593 self.prefer_col = u16::MAX;
1594 self.ensure_sel_anchor();
1595 if let Some(input) = self.input() {
1596 input.clear_selection();
1597 }
1598 if self.is_image_line(self.cursor) {
1600 self.step_sel_prev_line();
1601 return;
1602 }
1603 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1604 if !at_start {
1605 if let Some(input) = self.input() {
1606 let c = input.cursor().saturating_sub(1);
1607 input.place_cursor(c);
1608 }
1609 } else {
1610 self.step_sel_prev_line();
1611 }
1612 }
1613
1614 pub fn select_right(&mut self) {
1615 self.close_menu();
1616 self.prefer_col = u16::MAX;
1617 self.ensure_sel_anchor();
1618 if let Some(input) = self.input() {
1619 input.clear_selection();
1620 }
1621 if self.is_image_line(self.cursor) {
1622 self.step_sel_next_line();
1623 return;
1624 }
1625 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1626 if !at_end {
1627 if let Some(input) = self.input() {
1628 let c = (input.cursor() + 1).min(input.len());
1629 input.place_cursor(c);
1630 }
1631 } else {
1632 self.step_sel_next_line();
1633 }
1634 }
1635
1636 pub fn select_word_left(&mut self) {
1638 self.close_menu();
1639 self.prefer_col = u16::MAX;
1640 self.ensure_sel_anchor();
1641 if let Some(input) = self.input() {
1642 input.clear_selection();
1643 }
1644 if self.is_image_line(self.cursor) {
1646 self.step_sel_prev_line();
1647 if !self.is_image_line(self.cursor)
1648 && let Some(input) = self.input()
1649 {
1650 input.end();
1651 let target = input.word_left_index();
1652 input.place_cursor(target);
1653 }
1654 return;
1655 }
1656 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1657 if !at_start {
1658 if let Some(input) = self.input() {
1659 let target = input.word_left_index();
1660 input.place_cursor(target);
1661 }
1662 return;
1663 }
1664 self.step_sel_prev_line();
1665 if self.is_image_line(self.cursor) {
1666 return;
1667 }
1668 if let Some(input) = self.input() {
1669 input.end();
1670 let target = input.word_left_index();
1671 input.place_cursor(target);
1672 }
1673 }
1674
1675 pub fn select_word_right(&mut self) {
1677 self.close_menu();
1678 self.prefer_col = u16::MAX;
1679 self.ensure_sel_anchor();
1680 if let Some(input) = self.input() {
1681 input.clear_selection();
1682 }
1683 if self.is_image_line(self.cursor) {
1684 self.step_sel_next_line();
1685 if !self.is_image_line(self.cursor)
1686 && let Some(input) = self.input()
1687 {
1688 input.home();
1689 let target = input.word_right_index();
1690 input.place_cursor(target);
1691 }
1692 return;
1693 }
1694 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1695 if !at_end {
1696 if let Some(input) = self.input() {
1697 let target = input.word_right_index();
1698 input.place_cursor(target);
1699 }
1700 return;
1701 }
1702 self.step_sel_next_line();
1703 if self.is_image_line(self.cursor) {
1704 return;
1705 }
1706 if let Some(input) = self.input() {
1707 input.home();
1708 let target = input.word_right_index();
1709 input.place_cursor(target);
1710 }
1711 }
1712
1713 fn step_sel_prev_line(&mut self) {
1716 if self.cursor == 0 {
1717 return;
1718 }
1719 self.cursor -= 1;
1720 if let Some(input) = self.input() {
1721 input.end();
1722 }
1723 }
1724
1725 fn step_sel_next_line(&mut self) {
1727 if self.cursor + 1 >= self.lines.len() {
1728 return;
1729 }
1730 self.cursor += 1;
1731 if let Some(input) = self.input() {
1732 input.home();
1733 }
1734 }
1735
1736 pub fn select_home(&mut self) {
1737 self.close_menu();
1738 self.ensure_sel_anchor();
1739 if let Some(input) = self.input() {
1740 input.clear_selection();
1741 input.place_cursor(0);
1742 }
1743 }
1744
1745 pub fn select_end(&mut self) {
1746 self.close_menu();
1747 self.ensure_sel_anchor();
1748 if let Some(input) = self.input() {
1749 input.clear_selection();
1750 let n = input.len();
1751 input.place_cursor(n);
1752 }
1753 }
1754
1755 pub fn delete_to_start(&mut self) {
1756 if let Some(input) = self.input() {
1757 input.delete_to_start();
1758 }
1759 }
1760
1761 pub fn delete_to_end(&mut self) {
1762 if let Some(input) = self.input() {
1763 input.delete_to_end();
1764 }
1765 }
1766
1767 pub fn delete_word_left(&mut self) {
1768 if let Some(input) = self.input() {
1769 input.delete_word_left();
1770 }
1771 }
1772
1773 pub fn menu_next(&mut self) {
1776 let count = self.menu_commands().len();
1777 if let Some(menu) = &mut self.menu
1778 && count > 0
1779 {
1780 menu.index = (menu.index + 1) % count;
1781 }
1782 }
1783
1784 pub fn menu_prev(&mut self) {
1785 let count = self.menu_commands().len();
1786 if let Some(menu) = &mut self.menu
1787 && count > 0
1788 {
1789 menu.index = (menu.index + count - 1) % count;
1790 }
1791 }
1792
1793 pub fn close_menu(&mut self) {
1794 self.menu = None;
1795 }
1796
1797 pub fn menu_selected(&self) -> Option<Command> {
1799 self.menu
1800 .as_ref()
1801 .and_then(|m| m.selected_in(self.allowed_commands()))
1802 }
1803
1804 pub fn text_for_copy(&self) -> String {
1806 self.lines_for_copy_all()
1807 .into_iter()
1808 .filter_map(|line| match line {
1809 CopyLine::Text(s) | CopyLine::Link(s) => Some(s),
1810 CopyLine::Image(_) => None,
1811 })
1812 .collect::<Vec<_>>()
1813 .join("\n")
1814 }
1815
1816 pub fn lines_for_copy_all(&self) -> Vec<CopyLine> {
1818 let numbers = number_runs(&self.lines);
1819 self.lines
1820 .iter()
1821 .zip(numbers)
1822 .filter_map(|(line, number)| match line {
1823 Line::Text(text) => {
1824 let s = text.value();
1825 (!s.trim().is_empty()).then_some(CopyLine::Text(s))
1826 }
1827 Line::Bullet(text) => Some(CopyLine::Text(format!("- {}", text.value()))),
1828 Line::Number(text) => {
1829 let n = number.expect("numbered lines have a run index");
1830 Some(CopyLine::Text(format!("{n}. {}", text.value())))
1831 }
1832 Line::Todo { text, done } => {
1833 let mark = if *done { "[✓]" } else { "[ ]" };
1834 Some(CopyLine::Text(format!("{mark} {}", text.value())))
1835 }
1836 Line::Link(text) => {
1837 let s = text.value();
1838 (!s.trim().is_empty()).then_some(CopyLine::Link(s))
1839 }
1840 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
1841 path,
1842 &self.image_root,
1843 &self.attachments,
1844 ))),
1845 })
1846 .collect()
1847 }
1848
1849 pub fn image_for_copy(&self) -> Option<PathBuf> {
1851 (0..=self.cursor)
1852 .rev()
1853 .chain(self.cursor + 1..self.lines.len())
1854 .find_map(|i| match &self.lines[i] {
1855 Line::Image { path } => Some(resolve_image_reference(
1856 path,
1857 &self.image_root,
1858 &self.attachments,
1859 )),
1860 _ => None,
1861 })
1862 }
1863
1864 pub fn apply(&mut self, command: Command) -> Option<CommandRequest> {
1867 if !self.allowed_commands().contains(&command) {
1868 self.close_menu();
1869 return None;
1870 }
1871 let menu = self.menu.take()?;
1872 if let Some(input) = self.input() {
1873 let end = input.cursor();
1875 input.set_cursor(end);
1876 for _ in menu.start.saturating_sub(1)..end {
1877 input.backspace();
1878 }
1879 }
1880 let request = match command {
1881 Command::Paste => Some(CommandRequest::Paste),
1882 Command::Copy => Some(CommandRequest::Copy(CopyPayload::Text(
1883 self.text_for_copy(),
1884 ))),
1885 Command::CopyImage => self
1886 .image_for_copy()
1887 .map(CopyPayload::Image)
1888 .map(CommandRequest::Copy),
1889 Command::CopyAll => Some(CommandRequest::Copy(CopyPayload::All(
1890 self.lines_for_copy_all(),
1891 ))),
1892 Command::Todo | Command::Bullet | Command::Number | Command::Link => {
1893 let text = match self.line() {
1894 Line::Text(text)
1895 | Line::Todo { text, .. }
1896 | Line::Bullet(text)
1897 | Line::Number(text)
1898 | Line::Link(text) => text.clone(),
1899 Line::Image { .. } => return None,
1900 };
1901 self.lines[self.cursor] = match command {
1902 Command::Todo => Line::Todo { text, done: false },
1903 Command::Bullet => Line::Bullet(text),
1904 Command::Number => Line::Number(text),
1905 Command::Link => {
1906 let url = link_url_from_line(&text.value());
1907 Line::Link(TextInput::new(&url, self.line_max_len))
1908 }
1909 Command::Paste | Command::Copy | Command::CopyImage | Command::CopyAll => {
1910 return None;
1911 }
1912 };
1913 None
1914 }
1915 };
1916 if matches!(
1917 command,
1918 Command::Copy | Command::CopyImage | Command::CopyAll
1919 ) && self.input().is_some_and(|input| input.is_empty())
1920 {
1921 self.remove_block();
1922 }
1923 request
1924 }
1925
1926 pub fn insert_block(&mut self, block: Block) -> bool {
1929 if self.plain && matches!(block, Block::Todo { .. } | Block::Image { .. }) {
1930 return false;
1931 }
1932 self.close_menu();
1933 let is_image = matches!(block, Block::Image { .. });
1934 let replace = match &self.lines[self.cursor] {
1935 Line::Text(text) => text.is_empty(),
1936 _ => false,
1937 };
1938 let extra = match (replace, is_image) {
1939 (true, true) => 1, (true, false) => 0,
1941 (false, true) => 2, (false, false) => 1,
1943 };
1944 if extra > 0 && !self.can_add_lines(extra) {
1945 return false;
1946 }
1947 let line = line_from_block(&block, self.line_max_len);
1948 if replace {
1949 self.lines[self.cursor] = line;
1950 } else {
1951 self.lines.insert(self.cursor + 1, line);
1952 self.cursor += 1;
1953 }
1954 if is_image {
1956 self.lines.insert(self.cursor + 1, self.empty_line());
1957 self.cursor += 1;
1958 }
1959 true
1960 }
1961
1962 fn adopt_pasted_paths(&mut self) {
1964 if self.plain {
1965 return;
1966 }
1967 self.merge_broken_image_paths();
1968 for i in 0..self.lines.len() {
1969 self.try_adopt_line(i);
1970 }
1971 }
1972
1973 fn try_adopt_line(&mut self, i: usize) {
1974 let Line::Text(text) = &self.lines[i] else {
1975 return;
1976 };
1977 let value = text.value();
1978 let reference = crate::image::reference_path(&value);
1979 let managed = reference.filter(|reference| self.attachments.contains(reference));
1980 if !crate::image::looks_like_image(&value) && managed.is_none() {
1981 return;
1982 }
1983 if let Some(reference) = managed {
1984 self.lines[i] = Line::Image {
1985 path: reference.to_string(),
1986 };
1987 } else if let Some(path) = crate::image::path_if_image_in(&value, &self.image_root) {
1988 self.lines[i] = Line::Image {
1989 path: crate::image::short_in(&path, &self.image_root),
1990 };
1991 }
1992 }
1993
1994 fn merge_broken_image_paths(&mut self) {
1997 let mut i = 0;
1998 while i + 1 < self.lines.len() {
1999 let joined = match (&self.lines[i], &self.lines[i + 1]) {
2000 (Line::Text(a), Line::Text(b)) => {
2001 let left = a.value();
2002 let right = b.value();
2003 let joined = format!("{left}{right}");
2004 if left.contains('/')
2007 && !crate::image::looks_like_image(&left)
2008 && self.line_text_fits(&joined)
2009 && crate::image::path_if_image_in(&joined, &self.image_root).is_some()
2010 {
2011 Some(joined)
2012 } else {
2013 None
2014 }
2015 }
2016 _ => None,
2017 };
2018 if let Some(path) = joined {
2019 let len = self.line_max_len;
2020 self.lines[i] = Line::Text(TextInput::new(&path, len));
2021 self.lines.remove(i + 1);
2022 if self.cursor > i {
2023 self.cursor -= 1;
2024 }
2025 } else {
2027 i += 1;
2028 }
2029 }
2030 }
2031
2032 pub fn layout(&mut self, width: usize, height: u16) -> (Vec<Placed>, Option<(u16, u16)>) {
2038 if height == 0 || width == 0 {
2039 return (Vec::new(), None);
2040 }
2041 self.layout_width = width;
2042
2043 let numbers = number_runs(&self.lines);
2044 let mut total = 0usize;
2045 let layouts: Vec<LineLayout> = self
2046 .lines
2047 .iter()
2048 .enumerate()
2049 .map(|(i, line)| {
2050 let number = numbers[i];
2051 let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
2052 let field = width.saturating_sub(indent).max(1);
2053 let wraps = line
2054 .input_ref()
2055 .map(|text| text.wrap_breaks(field))
2056 .unwrap_or_default();
2057 let rows = if matches!(line, Line::Image { .. }) {
2058 usize::from(IMAGE_ROWS)
2059 } else {
2060 wraps.len().max(1)
2061 };
2062 let layout = LineLayout {
2063 number,
2064 wraps,
2065 start: total,
2066 rows,
2067 selection: self
2068 .char_sel_on_line(i)
2069 .or_else(|| line.input_ref().and_then(TextInput::selection_range)),
2070 selected: i == self.cursor || self.line_in_selection(i),
2071 };
2072 total = total.saturating_add(rows);
2073 layout
2074 })
2075 .collect();
2076 self.content_height = total;
2077
2078 let cursor_visual = {
2080 let layout = &layouts[self.cursor];
2081 let row_in_block = self.lines[self.cursor]
2082 .input_ref()
2083 .map(|text| text.wrap_cursor_from_breaks(&layout.wraps).0)
2084 .unwrap_or(0);
2085 layout.start.saturating_add(row_in_block)
2086 };
2087 if cursor_visual < self.scroll {
2088 self.scroll = cursor_visual;
2089 } else if cursor_visual >= self.scroll.saturating_add(usize::from(height)) {
2090 self.scroll = cursor_visual + 1 - usize::from(height);
2091 }
2092 self.scroll = self.scroll.min(total.saturating_sub(usize::from(height)));
2093
2094 let mut placed = Vec::new();
2095 let mut cursor_at = None;
2096 for (i, (line, layout)) in self.lines.iter_mut().zip(&layouts).enumerate() {
2097 let Some((y, vis_rows, skip)) =
2102 visible_band(layout.start, layout.rows, self.scroll, height)
2103 else {
2104 continue;
2105 };
2106 let (text, kind) = match line {
2107 Line::Todo { text, done } => (text, TextKind::Todo { done: *done }),
2108 Line::Bullet(text) => (text, TextKind::Bullet),
2109 Line::Number(text) => (text, TextKind::Number(layout.number.unwrap_or(1))),
2110 Line::Link(text) => (text, TextKind::Link),
2111 Line::Text(text) => (text, TextKind::Plain),
2112 Line::Image { path } => {
2113 placed.push(Placed {
2114 block: Painted::Image(resolve_image_reference(
2115 path,
2116 &self.image_root,
2117 &self.attachments,
2118 )),
2119 line: i,
2120 y,
2121 rows: vis_rows,
2122 selected: layout.selected,
2123 });
2124 continue;
2125 }
2126 };
2127 let indent = kind.indent();
2128 let view = text.wrapped_from_breaks(&layout.wraps, layout.selection);
2129 if i == self.cursor {
2130 let row = usize::from(view.cursor_row).saturating_sub(skip) as u16;
2131 cursor_at = Some((y.saturating_add(row), view.cursor_col + indent as u16));
2132 }
2133 let wrap_rows: Vec<WrappedRow> = view
2134 .lines
2135 .into_iter()
2136 .skip(skip)
2137 .take(vis_rows as usize)
2138 .map(|l| WrappedRow {
2139 text: l.text,
2140 sel: l.sel_cols,
2141 })
2142 .collect();
2143 placed.push(Placed {
2144 block: Painted::Text {
2145 rows: wrap_rows,
2146 kind,
2147 },
2148 line: i,
2149 y,
2150 rows: vis_rows,
2151 selected: layout.selected,
2152 });
2153 }
2154 (placed, cursor_at)
2155 }
2156
2157 pub fn scroll(&self) -> usize {
2159 self.scroll
2160 }
2161
2162 pub fn content_height(&self) -> usize {
2164 self.content_height
2165 }
2166
2167 pub fn click(&mut self, row: u16, col: usize) -> bool {
2171 let width = self.layout_width.max(1);
2172 let numbers = number_runs(&self.lines);
2173 let target = self.scroll.saturating_add(usize::from(row));
2174 let mut at = 0usize;
2175 let mut hit = false;
2176 for (i, line) in self.lines.iter().enumerate() {
2177 let h = line.height(width, numbers[i]);
2178 if target < at.saturating_add(h) {
2179 self.cursor = i;
2180 let row_in = target.saturating_sub(at);
2181 let indent = numbers[i]
2182 .map(number_indent)
2183 .unwrap_or_else(|| line.indent());
2184 let field = width.saturating_sub(indent).max(1);
2185 if let Some(input) = self.lines[i].input() {
2186 input.set_cursor_from_wrap(field, row_in, col.saturating_sub(indent));
2187 }
2188 hit = true;
2189 break;
2190 }
2191 at = at.saturating_add(h);
2192 }
2193 self.close_menu();
2194 self.prefer_col = u16::MAX;
2195 self.clear_description_selection();
2196 hit
2197 }
2198}
2199
2200#[cfg(test)]
2201mod tests {
2202 use super::*;
2203
2204 fn editor(blocks: &[Block]) -> DescriptionEditor {
2205 DescriptionEditor::new(blocks)
2206 }
2207
2208 fn type_in(editor: &mut DescriptionEditor, text: &str) {
2209 for c in text.chars() {
2210 editor.insert(c);
2211 }
2212 }
2213
2214 #[test]
2215 fn types_prose_and_splits_lines() {
2216 let mut e = editor(&[]);
2217 type_in(&mut e, "first");
2218 e.newline();
2219 type_in(&mut e, "second");
2220 assert_eq!(e.value(), vec![Block::text("first"), Block::text("second")]);
2221 }
2222
2223 #[test]
2224 fn description_value_round_trips_blank_rows_around_content() {
2225 let mut e = editor(&[]);
2226 assert!(e.newline());
2227 type_in(&mut e, "first");
2228 assert!(e.newline());
2229 assert!(e.newline());
2230 type_in(&mut e, "second");
2231 assert!(e.newline());
2232
2233 let expected = vec![
2234 Block::text(""),
2235 Block::text("first"),
2236 Block::text(""),
2237 Block::text("second"),
2238 Block::text(""),
2239 ];
2240 assert_eq!(e.value(), expected);
2241 assert_eq!(editor(&e.value()).value(), expected);
2242 assert!(editor(&[]).value().is_empty());
2243 }
2244
2245 #[test]
2246 fn refuses_more_lines_than_the_cap() {
2247 let mut e = DescriptionEditor::from_blocks(
2248 &[],
2249 2,
2250 32,
2251 false,
2252 crate::image::default_images_root(),
2253 crate::image::AttachmentCatalog::default(),
2254 );
2255 type_in(&mut e, "a");
2256 assert!(e.newline());
2257 type_in(&mut e, "b");
2258 assert!(!e.newline(), "third line blocked");
2259 assert_eq!(e.value().len(), 2);
2260 }
2261
2262 #[test]
2263 fn plain_description_uses_shorter_line_cap() {
2264 let mut e = DescriptionEditor::plain("");
2265 type_in(&mut e, &"x".repeat(MAX_CATEGORY_DESC_LINE_LEN + 10));
2266 assert_eq!(
2267 e.value()[0],
2268 Block::text(&"x".repeat(MAX_CATEGORY_DESC_LINE_LEN))
2269 );
2270 }
2271
2272 #[test]
2273 fn slash_bullet_turns_the_line_into_a_point() {
2274 let mut e = editor(&[]);
2275 type_in(&mut e, "/bul");
2276 e.apply(Command::Bullet);
2277 type_in(&mut e, "a point");
2278 assert_eq!(e.value(), vec![Block::bullet("a point")]);
2279 }
2280
2281 #[test]
2282 fn slash_number_turns_the_line_into_a_list_item() {
2283 let mut e = editor(&[]);
2284 type_in(&mut e, "/num");
2285 assert_eq!(e.menu_selected(), Some(Command::Number));
2286 e.apply(Command::Number);
2287 type_in(&mut e, "first");
2288 e.newline();
2289 type_in(&mut e, "second");
2290 assert_eq!(
2291 e.value(),
2292 vec![Block::number("first"), Block::number("second")]
2293 );
2294 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2295 }
2296
2297 #[test]
2298 fn typing_1_dot_space_makes_a_numbered_item() {
2299 let mut e = editor(&[]);
2300 type_in(&mut e, "1. ");
2301 type_in(&mut e, "alpha");
2302 assert_eq!(e.value(), vec![Block::number("alpha")]);
2303 }
2304
2305 #[test]
2306 fn slash_link_turns_the_line_into_a_url() {
2307 let mut e = editor(&[Block::text("https://example.com")]);
2308 e.end();
2309 type_in(&mut e, "/link");
2310 assert_eq!(e.menu_selected(), Some(Command::Link));
2311 e.apply(Command::Link);
2312 assert_eq!(e.value(), vec![Block::link("https://example.com")]);
2313 }
2314
2315 #[test]
2316 fn slash_link_unwraps_markdown() {
2317 let mut e = editor(&[Block::text("[docs](https://example.com/docs)")]);
2318 e.end();
2319 type_in(&mut e, "/link");
2320 e.apply(Command::Link);
2321 assert_eq!(e.value(), vec![Block::link("https://example.com/docs")]);
2322 }
2323
2324 #[test]
2325 fn slash_link_on_empty_line_is_ready_for_a_url() {
2326 let mut e = editor(&[]);
2327 type_in(&mut e, "/link");
2328 e.apply(Command::Link);
2329 type_in(&mut e, "https://x.ai");
2330 assert_eq!(e.value(), vec![Block::link("https://x.ai")]);
2331 }
2332
2333 #[test]
2334 fn slash_todo_turns_the_line_into_a_task() {
2335 let mut e = editor(&[]);
2336 type_in(&mut e, "/todo");
2337 let menu = e.menu.as_ref().expect("menu is open");
2338 assert_eq!(menu.query, "todo");
2339 assert_eq!(e.menu_selected(), Some(Command::Todo));
2340 e.apply(Command::Todo);
2341 type_in(&mut e, "buy milk");
2342 assert_eq!(e.value(), vec![Block::todo("buy milk", false)]);
2343 assert!(e.menu.is_none());
2344 }
2345
2346 #[test]
2347 fn enter_in_a_todo_starts_an_unchecked_todo() {
2348 let mut e = editor(&[Block::todo("one", true)]);
2349 e.end();
2350 e.newline();
2351 type_in(&mut e, "two");
2352 assert_eq!(
2353 e.value(),
2354 vec![Block::todo("one", true), Block::todo("two", false)]
2355 );
2356 }
2357
2358 #[test]
2359 fn enter_in_a_bullet_continues_the_list() {
2360 let mut e = editor(&[Block::bullet("first")]);
2361 e.end();
2362 e.newline();
2363 type_in(&mut e, "second");
2364
2365 assert_eq!(
2366 e.value(),
2367 vec![Block::bullet("first"), Block::bullet("second")]
2368 );
2369 }
2370
2371 #[test]
2372 fn enter_splits_a_numbered_item_into_the_continued_list() {
2373 let mut e = editor(&[Block::number("firstsecond")]);
2374 e.home();
2375 for _ in 0..5 {
2376 e.right();
2377 }
2378 e.newline();
2379
2380 assert_eq!(
2381 e.value(),
2382 vec![Block::number("first"), Block::number("second")]
2383 );
2384 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2385 }
2386
2387 #[test]
2388 fn enter_on_an_empty_list_item_returns_to_plain_text() {
2389 let mut e = editor(&[]);
2390 type_in(&mut e, "- first");
2391 e.newline();
2392 e.newline();
2393 type_in(&mut e, "plain");
2394
2395 assert_eq!(
2396 e.value(),
2397 vec![Block::bullet("first"), Block::text("plain")]
2398 );
2399 }
2400
2401 #[test]
2402 fn backspace_at_the_start_unmakes_a_todo() {
2403 let mut e = editor(&[Block::todo("one", false)]);
2404 e.home();
2405 e.backspace();
2406 assert_eq!(e.value(), vec![Block::text("one")]);
2407 }
2408
2409 #[test]
2410 fn moving_the_cursor_closes_the_description_command_menu() {
2411 let mut editor = DescriptionEditor::new(&[]);
2412 for c in "/todo".chars() {
2413 editor.insert(c);
2414 }
2415 assert!(editor.menu.is_some());
2416
2417 editor.left();
2418
2419 assert!(
2420 editor.menu.is_none(),
2421 "the cached query must never outlive its caret range"
2422 );
2423 assert_eq!(editor.value(), vec![Block::text("/todo")]);
2424 }
2425
2426 #[test]
2427 fn link_hit_testing_excludes_blank_row_padding() {
2428 let mut editor = DescriptionEditor::new(&[Block::link("https://example.com")]);
2429 let _ = editor.layout(40, 4);
2430
2431 assert_eq!(
2432 editor.link_url_at_position(0, 3).as_deref(),
2433 Some("https://example.com")
2434 );
2435 assert_eq!(editor.link_url_at_position(0, 39), None);
2436 }
2437
2438 #[test]
2439 fn toggling_counts_towards_progress() {
2440 let mut e = editor(&[Block::todo("a", false), Block::todo("b", false)]);
2441 assert_eq!(e.progress(), (0, 2));
2442 e.toggle();
2443 assert_eq!(e.progress(), (1, 2));
2444 }
2445
2446 #[test]
2447 fn an_image_lands_between_the_lines_with_room_to_type() {
2448 let mut e = editor(&[]);
2449 type_in(&mut e, "before");
2450 e.newline();
2451 e.insert_block(Block::image("/tmp/a.png"));
2452 type_in(&mut e, "after");
2453 assert_eq!(
2454 e.value(),
2455 vec![
2456 Block::text("before"),
2457 Block::image("/tmp/a.png"),
2458 Block::text("after")
2459 ]
2460 );
2461 assert_eq!(e.images().len(), 1);
2462 }
2463
2464 #[test]
2465 fn a_path_in_the_description_becomes_a_picture() {
2466 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2468 let e = editor(&[Block::text(path), Block::text("below")]);
2469 assert!(matches!(e.value()[0], Block::Image { .. }));
2470 assert_eq!(e.value()[1], Block::text("below"));
2471 }
2472
2473 #[test]
2474 fn a_path_to_nothing_stays_text() {
2475 let e = editor(&[Block::text("/tmp/not-here-at-all.png")]);
2476 assert!(matches!(e.value()[0], Block::Text { .. }));
2477 }
2478
2479 #[test]
2480 fn a_pasted_path_is_a_picture_at_once() {
2481 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2482 let mut e = editor(&[]);
2483 e.insert_str(path);
2484 assert!(
2485 matches!(e.value()[0], Block::Image { .. }),
2486 "no need to move the cursor off it first"
2487 );
2488 type_in(&mut e, "and on we go");
2489 assert_eq!(e.value()[1], Block::text("and on we go"));
2490 }
2491
2492 #[test]
2493 fn a_complete_path_becomes_a_picture_even_under_the_cursor() {
2494 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2495 let mut e = editor(&[]);
2496 type_in(&mut e, path);
2497 e.layout(40, 20);
2498 assert!(
2499 matches!(e.value()[0], Block::Image { .. }),
2500 "complete existing path converts without leaving the line"
2501 );
2502 }
2503
2504 #[test]
2505 fn shift_option_left_selects_across_lines() {
2506 let mut e = editor(&[Block::text("one two"), Block::text("three four")]);
2507 e.down();
2508 e.end(); e.select_word_left(); assert_eq!(e.selected_text().as_deref(), Some("four"));
2511 e.select_word_left(); e.select_word_left(); let sel = e.selected_text().expect("cross-line selection");
2514 assert!(
2515 sel.contains("two") && sel.contains("three"),
2516 "expected multi-line sel, got {sel:?}"
2517 );
2518 }
2519
2520 #[test]
2521 fn shift_selection_includes_pictures_with_text() {
2522 let path = "/tmp/shot.png";
2523 let mut e = editor(&[
2524 Block::text("above"),
2525 Block::image(path),
2526 Block::text("below here"),
2527 ]);
2528 e.down();
2530 e.down();
2531 e.home();
2532 e.select_word_left(); assert!(e.line_in_selection(1), "picture covered by selection");
2534 assert!(
2535 matches!(e.selected_payload(), Some(CopyPayload::All(_))),
2536 "mixed selection is rich copy"
2537 );
2538 e.select_word_left(); let sel = e.selected_text().expect("text+image selection");
2540 assert!(
2541 sel.contains("above") && sel.contains("[image:") && !sel.contains("below"),
2542 "got {sel:?}"
2543 );
2544 let (placed, _) = e.layout(40, 40);
2546 let img = placed
2547 .iter()
2548 .find(|p| matches!(p.block, Painted::Image(_)))
2549 .expect("image placed");
2550 assert!(img.selected, "outer frame while selection covers image");
2551 }
2552
2553 #[test]
2554 fn a_pasted_path_broken_by_newlines_still_becomes_a_picture() {
2555 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2556 let mid = path.len() / 2;
2558 let broken = format!("{}\n{}", &path[..mid], &path[mid..]);
2559 let mut e = editor(&[]);
2560 e.insert_str(&broken);
2561 assert!(
2562 matches!(e.value()[0], Block::Image { .. }),
2563 "flattened paste: {broken:?} → {:?}",
2564 e.value()
2565 );
2566 }
2567
2568 #[test]
2569 fn two_text_lines_that_form_a_path_are_merged() {
2570 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2571 let mid = path.len() / 2;
2572 let e = editor(&[Block::text(&path[..mid]), Block::text(&path[mid..])]);
2573 assert!(
2574 matches!(e.value()[0], Block::Image { .. }),
2575 "split lines rejoin: {:?}",
2576 e.value()
2577 );
2578 }
2579
2580 #[test]
2581 fn dash_and_a_space_make_a_bullet() {
2582 let mut e = editor(&[]);
2583 type_in(&mut e, "- buy milk");
2584 assert_eq!(e.value(), vec![Block::bullet("buy milk")]);
2585 }
2586
2587 #[test]
2588 fn a_dash_mid_line_is_just_a_dash() {
2589 let mut e = editor(&[]);
2590 type_in(&mut e, "a - b");
2591 assert_eq!(e.value(), vec![Block::text("a - b")]);
2592 }
2593
2594 #[test]
2595 fn backspace_at_the_start_unmakes_a_bullet() {
2596 let mut e = editor(&[Block::bullet("one")]);
2597 e.home();
2598 e.backspace();
2599 assert_eq!(e.value(), vec![Block::text("one")]);
2600 }
2601
2602 #[test]
2603 fn plain_text_round_trips_its_bullets() {
2604 let e = DescriptionEditor::plain("intro\n- first\n- second");
2605 assert_eq!(e.value().len(), 3);
2606 assert_eq!(e.plain_value(), "intro\n- first\n- second");
2607 }
2608
2609 #[test]
2610 fn plain_description_round_trips_blank_rows() {
2611 let description = "\nfirst\n\nsecond\n";
2612 let e = DescriptionEditor::plain(description);
2613
2614 assert_eq!(e.plain_value(), description);
2615 assert_eq!(
2616 DescriptionEditor::plain(&e.plain_value()).plain_value(),
2617 description
2618 );
2619 }
2620
2621 #[test]
2622 fn plain_mode_rejects_todo_slash_command() {
2623 let mut e = DescriptionEditor::plain("");
2624 type_in(&mut e, "/todo");
2625 assert!(e.menu.is_none(), "no to-do command in plain mode");
2626 assert_eq!(e.value(), vec![Block::text("/todo")]);
2627 }
2628
2629 #[test]
2630 fn plain_mode_slash_makes_a_bullet() {
2631 let mut e = DescriptionEditor::plain("");
2632 type_in(&mut e, "/bul");
2633 assert_eq!(e.menu_selected(), Some(Command::Bullet));
2634 e.apply(Command::Bullet);
2635 type_in(&mut e, "a point");
2636 assert_eq!(e.plain_value(), "- a point");
2637 }
2638
2639 #[test]
2640 fn plain_mode_supports_numbered_lists_and_links_but_not_images() {
2641 let mut numbered = DescriptionEditor::plain("");
2642 type_in(&mut numbered, "/num");
2643 assert_eq!(numbered.menu_selected(), Some(Command::Number));
2644 numbered.apply(Command::Number);
2645 type_in(&mut numbered, "first");
2646 assert_eq!(numbered.plain_value(), "1. first");
2647 assert!(matches!(
2648 DescriptionEditor::plain(&numbered.plain_value()).value().as_slice(),
2649 [Block::Number { text }] if text == "first"
2650 ));
2651
2652 let mut link = DescriptionEditor::plain("https://example.com");
2653 link.end();
2654 type_in(&mut link, "/link");
2655 assert_eq!(link.menu_selected(), Some(Command::Link));
2656 link.apply(Command::Link);
2657 assert!(matches!(link.value().as_slice(), [Block::Link { .. }]));
2658 assert!(matches!(
2659 DescriptionEditor::plain(&link.plain_value()).value().as_slice(),
2660 [Block::Link { url }] if url == "https://example.com"
2661 ));
2662
2663 let mut bare_link = DescriptionEditor::plain("example.com");
2664 bare_link.end();
2665 type_in(&mut bare_link, "/link");
2666 bare_link.apply(Command::Link);
2667 assert_eq!(bare_link.plain_value(), "[link](example.com)");
2668 assert!(matches!(
2669 DescriptionEditor::plain(&bare_link.plain_value())
2670 .value()
2671 .as_slice(),
2672 [Block::Link { url }] if url == "example.com"
2673 ));
2674
2675 assert!(
2676 !DescriptionEditor::plain("")
2677 .allowed_commands()
2678 .contains(&Command::CopyImage)
2679 );
2680 assert!(
2681 !DescriptionEditor::plain("")
2682 .allowed_commands()
2683 .contains(&Command::CopyAll)
2684 );
2685 }
2686
2687 #[test]
2688 fn plain_description_does_not_adopt_image_files() {
2689 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2690 let mut editor = DescriptionEditor::plain(path);
2691
2692 assert_eq!(editor.value(), vec![Block::text(path)]);
2693 assert!(!editor.insert_block(Block::image(path)));
2694 assert_eq!(editor.value(), vec![Block::text(path)]);
2695 }
2696
2697 #[test]
2698 fn plain_description_slash_paste_requests_clipboard_content() {
2699 let mut e = DescriptionEditor::plain("before");
2700 e.end();
2701 e.newline();
2702 type_in(&mut e, "/paste");
2703
2704 assert_eq!(e.menu_selected(), Some(Command::Paste));
2705 assert!(matches!(
2706 e.apply(Command::Paste),
2707 Some(CommandRequest::Paste)
2708 ));
2709 e.insert_str("clipboard\n- item");
2710 assert_eq!(e.plain_value(), "before\nclipboard\n- item");
2711 }
2712
2713 #[test]
2714 fn copy_exports_text_and_skips_images() {
2715 let e = editor(&[
2716 Block::text("hello"),
2717 Block::todo("one", true),
2718 Block::image("/tmp/a.png"),
2719 Block::bullet("point"),
2720 ]);
2721 assert_eq!(e.text_for_copy(), "hello\n[✓] one\n- point");
2722 }
2723
2724 #[test]
2725 fn copy_all_keeps_text_and_images_in_order() {
2726 let e = editor(&[
2727 Block::text("hello"),
2728 Block::image("/tmp/a.png"),
2729 Block::bullet("point"),
2730 ]);
2731 let lines = e.lines_for_copy_all();
2732 assert_eq!(lines.len(), 3);
2733 match &lines[0] {
2734 CopyLine::Text(t) => assert_eq!(t, "hello"),
2735 _ => panic!("text first"),
2736 }
2737 match &lines[1] {
2738 CopyLine::Image(p) => assert!(p.ends_with("a.png")),
2739 _ => panic!("image second"),
2740 }
2741 match &lines[2] {
2742 CopyLine::Text(t) => assert_eq!(t, "- point"),
2743 _ => panic!("bullet third"),
2744 }
2745 }
2746
2747 #[test]
2748 fn slash_copy_strips_the_query_and_returns_text() {
2749 let mut e = editor(&[Block::text("keep me")]);
2750 e.end();
2751 e.newline();
2752 type_in(&mut e, "/copy");
2753 assert_eq!(e.menu_selected(), Some(Command::Copy));
2754 match e.apply(Command::Copy).expect("copy yields request") {
2755 CommandRequest::Copy(CopyPayload::Text(text)) => assert_eq!(text, "keep me"),
2756 CommandRequest::Copy(CopyPayload::Image(_) | CopyPayload::All(_))
2757 | CommandRequest::Paste => panic!("expected text copy"),
2758 }
2759 assert!(e.menu.is_none());
2760 assert_eq!(e.value(), vec![Block::text("keep me")]);
2762 }
2763
2764 #[test]
2765 fn description_slash_paste_requests_clipboard_content() {
2766 let mut e = editor(&[Block::text("before")]);
2767 e.end();
2768 e.newline();
2769 type_in(&mut e, "/pas");
2770
2771 assert_eq!(e.menu_selected(), Some(Command::Paste));
2772 assert!(matches!(
2773 e.apply(Command::Paste),
2774 Some(CommandRequest::Paste)
2775 ));
2776 e.insert_str("clipboard\n- item");
2777 assert_eq!(
2778 e.value(),
2779 vec![
2780 Block::text("before"),
2781 Block::text("clipboard"),
2782 Block::text("- item")
2783 ]
2784 );
2785 }
2786
2787 #[test]
2788 fn image_for_copy_picks_nearest_above_the_cursor() {
2789 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2790 let mut e = editor(&[
2791 Block::text("above"),
2792 Block::image(path),
2793 Block::text("below"),
2794 ]);
2795 e.down();
2797 e.down();
2798 let got = e.image_for_copy().expect("finds the image above");
2799 assert!(got.ends_with("screenshot.png"));
2800 }
2801
2802 #[test]
2803 fn slash_image_returns_the_picture_path() {
2804 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2805 let mut e = editor(&[Block::image(path), Block::text("")]);
2806 e.down();
2807 type_in(&mut e, "/img");
2808 assert_eq!(e.menu_selected(), Some(Command::CopyImage));
2809 match e.apply(Command::CopyImage).expect("image request") {
2810 CommandRequest::Copy(CopyPayload::Image(p)) => {
2811 assert!(p.ends_with("screenshot.png"))
2812 }
2813 CommandRequest::Copy(CopyPayload::Text(_) | CopyPayload::All(_))
2814 | CommandRequest::Paste => panic!("expected image copy"),
2815 }
2816 }
2817
2818 #[test]
2819 fn a_picture_is_deleted_by_backspace() {
2820 let mut e = editor(&[Block::text("a"), Block::image("/tmp/a.png")]);
2821 e.down();
2822 e.backspace();
2823 assert_eq!(e.value(), vec![Block::text("a")]);
2824 }
2825
2826 #[test]
2827 fn the_menu_filters_by_abbreviation() {
2828 let mut e = editor(&[]);
2829 type_in(&mut e, "/che");
2830 assert_eq!(e.menu_selected(), Some(Command::Todo));
2831 }
2832
2833 #[test]
2834 fn the_menu_closes_when_nothing_matches() {
2835 let mut e = editor(&[]);
2836 type_in(&mut e, "/zz");
2837 assert!(e.menu.is_none());
2838 assert_eq!(e.value(), vec![Block::text("/zz")], "the text is kept");
2839 }
2840
2841 #[test]
2842 fn the_menu_closes_when_the_slash_is_removed() {
2843 let mut e = editor(&[]);
2844 type_in(&mut e, "/to");
2845 assert!(e.menu.is_some());
2846 e.backspace();
2847 e.backspace();
2848 assert!(e.menu.is_some());
2849 e.backspace();
2850 assert!(e.menu.is_none(), "removing the slash closes the menu");
2851 }
2852
2853 #[test]
2854 fn right_on_sole_image_inserts_text_line() {
2855 let blocks = [Block::image("/tmp/x.png")];
2856 let mut e = editor(&blocks);
2857 assert!(matches!(e.lines[0], Line::Image { .. }));
2858 assert_eq!(e.cursor, 0);
2859 e.right();
2860 assert_eq!(e.lines.len(), 2, "should insert a text line");
2861 assert_eq!(e.cursor, 1);
2862 assert!(matches!(e.lines[1], Line::Text(_)));
2863 assert!(e.input().is_some());
2864 }
2865
2866 #[test]
2867 fn left_on_first_image_inserts_text_line_above() {
2868 let blocks = [Block::image("/tmp/x.png")];
2869 let mut e = editor(&blocks);
2870 e.left();
2871 assert_eq!(e.lines.len(), 2);
2872 assert_eq!(e.cursor, 0);
2873 assert!(matches!(e.lines[0], Line::Text(_)));
2874 assert!(matches!(e.lines[1], Line::Image { .. }));
2875 }
2876
2877 #[test]
2878 fn visible_band_clips_top_like_bottom() {
2879 assert_eq!(visible_band(5, 10, 8, 10), Some((0, 7, 3)));
2882 assert_eq!(visible_band(0, 5, 8, 10), None);
2884 assert_eq!(visible_band(20, 5, 8, 10), None);
2885 assert_eq!(visible_band(8, 10, 0, 12), Some((8, 4, 0)));
2887 }
2888
2889 #[test]
2890 fn consecutive_images_shrink_when_scrolled_off_the_top() {
2891 let path = "/tmp/a.png";
2892 let mut e = editor(&[Block::image(path), Block::image(path), Block::text("tail")]);
2893 e.layout(40, 30);
2895 assert!(e.click(20, 0));
2896 assert_eq!(e.cursor_line(), 2);
2897
2898 let (placed, _) = e.layout(40, 12);
2899 assert_eq!(e.scroll(), 9);
2901 let imgs: Vec<_> = placed
2902 .iter()
2903 .filter(|p| matches!(p.block, Painted::Image(_)))
2904 .collect();
2905 assert_eq!(imgs.len(), 2);
2906 assert_eq!((imgs[0].y, imgs[0].rows), (0, 1));
2908 assert_eq!((imgs[1].y, imgs[1].rows), (1, 10));
2910 assert_eq!(imgs[0].y + imgs[0].rows, imgs[1].y);
2912 }
2913
2914 #[test]
2915 fn narrow_maximum_description_keeps_the_last_row_addressable() {
2916 let line = "x".repeat(MAX_NOTES_LINE_LEN);
2917 let blocks = vec![Block::text(&line); MAX_DESCRIPTION_LINES];
2918 let mut e = editor(&blocks);
2919 e.cursor = e.lines.len() - 1;
2920 e.input().unwrap().end();
2921
2922 let (_, cursor) = e.layout(1, 10);
2923
2924 let expected_height = MAX_DESCRIPTION_LINES * MAX_NOTES_LINE_LEN;
2925 assert_eq!(e.content_height() as usize, expected_height);
2926 assert_eq!(e.scroll() as usize, expected_height - 10);
2927 assert_eq!(cursor, Some((9, 1)));
2928 assert!(e.click(0, 0));
2929 assert_eq!(e.cursor_line(), MAX_DESCRIPTION_LINES - 1);
2930 }
2931
2932 #[test]
2933 fn line_join_at_the_length_limit_never_discards_the_next_line() {
2934 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2935
2936 let mut backward = editor(&[Block::text(&full), Block::text("tail")]);
2937 backward.cursor = 1;
2938 backward.input().unwrap().home();
2939 backward.backspace();
2940 assert_eq!(
2941 backward.value(),
2942 vec![Block::text(&full), Block::text("tail")]
2943 );
2944
2945 let mut forward = editor(&[Block::text(&full), Block::text("tail")]);
2946 forward.input().unwrap().end();
2947 forward.delete();
2948 assert_eq!(
2949 forward.value(),
2950 vec![Block::text(&full), Block::text("tail")]
2951 );
2952 }
2953
2954 #[test]
2955 fn oversized_cross_line_selection_replacement_is_rejected_without_data_loss() {
2956 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2957 let mut editor = editor(&[Block::text(&full), Block::text("tail")]);
2958 editor.sel_anchor = Some((0, MAX_NOTES_LINE_LEN));
2959 editor.cursor = 1;
2960 editor.input().unwrap().home();
2961
2962 editor.insert('x');
2963
2964 assert_eq!(
2965 editor.value(),
2966 vec![Block::text(&full), Block::text("tail")]
2967 );
2968 }
2969
2970 #[test]
2971 fn oversized_image_path_detection_never_discards_a_source_line() {
2972 let root = std::env::temp_dir().join(format!(
2973 "mach-long-image-path-test-{}",
2974 uuid::Uuid::new_v4()
2975 ));
2976 let first_dir = "a".repeat(240);
2977 let second_dir = "b".repeat(240);
2978 let file = format!("{}.png", "c".repeat(40));
2979 let relative = format!("{first_dir}/{second_dir}/{file}");
2980 assert!(relative.graphemes(true).count() > MAX_NOTES_LINE_LEN);
2981 std::fs::create_dir_all(root.join(&first_dir).join(&second_dir)).unwrap();
2982 std::fs::write(root.join(&relative), []).unwrap();
2983
2984 let split = relative.len() / 2;
2985 let (left, right) = relative.split_at(split);
2986 let expected = vec![Block::text(left), Block::text(right)];
2987 let mut loaded = editor(&expected);
2988 loaded.set_image_root(root.clone());
2989 assert_eq!(loaded.value(), expected);
2990
2991 let mut pasted = editor(&[]);
2992 pasted.set_image_root(root.clone());
2993 pasted.insert_str(&format!("{left}\n{right}"));
2994 assert_eq!(pasted.value(), expected);
2995
2996 std::fs::remove_dir_all(root).unwrap();
2997 }
2998}