Skip to main content

tui_file_explorer/
render.rs

1//! Ratatui rendering functions for the file-explorer widget.
2//!
3//! ## Single-pane entry-points
4//!
5//! * [`render`] — renders one [`FileExplorer`] using the built-in [`Theme::default()`] palette.
6//! * [`render_themed`] — same, but accepts a custom [`Theme`].
7//!
8//! ## Dual-pane entry-points
9//!
10//! * [`render_dual_pane`] — renders a [`DualPane`] using the default palette.
11//! * [`render_dual_pane_themed`] — same, but accepts a custom [`Theme`].
12//!
13//! In dual-pane mode the available area is split evenly into two columns, each
14//! rendered as an independent [`FileExplorer`].  The active pane's border is
15//! drawn in the theme's accent colour; the inactive pane's border is dimmed.
16//! In single-pane mode (`dual.single_pane == true`) the full area is given to
17//! the active pane.
18//!
19//! Both families delegate to the same three private helpers (`render_header`,
20//! `render_list`, `render_footer`) that handle the three vertical segments of
21//! each pane area.
22
23use ratatui::{
24    layout::{Alignment, Constraint, Direction, Layout, Rect},
25    style::{Modifier, Style},
26    text::{Line, Span},
27    widgets::{Block, BorderType, Borders, List, ListItem, ListState, Padding, Paragraph},
28    Frame,
29};
30
31use crate::{
32    dual_pane::DualPane,
33    explorer::{entry_icon, fmt_size},
34    palette::Theme,
35    FileExplorer,
36};
37
38// ── render_input_footer! ──────────────────────────────────────────────────────
39
40/// Render an inline text-input footer and early-return from `render_footer`.
41///
42/// Checks `$explorer.$active`; if true, renders a single-line [`Paragraph`]
43/// containing: label span + typed-input span + cursor-block span + hint span,
44/// then calls `frame.render_widget(…, area)` and `return`.
45///
46/// Parameters
47/// ----------
48/// `$explorer`   — `&FileExplorer` reference (named `explorer` in the function)
49/// `$frame`      — `&mut Frame`
50/// `$area`       — `Rect`
51/// `$theme`      — `&Theme`
52/// `$active`     — field name on `$explorer` (e.g. `mkdir_active`)
53/// `$input_expr` — expression that yields the input string (e.g.
54///                 `explorer.mkdir_input()` or `&explorer.search_query`)
55/// `$label`      — string literal for the label span
56/// `$colour`     — `theme` field name for both label and border colour
57/// `$hint`       — string literal for the trailing hint
58macro_rules! render_input_footer {
59    ($explorer:expr, $frame:expr, $area:expr, $theme:expr,
60     $active:ident, $input_expr:expr, $label:expr, $colour:ident, $hint:expr) => {
61        if $explorer.$active {
62            let left_line = Line::from(vec![
63                Span::styled(
64                    $label,
65                    Style::default()
66                        .fg($theme.$colour)
67                        .add_modifier(Modifier::BOLD),
68                ),
69                Span::styled(
70                    $input_expr,
71                    Style::default()
72                        .fg($theme.accent)
73                        .add_modifier(Modifier::BOLD),
74                ),
75                Span::styled("\u{2588}", Style::default().fg($theme.accent)),
76                Span::styled($hint, Style::default().fg($theme.dim)),
77            ]);
78            let para = Paragraph::new(left_line).block(
79                Block::default()
80                    .borders(Borders::ALL)
81                    .border_type(BorderType::Rounded)
82                    .border_style(Style::default().fg($theme.$colour)),
83            );
84            $frame.render_widget(para, $area);
85            return;
86        }
87    };
88}
89
90// ── Public render entry-points ────────────────────────────────────────────────
91
92/// Render the file explorer into `area` using the default colour theme.
93///
94/// This is the simplest rendering entry-point. Call it from your application's
95/// `Terminal::draw` closure, passing a mutable reference to the explorer state
96/// and the current Ratatui [`Frame`].
97///
98/// The widget renders three vertical zones:
99/// * **Header** — current directory path inside a rounded border.
100/// * **List**   — scrollable, highlighted list of directory entries.
101/// * **Footer** — key hints (left) and status / filter info (right).
102///
103/// # Example
104///
105/// ```no_run
106/// # use tui_file_explorer::{FileExplorer, render};
107/// # use ratatui::{Terminal, backend::TestBackend};
108/// # let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
109/// # let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
110/// terminal.draw(|frame| {
111///     render(&mut explorer, frame, frame.area());
112/// }).unwrap();
113/// ```
114pub fn render(explorer: &mut FileExplorer, frame: &mut Frame, area: Rect) {
115    render_themed(explorer, frame, area, &Theme::default());
116}
117
118/// Render a [`DualPane`] into `area` using the default colour theme.
119///
120/// In dual-pane mode the area is split evenly into two columns.  In
121/// single-pane mode (`dual.single_pane == true`) the full area is given to
122/// the active pane.
123///
124/// The active pane's border uses `theme.accent`; the inactive pane's border
125/// uses `theme.dim` so the user always knows which side has focus.
126///
127/// # Example
128///
129/// ```no_run
130/// # use tui_file_explorer::{DualPane, render_dual_pane};
131/// # use ratatui::{Terminal, backend::TestBackend};
132/// # let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
133/// let mut dual = DualPane::builder(std::env::current_dir().unwrap()).build();
134/// terminal.draw(|frame| {
135///     render_dual_pane(&mut dual, frame, frame.area());
136/// }).unwrap();
137/// ```
138pub fn render_dual_pane(dual: &mut DualPane, frame: &mut Frame, area: Rect) {
139    render_dual_pane_themed(dual, frame, area, &Theme::default());
140}
141
142/// Render a [`DualPane`] into `area` with a custom [`Theme`].
143///
144/// This is identical to [`render_dual_pane`] except that you supply the
145/// colour palette.
146///
147/// # Example
148///
149/// ```no_run
150/// # use tui_file_explorer::{DualPane, render_dual_pane_themed, Theme};
151/// # use ratatui::{Terminal, backend::TestBackend};
152/// # let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
153/// let mut dual  = DualPane::builder(std::env::current_dir().unwrap()).build();
154/// let theme = Theme::nord();
155/// terminal.draw(|frame| {
156///     render_dual_pane_themed(&mut dual, frame, frame.area(), &theme);
157/// }).unwrap();
158/// ```
159pub fn render_dual_pane_themed(dual: &mut DualPane, frame: &mut Frame, area: Rect, theme: &Theme) {
160    use crate::dual_pane::DualPaneActive;
161
162    if dual.single_pane {
163        // Full area goes to whichever pane is active.
164        match dual.active_side {
165            DualPaneActive::Left => render_pane(
166                &mut dual.left,
167                frame,
168                area,
169                theme,
170                true, // is_active
171            ),
172            DualPaneActive::Right => render_pane(&mut dual.right, frame, area, theme, true),
173        }
174    } else {
175        // Split evenly: left | right.
176        let halves = Layout::default()
177            .direction(Direction::Horizontal)
178            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
179            .split(area);
180
181        let left_active = dual.active_side == DualPaneActive::Left;
182        render_pane(&mut dual.left, frame, halves[0], theme, left_active);
183        render_pane(&mut dual.right, frame, halves[1], theme, !left_active);
184    }
185}
186
187/// Render a single [`FileExplorer`] pane, dimming the border when inactive.
188fn render_pane(
189    explorer: &mut FileExplorer,
190    frame: &mut Frame,
191    area: Rect,
192    theme: &Theme,
193    is_active: bool,
194) {
195    // Build a locally-adjusted theme copy so the inactive pane has a dimmed
196    // border without altering the caller's theme value.
197    let pane_theme;
198    let effective_theme = if is_active {
199        theme
200    } else {
201        pane_theme = Theme {
202            accent: theme.dim,
203            ..*theme
204        };
205        &pane_theme
206    };
207
208    let chunks = Layout::default()
209        .direction(Direction::Vertical)
210        .constraints([
211            Constraint::Length(3),
212            Constraint::Min(1),
213            Constraint::Length(3),
214        ])
215        .split(area);
216
217    render_header(explorer, frame, chunks[0], effective_theme);
218    render_list(explorer, frame, chunks[1], effective_theme);
219    render_footer(explorer, frame, chunks[2], effective_theme);
220}
221
222/// Render the file explorer into `area` with a custom [`Theme`].
223///
224/// This is identical to [`render`] except that you supply the colour palette.
225/// Construct a [`Theme`] from [`Theme::default()`] and override the fields you
226/// care about, or build one entirely from scratch.
227///
228/// # Example
229///
230/// ```no_run
231/// # use tui_file_explorer::{FileExplorer, render_themed, Theme};
232/// # use ratatui::{Terminal, backend::TestBackend, style::Color};
233/// # let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
234/// # let mut explorer = FileExplorer::new(std::env::current_dir().unwrap(), vec![]);
235/// let theme = Theme::default()
236///     .brand(Color::Magenta)
237///     .accent(Color::Cyan)
238///     .dir(Color::Yellow);
239///
240/// terminal.draw(|frame| {
241///     render_themed(&mut explorer, frame, frame.area(), &theme);
242/// }).unwrap();
243/// ```
244pub fn render_themed(explorer: &mut FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
245    let chunks = Layout::default()
246        .direction(Direction::Vertical)
247        .constraints([
248            Constraint::Length(3),
249            Constraint::Min(1),
250            Constraint::Length(3),
251        ])
252        .split(area);
253
254    render_header(explorer, frame, chunks[0], theme);
255    render_list(explorer, frame, chunks[1], theme);
256    render_footer(explorer, frame, chunks[2], theme);
257}
258
259// ── Header ────────────────────────────────────────────────────────────────────
260
261fn render_header(explorer: &FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
262    let path_str = explorer.current_dir.to_string_lossy();
263
264    // Truncate from the left when the path exceeds available width.
265    let inner_width = area.width.saturating_sub(4) as usize;
266    let display_path = if path_str.len() > inner_width && inner_width > 3 {
267        let skip = path_str.len() - inner_width + 1;
268        format!("\u{2026}{}", &path_str[skip..])
269    } else {
270        path_str.to_string()
271    };
272
273    let version = concat!(" v", env!("CARGO_PKG_VERSION"), " ");
274
275    let mut block = Block::default()
276        .title(Span::styled(
277            " \u{1F4C1}  File Explorer ",
278            Style::default()
279                .fg(theme.brand)
280                .add_modifier(Modifier::BOLD),
281        ))
282        .title_bottom(
283            ratatui::text::Line::from(Span::styled(version, Style::default().fg(theme.dim)))
284                .right_aligned(),
285        )
286        .borders(Borders::ALL)
287        .border_type(BorderType::Rounded)
288        .border_style(Style::default().fg(theme.accent))
289        .padding(Padding::horizontal(1));
290
291    if !explorer.theme_name.is_empty() {
292        let theme_label = format!(" {} ", explorer.theme_name);
293        block = block.title(
294            ratatui::text::Line::from(Span::styled(theme_label, Style::default().fg(theme.dim)))
295                .right_aligned(),
296        );
297    }
298
299    if !explorer.editor_name.is_empty() {
300        let editor_label = format!(" \u{270F}  {} ", explorer.editor_name);
301        block = block.title_bottom(
302            ratatui::text::Line::from(Span::styled(editor_label, Style::default().fg(theme.dim)))
303                .left_aligned(),
304        );
305    }
306
307    let header = Paragraph::new(Span::styled(
308        display_path,
309        Style::default()
310            .fg(theme.accent)
311            .add_modifier(Modifier::BOLD),
312    ))
313    .block(block)
314    .alignment(Alignment::Left);
315
316    frame.render_widget(header, area);
317}
318
319// ── Entry list ────────────────────────────────────────────────────────────────
320
321fn render_list(explorer: &mut FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
322    let visible_height = area.height.saturating_sub(2) as usize;
323
324    // Keep scroll_offset in sync so the cursor is always visible.
325    // All arithmetic uses saturating ops so a zero visible_height or a cursor
326    // of 0 can never underflow the usize values.
327    if explorer.cursor < explorer.scroll_offset {
328        explorer.scroll_offset = explorer.cursor;
329    } else if explorer.cursor >= explorer.scroll_offset.saturating_add(visible_height) {
330        explorer.scroll_offset = explorer
331            .cursor
332            .saturating_sub(visible_height.saturating_sub(1));
333    }
334    // Guard: scroll_offset must never exceed the last valid entry index.
335    let max_scroll = explorer.entries.len().saturating_sub(1);
336    if explorer.scroll_offset > max_scroll {
337        explorer.scroll_offset = max_scroll;
338    }
339
340    let items: Vec<ListItem> = explorer
341        .entries
342        .iter()
343        .skip(explorer.scroll_offset)
344        .take(visible_height)
345        .enumerate()
346        .map(|(visible_idx, entry)| {
347            let abs_idx = visible_idx + explorer.scroll_offset;
348            let is_selected = abs_idx == explorer.cursor;
349            let is_marked = explorer.marked.contains(&entry.path);
350
351            let icon = entry_icon(entry);
352
353            // All visible entries already passed the extension filter in
354            // load_entries, so files are always styled as selectable.
355            let name_style = if is_marked {
356                Style::default()
357                    .fg(theme.brand)
358                    .add_modifier(Modifier::BOLD)
359            } else if entry.is_dir {
360                Style::default().fg(theme.dir).add_modifier(Modifier::BOLD)
361            } else {
362                Style::default()
363                    .fg(theme.match_file)
364                    .add_modifier(Modifier::BOLD)
365            };
366
367            let size_str = match entry.size {
368                Some(b) => fmt_size(b),
369                None => String::new(),
370            };
371
372            // Leading marker: ◆ for marked entries, space otherwise.
373            let marker = if is_marked {
374                Span::styled(
375                    "◆",
376                    Style::default()
377                        .fg(theme.brand)
378                        .add_modifier(Modifier::BOLD),
379                )
380            } else {
381                Span::styled(" ", Style::default())
382            };
383
384            let mut spans = vec![
385                marker,
386                Span::styled(
387                    format!("{icon} "),
388                    Style::default().fg(if entry.is_dir { theme.dir } else { theme.fg }),
389                ),
390                Span::styled(entry.name.clone(), name_style),
391            ];
392
393            if !size_str.is_empty() {
394                spans.push(Span::styled(
395                    format!("  {size_str}"),
396                    Style::default().fg(theme.dim),
397                ));
398            }
399
400            if entry.is_dir {
401                spans.push(Span::styled("/", Style::default().fg(theme.dir)));
402            }
403
404            let line = Line::from(spans);
405            if is_selected {
406                ListItem::new(line).style(
407                    Style::default()
408                        .bg(theme.sel_bg)
409                        .add_modifier(Modifier::BOLD),
410                )
411            } else if is_marked {
412                ListItem::new(line).style(Style::default().add_modifier(Modifier::BOLD))
413            } else {
414                ListItem::new(line)
415            }
416        })
417        .collect();
418
419    let count = explorer.entries.len();
420    let marked_count = explorer.marked.len();
421    let pos = if count == 0 {
422        "empty".to_string()
423    } else {
424        format!("{}/{count}", explorer.cursor + 1)
425    };
426    let title = if marked_count > 0 {
427        format!(" Files {pos}  ◆ {marked_count} marked ")
428    } else {
429        format!(" Files {pos} ")
430    };
431
432    let block = Block::default()
433        .title(Span::styled(title, Style::default().fg(theme.dim)))
434        .borders(Borders::ALL)
435        .border_type(BorderType::Rounded)
436        .border_style(Style::default().fg(theme.accent));
437
438    let mut list_state = ListState::default();
439    if !explorer.entries.is_empty() {
440        list_state.select(Some(explorer.cursor.saturating_sub(explorer.scroll_offset)));
441    }
442
443    let list = List::new(items).block(block);
444    frame.render_stateful_widget(list, area, &mut list_state);
445}
446
447// ── Footer ────────────────────────────────────────────────────────────────────
448
449fn render_footer(explorer: &FileExplorer, frame: &mut Frame, area: Rect, theme: &Theme) {
450    // ── Mkdir input (shown instead of status bar while mkdir mode is active) ──
451    render_input_footer!(
452        explorer,
453        frame,
454        area,
455        theme,
456        mkdir_active,
457        explorer.mkdir_input(),
458        " \u{1F4C2} New folder: ",
459        success,
460        "  Enter confirm  Esc cancel"
461    );
462
463    // ── Touch input (shown instead of status bar while touch mode is active) ──
464    render_input_footer!(
465        explorer,
466        frame,
467        area,
468        theme,
469        touch_active,
470        explorer.touch_input(),
471        " \u{1F4C4} New file: ",
472        accent,
473        "  Enter confirm  Esc cancel"
474    );
475
476    // ── Rename input (shown instead of status bar while rename mode is active) ─
477    render_input_footer!(
478        explorer,
479        frame,
480        area,
481        theme,
482        rename_active,
483        explorer.rename_input(),
484        " \u{270F}\u{FE0F}  Rename: ",
485        brand,
486        "  Enter confirm  Esc cancel"
487    );
488
489    // ── Search input (shown instead of status bar while search is active) ─────
490    render_input_footer!(
491        explorer,
492        frame,
493        area,
494        theme,
495        search_active,
496        explorer.search_query.as_str(),
497        " / ",
498        brand,
499        "  Backspace delete  Esc cancel"
500    );
501
502    // ── Sort / filter status (full width) ─────────────────────────────────────
503    let status = if explorer.status.is_empty() {
504        let filter = if explorer.extension_filter.is_empty() {
505            "all".to_string()
506        } else {
507            explorer
508                .extension_filter
509                .iter()
510                .map(|e| format!(".{e}"))
511                .collect::<Vec<_>>()
512                .join(", ")
513        };
514        let hidden_hint = if explorer.show_hidden { " +hidden" } else { "" };
515        format!(
516            "sort:{} filter:{}{} ",
517            explorer.sort_mode.label(),
518            filter,
519            hidden_hint,
520        )
521    } else {
522        format!(" {} ", explorer.status)
523    };
524
525    let status_para = Paragraph::new(Span::styled(status, Style::default().fg(theme.success)))
526        .alignment(Alignment::Right)
527        .block(
528            Block::default()
529                .borders(Borders::ALL)
530                .border_type(BorderType::Rounded)
531                .border_style(Style::default().fg(theme.dim)),
532        );
533    frame.render_widget(status_para, area);
534}
535
536// ── Tests ─────────────────────────────────────────────────────────────────────
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use ratatui::{backend::TestBackend, Terminal};
542
543    fn make_terminal() -> Terminal<TestBackend> {
544        Terminal::new(TestBackend::new(80, 24)).unwrap()
545    }
546
547    fn make_explorer() -> FileExplorer {
548        FileExplorer::new(std::env::current_dir().unwrap(), vec![])
549    }
550
551    #[test]
552    fn render_footer_mkdir_active_does_not_panic() {
553        let mut terminal = make_terminal();
554        let mut explorer = make_explorer();
555        explorer.mkdir_active = true;
556        terminal
557            .draw(|frame| {
558                render(&mut explorer, frame, frame.area());
559            })
560            .unwrap();
561    }
562
563    #[test]
564    fn render_footer_touch_active_does_not_panic() {
565        let mut terminal = make_terminal();
566        let mut explorer = make_explorer();
567        explorer.touch_active = true;
568        terminal
569            .draw(|frame| {
570                render(&mut explorer, frame, frame.area());
571            })
572            .unwrap();
573    }
574
575    #[test]
576    fn render_footer_rename_active_does_not_panic() {
577        let mut terminal = make_terminal();
578        let mut explorer = make_explorer();
579        explorer.rename_active = true;
580        terminal
581            .draw(|frame| {
582                render(&mut explorer, frame, frame.area());
583            })
584            .unwrap();
585    }
586
587    #[test]
588    fn render_footer_search_active_does_not_panic() {
589        let mut terminal = make_terminal();
590        let mut explorer = make_explorer();
591        explorer.search_active = true;
592        terminal
593            .draw(|frame| {
594                render(&mut explorer, frame, frame.area());
595            })
596            .unwrap();
597    }
598
599    #[test]
600    fn render_footer_all_inactive_does_not_panic() {
601        let mut terminal = make_terminal();
602        let mut explorer = make_explorer();
603        terminal
604            .draw(|frame| {
605                render(&mut explorer, frame, frame.area());
606            })
607            .unwrap();
608    }
609}