mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
1470 lines
49 KiB
Rust
1470 lines
49 KiB
Rust
//! `pdfs-prompt` — the launcher: one search field over both Proton Drive and the
|
|
//! files on this machine, styled after Google Drive's search overlay.
|
|
//!
|
|
//! Bind it to a system shortcut (e.g. in Hyprland) for a quick HUD search. The
|
|
//! application is single-instance and keeps its window alive between summons,
|
|
//! so repeat activations only reset and present the existing widget tree. Every
|
|
//! daemon round-trip runs on a worker thread and lands back through an async
|
|
//! channel. Drive and local lookup share one request so a result set paints once.
|
|
|
|
use std::cell::{Cell, RefCell};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::rc::Rc;
|
|
use std::time::Duration;
|
|
|
|
use adw::prelude::*;
|
|
use gtk4::{gio, glib};
|
|
|
|
use pdfs_core::config::AppDirs;
|
|
use pdfs_core::control::{
|
|
LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
|
|
};
|
|
|
|
mod activation;
|
|
use activation::{DriveActivation, drive_activation, mounted_path};
|
|
|
|
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
|
|
|
/// Debounce before a keystroke turns into a daemon round-trip. Short enough to
|
|
/// feel live, long enough that typing a word is one search, not five.
|
|
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(120);
|
|
|
|
/// Per-section result cap. The list is a launcher, not a file manager: more rows
|
|
/// than fit on screen only cost render time.
|
|
const SEARCH_LIMIT: usize = 20;
|
|
|
|
/// Which file kinds a chip narrows the results to. Applied client-side to hits
|
|
/// the daemon already returned, so switching chips never re-queries.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Filter {
|
|
All,
|
|
Folders,
|
|
Documents,
|
|
Images,
|
|
Media,
|
|
}
|
|
|
|
/// The chip row, in Tab-cycle order.
|
|
const FILTERS: [(Filter, &str); 5] = [
|
|
(Filter::All, "All"),
|
|
(Filter::Folders, "Folders"),
|
|
(Filter::Documents, "Documents"),
|
|
(Filter::Images, "Images"),
|
|
(Filter::Media, "Media"),
|
|
];
|
|
|
|
impl Filter {
|
|
/// Whether a hit of this name/kind survives the chip.
|
|
fn accepts(self, name: &str, is_dir: bool) -> bool {
|
|
match self {
|
|
Filter::All => true,
|
|
Filter::Folders => is_dir,
|
|
Filter::Documents => !is_dir && is_document(name),
|
|
Filter::Images => !is_dir && is_image(name),
|
|
Filter::Media => !is_dir && is_media(name),
|
|
}
|
|
}
|
|
|
|
fn search_kind(self) -> SearchKind {
|
|
match self {
|
|
Self::All => SearchKind::All,
|
|
Self::Folders => SearchKind::Folders,
|
|
Self::Documents => SearchKind::Documents,
|
|
Self::Images => SearchKind::Images,
|
|
Self::Media => SearchKind::Media,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn extension(name: &str) -> String {
|
|
Path::new(name)
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("")
|
|
.to_lowercase()
|
|
}
|
|
|
|
fn is_document(name: &str) -> bool {
|
|
matches!(
|
|
extension(name).as_str(),
|
|
"pdf"
|
|
| "doc"
|
|
| "docx"
|
|
| "odt"
|
|
| "rtf"
|
|
| "txt"
|
|
| "md"
|
|
| "xls"
|
|
| "xlsx"
|
|
| "ods"
|
|
| "csv"
|
|
| "ppt"
|
|
| "pptx"
|
|
| "odp"
|
|
| "epub"
|
|
)
|
|
}
|
|
|
|
fn is_image(name: &str) -> bool {
|
|
matches!(
|
|
extension(name).as_str(),
|
|
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff"
|
|
)
|
|
}
|
|
|
|
fn is_media(name: &str) -> bool {
|
|
matches!(
|
|
extension(name).as_str(),
|
|
"mp4" | "mkv" | "webm" | "mov" | "avi" | "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a"
|
|
)
|
|
}
|
|
|
|
/// One row in the unified result list. The two sections hold different payloads
|
|
/// — a Drive hit must be hydrated through the daemon before it can be opened, a
|
|
/// local file is already on disk — but they share one keyboard cursor.
|
|
#[derive(Clone)]
|
|
enum Hit {
|
|
Drive(SearchHit),
|
|
Local(LocalHit),
|
|
}
|
|
|
|
impl Hit {
|
|
fn name(&self) -> &str {
|
|
match self {
|
|
Hit::Drive(h) => &h.name,
|
|
Hit::Local(h) => &h.name,
|
|
}
|
|
}
|
|
|
|
fn is_dir(&self) -> bool {
|
|
match self {
|
|
Hit::Drive(h) => h.is_dir,
|
|
Hit::Local(h) => h.is_dir,
|
|
}
|
|
}
|
|
|
|
/// The dimmed second line: the containing folder, as the user thinks of it.
|
|
fn location(&self) -> String {
|
|
match self {
|
|
Hit::Drive(h) => {
|
|
let parent = parent_of(&h.path);
|
|
if parent.is_empty() {
|
|
"My files".to_string()
|
|
} else {
|
|
format!("My files / {}", parent.replace('/', " / "))
|
|
}
|
|
}
|
|
Hit::Local(h) => {
|
|
let parent = parent_of(&h.path);
|
|
match dirs_home().and_then(|home| {
|
|
Path::new(&parent)
|
|
.strip_prefix(&home)
|
|
.ok()
|
|
.map(|rel| rel.display().to_string())
|
|
}) {
|
|
Some(rel) if rel.is_empty() => "Home".to_string(),
|
|
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
|
None => parent,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn size(&self) -> u64 {
|
|
match self {
|
|
Hit::Drive(h) => h.size,
|
|
Hit::Local(h) => h.size,
|
|
}
|
|
}
|
|
|
|
fn modified(&self) -> i64 {
|
|
match self {
|
|
Hit::Drive(h) => h.modified,
|
|
Hit::Local(h) => h.modified,
|
|
}
|
|
}
|
|
|
|
fn pinned(&self) -> bool {
|
|
matches!(self, Hit::Drive(h) if h.pinned)
|
|
}
|
|
|
|
fn score(&self) -> i64 {
|
|
match self {
|
|
Self::Drive(hit) => hit.score,
|
|
Self::Local(hit) => hit.score,
|
|
}
|
|
}
|
|
|
|
/// Stable identity used to preserve the keyboard cursor across re-renders.
|
|
/// The bool keeps a Drive and a local hit at the same path distinct.
|
|
fn key(&self) -> (bool, String) {
|
|
match self {
|
|
Hit::Drive(h) => (false, h.path.clone()),
|
|
Hit::Local(h) => (true, h.path.clone()),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rank_hits(hits: &mut [Hit]) {
|
|
hits.sort_by(|a, b| {
|
|
b.score()
|
|
.cmp(&a.score())
|
|
.then_with(|| a.name().cmp(b.name()))
|
|
.then_with(|| a.key().cmp(&b.key()))
|
|
});
|
|
}
|
|
|
|
/// Everything the window needs to render and act, in one place so the many
|
|
/// callbacks below capture a single `Rc` instead of a dozen clones each.
|
|
struct Ui {
|
|
socket: PathBuf,
|
|
mountpoint: RefCell<PathBuf>,
|
|
|
|
entry: gtk4::Entry,
|
|
spinner: gtk4::Spinner,
|
|
stack: gtk4::Stack,
|
|
scroller: gtk4::ScrolledWindow,
|
|
results: gtk4::Box,
|
|
search_section: Section,
|
|
drive_section: Section,
|
|
local_section: Section,
|
|
placeholder: adw::StatusPage,
|
|
hint: gtk4::Label,
|
|
chips: RefCell<Vec<(Filter, gtk4::ToggleButton)>>,
|
|
|
|
/// Raw (unfiltered) hits from the last reply of each search.
|
|
drive_hits: RefCell<Vec<SearchHit>>,
|
|
local_hits: RefCell<Vec<LocalHit>>,
|
|
/// The filtered, flattened list the cursor indexes into — Drive rows first,
|
|
/// then local ones, matching the visual order.
|
|
visible: RefCell<Vec<Hit>>,
|
|
cursor: Cell<Option<usize>>,
|
|
/// The trimmed query the currently-rendered rows belong to. Enter compares
|
|
/// against it so a keystroke that lands just before a fresher render can't
|
|
/// open a file from a result set the user has already typed past.
|
|
rendered_query: RefCell<String>,
|
|
|
|
filter: Cell<Filter>,
|
|
/// Monotonic query id. A reply whose id is stale (the user typed again while
|
|
/// it was in flight) is dropped instead of overwriting fresher results.
|
|
query_id: Cell<u64>,
|
|
/// In-flight requests for the current query id.
|
|
pending: Cell<u8>,
|
|
/// True once a Drive open is running: the window is about to close, so
|
|
/// further input is ignored.
|
|
opening: Cell<bool>,
|
|
indexing: Cell<bool>,
|
|
/// Set when Enter arrives before the in-flight query has rendered (typing
|
|
/// then hitting Enter faster than the search debounce). The open is honoured
|
|
/// against the selected row once the fresh results settle, rather than being
|
|
/// dropped — so a plain "type, Enter" always opens something.
|
|
open_pending: Cell<bool>,
|
|
/// Prevent lifecycle-driven entry resets from also scheduling the normal
|
|
/// debounced empty-query request; activation performs one explicit daemon
|
|
/// bootstrap instead.
|
|
suppress_entry_change: Cell<bool>,
|
|
/// The window, so a deferred open (above) can reach it from `render`.
|
|
window: RefCell<Option<adw::ApplicationWindow>>,
|
|
}
|
|
|
|
/// A titled group of rows: header (hidden when empty) plus its list.
|
|
struct Section {
|
|
header: gtk4::Box,
|
|
title: gtk4::Label,
|
|
list: gtk4::ListBox,
|
|
}
|
|
|
|
impl Section {
|
|
fn new(title: &str, icon: &str) -> Self {
|
|
let header = gtk4::Box::builder()
|
|
.orientation(gtk4::Orientation::Horizontal)
|
|
.spacing(8)
|
|
.visible(false)
|
|
.build();
|
|
header.add_css_class("section-header");
|
|
|
|
let image = gtk4::Image::from_icon_name(icon);
|
|
image.add_css_class("section-icon");
|
|
header.append(&image);
|
|
|
|
let label = gtk4::Label::builder().label(title).xalign(0.0).build();
|
|
label.add_css_class("section-title");
|
|
header.append(&label);
|
|
|
|
let list = gtk4::ListBox::builder()
|
|
.selection_mode(gtk4::SelectionMode::Single)
|
|
.visible(false)
|
|
.build();
|
|
list.add_css_class("result-list");
|
|
|
|
Self {
|
|
header,
|
|
title: label,
|
|
list,
|
|
}
|
|
}
|
|
|
|
fn attach(&self, parent: >k4::Box) {
|
|
parent.append(&self.header);
|
|
parent.append(&self.list);
|
|
}
|
|
|
|
/// Swap in a new set of rows. Rebuilds the list — at [`SEARCH_LIMIT`] rows a
|
|
/// rebuild is a handful of widgets, far cheaper than diffing them.
|
|
fn set_rows(&self, hits: &[Hit], subtitle: Option<&str>) {
|
|
while let Some(child) = self.list.first_child() {
|
|
self.list.remove(&child);
|
|
}
|
|
for hit in hits {
|
|
self.list.append(&build_row(hit));
|
|
}
|
|
let shown = !hits.is_empty();
|
|
self.header.set_visible(shown);
|
|
self.list.set_visible(shown);
|
|
if let Some(text) = subtitle {
|
|
self.title.set_label(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn main() -> glib::ExitCode {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
|
)
|
|
.init();
|
|
|
|
let app = adw::Application::builder().application_id(APP_ID).build();
|
|
// GtkApplication normally remains active while it owns a window, including
|
|
// when that window is hidden. Hold it explicitly as well: residency is a
|
|
// product requirement here, not an incidental window-lifetime side effect.
|
|
let _resident = app.hold();
|
|
app.connect_startup(|_| load_css());
|
|
let prompt: Rc<RefCell<Option<Rc<Ui>>>> = Rc::new(RefCell::new(None));
|
|
app.connect_activate(move |app| {
|
|
let ui = if let Some(ui) = prompt.borrow().clone() {
|
|
ui
|
|
} else {
|
|
let Some(ui) = build_window(app) else {
|
|
return;
|
|
};
|
|
*prompt.borrow_mut() = Some(ui.clone());
|
|
ui
|
|
};
|
|
ui.activate();
|
|
});
|
|
app.run()
|
|
}
|
|
|
|
fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
|
|
let dirs = match AppDirs::new() {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::error!("cannot resolve app dirs: {e}");
|
|
return None;
|
|
}
|
|
};
|
|
|
|
let window = adw::ApplicationWindow::builder()
|
|
.application(app)
|
|
.title("Search Proton Drive")
|
|
.default_width(720)
|
|
.default_height(540)
|
|
.resizable(false)
|
|
.decorated(false)
|
|
.build();
|
|
window.add_css_class("launcher-window");
|
|
|
|
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
card.add_css_class("launcher-card");
|
|
window.set_content(Some(&card));
|
|
|
|
// --- search bar -------------------------------------------------------
|
|
let search_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
|
search_bar.add_css_class("search-bar");
|
|
|
|
let search_icon = gtk4::Image::from_icon_name("system-search-symbolic");
|
|
search_icon.add_css_class("search-icon");
|
|
search_bar.append(&search_icon);
|
|
|
|
let entry = gtk4::Entry::builder()
|
|
.placeholder_text("Search in Drive and on this computer")
|
|
.hexpand(true)
|
|
.build();
|
|
entry.add_css_class("search-entry");
|
|
search_bar.append(&entry);
|
|
|
|
let spinner = gtk4::Spinner::new();
|
|
spinner.set_visible(false);
|
|
search_bar.append(&spinner);
|
|
|
|
let esc = gtk4::Label::new(Some("Esc"));
|
|
esc.add_css_class("key-hint");
|
|
search_bar.append(&esc);
|
|
card.append(&search_bar);
|
|
|
|
// --- filter chips -----------------------------------------------------
|
|
let chip_row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
chip_row.add_css_class("chip-row");
|
|
card.append(&chip_row);
|
|
|
|
// --- results ----------------------------------------------------------
|
|
let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
results.add_css_class("results");
|
|
|
|
let search_section = Section::new("Best matches", "system-search-symbolic");
|
|
let drive_section = Section::new("Proton Drive", "folder-remote-symbolic");
|
|
let local_section = Section::new("This computer", "drive-harddisk-symbolic");
|
|
search_section.attach(&results);
|
|
drive_section.attach(&results);
|
|
local_section.attach(&results);
|
|
|
|
let scroller = gtk4::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
|
.vexpand(true)
|
|
.child(&results)
|
|
.build();
|
|
|
|
let placeholder = adw::StatusPage::builder()
|
|
.icon_name("system-search-symbolic")
|
|
.title("No results")
|
|
.build();
|
|
placeholder.add_css_class("compact");
|
|
|
|
// The daemon-offline page is a peer of the results, not a modal: the prompt
|
|
// is still usable for nothing else, so it owns the whole view.
|
|
let offline = adw::StatusPage::builder()
|
|
.icon_name("network-offline-symbolic")
|
|
.title("Proton Drive is not running")
|
|
.description("Start the mount daemon to search your Drive.")
|
|
.build();
|
|
offline.add_css_class("compact");
|
|
let retry = gtk4::Button::builder()
|
|
.label("Retry")
|
|
.halign(gtk4::Align::Center)
|
|
.build();
|
|
retry.add_css_class("pill");
|
|
retry.add_css_class("suggested-action");
|
|
offline.set_child(Some(&retry));
|
|
|
|
let stack = gtk4::Stack::builder()
|
|
.transition_type(gtk4::StackTransitionType::Crossfade)
|
|
.transition_duration(120)
|
|
.vexpand(true)
|
|
.build();
|
|
stack.add_named(&scroller, Some("results"));
|
|
stack.add_named(&placeholder, Some("empty"));
|
|
stack.add_named(&offline, Some("offline"));
|
|
card.append(&stack);
|
|
|
|
// --- footer -----------------------------------------------------------
|
|
let footer = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
|
footer.add_css_class("footer");
|
|
let hint = gtk4::Label::builder()
|
|
.label("Connecting…")
|
|
.xalign(0.0)
|
|
.hexpand(true)
|
|
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
|
.build();
|
|
hint.add_css_class("footer-text");
|
|
footer.append(&hint);
|
|
let keys = gtk4::Label::new(Some("↑↓ navigate · ↵ open · Tab filter"));
|
|
keys.add_css_class("footer-text");
|
|
footer.append(&keys);
|
|
card.append(&footer);
|
|
|
|
let ui = Rc::new(Ui {
|
|
socket: dirs.control_socket(),
|
|
mountpoint: RefCell::new(dirs.default_mountpoint()),
|
|
entry: entry.clone(),
|
|
spinner,
|
|
stack,
|
|
scroller,
|
|
results,
|
|
search_section,
|
|
drive_section,
|
|
local_section,
|
|
placeholder,
|
|
hint,
|
|
chips: RefCell::new(Vec::new()),
|
|
drive_hits: RefCell::new(Vec::new()),
|
|
local_hits: RefCell::new(Vec::new()),
|
|
visible: RefCell::new(Vec::new()),
|
|
cursor: Cell::new(None),
|
|
rendered_query: RefCell::new(String::new()),
|
|
filter: Cell::new(Filter::All),
|
|
query_id: Cell::new(0),
|
|
pending: Cell::new(0),
|
|
opening: Cell::new(false),
|
|
indexing: Cell::new(false),
|
|
open_pending: Cell::new(false),
|
|
suppress_entry_change: Cell::new(false),
|
|
window: RefCell::new(None),
|
|
});
|
|
*ui.window.borrow_mut() = Some(window.clone());
|
|
|
|
for (filter, label) in FILTERS {
|
|
let chip = gtk4::ToggleButton::builder()
|
|
.label(label)
|
|
.active(filter == Filter::All)
|
|
.build();
|
|
chip.add_css_class("chip");
|
|
let ui_chip = ui.clone();
|
|
chip.connect_clicked(move |btn| {
|
|
// A chip is a radio, not a switch: clicking the active one keeps it on.
|
|
if btn.is_active() {
|
|
ui_chip.set_filter(filter);
|
|
} else {
|
|
btn.set_active(true);
|
|
}
|
|
});
|
|
chip_row.append(&chip);
|
|
ui.chips.borrow_mut().push((filter, chip));
|
|
}
|
|
|
|
// Clicking a row (or tapping it) opens it, same as Enter on the cursor. A
|
|
// local row's index is relative to its own list, so the Drive rows above it
|
|
// are added back to land on the same flattened index the cursor uses.
|
|
for (list, is_local) in [
|
|
(&ui.drive_section.list, false),
|
|
(&ui.local_section.list, true),
|
|
] {
|
|
let ui_row = ui.clone();
|
|
list.connect_row_activated(move |_, row| {
|
|
let offset = if is_local { ui_row.drive_count() } else { 0 };
|
|
ui_row.open(offset + row.index() as usize);
|
|
});
|
|
}
|
|
let ui_row = ui.clone();
|
|
ui.search_section
|
|
.list
|
|
.connect_row_activated(move |_, row| ui_row.open(row.index() as usize));
|
|
|
|
let ui_key = ui.clone();
|
|
let keys = gtk4::EventControllerKey::new();
|
|
keys.connect_key_pressed(move |_, key, _, state| {
|
|
if ui_key.opening.get() {
|
|
return glib::Propagation::Stop;
|
|
}
|
|
match key {
|
|
gtk4::gdk::Key::Escape => {
|
|
// Spotlight-style: first Escape clears a non-empty query, a
|
|
// second (or an already-empty box) dismisses the launcher.
|
|
if ui_key.entry.text().is_empty() {
|
|
ui_key.dismiss();
|
|
} else {
|
|
ui_key.entry.set_text("");
|
|
}
|
|
glib::Propagation::Stop
|
|
}
|
|
gtk4::gdk::Key::Down => {
|
|
ui_key.move_cursor(1);
|
|
glib::Propagation::Stop
|
|
}
|
|
gtk4::gdk::Key::Up => {
|
|
ui_key.move_cursor(-1);
|
|
glib::Propagation::Stop
|
|
}
|
|
gtk4::gdk::Key::Tab | gtk4::gdk::Key::ISO_Left_Tab => {
|
|
let back = state.contains(gtk4::gdk::ModifierType::SHIFT_MASK);
|
|
ui_key.cycle_filter(back);
|
|
glib::Propagation::Stop
|
|
}
|
|
// Return is deliberately absent: `GtkText` binds it to `activate`
|
|
// and consumes it, so a bubble-phase controller on the window never
|
|
// sees it while the entry has focus (which is always, here). It is
|
|
// handled on the entry's `activate` signal instead — see below.
|
|
_ => glib::Propagation::Proceed,
|
|
}
|
|
});
|
|
// On the window, not the entry: arrow keys must steer the list even when the
|
|
// pointer has moved focus into it.
|
|
window.add_controller(keys);
|
|
|
|
// Enter opens the row under the cursor. This lives on the entry rather than
|
|
// with the other keys on the window because `GtkText` claims Return for its
|
|
// own `activate` binding, so a bubble-phase controller upstream of the
|
|
// focused entry is never reached. Enter with focus in the list is covered by
|
|
// `row_activated` above.
|
|
let ui_activate = ui.clone();
|
|
entry.connect_activate(move |_| {
|
|
if ui_activate.opening.get() {
|
|
return;
|
|
}
|
|
// Fall back to the top row when nothing is explicitly selected (e.g.
|
|
// results haven't rendered yet); `open` guards the rest.
|
|
ui_activate.open(ui_activate.cursor.get().unwrap_or(0));
|
|
});
|
|
|
|
// Some GTK/input-method combinations consume the physical Return binding in
|
|
// GtkText without emitting Entry::activate. Capture only Return ahead of the
|
|
// text widget and leave every other key to normal GTK/IME handling. Stopping
|
|
// propagation also prevents the activate signal above from opening twice;
|
|
// that signal remains the programmatic/accessibility activation path.
|
|
let ui_return = ui.clone();
|
|
let return_key = gtk4::EventControllerKey::new();
|
|
return_key.set_propagation_phase(gtk4::PropagationPhase::Capture);
|
|
return_key.connect_key_pressed(move |_, key, _, _| {
|
|
if matches!(key, gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter) {
|
|
if !ui_return.opening.get() {
|
|
ui_return.open(ui_return.cursor.get().unwrap_or(0));
|
|
}
|
|
glib::Propagation::Stop
|
|
} else {
|
|
glib::Propagation::Proceed
|
|
}
|
|
});
|
|
entry.add_controller(return_key);
|
|
|
|
let debounce: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
|
|
let ui_changed = ui.clone();
|
|
entry.connect_changed(move |entry| {
|
|
if ui_changed.suppress_entry_change.get() {
|
|
return;
|
|
}
|
|
// A fresh keystroke supersedes any Enter that was waiting on results:
|
|
// the user is still refining the query, not asking to open yet.
|
|
ui_changed.open_pending.set(false);
|
|
if let Some(source) = debounce.borrow_mut().take() {
|
|
source.remove();
|
|
}
|
|
let query = entry.text().trim().to_string();
|
|
let ui_timeout = ui_changed.clone();
|
|
let debounce_timeout = debounce.clone();
|
|
let source = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || {
|
|
debounce_timeout.borrow_mut().take();
|
|
ui_timeout.search(&query);
|
|
});
|
|
*debounce.borrow_mut() = Some(source);
|
|
});
|
|
|
|
let ui_retry = ui.clone();
|
|
retry.connect_clicked(move |_| ui_retry.connect());
|
|
|
|
// Keep the widget tree alive when the compositor asks the window to close.
|
|
// This also avoids running GTK's destruction path while async replies still
|
|
// hold references to prompt widgets.
|
|
let ui_close = ui.clone();
|
|
window.connect_close_request(move |_| {
|
|
ui_close.dismiss();
|
|
glib::Propagation::Stop
|
|
});
|
|
let entry_map = entry.clone();
|
|
window.connect_map(move |_| {
|
|
entry_map.grab_focus();
|
|
});
|
|
|
|
Some(ui)
|
|
}
|
|
|
|
impl Ui {
|
|
/// Reset and present the resident prompt in response to GApplication
|
|
/// activation. A second invocation while a file is materialising merely
|
|
/// raises the progress window; it must not make the in-flight open mutable.
|
|
fn activate(self: &Rc<Self>) {
|
|
let Some(window) = self.window.borrow().clone() else {
|
|
return;
|
|
};
|
|
if !self.opening.get() {
|
|
// Supersede replies from the previous showing before clearing the
|
|
// query. The daemon work may finish, but it can no longer repaint
|
|
// the newly activated prompt with stale rows.
|
|
self.query_id.set(self.query_id.get() + 1);
|
|
self.pending.set(0);
|
|
self.open_pending.set(false);
|
|
self.spinner.stop();
|
|
self.spinner.set_visible(false);
|
|
self.suppress_entry_change.set(true);
|
|
self.entry.set_text("");
|
|
self.suppress_entry_change.set(false);
|
|
self.set_filter(Filter::All);
|
|
self.connect();
|
|
}
|
|
window.present();
|
|
self.entry.grab_focus();
|
|
}
|
|
|
|
/// Dismiss without destroying the application window. GTK continues to
|
|
/// associate the hidden window with the single application instance, so a
|
|
/// later desktop shortcut invocation is a cheap `activate` round-trip.
|
|
fn dismiss(&self) {
|
|
self.open_pending.set(false);
|
|
if let Some(window) = self.window.borrow().as_ref() {
|
|
window.set_visible(false);
|
|
}
|
|
}
|
|
|
|
/// Ask the daemon for status and, if it answers, load the suggested (pinned)
|
|
/// files. Runs off-thread: a dead daemon must not freeze the first frame.
|
|
fn connect(self: &Rc<Self>) {
|
|
self.hint.set_label("Connecting…");
|
|
let rx = spawn_request(self.socket.clone(), Request::Status);
|
|
let ui = self.clone();
|
|
glib::spawn_future_local(async move {
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Status { mountpoint, .. })) => {
|
|
*ui.mountpoint.borrow_mut() = PathBuf::from(mountpoint);
|
|
ui.entry.set_sensitive(true);
|
|
ui.stack.set_visible_child_name("results");
|
|
ui.load_suggestions();
|
|
}
|
|
_ => {
|
|
ui.entry.set_sensitive(false);
|
|
ui.hint.set_label("Daemon offline");
|
|
ui.stack.set_visible_child_name("offline");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The daemon stopped answering after the window was already open. Flip to
|
|
/// the offline page and lock the entry; the retry button re-runs `connect`.
|
|
fn go_offline(self: &Rc<Self>) {
|
|
self.pending.set(0);
|
|
self.spinner.stop();
|
|
self.spinner.set_visible(false);
|
|
self.entry.set_sensitive(false);
|
|
self.hint.set_label("Daemon offline");
|
|
self.stack.set_visible_child_name("offline");
|
|
}
|
|
|
|
/// The empty-query view: pinned files, which are the ones the user chose to
|
|
/// keep on this machine — the closest thing the daemon has to "recent".
|
|
fn load_suggestions(self: &Rc<Self>) {
|
|
let id = self.begin_query(1);
|
|
let rx = spawn_request(self.socket.clone(), Request::ListPins);
|
|
let ui = self.clone();
|
|
glib::spawn_future_local(async move {
|
|
let reply = rx.recv().await;
|
|
if !ui.finish_query(id) {
|
|
return;
|
|
}
|
|
if matches!(reply, Ok(Err(_)) | Err(_)) {
|
|
ui.go_offline();
|
|
return;
|
|
}
|
|
let hits = match reply {
|
|
Ok(Ok(Response::Pins { pins })) => pins
|
|
.into_iter()
|
|
.map(|pin| SearchHit {
|
|
name: file_name(&pin.path),
|
|
path: pin.path,
|
|
is_dir: pin.recursive,
|
|
size: 0,
|
|
modified: 0,
|
|
pinned: true,
|
|
uid: pin.uid,
|
|
mounted_path: None,
|
|
score: 0,
|
|
})
|
|
.collect(),
|
|
_ => Vec::new(),
|
|
};
|
|
*ui.drive_hits.borrow_mut() = hits;
|
|
ui.local_hits.borrow_mut().clear();
|
|
ui.render();
|
|
});
|
|
}
|
|
|
|
/// Search Drive and local metadata in one daemon round-trip. An empty query
|
|
/// falls back to the suggestions view.
|
|
fn search(self: &Rc<Self>, query: &str) {
|
|
if query.is_empty() {
|
|
self.load_suggestions();
|
|
return;
|
|
}
|
|
|
|
let id = self.begin_query(1);
|
|
let search = spawn_request(
|
|
self.socket.clone(),
|
|
Request::SearchV2 {
|
|
query: query.to_string(),
|
|
limit: SEARCH_LIMIT,
|
|
filters: SearchFilters {
|
|
sources: vec![SearchSource::Drive, SearchSource::Local],
|
|
kind: self.filter.get().search_kind(),
|
|
},
|
|
},
|
|
);
|
|
let ui = self.clone();
|
|
glib::spawn_future_local(async move {
|
|
let reply = search.recv().await;
|
|
if !ui.finish_query(id) {
|
|
return;
|
|
}
|
|
match reply {
|
|
Ok(Ok(Response::SearchResultsV2 {
|
|
drive_hits,
|
|
local_hits,
|
|
local_indexing,
|
|
})) => {
|
|
*ui.drive_hits.borrow_mut() = drive_hits;
|
|
*ui.local_hits.borrow_mut() = local_hits;
|
|
ui.indexing.set(local_indexing);
|
|
ui.render();
|
|
}
|
|
// Transport failure: the daemon is gone. Don't paint "No
|
|
// results" over a crash — say so.
|
|
Ok(Err(_)) | Err(_) => ui.go_offline(),
|
|
_ => {
|
|
ui.drive_hits.borrow_mut().clear();
|
|
ui.local_hits.borrow_mut().clear();
|
|
ui.render();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Open a new query id, expecting `requests` replies. Any reply still in
|
|
/// flight for an older id is now stale and will be dropped by
|
|
/// [`finish_query`](Self::finish_query).
|
|
fn begin_query(&self, requests: u8) -> u64 {
|
|
let id = self.query_id.get() + 1;
|
|
self.query_id.set(id);
|
|
self.pending.set(requests);
|
|
self.spinner.set_visible(true);
|
|
self.spinner.start();
|
|
id
|
|
}
|
|
|
|
/// Account for one reply. Returns false if it belongs to a superseded query,
|
|
/// in which case the caller must discard it.
|
|
fn finish_query(&self, id: u64) -> bool {
|
|
if self.query_id.get() != id {
|
|
return false;
|
|
}
|
|
let left = self.pending.get().saturating_sub(1);
|
|
self.pending.set(left);
|
|
if left == 0 {
|
|
self.spinner.stop();
|
|
self.spinner.set_visible(false);
|
|
}
|
|
true
|
|
}
|
|
|
|
fn set_filter(self: &Rc<Self>, filter: Filter) {
|
|
if self.filter.get() == filter {
|
|
return;
|
|
}
|
|
self.filter.set(filter);
|
|
for (chip_filter, chip) in self.chips.borrow().iter() {
|
|
chip.set_active(*chip_filter == filter);
|
|
}
|
|
let query = self.entry.text().trim().to_string();
|
|
if query.is_empty() {
|
|
self.render();
|
|
} else {
|
|
self.search(&query);
|
|
}
|
|
}
|
|
|
|
fn cycle_filter(self: &Rc<Self>, backwards: bool) {
|
|
let current = FILTERS
|
|
.iter()
|
|
.position(|(f, _)| *f == self.filter.get())
|
|
.unwrap_or(0);
|
|
let count = FILTERS.len();
|
|
let next = if backwards {
|
|
(current + count - 1) % count
|
|
} else {
|
|
(current + 1) % count
|
|
};
|
|
self.set_filter(FILTERS[next].0);
|
|
}
|
|
|
|
fn drive_count(&self) -> usize {
|
|
self.visible
|
|
.borrow()
|
|
.iter()
|
|
.filter(|hit| matches!(hit, Hit::Drive(_)))
|
|
.count()
|
|
}
|
|
|
|
/// Rebuild suggestions or the globally-ranked search section, preserving the
|
|
/// selected identity when a result survives the update.
|
|
fn render(self: &Rc<Self>) {
|
|
let filter = self.filter.get();
|
|
|
|
// Identity of the row under the cursor before we rebuild the list, so a
|
|
// late-arriving second search can't yank the selection back to the top.
|
|
let selected_key = self
|
|
.cursor
|
|
.get()
|
|
.and_then(|i| self.visible.borrow().get(i).map(Hit::key));
|
|
|
|
let drive: Vec<Hit> = self
|
|
.drive_hits
|
|
.borrow()
|
|
.iter()
|
|
.filter(|hit| filter.accepts(&hit.name, hit.is_dir))
|
|
.cloned()
|
|
.map(Hit::Drive)
|
|
.collect();
|
|
let local: Vec<Hit> = self
|
|
.local_hits
|
|
.borrow()
|
|
.iter()
|
|
.filter(|hit| filter.accepts(&hit.name, hit.is_dir))
|
|
.cloned()
|
|
.map(Hit::Local)
|
|
.collect();
|
|
|
|
let query = self.entry.text().trim().to_string();
|
|
let searching = !query.is_empty();
|
|
let total = drive.len() + local.len();
|
|
let mut visible = drive;
|
|
visible.extend(local);
|
|
if searching {
|
|
rank_hits(&mut visible);
|
|
self.search_section.set_rows(&visible, Some("Best matches"));
|
|
self.drive_section.set_rows(&[], None);
|
|
self.local_section.set_rows(&[], None);
|
|
} else {
|
|
self.search_section.set_rows(&[], None);
|
|
let drive_count = visible
|
|
.iter()
|
|
.take_while(|hit| matches!(hit, Hit::Drive(_)))
|
|
.count();
|
|
self.drive_section
|
|
.set_rows(&visible[..drive_count], Some("Pinned in Proton Drive"));
|
|
self.local_section.set_rows(&visible[drive_count..], None);
|
|
}
|
|
|
|
// Find where the previously-selected row landed in the rebuilt list.
|
|
let restored = selected_key.and_then(|key| visible.iter().position(|hit| hit.key() == key));
|
|
|
|
*self.visible.borrow_mut() = visible;
|
|
*self.rendered_query.borrow_mut() = query;
|
|
|
|
if total == 0 {
|
|
self.placeholder.set_title(if searching {
|
|
"No results"
|
|
} else {
|
|
"Search your Drive"
|
|
});
|
|
self.placeholder
|
|
.set_description(Some(match (searching, self.indexing.get()) {
|
|
(true, true) => {
|
|
"Still indexing this computer — local results will fill in shortly."
|
|
}
|
|
(true, false) => "Try a different search, or another filter.",
|
|
_ => "Start typing to search Proton Drive and the files on this computer.",
|
|
}));
|
|
self.stack.set_visible_child_name("empty");
|
|
self.cursor.set(None);
|
|
} else {
|
|
self.stack.set_visible_child_name("results");
|
|
self.select(restored.unwrap_or(0));
|
|
}
|
|
|
|
self.hint.set_label(&self.status_text(total, searching));
|
|
|
|
// Honour an Enter that arrived before this query rendered, now that the
|
|
// results (and the cursor) are settled. Only once both searches are in,
|
|
// so it lands on the final list, not a half-populated one.
|
|
if self.open_pending.get() && self.pending.get() == 0 {
|
|
self.open_pending.set(false);
|
|
if let Some(index) = self.cursor.get() {
|
|
self.open(index);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn status_text(&self, total: usize, searching: bool) -> String {
|
|
if !searching {
|
|
return match total {
|
|
0 => "No pinned files".to_string(),
|
|
1 => "1 pinned file".to_string(),
|
|
n => format!("{n} pinned files"),
|
|
};
|
|
}
|
|
let drive = self.drive_count();
|
|
let local = total - drive;
|
|
let mut text = format!("{drive} in Drive · {local} on this computer");
|
|
if self.indexing.get() {
|
|
text.push_str(" · indexing…");
|
|
}
|
|
text
|
|
}
|
|
|
|
/// Move the cursor by `delta` rows, wrapping at both ends.
|
|
fn move_cursor(self: &Rc<Self>, delta: i32) {
|
|
let count = self.visible.borrow().len() as i32;
|
|
if count == 0 {
|
|
return;
|
|
}
|
|
let current = self.cursor.get().map_or(0, |c| c as i32);
|
|
let next = (current + delta).rem_euclid(count);
|
|
self.select(next as usize);
|
|
}
|
|
|
|
/// Put the cursor on row `index` of the flattened list: select it in whichever
|
|
/// section owns it, clear the other section's selection, and scroll it into
|
|
/// view. Focus stays in the entry so typing never breaks.
|
|
fn select(self: &Rc<Self>, index: usize) {
|
|
if !self.rendered_query.borrow().is_empty() {
|
|
self.drive_section.list.unselect_all();
|
|
self.local_section.list.unselect_all();
|
|
let Some(row) = self.search_section.list.row_at_index(index as i32) else {
|
|
return;
|
|
};
|
|
self.search_section.list.select_row(Some(&row));
|
|
self.cursor.set(Some(index));
|
|
self.scroll_into_view(&row);
|
|
return;
|
|
}
|
|
|
|
self.search_section.list.unselect_all();
|
|
let drive_count = self.drive_count();
|
|
let (list, other, local_index) = if index < drive_count {
|
|
(
|
|
&self.drive_section.list,
|
|
&self.local_section.list,
|
|
index as i32,
|
|
)
|
|
} else {
|
|
(
|
|
&self.local_section.list,
|
|
&self.drive_section.list,
|
|
(index - drive_count) as i32,
|
|
)
|
|
};
|
|
other.unselect_all();
|
|
let Some(row) = list.row_at_index(local_index) else {
|
|
return;
|
|
};
|
|
list.select_row(Some(&row));
|
|
self.cursor.set(Some(index));
|
|
self.scroll_into_view(&row);
|
|
}
|
|
|
|
/// Keep the selected row inside the viewport, scrolling by the smallest
|
|
/// amount that reveals it (like a native list, unlike jumping it to centre).
|
|
fn scroll_into_view(&self, row: >k4::ListBoxRow) {
|
|
let Some(bounds) = row.compute_bounds(&self.results) else {
|
|
return;
|
|
};
|
|
let adjustment = self.scroller.vadjustment();
|
|
let (top, bottom) = (
|
|
f64::from(bounds.y()),
|
|
f64::from(bounds.y() + bounds.height()),
|
|
);
|
|
let (value, page) = (adjustment.value(), adjustment.page_size());
|
|
if top < value {
|
|
adjustment.set_value(top);
|
|
} else if bottom > value + page {
|
|
adjustment.set_value(bottom - page);
|
|
}
|
|
}
|
|
|
|
/// Open the hit at `index`. Local files are already on disk, so they hand
|
|
/// straight to the desktop; a Drive hit must first be hydrated into the cache
|
|
/// by the daemon (`OpenFile`), which can take a moment — the window stays up,
|
|
/// with the spinner running, until the path comes back.
|
|
fn open(self: &Rc<Self>, index: usize) {
|
|
if self.opening.get() {
|
|
return;
|
|
}
|
|
// The rows on screen may belong to an older query whose reply is still
|
|
// settling. Don't act on them — Enter must never launch a file the user
|
|
// has typed past. But don't drop the intent either: remember it and open
|
|
// the right row once the fresh results land (see `render`).
|
|
if self.entry.text().trim() != *self.rendered_query.borrow() {
|
|
self.open_pending.set(true);
|
|
return;
|
|
}
|
|
let Some(hit) = self.visible.borrow().get(index).cloned() else {
|
|
return;
|
|
};
|
|
|
|
match hit {
|
|
Hit::Local(local) => {
|
|
xdg_open(Path::new(&local.path));
|
|
self.dismiss();
|
|
}
|
|
Hit::Drive(drive) => {
|
|
// Empty-query rows come from ListPins, whose `recursive` flag is
|
|
// pin policy rather than reliable node kind metadata. Every pin
|
|
// already has a mounted path, so activate that path directly;
|
|
// this handles non-recursively pinned folders without mistaking
|
|
// them for files and sending an invalid OpenFile request.
|
|
if self.rendered_query.borrow().is_empty() {
|
|
let path = drive.mounted_path.as_deref().map_or_else(
|
|
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
|
|
PathBuf::from,
|
|
);
|
|
xdg_open(&path);
|
|
self.dismiss();
|
|
return;
|
|
}
|
|
match drive_activation(&drive.name, drive.is_dir) {
|
|
DriveActivation::Folder | DriveActivation::MountedMedia => {
|
|
let path = drive.mounted_path.as_deref().map_or_else(
|
|
|| mounted_path(&self.mountpoint.borrow(), &drive.path),
|
|
PathBuf::from,
|
|
);
|
|
xdg_open(&path);
|
|
self.dismiss();
|
|
return;
|
|
}
|
|
DriveActivation::Materialize => {}
|
|
}
|
|
|
|
self.opening.set(true);
|
|
self.spinner.set_visible(true);
|
|
self.spinner.start();
|
|
self.hint.set_label(&format!("Opening {}…", drive.name));
|
|
|
|
let rx = spawn_request(
|
|
self.socket.clone(),
|
|
Request::OpenFile {
|
|
path: drive.path.clone(),
|
|
},
|
|
);
|
|
let ui = self.clone();
|
|
glib::spawn_future_local(async move {
|
|
let reply = rx.recv().await;
|
|
ui.spinner.stop();
|
|
ui.spinner.set_visible(false);
|
|
ui.opening.set(false);
|
|
match reply {
|
|
Ok(Ok(Response::FilePath { path })) => {
|
|
xdg_open(Path::new(&path));
|
|
ui.dismiss();
|
|
}
|
|
Ok(Ok(Response::Error { message, .. })) => {
|
|
ui.hint.set_label(&format!("Could not open: {message}"));
|
|
}
|
|
_ => ui.hint.set_label("Could not reach the daemon"),
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One result row: type icon, name, location, then size/age on the right.
|
|
fn build_row(hit: &Hit) -> gtk4::ListBoxRow {
|
|
let row = gtk4::ListBoxRow::new();
|
|
row.add_css_class("result-row");
|
|
|
|
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
|
|
let icon = gtk4::Image::from_gicon(&icon_for(hit.name(), hit.is_dir()));
|
|
icon.set_pixel_size(24);
|
|
content.append(&icon);
|
|
|
|
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 1);
|
|
text.set_hexpand(true);
|
|
text.set_valign(gtk4::Align::Center);
|
|
|
|
let name = gtk4::Label::builder()
|
|
.label(hit.name())
|
|
.xalign(0.0)
|
|
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
|
.build();
|
|
name.add_css_class("result-name");
|
|
text.append(&name);
|
|
|
|
let location = gtk4::Label::builder()
|
|
.label(hit.location())
|
|
.xalign(0.0)
|
|
.ellipsize(gtk4::pango::EllipsizeMode::Start)
|
|
.build();
|
|
location.add_css_class("result-location");
|
|
text.append(&location);
|
|
content.append(&text);
|
|
|
|
let meta = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
|
|
meta.set_valign(gtk4::Align::Center);
|
|
if hit.pinned() {
|
|
let pin = gtk4::Image::from_icon_name("view-pin-symbolic");
|
|
pin.add_css_class("pin-icon");
|
|
meta.append(&pin);
|
|
}
|
|
if !hit.is_dir() && hit.size() > 0 {
|
|
let size = gtk4::Label::new(Some(&format_size(hit.size())));
|
|
size.add_css_class("result-meta");
|
|
meta.append(&size);
|
|
}
|
|
if hit.modified() > 0 {
|
|
let modified = gtk4::Label::new(Some(&format_age(hit.modified())));
|
|
modified.add_css_class("result-meta");
|
|
meta.append(&modified);
|
|
}
|
|
content.append(&meta);
|
|
|
|
row.set_child(Some(&content));
|
|
row
|
|
}
|
|
|
|
/// The desktop's own icon for a file name, so results look like the user's file
|
|
/// manager instead of a bespoke palette. Falls back to a generic document icon
|
|
/// for names the content-type database cannot place.
|
|
fn icon_for(name: &str, is_dir: bool) -> gio::Icon {
|
|
if is_dir {
|
|
return gio::ThemedIcon::new("folder").upcast();
|
|
}
|
|
let (content_type, _uncertain) = gio::functions::content_type_guess(Some(name), &[]);
|
|
gio::functions::content_type_get_icon(&content_type)
|
|
}
|
|
|
|
/// Run one blocking control-socket round-trip on a worker thread. The GTK main
|
|
/// loop never blocks on the daemon: the reply arrives through the channel.
|
|
fn spawn_request(
|
|
socket: PathBuf,
|
|
request: Request,
|
|
) -> async_channel::Receiver<Result<Response, String>> {
|
|
let (tx, rx) = async_channel::bounded(1);
|
|
std::thread::spawn(move || {
|
|
let _ = tx.send_blocking(send(&socket, &request).map_err(|e| e.to_string()));
|
|
});
|
|
rx
|
|
}
|
|
|
|
fn xdg_open(path: &Path) {
|
|
tracing::info!(path = %path.display(), "opening");
|
|
if let Err(e) = Command::new("xdg-open").arg(path).spawn() {
|
|
tracing::error!("xdg-open failed: {e}");
|
|
}
|
|
}
|
|
|
|
fn dirs_home() -> Option<PathBuf> {
|
|
AppDirs::new().ok().and_then(|dirs| dirs.home_dir())
|
|
}
|
|
|
|
fn file_name(path: &str) -> String {
|
|
Path::new(path)
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or(path)
|
|
.to_string()
|
|
}
|
|
|
|
fn parent_of(path: &str) -> String {
|
|
Path::new(path)
|
|
.parent()
|
|
.map(|p| p.display().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn format_size(bytes: u64) -> String {
|
|
const UNITS: [(u64, &str); 3] = [
|
|
(1024 * 1024 * 1024, "GB"),
|
|
(1024 * 1024, "MB"),
|
|
(1024, "KB"),
|
|
];
|
|
for (scale, unit) in UNITS {
|
|
if bytes >= scale {
|
|
return format!("{:.1} {unit}", bytes as f64 / scale as f64);
|
|
}
|
|
}
|
|
format!("{bytes} B")
|
|
}
|
|
|
|
/// Coarse relative age, in the granularity a launcher row has room for.
|
|
fn format_age(epoch_secs: i64) -> String {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_secs() as i64);
|
|
let diff = now - epoch_secs;
|
|
match diff {
|
|
d if d < 60 => "now".to_string(),
|
|
d if d < 3600 => format!("{}m", d / 60),
|
|
d if d < 86_400 => format!("{}h", d / 3600),
|
|
d if d < 2_592_000 => format!("{}d", d / 86_400),
|
|
d if d < 31_536_000 => format!("{}mo", d / 2_592_000),
|
|
d => format!("{}y", d / 31_536_000),
|
|
}
|
|
}
|
|
|
|
/// Styling. Everything is expressed against libadwaita's named colors, so the
|
|
/// launcher follows the system light/dark theme instead of pinning its own.
|
|
fn load_css() {
|
|
let css = "
|
|
/* Pin the accent to Proton Purple so the HUD matches the main app,
|
|
which its own process/theme would otherwise leave at the system
|
|
default (often blue or orange). */
|
|
@define-color accent_bg_color #6d4aff;
|
|
@define-color accent_color #6d4aff;
|
|
@define-color accent_fg_color #ffffff;
|
|
|
|
window.launcher-window {
|
|
background: transparent;
|
|
}
|
|
|
|
.launcher-card {
|
|
background-color: @view_bg_color;
|
|
border: 1px solid alpha(@borders, 0.8);
|
|
border-radius: 14px;
|
|
box-shadow: 0 16px 48px alpha(black, 0.35);
|
|
}
|
|
|
|
.search-bar {
|
|
padding: 14px 18px;
|
|
border-bottom: 1px solid alpha(@borders, 0.6);
|
|
}
|
|
|
|
.search-icon {
|
|
-gtk-icon-size: 20px;
|
|
color: alpha(currentColor, 0.55);
|
|
}
|
|
|
|
.search-entry {
|
|
background: none;
|
|
border: none;
|
|
box-shadow: none;
|
|
outline: none;
|
|
padding: 2px 0;
|
|
font-size: 1.15rem;
|
|
}
|
|
|
|
.key-hint {
|
|
padding: 2px 8px;
|
|
border-radius: 6px;
|
|
background-color: alpha(currentColor, 0.08);
|
|
font-size: 0.75rem;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.chip-row {
|
|
padding: 10px 14px;
|
|
border-bottom: 1px solid alpha(@borders, 0.6);
|
|
}
|
|
|
|
.chip {
|
|
padding: 4px 14px;
|
|
min-height: 0;
|
|
border-radius: 999px;
|
|
background: none;
|
|
border: 1px solid alpha(@borders, 0.9);
|
|
box-shadow: none;
|
|
font-size: 0.86rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.chip:hover {
|
|
background-color: alpha(currentColor, 0.06);
|
|
}
|
|
|
|
.chip:checked {
|
|
background-color: @accent_bg_color;
|
|
color: @accent_fg_color;
|
|
border-color: @accent_bg_color;
|
|
}
|
|
|
|
.results {
|
|
padding: 6px 8px 10px 8px;
|
|
}
|
|
|
|
.section-header {
|
|
padding: 12px 10px 6px 10px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.section-icon {
|
|
-gtk-icon-size: 14px;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.result-list {
|
|
background: none;
|
|
}
|
|
|
|
.result-row {
|
|
padding: 9px 10px;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.result-row:hover {
|
|
background-color: alpha(currentColor, 0.05);
|
|
}
|
|
|
|
.result-row:selected {
|
|
background-color: @accent_bg_color;
|
|
color: @accent_fg_color;
|
|
}
|
|
|
|
.result-name {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.result-location, .result-meta {
|
|
font-size: 0.8rem;
|
|
opacity: 0.55;
|
|
}
|
|
|
|
.result-row:selected .result-location,
|
|
.result-row:selected .result-meta {
|
|
opacity: 0.75;
|
|
}
|
|
|
|
.footer {
|
|
padding: 8px 16px;
|
|
border-top: 1px solid alpha(@borders, 0.6);
|
|
}
|
|
|
|
.footer-text {
|
|
font-size: 0.78rem;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
statuspage.compact > scrolledwindow > viewport > box {
|
|
margin: 24px 12px;
|
|
}
|
|
";
|
|
|
|
let provider = gtk4::CssProvider::new();
|
|
provider.load_from_string(css);
|
|
if let Some(display) = gtk4::gdk::Display::default() {
|
|
gtk4::style_context_add_provider_for_display(
|
|
&display,
|
|
&provider,
|
|
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn ranked_hits_merge_sources_on_one_score_scale() {
|
|
let mut hits = vec![
|
|
Hit::Drive(SearchHit {
|
|
name: "alpha.pdf".into(),
|
|
path: "Drive/alpha.pdf".into(),
|
|
is_dir: false,
|
|
size: 1,
|
|
modified: 1,
|
|
pinned: false,
|
|
uid: "v~a".into(),
|
|
mounted_path: None,
|
|
score: 100,
|
|
}),
|
|
Hit::Local(LocalHit {
|
|
name: "better.pdf".into(),
|
|
path: "/home/me/better.pdf".into(),
|
|
is_dir: false,
|
|
size: 1,
|
|
modified: 1,
|
|
score: 300,
|
|
}),
|
|
];
|
|
|
|
rank_hits(&mut hits);
|
|
assert!(matches!(&hits[0], Hit::Local(hit) if hit.name == "better.pdf"));
|
|
assert!(matches!(&hits[1], Hit::Drive(hit) if hit.name == "alpha.pdf"));
|
|
}
|
|
}
|