Skip to main content

tui_file_explorer/
preview.rs

1//! File preview pane for the TUI file explorer.
2//!
3//! Supports four content kinds:
4//!
5//! * **Text** — UTF-8 files with line numbers and scrolling.
6//! * **Image** — raster images rendered via Unicode half-block (`▀`) pixels.
7//! * **Binary** — classic hex dump (16 bytes per line, ASCII sidebar).
8//! * **Directory** — entry / file / dir counts and total size.
9//!
10//! The public entry-points are [`PreviewState`] (owns cached content and scroll
11//! position) and [`render_preview`] (draws into a [`ratatui::Frame`]).
12
13use 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
27// ── Constants ─────────────────────────────────────────────────────────────────
28
29/// File extensions recognised as raster images.
30const IMAGE_EXTENSIONS: &[&str] = &[
31    "png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff", "tif", "ico", "svg",
32];
33
34/// Maximum number of bytes read for a text preview.
35const MAX_PREVIEW_BYTES: usize = 512 * 1024; // 512 KB
36
37/// Maximum number of lines kept for a text preview.
38const MAX_PREVIEW_LINES: usize = 10_000;
39
40/// Number of leading bytes inspected when deciding "text vs binary".
41const BINARY_CHECK_BYTES: usize = 8192;
42
43/// Number of raw bytes shown in the binary hex-dump view.
44const BINARY_DUMP_BYTES: usize = 4096;
45
46// ── File-type helpers ─────────────────────────────────────────────────────────
47
48/// Returns `true` when `ext` (case-insensitive) matches a known image format.
49pub fn is_image_extension(ext: &str) -> bool {
50    let lower = ext.to_ascii_lowercase();
51    IMAGE_EXTENSIONS.iter().any(|e| *e == lower)
52}
53
54/// Heuristic: a buffer is "likely binary" when it contains at least one null
55/// byte within the first [`BINARY_CHECK_BYTES`].
56fn is_likely_binary(data: &[u8]) -> bool {
57    let limit = data.len().min(BINARY_CHECK_BYTES);
58    data[..limit].contains(&0)
59}
60
61// ── Preview content types ─────────────────────────────────────────────────────
62
63/// The payload of a preview pane.
64pub enum PreviewContent {
65    /// UTF-8 text with line data.
66    Text(TextPreview),
67    /// Decoded raster image (RGB pixels, already resized to fit the area).
68    Image(Box<ImagePreview>),
69    /// Hex dump of the first few kilobytes.
70    Binary(BinaryPreview),
71    /// Directory statistics.
72    Directory(DirPreview),
73    /// Nothing selected.
74    Empty,
75    /// File too large for a text preview.
76    TooLarge {
77        /// Size in bytes.
78        size: u64,
79    },
80    /// An I/O or decode error.
81    Error(String),
82}
83
84/// Text file preview data.
85pub struct TextPreview {
86    /// Individual lines (no trailing newline).
87    pub lines: Vec<String>,
88    /// Total number of lines in the file (may exceed `lines.len()` when
89    /// truncated by [`MAX_PREVIEW_LINES`]).
90    pub total_lines: usize,
91}
92
93/// Image rendered via ratatui-image (supports Kitty/Sixel/iTerm2/halfblocks).
94pub struct ImagePreview {
95    /// Stateful protocol handle used by `ratatui-image` to render the image.
96    /// Wrapped in a `RefCell` so we can obtain `&mut` during rendering even
97    /// when the preview state is shared.
98    pub protocol: std::cell::RefCell<ratatui_image::protocol::StatefulProtocol>,
99}
100
101/// Binary hex-dump preview data.
102pub struct BinaryPreview {
103    /// Pre-formatted hex lines (offset + hex + ASCII).
104    pub hex_lines: Vec<String>,
105    /// Total size of the file in bytes.
106    pub total_bytes: u64,
107}
108
109/// Directory statistics.
110pub struct DirPreview {
111    /// Number of direct children.
112    pub entry_count: usize,
113    /// Number of child files.
114    pub file_count: usize,
115    /// Number of child directories.
116    pub dir_count: usize,
117    /// Cumulative size of direct child files (non-recursive).
118    pub total_size: u64,
119}
120
121// ── PreviewState ──────────────────────────────────────────────────────────────
122
123/// Owns the cached preview content, the path it was generated for, and the
124/// current scroll offset.
125pub struct PreviewState {
126    /// Path for which the current content was loaded, or `None`.
127    pub cached_path: Option<PathBuf>,
128    /// The loaded content.
129    pub content: PreviewContent,
130    /// Vertical scroll offset (in lines / hex rows / pixel-rows, depending on
131    /// content kind).
132    pub scroll: usize,
133    /// Width of the area the content was rendered into last time.
134    cached_width: u16,
135    /// Height of the area the content was rendered into last time.
136    cached_height: u16,
137    /// Image protocol picker (detects best protocol for the terminal).
138    picker: ratatui_image::picker::Picker,
139}
140
141impl Default for PreviewState {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl PreviewState {
148    /// Create a blank preview state.
149    ///
150    /// Falls back to halfblocks because `from_query_stdio` must be called
151    /// **before** the terminal enters the alternate screen.  Use
152    /// [`PreviewState::with_picker`] when you can create the picker early.
153    pub fn new() -> Self {
154        Self::with_picker(ratatui_image::picker::Picker::halfblocks())
155    }
156
157    /// Create a preview state with a pre-initialised image-protocol picker.
158    ///
159    /// Call [`ratatui_image::picker::Picker::from_query_stdio`] (or similar)
160    /// **before** entering the alternate screen and pass the result here.
161    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    /// Return the detected image protocol type (for diagnostics / UI display).
173    pub fn protocol_type(&self) -> ratatui_image::picker::ProtocolType {
174        self.picker.protocol_type()
175    }
176
177    /// Reload the preview if the path changed or the available area was
178    /// resized.  When neither changed, this is a no-op.
179    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    /// Scroll the preview up by `n` rows (clamped at zero).
209    pub fn scroll_up(&mut self, n: usize) {
210        self.scroll = self.scroll.saturating_sub(n);
211    }
212
213    /// Scroll the preview down by `n` rows.
214    pub fn scroll_down(&mut self, n: usize) {
215        self.scroll = self.scroll.saturating_add(n);
216    }
217
218    /// Mark the cache as stale so that the next [`update`](Self::update) call
219    /// reloads even if the path has not changed.
220    pub fn invalidate(&mut self) {
221        self.cached_path = None;
222        self.cached_width = 0;
223        self.cached_height = 0;
224    }
225}
226
227// ── Loading helpers (private) ─────────────────────────────────────────────────
228
229/// Top-level dispatcher: decide which loader to call based on metadata and
230/// extension.
231fn 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    // Check for image extension.
247    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    // Guard against very large files.
254    if meta.len() > MAX_PREVIEW_BYTES as u64 {
255        return PreviewContent::TooLarge { size: meta.len() };
256    }
257
258    load_text_preview(path)
259}
260
261/// Read a UTF-8 text file, capping at [`MAX_PREVIEW_LINES`].  Falls back to
262/// binary preview if null bytes are detected in the first chunk.
263fn 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    // Sniff the first bytes for binary content.
271    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    // Re-open from the start (fill_buf does not consume, but to be safe with
282    // large files we re-open).
283    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                // Non-UTF-8 — fall back to binary.
301                return load_binary_preview(path);
302            }
303        }
304    }
305
306    PreviewContent::Text(TextPreview { lines, total_lines })
307}
308
309/// Decode a raster image and create a stateful protocol for rendering.
310fn 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
322/// Read the first [`BINARY_DUMP_BYTES`] and format a classic hex dump.
323fn 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
349/// Produce hex-dump lines: `OFFSET  HH HH … HH  |ASCII...|`
350fn 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        // Pad the hex section to a fixed width (49 chars: 16*3 + 1 mid-space).
371        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
380/// Walk the immediate children of a directory and gather counts + sizes.
381fn 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
412// ── Public rendering entry-point ──────────────────────────────────────────────
413
414/// Render the preview pane into `area`.
415///
416/// Draws a single-line header (file name) and the content below.  The
417/// appearance is driven by `theme`.
418pub 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    // ── Split: header (1 line) + content ──────────────────────────────────
424    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    // ── Header ────────────────────────────────────────────────────────────
433    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    // Show detected image protocol in the header when previewing an image.
442    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    // ── Content ───────────────────────────────────────────────────────────
462    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
476// ── Content renderers (private) ───────────────────────────────────────────────
477
478/// Line-numbered text with scroll offset.
479fn render_text(frame: &mut Frame, area: Rect, preview: &TextPreview, scroll: usize, theme: &Theme) {
480    let gutter_width = 5u16; // "NNNN " — 4 digits + space
481    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    // Scroll thumb.
505    crate::render::paint_scrollbar(frame, area, preview.total_lines, scroll, theme.accent);
506}
507
508/// Image inside a bordered block, rendered via ratatui-image.
509fn 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
525/// Hex-dump paragraph with scroll.
526fn 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    // Scroll thumb.
555    crate::render::paint_scrollbar(frame, area, preview.hex_lines.len(), scroll, theme.accent);
556}
557
558/// Directory statistics.
559fn 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
592/// Generic info / error message centred in the area.
593fn 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
604/// Human-readable byte size (simple, no external crate).
605fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use ratatui::{backend::TestBackend, Terminal};
626
627    // ── Helpers ───────────────────────────────────────────────────────────
628
629    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    /// Create a tiny 2×2 RGBA PNG in memory for image tests.
638    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    // ── File-type detection ───────────────────────────────────────────────
654
655    #[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    // ── PreviewState ──────────────────────────────────────────────────────
697
698    #[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    // ── TextPreview ───────────────────────────────────────────────────────
736
737    #[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    // ── BinaryPreview ─────────────────────────────────────────────────────
782
783    #[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                // First line should start with "00000000"
795                assert!(bp.hex_lines[0].starts_with("00000000"));
796            }
797            other => panic!("expected Binary, got {:?}", content_variant(&other)),
798        }
799    }
800
801    // ── DirPreview ────────────────────────────────────────────────────────
802
803    #[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    // ── ImagePreview ──────────────────────────────────────────────────────
822
823    #[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    // ── Rendering smoke tests ─────────────────────────────────────────────
838
839    #[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    // ── Lazy picker / no-query tests ──────────────────────────────────────
957    //
958    // These tests verify that previews work correctly with the default
959    // halfblocks picker (i.e. without calling `from_query_stdio`).  This is
960    // the code path taken when stdout is piped and the eager protocol query
961    // is skipped to avoid a 2-second timeout and a raw-mode race condition.
962
963    #[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(); // halfblocks, no query
970        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(); // halfblocks — no from_query_stdio
1017        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    // ── Test utility ──────────────────────────────────────────────────────
1036
1037    /// Return a printable label for a [`PreviewContent`] variant (for error
1038    /// messages in assertions).
1039    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}