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