Desktop: convert Settings/Stats/Maintenance to scrollable screens

The three big dialogs were floating egui::Windows that crammed everything
into a fixed-height popover and hid the library underneath. They're now
full CentralPanel views with a "← Library" back button and a vertical
ScrollArea, so long settings panels and big stats reports scroll cleanly.

Top-bar nav uses a Screen enum instead of a trio of show_* booleans;
clicking the active screen returns to Library. Floating dialogs that are
genuinely modal (channel options, folder manager, move-to-folder) stay
as windows since they're actions, not screens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-27 01:21:26 -07:00
parent 9e64297da6
commit 980ffbc4e7

View file

@ -29,6 +29,20 @@ const BROWSERS: &[(&str, &str)] = &[
("none", "None (no cookies)"), ("none", "None (no cookies)"),
]; ];
/// Top-level navigation. The desktop swaps the CentralPanel content
/// between these instead of stacking floating windows. Smaller dialogs
/// (channel options, folder manager, move-to-folder) stay as floating
/// `egui::Window` since they're modal-ish actions, not screens.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
enum Screen {
/// Channel sidebar + video grid + detail panel. The default.
#[default]
Library,
Settings,
Stats,
Maintenance,
}
#[derive(Clone, PartialEq)] #[derive(Clone, PartialEq)]
enum SortMode { enum SortMode {
Title, Title,
@ -90,7 +104,10 @@ pub struct App {
search: String, search: String,
downloader: Downloader, downloader: Downloader,
show_downloads: bool, show_downloads: bool,
show_settings: bool, /// Which top-level screen is currently rendered into CentralPanel.
/// Settings / Stats / Maintenance now take the full window (with
/// scroll) instead of floating over the library.
current_screen: Screen,
dl_url: String, dl_url: String,
dl_full_scan: bool, dl_full_scan: bool,
dl_quality: DownloadQuality, dl_quality: DownloadQuality,
@ -154,11 +171,9 @@ pub struct App {
// is. // is.
backup_save_tx: Sender<PathBuf>, backup_save_tx: Sender<PathBuf>,
backup_save_rx: Receiver<PathBuf>, backup_save_rx: Receiver<PathBuf>,
// Maintenance (library health) window // Maintenance (library health) screen state. The flag is gone now —
show_maintenance: bool, // Screen::Maintenance + this report's presence drive the render.
health_report: Option<crate::maintenance::HealthReport>, health_report: Option<crate::maintenance::HealthReport>,
// Statistics window
show_stats: bool,
stats_report: Option<crate::stats::StatsReport>, stats_report: Option<crate::stats::StatsReport>,
// Per-channel download-options dialog state // Per-channel download-options dialog state
show_channel_options: bool, show_channel_options: bool,
@ -286,7 +301,7 @@ impl App {
search: String::new(), search: String::new(),
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp), downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp),
show_downloads: false, show_downloads: false,
show_settings: false, current_screen: Screen::Library,
dl_url: String::new(), dl_url: String::new(),
dl_full_scan: true, dl_full_scan: true,
dl_quality: DownloadQuality::Best, dl_quality: DownloadQuality::Best,
@ -332,9 +347,7 @@ impl App {
cookies_pick_rx, cookies_pick_rx,
backup_save_tx, backup_save_tx,
backup_save_rx, backup_save_rx,
show_maintenance: false,
health_report: None, health_report: None,
show_stats: false,
stats_report: None, stats_report: None,
show_channel_options: false, show_channel_options: false,
channel_options_target: None, channel_options_target: None,
@ -929,27 +942,37 @@ impl App {
if ui.selectable_label(self.show_downloads, dl_label).clicked() { if ui.selectable_label(self.show_downloads, dl_label).clicked() {
self.show_downloads = !self.show_downloads; self.show_downloads = !self.show_downloads;
} }
if ui.selectable_label(self.show_stats, "📊 Stats").clicked() { // Top-level screen nav. Clicking the active screen returns
self.show_stats = !self.show_stats; // to Library (which is the canonical "home" view).
if self.show_stats { if ui.selectable_label(self.current_screen == Screen::Library, "📚 Library").clicked() {
self.current_screen = Screen::Library;
}
if ui.selectable_label(self.current_screen == Screen::Stats, "📊 Stats").clicked() {
if self.current_screen == Screen::Stats {
self.current_screen = Screen::Library;
} else {
self.stats_report = Some(crate::stats::build( self.stats_report = Some(crate::stats::build(
&self.library, &self.library,
&self.watched, &self.watched,
&self.resume_positions, &self.resume_positions,
crate::stats::now_unix(), crate::stats::now_unix(),
)); ));
self.current_screen = Screen::Stats;
} }
} }
if ui.selectable_label(self.show_maintenance, "🩺 Maintenance").clicked() { if ui.selectable_label(self.current_screen == Screen::Maintenance, "🩺 Maintenance").clicked() {
self.show_maintenance = !self.show_maintenance; if self.current_screen == Screen::Maintenance {
if self.show_maintenance { self.current_screen = Screen::Library;
} else {
self.health_report = self.health_report =
Some(crate::maintenance::scan(&self.library_root, &self.library)); Some(crate::maintenance::scan(&self.library_root, &self.library));
self.current_screen = Screen::Maintenance;
} }
} }
if ui.selectable_label(self.show_settings, "⚙ Settings").clicked() { if ui.selectable_label(self.current_screen == Screen::Settings, "⚙ Settings").clicked() {
self.show_settings = !self.show_settings; if self.current_screen == Screen::Settings {
if self.show_settings { self.current_screen = Screen::Library;
} else {
self.settings_dir = self.channels_root.display().to_string(); self.settings_dir = self.channels_root.display().to_string();
self.settings_plex_path = self.config.plex.library_path self.settings_plex_path = self.config.plex.library_path
.as_deref().map(|p| p.display().to_string()).unwrap_or_default(); .as_deref().map(|p| p.display().to_string()).unwrap_or_default();
@ -967,6 +990,7 @@ impl App {
} else { } else {
"no cookies.txt".to_string() "no cookies.txt".to_string()
}; };
self.current_screen = Screen::Settings;
} }
} }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@ -1473,11 +1497,7 @@ impl App {
}); });
} }
fn maintenance_window(&mut self, ctx: &egui::Context) { fn maintenance_screen(&mut self, ctx: &egui::Context) {
if !self.show_maintenance {
return;
}
let mut open = self.show_maintenance;
let report = self.health_report.clone().unwrap_or_default(); let report = self.health_report.clone().unwrap_or_default();
// Actions are collected during rendering and applied after the closure // Actions are collected during rendering and applied after the closure
@ -1486,18 +1506,19 @@ impl App {
let mut to_repair: Vec<String> = Vec::new(); let mut to_repair: Vec<String> = Vec::new();
let mut rescan_health = false; let mut rescan_health = false;
egui::Window::new("🩺 Library health") egui::CentralPanel::default().show(ctx, |ui| {
.open(&mut open) ui.horizontal(|ui| {
.collapsible(false) if ui.button("← Library").clicked() {
.resizable(true) self.current_screen = Screen::Library;
.default_width(620.0) }
.default_height(500.0) ui.heading("🩺 Library health");
.show(ctx, |ui| { ui.separator();
if ui.button("⟳ Rescan health").clicked() { if ui.button("⟳ Rescan health").clicked() {
rescan_health = true; rescan_health = true;
} }
ui.separator(); });
egui::ScrollArea::vertical().show(ui, |ui| { ui.separator();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.heading(format!("Duplicates ({})", report.duplicates.len())); ui.heading(format!("Duplicates ({})", report.duplicates.len()));
if report.duplicates.is_empty() { if report.duplicates.is_empty() {
ui.label(egui::RichText::new("No duplicate video IDs found.").weak()); ui.label(egui::RichText::new("No duplicate video IDs found.").weak());
@ -1563,9 +1584,8 @@ impl App {
} }
}); });
} }
});
}); });
self.show_maintenance = open; });
// ── Apply collected actions ──────────────────────────────────────── // ── Apply collected actions ────────────────────────────────────────
let mut changed = false; let mut changed = false;
@ -1595,24 +1615,35 @@ impl App {
} }
} }
fn stats_window(&mut self, ctx: &egui::Context) { fn stats_screen(&mut self, ctx: &egui::Context) {
if !self.show_stats { return; }
let mut open = self.show_stats;
let report = match &self.stats_report { let report = match &self.stats_report {
Some(r) => r.clone(), Some(r) => r.clone(),
None => return, None => {
egui::CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("← Library").clicked() {
self.current_screen = Screen::Library;
}
ui.heading("📊 Library statistics");
});
ui.separator();
ui.label(egui::RichText::new("No stats yet — try Recompute.").weak());
});
return;
}
}; };
let mut rescan = false; let mut rescan = false;
egui::Window::new("📊 Library statistics") egui::CentralPanel::default().show(ctx, |ui| {
.open(&mut open) ui.horizontal(|ui| {
.collapsible(false) if ui.button("← Library").clicked() {
.resizable(true) self.current_screen = Screen::Library;
.default_width(560.0) }
.default_height(520.0) ui.heading("📊 Library statistics");
.show(ctx, |ui| {
if ui.button("⟳ Recompute").clicked() { rescan = true; }
ui.separator(); ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| { if ui.button("⟳ Recompute").clicked() { rescan = true; }
});
ui.separator();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.heading("Totals"); ui.heading("Totals");
egui::Grid::new("stats_totals").num_columns(2).striped(true).show(ui, |ui| { egui::Grid::new("stats_totals").num_columns(2).striped(true).show(ui, |ui| {
ui.label("Channels"); ui.label(report.total_channels.to_string()); ui.end_row(); ui.label("Channels"); ui.label(report.total_channels.to_string()); ui.end_row();
@ -1665,7 +1696,6 @@ impl App {
} }
}); });
}); });
self.show_stats = open;
if rescan { if rescan {
self.stats_report = Some(crate::stats::build( self.stats_report = Some(crate::stats::build(
&self.library, &self.watched, &self.resume_positions, crate::stats::now_unix(), &self.library, &self.watched, &self.resume_positions, crate::stats::now_unix(),
@ -2011,17 +2041,16 @@ impl App {
} }
} }
fn settings_window(&mut self, ctx: &egui::Context) { fn settings_screen(&mut self, ctx: &egui::Context) {
if !self.show_settings { egui::CentralPanel::default().show(ctx, |ui| {
return; ui.horizontal(|ui| {
} if ui.button("← Library").clicked() {
let mut open = self.show_settings; self.current_screen = Screen::Library;
egui::Window::new("⚙ Settings") }
.open(&mut open) ui.heading("⚙ Settings");
.collapsible(false) });
.resizable(false) ui.separator();
.default_width(480.0) egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
.show(ctx, |ui| {
egui::Grid::new("settings_grid") egui::Grid::new("settings_grid")
.num_columns(2) .num_columns(2)
.spacing([12.0, 8.0]) .spacing([12.0, 8.0])
@ -2443,7 +2472,7 @@ impl App {
); );
}); });
}); });
self.show_settings = open; });
} }
fn detail_panel(&mut self, ctx: &egui::Context) { fn detail_panel(&mut self, ctx: &egui::Context) {
@ -3089,20 +3118,26 @@ impl eframe::App for App {
} }
self.top_bar(ctx); self.top_bar(ctx);
self.channel_panel(ctx); // Floating sub-dialogs that overlay any screen.
if self.show_downloads {
self.downloads_panel(ctx);
}
self.settings_window(ctx);
self.maintenance_window(ctx);
self.stats_window(ctx);
self.channel_options_window(ctx); self.channel_options_window(ctx);
self.folder_manager_window(ctx); self.folder_manager_window(ctx);
self.move_to_folder_window(ctx); self.move_to_folder_window(ctx);
self.detail_panel(ctx);
egui::CentralPanel::default().show(ctx, |ui| { match self.current_screen {
self.video_list(ctx, ui); Screen::Library => {
}); self.channel_panel(ctx);
if self.show_downloads {
self.downloads_panel(ctx);
}
self.detail_panel(ctx);
egui::CentralPanel::default().show(ctx, |ui| {
self.video_list(ctx, ui);
});
}
Screen::Settings => self.settings_screen(ctx),
Screen::Stats => self.stats_screen(ctx),
Screen::Maintenance => self.maintenance_screen(ctx),
}
} }
} }