1use std::fs::{self, File};
14use std::io::{BufRead, BufReader, Read};
15use std::path::{Path, PathBuf};
16
17use ratatui::{
18 layout::{Alignment, Constraint, Direction, Layout, Rect},
19 style::{Modifier, Style},
20 text::{Line, Span},
21 widgets::{Block, Borders, Paragraph, Wrap},
22 Frame,
23};
24
25use crate::palette::Theme;
26
27const IMAGE_EXTENSIONS: &[&str] = &[
31 "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "tif", "ico", "svg",
32];
33
34const MAX_PREVIEW_BYTES: usize = 512 * 1024; const MAX_PREVIEW_LINES: usize = 10_000;
39
40const BINARY_CHECK_BYTES: usize = 8192;
42
43const BINARY_DUMP_BYTES: usize = 4096;
45
46pub fn is_image_extension(ext: &str) -> bool {
50 let lower = ext.to_ascii_lowercase();
51 IMAGE_EXTENSIONS.iter().any(|e| *e == lower)
52}
53
54fn is_likely_binary(data: &[u8]) -> bool {
57 let limit = data.len().min(BINARY_CHECK_BYTES);
58 data[..limit].contains(&0)
59}
60
61pub enum PreviewContent {
65 Text(TextPreview),
67 Image(Box<ImagePreview>),
69 Binary(BinaryPreview),
71 Directory(DirPreview),
73 Empty,
75 TooLarge {
77 size: u64,
79 },
80 Error(String),
82}
83
84pub struct TextPreview {
86 pub lines: Vec<String>,
88 pub total_lines: usize,
91}
92
93pub struct ImagePreview {
95 pub protocol: std::cell::RefCell<ratatui_image::protocol::StatefulProtocol>,
99}
100
101pub struct BinaryPreview {
103 pub hex_lines: Vec<String>,
105 pub total_bytes: u64,
107}
108
109pub struct DirPreview {
111 pub entry_count: usize,
113 pub file_count: usize,
115 pub dir_count: usize,
117 pub total_size: u64,
119}
120
121pub struct PreviewState {
126 pub cached_path: Option<PathBuf>,
128 pub content: PreviewContent,
130 pub scroll: usize,
133 cached_width: u16,
135 cached_height: u16,
137 picker: ratatui_image::picker::Picker,
139}
140
141impl Default for PreviewState {
142 fn default() -> Self {
143 Self::new()
144 }
145}
146
147impl PreviewState {
148 pub fn new() -> Self {
154 Self::with_picker(ratatui_image::picker::Picker::halfblocks())
155 }
156
157 pub fn with_picker(picker: ratatui_image::picker::Picker) -> Self {
162 Self {
163 cached_path: None,
164 content: PreviewContent::Empty,
165 scroll: 0,
166 cached_width: 0,
167 cached_height: 0,
168 picker,
169 }
170 }
171
172 pub fn protocol_type(&self) -> ratatui_image::picker::ProtocolType {
174 self.picker.protocol_type()
175 }
176
177 pub fn update(&mut self, path: Option<&Path>, area_width: u16, area_height: u16) {
180 let path_changed = match (&self.cached_path, path) {
181 (Some(cached), Some(new)) => cached != new,
182 (None, None) => false,
183 _ => true,
184 };
185
186 let size_changed = self.cached_width != area_width || self.cached_height != area_height;
187
188 if !path_changed && !size_changed {
189 return;
190 }
191
192 self.scroll = 0;
193 self.cached_width = area_width;
194 self.cached_height = area_height;
195
196 match path {
197 Some(p) => {
198 self.cached_path = Some(p.to_path_buf());
199 self.content = load_preview(p, area_width, area_height, &mut self.picker);
200 }
201 None => {
202 self.cached_path = None;
203 self.content = PreviewContent::Empty;
204 }
205 }
206 }
207
208 pub fn scroll_up(&mut self, n: usize) {
210 self.scroll = self.scroll.saturating_sub(n);
211 }
212
213 pub fn scroll_down(&mut self, n: usize) {
215 self.scroll = self.scroll.saturating_add(n);
216 }
217
218 pub fn invalidate(&mut self) {
221 self.cached_path = None;
222 self.cached_width = 0;
223 self.cached_height = 0;
224 }
225}
226
227fn load_preview(
232 path: &Path,
233 _area_width: u16,
234 _area_height: u16,
235 picker: &mut ratatui_image::picker::Picker,
236) -> PreviewContent {
237 let meta = match fs::metadata(path) {
238 Ok(m) => m,
239 Err(e) => return PreviewContent::Error(format!("{e}")),
240 };
241
242 if meta.is_dir() {
243 return load_dir_preview(path);
244 }
245
246 if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
248 if is_image_extension(ext) {
249 return load_image_preview(path, picker);
250 }
251 }
252
253 if meta.len() > MAX_PREVIEW_BYTES as u64 {
255 return PreviewContent::TooLarge { size: meta.len() };
256 }
257
258 load_text_preview(path)
259}
260
261fn load_text_preview(path: &Path) -> PreviewContent {
264 let file = match File::open(path) {
265 Ok(f) => f,
266 Err(e) => return PreviewContent::Error(format!("{e}")),
267 };
268 let mut reader = BufReader::new(file);
269
270 let buf = reader.fill_buf();
272 match buf {
273 Ok(data) => {
274 if is_likely_binary(data) {
275 return load_binary_preview(path);
276 }
277 }
278 Err(e) => return PreviewContent::Error(format!("{e}")),
279 }
280
281 let file = match File::open(path) {
284 Ok(f) => f,
285 Err(e) => return PreviewContent::Error(format!("{e}")),
286 };
287 let reader = BufReader::new(file);
288
289 let mut lines = Vec::new();
290 let mut total_lines = 0usize;
291 for maybe_line in reader.lines() {
292 match maybe_line {
293 Ok(line) => {
294 total_lines += 1;
295 if lines.len() < MAX_PREVIEW_LINES {
296 lines.push(line);
297 }
298 }
299 Err(_) => {
300 return load_binary_preview(path);
302 }
303 }
304 }
305
306 PreviewContent::Text(TextPreview { lines, total_lines })
307}
308
309fn load_image_preview(path: &Path, picker: &mut ratatui_image::picker::Picker) -> PreviewContent {
311 let dyn_img = match image::open(path) {
312 Ok(i) => i,
313 Err(e) => return PreviewContent::Error(format!("image decode: {e}")),
314 };
315
316 let protocol = picker.new_resize_protocol(dyn_img);
317 PreviewContent::Image(Box::new(ImagePreview {
318 protocol: std::cell::RefCell::new(protocol),
319 }))
320}
321
322fn load_binary_preview(path: &Path) -> PreviewContent {
324 let total_bytes = match fs::metadata(path) {
325 Ok(m) => m.len(),
326 Err(e) => return PreviewContent::Error(format!("{e}")),
327 };
328
329 let mut file = match File::open(path) {
330 Ok(f) => f,
331 Err(e) => return PreviewContent::Error(format!("{e}")),
332 };
333
334 let mut buf = vec![0u8; BINARY_DUMP_BYTES];
335 let n = match file.read(&mut buf) {
336 Ok(n) => n,
337 Err(e) => return PreviewContent::Error(format!("{e}")),
338 };
339 buf.truncate(n);
340
341 let hex_lines = format_hex_dump(&buf);
342
343 PreviewContent::Binary(BinaryPreview {
344 hex_lines,
345 total_bytes,
346 })
347}
348
349fn format_hex_dump(data: &[u8]) -> Vec<String> {
351 let mut lines = Vec::with_capacity(data.len().div_ceil(16));
352 for (i, chunk) in data.chunks(16).enumerate() {
353 let offset = i * 16;
354 let mut hex_part = String::with_capacity(48);
355 let mut ascii_part = String::with_capacity(16);
356
357 for (j, &byte) in chunk.iter().enumerate() {
358 if j == 8 {
359 hex_part.push(' ');
360 }
361 hex_part.push_str(&format!("{byte:02x} "));
362
363 ascii_part.push(if byte.is_ascii_graphic() || byte == b' ' {
364 byte as char
365 } else {
366 '.'
367 });
368 }
369
370 while hex_part.len() < 49 {
372 hex_part.push(' ');
373 }
374
375 lines.push(format!("{offset:08x} {hex_part} |{ascii_part}|"));
376 }
377 lines
378}
379
380fn load_dir_preview(path: &Path) -> PreviewContent {
382 let entries = match fs::read_dir(path) {
383 Ok(e) => e,
384 Err(e) => return PreviewContent::Error(format!("{e}")),
385 };
386
387 let mut entry_count = 0usize;
388 let mut file_count = 0usize;
389 let mut dir_count = 0usize;
390 let mut total_size = 0u64;
391
392 for entry in entries.flatten() {
393 entry_count += 1;
394 if let Ok(meta) = entry.metadata() {
395 if meta.is_dir() {
396 dir_count += 1;
397 } else {
398 file_count += 1;
399 total_size += meta.len();
400 }
401 }
402 }
403
404 PreviewContent::Directory(DirPreview {
405 entry_count,
406 file_count,
407 dir_count,
408 total_size,
409 })
410}
411
412pub fn render_preview(frame: &mut Frame, area: Rect, state: &PreviewState, theme: &Theme) {
419 if area.width < 3 || area.height < 3 {
420 return;
421 }
422
423 let chunks = Layout::default()
425 .direction(Direction::Vertical)
426 .constraints([Constraint::Length(1), Constraint::Min(1)])
427 .split(area);
428
429 let header_area = chunks[0];
430 let content_area = chunks[1];
431
432 let title = match &state.cached_path {
434 Some(p) => p
435 .file_name()
436 .map(|n| n.to_string_lossy().to_string())
437 .unwrap_or_else(|| p.display().to_string()),
438 None => String::from("No selection"),
439 };
440
441 let proto_hint = if matches!(&state.content, PreviewContent::Image(_)) {
443 let proto = state.protocol_type();
444 format!(" [{proto:?}]")
445 } else {
446 String::new()
447 };
448
449 let header = Paragraph::new(Line::from(vec![
450 Span::styled(
451 format!(" {title}"),
452 Style::default()
453 .fg(theme.accent)
454 .add_modifier(Modifier::BOLD),
455 ),
456 Span::styled(proto_hint, Style::default().fg(theme.dim)),
457 ]))
458 .alignment(Alignment::Left);
459 frame.render_widget(header, header_area);
460
461 match &state.content {
463 PreviewContent::Text(tp) => render_text(frame, content_area, tp, state.scroll, theme),
464 PreviewContent::Image(ip) => render_image(frame, content_area, ip, theme),
465 PreviewContent::Binary(bp) => render_binary(frame, content_area, bp, state.scroll, theme),
466 PreviewContent::Directory(dp) => render_directory(frame, content_area, dp, theme),
467 PreviewContent::Empty => render_info(frame, content_area, "Nothing selected", theme),
468 PreviewContent::TooLarge { size } => {
469 let msg = format!("File too large for preview ({} bytes)", size);
470 render_info(frame, content_area, &msg, theme);
471 }
472 PreviewContent::Error(msg) => render_info(frame, content_area, msg, theme),
473 }
474}
475
476fn render_text(frame: &mut Frame, area: Rect, preview: &TextPreview, scroll: usize, theme: &Theme) {
480 let gutter_width = 5u16; if area.width <= gutter_width {
482 return;
483 }
484
485 let visible = area.height as usize;
486 let start = scroll.min(preview.lines.len().saturating_sub(1));
487 let end = (start + visible).min(preview.lines.len());
488
489 let lines: Vec<Line<'_>> = preview.lines[start..end]
490 .iter()
491 .enumerate()
492 .map(|(i, text)| {
493 let line_no = start + i + 1;
494 Line::from(vec![
495 Span::styled(format!("{line_no:>4} "), Style::default().fg(theme.dim)),
496 Span::styled(text.as_str(), Style::default().fg(theme.fg)),
497 ])
498 })
499 .collect();
500
501 let paragraph = Paragraph::new(lines);
502 frame.render_widget(paragraph, area);
503
504 crate::render::paint_scrollbar(frame, area, preview.total_lines, scroll, theme.accent);
506}
507
508fn render_image(frame: &mut Frame, area: Rect, preview: &ImagePreview, theme: &Theme) {
510 let block = Block::default()
511 .borders(Borders::ALL)
512 .border_style(Style::default().fg(theme.dim))
513 .title(Span::styled(
514 " Image Preview ",
515 Style::default().fg(theme.dim),
516 ));
517
518 let inner = block.inner(area);
519 frame.render_widget(block, area);
520
521 let image_widget = ratatui_image::StatefulImage::default();
522 frame.render_stateful_widget(image_widget, inner, &mut *preview.protocol.borrow_mut());
523}
524
525fn render_binary(
527 frame: &mut Frame,
528 area: Rect,
529 preview: &BinaryPreview,
530 scroll: usize,
531 theme: &Theme,
532) {
533 let visible = area.height as usize;
534 let start = scroll.min(preview.hex_lines.len().saturating_sub(1));
535 let end = (start + visible).min(preview.hex_lines.len());
536
537 let lines: Vec<Line<'_>> = preview.hex_lines[start..end]
538 .iter()
539 .map(|l| Line::from(Span::styled(l.as_str(), Style::default().fg(theme.fg))))
540 .collect();
541
542 let footer = Line::from(Span::styled(
543 format!(" total: {} bytes", preview.total_bytes),
544 Style::default().fg(theme.dim),
545 ));
546
547 let mut all = lines;
548 all.push(Line::from(""));
549 all.push(footer);
550
551 let paragraph = Paragraph::new(all);
552 frame.render_widget(paragraph, area);
553
554 crate::render::paint_scrollbar(frame, area, preview.hex_lines.len(), scroll, theme.accent);
556}
557
558fn render_directory(frame: &mut Frame, area: Rect, preview: &DirPreview, theme: &Theme) {
560 let info = vec![
561 Line::from(vec![
562 Span::styled(" Entries: ", Style::default().fg(theme.dim)),
563 Span::styled(
564 preview.entry_count.to_string(),
565 Style::default().fg(theme.fg),
566 ),
567 ]),
568 Line::from(vec![
569 Span::styled(" Files: ", Style::default().fg(theme.dim)),
570 Span::styled(
571 preview.file_count.to_string(),
572 Style::default().fg(theme.fg),
573 ),
574 ]),
575 Line::from(vec![
576 Span::styled(" Dirs: ", Style::default().fg(theme.dim)),
577 Span::styled(preview.dir_count.to_string(), Style::default().fg(theme.fg)),
578 ]),
579 Line::from(vec![
580 Span::styled(" Size: ", Style::default().fg(theme.dim)),
581 Span::styled(
582 format_size(preview.total_size),
583 Style::default().fg(theme.fg),
584 ),
585 ]),
586 ];
587
588 let paragraph = Paragraph::new(info);
589 frame.render_widget(paragraph, area);
590}
591
592fn render_info(frame: &mut Frame, area: Rect, message: &str, theme: &Theme) {
594 let paragraph = Paragraph::new(Line::from(Span::styled(
595 message,
596 Style::default().fg(theme.dim),
597 )))
598 .alignment(Alignment::Center)
599 .wrap(Wrap { trim: false });
600
601 frame.render_widget(paragraph, area);
602}
603
604fn format_size(bytes: u64) -> String {
606 const KB: u64 = 1024;
607 const MB: u64 = 1024 * KB;
608 const GB: u64 = 1024 * MB;
609 if bytes >= GB {
610 format!("{:.1} GB", bytes as f64 / GB as f64)
611 } else if bytes >= MB {
612 format!("{:.1} MB", bytes as f64 / MB as f64)
613 } else if bytes >= KB {
614 format!("{:.1} KB", bytes as f64 / KB as f64)
615 } else {
616 format!("{bytes} B")
617 }
618}
619
620#[cfg(test)]
623mod tests {
624 use super::*;
625 use ratatui::{backend::TestBackend, Terminal};
626
627 fn make_terminal() -> Terminal<TestBackend> {
630 Terminal::new(TestBackend::new(80, 24)).unwrap()
631 }
632
633 fn default_theme() -> Theme {
634 Theme::default()
635 }
636
637 fn make_tiny_png() -> Vec<u8> {
639 let mut img = image::RgbImage::new(2, 2);
640 img.put_pixel(0, 0, image::Rgb([255, 0, 0]));
641 img.put_pixel(1, 0, image::Rgb([0, 255, 0]));
642 img.put_pixel(0, 1, image::Rgb([0, 0, 255]));
643 img.put_pixel(1, 1, image::Rgb([255, 255, 0]));
644 let mut buf = Vec::new();
645 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
646 use image::ImageEncoder;
647 encoder
648 .write_image(&img, 2, 2, image::ExtendedColorType::Rgb8)
649 .unwrap();
650 buf
651 }
652
653 #[test]
656 fn is_image_extension_png() {
657 assert!(is_image_extension("png"));
658 }
659
660 #[test]
661 fn is_image_extension_jpg() {
662 assert!(is_image_extension("jpg"));
663 }
664
665 #[test]
666 fn is_image_extension_jpeg() {
667 assert!(is_image_extension("jpeg"));
668 }
669
670 #[test]
671 fn is_image_extension_case_insensitive() {
672 assert!(is_image_extension("PNG"));
673 assert!(is_image_extension("Jpg"));
674 assert!(is_image_extension("WEBP"));
675 }
676
677 #[test]
678 fn is_image_extension_unknown_returns_false() {
679 assert!(!is_image_extension("rs"));
680 assert!(!is_image_extension("txt"));
681 assert!(!is_image_extension(""));
682 }
683
684 #[test]
685 fn is_likely_binary_detects_null_bytes() {
686 let data = b"hello\x00world";
687 assert!(is_likely_binary(data));
688 }
689
690 #[test]
691 fn is_likely_binary_text_returns_false() {
692 let data = b"just some plain text\n";
693 assert!(!is_likely_binary(data));
694 }
695
696 #[test]
699 fn preview_state_new_has_empty_content() {
700 let state = PreviewState::new();
701 assert!(state.cached_path.is_none());
702 assert!(matches!(state.content, PreviewContent::Empty));
703 assert_eq!(state.scroll, 0);
704 }
705
706 #[test]
707 fn preview_state_invalidate_clears_cache() {
708 let mut state = PreviewState::new();
709 state.cached_path = Some(PathBuf::from("/tmp/dummy"));
710 state.cached_width = 80;
711 state.cached_height = 24;
712 state.invalidate();
713 assert!(state.cached_path.is_none());
714 assert_eq!(state.cached_width, 0);
715 assert_eq!(state.cached_height, 0);
716 }
717
718 #[test]
719 fn preview_state_scroll_up_clamps_at_zero() {
720 let mut state = PreviewState::new();
721 state.scroll = 3;
722 state.scroll_up(5);
723 assert_eq!(state.scroll, 0);
724 }
725
726 #[test]
727 fn preview_state_scroll_down_increments() {
728 let mut state = PreviewState::new();
729 state.scroll_down(7);
730 assert_eq!(state.scroll, 7);
731 state.scroll_down(3);
732 assert_eq!(state.scroll, 10);
733 }
734
735 #[test]
738 fn load_text_preview_reads_file_content() {
739 let dir = tempfile::tempdir().unwrap();
740 let file_path = dir.path().join("hello.txt");
741 fs::write(&file_path, "line one\nline two\nline three\n").unwrap();
742
743 match load_text_preview(&file_path) {
744 PreviewContent::Text(tp) => {
745 assert_eq!(tp.lines.len(), 3);
746 assert_eq!(tp.lines[0], "line one");
747 assert_eq!(tp.lines[2], "line three");
748 assert_eq!(tp.total_lines, 3);
749 }
750 other => panic!("expected Text, got {:?}", content_variant(&other)),
751 }
752 }
753
754 #[test]
755 fn load_text_preview_limits_lines() {
756 let dir = tempfile::tempdir().unwrap();
757 let file_path = dir.path().join("big.txt");
758 let content: String = (0..MAX_PREVIEW_LINES + 500)
759 .map(|i| format!("line {i}\n"))
760 .collect();
761 fs::write(&file_path, &content).unwrap();
762
763 match load_text_preview(&file_path) {
764 PreviewContent::Text(tp) => {
765 assert_eq!(tp.lines.len(), MAX_PREVIEW_LINES);
766 assert!(tp.total_lines >= MAX_PREVIEW_LINES + 500);
767 }
768 other => panic!("expected Text, got {:?}", content_variant(&other)),
769 }
770 }
771
772 #[test]
773 fn load_text_preview_nonexistent_returns_error() {
774 let result = load_text_preview(Path::new("/nonexistent/path/file.txt"));
775 assert!(
776 matches!(result, PreviewContent::Error(_)),
777 "expected Error variant"
778 );
779 }
780
781 #[test]
784 fn load_binary_preview_formats_hex_lines() {
785 let dir = tempfile::tempdir().unwrap();
786 let file_path = dir.path().join("data.bin");
787 let data: Vec<u8> = (0u8..=255).collect();
788 fs::write(&file_path, &data).unwrap();
789
790 match load_binary_preview(&file_path) {
791 PreviewContent::Binary(bp) => {
792 assert!(!bp.hex_lines.is_empty());
793 assert_eq!(bp.total_bytes, 256);
794 assert!(bp.hex_lines[0].starts_with("00000000"));
796 }
797 other => panic!("expected Binary, got {:?}", content_variant(&other)),
798 }
799 }
800
801 #[test]
804 fn load_dir_preview_counts_entries() {
805 let dir = tempfile::tempdir().unwrap();
806 fs::write(dir.path().join("a.txt"), "aaa").unwrap();
807 fs::write(dir.path().join("b.txt"), "bbb").unwrap();
808 fs::create_dir(dir.path().join("subdir")).unwrap();
809
810 match load_dir_preview(dir.path()) {
811 PreviewContent::Directory(dp) => {
812 assert_eq!(dp.entry_count, 3);
813 assert_eq!(dp.file_count, 2);
814 assert_eq!(dp.dir_count, 1);
815 assert!(dp.total_size > 0);
816 }
817 other => panic!("expected Directory, got {:?}", content_variant(&other)),
818 }
819 }
820
821 #[test]
824 fn load_image_preview_decodes_and_resizes() {
825 let dir = tempfile::tempdir().expect("tempdir");
826 let png_path = dir.path().join("tiny.png");
827 std::fs::write(&png_path, make_tiny_png()).expect("write png");
828
829 let mut picker = ratatui_image::picker::Picker::halfblocks();
830 let content = load_image_preview(&png_path, &mut picker);
831 assert!(
832 matches!(content, PreviewContent::Image(_)),
833 "expected Image variant"
834 );
835 }
836
837 #[test]
840 fn render_preview_text_does_not_panic() {
841 let mut terminal = make_terminal();
842 let theme = default_theme();
843 let state = PreviewState {
844 cached_path: Some(PathBuf::from("test.rs")),
845 content: PreviewContent::Text(TextPreview {
846 lines: vec!["fn main() {}".to_string(), "// done".to_string()],
847 total_lines: 2,
848 }),
849 scroll: 0,
850 cached_width: 80,
851 cached_height: 24,
852 picker: ratatui_image::picker::Picker::halfblocks(),
853 };
854 terminal
855 .draw(|frame| render_preview(frame, frame.area(), &state, &theme))
856 .unwrap();
857 }
858
859 #[test]
860 fn render_preview_empty_does_not_panic() {
861 let mut terminal = make_terminal();
862 let theme = default_theme();
863 let state = PreviewState::new();
864 terminal
865 .draw(|frame| render_preview(frame, frame.area(), &state, &theme))
866 .unwrap();
867 }
868
869 #[test]
870 fn render_preview_error_does_not_panic() {
871 let mut terminal = make_terminal();
872 let theme = default_theme();
873 let state = PreviewState {
874 cached_path: None,
875 content: PreviewContent::Error("something broke".to_string()),
876 scroll: 0,
877 cached_width: 80,
878 cached_height: 24,
879 picker: ratatui_image::picker::Picker::halfblocks(),
880 };
881 terminal
882 .draw(|frame| render_preview(frame, frame.area(), &state, &theme))
883 .unwrap();
884 }
885
886 #[test]
887 fn render_preview_binary_does_not_panic() {
888 let mut terminal = make_terminal();
889 let theme = default_theme();
890 let state = PreviewState {
891 cached_path: Some(PathBuf::from("data.bin")),
892 content: PreviewContent::Binary(BinaryPreview {
893 hex_lines: vec![
894 "00000000 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f |................|"
895 .to_string(),
896 ],
897 total_bytes: 16,
898 }),
899 scroll: 0,
900 cached_width: 80,
901 cached_height: 24,
902 picker: ratatui_image::picker::Picker::halfblocks(),
903 };
904 terminal
905 .draw(|frame| render_preview(frame, frame.area(), &state, &theme))
906 .unwrap();
907 }
908
909 #[test]
910 fn render_preview_directory_does_not_panic() {
911 let mut terminal = make_terminal();
912 let theme = default_theme();
913 let state = PreviewState {
914 cached_path: Some(PathBuf::from("/tmp")),
915 content: PreviewContent::Directory(DirPreview {
916 entry_count: 5,
917 file_count: 3,
918 dir_count: 2,
919 total_size: 12345,
920 }),
921 scroll: 0,
922 cached_width: 80,
923 cached_height: 24,
924 picker: ratatui_image::picker::Picker::halfblocks(),
925 };
926 terminal
927 .draw(|frame| render_preview(frame, frame.area(), &state, &theme))
928 .unwrap();
929 }
930
931 #[test]
932 fn render_preview_image_does_not_panic() {
933 let dir = tempfile::tempdir().expect("tempdir");
934 let png_path = dir.path().join("tiny.png");
935 std::fs::write(&png_path, make_tiny_png()).expect("write png");
936
937 let mut picker = ratatui_image::picker::Picker::halfblocks();
938 let content = load_image_preview(&png_path, &mut picker);
939
940 let state = PreviewState {
941 cached_path: Some(png_path),
942 content,
943 scroll: 0,
944 cached_width: 80,
945 cached_height: 24,
946 picker,
947 };
948
949 let mut terminal = make_terminal();
950 let theme = default_theme();
951 terminal
952 .draw(|frame| render_preview(frame, frame.area(), &state, &theme))
953 .expect("draw");
954 }
955
956 #[test]
964 fn text_preview_works_with_halfblocks_picker() {
965 let dir = tempfile::tempdir().expect("tempdir");
966 let file = dir.path().join("hello.txt");
967 std::fs::write(&file, "line one\nline two\n").unwrap();
968
969 let mut state = PreviewState::new(); state.update(Some(&file), 80, 24);
971
972 assert!(
973 matches!(state.content, PreviewContent::Text(_)),
974 "expected Text, got {}",
975 content_variant(&state.content)
976 );
977 }
978
979 #[test]
980 fn binary_preview_works_with_halfblocks_picker() {
981 let dir = tempfile::tempdir().expect("tempdir");
982 let file = dir.path().join("blob.bin");
983 std::fs::write(&file, b"\x00\x01\x02\x03\x04").unwrap();
984
985 let mut state = PreviewState::new();
986 state.update(Some(&file), 80, 24);
987
988 assert!(
989 matches!(state.content, PreviewContent::Binary(_)),
990 "expected Binary, got {}",
991 content_variant(&state.content)
992 );
993 }
994
995 #[test]
996 fn directory_preview_works_with_halfblocks_picker() {
997 let dir = tempfile::tempdir().expect("tempdir");
998 std::fs::write(dir.path().join("a.txt"), b"a").unwrap();
999
1000 let mut state = PreviewState::new();
1001 state.update(Some(dir.path()), 80, 24);
1002
1003 assert!(
1004 matches!(state.content, PreviewContent::Directory(_)),
1005 "expected Directory, got {}",
1006 content_variant(&state.content)
1007 );
1008 }
1009
1010 #[test]
1011 fn image_preview_works_with_halfblocks_picker() {
1012 let dir = tempfile::tempdir().expect("tempdir");
1013 let png_path = dir.path().join("pic.png");
1014 std::fs::write(&png_path, make_tiny_png()).unwrap();
1015
1016 let mut state = PreviewState::new(); state.update(Some(&png_path), 80, 24);
1018
1019 assert!(
1020 matches!(state.content, PreviewContent::Image(_)),
1021 "expected Image, got {}",
1022 content_variant(&state.content)
1023 );
1024 }
1025
1026 #[test]
1027 fn preview_state_default_uses_halfblocks() {
1028 let state = PreviewState::new();
1029 assert_eq!(
1030 state.protocol_type(),
1031 ratatui_image::picker::ProtocolType::Halfblocks,
1032 );
1033 }
1034
1035 fn content_variant(c: &PreviewContent) -> &'static str {
1040 match c {
1041 PreviewContent::Text(_) => "Text",
1042 PreviewContent::Image(_) => "Image",
1043 PreviewContent::Binary(_) => "Binary",
1044 PreviewContent::Directory(_) => "Directory",
1045 PreviewContent::Empty => "Empty",
1046 PreviewContent::TooLarge { .. } => "TooLarge",
1047 PreviewContent::Error(_) => "Error",
1048 }
1049 }
1050}