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);
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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.
|
/// 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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue