Skip to main content

mach/
body.rs

1//! The body editor: one free-form stack of blocks — prose, bullets,
2//! numbered items, links, to-dos and pictures. A bullet is `- ` at the
3//! head of a line, a number is `1. `, a picture is a pasted or typed
4//! path, and the `/` menu turns a line into a to-do, bullet, number or
5//! link, or copies the text out. Backspace at the head of a list item
6//! turns it back into prose.
7
8use 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
17/// How many rows a picture takes in the body, its frame included.
18pub const IMAGE_ROWS: u16 = 10;
19/// `[ ] ` / `[✓] ` before a subtask (same width open or done).
20pub const TODO_INDENT: usize = 4;
21/// `• ` before a bullet — shorter than a subtask checkbox.
22pub const BULLET_INDENT: usize = 2;
23/// `↗ ` before a link URL.
24pub 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 non-image body text to the clipboard.
33    Copy,
34    /// Copy a picture from the body to the clipboard.
35    CopyImage,
36    /// Copy the whole body as HTML (text + embedded pictures).
37    CopyAll,
38}
39
40/// One line of a "copy all" export, in body order.
41#[derive(Debug, Clone)]
42pub enum CopyLine {
43    Text(String),
44    Link(String),
45    Image(PathBuf),
46}
47
48/// What `/copy` / `/image` / `/copyall` hands back for the clipboard.
49#[derive(Debug, Clone)]
50pub enum CopyPayload {
51    Text(String),
52    Image(PathBuf),
53    /// Mixed content for HTML + plain-text clipboard.
54    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    /// Categories: bullets and text copy (no to-dos or pictures).
68    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/// The `/` menu, open while a command is being typed.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct SlashMenu {
115    /// Char index of the `/` that opened it, within its block.
116    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    /// A numbered line's prefix depends on its position in the run, so
172    /// callers pass that width in themselves; here it counts as zero.
173    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
197/// If `text` is an image path once newlines are removed, return the flat
198/// path; otherwise `None` (keep multi-line paste as separate lines).
199fn 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
208/// Pull a URL out of a markdown `[label](url)` line, or keep the text.
209fn 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
224/// Display width of `n. ` (e.g. `1. ` → 3, `10. ` → 4).
225fn 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
231/// 1-based index within a run of consecutive numbered lines.
232fn 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
244/// Per-line 1-based index in a consecutive numbered run (`None` if not a number).
245fn 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
290/// Visible slice of a block inside a scrolled viewport: `(y, rows, skip_top)`.
291/// `skip_top` is how many of the block's own rows sit above the viewport
292/// (for trimming wrap lines / shrinking pictures from the top).
293fn 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/// One soft-wrapped visual row of a text-like block.
317#[derive(Debug, Clone)]
318pub struct WrappedRow {
319    pub text: String,
320    pub sel: Option<(u16, u16)>,
321}
322
323/// What one visible block looks like, for the drawing code.
324pub enum Painted {
325    /// Soft-wrapped prose / list / link content. `prefix` only paints on
326    /// the first visual row; continuation rows are indented to match.
327    Text {
328        rows: Vec<WrappedRow>,
329        kind: TextKind,
330    },
331    Image(PathBuf),
332}
333
334/// How the first row of a wrapped text block is marked.
335#[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    /// Index into the body line list (for image hit-testing).
359    pub line: usize,
360    /// Row of the body box this block starts on, and how tall it is.
361    pub y: u16,
362    pub rows: u16,
363    /// Whether the cursor is on this block. A picture cannot hold a text
364    /// cursor, so this is how it shows that it is the one selected.
365    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    /// Prose only: no `/` menu, no pictures. Categories use this.
384    plain: bool,
385    /// Cap on how many blocks may be added (existing oversize files stay).
386    max_lines: usize,
387    /// Cap passed to each line's [`crate::text_input::TextInput`].
388    line_max_len: usize,
389    /// Last body width used for layout (for wrap / click / vertical move).
390    layout_width: usize,
391    /// Total content rows from the last [`Self::layout`] (for the scrollbar).
392    content_height: usize,
393    /// Preferred display column when moving up/down across wrap rows.
394    prefer_col: u16,
395    /// Body-level selection anchor `(line, grapheme)`. Cursor is the other end.
396    /// Used for Shift(+Option) motions that can span multiple lines.
397    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    /// A prose editor with bullets: for category descriptions. No to-dos
408    /// or pictures — `/` only offers a bullet.
409    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        // Turn bare image paths in the body into picture blocks.
449        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    /// Commands the `/` menu may offer in this editor.
484    pub fn allowed_commands(&self) -> &'static [Command] {
485        if self.plain {
486            &Command::PLAIN
487        } else {
488            &Command::ALL
489        }
490    }
491
492    /// Filtered slash-menu rows for the open menu, if any.
493    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    /// The prose back out, one block per line.
501    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    /// The blocks worth saving. Empty prose lines are layout and round-trip
532    /// with surrounding content; an entirely empty editor stays an empty body.
533    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    /// Index of the block under the cursor.
580    pub fn cursor_line(&self) -> usize {
581        self.cursor
582    }
583
584    /// Whether any body or in-line selection is active (no string build).
585    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    /// Selected text: multi-line body selection if active, else the
595    /// current line's in-line selection. Pictures become `[image: path]`.
596    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    /// Clipboard payload for the current selection (text, picture, or both).
618    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    /// Whether line `i` sits inside the body selection (for frames / paint).
688    /// A picture is selected as a whole unit whenever the range covers it
689    /// (including when the caret has only just landed on it).
690    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            // Same-line image never forms a range (a == b). Multi-line: whole unit.
699            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        // Start line: from ac through end (whole picture if it is one).
717        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        // End line: start through bc. Picture at the caret is included whole.
726        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    /// One export line for copy. `from`/`to` are char bounds on text lines;
741    /// `None` means start/end of the line. Empty slices are omitted.
742    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    /// Char range selected on line `i`, if any (for painting text).
772    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        // Middle line: whole content (empty line still "selected").
792        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    /// Delete body-level or in-line selection. Returns true if anything
811    /// was removed.
812    pub fn delete_body_selection(&mut self) -> bool {
813        if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
814            if al == bl {
815                // One line: cut the selected range out and rebuild the
816                // line, keeping whatever kind it was.
817                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                // Keep prefix of start + suffix of end. Pictures contribute no
834                // text — deleting a range that covers one drops the picture.
835                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                // Remove lines al+1 ..= bl
864                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    /// URL of the link block under the cursor, if any.
897    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    /// URL under a rendered body cell. Padding to the right of a short link
909    /// is deliberately not interactive.
910    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    /// Every image in the body, so the dialog can preview one.
957    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    /// Move the cursor off a picture onto a neighbouring text line without
972    /// inserting blanks. Used when a click lands on the letterbox gutter.
973    /// If there is no editable neighbour, the cursor stays on the picture
974    /// (←/→ still create a caret).
975    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    // -------------------------------------------------------------- typing
1003
1004    pub fn insert(&mut self, c: char) {
1005        // Typing over a selection replaces it.
1006        if self.has_selection() && !self.delete_body_selection() {
1007            return;
1008        }
1009        if self.input().is_none() {
1010            // Typing next to a picture starts a line under it.
1011            self.insert_block(Block::text(""));
1012        }
1013        if let Some(input) = self.input() {
1014            input.insert(c);
1015        }
1016        // Leading "- "/"* " → bullet; "N. " → numbered item.
1017        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            // No matching command → treat `/` as plain text.
1048            if self.menu_commands().is_empty() {
1049                self.close_menu();
1050            }
1051        }
1052        // Convert a complete image path on this line (extension gate inside).
1053        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        // Paste may wrap a long path across lines; flatten if it is one image path.
1062        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        // A pasted path shows its picture straight away — unlike one
1078        // being typed out, it is complete the moment it arrives.
1079        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    /// Enter always starts a plain line: a to-do is asked for with
1092    /// `/todo`, not inherited from the line above.
1093    /// Returns false when the line cap is hit.
1094    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        // Leaving a line may complete a typed image path.
1110        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            // A to-do, bullet, number or link turns back into plain
1133            // text before it disappears.
1134            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        // At the start of a text line: fold it into the one above.
1157        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                    // Above is a picture: an empty spacer can disappear.
1167                    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    /// Drops the block under the cursor, keeping at least one line.
1217    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    // ------------------------------------------------------------ movement
1233
1234    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        // Already on a picture: leave it upward (may insert a blank above).
1250        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            // Landing on a picture selects it (do not skip through).
1272            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            // Landing on a picture selects it.
1313            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        // On a picture there is no text caret — ← steps into the line above
1333        // (creating an empty one when the picture is first).
1334        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                    // Landing on a picture selects it.
1344                    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        // On a picture there is no text caret — → steps into a line below
1360        // (creating an empty one when needed) so the user can type again.
1361        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                    // Landing on a picture selects it.
1371                    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    /// Move off a selected picture onto the line right under it.
1383    /// Always inserts a blank text line when the next block is missing or
1384    /// not editable — works when the picture is the first / only line.
1385    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        // Insert immediately under this picture (even if another picture
1398        // follows — user asked for a caret, not to hop to the next image).
1399        self.lines.insert(next, self.empty_line());
1400        self.cursor = next;
1401    }
1402
1403    /// Move off a picture onto the line right above it. Inserts a blank
1404    /// line when the picture is first so ← / ↑ always yield a caret.
1405    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        // cursor stays on the new blank line at the same index
1418        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        // Cross into the previous editable line.
1463        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        // Pictures are one unit: ← leaves them for the previous line.
1522        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    /// Shift+Option+← — extend selection by a word, crossing lines and pictures.
1560    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        // A picture counts as one word.
1568        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    /// Shift+Option+→ — extend selection by a word, crossing lines and pictures.
1599    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    /// Move the selection caret onto the previous line (pictures included).
1637    /// Does not insert blank lines — unlike plain ← on a picture.
1638    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    /// Move the selection caret onto the next line (pictures included).
1649    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    // ---------------------------------------------------------- slash menu
1697
1698    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    /// The command under the cursor in the open menu, if any.
1721    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    /// Plain-text export of every non-image block, for `/copy`.
1728    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    /// Full body in order for `/copyall` — text lines and pictures.
1740    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    /// Picture nearest the cursor: search upward first, then downward.
1772    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    /// Removes the typed `/command` and applies it. Returns clipboard
1795    /// payload for copy commands.
1796    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            // Cut away the `/query` that was typed.
1804            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    /// Puts a block in at the cursor, replacing the line when it is an
1847    /// empty one and pushing it down otherwise.
1848    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, // image replaces empty, then a blank line under it
1857            (true, false) => 0,
1858            (false, true) => 2, // image + blank line
1859            (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        // A picture is not editable, so leave a line under it to type on.
1872        if is_image {
1873            self.lines.insert(self.cursor + 1, self.empty_line());
1874            self.cursor += 1;
1875        }
1876    }
1877
1878    /// Turn bare image-file paths into image blocks (paste / leave-line).
1879    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    /// If line *i* + line *i+1* form an existing image path when joined,
1905    /// fold them into one text line (for the next convert pass).
1906    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                    // Only glue when the first piece looks like a path
1915                    // fragment (no image ext yet) and the second finishes it.
1916                    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                // Don't advance — the merged line may convert next.
1936            } else {
1937                i += 1;
1938            }
1939        }
1940    }
1941
1942    // ------------------------------------------------------------ painting
1943
1944    /// Lays the blocks out in a `width` x `height` box and scrolls so the
1945    /// cursor stays in view. Returns the visible blocks and where the
1946    /// text cursor sits, if it is on an editable line.
1947    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        // Keep the caret's visual row on screen (not just the block).
1989        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            // Intersection with the viewport — clip top and bottom the same
2008            // way so a tall block (picture) shrinks until it disappears when
2009            // scrolled off either edge, instead of painting full-height at y=0
2010            // and overlapping the next block.
2011            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    /// Body scroll offset after the last [`Self::layout`] call.
2068    pub fn scroll(&self) -> usize {
2069        self.scroll
2070    }
2071
2072    /// Total laid-out rows after the last [`Self::layout`] call (scrollbar).
2073    pub fn content_height(&self) -> usize {
2074        self.content_height
2075    }
2076
2077    /// Moves the cursor to a clicked cell of the body box.
2078    /// Returns `true` when the click landed on a real block (not empty
2079    /// padding below the content).
2080    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        // Enter starts a plain line; convert the next one too.
2193        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        // A real file, so the check that it is readable passes.
2330        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(); // on "four"
2372        e.select_word_left(); // select "four"
2373        assert_eq!(e.selected_text().as_deref(), Some("four"));
2374        e.select_word_left(); // "three "
2375        e.select_word_left(); // cross into previous line
2376        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        // Caret at start of "below here".
2392        e.down();
2393        e.down();
2394        e.home();
2395        e.select_word_left(); // onto the picture
2396        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(); // into "above"
2402        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        // Layout marks the picture selected for its frame.
2408        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        // Simulate a clipboard soft-break in the middle of the path.
2420        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        // The `/copy` line is gone (it was empty after stripping).
2549        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        // Cursor on the last line (under the image).
2561        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        // Block at rows 5..15 inside viewport scroll=8 height=10 → show 8..15
2642        // at y=0 with 7 rows, 3 skipped off the top.
2643        assert_eq!(visible_band(5, 10, 8, 10), Some((0, 7, 3)));
2644        // Fully above / below.
2645        assert_eq!(visible_band(0, 5, 8, 10), None);
2646        assert_eq!(visible_band(20, 5, 8, 10), None);
2647        // Bottom clip only (scroll=0): y=start, shrink from bottom.
2648        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        // Full view first so click can land on the trailing text (row 20).
2656        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        // cursor at visual 20 → scroll = 20 + 1 - 12 = 9
2662        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        // Image 0 occupied 0..10; with scroll 9 only 1 row remains at y=0.
2669        assert_eq!((imgs[0].y, imgs[0].rows), (0, 1));
2670        // Image 1 at 10..20 → y=1, full 10 rows (fits in remaining 11).
2671        assert_eq!((imgs[1].y, imgs[1].rows), (1, 10));
2672        // No overlap: first ends at y+rows = 1, second starts at 1.
2673        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}