diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1f0283e0e1..0f56341bb8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (250) +## In this repository (251) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -15,6 +15,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 64fc7a96ee..5b7db4b38f 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b2afa2c39b397f44feed8aa3ebbaf8103c8d69c8e75a9671e01cf08140c85a4b" +sha256 = "741e71280fc40ccf711fd8c2d7cf9ad66ceb4305827b2a82b462c2a27af25d92" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6d61957b3b6208ceb5d313638c610e81eca6ee186a4fe03ece08b4f87f39fd0c" +sha256 = "7e64066dd8d5a872899a1bd19916ee4b3a57effffe54a55d6e0a5777798fc97d" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6accf0a44ae93cab6fde939458c0dd651687ce36760270bd3646b94668db1cda" +sha256 = "1facd561cbfbfa4f2cc81c783f4ed7675871a3d63724e8b2c0fb9e29550ea4f3" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "56159c856872ae5a6a521b02cf6dc682702ece1dff27a8cee0bd5fad24d3e627" +sha256 = "c041497ad777eb4616a204a2fbe78c6bfc38150632450d83d8498f1c573b74eb" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "47ea331f7cf88a7e8584d358999d03af8b4250904a40d450f3d23bb151ce5c78" +sha256 = "e7c4d57500dea64165fe9a356bab10ed1e288bf3a8b1d6ab8377ee642e0fd109" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a4f02e370ce90fc5917915ebaa79ffe82a679e5e2b25087b7a6646430de1e457" +sha256 = "4a8508d6fd385dff2d9214e41719824639c0608e5807c46d32ad2933e679f6e1" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f862797c038b3c5e2d91d387356ebe2b38d90a0e25f4a2154ae73182af575f59" +sha256 = "b2a4bb2fc0eb247cada87f14c8186abf72cd36b7269d173503fcb65383a9439e" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3c5adb79844f784648b3d6bb0056026d498a683ac67b0746df118b1faaf69aa1" +sha256 = "d9419953763bcb55a017196d72d28e8e47eb5e9197fd508e348bc047fcb433db" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "38eae66397d61c9ed13bef9c393f612df08936b3ac066a3e8af3a6aea36d7b5e" +sha256 = "f8af4c1a71747533dc4b609c495c19c47f9ad6aefe1f470dfcb6440a43f3e55f" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/ROADMAP.md b/ROADMAP.md index 34478b73b3..e61995ad1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -136,6 +136,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%). + - **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M14, Simplified Chinese About):** The About tabs, contributor controls and statistics, and model table now have Simplified Chinese translations, along with multiline link-privacy and cut-and-stack guidance. Coverage checks include direct `i18n::t` calls, multiline literals and dynamic credit labels; UI control checks exercise the translated About tabs and tables. Chinese font delivery remains incomplete; overall ≈ 30–35% (unchanged). diff --git a/contributors/people.toml b/contributors/people.toml index b3fb1e040a..00beb65874 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,6 @@ sync_github_name = false real_name = "Chester Fung" display_name = "Chester Fung" sync_github_name = true + +[people.amitabhthakur] +sync_github_name = true diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 331652e438..e94691a714 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -150,6 +150,7 @@ const HELP: Option<&str> = Some("Help"); pub const COMMANDS: &[CommandSpec] = &[ c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), + c("file.pin_folder", "Pin folder to Home…", FILE, None, Nothing, "folder-plus"), c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"), c("measure.distance", "Measure distance", None, None, Annotate, "ruler"), c("measure.perimeter", "Measure perimeter", None, None, Annotate, "ruler"), diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 780d3c5e89..0fd087cd8b 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -1102,7 +1102,7 @@ fn grip(ui: &egui::Ui, rect: Rect, colour: Color32) { } /// How long ago `time` was ("5 min ago"); a date after a week. Relative times need no time zone. -fn ago(time: SystemTime) -> String { +pub(crate) fn ago(time: SystemTime) -> String { let secs = SystemTime::now().duration_since(time).map(|d| d.as_secs()).unwrap_or(0); let (mins, hours, days) = (secs / 60, secs / 3600, secs / 86_400); match () { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 86d3a74c01..b3a8665162 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -85,6 +85,7 @@ impl PdfCraftApp { Some(p) => self.open_recent(&p), None => self.notify_tr("No recent files"), }, + "file.pin_folder" => self.pin_folder_dialog(), "page.combine" => self.open_combine_tab(), "file.save" => { self.save_active(SaveTarget::InPlace); diff --git a/crates/ui-egui/src/folders_ui.rs b/crates/ui-egui/src/folders_ui.rs new file mode 100644 index 0000000000..f5cd90b5d6 --- /dev/null +++ b/crates/ui-egui/src/folders_ui.rs @@ -0,0 +1,476 @@ +//! Pinned folders on the Home view: the newest PDFs in folders the user chose (where a scanner +//! saves, or a cloud-synced folder such as iCloud Drive, OneDrive or Dropbox), one click away. +//! +//! Local only: a pinned folder is read through the file system like any other path, never +//! through a storage provider's API, and nothing about it leaves the machine. Folders are +//! listed on a worker thread and at most every few seconds while Home shows, so a slow, huge or +//! offline synced folder never blocks the frame. + +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::SystemTime; + +use crate::PdfCraftApp; + +/// At most this many folders can be pinned. +pub const MAX_PINNED: usize = 8; +/// Home shows this many of a folder's newest PDFs until "Show more" is clicked. +pub const SHOWN: usize = 8; +/// A folder's listing keeps at most this many PDFs (its newest). +pub const MAX_LISTED: usize = 100; +/// Directory entries read per folder, so a huge folder can't stall the scan. +const MAX_ENTRIES: usize = 20_000; +/// While Home shows, the folders are listed again at most this often (seconds). +const RESCAN_SECS: f64 = 5.0; +/// Longest folder path accepted, from the picker or from settings. +const MAX_PATH_CHARS: usize = 4096; + +/// A PDF in a pinned folder. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FolderFile { + pub name: String, + pub path: String, + pub size: u64, + pub modified: Option, +} + +/// What a pinned folder held when it was last listed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FolderListing { + pub folder: String, + /// Newest first (files without a modified time last), at most [`MAX_LISTED`]. + pub files: Vec, + /// How many PDFs the folder holds; can be more than `files.len()`. + pub total: usize, + /// The folder couldn't be read: moved or deleted, no permission, or its drive or cloud sync + /// is offline. It stays pinned and is listed again later. + pub unavailable: bool, +} + +/// The pinned folders and their latest listings. +#[derive(Debug, Default)] +pub struct PinnedFolders { + /// Absolute paths, in the order they were pinned. + pub folders: Vec, + /// The latest finished listing of each folder. + pub listings: Vec, + /// Folders whose "Show more" is open. + pub expanded: Vec, + /// A listing in progress: the worker fills the slot. + scan: Option>>>>, + /// When (egui time) the last listing started. + last_scan: Option, + /// The folders changed since the last listing started: list them on the next frame. + stale: bool, +} + +impl PinnedFolders { + /// The latest listing of `folder`, if one has finished since it was pinned. + pub fn listing(&self, folder: &str) -> Option<&FolderListing> { + self.listings.iter().find(|l| l.folder == folder) + } + + /// Restore pinned folders from settings: absolute paths only, without duplicates, at most + /// [`MAX_PINNED`]. Settings are untrusted, so anything else is ignored. A folder that is + /// missing right now is kept (a synced drive may be offline) and shown as unavailable. + pub(crate) fn restore(&mut self, value: &serde_json::Value) { + let Some(items) = value.as_array() else { return }; + let mut folders = Vec::new(); + for folder in items.iter().filter_map(serde_json::Value::as_str).filter_map(clean) { + if folders.len() >= MAX_PINNED { + break; + } + if !folders.contains(&folder) { + folders.push(folder); + } + } + self.folders = folders; + self.listings.clear(); + self.expanded.clear(); + self.stale = true; + } +} + +/// `path` as a folder to pin: trimmed, without a trailing separator, and absolute; `None` if it +/// can't be one. +fn clean(path: &str) -> Option { + let path = path.trim(); + if path.is_empty() || path.chars().count() > MAX_PATH_CHARS || path.contains('\0') { + return None; + } + let trimmed = path.trim_end_matches(['/', '\\']); + // The file system root ("/") keeps its separator, as does a Windows drive root ("C:\"). + let path = if trimmed.is_empty() || trimmed.ends_with(':') { path } else { trimmed }; + std::path::Path::new(path).is_absolute().then(|| path.to_string()) +} + +/// The PDFs directly inside `folder` (not its subfolders, and not hidden files), newest first. +pub fn list(folder: &str) -> FolderListing { + let mut files = Vec::new(); + let Ok(entries) = std::fs::read_dir(folder) else { + return FolderListing { folder: folder.to_string(), files, total: 0, unavailable: true }; + }; + for entry in entries.flatten().take(MAX_ENTRIES) { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + if name.starts_with('.') || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("pdf")) { + continue; + } + // Follows links, like opening the file does. + let Ok(meta) = std::fs::metadata(&path) else { continue }; + if meta.is_file() { + files.push(FolderFile { name, path: path.to_string_lossy().into_owned(), size: meta.len(), modified: meta.modified().ok() }); + } + } + let total = files.len(); + // Newest first; `None` sorts before any time, so files without one come last. + files.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.name.cmp(&b.name))); + files.truncate(MAX_LISTED); + FolderListing { folder: folder.to_string(), files, total, unavailable: false } +} + +/// The last part of `folder`'s path, for headings and messages. +pub fn folder_name(folder: &str) -> String { + std::path::Path::new(folder).file_name().map_or_else(|| folder.to_string(), |n| n.to_string_lossy().into_owned()) +} + +/// Where `folder` is, in words, so two folders with the same name can be told apart without a +/// long path: "iCloud Drive", "OneDrive · Contoso", "Documents › Work", or the parent path with +/// the home folder as "~". The full path is shown on hover. +pub fn location(folder: &str) -> String { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from); + location_in(folder, home.as_deref()) +} + +/// [`location`] with the home folder given (tests). +pub fn location_in(folder: &str, home: Option<&std::path::Path>) -> String { + use std::path::Path; + let parent = Path::new(folder).parent().unwrap_or_else(|| Path::new(folder)); + let Some(rel) = home.and_then(|h| parent.strip_prefix(h).ok()) else { + return parent.to_string_lossy().into_owned(); + }; + let parts: Vec = rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect(); + let (place, rest) = match parts.iter().map(String::as_str).collect::>().as_slice() { + [] => return "~".to_string(), + // macOS keeps iCloud Drive and File Provider cloud drives under ~/Library. + ["Library", "Mobile Documents", "com~apple~CloudDocs", ..] => ("iCloud Drive".to_string(), 3), + ["Library", "CloudStorage", drive, ..] => (cloud_drive(drive), 3), + _ => (String::new(), 0), + }; + let rest: Vec<&str> = parts.iter().skip(rest).map(String::as_str).collect(); + match (place.is_empty(), rest.is_empty()) { + (true, _) => rest.join(" › "), + (false, true) => place, + (false, false) => format!("{place} › {}", rest.join(" › ")), + } +} + +/// A `~/Library/CloudStorage` folder name ("OneDrive-Contoso", "GoogleDrive-me@example.com", +/// "Dropbox") as the drive's name and, shortened, its account. +fn cloud_drive(folder: &str) -> String { + let (provider, account) = folder.split_once('-').unwrap_or((folder, "")); + let (provider, account) = match (provider, account.split_once('-')) { + ("OneDrive", Some(("SharedLibraries", org))) => ("SharePoint", org), + ("GoogleDrive", _) => ("Google Drive", account), + _ => (provider, account), + }; + // Business accounts are named after the organisation with its spaces removed, which can be + // very long: its first part is enough to tell accounts apart. + let account = account.split('-').next().unwrap_or(account); + let account: String = if account.chars().count() > 32 { account.chars().take(31).chain(['…']).collect() } else { account.to_string() }; + if account.is_empty() { provider.to_string() } else { format!("{provider} · {account}") } +} + +impl PdfCraftApp { + /// File ▸ Pin folder to Home…: choose a folder whose newest PDFs Home lists. + pub fn pin_folder_dialog(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + let dialog = rfd::AsyncFileDialog::new().set_title(tl!("Pin a folder to Home").to_string()); + self.ask_one(crate::pickers::Ask::Folder(dialog), None, |app, dir| { + app.pin_folder(&dir.to_string_lossy()); + }); + } + #[cfg(target_arch = "wasm32")] + self.notify_tr("Pinned folders need the desktop app"); + } + + /// Pin `folder` (an absolute path) to Home. Returns false, after telling the user, when it + /// can't be pinned. Pinning a folder again changes nothing. + pub fn pin_folder(&mut self, folder: &str) -> bool { + let Some(folder) = clean(folder) else { + self.notify_tr("Choose a folder on this computer to pin"); + return false; + }; + let name = folder_name(&folder); + if self.pinned.folders.contains(&folder) { + self.notify_fmt("{folder} is already pinned to Home", &[("folder", &name)]); + return true; + } + if self.pinned.folders.len() >= MAX_PINNED { + self.notify_fmt("You can pin up to {n} folders. Unpin one first.", &[("n", &MAX_PINNED.to_string())]); + return false; + } + self.pinned.folders.push(folder); + self.pinned.stale = true; + self.notify_fmt("Pinned {folder} to Home", &[("folder", &name)]); + true + } + + /// Remove `folder` from Home. Its files are not touched. + pub fn unpin_folder(&mut self, folder: &str) { + self.pinned.folders.retain(|f| f != folder); + self.pinned.listings.retain(|l| l.folder != folder); + self.pinned.expanded.retain(|f| f != folder); + } + + /// List the pinned folders again if the last listing is older than a few seconds or the + /// folders changed (called while Home shows; `now` is egui time). The listing runs on a + /// worker thread, inline in tests ([`PdfCraftApp::run_inline`]). + pub fn refresh_pinned(&mut self, now: f64) { + self.poll_pinned(); + if self.pinned.scan.is_some() { + return; + } + if self.pinned.folders.is_empty() { + self.pinned.listings.clear(); + return; + } + // `now < last` means the clock restarted (a new context in tests): list again. + let due = self.pinned.stale || self.pinned.last_scan.is_none_or(|last| now < last || now - last >= RESCAN_SECS); + if !due { + return; + } + self.pinned.stale = false; + self.pinned.last_scan = Some(now); + let folders = self.pinned.folders.clone(); + let slot = Arc::new(Mutex::new(None)); + let out = slot.clone(); + let ctx = self.ctx.clone(); + let work = move || { + let listings: Vec = folders.iter().map(|f| list(f)).collect(); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(listings); + if let Some(ctx) = ctx { + ctx.request_repaint(); + } + }; + if self.run_inline { + work(); + } else if std::thread::Builder::new().name("pdfcraft-folders".into()).spawn(work).is_err() { + // No worker: try again when the next listing is due. + return; + } + self.pinned.scan = Some(slot); + self.poll_pinned(); + } + + /// Take a finished listing, if there is one. + fn poll_pinned(&mut self) { + let Some(slot) = &self.pinned.scan else { return }; + let done = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); + match done { + Some(listings) => { + self.pinned.scan = None; + // A folder unpinned while it was being listed stays gone. + let folders = &self.pinned.folders; + self.pinned.listings = listings.into_iter().filter(|l| folders.contains(&l.folder)).collect(); + } + // The worker ended without a result (it panicked): list again when due. + None if Arc::strong_count(slot) == 1 => self.pinned.scan = None, + None => {} + } + } +} + +/// The Home view's Folders section (desktop only: the web build has no folders to read). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + use egui::{Align2, CornerRadius, Rect, Sense, vec2}; + + use crate::theme::{self, Tokens}; + use crate::{icons, widgets}; + + let t = Tokens::get(ui.ctx()); + app.refresh_pinned(ui.input(|i| i.time)); + let mut pin = false; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Folders")).font(theme::semibold(17.0))); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + pin = widgets::ghost_button(ui, "folder-plus", tl!("Pin a folder…")).clicked(); + }); + }); + ui.add_space(4.0); + if app.pinned.folders.is_empty() { + ui.label( + egui::RichText::new(tl!("Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them.")) + .color(t.text_muted), + ); + } + let mut open = None; + let mut unpin = None; + let mut toggle = None; + for folder in &app.pinned.folders { + let name = folder_name(folder); + ui.add_space(6.0); + ui.horizontal(|ui| { + let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + icons::paint(ui, icon, "folder", 20.0, t.icon); + ui.label(egui::RichText::new(crate::bidi::visual(&name)).font(theme::semibold(14.0))).on_hover_text(folder); + ui.add_space(4.0); + // Leave room for Unpin: a long location is cut short, never the button. + let room = (ui.available_width() - 110.0).max(0.0); + ui.scope(|ui| { + ui.set_max_width(room); + let place = egui::RichText::new(crate::bidi::visual(&location(folder))).font(theme::regular(12.0)).color(t.text_faint); + ui.add(egui::Label::new(place).truncate()).on_hover_text(folder); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let resp = widgets::ghost_button(ui, "x", tl!("Unpin")); + // Every folder has an Unpin button: say which one it is. + let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + if resp.clicked() { + unpin = Some(folder.clone()); + } + }); + }); + let note = |ui: &mut egui::Ui, text: &str| { + ui.add_space(2.0); + ui.label(egui::RichText::new(text).color(t.text_muted)); + }; + let Some(listing) = app.pinned.listing(folder) else { + note(ui, tl!("Looking for PDFs…")); + continue; + }; + if listing.unavailable { + note(ui, tl!("This folder isn't available right now. It may have moved, or its drive or cloud sync is offline.")); + continue; + } + if listing.files.is_empty() { + note(ui, tl!("No PDFs in this folder yet")); + continue; + } + let expanded = app.pinned.expanded.contains(folder); + let shown = if expanded { listing.files.len() } else { listing.files.len().min(SHOWN) }; + for f in listing.files.iter().take(shown) { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &f.name)); + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover); + } + icons::paint( + ui, + Rect::from_min_size(rect.min + vec2(32.0, 9.0), vec2(20.0, 20.0)), + "file-text", + 18.0, + egui::Color32::from_rgb(0xE0, 0x3E, 0x3E), + ); + ui.painter().text(rect.min + vec2(62.0, 19.0), Align2::LEFT_CENTER, crate::bidi::visual(&f.name), theme::medium(13.0), t.text); + let size = crate::panels::human_size(usize::try_from(f.size).unwrap_or(usize::MAX)); + let when = f.modified.map(crate::combine_ui::ago).unwrap_or_default(); + let detail = if when.is_empty() { size } else { format!("{when} · {size}") }; + ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted); + if resp.on_hover_text(&f.path).clicked() { + open = Some(f.path.clone()); + } + } + let more = listing.files.len().saturating_sub(shown); + let capped = listing.total > listing.files.len(); + if more > 0 || (expanded && (listing.files.len() > SHOWN || capped)) { + ui.horizontal(|ui| { + ui.add_space(30.0); + let label = if expanded { tl!("Show less").to_string() } else { crate::i18n::fmt(tl!("Show {n} more"), &[("n", &more.to_string())]) }; + if (more > 0 || listing.files.len() > SHOWN) && ui.link(label).clicked() { + toggle = Some(folder.clone()); + } + if expanded && capped { + let n = listing.files.len().to_string(); + let text = crate::i18n::fmt(tl!("The newest {n} of {total} PDFs"), &[("n", &n), ("total", &listing.total.to_string())]); + ui.label(egui::RichText::new(text).color(t.text_faint)); + } + }); + } + } + if pin { + app.pin_folder_dialog(); + } + if let Some(folder) = unpin { + app.unpin_folder(&folder); + } + if let Some(folder) = toggle { + if app.pinned.expanded.contains(&folder) { + app.pinned.expanded.retain(|f| *f != folder); + } else { + app.pinned.expanded.push(folder); + } + } + if let Some(path) = open { + app.open_recent(&path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folders_from_settings_must_be_absolute_unique_and_few() { + let mut p = PinnedFolders::default(); + let root = if cfg!(windows) { "C:\\" } else { "/" }; + let a = format!("{root}scans"); + let many: Vec = (0..20).map(|i| format!("{root}f{i}")).collect(); + let mut items = vec![ + serde_json::json!(5), + serde_json::json!("relative/folder"), + serde_json::json!(""), + serde_json::json!(format!("{a}/")), + serde_json::json!(a.clone()), + serde_json::json!("x".repeat(MAX_PATH_CHARS + 1)), + ]; + items.extend(many.iter().map(|f| serde_json::json!(f))); + p.restore(&serde_json::Value::Array(items)); + assert_eq!(p.folders.len(), MAX_PINNED); + assert_eq!(p.folders.first(), Some(&a), "trailing separator trimmed, duplicate dropped"); + assert!(p.folders.iter().all(|f| std::path::Path::new(f).is_absolute())); + p.restore(&serde_json::json!("not a list")); + assert_eq!(p.folders.len(), MAX_PINNED, "garbage keeps what is there"); + } + + #[test] + fn the_root_keeps_its_separator() { + if cfg!(windows) { + assert_eq!(clean("C:\\").as_deref(), Some("C:\\")); + } else { + assert_eq!(clean("/").as_deref(), Some("/")); + assert_eq!(clean(" /tmp/scans// ").as_deref(), Some("/tmp/scans")); + } + assert_eq!(clean("a\0b"), None); + } + + #[cfg(unix)] + #[test] + fn locations_are_said_in_words() { + let home = Some(std::path::Path::new("/Users/me")); + let at = |p: &str| location_in(p, home); + assert_eq!(at("/Users/me/Library/Mobile Documents/com~apple~CloudDocs/Scans"), "iCloud Drive"); + assert_eq!(at("/Users/me/Library/Mobile Documents/com~apple~CloudDocs/Personal/Scans"), "iCloud Drive › Personal"); + assert_eq!(at("/Users/me/Library/CloudStorage/OneDrive-ContosoLtd-salesandservice/Scans"), "OneDrive · ContosoLtd"); + assert_eq!(at("/Users/me/Library/CloudStorage/OneDrive-Personal/Scans"), "OneDrive · Personal"); + assert_eq!(at("/Users/me/Library/CloudStorage/OneDrive-SharedLibraries-Contoso/Team/Scans"), "SharePoint · Contoso › Team"); + assert_eq!(at("/Users/me/Library/CloudStorage/GoogleDrive-me@example.com/My Drive/Scans"), "Google Drive · me@example.com › My Drive"); + assert_eq!(at("/Users/me/Library/CloudStorage/Dropbox/Scans"), "Dropbox"); + assert_eq!(at("/Users/me/Documents/Work/Scans"), "Documents › Work"); + assert_eq!(at("/Users/me/Scans"), "~"); + assert_eq!(at("/Volumes/NAS/Scans"), "/Volumes/NAS"); + assert_eq!(location_in("/Users/me/Documents/Scans", None), "/Users/me/Documents"); + let long = format!("/Users/me/Library/CloudStorage/OneDrive-{}/Scans", "x".repeat(50)); + assert_eq!(at(&long).chars().count(), "OneDrive · ".chars().count() + 32); + } + + #[test] + fn a_missing_folder_lists_as_unavailable() { + let missing = std::env::temp_dir().join(format!("pdfcraft-no-such-folder-{}", std::process::id())); + let l = list(&missing.to_string_lossy()); + assert!(l.unavailable); + assert!(l.files.is_empty()); + } +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 4d1d95c115..7e21c126b8 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -1,4 +1,5 @@ -//! Home tab: recommended tools, open card, recent files (local only, never another app's list). +//! Home tab: recommended tools, open card, pinned folders and recent files (local only, never +//! another app's list). use egui::{Align2, CornerRadius, Rect, Sense, Stroke, vec2}; use pdfcraft_engine::catalog; @@ -96,6 +97,12 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); + #[cfg(not(target_arch = "wasm32"))] + { + ui.add_space(26.0); + crate::folders_ui::section(app, ui); + } + ui.add_space(26.0); ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0))); ui.add_space(8.0); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c1e69d9f5a..76d0008ece 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -29,6 +29,7 @@ OK OK Open… Otevřít… Open Recent Otevřít nedávné soubory + Pin folder to Home… Připnout složku na úvodní stránku… No recent files Žádné nedávné soubory New blank PDF Nový prázdný dokument PDF Create PDF from file… Vytvořit PDF ze souboru… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 657d73f83f..828b9c5daf 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -22,6 +22,24 @@ Name on new comments Nombre en los comentarios nuevos Open… Abrir… Open Recent Abrir recientes + Pin folder to Home… Anclar carpeta en Inicio… + Folders Carpetas + Pin a folder… Anclar una carpeta… + Pin a folder to Home Anclar una carpeta en Inicio + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Ancla una carpeta para ver aquí sus PDF más recientes, por ejemplo donde los guarda tu escáner o una unidad en la nube sincronizada. + Unpin Desanclar + Unpin {folder} Desanclar {folder} + Looking for PDFs… Buscando PDF… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Esta carpeta no está disponible ahora. Puede que se haya movido o que su unidad o la sincronización en la nube estén sin conexión. + No PDFs in this folder yet Aún no hay PDF en esta carpeta + Show less Mostrar menos + Show {n} more Mostrar {n} más + The newest {n} of {total} PDFs Los {n} PDF más recientes de {total} + Pinned folders need the desktop app Las carpetas ancladas requieren la aplicación de escritorio + Choose a folder on this computer to pin Elige una carpeta de este equipo para anclarla + {folder} is already pinned to Home {folder} ya está anclada en Inicio + You can pin up to {n} folders. Unpin one first. Puedes anclar hasta {n} carpetas. Desancla una primero. + Pinned {folder} to Home {folder} anclada en Inicio No recent files No hay archivos recientes New blank PDF Nuevo PDF en blanco Create PDF from file… Crear PDF a partir de un archivo… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b99824aa06..2544324b4c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2013,4 +2013,22 @@ Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Choisissez une image PNG ou JPEG (jusqu’à 4 Mio). La transparence est conservée. Couldn't import the signature image: {e} Impossible d’importer l’image de la signature : {e} Open Recent Ouvrir un fichier récent + Pin folder to Home… Épingler un dossier à l'Accueil… + Folders Dossiers + Pin a folder… Épingler un dossier… + Pin a folder to Home Épingler un dossier à l'Accueil + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Épinglez un dossier pour afficher ici ses PDF les plus récents, par exemple celui où votre scanner ou un stockage cloud synchronisé les enregistre. + Unpin Désépingler + Unpin {folder} Désépingler {folder} + Looking for PDFs… Recherche de PDF… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Ce dossier n'est pas disponible pour le moment. Il a peut-être été déplacé, ou son disque ou sa synchronisation cloud est hors ligne. + No PDFs in this folder yet Aucun PDF dans ce dossier pour l'instant + Show less Afficher moins + Show {n} more Afficher {n} de plus + The newest {n} of {total} PDFs Les {n} PDF les plus récents sur {total} + Pinned folders need the desktop app Les dossiers épinglés nécessitent l'application de bureau + Choose a folder on this computer to pin Choisissez un dossier de cet ordinateur à épingler + {folder} is already pinned to Home {folder} est déjà épinglé à l'Accueil + You can pin up to {n} folders. Unpin one first. Vous pouvez épingler jusqu'à {n} dossiers. Désépinglez-en un d'abord. + Pinned {folder} to Home {folder} épinglé à l'Accueil No recent files Aucun fichier récent diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b81e885563..d45df9bfc7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -28,6 +28,24 @@ Name on new comments 新しい注釈の作成者名 Open… 開く… Open Recent 最近のファイルを開く + Pin folder to Home… フォルダーをホームにピン留め… + Folders フォルダー + Pin a folder… フォルダーをピン留め… + Pin a folder to Home フォルダーをホームにピン留め + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. フォルダーをピン留めすると、スキャナーや同期中のクラウドドライブの保存先など、そのフォルダー内の新しい PDF がここに表示されます。 + Unpin ピン留めを外す + Unpin {folder} {folder} のピン留めを外す + Looking for PDFs… PDF を探しています… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. このフォルダーは現在利用できません。移動されたか、ドライブまたはクラウド同期がオフラインになっている可能性があります。 + No PDFs in this folder yet このフォルダーにはまだ PDF がありません + Show less 表示を減らす + Show {n} more さらに {n} 件表示 + The newest {n} of {total} PDFs {total} 件中、新しい {n} 件の PDF + Pinned folders need the desktop app フォルダーのピン留めにはデスクトップ版が必要です + Choose a folder on this computer to pin ピン留めするフォルダーをこのコンピューターから選択してください + {folder} is already pinned to Home {folder} はすでにホームにピン留めされています + You can pin up to {n} folders. Unpin one first. ピン留めできるフォルダーは {n} 個までです。先にいずれかのピン留めを外してください。 + Pinned {folder} to Home {folder} をホームにピン留めしました No recent files 最近のファイルがありません New blank PDF 空白の PDF を作成 Create PDF from file… ファイルから PDF を作成… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 56b0deb905..43fc9a6013 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -21,6 +21,7 @@ Name on new comments Nome nos novos comentários Open… Abrir… Open Recent Abrir recentes + Pin folder to Home… Fixar pasta no Início… No recent files Nenhum arquivo recente New blank PDF Novo PDF em branco Create PDF from file… Criar PDF a partir de arquivo… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index d213871049..2ab48e49f2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -21,6 +21,24 @@ Name on new comments Имя в новых комментариях Open… Открыть… Open Recent Открыть недавние + Pin folder to Home… Закрепить папку на главной… + Folders Папки + Pin a folder… Закрепить папку… + Pin a folder to Home Закрепить папку на главной + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. Закрепите папку, чтобы видеть здесь её новые PDF, например папку, куда сохраняет файлы сканер или синхронизируемый облачный диск. + Unpin Открепить + Unpin {folder} Открепить {folder} + Looking for PDFs… Поиск PDF… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. Эта папка сейчас недоступна. Возможно, она перемещена или её диск либо облачная синхронизация не в сети. + No PDFs in this folder yet В этой папке пока нет PDF + Show less Свернуть + Show {n} more Показать ещё {n} + The newest {n} of {total} PDFs Последние {n} PDF из {total} + Pinned folders need the desktop app Для закреплённых папок нужно настольное приложение + Choose a folder on this computer to pin Выберите папку на этом компьютере, чтобы закрепить её + {folder} is already pinned to Home {folder} уже закреплена на главной + You can pin up to {n} folders. Unpin one first. Можно закрепить не более {n} папок. Сначала открепите одну из них. + Pinned {folder} to Home Папка {folder} закреплена на главной No recent files Нет недавних файлов New blank PDF Новый пустой PDF Create PDF from file… Создать PDF из файла… diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 17d7fda3e9..b08744a961 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2016,4 +2016,5 @@ action wizard Start ప్రారంభించు Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG లేదా JPEG చిత్రాన్ని ఎంచుకోండి (గరిష్ఠంగా 4 MiB). పారదర్శకత అలాగే ఉంటుంది. Couldn't import the signature image: {e} సంతకం చిత్రాన్ని దిగుమతి చేయడం సాధ్యం కాలేదు: {e} Open Recent ఇటీవలివి తెరువు + Pin folder to Home… ఫోల్డర్‌ను హోమ్‌కు పిన్ చేయి… No recent files ఇటీవలి ఫైళ్లు లేవు diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4f0487f68a..37ffbabb51 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -62,6 +62,24 @@ Name on new comments 新注释的作者姓名 Open… 打开… Open Recent 打开最近的文件 + Pin folder to Home… 将文件夹固定到主页… + Folders 文件夹 + Pin a folder… 固定文件夹… + Pin a folder to Home 将文件夹固定到主页 + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. 固定一个文件夹,即可在此列出其中最新的 PDF,例如扫描仪或同步云盘保存文件的位置。 + Unpin 取消固定 + Unpin {folder} 取消固定 {folder} + Looking for PDFs… 正在查找 PDF… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. 此文件夹当前不可用。它可能已被移动,或其所在驱动器或云同步处于离线状态。 + No PDFs in this folder yet 此文件夹中还没有 PDF + Show less 收起 + Show {n} more 再显示 {n} 个 + The newest {n} of {total} PDFs 共 {total} 个 PDF 中最新的 {n} 个 + Pinned folders need the desktop app 固定文件夹需要桌面版应用 + Choose a folder on this computer to pin 请选择此电脑上要固定的文件夹 + {folder} is already pinned to Home {folder} 已固定到主页 + You can pin up to {n} folders. Unpin one first. 最多可固定 {n} 个文件夹。请先取消固定一个。 + Pinned {folder} to Home 已将 {folder} 固定到主页 No recent files 没有最近的文件 Open 打开 Open file 打开文件 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8c041d9296..b5ad89e7ad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -21,6 +21,24 @@ Name on new comments 新註解的作者名稱 Open… 開啟… Open Recent 開啟最近的檔案 + Pin folder to Home… 將資料夾釘選到首頁… + Folders 資料夾 + Pin a folder… 釘選資料夾… + Pin a folder to Home 將資料夾釘選到首頁 + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. 釘選資料夾後,即可在此列出其中最新的 PDF,例如掃描器或同步雲端硬碟儲存檔案的位置。 + Unpin 取消釘選 + Unpin {folder} 取消釘選 {folder} + Looking for PDFs… 正在尋找 PDF… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. 此資料夾目前無法使用。它可能已被移動,或其所在磁碟或雲端同步處於離線狀態。 + No PDFs in this folder yet 此資料夾中還沒有 PDF + Show less 顯示較少 + Show {n} more 再顯示 {n} 個 + The newest {n} of {total} PDFs 共 {total} 個 PDF 中最新的 {n} 個 + Pinned folders need the desktop app 釘選資料夾需要桌面版應用程式 + Choose a folder on this computer to pin 請選擇這部電腦上要釘選的資料夾 + {folder} is already pinned to Home {folder} 已釘選到首頁 + You can pin up to {n} folders. Unpin one first. 最多可釘選 {n} 個資料夾。請先取消釘選一個。 + Pinned {folder} to Home 已將 {folder} 釘選到首頁 No recent files 沒有最近的檔案 Open 開啟 Open file 開啟檔案 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1137e689c7..db6137b4e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -67,6 +67,7 @@ mod edit_text_ui; mod editing; mod files; pub mod fill_sign; +pub mod folders_ui; pub mod forms_ui; mod home; mod icon_data; @@ -373,6 +374,8 @@ pub struct PdfCraftApp { pub palette_query: String, pub all_tools_expanded: bool, pub recent: Vec, + /// Folders pinned to Home, and what they held when last listed. + pub pinned: folders_ui::PinnedFolders, pub toast: Option<(String, f64)>, /// Whether the macOS title bar is drawn by us (traffic lights over our tab strip). pub integrated_titlebar: bool, @@ -420,7 +423,7 @@ pub struct PdfCraftApp { pub ocr_draft: ocr_ui::OcrDraft, pub ocr_run: Option, pub ocr_batch: Option>>, - /// Background jobs (OCR, actions) run inline instead (tests). + /// Background jobs (OCR, actions, listing pinned folders) run inline instead (tests). pub run_inline: bool, /// Action Wizard: the user's actions, the dialog state, the running action and (tests) the /// files to use instead of a picker. @@ -612,6 +615,7 @@ impl PdfCraftApp { palette_query: String::new(), all_tools_expanded: false, recent: Vec::new(), + pinned: Default::default(), toast: None, integrated_titlebar: false, password_prompt: None, @@ -1166,6 +1170,7 @@ impl PdfCraftApp { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); serde_json::json!({ "recent": self.recent, + "pinned_folders": self.pinned.folders, "theme": self.theme_preference, "default_mode": self.default_mode, "default_layout": self.view_defaults.layout.as_str(), @@ -1199,6 +1204,7 @@ impl PdfCraftApp { let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); self.recent = r; } + self.pinned.restore(&v["pinned_folders"]); if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) { self.set_theme_preference(preference); } diff --git a/crates/ui-egui/tests/folders.rs b/crates/ui-egui/tests/folders.rs new file mode 100644 index 0000000000..bfc5196d14 --- /dev/null +++ b/crates/ui-egui/tests/folders.rs @@ -0,0 +1,210 @@ +//! Pinned folders on Home: File ▸ Pin folder to Home…, the newest PDFs listed per folder, +//! opening one, unpinning, and the setting surviving a restart. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::folders_ui::{MAX_PINNED, SHOWN}; + +/// A one-page PDF. +const FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A fresh, empty folder under the temp dir, removed when dropped. +struct TempFolder(PathBuf); + +impl TempFolder { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("pdfcraft-pinned-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } + + fn path(&self) -> String { + self.0.to_string_lossy().into_owned() + } + + /// Write `name` with `bytes`, last modified `age` ago. + fn file(&self, name: &str, bytes: &[u8], age: Duration) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, bytes).unwrap(); + let file = std::fs::File::options().write(true).open(&path).unwrap(); + file.set_modified(SystemTime::now() - age).unwrap(); + path + } +} + +impl Drop for TempFolder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + // List folders on the frame that asks, not on a worker thread. + app.run_inline = true; + setup(&mut app); + app + }); + // Fonts install on frame 1 and apply on frame 2. + h.run_steps(4); + h +} + +fn listed(h: &Harness<'static, PdfCraftApp>, folder: &str) -> Vec { + h.state().pinned.listing(folder).map(|l| l.files.iter().map(|f| f.name.clone()).collect()).unwrap_or_default() +} + +#[test] +fn a_pinned_folder_lists_its_newest_pdfs_on_home() { + let dir = TempFolder::new("newest"); + dir.file("older.pdf", FIXTURE, Duration::from_secs(2 * 86_400)); + dir.file("newer.PDF", FIXTURE, Duration::from_secs(300)); + dir.file("notes.txt", b"not a pdf", Duration::from_secs(60)); + dir.file(".hidden.pdf", FIXTURE, Duration::from_secs(60)); + std::fs::create_dir_all(dir.0.join("inner.pdf")).unwrap(); + let folder = dir.path(); + let pick = folder.clone(); + let mut h = harness(move |app| app.pick_override = Some(vec![pick])); + h.get_by_label("Pin a folder…"); + assert!(h.state_mut().execute("file.pin_folder")); + h.run_steps(4); + assert_eq!(h.state().pinned.folders, std::slice::from_ref(&folder)); + assert_eq!(listed(&h, &folder), ["newer.PDF", "older.pdf"], "newest first; no text files, hidden files or folders"); + assert_eq!(h.state().pinned.listing(&folder).map(|l| l.total), Some(2)); + h.get_by_label("newer.PDF"); + h.get_by_label("older.pdf"); + assert!(h.query_by_label("notes.txt").is_none()); + assert!(h.query_by_label(".hidden.pdf").is_none()); +} + +#[test] +fn clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent() { + let dir = TempFolder::new("open"); + let path = dir.file("scan.pdf", FIXTURE, Duration::from_secs(10)); + let folder = dir.path(); + let mut h = harness(move |app| { + app.pin_folder(&folder); + }); + h.run_steps(2); + h.get_by_label("scan.pdf").click(); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1, "the PDF opens in a tab"); + assert_eq!(app.recent.first().map(|r| r.path.clone()), Some(path.to_string_lossy().into_owned())); +} + +#[test] +fn new_scans_appear_without_reopening_home() { + let dir = TempFolder::new("rescan"); + dir.file("first.pdf", FIXTURE, Duration::from_secs(600)); + let folder = dir.path(); + let pin = folder.clone(); + let mut h = harness(move |app| { + app.pin_folder(&pin); + }); + h.run_steps(2); + assert_eq!(listed(&h, &folder), ["first.pdf"]); + dir.file("second.pdf", FIXTURE, Duration::from_secs(5)); + // The listing is refreshed every few seconds while Home shows. + h.state_mut().refresh_pinned(1.0e6); + assert_eq!(listed(&h, &folder), ["second.pdf", "first.pdf"]); +} + +#[test] +fn unpinning_removes_the_folder_but_not_its_files() { + let dir = TempFolder::new("unpin"); + let path = dir.file("keep.pdf", FIXTURE, Duration::from_secs(10)); + let folder = dir.path(); + let pin = folder.clone(); + let mut h = harness(move |app| { + app.pin_folder(&pin); + }); + h.run_steps(2); + let name = pdfcraft_ui_egui::folders_ui::folder_name(&folder); + h.get_by_label(&format!("Unpin {name}")).click(); + h.run_steps(2); + assert!(h.state().pinned.folders.is_empty()); + assert!(h.query_by_label("keep.pdf").is_none()); + assert!(path.exists(), "unpinning never touches the files"); +} + +#[test] +fn pinned_folders_survive_a_restart() { + let dir = TempFolder::new("restart"); + let folder = dir.path(); + let mut a = PdfCraftApp::new(); + assert!(a.pin_folder(&folder)); + let mut b = PdfCraftApp::new(); + b.restore(&a.persist()); + assert_eq!(b.pinned.folders, [folder]); + b.restore("{\"pinned_folders\": [5, \"relative/path\"]}"); + assert!(b.pinned.folders.is_empty(), "only absolute folder paths are restored"); + b.restore("{not json"); + assert!(b.pinned.folders.is_empty()); +} + +#[test] +fn an_unavailable_folder_says_so_and_stays_pinned() { + let missing = std::env::temp_dir().join(format!("pdfcraft-pinned-gone-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&missing); + let folder = missing.to_string_lossy().into_owned(); + let pin = folder.clone(); + let mut h = harness(move |app| { + app.pin_folder(&pin); + }); + h.run_steps(2); + h.get_by_label_contains("isn't available right now"); + assert_eq!(h.state().pinned.folders, std::slice::from_ref(&folder)); + // It comes back by itself once the folder (or its synced drive) is there again. + std::fs::create_dir_all(&missing).unwrap(); + std::fs::write(missing.join("back.pdf"), FIXTURE).unwrap(); + h.state_mut().refresh_pinned(1.0e6); + h.run_steps(2); + let _ = std::fs::remove_dir_all(&missing); + assert_eq!(listed(&h, &folder), ["back.pdf"]); +} + +#[test] +fn show_more_reveals_the_older_pdfs() { + let dir = TempFolder::new("more"); + for i in 0..SHOWN + 2 { + dir.file(&format!("scan{i:02}.pdf"), FIXTURE, Duration::from_secs(60 * (i as u64 + 1))); + } + let folder = dir.path(); + let mut h = harness(move |app| { + app.pin_folder(&folder); + }); + h.run_steps(2); + h.get_by_label("scan00.pdf"); + assert!(h.query_by_label("scan09.pdf").is_none(), "only the newest {SHOWN} show at first"); + h.get_by_label("Show 2 more").click(); + h.run_steps(2); + h.get_by_label("scan09.pdf"); + h.get_by_label("Show less"); +} + +#[test] +fn pinning_is_limited_and_never_duplicates() { + let mut app = PdfCraftApp::new(); + assert!(!app.pin_folder("relative/scans"), "a relative path is refused"); + let root = if cfg!(windows) { "C:\\" } else { "/" }; + let folders: Vec = (0..MAX_PINNED).map(|i| Path::new(root).join(format!("pinned-{i}")).to_string_lossy().into_owned()).collect(); + for f in &folders { + assert!(app.pin_folder(f)); + } + assert!(app.pin_folder(&folders[0]), "pinning again is fine"); + assert_eq!(app.pinned.folders.len(), MAX_PINNED, "and adds nothing"); + assert!(!app.pin_folder(&Path::new(root).join("one-too-many").to_string_lossy())); + assert!(app.toast.as_ref().is_some_and(|(m, _)| m.contains("Unpin one first")), "the user is told"); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c790b121b8..0d77445698 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1608,10 +1608,10 @@ area = "B" tier = "P1" milestone = "M3" status = "shipped" -commands = [] +commands = ["file.pin_folder"] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::home_shows_welcome_and_tools"] -notes = "" +evidence = ["crates/ui-egui/tests/ui.rs::home_shows_welcome_and_tools", "crates/ui-egui/tests/folders.rs::a_pinned_folder_lists_its_newest_pdfs_on_home", "crates/ui-egui/tests/folders.rs::clicking_a_pinned_pdf_opens_it_and_adds_it_to_recent", "crates/ui-egui/tests/folders.rs::pinned_folders_survive_a_restart"] +notes = "Desktop: folders pinned to Home (File ▸ Pin folder to Home…, up to 8) list their newest PDFs, read on a worker thread and refreshed every few seconds while Home shows; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Pinned folders are local paths, so a cloud drive works through its synced folder (iCloud Drive, OneDrive, Dropbox). Not yet: subfolders, search within a pinned folder, the web build." [[feature]] id = "view.all-tools" @@ -9555,7 +9555,7 @@ status = "na" commands = [] tools = [] evidence = [] -notes = "Adobe cloud; use OS file dialogs and synced folders" +notes = "Adobe cloud; use OS file dialogs and synced folders, which can be pinned to Home (view.home-view)" [[feature]] id = "misc.cross-device-sync"