feat(tray): integrate egui context for system tray events to ensure immediate UI updates

This commit is contained in:
Luna 2026-07-03 07:03:27 -07:00
parent f185e7062f
commit 4de452c503
4 changed files with 122 additions and 25 deletions

View file

@ -429,6 +429,13 @@ impl App {
}; };
theme::apply(&cc.egui_ctx, &config.ui.theme); theme::apply(&cc.egui_ctx, &config.ui.theme);
// Hand the tray thread a clone of the egui context so its menu
// activations (Show/Hide/Quit) can wake the event loop; otherwise
// those events sit unread in the channel while the app is idle or
// hidden to tray and appear to do nothing.
if let Some(handle) = &tray {
let _ = handle.ctx.set(cc.egui_ctx.clone());
}
let theme_accents = theme::accents_for(&config.ui.theme); let theme_accents = theme::accents_for(&config.ui.theme);
let default_view_mode = ViewMode::from_config(&config.ui.default_view_mode); let default_view_mode = ViewMode::from_config(&config.ui.default_view_mode);
// Typography & spacing baseline: slightly larger base text, more // Typography & spacing baseline: slightly larger base text, more
@ -2207,19 +2214,6 @@ impl App {
fn maintenance_screen(&mut self, ctx: &egui::Context) { fn maintenance_screen(&mut self, ctx: &egui::Context) {
let report = self.health_report.clone().unwrap_or_default(); let report = self.health_report.clone().unwrap_or_default();
// Swap in the library once the deferred startup scan finishes.
let loaded_library = self.library_load_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some(lib) = loaded_library {
self.library = lib;
self.status = format!(
"{} channels, {} videos",
self.library.len(),
self.library.iter().map(|c| c.total_videos()).sum::<usize>()
);
self.library_load_rx = None;
ctx.request_repaint();
}
// Receive a background remote-library fetch (federation). // Receive a background remote-library fetch (federation).
let remote_result = self.remote_rx.as_ref().and_then(|rx| rx.try_recv().ok()); let remote_result = self.remote_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some(res) = remote_result { if let Some(res) = remote_result {
@ -3248,6 +3242,13 @@ impl App {
self.config.ui.theme = id.to_string(); self.config.ui.theme = id.to_string();
theme::apply(ctx, id); theme::apply(ctx, id);
self.theme_accents = theme::accents_for(id); self.theme_accents = theme::accents_for(id);
// Persist immediately: the theme
// applies live, so users expect it to
// stick without also hitting the
// separate "Apply & Save" button.
if let Err(e) = self.config.save(&self.config_path) {
self.status = format!("Error saving config: {e}");
}
} }
} }
}); });
@ -3469,7 +3470,22 @@ impl App {
ui.label("Download password:"); ui.label("Download password:");
ui.vertical(|ui| { ui.vertical(|ui| {
ui.checkbox(&mut self.settings_password_enabled, "require for web downloads"); // Persist the enable/disable state immediately so
// "whether password is enabled" survives a restart
// even if the user never clicks "Apply & Save".
// "Enabled" isn't stored as a flag — it's derived
// from whether a password_hash row exists — so
// turning it off clears the hash right away.
let toggled = ui
.checkbox(&mut self.settings_password_enabled, "require for web downloads")
.changed();
if toggled && !self.settings_password_enabled {
match self.db.set_setting("password_hash", None) {
Ok(_) => self.status = "Download password disabled.".to_string(),
Err(e) => self.status = format!("DB error: {e}"),
}
self.settings_password_input.clear();
}
if self.settings_password_enabled { if self.settings_password_enabled {
let hint = if self.settings_password_input.is_empty() { let hint = if self.settings_password_input.is_empty() {
"leave blank to keep current" "leave blank to keep current"
@ -3482,6 +3498,24 @@ impl App {
.desired_width(220.0) .desired_width(220.0)
.hint_text(hint), .hint_text(hint),
); );
// A password can only be stored once it's been
// typed and hashed, so offer an explicit commit
// that persists it right away (independent of
// "Apply & Save").
if !self.settings_password_input.is_empty()
&& ui.button("💾 Set password").clicked()
{
match crate::web::hash_password(&self.settings_password_input) {
Some(hash) => match self.db.set_setting("password_hash", Some(&hash)) {
Ok(_) => {
self.settings_password_input.clear();
self.status = "Download password set.".to_string();
}
Err(e) => self.status = format!("DB error: {e}"),
},
None => self.status = "Error hashing password".to_string(),
}
}
} }
}); });
ui.end_row(); ui.end_row();
@ -4661,6 +4695,24 @@ impl eframe::App for App {
} }
} }
// ── Deferred library-scan hand-off ──────────────────────────────
// The startup library scan runs on a background thread and delivers
// its result over `library_load_rx`. Drain it here in the main loop
// so the library populates on whatever screen is showing — the
// default is Library, so draining only inside maintenance_screen()
// left the library empty until the user pressed Rescan.
if let Some(lib) = self.library_load_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.library = lib;
self.status = format!(
"{} channels, {} videos",
self.library.len(),
self.library.iter().map(|c| c.total_videos()).sum::<usize>()
);
self.library_load_rx = None;
self.library_generation += 1;
ctx.request_repaint();
}
// ── System-tray event drain ───────────────────────────────────── // ── System-tray event drain ─────────────────────────────────────
// Tray menu activations arrive on a background-thread channel. // Tray menu activations arrive on a background-thread channel.
// Drain them all each frame and translate into viewport commands. // Drain them all each frame and translate into viewport commands.

