From d131d3dca8ac42a0ede1382ae0cfdd650eb96956 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 1 Jul 2026 00:21:22 +0200 Subject: [PATCH] implemented proton drive prompt --- crates/pdfs-gui/Cargo.toml | 4 + crates/pdfs-gui/src/prompt.rs | 716 ++++++++++++++++++++++++++++++++++ packaging/PKGBUILD | 4 +- 3 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 crates/pdfs-gui/src/prompt.rs diff --git a/crates/pdfs-gui/Cargo.toml b/crates/pdfs-gui/Cargo.toml index e6da753..aa1c3df 100644 --- a/crates/pdfs-gui/Cargo.toml +++ b/crates/pdfs-gui/Cargo.toml @@ -14,6 +14,10 @@ path = "src/main.rs" name = "pdfs-app" path = "src/app.rs" +[[bin]] +name = "pdfs-prompt" +path = "src/prompt.rs" + [dependencies] pdfs-core.workspace = true tracing.workspace = true diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs new file mode 100644 index 0000000..bcaaf94 --- /dev/null +++ b/crates/pdfs-gui/src/prompt.rs @@ -0,0 +1,716 @@ +//! `pdfs-prompt` — a fast, Google Drive-style launcher prompt for searching +//! and opening files or folders in Proton Drive. +//! +//! Designed to be bound to a system shortcut (e.g. in Hyprland) for quick HUD search. + +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::glib; + +use pdfs_core::config::AppDirs; +use pdfs_core::control::{Request, Response, SearchHit, send}; + +const APP_ID: &str = "io.narl.proton-drive-linux-prompt"; +const PROTON_PURPLE: &str = "#6d4aff"; +const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150); + +/// Run one blocking control-socket round-trip on a worker thread, returning a +/// channel that yields the [`Response`] once. +fn spawn_request( + socket: PathBuf, + req: Request, +) -> async_channel::Receiver> { + let (tx, rx) = async_channel::bounded(1); + std::thread::spawn(move || { + let result = send(&socket, &req).map_err(|e| e.to_string()); + let _ = tx.send_blocking(result); + }); + rx +} + +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(); + + app.connect_startup(|_| { + load_prompt_theme(); + }); + + app.connect_activate(build_window); + + app.run() +} + +/// Override accent colors and style the launcher for a premium HUD look. +fn load_prompt_theme() { + let css = format!( + "@define-color accent_bg_color {PROTON_PURPLE};\n\ + @define-color accent_color {PROTON_PURPLE};\n\ + \n\ + window.launcher-window {{\n\ + background-color: transparent;\n\ + }}\n\ + \n\ + .launcher-container {{\n\ + background-color: @window_bg_color;\n\ + border: 2px solid {PROTON_PURPLE};\n\ + border-radius: 12px;\n\ + padding: 12px;\n\ + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);\n\ + }}\n\ + \n\ + .launcher-header-box {{\n\ + padding: 6px 12px;\n\ + background-color: alpha(@view_bg_color, 0.4);\n\ + border-radius: 8px;\n\ + border: 1px solid alpha({PROTON_PURPLE}, 0.2);\n\ + }}\n\ + \n\ + .launcher-entry {{\n\ + font-size: 1.15rem;\n\ + background: transparent;\n\ + border: none;\n\ + box-shadow: none;\n\ + }}\n\ + \n\ + .launcher-listbox {{\n\ + background: transparent;\n\ + }}\n\ + \n\ + .launcher-row {{\n\ + padding: 10px 14px;\n\ + border-radius: 6px;\n\ + margin: 2px 0px;\n\ + transition: background-color 0.15s ease, color 0.15s ease;\n\ + }}\n\ + \n\ + .launcher-row:hover {{\n\ + background-color: alpha({PROTON_PURPLE}, 0.1);\n\ + }}\n\ + \n\ + .launcher-row:selected {{\n\ + background-color: {PROTON_PURPLE};\n\ + color: white;\n\ + }}\n\ + \n\ + .launcher-row:selected .dim-label {{\n\ + color: alpha(white, 0.7);\n\ + }}\n\ + \n\ + .launcher-footer {{\n\ + padding: 6px 12px 0px 12px;\n\ + font-size: 0.85rem;\n\ + border-top: 1px solid alpha({PROTON_PURPLE}, 0.1);\n\ + }}\n\ + \n\ + .dim-label {{\n\ + color: @view_fg_color;\n\ + opacity: 0.6;\n\ + font-size: 0.85rem;\n\ + }}\n\ + \n\ + .error-box {{\n\ + padding: 24px;\n\ + }}\n\ + \n\ + .error-title {{\n\ + font-size: 1.25rem;\n\ + font-weight: bold;\n\ + margin-bottom: 8px;\n\ + }}\n" + ); + + 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, + ); + } +} + +fn build_window(app: &adw::Application) { + let dirs = match AppDirs::new() { + Ok(d) => d, + Err(e) => { + tracing::error!("cannot resolve app dirs: {e}"); + return; + } + }; + let socket = dirs.control_socket(); + let default_mountpoint = dirs.default_mountpoint(); + + let window = adw::ApplicationWindow::builder() + .application(app) + .title("Proton Drive Launcher") + .default_width(650) + .default_height(450) + .decorated(false) + .build(); + + window.add_css_class("launcher-window"); + + // Prefer dark mode for high-tech HUD look + let style_manager = adw::StyleManager::default(); + style_manager.set_color_scheme(adw::ColorScheme::PreferDark); + + let container = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + container.add_css_class("launcher-container"); + window.set_content(Some(&container)); + + // Header box: search icon, entry field, and loading spinner + let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header_box.add_css_class("launcher-header-box"); + + let search_icon = gtk4::Image::from_icon_name("system-search-symbolic"); + header_box.append(&search_icon); + + let entry = gtk4::Entry::builder() + .placeholder_text("Search Proton Drive...") + .hexpand(true) + .build(); + entry.add_css_class("launcher-entry"); + header_box.append(&entry); + + let spinner = gtk4::Spinner::new(); + spinner.set_visible(false); + header_box.append(&spinner); + + container.append(&header_box); + + let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal); + container.append(&separator); + + // View stack to transition between results list and warning page + let stack = adw::ViewStack::new(); + container.append(&stack); + + // Page 1: Scrolled window and listbox for results + let list_box = gtk4::ListBox::new(); + list_box.add_css_class("launcher-listbox"); + + let scrolled_window = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .vscrollbar_policy(gtk4::PolicyType::Automatic) + .vexpand(true) + .child(&list_box) + .build(); + stack.add_named(&scrolled_window, Some("search")); + + // Page 2: Error screen if daemon is offline + let error_box = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + error_box.add_css_class("error-box"); + error_box.set_valign(gtk4::Align::Center); + error_box.set_halign(gtk4::Align::Center); + + let error_icon = gtk4::Image::from_icon_name("dialog-warning-symbolic"); + error_icon.set_pixel_size(48); + error_icon.add_css_class("brand-icon"); + error_box.append(&error_icon); + + let error_title = gtk4::Label::builder() + .label("Daemon Offline") + .justify(gtk4::Justification::Center) + .build(); + error_title.add_css_class("error-title"); + error_box.append(&error_title); + + let error_desc = gtk4::Label::builder() + .label("Proton Drive mount daemon is not running.\nStart the daemon or run `pdfs mount` to connect.") + .justify(gtk4::Justification::Center) + .build(); + error_desc.add_css_class("dim-label"); + error_box.append(&error_desc); + + let retry_button = gtk4::Button::builder() + .label("Retry Connection") + .halign(gtk4::Align::Center) + .build(); + retry_button.add_css_class("suggested-action"); + retry_button.add_css_class("pill"); + error_box.append(&retry_button); + + stack.add_named(&error_box, Some("error")); + + // Footer box: status label + let footer_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + footer_box.add_css_class("launcher-footer"); + + let status_label = gtk4::Label::builder() + .label("Initialising...") + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .hexpand(true) + .build(); + status_label.add_css_class("dim-label"); + footer_box.append(&status_label); + + container.append(&footer_box); + + // Shared state variables + let hits = Rc::new(RefCell::new(Vec::::new())); + let mountpoint = Rc::new(RefCell::new(default_mountpoint.display().to_string())); + let is_opening = Rc::new(Cell::new(false)); + + // Handle retry on connection failure + let stack_clone = stack.clone(); + let socket_clone = socket.clone(); + let mountpoint_clone = mountpoint.clone(); + let status_label_clone = status_label.clone(); + let spinner_clone = spinner.clone(); + let list_box_clone = list_box.clone(); + let hits_clone = hits.clone(); + let entry_clone = entry.clone(); + + let check_connection = move || { + status_label_clone.set_text("Connecting to daemon..."); + match send(&socket_clone, &Request::Status) { + Ok(Response::Status { mountpoint: mp, .. }) => { + *mountpoint_clone.borrow_mut() = mp; + stack_clone.set_visible_child_name("search"); + entry_clone.set_sensitive(true); + load_initial_pins( + &list_box_clone, + &hits_clone, + &socket_clone, + &status_label_clone, + &spinner_clone, + ); + true + } + _ => { + stack_clone.set_visible_child_name("error"); + entry_clone.set_sensitive(false); + status_label_clone.set_text("Daemon offline."); + false + } + } + }; + + let check_conn = check_connection.clone(); + retry_button.connect_clicked(move |_| { + check_conn(); + }); + + window.connect_is_active_notify(move |w| { + if !w.is_active() { + w.close(); + } + }); + + // Auto-focus search entry on window show + let entry_focus = entry.clone(); + window.connect_map(move |_| { + entry_focus.grab_focus(); + }); + + // Event key controller on entry field for keyboard shortcuts + let key_controller = gtk4::EventControllerKey::new(); + let list_box_keys = list_box.clone(); + let window_keys = window.clone(); + let entry_keys = entry.clone(); + let hits_keys = hits.clone(); + let is_opening_keys = is_opening.clone(); + + key_controller.connect_key_pressed(move |_, key, _keycode, _state| { + if is_opening_keys.get() { + return glib::Propagation::Stop; + } + + match key { + gtk4::gdk::Key::Escape => { + window_keys.close(); + glib::Propagation::Stop + } + gtk4::gdk::Key::Down => { + let count = hits_keys.borrow().len(); + if count > 0 { + let selected = list_box_keys.selected_row(); + let next_idx = if let Some(row) = selected { + (row.index() + 1) % count as i32 + } else { + 0 + }; + if let Some(row) = list_box_keys.row_at_index(next_idx) { + list_box_keys.select_row(Some(&row)); + row.grab_focus(); + entry_keys.grab_focus(); + } + } + glib::Propagation::Stop + } + gtk4::gdk::Key::Up => { + let count = hits_keys.borrow().len(); + if count > 0 { + let selected = list_box_keys.selected_row(); + let next_idx = if let Some(row) = selected { + (row.index() - 1 + count as i32) % count as i32 + } else { + (count - 1) as i32 + }; + if let Some(row) = list_box_keys.row_at_index(next_idx) { + list_box_keys.select_row(Some(&row)); + row.grab_focus(); + entry_keys.grab_focus(); + } + } + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + entry.add_controller(key_controller); + + // Entry activation (pressing Return/Enter) + let list_box_activate = list_box.clone(); + let hits_activate = hits.clone(); + let window_activate = window.clone(); + let socket_activate = socket.clone(); + let mountpoint_activate = mountpoint.clone(); + let status_label_activate = status_label.clone(); + let spinner_activate = spinner.clone(); + let is_opening_activate = is_opening.clone(); + + entry.connect_activate(move |_| { + if is_opening_activate.get() { + return; + } + if let Some(row) = list_box_activate.selected_row() { + let idx = row.index() as usize; + let hits_borrow = hits_activate.borrow(); + if idx < hits_borrow.len() { + let hit = &hits_borrow[idx]; + open_hit( + &window_activate, + hit, + &socket_activate, + &mountpoint_activate, + &status_label_activate, + &spinner_activate, + is_opening_activate.clone(), + ); + } + } + }); + + // List box row activation (mouse clicks / touch taps) + let hits_act = hits.clone(); + let window_act = window.clone(); + let socket_act = socket.clone(); + let mountpoint_act = mountpoint.clone(); + let status_label_act = status_label.clone(); + let spinner_act = spinner.clone(); + let is_opening_act = is_opening.clone(); + + list_box.connect_row_activated(move |_, row| { + if is_opening_act.get() { + return; + } + let idx = row.index() as usize; + let hits_borrow = hits_act.borrow(); + if idx < hits_borrow.len() { + let hit = &hits_borrow[idx]; + open_hit( + &window_act, + hit, + &socket_act, + &mountpoint_act, + &status_label_act, + &spinner_act, + is_opening_act.clone(), + ); + } + }); + + // Debounced search text entry listener + let search_source = Rc::new(RefCell::new(None::)); + let search_source_changed = search_source.clone(); + let list_box_search = list_box.clone(); + let hits_search = hits.clone(); + let socket_search = socket.clone(); + let status_label_search = status_label.clone(); + let spinner_search = spinner.clone(); + + entry.connect_changed(move |ent| { + if let Some(src) = search_source_changed.borrow_mut().take() { + src.remove(); + } + + let ent_clone = ent.clone(); + let list_box_clone = list_box_search.clone(); + let hits_clone = hits_search.clone(); + let socket_clone = socket_search.clone(); + let status_label_clone = status_label_search.clone(); + let spinner_clone = spinner_search.clone(); + let search_source_cb = search_source_changed.clone(); + + let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || { + search_source_cb.borrow_mut().take(); + let query = ent_clone.text().trim().to_string(); + if query.is_empty() { + load_initial_pins( + &list_box_clone, + &hits_clone, + &socket_clone, + &status_label_clone, + &spinner_clone, + ); + } else { + run_prompt_search( + &list_box_clone, + &hits_clone, + &socket_clone, + &query, + &status_label_clone, + &spinner_clone, + ); + } + }); + *search_source_changed.borrow_mut() = Some(src); + }); + + // Perform the initial connection and load check + check_connection(); + + window.present(); +} + +/// Retrieve pinned files from the daemon and render them in the results list. +fn load_initial_pins( + list_box: >k4::ListBox, + hits: &Rc>>, + socket: &Path, + status_label: >k4::Label, + spinner: >k4::Spinner, +) { + status_label.set_text("Loading pinned files..."); + spinner.set_visible(true); + spinner.start(); + + let rx = spawn_request(socket.to_path_buf(), Request::ListPins); + let list_box = list_box.clone(); + let hits = hits.clone(); + let status_label = status_label.clone(); + let spinner = spinner.clone(); + + glib::spawn_future_local(async move { + let res = rx.recv().await; + spinner.stop(); + spinner.set_visible(false); + + match res { + Ok(Ok(Response::Pins { pins })) => { + let mapped_hits: Vec = pins + .into_iter() + .map(|p| { + let path_obj = Path::new(&p.path); + let name = path_obj + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(&p.path) + .to_string(); + SearchHit { + name, + path: p.path, + is_dir: p.recursive, + size: 0, + modified: 0, + pinned: true, + uid: p.uid, + } + }) + .collect(); + + repaint_prompt_results(&list_box, &hits, &mapped_hits); + if mapped_hits.is_empty() { + status_label.set_text("No pinned files. Type to search."); + } else { + status_label.set_text("Showing pinned files. Type to search."); + } + } + _ => { + status_label.set_text("Failed to load pinned files. Type to search."); + } + } + }); +} + +/// Run search on the daemon and update the results list. +fn run_prompt_search( + list_box: >k4::ListBox, + hits: &Rc>>, + socket: &Path, + query: &str, + status_label: >k4::Label, + spinner: >k4::Spinner, +) { + status_label.set_text("Searching..."); + spinner.set_visible(true); + spinner.start(); + + let rx = spawn_request( + socket.to_path_buf(), + Request::Search { + query: query.to_string(), + limit: 30, + }, + ); + let list_box = list_box.clone(); + let hits = hits.clone(); + let status_label = status_label.clone(); + let spinner = spinner.clone(); + + glib::spawn_future_local(async move { + let res = rx.recv().await; + spinner.stop(); + spinner.set_visible(false); + + match res { + Ok(Ok(Response::SearchResults { hits: search_hits })) => { + repaint_prompt_results(&list_box, &hits, &search_hits); + if search_hits.is_empty() { + status_label.set_text("No matches found."); + } else { + status_label.set_text(&format!("Found {} matches.", search_hits.len())); + } + } + Ok(Ok(Response::Error { message })) => { + status_label.set_text(&format!("Search error: {message}")); + } + _ => { + status_label.set_text("Search request failed."); + } + } + }); +} + +/// Render the provided search hits into the ListBox. +fn repaint_prompt_results( + list_box: >k4::ListBox, + hits: &Rc>>, + new_hits: &[SearchHit], +) { + // Clear all rows + while let Some(child) = list_box.first_child() { + list_box.remove(&child); + } + + *hits.borrow_mut() = new_hits.to_vec(); + + for hit in new_hits { + let row = gtk4::ListBoxRow::new(); + row.add_css_class("launcher-row"); + + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + + let icon_name = if hit.is_dir { + "folder-symbolic" + } else { + "document-symbolic" + }; + let icon = gtk4::Image::from_icon_name(icon_name); + row_box.append(&icon); + + let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + text_box.set_hexpand(true); + + let name_label = gtk4::Label::builder() + .label(&hit.name) + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + + let path_label = gtk4::Label::builder() + .label(&hit.path) + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + path_label.add_css_class("dim-label"); + + text_box.append(&name_label); + text_box.append(&path_label); + + row_box.append(&text_box); + row.set_child(Some(&row_box)); + + list_box.append(&row); + } + + // Automatically select the first item + if !new_hits.is_empty() + && let Some(row) = list_box.row_at_index(0) { + list_box.select_row(Some(&row)); + } +} + +/// Open the selected search hit. Directories open FUSE directly, files trigger cache download. +fn open_hit( + window: &adw::ApplicationWindow, + hit: &SearchHit, + socket: &Path, + mountpoint: &Rc>, + status_label: >k4::Label, + spinner: >k4::Spinner, + is_opening: Rc>, +) { + if hit.is_dir { + let mp = mountpoint.borrow(); + let full_path = Path::new(&*mp).join(&hit.path); + tracing::info!("Opening directory: {}", full_path.display()); + if let Err(e) = Command::new("xdg-open").arg(&full_path).spawn() { + tracing::error!("Failed to xdg-open directory: {e}"); + } + window.close(); + } else { + is_opening.set(true); + status_label.set_text(&format!("Downloading {}...", hit.name)); + spinner.set_visible(true); + spinner.start(); + + let rx = spawn_request( + socket.to_path_buf(), + Request::OpenFile { + path: hit.path.clone(), + }, + ); + let window_clone = window.clone(); + let status_label_clone = status_label.clone(); + let spinner_clone = spinner.clone(); + let is_opening_clone = is_opening.clone(); + + glib::spawn_future_local(async move { + let res = rx.recv().await; + spinner_clone.stop(); + spinner_clone.set_visible(false); + is_opening_clone.set(false); + + match res { + Ok(Ok(Response::FilePath { path })) => { + tracing::info!("Opening file: {}", path); + if let Err(e) = Command::new("xdg-open").arg(&path).spawn() { + tracing::error!("Failed to xdg-open file: {e}"); + } + window_clone.close(); + } + Ok(Ok(Response::Error { message })) => { + status_label_clone.set_text(&format!("Error: {message}")); + } + _ => { + status_label_clone.set_text("Error: Failed to communicate with daemon."); + } + } + }); + } +} diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 24d980f..18a6386 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -43,7 +43,8 @@ build() { cargo build --release --frozen \ --bin pdfs \ --bin pdfs-tray \ - --bin pdfs-app + --bin pdfs-app \ + --bin pdfs-prompt } package() { @@ -53,6 +54,7 @@ package() { install -Dm755 "$rel/pdfs" "$pkgdir/usr/bin/pdfs" install -Dm755 "$rel/pdfs-tray" "$pkgdir/usr/bin/pdfs-tray" install -Dm755 "$rel/pdfs-app" "$pkgdir/usr/bin/pdfs-app" + install -Dm755 "$rel/pdfs-prompt" "$pkgdir/usr/bin/pdfs-prompt" install -Dm644 "packaging/io.narl.proton-drive-linux.desktop" \ "$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"