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)
This commit is contained in:
midasdf 2026-10-09 22:15:24 +09:00 committed by GitHub
parent c8c7959c0f
commit 57c281ca0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 137 additions and 24 deletions

View File

@ -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.
</td>

View File

@ -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>, 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")),

View File

@ -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();