1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23pub enum ExpandState {
24 Collapsed,
26 Context,
28 Full,
30}
31
32pub enum Highlight<'a> {
39 Needles(&'a [String]),
42 Range(Option<&'a Range<usize>>),
47}
48
49#[derive(Clone, Copy)]
56struct ContentScroll {
57 anchored: bool,
61 offset: usize,
63 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 fn reset(&mut self) {
78 *self = Self::new();
79 }
80
81 fn re_anchor(&mut self) {
84 self.anchored = true;
85 }
86
87 fn scroll_up(&mut self) {
91 if self.offset > 0 {
92 self.offset -= 1;
93 self.anchored = false;
94 }
95 }
96
97 fn scroll_down(&mut self) {
102 if self.offset < self.max {
103 self.offset += 1;
104 self.anchored = false;
105 }
106 }
107
108 fn set_max(&mut self, max: usize) {
111 self.max = max;
112 self.offset = self.offset.min(max);
113 }
114
115 fn anchor_to(&mut self, offset: usize) {
118 if self.anchored {
119 self.offset = offset.min(self.max);
120 }
121 }
122
123 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
138pub struct PreviewPane {
140 expand: ExpandState,
141 expand_path: Option<VaultPath>,
143 scroll: ContentScroll,
144 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 pub fn clear_header(&mut self) {
183 self.full_header_rect = Rect::default();
184 }
185
186 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 pub fn re_anchor(&mut self) {
198 self.scroll.re_anchor();
199 }
200
201 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 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 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 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 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 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), Constraint::Length(1), Constraint::Min(0), ])
310 .split(inner);
311
312 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 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 #[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 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 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 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 pub fn force_user_scrolled(&mut self) {
420 self.scroll.anchored = false;
421 }
422}
423
424fn 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 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 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 #[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"))); assert!(p.is_context());
538 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"))); 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"))); 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"))); p.toggle(Some(path("a"))); p.scroll_down();
575 p.force_user_scrolled();
576 assert!(p.is_full() && !p.is_anchored());
577 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 #[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(); 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(); 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 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 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(); s.anchor_to_link(40, 200, 10);
645 assert_eq!(s.offset, 1, "user-owned offset is not re-anchored");
646 }
647
648 #[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()); p.toggle(sel()); assert!(p.is_full());
670 p.collapse_step(sel()); assert!(p.is_context());
672 p.collapse_step(sel()); assert!(p.is_collapsed());
674 p.collapse_step(sel()); 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 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 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}