diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index bfe6ccecd..75aaceb27 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 = "2d0c3c882f0d842230b32834150df3307111b3b91d3c1c403001c78b8f4202df" +sha256 = "8792b00c7e9a12a247cbf6d4758d8583972f861010f4e4ed52949978e5a380e7" [[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 = "bff6a243f40151a20a5082e02a1e7b588e46fca5f7b616ca618e57576b5218e3" +sha256 = "9429421177790a0492b1513102823a9aa5dbd34c7a850a737792602d4d19b8d9" [[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 = "92cd3816ae6f0cf59244f4836cc95a3b12961c88d219dae593fd60dbc6eb8de0" +sha256 = "9a655daa584e3a5d049dbaaa30229ae7a931984b0f1c7f0177fde2ccdd6804ca" [[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 = "0cd7b7d0696323e281f7a23ef7511785b6a519c94ddb9d89c2468bcdc44bbc84" +sha256 = "777a7f441950714a61d41e5a44405209aa85bfdd4b46e27090544eb9122d715f" [[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 = "de653ffb92e6b99e8d390e6c21f5f13ecef198e21be3045e159b86073fbe7f4e" +sha256 = "cb3759b57445a3948a707aaf9423338053c7e7ad68f19f52583527dae3dfabee" [[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 = "415aa786c8afc8095be14ac9d74b864e968326699ce0130fede5616ccf9e70ad" +sha256 = "b131025cb6c14135440968f08ab6334d70dd3b20271539471878d3825ecc9629" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -85,7 +85,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 = "d3a89359bdb7c8af830a49a1b574312c65f757e4f69cf221e06f27ea0c0e7373" +sha256 = "c9879375eed52003bb16790e687808fe0d50dd9405390be383eab66cd513291c" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index ae68cecca..49b632734 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -149,6 +149,7 @@ const HELP: Option<&str> = Some("Help"); /// Every command, in menu order. 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("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/commands.rs b/crates/ui-egui/src/commands.rs index 799530885..d26b04940 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -80,6 +80,11 @@ impl PdfCraftApp { let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default(); match id { "file.open" => self.open_dialog(), + "file.open_recent" => match self.recent.first().map(|r| r.path.clone()) { + // The palette runs commands without a submenu: open the most recent file. + Some(p) => self.open_recent(&p), + None => self.notify_tr("No recent files"), + }, "page.combine" => self.open_combine_tab(), "file.save" => { self.save_active(SaveTarget::InPlace); @@ -551,6 +556,28 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str for spec in commands::menu(menu) { let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id)); let label = crate::i18n::menu_label(spec.id, &label); + // Open Recent is a submenu of the live recent list, not one action: disabled while the + // list is empty, otherwise each entry opens its file (or focuses the tab showing it). + if spec.id == "file.open_recent" { + if app.recent.is_empty() { + ui.add_enabled(false, egui::Button::new(label)); + continue; + } + let mut open: Option = None; + ui.menu_button(label, |ui| { + for r in &app.recent { + if ui.button(&r.name).on_hover_text(&r.path).clicked() { + open = Some(r.path.clone()); + ui.close(); + } + } + }); + if let Some(p) = open { + app.open_recent(&p); + ui.close(); + } + continue; + } let shortcut = spec.shortcut.map(|s| s.label(mac)).unwrap_or_default(); let enabled = app.command_enabled(spec); let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index e87a531ea..4d1d95c11 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -130,12 +130,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } if let Some(p) = open { - if let Some(i) = app.views.iter().position(|v| app.session.get(v.id).and_then(|d| d.path.as_deref()) == Some(p.as_str())) { - app.active = Some(i); - } else { - #[cfg(not(target_arch = "wasm32"))] - app.open_path(&p); - } + app.open_recent(&p); } ui.add_space(20.0); widgets::section_title(ui, tl!("Privacy")); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index cf0e5d036..1f37708f3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -28,6 +28,8 @@ Enable Acrobat JavaScript Povolit Acrobat JavaScript OK OK Open… Otevřít… + Open Recent Otevřít nedávné soubory + No recent files Žádné nedávné soubory New blank PDF Nový prázdný dokument PDF Create PDF from file… Vytvořit PDF ze souboru… Create PDF from images… Vytvořit PDF z obrázků… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index a3f96154d..f4a560c08 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -21,6 +21,8 @@ Identity Identidad Name on new comments Nombre en los comentarios nuevos Open… Abrir… + Open Recent Abrir recientes + 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… Create PDF from images… Crear PDF a partir de imágenes… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 047daebaf..28391f9ac 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1991,3 +1991,5 @@ {n} selected {n} sélectionnés 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 + 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 14d72fd0d..342955dc2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -27,6 +27,8 @@ Identity 個人情報 Name on new comments 新しい注釈の作成者名 Open… 開く… + Open Recent 最近のファイルを開く + No recent files 最近のファイルがありません New blank PDF 空白の PDF を作成 Create PDF from file… ファイルから PDF を作成… Create PDF from images… 画像から PDF を作成… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5cf6f8bd2..0cbc9a887 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -20,6 +20,8 @@ Identity Identidade Name on new comments Nome nos novos comentários Open… Abrir… + Open Recent Abrir recentes + No recent files Nenhum arquivo recente New blank PDF Novo PDF em branco Create PDF from file… Criar PDF a partir de arquivo… Create PDF from images… Criar PDF a partir de imagens… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e1f0200ed..583acd5f8 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -61,6 +61,8 @@ Identity 身份 Name on new comments 新注释的作者姓名 Open… 打开… + Open Recent 打开最近的文件 + No recent files 没有最近的文件 Open 打开 Open file 打开文件 Home 主页 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 704be78fa..adea61bba 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -20,6 +20,8 @@ Identity 個人資訊 Name on new comments 新註解的作者名稱 Open… 開啟… + Open Recent 開啟最近的檔案 + No recent files 沒有最近的檔案 Open 開啟 Open file 開啟檔案 Home 首頁 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bed9bb6fc..a8c9d795a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -884,6 +884,17 @@ impl PdfCraftApp { } } + /// Open a file from a recent list: focus the tab already showing it, else open it (File ▸ + /// Open Recent and the Home view's list share this). + pub fn open_recent(&mut self, path: &str) { + if let Some(i) = self.views.iter().position(|v| self.session.get(v.id).and_then(|d| d.path.as_deref()) == Some(path)) { + self.active = Some(i); + } else { + #[cfg(not(target_arch = "wasm32"))] + self.open_path(path); + } + } + #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) { let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index d012eaedd..28e4e01bb 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -194,6 +194,50 @@ fn the_pages_menu_comes_from_the_registry() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); } +#[test] +fn the_open_recent_menu_lists_files_and_opens_one() { + let dir = std::env::temp_dir().join(format!("pdfcraft-recent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("recent.pdf"); + std::fs::write(&path, fixture(2)).unwrap(); + let path = path.to_string_lossy().into_owned(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe({ + let path = path.clone(); + move |_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("doc.pdf", None, fixture(3)).unwrap(); + app.recent.push(pdfcraft_ui_egui::RecentFile { name: "recent.pdf".into(), path, pages: 2, size: 0 }); + app + } + }); + h.run_steps(4); + h.get_by_label("Menu").click(); + h.run_steps(2); + h.get_by_label("File ⏵").hover(); + h.run_steps(3); + h.get_by_label("Open Recent ⏵").hover(); + h.run_steps(3); + h.get_by_label_contains("recent.pdf").click(); + h.run_steps(4); + let app = h.state(); + assert_eq!(app.views.len(), 2, "the recent file opened in a new tab"); + let active = app.active.unwrap(); + assert_eq!(app.session.get(app.views[active].id).and_then(|d| d.path.as_deref()), Some(path.as_str()), "the active tab is the recent file"); +} + +#[test] +fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { + let mut h = harness(); + h.get_by_label("Menu").click(); + h.run_steps(2); + h.get_by_label("File ⏵").hover(); + h.run_steps(3); + assert!( + h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), + "Open Recent is disabled while no file has been opened" + ); +} + #[test] fn the_shortcuts_dialog_lists_the_real_bindings() { let mut h = harness(); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d610056b2..1fe73ce27 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -656,10 +656,10 @@ area = "A" tier = "P1" milestone = "M3" status = "shipped" -commands = [] +commands = ["file.open_recent"] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage"] -notes = "never reads other apps' lists" +evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty"] +notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it" [[feature]] id = "core.usage-rights-preserve"