View file

@ -982,7 +982,7 @@ impl Downloader {
let child_pid = Some(child.id()); let child_pid = Some(child.id());
thread::spawn(move || { thread::spawn(move || {
if let Some(stderr) = child.stderr.take() { let stderr_handle = child.stderr.take().map(|stderr| {
let tx = tx.clone(); let tx = tx.clone();
let cookies_abs = cookies_abs.clone(); let cookies_abs = cookies_abs.clone();
thread::spawn(move || { thread::spawn(move || {
@ -990,8 +990,8 @@ impl Downloader {
let line = redact_sensitive(&line, &cookies_abs); let line = redact_sensitive(&line, &cookies_abs);
let _ = tx.send(Msg::Line(format!("[stderr] {line}"))); let _ = tx.send(Msg::Line(format!("[stderr] {line}")));
} }
}); })
} });
if let Some(stdout) = child.stdout.take() { if let Some(stdout) = child.stdout.take() {
for line in BufReader::new(stdout).lines().map_while(Result::ok) { for line in BufReader::new(stdout).lines().map_while(Result::ok) {
@ -1026,6 +1026,13 @@ impl Downloader {
} }
Err(_) => false, Err(_) => false,
}; };
// Wait for the stderr reader to flush all buffered lines before
// announcing completion — otherwise Finished can race ahead of
// trailing stderr output still in flight, and failure_class gets
// computed from an incomplete log (see Job::drain).
if let Some(h) = stderr_handle {
let _ = h.join();
}
let _ = tx.send(Msg::Finished(ok)); let _ = tx.send(Msg::Finished(ok));
}); });
@ -1337,15 +1344,20 @@ impl Downloader {
}; };
let child_pid = Some(child.id()); let child_pid = Some(child.id());
thread::spawn(move || { thread::spawn(move || {
if let Some(stderr) = child.stderr.take() { let stderr_handle = child.stderr.take().map(|stderr| {
let tx = tx.clone(); let tx = tx.clone();
thread::spawn(move || { thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) { for line in BufReader::new(stderr).lines().map_while(Result::ok) {
let _ = tx.send(Msg::Line(format!("[ffmpeg] {line}"))); let _ = tx.send(Msg::Line(format!("[ffmpeg] {line}")));
} }
}); })
} });
let ok = matches!(child.wait(), Ok(s) if s.success()); let ok = matches!(child.wait(), Ok(s) if s.success());
// See the analogous join in spawn_job: wait for stderr to finish
// flushing before any Finished-triggered classification runs.
if let Some(h) = stderr_handle {
let _ = h.join();
}
if ok { if ok {
// File bookkeeping on success. // File bookkeeping on success.
if same_ext { if same_ext {

View file

@ -43,6 +43,15 @@ pub enum TrayEvent {
/// We just hold the receiver end of the menu-event channel. /// We just hold the receiver end of the menu-event channel.
pub struct TrayHandle { pub struct TrayHandle {
pub events: Receiver<TrayEvent>, pub events: Receiver<TrayEvent>,
/// Shared slot for the egui context, filled in by [`App`](crate::app::App)
/// right after construction. The tray runs on a background thread and
/// only pushes events onto `events`; without a way to wake the egui
/// event loop, those events sit unread until the next *natural* repaint —
/// which never happens while the app is idle or hidden-to-tray, so
/// "Show"/"Hide" appear to do nothing. Storing the context here lets the
/// tray thread call `request_repaint()` after every send so the main loop
/// drains the event on the very next frame.
pub ctx: std::sync::Arc<std::sync::OnceLock<eframe::egui::Context>>,
} }
/// Internal tray model implementing `ksni::Tray`. Each method is called /// Internal tray model implementing `ksni::Tray`. Each method is called
@ -52,6 +61,9 @@ pub struct TrayHandle {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
struct TrayModel { struct TrayModel {
tx: std::sync::mpsc::Sender<TrayEvent>, tx: std::sync::mpsc::Sender<TrayEvent>,
/// Shared egui context (see [`TrayHandle::ctx`]). Used to wake the main
/// loop after emitting an event so it's drained immediately.
ctx: std::sync::Arc<std::sync::OnceLock<eframe::egui::Context>>,
/// PNG bytes for the icon, decoded once at construction. ksni asks /// PNG bytes for the icon, decoded once at construction. ksni asks
/// for raw ARGB so we keep both forms. /// for raw ARGB so we keep both forms.
icon_argb: Vec<u8>, icon_argb: Vec<u8>,
@ -59,6 +71,18 @@ struct TrayModel {
icon_h: i32, icon_h: i32,
} }
#[cfg(target_os = "linux")]
impl TrayModel {
/// Emit a tray event and wake the egui loop so it's handled on the next
/// frame even when the app is idle or hidden to the tray.
fn emit(&self, evt: TrayEvent) {
let _ = self.tx.send(evt);
if let Some(ctx) = self.ctx.get() {
ctx.request_repaint();
}
}
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
impl ksni::Tray for TrayModel { impl ksni::Tray for TrayModel {
fn id(&self) -> String { fn id(&self) -> String {
@ -86,7 +110,7 @@ impl ksni::Tray for TrayModel {
/// Activate fires on left-click of the tray icon. Surfacing the main /// Activate fires on left-click of the tray icon. Surfacing the main
/// window is the most common "what did the user mean" interpretation. /// window is the most common "what did the user mean" interpretation.
fn activate(&mut self, _x: i32, _y: i32) { fn activate(&mut self, _x: i32, _y: i32) {
let _ = self.tx.send(TrayEvent::Show); self.emit(TrayEvent::Show);
} }
fn menu(&self) -> Vec<ksni::MenuItem<Self>> { fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::StandardItem; use ksni::menu::StandardItem;
@ -94,7 +118,7 @@ impl ksni::Tray for TrayModel {
StandardItem { StandardItem {
label: "Show Catacomb".into(), label: "Show Catacomb".into(),
activate: Box::new(|m: &mut Self| { activate: Box::new(|m: &mut Self| {
let _ = m.tx.send(TrayEvent::Show); m.emit(TrayEvent::Show);
}), }),
..Default::default() ..Default::default()
} }
@ -102,7 +126,7 @@ impl ksni::Tray for TrayModel {
StandardItem { StandardItem {
label: "Hide window".into(), label: "Hide window".into(),
activate: Box::new(|m: &mut Self| { activate: Box::new(|m: &mut Self| {
let _ = m.tx.send(TrayEvent::Hide); m.emit(TrayEvent::Hide);
}), }),
..Default::default() ..Default::default()
} }
@ -112,7 +136,7 @@ impl ksni::Tray for TrayModel {
label: "Quit".into(), label: "Quit".into(),
icon_name: "application-exit".into(), icon_name: "application-exit".into(),
activate: Box::new(|m: &mut Self| { activate: Box::new(|m: &mut Self| {
let _ = m.tx.send(TrayEvent::Quit); m.emit(TrayEvent::Quit);
}), }),
..Default::default() ..Default::default()
} }
@ -145,8 +169,11 @@ pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
} }
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let ctx: std::sync::Arc<std::sync::OnceLock<eframe::egui::Context>> =
std::sync::Arc::new(std::sync::OnceLock::new());
let model = TrayModel { let model = TrayModel {
tx, tx,
ctx: ctx.clone(),
icon_argb: argb, icon_argb: argb,
icon_w: w as i32, icon_w: w as i32,
icon_h: h as i32, icon_h: h as i32,
@ -176,7 +203,7 @@ pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
}) })
.ok()?; .ok()?;
Some(TrayHandle { events: rx }) Some(TrayHandle { events: rx, ctx })
} }
/// Non-Linux stub: there's no StatusNotifierItem tray backend on Windows or /// Non-Linux stub: there's no StatusNotifierItem tray backend on Windows or

View file

@ -27,7 +27,13 @@ use std::process::Command;
/// Root directory holding everything bundled — venv + bin. /// Root directory holding everything bundled — venv + bin.
pub fn bundled_root() -> PathBuf { pub fn bundled_root() -> PathBuf {
// Native Windows shells generally don't set `HOME`; fall back to
// `USERPROFILE` there so the bundled install has a stable location
// instead of silently relocating to the current working directory.
let home = std::env::var_os("HOME") let home = std::env::var_os("HOME")
.or_else(|| {
if cfg!(windows) { std::env::var_os("USERPROFILE") } else { None }
})
.map(PathBuf::from) .map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(".")); .unwrap_or_else(|| PathBuf::from("."));
home.join(".local").join("share").join("catacomb") home.join(".local").join("share").join("catacomb")