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