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
1097 let exits_list = match &self.lines[self.cursor] {
1098 Line::Todo { text, .. } | Line::Bullet(text) | Line::Number(text) => {
1099 text.value().trim().is_empty()
1100 }
1101 Line::Text(_) | Line::Link(_) | Line::Image { .. } => false,
1102 };
1103 if exits_list {
1104 self.lines[self.cursor] = self.empty_line();
1105 return true;
1106 }
1107
1108 if !self.can_add_lines(1) {
1109 return false;
1110 }
1111 let line_max_len = self.line_max_len;
1112 let next = match self.line() {
1113 Line::Text(text) | Line::Link(text) => Line::Text(text.split_off_at_cursor()),
1114 Line::Todo { text, .. } => Line::Todo {
1115 text: text.split_off_at_cursor(),
1116 done: false,
1117 },
1118 Line::Bullet(text) => Line::Bullet(text.split_off_at_cursor()),
1119 Line::Number(text) => Line::Number(text.split_off_at_cursor()),
1120 Line::Image { .. } => Line::Text(TextInput::new("", line_max_len)),
1121 };
1122 self.lines.insert(self.cursor + 1, next);
1123 self.cursor += 1;
1124 self.adopt_pasted_paths();
1126 true
1127 }
1128
1129 pub fn backspace(&mut self) {
1130 if let Some(start) = self.menu.as_ref().map(|m| m.start) {
1131 let at = self.input().map(|i| i.cursor()).unwrap_or(0);
1132 if at <= start {
1133 self.close_menu();
1134 } else if let Some(menu) = &mut self.menu {
1135 menu.query.pop();
1136 menu.index = 0;
1137 }
1138 }
1139 let had_selection = self.has_selection();
1140 if self.delete_body_selection() {
1141 return;
1142 }
1143 if had_selection {
1144 return;
1145 }
1146 match self.line() {
1147 Line::Todo { text, .. }
1150 | Line::Bullet(text)
1151 | Line::Number(text)
1152 | Line::Link(text)
1153 if text.at_start() =>
1154 {
1155 let text = text.clone();
1156 self.lines[self.cursor] = Line::Text(text);
1157 return;
1158 }
1159 Line::Text(text) if text.at_start() => {}
1160 Line::Image { .. } => {
1161 self.remove_block();
1162 return;
1163 }
1164 _ => {
1165 if let Some(input) = self.input() {
1166 input.backspace();
1167 }
1168 return;
1169 }
1170 }
1171 if self.cursor == 0 {
1173 return;
1174 }
1175 let current = self.lines.remove(self.cursor);
1176 self.cursor -= 1;
1177 match current {
1178 Line::Text(text) => {
1179 let merged = match self.lines[self.cursor].input() {
1180 Some(previous) => previous.append(&text),
1181 None => text.is_empty(),
1183 };
1184 if !merged {
1185 self.cursor += 1;
1186 self.lines.insert(self.cursor, Line::Text(text));
1187 }
1188 }
1189 line => {
1190 self.cursor += 1;
1191 self.lines.insert(self.cursor, line);
1192 }
1193 }
1194 }
1195
1196 pub fn delete(&mut self) {
1197 self.close_menu();
1198 let had_selection = self.has_selection();
1199 if self.delete_body_selection() {
1200 return;
1201 }
1202 if had_selection {
1203 return;
1204 }
1205 if matches!(self.line(), Line::Image { .. }) {
1206 self.remove_block();
1207 return;
1208 }
1209 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1210 if !at_end {
1211 if let Some(input) = self.input() {
1212 input.delete();
1213 }
1214 return;
1215 }
1216 if self.cursor + 1 >= self.lines.len() {
1217 return;
1218 }
1219 let next = self.lines.remove(self.cursor + 1);
1220 let merged = match next.input_ref() {
1221 Some(text) => self.lines[self.cursor]
1222 .input()
1223 .is_some_and(|current| current.append(text)),
1224 None => false,
1225 };
1226 if !merged {
1227 self.lines.insert(self.cursor + 1, next);
1228 }
1229 }
1230
1231 pub fn remove_block(&mut self) {
1233 if self.lines.len() == 1 {
1234 self.lines[0] = self.empty_line();
1235 return;
1236 }
1237 self.lines.remove(self.cursor);
1238 self.cursor = self.cursor.min(self.lines.len() - 1);
1239 }
1240
1241 pub fn toggle(&mut self) {
1242 if let Line::Todo { done, .. } = self.line() {
1243 *done = !*done;
1244 }
1245 }
1246
1247 fn field_width_for(&self, index: usize) -> usize {
1250 let width = self.layout_width.max(1);
1251 let n = match &self.lines[index] {
1252 Line::Number(_) => Some(number_at(&self.lines, index)),
1253 _ => None,
1254 };
1255 let indent = n
1256 .map(number_indent)
1257 .unwrap_or_else(|| self.lines[index].indent());
1258 width.saturating_sub(indent).max(1)
1259 }
1260
1261 pub fn up(&mut self) {
1262 self.close_menu();
1263 self.clear_body_selection();
1264 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1266 self.leave_image_backward();
1267 return;
1268 }
1269 let width = self.field_width_for(self.cursor);
1270 let prefer = self.prefer_col;
1271 let moved = self
1272 .input()
1273 .is_some_and(|input| input.wrap_up(width, prefer));
1274 if moved {
1275 if self.prefer_col == u16::MAX
1276 && let Some(input) = self.input()
1277 {
1278 self.prefer_col = input.wrap_cursor(width).1;
1279 }
1280 return;
1281 }
1282 if self.cursor > 0 {
1283 let left = self.cursor;
1284 self.cursor -= 1;
1285 self.try_adopt_line(left);
1286 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1288 return;
1289 }
1290 let width = self.field_width_for(self.cursor);
1291 let prefer = if self.prefer_col == u16::MAX {
1292 0
1293 } else {
1294 self.prefer_col
1295 };
1296 if let Some(input) = self.input() {
1297 let last = input.wrap_height(width).saturating_sub(1);
1298 input.set_cursor_from_wrap(width, last, prefer as usize);
1299 }
1300 }
1301 }
1302
1303 pub fn down(&mut self) {
1304 self.close_menu();
1305 self.clear_body_selection();
1306 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1307 self.leave_image_forward();
1308 return;
1309 }
1310 let width = self.field_width_for(self.cursor);
1311 let prefer = self.prefer_col;
1312 let moved = self
1313 .input()
1314 .is_some_and(|input| input.wrap_down(width, prefer));
1315 if moved {
1316 if self.prefer_col == u16::MAX
1317 && let Some(input) = self.input()
1318 {
1319 self.prefer_col = input.wrap_cursor(width).1;
1320 }
1321 return;
1322 }
1323 if self.cursor + 1 < self.lines.len() {
1324 let left = self.cursor;
1325 self.cursor += 1;
1326 self.try_adopt_line(left);
1327 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1329 return;
1330 }
1331 let width = self.field_width_for(self.cursor);
1332 let prefer = if self.prefer_col == u16::MAX {
1333 0
1334 } else {
1335 self.prefer_col
1336 };
1337 if let Some(input) = self.input() {
1338 input.set_cursor_from_wrap(width, 0, prefer as usize);
1339 }
1340 }
1341 }
1342
1343 pub fn left(&mut self) {
1344 self.close_menu();
1345 self.prefer_col = u16::MAX;
1346 self.clear_body_selection();
1347 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1350 self.leave_image_backward();
1351 return;
1352 }
1353 match self.input() {
1354 Some(input) if !input.at_start() => input.left(),
1355 _ => {
1356 if self.cursor > 0 {
1357 self.cursor -= 1;
1358 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1360 return;
1361 }
1362 if let Some(input) = self.input() {
1363 input.end();
1364 }
1365 }
1366 }
1367 }
1368 }
1369
1370 pub fn right(&mut self) {
1371 self.close_menu();
1372 self.prefer_col = u16::MAX;
1373 self.clear_body_selection();
1374 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1377 self.leave_image_forward();
1378 return;
1379 }
1380 match self.input() {
1381 Some(input) if !input.at_end() => input.right(),
1382 _ => {
1383 if self.cursor + 1 < self.lines.len() {
1384 self.cursor += 1;
1385 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1387 return;
1388 }
1389 if let Some(input) = self.input() {
1390 input.home();
1391 }
1392 }
1393 }
1394 }
1395 }
1396
1397 fn leave_image_forward(&mut self) {
1401 let next = self.cursor + 1;
1402 if next < self.lines.len() && self.lines[next].input_ref().is_some() {
1403 self.cursor = next;
1404 if let Some(input) = self.input() {
1405 input.home();
1406 }
1407 return;
1408 }
1409 if !self.can_add_lines(1) {
1410 return;
1411 }
1412 self.lines.insert(next, self.empty_line());
1415 self.cursor = next;
1416 }
1417
1418 fn leave_image_backward(&mut self) {
1421 if self.cursor > 0 && self.lines[self.cursor - 1].input_ref().is_some() {
1422 self.cursor -= 1;
1423 if let Some(input) = self.input() {
1424 input.end();
1425 }
1426 return;
1427 }
1428 if !self.can_add_lines(1) {
1429 return;
1430 }
1431 self.lines.insert(self.cursor, self.empty_line());
1432 if let Some(input) = self.input() {
1434 input.home();
1435 }
1436 }
1437
1438 pub fn home(&mut self) {
1439 self.close_menu();
1440 self.clear_body_selection();
1441 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1442 self.leave_image_backward();
1443 return;
1444 }
1445 if let Some(input) = self.input() {
1446 input.home();
1447 }
1448 }
1449
1450 pub fn end(&mut self) {
1451 self.close_menu();
1452 self.clear_body_selection();
1453 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1454 self.leave_image_forward();
1455 return;
1456 }
1457 if let Some(input) = self.input() {
1458 input.end();
1459 }
1460 }
1461
1462 pub fn word_left(&mut self) {
1463 self.close_menu();
1464 self.clear_body_selection();
1465 self.prefer_col = u16::MAX;
1466 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1467 self.leave_image_backward();
1468 return;
1469 }
1470 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1471 if !at_start {
1472 if let Some(input) = self.input() {
1473 input.word_left();
1474 }
1475 return;
1476 }
1477 if let Some(prev) = self.prev_editable(self.cursor) {
1479 self.cursor = prev;
1480 if let Some(input) = self.input() {
1481 input.end();
1482 input.word_left();
1483 }
1484 }
1485 }
1486
1487 pub fn word_right(&mut self) {
1488 self.close_menu();
1489 self.clear_body_selection();
1490 self.prefer_col = u16::MAX;
1491 if matches!(self.lines[self.cursor], Line::Image { .. }) {
1492 self.leave_image_forward();
1493 return;
1494 }
1495 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1496 if !at_end {
1497 if let Some(input) = self.input() {
1498 input.word_right();
1499 }
1500 return;
1501 }
1502 if let Some(next) = self.next_editable(self.cursor) {
1503 self.cursor = next;
1504 if let Some(input) = self.input() {
1505 input.home();
1506 input.word_right();
1507 }
1508 }
1509 }
1510
1511 fn prev_editable(&self, from: usize) -> Option<usize> {
1512 (0..from)
1513 .rev()
1514 .find(|&i| self.lines[i].input_ref().is_some())
1515 }
1516
1517 fn next_editable(&self, from: usize) -> Option<usize> {
1518 ((from + 1)..self.lines.len()).find(|&i| self.lines[i].input_ref().is_some())
1519 }
1520
1521 pub fn select_word(&mut self) {
1522 self.close_menu();
1523 self.sel_anchor = None;
1524 if let Some(input) = self.input() {
1525 input.select_word();
1526 }
1527 }
1528
1529 pub fn select_left(&mut self) {
1530 self.close_menu();
1531 self.prefer_col = u16::MAX;
1532 self.ensure_sel_anchor();
1533 if let Some(input) = self.input() {
1534 input.clear_selection();
1535 }
1536 if self.is_image_line(self.cursor) {
1538 self.step_sel_prev_line();
1539 return;
1540 }
1541 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1542 if !at_start {
1543 if let Some(input) = self.input() {
1544 let c = input.cursor().saturating_sub(1);
1545 input.place_cursor(c);
1546 }
1547 } else {
1548 self.step_sel_prev_line();
1549 }
1550 }
1551
1552 pub fn select_right(&mut self) {
1553 self.close_menu();
1554 self.prefer_col = u16::MAX;
1555 self.ensure_sel_anchor();
1556 if let Some(input) = self.input() {
1557 input.clear_selection();
1558 }
1559 if self.is_image_line(self.cursor) {
1560 self.step_sel_next_line();
1561 return;
1562 }
1563 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1564 if !at_end {
1565 if let Some(input) = self.input() {
1566 let c = (input.cursor() + 1).min(input.len());
1567 input.place_cursor(c);
1568 }
1569 } else {
1570 self.step_sel_next_line();
1571 }
1572 }
1573
1574 pub fn select_word_left(&mut self) {
1576 self.close_menu();
1577 self.prefer_col = u16::MAX;
1578 self.ensure_sel_anchor();
1579 if let Some(input) = self.input() {
1580 input.clear_selection();
1581 }
1582 if self.is_image_line(self.cursor) {
1584 self.step_sel_prev_line();
1585 if !self.is_image_line(self.cursor)
1586 && let Some(input) = self.input()
1587 {
1588 input.end();
1589 let target = input.word_left_index();
1590 input.place_cursor(target);
1591 }
1592 return;
1593 }
1594 let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1595 if !at_start {
1596 if let Some(input) = self.input() {
1597 let target = input.word_left_index();
1598 input.place_cursor(target);
1599 }
1600 return;
1601 }
1602 self.step_sel_prev_line();
1603 if self.is_image_line(self.cursor) {
1604 return;
1605 }
1606 if let Some(input) = self.input() {
1607 input.end();
1608 let target = input.word_left_index();
1609 input.place_cursor(target);
1610 }
1611 }
1612
1613 pub fn select_word_right(&mut self) {
1615 self.close_menu();
1616 self.prefer_col = u16::MAX;
1617 self.ensure_sel_anchor();
1618 if let Some(input) = self.input() {
1619 input.clear_selection();
1620 }
1621 if self.is_image_line(self.cursor) {
1622 self.step_sel_next_line();
1623 if !self.is_image_line(self.cursor)
1624 && let Some(input) = self.input()
1625 {
1626 input.home();
1627 let target = input.word_right_index();
1628 input.place_cursor(target);
1629 }
1630 return;
1631 }
1632 let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1633 if !at_end {
1634 if let Some(input) = self.input() {
1635 let target = input.word_right_index();
1636 input.place_cursor(target);
1637 }
1638 return;
1639 }
1640 self.step_sel_next_line();
1641 if self.is_image_line(self.cursor) {
1642 return;
1643 }
1644 if let Some(input) = self.input() {
1645 input.home();
1646 let target = input.word_right_index();
1647 input.place_cursor(target);
1648 }
1649 }
1650
1651 fn step_sel_prev_line(&mut self) {
1654 if self.cursor == 0 {
1655 return;
1656 }
1657 self.cursor -= 1;
1658 if let Some(input) = self.input() {
1659 input.end();
1660 }
1661 }
1662
1663 fn step_sel_next_line(&mut self) {
1665 if self.cursor + 1 >= self.lines.len() {
1666 return;
1667 }
1668 self.cursor += 1;
1669 if let Some(input) = self.input() {
1670 input.home();
1671 }
1672 }
1673
1674 pub fn select_home(&mut self) {
1675 self.close_menu();
1676 self.ensure_sel_anchor();
1677 if let Some(input) = self.input() {
1678 input.clear_selection();
1679 input.place_cursor(0);
1680 }
1681 }
1682
1683 pub fn select_end(&mut self) {
1684 self.close_menu();
1685 self.ensure_sel_anchor();
1686 if let Some(input) = self.input() {
1687 input.clear_selection();
1688 let n = input.len();
1689 input.place_cursor(n);
1690 }
1691 }
1692
1693 pub fn delete_to_start(&mut self) {
1694 if let Some(input) = self.input() {
1695 input.delete_to_start();
1696 }
1697 }
1698
1699 pub fn delete_to_end(&mut self) {
1700 if let Some(input) = self.input() {
1701 input.delete_to_end();
1702 }
1703 }
1704
1705 pub fn delete_word_left(&mut self) {
1706 if let Some(input) = self.input() {
1707 input.delete_word_left();
1708 }
1709 }
1710
1711 pub fn menu_next(&mut self) {
1714 let count = self.menu_commands().len();
1715 if let Some(menu) = &mut self.menu
1716 && count > 0
1717 {
1718 menu.index = (menu.index + 1) % count;
1719 }
1720 }
1721
1722 pub fn menu_prev(&mut self) {
1723 let count = self.menu_commands().len();
1724 if let Some(menu) = &mut self.menu
1725 && count > 0
1726 {
1727 menu.index = (menu.index + count - 1) % count;
1728 }
1729 }
1730
1731 pub fn close_menu(&mut self) {
1732 self.menu = None;
1733 }
1734
1735 pub fn menu_selected(&self) -> Option<Command> {
1737 self.menu
1738 .as_ref()
1739 .and_then(|m| m.selected_in(self.allowed_commands()))
1740 }
1741
1742 pub fn text_for_copy(&self) -> String {
1744 self.lines_for_copy_all()
1745 .into_iter()
1746 .filter_map(|line| match line {
1747 CopyLine::Text(s) | CopyLine::Link(s) => Some(s),
1748 CopyLine::Image(_) => None,
1749 })
1750 .collect::<Vec<_>>()
1751 .join("\n")
1752 }
1753
1754 pub fn lines_for_copy_all(&self) -> Vec<CopyLine> {
1756 self.lines
1757 .iter()
1758 .enumerate()
1759 .filter_map(|(i, line)| match line {
1760 Line::Text(text) => {
1761 let s = text.value();
1762 (!s.trim().is_empty()).then_some(CopyLine::Text(s))
1763 }
1764 Line::Bullet(text) => Some(CopyLine::Text(format!("- {}", text.value()))),
1765 Line::Number(text) => {
1766 let n = number_at(&self.lines, i);
1767 Some(CopyLine::Text(format!("{n}. {}", text.value())))
1768 }
1769 Line::Todo { text, done } => {
1770 let mark = if *done { "[✓]" } else { "[ ]" };
1771 Some(CopyLine::Text(format!("{mark} {}", text.value())))
1772 }
1773 Line::Link(text) => {
1774 let s = text.value();
1775 (!s.trim().is_empty()).then_some(CopyLine::Link(s))
1776 }
1777 Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
1778 path,
1779 &self.image_root,
1780 &self.attachments,
1781 ))),
1782 })
1783 .collect()
1784 }
1785
1786 pub fn image_for_copy(&self) -> Option<PathBuf> {
1788 for i in (0..=self.cursor).rev() {
1789 if let Line::Image { path } = &self.lines[i] {
1790 return Some(resolve_image_reference(
1791 path,
1792 &self.image_root,
1793 &self.attachments,
1794 ));
1795 }
1796 }
1797 for i in (self.cursor + 1)..self.lines.len() {
1798 if let Line::Image { path } = &self.lines[i] {
1799 return Some(resolve_image_reference(
1800 path,
1801 &self.image_root,
1802 &self.attachments,
1803 ));
1804 }
1805 }
1806 None
1807 }
1808
1809 pub fn apply(&mut self, command: Command) -> Option<CopyPayload> {
1812 if !self.allowed_commands().contains(&command) {
1813 self.close_menu();
1814 return None;
1815 }
1816 let menu = self.menu.take()?;
1817 if let Some(input) = self.input() {
1818 let end = input.cursor();
1820 input.set_cursor(end);
1821 for _ in menu.start.saturating_sub(1)..end {
1822 input.backspace();
1823 }
1824 }
1825 let payload = match command {
1826 Command::Copy => Some(CopyPayload::Text(self.text_for_copy())),
1827 Command::CopyImage => self.image_for_copy().map(CopyPayload::Image),
1828 Command::CopyAll => Some(CopyPayload::All(self.lines_for_copy_all())),
1829 Command::Todo | Command::Bullet | Command::Number | Command::Link => {
1830 let text = match self.line() {
1831 Line::Text(text)
1832 | Line::Todo { text, .. }
1833 | Line::Bullet(text)
1834 | Line::Number(text)
1835 | Line::Link(text) => text.clone(),
1836 Line::Image { .. } => return None,
1837 };
1838 self.lines[self.cursor] = match command {
1839 Command::Todo => Line::Todo { text, done: false },
1840 Command::Bullet => Line::Bullet(text),
1841 Command::Number => Line::Number(text),
1842 Command::Link => {
1843 let url = link_url_from_line(&text.value());
1844 Line::Link(TextInput::new(&url, self.line_max_len))
1845 }
1846 Command::Copy | Command::CopyImage | Command::CopyAll => return None,
1847 };
1848 None
1849 }
1850 };
1851 if matches!(
1852 command,
1853 Command::Copy | Command::CopyImage | Command::CopyAll
1854 ) && self.input().is_some_and(|input| input.is_empty())
1855 {
1856 self.remove_block();
1857 }
1858 payload
1859 }
1860
1861 pub fn insert_block(&mut self, block: Block) {
1864 self.close_menu();
1865 let is_image = matches!(block, Block::Image { .. });
1866 let replace = match &self.lines[self.cursor] {
1867 Line::Text(text) => text.is_empty(),
1868 _ => false,
1869 };
1870 let extra = match (replace, is_image) {
1871 (true, true) => 1, (true, false) => 0,
1873 (false, true) => 2, (false, false) => 1,
1875 };
1876 if extra > 0 && !self.can_add_lines(extra) {
1877 return;
1878 }
1879 let line = line_from_block(&block, self.line_max_len);
1880 if replace {
1881 self.lines[self.cursor] = line;
1882 } else {
1883 self.lines.insert(self.cursor + 1, line);
1884 self.cursor += 1;
1885 }
1886 if is_image {
1888 self.lines.insert(self.cursor + 1, self.empty_line());
1889 self.cursor += 1;
1890 }
1891 }
1892
1893 fn adopt_pasted_paths(&mut self) {
1895 if self.plain {
1896 return;
1897 }
1898 self.merge_broken_image_paths();
1899 for i in 0..self.lines.len() {
1900 self.try_adopt_line(i);
1901 }
1902 }
1903
1904 fn try_adopt_line(&mut self, i: usize) {
1905 let Line::Text(text) = &self.lines[i] else {
1906 return;
1907 };
1908 let value = text.value();
1909 if !crate::image::looks_like_image(&value) {
1910 return;
1911 }
1912 if let Some(path) = crate::image::path_if_image_in(&value, &self.image_root) {
1913 self.lines[i] = Line::Image {
1914 path: crate::image::short_in(&path, &self.image_root),
1915 };
1916 }
1917 }
1918
1919 fn merge_broken_image_paths(&mut self) {
1922 let mut i = 0;
1923 while i + 1 < self.lines.len() {
1924 let joined = match (&self.lines[i], &self.lines[i + 1]) {
1925 (Line::Text(a), Line::Text(b)) => {
1926 let left = a.value();
1927 let right = b.value();
1928 let joined = format!("{left}{right}");
1929 if left.contains('/')
1932 && !crate::image::looks_like_image(&left)
1933 && self.line_text_fits(&joined)
1934 && crate::image::path_if_image_in(&joined, &self.image_root).is_some()
1935 {
1936 Some(joined)
1937 } else {
1938 None
1939 }
1940 }
1941 _ => None,
1942 };
1943 if let Some(path) = joined {
1944 let len = self.line_max_len;
1945 self.lines[i] = Line::Text(TextInput::new(&path, len));
1946 self.lines.remove(i + 1);
1947 if self.cursor > i {
1948 self.cursor -= 1;
1949 }
1950 } else {
1952 i += 1;
1953 }
1954 }
1955 }
1956
1957 pub fn layout(&mut self, width: usize, height: u16) -> (Vec<Placed>, Option<(u16, u16)>) {
1963 if height == 0 || width == 0 {
1964 return (Vec::new(), None);
1965 }
1966 self.layout_width = width;
1967
1968 let numbers = number_runs(&self.lines);
1969 let mut total = 0usize;
1970 let layouts: Vec<LineLayout> = self
1971 .lines
1972 .iter()
1973 .enumerate()
1974 .map(|(i, line)| {
1975 let number = numbers[i];
1976 let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
1977 let field = width.saturating_sub(indent).max(1);
1978 let wraps = line
1979 .input_ref()
1980 .map(|text| text.wrap_breaks(field))
1981 .unwrap_or_default();
1982 let rows = if matches!(line, Line::Image { .. }) {
1983 usize::from(IMAGE_ROWS)
1984 } else {
1985 wraps.len().max(1)
1986 };
1987 let layout = LineLayout {
1988 number,
1989 wraps,
1990 start: total,
1991 rows,
1992 selection: self
1993 .char_sel_on_line(i)
1994 .or_else(|| line.input_ref().and_then(TextInput::selection_range)),
1995 selected: i == self.cursor || self.line_in_selection(i),
1996 };
1997 total = total.saturating_add(rows);
1998 layout
1999 })
2000 .collect();
2001 self.content_height = total;
2002
2003 let cursor_visual = {
2005 let layout = &layouts[self.cursor];
2006 let row_in_block = self.lines[self.cursor]
2007 .input_ref()
2008 .map(|text| text.wrap_cursor_from_breaks(&layout.wraps).0)
2009 .unwrap_or(0);
2010 layout.start.saturating_add(row_in_block)
2011 };
2012 if cursor_visual < self.scroll {
2013 self.scroll = cursor_visual;
2014 } else if cursor_visual >= self.scroll.saturating_add(usize::from(height)) {
2015 self.scroll = cursor_visual + 1 - usize::from(height);
2016 }
2017 self.scroll = self.scroll.min(total.saturating_sub(usize::from(height)));
2018
2019 let mut placed = Vec::new();
2020 let mut cursor_at = None;
2021 for (i, (line, layout)) in self.lines.iter_mut().zip(&layouts).enumerate() {
2022 let Some((y, vis_rows, skip)) =
2027 visible_band(layout.start, layout.rows, self.scroll, height)
2028 else {
2029 continue;
2030 };
2031 let (text, kind) = match line {
2032 Line::Todo { text, done } => (text, TextKind::Todo { done: *done }),
2033 Line::Bullet(text) => (text, TextKind::Bullet),
2034 Line::Number(text) => (text, TextKind::Number(layout.number.unwrap_or(1))),
2035 Line::Link(text) => (text, TextKind::Link),
2036 Line::Text(text) => (text, TextKind::Plain),
2037 Line::Image { path } => {
2038 placed.push(Placed {
2039 block: Painted::Image(resolve_image_reference(
2040 path,
2041 &self.image_root,
2042 &self.attachments,
2043 )),
2044 line: i,
2045 y,
2046 rows: vis_rows,
2047 selected: layout.selected,
2048 });
2049 continue;
2050 }
2051 };
2052 let indent = kind.indent();
2053 let view = text.wrapped_from_breaks(&layout.wraps, layout.selection);
2054 if i == self.cursor {
2055 let row = usize::from(view.cursor_row).saturating_sub(skip) as u16;
2056 cursor_at = Some((y.saturating_add(row), view.cursor_col + indent as u16));
2057 }
2058 let wrap_rows: Vec<WrappedRow> = view
2059 .lines
2060 .into_iter()
2061 .skip(skip)
2062 .take(vis_rows as usize)
2063 .map(|l| WrappedRow {
2064 text: l.text,
2065 sel: l.sel_cols,
2066 })
2067 .collect();
2068 placed.push(Placed {
2069 block: Painted::Text {
2070 rows: wrap_rows,
2071 kind,
2072 },
2073 line: i,
2074 y,
2075 rows: vis_rows,
2076 selected: layout.selected,
2077 });
2078 }
2079 (placed, cursor_at)
2080 }
2081
2082 pub fn scroll(&self) -> usize {
2084 self.scroll
2085 }
2086
2087 pub fn content_height(&self) -> usize {
2089 self.content_height
2090 }
2091
2092 pub fn click(&mut self, row: u16, col: usize) -> bool {
2096 let width = self.layout_width.max(1);
2097 let numbers = number_runs(&self.lines);
2098 let target = self.scroll.saturating_add(usize::from(row));
2099 let mut at = 0usize;
2100 let mut hit = false;
2101 for (i, line) in self.lines.iter().enumerate() {
2102 let h = line.height(width, numbers[i]);
2103 if target < at.saturating_add(h) {
2104 self.cursor = i;
2105 let row_in = target.saturating_sub(at);
2106 let indent = numbers[i]
2107 .map(number_indent)
2108 .unwrap_or_else(|| line.indent());
2109 let field = width.saturating_sub(indent).max(1);
2110 if let Some(input) = self.lines[i].input() {
2111 input.set_cursor_from_wrap(field, row_in, col.saturating_sub(indent));
2112 }
2113 hit = true;
2114 break;
2115 }
2116 at = at.saturating_add(h);
2117 }
2118 self.close_menu();
2119 self.prefer_col = u16::MAX;
2120 self.clear_body_selection();
2121 hit
2122 }
2123}
2124
2125#[cfg(test)]
2126mod tests {
2127 use super::*;
2128
2129 fn editor(blocks: &[Block]) -> BodyEditor {
2130 BodyEditor::new(blocks)
2131 }
2132
2133 fn type_in(editor: &mut BodyEditor, text: &str) {
2134 for c in text.chars() {
2135 editor.insert(c);
2136 }
2137 }
2138
2139 #[test]
2140 fn types_prose_and_splits_lines() {
2141 let mut e = editor(&[]);
2142 type_in(&mut e, "first");
2143 e.newline();
2144 type_in(&mut e, "second");
2145 assert_eq!(e.value(), vec![Block::text("first"), Block::text("second")]);
2146 }
2147
2148 #[test]
2149 fn body_value_round_trips_blank_rows_around_content() {
2150 let mut e = editor(&[]);
2151 assert!(e.newline());
2152 type_in(&mut e, "first");
2153 assert!(e.newline());
2154 assert!(e.newline());
2155 type_in(&mut e, "second");
2156 assert!(e.newline());
2157
2158 let expected = vec![
2159 Block::text(""),
2160 Block::text("first"),
2161 Block::text(""),
2162 Block::text("second"),
2163 Block::text(""),
2164 ];
2165 assert_eq!(e.value(), expected);
2166 assert_eq!(editor(&e.value()).value(), expected);
2167 assert!(editor(&[]).value().is_empty());
2168 }
2169
2170 #[test]
2171 fn refuses_more_lines_than_the_cap() {
2172 let mut e = BodyEditor::from_blocks(&[], 2, 32, false);
2173 type_in(&mut e, "a");
2174 assert!(e.newline());
2175 type_in(&mut e, "b");
2176 assert!(!e.newline(), "third line blocked");
2177 assert_eq!(e.value().len(), 2);
2178 }
2179
2180 #[test]
2181 fn plain_description_uses_shorter_line_cap() {
2182 let mut e = BodyEditor::plain("");
2183 type_in(&mut e, &"x".repeat(MAX_CATEGORY_DESC_LINE_LEN + 10));
2184 assert_eq!(
2185 e.value()[0],
2186 Block::text(&"x".repeat(MAX_CATEGORY_DESC_LINE_LEN))
2187 );
2188 }
2189
2190 #[test]
2191 fn slash_bullet_turns_the_line_into_a_point() {
2192 let mut e = editor(&[]);
2193 type_in(&mut e, "/bul");
2194 e.apply(Command::Bullet);
2195 type_in(&mut e, "a point");
2196 assert_eq!(e.value(), vec![Block::bullet("a point")]);
2197 }
2198
2199 #[test]
2200 fn slash_number_turns_the_line_into_a_list_item() {
2201 let mut e = editor(&[]);
2202 type_in(&mut e, "/num");
2203 assert_eq!(e.menu_selected(), Some(Command::Number));
2204 e.apply(Command::Number);
2205 type_in(&mut e, "first");
2206 e.newline();
2207 type_in(&mut e, "second");
2208 assert_eq!(
2209 e.value(),
2210 vec![Block::number("first"), Block::number("second")]
2211 );
2212 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2213 }
2214
2215 #[test]
2216 fn typing_1_dot_space_makes_a_numbered_item() {
2217 let mut e = editor(&[]);
2218 type_in(&mut e, "1. ");
2219 type_in(&mut e, "alpha");
2220 assert_eq!(e.value(), vec![Block::number("alpha")]);
2221 }
2222
2223 #[test]
2224 fn slash_link_turns_the_line_into_a_url() {
2225 let mut e = editor(&[Block::text("https://example.com")]);
2226 e.end();
2227 type_in(&mut e, "/link");
2228 assert_eq!(e.menu_selected(), Some(Command::Link));
2229 e.apply(Command::Link);
2230 assert_eq!(e.value(), vec![Block::link("https://example.com")]);
2231 }
2232
2233 #[test]
2234 fn slash_link_unwraps_markdown() {
2235 let mut e = editor(&[Block::text("[docs](https://example.com/docs)")]);
2236 e.end();
2237 type_in(&mut e, "/link");
2238 e.apply(Command::Link);
2239 assert_eq!(e.value(), vec![Block::link("https://example.com/docs")]);
2240 }
2241
2242 #[test]
2243 fn slash_link_on_empty_line_is_ready_for_a_url() {
2244 let mut e = editor(&[]);
2245 type_in(&mut e, "/link");
2246 e.apply(Command::Link);
2247 type_in(&mut e, "https://x.ai");
2248 assert_eq!(e.value(), vec![Block::link("https://x.ai")]);
2249 }
2250
2251 #[test]
2252 fn slash_todo_turns_the_line_into_a_task() {
2253 let mut e = editor(&[]);
2254 type_in(&mut e, "/todo");
2255 let menu = e.menu.as_ref().expect("menu is open");
2256 assert_eq!(menu.query, "todo");
2257 assert_eq!(e.menu_selected(), Some(Command::Todo));
2258 e.apply(Command::Todo);
2259 type_in(&mut e, "buy milk");
2260 assert_eq!(e.value(), vec![Block::todo("buy milk", false)]);
2261 assert!(e.menu.is_none());
2262 }
2263
2264 #[test]
2265 fn enter_in_a_todo_starts_an_unchecked_todo() {
2266 let mut e = editor(&[Block::todo("one", true)]);
2267 e.end();
2268 e.newline();
2269 type_in(&mut e, "two");
2270 assert_eq!(
2271 e.value(),
2272 vec![Block::todo("one", true), Block::todo("two", false)]
2273 );
2274 }
2275
2276 #[test]
2277 fn enter_in_a_bullet_continues_the_list() {
2278 let mut e = editor(&[Block::bullet("first")]);
2279 e.end();
2280 e.newline();
2281 type_in(&mut e, "second");
2282
2283 assert_eq!(
2284 e.value(),
2285 vec![Block::bullet("first"), Block::bullet("second")]
2286 );
2287 }
2288
2289 #[test]
2290 fn enter_splits_a_numbered_item_into_the_continued_list() {
2291 let mut e = editor(&[Block::number("firstsecond")]);
2292 e.home();
2293 for _ in 0..5 {
2294 e.right();
2295 }
2296 e.newline();
2297
2298 assert_eq!(
2299 e.value(),
2300 vec![Block::number("first"), Block::number("second")]
2301 );
2302 assert_eq!(e.text_for_copy(), "1. first\n2. second");
2303 }
2304
2305 #[test]
2306 fn enter_on_an_empty_list_item_returns_to_plain_text() {
2307 let mut e = editor(&[]);
2308 type_in(&mut e, "- first");
2309 e.newline();
2310 e.newline();
2311 type_in(&mut e, "plain");
2312
2313 assert_eq!(
2314 e.value(),
2315 vec![Block::bullet("first"), Block::text("plain")]
2316 );
2317 }
2318
2319 #[test]
2320 fn backspace_at_the_start_unmakes_a_todo() {
2321 let mut e = editor(&[Block::todo("one", false)]);
2322 e.home();
2323 e.backspace();
2324 assert_eq!(e.value(), vec![Block::text("one")]);
2325 }
2326
2327 #[test]
2328 fn moving_the_cursor_closes_the_body_command_menu() {
2329 let mut editor = BodyEditor::new(&[]);
2330 for c in "/todo".chars() {
2331 editor.insert(c);
2332 }
2333 assert!(editor.menu.is_some());
2334
2335 editor.left();
2336
2337 assert!(
2338 editor.menu.is_none(),
2339 "the cached query must never outlive its caret range"
2340 );
2341 assert_eq!(editor.value(), vec![Block::text("/todo")]);
2342 }
2343
2344 #[test]
2345 fn link_hit_testing_excludes_blank_row_padding() {
2346 let mut editor = BodyEditor::new(&[Block::link("https://example.com")]);
2347 let _ = editor.layout(40, 4);
2348
2349 assert_eq!(
2350 editor.link_url_at_position(0, 3).as_deref(),
2351 Some("https://example.com")
2352 );
2353 assert_eq!(editor.link_url_at_position(0, 39), None);
2354 }
2355
2356 #[test]
2357 fn toggling_counts_towards_progress() {
2358 let mut e = editor(&[Block::todo("a", false), Block::todo("b", false)]);
2359 assert_eq!(e.progress(), (0, 2));
2360 e.toggle();
2361 assert_eq!(e.progress(), (1, 2));
2362 }
2363
2364 #[test]
2365 fn an_image_lands_between_the_lines_with_room_to_type() {
2366 let mut e = editor(&[]);
2367 type_in(&mut e, "before");
2368 e.newline();
2369 e.insert_block(Block::image("/tmp/a.png"));
2370 type_in(&mut e, "after");
2371 assert_eq!(
2372 e.value(),
2373 vec![
2374 Block::text("before"),
2375 Block::image("/tmp/a.png"),
2376 Block::text("after")
2377 ]
2378 );
2379 assert_eq!(e.images().len(), 1);
2380 }
2381
2382 #[test]
2383 fn a_path_in_the_body_becomes_a_picture() {
2384 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2386 let e = editor(&[Block::text(path), Block::text("below")]);
2387 assert!(matches!(e.value()[0], Block::Image { .. }));
2388 assert_eq!(e.value()[1], Block::text("below"));
2389 }
2390
2391 #[test]
2392 fn a_path_to_nothing_stays_text() {
2393 let e = editor(&[Block::text("/tmp/not-here-at-all.png")]);
2394 assert!(matches!(e.value()[0], Block::Text { .. }));
2395 }
2396
2397 #[test]
2398 fn a_pasted_path_is_a_picture_at_once() {
2399 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2400 let mut e = editor(&[]);
2401 e.insert_str(path);
2402 assert!(
2403 matches!(e.value()[0], Block::Image { .. }),
2404 "no need to move the cursor off it first"
2405 );
2406 type_in(&mut e, "and on we go");
2407 assert_eq!(e.value()[1], Block::text("and on we go"));
2408 }
2409
2410 #[test]
2411 fn a_complete_path_becomes_a_picture_even_under_the_cursor() {
2412 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2413 let mut e = editor(&[]);
2414 type_in(&mut e, path);
2415 e.layout(40, 20);
2416 assert!(
2417 matches!(e.value()[0], Block::Image { .. }),
2418 "complete existing path converts without leaving the line"
2419 );
2420 }
2421
2422 #[test]
2423 fn shift_option_left_selects_across_lines() {
2424 let mut e = editor(&[Block::text("one two"), Block::text("three four")]);
2425 e.down();
2426 e.end(); e.select_word_left(); assert_eq!(e.selected_text().as_deref(), Some("four"));
2429 e.select_word_left(); e.select_word_left(); let sel = e.selected_text().expect("cross-line selection");
2432 assert!(
2433 sel.contains("two") && sel.contains("three"),
2434 "expected multi-line sel, got {sel:?}"
2435 );
2436 }
2437
2438 #[test]
2439 fn shift_selection_includes_pictures_with_text() {
2440 let path = "/tmp/shot.png";
2441 let mut e = editor(&[
2442 Block::text("above"),
2443 Block::image(path),
2444 Block::text("below here"),
2445 ]);
2446 e.down();
2448 e.down();
2449 e.home();
2450 e.select_word_left(); assert!(e.line_in_selection(1), "picture covered by selection");
2452 assert!(
2453 matches!(e.selected_payload(), Some(CopyPayload::All(_))),
2454 "mixed selection is rich copy"
2455 );
2456 e.select_word_left(); let sel = e.selected_text().expect("text+image selection");
2458 assert!(
2459 sel.contains("above") && sel.contains("[image:") && !sel.contains("below"),
2460 "got {sel:?}"
2461 );
2462 let (placed, _) = e.layout(40, 40);
2464 let img = placed
2465 .iter()
2466 .find(|p| matches!(p.block, Painted::Image(_)))
2467 .expect("image placed");
2468 assert!(img.selected, "outer frame while selection covers image");
2469 }
2470
2471 #[test]
2472 fn a_pasted_path_broken_by_newlines_still_becomes_a_picture() {
2473 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2474 let mid = path.len() / 2;
2476 let broken = format!("{}\n{}", &path[..mid], &path[mid..]);
2477 let mut e = editor(&[]);
2478 e.insert_str(&broken);
2479 assert!(
2480 matches!(e.value()[0], Block::Image { .. }),
2481 "flattened paste: {broken:?} → {:?}",
2482 e.value()
2483 );
2484 }
2485
2486 #[test]
2487 fn two_text_lines_that_form_a_path_are_merged() {
2488 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2489 let mid = path.len() / 2;
2490 let e = editor(&[Block::text(&path[..mid]), Block::text(&path[mid..])]);
2491 assert!(
2492 matches!(e.value()[0], Block::Image { .. }),
2493 "split lines rejoin: {:?}",
2494 e.value()
2495 );
2496 }
2497
2498 #[test]
2499 fn dash_and_a_space_make_a_bullet() {
2500 let mut e = editor(&[]);
2501 type_in(&mut e, "- buy milk");
2502 assert_eq!(e.value(), vec![Block::bullet("buy milk")]);
2503 }
2504
2505 #[test]
2506 fn a_dash_mid_line_is_just_a_dash() {
2507 let mut e = editor(&[]);
2508 type_in(&mut e, "a - b");
2509 assert_eq!(e.value(), vec![Block::text("a - b")]);
2510 }
2511
2512 #[test]
2513 fn backspace_at_the_start_unmakes_a_bullet() {
2514 let mut e = editor(&[Block::bullet("one")]);
2515 e.home();
2516 e.backspace();
2517 assert_eq!(e.value(), vec![Block::text("one")]);
2518 }
2519
2520 #[test]
2521 fn plain_text_round_trips_its_bullets() {
2522 let e = BodyEditor::plain("intro\n- first\n- second");
2523 assert_eq!(e.value().len(), 3);
2524 assert_eq!(e.plain_value(), "intro\n- first\n- second");
2525 }
2526
2527 #[test]
2528 fn plain_description_round_trips_blank_rows() {
2529 let description = "\nfirst\n\nsecond\n";
2530 let e = BodyEditor::plain(description);
2531
2532 assert_eq!(e.plain_value(), description);
2533 assert_eq!(
2534 BodyEditor::plain(&e.plain_value()).plain_value(),
2535 description
2536 );
2537 }
2538
2539 #[test]
2540 fn plain_mode_rejects_todo_slash_command() {
2541 let mut e = BodyEditor::plain("");
2542 type_in(&mut e, "/todo");
2543 assert!(e.menu.is_none(), "no to-do command in plain mode");
2544 assert_eq!(e.value(), vec![Block::text("/todo")]);
2545 }
2546
2547 #[test]
2548 fn plain_mode_slash_makes_a_bullet() {
2549 let mut e = BodyEditor::plain("");
2550 type_in(&mut e, "/bul");
2551 assert_eq!(e.menu_selected(), Some(Command::Bullet));
2552 e.apply(Command::Bullet);
2553 type_in(&mut e, "a point");
2554 assert_eq!(e.plain_value(), "- a point");
2555 }
2556
2557 #[test]
2558 fn copy_exports_text_and_skips_images() {
2559 let e = editor(&[
2560 Block::text("hello"),
2561 Block::todo("one", true),
2562 Block::image("/tmp/a.png"),
2563 Block::bullet("point"),
2564 ]);
2565 assert_eq!(e.text_for_copy(), "hello\n[✓] one\n- point");
2566 }
2567
2568 #[test]
2569 fn copy_all_keeps_text_and_images_in_order() {
2570 let e = editor(&[
2571 Block::text("hello"),
2572 Block::image("/tmp/a.png"),
2573 Block::bullet("point"),
2574 ]);
2575 let lines = e.lines_for_copy_all();
2576 assert_eq!(lines.len(), 3);
2577 match &lines[0] {
2578 CopyLine::Text(t) => assert_eq!(t, "hello"),
2579 _ => panic!("text first"),
2580 }
2581 match &lines[1] {
2582 CopyLine::Image(p) => assert!(p.ends_with("a.png")),
2583 _ => panic!("image second"),
2584 }
2585 match &lines[2] {
2586 CopyLine::Text(t) => assert_eq!(t, "- point"),
2587 _ => panic!("bullet third"),
2588 }
2589 }
2590
2591 #[test]
2592 fn slash_copy_strips_the_query_and_returns_text() {
2593 let mut e = editor(&[Block::text("keep me")]);
2594 e.end();
2595 e.newline();
2596 type_in(&mut e, "/copy");
2597 assert_eq!(e.menu_selected(), Some(Command::Copy));
2598 match e.apply(Command::Copy).expect("copy yields payload") {
2599 CopyPayload::Text(text) => assert_eq!(text, "keep me"),
2600 CopyPayload::Image(_) | CopyPayload::All(_) => panic!("expected text"),
2601 }
2602 assert!(e.menu.is_none());
2603 assert_eq!(e.value(), vec![Block::text("keep me")]);
2605 }
2606
2607 #[test]
2608 fn image_for_copy_picks_nearest_above_the_cursor() {
2609 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2610 let mut e = editor(&[
2611 Block::text("above"),
2612 Block::image(path),
2613 Block::text("below"),
2614 ]);
2615 e.down();
2617 e.down();
2618 let got = e.image_for_copy().expect("finds the image above");
2619 assert!(got.ends_with("screenshot.png"));
2620 }
2621
2622 #[test]
2623 fn slash_image_returns_the_picture_path() {
2624 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2625 let mut e = editor(&[Block::image(path), Block::text("")]);
2626 e.down();
2627 type_in(&mut e, "/img");
2628 assert_eq!(e.menu_selected(), Some(Command::CopyImage));
2629 match e.apply(Command::CopyImage).expect("image payload") {
2630 CopyPayload::Image(p) => assert!(p.ends_with("screenshot.png")),
2631 CopyPayload::Text(_) | CopyPayload::All(_) => panic!("expected image"),
2632 }
2633 }
2634
2635 #[test]
2636 fn a_picture_is_deleted_by_backspace() {
2637 let mut e = editor(&[Block::text("a"), Block::image("/tmp/a.png")]);
2638 e.down();
2639 e.backspace();
2640 assert_eq!(e.value(), vec![Block::text("a")]);
2641 }
2642
2643 #[test]
2644 fn the_menu_filters_by_abbreviation() {
2645 let mut e = editor(&[]);
2646 type_in(&mut e, "/che");
2647 assert_eq!(e.menu_selected(), Some(Command::Todo));
2648 }
2649
2650 #[test]
2651 fn the_menu_closes_when_nothing_matches() {
2652 let mut e = editor(&[]);
2653 type_in(&mut e, "/zz");
2654 assert!(e.menu.is_none());
2655 assert_eq!(e.value(), vec![Block::text("/zz")], "the text is kept");
2656 }
2657
2658 #[test]
2659 fn the_menu_closes_when_the_slash_is_removed() {
2660 let mut e = editor(&[]);
2661 type_in(&mut e, "/to");
2662 assert!(e.menu.is_some());
2663 e.backspace();
2664 e.backspace();
2665 assert!(e.menu.is_some());
2666 e.backspace();
2667 assert!(e.menu.is_none(), "removing the slash closes the menu");
2668 }
2669
2670 #[test]
2671 fn right_on_sole_image_inserts_text_line() {
2672 let blocks = [Block::image("/tmp/x.png")];
2673 let mut e = editor(&blocks);
2674 assert!(matches!(e.lines[0], Line::Image { .. }));
2675 assert_eq!(e.cursor, 0);
2676 e.right();
2677 assert_eq!(e.lines.len(), 2, "should insert a text line");
2678 assert_eq!(e.cursor, 1);
2679 assert!(matches!(e.lines[1], Line::Text(_)));
2680 assert!(e.input().is_some());
2681 }
2682
2683 #[test]
2684 fn left_on_first_image_inserts_text_line_above() {
2685 let blocks = [Block::image("/tmp/x.png")];
2686 let mut e = editor(&blocks);
2687 e.left();
2688 assert_eq!(e.lines.len(), 2);
2689 assert_eq!(e.cursor, 0);
2690 assert!(matches!(e.lines[0], Line::Text(_)));
2691 assert!(matches!(e.lines[1], Line::Image { .. }));
2692 }
2693
2694 #[test]
2695 fn visible_band_clips_top_like_bottom() {
2696 assert_eq!(visible_band(5, 10, 8, 10), Some((0, 7, 3)));
2699 assert_eq!(visible_band(0, 5, 8, 10), None);
2701 assert_eq!(visible_band(20, 5, 8, 10), None);
2702 assert_eq!(visible_band(8, 10, 0, 12), Some((8, 4, 0)));
2704 }
2705
2706 #[test]
2707 fn consecutive_images_shrink_when_scrolled_off_the_top() {
2708 let path = "/tmp/a.png";
2709 let mut e = editor(&[Block::image(path), Block::image(path), Block::text("tail")]);
2710 e.layout(40, 30);
2712 assert!(e.click(20, 0));
2713 assert_eq!(e.cursor_line(), 2);
2714
2715 let (placed, _) = e.layout(40, 12);
2716 assert_eq!(e.scroll(), 9);
2718 let imgs: Vec<_> = placed
2719 .iter()
2720 .filter(|p| matches!(p.block, Painted::Image(_)))
2721 .collect();
2722 assert_eq!(imgs.len(), 2);
2723 assert_eq!((imgs[0].y, imgs[0].rows), (0, 1));
2725 assert_eq!((imgs[1].y, imgs[1].rows), (1, 10));
2727 assert_eq!(imgs[0].y + imgs[0].rows, imgs[1].y);
2729 }
2730
2731 #[test]
2732 fn narrow_maximum_body_keeps_the_last_row_addressable() {
2733 let line = "x".repeat(MAX_NOTES_LINE_LEN);
2734 let blocks = vec![Block::text(&line); MAX_BODY_LINES];
2735 let mut e = editor(&blocks);
2736 e.cursor = e.lines.len() - 1;
2737 e.input().unwrap().end();
2738
2739 let (_, cursor) = e.layout(1, 10);
2740
2741 let expected_height = MAX_BODY_LINES * MAX_NOTES_LINE_LEN;
2742 assert_eq!(e.content_height() as usize, expected_height);
2743 assert_eq!(e.scroll() as usize, expected_height - 10);
2744 assert_eq!(cursor, Some((9, 1)));
2745 assert!(e.click(0, 0));
2746 assert_eq!(e.cursor_line(), MAX_BODY_LINES - 1);
2747 }
2748
2749 #[test]
2750 fn line_join_at_the_length_limit_never_discards_the_next_line() {
2751 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2752
2753 let mut backward = editor(&[Block::text(&full), Block::text("tail")]);
2754 backward.cursor = 1;
2755 backward.input().unwrap().home();
2756 backward.backspace();
2757 assert_eq!(
2758 backward.value(),
2759 vec![Block::text(&full), Block::text("tail")]
2760 );
2761
2762 let mut forward = editor(&[Block::text(&full), Block::text("tail")]);
2763 forward.input().unwrap().end();
2764 forward.delete();
2765 assert_eq!(
2766 forward.value(),
2767 vec![Block::text(&full), Block::text("tail")]
2768 );
2769 }
2770
2771 #[test]
2772 fn oversized_cross_line_selection_replacement_is_rejected_without_data_loss() {
2773 let full = "a".repeat(MAX_NOTES_LINE_LEN);
2774 let mut editor = editor(&[Block::text(&full), Block::text("tail")]);
2775 editor.sel_anchor = Some((0, MAX_NOTES_LINE_LEN));
2776 editor.cursor = 1;
2777 editor.input().unwrap().home();
2778
2779 editor.insert('x');
2780
2781 assert_eq!(
2782 editor.value(),
2783 vec![Block::text(&full), Block::text("tail")]
2784 );
2785 }
2786
2787 #[test]
2788 fn oversized_image_path_detection_never_discards_a_source_line() {
2789 let root = std::env::temp_dir().join(format!(
2790 "mach-long-image-path-test-{}",
2791 uuid::Uuid::new_v4()
2792 ));
2793 let first_dir = "a".repeat(240);
2794 let second_dir = "b".repeat(240);
2795 let file = format!("{}.png", "c".repeat(40));
2796 let relative = format!("{first_dir}/{second_dir}/{file}");
2797 assert!(relative.graphemes(true).count() > MAX_NOTES_LINE_LEN);
2798 std::fs::create_dir_all(root.join(&first_dir).join(&second_dir)).unwrap();
2799 std::fs::write(root.join(&relative), []).unwrap();
2800
2801 let split = relative.len() / 2;
2802 let (left, right) = relative.split_at(split);
2803 let expected = vec![Block::text(left), Block::text(right)];
2804 let mut loaded = editor(&expected);
2805 loaded.set_image_root(root.clone());
2806 assert_eq!(loaded.value(), expected);
2807
2808 let mut pasted = editor(&[]);
2809 pasted.set_image_root(root.clone());
2810 pasted.insert_str(&format!("{left}\n{right}"));
2811 assert_eq!(pasted.value(), expected);
2812
2813 std::fs::remove_dir_all(root).unwrap();
2814 }
2815}