Skip to main content

piki_gui/
ui_adapters.rs

1use crate::content::{ContentLoader, ContentProvider};
2use crate::fltk_draw_context::FltkDrawContext;
3use crate::fltk_structured_rich_display::FltkStructuredRichDisplay;
4use crate::live_share::HighlightTarget;
5use crate::markdown_converter::document_to_markdown;
6use crate::note_ui::NoteUI;
7use fltk::{app, enums::Color, prelude::*, window};
8use rutle::editor::Editor;
9use rutle::renderer::SearchMatch;
10use rutle::structured_document::BlockType;
11use rutle::tree_path::{DocumentPosition, PathSegment, TreePath};
12use rutle::tree_walk::LeafInfo;
13use std::any::Any;
14use std::collections::{HashMap, HashSet};
15use tdoc::Document;
16
17/// Vertical breathing room, in pixels, kept above a heading when scrolling to a
18/// section so it does not sit flush against the top edge of the viewport.
19const ANCHOR_TOP_MARGIN: i32 = 12;
20
21/// NoteUI adapter for rutle's `Renderer` + FLTK Group wrapper
22pub struct StructuredRichUI(pub FltkStructuredRichDisplay);
23
24impl StructuredRichUI {
25    pub fn new(x: i32, y: i32, w: i32, h: i32, edit_mode: bool) -> Self {
26        Self(FltkStructuredRichDisplay::new(x, y, w, h, edit_mode))
27    }
28
29    pub fn has_selection(&self) -> bool {
30        self.0.display.borrow().editor().selection().is_some()
31    }
32
33    /// The web-view highlights mirroring the editor's *selection*: one
34    /// [`HighlightTarget`] per top-level block (or list/checklist item) the
35    /// selection touches, in document order. Empty when there is no selection,
36    /// so a bare caret shows no highlight and double-clicking a word brings one
37    /// back.
38    pub fn highlight_targets(&self) -> Vec<HighlightTarget> {
39        let disp = self.0.display.borrow();
40        let editor = disp.editor();
41        let Some((a, b)) = editor.selection() else {
42            return Vec::new();
43        };
44        // Order the endpoints so a reversed drag is handled the same way.
45        let (start, end) = if a <= b { (a, b) } else { (b, a) };
46        selection_targets(editor.document(), &start, &end)
47    }
48
49    /// Cut the current selection to the system clipboard (HTML + Markdown).
50    /// Returns `true` if there was a selection that was cut.
51    pub fn cut_selection(&mut self) -> bool {
52        let doc = self.0.display.borrow().editor().get_selection_document();
53        let Some(doc) = doc else {
54            return false;
55        };
56        crate::clipboard::copy_structured_to_system(&doc);
57        {
58            let mut disp = self.0.display.borrow_mut();
59            let _ = disp.editor_mut().delete_selection();
60        }
61        self.0.notify_change();
62        true
63    }
64
65    /// Copy the current selection to the system clipboard (HTML + Markdown).
66    /// Returns `true` if there was a selection that was copied.
67    pub fn copy_selection(&self) -> bool {
68        let doc = self.0.display.borrow().editor().get_selection_document();
69        match doc {
70            Some(doc) => {
71                crate::clipboard::copy_structured_to_system(&doc);
72                true
73            }
74            None => false,
75        }
76    }
77
78    pub fn paste_from_clipboard(&mut self) {
79        let group = self.0.group.clone();
80        app::paste(&group);
81    }
82
83    pub fn undo(&mut self) -> bool {
84        let changed = {
85            let mut disp = self.0.display.borrow_mut();
86            disp.editor_mut().undo()
87        };
88        if changed {
89            self.0.notify_change();
90            self.0.emit_paragraph_state();
91        }
92        changed
93    }
94
95    pub fn redo(&mut self) -> bool {
96        let changed = {
97            let mut disp = self.0.display.borrow_mut();
98            disp.editor_mut().redo()
99        };
100        if changed {
101            self.0.notify_change();
102            self.0.emit_paragraph_state();
103        }
104        changed
105    }
106
107    pub fn clear_formatting(&mut self) -> bool {
108        self.apply_edit(|editor| editor.clear_formatting())
109    }
110
111    pub fn set_block_type(&mut self, block_type: BlockType) -> bool {
112        self.apply_edit(move |editor| editor.set_block_type(block_type))
113    }
114
115    pub fn toggle_quote(&mut self) -> bool {
116        self.apply_edit(|editor| editor.toggle_quote())
117    }
118
119    pub fn toggle_code_block(&mut self) -> bool {
120        self.apply_edit(|editor| editor.toggle_code_block())
121    }
122
123    pub fn toggle_list(&mut self) -> bool {
124        self.apply_edit(|editor| editor.toggle_list())
125    }
126
127    pub fn toggle_checklist(&mut self) -> bool {
128        self.apply_edit(|editor| editor.toggle_checklist())
129    }
130
131    pub fn toggle_ordered_list(&mut self) -> bool {
132        self.apply_edit(|editor| editor.toggle_ordered_list())
133    }
134
135    pub fn toggle_bold(&mut self) -> bool {
136        self.apply_edit(|editor| editor.toggle_bold())
137    }
138
139    pub fn toggle_italic(&mut self) -> bool {
140        self.apply_edit(|editor| editor.toggle_italic())
141    }
142
143    pub fn toggle_code(&mut self) -> bool {
144        self.apply_edit(|editor| editor.toggle_code())
145    }
146
147    pub fn toggle_strikethrough(&mut self) -> bool {
148        self.apply_edit(|editor| editor.toggle_strikethrough())
149    }
150
151    pub fn toggle_underline(&mut self) -> bool {
152        self.apply_edit(|editor| editor.toggle_underline())
153    }
154
155    pub fn toggle_highlight(&mut self) -> bool {
156        self.apply_edit(|editor| editor.toggle_highlight())
157    }
158
159    pub fn current_block_type(&self) -> Option<BlockType> {
160        let disp = self.0.display.borrow();
161        Some(disp.editor().current_block_type())
162    }
163
164    /// The anchor slug of the heading the caret is currently inside, or `None`
165    /// when the caret is not in a heading. Duplicate headings are disambiguated
166    /// exactly as [`Self::scroll_to_anchor`] resolves them, so the slug links
167    /// back to *this* heading and not an earlier namesake.
168    pub fn current_heading_anchor(&self) -> Option<String> {
169        let disp = self.0.display.borrow();
170        let cursor = disp.editor().cursor();
171        // A heading is always a top-level leaf, so its caret path starts with a
172        // `Paragraph(i)` segment that indexes `Document.paragraphs`.
173        let para = match cursor.path.segments().first() {
174            Some(PathSegment::Paragraph(i)) => *i,
175            _ => return None,
176        };
177        heading_anchor_map(disp.editor().document())
178            .into_iter()
179            .find(|(idx, _)| *idx == para)
180            .map(|(_, anchor)| anchor)
181    }
182
183    /// Scroll so the heading whose anchor slug equals `anchor` sits near the top
184    /// of the viewport. Returns `false` if no heading matches.
185    pub fn scroll_to_anchor(&mut self, anchor: &str) -> bool {
186        let target = {
187            let disp = self.0.display.borrow();
188            heading_anchor_map(disp.editor().document())
189                .into_iter()
190                .find(|(_, a)| a == anchor)
191                .map(|(idx, _)| idx)
192        };
193        match target {
194            Some(idx) => self.scroll_to_block(idx),
195            None => false,
196        }
197    }
198
199    /// Scroll so top-level block `block_index` sits near the top of the viewport.
200    ///
201    /// The renderer exposes no public block→pixel mapping, so this moves the
202    /// caret into the target block and reuses the caret→pixel bridge
203    /// (`cursor_content_y`) after a layout pass. Returns `false` if the index is
204    /// out of range.
205    pub fn scroll_to_block(&mut self, block_index: usize) -> bool {
206        let mut ctx = FltkDrawContext::new(true, true);
207        let mut disp = self.0.display.borrow_mut();
208        if block_index >= disp.editor().document().paragraphs.len() {
209            return false;
210        }
211        disp.editor_mut()
212            .set_cursor(DocumentPosition::new(block_index, 0));
213        // Lay out with real font metrics so `cursor_content_y` is populated.
214        disp.ensure_cursor_visible(&mut ctx);
215        if let Some((content_y, _line_h)) = disp.cursor_content_y(&mut ctx) {
216            let max_scroll = (disp.content_height() - disp.h()).max(0);
217            let target = (content_y - ANCHOR_TOP_MARGIN).max(0).min(max_scroll);
218            disp.set_scroll(target);
219        }
220        drop(disp);
221        self.0.group.redraw();
222        true
223    }
224
225    /// Set horizontal padding (for write room mode)
226    pub fn set_horizontal_padding(&mut self, padding: i32) {
227        self.0.display.borrow_mut().set_horizontal_padding(padding);
228        self.0.group.redraw();
229    }
230
231    /// Get current horizontal padding
232    pub fn horizontal_padding(&self) -> i32 {
233        self.0.display.borrow().horizontal_padding()
234    }
235
236    /// Whether reveal-codes mode is active (rutle's inline-style tags, e.g.
237    /// `[Bold>`…`<Bold]`, shown inline).
238    pub fn reveal_codes(&self) -> bool {
239        self.0.display.borrow().reveal_codes()
240    }
241
242    /// Toggle reveal-codes mode, returning the new state. This is purely a view
243    /// change — not a document edit — so it deliberately skips the change/undo
244    /// machinery; the tags appearing inline are their own feedback.
245    pub fn toggle_reveal_codes(&mut self) -> bool {
246        let new_state = !self.0.display.borrow().reveal_codes();
247        self.0.display.borrow_mut().set_reveal_codes(new_state);
248        self.0.group.redraw();
249        new_state
250    }
251
252    /// Resize the editor widget
253    pub fn resize(&mut self, x: i32, y: i32, w: i32, h: i32) {
254        self.0.group.resize(x, y, w, h);
255        self.0.group.redraw();
256    }
257
258    /// Get current height
259    pub fn height(&self) -> i32 {
260        self.0.group.height()
261    }
262
263    /// Get current width
264    pub fn width(&self) -> i32 {
265        self.0.group.width()
266    }
267
268    /// Get current x position
269    pub fn x(&self) -> i32 {
270        self.0.group.x()
271    }
272
273    /// Get current y position
274    pub fn y(&self) -> i32 {
275        self.0.group.y()
276    }
277
278    // ==================== Search Methods ====================
279
280    /// Perform a case-insensitive search for the given term
281    pub fn search(&mut self, term: &str) -> usize {
282        self.0.display.borrow_mut().search(term)
283    }
284
285    /// Clear the search state
286    pub fn clear_search(&mut self) {
287        self.0.display.borrow_mut().clear_search();
288    }
289
290    /// Get all search matches
291    pub fn search_matches(&self) -> Vec<SearchMatch> {
292        self.0.display.borrow().search_matches().to_vec()
293    }
294
295    /// Get the current match index
296    pub fn search_current_index(&self) -> Option<usize> {
297        self.0.display.borrow().search_current_index()
298    }
299
300    /// Move to the next match
301    pub fn next_match(&mut self) -> bool {
302        self.0.display.borrow_mut().next_match()
303    }
304
305    /// Move to the previous match
306    pub fn prev_match(&mut self) -> bool {
307        self.0.display.borrow_mut().prev_match()
308    }
309
310    /// Scroll to make the current match visible
311    pub fn scroll_to_current_match(&mut self) {
312        let mut ctx = FltkDrawContext::new(true, true);
313        self.0
314            .display
315            .borrow_mut()
316            .scroll_to_current_match(&mut ctx);
317        self.0.group.redraw();
318    }
319
320    /// Focus the editor widget
321    pub fn take_focus(&mut self) {
322        let _ = self.0.group.take_focus();
323    }
324
325    fn apply_edit<F>(&mut self, edit: F) -> bool
326    where
327        F: FnOnce(&mut Editor) -> rutle::editor::EditResult,
328    {
329        let result = {
330            let mut disp = self.0.display.borrow_mut();
331            let editor = disp.editor_mut();
332            edit(editor)
333        };
334        if result.is_ok() {
335            self.0.notify_change();
336            self.0.emit_paragraph_state();
337            true
338        } else {
339            false
340        }
341    }
342}
343
344/// The web-view [`HighlightTarget`]s for the selection `[start, end]`: one per
345/// top-level block or list/checklist item the selection touches, in document
346/// order, deduplicated.
347///
348/// Every leaf between the endpoints (inclusive) maps to its highlight element via
349/// [`leaf_element`]; leaves of the same element (a multi-paragraph list item, a
350/// multi-child quote) collapse to one entry. A selection ending exactly at the
351/// start of a leaf does not include that leaf (nothing of it is selected).
352fn selection_targets(
353    doc: &Document,
354    start: &DocumentPosition,
355    end: &DocumentPosition,
356) -> Vec<HighlightTarget> {
357    let leaves = rutle::tree_walk::enumerate_leaves(doc);
358    let li_of = build_li_index(&leaves);
359
360    let mut out = Vec::new();
361    let mut seen = HashSet::new();
362    for leaf in &leaves {
363        if leaf.path < start.path || leaf.path > end.path {
364            continue;
365        }
366        // A selection ending at offset 0 of a leaf stops before it, so that leaf
367        // carries no selected content (unless it is also the start leaf).
368        if leaf.path == end.path && end.offset == 0 && start.path != end.path {
369            continue;
370        }
371        let target = leaf_element(&li_of, &leaf.path);
372        if seen.insert(target.clone()) {
373            out.push(target);
374        }
375    }
376    out
377}
378
379/// Map each list/checklist marker leaf (the leaf that begins a `<li>`) to that
380/// `<li>`'s 0-based document-order index within its top-level block. This is the
381/// same ordering tdoc emits `<li>` tags in, so it lines up with the server's
382/// k-th-`<li>` marking.
383fn build_li_index(leaves: &[LeafInfo]) -> HashMap<TreePath, usize> {
384    let mut map = HashMap::new();
385    let mut cur_block = None;
386    let mut counter = 0;
387    for leaf in leaves {
388        let block = match leaf.path.segments().first() {
389            Some(PathSegment::Paragraph(i)) => *i,
390            _ => continue,
391        };
392        if cur_block != Some(block) {
393            cur_block = Some(block);
394            counter = 0;
395        }
396        if leaf.marker.is_some() {
397            map.insert(leaf.path.clone(), counter);
398            counter += 1;
399        }
400    }
401    map
402}
403
404/// The highlight element a leaf at `path` belongs to: its top-level block, plus
405/// the enclosing `<li>` index when the leaf is inside a list/checklist (a
406/// continuation paragraph maps back to its item's marker leaf).
407fn leaf_element(li_of: &HashMap<TreePath, usize>, path: &TreePath) -> HighlightTarget {
408    let block = match path.segments().first() {
409        Some(PathSegment::Paragraph(i)) => *i,
410        _ => 0,
411    };
412    let li = enclosing_li_path(path).and_then(|p| li_of.get(&p).copied());
413    HighlightTarget { block, li }
414}
415
416/// The path of the marker leaf for the `<li>` enclosing `path`, or `None` when
417/// `path` is not inside a list/checklist. Found by truncating at the last
418/// list/checklist segment; a list entry's continuation paragraph is folded back
419/// to that entry's first paragraph (`para: 0`), which is the marker leaf.
420fn enclosing_li_path(path: &TreePath) -> Option<TreePath> {
421    let segs = path.segments();
422    let last_item = segs.iter().rposition(|s| {
423        matches!(
424            s,
425            PathSegment::ListEntry { .. } | PathSegment::ChecklistItem(_)
426        )
427    })?;
428    let mut li_segs = segs[..=last_item].to_vec();
429    if let Some(PathSegment::ListEntry { entry, .. }) = li_segs.last() {
430        let entry = *entry;
431        *li_segs.last_mut().unwrap() = PathSegment::ListEntry { entry, para: 0 };
432    }
433    Some(TreePath(li_segs))
434}
435
436/// Map each top-level heading to its unique anchor slug, in document order.
437///
438/// Uses rutle's tree helpers so link resolution stays in lockstep with how the
439/// editor classifies blocks, and delegates slug/duplicate handling to
440/// [`crate::section_link`] so generation and resolution share one algorithm.
441fn heading_anchor_map(doc: &tdoc::Document) -> Vec<(usize, String)> {
442    let mut indices = Vec::new();
443    let mut texts = Vec::new();
444    for i in 0..doc.paragraphs.len() {
445        let path = TreePath::root(i);
446        if matches!(
447            rutle::tree_walk::effective_block_type(doc, &path),
448            BlockType::Heading { .. }
449        ) {
450            texts.push(rutle::tree_walk::leaf_plain_text(doc, &path));
451            indices.push(i);
452        }
453    }
454    indices
455        .into_iter()
456        .zip(crate::section_link::heading_anchors(&texts))
457        .collect()
458}
459
460impl ContentProvider for StructuredRichUI {
461    fn get_content(&self) -> String {
462        let disp = self.0.display.borrow();
463        document_to_markdown(disp.editor().document())
464    }
465}
466
467impl ContentLoader for StructuredRichUI {
468    fn set_content_from_markdown(&mut self, markdown: &str) {
469        let mut disp = self.0.display.borrow_mut();
470        // Loading a different note starts a fresh undo history (set_document resets it).
471        let mut doc = crate::markdown_converter::markdown_to_document(markdown);
472        // A brand-new note loads as an empty document (no paragraphs). rutle's
473        // block-level commands (headings, lists, quotes) act on existing leaves,
474        // so with nothing to convert the first Cmd-Alt-1 would silently no-op
475        // until a character was typed and deleted. Seed one empty paragraph so
476        // formatting works immediately; document_to_markdown maps a lone empty
477        // paragraph back to the empty string, so an untouched new note stays
478        // empty on disk.
479        if doc.paragraphs.is_empty() {
480            doc.add_paragraph(tdoc::Paragraph::new_text());
481        }
482        disp.editor_mut().set_document(doc);
483        disp.set_scroll(0);
484        // The hovered link (mouse or caret) belongs to the document we just
485        // replaced: keep it and a link at the same position in the new note
486        // would render as hovered, and no hover event would fire to correct it.
487        disp.set_hovered_link(None);
488        drop(disp);
489        self.0.emit_paragraph_state();
490    }
491}
492
493impl NoteUI for StructuredRichUI {
494    fn on_change(&mut self, f: Box<dyn FnMut() + 'static>) {
495        self.0.set_change_callback(Some(f));
496    }
497
498    fn set_readonly(&mut self, readonly: bool) {
499        self.0.display.borrow_mut().set_cursor_visible(!readonly);
500    }
501
502    fn is_readonly(&self) -> bool {
503        !self.0.display.borrow().cursor_visible()
504    }
505
506    fn scroll_pos(&self) -> i32 {
507        self.0.display.borrow().scroll_offset()
508    }
509
510    fn set_scroll_pos(&mut self, pos: i32) {
511        self.0.display.borrow_mut().set_scroll(pos.max(0));
512    }
513
514    fn cursor_pos(&self) -> Option<DocumentPosition> {
515        Some(self.0.display.borrow().editor().cursor())
516    }
517
518    fn set_cursor_pos(&mut self, pos: DocumentPosition) {
519        // `set_cursor` clamps to the current document, so a position remembered
520        // against slightly stale content is corrected rather than rejected. It
521        // does not move the viewport — the scroll offset is restored separately.
522        self.0.display.borrow_mut().editor_mut().set_cursor(pos);
523    }
524
525    fn set_bg_color(&mut self, color: Color) {
526        self.0.group.set_color(color);
527    }
528
529    fn set_resizable(&self, wind: &mut window::Window) {
530        wind.resizable(&self.0.group);
531    }
532
533    fn on_link_click(&mut self, f: Box<dyn Fn(String) + 'static>) {
534        self.0.set_link_callback(Some(f));
535    }
536
537    fn tick(&mut self, ms_since_start: u64) {
538        self.0.tick(ms_since_start);
539    }
540
541    fn on_link_hover(&mut self, f: Box<dyn Fn(Option<String>) + 'static>) {
542        self.0.set_link_hover_callback(Some(f));
543    }
544
545    fn on_paragraph_style_change(&mut self, f: Box<dyn FnMut(BlockType) + 'static>) {
546        self.0.set_paragraph_callback(Some(f));
547    }
548
549    fn as_any(&self) -> &dyn Any {
550        self
551    }
552
553    fn as_any_mut(&mut self) -> &mut dyn Any {
554        self
555    }
556
557    fn hide(&mut self) {
558        self.0.group.hide();
559    }
560
561    fn take_focus(&mut self) {
562        let _ = self.0.group.take_focus();
563    }
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569
570    /// The highlight element a single leaf maps to (block + enclosing `<li>`).
571    fn target_at(doc: &Document, path: &TreePath) -> HighlightTarget {
572        let leaves = rutle::tree_walk::enumerate_leaves(doc);
573        leaf_element(&build_li_index(&leaves), path)
574    }
575
576    /// Highlight targets for every marker-bearing (list/checklist) leaf of a
577    /// block, in document order — the order that lines up with the emitted
578    /// `<li>` tags. Keyed nowhere; the caller asserts on the sequence.
579    fn item_targets(doc: &Document, block: usize) -> Vec<HighlightTarget> {
580        rutle::tree_walk::enumerate_leaves(doc)
581            .iter()
582            .filter(|l| {
583                l.marker.is_some()
584                    && l.path.segments().first() == Some(&PathSegment::Paragraph(block))
585            })
586            .map(|l| target_at(doc, &l.path))
587            .collect()
588    }
589
590    #[test]
591    fn highlight_target_uses_block_for_non_list_leaves() {
592        let md = "# Title\n\nA paragraph\n";
593        let doc = crate::markdown_converter::markdown_to_document(md);
594        let leaves = rutle::tree_walk::enumerate_leaves(&doc);
595        // Heading → block 0, whole block; paragraph → block 1, whole block.
596        assert_eq!(
597            target_at(&doc, &leaves[0].path),
598            HighlightTarget { block: 0, li: None }
599        );
600        assert_eq!(
601            target_at(&doc, &leaves[1].path),
602            HighlightTarget { block: 1, li: None }
603        );
604    }
605
606    #[test]
607    fn highlight_target_indexes_flat_list_items() {
608        let md = "intro\n\n- one\n- two\n- three\n";
609        let doc = crate::markdown_converter::markdown_to_document(md);
610        // Block 1 is the list; its three items get li 0, 1, 2.
611        assert_eq!(
612            item_targets(&doc, 1),
613            vec![
614                HighlightTarget {
615                    block: 1,
616                    li: Some(0)
617                },
618                HighlightTarget {
619                    block: 1,
620                    li: Some(1)
621                },
622                HighlightTarget {
623                    block: 1,
624                    li: Some(2)
625                },
626            ]
627        );
628    }
629
630    #[test]
631    fn highlight_target_indexes_nested_list_items_in_document_order() {
632        // a, (nested) b, c, d — the same order tdoc emits the four `<li>` tags.
633        let md = "- a\n  - b\n  - c\n- d\n";
634        let doc = crate::markdown_converter::markdown_to_document(md);
635        assert_eq!(
636            item_targets(&doc, 0),
637            vec![
638                HighlightTarget {
639                    block: 0,
640                    li: Some(0)
641                },
642                HighlightTarget {
643                    block: 0,
644                    li: Some(1)
645                },
646                HighlightTarget {
647                    block: 0,
648                    li: Some(2)
649                },
650                HighlightTarget {
651                    block: 0,
652                    li: Some(3)
653                },
654            ]
655        );
656    }
657
658    #[test]
659    fn highlight_target_indexes_checklist_items() {
660        let md = "- [ ] x\n- [x] y\n";
661        let doc = crate::markdown_converter::markdown_to_document(md);
662        assert_eq!(
663            item_targets(&doc, 0),
664            vec![
665                HighlightTarget {
666                    block: 0,
667                    li: Some(0)
668                },
669                HighlightTarget {
670                    block: 0,
671                    li: Some(1)
672                },
673            ]
674        );
675    }
676
677    #[test]
678    fn selection_spanning_paragraphs_yields_one_target_each() {
679        let md = "# Title\n\nfirst\n\nsecond\n\nthird\n";
680        let doc = crate::markdown_converter::markdown_to_document(md);
681        let leaves = rutle::tree_walk::enumerate_leaves(&doc);
682        // From "first" (block 1) to inside "third" (block 3): blocks 1, 2, 3.
683        let start = DocumentPosition::at(leaves[1].path.clone(), 0);
684        let end = DocumentPosition::at(leaves[3].path.clone(), 1);
685        assert_eq!(
686            selection_targets(&doc, &start, &end),
687            vec![
688                HighlightTarget { block: 1, li: None },
689                HighlightTarget { block: 2, li: None },
690                HighlightTarget { block: 3, li: None },
691            ]
692        );
693    }
694
695    #[test]
696    fn selection_spanning_list_items_yields_each_item() {
697        let md = "- one\n- two\n- three\n";
698        let doc = crate::markdown_converter::markdown_to_document(md);
699        let leaves = rutle::tree_walk::enumerate_leaves(&doc);
700        // From item 0 into item 1: two adjacent `<li>` targets.
701        let start = DocumentPosition::at(leaves[0].path.clone(), 0);
702        let end = DocumentPosition::at(leaves[1].path.clone(), 1);
703        assert_eq!(
704            selection_targets(&doc, &start, &end),
705            vec![
706                HighlightTarget {
707                    block: 0,
708                    li: Some(0)
709                },
710                HighlightTarget {
711                    block: 0,
712                    li: Some(1)
713                },
714            ]
715        );
716    }
717
718    #[test]
719    fn selection_from_paragraph_into_a_list() {
720        let md = "intro\n\n- one\n- two\n";
721        let doc = crate::markdown_converter::markdown_to_document(md);
722        let leaves = rutle::tree_walk::enumerate_leaves(&doc);
723        // "intro" (whole block) then the first list item.
724        let start = DocumentPosition::at(leaves[0].path.clone(), 0);
725        let end = DocumentPosition::at(leaves[1].path.clone(), 1);
726        assert_eq!(
727            selection_targets(&doc, &start, &end),
728            vec![
729                HighlightTarget { block: 0, li: None },
730                HighlightTarget {
731                    block: 1,
732                    li: Some(0)
733                },
734            ]
735        );
736    }
737
738    #[test]
739    fn selection_ending_at_a_block_start_excludes_it() {
740        let md = "one\n\ntwo\n\nthree\n";
741        let doc = crate::markdown_converter::markdown_to_document(md);
742        let leaves = rutle::tree_walk::enumerate_leaves(&doc);
743        // Drag from "one" and release at the very start of "three": "three" has
744        // nothing selected, so only blocks 0 and 1 are highlighted.
745        let start = DocumentPosition::at(leaves[0].path.clone(), 0);
746        let end = DocumentPosition::at(leaves[2].path.clone(), 0);
747        assert_eq!(
748            selection_targets(&doc, &start, &end),
749            vec![
750                HighlightTarget { block: 0, li: None },
751                HighlightTarget { block: 1, li: None },
752            ]
753        );
754    }
755
756    #[test]
757    fn heading_anchor_map_slugs_and_dedup() {
758        // Top-level headings (in tdoc `#`/`##`/`###` are all top-level blocks),
759        // interleaved with a paragraph and with duplicate heading texts.
760        let md = "# Overview\n\nsome text\n\n## Details\n\n## Details\n\n# Overview\n";
761        let doc = crate::markdown_converter::markdown_to_document(md);
762
763        let map = heading_anchor_map(&doc);
764        let slugs: Vec<&str> = map.iter().map(|(_, s)| s.as_str()).collect();
765        assert_eq!(slugs, ["overview", "details", "details-1", "overview-1"]);
766
767        // Every returned index really points at a heading block.
768        for (idx, _) in &map {
769            assert!(matches!(
770                rutle::tree_walk::effective_block_type(&doc, &TreePath::root(*idx)),
771                BlockType::Heading { .. }
772            ));
773        }
774    }
775
776    /// A brand-new note has no paragraphs, so rutle's block-level commands have
777    /// no leaf to convert: `set_block_type` is a silent no-op. This is the bug
778    /// the seeded empty paragraph in `set_content_from_markdown` fixes.
779    #[test]
780    fn block_type_is_a_no_op_on_an_empty_document() {
781        let mut editor = Editor::with_tdoc(tdoc::Document::new());
782        let _ = editor.set_block_type(BlockType::Heading { level: 1 });
783        assert_eq!(editor.current_block_type(), BlockType::Paragraph);
784    }
785
786    /// With the seeded empty paragraph a fresh note carries a leaf, so the very
787    /// first Cmd-Alt-1 converts it to a heading without needing a keystroke.
788    #[test]
789    fn seeded_empty_paragraph_accepts_a_heading() {
790        let mut doc = tdoc::Document::new();
791        assert!(doc.paragraphs.is_empty());
792        doc.add_paragraph(tdoc::Paragraph::new_text());
793
794        let mut editor = Editor::with_tdoc(doc);
795        assert!(
796            editor
797                .set_block_type(BlockType::Heading { level: 1 })
798                .is_ok()
799        );
800        assert_eq!(editor.current_block_type(), BlockType::Heading { level: 1 });
801    }
802}