feat(tray): integrate egui context for system tray events to ensure immediate UI updates
This commit is contained in:
parent
f185e7062f
commit
4de452c503
4 changed files with 122 additions and 25 deletions
80
src/app.rs
80
src/app.rs
|
|
@ -429,6 +429,13 @@ impl App {
|
|||
};
|
||||
|
||||
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 default_view_mode = ViewMode::from_config(&config.ui.default_view_mode);
|
||||
// Typography & spacing baseline: slightly larger base text, more
|
||||
|
|
@ -2207,19 +2214,6 @@ impl App {
|
|||
fn maintenance_screen(&mut self, ctx: &egui::Context) {
|
||||
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).
|
||||
let remote_result = self.remote_rx.as_ref().and_then(|rx| rx.try_recv().ok());
|
||||
if let Some(res) = remote_result {
|
||||
|
|
@ -3248,6 +3242,13 @@ impl App {
|
|||
self.config.ui.theme = id.to_string();
|
||||
theme::apply(ctx, 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.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 {
|
||||
let hint = if self.settings_password_input.is_empty() {
|
||||
"leave blank to keep current"
|
||||
|
|
@ -3482,6 +3498,24 @@ impl App {
|
|||
.desired_width(220.0)
|
||||
.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();
|
||||
|
|
@ -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 ─────────────────────────────────────
|
||||
// Tray menu activations arrive on a background-thread channel.
|
||||
// Drain them all each frame and translate into viewport commands.
|
||||
|
|
|
|||
|
|
@ -982,7 +982,7 @@ impl Downloader {
|
|||
let child_pid = Some(child.id());
|
||||
|
||||
thread::spawn(move || {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let stderr_handle = child.stderr.take().map(|stderr| {
|
||||
let tx = tx.clone();
|
||||
let cookies_abs = cookies_abs.clone();
|
||||
thread::spawn(move || {
|
||||
|
|
@ -990,8 +990,8 @@ impl Downloader {
|
|||
let line = redact_sensitive(&line, &cookies_abs);
|
||||
let _ = tx.send(Msg::Line(format!("[stderr] {line}")));
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
||||
|
|
@ -1026,6 +1026,13 @@ impl Downloader {
|
|||
}
|
||||
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));
|
||||
});
|
||||
|
||||
|
|
@ -1337,15 +1344,20 @@ impl Downloader {
|
|||
};
|
||||
let child_pid = Some(child.id());
|
||||
thread::spawn(move || {
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let stderr_handle = child.stderr.take().map(|stderr| {
|
||||
let tx = tx.clone();
|
||||
thread::spawn(move || {
|
||||
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||
let _ = tx.send(Msg::Line(format!("[ffmpeg] {line}")));
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
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 {
|
||||
// File bookkeeping on success.
|
||||
if same_ext {
|
||||
|
|
|
|||
37
src/tray.rs
37
src/tray.rs
|
|
@ -43,6 +43,15 @@ pub enum TrayEvent {
|
|||
/// We just hold the receiver end of the menu-event channel.
|
||||
pub struct TrayHandle {
|
||||
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
|
||||
|
|
@ -52,6 +61,9 @@ pub struct TrayHandle {
|
|||
#[cfg(target_os = "linux")]
|
||||
struct TrayModel {
|
||||
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
|
||||
/// for raw ARGB so we keep both forms.
|
||||
icon_argb: Vec<u8>,
|
||||
|
|
@ -59,6 +71,18 @@ struct TrayModel {
|
|||
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")]
|
||||
impl ksni::Tray for TrayModel {
|
||||
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
|
||||
/// window is the most common "what did the user mean" interpretation.
|
||||
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>> {
|
||||
use ksni::menu::StandardItem;
|
||||
|
|
@ -94,7 +118,7 @@ impl ksni::Tray for TrayModel {
|
|||
StandardItem {
|
||||
label: "Show Catacomb".into(),
|
||||
activate: Box::new(|m: &mut Self| {
|
||||
let _ = m.tx.send(TrayEvent::Show);
|
||||
m.emit(TrayEvent::Show);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
|
|
@ -102,7 +126,7 @@ impl ksni::Tray for TrayModel {
|
|||
StandardItem {
|
||||
label: "Hide window".into(),
|
||||
activate: Box::new(|m: &mut Self| {
|
||||
let _ = m.tx.send(TrayEvent::Hide);
|
||||
m.emit(TrayEvent::Hide);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
|
|
@ -112,7 +136,7 @@ impl ksni::Tray for TrayModel {
|
|||
label: "Quit".into(),
|
||||
icon_name: "application-exit".into(),
|
||||
activate: Box::new(|m: &mut Self| {
|
||||
let _ = m.tx.send(TrayEvent::Quit);
|
||||
m.emit(TrayEvent::Quit);
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
|
|
@ -145,8 +169,11 @@ pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
|
|||
}
|
||||
|
||||
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 {
|
||||
tx,
|
||||
ctx: ctx.clone(),
|
||||
icon_argb: argb,
|
||||
icon_w: w as i32,
|
||||
icon_h: h as i32,
|
||||
|
|
@ -176,7 +203,7 @@ pub fn start(icon_png_bytes: &[u8]) -> Option<TrayHandle> {
|
|||
})
|
||||
.ok()?;
|
||||
|
||||
Some(TrayHandle { events: rx })
|
||||
Some(TrayHandle { events: rx, ctx })
|
||||
}
|
||||
|
||||
/// Non-Linux stub: there's no StatusNotifierItem tray backend on Windows or
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ use std::process::Command;
|
|||
|
||||
/// Root directory holding everything bundled — venv + bin.
|
||||
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")
|
||||
.or_else(|| {
|
||||
if cfg!(windows) { std::env::var_os("USERPROFILE") } else { None }
|
||||
})
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
home.join(".local").join("share").join("catacomb")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue