From 57c281ca0a5eda3faa0ba27266039863507022b2 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:15:24 +0900 Subject: [PATCH] M3: keep overflowed document tabs reachable (#416) Refs #397. Scroll the tab strip, reveal the active tab and preserve the existing close-confirmation path. Assisted-by: OpenAI GPT-6.1 (gpt-6.1-sol) --- README.md | 2 + crates/ui-egui/src/chrome.rs | 76 ++++++++++++++++++++++----------- crates/ui-egui/tests/ui.rs | 83 ++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 74deff1e1..433424780 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,8 @@ Open **Organize pages** to see every page at once: - *Atomic:* the file is written to a temporary copy, then swapped in. - *Verified:* independently checked with qpdf. +When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view. + Unsaved documents carry a dot on their tab, and closing or quitting asks before anything is lost. Changes are autosaved every minute. If PdfCraft ever quits unexpectedly, it offers to recover your work the next time it opens. Encrypted documents stay encrypted on disk. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index bc4a717a4..7ef9361a4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -28,31 +28,59 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.active = None; app.combine_tab.focused = false; } + // Keep the two 28-point buttons, Discord's text/padding and three gaps outside the scrolling tabs. + let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 106.0; + let state = (app.active, 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); + data.insert_temp(id, state); + changed + }); let mut close = 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); - if tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i).clicked() { - app.active = Some(i); - } - } - if let Some(i) = close { - app.request_close_tab(i); - } - if app.combine_tab.open { - // After the document tabs; its index can't clash with theirs. - let mut close = None; - if tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX).clicked() { - app.open_combine_tab(); - } - if close.is_some() { - app.close_combine_tab(); - } - } - ui.add_space(4.0); - if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() { - app.open_dialog(); - } + ui.scope(|ui| { + ui.style_mut().always_scroll_the_only_direction = true; + egui::ScrollArea::horizontal() + .id_salt("document_tabs") + .max_width((ui.available_width() - controls_width).max(0.0)) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal_centered(|ui| { + 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); + let response = tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i); + if changed && app.active == Some(i) && !app.combine_showing() { + response.scroll_to_me(Some(Align::Center)); + } + if response.clicked() { + app.active = Some(i); + } + } + if let Some(i) = close { + app.request_close_tab(i); + } + if app.combine_tab.open { + // After the document tabs; its index can't clash with theirs. + let mut close = None; + let response = tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX); + if changed && app.combine_showing() { + response.scroll_to_me(Some(Align::Center)); + } + if response.clicked() { + app.open_combine_tab(); + } + if close.is_some() { + app.close_combine_tab(); + } + } + ui.add_space(4.0); + if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() { + app.open_dialog(); + } + }); + }); + }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let (icon, label) = match app.theme_preference { ThemePreference::System => ("settings", tl!("Use system setting")), diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 27fa3bc50..819adaea4 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -103,6 +103,89 @@ fn a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name() { h.get_by_label(long); } +#[test] +fn overflowing_tabs_can_be_reached_by_scrolling() { + let mut h = harness(|app| { + for i in 0..20 { + app.open_bytes(&format!("document-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap(); + } + }); + let last = h.get_by_label("document-19.pdf").rect(); + assert!(last.center().x < 1200.0, "newly opened active tab must be visible: {last:?}"); + h.state_mut().active = Some(0); + h.run_steps(4); + let first = h.get_by_label("document-00.pdf").rect(); + assert!(first.center().x < 1200.0, "changing active tab must reveal it"); + h.event(egui::Event::PointerMoved(first.center())); + for _ in 0..8 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(-500.0, 0.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(8); + } + let last = h.get_by_label("document-19.pdf").rect(); + assert!(last.center().x > 0.0 && last.center().x < 1200.0, "last tab must be reachable: {last:?}"); + h.get_by_label("document-19.pdf").click(); + h.run_steps(3); + assert_eq!(h.state().active, Some(19)); + assert!(h.get_by_label_contains("Display theme:").rect().center().x < 1400.0); + let close = last.right_center() - egui::vec2(16.0, 0.0); + h.event(egui::Event::PointerMoved(close)); + h.event(egui::Event::PointerButton { pos: close, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.step(); + h.event(egui::Event::PointerButton { pos: close, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); + assert_eq!(h.state().views.len(), 19); + assert!(h.state().active.is_some_and(|i| i < 19)); + if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { + settle(&mut h); + h.render().unwrap().save(format!("{dir}/overflow-tabs.png")).unwrap(); + } +} + +#[test] +fn narrow_tab_strips_accept_vertical_wheel_scrolling() { + for width in [600.0, 900.0] { + let mut h = Harness::builder().with_size(egui::vec2(width, 700.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + for i in 0..12 { + app.open_bytes(&format!("narrow-{i:02}.pdf"), None, FIXTURE.to_vec()).unwrap(); + } + app + }); + h.run_steps(4); + let last = h.get_by_label("narrow-11.pdf").rect(); + assert!(last.center().x > 28.0 && last.right() < width - 100.0, "active tab: {last:?}"); + h.state_mut().active = Some(0); + h.run_steps(4); + let first = h.get_by_label("narrow-00.pdf").rect(); + assert!(first.center().x > 28.0 && first.right() < width - 100.0); + h.event(egui::Event::PointerMoved(first.center())); + for _ in 0..8 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -500.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.run_steps(8); + } + let last = h.get_by_label("narrow-11.pdf").rect(); + assert!(last.center().x > 28.0 && last.right() < width - 100.0, "scrolled tab: {last:?}"); + h.get_by_label("narrow-11.pdf").click(); + h.run_steps(4); + assert_eq!(h.state().active, Some(11)); + assert!(h.get_by_label_contains("Display theme:").rect().right() <= width); + if let Ok(dir) = std::env::var("PDFCRAFT_TAB_SHOTS") { + settle(&mut h); + h.render().unwrap().save(format!("{dir}/tabs-{width}.png")).unwrap(); + } + } +} + #[test] fn garbage_input_is_rejected_without_panicking() { let mut app = PdfCraftApp::new();