Skip to main content

mach/
description.rs

1//! The description 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, pastes clipboard content, or copies content out. Backspace at the
6//! head of a list item turns it back into prose.
7
8use std::path::{Path, PathBuf};
9
10use unicode_segmentation::UnicodeSegmentation;
11
12use crate::model::{
13    Block, MAX_CATEGORY_DESC_LINE_LEN, MAX_CATEGORY_DESC_LINES, MAX_DESCRIPTION_LINES,
14    MAX_NOTES_LINE_LEN,
15};
16use crate::text_input::TextInput;
17
18/// How many rows a picture takes in the description, its frame included.
19pub const IMAGE_ROWS: u16 = 10;
20/// `[ ] ` / `[✓] ` before a subtask (same width open or done).
21pub const TODO_INDENT: usize = 4;
22/// `• ` before a bullet — shorter than a subtask checkbox.
23pub const BULLET_INDENT: usize = 2;
24/// `↗ ` before a link URL.
25pub const LINK_INDENT: usize = 2;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Command {
29    Todo,
30    Bullet,
31    Number,
32    Link,
33    /// Insert system clipboard content at the caret.
34    Paste,
35    /// Copy non-image description text to the clipboard.
36    Copy,
37    /// Copy a picture from the description to the clipboard.
38    CopyImage,
39    /// Copy the whole description as HTML (text + embedded pictures).
40    CopyAll,
41}
42
43/// One line of a "copy all" export, in description order.
44#[derive(Debug, Clone)]
45pub enum CopyLine {
46    Text(String),
47    Link(String),
48    Image(PathBuf),
49}
50
51/// What `/copy` / `/image` / `/copyall` hands back for the clipboard.
52#[derive(Debug, Clone)]
53pub enum CopyPayload {
54    Text(String),
55    Image(PathBuf),
56    /// Mixed content for HTML + plain-text clipboard.
57    All(Vec<CopyLine>),
58}
59
60/// Clipboard work requested by an editor command. The input layer owns the
61/// platform clipboard; the editor only owns command parsing and content.
62#[derive(Debug, Clone)]
63pub enum CommandRequest {
64    Copy(CopyPayload),
65    Paste,
66}
67
68impl Command {
69    pub const ALL: [Self; 8] = [
70        Self::Todo,
71        Self::Bullet,
72        Self::Number,
73        Self::Link,
74        Self::Paste,
75        Self::Copy,
76        Self::CopyImage,
77        Self::CopyAll,
78    ];
79    /// Category descriptions: prose, lists, links, text paste, and text copy.
80    pub const PLAIN: [Self; 5] = [
81        Self::Bullet,
82        Self::Number,
83        Self::Link,
84        Self::Paste,
85        Self::Copy,
86    ];
87
88    pub fn label(self) -> &'static str {
89        match self {
90            Self::Todo => "To-do list",
91            Self::Bullet => "Bullet point",
92            Self::Number => "Numbered list",
93            Self::Link => "Link",
94            Self::Paste => "Paste",
95            Self::Copy => "Copy text",
96            Self::CopyImage => "Copy image",
97            Self::CopyAll => "Copy all",
98        }
99    }
100
101    fn hint(self, plain: bool) -> &'static str {
102        match (self, plain) {
103            (Self::Paste, true) => "text from clipboard",
104            (Self::Copy, true) => "prose, lists, and links",
105            (command, _) => match command {
106                Self::Todo => "tick it off with Ctrl+D",
107                Self::Bullet => "or type - and a space",
108                Self::Number => "or type 1. and a space",
109                Self::Link => "click or ⌘↵ to open",
110                Self::Paste => "text and images from clipboard",
111                Self::Copy => "prose, bullets, to-dos",
112                Self::CopyImage => "nearest picture in the description",
113                Self::CopyAll => "text and pictures together",
114            },
115        }
116    }
117
118    fn keywords(self) -> &'static [&'static str] {
119        match self {
120            Self::Todo => &["todo", "to-do", "task", "check", "box", "list"],
121            Self::Bullet => &["bullet", "point", "dash", "item", "list"],
122            Self::Number => &["number", "numbered", "ordered", "ol", "1"],
123            Self::Link => &["link", "url", "href", "http", "https", "www"],
124            Self::Paste => &["paste", "insert"],
125            Self::Copy => &["copy", "clipboard", "text"],
126            Self::CopyImage => &["image", "picture", "pic", "img", "photo"],
127            Self::CopyAll => &["copyall", "all", "everything", "rich"],
128        }
129    }
130
131    fn matches(self, query: &str) -> bool {
132        let query = query.to_lowercase();
133        query.is_empty() || self.keywords().iter().any(|k| k.starts_with(&query))
134    }
135}
136
137/// The `/` menu, open while a command is being typed.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct SlashMenu {
140    /// Char index of the `/` that opened it, within its block.
141    start: usize,
142    pub query: String,
143    pub index: usize,
144}
145
146impl SlashMenu {
147    pub fn matches_in(&self, allowed: &[Command]) -> Vec<Command> {
148        allowed
149            .iter()
150            .copied()
151            .filter(|c| c.matches(&self.query))
152            .collect()
153    }
154
155    pub fn selected_in(&self, allowed: &[Command]) -> Option<Command> {
156        let matches = self.matches_in(allowed);
157        matches
158            .get(self.index.min(matches.len().saturating_sub(1)))
159            .copied()
160    }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164enum Line {
165    Text(TextInput),
166    Todo { text: TextInput, done: bool },
167    Bullet(TextInput),
168    Number(TextInput),
169    Link(TextInput),
170    Image { path: String },
171}
172
173impl Line {
174    fn input(&mut self) -> Option<&mut TextInput> {
175        match self {
176            Self::Text(text)
177            | Self::Todo { text, .. }
178            | Self::Bullet(text)
179            | Self::Number(text)
180            | Self::Link(text) => Some(text),
181            Self::Image { .. } => None,
182        }
183    }
184
185    fn input_ref(&self) -> Option<&TextInput> {
186        match self {
187            Self::Text(text)
188            | Self::Todo { text, .. }
189            | Self::Bullet(text)
190            | Self::Number(text)
191            | Self::Link(text) => Some(text),
192            Self::Image { .. } => None,
193        }
194    }
195
196    /// A numbered line's prefix depends on its position in the run, so
197    /// callers pass that width in themselves; here it counts as zero.
198    fn indent(&self) -> usize {
199        match self {
200            Self::Todo { .. } => TODO_INDENT,
201            Self::Bullet(_) => BULLET_INDENT,
202            Self::Link(_) => LINK_INDENT,
203            Self::Text(_) | Self::Number(_) | Self::Image { .. } => 0,
204        }
205    }
206
207    fn height(&self, width: usize, number: Option<usize>) -> usize {
208        match self {
209            Self::Image { .. } => usize::from(IMAGE_ROWS),
210            line => {
211                let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
212                let field = width.saturating_sub(indent).max(1);
213                line.input_ref()
214                    .map(|t| t.wrap_height(field))
215                    .unwrap_or(1)
216                    .max(1)
217            }
218        }
219    }
220}
221
222/// If `text` is an image path once newlines are removed, return the flat
223/// path; otherwise `None` (keep multi-line paste as separate lines).
224fn flatten_if_image_path(text: &str, images_root: &std::path::Path) -> Option<String> {
225    if !text.contains('\n') && !text.contains('\r') {
226        return None;
227    }
228    let flat: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
229    let flat = flat.trim();
230    crate::image::path_if_image_in(flat, images_root).map(|_| flat.to_string())
231}
232
233/// Pull a URL out of a markdown `[label](url)` line, or keep the text.
234fn link_url_from_line(s: &str) -> String {
235    let s = s.trim();
236    if let Some(open) = s.find("](")
237        && s.starts_with('[')
238        && s.ends_with(')')
239        && open + 2 < s.len()
240    {
241        let url = &s[open + 2..s.len() - 1];
242        if !url.is_empty() {
243            return url.to_string();
244        }
245    }
246    s.to_string()
247}
248
249fn plain_link_value(url: &str) -> String {
250    if crate::open::has_supported_scheme(url) {
251        url.to_string()
252    } else {
253        format!("[link]({url})")
254    }
255}
256
257/// Display width of `n. ` (e.g. `1. ` → 3, `10. ` → 4).
258fn number_indent(n: usize) -> usize {
259    let n = n.max(1);
260    n.ilog10() as usize + 3
261}
262
263/// 1-based index within a run of consecutive numbered lines.
264fn number_at(lines: &[Line], at: usize) -> usize {
265    let mut n = 0;
266    for i in (0..=at).rev() {
267        if matches!(lines[i], Line::Number(_)) {
268            n += 1;
269        } else {
270            break;
271        }
272    }
273    n
274}
275
276/// Per-line 1-based index in a consecutive numbered run (`None` if not a number).
277fn number_runs(lines: &[Line]) -> Vec<Option<usize>> {
278    let mut out = Vec::with_capacity(lines.len());
279    let mut run = 0usize;
280    for line in lines {
281        if matches!(line, Line::Number(_)) {
282            run += 1;
283            out.push(Some(run));
284        } else {
285            run = 0;
286            out.push(None);
287        }
288    }
289    out
290}
291
292fn line_from_block(block: &Block, line_max_len: usize) -> Line {
293    match block {
294        Block::Text { text } => Line::Text(TextInput::new(text, line_max_len)),
295        Block::Todo { text, done } => Line::Todo {
296            text: TextInput::new(text, line_max_len),
297            done: *done,
298        },
299        Block::Bullet { text } => Line::Bullet(TextInput::new(text, line_max_len)),
300        Block::Number { text } => Line::Number(TextInput::new(text, line_max_len)),
301        Block::Link { url } => Line::Link(TextInput::new(url, line_max_len)),
302        Block::Image { attachment_id } => Line::Image {
303            path: attachment_id.clone(),
304        },
305    }
306}
307
308fn plain_line_to_block(line: &str) -> Block {
309    if let Some(rest) = line.strip_prefix("- ") {
310        return Block::bullet(rest);
311    }
312    let digits = line.bytes().take_while(u8::is_ascii_digit).count();
313    if digits > 0
314        && let Some(rest) = line.get(digits..).and_then(|rest| rest.strip_prefix(". "))
315    {
316        return Block::number(rest);
317    }
318    let link = link_url_from_line(line);
319    if link != line || crate::open::has_supported_scheme(&link) {
320        return Block::link(&link);
321    }
322    Block::text(line)
323}
324
325fn block_from_input(input: &TextInput, make: impl FnOnce(&str) -> Block) -> Option<Block> {
326    let value = input.value();
327    let value = value.trim_end();
328    (!value.trim().is_empty()).then(|| make(value))
329}
330
331fn resolve_image_reference(
332    reference: &str,
333    image_root: &Path,
334    attachments: &crate::image::AttachmentCatalog,
335) -> PathBuf {
336    attachments.resolve(reference, image_root)
337}
338
339/// Visible slice of a block inside a scrolled viewport: `(y, rows, skip_top)`.
340/// `skip_top` is how many of the block's own rows sit above the viewport
341/// (for trimming wrap lines / shrinking pictures from the top).
342fn visible_band(
343    start: usize,
344    rows: usize,
345    scroll: usize,
346    height: u16,
347) -> Option<(u16, u16, usize)> {
348    if rows == 0 || height == 0 {
349        return None;
350    }
351    let height = usize::from(height);
352    let end = start.saturating_add(rows);
353    let viewport_end = scroll.saturating_add(height);
354    if end <= scroll || start >= viewport_end {
355        return None;
356    }
357    let vis_start = start.max(scroll);
358    let vis_end = end.min(viewport_end);
359    let y = (vis_start - scroll) as u16;
360    let vis_rows = (vis_end - vis_start) as u16;
361    let skip = vis_start - start;
362    (vis_rows > 0).then_some((y, vis_rows, skip))
363}
364
365/// One soft-wrapped visual row of a text-like block.
366#[derive(Debug, Clone)]
367pub struct WrappedRow {
368    pub text: String,
369    pub sel: Option<(u16, u16)>,
370}
371
372/// What one visible block looks like, for the drawing code.
373pub enum Painted {
374    /// Soft-wrapped prose / list / link content. `prefix` only paints on
375    /// the first visual row; continuation rows are indented to match.
376    Text {
377        rows: Vec<WrappedRow>,
378        kind: TextKind,
379    },
380    Image(PathBuf),
381}
382
383/// How the first row of a wrapped text block is marked.
384#[derive(Debug, Clone, Copy)]
385pub enum TextKind {
386    Plain,
387    Todo { done: bool },
388    Bullet,
389    Number(usize),
390    Link,
391}
392
393impl TextKind {
394    pub fn indent(self) -> usize {
395        match self {
396            Self::Plain => 0,
397            Self::Todo { .. } => TODO_INDENT,
398            Self::Bullet => BULLET_INDENT,
399            Self::Number(n) => number_indent(n),
400            Self::Link => LINK_INDENT,
401        }
402    }
403}
404
405pub struct Placed {
406    pub block: Painted,
407    /// Index into the description line list (for image hit-testing).
408    pub line: usize,
409    /// Row of the description box this block starts on, and how tall it is.
410    pub y: u16,
411    pub rows: u16,
412    /// Whether the cursor is on this block. A picture cannot hold a text
413    /// cursor, so this is how it shows that it is the one selected.
414    pub selected: bool,
415}
416
417struct LineLayout {
418    number: Option<usize>,
419    wraps: Vec<(usize, usize)>,
420    start: usize,
421    rows: usize,
422    selection: Option<(usize, usize)>,
423    selected: bool,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct DescriptionEditor {
428    lines: Vec<Line>,
429    cursor: usize,
430    scroll: usize,
431    pub menu: Option<SlashMenu>,
432    /// Restricted command set. Category descriptions use this.
433    plain: bool,
434    /// Cap on how many blocks may be added (existing oversize files stay).
435    max_lines: usize,
436    /// Cap passed to each line's [`crate::text_input::TextInput`].
437    line_max_len: usize,
438    /// Last description width used for layout (for wrap / click / vertical move).
439    layout_width: usize,
440    /// Total content rows from the last [`Self::layout`] (for the scrollbar).
441    content_height: usize,
442    /// Preferred display column when moving up/down across wrap rows.
443    prefer_col: u16,
444    /// Description-level selection anchor `(line, grapheme)`. Cursor is the other end.
445    /// Used for Shift(+Option) motions that can span multiple lines.
446    sel_anchor: Option<(usize, usize)>,
447    image_root: PathBuf,
448    attachments: crate::image::AttachmentCatalog,
449}
450
451impl DescriptionEditor {
452    pub fn new(blocks: &[Block]) -> Self {
453        Self::new_with_images(blocks, crate::image::default_images_root(), &[])
454    }
455
456    pub fn new_with_images(
457        blocks: &[Block],
458        image_root: PathBuf,
459        attachments: &[crate::store::Attachment],
460    ) -> Self {
461        let mut catalog = crate::image::AttachmentCatalog::default();
462        catalog.set(attachments);
463        Self::from_blocks(
464            blocks,
465            MAX_DESCRIPTION_LINES,
466            MAX_NOTES_LINE_LEN,
467            false,
468            image_root,
469            catalog,
470        )
471    }
472
473    /// A text-only editor for category descriptions. It supports prose,
474    /// bullets, numbered lists, and links, but not task-only to-dos or images.
475    pub fn plain(text: &str) -> Self {
476        let blocks: Vec<Block> = text.split('\n').map(plain_line_to_block).collect();
477        Self::from_blocks(
478            &blocks,
479            MAX_CATEGORY_DESC_LINES,
480            MAX_CATEGORY_DESC_LINE_LEN,
481            true,
482            crate::image::default_images_root(),
483            crate::image::AttachmentCatalog::default(),
484        )
485    }
486
487    fn from_blocks(
488        blocks: &[Block],
489        max_lines: usize,
490        line_max_len: usize,
491        plain: bool,
492        image_root: PathBuf,
493        attachments: crate::image::AttachmentCatalog,
494    ) -> Self {
495        let mut lines: Vec<Line> = blocks
496            .iter()
497            .map(|b| line_from_block(b, line_max_len))
498            .collect();
499        if lines.is_empty() {
500            lines.push(Line::Text(TextInput::new("", line_max_len)));
501        }
502        let mut editor = Self {
503            lines,
504            cursor: 0,
505            scroll: 0,
506            menu: None,
507            plain,
508            max_lines,
509            line_max_len,
510            layout_width: 40,
511            content_height: 0,
512            prefer_col: u16::MAX,
513            sel_anchor: None,
514            image_root,
515            attachments,
516        };
517        if !plain {
518            // Turn bare image paths in a task description into picture blocks.
519            editor.adopt_pasted_paths();
520        }
521        editor
522    }
523
524    fn can_add_lines(&self, n: usize) -> bool {
525        self.lines.len().saturating_add(n) <= self.max_lines
526    }
527
528    fn line_text_fits(&self, text: &str) -> bool {
529        text.len() <= crate::model::text_byte_limit(self.line_max_len)
530            && text.graphemes(true).count() <= self.line_max_len
531    }
532
533    pub fn set_image_root(&mut self, image_root: PathBuf) {
534        self.image_root = image_root;
535        if !self.plain {
536            self.adopt_pasted_paths();
537        }
538    }
539
540    pub fn set_attachments(&mut self, attachments: &[crate::store::Attachment]) {
541        self.attachments.set(attachments);
542        if !self.plain {
543            self.adopt_pasted_paths();
544        }
545    }
546
547    pub fn image_root(&self) -> &std::path::Path {
548        &self.image_root
549    }
550
551    fn empty_line(&self) -> Line {
552        Line::Text(TextInput::new("", self.line_max_len))
553    }
554
555    /// Commands the `/` menu may offer in this editor.
556    pub fn allowed_commands(&self) -> &'static [Command] {
557        if self.plain {
558            &Command::PLAIN
559        } else {
560            &Command::ALL
561        }
562    }
563
564    /// Filtered slash-menu rows for the open menu, if any.
565    pub fn menu_commands(&self) -> Vec<Command> {
566        self.menu
567            .as_ref()
568            .map(|m| m.matches_in(self.allowed_commands()))
569            .unwrap_or_default()
570    }
571
572    pub fn command_hint(&self, command: Command) -> &'static str {
573        command.hint(self.plain)
574    }
575
576    /// The prose back out, one block per line.
577    pub fn plain_value(&self) -> String {
578        let mut n = 0usize;
579        self.value()
580            .iter()
581            .filter_map(|b| match b {
582                Block::Text { text } => {
583                    n = 0;
584                    Some(text.clone())
585                }
586                Block::Bullet { text } => {
587                    n = 0;
588                    Some(format!("- {text}"))
589                }
590                Block::Number { text } => {
591                    n += 1;
592                    Some(format!("{n}. {text}"))
593                }
594                Block::Link { url } => {
595                    n = 0;
596                    Some(plain_link_value(url))
597                }
598                _ => {
599                    n = 0;
600                    None
601                }
602            })
603            .collect::<Vec<_>>()
604            .join("\n")
605    }
606
607    /// The blocks worth saving. Empty prose lines are layout and round-trip
608    /// with surrounding content; an entirely empty editor stays an empty description.
609    pub fn value(&self) -> Vec<Block> {
610        if self.is_empty() {
611            return Vec::new();
612        }
613        self.lines
614            .iter()
615            .filter_map(|line| match line {
616                Line::Text(text) => Some(Block::text(text.value().trim_end())),
617                Line::Todo { text, done } => {
618                    block_from_input(text, |value| Block::todo(value, *done))
619                }
620                Line::Bullet(text) => block_from_input(text, Block::bullet),
621                Line::Number(text) => block_from_input(text, Block::number),
622                Line::Link(text) => block_from_input(text, Block::link),
623                Line::Image { path } => Some(Block::image(path)),
624            })
625            .collect()
626    }
627
628    pub fn is_empty(&self) -> bool {
629        !self.lines.iter().any(|l| match l {
630            Line::Image { .. } => true,
631            Line::Text(t)
632            | Line::Bullet(t)
633            | Line::Number(t)
634            | Line::Link(t)
635            | Line::Todo { text: t, .. } => !t.value().trim().is_empty(),
636        })
637    }
638
639    pub fn progress(&self) -> (usize, usize) {
640        let mut done = 0usize;
641        let mut total = 0usize;
642        for l in &self.lines {
643            if let Line::Todo { text, done: d } = l
644                && !text.value().trim().is_empty()
645            {
646                total += 1;
647                if *d {
648                    done += 1;
649                }
650            }
651        }
652        (done, total)
653    }
654
655    /// Index of the block under the cursor.
656    pub fn cursor_line(&self) -> usize {
657        self.cursor
658    }
659
660    /// Whether any description or in-line selection is active (no string build).
661    pub fn has_selection(&self) -> bool {
662        if self.ordered_selection().is_some() {
663            return true;
664        }
665        self.lines[self.cursor]
666            .input_ref()
667            .is_some_and(|t| t.has_selection())
668    }
669
670    /// Selected text: multi-line description selection if active, else the
671    /// current line's in-line selection. Pictures become `[image: path]`.
672    pub fn selected_text(&self) -> Option<String> {
673        if let Some(payload) = self.selected_payload() {
674            return match payload {
675                CopyPayload::Text(s) => Some(s),
676                CopyPayload::All(lines) => {
677                    let s = lines
678                        .into_iter()
679                        .map(|l| match l {
680                            CopyLine::Text(t) | CopyLine::Link(t) => t,
681                            CopyLine::Image(p) => format!("[image: {}]", p.display()),
682                        })
683                        .collect::<Vec<_>>()
684                        .join("\n");
685                    (!s.is_empty()).then_some(s)
686                }
687                CopyPayload::Image(p) => Some(format!("[image: {}]", p.display())),
688            };
689        }
690        None
691    }
692
693    /// Clipboard payload for the current selection (text, picture, or both).
694    pub fn selected_payload(&self) -> Option<CopyPayload> {
695        if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
696            let lines = self.copy_lines_between(al, ac, bl, bc);
697            if lines.is_empty() {
698                return None;
699            }
700            if lines.iter().any(|l| matches!(l, CopyLine::Image(_))) {
701                return Some(CopyPayload::All(lines));
702            }
703            let text = lines
704                .into_iter()
705                .filter_map(|l| match l {
706                    CopyLine::Text(t) | CopyLine::Link(t) => Some(t),
707                    CopyLine::Image(_) => None,
708                })
709                .collect::<Vec<_>>()
710                .join("\n");
711            return (!text.is_empty()).then_some(CopyPayload::Text(text));
712        }
713        match &self.lines[self.cursor] {
714            Line::Text(t)
715            | Line::Todo { text: t, .. }
716            | Line::Bullet(t)
717            | Line::Number(t)
718            | Line::Link(t) => t.selected_text().map(CopyPayload::Text),
719            Line::Image { path } => Some(CopyPayload::Image(resolve_image_reference(
720                path,
721                &self.image_root,
722                &self.attachments,
723            ))),
724        }
725    }
726
727    fn caret(&self) -> (usize, usize) {
728        let col = self.lines[self.cursor]
729            .input_ref()
730            .map(|i| i.cursor())
731            .unwrap_or(0);
732        (self.cursor, col)
733    }
734
735    fn ordered_selection(&self) -> Option<((usize, usize), (usize, usize))> {
736        let a = self.sel_anchor?;
737        let b = self.caret();
738        if a == b {
739            return None;
740        }
741        Some(if (a.0, a.1) <= (b.0, b.1) {
742            (a, b)
743        } else {
744            (b, a)
745        })
746    }
747
748    fn line_char_len(&self, i: usize) -> usize {
749        self.lines[i].input_ref().map(|t| t.len()).unwrap_or(0)
750    }
751
752    fn line_text_value(&self, i: usize) -> String {
753        self.lines[i]
754            .input_ref()
755            .map(|t| t.value())
756            .unwrap_or_default()
757    }
758
759    fn is_image_line(&self, i: usize) -> bool {
760        matches!(self.lines.get(i), Some(Line::Image { .. }))
761    }
762
763    /// Whether line `i` sits inside the description selection (for frames / paint).
764    /// A picture is selected as a whole unit whenever the range covers it
765    /// (including when the caret has only just landed on it).
766    pub fn line_in_selection(&self, i: usize) -> bool {
767        let Some(((al, _), (bl, _))) = self.ordered_selection() else {
768            return false;
769        };
770        if i < al || i > bl {
771            return false;
772        }
773        if self.is_image_line(i) {
774            // Same-line image never forms a range (a == b). Multi-line: whole unit.
775            return al < bl;
776        }
777        self.char_sel_on_line(i).is_some()
778    }
779
780    fn copy_lines_between(&self, al: usize, ac: usize, bl: usize, bc: usize) -> Vec<CopyLine> {
781        let mut out = Vec::new();
782        if al == bl {
783            let s = self.line_text_value(al);
784            let chars: Vec<&str> = s.graphemes(true).collect();
785            let lo = ac.min(chars.len());
786            let hi = bc.min(chars.len());
787            if lo < hi {
788                out.push(CopyLine::Text(chars[lo..hi].concat()));
789            }
790            return out;
791        }
792        // Start line: from ac through end (whole picture if it is one).
793        if let Some(line) = self.copy_line_slice(al, Some(ac), None) {
794            out.push(line);
795        }
796        for i in (al + 1)..bl {
797            if let Some(line) = self.copy_line_slice(i, None, None) {
798                out.push(line);
799            }
800        }
801        // End line: start through bc. Picture at the caret is included whole.
802        if let Some(line) = self.copy_line_slice(bl, None, Some(bc)) {
803            out.push(line);
804        }
805        out
806    }
807
808    /// One export line for copy. `from`/`to` are char bounds on text lines;
809    /// `None` means start/end of the line. Empty slices are omitted.
810    fn copy_line_slice(
811        &self,
812        i: usize,
813        from: Option<usize>,
814        to: Option<usize>,
815    ) -> Option<CopyLine> {
816        match &self.lines[i] {
817            Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
818                path,
819                &self.image_root,
820                &self.attachments,
821            ))),
822            Line::Link(t) => {
823                let s = t.value();
824                let chars: Vec<&str> = s.graphemes(true).collect();
825                let lo = from.unwrap_or(0).min(chars.len());
826                let hi = to.unwrap_or(chars.len()).min(chars.len());
827                (lo < hi).then(|| CopyLine::Link(chars[lo..hi].concat()))
828            }
829            Line::Text(t) | Line::Bullet(t) | Line::Number(t) | Line::Todo { text: t, .. } => {
830                let s = t.value();
831                let chars: Vec<&str> = s.graphemes(true).collect();
832                let lo = from.unwrap_or(0).min(chars.len());
833                let hi = to.unwrap_or(chars.len()).min(chars.len());
834                (lo < hi).then(|| CopyLine::Text(chars[lo..hi].concat()))
835            }
836        }
837    }
838
839    /// Char range selected on line `i`, if any (for painting text).
840    fn char_sel_on_line(&self, i: usize) -> Option<(usize, usize)> {
841        let ((al, ac), (bl, bc)) = self.ordered_selection()?;
842        if i < al || i > bl || self.is_image_line(i) {
843            return None;
844        }
845        let len = self.line_char_len(i);
846        if al == bl {
847            let lo = ac.min(bc).min(len);
848            let hi = ac.max(bc).min(len);
849            return (lo < hi).then_some((lo, hi));
850        }
851        if i == al {
852            let lo = ac.min(len);
853            return (lo < len || len == 0).then_some((lo, len));
854        }
855        if i == bl {
856            let hi = bc.min(len);
857            return (hi > 0).then_some((0, hi));
858        }
859        // Middle line: whole content (empty line still "selected").
860        Some((0, len))
861    }
862
863    fn ensure_sel_anchor(&mut self) {
864        if self.sel_anchor.is_none() {
865            self.sel_anchor = Some(self.caret());
866        }
867    }
868
869    fn clear_description_selection(&mut self) {
870        self.sel_anchor = None;
871        for line in &mut self.lines {
872            if let Some(input) = line.input() {
873                input.clear_selection();
874            }
875        }
876    }
877
878    /// Delete description-level or in-line selection. Returns true if anything
879    /// was removed.
880    pub fn delete_description_selection(&mut self) -> bool {
881        if let Some(((al, ac), (bl, bc))) = self.ordered_selection() {
882            if al == bl {
883                // One line: cut the selected range out and rebuild the
884                // line, keeping whatever kind it was.
885                if let Some(input) = self.lines[al].input() {
886                    let value = input.value();
887                    let mut chars: Vec<&str> = value.graphemes(true).collect();
888                    let lo = ac.min(bc).min(chars.len());
889                    let hi = ac.max(bc).min(chars.len());
890                    if lo < hi {
891                        chars.drain(lo..hi);
892                    }
893                    let text = chars.concat();
894                    let len = self.line_max_len;
895                    self.lines[al] = self.line_with_text(al, &text, len);
896                    if let Some(input) = self.lines[al].input() {
897                        input.place_cursor(lo);
898                    }
899                }
900            } else {
901                // Keep prefix of start + suffix of end. Pictures contribute no
902                // text — deleting a range that covers one drops the picture.
903                let start_s = if self.is_image_line(al) {
904                    String::new()
905                } else {
906                    self.line_text_value(al)
907                };
908                let end_s = if self.is_image_line(bl) {
909                    String::new()
910                } else {
911                    self.line_text_value(bl)
912                };
913                let sc: Vec<&str> = start_s.graphemes(true).collect();
914                let ec: Vec<&str> = end_s.graphemes(true).collect();
915                let ac = if self.is_image_line(al) {
916                    0
917                } else {
918                    ac.min(sc.len())
919                };
920                let bc = if self.is_image_line(bl) {
921                    0
922                } else {
923                    bc.min(ec.len())
924                };
925                let merged = format!("{}{}", sc[..ac].concat(), ec[bc..].concat());
926                if !self.line_text_fits(&merged) {
927                    return false;
928                }
929                let len = self.line_max_len;
930                self.lines[al] = self.line_with_text(al, &merged, len);
931                self.lines.drain(al + 1..=bl);
932                self.cursor = al;
933                if let Some(input) = self.lines[al].input() {
934                    input.place_cursor(ac.min(input.len()));
935                }
936            }
937            self.sel_anchor = None;
938            return true;
939        }
940        if let Some(input) = self.input() {
941            return input.delete_selection();
942        }
943        false
944    }
945
946    fn line_with_text(&self, index: usize, text: &str, len: usize) -> Line {
947        match &self.lines[index] {
948            Line::Todo { done, .. } => Line::Todo {
949                text: TextInput::new(text, len),
950                done: *done,
951            },
952            Line::Bullet(_) => Line::Bullet(TextInput::new(text, len)),
953            Line::Number(_) => Line::Number(TextInput::new(text, len)),
954            Line::Link(_) => Line::Link(TextInput::new(text, len)),
955            Line::Text(_) | Line::Image { .. } => Line::Text(TextInput::new(text, len)),
956        }
957    }
958
959    /// URL of the link block under the cursor, if any.
960    pub fn link_url_at_cursor(&self) -> Option<String> {
961        match &self.lines[self.cursor] {
962            Line::Link(t) => {
963                let u = t.value();
964                let u = u.trim();
965                (!u.is_empty()).then(|| u.to_string())
966            }
967            _ => None,
968        }
969    }
970
971    /// URL under a rendered description cell. Padding to the right of a short link
972    /// is deliberately not interactive.
973    pub fn link_url_at_position(&self, row: u16, col: usize) -> Option<String> {
974        use unicode_width::UnicodeWidthStr;
975
976        let width = self.layout_width.max(1);
977        let numbers = number_runs(&self.lines);
978        let target = self.scroll.saturating_add(usize::from(row));
979        let mut at = 0usize;
980        for (index, line) in self.lines.iter().enumerate() {
981            let height = line.height(width, numbers[index]);
982            if target >= at.saturating_add(height) {
983                at = at.saturating_add(height);
984                continue;
985            }
986            let Line::Link(input) = line else {
987                return None;
988            };
989            let row_in = target.saturating_sub(at);
990            let indent = line.indent();
991            let field = width.saturating_sub(indent).max(1);
992            let breaks = input.wrap_breaks(field);
993            let &(start, end) = breaks.get(row_in)?;
994            let text = input.slice(start, end);
995            let text_width = text.width();
996            let on_marker = row_in == 0 && col < indent;
997            let on_text = col >= indent && col < indent.saturating_add(text_width);
998            if !on_marker && !on_text {
999                return None;
1000            }
1001            let url = input.value();
1002            let url = url.trim();
1003            return (!url.is_empty()).then(|| url.to_string());
1004        }
1005        None
1006    }
1007
1008    pub fn selected_image(&self) -> Option<PathBuf> {
1009        match &self.lines[self.cursor] {
1010            Line::Image { path } => Some(resolve_image_reference(
1011                path,
1012                &self.image_root,
1013                &self.attachments,
1014            )),
1015            _ => None,
1016        }
1017    }
1018
1019    /// Every image in the description, so the dialog can preview one.
1020    pub fn images(&self) -> Vec<PathBuf> {
1021        self.lines
1022            .iter()
1023            .filter_map(|l| match l {
1024                Line::Image { path } => Some(resolve_image_reference(
1025                    path,
1026                    &self.image_root,
1027                    &self.attachments,
1028                )),
1029                _ => None,
1030            })
1031            .collect()
1032    }
1033
1034    /// Move the cursor off a picture onto a neighbouring text line without
1035    /// inserting blanks. Used when a click lands on the letterbox gutter.
1036    /// If there is no editable neighbour, the cursor stays on the picture
1037    /// (←/→ still create a caret).
1038    pub fn abandon_image_selection(&mut self) {
1039        if !matches!(self.lines.get(self.cursor), Some(Line::Image { .. })) {
1040            return;
1041        }
1042        if let Some(next) = self.next_editable(self.cursor) {
1043            self.cursor = next;
1044            if let Some(input) = self.input() {
1045                input.home();
1046            }
1047            return;
1048        }
1049        if let Some(prev) = self.prev_editable(self.cursor) {
1050            self.cursor = prev;
1051            if let Some(input) = self.input() {
1052                input.end();
1053            }
1054        }
1055    }
1056
1057    fn line(&mut self) -> &mut Line {
1058        &mut self.lines[self.cursor]
1059    }
1060
1061    fn input(&mut self) -> Option<&mut TextInput> {
1062        self.lines[self.cursor].input()
1063    }
1064
1065    // -------------------------------------------------------------- typing
1066
1067    pub fn insert(&mut self, c: char) {
1068        // Typing over a selection replaces it.
1069        if self.has_selection() && !self.delete_description_selection() {
1070            return;
1071        }
1072        if self.input().is_none() {
1073            // Typing next to a picture starts a line under it.
1074            self.insert_block(Block::text(""));
1075        }
1076        if let Some(input) = self.input() {
1077            input.insert(c);
1078        }
1079        // Leading "- "/"* " → bullet; "N. " → numbered item.
1080        if c == ' '
1081            && let Line::Text(text) = &self.lines[self.cursor]
1082        {
1083            let v = text.value();
1084            if text.cursor() == v.graphemes(true).count() {
1085                if matches!(v.as_str(), "- " | "* ") {
1086                    self.lines[self.cursor] = Line::Bullet(TextInput::new("", self.line_max_len));
1087                    return;
1088                }
1089                if let Some(rest) = v.strip_suffix(". ")
1090                    && !rest.is_empty()
1091                    && rest.chars().all(|ch| ch.is_ascii_digit())
1092                {
1093                    self.lines[self.cursor] = Line::Number(TextInput::new("", self.line_max_len));
1094                    return;
1095                }
1096            }
1097        }
1098        if c == '/' {
1099            let start = self.input().map(|i| i.cursor()).unwrap_or(0);
1100            self.menu = Some(SlashMenu {
1101                start,
1102                query: String::new(),
1103                index: 0,
1104            });
1105        } else if self.menu.is_some() {
1106            if let Some(menu) = &mut self.menu {
1107                menu.query.push(c);
1108                menu.index = 0;
1109            }
1110            // No matching command → treat `/` as plain text.
1111            if self.menu_commands().is_empty() {
1112                self.close_menu();
1113            }
1114        }
1115        // Convert a complete image path on this line (extension gate inside).
1116        self.try_adopt_line(self.cursor);
1117    }
1118
1119    pub fn insert_str(&mut self, text: &str) {
1120        self.close_menu();
1121        if self.has_selection() && !self.delete_description_selection() {
1122            return;
1123        }
1124        // Paste may wrap a long path across lines; flatten if it is one image path.
1125        let text = match flatten_if_image_path(text, &self.image_root) {
1126            Some(flat) if self.line_text_fits(&flat) => flat,
1127            _ => text.to_string(),
1128        };
1129        for (i, part) in text.split('\n').enumerate() {
1130            if i > 0 && !self.newline() {
1131                break;
1132            }
1133            if self.input().is_none() {
1134                self.insert_block(Block::text(""));
1135            }
1136            if let Some(input) = self.input() {
1137                input.insert_str(part.trim_end_matches('\r'));
1138            }
1139        }
1140        // A pasted path shows its picture straight away — unlike one
1141        // being typed out, it is complete the moment it arrives.
1142        self.adopt_pasted_paths();
1143        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1144            if matches!(self.lines.get(self.cursor + 1), Some(Line::Text(_))) {
1145                self.cursor += 1;
1146            } else if self.can_add_lines(1) {
1147                self.lines.insert(self.cursor + 1, self.empty_line());
1148                self.cursor += 1;
1149            }
1150        }
1151    }
1152
1153    /// Enter continues to-do, bullet and numbered lines. An empty list item
1154    /// returns to plain text; prose, links and pictures still start prose.
1155    /// Returns false only when adding a line would exceed the line cap.
1156    pub fn newline(&mut self) -> bool {
1157        self.close_menu();
1158
1159        let exits_list = match &self.lines[self.cursor] {
1160            Line::Todo { text, .. } | Line::Bullet(text) | Line::Number(text) => {
1161                text.value().trim().is_empty()
1162            }
1163            Line::Text(_) | Line::Link(_) | Line::Image { .. } => false,
1164        };
1165        if exits_list {
1166            self.lines[self.cursor] = self.empty_line();
1167            return true;
1168        }
1169
1170        if !self.can_add_lines(1) {
1171            return false;
1172        }
1173        let line_max_len = self.line_max_len;
1174        let next = match self.line() {
1175            Line::Text(text) | Line::Link(text) => Line::Text(text.split_off_at_cursor()),
1176            Line::Todo { text, .. } => Line::Todo {
1177                text: text.split_off_at_cursor(),
1178                done: false,
1179            },
1180            Line::Bullet(text) => Line::Bullet(text.split_off_at_cursor()),
1181            Line::Number(text) => Line::Number(text.split_off_at_cursor()),
1182            Line::Image { .. } => Line::Text(TextInput::new("", line_max_len)),
1183        };
1184        self.lines.insert(self.cursor + 1, next);
1185        self.cursor += 1;
1186        // Leaving a line may complete a typed image path.
1187        self.adopt_pasted_paths();
1188        true
1189    }
1190
1191    pub fn backspace(&mut self) {
1192        if let Some(start) = self.menu.as_ref().map(|m| m.start) {
1193            let at = self.input().map(|i| i.cursor()).unwrap_or(0);
1194            if at <= start {
1195                self.close_menu();
1196            } else if let Some(menu) = &mut self.menu {
1197                menu.query.pop();
1198                menu.index = 0;
1199            }
1200        }
1201        let had_selection = self.has_selection();
1202        if self.delete_description_selection() {
1203            return;
1204        }
1205        if had_selection {
1206            return;
1207        }
1208        match self.line() {
1209            // A to-do, bullet, number or link turns back into plain
1210            // text before it disappears.
1211            Line::Todo { text, .. }
1212            | Line::Bullet(text)
1213            | Line::Number(text)
1214            | Line::Link(text)
1215                if text.at_start() =>
1216            {
1217                let text = text.clone();
1218                self.lines[self.cursor] = Line::Text(text);
1219                return;
1220            }
1221            Line::Text(text) if text.at_start() => {}
1222            Line::Image { .. } => {
1223                self.remove_block();
1224                return;
1225            }
1226            _ => {
1227                if let Some(input) = self.input() {
1228                    input.backspace();
1229                }
1230                return;
1231            }
1232        }
1233        // At the start of a text line: fold it into the one above.
1234        if self.cursor == 0 {
1235            return;
1236        }
1237        let current = self.lines.remove(self.cursor);
1238        self.cursor -= 1;
1239        match current {
1240            Line::Text(text) => {
1241                let merged = match self.lines[self.cursor].input() {
1242                    Some(previous) => previous.append(&text),
1243                    // Above is a picture: an empty spacer can disappear.
1244                    None => text.is_empty(),
1245                };
1246                if !merged {
1247                    self.cursor += 1;
1248                    self.lines.insert(self.cursor, Line::Text(text));
1249                }
1250            }
1251            line => {
1252                self.cursor += 1;
1253                self.lines.insert(self.cursor, line);
1254            }
1255        }
1256    }
1257
1258    pub fn delete(&mut self) {
1259        self.close_menu();
1260        let had_selection = self.has_selection();
1261        if self.delete_description_selection() {
1262            return;
1263        }
1264        if had_selection {
1265            return;
1266        }
1267        if matches!(self.line(), Line::Image { .. }) {
1268            self.remove_block();
1269            return;
1270        }
1271        let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1272        if !at_end {
1273            if let Some(input) = self.input() {
1274                input.delete();
1275            }
1276            return;
1277        }
1278        if self.cursor + 1 >= self.lines.len() {
1279            return;
1280        }
1281        let next = self.lines.remove(self.cursor + 1);
1282        let merged = match next.input_ref() {
1283            Some(text) => self.lines[self.cursor]
1284                .input()
1285                .is_some_and(|current| current.append(text)),
1286            None => false,
1287        };
1288        if !merged {
1289            self.lines.insert(self.cursor + 1, next);
1290        }
1291    }
1292
1293    /// Drops the block under the cursor, keeping at least one line.
1294    pub fn remove_block(&mut self) {
1295        if self.lines.len() == 1 {
1296            self.lines[0] = self.empty_line();
1297            return;
1298        }
1299        self.lines.remove(self.cursor);
1300        self.cursor = self.cursor.min(self.lines.len() - 1);
1301    }
1302
1303    pub fn toggle(&mut self) {
1304        if let Line::Todo { done, .. } = self.line() {
1305            *done = !*done;
1306        }
1307    }
1308
1309    // ------------------------------------------------------------ movement
1310
1311    fn field_width_for(&self, index: usize) -> usize {
1312        let width = self.layout_width.max(1);
1313        let n = match &self.lines[index] {
1314            Line::Number(_) => Some(number_at(&self.lines, index)),
1315            _ => None,
1316        };
1317        let indent = n
1318            .map(number_indent)
1319            .unwrap_or_else(|| self.lines[index].indent());
1320        width.saturating_sub(indent).max(1)
1321    }
1322
1323    pub fn up(&mut self) {
1324        self.close_menu();
1325        self.clear_description_selection();
1326        // Already on a picture: leave it upward (may insert a blank above).
1327        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1328            self.leave_image_backward();
1329            return;
1330        }
1331        let width = self.field_width_for(self.cursor);
1332        let prefer = self.prefer_col;
1333        let moved = self
1334            .input()
1335            .is_some_and(|input| input.wrap_up(width, prefer));
1336        if moved {
1337            if self.prefer_col == u16::MAX
1338                && let Some(input) = self.input()
1339            {
1340                self.prefer_col = input.wrap_cursor(width).1;
1341            }
1342            return;
1343        }
1344        if self.cursor > 0 {
1345            let left = self.cursor;
1346            self.cursor -= 1;
1347            self.try_adopt_line(left);
1348            // Landing on a picture selects it (do not skip through).
1349            if matches!(self.lines[self.cursor], Line::Image { .. }) {
1350                return;
1351            }
1352            let width = self.field_width_for(self.cursor);
1353            let prefer = if self.prefer_col == u16::MAX {
1354                0
1355            } else {
1356                self.prefer_col
1357            };
1358            if let Some(input) = self.input() {
1359                let last = input.wrap_height(width).saturating_sub(1);
1360                input.set_cursor_from_wrap(width, last, prefer as usize);
1361            }
1362        }
1363    }
1364
1365    pub fn down(&mut self) {
1366        self.close_menu();
1367        self.clear_description_selection();
1368        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1369            self.leave_image_forward();
1370            return;
1371        }
1372        let width = self.field_width_for(self.cursor);
1373        let prefer = self.prefer_col;
1374        let moved = self
1375            .input()
1376            .is_some_and(|input| input.wrap_down(width, prefer));
1377        if moved {
1378            if self.prefer_col == u16::MAX
1379                && let Some(input) = self.input()
1380            {
1381                self.prefer_col = input.wrap_cursor(width).1;
1382            }
1383            return;
1384        }
1385        if self.cursor + 1 < self.lines.len() {
1386            let left = self.cursor;
1387            self.cursor += 1;
1388            self.try_adopt_line(left);
1389            // Landing on a picture selects it.
1390            if matches!(self.lines[self.cursor], Line::Image { .. }) {
1391                return;
1392            }
1393            let width = self.field_width_for(self.cursor);
1394            let prefer = if self.prefer_col == u16::MAX {
1395                0
1396            } else {
1397                self.prefer_col
1398            };
1399            if let Some(input) = self.input() {
1400                input.set_cursor_from_wrap(width, 0, prefer as usize);
1401            }
1402        }
1403    }
1404
1405    pub fn left(&mut self) {
1406        self.close_menu();
1407        self.prefer_col = u16::MAX;
1408        self.clear_description_selection();
1409        // On a picture there is no text caret — ← steps into the line above
1410        // (creating an empty one when the picture is first).
1411        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1412            self.leave_image_backward();
1413            return;
1414        }
1415        match self.input() {
1416            Some(input) if !input.at_start() => input.left(),
1417            _ => {
1418                if self.cursor > 0 {
1419                    self.cursor -= 1;
1420                    // Landing on a picture selects it.
1421                    if matches!(self.lines[self.cursor], Line::Image { .. }) {
1422                        return;
1423                    }
1424                    if let Some(input) = self.input() {
1425                        input.end();
1426                    }
1427                }
1428            }
1429        }
1430    }
1431
1432    pub fn right(&mut self) {
1433        self.close_menu();
1434        self.prefer_col = u16::MAX;
1435        self.clear_description_selection();
1436        // On a picture there is no text caret — → steps into a line below
1437        // (creating an empty one when needed) so the user can type again.
1438        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1439            self.leave_image_forward();
1440            return;
1441        }
1442        match self.input() {
1443            Some(input) if !input.at_end() => input.right(),
1444            _ => {
1445                if self.cursor + 1 < self.lines.len() {
1446                    self.cursor += 1;
1447                    // Landing on a picture selects it.
1448                    if matches!(self.lines[self.cursor], Line::Image { .. }) {
1449                        return;
1450                    }
1451                    if let Some(input) = self.input() {
1452                        input.home();
1453                    }
1454                }
1455            }
1456        }
1457    }
1458
1459    /// Move off a selected picture onto the line right under it.
1460    /// Always inserts a blank text line when the next block is missing or
1461    /// not editable — works when the picture is the first / only line.
1462    fn leave_image_forward(&mut self) {
1463        let next = self.cursor + 1;
1464        if next < self.lines.len() && self.lines[next].input_ref().is_some() {
1465            self.cursor = next;
1466            if let Some(input) = self.input() {
1467                input.home();
1468            }
1469            return;
1470        }
1471        if !self.can_add_lines(1) {
1472            return;
1473        }
1474        // Insert immediately under this picture (even if another picture
1475        // follows — user asked for a caret, not to hop to the next image).
1476        self.lines.insert(next, self.empty_line());
1477        self.cursor = next;
1478    }
1479
1480    /// Move off a picture onto the line right above it. Inserts a blank
1481    /// line when the picture is first so ← / ↑ always yield a caret.
1482    fn leave_image_backward(&mut self) {
1483        if self.cursor > 0 && self.lines[self.cursor - 1].input_ref().is_some() {
1484            self.cursor -= 1;
1485            if let Some(input) = self.input() {
1486                input.end();
1487            }
1488            return;
1489        }
1490        if !self.can_add_lines(1) {
1491            return;
1492        }
1493        self.lines.insert(self.cursor, self.empty_line());
1494        // cursor stays on the new blank line at the same index
1495        if let Some(input) = self.input() {
1496            input.home();
1497        }
1498    }
1499
1500    pub fn home(&mut self) {
1501        self.close_menu();
1502        self.clear_description_selection();
1503        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1504            self.leave_image_backward();
1505            return;
1506        }
1507        if let Some(input) = self.input() {
1508            input.home();
1509        }
1510    }
1511
1512    pub fn end(&mut self) {
1513        self.close_menu();
1514        self.clear_description_selection();
1515        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1516            self.leave_image_forward();
1517            return;
1518        }
1519        if let Some(input) = self.input() {
1520            input.end();
1521        }
1522    }
1523
1524    pub fn word_left(&mut self) {
1525        self.close_menu();
1526        self.clear_description_selection();
1527        self.prefer_col = u16::MAX;
1528        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1529            self.leave_image_backward();
1530            return;
1531        }
1532        let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1533        if !at_start {
1534            if let Some(input) = self.input() {
1535                input.word_left();
1536            }
1537            return;
1538        }
1539        // Cross into the previous editable line.
1540        if let Some(prev) = self.prev_editable(self.cursor) {
1541            self.cursor = prev;
1542            if let Some(input) = self.input() {
1543                input.end();
1544                input.word_left();
1545            }
1546        }
1547    }
1548
1549    pub fn word_right(&mut self) {
1550        self.close_menu();
1551        self.clear_description_selection();
1552        self.prefer_col = u16::MAX;
1553        if matches!(self.lines[self.cursor], Line::Image { .. }) {
1554            self.leave_image_forward();
1555            return;
1556        }
1557        let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1558        if !at_end {
1559            if let Some(input) = self.input() {
1560                input.word_right();
1561            }
1562            return;
1563        }
1564        if let Some(next) = self.next_editable(self.cursor) {
1565            self.cursor = next;
1566            if let Some(input) = self.input() {
1567                input.home();
1568                input.word_right();
1569            }
1570        }
1571    }
1572
1573    fn prev_editable(&self, from: usize) -> Option<usize> {
1574        (0..from)
1575            .rev()
1576            .find(|&i| self.lines[i].input_ref().is_some())
1577    }
1578
1579    fn next_editable(&self, from: usize) -> Option<usize> {
1580        ((from + 1)..self.lines.len()).find(|&i| self.lines[i].input_ref().is_some())
1581    }
1582
1583    pub fn select_word(&mut self) {
1584        self.close_menu();
1585        self.sel_anchor = None;
1586        if let Some(input) = self.input() {
1587            input.select_word();
1588        }
1589    }
1590
1591    pub fn select_left(&mut self) {
1592        self.close_menu();
1593        self.prefer_col = u16::MAX;
1594        self.ensure_sel_anchor();
1595        if let Some(input) = self.input() {
1596            input.clear_selection();
1597        }
1598        // Pictures are one unit: ← leaves them for the previous line.
1599        if self.is_image_line(self.cursor) {
1600            self.step_sel_prev_line();
1601            return;
1602        }
1603        let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1604        if !at_start {
1605            if let Some(input) = self.input() {
1606                let c = input.cursor().saturating_sub(1);
1607                input.place_cursor(c);
1608            }
1609        } else {
1610            self.step_sel_prev_line();
1611        }
1612    }
1613
1614    pub fn select_right(&mut self) {
1615        self.close_menu();
1616        self.prefer_col = u16::MAX;
1617        self.ensure_sel_anchor();
1618        if let Some(input) = self.input() {
1619            input.clear_selection();
1620        }
1621        if self.is_image_line(self.cursor) {
1622            self.step_sel_next_line();
1623            return;
1624        }
1625        let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1626        if !at_end {
1627            if let Some(input) = self.input() {
1628                let c = (input.cursor() + 1).min(input.len());
1629                input.place_cursor(c);
1630            }
1631        } else {
1632            self.step_sel_next_line();
1633        }
1634    }
1635
1636    /// Shift+Option+← — extend selection by a word, crossing lines and pictures.
1637    pub fn select_word_left(&mut self) {
1638        self.close_menu();
1639        self.prefer_col = u16::MAX;
1640        self.ensure_sel_anchor();
1641        if let Some(input) = self.input() {
1642            input.clear_selection();
1643        }
1644        // A picture counts as one word.
1645        if self.is_image_line(self.cursor) {
1646            self.step_sel_prev_line();
1647            if !self.is_image_line(self.cursor)
1648                && let Some(input) = self.input()
1649            {
1650                input.end();
1651                let target = input.word_left_index();
1652                input.place_cursor(target);
1653            }
1654            return;
1655        }
1656        let at_start = self.input().map(|i| i.at_start()).unwrap_or(true);
1657        if !at_start {
1658            if let Some(input) = self.input() {
1659                let target = input.word_left_index();
1660                input.place_cursor(target);
1661            }
1662            return;
1663        }
1664        self.step_sel_prev_line();
1665        if self.is_image_line(self.cursor) {
1666            return;
1667        }
1668        if let Some(input) = self.input() {
1669            input.end();
1670            let target = input.word_left_index();
1671            input.place_cursor(target);
1672        }
1673    }
1674
1675    /// Shift+Option+→ — extend selection by a word, crossing lines and pictures.
1676    pub fn select_word_right(&mut self) {
1677        self.close_menu();
1678        self.prefer_col = u16::MAX;
1679        self.ensure_sel_anchor();
1680        if let Some(input) = self.input() {
1681            input.clear_selection();
1682        }
1683        if self.is_image_line(self.cursor) {
1684            self.step_sel_next_line();
1685            if !self.is_image_line(self.cursor)
1686                && let Some(input) = self.input()
1687            {
1688                input.home();
1689                let target = input.word_right_index();
1690                input.place_cursor(target);
1691            }
1692            return;
1693        }
1694        let at_end = self.input().map(|i| i.at_end()).unwrap_or(true);
1695        if !at_end {
1696            if let Some(input) = self.input() {
1697                let target = input.word_right_index();
1698                input.place_cursor(target);
1699            }
1700            return;
1701        }
1702        self.step_sel_next_line();
1703        if self.is_image_line(self.cursor) {
1704            return;
1705        }
1706        if let Some(input) = self.input() {
1707            input.home();
1708            let target = input.word_right_index();
1709            input.place_cursor(target);
1710        }
1711    }
1712
1713    /// Move the selection caret onto the previous line (pictures included).
1714    /// Does not insert blank lines — unlike plain ← on a picture.
1715    fn step_sel_prev_line(&mut self) {
1716        if self.cursor == 0 {
1717            return;
1718        }
1719        self.cursor -= 1;
1720        if let Some(input) = self.input() {
1721            input.end();
1722        }
1723    }
1724
1725    /// Move the selection caret onto the next line (pictures included).
1726    fn step_sel_next_line(&mut self) {
1727        if self.cursor + 1 >= self.lines.len() {
1728            return;
1729        }
1730        self.cursor += 1;
1731        if let Some(input) = self.input() {
1732            input.home();
1733        }
1734    }
1735
1736    pub fn select_home(&mut self) {
1737        self.close_menu();
1738        self.ensure_sel_anchor();
1739        if let Some(input) = self.input() {
1740            input.clear_selection();
1741            input.place_cursor(0);
1742        }
1743    }
1744
1745    pub fn select_end(&mut self) {
1746        self.close_menu();
1747        self.ensure_sel_anchor();
1748        if let Some(input) = self.input() {
1749            input.clear_selection();
1750            let n = input.len();
1751            input.place_cursor(n);
1752        }
1753    }
1754
1755    pub fn delete_to_start(&mut self) {
1756        if let Some(input) = self.input() {
1757            input.delete_to_start();
1758        }
1759    }
1760
1761    pub fn delete_to_end(&mut self) {
1762        if let Some(input) = self.input() {
1763            input.delete_to_end();
1764        }
1765    }
1766
1767    pub fn delete_word_left(&mut self) {
1768        if let Some(input) = self.input() {
1769            input.delete_word_left();
1770        }
1771    }
1772
1773    // ---------------------------------------------------------- slash menu
1774
1775    pub fn menu_next(&mut self) {
1776        let count = self.menu_commands().len();
1777        if let Some(menu) = &mut self.menu
1778            && count > 0
1779        {
1780            menu.index = (menu.index + 1) % count;
1781        }
1782    }
1783
1784    pub fn menu_prev(&mut self) {
1785        let count = self.menu_commands().len();
1786        if let Some(menu) = &mut self.menu
1787            && count > 0
1788        {
1789            menu.index = (menu.index + count - 1) % count;
1790        }
1791    }
1792
1793    pub fn close_menu(&mut self) {
1794        self.menu = None;
1795    }
1796
1797    /// The command under the cursor in the open menu, if any.
1798    pub fn menu_selected(&self) -> Option<Command> {
1799        self.menu
1800            .as_ref()
1801            .and_then(|m| m.selected_in(self.allowed_commands()))
1802    }
1803
1804    /// Plain-text export of every non-image block, for `/copy`.
1805    pub fn text_for_copy(&self) -> String {
1806        self.lines_for_copy_all()
1807            .into_iter()
1808            .filter_map(|line| match line {
1809                CopyLine::Text(s) | CopyLine::Link(s) => Some(s),
1810                CopyLine::Image(_) => None,
1811            })
1812            .collect::<Vec<_>>()
1813            .join("\n")
1814    }
1815
1816    /// Full description in order for `/copyall` — text lines and pictures.
1817    pub fn lines_for_copy_all(&self) -> Vec<CopyLine> {
1818        let numbers = number_runs(&self.lines);
1819        self.lines
1820            .iter()
1821            .zip(numbers)
1822            .filter_map(|(line, number)| match line {
1823                Line::Text(text) => {
1824                    let s = text.value();
1825                    (!s.trim().is_empty()).then_some(CopyLine::Text(s))
1826                }
1827                Line::Bullet(text) => Some(CopyLine::Text(format!("- {}", text.value()))),
1828                Line::Number(text) => {
1829                    let n = number.expect("numbered lines have a run index");
1830                    Some(CopyLine::Text(format!("{n}. {}", text.value())))
1831                }
1832                Line::Todo { text, done } => {
1833                    let mark = if *done { "[✓]" } else { "[ ]" };
1834                    Some(CopyLine::Text(format!("{mark} {}", text.value())))
1835                }
1836                Line::Link(text) => {
1837                    let s = text.value();
1838                    (!s.trim().is_empty()).then_some(CopyLine::Link(s))
1839                }
1840                Line::Image { path } => Some(CopyLine::Image(resolve_image_reference(
1841                    path,
1842                    &self.image_root,
1843                    &self.attachments,
1844                ))),
1845            })
1846            .collect()
1847    }
1848
1849    /// Picture nearest the cursor: search upward first, then downward.
1850    pub fn image_for_copy(&self) -> Option<PathBuf> {
1851        (0..=self.cursor)
1852            .rev()
1853            .chain(self.cursor + 1..self.lines.len())
1854            .find_map(|i| match &self.lines[i] {
1855                Line::Image { path } => Some(resolve_image_reference(
1856                    path,
1857                    &self.image_root,
1858                    &self.attachments,
1859                )),
1860                _ => None,
1861            })
1862    }
1863
1864    /// Removes the typed `/command` and applies it. Returns work that needs
1865    /// access to the platform clipboard.
1866    pub fn apply(&mut self, command: Command) -> Option<CommandRequest> {
1867        if !self.allowed_commands().contains(&command) {
1868            self.close_menu();
1869            return None;
1870        }
1871        let menu = self.menu.take()?;
1872        if let Some(input) = self.input() {
1873            // Cut away the `/query` that was typed.
1874            let end = input.cursor();
1875            input.set_cursor(end);
1876            for _ in menu.start.saturating_sub(1)..end {
1877                input.backspace();
1878            }
1879        }
1880        let request = match command {
1881            Command::Paste => Some(CommandRequest::Paste),
1882            Command::Copy => Some(CommandRequest::Copy(CopyPayload::Text(
1883                self.text_for_copy(),
1884            ))),
1885            Command::CopyImage => self
1886                .image_for_copy()
1887                .map(CopyPayload::Image)
1888                .map(CommandRequest::Copy),
1889            Command::CopyAll => Some(CommandRequest::Copy(CopyPayload::All(
1890                self.lines_for_copy_all(),
1891            ))),
1892            Command::Todo | Command::Bullet | Command::Number | Command::Link => {
1893                let text = match self.line() {
1894                    Line::Text(text)
1895                    | Line::Todo { text, .. }
1896                    | Line::Bullet(text)
1897                    | Line::Number(text)
1898                    | Line::Link(text) => text.clone(),
1899                    Line::Image { .. } => return None,
1900                };
1901                self.lines[self.cursor] = match command {
1902                    Command::Todo => Line::Todo { text, done: false },
1903                    Command::Bullet => Line::Bullet(text),
1904                    Command::Number => Line::Number(text),
1905                    Command::Link => {
1906                        let url = link_url_from_line(&text.value());
1907                        Line::Link(TextInput::new(&url, self.line_max_len))
1908                    }
1909                    Command::Paste | Command::Copy | Command::CopyImage | Command::CopyAll => {
1910                        return None;
1911                    }
1912                };
1913                None
1914            }
1915        };
1916        if matches!(
1917            command,
1918            Command::Copy | Command::CopyImage | Command::CopyAll
1919        ) && self.input().is_some_and(|input| input.is_empty())
1920        {
1921            self.remove_block();
1922        }
1923        request
1924    }
1925
1926    /// Puts a block in at the cursor, replacing the line when it is an
1927    /// empty one and pushing it down otherwise.
1928    pub fn insert_block(&mut self, block: Block) -> bool {
1929        if self.plain && matches!(block, Block::Todo { .. } | Block::Image { .. }) {
1930            return false;
1931        }
1932        self.close_menu();
1933        let is_image = matches!(block, Block::Image { .. });
1934        let replace = match &self.lines[self.cursor] {
1935            Line::Text(text) => text.is_empty(),
1936            _ => false,
1937        };
1938        let extra = match (replace, is_image) {
1939            (true, true) => 1, // image replaces empty, then a blank line under it
1940            (true, false) => 0,
1941            (false, true) => 2, // image + blank line
1942            (false, false) => 1,
1943        };
1944        if extra > 0 && !self.can_add_lines(extra) {
1945            return false;
1946        }
1947        let line = line_from_block(&block, self.line_max_len);
1948        if replace {
1949            self.lines[self.cursor] = line;
1950        } else {
1951            self.lines.insert(self.cursor + 1, line);
1952            self.cursor += 1;
1953        }
1954        // A picture is not editable, so leave a line under it to type on.
1955        if is_image {
1956            self.lines.insert(self.cursor + 1, self.empty_line());
1957            self.cursor += 1;
1958        }
1959        true
1960    }
1961
1962    /// Turn bare image-file paths into image blocks (paste / leave-line).
1963    fn adopt_pasted_paths(&mut self) {
1964        if self.plain {
1965            return;
1966        }
1967        self.merge_broken_image_paths();
1968        for i in 0..self.lines.len() {
1969            self.try_adopt_line(i);
1970        }
1971    }
1972
1973    fn try_adopt_line(&mut self, i: usize) {
1974        let Line::Text(text) = &self.lines[i] else {
1975            return;
1976        };
1977        let value = text.value();
1978        let reference = crate::image::reference_path(&value);
1979        let managed = reference.filter(|reference| self.attachments.contains(reference));
1980        if !crate::image::looks_like_image(&value) && managed.is_none() {
1981            return;
1982        }
1983        if let Some(reference) = managed {
1984            self.lines[i] = Line::Image {
1985                path: reference.to_string(),
1986            };
1987        } else if let Some(path) = crate::image::path_if_image_in(&value, &self.image_root) {
1988            self.lines[i] = Line::Image {
1989                path: crate::image::short_in(&path, &self.image_root),
1990            };
1991        }
1992    }
1993
1994    /// If line *i* + line *i+1* form an existing image path when joined,
1995    /// fold them into one text line (for the next convert pass).
1996    fn merge_broken_image_paths(&mut self) {
1997        let mut i = 0;
1998        while i + 1 < self.lines.len() {
1999            let joined = match (&self.lines[i], &self.lines[i + 1]) {
2000                (Line::Text(a), Line::Text(b)) => {
2001                    let left = a.value();
2002                    let right = b.value();
2003                    let joined = format!("{left}{right}");
2004                    // Only glue when the first piece looks like a path
2005                    // fragment (no image ext yet) and the second finishes it.
2006                    if left.contains('/')
2007                        && !crate::image::looks_like_image(&left)
2008                        && self.line_text_fits(&joined)
2009                        && crate::image::path_if_image_in(&joined, &self.image_root).is_some()
2010                    {
2011                        Some(joined)
2012                    } else {
2013                        None
2014                    }
2015                }
2016                _ => None,
2017            };
2018            if let Some(path) = joined {
2019                let len = self.line_max_len;
2020                self.lines[i] = Line::Text(TextInput::new(&path, len));
2021                self.lines.remove(i + 1);
2022                if self.cursor > i {
2023                    self.cursor -= 1;
2024                }
2025                // Don't advance — the merged line may convert next.
2026            } else {
2027                i += 1;
2028            }
2029        }
2030    }
2031
2032    // ------------------------------------------------------------ painting
2033
2034    /// Lays the blocks out in a `width` x `height` box and scrolls so the
2035    /// cursor stays in view. Returns the visible blocks and where the
2036    /// text cursor sits, if it is on an editable line.
2037    pub fn layout(&mut self, width: usize, height: u16) -> (Vec<Placed>, Option<(u16, u16)>) {
2038        if height == 0 || width == 0 {
2039            return (Vec::new(), None);
2040        }
2041        self.layout_width = width;
2042
2043        let numbers = number_runs(&self.lines);
2044        let mut total = 0usize;
2045        let layouts: Vec<LineLayout> = self
2046            .lines
2047            .iter()
2048            .enumerate()
2049            .map(|(i, line)| {
2050                let number = numbers[i];
2051                let indent = number.map(number_indent).unwrap_or_else(|| line.indent());
2052                let field = width.saturating_sub(indent).max(1);
2053                let wraps = line
2054                    .input_ref()
2055                    .map(|text| text.wrap_breaks(field))
2056                    .unwrap_or_default();
2057                let rows = if matches!(line, Line::Image { .. }) {
2058                    usize::from(IMAGE_ROWS)
2059                } else {
2060                    wraps.len().max(1)
2061                };
2062                let layout = LineLayout {
2063                    number,
2064                    wraps,
2065                    start: total,
2066                    rows,
2067                    selection: self
2068                        .char_sel_on_line(i)
2069                        .or_else(|| line.input_ref().and_then(TextInput::selection_range)),
2070                    selected: i == self.cursor || self.line_in_selection(i),
2071                };
2072                total = total.saturating_add(rows);
2073                layout
2074            })
2075            .collect();
2076        self.content_height = total;
2077
2078        // Keep the caret's visual row on screen (not just the block).
2079        let cursor_visual = {
2080            let layout = &layouts[self.cursor];
2081            let row_in_block = self.lines[self.cursor]
2082                .input_ref()
2083                .map(|text| text.wrap_cursor_from_breaks(&layout.wraps).0)
2084                .unwrap_or(0);
2085            layout.start.saturating_add(row_in_block)
2086        };
2087        if cursor_visual < self.scroll {
2088            self.scroll = cursor_visual;
2089        } else if cursor_visual >= self.scroll.saturating_add(usize::from(height)) {
2090            self.scroll = cursor_visual + 1 - usize::from(height);
2091        }
2092        self.scroll = self.scroll.min(total.saturating_sub(usize::from(height)));
2093
2094        let mut placed = Vec::new();
2095        let mut cursor_at = None;
2096        for (i, (line, layout)) in self.lines.iter_mut().zip(&layouts).enumerate() {
2097            // Intersection with the viewport — clip top and bottom the same
2098            // way so a tall block (picture) shrinks until it disappears when
2099            // scrolled off either edge, instead of painting full-height at y=0
2100            // and overlapping the next block.
2101            let Some((y, vis_rows, skip)) =
2102                visible_band(layout.start, layout.rows, self.scroll, height)
2103            else {
2104                continue;
2105            };
2106            let (text, kind) = match line {
2107                Line::Todo { text, done } => (text, TextKind::Todo { done: *done }),
2108                Line::Bullet(text) => (text, TextKind::Bullet),
2109                Line::Number(text) => (text, TextKind::Number(layout.number.unwrap_or(1))),
2110                Line::Link(text) => (text, TextKind::Link),
2111                Line::Text(text) => (text, TextKind::Plain),
2112                Line::Image { path } => {
2113                    placed.push(Placed {
2114                        block: Painted::Image(resolve_image_reference(
2115                            path,
2116                            &self.image_root,
2117                            &self.attachments,
2118                        )),
2119                        line: i,
2120                        y,
2121                        rows: vis_rows,
2122                        selected: layout.selected,
2123                    });
2124                    continue;
2125                }
2126            };
2127            let indent = kind.indent();
2128            let view = text.wrapped_from_breaks(&layout.wraps, layout.selection);
2129            if i == self.cursor {
2130                let row = usize::from(view.cursor_row).saturating_sub(skip) as u16;
2131                cursor_at = Some((y.saturating_add(row), view.cursor_col + indent as u16));
2132            }
2133            let wrap_rows: Vec<WrappedRow> = view
2134                .lines
2135                .into_iter()
2136                .skip(skip)
2137                .take(vis_rows as usize)
2138                .map(|l| WrappedRow {
2139                    text: l.text,
2140                    sel: l.sel_cols,
2141                })
2142                .collect();
2143            placed.push(Placed {
2144                block: Painted::Text {
2145                    rows: wrap_rows,
2146                    kind,
2147                },
2148                line: i,
2149                y,
2150                rows: vis_rows,
2151                selected: layout.selected,
2152            });
2153        }
2154        (placed, cursor_at)
2155    }
2156
2157    /// Description scroll offset after the last [`Self::layout`] call.
2158    pub fn scroll(&self) -> usize {
2159        self.scroll
2160    }
2161
2162    /// Total laid-out rows after the last [`Self::layout`] call (scrollbar).
2163    pub fn content_height(&self) -> usize {
2164        self.content_height
2165    }
2166
2167    /// Moves the cursor to a clicked cell of the description box.
2168    /// Returns `true` when the click landed on a real block (not empty
2169    /// padding below the content).
2170    pub fn click(&mut self, row: u16, col: usize) -> bool {
2171        let width = self.layout_width.max(1);
2172        let numbers = number_runs(&self.lines);
2173        let target = self.scroll.saturating_add(usize::from(row));
2174        let mut at = 0usize;
2175        let mut hit = false;
2176        for (i, line) in self.lines.iter().enumerate() {
2177            let h = line.height(width, numbers[i]);
2178            if target < at.saturating_add(h) {
2179                self.cursor = i;
2180                let row_in = target.saturating_sub(at);
2181                let indent = numbers[i]
2182                    .map(number_indent)
2183                    .unwrap_or_else(|| line.indent());
2184                let field = width.saturating_sub(indent).max(1);
2185                if let Some(input) = self.lines[i].input() {
2186                    input.set_cursor_from_wrap(field, row_in, col.saturating_sub(indent));
2187                }
2188                hit = true;
2189                break;
2190            }
2191            at = at.saturating_add(h);
2192        }
2193        self.close_menu();
2194        self.prefer_col = u16::MAX;
2195        self.clear_description_selection();
2196        hit
2197    }
2198}
2199
2200#[cfg(test)]
2201mod tests {
2202    use super::*;
2203
2204    fn editor(blocks: &[Block]) -> DescriptionEditor {
2205        DescriptionEditor::new(blocks)
2206    }
2207
2208    fn type_in(editor: &mut DescriptionEditor, text: &str) {
2209        for c in text.chars() {
2210            editor.insert(c);
2211        }
2212    }
2213
2214    #[test]
2215    fn types_prose_and_splits_lines() {
2216        let mut e = editor(&[]);
2217        type_in(&mut e, "first");
2218        e.newline();
2219        type_in(&mut e, "second");
2220        assert_eq!(e.value(), vec![Block::text("first"), Block::text("second")]);
2221    }
2222
2223    #[test]
2224    fn description_value_round_trips_blank_rows_around_content() {
2225        let mut e = editor(&[]);
2226        assert!(e.newline());
2227        type_in(&mut e, "first");
2228        assert!(e.newline());
2229        assert!(e.newline());
2230        type_in(&mut e, "second");
2231        assert!(e.newline());
2232
2233        let expected = vec![
2234            Block::text(""),
2235            Block::text("first"),
2236            Block::text(""),
2237            Block::text("second"),
2238            Block::text(""),
2239        ];
2240        assert_eq!(e.value(), expected);
2241        assert_eq!(editor(&e.value()).value(), expected);
2242        assert!(editor(&[]).value().is_empty());
2243    }
2244
2245    #[test]
2246    fn refuses_more_lines_than_the_cap() {
2247        let mut e = DescriptionEditor::from_blocks(
2248            &[],
2249            2,
2250            32,
2251            false,
2252            crate::image::default_images_root(),
2253            crate::image::AttachmentCatalog::default(),
2254        );
2255        type_in(&mut e, "a");
2256        assert!(e.newline());
2257        type_in(&mut e, "b");
2258        assert!(!e.newline(), "third line blocked");
2259        assert_eq!(e.value().len(), 2);
2260    }
2261
2262    #[test]
2263    fn plain_description_uses_shorter_line_cap() {
2264        let mut e = DescriptionEditor::plain("");
2265        type_in(&mut e, &"x".repeat(MAX_CATEGORY_DESC_LINE_LEN + 10));
2266        assert_eq!(
2267            e.value()[0],
2268            Block::text(&"x".repeat(MAX_CATEGORY_DESC_LINE_LEN))
2269        );
2270    }
2271
2272    #[test]
2273    fn slash_bullet_turns_the_line_into_a_point() {
2274        let mut e = editor(&[]);
2275        type_in(&mut e, "/bul");
2276        e.apply(Command::Bullet);
2277        type_in(&mut e, "a point");
2278        assert_eq!(e.value(), vec![Block::bullet("a point")]);
2279    }
2280
2281    #[test]
2282    fn slash_number_turns_the_line_into_a_list_item() {
2283        let mut e = editor(&[]);
2284        type_in(&mut e, "/num");
2285        assert_eq!(e.menu_selected(), Some(Command::Number));
2286        e.apply(Command::Number);
2287        type_in(&mut e, "first");
2288        e.newline();
2289        type_in(&mut e, "second");
2290        assert_eq!(
2291            e.value(),
2292            vec![Block::number("first"), Block::number("second")]
2293        );
2294        assert_eq!(e.text_for_copy(), "1. first\n2. second");
2295    }
2296
2297    #[test]
2298    fn typing_1_dot_space_makes_a_numbered_item() {
2299        let mut e = editor(&[]);
2300        type_in(&mut e, "1. ");
2301        type_in(&mut e, "alpha");
2302        assert_eq!(e.value(), vec![Block::number("alpha")]);
2303    }
2304
2305    #[test]
2306    fn slash_link_turns_the_line_into_a_url() {
2307        let mut e = editor(&[Block::text("https://example.com")]);
2308        e.end();
2309        type_in(&mut e, "/link");
2310        assert_eq!(e.menu_selected(), Some(Command::Link));
2311        e.apply(Command::Link);
2312        assert_eq!(e.value(), vec![Block::link("https://example.com")]);
2313    }
2314
2315    #[test]
2316    fn slash_link_unwraps_markdown() {
2317        let mut e = editor(&[Block::text("[docs](https://example.com/docs)")]);
2318        e.end();
2319        type_in(&mut e, "/link");
2320        e.apply(Command::Link);
2321        assert_eq!(e.value(), vec![Block::link("https://example.com/docs")]);
2322    }
2323
2324    #[test]
2325    fn slash_link_on_empty_line_is_ready_for_a_url() {
2326        let mut e = editor(&[]);
2327        type_in(&mut e, "/link");
2328        e.apply(Command::Link);
2329        type_in(&mut e, "https://x.ai");
2330        assert_eq!(e.value(), vec![Block::link("https://x.ai")]);
2331    }
2332
2333    #[test]
2334    fn slash_todo_turns_the_line_into_a_task() {
2335        let mut e = editor(&[]);
2336        type_in(&mut e, "/todo");
2337        let menu = e.menu.as_ref().expect("menu is open");
2338        assert_eq!(menu.query, "todo");
2339        assert_eq!(e.menu_selected(), Some(Command::Todo));
2340        e.apply(Command::Todo);
2341        type_in(&mut e, "buy milk");
2342        assert_eq!(e.value(), vec![Block::todo("buy milk", false)]);
2343        assert!(e.menu.is_none());
2344    }
2345
2346    #[test]
2347    fn enter_in_a_todo_starts_an_unchecked_todo() {
2348        let mut e = editor(&[Block::todo("one", true)]);
2349        e.end();
2350        e.newline();
2351        type_in(&mut e, "two");
2352        assert_eq!(
2353            e.value(),
2354            vec![Block::todo("one", true), Block::todo("two", false)]
2355        );
2356    }
2357
2358    #[test]
2359    fn enter_in_a_bullet_continues_the_list() {
2360        let mut e = editor(&[Block::bullet("first")]);
2361        e.end();
2362        e.newline();
2363        type_in(&mut e, "second");
2364
2365        assert_eq!(
2366            e.value(),
2367            vec![Block::bullet("first"), Block::bullet("second")]
2368        );
2369    }
2370
2371    #[test]
2372    fn enter_splits_a_numbered_item_into_the_continued_list() {
2373        let mut e = editor(&[Block::number("firstsecond")]);
2374        e.home();
2375        for _ in 0..5 {
2376            e.right();
2377        }
2378        e.newline();
2379
2380        assert_eq!(
2381            e.value(),
2382            vec![Block::number("first"), Block::number("second")]
2383        );
2384        assert_eq!(e.text_for_copy(), "1. first\n2. second");
2385    }
2386
2387    #[test]
2388    fn enter_on_an_empty_list_item_returns_to_plain_text() {
2389        let mut e = editor(&[]);
2390        type_in(&mut e, "- first");
2391        e.newline();
2392        e.newline();
2393        type_in(&mut e, "plain");
2394
2395        assert_eq!(
2396            e.value(),
2397            vec![Block::bullet("first"), Block::text("plain")]
2398        );
2399    }
2400
2401    #[test]
2402    fn backspace_at_the_start_unmakes_a_todo() {
2403        let mut e = editor(&[Block::todo("one", false)]);
2404        e.home();
2405        e.backspace();
2406        assert_eq!(e.value(), vec![Block::text("one")]);
2407    }
2408
2409    #[test]
2410    fn moving_the_cursor_closes_the_description_command_menu() {
2411        let mut editor = DescriptionEditor::new(&[]);
2412        for c in "/todo".chars() {
2413            editor.insert(c);
2414        }
2415        assert!(editor.menu.is_some());
2416
2417        editor.left();
2418
2419        assert!(
2420            editor.menu.is_none(),
2421            "the cached query must never outlive its caret range"
2422        );
2423        assert_eq!(editor.value(), vec![Block::text("/todo")]);
2424    }
2425
2426    #[test]
2427    fn link_hit_testing_excludes_blank_row_padding() {
2428        let mut editor = DescriptionEditor::new(&[Block::link("https://example.com")]);
2429        let _ = editor.layout(40, 4);
2430
2431        assert_eq!(
2432            editor.link_url_at_position(0, 3).as_deref(),
2433            Some("https://example.com")
2434        );
2435        assert_eq!(editor.link_url_at_position(0, 39), None);
2436    }
2437
2438    #[test]
2439    fn toggling_counts_towards_progress() {
2440        let mut e = editor(&[Block::todo("a", false), Block::todo("b", false)]);
2441        assert_eq!(e.progress(), (0, 2));
2442        e.toggle();
2443        assert_eq!(e.progress(), (1, 2));
2444    }
2445
2446    #[test]
2447    fn an_image_lands_between_the_lines_with_room_to_type() {
2448        let mut e = editor(&[]);
2449        type_in(&mut e, "before");
2450        e.newline();
2451        e.insert_block(Block::image("/tmp/a.png"));
2452        type_in(&mut e, "after");
2453        assert_eq!(
2454            e.value(),
2455            vec![
2456                Block::text("before"),
2457                Block::image("/tmp/a.png"),
2458                Block::text("after")
2459            ]
2460        );
2461        assert_eq!(e.images().len(), 1);
2462    }
2463
2464    #[test]
2465    fn a_path_in_the_description_becomes_a_picture() {
2466        // A real file, so the check that it is readable passes.
2467        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2468        let e = editor(&[Block::text(path), Block::text("below")]);
2469        assert!(matches!(e.value()[0], Block::Image { .. }));
2470        assert_eq!(e.value()[1], Block::text("below"));
2471    }
2472
2473    #[test]
2474    fn a_path_to_nothing_stays_text() {
2475        let e = editor(&[Block::text("/tmp/not-here-at-all.png")]);
2476        assert!(matches!(e.value()[0], Block::Text { .. }));
2477    }
2478
2479    #[test]
2480    fn a_pasted_path_is_a_picture_at_once() {
2481        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2482        let mut e = editor(&[]);
2483        e.insert_str(path);
2484        assert!(
2485            matches!(e.value()[0], Block::Image { .. }),
2486            "no need to move the cursor off it first"
2487        );
2488        type_in(&mut e, "and on we go");
2489        assert_eq!(e.value()[1], Block::text("and on we go"));
2490    }
2491
2492    #[test]
2493    fn a_complete_path_becomes_a_picture_even_under_the_cursor() {
2494        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2495        let mut e = editor(&[]);
2496        type_in(&mut e, path);
2497        e.layout(40, 20);
2498        assert!(
2499            matches!(e.value()[0], Block::Image { .. }),
2500            "complete existing path converts without leaving the line"
2501        );
2502    }
2503
2504    #[test]
2505    fn shift_option_left_selects_across_lines() {
2506        let mut e = editor(&[Block::text("one two"), Block::text("three four")]);
2507        e.down();
2508        e.end(); // on "four"
2509        e.select_word_left(); // select "four"
2510        assert_eq!(e.selected_text().as_deref(), Some("four"));
2511        e.select_word_left(); // "three "
2512        e.select_word_left(); // cross into previous line
2513        let sel = e.selected_text().expect("cross-line selection");
2514        assert!(
2515            sel.contains("two") && sel.contains("three"),
2516            "expected multi-line sel, got {sel:?}"
2517        );
2518    }
2519
2520    #[test]
2521    fn shift_selection_includes_pictures_with_text() {
2522        let path = "/tmp/shot.png";
2523        let mut e = editor(&[
2524            Block::text("above"),
2525            Block::image(path),
2526            Block::text("below here"),
2527        ]);
2528        // Caret at start of "below here".
2529        e.down();
2530        e.down();
2531        e.home();
2532        e.select_word_left(); // onto the picture
2533        assert!(e.line_in_selection(1), "picture covered by selection");
2534        assert!(
2535            matches!(e.selected_payload(), Some(CopyPayload::All(_))),
2536            "mixed selection is rich copy"
2537        );
2538        e.select_word_left(); // into "above"
2539        let sel = e.selected_text().expect("text+image selection");
2540        assert!(
2541            sel.contains("above") && sel.contains("[image:") && !sel.contains("below"),
2542            "got {sel:?}"
2543        );
2544        // Layout marks the picture selected for its frame.
2545        let (placed, _) = e.layout(40, 40);
2546        let img = placed
2547            .iter()
2548            .find(|p| matches!(p.block, Painted::Image(_)))
2549            .expect("image placed");
2550        assert!(img.selected, "outer frame while selection covers image");
2551    }
2552
2553    #[test]
2554    fn a_pasted_path_broken_by_newlines_still_becomes_a_picture() {
2555        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2556        // Simulate a clipboard soft-break in the middle of the path.
2557        let mid = path.len() / 2;
2558        let broken = format!("{}\n{}", &path[..mid], &path[mid..]);
2559        let mut e = editor(&[]);
2560        e.insert_str(&broken);
2561        assert!(
2562            matches!(e.value()[0], Block::Image { .. }),
2563            "flattened paste: {broken:?} → {:?}",
2564            e.value()
2565        );
2566    }
2567
2568    #[test]
2569    fn two_text_lines_that_form_a_path_are_merged() {
2570        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2571        let mid = path.len() / 2;
2572        let e = editor(&[Block::text(&path[..mid]), Block::text(&path[mid..])]);
2573        assert!(
2574            matches!(e.value()[0], Block::Image { .. }),
2575            "split lines rejoin: {:?}",
2576            e.value()
2577        );
2578    }
2579
2580    #[test]
2581    fn dash_and_a_space_make_a_bullet() {
2582        let mut e = editor(&[]);
2583        type_in(&mut e, "- buy milk");
2584        assert_eq!(e.value(), vec![Block::bullet("buy milk")]);
2585    }
2586
2587    #[test]
2588    fn a_dash_mid_line_is_just_a_dash() {
2589        let mut e = editor(&[]);
2590        type_in(&mut e, "a - b");
2591        assert_eq!(e.value(), vec![Block::text("a - b")]);
2592    }
2593
2594    #[test]
2595    fn backspace_at_the_start_unmakes_a_bullet() {
2596        let mut e = editor(&[Block::bullet("one")]);
2597        e.home();
2598        e.backspace();
2599        assert_eq!(e.value(), vec![Block::text("one")]);
2600    }
2601
2602    #[test]
2603    fn plain_text_round_trips_its_bullets() {
2604        let e = DescriptionEditor::plain("intro\n- first\n- second");
2605        assert_eq!(e.value().len(), 3);
2606        assert_eq!(e.plain_value(), "intro\n- first\n- second");
2607    }
2608
2609    #[test]
2610    fn plain_description_round_trips_blank_rows() {
2611        let description = "\nfirst\n\nsecond\n";
2612        let e = DescriptionEditor::plain(description);
2613
2614        assert_eq!(e.plain_value(), description);
2615        assert_eq!(
2616            DescriptionEditor::plain(&e.plain_value()).plain_value(),
2617            description
2618        );
2619    }
2620
2621    #[test]
2622    fn plain_mode_rejects_todo_slash_command() {
2623        let mut e = DescriptionEditor::plain("");
2624        type_in(&mut e, "/todo");
2625        assert!(e.menu.is_none(), "no to-do command in plain mode");
2626        assert_eq!(e.value(), vec![Block::text("/todo")]);
2627    }
2628
2629    #[test]
2630    fn plain_mode_slash_makes_a_bullet() {
2631        let mut e = DescriptionEditor::plain("");
2632        type_in(&mut e, "/bul");
2633        assert_eq!(e.menu_selected(), Some(Command::Bullet));
2634        e.apply(Command::Bullet);
2635        type_in(&mut e, "a point");
2636        assert_eq!(e.plain_value(), "- a point");
2637    }
2638
2639    #[test]
2640    fn plain_mode_supports_numbered_lists_and_links_but_not_images() {
2641        let mut numbered = DescriptionEditor::plain("");
2642        type_in(&mut numbered, "/num");
2643        assert_eq!(numbered.menu_selected(), Some(Command::Number));
2644        numbered.apply(Command::Number);
2645        type_in(&mut numbered, "first");
2646        assert_eq!(numbered.plain_value(), "1. first");
2647        assert!(matches!(
2648            DescriptionEditor::plain(&numbered.plain_value()).value().as_slice(),
2649            [Block::Number { text }] if text == "first"
2650        ));
2651
2652        let mut link = DescriptionEditor::plain("https://example.com");
2653        link.end();
2654        type_in(&mut link, "/link");
2655        assert_eq!(link.menu_selected(), Some(Command::Link));
2656        link.apply(Command::Link);
2657        assert!(matches!(link.value().as_slice(), [Block::Link { .. }]));
2658        assert!(matches!(
2659            DescriptionEditor::plain(&link.plain_value()).value().as_slice(),
2660            [Block::Link { url }] if url == "https://example.com"
2661        ));
2662
2663        let mut bare_link = DescriptionEditor::plain("example.com");
2664        bare_link.end();
2665        type_in(&mut bare_link, "/link");
2666        bare_link.apply(Command::Link);
2667        assert_eq!(bare_link.plain_value(), "[link](example.com)");
2668        assert!(matches!(
2669            DescriptionEditor::plain(&bare_link.plain_value())
2670                .value()
2671                .as_slice(),
2672            [Block::Link { url }] if url == "example.com"
2673        ));
2674
2675        assert!(
2676            !DescriptionEditor::plain("")
2677                .allowed_commands()
2678                .contains(&Command::CopyImage)
2679        );
2680        assert!(
2681            !DescriptionEditor::plain("")
2682                .allowed_commands()
2683                .contains(&Command::CopyAll)
2684        );
2685    }
2686
2687    #[test]
2688    fn plain_description_does_not_adopt_image_files() {
2689        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2690        let mut editor = DescriptionEditor::plain(path);
2691
2692        assert_eq!(editor.value(), vec![Block::text(path)]);
2693        assert!(!editor.insert_block(Block::image(path)));
2694        assert_eq!(editor.value(), vec![Block::text(path)]);
2695    }
2696
2697    #[test]
2698    fn plain_description_slash_paste_requests_clipboard_content() {
2699        let mut e = DescriptionEditor::plain("before");
2700        e.end();
2701        e.newline();
2702        type_in(&mut e, "/paste");
2703
2704        assert_eq!(e.menu_selected(), Some(Command::Paste));
2705        assert!(matches!(
2706            e.apply(Command::Paste),
2707            Some(CommandRequest::Paste)
2708        ));
2709        e.insert_str("clipboard\n- item");
2710        assert_eq!(e.plain_value(), "before\nclipboard\n- item");
2711    }
2712
2713    #[test]
2714    fn copy_exports_text_and_skips_images() {
2715        let e = editor(&[
2716            Block::text("hello"),
2717            Block::todo("one", true),
2718            Block::image("/tmp/a.png"),
2719            Block::bullet("point"),
2720        ]);
2721        assert_eq!(e.text_for_copy(), "hello\n[✓] one\n- point");
2722    }
2723
2724    #[test]
2725    fn copy_all_keeps_text_and_images_in_order() {
2726        let e = editor(&[
2727            Block::text("hello"),
2728            Block::image("/tmp/a.png"),
2729            Block::bullet("point"),
2730        ]);
2731        let lines = e.lines_for_copy_all();
2732        assert_eq!(lines.len(), 3);
2733        match &lines[0] {
2734            CopyLine::Text(t) => assert_eq!(t, "hello"),
2735            _ => panic!("text first"),
2736        }
2737        match &lines[1] {
2738            CopyLine::Image(p) => assert!(p.ends_with("a.png")),
2739            _ => panic!("image second"),
2740        }
2741        match &lines[2] {
2742            CopyLine::Text(t) => assert_eq!(t, "- point"),
2743            _ => panic!("bullet third"),
2744        }
2745    }
2746
2747    #[test]
2748    fn slash_copy_strips_the_query_and_returns_text() {
2749        let mut e = editor(&[Block::text("keep me")]);
2750        e.end();
2751        e.newline();
2752        type_in(&mut e, "/copy");
2753        assert_eq!(e.menu_selected(), Some(Command::Copy));
2754        match e.apply(Command::Copy).expect("copy yields request") {
2755            CommandRequest::Copy(CopyPayload::Text(text)) => assert_eq!(text, "keep me"),
2756            CommandRequest::Copy(CopyPayload::Image(_) | CopyPayload::All(_))
2757            | CommandRequest::Paste => panic!("expected text copy"),
2758        }
2759        assert!(e.menu.is_none());
2760        // The `/copy` line is gone (it was empty after stripping).
2761        assert_eq!(e.value(), vec![Block::text("keep me")]);
2762    }
2763
2764    #[test]
2765    fn description_slash_paste_requests_clipboard_content() {
2766        let mut e = editor(&[Block::text("before")]);
2767        e.end();
2768        e.newline();
2769        type_in(&mut e, "/pas");
2770
2771        assert_eq!(e.menu_selected(), Some(Command::Paste));
2772        assert!(matches!(
2773            e.apply(Command::Paste),
2774            Some(CommandRequest::Paste)
2775        ));
2776        e.insert_str("clipboard\n- item");
2777        assert_eq!(
2778            e.value(),
2779            vec![
2780                Block::text("before"),
2781                Block::text("clipboard"),
2782                Block::text("- item")
2783            ]
2784        );
2785    }
2786
2787    #[test]
2788    fn image_for_copy_picks_nearest_above_the_cursor() {
2789        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2790        let mut e = editor(&[
2791            Block::text("above"),
2792            Block::image(path),
2793            Block::text("below"),
2794        ]);
2795        // Cursor on the last line (under the image).
2796        e.down();
2797        e.down();
2798        let got = e.image_for_copy().expect("finds the image above");
2799        assert!(got.ends_with("screenshot.png"));
2800    }
2801
2802    #[test]
2803    fn slash_image_returns_the_picture_path() {
2804        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/screenshot.png");
2805        let mut e = editor(&[Block::image(path), Block::text("")]);
2806        e.down();
2807        type_in(&mut e, "/img");
2808        assert_eq!(e.menu_selected(), Some(Command::CopyImage));
2809        match e.apply(Command::CopyImage).expect("image request") {
2810            CommandRequest::Copy(CopyPayload::Image(p)) => {
2811                assert!(p.ends_with("screenshot.png"))
2812            }
2813            CommandRequest::Copy(CopyPayload::Text(_) | CopyPayload::All(_))
2814            | CommandRequest::Paste => panic!("expected image copy"),
2815        }
2816    }
2817
2818    #[test]
2819    fn a_picture_is_deleted_by_backspace() {
2820        let mut e = editor(&[Block::text("a"), Block::image("/tmp/a.png")]);
2821        e.down();
2822        e.backspace();
2823        assert_eq!(e.value(), vec![Block::text("a")]);
2824    }
2825
2826    #[test]
2827    fn the_menu_filters_by_abbreviation() {
2828        let mut e = editor(&[]);
2829        type_in(&mut e, "/che");
2830        assert_eq!(e.menu_selected(), Some(Command::Todo));
2831    }
2832
2833    #[test]
2834    fn the_menu_closes_when_nothing_matches() {
2835        let mut e = editor(&[]);
2836        type_in(&mut e, "/zz");
2837        assert!(e.menu.is_none());
2838        assert_eq!(e.value(), vec![Block::text("/zz")], "the text is kept");
2839    }
2840
2841    #[test]
2842    fn the_menu_closes_when_the_slash_is_removed() {
2843        let mut e = editor(&[]);
2844        type_in(&mut e, "/to");
2845        assert!(e.menu.is_some());
2846        e.backspace();
2847        e.backspace();
2848        assert!(e.menu.is_some());
2849        e.backspace();
2850        assert!(e.menu.is_none(), "removing the slash closes the menu");
2851    }
2852
2853    #[test]
2854    fn right_on_sole_image_inserts_text_line() {
2855        let blocks = [Block::image("/tmp/x.png")];
2856        let mut e = editor(&blocks);
2857        assert!(matches!(e.lines[0], Line::Image { .. }));
2858        assert_eq!(e.cursor, 0);
2859        e.right();
2860        assert_eq!(e.lines.len(), 2, "should insert a text line");
2861        assert_eq!(e.cursor, 1);
2862        assert!(matches!(e.lines[1], Line::Text(_)));
2863        assert!(e.input().is_some());
2864    }
2865
2866    #[test]
2867    fn left_on_first_image_inserts_text_line_above() {
2868        let blocks = [Block::image("/tmp/x.png")];
2869        let mut e = editor(&blocks);
2870        e.left();
2871        assert_eq!(e.lines.len(), 2);
2872        assert_eq!(e.cursor, 0);
2873        assert!(matches!(e.lines[0], Line::Text(_)));
2874        assert!(matches!(e.lines[1], Line::Image { .. }));
2875    }
2876
2877    #[test]
2878    fn visible_band_clips_top_like_bottom() {
2879        // Block at rows 5..15 inside viewport scroll=8 height=10 → show 8..15
2880        // at y=0 with 7 rows, 3 skipped off the top.
2881        assert_eq!(visible_band(5, 10, 8, 10), Some((0, 7, 3)));
2882        // Fully above / below.
2883        assert_eq!(visible_band(0, 5, 8, 10), None);
2884        assert_eq!(visible_band(20, 5, 8, 10), None);
2885        // Bottom clip only (scroll=0): y=start, shrink from bottom.
2886        assert_eq!(visible_band(8, 10, 0, 12), Some((8, 4, 0)));
2887    }
2888
2889    #[test]
2890    fn consecutive_images_shrink_when_scrolled_off_the_top() {
2891        let path = "/tmp/a.png";
2892        let mut e = editor(&[Block::image(path), Block::image(path), Block::text("tail")]);
2893        // Full view first so click can land on the trailing text (row 20).
2894        e.layout(40, 30);
2895        assert!(e.click(20, 0));
2896        assert_eq!(e.cursor_line(), 2);
2897
2898        let (placed, _) = e.layout(40, 12);
2899        // cursor at visual 20 → scroll = 20 + 1 - 12 = 9
2900        assert_eq!(e.scroll(), 9);
2901        let imgs: Vec<_> = placed
2902            .iter()
2903            .filter(|p| matches!(p.block, Painted::Image(_)))
2904            .collect();
2905        assert_eq!(imgs.len(), 2);
2906        // Image 0 occupied 0..10; with scroll 9 only 1 row remains at y=0.
2907        assert_eq!((imgs[0].y, imgs[0].rows), (0, 1));
2908        // Image 1 at 10..20 → y=1, full 10 rows (fits in remaining 11).
2909        assert_eq!((imgs[1].y, imgs[1].rows), (1, 10));
2910        // No overlap: first ends at y+rows = 1, second starts at 1.
2911        assert_eq!(imgs[0].y + imgs[0].rows, imgs[1].y);
2912    }
2913
2914    #[test]
2915    fn narrow_maximum_description_keeps_the_last_row_addressable() {
2916        let line = "x".repeat(MAX_NOTES_LINE_LEN);
2917        let blocks = vec![Block::text(&line); MAX_DESCRIPTION_LINES];
2918        let mut e = editor(&blocks);
2919        e.cursor = e.lines.len() - 1;
2920        e.input().unwrap().end();
2921
2922        let (_, cursor) = e.layout(1, 10);
2923
2924        let expected_height = MAX_DESCRIPTION_LINES * MAX_NOTES_LINE_LEN;
2925        assert_eq!(e.content_height() as usize, expected_height);
2926        assert_eq!(e.scroll() as usize, expected_height - 10);
2927        assert_eq!(cursor, Some((9, 1)));
2928        assert!(e.click(0, 0));
2929        assert_eq!(e.cursor_line(), MAX_DESCRIPTION_LINES - 1);
2930    }
2931
2932    #[test]
2933    fn line_join_at_the_length_limit_never_discards_the_next_line() {
2934        let full = "a".repeat(MAX_NOTES_LINE_LEN);
2935
2936        let mut backward = editor(&[Block::text(&full), Block::text("tail")]);
2937        backward.cursor = 1;
2938        backward.input().unwrap().home();
2939        backward.backspace();
2940        assert_eq!(
2941            backward.value(),
2942            vec![Block::text(&full), Block::text("tail")]
2943        );
2944
2945        let mut forward = editor(&[Block::text(&full), Block::text("tail")]);
2946        forward.input().unwrap().end();
2947        forward.delete();
2948        assert_eq!(
2949            forward.value(),
2950            vec![Block::text(&full), Block::text("tail")]
2951        );
2952    }
2953
2954    #[test]
2955    fn oversized_cross_line_selection_replacement_is_rejected_without_data_loss() {
2956        let full = "a".repeat(MAX_NOTES_LINE_LEN);
2957        let mut editor = editor(&[Block::text(&full), Block::text("tail")]);
2958        editor.sel_anchor = Some((0, MAX_NOTES_LINE_LEN));
2959        editor.cursor = 1;
2960        editor.input().unwrap().home();
2961
2962        editor.insert('x');
2963
2964        assert_eq!(
2965            editor.value(),
2966            vec![Block::text(&full), Block::text("tail")]
2967        );
2968    }
2969
2970    #[test]
2971    fn oversized_image_path_detection_never_discards_a_source_line() {
2972        let root = std::env::temp_dir().join(format!(
2973            "mach-long-image-path-test-{}",
2974            uuid::Uuid::new_v4()
2975        ));
2976        let first_dir = "a".repeat(240);
2977        let second_dir = "b".repeat(240);
2978        let file = format!("{}.png", "c".repeat(40));
2979        let relative = format!("{first_dir}/{second_dir}/{file}");
2980        assert!(relative.graphemes(true).count() > MAX_NOTES_LINE_LEN);
2981        std::fs::create_dir_all(root.join(&first_dir).join(&second_dir)).unwrap();
2982        std::fs::write(root.join(&relative), []).unwrap();
2983
2984        let split = relative.len() / 2;
2985        let (left, right) = relative.split_at(split);
2986        let expected = vec![Block::text(left), Block::text(right)];
2987        let mut loaded = editor(&expected);
2988        loaded.set_image_root(root.clone());
2989        assert_eq!(loaded.value(), expected);
2990
2991        let mut pasted = editor(&[]);
2992        pasted.set_image_root(root.clone());
2993        pasted.insert_str(&format!("{left}\n{right}"));
2994        assert_eq!(pasted.value(), expected);
2995
2996        std::fs::remove_dir_all(root).unwrap();
2997    }
2998}