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:
Ibn ZwanckaertandClaude Opus 5.5 authored and GitHub committed 2026-10-13 20:06:20 -06:00
1 parent d9607ec3be
commit 5e12f5fd6d
30 files changed
+376 -38

No files matched your search

+21 -21
View File
@@ -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"
+16 -11
View File
@@ -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");
}
+5
View File
@@ -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"),
+112 -3
View File
@@ -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).
+50
View File
@@ -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);
}
}
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -65,6 +65,8 @@
Column select Επιλογή στήλης
Full screen mode Λειτουργία πλήρους οθόνης
Read mode Λειτουργία ανάγνωσης
Next tab Επόμενη καρτέλα
Previous tab Προηγούμενη καρτέλα
Switch light / dark theme Εναλλαγή φωτεινού / σκοτεινού θέματος
Comments panel Πίνακας σχολίων
Form fields panel Πίνακας πεδίων φόρμας
1 # Greek (el) interface catalog. Columns: context <TAB> English source <TAB> Greek translation.
65
66
67
68
69
70
71
72
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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 Форма өрістері панелі
1 Menu Мәзір
54 Take a snapshot Суретке түсіру
55 Full screen mode Толық экран режимі
56 Read mode Оқу режимі
57 Next tab Келесі қойынды
58 Previous tab Алдыңғы қойынды
59 Switch light / dark theme Ашық / қараңғы теманы ауыстыру
60 Comments panel Түсініктемелер панелі
61 Form fields panel Форма өрістері панелі
+2
View File
@@ -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ą
1 # Lithuanian (lt) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
78
79
80
81
82
83
84
85
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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
1 # Turkish (tr) catalog. Format: see the header of ja.tsv
42
43
44
45
46
47
48
49
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+14
View File
@@ -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)))
}
+42
View File
@@ -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");
}
}
+71
View File
@@ -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);
}
+3 -3
View File
@@ -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"