use std::ops::Range;
use kimun_core::nfs::VaultPath;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::components::preview_highlight;
use crate::settings::themes::Theme;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ExpandState {
Collapsed,
Context,
Full,
}
pub enum Highlight<'a> {
Needles(&'a [String]),
Range(Option<&'a Range<usize>>),
}
#[derive(Clone, Copy)]
struct ContentScroll {
anchored: bool,
offset: usize,
max: usize,
}
impl ContentScroll {
fn new() -> Self {
Self {
anchored: true,
offset: 0,
max: 0,
}
}
fn reset(&mut self) {
*self = Self::new();
}
fn re_anchor(&mut self) {
self.anchored = true;
}
fn scroll_up(&mut self) {
if self.offset > 0 {
self.offset -= 1;
self.anchored = false;
}
}
fn scroll_down(&mut self) {
if self.offset < self.max {
self.offset += 1;
self.anchored = false;
}
}
fn set_max(&mut self, max: usize) {
self.max = max;
self.offset = self.offset.min(max);
}
fn anchor_to(&mut self, offset: usize) {
if self.anchored {
self.offset = offset.min(self.max);
}
}
fn anchor_to_link(&mut self, link_pos: usize, total: usize, viewport: usize) {
let lines_after_link = total.saturating_sub(link_pos);
let target = if lines_after_link <= viewport {
self.max
} else {
link_pos.saturating_sub(2)
};
self.anchor_to(target);
}
}
pub struct PreviewPane {
expand: ExpandState,
expand_path: Option<VaultPath>,
scroll: ContentScroll,
full_header_rect: Rect,
}
impl Default for PreviewPane {
fn default() -> Self {
Self::new()
}
}
impl PreviewPane {
pub fn new() -> Self {
Self {
expand: ExpandState::Collapsed,
expand_path: None,
scroll: ContentScroll::new(),
full_header_rect: Rect::default(),
}
}
pub fn is_collapsed(&self) -> bool {
self.expand == ExpandState::Collapsed
}
pub fn is_context(&self) -> bool {
self.expand == ExpandState::Context
}
pub fn is_full(&self) -> bool {
self.expand == ExpandState::Full
}
pub fn full_header_rect(&self) -> Rect {
self.full_header_rect
}
pub fn clear_header(&mut self) {
self.full_header_rect = Rect::default();
}
pub fn reset(&mut self) {
self.expand = ExpandState::Collapsed;
self.expand_path = None;
self.scroll.reset();
self.full_header_rect = Rect::default();
}
pub fn re_anchor(&mut self) {
self.scroll.re_anchor();
}
pub fn repoint(&mut self, selected: Option<VaultPath>) {
if selected.is_none() {
return;
}
self.expand_path = selected;
self.scroll.reset();
self.full_header_rect = Rect::default();
}
pub fn scroll_up(&mut self) {
self.scroll.scroll_up();
}
pub fn scroll_down(&mut self) {
self.scroll.scroll_down();
}
pub fn sync(&mut self, selected: Option<VaultPath>) -> bool {
if selected == self.expand_path {
return false;
}
if self.expand != ExpandState::Context || selected.is_none() {
self.expand = ExpandState::Collapsed;
}
self.expand_path = selected;
self.scroll.reset();
self.full_header_rect = Rect::default();
true
}
pub fn toggle(&mut self, selected: Option<VaultPath>) {
if selected.is_none() {
return;
}
self.expand_path = selected;
match self.expand {
ExpandState::Collapsed => {
self.expand = ExpandState::Context;
self.scroll.re_anchor();
}
ExpandState::Context => {
self.scroll.reset();
self.expand = ExpandState::Full;
}
ExpandState::Full => {
self.scroll.reset();
self.expand = ExpandState::Collapsed;
}
}
self.full_header_rect = Rect::default();
}
pub fn collapse_step(&mut self, selected: Option<VaultPath>) {
if selected.is_none() {
return;
}
self.expand_path = selected;
match self.expand {
ExpandState::Full => {
self.scroll.reset();
self.expand = ExpandState::Context;
}
ExpandState::Context => {
self.scroll.reset();
self.expand = ExpandState::Collapsed;
}
ExpandState::Collapsed => {}
}
self.full_header_rect = Rect::default();
}
fn render_full_chrome(
&mut self,
f: &mut Frame,
inner: Rect,
title: &str,
filename: &str,
theme: &Theme,
) -> Rect {
let gray = theme.gray.to_ratatui();
let bg = theme.bg_panel.to_ratatui();
let title_display = if title.is_empty() { filename } else { title };
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
self.full_header_rect = parts[0];
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("\u{25BC} {} ", title_display),
Style::default()
.fg(theme.selection_fg.to_ratatui())
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {filename}"), Style::default().fg(gray).bg(bg)),
]))
.style(Style::default().bg(bg)),
parts[0],
);
f.render_widget(
Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
.style(Style::default().fg(gray).bg(bg)),
parts[1],
);
parts[2]
}
#[allow(clippy::too_many_arguments)]
pub fn render_full(
&mut self,
f: &mut Frame,
inner: Rect,
title: &str,
filename: &str,
text: &str,
highlight: Highlight,
theme: &Theme,
) {
let bg = theme.bg_panel.to_ratatui();
let content = self.render_full_chrome(f, inner, title, filename, theme);
let indent = 2usize;
let wrap_width = content.width.saturating_sub(indent as u16 + 1) as usize;
let find_hit = self.scroll.anchored;
let (lines, hit) = build_lines(text, highlight, wrap_width, theme, find_hit, indent);
let viewport = content.height as usize;
let total = lines.len();
self.scroll.set_max(total.saturating_sub(viewport));
self.scroll
.anchor_to_link(hit.unwrap_or(0), total, viewport);
f.render_widget(
Paragraph::new(lines)
.scroll((self.scroll.offset as u16, 0))
.style(Style::default().bg(bg)),
content,
);
}
pub fn render_context(
&mut self,
f: &mut Frame,
area: Rect,
text: &str,
highlight: Highlight,
theme: &Theme,
) {
let bg = theme.bg_panel.to_ratatui();
let indent = 2usize;
let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
let find_hit = self.scroll.anchored;
let (lines, hit) = build_lines(text, highlight, wrap_width, theme, find_hit, indent);
let viewport = area.height as usize;
let total = lines.len();
self.scroll.set_max(total.saturating_sub(viewport));
self.scroll
.anchor_to_link(hit.unwrap_or(0), total, viewport);
f.render_widget(
Paragraph::new(lines)
.scroll((self.scroll.offset as u16, 0))
.style(Style::default().bg(bg)),
area,
);
}
}
#[cfg(test)]
impl PreviewPane {
pub fn scroll_offset(&self) -> usize {
self.scroll.offset
}
pub fn is_anchored(&self) -> bool {
self.scroll.anchored
}
pub fn scroll_max(&self) -> usize {
self.scroll.max
}
pub fn force_user_scrolled(&mut self) {
self.scroll.anchored = false;
}
}
fn build_lines(
text: &str,
highlight: Highlight,
wrap_width: usize,
theme: &Theme,
find_hit: bool,
indent: usize,
) -> (Vec<Line<'static>>, Option<usize>) {
let bg = theme.bg_panel.to_ratatui();
let normal = Style::default().fg(theme.gray.to_ratatui()).bg(bg);
let bold = Style::default()
.fg(theme.accent.to_ratatui())
.bg(bg)
.add_modifier(Modifier::BOLD);
let mut lines = Vec::new();
let mut first_hit = None;
let mut offset = 0usize;
for raw in text.split_inclusive('\n') {
let stripped = raw.strip_suffix('\n').unwrap_or(raw);
let stripped = stripped.strip_suffix('\r').unwrap_or(stripped);
let line_range = offset..offset + stripped.len();
offset += raw.len();
match highlight {
Highlight::Needles(needles) => {
for wline in preview_highlight::wrap_line(stripped, wrap_width) {
let ranges = preview_highlight::match_ranges(&wline, needles);
if find_hit && first_hit.is_none() && !ranges.is_empty() {
first_hit = Some(lines.len());
}
let mut indented =
vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
indented.extend(preview_highlight::style_ranges(
&wline,
&ranges,
|s, hit| Span::styled(s.to_string(), if hit { bold } else { normal }),
));
lines.push(Line::from(indented));
}
}
Highlight::Range(range) => {
let hit =
range.is_some_and(|h| line_range.start < h.end && h.start < line_range.end);
let style = if hit { bold } else { normal };
for wline in preview_highlight::wrap_line(stripped, wrap_width) {
if find_hit && hit && first_hit.is_none() {
first_hit = Some(lines.len());
}
lines.push(Line::from(vec![
Span::styled(" ".repeat(indent), Style::default().bg(bg)),
Span::styled(wline, style),
]));
}
}
}
}
if lines.is_empty() {
lines.push(Line::default());
}
(lines, first_hit)
}
#[cfg(test)]
mod tests {
use super::*;
fn path(name: &str) -> VaultPath {
VaultPath::note_path_from(name)
}
fn needles(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn toggle_cycles_collapsed_context_full() {
let mut p = PreviewPane::new();
let sel = || Some(path("a"));
assert!(p.is_collapsed());
p.toggle(sel());
assert!(p.is_context());
p.toggle(sel());
assert!(p.is_full());
p.toggle(sel());
assert!(p.is_collapsed());
}
#[test]
fn toggle_without_selection_is_noop() {
let mut p = PreviewPane::new();
p.toggle(None);
assert!(p.is_collapsed());
}
#[test]
fn sync_keeps_context_across_selection_change() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a"))); assert!(p.is_context());
let changed = p.sync(Some(path("b")));
assert!(changed, "selection change must clear the stale region");
assert!(p.is_context());
}
#[test]
fn sync_collapses_full_on_selection_change() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a")));
p.toggle(Some(path("a"))); assert!(p.is_full());
p.sync(Some(path("b")));
assert!(p.is_collapsed(), "Full does not stick across rows");
}
#[test]
fn sync_collapses_when_selection_vanishes() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a"))); p.sync(None);
assert!(p.is_collapsed());
}
#[test]
fn sync_same_selection_is_noop() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a")));
assert!(!p.sync(Some(path("a"))), "no change, no region clear");
}
#[test]
fn repoint_keeps_full_and_rearms_the_anchor() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a"))); p.toggle(Some(path("a"))); p.scroll_down();
p.force_user_scrolled();
assert!(p.is_full() && !p.is_anchored());
p.repoint(Some(path("b")));
assert!(p.is_full(), "repoint keeps the expand state (unlike sync)");
assert!(p.is_anchored(), "repoint re-arms the scroll anchor");
assert_eq!(p.scroll_offset(), 0);
}
#[test]
fn repoint_without_selection_is_noop() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a")));
p.repoint(None);
assert!(p.is_context(), "no-selection repoint must not change state");
}
#[test]
fn reset_collapses_and_rearms() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a")));
p.scroll_down();
p.reset();
assert!(p.is_collapsed());
assert!(p.scroll.anchored && p.scroll.offset == 0);
}
#[test]
fn scroll_clamps_and_takes_over_from_anchor() {
let mut s = ContentScroll::new();
s.set_max(3);
assert!(s.anchored);
s.scroll_up(); assert!(s.anchored && s.offset == 0);
s.scroll_down();
assert!(!s.anchored, "a real move disarms the anchor");
assert_eq!(s.offset, 1);
s.scroll_down();
s.scroll_down();
s.scroll_down(); assert_eq!(s.offset, 3);
}
#[test]
fn anchor_to_link_fills_viewport_when_tail_fits() {
let mut s = ContentScroll::new();
s.set_max(5);
s.anchor_to_link(8, 10, 5);
assert_eq!(s.offset, 5);
}
#[test]
fn anchor_to_link_shows_two_lines_of_context_above() {
let mut s = ContentScroll::new();
s.set_max(100);
s.anchor_to_link(40, 200, 10);
assert_eq!(s.offset, 38);
}
#[test]
fn anchor_to_link_is_noop_once_user_scrolled() {
let mut s = ContentScroll::new();
s.set_max(100);
s.scroll_down(); s.anchor_to_link(40, 200, 10);
assert_eq!(s.offset, 1, "user-owned offset is not re-anchored");
}
#[test]
fn build_lines_needles_reports_first_match_line() {
let theme = Theme::default();
let text = "alpha\nbeta widget\ngamma";
let ns = needles(&["widget"]);
let (lines, hit) = build_lines(text, Highlight::Needles(&ns), 80, &theme, true, 2);
assert_eq!(lines.len(), 3);
assert_eq!(hit, Some(1), "the match is on the second line");
}
#[test]
fn collapse_step_steps_back_and_stops_at_collapsed() {
let mut p = PreviewPane::new();
let sel = || Some(path("a"));
p.toggle(sel()); p.toggle(sel()); assert!(p.is_full());
p.collapse_step(sel()); assert!(p.is_context());
p.collapse_step(sel()); assert!(p.is_collapsed());
p.collapse_step(sel()); assert!(p.is_collapsed());
}
#[test]
fn collapse_step_without_selection_is_noop() {
let mut p = PreviewPane::new();
p.toggle(Some(path("a")));
p.collapse_step(None);
assert!(
p.is_context(),
"no-selection collapse_step must not change state"
);
}
#[test]
fn build_lines_range_highlights_and_anchors_the_section() {
let theme = Theme::default();
let text = "line0\nline1\nbeta body\ntail\n";
let start = text.find("beta body").unwrap();
let range = start..start + "beta body".len();
let (lines, first) = build_lines(text, Highlight::Range(Some(&range)), 80, &theme, true, 2);
assert_eq!(
first,
Some(2),
"anchor is the first highlighted wrapped row"
);
assert!(
lines[2].spans[1]
.style
.add_modifier
.contains(Modifier::BOLD)
);
assert!(
!lines[0].spans[1]
.style
.add_modifier
.contains(Modifier::BOLD)
);
}
#[test]
fn build_lines_range_no_highlight_reports_no_anchor() {
let theme = Theme::default();
let (_lines, first) = build_lines("a\nb\nc\n", Highlight::Range(None), 80, &theme, true, 2);
assert_eq!(first, None);
}
}