Skip to main content

tui_file_explorer/ui/
mod.rs

1//! Terminal UI drawing functions for the `tfe` binary.
2//!
3//! All [`ratatui`] rendering that is specific to the two-pane application
4//! lives here. The per-pane widget rendering (header, list, footer) remains in
5//! the library's own [`mod@crate::render`] module.
6//!
7//! Public entry-points:
8//!
9//! * [`draw`]               — top-level draw callback passed to `Terminal::draw`.
10//! * [`render_theme_panel`] — the slide-in theme-picker side panel.
11//! * [`render_action_bar`]  — the bottom status / key-hint bar.
12//! * [`render_modal`]       — the blocking confirmation dialog overlay.
13
14use crate::{Theme, render_themed};
15use ratatui::{
16    Frame,
17    layout::{Alignment, Constraint, Direction, Layout, Rect},
18    style::{Color, Modifier, Style},
19    text::{Line, Span},
20    widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
21};
22
23use crate::app::{App, CopyProgress, Modal, Snackbar};
24use crate::inline_editor::render_inline_editor;
25use crate::preview::render_preview;
26use tui_slider::{Slider, SliderOrientation, SliderState, style::SliderStyle};
27
28// ── Styled-span helpers ───────────────────────────────────────────────────────
29
30/// Create a bold-accent styled span for key-binding labels.
31fn key_span<'a>(s: &'a str, theme: &Theme) -> Span<'a> {
32    Span::styled(
33        s,
34        Style::default()
35            .fg(theme.accent)
36            .add_modifier(Modifier::BOLD),
37    )
38}
39
40/// Create a dim styled span for descriptions.
41fn dim_span<'a>(s: &'a str, theme: &Theme) -> Span<'a> {
42    Span::styled(s, Style::default().fg(theme.dim))
43}
44
45// ── Top-level draw ────────────────────────────────────────────────────────────
46
47/// Arrangement of paired action-bar hint cells.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum HintLayout {
50    /// Stack hint cells when the available area is narrower than 90 columns.
51    #[default]
52    Auto,
53    /// `Navigate | File Ops` and `Global | Status` remain side by side.
54    Horizontal,
55    /// Stack each pair vertically for narrow embedded layouts.
56    Vertical,
57}
58
59impl HintLayout {
60    /// Turn [`Auto`](Self::Auto) into the concrete layout for `width` columns.
61    #[must_use]
62    pub fn resolve(self, width: u16) -> Self {
63        match self {
64            Self::Auto if width < 90 => Self::Vertical,
65            Self::Auto => Self::Horizontal,
66            layout => layout,
67        }
68    }
69
70    /// Height of the action bar in rows at `width` columns.
71    #[must_use]
72    pub fn action_bar_rows(self, width: u16) -> u16 {
73        match self.resolve(width) {
74            Self::Horizontal => 6,
75            Self::Vertical => 12,
76            Self::Auto => unreachable!(),
77        }
78    }
79
80    /// Height of one row of hint cells at `width` columns.
81    #[must_use]
82    pub fn row_height(self, width: u16) -> u16 {
83        self.action_bar_rows(width) / 2
84    }
85}
86
87/// Layout controls for embedding the full application UI.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct AppViewOptions {
90    /// Render the six-row navigation and status area.
91    pub show_action_bar: bool,
92    /// Render the debug panel when `App::verbose` is enabled.
93    pub show_debug_panel: bool,
94    /// Width of the theme side panel.
95    pub theme_panel_width: u16,
96    /// Width of the options and editor side panels.
97    pub settings_panel_width: u16,
98    /// Percentage reserved for all explorer panes when preview is visible.
99    pub panes_with_preview_percent: u16,
100    /// Minimum target width per visible pane. Extra panes remain open and the
101    /// visible window follows the active pane.
102    pub minimum_pane_width: u16,
103    /// Arrangement of action-bar hint cells.
104    pub hint_layout: HintLayout,
105}
106
107impl Default for AppViewOptions {
108    fn default() -> Self {
109        Self {
110            show_action_bar: true,
111            show_debug_panel: true,
112            theme_panel_width: 32,
113            settings_panel_width: 42,
114            panes_with_preview_percent: 50,
115            minimum_pane_width: 24,
116            hint_layout: HintLayout::Auto,
117        }
118    }
119}
120
121/// Draw the complete application into the full terminal frame.
122pub fn draw(app: &mut App, frame: &mut Frame) {
123    draw_in(app, frame, frame.area());
124}
125
126/// Draw the complete application inside `area`.
127///
128/// Use this when embedding the full file explorer beside other Ratatui widgets.
129pub fn draw_in(app: &mut App, frame: &mut Frame, area: Rect) {
130    draw_in_with_options(app, frame, area, AppViewOptions::default());
131}
132
133/// Draw the complete application inside `area` with custom layout options.
134pub fn draw_in_with_options(app: &mut App, frame: &mut Frame, area: Rect, options: AppViewOptions) {
135    if area.is_empty() {
136        return;
137    }
138    if app.panes.is_empty() {
139        return;
140    }
141    // Pick up finished background work (previews, fuzzy walk, extraction).
142    app.poll_background();
143    let theme = *app.theme();
144    let full = area;
145
146    // Paint the entire terminal area with the theme's background colour.
147    // Without this, light themes appear broken because ratatui defaults
148    // unstyled cells to Color::Reset (the terminal's own background).
149    if theme.bg != Color::Reset {
150        frame.render_widget(Block::default().style(Style::default().bg(theme.bg)), full);
151    }
152
153    // ── Inline editor takes over the entire screen ────────────────────────────
154    if let Some(ref editor) = app.inline_editor {
155        render_inline_editor(frame, full, editor, &theme);
156        return;
157    }
158
159    // Vertical split: main area | [debug log panel] | action bar.
160    // The debug panel only appears when --verbose is active.
161    let debug_height = if full.height >= 30 {
162        10
163    } else if full.height >= 20 {
164        6
165    } else {
166        3
167    };
168
169    let show_debug = app.verbose && options.show_debug_panel;
170    let hint_layout = options.hint_layout.resolve(full.width);
171    let action_bar_height = hint_layout.action_bar_rows(full.width);
172    let action_row_height = hint_layout.row_height(full.width);
173    let mut vertical_constraints = vec![Constraint::Min(0)];
174    if show_debug {
175        vertical_constraints.push(Constraint::Length(debug_height));
176    }
177    if options.show_action_bar {
178        vertical_constraints.push(Constraint::Length(action_bar_height));
179    }
180    let v_chunks = Layout::default()
181        .direction(Direction::Vertical)
182        .constraints(vertical_constraints)
183        .split(full);
184
185    let main_area = v_chunks[0];
186    let action_area = options
187        .show_action_bar
188        .then(|| v_chunks[v_chunks.len() - 1]);
189
190    // Split the action bar vertically into three rows of 3:
191    //   row 0 — Navigate | File Ops
192    //   row 1 — Global   | Status
193    let action_rows = action_area.map(|area| {
194        Layout::default()
195            .direction(Direction::Vertical)
196            .constraints([
197                Constraint::Length(action_row_height),
198                Constraint::Length(action_row_height),
199            ])
200            .split(area)
201    });
202
203    // ── Debug log panel (verbose only) ────────────────────────────────────────
204    if show_debug {
205        render_debug_panel(frame, v_chunks[1], app, &theme);
206    }
207
208    // Horizontal split: one column per visible pane | [preview] | [theme panel].
209    let pane_capacity = (main_area.width / options.minimum_pane_width.max(1)).max(1) as usize;
210    let visible_pane_count = if app.single_pane {
211        1
212    } else {
213        app.panes.len().min(pane_capacity)
214    };
215    let visible_start = if app.single_pane {
216        app.active_idx
217    } else {
218        app.active_idx
219            .saturating_sub(visible_pane_count.saturating_sub(1))
220            .min(app.panes.len().saturating_sub(visible_pane_count))
221    };
222
223    let mut h_constraints = vec![];
224    if app.show_preview {
225        // With preview: pane columns share 40% (single pane) or 50% total,
226        // preview gets the rest.
227        if app.single_pane {
228            h_constraints.push(Constraint::Percentage(40));
229        } else {
230            let pct = (options.panes_with_preview_percent.min(100)
231                / crate::to_u16(visible_pane_count.max(1)))
232            .max(1);
233            for _ in 0..visible_pane_count {
234                h_constraints.push(Constraint::Percentage(pct));
235            }
236        }
237        h_constraints.push(Constraint::Min(0)); // Preview takes remaining space
238    } else {
239        let pct = crate::to_u16(100 / visible_pane_count.max(1));
240        for _ in 0..visible_pane_count {
241            h_constraints.push(Constraint::Percentage(pct));
242        }
243    }
244    if app.show_theme_panel {
245        h_constraints.push(Constraint::Length(options.theme_panel_width));
246    }
247    if app.show_options_panel {
248        h_constraints.push(Constraint::Length(options.settings_panel_width));
249    }
250    if app.show_editor_panel {
251        h_constraints.push(Constraint::Length(options.settings_panel_width));
252    }
253    let h_chunks = Layout::default()
254        .direction(Direction::Horizontal)
255        .constraints(h_constraints)
256        .split(main_area);
257
258    // ── Panes ─────────────────────────────────────────────────────────
259    let active_theme = theme;
260    let inactive_theme = theme.accent(theme.dim).brand(theme.dim);
261
262    // Sync the current theme name and editor label into every pane so
263    // render_header can display them. Only allocate when the value actually
264    // changed to avoid extra string allocations per frame.
265    let theme_changed = app.panes[0].theme_name != app.theme_name();
266    if theme_changed {
267        let theme_name = app.theme_name().to_string();
268        for p in &mut app.panes {
269            p.theme_name = theme_name.clone();
270        }
271    }
272
273    let editor_label = if app.editor == crate::app::Editor::None {
274        ""
275    } else {
276        app.editor.label()
277    };
278    if app.panes[0].editor_name != editor_label {
279        let editor_name = editor_label.to_string();
280        for p in &mut app.panes {
281            p.editor_name = editor_name.clone();
282        }
283    }
284
285    let active_idx = app.active_idx;
286    if app.single_pane {
287        render_themed(
288            &mut app.panes[active_idx],
289            frame,
290            h_chunks[0],
291            &active_theme,
292        );
293    } else {
294        for (slot, pane_index) in (visible_start..visible_start + visible_pane_count).enumerate() {
295            let pane_theme = if pane_index == active_idx {
296                &active_theme
297            } else {
298                &inactive_theme
299            };
300            render_themed(
301                &mut app.panes[pane_index],
302                frame,
303                h_chunks[slot],
304                pane_theme,
305            );
306        }
307    }
308
309    // ── Preview panel ────────────────────────────────
310    if app.show_preview {
311        let preview_idx = visible_pane_count;
312        let preview_area = h_chunks[preview_idx];
313
314        // Update preview state with the currently highlighted entry.
315        let current_path = app.active_pane().current_entry().map(|e| e.path.clone());
316        app.preview_state.update(
317            current_path.as_deref(),
318            preview_area.width,
319            preview_area.height,
320        );
321
322        render_preview(frame, preview_area, &app.preview_state, &theme);
323    }
324
325    // ── Theme panel ───────────────────────────────────────────────────────────
326    if app.show_theme_panel {
327        let panel_area = h_chunks[h_chunks.len() - 1];
328        render_theme_panel(frame, panel_area, app);
329    }
330
331    // ── Options panel ─────────────────────────────────────────────────────────
332    if app.show_options_panel {
333        let panel_area = h_chunks[h_chunks.len() - 1];
334        render_options_panel(frame, panel_area, app);
335    }
336
337    // ── Editor panel ──────────────────────────────────────────────────────────
338    if app.show_editor_panel {
339        let panel_area = h_chunks[h_chunks.len() - 1];
340        render_editor_panel(frame, panel_area, app);
341    }
342
343    // ── Action bar ────────────────────────────────────────────────────────────
344    if let Some(rows) = action_rows {
345        render_nav_hints_with_layout(frame, rows[0], rows[1], app, &theme, hint_layout);
346    }
347
348    // ── Picker overlay (bookmarks / fuzzy finder) ─────────────────────────────
349    if let Some(picker) = &app.picker {
350        crate::picker::render_picker(frame, full, picker, &theme);
351    }
352
353    // ── Modal overlay ─────────────────────────────────────────────────────────
354    if let Some(modal) = &app.modal {
355        render_modal_for(frame, full, modal, &theme, app.use_trash);
356    }
357
358    // ── Copy progress overlay ─────────────────────────────────────────────────
359    if let Some(progress) = &app.copy_progress {
360        render_copy_progress(frame, full, progress, &theme);
361    }
362
363    // ── Snackbar overlay ──────────────────────────────────────────────────────
364    // Expire stale snackbars first, then render if one is still active.
365    if app
366        .snackbar
367        .as_ref()
368        .is_some_and(super::app::Snackbar::is_expired)
369    {
370        app.snackbar = None;
371    }
372    if let Some(snackbar) = &app.snackbar {
373        render_snackbar(frame, full, snackbar, &theme);
374    }
375}
376
377mod action_bar;
378mod modal;
379mod overlays;
380mod panels;
381
382pub use self::action_bar::{render_action_bar, render_nav_hints, render_nav_hints_with_layout};
383#[cfg(test)]
384pub use self::action_bar::{render_action_bar_spans, render_nav_hints_spans};
385pub use self::modal::{render_modal, render_modal_for};
386pub use self::overlays::{render_copy_progress, render_debug_panel, render_snackbar};
387pub use self::panels::{render_editor_panel, render_options_panel, render_theme_panel};
388
389#[cfg(test)]
390#[path = "tests.rs"]
391mod tests;