mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
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:
1 parent
7099106802
commit
7a087b8827
22 files changed
+5310
-2929
No files matched your search
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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: >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) {
|
||||
|
||||
@@ -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<<b>b</b>>&c");
|
||||
assert_eq!(markup("R&D.pdf", "r&d"), "<b>R&D</b>.pdf");
|
||||
assert_eq!(markup("<none>", "zzz"), "<none>");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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<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(>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"),
|
||||
"<Control>Return",
|
||||
);
|
||||
let (copy, _) = action("edit-copy-symbolic", &gettext("Copy Path"), "<Control>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<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"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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<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: >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);
|
||||
}
|
||||
@@ -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: >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<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: >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::<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())
|
||||
}
|
||||
+77
-1183
File diff suppressed because it is too large.
Load diff
@@ -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: >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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user