1use 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum ExpandState {
22 Collapsed,
24 Context,
26 Full,
28}
29
30#[derive(Clone, Copy)]
37struct ContentScroll {
38 anchored: bool,
42 offset: usize,
44 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 fn reset(&mut self) {
59 *self = Self::new();
60 }
61
62 fn re_anchor(&mut self) {
65 self.anchored = true;
66 }
67
68 fn scroll_up(&mut self) {
72 if self.offset > 0 {
73 self.offset -= 1;
74 self.anchored = false;
75 }
76 }
77
78 fn scroll_down(&mut self) {
83 if self.offset < self.max {
84 self.offset += 1;
85 self.anchored = false;
86 }
87 }
88
89 fn set_max(&mut self, max: usize) {
92 self.max = max;
93 self.offset = self.offset.min(max);
94 }
95
96 fn anchor_to(&mut self, offset: usize) {
99 if self.anchored {
100 self.offset = offset.min(self.max);
101 }
102 }
103
104 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
119pub struct PreviewPane {
121 expand: ExpandState,
122 expand_path: Option<VaultPath>,
124 scroll: ContentScroll,
125 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 pub fn clear_header(&mut self) {
164 self.full_header_rect = Rect::default();
165 }
166
167 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 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 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 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 #[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), Constraint::Length(1), Constraint::Min(0), ])
256 .split(inner);
257
258 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 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 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 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 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 pub fn force_user_scrolled(&mut self) {
341 self.scroll.anchored = false;
342 }
343}
344
345fn 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 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 #[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"))); assert!(p.is_context());
423 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"))); 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"))); 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 #[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(); 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(); 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 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 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(); 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}