src/ui/mod.rs (11839B)
1 pub mod layout;
2 pub mod pane;
3 pub mod panes;
4 pub mod text;
5 pub mod theme;
6 pub mod widgets;
7
8 use ratatui::Frame;
9 use widgets::{about_modal, help_modal, playlist_modal, search_modal};
10 use widgets::playlist_modal::PlaylistModalMode;
11
12 use crate::app::App;
13 use crate::app::state::{FocusedPane, InfoView, Tab};
14 use layout::LayoutAreas;
15 use pane::Pane;
16 use panes::dir_browser_pane::DirBrowserPane;
17 use panes::library_pane::LibraryPane;
18 use panes::list_pane::{self, ListPane};
19 use panes::track_info_pane::TrackInfoPane;
20 use panes::queue_pane::QueuePane;
21
22 /// Splash timeline, in seconds: fade in, hold, then fade out until the end.
23 /// Always played in full — the scan runs behind it and does not cut it short.
24 pub const SPLASH_FADE_IN: f32 = 0.5;
25 pub const SPLASH_FADE_OUT_AT: f32 = 1.5;
26 pub const SPLASH_END: f32 = 2.0;
27
28 /// Index into `Ui::list_panes` for each list tab. Queue and Directories have
29 /// their own pane types and never reach here.
30 pub fn list_slot(tab: Tab) -> usize {
31 match tab {
32 Tab::Artists => 0,
33 Tab::Albums => 1,
34 Tab::Genre => 2,
35 Tab::Format => 3,
36 _ => 4, // Playlists
37 }
38 }
39 use theme::Theme;
40 use widgets::info_pane;
41 use widgets::progress_bar;
42 use widgets::status_bar;
43 use widgets::tab_bar;
44
45 pub struct Ui {
46 pub theme: Theme,
47 pub library_pane: LibraryPane,
48 pub dir_browser_pane: DirBrowserPane,
49 pub queue_pane: QueuePane,
50 /// One per list tab, keyed by Tab; each keeps its own selection and scroll.
51 pub list_panes: [ListPane; 5],
52 pub track_info_pane: TrackInfoPane,
53 pub last_click: Option<(std::time::Instant, u16, u16)>,
54 /// Last known mouse position (column, row) for hover tracking
55 pub mouse_pos: Option<(u16, u16)>,
56 /// Tab index currently hovered by mouse
57 pub hovered_tab: Option<usize>,
58 /// Pane width percentages [Library, Playlist, Lyrics], sum = 100
59 pub pane_widths: [u16; 3],
60 /// Border being dragged: 0 = lib|playlist, 1 = playlist|lyrics, 2 = info|lyrics (horizontal), None = not dragging
61 pub dragging_border: Option<u8>,
62 /// Right column split: info pane height percentage (top), lyrics gets the rest
63 pub right_split: u16,
64 /// Help modal visible
65 pub show_help_modal: bool,
66 /// Search modal visible
67 pub show_search_modal: bool,
68 /// Search modal input text
69 pub search_modal_input: String,
70 /// Search modal filtered results (track indices)
71 pub search_modal_results: Vec<usize>,
72 /// Search modal selected result index
73 pub search_modal_selected: usize,
74 /// Search modal scroll offset
75 pub search_modal_scroll: usize,
76 /// Search modal visible result row count (set during render)
77 pub search_modal_result_height: usize,
78 /// Search modal result area rect (set during render, for mouse hit-testing)
79 pub search_modal_result_area: ratatui::layout::Rect,
80 /// Search modal hovered row index
81 pub search_modal_hover_row: Option<usize>,
82 /// Playlist modal visible ("b" key)
83 pub show_playlist_modal: bool,
84 /// Playlist modal selected index
85 pub playlist_modal_selected: usize,
86 /// Playlist modal mode (List / Create / Rename)
87 pub playlist_modal_mode: PlaylistModalMode,
88 /// Playlist modal text input (for create/rename)
89 pub playlist_modal_input: String,
90 /// About modal visible
91 pub show_about_modal: bool,
92 /// Splash screen visible at startup
93 pub show_splash: bool,
94 /// Splash screen start time
95 pub splash_start: Option<std::time::Instant>,
96 /// Current info pane view (Clock / AlbumArt / TrackInfo)
97 pub info_view: InfoView,
98 /// Album art pixel cache
99 pub album_art_cache: info_pane::AlbumArtCache,
100 }
101
102 impl Ui {
103 pub fn new(music_dir: std::path::PathBuf, picker: ratatui_image::picker::Picker) -> Self {
104 Self {
105 theme: Theme::default(),
106 library_pane: LibraryPane::new(),
107 dir_browser_pane: DirBrowserPane::new(music_dir),
108 queue_pane: QueuePane::new(),
109 list_panes: Default::default(),
110 track_info_pane: TrackInfoPane::new(),
111 last_click: None,
112 mouse_pos: None,
113 hovered_tab: None,
114 pane_widths: [20, 60, 20],
115 dragging_border: None,
116 right_split: 50,
117 show_help_modal: false,
118 show_search_modal: false,
119 search_modal_input: String::new(),
120 search_modal_results: Vec::new(),
121 search_modal_selected: 0,
122 search_modal_scroll: 0,
123 search_modal_result_height: 10,
124 search_modal_result_area: ratatui::layout::Rect::default(),
125 search_modal_hover_row: None,
126 show_playlist_modal: false,
127 playlist_modal_selected: 0,
128 playlist_modal_mode: PlaylistModalMode::List,
129 playlist_modal_input: String::new(),
130 show_about_modal: false,
131 show_splash: true,
132 splash_start: Some(std::time::Instant::now()),
133 info_view: InfoView::Clock,
134 album_art_cache: info_pane::AlbumArtCache::new(picker),
135 }
136 }
137
138 /// Drop the splash immediately. Only a keypress does this; left alone the
139 /// timeline always runs in full.
140 pub fn dismiss_splash(&mut self) {
141 self.show_splash = false;
142 self.splash_start = None;
143 }
144
145 pub fn render(&mut self, frame: &mut Frame, app: &App) {
146 if self.show_splash {
147 let elapsed = self.splash_start
148 .map(|s| s.elapsed().as_secs_f32())
149 .unwrap_or(SPLASH_END);
150 let opacity = if elapsed < SPLASH_FADE_IN {
151 elapsed / SPLASH_FADE_IN
152 } else if elapsed < SPLASH_FADE_OUT_AT {
153 1.0
154 } else {
155 (1.0 - (elapsed - SPLASH_FADE_OUT_AT) / (SPLASH_END - SPLASH_FADE_OUT_AT)).max(0.0)
156 };
157 about_modal::render_splash_screen(frame, frame.area(), &self.theme, opacity);
158 return;
159 }
160
161 let areas = LayoutAreas::compute(frame.area(), self.pane_widths, self.right_split);
162
163 // Status bar
164 status_bar::render_status_bar(frame, areas.status_bar, app, &self.theme);
165
166 // Tab bar
167 tab_bar::render_tab_bar(frame, areas.tab_bar, app.tab, self.hovered_tab, &self.theme);
168
169 // Left pane (varies by tab)
170 let lib_focused = app.focus == FocusedPane::Library;
171 match app.tab {
172 Tab::Queue => self.library_pane.render(frame, areas.library, lib_focused, app, &self.theme),
173 Tab::Directories => self.dir_browser_pane.render(frame, areas.library, lib_focused, app, &self.theme),
174 tab => {
175 let rows = list_pane::rows_for(app, tab);
176 let theme = &self.theme;
177 self.list_panes[list_slot(tab)]
178 .render(frame, areas.library, lib_focused, theme, &rows);
179 }
180 }
181
182 // Center pane (Queue)
183 let playlist_focused = app.focus == FocusedPane::Playlist;
184 self.queue_pane.render(frame, areas.playlist, playlist_focused, app, &self.theme);
185
186 // Right pane top (Info)
187 info_pane::render_info_pane(frame, areas.info_pane, app, &self.theme, self.info_view, &mut self.album_art_cache);
188
189 // Right pane bottom (Lyrics)
190 let lyrics_focused = app.focus == FocusedPane::Lyrics;
191 self.track_info_pane.render(frame, areas.lyrics, lyrics_focused, app, &self.theme);
192
193 // Progress bar
194 progress_bar::render_progress_bar(frame, areas.progress_bar, app, &self.theme);
195
196
197 // Modal overlays (rendered last, on top of everything)
198 if self.show_search_modal {
199 let (rh, ra) = search_modal::render_search_modal(
200 frame,
201 frame.area(),
202 &self.search_modal_input,
203 &self.search_modal_results,
204 self.search_modal_selected,
205 self.search_modal_scroll,
206 self.search_modal_hover_row,
207 app,
208 &self.theme,
209 );
210 self.search_modal_result_height = rh;
211 self.search_modal_result_area = ra;
212 }
213
214 if self.show_help_modal {
215 help_modal::render_help_modal(frame, frame.area(), &self.theme);
216 }
217
218 if self.show_about_modal {
219 about_modal::render_about_modal(frame, frame.area(), &self.theme);
220 }
221
222 if self.show_playlist_modal {
223 playlist_modal::render_playlist_modal(
224 frame,
225 frame.area(),
226 self.playlist_modal_selected,
227 &self.playlist_modal_mode,
228 &self.playlist_modal_input,
229 app,
230 &self.theme,
231 );
232 }
233 }
234
235 /// The list pane backing `tab`. Panics only for Queue/Directories, which
236 /// have their own pane types — `list_slot` maps those to Playlists, so
237 /// callers must check the tab first.
238 pub fn list_pane_mut(&mut self, tab: Tab) -> &mut ListPane {
239 &mut self.list_panes[list_slot(tab)]
240 }
241
242 /// Scroll offset of whichever pane the library column is currently showing.
243 pub fn library_pane_scroll(&self, app: &App) -> usize {
244 match app.tab {
245 Tab::Queue => self.library_pane.scroll_offset,
246 Tab::Directories => self.dir_browser_pane.scroll_offset,
247 tab => self.list_panes[list_slot(tab)].scroll_offset,
248 }
249 }
250
251 pub fn refresh_dir_browser(&mut self, app: &App) {
252 self.dir_browser_pane.refresh(app);
253 }
254
255 /// Re-run the open search against the new library. `search_modal_results`
256 /// holds raw library indices and lives on `Ui`, so `App::replace_library`
257 /// never sees it — stale entries would panic the next render.
258 pub fn refresh_search_results(&mut self, app: &App) {
259 if !self.show_search_modal {
260 self.search_modal_results.clear();
261 return;
262 }
263 self.search_modal_results = app.library.search(&self.search_modal_input);
264 self.search_modal_selected = 0;
265 self.search_modal_scroll = 0;
266 self.search_modal_hover_row = None;
267 }
268
269 pub fn clamp_selections(&mut self, app: &App) {
270 for tab in [Tab::Artists, Tab::Albums, Tab::Genre, Tab::Format, Tab::Playlists] {
271 let len = list_pane::row_count(app, tab);
272 let pane = &mut self.list_panes[list_slot(tab)];
273 pane.selected = pane.selected.min(len.saturating_sub(1));
274 pane.scroll_offset = pane.scroll_offset.min(len.saturating_sub(1));
275 }
276
277 // Reset library/dir browser to top since track indices changed
278 self.library_pane.selected = 0;
279 self.library_pane.scroll_offset = 0;
280 self.dir_browser_pane.selected = 0;
281 self.dir_browser_pane.scroll_offset = 0;
282
283 // Clamp queue pane scroll
284 let queue_len = app.queue.tracks.len();
285 if queue_len == 0 {
286 self.queue_pane.scroll_offset = 0;
287 } else {
288 self.queue_pane.scroll_offset = self.queue_pane.scroll_offset.min(queue_len - 1);
289 }
290 }
291 }
292
293 #[cfg(test)]
294 mod tests {
295 use super::*;
296
297 fn ui() -> Ui {
298 Ui::new(
299 std::path::PathBuf::from("."),
300 ratatui_image::picker::Picker::from_fontsize((8, 16)),
301 )
302 }
303
304 #[test]
305 fn a_keypress_drops_the_splash_immediately() {
306 let mut ui = ui();
307 assert!(ui.show_splash);
308
309 ui.dismiss_splash();
310
311 // No trailing fade: the hint says the key skips to the app.
312 assert!(!ui.show_splash);
313 assert!(ui.splash_start.is_none());
314 }
315
316 #[test]
317 fn dismissing_twice_is_harmless() {
318 let mut ui = ui();
319 ui.dismiss_splash();
320 ui.dismiss_splash();
321 assert!(!ui.show_splash);
322 }
323 }