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