Skip to main content

trail/ui/
mod.rs

1//! UI rendering: draws the three-panel layout (nav, preview, status bar), and
2//! the terminal panel beneath the first two when it is open.
3//!
4//! Corresponds to the architecture doc's UI thread rendering responsibility.
5//! `render()` is the single entry point called once per tick when `state.dirty`
6//! is set. The function is generic over `B: Backend` so that tests can pass a
7//! `TestBackend` without a real terminal.
8
9pub mod nav_panel;
10mod preview_panel;
11mod status_bar;
12mod terminal_panel;
13mod theme;
14
15use ratatui::backend::Backend;
16use ratatui::layout::{Constraint, Direction, Layout, Rect};
17use ratatui::Terminal;
18use std::io;
19
20use crate::app::state::{AppState, PreviewSlot};
21use crate::preview::provider::PreviewContent;
22
23/// Where each panel goes this frame.
24#[derive(Debug, Clone, Copy)]
25struct Panes {
26    nav: Rect,
27    preview: Rect,
28    /// `None` while the terminal panel is closed.
29    terminal: Option<Rect>,
30    status: Rect,
31    /// False while a maximized terminal panel leaves no room for the file list
32    /// and the preview, which are then not drawn at all.
33    files_shown: bool,
34}
35
36impl Panes {
37    /// Lays the panels out in `area`.
38    ///
39    /// Separate from the draw because the preview's position has to be known
40    /// before it: the screen can only be cleared between frames.
41    fn new(area: Rect, state: &AppState) -> Panes {
42        let outer = Layout::default()
43            .direction(Direction::Vertical)
44            .constraints([
45                Constraint::Min(1),    // main area (nav + preview)
46                Constraint::Length(1), // status bar
47            ])
48            .split(area);
49
50        let (main, terminal) = if state.terminal.is_visible() {
51            let rows = crate::terminal::panel_rows(
52                outer[0].height,
53                state.config.terminal.height,
54                state.terminal.is_maximized(),
55            );
56            let split = Layout::default()
57                .direction(Direction::Vertical)
58                .constraints([Constraint::Min(0), Constraint::Length(rows)])
59                .split(outer[0]);
60            (split[0], Some(split[1]))
61        } else {
62            (outer[0], None)
63        };
64
65        let inner = Layout::default()
66            .direction(Direction::Horizontal)
67            .constraints([
68                Constraint::Percentage(40), // navigation panel
69                Constraint::Percentage(60), // preview panel
70            ])
71            .split(main);
72
73        Panes {
74            nav: inner[0],
75            preview: inner[1],
76            terminal,
77            status: outer[1],
78            files_shown: main.height > 0,
79        }
80    }
81}
82
83/// Records where the current image is about to be drawn, returning whether the
84/// screen must be cleared first because it moved or has only just arrived.
85///
86/// See [`crate::preview::graphics::ImagePreview::placed_at`] for why. Half-blocks
87/// repaint every cell they use, so they never ask for a clear.
88fn image_needs_clear(slot: &mut PreviewSlot, pane: Option<Rect>) -> bool {
89    let PreviewContent::Image(image) = &mut slot.content else {
90        return false;
91    };
92    let moved = pane.is_some() && image.placed_at != pane;
93    image.placed_at = pane;
94    moved && image.draws_over_cells
95}
96
97/// Draws all three panels into the terminal frame.
98///
99/// The layout splits the screen into a top region (nav + preview side by side)
100/// and a bottom status bar. The top region is split 40/60 between the
101/// navigation panel and the preview panel. An open terminal panel takes the
102/// bottom `[terminal] height` percent of the top region, or all of it when
103/// maximized — in which case the file list and preview are not drawn.
104///
105/// This function does **not** clear `state.dirty`; the caller (`main.rs`)
106/// is responsible for setting it to `false` after a successful render so
107/// that the invariant is visible at the event-loop level.
108///
109/// Generic over `B: Backend` so that integration tests can use
110/// `ratatui::backend::TestBackend` without a real terminal.
111///
112/// Takes `state` mutably because an image preview owns encoder state that
113/// `ratatui-image` re-encodes whenever the preview pane changes size.
114pub fn render<B: Backend>(terminal: &mut Terminal<B>, state: &mut AppState) -> io::Result<()> {
115    // An inline image is drawn by the *terminal*, not by ratatui: the protocol
116    // sequence places a picture that the cell diff knows nothing about and
117    // therefore cannot erase. Clearing when the previous frame drew one and this
118    // one does not is what keeps a placement from sitting under the next
119    // preview.
120    //
121    // This used to be an unconditional `terminal.clear()` on every frame — a
122    // physical clear-screen and a full repaint per keystroke, which is what made
123    // navigation flicker. The ghost content it was hiding came from preview text
124    // being written to the terminal unsanitized, so a stray ESC in a "text" file
125    // moved the cursor and painted outside the pane. That is fixed at the source
126    // in `preview::provider::sanitize`, which leaves ratatui's own diff free to
127    // do what it is good at.
128    let size = terminal.size()?;
129    let panes = Panes::new(Rect::new(0, 0, size.width, size.height), state);
130    let drawing_image =
131        panes.files_shown && matches!(state.preview.content, PreviewContent::Image(_));
132    let image_moved = image_needs_clear(
133        &mut state.preview,
134        panes.files_shown.then_some(panes.preview),
135    );
136    if image_moved || (state.preview.drew_image && !drawing_image) {
137        terminal.clear()?;
138    }
139    state.preview.drew_image = drawing_image;
140
141    terminal.draw(|frame| {
142        status_bar::draw(frame, panes.status, state);
143        if let Some(panel) = panes.terminal {
144            terminal_panel::draw(frame, panel, state);
145        }
146        if !panes.files_shown {
147            return;
148        }
149
150        // The panel's scroll position is decided here rather than inside the
151        // panel because it outlives the frame: the panel borrows `state` only
152        // to read, and this is where the pane height is first known.
153        let listed = state.filtered_count();
154        let nav_offset = crate::app::scroll::NavScroll::update(
155            &mut state.nav_scroll,
156            &state.cwd,
157            state.selected,
158            listed,
159            usize::from(panes.nav.height.saturating_sub(2)),
160            state.config.navigation.scroll_margin,
161        );
162        nav_panel::draw(frame, panes.nav, state, nav_offset);
163        // Drawn last: it borrows `state` mutably, so the read-only panels above
164        // must have finished with it.
165        preview_panel::draw(frame, panes.preview, state);
166    })?;
167    Ok(())
168}