1use std::path::{Path, PathBuf};
9
10use unicode_segmentation::UnicodeSegmentation;
11
12use crate::model::{
13 Block, MAX_BODY_LINES, MAX_CATEGORY_DESC_LINE_LEN, MAX_CATEGORY_DESC_LINES, MAX_NOTES_LINE_LEN,
14};
15use crate::text_input::TextInput;
16
17pub const IMAGE_ROWS: u16 = 10;
19pub const TODO_INDENT: usize = 4;
21pub const BULLET_INDENT: usize = 2;
23pub const LINK_INDENT: usize = 2;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Command {
28 Todo,
29 Bullet,
30 Number,
31 Link,
32 Copy,
34 CopyImage,
36 CopyAll,
38}
39
40#[derive(Debug, Clone)]
42pub enum CopyLine {
43 Text(String),
44 Link(String),
45 Image(PathBuf),
46}
47
48#[derive(Debug, Clone)]
50pub enum CopyPayload {
51 Text(String),
52 Image(PathBuf),
53 All(Vec<CopyLine>),
55}
56
57impl Command {
58 pub const ALL: [Self; 7] = [
59 Self::Todo,
60 Self::Bullet,
61 Self::Number,
62 Self::Link,
63 Self::Copy,
64 Self::CopyImage,
65 Self::CopyAll,
66 ];
67 pub const PLAIN: [Self; 2] = [Self::Bullet, Self::Copy];
69
70 pub fn label(self) -> &'static str {
71 match self {
72 Self::Todo => "To-do list",
73 Self::Bullet => "Bullet point",
74 Self::Number => "Numbered list",
75 Self::Link => "Link",
76 Self::Copy => "Copy text",
77 Self::CopyImage => "Copy image",
78 Self::CopyAll => "Copy all",
79 }
80 }
81
82 pub fn hint(self) -> &'static str {
83 match self {
84 Self::Todo => "tick it off with Ctrl+D",
85 Self::Bullet => "or type - and a space",
86 Self::Number => "or type 1. and a space",
87 Self::Link => "click or ⌘↵ to open",
88 Self::Copy => "prose, bullets, to-dos",
89 Self::CopyImage => "nearest picture in the body",
90 Self::CopyAll => "text and pictures together",
91 }
92 }
93
94 fn keywords(self) -> &'static [&'static str] {
95 match self {
96 Self::Todo => &["todo", "to-do", "task", "check", "box", "list"],
97 Self::Bullet => &["bullet", "point", "dash", "item", "list"],
98 Self::Number => &["number", "numbered", "ordered", "ol", "1"],
99 Self::Link => &["link", "url", "href", "http", "https", "www"],
100 Self::Copy => &["copy", "clipboard", "text"],
101 Self::CopyImage => &["image", "picture", "pic", "img", "photo"],
102 Self::CopyAll => &["copyall", "all", "everything", "rich"],
103 }
104 }
105
106 fn matches(self, query: &str) -> bool {
107 let query = query.to_lowercase();
108 query.is_empty() || self.keywords().iter().any(|k| k.starts_with(&query))
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SlashMenu {
115 start: usize,
117 pub query: String,
118 pub index: usize,
119}
120
121impl SlashMenu {
122 pub fn matches_in(&self, allowed: &[Command]) -> Vec<Command> {
123 allowed
124 .iter()
125 .copied()
126 .filter(|c| c.matches(&self.query))
127 .collect()
128 }
129
130 pub fn selected_in(&self, allowed: &[Command]) -> Option<Command> {
131 let matches = self.matches_in(allowed);
132 matches
133 .get(self.index.min(matches.len().saturating_sub(1)))
134 .copied()
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139enum Line {
140 Text(TextInput),
141 Todo { text: TextInput, done: bool },
142 Bullet(TextInput),
143 Number(TextInput),
144 Link(TextInput),
145 Image { path: String },
146}
147
148impl Line {
149 fn input(&mut self) -> Option<&mut TextInput> {
150 match self {
151 Self::Text(text)
152 | Self::Todo { text, .. }
153 | Self::Bullet(text)
154 | Self::Number(text)
155 | Self::Link(text) => Some(text),
156 Self::Image { .. } => None,
157 }
158 }
159
160 fn input_ref(&self) -> Option<&TextInput> {
161 match self {
162 Self::Text(text)
163 | Self::Todo { text, .. }
164 | Self::Bullet(text)
165 | Self::Number(text)
166 | Self::Link(text) => Some(text),
167 Self::Image { .. } => None,
168 }
169 }
170
171 fn indent(&self) -> usize {
174 match self {
175 Self::Todo { .. } => TODO_INDENT,
176 Self::Bullet(_) => BULLET_INDENT,
177 Self::Link(_) => LINK_INDENT,
178 Self::Text(_) | Self::Number(_) | Self::Image { .. } => 0,
179 }
180 }
181
182 fn height(&self, width: usize, number: Option<usize>) -> usize {
183 match self {
184 Self::Image { .. } => usize::from(IMAGE_ROWS),
185 line => {
186 let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
187 let field = width.saturating_sub(indent).max(1);
188 line.input_ref()
189 .map(|t| t.wrap_height(field))
190 .unwrap_or(1)
191 .max(1)
192 }
193 }
194 }
195}
196
197fn flatten_if_image_path(text: &str, images_root: &std::path::Path) -> Option<String> {
200 if !text.contains('\n') && !text.contains('\r') {
201 return None;
202 }
203 let flat: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
204 let flat = flat.trim();
205 crate::image::path_if_image_in(flat, images_root).map(|_| flat.to_string())
206}
207
208fn link_url_from_line(s: &str) -> String {
210 let s = s.trim();
211 if let Some(open) = s.find("](")
212 && s.starts_with('[')
213 && s.ends_with(')')
214 && open + 2 < s.len()
215 {
216 let url = &s[open + 2..s.len() - 1];
217 if !url.is_empty() {
218 return url.to_string();
219 }
220 }
221 s.to_string()
222}
223
224fn number_indent(n: usize) -> usize {
226 let n = n.max(1);
227 let digits = ((n as f64).log10().floor() as usize) + 1;
228 digits + 2
229}
230
231fn number_at(lines: &[Line], at: usize) -> usize {
233 let mut n = 0;
234 for i in (0..=at).rev() {
235 if matches!(lines[i], Line::Number(_)) {
236 n += 1;
237 } else {
238 break;
239 }
240 }
241 n
242}
243
244fn number_runs(lines: &[Line]) -> Vec<Option<usize>> {
246 let mut out = Vec::with_capacity(lines.len());
247 let mut run = 0usize;
248 for line in lines {
249 if matches!(line, Line::Number(_)) {
250 run += 1;
251 out.push(Some(run));
252 } else {
253 run = 0;
254 out.push(None);
255 }
256 }
257 out
258}
259
260fn line_from_block(block: &Block, line_max_len: usize) -> Line {
261 match block {
262 Block::Text { text } => Line::Text(TextInput::new(text, line_max_len)),
263 Block::Todo { text, done } => Line::Todo {
264 text: TextInput::new(text, line_max_len),
265 done: *done,
266 },
267 Block::Bullet { text } => Line::Bullet(TextInput::new(text, line_max_len)),
268 Block::Number { text } => Line::Number(TextInput::new(text, line_max_len)),
269 Block::Link { url } => Line::Link(TextInput::new(url, line_max_len)),
270 Block::Image { attachment_id } => Line::Image {
271 path: attachment_id.clone(),
272 },
273 }
274}
275
276fn block_from_input(input: &TextInput, make: impl FnOnce(&str) -> Block) -> Option<Block> {
277 let value = input.value();
278 let value = value.trim_end();
279 (!value.trim().is_empty()).then(|| make(value))
280}
281
282fn resolve_image_reference(
283 reference: &str,
284 image_root: &Path,
285 attachments: &crate::image::AttachmentCatalog,
286) -> PathBuf {
287 attachments.resolve(reference, image_root)
288}
289
290fn visible_band(
294 start: usize,
295 rows: usize,
296 scroll: usize,
297 height: u16,
298) -> Option<(u16, u16, usize)> {
299 if rows == 0 || height == 0 {
300 return None;
301 }
302 let height = usize::from(height);
303 let end = start.saturating_add(rows);
304 let viewport_end = scroll.saturating_add(height);
305 if end <= scroll || start >= viewport_end {
306 return None;
307 }
308 let vis_start = start.max(scroll);
309 let vis_end = end.min(viewport_end);
310 let y = (vis_start - scroll) as u16;
311 let vis_rows = (vis_end - vis_start) as u16;
312 let skip = vis_start - start;
313 (vis_rows > 0).then_some((y, vis_rows, skip))
314}
315
316#[derive(Debug, Clone)]
318pub struct WrappedRow {
319 pub text: String,
320 pub sel: Option<(u16, u16)>,
321}
322
323pub enum Painted {
325 Text {
328 rows: Vec<WrappedRow>,
329 kind: TextKind,
330 },
331 Image(PathBuf),
332}
333
334#[derive(Debug, Clone, Copy)]
336pub enum TextKind {
337 Plain,
338 Todo { done: bool },
339 Bullet,
340 Number(usize),
341 Link,
342}
343
344impl TextKind {
345 pub fn indent(self) -> usize {
346 match self {
347 Self::Plain => 0,
348 Self::Todo { .. } => TODO_INDENT,
349 Self::Bullet => BULLET_INDENT,
350 Self::Number(n) => number_indent(n),
351 Self::Link => LINK_INDENT,
352 }
353 }
354}
355
356pub struct Placed {
357 pub block: Painted,
358 pub line: usize,
360 pub y: u16,
362 pub rows: u16,
363 pub selected: bool,
366}
367
368struct LineLayout {
369 number: Option<usize>,
370 wraps: Vec<(usize, usize)>,
371 start: usize,
372 rows: usize,
373 selection: Option<(usize, usize)>,
374 selected: bool,
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct BodyEditor {
379 lines: Vec<Line>,
380 cursor: usize,
381 scroll: usize,
382 pub menu: Option<SlashMenu>,
383 plain: bool,
385 max_lines: usize,
387 line_max_len: usize,
389 layout_width: usize,
391 content_height: usize,
393 prefer_col: u16,
395 sel_anchor: Option<(usize, usize)>,
398 image_root: PathBuf,
399 attachments: crate::image::AttachmentCatalog,
400}
401
402impl BodyEditor {
403 pub fn new(blocks: &[Block]) -> Self {
404 Self::from_blocks(blocks, MAX_BODY_LINES, MAX_NOTES_LINE_LEN, false)
405 }
406
407 pub fn plain(text: &str) -> Self {
410 let blocks: Vec<Block> = text
411 .lines()
412 .map(|line| match line.strip_prefix("- ") {
413 Some(rest) => Block::bullet(rest),
414 None => Block::text(line),
415 })
416 .collect();
417 Self::from_blocks(
418 &blocks,
419 MAX_CATEGORY_DESC_LINES,
420 MAX_CATEGORY_DESC_LINE_LEN,
421 true,
422 )
423 }
424
425 fn from_blocks(blocks: &[Block], max_lines: usize, line_max_len: usize, plain: bool) -> Self {
426 let mut lines: Vec<Line> = blocks
427 .iter()
428 .map(|b| line_from_block(b, line_max_len))
429 .collect();
430 if lines.is_empty() {
431 lines.push(Line::Text(TextInput::new("", line_max_len)));
432 }
433 let mut editor = Self {
434 lines,
435 cursor: 0,
436 scroll: 0,
437 menu: None,
438 plain,
439 max_lines,
440 line_max_len,
441 layout_width: 40,
442 content_height: 0,
443 prefer_col: u16::MAX,
444 sel_anchor: None,
445 image_root: crate::image::default_images_root(),
446 attachments: crate::image::AttachmentCatalog::default(),
447 };
448 if !plain {
450 editor.adopt_pasted_paths();
451 }
452 editor
453 }
454
455 fn can_add_lines(&self, n: usize) -> bool {
456 self.lines.len().saturating_add(n) <= self.max_lines
457 }
458
459 fn line_text_fits(&self, text: &str) -> bool {
460 text.len() <= crate::model::text_byte_limit(self.line_max_len)
461 && text.graphemes(true).count() <= self.line_max_len
462 }
463
464 pub fn set_image_root(&mut self, image_root: PathBuf) {
465 self.image_root = image_root;
466 if !self.plain {
467 self.adopt_pasted_paths();
468 }
469 }
470
471 pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
472 self.attachments.set(attachments);
473 }
474
475 pub fn image_root(&self) -> &std::path::Path {
476 &self.image_root
477 }
478
479 fn empty_line(&self) -> Line {
480 Line::Text(TextInput::new("", self.line_max_len))
481 }
482
483 pub fn allowed_commands(&self) -> &'static [Command] {
485 if self.plain {
486 &Command::PLAIN
487 } else {
488 &Command::ALL
489 }
490 }
491
492 pub fn menu_commands(&self) -> Vec<Command> {
494 self.menu
495 .as_ref()
496 .map(|m| m.matches_in(self.allowed_commands()))
497 .unwrap_or_default()
498 }
499
500 pub fn plain_value(&self) -> String {
502 let mut n = 0usize;
503 self.value()
504 .iter()
505 .filter_map(|b| match b {
506 Block::Text { text } => {
507 n = 0;
508 Some(text.clone())
509 }
510 Block::Bullet { text } => {
511 n = 0;
512 Some(format!("- {text}"))
513 }
514 Block::Number { text } => {
515 n += 1;
516 Some(format!("{n}. {text}"))
517 }
518 Block::Link { url } => {
519 n = 0;
520 Some(url.clone())
521 }
522 _ => {
523 n = 0;
524 None
525 }
526 })
527 .collect::<Vec<_>>()
528 .join("\n")
529 }
530
531 pub fn value(&self) -> Vec<Block> {
533 self.lines
534 .iter()
535 .filter_map(|line| match line {
536 Line::Text(text) => block_from_input(text, Block::text),
537 Line::Todo { text, done } => {
538 block_from_input(text, |value| Block::todo(value, *done))
539 }
540 Line::Bullet(text) => block_from_input(text, Block::bullet),
541 Line::Number(text) => block_from_input(text, Block::number),
542 Line::Link(text) => block_from_input(text, Block::link),
543 Line::Image { path } => Some(Block::image(path)),
544 })
545 .collect()
546 }
547
548 pub fn is_empty(&self) -> bool {
549 !self.lines.iter().any(|l| match l {
550 Line::Image { .. } => true,
551 Line::Text(t)
552 | Line::Bullet(t)
553 | Line::Number(t)
554 | Line::Link(t)
555 | Line::Todo { text: t, .. } => !t.value().trim().is_empty(),
556 })
557 }
558
559 pub fn progress(&self) -> (usize, usize) {
560 let mut done = 0usize;
561 let mut total = 0usize;
562 for l in &self.lines {
563 if let Line::Todo { text, done: d } = l
564 && !text.value().trim().is_empty()
565 {
566 total += 1;
567 if *d {
568 done += 1;
569 }
570 }
571 }
572 (done, total)
573 }
574
575 pub fn cursor_line(&self) -> usize {
577 self.cursor
578 }
579
580 pub fn has_selection(&self) -> bool {
582 if self.ordered_selection().is_some() {
583 return true;
584 }
585 self.lines[self.cursor]
586 .input_ref()
587 .is_some_and(|t| t.has_selection())
588 }
589
590 pub fn selected_text(&self) -> Option<String> {
593 if let Some(payload) = self.selected_payload() {
594 return match payload {
595 CopyPayload::Text(s) => Some(s),
596 CopyPayload::All(lines) => {
597 let s = lines
598 .into_iter()
599 .map(|l| match l {
600 CopyLine::Text(t) | CopyLine::Link(t) => t,
601 CopyLine::Image(p) => format!("[image: {}]", p.display()),
602 })
603 .collect::<Vec<_>>()
604 .join("\n");
605 (!s.is_empty()).then_some(s)
606 }
607 CopyPayload::Image(p) => Some(format!("[image: {}]", p.display())),
608 };
609 }
610 None
611 }
612
613 pub fn selected_payload(&self) -> Option<CopyPayload> {
615 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
616 let lines = self.copy_lines_between(al, ac, bl, bc);
617 if lines.is_empty() {
618 return None;
619 }
620 if lines.iter().any(|l| matches!(l, CopyLine::Image(_))) {
621 return Some(CopyPayload::All(lines));
622 }
623 let text = lines
624 .into_iter()
625 .filter_map(|l| match l {
626 CopyLine::Text(t) | CopyLine::Link(t) => Some(t),
627 CopyLine::Image(_) => None,
628 })
629 .collect::<Vec<_>>()
630 .join("\n");
631 return (!text.is_empty()).then_some(CopyPayload::Text(text));
632 }
633 match &self.lines[self.cursor] {
634 Line::Text(t)
635 | Line::Todo { text: t, .. }
636 | Line::Bullet(t)
637 | Line::Number(t)
638 | Line::Link(t) => t.selected_text().map(CopyPayload::Text),
639 Line::Image { path } => Some(CopyPayload::Image(resolve_image_reference(
640 path,
641 &self.image_root,
642 &self.attachments,
643 ))),
644 }
645 }
646
647 fn caret(&self) -> (usize, usize) {
648 let col = self.lines[self.cursor]
649 .input_ref()
650 .map(|i| i.cursor())
651 .unwrap_or(0);
652 (self.cursor, col)
653 }
654
655 fn ordered_selection(&self) -> Option<((usize, usize), (usize, usize))> {
656 let a = self.sel_anchor?;
657 let b = self.caret();
658 if a == b {
659 return None;
660 }
661 Some(if (a.0, a.1) <= (b.0, b.1) {
662 (a, b)
663 } else {
664 (b, a)
665 })
666 }
667
668 fn line_char_len(&self, i: usize) -> usize {
669 self.lines[i].input_ref().map(|t| t.len()).unwrap_or(0)
670 }
671
672 fn line_text_value(&self, i: usize) -> String {
673 self.lines[i]
674 .input_ref()
675 .map(|t| t.value())
676 .unwrap_or_default()
677 }
678
679 fn is_image_line(&self, i: usize) -> bool {
680 matches!(self.lines.get(i), Some(Line::Image { .. }))
681 }
682
683 pub fn line_in_selection(&self, i: usize) -> bool {
687 let Some(((al, _), (bl, _))) = self.ordered_selection() else {
688 return false;
689 };
690 if i < al || i > bl {
691 return false;
692 }
693 if self.is_image_line(i) {
694 return al < bl;
696 }
697 self.char_sel_on_line(i).is_some()
698 }
699
700 fn copy_lines_between(&self, al: usize, ac: usize, bl: usize, bc: usize) -> Vec<CopyLine> {
701 let mut out = Vec::new();
702 if al == bl {
703 let s = self.line_text_value(al);
704 let chars: Vec<&str> = s.graphemes(true).collect();
705 let lo = ac.min(chars.len());
706 let hi = bc.min(chars.len());
707 if lo < hi {
708 out.push(CopyLine::Text(chars[lo..hi].concat()));
709 }
710 return out;
711 }
712 if let Some(line) = self.copy_line_slice(al, Some(ac), None) {
714 out.push(line);
715 }
716 for i in (al + 1)..bl {
717 if let Some(line) = self.copy_line_slice(i, None, None) {
718 out.push(line);
719 }
720 }
721 if self.is_image_line(bl) {
723 if let Line::Image { path } = &self.lines[bl] {
724 out.push(CopyLine::Image(resolve_image_reference(
725 path,
726 &self.image_root,
727 &self.attachments,
728 )));
729 }
730 } else if let Some(line) = self.copy_line_slice(bl, None, Some(bc)) {
731 out.push(line);
732 }
733 out
734 }
735
736 fn copy_line_slice(
739 &self,
740 i: usize,
741 from: Option<usize>,
742 to: Option<usize>,
743 ) -> Option<CopyLine> {
744 match &self.lines[i] {
745 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
746 path,
747 &self.image_root,
748 &self.attachments,
749 ))),
750 Line::Link(t) => {
751 let s = t.value();
752 let chars: Vec<&str> = s.graphemes(true).collect();
753 let lo = from.unwrap_or(0).min(chars.len());
754 let hi = to.unwrap_or(chars.len()).min(chars.len());
755 (lo < hi).then(|| CopyLine::Link(chars[lo..hi].concat()))
756 }
757 Line::Text(t) | Line::Bullet(t) | Line::Number(t) | Line::Todo { text: t, .. } => {
758 let s = t.value();
759 let chars: Vec<&str> = s.graphemes(true).collect();
760 let lo = from.unwrap_or(0).min(chars.len());
761 let hi = to.unwrap_or(chars.len()).min(chars.len());
762 (lo < hi).then(|| CopyLine::Text(chars[lo..hi].concat()))
763 }
764 }
765 }
766
767 fn char_sel_on_line(&self, i: usize) -> Option<(usize, usize)> {
769 let ((al, ac), (bl, bc)) = self.ordered_selection()?;
770 if i < al || i > bl || self.is_image_line(i) {
771 return None;
772 }
773 let len = self.line_char_len(i);
774 if al == bl {
775 let lo = ac.min(bc).min(len);
776 let hi = ac.max(bc).min(len);
777 return (lo < hi).then_some((lo, hi));
778 }
779 if i == al {
780 let lo = ac.min(len);
781 return (lo < len || len == 0).then_some((lo, len));
782 }
783 if i == bl {
784 let hi = bc.min(len);
785 return (hi > 0).then_some((0, hi));
786 }
787 Some((0, len))
789 }
790
791 fn ensure_sel_anchor(&mut self) {
792 if self.sel_anchor.is_none() {
793 self.sel_anchor = Some(self.caret());
794 }
795 }
796
797 fn clear_body_selection(&mut self) {
798 self.sel_anchor = None;
799 for line in &mut self.lines {
800 if let Some(input) = line.input() {
801 input.clear_selection();
802 }
803 }
804 }
805
806 pub fn delete_body_selection(&mut self) -> bool {
809 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
810 if al == bl {
811 if let Some(input) = self.lines[al].input() {
814 let value = input.value();
815 let mut chars: Vec<&str> = value.graphemes(true).collect();
816 let lo = ac.min(bc).min(chars.len());
817 let hi = ac.max(bc).min(chars.len());
818 if lo < hi {
819 chars.drain(lo..hi);
820 }
821 let text = chars.concat();
822 let len = self.line_max_len;
823 self.lines[al] = self.line_with_text(al, &text, len);
824 if let Some(input) = self.lines[al].input() {
825 input.place_cursor(lo);
826 }
827 }
828 } else {
829 let start_s = if self.is_image_line(al) {
832 String::new()
833 } else {
834 self.line_text_value(al)
835 };
836 let end_s = if self.is_image_line(bl) {
837 String::new()
838 } else {
839 self.line_text_value(bl)
840 };
841 let sc: Vec<&str> = start_s.graphemes(true).collect();
842 let ec: Vec<&str> = end_s.graphemes(true).collect();
843 let ac = if self.is_image_line(al) {
844 0
845 } else {
846 ac.min(sc.len())
847 };
848 let bc = if self.is_image_line(bl) {
849 0
850 } else {
851 bc.min(ec.len())
852 };
853 let merged = format!("{}{}", sc[..ac].concat(), ec[bc..].concat());
854 if !self.line_text_fits(&merged) {
855 return false;
856 }
857 let len = self.line_max_len;
858 self.lines[al] = self.line_with_text(al, &merged, len);
859 for _ in al..bl {
861 if al + 1 < self.lines.len() {
862 self.lines.remove(al + 1);
863 }
864 }
865 self.cursor = al;
866 if let Some(input) = self.lines[al].input() {
867 input.place_cursor(ac.min(input.len()));
868 }
869 }
870 self.sel_anchor = None;
871 return true;
872 }
873 if let Some(input) = self.input() {
874 return input.delete_selection();
875 }
876 false
877 }
878
879 fn line_with_text(&self, index: usize, text: &str, len: usize) -> Line {
880 match &self.lines[index] {
881 Line::Todo { done, .. } => Line::Todo {
882 text: TextInput::new(text, len),
883 done: *done,
884 },
885 Line::Bullet(_) => Line::Bullet(TextInput::new(text, len)),
886 Line::Number(_) => Line::Number(TextInput::new(text, len)),
887 Line::Link(_) => Line::Link(TextInput::new(text, len)),
888 Line::Text(_) | Line::Image { .. } => Line::Text(TextInput::new(text, len)),
889 }
890 }
891
892 pub fn link_url_at_cursor(&self) -> Option<String> {
894 match &self.lines[self.cursor] {
895 Line::Link(t) => {
896 let u = t.value();
897 let u = u.trim();
898 (!u.is_empty()).then(|| u.to_string())
899 }
900 _ => None,
901 }
902 }
903
904 pub fn link_url_at_position(&self, row: u16, col: usize) -> Option<String> {
907 use unicode_width::UnicodeWidthStr;
908
909 let width = self.layout_width.max(1);
910 let numbers = number_runs(&self.lines);
911 let target = self.scroll.saturating_add(usize::from(row));
912 let mut at = 0usize;
913 for (index, line) in self.lines.iter().enumerate() {
914 let height = line.height(width, numbers[index]);
915 if target >= at.saturating_add(height) {
916 at = at.saturating_add(height);
917 continue;
918 }
919 let Line::Link(input) = line else {
920 return None;
921 };
922 let row_in = target.saturating_sub(at);
923 let indent = line.indent();
924 let field = width.saturating_sub(indent).max(1);
925 let breaks = input.wrap_breaks(field);
926 let &(start, end) = breaks.get(row_in)?;
927 let text = input.slice(start, end);
928 let text_width = text.width();
929 let on_marker = row_in == 0 && col < indent;
930 let on_text = col >= indent && col < indent.saturating_add(text_width);
931 if !on_marker && !on_text {
932 return None;
933 }
934 let url = input.value();
935 let url = url.trim();
936 return (!url.is_empty()).then(|| url.to_string());
937 }
938 None
939 }
940
941 pub fn selected_image(&self) -> Option<PathBuf> {
942 match &self.lines[self.cursor] {
943 Line::Image { path } => Some(resolve_image_reference(
944 path,
945 &self.image_root,
946 &self.attachments,
947 )),
948 _ => None,
949 }
950 }
951
952 pub fn images(&self) -> Vec<PathBuf> {
954 self.lines
955 .iter()
956 .filter_map(|l| match l {
957 Line::Image { path } => Some(resolve_image_reference(
958 path,
959 &self.image_root,
960 &self.attachments,
961 )),
962 _ => None,
963 })
964 .collect()
965 }
966
967 pub fn abandon_image_selection(&mut self) {
972 if !matches!(self.lines.get(self.cursor), Some(Line::Image { .. })) {
973 return;
974 }
975 if let Some(next) = self.next_editable(self.cursor) {
976 self.cursor = next;
977 if let Some(input) = self.input() {
978 input.home();
979 }
980 return;
981 }
982 if let Some(prev) = self.prev_editable(self.cursor) {
983 self.cursor = prev;
984 if let Some(input) = self.input() {
985 input.end();
986 }
987 }
988 }
989
990 fn line(&mut self) -> &mut Line {
991 &mut self.lines[self.cursor]
992 }
993
994 fn input(&mut self) -> Option<&mut TextInput> {
995 self.lines[self.cursor].input()
996 }
997
998 pub fn insert(&mut self, c: char) {
1001 if self.has_selection() && !self.delete_body_selection() {
1003 return;
1004 }
1005 if self.input().is_none() {
1006 self.insert_block(Block::text(""));
1008 }
1009 if let Some(input) = self.input() {
1010 input.insert(c);
1011 }
1012 if c == ' '
1014 && let Line::Text(text) = &self.lines[self.cursor]
1015 {
1016 let v = text.value();
1017 if text.cursor() == v.graphemes(true).count() {
1018 if matches!(v.as_str(), "- " | "* ") {
1019 self.lines[self.cursor] = Line::Bullet(TextInput::new("", self.line_max_len));
1020 return;
1021 }
1022 if let Some(rest) = v.strip_suffix(". ")
1023 && !rest.is_empty()
1024 && rest.chars().all(|ch| ch.is_ascii_digit())
1025 {
1026 self.lines[self.cursor] = Line::Number(TextInput::new("", self.line_max_len));
1027 return;
1028 }
1029 }
1030 }
1031 if c == '/' {
1032 let start = self.input().map(|i| i.cursor()).unwrap_or(0);
1033 self.menu = Some(SlashMenu {
1034 start,
1035 query: String::new(),
1036 index: 0,
1037 });
1038 } else if self.menu.is_some() {
1039 if let Some(menu) = &mut self.menu {
1040 menu.query.push(c);
1041 menu.index = 0;
1042 }
1043 if self.menu_commands().is_empty() {
1045 self.close_menu();
1046 }
1047 }
1048 self.try_adopt_line(self.cursor);
1050 }
1051
1052 pub fn insert_str(&mut self, text: &str) {
1053 self.close_menu();
1054 if self.has_selection() && !self.delete_body_selection() {
1055 return;
1056 }
1057 let text = match flatten_if_image_path(text, &self.image_root) {
1059 Some(flat) if self.line_text_fits(&flat) => flat,
1060 _ => text.to_string(),
1061 };
1062 for (i, part) in text.split('\n').enumerate() {
1063 if i > 0 && !self.newline() {
1064 break;
1065 }
1066 if self.input().is_none() {
1067 self.insert_block(Block::text(""));
1068 }
1069 if let Some(input) = self.input() {
1070 input.insert_str(part.trim_end_matches('\r'));
1071 }
1072 }
1073 self.adopt_pasted_paths();
1076 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1077 let next_is_text = matches!(self.lines.get(self.cursor + 1), Some(Line::Text(_)));
1078 if !next_is_text && self.can_add_lines(1) {
1079 self.lines.insert(self.cursor + 1, self.empty_line());
1080 }
1081 if matches!(self.lines.get(self.cursor + 1), Some(Line::Text(_))) {
1082 self.cursor += 1;
1083 }
1084 }
1085 }
1086
1087 pub fn newline(&mut self) -> bool {
1091 self.close_menu();
1092 if !self.can_add_lines(1) {
1093 return false;
1094 }
1095 let tail = match self.line() {
1096 Line::Text(text)
1097 | Line::Todo { text, .. }
1098 | Line::Bullet(text)
1099 | Line::Number(text)
1100 | Line::Link(text) => text.split_off_at_cursor(),
1101 Line::Image { .. } => TextInput::new("", self.line_max_len),
1102 };
1103 self.lines.insert(self.cursor + 1, Line::Text(tail));
1104 self.cursor += 1;
1105 self.adopt_pasted_paths();
1107 true
1108 }
1109
1110 pub fn backspace(&mut self) {
1111 if let Some(start) = self.menu.as_ref().map(|m| m.start) {
1112 let at = self.input().map(|i| i.cursor()).unwrap_or(0);
1113 if at <= start {
1114 self.close_menu();
1115 } else if let Some(menu) = &mut self.menu {
1116 menu.query.pop();
1117 menu.index = 0;
1118 }
1119 }
1120 let had_selection = self.has_selection();
1121 if self.delete_body_selection() {
1122 return;
1123 }
1124 if had_selection {
1125 return;
1126 }
1127 match self.line() {
1128 Line::Todo { text, .. }
1131 | Line::Bullet(text)
1132 | Line::Number(text)
1133 | Line::Link(text)
1134 if text.at_start() =>
1135 {
1136 let text = text.clone();
1137 self.lines[self.cursor] = Line::Text(text);
1138 return;
1139 }
1140 Line::Text(text) if text.at_start() => {}
1141 Line::Image { .. } => {
1142 self.remove_block();
1143 return;
1144 }
1145 _ => {
1146 if let Some(input) = self.input() {
1147 input.backspace();
1148 }
1149 return;
1150 }
1151 }
1152 if self.cursor == 0 {
1154 return;
1155 }
1156 let current = self.lines.remove(self.cursor);
1157 self.cursor -= 1;
1158 match current {
1159 Line::Text(text) => {
1160 let merged = match self.lines[self.cursor].input() {
1161 Some(previous) => previous.append(&text),
1162 None => text.is_empty(),
1164 };
1165 if !merged {
1166 self.cursor += 1;
1167 self.lines.insert(self.cursor, Line::Text(text));
1168 }
1169 }
1170 line => {
1171 self.cursor += 1;
1172 self.lines.insert(self.cursor, line);
1173 }
1174 }
1175 }
1176
1177 pub fn delete(&mut self) {
1178 self.close_menu();
1179 let had_selection = self.has_selection();
1180 if self.delete_body_selection() {
1181 return;
1182 }
1183 if had_selection {
1184 return;
1185 }
1186 if matches!(self.line(), Line::Image { .. }) {
1187 self.remove_block();
1188 return;
1189 }
1190 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1191 if !at_end {
1192 if let Some(input) = self.input() {
1193 input.delete();
1194 }
1195 return;
1196 }
1197 if self.cursor + 1 >= self.lines.len() {
1198 return;
1199 }
1200 let next = self.lines.remove(self.cursor + 1);
1201 let merged = match next.input_ref() {
1202 Some(text) => self.lines[self.cursor]
1203 .input()
1204 .is_some_and(|current| current.append(text)),
1205 None => false,
1206 };
1207 if !merged {
1208 self.lines.insert(self.cursor + 1, next);
1209 }
1210 }
1211
1212 pub fn remove_block(&mut self) {
1214 if self.lines.len() == 1 {
1215 self.lines[0] = self.empty_line();
1216 return;
1217 }
1218 self.lines.remove(self.cursor);
1219 self.cursor = self.cursor.min(self.lines.len() - 1);
1220 }
1221
1222 pub fn toggle(&mut self) {
1223 if let Line::Todo { done, .. } = self.line() {
1224 *done = !*done;
1225 }
1226 }
1227
1228 fn field_width_for(&self, index: usize) -> usize {
1231 let width = self.layout_width.max(1);
1232 let n = match &self.lines[index] {
1233 Line::Number(_) => Some(number_at(&self.lines, index)),
1234 _ => None,
1235 };
1236 let indent = n
1237 .map(number_indent)
1238 .unwrap_or_else(|| self.lines[index].indent());
1239 width.saturating_sub(indent).max(1)
1240 }
1241
1242 pub fn up(&mut self) {
1243 self.close_menu();
1244 self.clear_body_selection();
1245 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1247 self.leave_image_backward();
1248 return;
1249 }
1250 let width = self.field_width_for(self.cursor);
1251 let prefer = self.prefer_col;
1252 let moved = self
1253 .input()
1254 .is_some_and(|input| input.wrap_up(width, prefer));
1255 if moved {
1256 if self.prefer_col == u16::MAX
1257 && let Some(input) = self.input()
1258 {
1259 self.prefer_col = input.wrap_cursor(width).1;
1260 }
1261 return;
1262 }
1263 if self.cursor > 0 {
1264 let left = self.cursor;
1265 self.cursor -= 1;
1266 self.try_adopt_line(left);
1267 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1269 return;
1270 }
1271 let width = self.field_width_for(self.cursor);
1272 let prefer = if self.prefer_col == u16::MAX {
1273 0
1274 } else {
1275 self.prefer_col
1276 };
1277 if let Some(input) = self.input() {
1278 let last = input.wrap_height(width).saturating_sub(1);
1279 input.set_cursor_from_wrap(width, last, prefer as usize);
1280 }
1281 }
1282 }
1283
1284 pub fn down(&mut self) {
1285 self.close_menu();
1286 self.clear_body_selection();
1287 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1288 self.leave_image_forward();
1289 return;
1290 }
1291 let width = self.field_width_for(self.cursor);
1292 let prefer = self.prefer_col;
1293 let moved = self
1294 .input()
1295 .is_some_and(|input| input.wrap_down(width, prefer));
1296 if moved {
1297 if self.prefer_col == u16::MAX
1298 && let Some(input) = self.input()
1299 {
1300 self.prefer_col = input.wrap_cursor(width).1;
1301 }
1302 return;
1303 }
1304 if self.cursor + 1 < self.lines.len() {
1305 let left = self.cursor;
1306 self.cursor += 1;
1307 self.try_adopt_line(left);
1308 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1310 return;
1311 }
1312 let width = self.field_width_for(self.cursor);
1313 let prefer = if self.prefer_col == u16::MAX {
1314 0
1315 } else {
1316 self.prefer_col
1317 };
1318 if let Some(input) = self.input() {
1319 input.set_cursor_from_wrap(width, 0, prefer as usize);
1320 }
1321 }
1322 }
1323
1324 pub fn left(&mut self) {
1325 self.close_menu();
1326 self.prefer_col = u16::MAX;
1327 self.clear_body_selection();
1328 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1331 self.leave_image_backward();
1332 return;
1333 }
1334 match self.input() {
1335 Some(input) if !input.at_start() => input.left(),
1336 _ => {
1337 if self.cursor > 0 {
1338 self.cursor -= 1;
1339 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1341 return;
1342 }
1343 if let Some(input) = self.input() {
1344 input.end();
1345 }
1346 }
1347 }
1348 }
1349 }
1350
1351 pub fn right(&mut self) {
1352 self.close_menu();
1353 self.prefer_col = u16::MAX;
1354 self.clear_body_selection();
1355 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1358 self.leave_image_forward();
1359 return;
1360 }
1361 match self.input() {
1362 Some(input) if !input.at_end() => input.right(),
1363 _ => {
1364 if self.cursor + 1 < self.lines.len() {
1365 self.cursor += 1;
1366 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1368 return;
1369 }
1370 if let Some(input) = self.input() {
1371 input.home();
1372 }
1373 }
1374 }
1375 }
1376 }
1377
1378 fn leave_image_forward(&mut self) {
1382 let next = self.cursor + 1;
1383 if next < self.lines.len() && self.lines[next].input_ref().is_some() {
1384 self.cursor = next;
1385 if let Some(input) = self.input() {
1386 input.home();
1387 }
1388 return;
1389 }
1390 if !self.can_add_lines(1) {
1391 return;
1392 }
1393 self.lines.insert(next, self.empty_line());
1396 self.cursor = next;
1397 }
1398
1399 fn leave_image_backward(&mut self) {
1402 if self.cursor > 0 && self.lines[self.cursor - 1].input_ref().is_some() {
1403 self.cursor -= 1;
1404 if let Some(input) = self.input() {
1405 input.end();
1406 }
1407 return;
1408 }
1409 if !self.can_add_lines(1) {
1410 return;
1411 }
1412 self.lines.insert(self.cursor, self.empty_line());
1413 if let Some(input) = self.input() {
1415 input.home();
1416 }
1417 }
1418
1419 pub fn home(&mut self) {
1420 self.close_menu();
1421 self.clear_body_selection();
1422 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1423 self.leave_image_backward();
1424 return;
1425 }
1426 if let Some(input) = self.input() {
1427 input.home();
1428 }
1429 }
1430
1431 pub fn end(&mut self) {
1432 self.close_menu();
1433 self.clear_body_selection();
1434 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1435 self.leave_image_forward();
1436 return;
1437 }
1438 if let Some(input) = self.input() {
1439 input.end();
1440 }
1441 }
1442
1443 pub fn word_left(&mut self) {
1444 self.close_menu();
1445 self.clear_body_selection();
1446 self.prefer_col = u16::MAX;
1447 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1448 self.leave_image_backward();
1449 return;
1450 }
1451 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1452 if !at_start {
1453 if let Some(input) = self.input() {
1454 input.word_left();
1455 }
1456 return;
1457 }
1458 if let Some(prev) = self.prev_editable(self.cursor) {
1460 self.cursor = prev;
1461 if let Some(input) = self.input() {
1462 input.end();
1463 input.word_left();
1464 }
1465 }
1466 }
1467
1468 pub fn word_right(&mut self) {
1469 self.close_menu();
1470 self.clear_body_selection();
1471 self.prefer_col = u16::MAX;
1472 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1473 self.leave_image_forward();
1474 return;
1475 }
1476 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1477 if !at_end {
1478 if let Some(input) = self.input() {
1479 input.word_right();
1480 }
1481 return;
1482 }
1483 if let Some(next) = self.next_editable(self.cursor) {
1484 self.cursor = next;
1485 if let Some(input) = self.input() {
1486 input.home();
1487 input.word_right();
1488 }
1489 }
1490 }
1491
1492 fn prev_editable(&self, from: usize) -> Option<usize> {
1493 (0..from)
1494 .rev()
1495 .find(|&i| self.lines[i].input_ref().is_some())
1496 }
1497
1498 fn next_editable(&self, from: usize) -> Option<usize> {
1499 ((from + 1)..self.lines.len()).find(|&i| self.lines[i].input_ref().is_some())
1500 }
1501
1502 pub fn select_word(&mut self) {
1503 self.close_menu();
1504 self.sel_anchor = None;
1505 if let Some(input) = self.input() {
1506 input.select_word();
1507 }
1508 }
1509
1510 pub fn select_left(&mut self) {
1511 self.close_menu();
1512 self.prefer_col = u16::MAX;
1513 self.ensure_sel_anchor();
1514 if let Some(input) = self.input() {
1515 input.clear_selection();
1516 }
1517 if self.is_image_line(self.cursor) {
1519 self.step_sel_prev_line();
1520 return;
1521 }
1522 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1523 if !at_start {
1524 if let Some(input) = self.input() {
1525 let c = input.cursor().saturating_sub(1);
1526 input.place_cursor(c);
1527 }
1528 } else {
1529 self.step_sel_prev_line();
1530 }
1531 }
1532
1533 pub fn select_right(&mut self) {
1534 self.close_menu();
1535 self.prefer_col = u16::MAX;
1536 self.ensure_sel_anchor();
1537 if let Some(input) = self.input() {
1538 input.clear_selection();
1539 }
1540 if self.is_image_line(self.cursor) {
1541 self.step_sel_next_line();
1542 return;
1543 }
1544 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1545 if !at_end {
1546 if let Some(input) = self.input() {
1547 let c = (input.cursor() + 1).min(input.len());
1548 input.place_cursor(c);
1549 }
1550 } else {
1551 self.step_sel_next_line();
1552 }
1553 }
1554
1555 pub fn select_word_left(&mut self) {
1557 self.close_menu();
1558 self.prefer_col = u16::MAX;
1559 self.ensure_sel_anchor();
1560 if let Some(input) = self.input() {
1561 input.clear_selection();
1562 }
1563 if self.is_image_line(self.cursor) {
1565 self.step_sel_prev_line();
1566 if !self.is_image_line(self.cursor)
1567 && let Some(input) = self.input()
1568 {
1569 input.end();
1570 let target = input.word_left_index();
1571 input.place_cursor(target);
1572 }
1573 return;
1574 }
1575 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1576 if !at_start {
1577 if let Some(input) = self.input() {
1578 let target = input.word_left_index();
1579 input.place_cursor(target);
1580 }
1581 return;
1582 }
1583 self.step_sel_prev_line();
1584 if self.is_image_line(self.cursor) {
1585 return;
1586 }
1587 if let Some(input) = self.input() {
1588 input.end();
1589 let target = input.word_left_index();
1590 input.place_cursor(target);
1591 }
1592 }
1593
1594 pub fn select_word_right(&mut self) {
1596 self.close_menu();
1597 self.prefer_col = u16::MAX;
1598 self.ensure_sel_anchor();
1599 if let Some(input) = self.input() {
1600 input.clear_selection();
1601 }
1602 if self.is_image_line(self.cursor) {
1603 self.step_sel_next_line();
1604 if !self.is_image_line(self.cursor)
1605 && let Some(input) = self.input()
1606 {
1607 input.home();
1608 let target = input.word_right_index();
1609 input.place_cursor(target);
1610 }
1611 return;
1612 }
1613 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1614 if !at_end {
1615 if let Some(input) = self.input() {
1616 let target = input.word_right_index();
1617 input.place_cursor(target);
1618 }
1619 return;
1620 }
1621 self.step_sel_next_line();
1622 if self.is_image_line(self.cursor) {
1623 return;
1624 }
1625 if let Some(input) = self.input() {
1626 input.home();
1627 let target = input.word_right_index();
1628 input.place_cursor(target);
1629 }
1630 }
1631
1632 fn step_sel_prev_line(&mut self) {
1635 if self.cursor == 0 {
1636 return;
1637 }
1638 self.cursor -= 1;
1639 if let Some(input) = self.input() {
1640 input.end();
1641 }
1642 }
1643
1644 fn step_sel_next_line(&mut self) {
1646 if self.cursor + 1 >= self.lines.len() {
1647 return;
1648 }
1649 self.cursor += 1;
1650 if let Some(input) = self.input() {
1651 input.home();
1652 }
1653 }
1654
1655 pub fn select_home(&mut self) {
1656 self.close_menu();
1657 self.ensure_sel_anchor();
1658 if let Some(input) = self.input() {
1659 input.clear_selection();
1660 input.place_cursor(0);
1661 }
1662 }
1663
1664 pub fn select_end(&mut self) {
1665 self.close_menu();
1666 self.ensure_sel_anchor();
1667 if let Some(input) = self.input() {
1668 input.clear_selection();
1669 let n = input.len();
1670 input.place_cursor(n);
1671 }
1672 }
1673
1674 pub fn delete_to_start(&mut self) {
1675 if let Some(input) = self.input() {
1676 input.delete_to_start();
1677 }
1678 }
1679
1680 pub fn delete_to_end(&mut self) {
1681 if let Some(input) = self.input() {
1682 input.delete_to_end();
1683 }
1684 }
1685
1686 pub fn delete_word_left(&mut self) {
1687 if let Some(input) = self.input() {
1688 input.delete_word_left();
1689 }
1690 }
1691
1692 pub fn menu_next(&mut self) {
1695 let count = self.menu_commands().len();
1696 if let Some(menu) = &mut self.menu
1697 && count > 0
1698 {
1699 menu.index = (menu.index + 1) % count;
1700 }
1701 }
1702
1703 pub fn menu_prev(&mut self) {
1704 let count = self.menu_commands().len();
1705 if let Some(menu) = &mut self.menu
1706 && count > 0
1707 {
1708 menu.index = (menu.index + count - 1) % count;
1709 }
1710 }
1711
1712 pub fn close_menu(&mut self) {
1713 self.menu = None;
1714 }
1715
1716 pub fn menu_selected(&self) -> Option<Command> {
1718 self.menu
1719 .as_ref()
1720 .and_then(|m| m.selected_in(self.allowed_commands()))
1721 }
1722
1723 pub fn text_for_copy(&self) -> String {
1725 self.lines_for_copy_all()
1726 .into_iter()
1727 .filter_map(|line| match line {
1728 CopyLine::Text(s) | CopyLine::Link(s) => Some(s),
1729 CopyLine::Image(_) => None,
1730 })
1731 .collect::<Vec<_>>()
1732 .join("\n")
1733 }
1734
1735 pub fn lines_for_copy_all(&self) -> Vec<CopyLine> {
1737 self.lines
1738 .iter()
1739 .enumerate()
1740 .filter_map(|(i, line)| match line {
1741 Line::Text(text) => {
1742 let s = text.value();
1743 (!s.trim().is_empty()).then_some(CopyLine::Text(s))
1744 }
1745 Line::Bullet(text) => Some(CopyLine::Text(format!("- {}", text.value()))),
1746 Line::Number(text) => {
1747 let n = number_at(&self.lines, i);
1748 Some(CopyLine::Text(format!("{n}. {}", text.value())))
1749 }
1750 Line::Todo { text, done } => {
1751 let mark = if *done { "[✓]" } else { "[ ]" };
1752 Some(CopyLine::Text(format!("{mark} {}", text.value())))
1753 }
1754 Line::Link(text) => {
1755 let s = text.value();
1756 (!s.trim().is_empty()).then_some(CopyLine::Link(s))
1757 }
1758 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
1759 path,
1760 &self.image_root,
1761 &self.attachments,
1762 ))),
1763 })
1764 .collect()
1765 }
1766
1767 pub fn image_for_copy(&self) -> Option<PathBuf> {
1769 for i in (0..=self.cursor).rev() {
1770 if let Line::Image { path } = &self.lines[i] {
1771 return Some(resolve_image_reference(
1772 path,
1773 &self.image_root,
1774 &self.attachments,
1775 ));
1776 }
1777 }
1778 for i in (self.cursor + 1)..self.lines.len() {
1779 if let Line::Image { path } = &self.lines[i] {
1780 return Some(resolve_image_reference(
1781 path,
1782 &self.image_root,
1783 &self.attachments,
1784 ));
1785 }
1786 }
1787 None
1788 }
1789
1790 pub fn apply(&mut self, command: Command) -> Option<CopyPayload> {
1793 if !self.allowed_commands().contains(&command) {
1794 self.close_menu();
1795 return None;
1796 }
1797 let menu = self.menu.take()?;
1798 if let Some(input) = self.input() {
1799 let end = input.cursor();
1801 input.set_cursor(end);
1802 for _ in menu.start.saturating_sub(1)..end {
1803 input.backspace();
1804 }
1805 }
1806 match command {
1807 Command::Copy => Some(CopyPayload::Text(self.text_for_copy())),
1808 Command::CopyImage => self.image_for_copy().map(CopyPayload::Image),
1809 Command::CopyAll => Some(CopyPayload::All(self.lines_for_copy_all())),
1810 Command::Todo | Command::Bullet | Command::Number | Command::Link => {
1811 let text = match self.line() {
1812 Line::Text(text)
1813 | Line::Todo { text, .. }
1814 | Line::Bullet(text)
1815 | Line::Number(text)
1816 | Line::Link(text) => text.clone(),
1817 Line::Image { .. } => return None,
1818 };
1819 self.lines[self.cursor] = match command {
1820 Command::Todo => Line::Todo { text, done: false },
1821 Command::Bullet => Line::Bullet(text),
1822 Command::Number => Line::Number(text),
1823 Command::Link => {
1824 let url = link_url_from_line(&text.value());
1825 Line::Link(TextInput::new(&url, self.line_max_len))
1826 }
1827 Command::Copy | Command::CopyImage | Command::CopyAll => return None,
1828 };
1829 None
1830 }
1831 }
1832 }
1833
1834 pub fn insert_block(&mut self, block: Block) {
1837 self.close_menu();
1838 let is_image = matches!(block, Block::Image { .. });
1839 let replace = match &self.lines[self.cursor] {
1840 Line::Text(text) => text.is_empty(),
1841 _ => false,
1842 };
1843 let extra = match (replace, is_image) {
1844 (true, true) => 1, (true, false) => 0,
1846 (false, true) => 2, (false, false) => 1,
1848 };
1849 if extra > 0 && !self.can_add_lines(extra) {
1850 return;
1851 }
1852 let line = line_from_block(&block, self.line_max_len);
1853 if replace {
1854 self.lines[self.cursor] = line;
1855 } else {
1856 self.lines.insert(self.cursor + 1, line);
1857 self.cursor += 1;
1858 }
1859 if is_image {
1861 self.lines.insert(self.cursor + 1, self.empty_line());
1862 self.cursor += 1;
1863 }
1864 }
1865
1866 fn adopt_pasted_paths(&mut self) {
1868 if self.plain {
1869 return;
1870 }
1871 self.merge_broken_image_paths();
1872 for i in 0..self.lines.len() {
1873 self.try_adopt_line(i);
1874 }
1875 }
1876
1877 fn try_adopt_line(&mut self, i: usize) {
1878 let Line::Text(text) = &self.lines[i] else {
1879 return;
1880 };
1881 let value = text.value();
1882 if !crate::image::looks_like_image(&value) {
1883 return;
1884 }
1885 if let Some(path) = crate::image::path_if_image_in(&value, &self.image_root) {
1886 self.lines[i] = Line::Image {
1887 path: crate::image::short_in(&path, &self.image_root),
1888 };
1889 }
1890 }
1891
1892 fn merge_broken_image_paths(&mut self) {
1895 let mut i = 0;
1896 while i + 1 < self.lines.len() {
1897 let joined = match (&self.lines[i], &self.lines[i + 1]) {
1898 (Line::Text(a), Line::Text(b)) => {
1899 let left = a.value();
1900 let right = b.value();
1901 let joined = format!("{left}{right}");
1902 if left.contains('/')
1905 && !crate::image::looks_like_image(&left)
1906 && self.line_text_fits(&joined)
1907 && crate::image::path_if_image_in(&joined, &self.image_root).is_some()
1908 {
1909 Some(joined)
1910 } else {
1911 None
1912 }
1913 }
1914 _ => None,
1915 };
1916 if let Some(path) = joined {
1917 let len = self.line_max_len;
1918 self.lines[i] = Line::Text(TextInput::new(&path, len));
1919 self.lines.remove(i + 1);
1920 if self.cursor > i {
1921 self.cursor -= 1;
1922 }
1923 } else {
1925 i += 1;
1926 }
1927 }
1928 }
1929
1930 pub fn layout(&mut self, width: usize, height: u16) -> (Vec<Placed>, Option<(u16, u16)>) {
1936 if height == 0 || width == 0 {
1937 return (Vec::new(), None);
1938 }
1939 self.layout_width = width;
1940
1941 let numbers = number_runs(&self.lines);
1942 let mut total = 0usize;
1943 let layouts: Vec<LineLayout> = self
1944 .lines
1945 .iter()
1946 .enumerate()
1947 .map(|(i, line)| {
1948 let number = numbers[i];
1949 let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
1950 let field = width.saturating_sub(indent).max(1);
1951 let wraps = line
1952 .input_ref()
1953 .map(|text| text.wrap_breaks(field))
1954 .unwrap_or_default();
1955 let rows = if matches!(line, Line::Image { .. }) {
1956 usize::from(IMAGE_ROWS)
1957 } else {
1958 wraps.len().max(1)
1959 };
1960 let layout = LineLayout {
1961 number,
1962 wraps,
1963 start: total,
1964 rows,
1965 selection: self
1966 .char_sel_on_line(i)
1967 .or_else(|| line.input_ref().and_then(TextInput::selection_range)),
1968 selected: i == self.cursor || self.line_in_selection(i),
1969 };
1970 total = total.saturating_add(rows);
1971 layout
1972 })
1973 .collect();
1974 self.content_height = total;
1975
1976 let cursor_visual = {
1978 let layout = &layouts[self.cursor];
1979 let row_in_block = self.lines[self.cursor]
1980 .input_ref()
1981 .map(|text| text.wrap_cursor_from_breaks(&layout.wraps).0)
1982 .unwrap_or(0);
1983 layout.start.saturating_add(row_in_block)
1984 };
1985 if cursor_visual < self.scroll {
1986 self.scroll = cursor_visual;
1987 } else if cursor_visual >= self.scroll.saturating_add(usize::from(height)) {
1988 self.scroll = cursor_visual + 1 - usize::from(height);
1989 }
1990 self.scroll = self.scroll.min(total.saturating_sub(usize::from(height)));
1991
1992 let mut placed = Vec::new();
1993 let mut cursor_at = None;
1994 for (i, (line, layout)) in self.lines.iter_mut().zip(&layouts).enumerate() {
1995 let Some((y, vis_rows, skip)) =
2000 visible_band(layout.start, layout.rows, self.scroll, height)
2001 else {
2002 continue;
2003 };
2004 let (text, kind) = match line {
2005 Line::Todo { text, done } => (text, TextKind::Todo { done: *done }),
2006 Line::Bullet(text) => (text, TextKind::Bullet),
2007 Line::Number(text) => (text, TextKind::Number(layout.number.unwrap_or(1))),
2008 Line::Link(text) => (text, TextKind::Link),
2009 Line::Text(text) => (text, TextKind::Plain),
2010 Line::Image { path } => {
2011 placed.push(Placed {
2012 block: Painted::Image(resolve_image_reference(
2013 path,
2014 &self.image_root,
2015 &self.attachments,
2016 )),
2017 line: i,
2018 y,
2019 rows: vis_rows,
2020 selected: layout.selected,
2021 });
2022 continue;
2023 }
2024 };
2025 let indent = kind.indent();
2026 let view = text.wrapped_from_breaks(&layout.wraps, layout.selection);
2027 if i == self.cursor {
2028 let row = usize::from(view.cursor_row).saturating_sub(skip) as u16;
2029 cursor_at = Some((y.saturating_add(row), view.cursor_col + indent as u16));
2030 }
2031 let wrap_rows: Vec<WrappedRow> = view
2032 .lines
2033 .into_iter()
2034 .skip(skip)
2035 .take(vis_rows as usize)
2036 .map(|l| WrappedRow {
2037 text: l.text,
2038 sel: l.sel_cols,
2039 })
2040 .collect();
2041 placed.push(Placed {
2042 block: Painted::Text {
2043 rows: wrap_rows,
2044 kind,
2045 },
2046 line: i,
2047 y,
2048 rows: vis_rows,
2049 selected: layout.selected,
2050 });
2051 }
2052 (placed, cursor_at)
2053 }
2054
2055 pub fn scroll(&self) -> usize {
2057 self.scroll
2058 }
2059
2060 pub fn content_height(&self) -> usize {
2062 self.content_height
2063 }
2064
2065 pub fn click(&mut self, row: u16, col: usize) -> bool {
2069 let width = self.layout_width.max(1);
2070 let numbers = number_runs(&self.lines);
2071 let target = self.scroll.saturating_add(usize::from(row));
2072 let mut at = 0usize;
2073 let mut hit = false;
2074 for (i, line) in self.lines.iter().enumerate() {
2075 let h = line.height(width, numbers[i]);
2076 if target < at.saturating_add(h) {
2077 self.cursor = i;
2078 let row_in = target.saturating_sub(at);
2079 let indent = numbers[i]
2080 .map(number_indent)
2081 .unwrap_or_else(|| line.indent());
2082 let field = width.saturating_sub(indent).max(1);
2083 if let Some(input) = self.lines[i].input() {
2084 input.set_cursor_from_wrap(field, row_in, col.saturating_sub(indent));
2085 }
2086 hit = true;
2087 break;
2088 }
2089 at = at.saturating_add(h);
2090 }
2091 self.close_menu();
2092 self.prefer_col = u16::MAX;
2093 self.clear_body_selection();
2094 hit
2095 }
2096}
2097
2098#[cfg(test)]
2099mod tests {
2100 use super::*;
2101
2102 fn editor(blocks: &[Block]) -> BodyEditor {
2103 BodyEditor::new(blocks)
2104 }
2105
2106 fn type_in(editor: &mut BodyEditor, text: &str) {
2107 for c in text.chars() {
2108 editor.insert(c);
2109 }
2110 }
2111
2112 #[test]
2113 fn types_prose_and_splits_lines() {
2114 let mut e = editor(&[]);
2115 type_in(&mut e, "first");
2116 e.newline();
2117 type_in(&mut e, "second");
2118 assert_eq!(e.value(), vec![Block::text("first"), Block::text("second")]);
2119 }
2120
2121 #[test]
2122 fn refuses_more_lines_than_the_cap() {
2123 let mut e = BodyEditor::from_blocks(&[], 2, 32, false);
2124 type_in(&mut e, "a");
2125 assert!(e.newline());
2126 type_in(&mut e, "b");
2127 assert!(!e.newline(), "third line blocked");
2128 assert_eq!(e.value().len(), 2);
2129 }
2130
2131 #[test]
2132 fn plain_description_uses_shorter_line_cap() {
2133 let mut e = BodyEditor::plain("");
2134 type_in(&mut e, &"x".repeat(MAX_CATEGORY_DESC_LINE_LEN + 10));
2135 assert_eq!(
2136 e.value()[0],
2137 Block::text(&"x".repeat(MAX_CATEGORY_DESC_LINE_LEN))
2138 );
2139 }
2140
2141 #[test]
2142 fn slash_bullet_turns_the_line_into_a_point() {
2143 let mut e = editor(&[]);
2144 type_in(&mut e, "/bul");
2145 e.apply(Command::Bullet);
2146 type_in(&mut e, "a point");
2147 assert_eq!(e.value(), vec![Block::bullet("a point")]);
2148 }
2149
2150 #[test]
2151 fn slash_number_turns_the_line_into_a_list_item() {
2152 let mut e = editor(&[]);
2153 type_in(&mut e, "/num");
2154 assert_eq!(e.menu_selected(), Some(Command::Number));
2155 e.apply(Command::Number);
2156 type_in(&mut e, "first");
2157 e.newline();
2158 type_in(&mut e, "/number");
2160 e.apply(Command::Number);
2161 type_in(&mut e, "second");
2162 assert_eq!(
2163 e.value(),
2164 vec![Block::number("first"), Block::number("second")]
2165 );
2166 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2167 }
2168
2169 #[test]
2170 fn typing_1_dot_space_makes_a_numbered_item() {
2171 let mut e = editor(&[]);
2172 type_in(&mut e, "1. ");
2173 type_in(&mut e, "alpha");
2174 assert_eq!(e.value(), vec![Block::number("alpha")]);
2175 }
2176
2177 #[test]
2178 fn slash_link_turns_the_line_into_a_url() {
2179 let mut e = editor(&[Block::text("https://example.com")]);
2180 e.end();
2181 type_in(&mut e, "/link");
2182 assert_eq!(e.menu_selected(), Some(Command::Link));
2183 e.apply(Command::Link);
2184 assert_eq!(e.value(), vec![Block::link("https://example.com")]);
2185 }
2186
2187 #[test]
2188 fn slash_link_unwraps_markdown() {
2189 let mut e = editor(&[Block::text("[docs](https://example.com/docs)")]);
2190 e.end();
2191 type_in(&mut e, "/link");
2192 e.apply(Command::Link);
2193 assert_eq!(e.value(), vec![Block::link("https://example.com/docs")]);
2194 }
2195
2196 #[test]
2197 fn slash_link_on_empty_line_is_ready_for_a_url() {
2198 let mut e = editor(&[]);
2199 type_in(&mut e, "/link");
2200 e.apply(Command::Link);
2201 type_in(&mut e, "https://x.ai");
2202 assert_eq!(e.value(), vec![Block::link("https://x.ai")]);
2203 }
2204
2205 #[test]
2206 fn slash_todo_turns_the_line_into_a_task() {
2207 let mut e = editor(&[]);
2208 type_in(&mut e, "/todo");
2209 let menu = e.menu.as_ref().expect("menu is open");
2210 assert_eq!(menu.query, "todo");
2211 assert_eq!(e.menu_selected(), Some(Command::Todo));
2212 e.apply(Command::Todo);
2213 type_in(&mut e, "buy milk");
2214 assert_eq!(e.value(), vec![Block::todo("buy milk", false)]);
2215 assert!(e.menu.is_none());
2216 }
2217
2218 #[test]
2219 fn enter_in_a_todo_starts_a_plain_line() {
2220 let mut e = editor(&[Block::todo("one", false)]);
2221 e.end();
2222 e.newline();
2223 type_in(&mut e, "two");
2224 assert_eq!(
2225 e.value(),
2226 vec![Block::todo("one", false), Block::text("two")]
2227 );
2228 }
2229
2230 #[test]
2231 fn backspace_at_the_start_unmakes_a_todo() {
2232 let mut e = editor(&[Block::todo("one", false)]);
2233 e.home();
2234 e.backspace();
2235 assert_eq!(e.value(), vec![Block::text("one")]);
2236 }
2237
2238 #[test]
2239 fn moving_the_cursor_closes_the_body_command_menu() {
2240 let mut editor = BodyEditor::new(&[]);
2241 for c in "/todo".chars() {
2242 editor.insert(c);
2243 }
2244 assert!(editor.menu.is_some());
2245
2246 editor.left();
2247
2248 assert!(
2249 editor.menu.is_none(),
2250 "the cached query must never outlive its caret range"
2251 );
2252 assert_eq!(editor.value(), vec![Block::text("/todo")]);
2253 }
2254
2255 #[test]
2256 fn link_hit_testing_excludes_blank_row_padding() {
2257 let mut editor = BodyEditor::new(&[Block::link("https://example.com")]);
2258 let _ = editor.layout(40, 4);
2259
2260 assert_eq!(
2261 editor.link_url_at_position(0, 3).as_deref(),
2262 Some("https://example.com")
2263 );
2264 assert_eq!(editor.link_url_at_position(0, 39), None);
2265 }
2266
2267 #[test]
2268 fn toggling_counts_towards_progress() {
2269 let mut e = editor(&[Block::todo("a", false), Block::todo("b", false)]);
2270 assert_eq!(e.progress(), (0, 2));
2271 e.toggle();
2272 assert_eq!(e.progress(), (1, 2));
2273 }
2274
2275 #[test]
2276 fn an_image_lands_between_the_lines_with_room_to_type() {
2277 let mut e = editor(&[]);
2278 type_in(&mut e, "before");
2279 e.newline();
2280 e.insert_block(Block::image("/tmp/a.png"));
2281 type_in(&mut e, "after");
2282 assert_eq!(
2283 e.value(),
2284 vec![
2285 Block::text("before"),
2286 Block::image("/tmp/a.png"),
2287 Block::text("after")
2288 ]
2289 );
2290 assert_eq!(e.images().len(), 1);
2291 }
2292
2293 #[test]
2294 fn a_path_in_the_body_becomes_a_picture() {
2295 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2297 let e = editor(&[Block::text(path), Block::text("below")]);
2298 assert!(matches!(e.value()[0], Block::Image { .. }));
2299 assert_eq!(e.value()[1], Block::text("below"));
2300 }
2301
2302 #[test]
2303 fn a_path_to_nothing_stays_text() {
2304 let e = editor(&[Block::text("/tmp/not-here-at-all.png")]);
2305 assert!(matches!(e.value()[0], Block::Text { .. }));
2306 }
2307
2308 #[test]
2309 fn a_pasted_path_is_a_picture_at_once() {
2310 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2311 let mut e = editor(&[]);
2312 e.insert_str(path);
2313 assert!(
2314 matches!(e.value()[0], Block::Image { .. }),
2315 "no need to move the cursor off it first"
2316 );
2317 type_in(&mut e, "and on we go");
2318 assert_eq!(e.value()[1], Block::text("and on we go"));
2319 }
2320
2321 #[test]
2322 fn a_complete_path_becomes_a_picture_even_under_the_cursor() {
2323 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2324 let mut e = editor(&[]);
2325 type_in(&mut e, path);
2326 e.layout(40, 20);
2327 assert!(
2328 matches!(e.value()[0], Block::Image { .. }),
2329 "complete existing path converts without leaving the line"
2330 );
2331 }
2332
2333 #[test]
2334 fn shift_option_left_selects_across_lines() {
2335 let mut e = editor(&[Block::text("one two"), Block::text("three four")]);
2336 e.down();
2337 e.end(); e.select_word_left(); assert_eq!(e.selected_text().as_deref(), Some("four"));
2340 e.select_word_left(); e.select_word_left(); let sel = e.selected_text().expect("cross-line selection");
2343 assert!(
2344 sel.contains("two") && sel.contains("three"),
2345 "expected multi-line sel, got {sel:?}"
2346 );
2347 }
2348
2349 #[test]
2350 fn shift_selection_includes_pictures_with_text() {
2351 let path = "/tmp/shot.png";
2352 let mut e = editor(&[
2353 Block::text("above"),
2354 Block::image(path),
2355 Block::text("below here"),
2356 ]);
2357 e.down();
2359 e.down();
2360 e.home();
2361 e.select_word_left(); assert!(e.line_in_selection(1), "picture covered by selection");
2363 assert!(
2364 matches!(e.selected_payload(), Some(CopyPayload::All(_))),
2365 "mixed selection is rich copy"
2366 );
2367 e.select_word_left(); let sel = e.selected_text().expect("text+image selection");
2369 assert!(
2370 sel.contains("above") && sel.contains("[image:") && !sel.contains("below"),
2371 "got {sel:?}"
2372 );
2373 let (placed, _) = e.layout(40, 40);
2375 let img = placed
2376 .iter()
2377 .find(|p| matches!(p.block, Painted::Image(_)))
2378 .expect("image placed");
2379 assert!(img.selected, "outer frame while selection covers image");
2380 }
2381
2382 #[test]
2383 fn a_pasted_path_broken_by_newlines_still_becomes_a_picture() {
2384 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2385 let mid = path.len() / 2;
2387 let broken = format!("{}\n{}", &path[..mid], &path[mid..]);
2388 let mut e = editor(&[]);
2389 e.insert_str(&broken);
2390 assert!(
2391 matches!(e.value()[0], Block::Image { .. }),
2392 "flattened paste: {broken:?} → {:?}",
2393 e.value()
2394 );
2395 }
2396
2397 #[test]
2398 fn two_text_lines_that_form_a_path_are_merged() {
2399 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2400 let mid = path.len() / 2;
2401 let e = editor(&[Block::text(&path[..mid]), Block::text(&path[mid..])]);
2402 assert!(
2403 matches!(e.value()[0], Block::Image { .. }),
2404 "split lines rejoin: {:?}",
2405 e.value()
2406 );
2407 }
2408
2409 #[test]
2410 fn dash_and_a_space_make_a_bullet() {
2411 let mut e = editor(&[]);
2412 type_in(&mut e, "- buy milk");
2413 assert_eq!(e.value(), vec![Block::bullet("buy milk")]);
2414 }
2415
2416 #[test]
2417 fn a_dash_mid_line_is_just_a_dash() {
2418 let mut e = editor(&[]);
2419 type_in(&mut e, "a - b");
2420 assert_eq!(e.value(), vec![Block::text("a - b")]);
2421 }
2422
2423 #[test]
2424 fn backspace_at_the_start_unmakes_a_bullet() {
2425 let mut e = editor(&[Block::bullet("one")]);
2426 e.home();
2427 e.backspace();
2428 assert_eq!(e.value(), vec![Block::text("one")]);
2429 }
2430
2431 #[test]
2432 fn plain_text_round_trips_its_bullets() {
2433 let e = BodyEditor::plain("intro\n- first\n- second");
2434 assert_eq!(e.value().len(), 3);
2435 assert_eq!(e.plain_value(), "intro\n- first\n- second");
2436 }
2437
2438 #[test]
2439 fn plain_mode_rejects_todo_slash_command() {
2440 let mut e = BodyEditor::plain("");
2441 type_in(&mut e, "/todo");
2442 assert!(e.menu.is_none(), "no to-do command in plain mode");
2443 assert_eq!(e.value(), vec![Block::text("/todo")]);
2444 }
2445
2446 #[test]
2447 fn plain_mode_slash_makes_a_bullet() {
2448 let mut e = BodyEditor::plain("");
2449 type_in(&mut e, "/bul");
2450 assert_eq!(e.menu_selected(), Some(Command::Bullet));
2451 e.apply(Command::Bullet);
2452 type_in(&mut e, "a point");
2453 assert_eq!(e.plain_value(), "- a point");
2454 }
2455
2456 #[test]
2457 fn copy_exports_text_and_skips_images() {
2458 let e = editor(&[
2459 Block::text("hello"),
2460 Block::todo("one", true),
2461 Block::image("/tmp/a.png"),
2462 Block::bullet("point"),
2463 ]);
2464 assert_eq!(e.text_for_copy(), "hello\n[✓] one\n- point");
2465 }
2466
2467 #[test]
2468 fn copy_all_keeps_text_and_images_in_order() {
2469 let e = editor(&[
2470 Block::text("hello"),
2471 Block::image("/tmp/a.png"),
2472 Block::bullet("point"),
2473 ]);
2474 let lines = e.lines_for_copy_all();
2475 assert_eq!(lines.len(), 3);
2476 match &lines[0] {
2477 CopyLine::Text(t) => assert_eq!(t, "hello"),
2478 _ => panic!("text first"),
2479 }
2480 match &lines[1] {
2481 CopyLine::Image(p) => assert!(p.ends_with("a.png")),
2482 _ => panic!("image second"),
2483 }
2484 match &lines[2] {
2485 CopyLine::Text(t) => assert_eq!(t, "- point"),
2486 _ => panic!("bullet third"),
2487 }
2488 }
2489
2490 #[test]
2491 fn slash_copy_strips_the_query_and_returns_text() {
2492 let mut e = editor(&[Block::text("keep me")]);
2493 e.end();
2494 e.newline();
2495 type_in(&mut e, "/copy");
2496 assert_eq!(e.menu_selected(), Some(Command::Copy));
2497 match e.apply(Command::Copy).expect("copy yields payload") {
2498 CopyPayload::Text(text) => assert_eq!(text, "keep me"),
2499 CopyPayload::Image(_) | CopyPayload::All(_) => panic!("expected text"),
2500 }
2501 assert!(e.menu.is_none());
2502 assert_eq!(e.value(), vec![Block::text("keep me")]);
2504 }
2505
2506 #[test]
2507 fn image_for_copy_picks_nearest_above_the_cursor() {
2508 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2509 let mut e = editor(&[
2510 Block::text("above"),
2511 Block::image(path),
2512 Block::text("below"),
2513 ]);
2514 e.down();
2516 e.down();
2517 let got = e.image_for_copy().expect("finds the image above");
2518 assert!(got.ends_with("screenshot.png"));
2519 }
2520
2521 #[test]
2522 fn slash_image_returns_the_picture_path() {
2523 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2524 let mut e = editor(&[Block::image(path), Block::text("")]);
2525 e.down();
2526 type_in(&mut e, "/img");
2527 assert_eq!(e.menu_selected(), Some(Command::CopyImage));
2528 match e.apply(Command::CopyImage).expect("image payload") {
2529 CopyPayload::Image(p) => assert!(p.ends_with("screenshot.png")),
2530 CopyPayload::Text(_) | CopyPayload::All(_) => panic!("expected image"),
2531 }
2532 }
2533
2534 #[test]
2535 fn a_picture_is_deleted_by_backspace() {
2536 let mut e = editor(&[Block::text("a"), Block::image("/tmp/a.png")]);
2537 e.down();
2538 e.backspace();
2539 assert_eq!(e.value(), vec![Block::text("a")]);
2540 }
2541
2542 #[test]
2543 fn the_menu_filters_by_abbreviation() {
2544 let mut e = editor(&[]);
2545 type_in(&mut e, "/che");
2546 assert_eq!(e.menu_selected(), Some(Command::Todo));
2547 }
2548
2549 #[test]
2550 fn the_menu_closes_when_nothing_matches() {
2551 let mut e = editor(&[]);
2552 type_in(&mut e, "/zz");
2553 assert!(e.menu.is_none());
2554 assert_eq!(e.value(), vec![Block::text("/zz")], "the text is kept");
2555 }
2556
2557 #[test]
2558 fn the_menu_closes_when_the_slash_is_removed() {
2559 let mut e = editor(&[]);
2560 type_in(&mut e, "/to");
2561 assert!(e.menu.is_some());
2562 e.backspace();
2563 e.backspace();
2564 assert!(e.menu.is_some());
2565 e.backspace();
2566 assert!(e.menu.is_none(), "removing the slash closes the menu");
2567 }
2568
2569 #[test]
2570 fn right_on_sole_image_inserts_text_line() {
2571 let blocks = [Block::image("/tmp/x.png")];
2572 let mut e = editor(&blocks);
2573 assert!(matches!(e.lines[0], Line::Image { .. }));
2574 assert_eq!(e.cursor, 0);
2575 e.right();
2576 assert_eq!(e.lines.len(), 2, "should insert a text line");
2577 assert_eq!(e.cursor, 1);
2578 assert!(matches!(e.lines[1], Line::Text(_)));
2579 assert!(e.input().is_some());
2580 }
2581
2582 #[test]
2583 fn left_on_first_image_inserts_text_line_above() {
2584 let blocks = [Block::image("/tmp/x.png")];
2585 let mut e = editor(&blocks);
2586 e.left();
2587 assert_eq!(e.lines.len(), 2);
2588 assert_eq!(e.cursor, 0);
2589 assert!(matches!(e.lines[0], Line::Text(_)));
2590 assert!(matches!(e.lines[1], Line::Image { .. }));
2591 }
2592
2593 #[test]
2594 fn visible_band_clips_top_like_bottom() {
2595 assert_eq!(visible_band(5, 10, 8, 10), Some((0, 7, 3)));
2598 assert_eq!(visible_band(0, 5, 8, 10), None);
2600 assert_eq!(visible_band(20, 5, 8, 10), None);
2601 assert_eq!(visible_band(8, 10, 0, 12), Some((8, 4, 0)));
2603 }
2604
2605 #[test]
2606 fn consecutive_images_shrink_when_scrolled_off_the_top() {
2607 let path = "/tmp/a.png";
2608 let mut e = editor(&[Block::image(path), Block::image(path), Block::text("tail")]);
2609 e.layout(40, 30);
2611 assert!(e.click(20, 0));
2612 assert_eq!(e.cursor_line(), 2);
2613
2614 let (placed, _) = e.layout(40, 12);
2615 assert_eq!(e.scroll(), 9);
2617 let imgs: Vec<_> = placed
2618 .iter()
2619 .filter(|p| matches!(p.block, Painted::Image(_)))
2620 .collect();
2621 assert_eq!(imgs.len(), 2);
2622 assert_eq!((imgs[0].y, imgs[0].rows), (0, 1));
2624 assert_eq!((imgs[1].y, imgs[1].rows), (1, 10));
2626 assert_eq!(imgs[0].y + imgs[0].rows, imgs[1].y);
2628 }
2629
2630 #[test]
2631 fn narrow_maximum_body_keeps_the_last_row_addressable() {
2632 let line = "x".repeat(MAX_NOTES_LINE_LEN);
2633 let blocks = vec![Block::text(&line); MAX_BODY_LINES];
2634 let mut e = editor(&blocks);
2635 e.cursor = e.lines.len() - 1;
2636 e.input().unwrap().end();
2637
2638 let (_, cursor) = e.layout(1, 10);
2639
2640 let expected_height = MAX_BODY_LINES * MAX_NOTES_LINE_LEN;
2641 assert_eq!(e.content_height() as usize, expected_height);
2642 assert_eq!(e.scroll() as usize, expected_height - 10);
2643 assert_eq!(cursor, Some((9, 1)));
2644 assert!(e.click(0, 0));
2645 assert_eq!(e.cursor_line(), MAX_BODY_LINES - 1);
2646 }
2647
2648 #[test]
2649 fn line_join_at_the_length_limit_never_discards_the_next_line() {
2650 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2651
2652 let mut backward = editor(&[Block::text(&full), Block::text("tail")]);
2653 backward.cursor = 1;
2654 backward.input().unwrap().home();
2655 backward.backspace();
2656 assert_eq!(
2657 backward.value(),
2658 vec![Block::text(&full), Block::text("tail")]
2659 );
2660
2661 let mut forward = editor(&[Block::text(&full), Block::text("tail")]);
2662 forward.input().unwrap().end();
2663 forward.delete();
2664 assert_eq!(
2665 forward.value(),
2666 vec![Block::text(&full), Block::text("tail")]
2667 );
2668 }
2669
2670 #[test]
2671 fn oversized_cross_line_selection_replacement_is_rejected_without_data_loss() {
2672 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2673 let mut editor = editor(&[Block::text(&full), Block::text("tail")]);
2674 editor.sel_anchor = Some((0, MAX_NOTES_LINE_LEN));
2675 editor.cursor = 1;
2676 editor.input().unwrap().home();
2677
2678 editor.insert('x');
2679
2680 assert_eq!(
2681 editor.value(),
2682 vec![Block::text(&full), Block::text("tail")]
2683 );
2684 }
2685
2686 #[test]
2687 fn oversized_image_path_detection_never_discards_a_source_line() {
2688 let root = std::env::temp_dir().join(format!(
2689 "mach-long-image-path-test-{}",
2690 uuid::Uuid::new_v4()
2691 ));
2692 let first_dir = "a".repeat(240);
2693 let second_dir = "b".repeat(240);
2694 let file = format!("{}.png", "c".repeat(40));
2695 let relative = format!("{first_dir}/{second_dir}/{file}");
2696 assert!(relative.graphemes(true).count() > MAX_NOTES_LINE_LEN);
2697 std::fs::create_dir_all(root.join(&first_dir).join(&second_dir)).unwrap();
2698 std::fs::write(root.join(&relative), []).unwrap();
2699
2700 let split = relative.len() / 2;
2701 let (left, right) = relative.split_at(split);
2702 let expected = vec![Block::text(left), Block::text(right)];
2703 let mut loaded = editor(&expected);
2704 loaded.set_image_root(root.clone());
2705 assert_eq!(loaded.value(), expected);
2706
2707 let mut pasted = editor(&[]);
2708 pasted.set_image_root(root.clone());
2709 pasted.insert_str(&format!("{left}\n{right}"));
2710 assert_eq!(pasted.value(), expected);
2711
2712 std::fs::remove_dir_all(root).unwrap();
2713 }
2714}