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 kimun_core::nfs::VaultPath;
10use ratatui::Frame;
11use ratatui::layout::{Constraint, Direction, Layout, Rect};
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::Paragraph;
15
16use crate::components::preview_highlight;
17use crate::settings::themes::Theme;
18
19/// How much of the selected note the preview shows.
20#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum ExpandState {
22    /// List only, no preview.
23    Collapsed,
24    /// Half-height preview below the list; sticks across selection moves.
25    Context,
26    /// Preview takes the whole panel; the list is hidden.
27    Full,
28}
29
30/// Scroll state for the expanded content views (Full mode and the half-height
31/// Context preview). The offset is either *anchored* — the Context render
32/// recomputes it from the first needle match each frame — or user-owned after a
33/// scroll. Every transition (take-over, re-anchor, clamp) lives here, so paths
34/// that should re-anchor have one decision point and the offset is never out of
35/// range between events.
36#[derive(Clone, Copy)]
37struct ContentScroll {
38    /// True while the render owns the offset (anchor on the first needle
39    /// match). The first tick that actually moves the view flips it;
40    /// re-anchoring events set it back.
41    anchored: bool,
42    /// The rendered scroll offset (first visible content line).
43    offset: usize,
44    /// Maximum offset, recorded by render from content/viewport size.
45    max: usize,
46}
47
48impl ContentScroll {
49    fn new() -> Self {
50        Self {
51            anchored: true,
52            offset: 0,
53            max: 0,
54        }
55    }
56
57    /// Back to the top, offset handed back to the auto-anchor.
58    fn reset(&mut self) {
59        *self = Self::new();
60    }
61
62    /// Re-arm the auto-anchor without touching the offset (the next anchored
63    /// render overwrites it).
64    fn re_anchor(&mut self) {
65        self.anchored = true;
66    }
67
68    /// One wheel/key tick up, clamped at the top. Only a tick that moves the
69    /// view takes the offset over from the anchor — a saturated no-op must
70    /// not silently disarm it.
71    fn scroll_up(&mut self) {
72        if self.offset > 0 {
73            self.offset -= 1;
74            self.anchored = false;
75        }
76    }
77
78    /// One wheel/key tick down, clamped at `max` at mutation time so the
79    /// offset is never out of range. Same no-op rule as [`scroll_up`].
80    ///
81    /// [`scroll_up`]: Self::scroll_up
82    fn scroll_down(&mut self) {
83        if self.offset < self.max {
84            self.offset += 1;
85            self.anchored = false;
86        }
87    }
88
89    /// Render-time sync: record the current max offset and clamp — a resize
90    /// can shrink the content below the held offset.
91    fn set_max(&mut self, max: usize) {
92        self.max = max;
93        self.offset = self.offset.min(max);
94    }
95
96    /// Render-time anchor: while anchored, place the offset (clamped). A
97    /// user-owned offset is left alone.
98    fn anchor_to(&mut self, offset: usize) {
99        if self.anchored {
100            self.offset = offset.min(self.max);
101        }
102    }
103
104    /// Anchor the view on the line holding the first needle match: if the
105    /// content from the link to the end fits the viewport, scroll back to fill
106    /// it; otherwise show two lines of context above the link. No-op unless
107    /// anchored. `set_max` must run first (this clamps against `max`).
108    fn anchor_to_link(&mut self, link_pos: usize, total: usize, viewport: usize) {
109        let lines_after_link = total.saturating_sub(link_pos);
110        let target = if lines_after_link <= viewport {
111            self.max
112        } else {
113            link_pos.saturating_sub(2)
114        };
115        self.anchor_to(target);
116    }
117}
118
119/// The note-preview surface beneath/over the Query panel's result list.
120pub struct PreviewPane {
121    expand: ExpandState,
122    /// The path the expand state belongs to, so a selection change re-anchors.
123    expand_path: Option<VaultPath>,
124    scroll: ContentScroll,
125    /// The full-expand header's screen area, recorded each render so a click on
126    /// it collapses the view (mirroring Enter). Empty when full mode is off.
127    full_header_rect: Rect,
128}
129
130impl Default for PreviewPane {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136impl PreviewPane {
137    pub fn new() -> Self {
138        Self {
139            expand: ExpandState::Collapsed,
140            expand_path: None,
141            scroll: ContentScroll::new(),
142            full_header_rect: Rect::default(),
143        }
144    }
145
146    pub fn is_collapsed(&self) -> bool {
147        self.expand == ExpandState::Collapsed
148    }
149
150    pub fn is_context(&self) -> bool {
151        self.expand == ExpandState::Context
152    }
153
154    pub fn is_full(&self) -> bool {
155        self.expand == ExpandState::Full
156    }
157
158    pub fn full_header_rect(&self) -> Rect {
159        self.full_header_rect
160    }
161
162    /// Drop the recorded full-expand header rect (the previous frame's region).
163    pub fn clear_header(&mut self) {
164        self.full_header_rect = Rect::default();
165    }
166
167    /// Collapse to the list and re-arm the auto-anchor (programmatic resets:
168    /// query change, sort, note change).
169    pub fn reset(&mut self) {
170        self.expand = ExpandState::Collapsed;
171        self.expand_path = None;
172        self.scroll.reset();
173        self.full_header_rect = Rect::default();
174    }
175
176    /// Re-arm the auto-anchor without changing the expand state (a query edit
177    /// moves the matches, so a user scroll position is stale).
178    pub fn re_anchor(&mut self) {
179        self.scroll.re_anchor();
180    }
181
182    pub fn scroll_up(&mut self) {
183        self.scroll.scroll_up();
184    }
185
186    pub fn scroll_down(&mut self) {
187        self.scroll.scroll_down();
188    }
189
190    /// Re-anchor the expand state on the currently-selected row. The Context
191    /// (half-height) preview sticks across selection moves: it stays open and
192    /// re-anchors on the new row. Full collapses, and a vanished selection
193    /// always collapses. Returns `true` when the state changed, so the caller
194    /// drops the stale wheel-routing region.
195    pub fn sync(&mut self, selected: Option<VaultPath>) -> bool {
196        if selected == self.expand_path {
197            return false;
198        }
199        if self.expand != ExpandState::Context || selected.is_none() {
200            self.expand = ExpandState::Collapsed;
201        }
202        self.expand_path = selected;
203        self.scroll.reset();
204        self.full_header_rect = Rect::default();
205        true
206    }
207
208    /// Cycle the selected row's preview: Collapsed → Context → Full →
209    /// Collapsed. No-op without a selection.
210    pub fn toggle(&mut self, selected: Option<VaultPath>) {
211        if selected.is_none() {
212            return;
213        }
214        self.expand_path = selected;
215        match self.expand {
216            ExpandState::Collapsed => {
217                self.expand = ExpandState::Context;
218                self.scroll.re_anchor();
219            }
220            ExpandState::Context => {
221                self.scroll.reset();
222                self.expand = ExpandState::Full;
223            }
224            ExpandState::Full => {
225                self.scroll.reset();
226                self.expand = ExpandState::Collapsed;
227            }
228        }
229        self.full_header_rect = Rect::default();
230    }
231
232    /// Render the full-screen preview (fixed title + divider, scrollable
233    /// content) into `inner`. Records the header rect for click-to-collapse.
234    #[allow(clippy::too_many_arguments)]
235    pub fn render_full(
236        &mut self,
237        f: &mut Frame,
238        inner: Rect,
239        title: &str,
240        filename: &str,
241        text: &str,
242        needles: &[String],
243        theme: &Theme,
244    ) {
245        let gray = theme.gray.to_ratatui();
246        let bg = theme.bg_panel.to_ratatui();
247        let title_display = if title.is_empty() { filename } else { title };
248
249        let parts = Layout::default()
250            .direction(Direction::Vertical)
251            .constraints([
252                Constraint::Length(1), // title
253                Constraint::Length(1), // divider
254                Constraint::Min(0),    // content
255            ])
256            .split(inner);
257
258        // Fixed title header — clicking it collapses the view (mirroring Enter).
259        self.full_header_rect = parts[0];
260        f.render_widget(
261            Paragraph::new(Line::from(vec![
262                Span::styled(
263                    format!("\u{25BC} {} ", title_display),
264                    Style::default()
265                        .fg(theme.selection_fg.to_ratatui())
266                        .bg(bg)
267                        .add_modifier(Modifier::BOLD),
268                ),
269                Span::styled(format!(" {filename}"), Style::default().fg(gray).bg(bg)),
270            ]))
271            .style(Style::default().bg(bg)),
272            parts[0],
273        );
274
275        // Fixed divider.
276        f.render_widget(
277            Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
278                .style(Style::default().fg(gray).bg(bg)),
279            parts[1],
280        );
281
282        let indent = 2usize;
283        let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
284        let (lines, _) = build_lines(text, needles, wrap_width, theme, false, indent);
285        let viewport = parts[2].height as usize;
286        self.scroll.set_max(lines.len().saturating_sub(viewport));
287        f.render_widget(
288            Paragraph::new(lines)
289                .scroll((self.scroll.offset as u16, 0))
290                .style(Style::default().bg(bg)),
291            parts[2],
292        );
293    }
294
295    /// Render the half-height Context preview into `area`, scrolled so the
296    /// first link occurrence shows with context above (while anchored).
297    pub fn render_context(
298        &mut self,
299        f: &mut Frame,
300        area: Rect,
301        text: &str,
302        needles: &[String],
303        theme: &Theme,
304    ) {
305        let bg = theme.bg_panel.to_ratatui();
306        let indent = 2usize;
307        let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
308        // The link-line scan only matters while anchored (a user-owned scroll
309        // never reads it), so skip the per-line work otherwise.
310        let find_link = self.scroll.anchored;
311        let (lines, link_line) = build_lines(text, needles, wrap_width, theme, find_link, indent);
312        let viewport = area.height as usize;
313        let total = lines.len();
314        self.scroll.set_max(total.saturating_sub(viewport));
315        self.scroll
316            .anchor_to_link(link_line.unwrap_or(0), total, viewport);
317        f.render_widget(
318            Paragraph::new(lines)
319                .scroll((self.scroll.offset as u16, 0))
320                .style(Style::default().bg(bg)),
321            area,
322        );
323    }
324}
325
326#[cfg(test)]
327impl PreviewPane {
328    /// Test observers for the composing panel's integration tests, which assert
329    /// the scroll/anchor state after a real render.
330    pub fn scroll_offset(&self) -> usize {
331        self.scroll.offset
332    }
333    pub fn is_anchored(&self) -> bool {
334        self.scroll.anchored
335    }
336    pub fn scroll_max(&self) -> usize {
337        self.scroll.max
338    }
339    /// Simulate a user-owned scroll without a viewport-sized content set.
340    pub fn force_user_scrolled(&mut self) {
341        self.scroll.anchored = false;
342    }
343}
344
345/// Build the wrapped, needle-highlighted, indented content lines. When
346/// `find_link` is set, also report the first wrapped-line index carrying a
347/// match (for the Context anchor).
348fn build_lines(
349    text: &str,
350    needles: &[String],
351    wrap_width: usize,
352    theme: &Theme,
353    find_link: bool,
354    indent: usize,
355) -> (Vec<Line<'static>>, Option<usize>) {
356    let bg = theme.bg_panel.to_ratatui();
357    let normal = Style::default().fg(theme.gray.to_ratatui()).bg(bg);
358    let bold = Style::default()
359        .fg(theme.accent.to_ratatui())
360        .bg(bg)
361        .add_modifier(Modifier::BOLD);
362    let mut lines = Vec::new();
363    let mut link_line = None;
364    for line in text.lines() {
365        for wline in preview_highlight::wrap_line(line, wrap_width) {
366            // One scan per wrapped line: the link-line probe and the span
367            // styling share it.
368            let ranges = preview_highlight::match_ranges(&wline, needles);
369            if find_link && link_line.is_none() && !ranges.is_empty() {
370                link_line = Some(lines.len());
371            }
372            let mut indented = vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
373            indented.extend(preview_highlight::style_ranges(
374                &wline,
375                &ranges,
376                |s, hit| Span::styled(s.to_string(), if hit { bold } else { normal }),
377            ));
378            lines.push(Line::from(indented));
379        }
380    }
381    (lines, link_line)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    fn path(name: &str) -> VaultPath {
389        VaultPath::note_path_from(name)
390    }
391
392    fn needles(v: &[&str]) -> Vec<String> {
393        v.iter().map(|s| s.to_string()).collect()
394    }
395
396    // ── Expand state machine ──────────────────────────────────────────────
397
398    #[test]
399    fn toggle_cycles_collapsed_context_full() {
400        let mut p = PreviewPane::new();
401        let sel = || Some(path("a"));
402        assert!(p.is_collapsed());
403        p.toggle(sel());
404        assert!(p.is_context());
405        p.toggle(sel());
406        assert!(p.is_full());
407        p.toggle(sel());
408        assert!(p.is_collapsed());
409    }
410
411    #[test]
412    fn toggle_without_selection_is_noop() {
413        let mut p = PreviewPane::new();
414        p.toggle(None);
415        assert!(p.is_collapsed());
416    }
417
418    #[test]
419    fn sync_keeps_context_across_selection_change() {
420        let mut p = PreviewPane::new();
421        p.toggle(Some(path("a"))); // -> Context, anchored on "a"
422        assert!(p.is_context());
423        // Moving to another row: Context sticks, re-anchored on the new row.
424        let changed = p.sync(Some(path("b")));
425        assert!(changed, "selection change must clear the stale region");
426        assert!(p.is_context());
427    }
428
429    #[test]
430    fn sync_collapses_full_on_selection_change() {
431        let mut p = PreviewPane::new();
432        p.toggle(Some(path("a")));
433        p.toggle(Some(path("a"))); // -> Full
434        assert!(p.is_full());
435        p.sync(Some(path("b")));
436        assert!(p.is_collapsed(), "Full does not stick across rows");
437    }
438
439    #[test]
440    fn sync_collapses_when_selection_vanishes() {
441        let mut p = PreviewPane::new();
442        p.toggle(Some(path("a"))); // Context
443        p.sync(None);
444        assert!(p.is_collapsed());
445    }
446
447    #[test]
448    fn sync_same_selection_is_noop() {
449        let mut p = PreviewPane::new();
450        p.toggle(Some(path("a")));
451        assert!(!p.sync(Some(path("a"))), "no change, no region clear");
452    }
453
454    #[test]
455    fn reset_collapses_and_rearms() {
456        let mut p = PreviewPane::new();
457        p.toggle(Some(path("a")));
458        p.scroll_down();
459        p.reset();
460        assert!(p.is_collapsed());
461        assert!(p.scroll.anchored && p.scroll.offset == 0);
462    }
463
464    // ── Scroll / anchor logic ─────────────────────────────────────────────
465
466    #[test]
467    fn scroll_clamps_and_takes_over_from_anchor() {
468        let mut s = ContentScroll::new();
469        s.set_max(3);
470        assert!(s.anchored);
471        s.scroll_up(); // already at top → no-op, stays anchored
472        assert!(s.anchored && s.offset == 0);
473        s.scroll_down();
474        assert!(!s.anchored, "a real move disarms the anchor");
475        assert_eq!(s.offset, 1);
476        s.scroll_down();
477        s.scroll_down();
478        s.scroll_down(); // clamped at max
479        assert_eq!(s.offset, 3);
480    }
481
482    #[test]
483    fn anchor_to_link_fills_viewport_when_tail_fits() {
484        let mut s = ContentScroll::new();
485        // 10 lines, viewport 5 → max offset 5. Link near the end: tail fits, so
486        // scroll back to max to fill the viewport.
487        s.set_max(5);
488        s.anchor_to_link(8, 10, 5);
489        assert_eq!(s.offset, 5);
490    }
491
492    #[test]
493    fn anchor_to_link_shows_two_lines_of_context_above() {
494        let mut s = ContentScroll::new();
495        // Link deep in long content, tail does NOT fit → show link_pos - 2.
496        s.set_max(100);
497        s.anchor_to_link(40, 200, 10);
498        assert_eq!(s.offset, 38);
499    }
500
501    #[test]
502    fn anchor_to_link_is_noop_once_user_scrolled() {
503        let mut s = ContentScroll::new();
504        s.set_max(100);
505        s.scroll_down(); // user owns the offset now (offset 1, not anchored)
506        s.anchor_to_link(40, 200, 10);
507        assert_eq!(s.offset, 1, "user-owned offset is not re-anchored");
508    }
509
510    #[test]
511    fn build_lines_reports_first_match_line() {
512        let theme = Theme::default();
513        let text = "alpha\nbeta widget\ngamma";
514        let (lines, link) = build_lines(text, &needles(&["widget"]), 80, &theme, true, 2);
515        assert_eq!(lines.len(), 3);
516        assert_eq!(link, Some(1), "the match is on the second line");
517    }
518}