From 5e12f5fd6d5dcf86bb332ca5940c622059168fea Mon Sep 17 00:00:00 2001 From: Ibn Zwanckaert <71528616+ThatGhost@users.noreply.github.com> Date: Wed, 14 Oct 2026 04:06:20 +0200 Subject: [PATCH] feat(ui): reorder and cycle document tabs Reorder document tabs by dragging while keeping the active document and its view state. Add Ctrl+Tab and Ctrl+Shift+Tab cycling in visible order, including Combine files, and translate the menu labels. Keep timestamped batch test expectations tied to the originally generated PDF bytes. Closes #1537 Closes #1542 Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.toml | 42 +++++----- crates/automation/tests/batch.rs | 27 ++++--- crates/engine/src/commands.rs | 5 ++ crates/ui-egui/src/chrome.rs | 115 +++++++++++++++++++++++++++- crates/ui-egui/src/commands.rs | 50 ++++++++++++ crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 14 ++++ crates/ui-egui/tests/commands.rs | 42 ++++++++++ crates/ui-egui/tests/ui.rs | 71 +++++++++++++++++ parity/acrobat-features.toml | 6 +- 30 files changed, 376 insertions(+), 38 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 714e7763..07433fc4 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d0a69a35403e70b94c3adfef88b41ec719f4f6ab700d351c8f82a78c53b63ef6" +sha256 = "389982dcbb7f1ac29fb150c9af43f9529db6e59a091b9971f0401684847d8b53" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,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 = "caa23201b7f25dba9f8dc579771e13435e5e8c72a7efb9770e917a2a33003a39" +sha256 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,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 = "5ead9fccdca17810a3a98de3fa499d8a2469a1ff015c3d0e11880ca629295ea1" +sha256 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,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 = "d48f9d68711850cae48af53f7691facee4209f7846e3eb2c064d97dad2ae2426" +sha256 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,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 = "6bc2edec99534d0df9b5e4a1c2e18dc32ad84c32e404c74dd437d86119b1d6ea" +sha256 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,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 = "cb6c04bd36ba6ce0ebc808da2e47b65797e14d59466e93802adec4d4f5056e3b" +sha256 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,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 = "0368944155991c2ea304788418fb326f30e45649539a0cf49539194b026ca104" +sha256 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f4969c353f1424c6b9b5b110820c69dc59907590d013221511eae3f86c29024e" +sha256 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,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 = "4bb6fa4f3b6e236a4a353df79acd0c5528ea8354058eeaeb66c28bc0f7e02939" +sha256 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "adcbdfc683589b067e062a6200084bf9192935b297f5b616f8ed39db9c71db9b" +sha256 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "aebe63cd91df2d2f9d2bdc1da43b017823dbc8a32135e999b9ac7041277be0c8" +sha256 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,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 = "3c8e3a28a5b751c6d6384809d4fd81c14c2ff5f561aaee5b58d24fbfa2ca4dc1" +sha256 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "637fcfdd0bf1898b413af7ccbc2eb4228bf656273de9f3ef9241bd128940aeab" +sha256 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5285f8987bd8c265f73ff443906c1832a0886037592d334974cf539b9f972a3f" +sha256 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "23779abc08be40ddfb305fd638fe280c854ac33473b2822006ba8bad399c0cad" +sha256 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,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 = "c5129c40b202d8467d8d4cf0a189da8c2ba2af711998edb73f67e1b291289a04" +sha256 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "11ed2aa56dc5f8f6656eba717500c74bb8c8f931c08f9f9b210b5b33dd9c2d5a" +sha256 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a3863e2fed5ef584a87f65083a6b9fedf26d18ecebd574efce22bd7a01e3281c" +sha256 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cade94846289c2822b408a2ea23e6c8f8f321708f4f27fd563695a3c4fade6a1" +sha256 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "35b6b03dae01b9a2e98f6d1f682d0699d77772e6efec7e28e1f26039c57b151f" +sha256 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64" [[asset]] path = "assets/icons/accessibility.svg" @@ -3351,4 +3351,4 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2685fbb694cbdeb7632ecb5e7396e1209047c83319e28ceea032b3ec9fb2fc0e" +sha256 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288" diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/batch.rs index 20a33591..b2d34289 100644 --- a/crates/automation/tests/batch.rs +++ b/crates/automation/tests/batch.rs @@ -9,6 +9,7 @@ fn result(auto: &mut Automation, args: Value) -> Value { } } +// Fresh fixtures include the current timestamp; retain their bytes for output comparisons. fn pdf() -> Vec { Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() } @@ -49,8 +50,9 @@ fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("input/sub")).unwrap(); std::fs::write(dir.path().join("input/0.pdf"), b"broken").unwrap(); - std::fs::write(dir.path().join("input/1.pdf"), pdf()).unwrap(); - std::fs::write(dir.path().join("input/sub/2.pdf"), pdf()).unwrap(); + let original = pdf(); + std::fs::write(dir.path().join("input/1.pdf"), &original).unwrap(); + std::fs::write(dir.path().join("input/sub/2.pdf"), &original).unwrap(); let mut auto = Automation::new().with_root(dir.path()).unwrap(); let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out","stop_on_error":true})); assert_eq!(report["failed"], 1); @@ -61,7 +63,7 @@ fn batch_is_nonrecursive_by_default_and_stop_reports_unprocessed_files() { let report = result(&mut auto, json!({"paths":["input"],"steps":["optimize"],"out_dir":"out"})); assert_eq!(report["completed"], 1); assert!(!dir.path().join("out/sub").exists()); - assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= pdf().len() as u64); + assert!(std::fs::metadata(dir.path().join("out/1.pdf")).unwrap().len() <= original.len() as u64); } #[test] @@ -107,12 +109,13 @@ fn explicitly_selected_subfolder_is_processed_without_recursive() { #[test] fn explicit_file_can_write_a_numbered_copy_beside_its_source() { let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap(); + let original = pdf(); + std::fs::write(dir.path().join("page.pdf"), &original).unwrap(); let mut auto = Automation::new().with_root(dir.path()).unwrap(); let report = result(&mut auto, json!({"paths":["page.pdf"],"steps":["convert"],"out_dir":"."})); assert_eq!(report["completed"], 1, "{report}"); - assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), pdf()); - assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), pdf()); + assert_eq!(std::fs::read(dir.path().join("page.pdf")).unwrap(), original); + assert_eq!(std::fs::read(dir.path().join("page (2).pdf")).unwrap(), original); } #[cfg(unix)] @@ -132,13 +135,14 @@ fn non_utf8_discovered_names_are_reported_without_panicking() { assert_eq!(std::fs::read_dir(dir.path().join("input")).unwrap().count(), 0); assert!(!dir.path().join("out").exists()); let valid = dir.path().join("input/valid-λ.pdf"); - std::fs::write(&valid, pdf()).unwrap(); - assert_eq!(std::fs::read(&valid).unwrap(), pdf()); + let original = pdf(); + std::fs::write(&valid, &original).unwrap(); + assert_eq!(std::fs::read(&valid).unwrap(), original); let mut auto = Automation::new().with_root(dir.path()).unwrap(); let report = result(&mut auto, json!({"paths":["input"],"steps":["convert"],"out_dir":"out"})); assert_eq!(report["completed"], 1, "{report}"); assert_eq!(report["failed"], 0, "{report}"); - assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), pdf()); + assert_eq!(std::fs::read(dir.path().join("out/valid-λ.pdf")).unwrap(), original); eprintln!( "non-UTF-8 fixture capability unavailable: macOS filesystem rejected creation with EILSEQ92; valid-Unicode batch companion passed; invalid-name discovery still requires Linux/FreeBSD or a supporting filesystem" ); @@ -189,7 +193,8 @@ fn batch_ocr_uses_existing_models_or_reports_the_missing_models_per_file() { #[test] fn batch_output_collisions_are_safe_under_concurrent_writers() { let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("page.pdf"), pdf()).unwrap(); + let original = pdf(); + std::fs::write(dir.path().join("page.pdf"), &original).unwrap(); let barrier = std::sync::Barrier::new(2); std::thread::scope(|scope| { let handles: Vec<_> = (0..2) @@ -206,7 +211,7 @@ fn batch_output_collisions_are_safe_under_concurrent_writers() { assert_ne!(reports[0]["files"][0]["output"], reports[1]["files"][0]["output"]); }); for name in ["page.pdf", "page (2).pdf"] { - assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), pdf()); + assert_eq!(std::fs::read(dir.path().join("out").join(name)).unwrap(), original); } assert_eq!(std::fs::read_dir(dir.path().join("out")).unwrap().count(), 2, "no staging files remain"); } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c3294dc1..cf403864 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -216,6 +216,11 @@ pub const COMMANDS: &[CommandSpec] = &[ c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"), + // Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS, + // where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and + // also run from Combine files, which has no document open. + c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"), + c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"), c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"), c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"), c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"), diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 78018781..9617bf67 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -98,15 +98,19 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) { scroll_to = Some((offset - (TAB_MIN_WIDTH + gap)).max(0.0)); } - let state = (app.active, app.views.len(), app.combine_showing()); + // Keyed by document, not index: a tab dragged past the active one moves it without + // the strip jumping to it. + let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing()); let changed = ui.data_mut(|data| { let id = ui.id().with("active_tab"); - let changed = data.get_temp::<(Option, usize, bool)>(id) != Some(state); + let changed = data.get_temp::<(Option, usize, bool)>(id) != Some(state); data.insert_temp(id, state); changed }); let mut action = None; let mut close = None; + // The pointer's x while a tab is dragged, for scrolling at the strip's edges. + let mut drag_x = None; ui.scope(|ui| { ui.style_mut().always_scroll_the_only_direction = true; let mut area = egui::ScrollArea::horizontal().id_salt("document_tabs").max_width(tabs_width).auto_shrink([true, true]); @@ -115,6 +119,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } let out = area.show(ui, |ui| { ui.horizontal_centered(|ui| { + // Each document tab's index and rect, and the one being dragged + // (with whether it was just released). + let mut slots = Vec::with_capacity(app.views.len()); + let mut dragged = None; for i in 0..app.views.len() { let Some(doc) = app.session.get(app.views[i].id) else { continue }; let (name, dirty) = (doc.display_name(), doc.dirty); @@ -133,7 +141,12 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { i, cap, tab_h, + true, ); + slots.push((i, response.rect)); + if response.dragged() || response.drag_stopped() { + dragged = Some((i, response.drag_stopped())); + } if changed && app.active == Some(i) && !app.combine_showing() { response.scroll_to_me(Some(Align::Center)); } @@ -158,6 +171,25 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.active = Some(i); } } + // Dragging a tab moves it (#1537): a line marks the gap it will land + // in, and releasing the button drops it there. + if let Some((from, released)) = dragged + && let Some(x) = ui.ctx().pointer_latest_pos().map(|p| p.x) + { + let to = drop_index(&slots, from, x); + if released { + if close.is_none() && action.is_none() { + app.move_tab(from, to); + } + } else { + drag_x = Some(x); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) { + let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range()); + ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent)); + } + } + } if let Some(i) = close && let Some(view) = app.views.get(i) { @@ -182,6 +214,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { usize::MAX, cap, tab_h, + false, ); if changed && app.combine_showing() { response.scroll_to_me(Some(Align::Center)); @@ -197,6 +230,22 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); let max = (out.content_size.x - out.inner_rect.width()).max(0.0); ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max))); + // A tab dragged to either edge of a strip that scrolls brings the hidden tabs in. + if let Some(x) = drag_x + && max > 0.0 + { + let step = if x < out.inner_rect.left() + TAB_DRAG_EDGE { + -TAB_DRAG_STEP + } else if x > out.inner_rect.right() - TAB_DRAG_EDGE { + TAB_DRAG_STEP + } else { + 0.0 + }; + if step != 0.0 { + ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max))); + ui.ctx().request_repaint(); + } + } }); if overflow && arrow(ui, "chevron-right", offset < max_offset - 0.5, tl!("Scroll tabs right")) { scroll_to = Some((offset + TAB_MIN_WIDTH + gap).min(max_offset)); @@ -289,6 +338,29 @@ fn tabs_overflow(natural: &[f32], budget: f32, gap: f32, min: f32) -> bool { narrowest + gap * natural.len().saturating_sub(1) as f32 > budget } +/// How close to either end of a scrolling strip a dragged tab scrolls it, and how far per frame. +const TAB_DRAG_EDGE: f32 = 24.0; +const TAB_DRAG_STEP: f32 = 8.0; + +/// Where the tab at `from` lands when dropped with the pointer at `x`: past every other tab +/// whose centre is left of the pointer. `slots` are the tabs' indices and rects, in order. +fn drop_index(slots: &[(usize, Rect)], from: usize, x: f32) -> usize { + slots.iter().filter(|(i, r)| *i != from && r.center().x < x).count() +} + +/// The x of the line marking where the tab at `from` drops at `to`: in the gap before the other +/// tab it lands in front of, or after the last. `None` when it would stay where it is. +fn drop_marker(slots: &[(usize, Rect)], from: usize, to: usize, gap: f32) -> Option { + if slots.iter().position(|(i, _)| *i == from) == Some(to) { + return None; + } + let mut others = slots.iter().filter(|(i, _)| *i != from).map(|(_, r)| r); + match to.checked_sub(1) { + None => others.next().map(|r| r.left() - gap / 2.0), + Some(before) => others.nth(before).map(|r| r.right() + gap / 2.0), + } +} + /// A scroll arrow beside the tabs; dimmed (and inert) at its end. True when clicked. fn arrow(ui: &mut egui::Ui, icon: &str, enabled: bool, tip: &str) -> bool { ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, TAB_ARROW, false, tip)).inner.clicked() @@ -343,6 +415,7 @@ fn tab( index: usize, cap: Option, height: f32, + draggable: bool, ) -> egui::Response { let font = theme::regular(13.0); let mut label = tab_name(name); @@ -361,7 +434,8 @@ fn tab( let height = ui.available_height().max(height); let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() }; - let resp = craft_ui::tabs::Tab::new(id, &a11y, active).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| { + let sense = if draggable { Sense::click_and_drag() } else { Sense::click() }; + let resp = craft_ui::tabs::Tab::new(id, &a11y, active).sense(sense).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| { let press = theme::Press::track(ui, resp); let bg = if active { press.fill(t.chrome, t.chrome.lerp_to_gamma(t.hover, 0.65), t.hover) @@ -750,6 +824,41 @@ mod tab_widths { assert_eq!(tab_cap(&[250.0; 3], 0.0, 4.0, 110.0), Some(110.0)); } + /// Three 100-wide tabs, 4 apart, starting at x = 0. + fn three_slots() -> Vec<(usize, egui::Rect)> { + (0..3).map(|i| (i, egui::Rect::from_min_size(egui::pos2(i as f32 * 104.0, 0.0), egui::vec2(100.0, 30.0)))).collect() + } + + /// A dragged tab lands past every other tab whose centre the pointer has crossed. + #[test] + fn a_dragged_tab_drops_past_the_centres_it_crossed() { + use super::drop_index; + let slots = three_slots(); + // Centres at 50, 154 and 258. + assert_eq!(drop_index(&slots, 0, 10.0), 0, "still over itself"); + assert_eq!(drop_index(&slots, 0, 160.0), 1, "past the middle tab's centre"); + assert_eq!(drop_index(&slots, 0, 900.0), 2, "past the end"); + assert_eq!(drop_index(&slots, 2, -50.0), 0, "before the start"); + assert_eq!(drop_index(&slots, 2, 100.0), 1, "between the first and middle centres"); + assert_eq!(drop_index(&[], 0, 100.0), 0); + } + + /// The drop line sits in the gap the tab will land in, and not at all where it already is. + #[test] + fn the_drop_line_marks_the_gap_and_hides_when_nothing_moves() { + use super::drop_marker; + let slots = three_slots(); + assert_eq!(drop_marker(&slots, 0, 0, 4.0), None); + assert_eq!(drop_marker(&slots, 1, 1, 4.0), None); + // The first tab dropped at the end: after the last tab. + assert_eq!(drop_marker(&slots, 0, 2, 4.0), Some(310.0)); + // The first tab dropped between the middle and the last: in the gap between them. + assert_eq!(drop_marker(&slots, 0, 1, 4.0), Some(206.0)); + // The last tab dropped at the start: before the first tab. + assert_eq!(drop_marker(&slots, 2, 0, 4.0), Some(-2.0)); + assert_eq!(drop_marker(&[], 0, 1, 4.0), None); + } + #[test] fn the_mac_title_bar_centres_and_clears_the_traffic_lights() { // The lights are centred in the bar, and content keeps the same gap after them (#789). diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 7d7b6b9c..a8deee25 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -260,6 +260,8 @@ impl PdfCraftApp { } } "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), + "view.next_tab" => self.step_tab(true), + "view.previous_tab" => self.step_tab(false), "view.theme" => { let next = if self.theme == ThemeKind::Light { ThemePreference::Dark } else { ThemePreference::Light }; self.set_theme_preference(next); @@ -636,6 +638,20 @@ impl PdfCraftApp { true } + /// Shows the next tab (or the previous one) in the strip's order, wrapping around at either + /// end (#1542). The Combine files tab, when open, comes after the documents. From Home the + /// next tab is the first and the previous one the last. + fn step_tab(&mut self, forward: bool) { + let count = self.views.len() + usize::from(self.combine_tab.open); + let current = if self.combine_showing() { Some(self.views.len()) } else { self.active }; + let Some(next) = tab_after(current, count, forward) else { return }; + if next < self.views.len() { + self.active = Some(next); + } else { + self.open_combine_tab(); + } + } + /// Run the registered keyboard shortcuts (more specific combinations first). pub(crate) fn registry_shortcuts(&mut self, ctx: &egui::Context) { use egui::{Key, KeyboardShortcut, Modifiers}; @@ -685,6 +701,24 @@ impl PdfCraftApp { } } +/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From +/// no tab, forward is the first and back the last. `None` when there are no tabs. +fn tab_after(current: Option, count: usize, forward: bool) -> Option { + let last = count.checked_sub(1)?; + Some(match (current.filter(|&i| i <= last), forward) { + (None, true) => 0, + (None, false) => last, + (Some(i), true) => { + if i == last { + 0 + } else { + i + 1 + } + } + (Some(i), false) => i.checked_sub(1).unwrap_or(last), + }) +} + /// Render a top-level menu's registered commands (with live labels, shortcuts and enablement). pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) { for spec in commands::menu(menu) { @@ -761,4 +795,20 @@ mod tests { assert_eq!(command_modifier_label(&ctx), "⌘"); crate::i18n::set_current(previous); } + + /// Next and previous tab step one place and wrap around at either end; from no tab they go + /// to the first or the last (#1542). + #[test] + fn tab_steps_wrap_around_and_start_from_either_end() { + assert_eq!(tab_after(Some(0), 3, true), Some(1)); + assert_eq!(tab_after(Some(2), 3, true), Some(0), "the last wraps to the first"); + assert_eq!(tab_after(Some(1), 3, false), Some(0)); + assert_eq!(tab_after(Some(0), 3, false), Some(2), "the first wraps to the last"); + assert_eq!(tab_after(None, 3, true), Some(0)); + assert_eq!(tab_after(None, 3, false), Some(2)); + assert_eq!(tab_after(Some(0), 1, true), Some(0), "a single tab stays"); + assert_eq!(tab_after(Some(7), 3, true), Some(0), "a stale index starts over"); + assert_eq!(tab_after(None, 0, true), None); + assert_eq!(tab_after(Some(0), 0, false), None); + } } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 222865d7..a8c7d538 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -80,6 +80,8 @@ Column select تحديد عمود Full screen mode وضع ملء الشاشة Read mode وضع القراءة + Next tab علامة التبويب التالية + Previous tab علامة التبويب السابقة Switch light / dark theme التبديل بين السمة الفاتحة والداكنة Comments panel لوحة التعليقات Form fields panel لوحة حقول النموذج diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 17a0890f..e107408e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -116,6 +116,8 @@ Column select Избор на колона Full screen mode Цял екран Read mode Режим на четене + Next tab Следващ раздел + Previous tab Предишен раздел Switch light / dark theme Превключи светла / тъмна тема Comments panel Панел с коментари Form fields panel Панел с полета на формуляра diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e6e29c8b..835388f4 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -93,6 +93,8 @@ Marquee zoom Zvětšit vybranou oblast Full screen mode Režim celé obrazovky Read mode Režim čtení + Next tab Další karta + Previous tab Předchozí karta Switch light / dark theme Přepnout světlý / tmavý motiv Comments panel Panel komentářů Form fields panel Panel formulářových polí diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 38ef665d..7a418b54 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -51,6 +51,8 @@ Column select Spaltenauswahl Full screen mode Vollbildmodus Read mode Lesemodus + Next tab Nächster Tab + Previous tab Vorheriger Tab Switch light / dark theme Helles / dunkles Design umschalten Comments panel Kommentarbereich Form fields panel Formularfeldbereich diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 106278f0..3a99376d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -65,6 +65,8 @@ Column select Επιλογή στήλης Full screen mode Λειτουργία πλήρους οθόνης Read mode Λειτουργία ανάγνωσης + Next tab Επόμενη καρτέλα + Previous tab Προηγούμενη καρτέλα Switch light / dark theme Εναλλαγή φωτεινού / σκοτεινού θέματος Comments panel Πίνακας σχολίων Form fields panel Πίνακας πεδίων φόρμας diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5afbe4f2..f6938056 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -74,6 +74,8 @@ Column select Selección de columna Full screen mode Modo de pantalla completa Read mode Modo de lectura + Next tab Pestaña siguiente + Previous tab Pestaña anterior Switch light / dark theme Cambiar entre tema claro y oscuro Comments panel Panel de comentarios Form fields panel Panel de campos de formulario diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 79389837..76be27ca 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -51,6 +51,8 @@ Column select Sélection de colonne Full screen mode Mode plein écran Read mode Mode lecture + Next tab Onglet suivant + Previous tab Onglet précédent Switch light / dark theme Basculer entre les thèmes clair et sombre Comments panel Panneau des commentaires Form fields panel Panneau des champs de formulaire diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3d6e1e3a..221ad21f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -51,6 +51,8 @@ Column select Oszlopkijelölés Full screen mode Teljes képernyő Read mode Olvasási mód + Next tab Következő lap + Previous tab Előző lap Switch light / dark theme Világos / sötét téma váltása Comments panel Megjegyzések panel Form fields panel Űrlapmezők panel diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3575ab37..94eeab15 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -77,6 +77,8 @@ Column select Selezione colonna Full screen mode Modalità schermo intero Read mode Modalità lettura + Next tab Scheda successiva + Previous tab Scheda precedente Switch light / dark theme Alterna tema chiaro/scuro Comments panel Pannello commenti Form fields panel Pannello campi modulo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5e61802c..777410e9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -80,6 +80,8 @@ Column select 列の選択 Full screen mode 全画面表示 Read mode 閲覧モード + Next tab 次のタブ + Previous tab 前のタブ Switch light / dark theme 明るい/暗いテーマを切り替え Comments panel コメントパネル Form fields panel フォームフィールドパネル diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 8e4a0904..2ce5830a 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -54,6 +54,8 @@ Take a snapshot Суретке түсіру Full screen mode Толық экран режимі Read mode Оқу режимі + Next tab Келесі қойынды + Previous tab Алдыңғы қойынды Switch light / dark theme Ашық / қараңғы теманы ауыстыру Comments panel Түсініктемелер панелі Form fields panel Форма өрістері панелі diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ae61e396..36c698c8 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -78,6 +78,8 @@ Default zoom Numatytasis mastelis Full screen mode Viso ekrano veiksena Read mode Skaitymo veiksena + Next tab Kita kortelė + Previous tab Ankstesnė kortelė Comments Komentarai Comment Komentaras Add comment Pridėti komentarą diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 310a990f..b443c841 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -76,6 +76,8 @@ Take a snapshot Zrób migawkę Full screen mode Tryb pełnoekranowy Read mode Tryb czytania + Next tab Następna karta + Previous tab Poprzednia karta Switch light / dark theme Przełącz jasny / ciemny motyw Comments panel Panel komentarzy Form fields panel Panel pól formularza diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 623d69ce..9b2a6839 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -58,6 +58,8 @@ Column select Seleção de coluna Full screen mode Modo de tela cheia Read mode Modo de leitura + Next tab Próxima aba + Previous tab Aba anterior Switch light / dark theme Alternar tema claro / escuro Comments panel Painel de comentários Form fields panel Painel de campos de formulário diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7f628088..834e5528 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -71,6 +71,8 @@ Column select Выделение столбца Full screen mode Полноэкранный режим Read mode Режим чтения + Next tab Следующая вкладка + Previous tab Предыдущая вкладка Switch light / dark theme Переключить светлую / тёмную тему Comments panel Панель комментариев Form fields panel Панель полей формы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 3feaad35..506e52ee 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1393,6 +1393,8 @@ Read Branje Read Only Samo za branje Read mode Način branja + Next tab Naslednji zavihek + Previous tab Prejšnji zavihek Ready Omogočeno Real name Pravo ime Reason Razlog diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index d15365f3..08c53e84 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -57,6 +57,8 @@ comment menu Edit సవరించు Column select నిలువు వరుస ఎంపిక Full screen mode పూర్తి స్క్రీన్ మోడ్ Read mode చదివే మోడ్ + Next tab తదుపరి ట్యాబ్ + Previous tab మునుపటి ట్యాబ్ Switch light / dark theme లేత / ముదురు థీమ్‌ల మధ్య మారు Comments panel వ్యాఖ్యల ప్యానెల్ Form fields panel ఫారం ఫీల్డ్‌ల ప్యానెల్ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 44fd05f0..eb03e7f9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -42,6 +42,8 @@ Take a snapshot Anlık görüntü al Full screen mode Tam ekran modu Read mode Okuma modu + Next tab Sonraki sekme + Previous tab Önceki sekme Switch light / dark theme Açık / koyu temaya geç Comments panel Yorumlar paneli Form fields panel Form alanları paneli diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 648dc855..eb1cea0e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -78,6 +78,8 @@ Column select Виділення стовпця Full screen mode Повноекранний режим Read mode Режим читання + Next tab Наступна вкладка + Previous tab Попередня вкладка Switch light / dark theme Перемкнути світлу / темну тему Comments panel Панель коментарів Form fields panel Панель полів форми diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 391c8eda..d13078e7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -122,6 +122,8 @@ Column select 列选择 Full screen mode 全屏模式 Read mode 阅读模式 + Next tab 下一个标签页 + Previous tab 上一个标签页 Switch light / dark theme 切换浅色/深色主题 Comments panel 注释面板 Form fields panel 表单域面板 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index da45921c..9f8856ae 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -81,6 +81,8 @@ Column select 欄選取 Full screen mode 全螢幕模式 Read mode 閱讀模式 + Next tab 下一個分頁 + Previous tab 上一個分頁 Switch light / dark theme 切換淺色/深色主題 Comments panel 註解面板 Form fields panel 表單欄位面板 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ebbd45d5..f800ad6a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1800,6 +1800,20 @@ impl PdfCraftApp { }; } + /// Moves the tab at `from` so it sits at `to` (#1537). The same document stays active + /// wherever it lands; an index out of range changes nothing. + pub fn move_tab(&mut self, from: usize, to: usize) { + if from >= self.views.len() || to >= self.views.len() || from == to { + return; + } + let active = self.active_ids().map(|(_, id)| id); + let view = self.views.remove(from); + self.views.insert(to, view); + if let Some(id) = active { + self.active = self.views.iter().position(|v| v.id == id); + } + } + pub fn active_ids(&self) -> Option<(usize, DocId)> { self.active.and_then(|i| self.views.get(i).map(|v| (i, v.id))) } diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index ddbb6738..a8b9d88f 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -590,3 +590,45 @@ fn palette_ime_outside_click_closes_without_running_a_command() { assert!(h.state().palette_query.is_empty()); assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].rotation, 0); } + +/// Ctrl+Tab and Ctrl+Shift+Tab step through the tabs in the strip's order, wrap around at either +/// end and include the Combine files tab after the documents (#1542). A Windows or Linux Ctrl +/// press is both `ctrl` and `command`. +#[test] +fn ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around() { + let mut h = harness(); + h.state_mut().open_bytes("second.pdf", None, fixture(1)).unwrap(); + h.state_mut().open_bytes("third.pdf", None, fixture(1)).unwrap(); + h.run_steps(3); + let ctrl = Modifiers::CTRL | Modifiers::COMMAND; + let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers| { + h.key_press_modifiers(modifiers, Key::Tab); + h.run_steps(2); + h.state().active + }; + assert_eq!(h.state().active, Some(2)); + assert_eq!(press(&mut h, ctrl), Some(0), "the last tab wraps to the first"); + assert_eq!(press(&mut h, ctrl), Some(1)); + assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), Some(0)); + assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), Some(2), "the first tab wraps to the last"); + // The order is the one on screen, after the tabs are moved. + let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect(); + h.state_mut().move_tab(2, 0); + h.run_steps(2); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2])); + press(&mut h, ctrl); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[0])); + // Combine files comes after the documents. + h.state_mut().execute("page.combine"); + h.run_steps(2); + assert!(h.state().combine_showing()); + assert_eq!(press(&mut h, ctrl), Some(0), "Combine files wraps to the first document"); + assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), None); + assert!(h.state().combine_showing(), "back from the first document is Combine files"); + // Plain Tab still belongs to keyboard focus, not to the tabs. + press(&mut h, Modifiers::NONE); + assert!(h.state().combine_showing()); + for doc in h.state().session.docs() { + assert!(!doc.dirty, "switching tabs changes no document"); + } +} diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 6dbb3c24..4c1bdffb 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -1186,3 +1186,74 @@ fn combine_tab_uses_tab_interaction_without_changing_close_or_document_state() { assert!(h.query_by_role_and_label(Role::Tab, "Combine files").is_none()); assert_eq!(h.state().views.iter().map(|v| v.id).collect::>(), document_ids); } + +/// Presses the named tab, drags it to `to` in a few steps and, unless `release` is false, lets go. +fn drag_named_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str, to: egui::Pos2, release: bool) { + let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect().center(); + h.event(egui::Event::PointerMoved(from)); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + for k in 1..=6 { + h.event(egui::Event::PointerMoved(from.lerp(to, k as f32 / 6.0))); + h.run_steps(1); + } + if release { + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(3); + } +} + +#[test] +fn dragging_a_tab_moves_it_and_keeps_the_same_document_active() { + use egui::accesskit::Role; + let mut h = three_tab_harness(); + let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect(); + let tab = |h: &Harness<'static, PdfCraftApp>, name: &str| h.get_by_role_and_label(Role::Tab, name).rect(); + // first.pdf past the end; last.pdf, the active one, keeps the focus at its new place. + let to = tab(&h, "last.pdf").right_center() + egui::vec2(20.0, 0.0); + drag_named_tab(&mut h, "first.pdf", to, true); + assert_eq!(h.state().views.iter().map(|view| view.id).collect::>(), [ids[1], ids[2], ids[0]]); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2])); + assert!(tab(&h, "first.pdf").left() > tab(&h, "last.pdf").right(), "the strip draws the new order"); + // last.pdf, now in the middle, to the front. + let to = tab(&h, "middle.pdf").left_center() - egui::vec2(10.0, 0.0); + drag_named_tab(&mut h, "last.pdf", to, true); + assert_eq!(h.state().views.iter().map(|view| view.id).collect::>(), [ids[2], ids[1], ids[0]]); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2])); + assert_eq!(h.get_by_role_and_label(Role::Tab, "last.pdf").accesskit_node().is_selected(), Some(true)); + // Moving tabs touches no document. + assert_eq!(h.state().session.docs().len(), 3); + for doc in h.state().session.docs() { + assert_eq!(doc.bytes.as_slice(), FIXTURE); + assert!(!doc.dirty); + } + if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { + let to = tab(&h, "first.pdf").left_center() - egui::vec2(10.0, 0.0); + drag_named_tab(&mut h, "last.pdf", to, false); + h.render().unwrap().save(format!("{dir}/dragging-tab.png")).unwrap(); + } +} + +#[test] +fn a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it() { + let mut h = three_tab_harness(); + let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect(); + let middle = h.get_by_role_and_label(egui::accesskit::Role::Tab, "middle.pdf").rect(); + drag_named_tab(&mut h, "middle.pdf", middle.center() + egui::vec2(30.0, 0.0), true); + assert_eq!(h.state().views.iter().map(|view| view.id).collect::>(), ids); + h.get_by_label("first.pdf").click(); + h.run_steps(3); + assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[0])); + assert_eq!(h.state().views.iter().map(|view| view.id).collect::>(), ids); +} + +#[test] +fn the_combine_tab_does_not_move_and_documents_stay_in_order() { + let mut h = three_tab_harness(); + let ids: Vec<_> = h.state().views.iter().map(|view| view.id).collect(); + h.state_mut().execute("page.combine"); + h.run_steps(3); + let first = h.get_by_role_and_label(egui::accesskit::Role::Tab, "first.pdf").rect(); + drag_named_tab(&mut h, "Combine files", first.left_center() - egui::vec2(10.0, 0.0), true); + assert_eq!(h.state().views.iter().map(|view| view.id).collect::>(), ids); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 904ca868..359a71e9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1188,10 +1188,10 @@ area = "B" tier = "P1" milestone = "M3" status = "shipped" -commands = [] +commands = ["view.next_tab", "view.previous_tab"] tools = ["doc_list"] -evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch"] -notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll" +evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch", "crates/ui-egui/tests/ui.rs::dragging_a_tab_moves_it_and_keeps_the_same_document_active", "crates/ui-egui/tests/ui.rs::a_tab_dropped_where_it_was_stays_and_a_click_still_selects_it", "crates/ui-egui/tests/ui.rs::the_combine_tab_does_not_move_and_documents_stay_in_order", "crates/ui-egui/tests/commands.rs::ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around"] +notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll. Document tabs are dragged to reorder them (#1537): a line marks where the tab will land, the strip scrolls at its edges when it overflows, and the same document stays active; the Combine files tab stays after the documents. Dragging a tab out into its own window is view.multiple-windows. Ctrl+Tab and Ctrl+Shift+Tab (⌃Tab and ⌃⇧Tab on macOS; View ▸ Next tab / Previous tab) step through the tabs in their order on screen, Combine files included, and wrap around at either end (#1542)." [[feature]] id = "view.multiple-windows"