feat(gui): rewrite the search launcher with a preview pane

The launcher (pdfs-prompt) now follows the app's theme: the theme code
moved into a shared theme.rs, the prompt's stylesheet lives in
resources/prompt.css on the libadwaita palette, the Proton-purple accent
pin is gone, and every activation re-reads the Proton theme and open_with
settings so changes in the app apply without restarting the launcher.

The window is rebuilt in src/hud/:
- one list with Recent, Pinned and Best matches sections; a click
  selects, a double-click or Enter opens, focus stays in the entry
- rows show a thumbnail, the name with its matched characters in bold,
  location, sync badge, size and age
- a preview pane with a large thumbnail, type, size, modified date,
  location and availability, plus Open (Enter), Show in Folder
  (Ctrl+Enter, via FileManager1) and Copy Path (Ctrl+C / Ctrl+Shift+C)
- thumbnails from the freedesktop cache, local decoding (written back
  to the shared cache) or the daemon, on two worker threads
- a history of opened files that fills the Recent section and adds a
  frecency bonus to search ranking
- the mount check before opening a Drive file runs off the main thread
  with a 2 s timeout, and Escape cancels a slow open
- rows can be dragged out as files
- the search debounce drops to 80 ms

New strings are translated in every catalog.
This commit is contained in:
nvrl committed 2026-09-28 22:53:46 +02:00
1 parent 7099106802
commit 7a087b8827
22 files changed
+5310 -2929

No files matched your search

+1
View File
@@ -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");
}
@@ -33,5 +33,7 @@
<gresource prefix="/de/nils/protondrivelinux">
<file>style.css</file>
<file>proton-theme.css</file>
<!-- The search launcher's own sheet, over the same palette. -->
<file>prompt.css</file>
</gresource>
</gresources>
+224
View File
@@ -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;
}
+4 -152
View File
@@ -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<Option<gtk4::CssProvider>> = 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: &gtk4::CssProvider) {
fn set_enum(provider: &gtk4::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: &gtk4::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) {
+143
View File
@@ -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<Folded> {
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<String> = 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<usize> {
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<Vec<usize>> {
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("<b>");
out.push_str(&glib::markup_escape_text(&name[start..end]));
out.push_str("</b>");
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 <b>Größe</b>.odt");
assert_eq!(
markup("日本語のメモ.txt", "メモ"),
"日本語の<b>メモ</b>.txt"
);
}
#[test]
fn markup_escapes_the_name_inside_and_outside_matches() {
assert_eq!(markup("a<b>&c", "b"), "a&lt;<b>b</b>&gt;&amp;c");
assert_eq!(markup("R&D.pdf", "r&d"), "<b>R&amp;D</b>.pdf");
assert_eq!(markup("<none>", "zzz"), "&lt;none&gt;");
}
}
+363
View File
@@ -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<Visit>, 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<Visit>) -> 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<Visit> {
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<Command>, publish: impl Fn(Vec<Visit>)) {
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<RefCell<Vec<Visit>>>,
commands: async_channel::Sender<Command>,
}
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::<Vec<Visit>>();
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<Hit> {
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();
}
}
File diff suppressed because it is too large. Load diff
+311
View File
@@ -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: &gtk4::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<RefCell<Option<glib::SourceId>>>,
}
/// 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(&gtk4::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"),
"<Control>Return",
);
let (copy, _) = action("edit-copy-symbolic", &gettext("Copy Path"), "<Control>c");
actions.append(&open);
actions.append(&reveal);
actions.append(&copy);
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<Thumbs>, 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"),
)),
}
}
+187
View File
@@ -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: &gtk4::Overlay, hit: &Hit, thumbs: &Rc<Thumbs>, mountpoint: &Path) {
let Some(fallback) = slot.child().and_downcast::<gtk4::Image>() else {
return;
};
let Some(picture) = fallback.next_sibling().and_downcast::<gtk4::Picture>() 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<Thumbs>,
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: &gtk4::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(&gtk4::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);
}
+697
View File
@@ -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<AtomicU64>,
jobs: async_channel::Sender<Job>,
textures: RefCell<HashMap<String, gdk::Texture>>,
/// Least recently used first.
order: RefCell<VecDeque<String>>,
missing: RefCell<HashSet<String>>,
missing_order: RefCell<VecDeque<String>>,
waiters: RefCell<HashMap<String, Vec<glib::WeakRef<gtk4::Picture>>>>,
/// Keys with a worker job or a daemon request outstanding.
working: RefCell<HashSet<String>>,
/// Drive misses waiting for the next batch, by key.
queued: RefCell<HashMap<String, FileThumbRequest>>,
/// Drive items the daemon is working on under `generation`, by key.
sent: RefCell<HashMap<String, FileThumbRequest>>,
generation: Cell<u64>,
reserving: Cell<bool>,
inflight: Cell<bool>,
flush: RefCell<Option<glib::SourceId>>,
}
impl Thumbs {
pub(crate) fn new(socket: PathBuf) -> Rc<Self> {
let epoch = Arc::new(AtomicU64::new(0));
let (jobs, inbox) = async_channel::unbounded::<Job>();
let (done, results) = async_channel::unbounded::<Done>();
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<Self>, hit: &Hit, mountpoint: &Path, picture: &gtk4::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<Self>) {
let wanted: HashSet<String> = 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<Self>, 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<Self>, 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<Self>) {
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<String> = 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<Self>,
generation: u64,
batch: Vec<(String, FileThumbRequest)>,
reply: Result<Result<Response, String>, 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<String, (String, FileThumbRequest)> = 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<Self>, 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<Self>) {
if self.reserving.replace(true) {
return;
}
let rx = spawn_request(self.socket.clone(), Request::ReserveFileThumbGeneration);
let weak: Weak<Self> = 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: &gtk4::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::<gtk4::Overlay>()
&& let Some(fallback) = overlay.child()
{
fallback.set_visible(false);
}
}
fn work(
index: usize,
inbox: &async_channel::Receiver<Job>,
done: &async_channel::Sender<Done>,
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<Pixbuf> {
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::<i64>().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())
}
File diff suppressed because it is too large. Load diff
+158
View File
@@ -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<Option<gtk4::CssProvider>> = 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: &gtk4::CssProvider) {
fn set_enum(provider: &gtk4::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: &gtk4::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;
}
_ => {}
}
});
}