Skip to main content

kimun_notes/components/
preview_pane.rs

1//! The Query panel's note-preview: the expand state machine (Collapsed →
2//! Context → Full), the content scroll (anchored vs user-owned), and the
3//! content render. Lifted out of the panel so the scroll/anchor logic — the
4//! subtle part — is testable on its own, without a vault, a `SearchList`, or a
5//! `Frame`. The panel composes one of these and feeds it the selected note's
6//! text + highlight needles; the panel keeps owning the list and the engine's
7//! wheel-routing region (`set_content_rect`).
8
9use std::ops::Range;
10
11use kimun_core::nfs::VaultPath;
12use ratatui::Frame;
13use ratatui::layout::{Constraint, Direction, Layout, Rect};
14use ratatui::style::{Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::Paragraph;
17
18use crate::components::preview_highlight;
19use crate::settings::themes::Theme;
20
21/// How much of the selected note the preview shows.
22#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23pub enum ExpandState {
24    /// List only, no preview.
25    Collapsed,
26    /// Half-height preview below the list; sticks across selection moves.
27    Context,
28    /// Preview takes the whole panel; the list is hidden.
29    Full,
30}
31
32/// The Preview pane's one "what makes a line hot" seam. FIND highlights query
33/// needles scattered anywhere in a line; Ask Sources highlights one contiguous
34/// byte range (the retrieved section). Every other part of the render — chrome,
35/// wrapping, indent, scroll anchoring — is a single body shared by both; this
36/// enum is the only place that body branches on which kind of highlight is
37/// active.
38pub enum Highlight<'a> {
39    /// FIND: a (wrapped) line is hot if it contains any of these needles,
40    /// case-insensitively; each match renders bold within the line.
41    Needles(&'a [String]),
42    /// Ask Sources: a source line is hot if its byte range intersects this
43    /// one; the whole line renders bold when it does. `None` when no section
44    /// resolved (nothing is hot) — distinct from an empty needle list, which
45    /// is expressed as `Needles(&[])`.
46    Range(Option<&'a Range<usize>>),
47}
48
49/// Scroll state for the expanded content views (Full mode and the half-height
50/// Context preview). The offset is either *anchored* — the Context render
51/// recomputes it from the first needle match each frame — or user-owned after a
52/// scroll. Every transition (take-over, re-anchor, clamp) lives here, so paths
53/// that should re-anchor have one decision point and the offset is never out of
54/// range between events.
55#[derive(Clone, Copy)]
56struct ContentScroll {
57    /// True while the render owns the offset (anchor on the first needle
58    /// match). The first tick that actually moves the view flips it;
59    /// re-anchoring events set it back.
60    anchored: bool,
61    /// The rendered scroll offset (first visible content line).
62    offset: usize,
63    /// Maximum offset, recorded by render from content/viewport size.
64    max: usize,
65}
66
67impl ContentScroll {
68    fn new() -> Self {
69        Self {
70            anchored: true,
71            offset: 0,
72            max: 0,
73        }
74    }
75
76    /// Back to the top, offset handed back to the auto-anchor.
77    fn reset(&mut self) {
78        *self = Self::new();
79    }
80
81    /// Re-arm the auto-anchor without touching the offset (the next anchored
82    /// render overwrites it).
83    fn re_anchor(&mut self) {
84        self.anchored = true;
85    }
86
87    /// One wheel/key tick up, clamped at the top. Only a tick that moves the
88    /// view takes the offset over from the anchor — a saturated no-op must
89    /// not silently disarm it.
90    fn scroll_up(&mut self) {
91        if self.offset > 0 {
92            self.offset -= 1;
93            self.anchored = false;
94        }
95    }
96
97    /// One wheel/key tick down, clamped at `max` at mutation time so the
98    /// offset is never out of range. Same no-op rule as [`scroll_up`].
99    ///
100    /// [`scroll_up`]: Self::scroll_up
101    fn scroll_down(&mut self) {
102        if self.offset < self.max {
103            self.offset += 1;
104            self.anchored = false;
105        }
106    }
107
108    /// Render-time sync: record the current max offset and clamp — a resize
109    /// can shrink the content below the held offset.
110    fn set_max(&mut self, max: usize) {
111        self.max = max;
112        self.offset = self.offset.min(max);
113    }
114
115    /// Render-time anchor: while anchored, place the offset (clamped). A
116    /// user-owned offset is left alone.
117    fn anchor_to(&mut self, offset: usize) {
118        if self.anchored {
119            self.offset = offset.min(self.max);
120        }
121    }
122
123    /// Anchor the view on the line holding the first needle match: if the
124    /// content from the link to the end fits the viewport, scroll back to fill
125    /// it; otherwise show two lines of context above the link. No-op unless
126    /// anchored. `set_max` must run first (this clamps against `max`).
127    fn anchor_to_link(&mut self, link_pos: usize, total: usize, viewport: usize) {
128        let lines_after_link = total.saturating_sub(link_pos);
129        let target = if lines_after_link <= viewport {
130            self.max
131        } else {
132            link_pos.saturating_sub(2)
133        };
134        self.anchor_to(target);
135    }
136}
137
138/// The note-preview surface beneath/over the Query panel's result list.
139pub struct PreviewPane {
140    expand: ExpandState,
141    /// The path the expand state belongs to, so a selection change re-anchors.
142    expand_path: Option<VaultPath>,
143    scroll: ContentScroll,
144    /// The full-expand header's screen area, recorded each render so a click on
145    /// it collapses the view (mirroring Enter). Empty when full mode is off.
146    full_header_rect: Rect,
147}
148
149impl Default for PreviewPane {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl PreviewPane {
156    pub fn new() -> Self {
157        Self {
158            expand: ExpandState::Collapsed,
159            expand_path: None,
160            scroll: ContentScroll::new(),
161            full_header_rect: Rect::default(),
162        }
163    }
164
165    pub fn is_collapsed(&self) -> bool {
166        self.expand == ExpandState::Collapsed
167    }
168
169    pub fn is_context(&self) -> bool {
170        self.expand == ExpandState::Context
171    }
172
173    pub fn is_full(&self) -> bool {
174        self.expand == ExpandState::Full
175    }
176
177    pub fn full_header_rect(&self) -> Rect {
178        self.full_header_rect
179    }
180
181    /// Drop the recorded full-expand header rect (the previous frame's region).
182    pub fn clear_header(&mut self) {
183        self.full_header_rect = Rect::default();
184    }
185
186    /// Collapse to the list and re-arm the auto-anchor (programmatic resets:
187    /// query change, sort, note change).
188    pub fn reset(&mut self) {
189        self.expand = ExpandState::Collapsed;
190        self.expand_path = None;
191        self.scroll.reset();
192        self.full_header_rect = Rect::default();
193    }
194
195    /// Re-arm the auto-anchor without changing the expand state (a query edit
196    /// moves the matches, so a user scroll position is stale).
197    pub fn re_anchor(&mut self) {
198        self.scroll.re_anchor();
199    }
200
201    /// Re-point the preview at `selected`, keeping the expand state and
202    /// re-anchoring the scroll from the top — for a *directed* reveal (Ask's
203    /// `open_reader`, or a same-note section change) that must land revealed on
204    /// the requested source rather than collapse the way a plain selection move
205    /// ([`sync`](Self::sync)) would. No-op without a selection.
206    pub fn repoint(&mut self, selected: Option<VaultPath>) {
207        if selected.is_none() {
208            return;
209        }
210        self.expand_path = selected;
211        self.scroll.reset();
212        self.full_header_rect = Rect::default();
213    }
214
215    pub fn scroll_up(&mut self) {
216        self.scroll.scroll_up();
217    }
218
219    pub fn scroll_down(&mut self) {
220        self.scroll.scroll_down();
221    }
222
223    /// Re-anchor the expand state on the currently-selected row. The Context
224    /// (half-height) preview sticks across selection moves: it stays open and
225    /// re-anchors on the new row. Full collapses, and a vanished selection
226    /// always collapses. Returns `true` when the state changed, so the caller
227    /// drops the stale wheel-routing region.
228    pub fn sync(&mut self, selected: Option<VaultPath>) -> bool {
229        if selected == self.expand_path {
230            return false;
231        }
232        if self.expand != ExpandState::Context || selected.is_none() {
233            self.expand = ExpandState::Collapsed;
234        }
235        self.expand_path = selected;
236        self.scroll.reset();
237        self.full_header_rect = Rect::default();
238        true
239    }
240
241    /// Cycle the selected row's preview: Collapsed → Context → Full →
242    /// Collapsed. No-op without a selection.
243    pub fn toggle(&mut self, selected: Option<VaultPath>) {
244        if selected.is_none() {
245            return;
246        }
247        self.expand_path = selected;
248        match self.expand {
249            ExpandState::Collapsed => {
250                self.expand = ExpandState::Context;
251                self.scroll.re_anchor();
252            }
253            ExpandState::Context => {
254                self.scroll.reset();
255                self.expand = ExpandState::Full;
256            }
257            ExpandState::Full => {
258                self.scroll.reset();
259                self.expand = ExpandState::Collapsed;
260            }
261        }
262        self.full_header_rect = Rect::default();
263    }
264
265    /// Step the reveal cycle backward: Full → Context → Collapsed, stopping at
266    /// Collapsed (the vim-natural `h` mirror of [`Self::toggle`]). No-op without a
267    /// selection, or already Collapsed.
268    pub fn collapse_step(&mut self, selected: Option<VaultPath>) {
269        if selected.is_none() {
270            return;
271        }
272        self.expand_path = selected;
273        match self.expand {
274            ExpandState::Full => {
275                // Back to the half-height preview, re-anchored on the row.
276                self.scroll.reset();
277                self.expand = ExpandState::Context;
278            }
279            ExpandState::Context => {
280                self.scroll.reset();
281                self.expand = ExpandState::Collapsed;
282            }
283            ExpandState::Collapsed => {}
284        }
285        self.full_header_rect = Rect::default();
286    }
287
288    /// Draw the full-expand chrome (fixed title header + divider), record the
289    /// header rect for click-to-collapse, and return the scrollable content
290    /// sub-rect. Used by [`Self::render_full`].
291    fn render_full_chrome(
292        &mut self,
293        f: &mut Frame,
294        inner: Rect,
295        title: &str,
296        filename: &str,
297        theme: &Theme,
298    ) -> Rect {
299        let gray = theme.gray.to_ratatui();
300        let bg = theme.bg_panel.to_ratatui();
301        let title_display = if title.is_empty() { filename } else { title };
302
303        let parts = Layout::default()
304            .direction(Direction::Vertical)
305            .constraints([
306                Constraint::Length(1), // title
307                Constraint::Length(1), // divider
308                Constraint::Min(0),    // content
309            ])
310            .split(inner);
311
312        // Fixed title header — clicking it collapses the view (mirroring Enter).
313        self.full_header_rect = parts[0];
314        f.render_widget(
315            Paragraph::new(Line::from(vec![
316                Span::styled(
317                    format!("\u{25BC} {} ", title_display),
318                    Style::default()
319                        .fg(theme.selection_fg.to_ratatui())
320                        .bg(bg)
321                        .add_modifier(Modifier::BOLD),
322                ),
323                Span::styled(format!(" {filename}"), Style::default().fg(gray).bg(bg)),
324            ]))
325            .style(Style::default().bg(bg)),
326            parts[0],
327        );
328
329        // Fixed divider.
330        f.render_widget(
331            Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
332                .style(Style::default().fg(gray).bg(bg)),
333            parts[1],
334        );
335        parts[2]
336    }
337
338    /// Render the full-screen preview (fixed title + divider, scrollable
339    /// content) into `inner`. Records the header rect for click-to-collapse.
340    /// Anchors the initial scroll to the first hot line (deliberate
341    /// unification: FIND's needle preview now anchors like the Context
342    /// preview and the Ask Sources range preview already did — previously
343    /// this variant alone ignored the anchor and always opened at the top).
344    #[allow(clippy::too_many_arguments)]
345    pub fn render_full(
346        &mut self,
347        f: &mut Frame,
348        inner: Rect,
349        title: &str,
350        filename: &str,
351        text: &str,
352        highlight: Highlight,
353        theme: &Theme,
354    ) {
355        let bg = theme.bg_panel.to_ratatui();
356        let content = self.render_full_chrome(f, inner, title, filename, theme);
357        let indent = 2usize;
358        let wrap_width = content.width.saturating_sub(indent as u16 + 1) as usize;
359        let find_hit = self.scroll.anchored;
360        let (lines, hit) = build_lines(text, highlight, wrap_width, theme, find_hit, indent);
361        let viewport = content.height as usize;
362        let total = lines.len();
363        self.scroll.set_max(total.saturating_sub(viewport));
364        self.scroll
365            .anchor_to_link(hit.unwrap_or(0), total, viewport);
366        f.render_widget(
367            Paragraph::new(lines)
368                .scroll((self.scroll.offset as u16, 0))
369                .style(Style::default().bg(bg)),
370            content,
371        );
372    }
373
374    /// Render the half-height Context preview into `area`, scrolled so the
375    /// first hot line shows with context above (while anchored).
376    pub fn render_context(
377        &mut self,
378        f: &mut Frame,
379        area: Rect,
380        text: &str,
381        highlight: Highlight,
382        theme: &Theme,
383    ) {
384        let bg = theme.bg_panel.to_ratatui();
385        let indent = 2usize;
386        let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
387        // The hit-line scan only matters while anchored (a user-owned scroll
388        // never reads it), so skip the per-line work otherwise.
389        let find_hit = self.scroll.anchored;
390        let (lines, hit) = build_lines(text, highlight, wrap_width, theme, find_hit, indent);
391        let viewport = area.height as usize;
392        let total = lines.len();
393        self.scroll.set_max(total.saturating_sub(viewport));
394        self.scroll
395            .anchor_to_link(hit.unwrap_or(0), total, viewport);
396        f.render_widget(
397            Paragraph::new(lines)
398                .scroll((self.scroll.offset as u16, 0))
399                .style(Style::default().bg(bg)),
400            area,
401        );
402    }
403}
404
405#[cfg(test)]
406impl PreviewPane {
407    /// Test observers for the composing panel's integration tests, which assert
408    /// the scroll/anchor state after a real render.
409    pub fn scroll_offset(&self) -> usize {
410        self.scroll.offset
411    }
412    pub fn is_anchored(&self) -> bool {
413        self.scroll.anchored
414    }
415    pub fn scroll_max(&self) -> usize {
416        self.scroll.max
417    }
418    /// Simulate a user-owned scroll without a viewport-sized content set.
419    pub fn force_user_scrolled(&mut self) {
420        self.scroll.anchored = false;
421    }
422}
423
424/// Build the wrapped, highlighted, indented content lines. The per-line hit
425/// test is the only place this branches on [`Highlight`]: needles test each
426/// wrapped line's text for scattered matches (styling each match span bold);
427/// a range tests the *source* line's byte span against the highlighted range
428/// (styling the whole wrapped line bold when it overlaps). Byte offsets are
429/// tracked over `split_inclusive('\n')` (line terminators kept) so a `Range`
430/// highlight maps correctly onto the original text regardless of wrapping.
431/// When `find_hit` is set, also reports the first wrapped-line index carrying
432/// a hit (for the Context/Full anchor); skip the scan once user-scrolled.
433fn build_lines(
434    text: &str,
435    highlight: Highlight,
436    wrap_width: usize,
437    theme: &Theme,
438    find_hit: bool,
439    indent: usize,
440) -> (Vec<Line<'static>>, Option<usize>) {
441    let bg = theme.bg_panel.to_ratatui();
442    let normal = Style::default().fg(theme.gray.to_ratatui()).bg(bg);
443    let bold = Style::default()
444        .fg(theme.accent.to_ratatui())
445        .bg(bg)
446        .add_modifier(Modifier::BOLD);
447    let mut lines = Vec::new();
448    let mut first_hit = None;
449    let mut offset = 0usize;
450    for raw in text.split_inclusive('\n') {
451        let stripped = raw.strip_suffix('\n').unwrap_or(raw);
452        // CRLF notes: drop the carriage return too, so it never reaches
453        // wrapping or highlight matching as a phantom trailing char.
454        let stripped = stripped.strip_suffix('\r').unwrap_or(stripped);
455        let line_range = offset..offset + stripped.len();
456        offset += raw.len();
457
458        match highlight {
459            Highlight::Needles(needles) => {
460                for wline in preview_highlight::wrap_line(stripped, wrap_width) {
461                    // One scan per wrapped line: the hit probe and the span
462                    // styling share it.
463                    let ranges = preview_highlight::match_ranges(&wline, needles);
464                    if find_hit && first_hit.is_none() && !ranges.is_empty() {
465                        first_hit = Some(lines.len());
466                    }
467                    let mut indented =
468                        vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
469                    indented.extend(preview_highlight::style_ranges(
470                        &wline,
471                        &ranges,
472                        |s, hit| Span::styled(s.to_string(), if hit { bold } else { normal }),
473                    ));
474                    lines.push(Line::from(indented));
475                }
476            }
477            Highlight::Range(range) => {
478                let hit =
479                    range.is_some_and(|h| line_range.start < h.end && h.start < line_range.end);
480                let style = if hit { bold } else { normal };
481                for wline in preview_highlight::wrap_line(stripped, wrap_width) {
482                    if find_hit && hit && first_hit.is_none() {
483                        first_hit = Some(lines.len());
484                    }
485                    lines.push(Line::from(vec![
486                        Span::styled(" ".repeat(indent), Style::default().bg(bg)),
487                        Span::styled(wline, style),
488                    ]));
489                }
490            }
491        }
492    }
493    if lines.is_empty() {
494        lines.push(Line::default());
495    }
496    (lines, first_hit)
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    fn path(name: &str) -> VaultPath {
504        VaultPath::note_path_from(name)
505    }
506
507    fn needles(v: &[&str]) -> Vec<String> {
508        v.iter().map(|s| s.to_string()).collect()
509    }
510
511    // ── Expand state machine ──────────────────────────────────────────────
512
513    #[test]
514    fn toggle_cycles_collapsed_context_full() {
515        let mut p = PreviewPane::new();
516        let sel = || Some(path("a"));
517        assert!(p.is_collapsed());
518        p.toggle(sel());
519        assert!(p.is_context());
520        p.toggle(sel());
521        assert!(p.is_full());
522        p.toggle(sel());
523        assert!(p.is_collapsed());
524    }
525
526    #[test]
527    fn toggle_without_selection_is_noop() {
528        let mut p = PreviewPane::new();
529        p.toggle(None);
530        assert!(p.is_collapsed());
531    }
532
533    #[test]
534    fn sync_keeps_context_across_selection_change() {
535        let mut p = PreviewPane::new();
536        p.toggle(Some(path("a"))); // -> Context, anchored on "a"
537        assert!(p.is_context());
538        // Moving to another row: Context sticks, re-anchored on the new row.
539        let changed = p.sync(Some(path("b")));
540        assert!(changed, "selection change must clear the stale region");
541        assert!(p.is_context());
542    }
543
544    #[test]
545    fn sync_collapses_full_on_selection_change() {
546        let mut p = PreviewPane::new();
547        p.toggle(Some(path("a")));
548        p.toggle(Some(path("a"))); // -> Full
549        assert!(p.is_full());
550        p.sync(Some(path("b")));
551        assert!(p.is_collapsed(), "Full does not stick across rows");
552    }
553
554    #[test]
555    fn sync_collapses_when_selection_vanishes() {
556        let mut p = PreviewPane::new();
557        p.toggle(Some(path("a"))); // Context
558        p.sync(None);
559        assert!(p.is_collapsed());
560    }
561
562    #[test]
563    fn sync_same_selection_is_noop() {
564        let mut p = PreviewPane::new();
565        p.toggle(Some(path("a")));
566        assert!(!p.sync(Some(path("a"))), "no change, no region clear");
567    }
568
569    #[test]
570    fn repoint_keeps_full_and_rearms_the_anchor() {
571        let mut p = PreviewPane::new();
572        p.toggle(Some(path("a"))); // Context
573        p.toggle(Some(path("a"))); // Full
574        p.scroll_down();
575        p.force_user_scrolled();
576        assert!(p.is_full() && !p.is_anchored());
577        // A directed re-point at another source stays Full and re-anchors.
578        p.repoint(Some(path("b")));
579        assert!(p.is_full(), "repoint keeps the expand state (unlike sync)");
580        assert!(p.is_anchored(), "repoint re-arms the scroll anchor");
581        assert_eq!(p.scroll_offset(), 0);
582    }
583
584    #[test]
585    fn repoint_without_selection_is_noop() {
586        let mut p = PreviewPane::new();
587        p.toggle(Some(path("a")));
588        p.repoint(None);
589        assert!(p.is_context(), "no-selection repoint must not change state");
590    }
591
592    #[test]
593    fn reset_collapses_and_rearms() {
594        let mut p = PreviewPane::new();
595        p.toggle(Some(path("a")));
596        p.scroll_down();
597        p.reset();
598        assert!(p.is_collapsed());
599        assert!(p.scroll.anchored && p.scroll.offset == 0);
600    }
601
602    // ── Scroll / anchor logic ─────────────────────────────────────────────
603
604    #[test]
605    fn scroll_clamps_and_takes_over_from_anchor() {
606        let mut s = ContentScroll::new();
607        s.set_max(3);
608        assert!(s.anchored);
609        s.scroll_up(); // already at top → no-op, stays anchored
610        assert!(s.anchored && s.offset == 0);
611        s.scroll_down();
612        assert!(!s.anchored, "a real move disarms the anchor");
613        assert_eq!(s.offset, 1);
614        s.scroll_down();
615        s.scroll_down();
616        s.scroll_down(); // clamped at max
617        assert_eq!(s.offset, 3);
618    }
619
620    #[test]
621    fn anchor_to_link_fills_viewport_when_tail_fits() {
622        let mut s = ContentScroll::new();
623        // 10 lines, viewport 5 → max offset 5. Link near the end: tail fits, so
624        // scroll back to max to fill the viewport.
625        s.set_max(5);
626        s.anchor_to_link(8, 10, 5);
627        assert_eq!(s.offset, 5);
628    }
629
630    #[test]
631    fn anchor_to_link_shows_two_lines_of_context_above() {
632        let mut s = ContentScroll::new();
633        // Link deep in long content, tail does NOT fit → show link_pos - 2.
634        s.set_max(100);
635        s.anchor_to_link(40, 200, 10);
636        assert_eq!(s.offset, 38);
637    }
638
639    #[test]
640    fn anchor_to_link_is_noop_once_user_scrolled() {
641        let mut s = ContentScroll::new();
642        s.set_max(100);
643        s.scroll_down(); // user owns the offset now (offset 1, not anchored)
644        s.anchor_to_link(40, 200, 10);
645        assert_eq!(s.offset, 1, "user-owned offset is not re-anchored");
646    }
647
648    // The next two tests drive the SAME `build_lines` render path with each
649    // `Highlight` variant, proving the single body serves both: one needle
650    // scan (scattered matches within a line) and one range scan (a
651    // contiguous byte span across lines).
652
653    #[test]
654    fn build_lines_needles_reports_first_match_line() {
655        let theme = Theme::default();
656        let text = "alpha\nbeta widget\ngamma";
657        let ns = needles(&["widget"]);
658        let (lines, hit) = build_lines(text, Highlight::Needles(&ns), 80, &theme, true, 2);
659        assert_eq!(lines.len(), 3);
660        assert_eq!(hit, Some(1), "the match is on the second line");
661    }
662
663    #[test]
664    fn collapse_step_steps_back_and_stops_at_collapsed() {
665        let mut p = PreviewPane::new();
666        let sel = || Some(path("a"));
667        p.toggle(sel()); // Collapsed -> Context
668        p.toggle(sel()); // Context -> Full
669        assert!(p.is_full());
670        p.collapse_step(sel()); // Full -> Context
671        assert!(p.is_context());
672        p.collapse_step(sel()); // Context -> Collapsed
673        assert!(p.is_collapsed());
674        p.collapse_step(sel()); // Collapsed stays Collapsed (h no-op at bottom)
675        assert!(p.is_collapsed());
676    }
677
678    #[test]
679    fn collapse_step_without_selection_is_noop() {
680        let mut p = PreviewPane::new();
681        p.toggle(Some(path("a")));
682        p.collapse_step(None);
683        assert!(
684            p.is_context(),
685            "no-selection collapse_step must not change state"
686        );
687    }
688
689    #[test]
690    fn build_lines_range_highlights_and_anchors_the_section() {
691        let theme = Theme::default();
692        // "beta body" is the third source line; its byte range anchors row 2.
693        let text = "line0\nline1\nbeta body\ntail\n";
694        let start = text.find("beta body").unwrap();
695        let range = start..start + "beta body".len();
696        let (lines, first) = build_lines(text, Highlight::Range(Some(&range)), 80, &theme, true, 2);
697        assert_eq!(
698            first,
699            Some(2),
700            "anchor is the first highlighted wrapped row"
701        );
702        // The highlighted content span carries the bold accent modifier; a
703        // non-highlighted line does not.
704        assert!(
705            lines[2].spans[1]
706                .style
707                .add_modifier
708                .contains(Modifier::BOLD)
709        );
710        assert!(
711            !lines[0].spans[1]
712                .style
713                .add_modifier
714                .contains(Modifier::BOLD)
715        );
716    }
717
718    #[test]
719    fn build_lines_range_no_highlight_reports_no_anchor() {
720        let theme = Theme::default();
721        let (_lines, first) = build_lines("a\nb\nc\n", Highlight::Range(None), 80, &theme, true, 2);
722        assert_eq!(first, None);
723    }
724}