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}