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 .split('\n')
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> {
534 if self.is_empty() {
535 return Vec::new();
536 }
537 self.lines
538 .iter()
539 .filter_map(|line| match line {
540 Line::Text(text) => Some(Block::text(text.value().trim_end())),
541 Line::Todo { text, done } => {
542 block_from_input(text, |value| Block::todo(value, *done))
543 }
544 Line::Bullet(text) => block_from_input(text, Block::bullet),
545 Line::Number(text) => block_from_input(text, Block::number),
546 Line::Link(text) => block_from_input(text, Block::link),
547 Line::Image { path } => Some(Block::image(path)),
548 })
549 .collect()
550 }
551
552 pub fn is_empty(&self) -> bool {
553 !self.lines.iter().any(|l| match l {
554 Line::Image { .. } => true,
555 Line::Text(t)
556 | Line::Bullet(t)
557 | Line::Number(t)
558 | Line::Link(t)
559 | Line::Todo { text: t, .. } => !t.value().trim().is_empty(),
560 })
561 }
562
563 pub fn progress(&self) -> (usize, usize) {
564 let mut done = 0usize;
565 let mut total = 0usize;
566 for l in &self.lines {
567 if let Line::Todo { text, done: d } = l
568 && !text.value().trim().is_empty()
569 {
570 total += 1;
571 if *d {
572 done += 1;
573 }
574 }
575 }
576 (done, total)
577 }
578
579 pub fn cursor_line(&self) -> usize {
581 self.cursor
582 }
583
584 pub fn has_selection(&self) -> bool {
586 if self.ordered_selection().is_some() {
587 return true;
588 }
589 self.lines[self.cursor]
590 .input_ref()
591 .is_some_and(|t| t.has_selection())
592 }
593
594 pub fn selected_text(&self) -> Option<String> {
597 if let Some(payload) = self.selected_payload() {
598 return match payload {
599 CopyPayload::Text(s) => Some(s),
600 CopyPayload::All(lines) => {
601 let s = lines
602 .into_iter()
603 .map(|l| match l {
604 CopyLine::Text(t) | CopyLine::Link(t) => t,
605 CopyLine::Image(p) => format!("[image: {}]", p.display()),
606 })
607 .collect::<Vec<_>>()
608 .join("\n");
609 (!s.is_empty()).then_some(s)
610 }
611 CopyPayload::Image(p) => Some(format!("[image: {}]", p.display())),
612 };
613 }
614 None
615 }
616
617 pub fn selected_payload(&self) -> Option<CopyPayload> {
619 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
620 let lines = self.copy_lines_between(al, ac, bl, bc);
621 if lines.is_empty() {
622 return None;
623 }
624 if lines.iter().any(|l| matches!(l, CopyLine::Image(_))) {
625 return Some(CopyPayload::All(lines));
626 }
627 let text = lines
628 .into_iter()
629 .filter_map(|l| match l {
630 CopyLine::Text(t) | CopyLine::Link(t) => Some(t),
631 CopyLine::Image(_) => None,
632 })
633 .collect::<Vec<_>>()
634 .join("\n");
635 return (!text.is_empty()).then_some(CopyPayload::Text(text));
636 }
637 match &self.lines[self.cursor] {
638 Line::Text(t)
639 | Line::Todo { text: t, .. }
640 | Line::Bullet(t)
641 | Line::Number(t)
642 | Line::Link(t) => t.selected_text().map(CopyPayload::Text),
643 Line::Image { path } => Some(CopyPayload::Image(resolve_image_reference(
644 path,
645 &self.image_root,
646 &self.attachments,
647 ))),
648 }
649 }
650
651 fn caret(&self) -> (usize, usize) {
652 let col = self.lines[self.cursor]
653 .input_ref()
654 .map(|i| i.cursor())
655 .unwrap_or(0);
656 (self.cursor, col)
657 }
658
659 fn ordered_selection(&self) -> Option<((usize, usize), (usize, usize))> {
660 let a = self.sel_anchor?;
661 let b = self.caret();
662 if a == b {
663 return None;
664 }
665 Some(if (a.0, a.1) <= (b.0, b.1) {
666 (a, b)
667 } else {
668 (b, a)
669 })
670 }
671
672 fn line_char_len(&self, i: usize) -> usize {
673 self.lines[i].input_ref().map(|t| t.len()).unwrap_or(0)
674 }
675
676 fn line_text_value(&self, i: usize) -> String {
677 self.lines[i]
678 .input_ref()
679 .map(|t| t.value())
680 .unwrap_or_default()
681 }
682
683 fn is_image_line(&self, i: usize) -> bool {
684 matches!(self.lines.get(i), Some(Line::Image { .. }))
685 }
686
687 pub fn line_in_selection(&self, i: usize) -> bool {
691 let Some(((al, _), (bl, _))) = self.ordered_selection() else {
692 return false;
693 };
694 if i < al || i > bl {
695 return false;
696 }
697 if self.is_image_line(i) {
698 return al < bl;
700 }
701 self.char_sel_on_line(i).is_some()
702 }
703
704 fn copy_lines_between(&self, al: usize, ac: usize, bl: usize, bc: usize) -> Vec<CopyLine> {
705 let mut out = Vec::new();
706 if al == bl {
707 let s = self.line_text_value(al);
708 let chars: Vec<&str> = s.graphemes(true).collect();
709 let lo = ac.min(chars.len());
710 let hi = bc.min(chars.len());
711 if lo < hi {
712 out.push(CopyLine::Text(chars[lo..hi].concat()));
713 }
714 return out;
715 }
716 if let Some(line) = self.copy_line_slice(al, Some(ac), None) {
718 out.push(line);
719 }
720 for i in (al + 1)..bl {
721 if let Some(line) = self.copy_line_slice(i, None, None) {
722 out.push(line);
723 }
724 }
725 if self.is_image_line(bl) {
727 if let Line::Image { path } = &self.lines[bl] {
728 out.push(CopyLine::Image(resolve_image_reference(
729 path,
730 &self.image_root,
731 &self.attachments,
732 )));
733 }
734 } else if let Some(line) = self.copy_line_slice(bl, None, Some(bc)) {
735 out.push(line);
736 }
737 out
738 }
739
740 fn copy_line_slice(
743 &self,
744 i: usize,
745 from: Option<usize>,
746 to: Option<usize>,
747 ) -> Option<CopyLine> {
748 match &self.lines[i] {
749 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
750 path,
751 &self.image_root,
752 &self.attachments,
753 ))),
754 Line::Link(t) => {
755 let s = t.value();
756 let chars: Vec<&str> = s.graphemes(true).collect();
757 let lo = from.unwrap_or(0).min(chars.len());
758 let hi = to.unwrap_or(chars.len()).min(chars.len());
759 (lo < hi).then(|| CopyLine::Link(chars[lo..hi].concat()))
760 }
761 Line::Text(t) | Line::Bullet(t) | Line::Number(t) | Line::Todo { text: t, .. } => {
762 let s = t.value();
763 let chars: Vec<&str> = s.graphemes(true).collect();
764 let lo = from.unwrap_or(0).min(chars.len());
765 let hi = to.unwrap_or(chars.len()).min(chars.len());
766 (lo < hi).then(|| CopyLine::Text(chars[lo..hi].concat()))
767 }
768 }
769 }
770
771 fn char_sel_on_line(&self, i: usize) -> Option<(usize, usize)> {
773 let ((al, ac), (bl, bc)) = self.ordered_selection()?;
774 if i < al || i > bl || self.is_image_line(i) {
775 return None;
776 }
777 let len = self.line_char_len(i);
778 if al == bl {
779 let lo = ac.min(bc).min(len);
780 let hi = ac.max(bc).min(len);
781 return (lo < hi).then_some((lo, hi));
782 }
783 if i == al {
784 let lo = ac.min(len);
785 return (lo < len || len == 0).then_some((lo, len));
786 }
787 if i == bl {
788 let hi = bc.min(len);
789 return (hi > 0).then_some((0, hi));
790 }
791 Some((0, len))
793 }
794
795 fn ensure_sel_anchor(&mut self) {
796 if self.sel_anchor.is_none() {
797 self.sel_anchor = Some(self.caret());
798 }
799 }
800
801 fn clear_body_selection(&mut self) {
802 self.sel_anchor = None;
803 for line in &mut self.lines {
804 if let Some(input) = line.input() {
805 input.clear_selection();
806 }
807 }
808 }
809
810 pub fn delete_body_selection(&mut self) -> bool {
813 if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
814 if al == bl {
815 if let Some(input) = self.lines[al].input() {
818 let value = input.value();
819 let mut chars: Vec<&str> = value.graphemes(true).collect();
820 let lo = ac.min(bc).min(chars.len());
821 let hi = ac.max(bc).min(chars.len());
822 if lo < hi {
823 chars.drain(lo..hi);
824 }
825 let text = chars.concat();
826 let len = self.line_max_len;
827 self.lines[al] = self.line_with_text(al, &text, len);
828 if let Some(input) = self.lines[al].input() {
829 input.place_cursor(lo);
830 }
831 }
832 } else {
833 let start_s = if self.is_image_line(al) {
836 String::new()
837 } else {
838 self.line_text_value(al)
839 };
840 let end_s = if self.is_image_line(bl) {
841 String::new()
842 } else {
843 self.line_text_value(bl)
844 };
845 let sc: Vec<&str> = start_s.graphemes(true).collect();
846 let ec: Vec<&str> = end_s.graphemes(true).collect();
847 let ac = if self.is_image_line(al) {
848 0
849 } else {
850 ac.min(sc.len())
851 };
852 let bc = if self.is_image_line(bl) {
853 0
854 } else {
855 bc.min(ec.len())
856 };
857 let merged = format!("{}{}", sc[..ac].concat(), ec[bc..].concat());
858 if !self.line_text_fits(&merged) {
859 return false;
860 }
861 let len = self.line_max_len;
862 self.lines[al] = self.line_with_text(al, &merged, len);
863 for _ in al..bl {
865 if al + 1 < self.lines.len() {
866 self.lines.remove(al + 1);
867 }
868 }
869 self.cursor = al;
870 if let Some(input) = self.lines[al].input() {
871 input.place_cursor(ac.min(input.len()));
872 }
873 }
874 self.sel_anchor = None;
875 return true;
876 }
877 if let Some(input) = self.input() {
878 return input.delete_selection();
879 }
880 false
881 }
882
883 fn line_with_text(&self, index: usize, text: &str, len: usize) -> Line {
884 match &self.lines[index] {
885 Line::Todo { done, .. } => Line::Todo {
886 text: TextInput::new(text, len),
887 done: *done,
888 },
889 Line::Bullet(_) => Line::Bullet(TextInput::new(text, len)),
890 Line::Number(_) => Line::Number(TextInput::new(text, len)),
891 Line::Link(_) => Line::Link(TextInput::new(text, len)),
892 Line::Text(_) | Line::Image { .. } => Line::Text(TextInput::new(text, len)),
893 }
894 }
895
896 pub fn link_url_at_cursor(&self) -> Option<String> {
898 match &self.lines[self.cursor] {
899 Line::Link(t) => {
900 let u = t.value();
901 let u = u.trim();
902 (!u.is_empty()).then(|| u.to_string())
903 }
904 _ => None,
905 }
906 }
907
908 pub fn link_url_at_position(&self, row: u16, col: usize) -> Option<String> {
911 use unicode_width::UnicodeWidthStr;
912
913 let width = self.layout_width.max(1);
914 let numbers = number_runs(&self.lines);
915 let target = self.scroll.saturating_add(usize::from(row));
916 let mut at = 0usize;
917 for (index, line) in self.lines.iter().enumerate() {
918 let height = line.height(width, numbers[index]);
919 if target >= at.saturating_add(height) {
920 at = at.saturating_add(height);
921 continue;
922 }
923 let Line::Link(input) = line else {
924 return None;
925 };
926 let row_in = target.saturating_sub(at);
927 let indent = line.indent();
928 let field = width.saturating_sub(indent).max(1);
929 let breaks = input.wrap_breaks(field);
930 let &(start, end) = breaks.get(row_in)?;
931 let text = input.slice(start, end);
932 let text_width = text.width();
933 let on_marker = row_in == 0 && col < indent;
934 let on_text = col >= indent && col < indent.saturating_add(text_width);
935 if !on_marker && !on_text {
936 return None;
937 }
938 let url = input.value();
939 let url = url.trim();
940 return (!url.is_empty()).then(|| url.to_string());
941 }
942 None
943 }
944
945 pub fn selected_image(&self) -> Option<PathBuf> {
946 match &self.lines[self.cursor] {
947 Line::Image { path } => Some(resolve_image_reference(
948 path,
949 &self.image_root,
950 &self.attachments,
951 )),
952 _ => None,
953 }
954 }
955
956 pub fn images(&self) -> Vec<PathBuf> {
958 self.lines
959 .iter()
960 .filter_map(|l| match l {
961 Line::Image { path } => Some(resolve_image_reference(
962 path,
963 &self.image_root,
964 &self.attachments,
965 )),
966 _ => None,
967 })
968 .collect()
969 }
970
971 pub fn abandon_image_selection(&mut self) {
976 if !matches!(self.lines.get(self.cursor), Some(Line::Image { .. })) {
977 return;
978 }
979 if let Some(next) = self.next_editable(self.cursor) {
980 self.cursor = next;
981 if let Some(input) = self.input() {
982 input.home();
983 }
984 return;
985 }
986 if let Some(prev) = self.prev_editable(self.cursor) {
987 self.cursor = prev;
988 if let Some(input) = self.input() {
989 input.end();
990 }
991 }
992 }
993
994 fn line(&mut self) -> &mut Line {
995 &mut self.lines[self.cursor]
996 }
997
998 fn input(&mut self) -> Option<&mut TextInput> {
999 self.lines[self.cursor].input()
1000 }
1001
1002 pub fn insert(&mut self, c: char) {
1005 if self.has_selection() && !self.delete_body_selection() {
1007 return;
1008 }
1009 if self.input().is_none() {
1010 self.insert_block(Block::text(""));
1012 }
1013 if let Some(input) = self.input() {
1014 input.insert(c);
1015 }
1016 if c == ' '
1018 && let Line::Text(text) = &self.lines[self.cursor]
1019 {
1020 let v = text.value();
1021 if text.cursor() == v.graphemes(true).count() {
1022 if matches!(v.as_str(), "- " | "* ") {
1023 self.lines[self.cursor] = Line::Bullet(TextInput::new("", self.line_max_len));
1024 return;
1025 }
1026 if let Some(rest) = v.strip_suffix(". ")
1027 && !rest.is_empty()
1028 && rest.chars().all(|ch| ch.is_ascii_digit())
1029 {
1030 self.lines[self.cursor] = Line::Number(TextInput::new("", self.line_max_len));
1031 return;
1032 }
1033 }
1034 }
1035 if c == '/' {
1036 let start = self.input().map(|i| i.cursor()).unwrap_or(0);
1037 self.menu = Some(SlashMenu {
1038 start,
1039 query: String::new(),
1040 index: 0,
1041 });
1042 } else if self.menu.is_some() {
1043 if let Some(menu) = &mut self.menu {
1044 menu.query.push(c);
1045 menu.index = 0;
1046 }
1047 if self.menu_commands().is_empty() {
1049 self.close_menu();
1050 }
1051 }
1052 self.try_adopt_line(self.cursor);
1054 }
1055
1056 pub fn insert_str(&mut self, text: &str) {
1057 self.close_menu();
1058 if self.has_selection() && !self.delete_body_selection() {
1059 return;
1060 }
1061 let text = match flatten_if_image_path(text, &self.image_root) {
1063 Some(flat) if self.line_text_fits(&flat) => flat,
1064 _ => text.to_string(),
1065 };
1066 for (i, part) in text.split('\n').enumerate() {
1067 if i > 0 && !self.newline() {
1068 break;
1069 }
1070 if self.input().is_none() {
1071 self.insert_block(Block::text(""));
1072 }
1073 if let Some(input) = self.input() {
1074 input.insert_str(part.trim_end_matches('\r'));
1075 }
1076 }
1077 self.adopt_pasted_paths();
1080 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1081 let next_is_text = matches!(self.lines.get(self.cursor + 1), Some(Line::Text(_)));
1082 if !next_is_text && self.can_add_lines(1) {
1083 self.lines.insert(self.cursor + 1, self.empty_line());
1084 }
1085 if matches!(self.lines.get(self.cursor + 1), Some(Line::Text(_))) {
1086 self.cursor += 1;
1087 }
1088 }
1089 }
1090
1091 pub fn newline(&mut self) -> bool {
1095 self.close_menu();
1096 if !self.can_add_lines(1) {
1097 return false;
1098 }
1099 let tail = match self.line() {
1100 Line::Text(text)
1101 | Line::Todo { text, .. }
1102 | Line::Bullet(text)
1103 | Line::Number(text)
1104 | Line::Link(text) => text.split_off_at_cursor(),
1105 Line::Image { .. } => TextInput::new("", self.line_max_len),
1106 };
1107 self.lines.insert(self.cursor + 1, Line::Text(tail));
1108 self.cursor += 1;
1109 self.adopt_pasted_paths();
1111 true
1112 }
1113
1114 pub fn backspace(&mut self) {
1115 if let Some(start) = self.menu.as_ref().map(|m| m.start) {
1116 let at = self.input().map(|i| i.cursor()).unwrap_or(0);
1117 if at <= start {
1118 self.close_menu();
1119 } else if let Some(menu) = &mut self.menu {
1120 menu.query.pop();
1121 menu.index = 0;
1122 }
1123 }
1124 let had_selection = self.has_selection();
1125 if self.delete_body_selection() {
1126 return;
1127 }
1128 if had_selection {
1129 return;
1130 }
1131 match self.line() {
1132 Line::Todo { text, .. }
1135 | Line::Bullet(text)
1136 | Line::Number(text)
1137 | Line::Link(text)
1138 if text.at_start() =>
1139 {
1140 let text = text.clone();
1141 self.lines[self.cursor] = Line::Text(text);
1142 return;
1143 }
1144 Line::Text(text) if text.at_start() => {}
1145 Line::Image { .. } => {
1146 self.remove_block();
1147 return;
1148 }
1149 _ => {
1150 if let Some(input) = self.input() {
1151 input.backspace();
1152 }
1153 return;
1154 }
1155 }
1156 if self.cursor == 0 {
1158 return;
1159 }
1160 let current = self.lines.remove(self.cursor);
1161 self.cursor -= 1;
1162 match current {
1163 Line::Text(text) => {
1164 let merged = match self.lines[self.cursor].input() {
1165 Some(previous) => previous.append(&text),
1166 None => text.is_empty(),
1168 };
1169 if !merged {
1170 self.cursor += 1;
1171 self.lines.insert(self.cursor, Line::Text(text));
1172 }
1173 }
1174 line => {
1175 self.cursor += 1;
1176 self.lines.insert(self.cursor, line);
1177 }
1178 }
1179 }
1180
1181 pub fn delete(&mut self) {
1182 self.close_menu();
1183 let had_selection = self.has_selection();
1184 if self.delete_body_selection() {
1185 return;
1186 }
1187 if had_selection {
1188 return;
1189 }
1190 if matches!(self.line(), Line::Image { .. }) {
1191 self.remove_block();
1192 return;
1193 }
1194 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1195 if !at_end {
1196 if let Some(input) = self.input() {
1197 input.delete();
1198 }
1199 return;
1200 }
1201 if self.cursor + 1 >= self.lines.len() {
1202 return;
1203 }
1204 let next = self.lines.remove(self.cursor + 1);
1205 let merged = match next.input_ref() {
1206 Some(text) => self.lines[self.cursor]
1207 .input()
1208 .is_some_and(|current| current.append(text)),
1209 None => false,
1210 };
1211 if !merged {
1212 self.lines.insert(self.cursor + 1, next);
1213 }
1214 }
1215
1216 pub fn remove_block(&mut self) {
1218 if self.lines.len() == 1 {
1219 self.lines[0] = self.empty_line();
1220 return;
1221 }
1222 self.lines.remove(self.cursor);
1223 self.cursor = self.cursor.min(self.lines.len() - 1);
1224 }
1225
1226 pub fn toggle(&mut self) {
1227 if let Line::Todo { done, .. } = self.line() {
1228 *done = !*done;
1229 }
1230 }
1231
1232 fn field_width_for(&self, index: usize) -> usize {
1235 let width = self.layout_width.max(1);
1236 let n = match &self.lines[index] {
1237 Line::Number(_) => Some(number_at(&self.lines, index)),
1238 _ => None,
1239 };
1240 let indent = n
1241 .map(number_indent)
1242 .unwrap_or_else(|| self.lines[index].indent());
1243 width.saturating_sub(indent).max(1)
1244 }
1245
1246 pub fn up(&mut self) {
1247 self.close_menu();
1248 self.clear_body_selection();
1249 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1251 self.leave_image_backward();
1252 return;
1253 }
1254 let width = self.field_width_for(self.cursor);
1255 let prefer = self.prefer_col;
1256 let moved = self
1257 .input()
1258 .is_some_and(|input| input.wrap_up(width, prefer));
1259 if moved {
1260 if self.prefer_col == u16::MAX
1261 && let Some(input) = self.input()
1262 {
1263 self.prefer_col = input.wrap_cursor(width).1;
1264 }
1265 return;
1266 }
1267 if self.cursor > 0 {
1268 let left = self.cursor;
1269 self.cursor -= 1;
1270 self.try_adopt_line(left);
1271 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1273 return;
1274 }
1275 let width = self.field_width_for(self.cursor);
1276 let prefer = if self.prefer_col == u16::MAX {
1277 0
1278 } else {
1279 self.prefer_col
1280 };
1281 if let Some(input) = self.input() {
1282 let last = input.wrap_height(width).saturating_sub(1);
1283 input.set_cursor_from_wrap(width, last, prefer as usize);
1284 }
1285 }
1286 }
1287
1288 pub fn down(&mut self) {
1289 self.close_menu();
1290 self.clear_body_selection();
1291 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1292 self.leave_image_forward();
1293 return;
1294 }
1295 let width = self.field_width_for(self.cursor);
1296 let prefer = self.prefer_col;
1297 let moved = self
1298 .input()
1299 .is_some_and(|input| input.wrap_down(width, prefer));
1300 if moved {
1301 if self.prefer_col == u16::MAX
1302 && let Some(input) = self.input()
1303 {
1304 self.prefer_col = input.wrap_cursor(width).1;
1305 }
1306 return;
1307 }
1308 if self.cursor + 1 < self.lines.len() {
1309 let left = self.cursor;
1310 self.cursor += 1;
1311 self.try_adopt_line(left);
1312 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1314 return;
1315 }
1316 let width = self.field_width_for(self.cursor);
1317 let prefer = if self.prefer_col == u16::MAX {
1318 0
1319 } else {
1320 self.prefer_col
1321 };
1322 if let Some(input) = self.input() {
1323 input.set_cursor_from_wrap(width, 0, prefer as usize);
1324 }
1325 }
1326 }
1327
1328 pub fn left(&mut self) {
1329 self.close_menu();
1330 self.prefer_col = u16::MAX;
1331 self.clear_body_selection();
1332 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1335 self.leave_image_backward();
1336 return;
1337 }
1338 match self.input() {
1339 Some(input) if !input.at_start() => input.left(),
1340 _ => {
1341 if self.cursor > 0 {
1342 self.cursor -= 1;
1343 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1345 return;
1346 }
1347 if let Some(input) = self.input() {
1348 input.end();
1349 }
1350 }
1351 }
1352 }
1353 }
1354
1355 pub fn right(&mut self) {
1356 self.close_menu();
1357 self.prefer_col = u16::MAX;
1358 self.clear_body_selection();
1359 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1362 self.leave_image_forward();
1363 return;
1364 }
1365 match self.input() {
1366 Some(input) if !input.at_end() => input.right(),
1367 _ => {
1368 if self.cursor + 1 < self.lines.len() {
1369 self.cursor += 1;
1370 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1372 return;
1373 }
1374 if let Some(input) = self.input() {
1375 input.home();
1376 }
1377 }
1378 }
1379 }
1380 }
1381
1382 fn leave_image_forward(&mut self) {
1386 let next = self.cursor + 1;
1387 if next < self.lines.len() && self.lines[next].input_ref().is_some() {
1388 self.cursor = next;
1389 if let Some(input) = self.input() {
1390 input.home();
1391 }
1392 return;
1393 }
1394 if !self.can_add_lines(1) {
1395 return;
1396 }
1397 self.lines.insert(next, self.empty_line());
1400 self.cursor = next;
1401 }
1402
1403 fn leave_image_backward(&mut self) {
1406 if self.cursor > 0 && self.lines[self.cursor - 1].input_ref().is_some() {
1407 self.cursor -= 1;
1408 if let Some(input) = self.input() {
1409 input.end();
1410 }
1411 return;
1412 }
1413 if !self.can_add_lines(1) {
1414 return;
1415 }
1416 self.lines.insert(self.cursor, self.empty_line());
1417 if let Some(input) = self.input() {
1419 input.home();
1420 }
1421 }
1422
1423 pub fn home(&mut self) {
1424 self.close_menu();
1425 self.clear_body_selection();
1426 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1427 self.leave_image_backward();
1428 return;
1429 }
1430 if let Some(input) = self.input() {
1431 input.home();
1432 }
1433 }
1434
1435 pub fn end(&mut self) {
1436 self.close_menu();
1437 self.clear_body_selection();
1438 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1439 self.leave_image_forward();
1440 return;
1441 }
1442 if let Some(input) = self.input() {
1443 input.end();
1444 }
1445 }
1446
1447 pub fn word_left(&mut self) {
1448 self.close_menu();
1449 self.clear_body_selection();
1450 self.prefer_col = u16::MAX;
1451 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1452 self.leave_image_backward();
1453 return;
1454 }
1455 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1456 if !at_start {
1457 if let Some(input) = self.input() {
1458 input.word_left();
1459 }
1460 return;
1461 }
1462 if let Some(prev) = self.prev_editable(self.cursor) {
1464 self.cursor = prev;
1465 if let Some(input) = self.input() {
1466 input.end();
1467 input.word_left();
1468 }
1469 }
1470 }
1471
1472 pub fn word_right(&mut self) {
1473 self.close_menu();
1474 self.clear_body_selection();
1475 self.prefer_col = u16::MAX;
1476 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1477 self.leave_image_forward();
1478 return;
1479 }
1480 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1481 if !at_end {
1482 if let Some(input) = self.input() {
1483 input.word_right();
1484 }
1485 return;
1486 }
1487 if let Some(next) = self.next_editable(self.cursor) {
1488 self.cursor = next;
1489 if let Some(input) = self.input() {
1490 input.home();
1491 input.word_right();
1492 }
1493 }
1494 }
1495
1496 fn prev_editable(&self, from: usize) -> Option<usize> {
1497 (0..from)
1498 .rev()
1499 .find(|&i| self.lines[i].input_ref().is_some())
1500 }
1501
1502 fn next_editable(&self, from: usize) -> Option<usize> {
1503 ((from + 1)..self.lines.len()).find(|&i| self.lines[i].input_ref().is_some())
1504 }
1505
1506 pub fn select_word(&mut self) {
1507 self.close_menu();
1508 self.sel_anchor = None;
1509 if let Some(input) = self.input() {
1510 input.select_word();
1511 }
1512 }
1513
1514 pub fn select_left(&mut self) {
1515 self.close_menu();
1516 self.prefer_col = u16::MAX;
1517 self.ensure_sel_anchor();
1518 if let Some(input) = self.input() {
1519 input.clear_selection();
1520 }
1521 if self.is_image_line(self.cursor) {
1523 self.step_sel_prev_line();
1524 return;
1525 }
1526 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1527 if !at_start {
1528 if let Some(input) = self.input() {
1529 let c = input.cursor().saturating_sub(1);
1530 input.place_cursor(c);
1531 }
1532 } else {
1533 self.step_sel_prev_line();
1534 }
1535 }
1536
1537 pub fn select_right(&mut self) {
1538 self.close_menu();
1539 self.prefer_col = u16::MAX;
1540 self.ensure_sel_anchor();
1541 if let Some(input) = self.input() {
1542 input.clear_selection();
1543 }
1544 if self.is_image_line(self.cursor) {
1545 self.step_sel_next_line();
1546 return;
1547 }
1548 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1549 if !at_end {
1550 if let Some(input) = self.input() {
1551 let c = (input.cursor() + 1).min(input.len());
1552 input.place_cursor(c);
1553 }
1554 } else {
1555 self.step_sel_next_line();
1556 }
1557 }
1558
1559 pub fn select_word_left(&mut self) {
1561 self.close_menu();
1562 self.prefer_col = u16::MAX;
1563 self.ensure_sel_anchor();
1564 if let Some(input) = self.input() {
1565 input.clear_selection();
1566 }
1567 if self.is_image_line(self.cursor) {
1569 self.step_sel_prev_line();
1570 if !self.is_image_line(self.cursor)
1571 && let Some(input) = self.input()
1572 {
1573 input.end();
1574 let target = input.word_left_index();
1575 input.place_cursor(target);
1576 }
1577 return;
1578 }
1579 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1580 if !at_start {
1581 if let Some(input) = self.input() {
1582 let target = input.word_left_index();
1583 input.place_cursor(target);
1584 }
1585 return;
1586 }
1587 self.step_sel_prev_line();
1588 if self.is_image_line(self.cursor) {
1589 return;
1590 }
1591 if let Some(input) = self.input() {
1592 input.end();
1593 let target = input.word_left_index();
1594 input.place_cursor(target);
1595 }
1596 }
1597
1598 pub fn select_word_right(&mut self) {
1600 self.close_menu();
1601 self.prefer_col = u16::MAX;
1602 self.ensure_sel_anchor();
1603 if let Some(input) = self.input() {
1604 input.clear_selection();
1605 }
1606 if self.is_image_line(self.cursor) {
1607 self.step_sel_next_line();
1608 if !self.is_image_line(self.cursor)
1609 && let Some(input) = self.input()
1610 {
1611 input.home();
1612 let target = input.word_right_index();
1613 input.place_cursor(target);
1614 }
1615 return;
1616 }
1617 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1618 if !at_end {
1619 if let Some(input) = self.input() {
1620 let target = input.word_right_index();
1621 input.place_cursor(target);
1622 }
1623 return;
1624 }
1625 self.step_sel_next_line();
1626 if self.is_image_line(self.cursor) {
1627 return;
1628 }
1629 if let Some(input) = self.input() {
1630 input.home();
1631 let target = input.word_right_index();
1632 input.place_cursor(target);
1633 }
1634 }
1635
1636 fn step_sel_prev_line(&mut self) {
1639 if self.cursor == 0 {
1640 return;
1641 }
1642 self.cursor -= 1;
1643 if let Some(input) = self.input() {
1644 input.end();
1645 }
1646 }
1647
1648 fn step_sel_next_line(&mut self) {
1650 if self.cursor + 1 >= self.lines.len() {
1651 return;
1652 }
1653 self.cursor += 1;
1654 if let Some(input) = self.input() {
1655 input.home();
1656 }
1657 }
1658
1659 pub fn select_home(&mut self) {
1660 self.close_menu();
1661 self.ensure_sel_anchor();
1662 if let Some(input) = self.input() {
1663 input.clear_selection();
1664 input.place_cursor(0);
1665 }
1666 }
1667
1668 pub fn select_end(&mut self) {
1669 self.close_menu();
1670 self.ensure_sel_anchor();
1671 if let Some(input) = self.input() {
1672 input.clear_selection();
1673 let n = input.len();
1674 input.place_cursor(n);
1675 }
1676 }
1677
1678 pub fn delete_to_start(&mut self) {
1679 if let Some(input) = self.input() {
1680 input.delete_to_start();
1681 }
1682 }
1683
1684 pub fn delete_to_end(&mut self) {
1685 if let Some(input) = self.input() {
1686 input.delete_to_end();
1687 }
1688 }
1689
1690 pub fn delete_word_left(&mut self) {
1691 if let Some(input) = self.input() {
1692 input.delete_word_left();
1693 }
1694 }
1695
1696 pub fn menu_next(&mut self) {
1699 let count = self.menu_commands().len();
1700 if let Some(menu) = &mut self.menu
1701 && count > 0
1702 {
1703 menu.index = (menu.index + 1) % count;
1704 }
1705 }
1706
1707 pub fn menu_prev(&mut self) {
1708 let count = self.menu_commands().len();
1709 if let Some(menu) = &mut self.menu
1710 && count > 0
1711 {
1712 menu.index = (menu.index + count - 1) % count;
1713 }
1714 }
1715
1716 pub fn close_menu(&mut self) {
1717 self.menu = None;
1718 }
1719
1720 pub fn menu_selected(&self) -> Option<Command> {
1722 self.menu
1723 .as_ref()
1724 .and_then(|m| m.selected_in(self.allowed_commands()))
1725 }
1726
1727 pub fn text_for_copy(&self) -> String {
1729 self.lines_for_copy_all()
1730 .into_iter()
1731 .filter_map(|line| match line {
1732 CopyLine::Text(s) | CopyLine::Link(s) => Some(s),
1733 CopyLine::Image(_) => None,
1734 })
1735 .collect::<Vec<_>>()
1736 .join("\n")
1737 }
1738
1739 pub fn lines_for_copy_all(&self) -> Vec<CopyLine> {
1741 self.lines
1742 .iter()
1743 .enumerate()
1744 .filter_map(|(i, line)| match line {
1745 Line::Text(text) => {
1746 let s = text.value();
1747 (!s.trim().is_empty()).then_some(CopyLine::Text(s))
1748 }
1749 Line::Bullet(text) => Some(CopyLine::Text(format!("- {}", text.value()))),
1750 Line::Number(text) => {
1751 let n = number_at(&self.lines, i);
1752 Some(CopyLine::Text(format!("{n}. {}", text.value())))
1753 }
1754 Line::Todo { text, done } => {
1755 let mark = if *done { "[✓]" } else { "[ ]" };
1756 Some(CopyLine::Text(format!("{mark} {}", text.value())))
1757 }
1758 Line::Link(text) => {
1759 let s = text.value();
1760 (!s.trim().is_empty()).then_some(CopyLine::Link(s))
1761 }
1762 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
1763 path,
1764 &self.image_root,
1765 &self.attachments,
1766 ))),
1767 })
1768 .collect()
1769 }
1770
1771 pub fn image_for_copy(&self) -> Option<PathBuf> {
1773 for i in (0..=self.cursor).rev() {
1774 if let Line::Image { path } = &self.lines[i] {
1775 return Some(resolve_image_reference(
1776 path,
1777 &self.image_root,
1778 &self.attachments,
1779 ));
1780 }
1781 }
1782 for i in (self.cursor + 1)..self.lines.len() {
1783 if let Line::Image { path } = &self.lines[i] {
1784 return Some(resolve_image_reference(
1785 path,
1786 &self.image_root,
1787 &self.attachments,
1788 ));
1789 }
1790 }
1791 None
1792 }
1793
1794 pub fn apply(&mut self, command: Command) -> Option<CopyPayload> {
1797 if !self.allowed_commands().contains(&command) {
1798 self.close_menu();
1799 return None;
1800 }
1801 let menu = self.menu.take()?;
1802 if let Some(input) = self.input() {
1803 let end = input.cursor();
1805 input.set_cursor(end);
1806 for _ in menu.start.saturating_sub(1)..end {
1807 input.backspace();
1808 }
1809 }
1810 let payload = match command {
1811 Command::Copy => Some(CopyPayload::Text(self.text_for_copy())),
1812 Command::CopyImage => self.image_for_copy().map(CopyPayload::Image),
1813 Command::CopyAll => Some(CopyPayload::All(self.lines_for_copy_all())),
1814 Command::Todo | Command::Bullet | Command::Number | Command::Link => {
1815 let text = match self.line() {
1816 Line::Text(text)
1817 | Line::Todo { text, .. }
1818 | Line::Bullet(text)
1819 | Line::Number(text)
1820 | Line::Link(text) => text.clone(),
1821 Line::Image { .. } => return None,
1822 };
1823 self.lines[self.cursor] = match command {
1824 Command::Todo => Line::Todo { text, done: false },
1825 Command::Bullet => Line::Bullet(text),
1826 Command::Number => Line::Number(text),
1827 Command::Link => {
1828 let url = link_url_from_line(&text.value());
1829 Line::Link(TextInput::new(&url, self.line_max_len))
1830 }
1831 Command::Copy | Command::CopyImage | Command::CopyAll => return None,
1832 };
1833 None
1834 }
1835 };
1836 if matches!(
1837 command,
1838 Command::Copy | Command::CopyImage | Command::CopyAll
1839 ) && self.input().is_some_and(|input| input.is_empty())
1840 {
1841 self.remove_block();
1842 }
1843 payload
1844 }
1845
1846 pub fn insert_block(&mut self, block: Block) {
1849 self.close_menu();
1850 let is_image = matches!(block, Block::Image { .. });
1851 let replace = match &self.lines[self.cursor] {
1852 Line::Text(text) => text.is_empty(),
1853 _ => false,
1854 };
1855 let extra = match (replace, is_image) {
1856 (true, true) => 1, (true, false) => 0,
1858 (false, true) => 2, (false, false) => 1,
1860 };
1861 if extra > 0 && !self.can_add_lines(extra) {
1862 return;
1863 }
1864 let line = line_from_block(&block, self.line_max_len);
1865 if replace {
1866 self.lines[self.cursor] = line;
1867 } else {
1868 self.lines.insert(self.cursor + 1, line);
1869 self.cursor += 1;
1870 }
1871 if is_image {
1873 self.lines.insert(self.cursor + 1, self.empty_line());
1874 self.cursor += 1;
1875 }
1876 }
1877
1878 fn adopt_pasted_paths(&mut self) {
1880 if self.plain {
1881 return;
1882 }
1883 self.merge_broken_image_paths();
1884 for i in 0..self.lines.len() {
1885 self.try_adopt_line(i);
1886 }
1887 }
1888
1889 fn try_adopt_line(&mut self, i: usize) {
1890 let Line::Text(text) = &self.lines[i] else {
1891 return;
1892 };
1893 let value = text.value();
1894 if !crate::image::looks_like_image(&value) {
1895 return;
1896 }
1897 if let Some(path) = crate::image::path_if_image_in(&value, &self.image_root) {
1898 self.lines[i] = Line::Image {
1899 path: crate::image::short_in(&path, &self.image_root),
1900 };
1901 }
1902 }
1903
1904 fn merge_broken_image_paths(&mut self) {
1907 let mut i = 0;
1908 while i + 1 < self.lines.len() {
1909 let joined = match (&self.lines[i], &self.lines[i + 1]) {
1910 (Line::Text(a), Line::Text(b)) => {
1911 let left = a.value();
1912 let right = b.value();
1913 let joined = format!("{left}{right}");
1914 if left.contains('/')
1917 && !crate::image::looks_like_image(&left)
1918 && self.line_text_fits(&joined)
1919 && crate::image::path_if_image_in(&joined, &self.image_root).is_some()
1920 {
1921 Some(joined)
1922 } else {
1923 None
1924 }
1925 }
1926 _ => None,
1927 };
1928 if let Some(path) = joined {
1929 let len = self.line_max_len;
1930 self.lines[i] = Line::Text(TextInput::new(&path, len));
1931 self.lines.remove(i + 1);
1932 if self.cursor > i {
1933 self.cursor -= 1;
1934 }
1935 } else {
1937 i += 1;
1938 }
1939 }
1940 }
1941
1942 pub fn layout(&mut self, width: usize, height: u16) -> (Vec<Placed>, Option<(u16, u16)>) {
1948 if height == 0 || width == 0 {
1949 return (Vec::new(), None);
1950 }
1951 self.layout_width = width;
1952
1953 let numbers = number_runs(&self.lines);
1954 let mut total = 0usize;
1955 let layouts: Vec<LineLayout> = self
1956 .lines
1957 .iter()
1958 .enumerate()
1959 .map(|(i, line)| {
1960 let number = numbers[i];
1961 let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
1962 let field = width.saturating_sub(indent).max(1);
1963 let wraps = line
1964 .input_ref()
1965 .map(|text| text.wrap_breaks(field))
1966 .unwrap_or_default();
1967 let rows = if matches!(line, Line::Image { .. }) {
1968 usize::from(IMAGE_ROWS)
1969 } else {
1970 wraps.len().max(1)
1971 };
1972 let layout = LineLayout {
1973 number,
1974 wraps,
1975 start: total,
1976 rows,
1977 selection: self
1978 .char_sel_on_line(i)
1979 .or_else(|| line.input_ref().and_then(TextInput::selection_range)),
1980 selected: i == self.cursor || self.line_in_selection(i),
1981 };
1982 total = total.saturating_add(rows);
1983 layout
1984 })
1985 .collect();
1986 self.content_height = total;
1987
1988 let cursor_visual = {
1990 let layout = &layouts[self.cursor];
1991 let row_in_block = self.lines[self.cursor]
1992 .input_ref()
1993 .map(|text| text.wrap_cursor_from_breaks(&layout.wraps).0)
1994 .unwrap_or(0);
1995 layout.start.saturating_add(row_in_block)
1996 };
1997 if cursor_visual < self.scroll {
1998 self.scroll = cursor_visual;
1999 } else if cursor_visual >= self.scroll.saturating_add(usize::from(height)) {
2000 self.scroll = cursor_visual + 1 - usize::from(height);
2001 }
2002 self.scroll = self.scroll.min(total.saturating_sub(usize::from(height)));
2003
2004 let mut placed = Vec::new();
2005 let mut cursor_at = None;
2006 for (i, (line, layout)) in self.lines.iter_mut().zip(&layouts).enumerate() {
2007 let Some((y, vis_rows, skip)) =
2012 visible_band(layout.start, layout.rows, self.scroll, height)
2013 else {
2014 continue;
2015 };
2016 let (text, kind) = match line {
2017 Line::Todo { text, done } => (text, TextKind::Todo { done: *done }),
2018 Line::Bullet(text) => (text, TextKind::Bullet),
2019 Line::Number(text) => (text, TextKind::Number(layout.number.unwrap_or(1))),
2020 Line::Link(text) => (text, TextKind::Link),
2021 Line::Text(text) => (text, TextKind::Plain),
2022 Line::Image { path } => {
2023 placed.push(Placed {
2024 block: Painted::Image(resolve_image_reference(
2025 path,
2026 &self.image_root,
2027 &self.attachments,
2028 )),
2029 line: i,
2030 y,
2031 rows: vis_rows,
2032 selected: layout.selected,
2033 });
2034 continue;
2035 }
2036 };
2037 let indent = kind.indent();
2038 let view = text.wrapped_from_breaks(&layout.wraps, layout.selection);
2039 if i == self.cursor {
2040 let row = usize::from(view.cursor_row).saturating_sub(skip) as u16;
2041 cursor_at = Some((y.saturating_add(row), view.cursor_col + indent as u16));
2042 }
2043 let wrap_rows: Vec<WrappedRow> = view
2044 .lines
2045 .into_iter()
2046 .skip(skip)
2047 .take(vis_rows as usize)
2048 .map(|l| WrappedRow {
2049 text: l.text,
2050 sel: l.sel_cols,
2051 })
2052 .collect();
2053 placed.push(Placed {
2054 block: Painted::Text {
2055 rows: wrap_rows,
2056 kind,
2057 },
2058 line: i,
2059 y,
2060 rows: vis_rows,
2061 selected: layout.selected,
2062 });
2063 }
2064 (placed, cursor_at)
2065 }
2066
2067 pub fn scroll(&self) -> usize {
2069 self.scroll
2070 }
2071
2072 pub fn content_height(&self) -> usize {
2074 self.content_height
2075 }
2076
2077 pub fn click(&mut self, row: u16, col: usize) -> bool {
2081 let width = self.layout_width.max(1);
2082 let numbers = number_runs(&self.lines);
2083 let target = self.scroll.saturating_add(usize::from(row));
2084 let mut at = 0usize;
2085 let mut hit = false;
2086 for (i, line) in self.lines.iter().enumerate() {
2087 let h = line.height(width, numbers[i]);
2088 if target < at.saturating_add(h) {
2089 self.cursor = i;
2090 let row_in = target.saturating_sub(at);
2091 let indent = numbers[i]
2092 .map(number_indent)
2093 .unwrap_or_else(|| line.indent());
2094 let field = width.saturating_sub(indent).max(1);
2095 if let Some(input) = self.lines[i].input() {
2096 input.set_cursor_from_wrap(field, row_in, col.saturating_sub(indent));
2097 }
2098 hit = true;
2099 break;
2100 }
2101 at = at.saturating_add(h);
2102 }
2103 self.close_menu();
2104 self.prefer_col = u16::MAX;
2105 self.clear_body_selection();
2106 hit
2107 }
2108}
2109
2110#[cfg(test)]
2111mod tests {
2112 use super::*;
2113
2114 fn editor(blocks: &[Block]) -> BodyEditor {
2115 BodyEditor::new(blocks)
2116 }
2117
2118 fn type_in(editor: &mut BodyEditor, text: &str) {
2119 for c in text.chars() {
2120 editor.insert(c);
2121 }
2122 }
2123
2124 #[test]
2125 fn types_prose_and_splits_lines() {
2126 let mut e = editor(&[]);
2127 type_in(&mut e, "first");
2128 e.newline();
2129 type_in(&mut e, "second");
2130 assert_eq!(e.value(), vec![Block::text("first"), Block::text("second")]);
2131 }
2132
2133 #[test]
2134 fn body_value_round_trips_blank_rows_around_content() {
2135 let mut e = editor(&[]);
2136 assert!(e.newline());
2137 type_in(&mut e, "first");
2138 assert!(e.newline());
2139 assert!(e.newline());
2140 type_in(&mut e, "second");
2141 assert!(e.newline());
2142
2143 let expected = vec![
2144 Block::text(""),
2145 Block::text("first"),
2146 Block::text(""),
2147 Block::text("second"),
2148 Block::text(""),
2149 ];
2150 assert_eq!(e.value(), expected);
2151 assert_eq!(editor(&e.value()).value(), expected);
2152 assert!(editor(&[]).value().is_empty());
2153 }
2154
2155 #[test]
2156 fn refuses_more_lines_than_the_cap() {
2157 let mut e = BodyEditor::from_blocks(&[], 2, 32, false);
2158 type_in(&mut e, "a");
2159 assert!(e.newline());
2160 type_in(&mut e, "b");
2161 assert!(!e.newline(), "third line blocked");
2162 assert_eq!(e.value().len(), 2);
2163 }
2164
2165 #[test]
2166 fn plain_description_uses_shorter_line_cap() {
2167 let mut e = BodyEditor::plain("");
2168 type_in(&mut e, &"x".repeat(MAX_CATEGORY_DESC_LINE_LEN + 10));
2169 assert_eq!(
2170 e.value()[0],
2171 Block::text(&"x".repeat(MAX_CATEGORY_DESC_LINE_LEN))
2172 );
2173 }
2174
2175 #[test]
2176 fn slash_bullet_turns_the_line_into_a_point() {
2177 let mut e = editor(&[]);
2178 type_in(&mut e, "/bul");
2179 e.apply(Command::Bullet);
2180 type_in(&mut e, "a point");
2181 assert_eq!(e.value(), vec![Block::bullet("a point")]);
2182 }
2183
2184 #[test]
2185 fn slash_number_turns_the_line_into_a_list_item() {
2186 let mut e = editor(&[]);
2187 type_in(&mut e, "/num");
2188 assert_eq!(e.menu_selected(), Some(Command::Number));
2189 e.apply(Command::Number);
2190 type_in(&mut e, "first");
2191 e.newline();
2192 type_in(&mut e, "/number");
2194 e.apply(Command::Number);
2195 type_in(&mut e, "second");
2196 assert_eq!(
2197 e.value(),
2198 vec![Block::number("first"), Block::number("second")]
2199 );
2200 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2201 }
2202
2203 #[test]
2204 fn typing_1_dot_space_makes_a_numbered_item() {
2205 let mut e = editor(&[]);
2206 type_in(&mut e, "1. ");
2207 type_in(&mut e, "alpha");
2208 assert_eq!(e.value(), vec![Block::number("alpha")]);
2209 }
2210
2211 #[test]
2212 fn slash_link_turns_the_line_into_a_url() {
2213 let mut e = editor(&[Block::text("https://example.com")]);
2214 e.end();
2215 type_in(&mut e, "/link");
2216 assert_eq!(e.menu_selected(), Some(Command::Link));
2217 e.apply(Command::Link);
2218 assert_eq!(e.value(), vec![Block::link("https://example.com")]);
2219 }
2220
2221 #[test]
2222 fn slash_link_unwraps_markdown() {
2223 let mut e = editor(&[Block::text("[docs](https://example.com/docs)")]);
2224 e.end();
2225 type_in(&mut e, "/link");
2226 e.apply(Command::Link);
2227 assert_eq!(e.value(), vec![Block::link("https://example.com/docs")]);
2228 }
2229
2230 #[test]
2231 fn slash_link_on_empty_line_is_ready_for_a_url() {
2232 let mut e = editor(&[]);
2233 type_in(&mut e, "/link");
2234 e.apply(Command::Link);
2235 type_in(&mut e, "https://x.ai");
2236 assert_eq!(e.value(), vec![Block::link("https://x.ai")]);
2237 }
2238
2239 #[test]
2240 fn slash_todo_turns_the_line_into_a_task() {
2241 let mut e = editor(&[]);
2242 type_in(&mut e, "/todo");
2243 let menu = e.menu.as_ref().expect("menu is open");
2244 assert_eq!(menu.query, "todo");
2245 assert_eq!(e.menu_selected(), Some(Command::Todo));
2246 e.apply(Command::Todo);
2247 type_in(&mut e, "buy milk");
2248 assert_eq!(e.value(), vec![Block::todo("buy milk", false)]);
2249 assert!(e.menu.is_none());
2250 }
2251
2252 #[test]
2253 fn enter_in_a_todo_starts_a_plain_line() {
2254 let mut e = editor(&[Block::todo("one", false)]);
2255 e.end();
2256 e.newline();
2257 type_in(&mut e, "two");
2258 assert_eq!(
2259 e.value(),
2260 vec![Block::todo("one", false), Block::text("two")]
2261 );
2262 }
2263
2264 #[test]
2265 fn backspace_at_the_start_unmakes_a_todo() {
2266 let mut e = editor(&[Block::todo("one", false)]);
2267 e.home();
2268 e.backspace();
2269 assert_eq!(e.value(), vec![Block::text("one")]);
2270 }
2271
2272 #[test]
2273 fn moving_the_cursor_closes_the_body_command_menu() {
2274 let mut editor = BodyEditor::new(&[]);
2275 for c in "/todo".chars() {
2276 editor.insert(c);
2277 }
2278 assert!(editor.menu.is_some());
2279
2280 editor.left();
2281
2282 assert!(
2283 editor.menu.is_none(),
2284 "the cached query must never outlive its caret range"
2285 );
2286 assert_eq!(editor.value(), vec![Block::text("/todo")]);
2287 }
2288
2289 #[test]
2290 fn link_hit_testing_excludes_blank_row_padding() {
2291 let mut editor = BodyEditor::new(&[Block::link("https://example.com")]);
2292 let _ = editor.layout(40, 4);
2293
2294 assert_eq!(
2295 editor.link_url_at_position(0, 3).as_deref(),
2296 Some("https://example.com")
2297 );
2298 assert_eq!(editor.link_url_at_position(0, 39), None);
2299 }
2300
2301 #[test]
2302 fn toggling_counts_towards_progress() {
2303 let mut e = editor(&[Block::todo("a", false), Block::todo("b", false)]);
2304 assert_eq!(e.progress(), (0, 2));
2305 e.toggle();
2306 assert_eq!(e.progress(), (1, 2));
2307 }
2308
2309 #[test]
2310 fn an_image_lands_between_the_lines_with_room_to_type() {
2311 let mut e = editor(&[]);
2312 type_in(&mut e, "before");
2313 e.newline();
2314 e.insert_block(Block::image("/tmp/a.png"));
2315 type_in(&mut e, "after");
2316 assert_eq!(
2317 e.value(),
2318 vec![
2319 Block::text("before"),
2320 Block::image("/tmp/a.png"),
2321 Block::text("after")
2322 ]
2323 );
2324 assert_eq!(e.images().len(), 1);
2325 }
2326
2327 #[test]
2328 fn a_path_in_the_body_becomes_a_picture() {
2329 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2331 let e = editor(&[Block::text(path), Block::text("below")]);
2332 assert!(matches!(e.value()[0], Block::Image { .. }));
2333 assert_eq!(e.value()[1], Block::text("below"));
2334 }
2335
2336 #[test]
2337 fn a_path_to_nothing_stays_text() {
2338 let e = editor(&[Block::text("/tmp/not-here-at-all.png")]);
2339 assert!(matches!(e.value()[0], Block::Text { .. }));
2340 }
2341
2342 #[test]
2343 fn a_pasted_path_is_a_picture_at_once() {
2344 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2345 let mut e = editor(&[]);
2346 e.insert_str(path);
2347 assert!(
2348 matches!(e.value()[0], Block::Image { .. }),
2349 "no need to move the cursor off it first"
2350 );
2351 type_in(&mut e, "and on we go");
2352 assert_eq!(e.value()[1], Block::text("and on we go"));
2353 }
2354
2355 #[test]
2356 fn a_complete_path_becomes_a_picture_even_under_the_cursor() {
2357 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2358 let mut e = editor(&[]);
2359 type_in(&mut e, path);
2360 e.layout(40, 20);
2361 assert!(
2362 matches!(e.value()[0], Block::Image { .. }),
2363 "complete existing path converts without leaving the line"
2364 );
2365 }
2366
2367 #[test]
2368 fn shift_option_left_selects_across_lines() {
2369 let mut e = editor(&[Block::text("one two"), Block::text("three four")]);
2370 e.down();
2371 e.end(); e.select_word_left(); assert_eq!(e.selected_text().as_deref(), Some("four"));
2374 e.select_word_left(); e.select_word_left(); let sel = e.selected_text().expect("cross-line selection");
2377 assert!(
2378 sel.contains("two") && sel.contains("three"),
2379 "expected multi-line sel, got {sel:?}"
2380 );
2381 }
2382
2383 #[test]
2384 fn shift_selection_includes_pictures_with_text() {
2385 let path = "/tmp/shot.png";
2386 let mut e = editor(&[
2387 Block::text("above"),
2388 Block::image(path),
2389 Block::text("below here"),
2390 ]);
2391 e.down();
2393 e.down();
2394 e.home();
2395 e.select_word_left(); assert!(e.line_in_selection(1), "picture covered by selection");
2397 assert!(
2398 matches!(e.selected_payload(), Some(CopyPayload::All(_))),
2399 "mixed selection is rich copy"
2400 );
2401 e.select_word_left(); let sel = e.selected_text().expect("text+image selection");
2403 assert!(
2404 sel.contains("above") && sel.contains("[image:") && !sel.contains("below"),
2405 "got {sel:?}"
2406 );
2407 let (placed, _) = e.layout(40, 40);
2409 let img = placed
2410 .iter()
2411 .find(|p| matches!(p.block, Painted::Image(_)))
2412 .expect("image placed");
2413 assert!(img.selected, "outer frame while selection covers image");
2414 }
2415
2416 #[test]
2417 fn a_pasted_path_broken_by_newlines_still_becomes_a_picture() {
2418 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2419 let mid = path.len() / 2;
2421 let broken = format!("{}\n{}", &path[..mid], &path[mid..]);
2422 let mut e = editor(&[]);
2423 e.insert_str(&broken);
2424 assert!(
2425 matches!(e.value()[0], Block::Image { .. }),
2426 "flattened paste: {broken:?} → {:?}",
2427 e.value()
2428 );
2429 }
2430
2431 #[test]
2432 fn two_text_lines_that_form_a_path_are_merged() {
2433 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2434 let mid = path.len() / 2;
2435 let e = editor(&[Block::text(&path[..mid]), Block::text(&path[mid..])]);
2436 assert!(
2437 matches!(e.value()[0], Block::Image { .. }),
2438 "split lines rejoin: {:?}",
2439 e.value()
2440 );
2441 }
2442
2443 #[test]
2444 fn dash_and_a_space_make_a_bullet() {
2445 let mut e = editor(&[]);
2446 type_in(&mut e, "- buy milk");
2447 assert_eq!(e.value(), vec![Block::bullet("buy milk")]);
2448 }
2449
2450 #[test]
2451 fn a_dash_mid_line_is_just_a_dash() {
2452 let mut e = editor(&[]);
2453 type_in(&mut e, "a - b");
2454 assert_eq!(e.value(), vec![Block::text("a - b")]);
2455 }
2456
2457 #[test]
2458 fn backspace_at_the_start_unmakes_a_bullet() {
2459 let mut e = editor(&[Block::bullet("one")]);
2460 e.home();
2461 e.backspace();
2462 assert_eq!(e.value(), vec![Block::text("one")]);
2463 }
2464
2465 #[test]
2466 fn plain_text_round_trips_its_bullets() {
2467 let e = BodyEditor::plain("intro\n- first\n- second");
2468 assert_eq!(e.value().len(), 3);
2469 assert_eq!(e.plain_value(), "intro\n- first\n- second");
2470 }
2471
2472 #[test]
2473 fn plain_description_round_trips_blank_rows() {
2474 let description = "\nfirst\n\nsecond\n";
2475 let e = BodyEditor::plain(description);
2476
2477 assert_eq!(e.plain_value(), description);
2478 assert_eq!(
2479 BodyEditor::plain(&e.plain_value()).plain_value(),
2480 description
2481 );
2482 }
2483
2484 #[test]
2485 fn plain_mode_rejects_todo_slash_command() {
2486 let mut e = BodyEditor::plain("");
2487 type_in(&mut e, "/todo");
2488 assert!(e.menu.is_none(), "no to-do command in plain mode");
2489 assert_eq!(e.value(), vec![Block::text("/todo")]);
2490 }
2491
2492 #[test]
2493 fn plain_mode_slash_makes_a_bullet() {
2494 let mut e = BodyEditor::plain("");
2495 type_in(&mut e, "/bul");
2496 assert_eq!(e.menu_selected(), Some(Command::Bullet));
2497 e.apply(Command::Bullet);
2498 type_in(&mut e, "a point");
2499 assert_eq!(e.plain_value(), "- a point");
2500 }
2501
2502 #[test]
2503 fn copy_exports_text_and_skips_images() {
2504 let e = editor(&[
2505 Block::text("hello"),
2506 Block::todo("one", true),
2507 Block::image("/tmp/a.png"),
2508 Block::bullet("point"),
2509 ]);
2510 assert_eq!(e.text_for_copy(), "hello\n[✓] one\n- point");
2511 }
2512
2513 #[test]
2514 fn copy_all_keeps_text_and_images_in_order() {
2515 let e = editor(&[
2516 Block::text("hello"),
2517 Block::image("/tmp/a.png"),
2518 Block::bullet("point"),
2519 ]);
2520 let lines = e.lines_for_copy_all();
2521 assert_eq!(lines.len(), 3);
2522 match &lines[0] {
2523 CopyLine::Text(t) => assert_eq!(t, "hello"),
2524 _ => panic!("text first"),
2525 }
2526 match &lines[1] {
2527 CopyLine::Image(p) => assert!(p.ends_with("a.png")),
2528 _ => panic!("image second"),
2529 }
2530 match &lines[2] {
2531 CopyLine::Text(t) => assert_eq!(t, "- point"),
2532 _ => panic!("bullet third"),
2533 }
2534 }
2535
2536 #[test]
2537 fn slash_copy_strips_the_query_and_returns_text() {
2538 let mut e = editor(&[Block::text("keep me")]);
2539 e.end();
2540 e.newline();
2541 type_in(&mut e, "/copy");
2542 assert_eq!(e.menu_selected(), Some(Command::Copy));
2543 match e.apply(Command::Copy).expect("copy yields payload") {
2544 CopyPayload::Text(text) => assert_eq!(text, "keep me"),
2545 CopyPayload::Image(_) | CopyPayload::All(_) => panic!("expected text"),
2546 }
2547 assert!(e.menu.is_none());
2548 assert_eq!(e.value(), vec![Block::text("keep me")]);
2550 }
2551
2552 #[test]
2553 fn image_for_copy_picks_nearest_above_the_cursor() {
2554 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2555 let mut e = editor(&[
2556 Block::text("above"),
2557 Block::image(path),
2558 Block::text("below"),
2559 ]);
2560 e.down();
2562 e.down();
2563 let got = e.image_for_copy().expect("finds the image above");
2564 assert!(got.ends_with("screenshot.png"));
2565 }
2566
2567 #[test]
2568 fn slash_image_returns_the_picture_path() {
2569 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2570 let mut e = editor(&[Block::image(path), Block::text("")]);
2571 e.down();
2572 type_in(&mut e, "/img");
2573 assert_eq!(e.menu_selected(), Some(Command::CopyImage));
2574 match e.apply(Command::CopyImage).expect("image payload") {
2575 CopyPayload::Image(p) => assert!(p.ends_with("screenshot.png")),
2576 CopyPayload::Text(_) | CopyPayload::All(_) => panic!("expected image"),
2577 }
2578 }
2579
2580 #[test]
2581 fn a_picture_is_deleted_by_backspace() {
2582 let mut e = editor(&[Block::text("a"), Block::image("/tmp/a.png")]);
2583 e.down();
2584 e.backspace();
2585 assert_eq!(e.value(), vec![Block::text("a")]);
2586 }
2587
2588 #[test]
2589 fn the_menu_filters_by_abbreviation() {
2590 let mut e = editor(&[]);
2591 type_in(&mut e, "/che");
2592 assert_eq!(e.menu_selected(), Some(Command::Todo));
2593 }
2594
2595 #[test]
2596 fn the_menu_closes_when_nothing_matches() {
2597 let mut e = editor(&[]);
2598 type_in(&mut e, "/zz");
2599 assert!(e.menu.is_none());
2600 assert_eq!(e.value(), vec![Block::text("/zz")], "the text is kept");
2601 }
2602
2603 #[test]
2604 fn the_menu_closes_when_the_slash_is_removed() {
2605 let mut e = editor(&[]);
2606 type_in(&mut e, "/to");
2607 assert!(e.menu.is_some());
2608 e.backspace();
2609 e.backspace();
2610 assert!(e.menu.is_some());
2611 e.backspace();
2612 assert!(e.menu.is_none(), "removing the slash closes the menu");
2613 }
2614
2615 #[test]
2616 fn right_on_sole_image_inserts_text_line() {
2617 let blocks = [Block::image("/tmp/x.png")];
2618 let mut e = editor(&blocks);
2619 assert!(matches!(e.lines[0], Line::Image { .. }));
2620 assert_eq!(e.cursor, 0);
2621 e.right();
2622 assert_eq!(e.lines.len(), 2, "should insert a text line");
2623 assert_eq!(e.cursor, 1);
2624 assert!(matches!(e.lines[1], Line::Text(_)));
2625 assert!(e.input().is_some());
2626 }
2627
2628 #[test]
2629 fn left_on_first_image_inserts_text_line_above() {
2630 let blocks = [Block::image("/tmp/x.png")];
2631 let mut e = editor(&blocks);
2632 e.left();
2633 assert_eq!(e.lines.len(), 2);
2634 assert_eq!(e.cursor, 0);
2635 assert!(matches!(e.lines[0], Line::Text(_)));
2636 assert!(matches!(e.lines[1], Line::Image { .. }));
2637 }
2638
2639 #[test]
2640 fn visible_band_clips_top_like_bottom() {
2641 assert_eq!(visible_band(5, 10, 8, 10), Some((0, 7, 3)));
2644 assert_eq!(visible_band(0, 5, 8, 10), None);
2646 assert_eq!(visible_band(20, 5, 8, 10), None);
2647 assert_eq!(visible_band(8, 10, 0, 12), Some((8, 4, 0)));
2649 }
2650
2651 #[test]
2652 fn consecutive_images_shrink_when_scrolled_off_the_top() {
2653 let path = "/tmp/a.png";
2654 let mut e = editor(&[Block::image(path), Block::image(path), Block::text("tail")]);
2655 e.layout(40, 30);
2657 assert!(e.click(20, 0));
2658 assert_eq!(e.cursor_line(), 2);
2659
2660 let (placed, _) = e.layout(40, 12);
2661 assert_eq!(e.scroll(), 9);
2663 let imgs: Vec<_> = placed
2664 .iter()
2665 .filter(|p| matches!(p.block, Painted::Image(_)))
2666 .collect();
2667 assert_eq!(imgs.len(), 2);
2668 assert_eq!((imgs[0].y, imgs[0].rows), (0, 1));
2670 assert_eq!((imgs[1].y, imgs[1].rows), (1, 10));
2672 assert_eq!(imgs[0].y + imgs[0].rows, imgs[1].y);
2674 }
2675
2676 #[test]
2677 fn narrow_maximum_body_keeps_the_last_row_addressable() {
2678 let line = "x".repeat(MAX_NOTES_LINE_LEN);
2679 let blocks = vec![Block::text(&line); MAX_BODY_LINES];
2680 let mut e = editor(&blocks);
2681 e.cursor = e.lines.len() - 1;
2682 e.input().unwrap().end();
2683
2684 let (_, cursor) = e.layout(1, 10);
2685
2686 let expected_height = MAX_BODY_LINES * MAX_NOTES_LINE_LEN;
2687 assert_eq!(e.content_height() as usize, expected_height);
2688 assert_eq!(e.scroll() as usize, expected_height - 10);
2689 assert_eq!(cursor, Some((9, 1)));
2690 assert!(e.click(0, 0));
2691 assert_eq!(e.cursor_line(), MAX_BODY_LINES - 1);
2692 }
2693
2694 #[test]
2695 fn line_join_at_the_length_limit_never_discards_the_next_line() {
2696 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2697
2698 let mut backward = editor(&[Block::text(&full), Block::text("tail")]);
2699 backward.cursor = 1;
2700 backward.input().unwrap().home();
2701 backward.backspace();
2702 assert_eq!(
2703 backward.value(),
2704 vec![Block::text(&full), Block::text("tail")]
2705 );
2706
2707 let mut forward = editor(&[Block::text(&full), Block::text("tail")]);
2708 forward.input().unwrap().end();
2709 forward.delete();
2710 assert_eq!(
2711 forward.value(),
2712 vec![Block::text(&full), Block::text("tail")]
2713 );
2714 }
2715
2716 #[test]
2717 fn oversized_cross_line_selection_replacement_is_rejected_without_data_loss() {
2718 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2719 let mut editor = editor(&[Block::text(&full), Block::text("tail")]);
2720 editor.sel_anchor = Some((0, MAX_NOTES_LINE_LEN));
2721 editor.cursor = 1;
2722 editor.input().unwrap().home();
2723
2724 editor.insert('x');
2725
2726 assert_eq!(
2727 editor.value(),
2728 vec![Block::text(&full), Block::text("tail")]
2729 );
2730 }
2731
2732 #[test]
2733 fn oversized_image_path_detection_never_discards_a_source_line() {
2734 let root = std::env::temp_dir().join(format!(
2735 "mach-long-image-path-test-{}",
2736 uuid::Uuid::new_v4()
2737 ));
2738 let first_dir = "a".repeat(240);
2739 let second_dir = "b".repeat(240);
2740 let file = format!("{}.png", "c".repeat(40));
2741 let relative = format!("{first_dir}/{second_dir}/{file}");
2742 assert!(relative.graphemes(true).count() > MAX_NOTES_LINE_LEN);
2743 std::fs::create_dir_all(root.join(&first_dir).join(&second_dir)).unwrap();
2744 std::fs::write(root.join(&relative), []).unwrap();
2745
2746 let split = relative.len() / 2;
2747 let (left, right) = relative.split_at(split);
2748 let expected = vec![Block::text(left), Block::text(right)];
2749 let mut loaded = editor(&expected);
2750 loaded.set_image_root(root.clone());
2751 assert_eq!(loaded.value(), expected);
2752
2753 let mut pasted = editor(&[]);
2754 pasted.set_image_root(root.clone());
2755 pasted.insert_str(&format!("{left}\n{right}"));
2756 assert_eq!(pasted.value(), expected);
2757
2758 std::fs::remove_dir_all(root).unwrap();
2759 }
2760}