diff --git a/crates/pdfs-gui/build.rs b/crates/pdfs-gui/build.rs index 2362531..dd7440a 100644 --- a/crates/pdfs-gui/build.rs +++ b/crates/pdfs-gui/build.rs @@ -27,5 +27,6 @@ fn main() { println!("cargo:rerun-if-changed=resources/icons"); println!("cargo:rerun-if-changed=resources/style.css"); println!("cargo:rerun-if-changed=resources/proton-theme.css"); + println!("cargo:rerun-if-changed=resources/prompt.css"); println!("cargo:rerun-if-changed=../../packaging/io.narl.proton-drive-linux.svg"); } diff --git a/crates/pdfs-gui/resources/pdfs.gresource.xml b/crates/pdfs-gui/resources/pdfs.gresource.xml index 73120cb..1026a5a 100644 --- a/crates/pdfs-gui/resources/pdfs.gresource.xml +++ b/crates/pdfs-gui/resources/pdfs.gresource.xml @@ -33,5 +33,7 @@ style.css proton-theme.css + + prompt.css diff --git a/crates/pdfs-gui/resources/prompt.css b/crates/pdfs-gui/resources/prompt.css new file mode 100644 index 0000000..7f769d1 --- /dev/null +++ b/crates/pdfs-gui/resources/prompt.css @@ -0,0 +1,224 @@ +/* The search launcher (pdfs-prompt). Colours come from the libadwaita + palette, so the launcher follows the system accent, light/dark and high + contrast like the app; the opt-in Proton theme recolours both alike. */ + +window.launcher-window { + background: transparent; +} + +.launcher-card { + background-color: var(--view-bg-color); + color: var(--view-fg-color); + border: 1px solid var(--border-color); + border-radius: 14px; + box-shadow: 0 16px 48px rgb(0 0 0 / 35%); +} + +.search-bar { + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); +} + +.search-icon { + -gtk-icon-size: 20px; + color: color-mix(in srgb, currentColor 55%, transparent); +} + +.search-entry { + background: none; + border: none; + box-shadow: none; + outline: none; + padding: 2px 0; + font-size: 1.15rem; +} + +.key-hint { + padding: 2px 8px; + border-radius: 6px; + background-color: color-mix(in srgb, currentColor 8%, transparent); + font-size: 0.75rem; + opacity: 0.55; +} + +.chip-row { + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); +} + +.chip { + padding: 4px 14px; + min-height: 0; + border-radius: 999px; + background: none; + border: 1px solid var(--border-color); + box-shadow: none; + font-size: 0.86rem; + font-weight: 500; +} + +.chip:hover { + background-color: color-mix(in srgb, currentColor 6%, transparent); +} + +.chip:checked { + background-color: var(--accent-bg-color); + color: var(--accent-fg-color); + border-color: var(--accent-bg-color); +} + +/* --- results ------------------------------------------------------------ */ + +.results { + padding: 2px 8px 10px 8px; +} + +.section-header { + padding: 12px 10px 6px 10px; +} + +.section-title { + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.55; +} + +.section-icon { + -gtk-icon-size: 14px; + opacity: 0.55; +} + +.result-list { + background: none; +} + +.result-row { + padding: 6px 10px; + border-radius: 10px; +} + +.result-row:hover { + background-color: color-mix(in srgb, currentColor 5%, transparent); +} + +/* A soft tint rather than a solid accent bar: the text keeps its colour, so + the highlighted match stays readable on the selected row. */ +.result-row:selected, +.result-row:selected:hover { + background-color: color-mix(in srgb, var(--accent-bg-color) 18%, transparent); + color: inherit; +} + +.result-name { + font-weight: 500; +} + +.result-location, +.result-meta { + font-size: 0.8rem; + opacity: 0.55; +} + +.result-row:selected .result-location, +.result-row:selected .result-meta { + opacity: 0.7; +} + +.thumb { + border-radius: 6px; +} + +.result-badge, +.preview-badge { + -gtk-icon-size: 14px; +} + +.badge-pinned { + color: var(--success-color); +} + +.badge-cached { + color: var(--accent-color); +} + +.badge-cloud, +.badge-local { + opacity: 0.55; +} + +/* --- preview ------------------------------------------------------------ */ + +.preview { + padding: 16px; + background-color: color-mix(in srgb, currentColor 3%, transparent); +} + +.preview-thumb { + margin-bottom: 12px; +} + +.preview-thumb .thumb { + border-radius: 10px; +} + +.preview-name { + font-weight: 700; + font-size: 1.05rem; +} + +.preview-kind { + font-size: 0.85rem; + opacity: 0.6; + margin-bottom: 12px; +} + +.preview-key { + font-size: 0.8rem; + opacity: 0.55; +} + +.preview-value { + font-size: 0.85rem; +} + +.preview-actions { + margin-top: 12px; +} + +.preview-action { + padding: 6px 8px; + border-radius: 8px; +} + +.preview-shortcut { + font-size: 0.75rem; + opacity: 0.6; +} + +.preview-feedback { + margin-top: 6px; + font-size: 0.8rem; + color: var(--accent-color); +} + +.preview-empty { + opacity: 0.2; +} + +/* --- footer ------------------------------------------------------------- */ + +.footer { + padding: 8px 16px; + border-top: 1px solid var(--border-color); +} + +.footer-text { + font-size: 0.78rem; + opacity: 0.5; +} + +statuspage.compact > scrolledwindow > viewport > box { + margin: 24px 12px; +} diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 643366f..9e334bb 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -8,6 +8,8 @@ pub(crate) mod events; pub(crate) mod i18n; pub(crate) mod pages; pub(crate) mod reload; +#[path = "../theme.rs"] +pub(crate) mod theme; pub(crate) mod widgets; use compat::*; @@ -30,6 +32,7 @@ use pages::takeout::*; use pages::trash::*; use pages::verify::*; use reload::{LoadTicket, Loader, replace_items}; +use theme::{load_resources, proton_theme_active, set_proton_theme}; use widgets::compat::*; use widgets::details::*; use widgets::file_list::*; @@ -210,7 +213,7 @@ fn main() -> glib::ExitCode { .flags(gio::ApplicationFlags::HANDLES_COMMAND_LINE) .build(); app.connect_startup(|_| { - load_proton_theme(); + load_resources("/de/nils/protondrivelinux/style.css"); if let Ok(dirs) = AppDirs::new() { set_proton_theme(dirs.load_config().proton_theme.unwrap_or(false)); } @@ -287,157 +290,6 @@ pub(crate) fn spawn_tray() { } } -/// Register the bundled GResources (custom icons, the stylesheet) and load the -/// app stylesheet. The Proton theme is a separate provider, applied by -/// [`set_proton_theme`] from the saved preference. -fn load_proton_theme() { - let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource")); - let resource_data = glib::Bytes::from_static(bytes); - if let Ok(resource) = gio::Resource::from_data(&resource_data) { - gio::resources_register(&resource); - } else { - tracing::error!("failed to load gresource bundle"); - } - - let Some(display) = gtk4::gdk::Display::default() else { - return; - }; - let provider = gtk4::CssProvider::new(); - provider.load_from_resource("/de/nils/protondrivelinux/style.css"); - // Above the user's own gtk.css, which often imports a whole theme: its - // `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every - // photo tile and push the grid past the window. Every rule in the sheet is - // scoped to the app's own classes, so nothing else the user styled changes. - // One above the Proton theme too, so the app's rules refine it. - gtk4::style_context_add_provider_for_display( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_USER + 2, - ); - gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons"); -} - -thread_local! { - /// The Proton theme provider while it is installed. Kept so turning the - /// preference off can remove exactly what turning it on added. - static PROTON_THEME: RefCell> = const { RefCell::new(None) }; -} - -/// Whether the Proton theme is on. -fn proton_theme_active() -> bool { - PROTON_THEME.with(|slot| slot.borrow().is_some()) -} - -/// libadwaita's own stylesheet, bundled in the library. -const ADWAITA_STYLESHEET: &str = "/org/gnome/Adwaita/styles/gtk.css"; - -/// The Proton palette laid over it, from our own bundle. -const PROTON_PALETTE: &str = "/de/nils/protondrivelinux/proton-theme.css"; - -/// Make the theme provider's `@media (prefers-color-scheme)` and -/// `(prefers-contrast)` rules match what libadwaita shows, now and whenever it -/// changes. A provider evaluates those itself, and a new one assumes light. -/// -/// The properties are set by name because they only exist since GTK 4.20, and -/// the app still runs on older GTK, whose libadwaita has no such rules anyway. -fn follow_style_manager(provider: >k4::CssProvider) { - fn set_enum(provider: >k4::CssProvider, property: &str, nick: &str) { - let Some(pspec) = provider.find_property(property) else { - return; - }; - if let Some(value) = glib::EnumClass::with_type(pspec.value_type()) - .and_then(|class| class.to_value_by_nick(nick)) - { - provider.set_property_from_value(property, &value); - } - } - fn apply(provider: >k4::CssProvider, manager: &adw::StyleManager) { - let scheme = if manager.is_dark() { "dark" } else { "light" }; - set_enum(provider, "prefers-color-scheme", scheme); - let contrast = if manager.is_high_contrast() { - "more" - } else { - "no-preference" - }; - set_enum(provider, "prefers-contrast", contrast); - } - - let manager = adw::StyleManager::default(); - apply(provider, &manager); - // Weak, so a provider the preference removed isn't kept alive; its - // handlers then do nothing. - let weak = provider.downgrade(); - let weak_dark = weak.clone(); - manager.connect_dark_notify(move |manager| { - if let Some(provider) = weak_dark.upgrade() { - apply(&provider, manager); - } - }); - manager.connect_high_contrast_notify(move |manager| { - if let Some(provider) = weak.upgrade() { - apply(&provider, manager); - } - }); -} - -/// Paint the app in the Proton web apps' colours (`on`) or follow the system -/// theme and accent colour. -/// -/// Setting libadwaita's colour variables alone isn't enough: a GTK theme the -/// user's `gtk.css` imports (Catppuccin, Fluent, ...) sits above the app and -/// hard-codes its own accent into hundreds of rules. So the theme provider, -/// one above the user's CSS, first resets every property, then re-applies -/// libadwaita's whole stylesheet and finally the Proton palette, which leaves -/// nothing of the user's theme inside the app. -fn set_proton_theme(on: bool) { - let Some(display) = gtk4::gdk::Display::default() else { - return; - }; - PROTON_THEME.with(|slot| { - let mut slot = slot.borrow_mut(); - match (on, slot.as_ref()) { - (true, None) => { - let stylesheet = |path| { - gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE) - .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) - }; - let palette = stylesheet(PROTON_PALETTE).unwrap_or_default(); - let css = match stylesheet(ADWAITA_STYLESHEET) { - // Its images are named relative to the sheet, which a - // string loaded here doesn't have. - Ok(adwaita) => format!( - "* {{ all: unset; }}\n{}\n{palette}", - adwaita.replace( - "url(\"assets/", - "url(\"resource:///org/gnome/Adwaita/styles/assets/" - ) - ), - // Still recolours everything that follows libadwaita's - // variables; only a custom theme's own rules show through. - Err(e) => { - tracing::warn!("libadwaita stylesheet not found, only recolouring: {e}"); - palette - } - }; - let provider = gtk4::CssProvider::new(); - follow_style_manager(&provider); - provider.load_from_string(&css); - gtk4::style_context_add_provider_for_display( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_USER + 1, - ); - *slot = Some(provider); - } - (false, Some(provider)) => { - gtk4::style_context_remove_provider_for_display(&display, provider); - *slot = None; - } - _ => {} - } - }); -} - /// Build the application window, wire the two pages, kick off the refresh loop, /// and present it. fn build_window(app: &adw::Application) { diff --git a/crates/pdfs-gui/src/hud/highlight.rs b/crates/pdfs-gui/src/hud/highlight.rs new file mode 100644 index 0000000..7473a8a --- /dev/null +++ b/crates/pdfs-gui/src/hud/highlight.rs @@ -0,0 +1,143 @@ +//! Match highlighting: which characters of a result name the query matched, +//! as Pango markup with those characters in bold. +//! +//! Each query term is looked for as a substring of the name first, and as a +//! subsequence ("rpt" in "report") when that fails, which is roughly how the +//! daemon's ranking treats a term too. Case is folded one character at a +//! time, so every range stays on a character boundary of the original name, +//! whatever the script. + +use gtk4::glib; + +/// One character of a name: its byte range and its case-folded form. +struct Folded { + start: usize, + end: usize, + folded: String, +} + +fn fold(text: &str) -> Vec { + text.char_indices() + .map(|(start, c)| Folded { + start, + end: start + c.len_utf8(), + folded: c.to_lowercase().collect(), + }) + .collect() +} + +/// The byte ranges of `name` that `query` matched, sorted and merged. +pub(crate) fn ranges(name: &str, query: &str) -> Vec<(usize, usize)> { + let chars = fold(name); + let mut found: Vec<(usize, usize)> = Vec::new(); + for term in query.split_whitespace() { + let term: Vec = fold(term).into_iter().map(|c| c.folded).collect(); + if let Some(at) = substring(&chars, &term) { + found.push((chars[at].start, chars[at + term.len() - 1].end)); + } else if let Some(hits) = subsequence(&chars, &term) { + found.extend(hits.into_iter().map(|at| (chars[at].start, chars[at].end))); + } + } + found.sort_unstable(); + let mut merged: Vec<(usize, usize)> = Vec::with_capacity(found.len()); + for (start, end) in found { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + merged +} + +/// Where `term` first occurs in `chars`, as a character index. +fn substring(chars: &[Folded], term: &[String]) -> Option { + if term.is_empty() || term.len() > chars.len() { + return None; + } + (0..=chars.len() - term.len()).find(|&at| { + chars[at..at + term.len()] + .iter() + .zip(term) + .all(|(c, t)| c.folded == *t) + }) +} + +/// The character indices of the leftmost match of `term` as a subsequence, +/// or `None` when not every character of it can be found in order. +fn subsequence(chars: &[Folded], term: &[String]) -> Option> { + if term.is_empty() { + return None; + } + let mut hits = Vec::with_capacity(term.len()); + let mut next = 0; + for wanted in term { + let at = (next..chars.len()).find(|&i| chars[i].folded == *wanted)?; + hits.push(at); + next = at + 1; + } + Some(hits) +} + +/// `name` as Pango markup, escaped, with the matched characters in bold. +pub(crate) fn markup(name: &str, query: &str) -> String { + let mut out = String::with_capacity(name.len() + 16); + let mut done = 0; + for (start, end) in ranges(name, query) { + out.push_str(&glib::markup_escape_text(&name[done..start])); + out.push_str(""); + out.push_str(&glib::markup_escape_text(&name[start..end])); + out.push_str(""); + done = end; + } + out.push_str(&glib::markup_escape_text(&name[done..])); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_substring_match_is_one_range_in_any_case() { + assert_eq!(ranges("Tax Report.pdf", "REP"), vec![(4, 7)]); + } + + #[test] + fn a_term_that_is_no_substring_falls_back_to_a_subsequence() { + // r, p and t of "report", not the "rt" pair at the end of a substring. + assert_eq!(ranges("report.txt", "rpt"), vec![(0, 1), (2, 3), (5, 6)]); + } + + #[test] + fn a_term_with_no_match_highlights_nothing() { + assert!(ranges("report.txt", "xyz").is_empty()); + assert!(ranges("report.txt", "").is_empty()); + } + + #[test] + fn several_terms_merge_into_sorted_ranges() { + assert_eq!( + ranges("tax return 2024", "2024 tax ret"), + vec![(0, 3), (4, 7), (11, 15)] + ); + assert_eq!(ranges("abc", "ab bc"), vec![(0, 3)]); + } + + #[test] + fn non_ascii_names_keep_ranges_on_character_boundaries() { + assert_eq!(ranges("Über Größe.odt", "über"), vec![(0, 5)]); + assert_eq!(ranges("Über Größe.odt", "GRÖ"), vec![(6, 10)]); + assert_eq!(markup("Über Größe.odt", "größe"), "Über Größe.odt"); + assert_eq!( + markup("日本語のメモ.txt", "メモ"), + "日本語のメモ.txt" + ); + } + + #[test] + fn markup_escapes_the_name_inside_and_outside_matches() { + assert_eq!(markup("a&c", "b"), "a<b>&c"); + assert_eq!(markup("R&D.pdf", "r&d"), "R&D.pdf"); + assert_eq!(markup("", "zzz"), "<none>"); + } +} diff --git a/crates/pdfs-gui/src/hud/history.rs b/crates/pdfs-gui/src/hud/history.rs new file mode 100644 index 0000000..84aaba2 --- /dev/null +++ b/crates/pdfs-gui/src/hud/history.rs @@ -0,0 +1,363 @@ +//! Recent files and frecency. +//! +//! The launcher remembers what it opened in `prompt-history.json` under the +//! state directory. With an empty query those files come first, and while +//! searching a file opened often and lately gets a bonus on top of its match +//! score (see [`frecency_bonus`]). +//! +//! One writer thread owns the file: it loads it, applies every recorded visit +//! in order and writes the result back atomically. A visit recorded before the +//! first load finished simply waits in the channel, so it is merged into the +//! loaded list rather than overwriting it. The main thread only ever sees +//! snapshots. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use gtk4::glib; + +use crate::Hit; + +/// How many files the history keeps. More than the launcher ever shows, so +/// frecency still knows a file that dropped off the recent list. +const MAX_VISITS: usize = 50; + +/// The frecency bonus of one visit made just now. +const VISIT_BONUS: f64 = 1500.0; + +/// The bonus halves every this many days without a visit. +const HALF_LIFE_DAYS: f64 = 14.0; + +/// The largest frecency bonus. A name match is worth up to 20000 points, so +/// history breaks ties between similar matches but cannot beat a much better +/// one. +const MAX_BONUS: f64 = 6000.0; + +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct Visit { + pub(crate) hit: Hit, + /// When the file was last opened, in epoch seconds. + pub(crate) last: i64, + pub(crate) count: u32, +} + +/// The history bonus for a file opened `count` times, the last time +/// `age_secs` ago. +pub(crate) fn frecency_bonus(count: u32, age_secs: i64) -> i64 { + let age_days = age_secs.max(0) as f64 / 86_400.0; + let bonus = f64::from(count) * VISIT_BONUS * 0.5f64.powf(age_days / HALF_LIFE_DAYS); + bonus.min(MAX_BONUS) as i64 +} + +/// Count one more visit of `hit` at `now`, newest first, and keep at most +/// [`MAX_VISITS`]. The stored hit is replaced, so its size, date and sync +/// state are the ones from the latest search. +fn record(visits: &mut Vec, hit: Hit, now: i64) { + let key = hit.key(); + let count = match visits.iter().position(|visit| visit.hit.key() == key) { + Some(at) => visits.remove(at).count.saturating_add(1), + None => 1, + }; + visits.insert( + 0, + Visit { + hit, + last: now, + count, + }, + ); + visits.truncate(MAX_VISITS); +} + +/// Drop local files that no longer exist. Drive files stay: checking them +/// would touch the mount, and a stale one only costs a row. +fn prune(visits: &mut Vec) -> bool { + let before = visits.len(); + visits.retain(|visit| match &visit.hit { + Hit::Local(hit) => Path::new(&hit.path).symlink_metadata().is_ok(), + Hit::Drive(_) => true, + }); + visits.len() != before +} + +fn load(path: &Path) -> Vec { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| { + tracing::warn!("ignoring unreadable {}: {e}", path.display()); + Vec::new() + }), + Err(_) => Vec::new(), + } +} + +/// Write the history through a temporary file and a rename, so a crash +/// mid-write leaves the old file rather than half a new one. +fn save(path: &Path, visits: &[Visit]) -> std::io::Result<()> { + let json = serde_json::to_vec(visits).map_err(std::io::Error::other)?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("json.tmp"); + let mut file = std::fs::File::create(&tmp)?; + file.write_all(&json)?; + file.sync_all()?; + std::fs::rename(&tmp, path) +} + +enum Command { + Record(Hit, i64), + Prune, +} + +/// The writer thread's loop: load, then apply commands until the launcher +/// exits. `publish` receives the list after the load and after every change. +fn serve(path: &Path, commands: &async_channel::Receiver, publish: impl Fn(Vec)) { + let mut visits = load(path); + let pruned = prune(&mut visits); + publish(visits.clone()); + if pruned && let Err(e) = save(path, &visits) { + tracing::warn!("cannot write {}: {e}", path.display()); + } + while let Ok(command) = commands.recv_blocking() { + let changed = match command { + Command::Record(hit, now) => { + record(&mut visits, hit, now); + true + } + Command::Prune => prune(&mut visits), + }; + if changed { + if let Err(e) = save(path, &visits) { + tracing::warn!("cannot write {}: {e}", path.display()); + } + publish(visits.clone()); + } + } +} + +/// The main thread's view of the history. +pub(crate) struct History { + visits: Rc>>, + commands: async_channel::Sender, +} + +impl History { + /// Start the writer thread. `changed` runs on the main thread whenever a + /// new snapshot arrives. + pub(crate) fn start(path: PathBuf, changed: impl Fn() + 'static) -> Self { + let (commands, inbox) = async_channel::unbounded(); + let (snapshots, updates) = async_channel::unbounded::>(); + let spawned = std::thread::Builder::new() + .name("prompt-history".into()) + .spawn(move || { + serve(&path, &inbox, |visits| { + let _ = snapshots.send_blocking(visits); + }) + }); + if let Err(e) = spawned { + tracing::warn!("cannot start the history writer: {e}"); + } + + let visits = Rc::new(RefCell::new(Vec::new())); + let shared = visits.clone(); + glib::spawn_future_local(async move { + while let Ok(snapshot) = updates.recv().await { + *shared.borrow_mut() = snapshot; + changed(); + } + }); + Self { visits, commands } + } + + /// Remember that `hit` was opened just now. + pub(crate) fn record(&self, hit: Hit) { + let _ = self.commands.try_send(Command::Record(hit, now())); + } + + /// Forget local files that were deleted since they were opened. + pub(crate) fn prune(&self) { + let _ = self.commands.try_send(Command::Prune); + } + + /// The most recently opened files, newest first. + pub(crate) fn recent(&self, limit: usize) -> Vec { + self.visits + .borrow() + .iter() + .take(limit) + .map(|visit| visit.hit.clone()) + .collect() + } + + /// The frecency bonus of every remembered file, by [`Hit::key`]. + pub(crate) fn bonuses(&self) -> HashMap<(bool, String), i64> { + let now = now(); + self.visits + .borrow() + .iter() + .map(|visit| { + ( + visit.hit.key(), + frecency_bonus(visit.count, now - visit.last), + ) + }) + .collect() + } +} + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfs_core::control::{LocalHit, SearchHit}; + + const DAY: i64 = 86_400; + + fn drive(name: &str) -> Hit { + Hit::Drive(SearchHit { + name: name.into(), + path: format!("Docs/{name}"), + is_dir: false, + size: 1, + modified: 1, + pinned: false, + cached: false, + uid: format!("v~{name}"), + mounted_path: None, + score: 0, + }) + } + + fn scratch_dir(test: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "pdfs-prompt-history-{test}-{}-{}", + std::process::id(), + now() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn the_bonus_halves_every_two_weeks() { + assert_eq!(frecency_bonus(1, 0), 1500); + assert_eq!(frecency_bonus(1, 14 * DAY), 750); + assert_eq!(frecency_bonus(1, 28 * DAY), 375); + assert_eq!(frecency_bonus(2, 14 * DAY), 1500); + // A clock that went backwards counts as "just now", not as a boost. + assert_eq!(frecency_bonus(1, -DAY), 1500); + } + + #[test] + fn the_bonus_is_capped() { + assert_eq!(frecency_bonus(4, 0), 6000); + assert_eq!(frecency_bonus(1000, 0), 6000); + assert!(frecency_bonus(1000, 365 * DAY) < 6000); + } + + #[test] + fn a_repeat_visit_moves_the_file_to_the_top_and_counts_it() { + let mut visits = Vec::new(); + record(&mut visits, drive("a.pdf"), 10); + record(&mut visits, drive("b.pdf"), 20); + record(&mut visits, drive("a.pdf"), 30); + assert_eq!(visits.len(), 2); + assert_eq!(visits[0].hit.name(), "a.pdf"); + assert_eq!((visits[0].count, visits[0].last), (2, 30)); + assert_eq!(visits[1].count, 1); + } + + #[test] + fn the_history_keeps_the_newest_fifty_files() { + let mut visits = Vec::new(); + for n in 0..60 { + record(&mut visits, drive(&format!("{n}.pdf")), n); + } + assert_eq!(visits.len(), MAX_VISITS); + assert_eq!(visits[0].hit.name(), "59.pdf"); + assert_eq!(visits[MAX_VISITS - 1].hit.name(), "10.pdf"); + } + + #[test] + fn the_history_file_round_trips() { + let dir = scratch_dir("round-trip"); + let path = dir.join("prompt-history.json"); + let mut visits = Vec::new(); + record(&mut visits, drive("a.pdf"), 10); + record(&mut visits, drive("a.pdf"), 20); + save(&path, &visits).unwrap(); + + let loaded = load(&path); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].hit.key(), drive("a.pdf").key()); + assert_eq!((loaded[0].count, loaded[0].last), (2, 20)); + assert!(!path.with_extension("json.tmp").exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn visits_recorded_before_the_load_are_merged_into_it() { + let dir = scratch_dir("merge"); + let path = dir.join("prompt-history.json"); + let mut stored = Vec::new(); + record(&mut stored, drive("old.pdf"), 10); + record(&mut stored, drive("old.pdf"), 20); + save(&path, &stored).unwrap(); + + // Both visits are queued before the writer has read the file. + let (commands, inbox) = async_channel::unbounded(); + commands + .send_blocking(Command::Record(drive("old.pdf"), 30)) + .unwrap(); + commands + .send_blocking(Command::Record(drive("new.pdf"), 40)) + .unwrap(); + drop(commands); + + let published = RefCell::new(Vec::new()); + serve(&path, &inbox, |visits| published.borrow_mut().push(visits)); + + let last = published.borrow().last().cloned().unwrap(); + assert_eq!(last.len(), 2); + assert_eq!(last[0].hit.name(), "new.pdf"); + assert_eq!((last[1].hit.name(), last[1].count), ("old.pdf", 3)); + let on_disk = load(&path); + assert_eq!(on_disk.len(), 2); + assert_eq!(on_disk[1].count, 3); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn pruning_drops_deleted_local_files_only() { + let dir = scratch_dir("prune"); + let kept = dir.join("kept.txt"); + std::fs::write(&kept, "x").unwrap(); + let local = |path: &Path| { + Hit::Local(LocalHit { + name: path.file_name().unwrap().to_string_lossy().into_owned(), + path: path.display().to_string(), + is_dir: false, + size: 1, + modified: 1, + score: 0, + }) + }; + let mut visits = Vec::new(); + record(&mut visits, local(&kept), 1); + record(&mut visits, local(&dir.join("gone.txt")), 2); + record(&mut visits, drive("remote.pdf"), 3); + + assert!(prune(&mut visits)); + let names: Vec<&str> = visits.iter().map(|visit| visit.hit.name()).collect(); + assert_eq!(names, ["remote.pdf", "kept.txt"]); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/crates/pdfs-gui/src/hud/mod.rs b/crates/pdfs-gui/src/hud/mod.rs new file mode 100644 index 0000000..a8f0963 --- /dev/null +++ b/crates/pdfs-gui/src/hud/mod.rs @@ -0,0 +1,1184 @@ +//! The built-in GTK launcher: a search entry over Drive and this computer, +//! the results on the left and a preview of the selected one on the right. +//! +//! Focus never leaves the search entry. The arrow keys move a cursor through +//! the results, Enter opens, Ctrl+Enter shows the file in its folder and +//! Ctrl+C copies its path. A single click selects a row, a double-click opens +//! it, and a row can be dragged out as a file. + +mod highlight; +mod history; +mod preview; +mod row; +mod thumbs; + +use std::cell::{Cell, RefCell}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Duration; + +use adw::prelude::*; +use gtk4::{gdk, gio, glib}; + +use pdfs_core::config::AppDirs; +use pdfs_core::control::{Request, Response, SearchFilters, SearchHit, SearchSource}; +use pdfs_core::opener::{self, OpenWith}; + +use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; +use crate::compat::{Spinner, spinner}; +use crate::theme::set_proton_theme; +use crate::{ + FILTERS, Filter, Hit, SEARCH_DEBOUNCE, SEARCH_LIMIT, file_name, gettext, gettext_f, ngettext_f, + rank_hits_by, spawn_request, +}; +use history::History; +use preview::Preview; +use thumbs::Thumbs; + +/// Recent files shown above the pinned ones while the query is empty. +const RECENT_LIMIT: usize = 8; + +/// How long opening waits for the mount to answer whether it has a file. A +/// frozen mount must not freeze the launcher; the daemon is asked instead. +const STAT_TIMEOUT: Duration = Duration::from_secs(2); + +/// The titled groups of the result list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Section { + Recent, + Pinned, + Matches, +} + +impl Section { + fn title(self) -> String { + match self { + // Translators: the launcher's section of recently opened files. + Section::Recent => gettext("Recent"), + Section::Pinned => gettext("Pinned in Proton Drive"), + Section::Matches => gettext("Best matches"), + } + } + + fn icon(self) -> &'static str { + match self { + Section::Recent => "document-open-recent-symbolic", + Section::Pinned => "view-pin-symbolic", + Section::Matches => "system-search-symbolic", + } + } + + fn header(self) -> gtk4::Box { + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.add_css_class("section-header"); + let image = gtk4::Image::from_icon_name(self.icon()); + image.add_css_class("section-icon"); + header.append(&image); + let label = gtk4::Label::builder() + .label(self.title()) + .xalign(0.0) + .build(); + label.add_css_class("section-title"); + header.append(&label); + header + } +} + +/// Everything the window needs to render and act, in one place so the many +/// callbacks below capture a single `Rc` instead of a dozen clones each. +pub(crate) struct Ui { + socket: PathBuf, + mountpoint: RefCell, + /// How a chosen file is launched. Re-read on every activation, so a + /// change in the app applies the next time the launcher opens. + opener: RefCell, + + window: adw::ApplicationWindow, + entry: gtk4::Entry, + spinner: Spinner, + stack: gtk4::Stack, + scroller: gtk4::ScrolledWindow, + list: gtk4::ListBox, + /// The section that starts at each row index, for the header function. + headers: Rc>>>, + placeholder: adw::StatusPage, + hint: gtk4::Label, + chips: RefCell>, + preview: Preview, + /// The hit the preview shows, so a repeated selection does not redo it. + previewed: RefCell>, + + thumbs: Rc, + history: History, + + /// Raw (unfiltered) hits from the last reply: search results, or the + /// pinned files while the query is empty. + drive_hits: RefCell>, + local_hits: RefCell>, + /// The rows in list order; the cursor indexes into it. + visible: RefCell>, + cursor: Cell>, + /// The trimmed query the currently-rendered rows belong to. Enter compares + /// against it so a keystroke that lands just before a fresher render can't + /// open a file from a result set the user has already typed past. + rendered_query: RefCell, + + filter: Cell, + /// Monotonic query id. A reply whose id is stale (the user typed again while + /// it was in flight) is dropped instead of overwriting fresher results. + query_id: Cell, + /// In-flight requests for the current query id. + pending: Cell, + /// The ticket of the Drive open in progress. Escape clears it, and a + /// reply for a ticket that is no longer current launches nothing. + opening: Cell>, + last_ticket: Cell, + indexing: Cell, + /// Set when Enter arrives before the in-flight query has rendered (typing + /// then hitting Enter faster than the search debounce). The open is honoured + /// against the selected row once the fresh results settle, rather than being + /// dropped — so a plain "type, Enter" always opens something. + open_pending: Cell, + /// Prevent lifecycle-driven entry resets from also scheduling the normal + /// debounced empty-query request; activation performs one explicit daemon + /// bootstrap instead. + suppress_entry_change: Cell, +} + +pub(crate) fn build_window(app: &adw::Application) -> Option> { + let dirs = match AppDirs::new() { + Ok(d) => d, + Err(e) => { + tracing::error!("cannot resolve app dirs: {e}"); + return None; + } + }; + + let window = adw::ApplicationWindow::builder() + .application(app) + .title(gettext("Search Proton Drive")) + .default_width(860) + .default_height(560) + .resizable(false) + .decorated(false) + .build(); + window.add_css_class("launcher-window"); + + let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + card.add_css_class("launcher-card"); + window.set_content(Some(&card)); + + // --- search bar ------------------------------------------------------- + let search_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + search_bar.add_css_class("search-bar"); + + let search_icon = gtk4::Image::from_icon_name("system-search-symbolic"); + search_icon.add_css_class("search-icon"); + search_bar.append(&search_icon); + + let entry = gtk4::Entry::builder() + .placeholder_text(gettext("Search in Drive and on this computer")) + .hexpand(true) + .build(); + entry.add_css_class("search-entry"); + search_bar.append(&entry); + + let spinner = spinner(); + spinner.set_visible(false); + search_bar.append(&spinner); + + // Translators: the Escape key, shown as a hint that it closes the window. + let esc = gtk4::Label::new(Some(&gettext("Esc"))); + esc.add_css_class("key-hint"); + search_bar.append(&esc); + card.append(&search_bar); + + // --- filter chips ----------------------------------------------------- + let chip_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + chip_row.add_css_class("chip-row"); + card.append(&chip_row); + + // --- results and preview ---------------------------------------------- + let list = gtk4::ListBox::builder() + .selection_mode(gtk4::SelectionMode::Single) + .activate_on_single_click(false) + .focusable(false) + .build(); + list.add_css_class("result-list"); + let headers: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let header_of = headers.clone(); + list.set_header_func(move |row, _| { + let section = header_of + .borrow() + .get(row.index() as usize) + .copied() + .flatten(); + match section { + Some(section) => row.set_header(Some(§ion.header())), + None => row.set_header(gtk4::Widget::NONE), + } + }); + + let scroller = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .vscrollbar_policy(gtk4::PolicyType::Automatic) + .vexpand(true) + .child(&list) + .build(); + scroller.add_css_class("results"); + + let placeholder = adw::StatusPage::builder() + .icon_name("system-search-symbolic") + .title(gettext("No results")) + .build(); + placeholder.add_css_class("compact"); + + // The daemon-offline page is a peer of the results, not a modal: the prompt + // is still usable for nothing else, so it owns the whole view. + let offline = adw::StatusPage::builder() + .icon_name("network-offline-symbolic") + .title(gettext("Proton Drive is not running")) + .description(gettext("Start Proton Drive to search your files.")) + .build(); + offline.add_css_class("compact"); + let retry = gtk4::Button::builder() + .label(gettext("Retry")) + .halign(gtk4::Align::Center) + .focus_on_click(false) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + offline.set_child(Some(&retry)); + + let stack = gtk4::Stack::builder() + .transition_type(gtk4::StackTransitionType::Crossfade) + .transition_duration(120) + .vexpand(true) + .hexpand(true) + .build(); + stack.add_named(&scroller, Some("results")); + stack.add_named(&placeholder, Some("empty")); + stack.add_named(&offline, Some("offline")); + + let preview = Preview::new(); + let body = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + body.append(&stack); + body.append(>k4::Separator::new(gtk4::Orientation::Vertical)); + body.append(&preview.root); + card.append(&body); + + // --- footer ----------------------------------------------------------- + let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + footer.add_css_class("footer"); + let hint = gtk4::Label::builder() + .label(gettext("Connecting…")) + .xalign(0.0) + .hexpand(true) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + hint.add_css_class("footer-text"); + footer.append(&hint); + // Translators: keyboard hints in the footer; keep the arrow and Enter symbols. + let keys = gtk4::Label::new(Some(&gettext("↑↓ navigate · ↵ open · Tab filter"))); + keys.add_css_class("footer-text"); + footer.append(&keys); + card.append(&footer); + + let socket = dirs.control_socket(); + let ui = Rc::new_cyclic(|weak: &std::rc::Weak| { + let weak = weak.clone(); + Ui { + thumbs: Thumbs::new(socket.clone()), + // A visit recorded elsewhere, or the first load finishing, adds + // to the recent list the user may be looking at. + history: History::start(dirs.state_dir().join("prompt-history.json"), move || { + if let Some(ui) = weak.upgrade() + && ui.showing_suggestions() + { + ui.render(); + } + }), + socket, + mountpoint: RefCell::new(dirs.default_mountpoint()), + opener: RefCell::new(dirs.load_config().resolved_open_with()), + window: window.clone(), + entry: entry.clone(), + spinner, + stack, + scroller, + list: list.clone(), + headers, + placeholder, + hint, + chips: RefCell::new(Vec::new()), + preview, + previewed: RefCell::new(None), + drive_hits: RefCell::new(Vec::new()), + local_hits: RefCell::new(Vec::new()), + visible: RefCell::new(Vec::new()), + cursor: Cell::new(None), + rendered_query: RefCell::new(String::new()), + filter: Cell::new(Filter::All), + query_id: Cell::new(0), + pending: Cell::new(0), + opening: Cell::new(None), + last_ticket: Cell::new(0), + indexing: Cell::new(false), + open_pending: Cell::new(false), + suppress_entry_change: Cell::new(false), + } + }); + + for (filter, label) in FILTERS { + let chip = gtk4::ToggleButton::builder() + .label(gettext(label)) + .active(filter == Filter::All) + .focus_on_click(false) + .build(); + chip.add_css_class("chip"); + let ui_chip = ui.clone(); + chip.connect_clicked(move |btn| { + // A chip is a radio, not a switch: clicking the active one keeps it on. + if btn.is_active() { + ui_chip.set_filter(filter); + } else { + btn.set_active(true); + } + }); + chip_row.append(&chip); + ui.chips.borrow_mut().push((filter, chip)); + } + + // A click selects a row and previews it; a double-click opens it. + let ui_selected = ui.clone(); + list.connect_row_selected(move |_, row| { + if let Some(row) = row { + ui_selected.cursor.set(Some(row.index() as usize)); + ui_selected.show_preview(); + } + }); + let ui_activated = ui.clone(); + list.connect_row_activated(move |_, row| ui_activated.open(row.index() as usize)); + + let ui_open = ui.clone(); + ui.preview + .open + .connect_clicked(move |_| ui_open.open(ui_open.cursor.get().unwrap_or(0))); + let ui_reveal = ui.clone(); + ui.preview.reveal.connect_clicked(move |_| { + if let Some(index) = ui_reveal.cursor.get() { + ui_reveal.show_in_folder(index); + } + }); + let ui_copy = ui.clone(); + ui.preview.copy.connect_clicked(move |_| { + if let Some(index) = ui_copy.cursor.get() { + ui_copy.copy_path(index); + } + }); + + let ui_key = ui.clone(); + let keys = gtk4::EventControllerKey::new(); + keys.connect_key_pressed(move |_, key, _, state| { + if ui_key.opening.get().is_some() { + // Escape gives up on a Drive file that is still materialising; + // everything else waits for it. + if key == gdk::Key::Escape { + ui_key.cancel_open(); + } + return glib::Propagation::Stop; + } + match key { + gdk::Key::Escape => { + // Spotlight-style: first Escape clears a non-empty query, a + // second (or an already-empty box) dismisses the launcher. + if ui_key.entry.text().is_empty() { + ui_key.dismiss(); + } else { + ui_key.entry.set_text(""); + } + glib::Propagation::Stop + } + gdk::Key::Down => { + ui_key.move_cursor(1); + glib::Propagation::Stop + } + gdk::Key::Up => { + ui_key.move_cursor(-1); + glib::Propagation::Stop + } + gdk::Key::Tab | gdk::Key::ISO_Left_Tab => { + let back = state.contains(gdk::ModifierType::SHIFT_MASK); + ui_key.cycle_filter(back); + glib::Propagation::Stop + } + // Return is deliberately absent: `GtkText` binds it to `activate` + // and consumes it, so a bubble-phase controller on the window never + // sees it while the entry has focus (which is always, here). It is + // handled on the entry itself — see below. + _ => glib::Propagation::Proceed, + } + }); + window.add_controller(keys); + + // Enter opens the row under the cursor. This lives on the entry rather than + // with the other keys on the window because `GtkText` claims Return for its + // own `activate` binding, so a bubble-phase controller upstream of the + // focused entry is never reached. + let ui_activate = ui.clone(); + entry.connect_activate(move |_| { + if ui_activate.opening.get().is_some() { + return; + } + // Fall back to the top row when nothing is explicitly selected (e.g. + // results haven't rendered yet); `open` guards the rest. + ui_activate.open(ui_activate.cursor.get().unwrap_or(0)); + }); + + // Some GTK/input-method combinations consume the physical Return binding in + // GtkText without emitting Entry::activate. Capture Return ahead of the text + // widget and leave every other key to normal GTK/IME handling. Stopping + // propagation also prevents the activate signal above from opening twice; + // that signal remains the programmatic/accessibility activation path. + // + // Ctrl+C is taken here too, but only while the entry has no selection, so + // copying part of the query still works; Ctrl+Shift+C always copies the path. + let ui_return = ui.clone(); + let entry_keys = gtk4::EventControllerKey::new(); + entry_keys.set_propagation_phase(gtk4::PropagationPhase::Capture); + entry_keys.connect_key_pressed(move |_, key, _, state| { + let control = state.contains(gdk::ModifierType::CONTROL_MASK); + match key { + gdk::Key::Return | gdk::Key::KP_Enter => { + if ui_return.opening.get().is_none() { + let index = ui_return.cursor.get().unwrap_or(0); + if control { + ui_return.show_in_folder(index); + } else { + ui_return.open(index); + } + } + glib::Propagation::Stop + } + gdk::Key::c | gdk::Key::C if control => { + let shift = state.contains(gdk::ModifierType::SHIFT_MASK); + if !shift && ui_return.entry.selection_bounds().is_some() { + return glib::Propagation::Proceed; + } + if let Some(index) = ui_return.cursor.get() { + ui_return.copy_path(index); + } + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + entry.add_controller(entry_keys); + + let debounce: Rc>> = Rc::new(RefCell::new(None)); + let ui_changed = ui.clone(); + entry.connect_changed(move |entry| { + if ui_changed.suppress_entry_change.get() { + return; + } + // A fresh keystroke supersedes any Enter that was waiting on results: + // the user is still refining the query, not asking to open yet. + ui_changed.open_pending.set(false); + if let Some(source) = debounce.borrow_mut().take() { + source.remove(); + } + let query = entry.text().trim().to_string(); + let ui_timeout = ui_changed.clone(); + let debounce_timeout = debounce.clone(); + let source = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || { + debounce_timeout.borrow_mut().take(); + ui_timeout.search(&query); + }); + *debounce.borrow_mut() = Some(source); + }); + + let ui_retry = ui.clone(); + retry.connect_clicked(move |_| ui_retry.connect()); + + // Keep the widget tree alive when the compositor asks the window to close. + // This also avoids running GTK's destruction path while async replies still + // hold references to prompt widgets. + let ui_close = ui.clone(); + window.connect_close_request(move |_| { + ui_close.dismiss(); + glib::Propagation::Stop + }); + let entry_map = entry.clone(); + window.connect_map(move |_| { + entry_map.grab_focus(); + }); + + Some(ui) +} + +/// Whether the mount has `hit`, asked on a worker thread and given up on +/// after [`STAT_TIMEOUT`]. `None` means "not there" or "no answer"; either +/// way the daemon is the fallback. +async fn mounted_target_within(mountpoint: PathBuf, hit: SearchHit) -> Option { + let (tx, rx) = async_channel::bounded(1); + std::thread::spawn(move || { + let _ = tx.send_blocking(mounted_target(&mountpoint, &hit)); + }); + match glib::future_with_timeout(STAT_TIMEOUT, rx.recv()).await { + Ok(Ok(target)) => target, + Ok(Err(_)) => None, + Err(_) => { + tracing::warn!("the mount did not answer in time; opening through the daemon"); + None + } + } +} + +impl Ui { + /// Reset and present the resident prompt in response to GApplication + /// activation. A second invocation while a file is materialising merely + /// raises the progress window; it must not make the in-flight open mutable. + pub(crate) fn activate(self: &Rc) { + if self.opening.get().is_none() { + // Preferences may have changed in the app while the launcher was + // hidden. + if let Ok(dirs) = AppDirs::new() { + let config = dirs.load_config(); + set_proton_theme(config.proton_theme.unwrap_or(false)); + *self.opener.borrow_mut() = config.resolved_open_with(); + } + self.history.prune(); + // Supersede replies from the previous showing before clearing the + // query. The daemon work may finish, but it can no longer repaint + // the newly activated prompt with stale rows. + self.query_id.set(self.query_id.get() + 1); + self.pending.set(0); + self.open_pending.set(false); + self.spinner.set_visible(false); + self.suppress_entry_change.set(true); + self.entry.set_text(""); + self.suppress_entry_change.set(false); + self.set_filter(Filter::All); + self.connect(); + } + self.window.present(); + self.entry.grab_focus(); + } + + /// Dismiss without destroying the application window. GTK continues to + /// associate the hidden window with the single application instance, so a + /// later desktop shortcut invocation is a cheap `activate` round-trip. + fn dismiss(&self) { + self.open_pending.set(false); + self.thumbs.cancel_all(); + self.window.set_visible(false); + } + + /// Whether the empty-query view is on screen, so a history update may + /// repaint it. + fn showing_suggestions(&self) -> bool { + self.opening.get().is_none() + && self.rendered_query.borrow().is_empty() + && self.entry.text().trim().is_empty() + && self.stack.visible_child_name().as_deref() != Some("offline") + } + + /// Ask the daemon for status and, if it answers, load the suggested (pinned) + /// files. Runs off-thread: a dead daemon must not freeze the first frame. + fn connect(self: &Rc) { + self.hint.set_label(&gettext("Connecting…")); + let rx = spawn_request(self.socket.clone(), Request::Status); + let ui = self.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Status { mountpoint, .. })) => { + *ui.mountpoint.borrow_mut() = PathBuf::from(mountpoint); + ui.entry.set_sensitive(true); + ui.stack.set_visible_child_name("results"); + ui.load_suggestions(); + } + _ => { + ui.entry.set_sensitive(false); + ui.hint.set_label(&gettext("Proton Drive isn't running")); + ui.stack.set_visible_child_name("offline"); + ui.preview.show(None, &ui.thumbs, &ui.mountpoint.borrow()); + } + } + }); + } + + /// The daemon stopped answering after the window was already open. Flip to + /// the offline page and lock the entry; the retry button re-runs `connect`. + fn go_offline(self: &Rc) { + self.pending.set(0); + self.spinner.set_visible(false); + self.entry.set_sensitive(false); + self.hint.set_label(&gettext("Proton Drive isn't running")); + self.stack.set_visible_child_name("offline"); + self.preview + .show(None, &self.thumbs, &self.mountpoint.borrow()); + } + + /// The empty-query view: recently opened files, then the pinned ones. + fn load_suggestions(self: &Rc) { + let id = self.begin_query(1); + let rx = spawn_request(self.socket.clone(), Request::ListPins); + let ui = self.clone(); + glib::spawn_future_local(async move { + let reply = rx.recv().await; + if !ui.finish_query(id) { + return; + } + if matches!(reply, Ok(Err(_)) | Err(_)) { + ui.go_offline(); + return; + } + let hits = match reply { + Ok(Ok(Response::Pins { pins })) => pins + .into_iter() + .map(|pin| SearchHit { + name: file_name(&pin.path), + path: pin.path, + is_dir: pin.is_dir.unwrap_or(pin.recursive), + size: 0, + modified: 0, + pinned: true, + cached: pin.cached, + uid: pin.uid, + mounted_path: None, + score: 0, + }) + .collect(), + _ => Vec::new(), + }; + *ui.drive_hits.borrow_mut() = hits; + ui.local_hits.borrow_mut().clear(); + ui.render(); + }); + } + + /// Search Drive and local metadata in one daemon round-trip. An empty query + /// falls back to the suggestions view. + fn search(self: &Rc, query: &str) { + if query.is_empty() { + self.load_suggestions(); + return; + } + + let id = self.begin_query(1); + let search = spawn_request( + self.socket.clone(), + Request::SearchV2 { + query: query.to_string(), + limit: SEARCH_LIMIT, + filters: SearchFilters { + sources: vec![SearchSource::Drive, SearchSource::Local], + kind: self.filter.get().search_kind(), + }, + }, + ); + let ui = self.clone(); + glib::spawn_future_local(async move { + let reply = search.recv().await; + if !ui.finish_query(id) { + return; + } + match reply { + Ok(Ok(Response::SearchResultsV2 { + drive_hits, + local_hits, + local_indexing, + })) => { + *ui.drive_hits.borrow_mut() = drive_hits; + *ui.local_hits.borrow_mut() = local_hits; + ui.indexing.set(local_indexing); + ui.render(); + } + // Transport failure: the daemon is gone. Don't paint "No + // results" over a crash — say so. + Ok(Err(_)) | Err(_) => ui.go_offline(), + _ => { + ui.drive_hits.borrow_mut().clear(); + ui.local_hits.borrow_mut().clear(); + ui.render(); + } + } + }); + } + + /// Open a new query id, expecting `requests` replies. Any reply still in + /// flight for an older id is now stale and will be dropped by + /// [`finish_query`](Self::finish_query). + fn begin_query(&self, requests: u8) -> u64 { + let id = self.query_id.get() + 1; + self.query_id.set(id); + self.pending.set(requests); + self.spinner.set_visible(true); + id + } + + /// Account for one reply. Returns false if it belongs to a superseded query, + /// in which case the caller must discard it. + fn finish_query(&self, id: u64) -> bool { + if self.query_id.get() != id { + return false; + } + let left = self.pending.get().saturating_sub(1); + self.pending.set(left); + if left == 0 && self.opening.get().is_none() { + self.spinner.set_visible(false); + } + true + } + + fn set_filter(self: &Rc, filter: Filter) { + if self.filter.get() == filter { + return; + } + self.filter.set(filter); + for (chip_filter, chip) in self.chips.borrow().iter() { + chip.set_active(*chip_filter == filter); + } + let query = self.entry.text().trim().to_string(); + if query.is_empty() { + self.render(); + } else { + self.search(&query); + } + } + + fn cycle_filter(self: &Rc, backwards: bool) { + let current = FILTERS + .iter() + .position(|(f, _)| *f == self.filter.get()) + .unwrap_or(0); + let count = FILTERS.len(); + let next = if backwards { + (current + count - 1) % count + } else { + (current + 1) % count + }; + self.set_filter(FILTERS[next].0); + } + + /// The rows for the current state, with the section each one starts. + fn rows(&self, query: &str) -> (Vec, Vec>) { + let filter = self.filter.get(); + let mut hits: Vec = self + .drive_hits + .borrow() + .iter() + .filter(|hit| filter.accepts(&hit.name, hit.is_dir)) + .cloned() + .map(Hit::Drive) + .collect(); + hits.extend( + self.local_hits + .borrow() + .iter() + .filter(|hit| filter.accepts(&hit.name, hit.is_dir)) + .cloned() + .map(Hit::Local), + ); + + if !query.is_empty() { + let bonuses = self.history.bonuses(); + rank_hits_by(&mut hits, |hit| { + bonuses.get(&hit.key()).copied().unwrap_or_default() + }); + let mut headers = vec![None; hits.len()]; + if let Some(first) = headers.first_mut() { + *first = Some(Section::Matches); + } + return (hits, headers); + } + + // Recent files first, without the pinned files listed below them. + let pinned: std::collections::HashSet<(bool, String)> = hits.iter().map(Hit::key).collect(); + let recent: Vec = self + .history + .recent(usize::MAX) + .into_iter() + .filter(|hit| filter.accepts(hit.name(), hit.is_dir())) + .filter(|hit| !pinned.contains(&hit.key())) + .take(RECENT_LIMIT) + .collect(); + let mut headers = vec![None; recent.len() + hits.len()]; + if !recent.is_empty() { + headers[0] = Some(Section::Recent); + } + if !hits.is_empty() { + headers[recent.len()] = Some(Section::Pinned); + } + let mut rows = recent; + rows.extend(hits); + (rows, headers) + } + + /// Rebuild the list, preserving the selected identity when a result + /// survives the update. + fn render(self: &Rc) { + // Identity of the row under the cursor before we rebuild the list, so a + // late-arriving second search can't yank the selection back to the top. + let selected_key = self + .cursor + .get() + .and_then(|i| self.visible.borrow().get(i).map(Hit::key)); + + let query = self.entry.text().trim().to_string(); + let searching = !query.is_empty(); + let (visible, headers) = self.rows(&query); + let total = visible.len(); + + self.thumbs.begin_render(); + *self.headers.borrow_mut() = headers; + self.cursor.set(None); + self.previewed.borrow_mut().take(); + self.list.remove_all(); + let mountpoint = self.mountpoint.borrow().clone(); + for hit in &visible { + let row = row::build_row(hit, &query, &self.thumbs, &mountpoint); + let ui = Rc::downgrade(self); + let dragged = hit.clone(); + row::attach_drag(&row, hit.fs_path(&mountpoint), move || { + if let Some(ui) = ui.upgrade() { + ui.history.record(dragged.clone()); + ui.dismiss(); + } + }); + self.list.append(&row); + } + self.list.invalidate_headers(); + self.thumbs.end_render(); + + // Find where the previously-selected row landed in the rebuilt list. + let restored = selected_key.and_then(|key| visible.iter().position(|hit| hit.key() == key)); + + *self.visible.borrow_mut() = visible; + *self.rendered_query.borrow_mut() = query; + + if total == 0 { + self.placeholder.set_title(&if searching { + gettext("No results") + } else { + gettext("Search your Drive") + }); + self.placeholder + .set_description(Some(&match (searching, self.indexing.get()) { + (true, true) => gettext( + "Still indexing this computer — local results will fill in shortly.", + ), + (true, false) => gettext("Try a different search, or another filter."), + _ => gettext( + "Start typing to search Proton Drive and the files on this computer.", + ), + })); + self.stack.set_visible_child_name("empty"); + self.preview.show(None, &self.thumbs, &mountpoint); + } else { + self.stack.set_visible_child_name("results"); + self.select(restored.unwrap_or(0)); + } + + self.refresh_hint(); + + // Honour an Enter that arrived before this query rendered, now that the + // results (and the cursor) are settled. + if self.open_pending.get() && self.pending.get() == 0 { + self.open_pending.set(false); + if let Some(index) = self.cursor.get() { + self.open(index); + } + } + } + + /// The footer's summary of what the list shows. + fn refresh_hint(&self) { + let visible = self.visible.borrow(); + let text = if self.rendered_query.borrow().is_empty() { + match visible.iter().filter(|hit| hit.pinned()).count() { + 0 => gettext("No pinned files"), + n => ngettext_f("{n} pinned file", "{n} pinned files", n as u64, &[]), + } + } else { + let drive_count = visible + .iter() + .filter(|hit| matches!(hit, Hit::Drive(_))) + .count(); + let drive = drive_count.to_string(); + let local = (visible.len() - drive_count).to_string(); + let args = [("drive", drive.as_str()), ("local", local.as_str())]; + if self.indexing.get() { + // Translators: result counts in the footer while this computer is still being indexed; {drive} and {local} are numbers. + gettext_f( + "{drive} in Drive · {local} on this computer · indexing…", + &args, + ) + } else { + // Translators: result counts in the footer; {drive} and {local} are numbers. + gettext_f("{drive} in Drive · {local} on this computer", &args) + } + }; + self.hint.set_label(&text); + } + + /// Move the cursor by `delta` rows, wrapping at both ends. + fn move_cursor(self: &Rc, delta: i32) { + let count = self.visible.borrow().len() as i32; + if count == 0 { + return; + } + let current = self.cursor.get().map_or(0, |c| c as i32); + let next = (current + delta).rem_euclid(count); + self.select(next as usize); + } + + /// Put the cursor on row `index`, preview it and scroll it into view. + /// Focus stays in the entry so typing never breaks. + fn select(self: &Rc, index: usize) { + let Some(row) = self.list.row_at_index(index as i32) else { + return; + }; + self.list.select_row(Some(&row)); + self.cursor.set(Some(index)); + self.show_preview(); + self.scroll_into_view(&row); + } + + fn show_preview(&self) { + let hit = self + .cursor + .get() + .and_then(|index| self.visible.borrow().get(index).cloned()); + let key = hit.as_ref().map(Hit::key); + if *self.previewed.borrow() == key && key.is_some() { + return; + } + *self.previewed.borrow_mut() = key; + self.preview + .show(hit.as_ref(), &self.thumbs, &self.mountpoint.borrow()); + } + + /// Keep the selected row inside the viewport, scrolling by the smallest + /// amount that reveals it (like a native list, unlike jumping it to centre). + /// A section header above the first row of a section counts as part of it. + fn scroll_into_view(&self, row: >k4::ListBoxRow) { + let target = row.header().unwrap_or_else(|| row.clone().upcast()); + let (Some(top), Some(bottom)) = ( + target.compute_bounds(&self.list), + row.compute_bounds(&self.list), + ) else { + return; + }; + let adjustment = self.scroller.vadjustment(); + let (top, bottom) = (f64::from(top.y()), f64::from(bottom.y() + bottom.height())); + let (value, page) = (adjustment.value(), adjustment.page_size()); + if top < value { + adjustment.set_value(top); + } else if bottom > value + page { + adjustment.set_value(bottom - page); + } + } + + /// The hit at `index`, if it belongs to the query on screen. The rows may + /// still show an older query whose reply is settling; nothing may act on + /// them then. + fn current_hit(&self, index: usize) -> Option { + if self.entry.text().trim() != *self.rendered_query.borrow() { + return None; + } + self.visible.borrow().get(index).cloned() + } + + /// Hand a path to the configured opener — `xdg-open` unless `open_with` in + /// `config.json` says otherwise — remember the visit, and close. + fn launch(&self, hit: &Hit, path: &Path, is_dir: bool) { + opener::open(&self.opener.borrow(), path, is_dir); + self.history.record(hit.clone()); + self.dismiss(); + } + + /// Open the hit at `index`. Local files are already on disk, so they hand + /// straight to the desktop. A Drive file opens from the mount when the + /// mount has it, and is otherwise materialised by the daemon (`OpenFile`), + /// which can take a moment — the window stays up, with the spinner + /// running, until the path comes back or Escape gives up on it. + fn open(self: &Rc, index: usize) { + if self.opening.get().is_some() { + return; + } + // Enter must never launch a file the user has typed past. But don't + // drop the intent either: remember it and open the right row once the + // fresh results land (see `render`). + if self.entry.text().trim() != *self.rendered_query.borrow() { + self.open_pending.set(true); + return; + } + let Some(hit) = self.current_hit(index) else { + return; + }; + let drive = match &hit { + Hit::Local(local) => { + self.launch(&hit, Path::new(&local.path), local.is_dir); + return; + } + Hit::Drive(drive) => drive.clone(), + }; + + // Pins come from ListPins, whose `recursive` flag is pin policy rather + // than reliable node kind metadata. Every pin already has a mounted + // path, so activate that path directly; this handles non-recursively + // pinned folders without mistaking them for files and sending an + // invalid OpenFile request. + let pin_row = self.rendered_query.borrow().is_empty() && drive.pinned; + if pin_row + || matches!( + drive_activation(&drive.name, drive.is_dir), + DriveActivation::Folder | DriveActivation::MountedMedia + ) + { + let path = mounted_or_relative(&self.mountpoint.borrow(), &drive); + self.launch(&hit, &path, drive.is_dir); + return; + } + + let ticket = self.last_ticket.get() + 1; + self.last_ticket.set(ticket); + self.opening.set(Some(ticket)); + self.spinner.set_visible(true); + // Translators: {name} is a file name. + let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]); + self.hint.set_label(&opening); + + let mountpoint = self.mountpoint.borrow().clone(); + let ui = self.clone(); + glib::spawn_future_local(async move { + // The mount holds the real file; the cache holds a copy an editor + // would save into for nothing. Prefer the mount, and materialise + // only what it does not expose. + if let Some(path) = mounted_target_within(mountpoint, drive.clone()).await { + if ui.finish_open(ticket) { + ui.launch(&hit, &path, false); + } + return; + } + if ui.opening.get() != Some(ticket) { + return; + } + let rx = spawn_request( + ui.socket.clone(), + Request::OpenFile { + path: drive.path.clone(), + uid: Some(drive.uid.clone()), + }, + ); + let reply = rx.recv().await; + if !ui.finish_open(ticket) { + return; + } + match reply { + Ok(Ok(Response::FilePath { path })) => { + // The blob is named after its content hash, so the open + // rules have to be matched against the Drive name instead. + opener::open_named(&ui.opener.borrow(), Path::new(&path), &drive.name, false); + ui.history.record(hit.clone()); + ui.dismiss(); + } + Ok(Ok(Response::Error { message, .. })) => { + // Translators: {message} is an error from the daemon, in English. + ui.hint.set_label(&gettext_f( + "Could not open: {message}", + &[("message", &message)], + )); + } + _ => ui.hint.set_label(&gettext("Couldn't reach Proton Drive")), + } + }); + } + + /// End the open with `ticket`. False when it was cancelled or superseded, + /// in which case the caller must not launch anything. + fn finish_open(&self, ticket: u64) -> bool { + if self.opening.get() != Some(ticket) { + return false; + } + self.opening.set(None); + self.spinner.set_visible(self.pending.get() > 0); + true + } + + /// Give up on the Drive open in progress. The daemon may still finish + /// materialising the file, but it is not launched. + fn cancel_open(&self) { + self.opening.set(None); + self.spinner.set_visible(self.pending.get() > 0); + self.refresh_hint(); + } + + /// Show the hit at `index` selected in the file manager, through the + /// freedesktop `FileManager1` interface, or open its folder when no file + /// manager offers that. + fn show_in_folder(self: &Rc, index: usize) { + if self.opening.get().is_some() { + return; + } + let Some(hit) = self.current_hit(index) else { + return; + }; + let path = hit.fs_path(&self.mountpoint.borrow()); + let uri = gio::File::for_path(&path).uri().to_string(); + let Some(connection) = self + .window + .application() + .and_then(|app| app.dbus_connection()) + else { + self.open_parent(&hit, &path); + return; + }; + let ui = self.clone(); + glib::spawn_future_local(async move { + let shown = connection + .call_future( + Some("org.freedesktop.FileManager1"), + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1", + "ShowItems", + Some(&(vec![uri], String::new()).to_variant()), + None, + gio::DBusCallFlags::NONE, + 2000, + ) + .await; + match shown { + Ok(_) => { + ui.history.record(hit); + ui.dismiss(); + } + Err(e) => { + tracing::debug!("FileManager1.ShowItems failed: {e}"); + ui.open_parent(&hit, &path); + } + } + }); + } + + fn open_parent(&self, hit: &Hit, path: &Path) { + let Some(parent) = path.parent() else { + return; + }; + opener::open(&self.opener.borrow(), parent, true); + self.history.record(hit.clone()); + self.dismiss(); + } + + /// Copy the path of the hit at `index`: the file itself for a local hit, + /// its place in the mount for a Drive hit. + fn copy_path(&self, index: usize) { + let Some(hit) = self.current_hit(index) else { + return; + }; + let path = hit.fs_path(&self.mountpoint.borrow()); + self.entry.clipboard().set_text(&path.display().to_string()); + self.preview.flash_copied(); + } +} diff --git a/crates/pdfs-gui/src/hud/preview.rs b/crates/pdfs-gui/src/hud/preview.rs new file mode 100644 index 0000000..ac949d8 --- /dev/null +++ b/crates/pdfs-gui/src/hud/preview.rs @@ -0,0 +1,311 @@ +//! The preview pane beside the results: a thumbnail of the selected hit, what +//! it is, where it is and whether it is on this computer, and the actions for +//! it with their shortcuts. + +use std::cell::RefCell; +use std::path::Path; +use std::rc::Rc; +use std::time::Duration; + +use adw::prelude::*; +use gtk4::{gio, glib}; + +use super::row::{fill_slot, thumb_slot}; +use super::thumbs::Thumbs; +use crate::{Hit, dates, gettext, human_bytes, pgettext}; + +/// Width of the pane. +pub(crate) const PREVIEW_WIDTH: i32 = 300; + +/// The thumbnail area, inside the pane's padding. +const THUMB_WIDTH: i32 = 268; +const THUMB_HEIGHT: i32 = 180; + +/// How long "Path copied" stays up. +const COPIED_FEEDBACK: Duration = Duration::from_millis(1500); + +/// One label/value line of the details grid. +struct Detail { + title: gtk4::Label, + value: gtk4::Label, +} + +impl Detail { + fn new(grid: >k4::Grid, row: i32, title: &str) -> Self { + let title = gtk4::Label::builder() + .label(title) + .xalign(0.0) + .valign(gtk4::Align::Start) + .build(); + title.add_css_class("preview-key"); + let value = gtk4::Label::builder() + .xalign(0.0) + .hexpand(true) + .wrap(true) + .wrap_mode(gtk4::pango::WrapMode::WordChar) + .build(); + value.add_css_class("preview-value"); + grid.attach(&title, 0, row, 1, 1); + grid.attach(&value, 1, row, 1, 1); + Self { title, value } + } + + /// Show `value`, or hide the line when there is nothing to say. + fn set(&self, value: Option<&str>) { + self.title.set_visible(value.is_some()); + self.value.set_visible(value.is_some()); + self.value.set_label(value.unwrap_or_default()); + } +} + +pub(crate) struct Preview { + pub(crate) root: gtk4::Box, + stack: gtk4::Stack, + slot: gtk4::Overlay, + name: gtk4::Label, + kind: gtk4::Label, + size: Detail, + modified: Detail, + location: Detail, + availability: Detail, + availability_icon: gtk4::Image, + pub(crate) open: gtk4::Button, + open_label: gtk4::Label, + pub(crate) reveal: gtk4::Button, + pub(crate) copy: gtk4::Button, + copied: gtk4::Revealer, + copied_timer: Rc>>, +} + +/// A flat action button: the label, and its shortcut at the far end. +fn action(icon: &str, label: &str, accelerator: &str) -> (gtk4::Button, gtk4::Label) { + let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + content.append(>k4::Image::from_icon_name(icon)); + let text = gtk4::Label::builder() + .label(label) + .xalign(0.0) + .hexpand(true) + .build(); + content.append(&text); + let shortcut = gtk4::ShortcutLabel::new(accelerator); + shortcut.add_css_class("preview-shortcut"); + content.append(&shortcut); + let button = gtk4::Button::builder() + .child(&content) + .focus_on_click(false) + .focusable(false) + .build(); + button.add_css_class("flat"); + button.add_css_class("preview-action"); + (button, text) +} + +impl Preview { + pub(crate) fn new() -> Self { + let details = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + + let slot = thumb_slot(THUMB_WIDTH, THUMB_HEIGHT, 96, gtk4::ContentFit::Contain); + slot.add_css_class("preview-thumb"); + details.append(&slot); + + let name = gtk4::Label::builder() + .xalign(0.0) + .wrap(true) + .wrap_mode(gtk4::pango::WrapMode::WordChar) + .lines(3) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + name.add_css_class("preview-name"); + details.append(&name); + + let kind = gtk4::Label::builder() + .xalign(0.0) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + kind.add_css_class("preview-kind"); + details.append(&kind); + + let grid = gtk4::Grid::builder() + .row_spacing(6) + .column_spacing(12) + .build(); + grid.add_css_class("preview-details"); + let size = Detail::new(&grid, 0, &pgettext("column", "Size")); + let modified = Detail::new(&grid, 1, &pgettext("column", "Modified")); + let location = Detail::new(&grid, 2, &gettext("Location")); + // Translators: a heading in the launcher's preview pane, followed by + // "Available offline", "Downloaded", "Online only" or "On this computer". + let availability = Detail::new(&grid, 3, &gettext("Availability")); + // The availability value is an icon and a text. + let availability_icon = gtk4::Image::new(); + availability_icon.add_css_class("preview-badge"); + grid.remove(&availability.value); + let availability_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + availability_box.append(&availability_icon); + availability_box.append(&availability.value); + grid.attach(&availability_box, 1, 3, 1, 1); + details.append(&grid); + + let spacer = gtk4::Box::builder().vexpand(true).build(); + details.append(&spacer); + + let actions = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + actions.add_css_class("preview-actions"); + let (open, open_label) = action("document-open-symbolic", "", "Return"); + let (reveal, _) = action( + "folder-open-symbolic", + &gettext("Show in Folder"), + "Return", + ); + let (copy, _) = action("edit-copy-symbolic", &gettext("Copy Path"), "c"); + actions.append(&open); + actions.append(&reveal); + actions.append(©); + details.append(&actions); + + let copied_label = gtk4::Label::new(Some(&gettext("Path copied"))); + copied_label.add_css_class("preview-feedback"); + let copied = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::Crossfade) + .child(&copied_label) + .build(); + details.append(&copied); + + let empty = gtk4::Image::from_icon_name("system-search-symbolic"); + empty.set_pixel_size(64); + empty.add_css_class("preview-empty"); + + let stack = gtk4::Stack::builder() + .transition_type(gtk4::StackTransitionType::Crossfade) + .transition_duration(100) + .vexpand(true) + .build(); + stack.add_named(&empty, Some("empty")); + stack.add_named(&details, Some("details")); + + let root = gtk4::Box::builder() + .orientation(gtk4::Orientation::Vertical) + .width_request(PREVIEW_WIDTH) + .build(); + root.add_css_class("preview"); + root.append(&stack); + + Self { + root, + stack, + slot, + name, + kind, + size, + modified, + location, + availability, + availability_icon, + open, + open_label, + reveal, + copy, + copied, + copied_timer: Rc::new(RefCell::new(None)), + } + } + + /// Show `hit`, or the empty pane when nothing is selected. + pub(crate) fn show(&self, hit: Option<&Hit>, thumbs: &Rc, mountpoint: &Path) { + let Some(hit) = hit else { + self.stack.set_visible_child_name("empty"); + return; + }; + self.stack.set_visible_child_name("details"); + fill_slot(&self.slot, hit, thumbs, mountpoint); + + self.name.set_label(hit.name()); + self.name.set_tooltip_text(Some(hit.name())); + let content_type = if hit.is_dir() { + glib::GString::from("inode/directory") + } else { + gio::functions::content_type_guess(Some(hit.name()), None).0 + }; + self.kind + .set_label(&gio::functions::content_type_get_description(&content_type)); + + let size = (!hit.is_dir() && hit.size() > 0).then(|| human_bytes(hit.size())); + self.size.set(size.as_deref()); + + let modified = (hit.modified() > 0).then(|| dates::relative(hit.modified())); + self.modified.set(modified.as_deref()); + self.modified.value.set_tooltip_text( + (hit.modified() > 0) + .then(|| dates::full(hit.modified())) + .as_deref(), + ); + + let location = hit.location(); + self.location.set(Some(&location)); + + match availability(hit) { + Some((icon, class, text)) => { + self.availability.set(Some(&text)); + self.availability_icon.set_icon_name(Some(icon)); + for other in ["badge-pinned", "badge-cached", "badge-cloud", "badge-local"] { + self.availability_icon.remove_css_class(other); + } + self.availability_icon.add_css_class(class); + self.availability_icon.set_visible(true); + } + None => { + self.availability.set(None); + self.availability_icon.set_visible(false); + } + } + + self.open_label.set_label(&if hit.is_dir() { + gettext("Open folder") + } else { + pgettext("verb", "Open") + }); + } + + /// Say "Path copied" for a moment. + pub(crate) fn flash_copied(&self) { + self.copied.set_reveal_child(true); + if let Some(source) = self.copied_timer.borrow_mut().take() { + source.remove(); + } + let copied = self.copied.clone(); + let slot = self.copied_timer.clone(); + let timer = glib::timeout_add_local_once(COPIED_FEEDBACK, move || { + slot.borrow_mut().take(); + copied.set_reveal_child(false); + }); + *self.copied_timer.borrow_mut() = Some(timer); + } +} + +/// Where the hit's content is: its icon, its style class and its wording. +fn availability(hit: &Hit) -> Option<(&'static str, &'static str, String)> { + match hit { + Hit::Local(_) => Some(( + "drive-harddisk-symbolic", + "badge-local", + gettext("On this computer"), + )), + Hit::Drive(drive) if drive.pinned => Some(( + "pdfs-offline-symbolic", + "badge-pinned", + gettext("Available offline"), + )), + // A folder is never downloaded as a whole; only pinning says anything. + Hit::Drive(drive) if drive.is_dir => None, + Hit::Drive(drive) if drive.cached => Some(( + "pdfs-cached-symbolic", + "badge-cached", + pgettext("file state", "Downloaded"), + )), + Hit::Drive(_) => Some(( + "pdfs-online-only-symbolic", + "badge-cloud", + pgettext("file state", "Online only"), + )), + } +} diff --git a/crates/pdfs-gui/src/hud/row.rs b/crates/pdfs-gui/src/hud/row.rs new file mode 100644 index 0000000..5bf7c52 --- /dev/null +++ b/crates/pdfs-gui/src/hud/row.rs @@ -0,0 +1,187 @@ +//! Result rows: a thumbnail or icon, the name with its matched characters in +//! bold, the location, and on the right the sync state, size and age. + +use std::cell::Cell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use adw::prelude::*; +use gtk4::{gdk, gio}; + +use super::highlight; +use super::thumbs::Thumbs; +use crate::theme::proton_theme_active; +use crate::{Hit, format_age, human_bytes}; + +/// Edge of a row's thumbnail. +const ROW_THUMB: i32 = 40; + +/// A thumbnail surface: an icon, with a picture laid over it that shows once +/// the thumbnail has loaded. [`Thumbs`] paints the picture. +pub(crate) fn thumb_slot( + width: i32, + height: i32, + icon_size: i32, + fit: gtk4::ContentFit, +) -> gtk4::Overlay { + let fallback = gtk4::Image::builder().pixel_size(icon_size).build(); + let picture = gtk4::Picture::builder() + .width_request(width) + .height_request(height) + .content_fit(fit) + .can_shrink(true) + .visible(false) + .build(); + picture.set_overflow(gtk4::Overflow::Hidden); + picture.add_css_class("thumb"); + + let overlay = gtk4::Overlay::new(); + overlay.set_size_request(width, height); + overlay.set_halign(gtk4::Align::Center); + overlay.set_valign(gtk4::Align::Center); + overlay.set_child(Some(&fallback)); + overlay.add_overlay(&picture); + overlay +} + +/// Show `hit` in a slot from [`thumb_slot`]: its icon at once, and its +/// thumbnail when there is one. +pub(crate) fn fill_slot(slot: >k4::Overlay, hit: &Hit, thumbs: &Rc, mountpoint: &Path) { + let Some(fallback) = slot.child().and_downcast::() else { + return; + }; + let Some(picture) = fallback.next_sibling().and_downcast::() else { + return; + }; + fallback.set_from_gicon(&icon_for(hit.name(), hit.is_dir())); + fallback.set_visible(true); + picture.set_visible(false); + picture.set_paintable(gdk::Paintable::NONE); + thumbs.want(hit, mountpoint, &picture); +} + +/// The desktop's own full-colour icon for a file name, so results look like +/// the user's file manager. Folders are Proton's purple ones while the Proton +/// theme is on, as in the app's browser. +pub(crate) fn icon_for(name: &str, is_dir: bool) -> gio::Icon { + if is_dir { + let folder = if proton_theme_active() { + "pdfs-proton-folder" + } else { + "folder" + }; + return gio::ThemedIcon::new(folder).upcast(); + } + let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None); + gio::functions::content_type_get_icon(&content_type) +} + +/// The sync badge of a Drive file, as in the app's browser: pinned, cached, +/// or online only. Folders and local files have none. +pub(crate) fn badge_for(hit: &Hit) -> Option<(&'static str, &'static str)> { + let Hit::Drive(drive) = hit else { + return None; + }; + if drive.is_dir { + return None; + } + Some(if drive.pinned { + ("pdfs-offline-symbolic", "badge-pinned") + } else if drive.cached { + ("pdfs-cached-symbolic", "badge-cached") + } else { + ("pdfs-online-only-symbolic", "badge-cloud") + }) +} + +pub(crate) fn build_row( + hit: &Hit, + query: &str, + thumbs: &Rc, + mountpoint: &Path, +) -> gtk4::ListBoxRow { + let row = gtk4::ListBoxRow::new(); + row.add_css_class("result-row"); + // Focus stays in the search entry; the list only shows the cursor. + row.set_focusable(false); + + let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + + let slot = thumb_slot(ROW_THUMB, ROW_THUMB, 32, gtk4::ContentFit::Cover); + fill_slot(&slot, hit, thumbs, mountpoint); + content.append(&slot); + + let text = gtk4::Box::new(gtk4::Orientation::Vertical, 1); + text.set_hexpand(true); + text.set_valign(gtk4::Align::Center); + + let name = gtk4::Label::builder() + .label(highlight::markup(hit.name(), query)) + .use_markup(true) + .xalign(0.0) + .ellipsize(gtk4::pango::EllipsizeMode::Middle) + .build(); + name.add_css_class("result-name"); + text.append(&name); + + let location = gtk4::Label::builder() + .label(hit.location()) + .xalign(0.0) + .ellipsize(gtk4::pango::EllipsizeMode::Start) + .build(); + location.add_css_class("result-location"); + text.append(&location); + content.append(&text); + + let meta = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); + meta.set_valign(gtk4::Align::Center); + if let Some((icon, class)) = badge_for(hit) { + let badge = gtk4::Image::from_icon_name(icon); + badge.add_css_class("result-badge"); + badge.add_css_class(class); + meta.append(&badge); + } + if !hit.is_dir() && hit.size() > 0 { + let size = gtk4::Label::new(Some(&human_bytes(hit.size()))); + size.add_css_class("result-meta"); + meta.append(&size); + } + if hit.modified() > 0 { + let modified = gtk4::Label::new(Some(&format_age(hit.modified()))); + modified.add_css_class("result-meta"); + meta.append(&modified); + } + content.append(&meta); + + row.set_child(Some(&content)); + row +} + +/// Let a row be dragged into a file manager or an application as a file. +/// `dropped` runs once a drop was accepted; a cancelled drag changes nothing. +pub(crate) fn attach_drag(row: >k4::ListBoxRow, path: PathBuf, dropped: impl Fn() + 'static) { + let source = gtk4::DragSource::new(); + source.set_actions(gdk::DragAction::COPY); + let files = gdk::FileList::from_array(&[gio::File::for_path(&path)]); + source.set_content(Some(&gdk::ContentProvider::for_value(&files.to_value()))); + + let cancelled = Rc::new(Cell::new(false)); + let begin = cancelled.clone(); + source.connect_drag_begin(move |source, _| { + begin.set(false); + if let Some(widget) = source.widget() { + source.set_icon(Some(>k4::WidgetPaintable::new(Some(&widget))), 0, 0); + } + }); + let cancel = cancelled.clone(); + source.connect_drag_cancel(move |_, _, _| { + cancel.set(true); + false + }); + source.connect_drag_end(move |_, _, _| { + if !cancelled.get() { + dropped(); + } + }); + row.add_controller(source); +} diff --git a/crates/pdfs-gui/src/hud/thumbs.rs b/crates/pdfs-gui/src/hud/thumbs.rs new file mode 100644 index 0000000..1e66df4 --- /dev/null +++ b/crates/pdfs-gui/src/hud/thumbs.rs @@ -0,0 +1,697 @@ +//! Thumbnails for the launcher's rows and preview pane. +//! +//! The main thread keeps a small LRU of textures, the keys known to have no +//! thumbnail, and the pictures waiting for one (as weak references, so a row +//! that was rebuilt or dropped keeps nothing alive). Everything that touches +//! a file runs on two worker threads, which try in order: +//! +//! 1. the freedesktop thumbnail cache, valid only when its `Thumb::MTime` +//! matches the file's; +//! 2. for local images, decoding the file itself, and writing the result back +//! to that cache for the next time and for every other application; +//! 3. for Drive images, the daemon's `FileThumbs`, batched after a short quiet +//! period. +//! +//! Workers hand back `gdk::Texture`s, never a `Pixbuf`, which may not cross +//! threads. Each job carries the render epoch it was queued in, and workers +//! skip jobs a newer query has made pointless. + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io::Write; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; +use std::path::{Path, PathBuf}; +use std::rc::{Rc, Weak}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, UNIX_EPOCH}; + +use gtk4::gdk_pixbuf::Pixbuf; +use gtk4::prelude::*; +use gtk4::{gdk, glib}; + +use pdfs_core::control::{FileThumbRequest, Request, Response, is_thumbnail_image_name}; + +use crate::{Hit, spawn_request}; + +/// Decoded textures kept in memory. A launcher shows a few dozen rows, so +/// this covers several searches back. +const TEXTURE_CACHE_MAX: usize = 128; +const MISSING_MAX: usize = 1024; +const WORKERS: usize = 2; + +/// Edge of the thumbnails this loader decodes and writes: the freedesktop +/// "large" size. +const THUMB_SIZE: i32 = 256; + +/// Local images above this size are not decoded here; a thumbnail is not +/// worth reading that much. +const MAX_SOURCE_BYTES: u64 = 64 * 1024 * 1024; + +/// Quiet period before Drive misses go to the daemon as one batch, so typing +/// a word asks once for the final result set. +const DAEMON_DEBOUNCE: Duration = Duration::from_millis(150); +const DAEMON_RETRY: Duration = Duration::from_secs(1); +const DAEMON_BATCH: usize = 32; + +/// Where a job finds its image. +enum Source { + /// A file on this machine. + Local(PathBuf), + /// A Drive file: the thumbnail cache is keyed by its mounted path, and + /// the daemon is asked on a miss. + Drive { + mounted: PathBuf, + request: FileThumbRequest, + }, + /// A thumbnail the daemon made, in its own cache. + Daemon(PathBuf), +} + +struct Job { + epoch: u64, + key: String, + source: Source, +} + +enum Outcome { + Texture(gdk::Texture), + Missing, + /// Not in the thumbnail cache; the daemon has to make it. + Daemon(FileThumbRequest), + /// Queued for an older render; handed back in case it is wanted again. + Skipped(Source), +} + +struct Done { + key: String, + outcome: Outcome, +} + +pub(crate) struct Thumbs { + socket: PathBuf, + epoch: Arc, + jobs: async_channel::Sender, + + textures: RefCell>, + /// Least recently used first. + order: RefCell>, + missing: RefCell>, + missing_order: RefCell>, + waiters: RefCell>>>, + /// Keys with a worker job or a daemon request outstanding. + working: RefCell>, + + /// Drive misses waiting for the next batch, by key. + queued: RefCell>, + /// Drive items the daemon is working on under `generation`, by key. + sent: RefCell>, + generation: Cell, + reserving: Cell, + inflight: Cell, + flush: RefCell>, +} + +impl Thumbs { + pub(crate) fn new(socket: PathBuf) -> Rc { + let epoch = Arc::new(AtomicU64::new(0)); + let (jobs, inbox) = async_channel::unbounded::(); + let (done, results) = async_channel::unbounded::(); + for n in 0..WORKERS { + let inbox = inbox.clone(); + let done = done.clone(); + let epoch = epoch.clone(); + let spawned = std::thread::Builder::new() + .name(format!("prompt-thumbs-{n}")) + .spawn(move || work(n, &inbox, &done, &epoch)); + if let Err(e) = spawned { + tracing::warn!("cannot start a thumbnail worker: {e}"); + } + } + + let thumbs = Rc::new(Self { + socket, + epoch, + jobs, + textures: RefCell::new(HashMap::new()), + order: RefCell::new(VecDeque::new()), + missing: RefCell::new(HashSet::new()), + missing_order: RefCell::new(VecDeque::new()), + waiters: RefCell::new(HashMap::new()), + working: RefCell::new(HashSet::new()), + queued: RefCell::new(HashMap::new()), + sent: RefCell::new(HashMap::new()), + generation: Cell::new(0), + reserving: Cell::new(false), + inflight: Cell::new(false), + flush: RefCell::new(None), + }); + let weak = Rc::downgrade(&thumbs); + glib::spawn_future_local(async move { + while let Ok(done) = results.recv().await { + let Some(thumbs) = weak.upgrade() else { + break; + }; + thumbs.finish(done); + } + }); + thumbs + } + + /// Paint `hit`'s thumbnail into `picture` now if it is known, or as soon + /// as it is. Folders and non-images keep the slot's icon. + pub(crate) fn want(self: &Rc, hit: &Hit, mountpoint: &Path, picture: >k4::Picture) { + let key = hit.thumb_key(); + // The widget name binds the picture to one key: a late reply for + // another hit must not paint into it. + picture.set_widget_name(&key); + if hit.is_dir() || !is_thumbnail_image_name(hit.name()) { + return; + } + if matches!(hit, Hit::Drive(drive) if drive.uid.is_empty()) { + return; + } + let cached = self.textures.borrow().get(&key).cloned(); + if let Some(texture) = cached { + self.touch(&key); + paint(picture, &key, &texture); + return; + } + if self.missing.borrow().contains(&key) { + return; + } + self.waiters + .borrow_mut() + .entry(key.clone()) + .or_default() + .push(picture.downgrade()); + if !self.working.borrow_mut().insert(key.clone()) { + return; + } + let source = match hit { + Hit::Local(local) => Source::Local(PathBuf::from(&local.path)), + Hit::Drive(drive) => Source::Drive { + mounted: hit.fs_path(mountpoint), + request: FileThumbRequest { + uid: drive.uid.clone(), + modified: drive.modified, + name: drive.name.clone(), + }, + }, + }; + self.dispatch(key, source); + } + + /// The rows are about to be rebuilt: forget their pictures, and let the + /// workers skip what was queued for them. + pub(crate) fn begin_render(&self) { + self.epoch.fetch_add(1, Ordering::SeqCst); + self.waiters.borrow_mut().clear(); + } + + /// The rows are rebuilt and have asked for their thumbnails. Drive work + /// for hits that are gone is dropped, and cancelled at the daemon when it + /// had already been sent. + pub(crate) fn end_render(self: &Rc) { + let wanted: HashSet = self.waiters.borrow().keys().cloned().collect(); + let mut dropped = Vec::new(); + self.queued.borrow_mut().retain(|key, _| { + let keep = wanted.contains(key); + if !keep { + dropped.push(key.clone()); + } + keep + }); + let stale = self.sent.borrow().keys().any(|key| !wanted.contains(key)); + if stale { + self.cancel_daemon(); + // Still-wanted items go back into the next batch, under a fresh + // generation. + let sent: Vec<(String, FileThumbRequest)> = self.sent.borrow_mut().drain().collect(); + for (key, request) in sent { + if wanted.contains(&key) { + self.queued.borrow_mut().insert(key, request); + } else { + dropped.push(key); + } + } + self.schedule(DAEMON_DEBOUNCE); + } + let mut working = self.working.borrow_mut(); + for key in dropped { + working.remove(&key); + } + } + + /// The launcher was dismissed: stop everything. + pub(crate) fn cancel_all(&self) { + self.epoch.fetch_add(1, Ordering::SeqCst); + self.waiters.borrow_mut().clear(); + self.working.borrow_mut().clear(); + self.queued.borrow_mut().clear(); + self.sent.borrow_mut().clear(); + if let Some(source) = self.flush.borrow_mut().take() { + source.remove(); + } + self.cancel_daemon(); + } + + fn dispatch(&self, key: String, source: Source) { + let job = Job { + epoch: self.epoch.load(Ordering::SeqCst), + key: key.clone(), + source, + }; + if self.jobs.try_send(job).is_err() { + self.working.borrow_mut().remove(&key); + } + } + + fn wanted(&self, key: &str) -> bool { + self.waiters.borrow().contains_key(key) + } + + fn finish(self: &Rc, done: Done) { + let Done { key, outcome } = done; + match outcome { + Outcome::Texture(texture) => { + self.working.borrow_mut().remove(&key); + self.store_texture(&key, texture.clone()); + let waiters = self.waiters.borrow_mut().remove(&key); + for picture in waiters.into_iter().flatten() { + if let Some(picture) = picture.upgrade() { + paint(&picture, &key, &texture); + } + } + } + Outcome::Missing => { + self.working.borrow_mut().remove(&key); + self.store_missing(key.clone()); + self.waiters.borrow_mut().remove(&key); + } + Outcome::Daemon(request) => { + if self.wanted(&key) { + self.queued.borrow_mut().insert(key, request); + self.schedule(DAEMON_DEBOUNCE); + } else { + self.working.borrow_mut().remove(&key); + } + } + Outcome::Skipped(source) => { + // A newer render may want the same image again. + if self.wanted(&key) { + self.dispatch(key, source); + } else { + self.working.borrow_mut().remove(&key); + } + } + } + } + + fn touch(&self, key: &str) { + let mut order = self.order.borrow_mut(); + if let Some(at) = order.iter().position(|held| held == key) { + order.remove(at); + order.push_back(key.to_string()); + } + } + + fn store_texture(&self, key: &str, texture: gdk::Texture) { + let mut textures = self.textures.borrow_mut(); + if textures.insert(key.to_string(), texture).is_none() { + self.order.borrow_mut().push_back(key.to_string()); + } else { + drop(textures); + self.touch(key); + return; + } + let mut order = self.order.borrow_mut(); + while order.len() > TEXTURE_CACHE_MAX { + if let Some(old) = order.pop_front() { + textures.remove(&old); + } + } + } + + fn store_missing(&self, key: String) { + let mut missing = self.missing.borrow_mut(); + let mut order = self.missing_order.borrow_mut(); + if missing.insert(key.clone()) { + order.push_back(key); + } + while order.len() > MISSING_MAX { + if let Some(old) = order.pop_front() { + missing.remove(&old); + } + } + } + + /// Stop the daemon's work for this launcher. The generation is shared + /// with the app, so this is only sent when something was asked for. + fn cancel_daemon(&self) { + let generation = self.generation.replace(0); + if generation != 0 { + // The reply is not needed; `spawn_request` owns the round-trip. + drop(spawn_request( + self.socket.clone(), + Request::CancelFileThumbs { + generation: generation.saturating_add(1), + }, + )); + } + } + + fn schedule(self: &Rc, delay: Duration) { + if let Some(source) = self.flush.borrow_mut().take() { + source.remove(); + } + let weak = Rc::downgrade(self); + let source = glib::timeout_add_local_once(delay, move || { + if let Some(thumbs) = weak.upgrade() { + thumbs.flush.borrow_mut().take(); + thumbs.send_batch(); + } + }); + *self.flush.borrow_mut() = Some(source); + } + + fn send_batch(self: &Rc) { + if self.inflight.get() || self.queued.borrow().is_empty() { + return; + } + if self.generation.get() == 0 { + self.reserve(); + return; + } + let batch: Vec<(String, FileThumbRequest)> = { + let mut queued = self.queued.borrow_mut(); + let keys: Vec = queued.keys().take(DAEMON_BATCH).cloned().collect(); + keys.into_iter() + .filter_map(|key| queued.remove_entry(&key)) + .collect() + }; + self.sent.borrow_mut().extend(batch.iter().cloned()); + let generation = self.generation.get(); + self.inflight.set(true); + let rx = spawn_request( + self.socket.clone(), + Request::FileThumbs { + items: batch.iter().map(|(_, request)| request.clone()).collect(), + generation, + }, + ); + let weak = Rc::downgrade(self); + glib::spawn_future_local(async move { + let reply = rx.recv().await; + let Some(thumbs) = weak.upgrade() else { + return; + }; + thumbs.inflight.set(false); + thumbs.receive(generation, batch, reply); + if !thumbs.queued.borrow().is_empty() { + thumbs.schedule(Duration::ZERO); + } + }); + } + + fn receive( + self: &Rc, + generation: u64, + batch: Vec<(String, FileThumbRequest)>, + reply: Result, async_channel::RecvError>, + ) { + // Cancelled while in flight: `end_render` or `cancel_all` already + // re-queued what is still wanted. A finished thumbnail is still worth + // painting. + let current = generation == self.generation.get(); + match reply { + Ok(Ok(Response::Thumbs { items })) => { + let mut by_uid: HashMap = batch + .into_iter() + .map(|(key, request)| (request.uid.clone(), (key, request))) + .collect(); + let mut again = Vec::new(); + for item in items { + let Some((key, request)) = by_uid.remove(&item.uid) else { + continue; + }; + if let Some(path) = item.path { + if current { + self.sent.borrow_mut().remove(&key); + } + if self.wanted(&key) { + self.working.borrow_mut().insert(key.clone()); + self.dispatch(key, Source::Daemon(PathBuf::from(path))); + } else { + self.working.borrow_mut().remove(&key); + } + } else if !current { + continue; + } else if item.pending { + again.push((key, request)); + } else { + self.sent.borrow_mut().remove(&key); + self.working.borrow_mut().remove(&key); + self.store_missing(key.clone()); + self.waiters.borrow_mut().remove(&key); + } + } + if current { + again.extend(by_uid.into_values()); + self.retry(again); + } + } + Ok(Ok(Response::FileThumbsStale)) if current => { + self.generation.set(0); + self.retry(batch); + } + Ok(Ok(Response::Error { message, .. })) if current => { + tracing::debug!("launcher thumbnails failed: {message}"); + self.retry(batch); + } + _ if current => self.retry(batch), + _ => {} + } + } + + /// Ask again for items the daemon is still making, after a pause. + fn retry(self: &Rc, items: Vec<(String, FileThumbRequest)>) { + if items.is_empty() { + return; + } + let weak = Rc::downgrade(self); + glib::timeout_add_local_once(DAEMON_RETRY, move || { + let Some(thumbs) = weak.upgrade() else { + return; + }; + for (key, request) in items { + // Gone from `sent` means cancelled or already answered. + if thumbs.sent.borrow_mut().remove(&key).is_some() && thumbs.wanted(&key) { + thumbs.queued.borrow_mut().insert(key, request); + } else { + thumbs.working.borrow_mut().remove(&key); + } + } + thumbs.send_batch(); + }); + } + + fn reserve(self: &Rc) { + if self.reserving.replace(true) { + return; + } + let rx = spawn_request(self.socket.clone(), Request::ReserveFileThumbGeneration); + let weak: Weak = Rc::downgrade(self); + glib::spawn_future_local(async move { + let reply = rx.recv().await; + let Some(thumbs) = weak.upgrade() else { + return; + }; + thumbs.reserving.set(false); + match reply { + Ok(Ok(Response::FileThumbGeneration { generation })) if generation != 0 => { + thumbs.generation.set(generation); + thumbs.send_batch(); + } + _ => thumbs.schedule(DAEMON_RETRY), + } + }); + } +} + +/// Show `texture` in `picture` if the picture still belongs to `key`, and hide +/// the icon underneath it. +fn paint(picture: >k4::Picture, key: &str, texture: &gdk::Texture) { + if picture.widget_name() != key { + return; + } + picture.set_paintable(Some(texture)); + picture.set_visible(true); + if let Some(overlay) = picture.parent().and_downcast::() + && let Some(fallback) = overlay.child() + { + fallback.set_visible(false); + } +} + +fn work( + index: usize, + inbox: &async_channel::Receiver, + done: &async_channel::Sender, + epoch: &AtomicU64, +) { + while let Ok(job) = inbox.recv_blocking() { + // A daemon thumbnail is already made; decoding it is cheap and the + // texture is cached for the next render. + let stale = + !matches!(job.source, Source::Daemon(_)) && job.epoch < epoch.load(Ordering::SeqCst); + let outcome = if stale { + Outcome::Skipped(job.source) + } else { + load(index, job.source) + }; + if done + .send_blocking(Done { + key: job.key, + outcome, + }) + .is_err() + { + break; + } + } +} + +fn load(worker: usize, source: Source) -> Outcome { + match source { + Source::Local(path) => { + let Ok(meta) = std::fs::metadata(&path) else { + return Outcome::Missing; + }; + let mtime = meta + .modified() + .ok() + .and_then(|at| at.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |at| at.as_secs() as i64); + if let Some(pixbuf) = cached(&path, mtime) { + return texture(&pixbuf); + } + if meta.len() > MAX_SOURCE_BYTES { + return Outcome::Missing; + } + let Some((pixbuf, scaled)) = decode(&path) else { + return Outcome::Missing; + }; + // An image that already fits is its own thumbnail. + if scaled && let Err(e) = write_cached(worker, &path, mtime, &pixbuf) { + tracing::debug!("cannot cache a thumbnail of {}: {e}", path.display()); + } + texture(&pixbuf) + } + Source::Drive { mounted, request } => match cached(&mounted, request.modified) { + Some(pixbuf) => texture(&pixbuf), + None => Outcome::Daemon(request), + }, + Source::Daemon(path) => match gdk::Texture::from_filename(&path) { + Ok(texture) => Outcome::Texture(texture), + Err(e) => { + tracing::debug!("cannot decode {}: {e}", path.display()); + Outcome::Missing + } + }, + } +} + +/// `$XDG_CACHE_HOME/thumbnails`. +fn thumbnail_root() -> PathBuf { + glib::user_cache_dir().join("thumbnails") +} + +/// The URI and its MD5, which name a file's entry in the thumbnail cache. +fn cache_name(path: &Path) -> Option<(String, String)> { + let uri = glib::filename_to_uri(path, None).ok()?.to_string(); + let md5 = glib::compute_checksum_for_data(glib::ChecksumType::Md5, uri.as_bytes())?.to_string(); + Some((uri, md5)) +} + +/// A thumbnail from the shared cache, if one was made for this version of +/// the file. +fn cached(path: &Path, mtime: i64) -> Option { + let (_, md5) = cache_name(path)?; + let root = thumbnail_root(); + ["large", "x-large", "normal"].iter().find_map(|size| { + let pixbuf = Pixbuf::from_file(root.join(size).join(format!("{md5}.png"))).ok()?; + let stamp = pixbuf.option("tEXt::Thumb::MTime")?; + (stamp.parse::().ok()? == mtime).then_some(pixbuf) + }) +} + +/// Decode a local image at most [`THUMB_SIZE`] on its longer edge, upright. +/// The bool says whether it had to be scaled down. +fn decode(path: &Path) -> Option<(Pixbuf, bool)> { + let (_, width, height) = Pixbuf::file_info(path)?; + let scaled = width > THUMB_SIZE || height > THUMB_SIZE; + let pixbuf = if scaled { + Pixbuf::from_file_at_scale(path, THUMB_SIZE, THUMB_SIZE, true) + } else { + Pixbuf::from_file(path) + } + .ok()?; + let pixbuf = pixbuf.apply_embedded_orientation().unwrap_or(pixbuf); + Some((pixbuf, scaled)) +} + +/// Store a thumbnail as the freedesktop spec asks: in `large/`, with +/// `Thumb::URI` and `Thumb::MTime`, readable only by the user, and written +/// through a temporary file so another reader never sees half of it. +fn write_cached(worker: usize, path: &Path, mtime: i64, pixbuf: &Pixbuf) -> std::io::Result<()> { + let (uri, md5) = cache_name(path).ok_or_else(|| std::io::Error::other("no file URI"))?; + let dir = thumbnail_root().join("large"); + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&dir)?; + let mtime = mtime.to_string(); + let png = pixbuf + .save_to_bufferv( + "png", + &[ + ("tEXt::Thumb::URI", uri.as_str()), + ("tEXt::Thumb::MTime", mtime.as_str()), + ], + ) + .map_err(std::io::Error::other)?; + let tmp = dir.join(format!( + "{md5}.png.pdfs-{}-{worker}.tmp", + std::process::id() + )); + let written = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&tmp) + .and_then(|mut file| file.write_all(&png)) + .and_then(|()| std::fs::rename(&tmp, dir.join(format!("{md5}.png")))); + if written.is_err() { + let _ = std::fs::remove_file(&tmp); + } + written +} + +/// A `Pixbuf` may not leave its thread; its pixels, as a memory texture, can. +fn texture(pixbuf: &Pixbuf) -> Outcome { + let format = if pixbuf.has_alpha() { + gdk::MemoryFormat::R8g8b8a8 + } else { + gdk::MemoryFormat::R8g8b8 + }; + let texture = gdk::MemoryTexture::new( + pixbuf.width(), + pixbuf.height(), + format, + &pixbuf.read_pixel_bytes(), + pixbuf.rowstride() as usize, + ); + Outcome::Texture(texture.upcast()) +} diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index 69746c7..3cb8e8e 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -6,37 +6,42 @@ //! so repeat activations only reset and present the existing widget tree. Every //! daemon round-trip runs on a worker thread and lands back through an async //! channel. Drive and local lookup share one request so a result set paints once. +//! +//! This file holds the command line, the result model and the ranking shared by +//! every front end. The GTK window lives in [`hud`]. -use std::cell::{Cell, RefCell}; +use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::Duration; use adw::prelude::*; -use gtk4::{gio, glib}; +use gtk4::glib; use pdfs_core::config::AppDirs; -use pdfs_core::control::{ - LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send, -}; +use pdfs_core::control::{LocalHit, Request, Response, SearchHit, SearchKind, send}; use pdfs_core::menu::PromptMode; -use pdfs_core::opener::{self, OpenWith}; mod activation; mod compat; +// Only the relative and full forms are used here; the short date is the app's. +#[allow(dead_code)] +#[path = "app/dates.rs"] +mod dates; +mod hud; mod i18n; -use compat::{Spinner, spinner}; +mod theme; use i18n::{gettext, gettext_f, gettext_noop, human_bytes, ngettext_f, pgettext}; mod dmenu; mod fzf; mod query; -use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target}; +use activation::mounted_or_relative; const APP_ID: &str = "io.narl.proton-drive-linux-prompt"; /// Debounce before a keystroke turns into a daemon round-trip. Short enough to /// feel live, long enough that typing a word is one search, not five. -const SEARCH_DEBOUNCE: Duration = Duration::from_millis(120); +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(80); /// Per-section result cap. The list is a launcher, not a file manager: more rows /// than fit on screen only cost render time. @@ -65,15 +70,10 @@ const FILTERS: [(Filter, &str); 5] = [ ]; impl Filter { - /// Whether a hit of this name/kind survives the chip. + /// Whether a hit of this name/kind survives the chip. The daemon's own + /// classification, so a chip never drops a hit the daemon returned for it. fn accepts(self, name: &str, is_dir: bool) -> bool { - match self { - Filter::All => true, - Filter::Folders => is_dir, - Filter::Documents => !is_dir && is_document(name), - Filter::Images => !is_dir && is_image(name), - Filter::Media => !is_dir && is_media(name), - } + self.search_kind().accepts(name, is_dir) } fn search_kind(self) -> SearchKind { @@ -215,134 +215,45 @@ impl Hit { Hit::Local(h) => (true, h.path.clone()), } } + + /// The thumbnail cache key. The modification time is part of it, so a + /// replaced image never shows its previous contents. + #[allow(dead_code)] // the GTK launcher only + fn thumb_key(&self) -> String { + match self { + Hit::Drive(h) => format!("d:{}:{}", h.uid, h.modified), + Hit::Local(h) => format!("l:{}:{}", h.path, h.modified), + } + } + + /// Where the file sits on this machine: the path itself for a local hit, + /// the mounted path for a Drive hit. Nothing is checked on disk, so this + /// is safe to call while a mount is frozen. + #[allow(dead_code)] // the GTK launcher only + fn fs_path(&self, mountpoint: &Path) -> PathBuf { + match self { + Hit::Drive(h) => mounted_or_relative(mountpoint, h), + Hit::Local(h) => PathBuf::from(&h.path), + } + } } fn rank_hits(hits: &mut [Hit]) { - hits.sort_by(|a, b| { - b.score() - .cmp(&a.score()) - .then_with(|| a.name().cmp(b.name())) - .then_with(|| a.key().cmp(&b.key())) + rank_hits_by(hits, |_| 0); +} + +/// [`rank_hits`] with an extra score per hit, such as the launcher's history +/// bonus for files the user opens often. +fn rank_hits_by(hits: &mut [Hit], bonus: impl Fn(&Hit) -> i64) { + hits.sort_by_cached_key(|hit| { + ( + std::cmp::Reverse(hit.score().saturating_add(bonus(hit))), + hit.name().to_string(), + hit.key(), + ) }); } -/// Everything the window needs to render and act, in one place so the many -/// callbacks below capture a single `Rc` instead of a dozen clones each. -struct Ui { - socket: PathBuf, - mountpoint: RefCell, - /// How a chosen file is launched. Read once at window build: the prompt is - /// resident, but a config edit is rare enough that a restart is a fair - /// price for not re-reading the file on every Enter. - opener: OpenWith, - - entry: gtk4::Entry, - spinner: Spinner, - stack: gtk4::Stack, - scroller: gtk4::ScrolledWindow, - results: gtk4::Box, - search_section: Section, - drive_section: Section, - local_section: Section, - placeholder: adw::StatusPage, - hint: gtk4::Label, - chips: RefCell>, - - /// Raw (unfiltered) hits from the last reply of each search. - drive_hits: RefCell>, - local_hits: RefCell>, - /// The filtered, flattened list the cursor indexes into — Drive rows first, - /// then local ones, matching the visual order. - visible: RefCell>, - cursor: Cell>, - /// The trimmed query the currently-rendered rows belong to. Enter compares - /// against it so a keystroke that lands just before a fresher render can't - /// open a file from a result set the user has already typed past. - rendered_query: RefCell, - - filter: Cell, - /// Monotonic query id. A reply whose id is stale (the user typed again while - /// it was in flight) is dropped instead of overwriting fresher results. - query_id: Cell, - /// In-flight requests for the current query id. - pending: Cell, - /// True once a Drive open is running: the window is about to close, so - /// further input is ignored. - opening: Cell, - indexing: Cell, - /// Set when Enter arrives before the in-flight query has rendered (typing - /// then hitting Enter faster than the search debounce). The open is honoured - /// against the selected row once the fresh results settle, rather than being - /// dropped — so a plain "type, Enter" always opens something. - open_pending: Cell, - /// Prevent lifecycle-driven entry resets from also scheduling the normal - /// debounced empty-query request; activation performs one explicit daemon - /// bootstrap instead. - suppress_entry_change: Cell, - /// The window, so a deferred open (above) can reach it from `render`. - window: RefCell>, -} - -/// A titled group of rows: header (hidden when empty) plus its list. -struct Section { - header: gtk4::Box, - title: gtk4::Label, - list: gtk4::ListBox, -} - -impl Section { - fn new(title: &str, icon: &str) -> Self { - let header = gtk4::Box::builder() - .orientation(gtk4::Orientation::Horizontal) - .spacing(8) - .visible(false) - .build(); - header.add_css_class("section-header"); - - let image = gtk4::Image::from_icon_name(icon); - image.add_css_class("section-icon"); - header.append(&image); - - let label = gtk4::Label::builder().label(title).xalign(0.0).build(); - label.add_css_class("section-title"); - header.append(&label); - - let list = gtk4::ListBox::builder() - .selection_mode(gtk4::SelectionMode::Single) - .visible(false) - .build(); - list.add_css_class("result-list"); - - Self { - header, - title: label, - list, - } - } - - fn attach(&self, parent: >k4::Box) { - parent.append(&self.header); - parent.append(&self.list); - } - - /// Swap in a new set of rows. Rebuilds the list — at [`SEARCH_LIMIT`] rows a - /// rebuild is a handful of widgets, far cheaper than diffing them. - fn set_rows(&self, hits: &[Hit], subtitle: Option<&str>) { - while let Some(child) = self.list.first_child() { - self.list.remove(&child); - } - for hit in hits { - self.list.append(&build_row(hit)); - } - let shown = !hits.is_empty(); - self.header.set_visible(shown); - self.list.set_visible(shown); - if let Some(text) = subtitle { - self.title.set_label(text); - } - } -} - const USAGE: &str = "\ pdfs-prompt — search Proton Drive and this computer @@ -488,13 +399,13 @@ fn main() -> glib::ExitCode { // when that window is hidden. Hold it explicitly as well: residency is a // product requirement here, not an incidental window-lifetime side effect. let _resident = app.hold(); - app.connect_startup(|_| load_css()); - let prompt: Rc>>> = Rc::new(RefCell::new(None)); + app.connect_startup(|_| theme::load_resources("/de/nils/protondrivelinux/prompt.css")); + let prompt: Rc>>> = Rc::new(RefCell::new(None)); app.connect_activate(move |app| { let ui = if let Some(ui) = prompt.borrow().clone() { ui } else { - let Some(ui) = build_window(app) else { + let Some(ui) = hud::build_window(app) else { return; }; *prompt.borrow_mut() = Some(ui.clone()); @@ -506,892 +417,6 @@ fn main() -> glib::ExitCode { app.run_with_args::<&str>(&[]) } -fn build_window(app: &adw::Application) -> Option> { - let dirs = match AppDirs::new() { - Ok(d) => d, - Err(e) => { - tracing::error!("cannot resolve app dirs: {e}"); - return None; - } - }; - - let window = adw::ApplicationWindow::builder() - .application(app) - .title(gettext("Search Proton Drive")) - .default_width(720) - .default_height(540) - .resizable(false) - .decorated(false) - .build(); - window.add_css_class("launcher-window"); - - let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - card.add_css_class("launcher-card"); - window.set_content(Some(&card)); - - // --- search bar ------------------------------------------------------- - let search_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); - search_bar.add_css_class("search-bar"); - - let search_icon = gtk4::Image::from_icon_name("system-search-symbolic"); - search_icon.add_css_class("search-icon"); - search_bar.append(&search_icon); - - let entry = gtk4::Entry::builder() - .placeholder_text(gettext("Search in Drive and on this computer")) - .hexpand(true) - .build(); - entry.add_css_class("search-entry"); - search_bar.append(&entry); - - let spinner = spinner(); - spinner.set_visible(false); - search_bar.append(&spinner); - - // Translators: the Escape key, shown as a hint that it closes the window. - let esc = gtk4::Label::new(Some(&gettext("Esc"))); - esc.add_css_class("key-hint"); - search_bar.append(&esc); - card.append(&search_bar); - - // --- filter chips ----------------------------------------------------- - let chip_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - chip_row.add_css_class("chip-row"); - card.append(&chip_row); - - // --- results ---------------------------------------------------------- - let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - results.add_css_class("results"); - - let search_section = Section::new(&gettext("Best matches"), "system-search-symbolic"); - let drive_section = Section::new("Proton Drive", "folder-remote-symbolic"); - let local_section = Section::new(&gettext("This computer"), "drive-harddisk-symbolic"); - search_section.attach(&results); - drive_section.attach(&results); - local_section.attach(&results); - - let scroller = gtk4::ScrolledWindow::builder() - .hscrollbar_policy(gtk4::PolicyType::Never) - .vscrollbar_policy(gtk4::PolicyType::Automatic) - .vexpand(true) - .child(&results) - .build(); - - let placeholder = adw::StatusPage::builder() - .icon_name("system-search-symbolic") - .title(gettext("No results")) - .build(); - placeholder.add_css_class("compact"); - - // The daemon-offline page is a peer of the results, not a modal: the prompt - // is still usable for nothing else, so it owns the whole view. - let offline = adw::StatusPage::builder() - .icon_name("network-offline-symbolic") - .title(gettext("Proton Drive is not running")) - .description(gettext("Start Proton Drive to search your files.")) - .build(); - offline.add_css_class("compact"); - let retry = gtk4::Button::builder() - .label(gettext("Retry")) - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - offline.set_child(Some(&retry)); - - let stack = gtk4::Stack::builder() - .transition_type(gtk4::StackTransitionType::Crossfade) - .transition_duration(120) - .vexpand(true) - .build(); - stack.add_named(&scroller, Some("results")); - stack.add_named(&placeholder, Some("empty")); - stack.add_named(&offline, Some("offline")); - card.append(&stack); - - // --- footer ----------------------------------------------------------- - let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - footer.add_css_class("footer"); - let hint = gtk4::Label::builder() - .label(gettext("Connecting…")) - .xalign(0.0) - .hexpand(true) - .ellipsize(gtk4::pango::EllipsizeMode::End) - .build(); - hint.add_css_class("footer-text"); - footer.append(&hint); - // Translators: keyboard hints in the footer; keep the arrow and Enter symbols. - let keys = gtk4::Label::new(Some(&gettext("↑↓ navigate · ↵ open · Tab filter"))); - keys.add_css_class("footer-text"); - footer.append(&keys); - card.append(&footer); - - let ui = Rc::new(Ui { - socket: dirs.control_socket(), - mountpoint: RefCell::new(dirs.default_mountpoint()), - opener: dirs.load_config().resolved_open_with(), - entry: entry.clone(), - spinner, - stack, - scroller, - results, - search_section, - drive_section, - local_section, - placeholder, - hint, - chips: RefCell::new(Vec::new()), - drive_hits: RefCell::new(Vec::new()), - local_hits: RefCell::new(Vec::new()), - visible: RefCell::new(Vec::new()), - cursor: Cell::new(None), - rendered_query: RefCell::new(String::new()), - filter: Cell::new(Filter::All), - query_id: Cell::new(0), - pending: Cell::new(0), - opening: Cell::new(false), - indexing: Cell::new(false), - open_pending: Cell::new(false), - suppress_entry_change: Cell::new(false), - window: RefCell::new(None), - }); - *ui.window.borrow_mut() = Some(window.clone()); - - for (filter, label) in FILTERS { - let chip = gtk4::ToggleButton::builder() - .label(gettext(label)) - .active(filter == Filter::All) - .build(); - chip.add_css_class("chip"); - let ui_chip = ui.clone(); - chip.connect_clicked(move |btn| { - // A chip is a radio, not a switch: clicking the active one keeps it on. - if btn.is_active() { - ui_chip.set_filter(filter); - } else { - btn.set_active(true); - } - }); - chip_row.append(&chip); - ui.chips.borrow_mut().push((filter, chip)); - } - - // Clicking a row (or tapping it) opens it, same as Enter on the cursor. A - // local row's index is relative to its own list, so the Drive rows above it - // are added back to land on the same flattened index the cursor uses. - for (list, is_local) in [ - (&ui.drive_section.list, false), - (&ui.local_section.list, true), - ] { - let ui_row = ui.clone(); - list.connect_row_activated(move |_, row| { - let offset = if is_local { ui_row.drive_count() } else { 0 }; - ui_row.open(offset + row.index() as usize); - }); - } - let ui_row = ui.clone(); - ui.search_section - .list - .connect_row_activated(move |_, row| ui_row.open(row.index() as usize)); - - let ui_key = ui.clone(); - let keys = gtk4::EventControllerKey::new(); - keys.connect_key_pressed(move |_, key, _, state| { - if ui_key.opening.get() { - return glib::Propagation::Stop; - } - match key { - gtk4::gdk::Key::Escape => { - // Spotlight-style: first Escape clears a non-empty query, a - // second (or an already-empty box) dismisses the launcher. - if ui_key.entry.text().is_empty() { - ui_key.dismiss(); - } else { - ui_key.entry.set_text(""); - } - glib::Propagation::Stop - } - gtk4::gdk::Key::Down => { - ui_key.move_cursor(1); - glib::Propagation::Stop - } - gtk4::gdk::Key::Up => { - ui_key.move_cursor(-1); - glib::Propagation::Stop - } - gtk4::gdk::Key::Tab | gtk4::gdk::Key::ISO_Left_Tab => { - let back = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK); - ui_key.cycle_filter(back); - glib::Propagation::Stop - } - // Return is deliberately absent: `GtkText` binds it to `activate` - // and consumes it, so a bubble-phase controller on the window never - // sees it while the entry has focus (which is always, here). It is - // handled on the entry's `activate` signal instead — see below. - _ => glib::Propagation::Proceed, - } - }); - // On the window, not the entry: arrow keys must steer the list even when the - // pointer has moved focus into it. - window.add_controller(keys); - - // Enter opens the row under the cursor. This lives on the entry rather than - // with the other keys on the window because `GtkText` claims Return for its - // own `activate` binding, so a bubble-phase controller upstream of the - // focused entry is never reached. Enter with focus in the list is covered by - // `row_activated` above. - let ui_activate = ui.clone(); - entry.connect_activate(move |_| { - if ui_activate.opening.get() { - return; - } - // Fall back to the top row when nothing is explicitly selected (e.g. - // results haven't rendered yet); `open` guards the rest. - ui_activate.open(ui_activate.cursor.get().unwrap_or(0)); - }); - - // Some GTK/input-method combinations consume the physical Return binding in - // GtkText without emitting Entry::activate. Capture only Return ahead of the - // text widget and leave every other key to normal GTK/IME handling. Stopping - // propagation also prevents the activate signal above from opening twice; - // that signal remains the programmatic/accessibility activation path. - let ui_return = ui.clone(); - let return_key = gtk4::EventControllerKey::new(); - return_key.set_propagation_phase(gtk4::PropagationPhase::Capture); - return_key.connect_key_pressed(move |_, key, _, _| { - if matches!(key, gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter) { - if !ui_return.opening.get() { - ui_return.open(ui_return.cursor.get().unwrap_or(0)); - } - glib::Propagation::Stop - } else { - glib::Propagation::Proceed - } - }); - entry.add_controller(return_key); - - let debounce: Rc>> = Rc::new(RefCell::new(None)); - let ui_changed = ui.clone(); - entry.connect_changed(move |entry| { - if ui_changed.suppress_entry_change.get() { - return; - } - // A fresh keystroke supersedes any Enter that was waiting on results: - // the user is still refining the query, not asking to open yet. - ui_changed.open_pending.set(false); - if let Some(source) = debounce.borrow_mut().take() { - source.remove(); - } - let query = entry.text().trim().to_string(); - let ui_timeout = ui_changed.clone(); - let debounce_timeout = debounce.clone(); - let source = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || { - debounce_timeout.borrow_mut().take(); - ui_timeout.search(&query); - }); - *debounce.borrow_mut() = Some(source); - }); - - let ui_retry = ui.clone(); - retry.connect_clicked(move |_| ui_retry.connect()); - - // Keep the widget tree alive when the compositor asks the window to close. - // This also avoids running GTK's destruction path while async replies still - // hold references to prompt widgets. - let ui_close = ui.clone(); - window.connect_close_request(move |_| { - ui_close.dismiss(); - glib::Propagation::Stop - }); - let entry_map = entry.clone(); - window.connect_map(move |_| { - entry_map.grab_focus(); - }); - - Some(ui) -} - -impl Ui { - /// Reset and present the resident prompt in response to GApplication - /// activation. A second invocation while a file is materialising merely - /// raises the progress window; it must not make the in-flight open mutable. - fn activate(self: &Rc) { - let Some(window) = self.window.borrow().clone() else { - return; - }; - if !self.opening.get() { - // Supersede replies from the previous showing before clearing the - // query. The daemon work may finish, but it can no longer repaint - // the newly activated prompt with stale rows. - self.query_id.set(self.query_id.get() + 1); - self.pending.set(0); - self.open_pending.set(false); - self.spinner.set_visible(false); - self.suppress_entry_change.set(true); - self.entry.set_text(""); - self.suppress_entry_change.set(false); - self.set_filter(Filter::All); - self.connect(); - } - window.present(); - self.entry.grab_focus(); - } - - /// Hand a resolved path to the configured opener — `xdg-open` unless - /// `open_with` in `config.json` says otherwise (a terminal editor for text - /// files, say). - fn open_path(&self, path: &Path, is_dir: bool) { - opener::open(&self.opener, path, is_dir); - } - - /// Open a file the daemon just materialised into the content cache. The - /// blob is named after its content hash, so the open rules have to be - /// matched against the Drive name instead. - fn open_materialized(&self, path: &Path, name: &str) { - opener::open_named(&self.opener, path, name, false); - } - - /// Dismiss without destroying the application window. GTK continues to - /// associate the hidden window with the single application instance, so a - /// later desktop shortcut invocation is a cheap `activate` round-trip. - fn dismiss(&self) { - self.open_pending.set(false); - if let Some(window) = self.window.borrow().as_ref() { - window.set_visible(false); - } - } - - /// Ask the daemon for status and, if it answers, load the suggested (pinned) - /// files. Runs off-thread: a dead daemon must not freeze the first frame. - fn connect(self: &Rc) { - self.hint.set_label(&gettext("Connecting…")); - let rx = spawn_request(self.socket.clone(), Request::Status); - let ui = self.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Status { mountpoint, .. })) => { - *ui.mountpoint.borrow_mut() = PathBuf::from(mountpoint); - ui.entry.set_sensitive(true); - ui.stack.set_visible_child_name("results"); - ui.load_suggestions(); - } - _ => { - ui.entry.set_sensitive(false); - ui.hint.set_label(&gettext("Proton Drive isn't running")); - ui.stack.set_visible_child_name("offline"); - } - } - }); - } - - /// The daemon stopped answering after the window was already open. Flip to - /// the offline page and lock the entry; the retry button re-runs `connect`. - fn go_offline(self: &Rc) { - self.pending.set(0); - self.spinner.set_visible(false); - self.entry.set_sensitive(false); - self.hint.set_label(&gettext("Proton Drive isn't running")); - self.stack.set_visible_child_name("offline"); - } - - /// The empty-query view: pinned files, which are the ones the user chose to - /// keep on this machine — the closest thing the daemon has to "recent". - fn load_suggestions(self: &Rc) { - let id = self.begin_query(1); - let rx = spawn_request(self.socket.clone(), Request::ListPins); - let ui = self.clone(); - glib::spawn_future_local(async move { - let reply = rx.recv().await; - if !ui.finish_query(id) { - return; - } - if matches!(reply, Ok(Err(_)) | Err(_)) { - ui.go_offline(); - return; - } - let hits = match reply { - Ok(Ok(Response::Pins { pins })) => pins - .into_iter() - .map(|pin| SearchHit { - name: file_name(&pin.path), - path: pin.path, - is_dir: pin.is_dir.unwrap_or(pin.recursive), - size: 0, - modified: 0, - pinned: true, - cached: pin.cached, - uid: pin.uid, - mounted_path: None, - score: 0, - }) - .collect(), - _ => Vec::new(), - }; - *ui.drive_hits.borrow_mut() = hits; - ui.local_hits.borrow_mut().clear(); - ui.render(); - }); - } - - /// Search Drive and local metadata in one daemon round-trip. An empty query - /// falls back to the suggestions view. - fn search(self: &Rc, query: &str) { - if query.is_empty() { - self.load_suggestions(); - return; - } - - let id = self.begin_query(1); - let search = spawn_request( - self.socket.clone(), - Request::SearchV2 { - query: query.to_string(), - limit: SEARCH_LIMIT, - filters: SearchFilters { - sources: vec![SearchSource::Drive, SearchSource::Local], - kind: self.filter.get().search_kind(), - }, - }, - ); - let ui = self.clone(); - glib::spawn_future_local(async move { - let reply = search.recv().await; - if !ui.finish_query(id) { - return; - } - match reply { - Ok(Ok(Response::SearchResultsV2 { - drive_hits, - local_hits, - local_indexing, - })) => { - *ui.drive_hits.borrow_mut() = drive_hits; - *ui.local_hits.borrow_mut() = local_hits; - ui.indexing.set(local_indexing); - ui.render(); - } - // Transport failure: the daemon is gone. Don't paint "No - // results" over a crash — say so. - Ok(Err(_)) | Err(_) => ui.go_offline(), - _ => { - ui.drive_hits.borrow_mut().clear(); - ui.local_hits.borrow_mut().clear(); - ui.render(); - } - } - }); - } - - /// Open a new query id, expecting `requests` replies. Any reply still in - /// flight for an older id is now stale and will be dropped by - /// [`finish_query`](Self::finish_query). - fn begin_query(&self, requests: u8) -> u64 { - let id = self.query_id.get() + 1; - self.query_id.set(id); - self.pending.set(requests); - self.spinner.set_visible(true); - id - } - - /// Account for one reply. Returns false if it belongs to a superseded query, - /// in which case the caller must discard it. - fn finish_query(&self, id: u64) -> bool { - if self.query_id.get() != id { - return false; - } - let left = self.pending.get().saturating_sub(1); - self.pending.set(left); - if left == 0 { - self.spinner.set_visible(false); - } - true - } - - fn set_filter(self: &Rc, filter: Filter) { - if self.filter.get() == filter { - return; - } - self.filter.set(filter); - for (chip_filter, chip) in self.chips.borrow().iter() { - chip.set_active(*chip_filter == filter); - } - let query = self.entry.text().trim().to_string(); - if query.is_empty() { - self.render(); - } else { - self.search(&query); - } - } - - fn cycle_filter(self: &Rc, backwards: bool) { - let current = FILTERS - .iter() - .position(|(f, _)| *f == self.filter.get()) - .unwrap_or(0); - let count = FILTERS.len(); - let next = if backwards { - (current + count - 1) % count - } else { - (current + 1) % count - }; - self.set_filter(FILTERS[next].0); - } - - fn drive_count(&self) -> usize { - self.visible - .borrow() - .iter() - .filter(|hit| matches!(hit, Hit::Drive(_))) - .count() - } - - /// Rebuild suggestions or the globally-ranked search section, preserving the - /// selected identity when a result survives the update. - fn render(self: &Rc) { - let filter = self.filter.get(); - - // Identity of the row under the cursor before we rebuild the list, so a - // late-arriving second search can't yank the selection back to the top. - let selected_key = self - .cursor - .get() - .and_then(|i| self.visible.borrow().get(i).map(Hit::key)); - - let drive: Vec = self - .drive_hits - .borrow() - .iter() - .filter(|hit| filter.accepts(&hit.name, hit.is_dir)) - .cloned() - .map(Hit::Drive) - .collect(); - let local: Vec = self - .local_hits - .borrow() - .iter() - .filter(|hit| filter.accepts(&hit.name, hit.is_dir)) - .cloned() - .map(Hit::Local) - .collect(); - - let query = self.entry.text().trim().to_string(); - let searching = !query.is_empty(); - let total = drive.len() + local.len(); - let mut visible = drive; - visible.extend(local); - if searching { - rank_hits(&mut visible); - self.search_section - .set_rows(&visible, Some(&gettext("Best matches"))); - self.drive_section.set_rows(&[], None); - self.local_section.set_rows(&[], None); - } else { - self.search_section.set_rows(&[], None); - let drive_count = visible - .iter() - .take_while(|hit| matches!(hit, Hit::Drive(_))) - .count(); - self.drive_section.set_rows( - &visible[..drive_count], - Some(&gettext("Pinned in Proton Drive")), - ); - self.local_section.set_rows(&visible[drive_count..], None); - } - - // Find where the previously-selected row landed in the rebuilt list. - let restored = selected_key.and_then(|key| visible.iter().position(|hit| hit.key() == key)); - - *self.visible.borrow_mut() = visible; - *self.rendered_query.borrow_mut() = query; - - if total == 0 { - self.placeholder.set_title(&if searching { - gettext("No results") - } else { - gettext("Search your Drive") - }); - self.placeholder - .set_description(Some(&match (searching, self.indexing.get()) { - (true, true) => gettext( - "Still indexing this computer — local results will fill in shortly.", - ), - (true, false) => gettext("Try a different search, or another filter."), - _ => gettext( - "Start typing to search Proton Drive and the files on this computer.", - ), - })); - self.stack.set_visible_child_name("empty"); - self.cursor.set(None); - } else { - self.stack.set_visible_child_name("results"); - self.select(restored.unwrap_or(0)); - } - - self.hint.set_label(&self.status_text(total, searching)); - - // Honour an Enter that arrived before this query rendered, now that the - // results (and the cursor) are settled. Only once both searches are in, - // so it lands on the final list, not a half-populated one. - if self.open_pending.get() && self.pending.get() == 0 { - self.open_pending.set(false); - if let Some(index) = self.cursor.get() { - self.open(index); - } - } - } - - fn status_text(&self, total: usize, searching: bool) -> String { - if !searching { - return match total { - 0 => gettext("No pinned files"), - n => ngettext_f("{n} pinned file", "{n} pinned files", n as u64, &[]), - }; - } - let drive = self.drive_count().to_string(); - let local = (total - self.drive_count()).to_string(); - let args = [("drive", drive.as_str()), ("local", local.as_str())]; - if self.indexing.get() { - // Translators: result counts in the footer while this computer is still being indexed; {drive} and {local} are numbers. - gettext_f( - "{drive} in Drive · {local} on this computer · indexing…", - &args, - ) - } else { - // Translators: result counts in the footer; {drive} and {local} are numbers. - gettext_f("{drive} in Drive · {local} on this computer", &args) - } - } - - /// Move the cursor by `delta` rows, wrapping at both ends. - fn move_cursor(self: &Rc, delta: i32) { - let count = self.visible.borrow().len() as i32; - if count == 0 { - return; - } - let current = self.cursor.get().map_or(0, |c| c as i32); - let next = (current + delta).rem_euclid(count); - self.select(next as usize); - } - - /// Put the cursor on row `index` of the flattened list: select it in whichever - /// section owns it, clear the other section's selection, and scroll it into - /// view. Focus stays in the entry so typing never breaks. - fn select(self: &Rc, index: usize) { - if !self.rendered_query.borrow().is_empty() { - self.drive_section.list.unselect_all(); - self.local_section.list.unselect_all(); - let Some(row) = self.search_section.list.row_at_index(index as i32) else { - return; - }; - self.search_section.list.select_row(Some(&row)); - self.cursor.set(Some(index)); - self.scroll_into_view(&row); - return; - } - - self.search_section.list.unselect_all(); - let drive_count = self.drive_count(); - let (list, other, local_index) = if index < drive_count { - ( - &self.drive_section.list, - &self.local_section.list, - index as i32, - ) - } else { - ( - &self.local_section.list, - &self.drive_section.list, - (index - drive_count) as i32, - ) - }; - other.unselect_all(); - let Some(row) = list.row_at_index(local_index) else { - return; - }; - list.select_row(Some(&row)); - self.cursor.set(Some(index)); - self.scroll_into_view(&row); - } - - /// Keep the selected row inside the viewport, scrolling by the smallest - /// amount that reveals it (like a native list, unlike jumping it to centre). - fn scroll_into_view(&self, row: >k4::ListBoxRow) { - let Some(bounds) = row.compute_bounds(&self.results) else { - return; - }; - let adjustment = self.scroller.vadjustment(); - let (top, bottom) = ( - f64::from(bounds.y()), - f64::from(bounds.y() + bounds.height()), - ); - let (value, page) = (adjustment.value(), adjustment.page_size()); - if top < value { - adjustment.set_value(top); - } else if bottom > value + page { - adjustment.set_value(bottom - page); - } - } - - /// Open the hit at `index`. Local files are already on disk, so they hand - /// straight to the desktop; a Drive hit must first be hydrated into the cache - /// by the daemon (`OpenFile`), which can take a moment — the window stays up, - /// with the spinner running, until the path comes back. - fn open(self: &Rc, index: usize) { - if self.opening.get() { - return; - } - // The rows on screen may belong to an older query whose reply is still - // settling. Don't act on them — Enter must never launch a file the user - // has typed past. But don't drop the intent either: remember it and open - // the right row once the fresh results land (see `render`). - if self.entry.text().trim() != *self.rendered_query.borrow() { - self.open_pending.set(true); - return; - } - let Some(hit) = self.visible.borrow().get(index).cloned() else { - return; - }; - - match hit { - Hit::Local(local) => { - self.open_path(Path::new(&local.path), local.is_dir); - self.dismiss(); - } - Hit::Drive(drive) => { - // Empty-query rows come from ListPins, whose `recursive` flag is - // pin policy rather than reliable node kind metadata. Every pin - // already has a mounted path, so activate that path directly; - // this handles non-recursively pinned folders without mistaking - // them for files and sending an invalid OpenFile request. - if self.rendered_query.borrow().is_empty() { - let path = mounted_or_relative(&self.mountpoint.borrow(), &drive); - self.open_path(&path, drive.is_dir); - self.dismiss(); - return; - } - match drive_activation(&drive.name, drive.is_dir) { - DriveActivation::Folder | DriveActivation::MountedMedia => { - let path = mounted_or_relative(&self.mountpoint.borrow(), &drive); - self.open_path(&path, drive.is_dir); - self.dismiss(); - return; - } - // The mount holds the real file; the cache holds a copy an - // editor would save into for nothing. Prefer the mount, and - // materialise only what it does not expose. - DriveActivation::Materialize => { - if let Some(path) = mounted_target(&self.mountpoint.borrow(), &drive) { - self.open_path(&path, false); - self.dismiss(); - return; - } - } - } - - self.opening.set(true); - self.spinner.set_visible(true); - // Translators: {name} is a file name. - let opening = gettext_f("Opening {name}…", &[("name", &drive.name)]); - self.hint.set_label(&opening); - - let rx = spawn_request( - self.socket.clone(), - Request::OpenFile { - path: drive.path.clone(), - uid: Some(drive.uid.clone()), - }, - ); - let ui = self.clone(); - glib::spawn_future_local(async move { - let reply = rx.recv().await; - ui.spinner.set_visible(false); - ui.opening.set(false); - match reply { - Ok(Ok(Response::FilePath { path })) => { - ui.open_materialized(Path::new(&path), &drive.name); - ui.dismiss(); - } - Ok(Ok(Response::Error { message, .. })) => { - // Translators: {message} is an error from the daemon, in English. - ui.hint.set_label(&gettext_f( - "Could not open: {message}", - &[("message", &message)], - )); - } - _ => ui.hint.set_label(&gettext("Couldn't reach Proton Drive")), - } - }); - } - } - } -} - -/// One result row: type icon, name, location, then size/age on the right. -fn build_row(hit: &Hit) -> gtk4::ListBoxRow { - let row = gtk4::ListBoxRow::new(); - row.add_css_class("result-row"); - - let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - - let icon = gtk4::Image::from_gicon(&icon_for(hit.name(), hit.is_dir())); - icon.set_pixel_size(24); - content.append(&icon); - - let text = gtk4::Box::new(gtk4::Orientation::Vertical, 1); - text.set_hexpand(true); - text.set_valign(gtk4::Align::Center); - - let name = gtk4::Label::builder() - .label(hit.name()) - .xalign(0.0) - .ellipsize(gtk4::pango::EllipsizeMode::Middle) - .build(); - name.add_css_class("result-name"); - text.append(&name); - - let location = gtk4::Label::builder() - .label(hit.location()) - .xalign(0.0) - .ellipsize(gtk4::pango::EllipsizeMode::Start) - .build(); - location.add_css_class("result-location"); - text.append(&location); - content.append(&text); - - let meta = gtk4::Box::new(gtk4::Orientation::Horizontal, 10); - meta.set_valign(gtk4::Align::Center); - if hit.pinned() { - let pin = gtk4::Image::from_icon_name("view-pin-symbolic"); - pin.add_css_class("pin-icon"); - meta.append(&pin); - } - if !hit.is_dir() && hit.size() > 0 { - let size = gtk4::Label::new(Some(&human_bytes(hit.size()))); - size.add_css_class("result-meta"); - meta.append(&size); - } - if hit.modified() > 0 { - let modified = gtk4::Label::new(Some(&format_age(hit.modified()))); - modified.add_css_class("result-meta"); - meta.append(&modified); - } - content.append(&meta); - - row.set_child(Some(&content)); - row -} - -/// The desktop's own icon for a file name, so results look like the user's file -/// manager instead of a bespoke palette. Falls back to a generic document icon -/// for names the content-type database cannot place. -fn icon_for(name: &str, is_dir: bool) -> gio::Icon { - if is_dir { - return gio::ThemedIcon::new("folder").upcast(); - } - let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), None); - gio::functions::content_type_get_icon(&content_type) -} - /// Run one blocking control-socket round-trip on a worker thread. The GTK main /// loop never blocks on the daemon: the reply arrives through the channel. fn spawn_request( @@ -1462,165 +487,34 @@ fn format_age(epoch_secs: i64) -> String { d => gettext_f("{count}y", &[("count", &(d / 31_536_000).to_string())]), } } - -/// Styling. Everything is expressed against libadwaita's named colors, so the -/// launcher follows the system light/dark theme instead of pinning its own. -fn load_css() { - let css = " - /* Pin the accent to Proton Purple so the HUD matches the main app, - which its own process/theme would otherwise leave at the system - default (often blue or orange). */ - @define-color accent_bg_color #6d4aff; - @define-color accent_color #6d4aff; - @define-color accent_fg_color #ffffff; - - window.launcher-window { - background: transparent; - } - - .launcher-card { - background-color: @view_bg_color; - border: 1px solid alpha(@borders, 0.8); - border-radius: 14px; - box-shadow: 0 16px 48px alpha(black, 0.35); - } - - .search-bar { - padding: 14px 18px; - border-bottom: 1px solid alpha(@borders, 0.6); - } - - .search-icon { - -gtk-icon-size: 20px; - color: alpha(currentColor, 0.55); - } - - .search-entry { - background: none; - border: none; - box-shadow: none; - outline: none; - padding: 2px 0; - font-size: 1.15rem; - } - - .key-hint { - padding: 2px 8px; - border-radius: 6px; - background-color: alpha(currentColor, 0.08); - font-size: 0.75rem; - opacity: 0.55; - } - - .chip-row { - padding: 10px 14px; - border-bottom: 1px solid alpha(@borders, 0.6); - } - - .chip { - padding: 4px 14px; - min-height: 0; - border-radius: 999px; - background: none; - border: 1px solid alpha(@borders, 0.9); - box-shadow: none; - font-size: 0.86rem; - font-weight: 500; - } - - .chip:hover { - background-color: alpha(currentColor, 0.06); - } - - .chip:checked { - background-color: @accent_bg_color; - color: @accent_fg_color; - border-color: @accent_bg_color; - } - - .results { - padding: 6px 8px 10px 8px; - } - - .section-header { - padding: 12px 10px 6px 10px; - } - - .section-title { - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - opacity: 0.55; - } - - .section-icon { - -gtk-icon-size: 14px; - opacity: 0.55; - } - - .result-list { - background: none; - } - - .result-row { - padding: 9px 10px; - border-radius: 10px; - } - - .result-row:hover { - background-color: alpha(currentColor, 0.05); - } - - .result-row:selected { - background-color: @accent_bg_color; - color: @accent_fg_color; - } - - .result-name { - font-weight: 500; - } - - .result-location, .result-meta { - font-size: 0.8rem; - opacity: 0.55; - } - - .result-row:selected .result-location, - .result-row:selected .result-meta { - opacity: 0.75; - } - - .footer { - padding: 8px 16px; - border-top: 1px solid alpha(@borders, 0.6); - } - - .footer-text { - font-size: 0.78rem; - opacity: 0.5; - } - - statuspage.compact > scrolledwindow > viewport > box { - margin: 24px 12px; - } - "; - - let provider = gtk4::CssProvider::new(); - provider.load_from_string(css); - if let Some(display) = gtk4::gdk::Display::default() { - gtk4::style_context_add_provider_for_display( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - } -} - #[cfg(test)] mod tests { use super::*; + fn local(name: &str, score: i64) -> Hit { + Hit::Local(LocalHit { + name: name.into(), + path: format!("/home/me/{name}"), + is_dir: false, + size: 1, + modified: 1, + score, + }) + } + + #[test] + fn visit_bonus_breaks_ties_but_cannot_beat_a_much_better_match() { + let mut hits = vec![ + local("a-report.pdf", 1000), + local("b-report.pdf", 1000), + local("exact.pdf", 20_000), + ]; + let visited = hits[1].key(); + rank_hits_by(&mut hits, |hit| if hit.key() == visited { 6000 } else { 0 }); + let names: Vec<&str> = hits.iter().map(Hit::name).collect(); + assert_eq!(names, ["exact.pdf", "b-report.pdf", "a-report.pdf"]); + } + #[test] fn ranked_hits_merge_sources_on_one_score_scale() { let mut hits = vec![ diff --git a/crates/pdfs-gui/src/theme.rs b/crates/pdfs-gui/src/theme.rs new file mode 100644 index 0000000..0cdfcfe --- /dev/null +++ b/crates/pdfs-gui/src/theme.rs @@ -0,0 +1,158 @@ +//! The bundled resources and the optional Proton theme, shared by `pdfs-app` +//! and `pdfs-prompt` so both paint the same way from the same preference. + +use std::cell::RefCell; + +use adw::prelude::*; +use gtk4::{gio, glib}; + +/// Register the bundled GResources (custom icons, the stylesheets) and load +/// `stylesheet`, the front end's own sheet. The Proton theme is a separate +/// provider, applied by [`set_proton_theme`] from the saved preference. +pub(crate) fn load_resources(stylesheet: &str) { + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource")); + let resource_data = glib::Bytes::from_static(bytes); + if let Ok(resource) = gio::Resource::from_data(&resource_data) { + gio::resources_register(&resource); + } else { + tracing::error!("failed to load gresource bundle"); + } + + let Some(display) = gtk4::gdk::Display::default() else { + return; + }; + let provider = gtk4::CssProvider::new(); + provider.load_from_resource(stylesheet); + // Above the user's own gtk.css, which often imports a whole theme: its + // `button { padding: 6px 10px; min-width: 16px }` would otherwise pad every + // photo tile and push the grid past the window. Every rule in the sheet is + // scoped to the app's own classes, so nothing else the user styled changes. + // One above the Proton theme too, so the app's rules refine it. + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER + 2, + ); + gtk4::IconTheme::for_display(&display).add_resource_path("/de/nils/protondrivelinux/icons"); +} + +thread_local! { + /// The Proton theme provider while it is installed. Kept so turning the + /// preference off can remove exactly what turning it on added. + static PROTON_THEME: RefCell> = const { RefCell::new(None) }; +} + +/// Whether the Proton theme is on. +pub(crate) fn proton_theme_active() -> bool { + PROTON_THEME.with(|slot| slot.borrow().is_some()) +} + +/// libadwaita's own stylesheet, bundled in the library. +const ADWAITA_STYLESHEET: &str = "/org/gnome/Adwaita/styles/gtk.css"; + +/// The Proton palette laid over it, from our own bundle. +const PROTON_PALETTE: &str = "/de/nils/protondrivelinux/proton-theme.css"; + +/// Make the theme provider's `@media (prefers-color-scheme)` and +/// `(prefers-contrast)` rules match what libadwaita shows, now and whenever it +/// changes. A provider evaluates those itself, and a new one assumes light. +/// +/// The properties are set by name because they only exist since GTK 4.20, and +/// the app still runs on older GTK, whose libadwaita has no such rules anyway. +fn follow_style_manager(provider: >k4::CssProvider) { + fn set_enum(provider: >k4::CssProvider, property: &str, nick: &str) { + let Some(pspec) = provider.find_property(property) else { + return; + }; + if let Some(value) = glib::EnumClass::with_type(pspec.value_type()) + .and_then(|class| class.to_value_by_nick(nick)) + { + provider.set_property_from_value(property, &value); + } + } + fn apply(provider: >k4::CssProvider, manager: &adw::StyleManager) { + let scheme = if manager.is_dark() { "dark" } else { "light" }; + set_enum(provider, "prefers-color-scheme", scheme); + let contrast = if manager.is_high_contrast() { + "more" + } else { + "no-preference" + }; + set_enum(provider, "prefers-contrast", contrast); + } + + let manager = adw::StyleManager::default(); + apply(provider, &manager); + // Weak, so a provider the preference removed isn't kept alive; its + // handlers then do nothing. + let weak = provider.downgrade(); + let weak_dark = weak.clone(); + manager.connect_dark_notify(move |manager| { + if let Some(provider) = weak_dark.upgrade() { + apply(&provider, manager); + } + }); + manager.connect_high_contrast_notify(move |manager| { + if let Some(provider) = weak.upgrade() { + apply(&provider, manager); + } + }); +} + +/// Paint the window in the Proton web apps' colours (`on`) or follow the +/// system theme and accent colour. +/// +/// Setting libadwaita's colour variables alone isn't enough: a GTK theme the +/// user's `gtk.css` imports (Catppuccin, Fluent, ...) sits above the app and +/// hard-codes its own accent into hundreds of rules. So the theme provider, +/// one above the user's CSS, first resets every property, then re-applies +/// libadwaita's whole stylesheet and finally the Proton palette, which leaves +/// nothing of the user's theme inside the app. +pub(crate) fn set_proton_theme(on: bool) { + let Some(display) = gtk4::gdk::Display::default() else { + return; + }; + PROTON_THEME.with(|slot| { + let mut slot = slot.borrow_mut(); + match (on, slot.as_ref()) { + (true, None) => { + let stylesheet = |path| { + gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE) + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + }; + let palette = stylesheet(PROTON_PALETTE).unwrap_or_default(); + let css = match stylesheet(ADWAITA_STYLESHEET) { + // Its images are named relative to the sheet, which a + // string loaded here doesn't have. + Ok(adwaita) => format!( + "* {{ all: unset; }}\n{}\n{palette}", + adwaita.replace( + "url(\"assets/", + "url(\"resource:///org/gnome/Adwaita/styles/assets/" + ) + ), + // Still recolours everything that follows libadwaita's + // variables; only a custom theme's own rules show through. + Err(e) => { + tracing::warn!("libadwaita stylesheet not found, only recolouring: {e}"); + palette + } + }; + let provider = gtk4::CssProvider::new(); + follow_style_manager(&provider); + provider.load_from_string(&css); + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_USER + 1, + ); + *slot = Some(provider); + } + (false, Some(provider)) => { + gtk4::style_context_remove_provider_for_display(&display, provider); + *slot = None; + } + _ => {} + } + }); +} diff --git a/po/POTFILES.in b/po/POTFILES.in index cba07d1..879f4b9 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -28,6 +28,8 @@ crates/pdfs-gui/src/app/widgets/thumbnails.rs crates/pdfs-gui/src/app/widgets/versions_dialog.rs crates/pdfs-gui/src/dmenu.rs crates/pdfs-gui/src/fzf.rs +crates/pdfs-gui/src/hud/mod.rs +crates/pdfs-gui/src/hud/preview.rs crates/pdfs-gui/src/i18n.rs crates/pdfs-gui/src/main.rs crates/pdfs-gui/src/prompt.rs diff --git a/po/de.po b/po/de.po index 5e3405d..b8c0dfc 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-09-28 16:17+0200\n" +"POT-Creation-Date: 2026-09-28 22:51+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,7 +57,7 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:882 +#: crates/pdfs-gui/src/app/main.rs:734 #: crates/pdfs-gui/src/app/pages/activity_text.rs:466 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 @@ -68,91 +68,91 @@ msgstr "%A, %-d. %B %Y, %H:%M" msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:885 crates/pdfs-gui/src/app/main.rs:1529 +#: crates/pdfs-gui/src/app/main.rs:737 crates/pdfs-gui/src/app/main.rs:1381 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:890 +#: crates/pdfs-gui/src/app/main.rs:742 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:895 +#: crates/pdfs-gui/src/app/main.rs:747 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:898 +#: crates/pdfs-gui/src/app/main.rs:750 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:899 +#: crates/pdfs-gui/src/app/main.rs:751 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:901 +#: crates/pdfs-gui/src/app/main.rs:753 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1218 +#: crates/pdfs-gui/src/app/main.rs:1070 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1089 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1238 +#: crates/pdfs-gui/src/app/main.rs:1090 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1239 +#: crates/pdfs-gui/src/app/main.rs:1091 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1240 +#: crates/pdfs-gui/src/app/main.rs:1092 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1093 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1282 crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1134 crates/pdfs-gui/src/app/main.rs:1363 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "Einstellungen" -#: crates/pdfs-gui/src/app/main.rs:1283 +#: crates/pdfs-gui/src/app/main.rs:1135 #: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "Tastenkürzel" -#: crates/pdfs-gui/src/app/main.rs:1285 +#: crates/pdfs-gui/src/app/main.rs:1137 msgid "About Proton Drive for Linux" msgstr "Info zu Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1290 +#: crates/pdfs-gui/src/app/main.rs:1142 msgid "Main menu" msgstr "Hauptmenü" -#: crates/pdfs-gui/src/app/main.rs:1346 +#: crates/pdfs-gui/src/app/main.rs:1198 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1354 +#: crates/pdfs-gui/src/app/main.rs:1206 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -164,23 +164,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1359 +#: crates/pdfs-gui/src/app/main.rs:1211 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1367 +#: crates/pdfs-gui/src/app/main.rs:1219 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1376 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -205,7 +205,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1384 +#: crates/pdfs-gui/src/app/main.rs:1236 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +225,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -222,7 +222,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -223,7 +223,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +224,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -222,7 +222,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -223,7 +223,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,7 +60,7 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:882 +#: crates/pdfs-gui/src/app/main.rs:734 #: crates/pdfs-gui/src/app/pages/activity_text.rs:466 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 @@ -71,91 +71,91 @@ msgstr "" msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:885 crates/pdfs-gui/src/app/main.rs:1529 +#: crates/pdfs-gui/src/app/main.rs:737 crates/pdfs-gui/src/app/main.rs:1381 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:890 +#: crates/pdfs-gui/src/app/main.rs:742 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:895 +#: crates/pdfs-gui/src/app/main.rs:747 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:898 +#: crates/pdfs-gui/src/app/main.rs:750 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:899 +#: crates/pdfs-gui/src/app/main.rs:751 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:901 +#: crates/pdfs-gui/src/app/main.rs:753 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1218 +#: crates/pdfs-gui/src/app/main.rs:1070 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1089 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1238 +#: crates/pdfs-gui/src/app/main.rs:1090 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1239 +#: crates/pdfs-gui/src/app/main.rs:1091 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1240 +#: crates/pdfs-gui/src/app/main.rs:1092 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1241 +#: crates/pdfs-gui/src/app/main.rs:1093 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1282 crates/pdfs-gui/src/app/main.rs:1511 +#: crates/pdfs-gui/src/app/main.rs:1134 crates/pdfs-gui/src/app/main.rs:1363 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1283 +#: crates/pdfs-gui/src/app/main.rs:1135 #: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1285 +#: crates/pdfs-gui/src/app/main.rs:1137 msgid "About Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1290 +#: crates/pdfs-gui/src/app/main.rs:1142 msgid "Main menu" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1346 +#: crates/pdfs-gui/src/app/main.rs:1198 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1354 +#: crates/pdfs-gui/src/app/main.rs:1206 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -163,23 +163,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1359 +#: crates/pdfs-gui/src/app/main.rs:1211 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1363 +#: crates/pdfs-gui/src/app/main.rs:1215 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1367 +#: crates/pdfs-gui/src/app/main.rs:1219 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1376 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -192,21 +192,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1384 +#: crates/pdfs-gui/src/app/main.rs:1236 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +226,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1392 +#: crates/pdfs-gui/src/app/main.rs:1244 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -222,7 +222,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1400 +#: crates/pdfs-gui/src/app/main.rs:1252 msgid "" "The street map, when turned on, comes from OpenFreeMap in the