mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 11:17:10 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
d9607ec3be
commit
5e12f5fd6d
30 files changed
+376
-38
No files matched your search
+21
-21
@@ -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"
|
||||
@@ -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<u8> {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>, usize, bool)>(id) != Some(state);
|
||||
let changed = data.get_temp::<(Option<pdfcraft_engine::DocId>, 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<f32> {
|
||||
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<f32>,
|
||||
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).
|
||||
|
||||
@@ -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<usize>, count: usize, forward: bool) -> Option<usize> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,8 @@
|
||||
Column select تحديد عمود
|
||||
Full screen mode وضع ملء الشاشة
|
||||
Read mode وضع القراءة
|
||||
Next tab علامة التبويب التالية
|
||||
Previous tab علامة التبويب السابقة
|
||||
Switch light / dark theme التبديل بين السمة الفاتحة والداكنة
|
||||
Comments panel لوحة التعليقات
|
||||
Form fields panel لوحة حقول النموذج
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -116,6 +116,8 @@
|
||||
Column select Избор на колона
|
||||
Full screen mode Цял екран
|
||||
Read mode Режим на четене
|
||||
Next tab Следващ раздел
|
||||
Previous tab Предишен раздел
|
||||
Switch light / dark theme Превключи светла / тъмна тема
|
||||
Comments panel Панел с коментари
|
||||
Form fields panel Панел с полета на формуляра
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -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í
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -65,6 +65,8 @@
|
||||
Column select Επιλογή στήλης
|
||||
Full screen mode Λειτουργία πλήρους οθόνης
|
||||
Read mode Λειτουργία ανάγνωσης
|
||||
Next tab Επόμενη καρτέλα
|
||||
Previous tab Προηγούμενη καρτέλα
|
||||
Switch light / dark theme Εναλλαγή φωτεινού / σκοτεινού θέματος
|
||||
Comments panel Πίνακας σχολίων
|
||||
Form fields panel Πίνακας πεδίων φόρμας
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -80,6 +80,8 @@
|
||||
Column select 列の選択
|
||||
Full screen mode 全画面表示
|
||||
Read mode 閲覧モード
|
||||
Next tab 次のタブ
|
||||
Previous tab 前のタブ
|
||||
Switch light / dark theme 明るい/暗いテーマを切り替え
|
||||
Comments panel コメントパネル
|
||||
Form fields panel フォームフィールドパネル
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Форма өрістері панелі
|
||||
|
||||
|
@@ -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ą
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -71,6 +71,8 @@
|
||||
Column select Выделение столбца
|
||||
Full screen mode Полноэкранный режим
|
||||
Read mode Режим чтения
|
||||
Next tab Следующая вкладка
|
||||
Previous tab Предыдущая вкладка
|
||||
Switch light / dark theme Переключить светлую / тёмную тему
|
||||
Comments panel Панель комментариев
|
||||
Form fields panel Панель полей формы
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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 ఫారం ఫీల్డ్ల ప్యానెల్
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -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
|
||||
|
||||
|
@@ -78,6 +78,8 @@
|
||||
Column select Виділення стовпця
|
||||
Full screen mode Повноекранний режим
|
||||
Read mode Режим читання
|
||||
Next tab Наступна вкладка
|
||||
Previous tab Попередня вкладка
|
||||
Switch light / dark theme Перемкнути світлу / темну тему
|
||||
Comments panel Панель коментарів
|
||||
Form fields panel Панель полів форми
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -122,6 +122,8 @@
|
||||
Column select 列选择
|
||||
Full screen mode 全屏模式
|
||||
Read mode 阅读模式
|
||||
Next tab 下一个标签页
|
||||
Previous tab 上一个标签页
|
||||
Switch light / dark theme 切换浅色/深色主题
|
||||
Comments panel 注释面板
|
||||
Form fields panel 表单域面板
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -81,6 +81,8 @@
|
||||
Column select 欄選取
|
||||
Full screen mode 全螢幕模式
|
||||
Read mode 閱讀模式
|
||||
Next tab 下一個分頁
|
||||
Previous tab 上一個分頁
|
||||
Switch light / dark theme 切換淺色/深色主題
|
||||
Comments panel 註解面板
|
||||
Form fields panel 表單欄位面板
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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::<Vec<_>>(), 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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>(), 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::<Vec<_>>(), 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::<Vec<_>>(), ids);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user