Keep long menus usable in short windows (#3374)

* M14: keep long menus usable in short windows

* Menus: scroll each top-level menu once, from its caller, so View's list isn't nested in a second scroll area

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Earl Co
2026-10-19 08:49:34 +02:00
committed by GitHub
parent 7d9adcb8f1
commit 6733e073ef
5 changed files with 129 additions and 86 deletions
+2
View File
@@ -171,6 +171,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
+88 -76
View File
@@ -533,88 +533,100 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
/// Keep menus usable when their contents are taller than the window.
pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
let height = (ui.ctx().content_rect().height() - 24.0).max(40.0);
egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add);
}
fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu"));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(230.0);
ui.menu_button(tl!("File"), |ui| crate::commands::registry_menu(app, ui, "File"));
ui.menu_button(tl!("Edit"), |ui| crate::commands::registry_menu(app, ui, "Edit"));
ui.menu_button(tl!("Pages"), |ui| crate::commands::registry_menu(app, ui, "Pages"));
ui.menu_button(tl!("View"), |ui| {
if let Some(i) = app.active {
let v = &mut app.views[i];
let ctx = ui.ctx().clone();
let key = |s| crate::commands::shortcut_label(&ctx, s);
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
v.set_zoom(1.0);
}
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
v.fit = Fit::Page;
}
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
v.fit = Fit::Width;
}
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
v.zoom_step(true);
}
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
v.zoom_step(false);
}
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
v.rotate_view(true);
}
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
v.rotate_view(false);
}
ui.separator();
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
v.view_history(false);
}
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
v.view_history(true);
}
ui.separator();
if let Some(id) = page_display_menu(ui, &app.views[i]) {
app.execute(id);
}
ui.separator();
}
crate::commands::registry_menu(app, ui, "View");
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
ui.menu_button(tl!("Side panels"), |ui| {
for (p, label) in [
(RightPanel::Comments, tl!("Comments")),
(RightPanel::Bookmarks, tl!("Bookmarks")),
(RightPanel::Pages, tl!("Pages")),
(RightPanel::Fields, tl!("Fields")),
(RightPanel::Layers, tl!("Layers")),
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
app.choose_right_panel(Some(p));
scroll_menu(ui, |ui| {
ui.set_min_width(230.0);
ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File")));
ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit")));
ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages")));
ui.menu_button(tl!("View"), |ui| {
scroll_menu(ui, |ui| {
if let Some(i) = app.active {
let v = &mut app.views[i];
let ctx = ui.ctx().clone();
let key = |s| crate::commands::shortcut_label(&ctx, s);
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
v.set_zoom(1.0);
}
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
v.fit = Fit::Page;
}
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
v.fit = Fit::Width;
}
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
v.zoom_step(true);
}
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
v.zoom_step(false);
}
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
v.rotate_view(true);
}
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
v.rotate_view(false);
}
ui.separator();
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
v.view_history(false);
}
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
v.view_history(true);
}
ui.separator();
if let Some(id) = page_display_menu(ui, &app.views[i]) {
app.execute(id);
}
ui.separator();
}
}
crate::commands::registry_menu(app, ui, "View");
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
ui.menu_button(tl!("Side panels"), |ui| {
scroll_menu(ui, |ui| {
for (p, label) in [
(RightPanel::Comments, tl!("Comments")),
(RightPanel::Bookmarks, tl!("Bookmarks")),
(RightPanel::Pages, tl!("Pages")),
(RightPanel::Fields, tl!("Fields")),
(RightPanel::Layers, tl!("Layers")),
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
app.choose_right_panel(Some(p));
}
}
})
});
})
});
});
ui.menu_button(tl!("Help"), |ui| crate::commands::registry_menu(app, ui, "Help"));
ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help")));
})
});
}
+11 -9
View File
@@ -779,17 +779,19 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
let mut open: Option<String> = None;
let mut clear = false;
ui.menu_button(label, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
crate::chrome::scroll_menu(ui, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
})
});
if let Some(p) = open {
app.open_recent(&p);
+27
View File
@@ -1618,3 +1618,30 @@ fn switching_modes_keeps_the_current_page_at_fixed_zoom() {
h.run_steps(8);
assert_eq!(h.state().views[1].current, 9, "the second tab keeps its own position");
}
#[test]
fn long_view_menu_fits_a_short_window() {
use egui_kittest::kittest::Queryable;
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 400.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap();
app
});
h.run_steps(8);
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("View ⏵").hover();
h.run_steps(4);
let before = h.get_by_label("Side panels ⏵").rect().bottom();
h.get_by_label("Fit to height ").hover();
h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move));
h.run_steps(6);
let after = h.get_by_label("Side panels ⏵").rect().bottom();
assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}");
h.get_by_label("Side panels ⏵").hover();
h.run_steps(4);
h.get_all_by_label("Bookmarks").last().unwrap().click();
h.run_steps(4);
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
}
+1 -1
View File
@@ -153,7 +153,7 @@ total matches the parity estimate.
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Brazilian Portuguese and Arabic (logical-order catalog put into display order; layout not yet mirrored); Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation |
| | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) |
## Upcoming milestones, ranked