Merge pull request #1988 from vangel76/fix/min-window-width

UI: allow a half-screen window, and fold the mode bar when narrow (#1913)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-08 13:22:15 +01:00
commit 3bfbe24d7b
8 files changed
+183 -6

No files matched your search

+1 -1
View File
@@ -333,7 +333,7 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
.with_min_inner_size([820.0, 520.0])
.with_min_inner_size(pdfcraft_ui_egui::MIN_WINDOW_SIZE)
.with_drag_and_drop(true)
// Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop.
.with_app_id(APP_ID);
+21
View File
@@ -251,8 +251,29 @@ pub(super) fn notices(
fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, text: String, buttons: impl FnOnce(&mut egui::Ui)) {
const ROW: f32 = 28.0;
const ICON: f32 = 16.0;
/// Content width below which the buttons stack under the message.
const STACK_BELOW: f32 = 200.0;
/// Content width under which stacking is not tried: the message would wrap to a column of a
/// few characters, thousands of points tall, and push the hotbar off the screen. Such a width
/// is below the smallest supported window, so the single row stays as it was.
const STACK_ABOVE: f32 = 100.0;
egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| {
let width = ui.available_width();
if (STACK_ABOVE..STACK_BELOW).contains(&width) {
// A narrow document area (a half-width window): the message takes the full width and
// the buttons sit on a row of their own below it, rather than squeezing it to nothing.
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min), |ui| {
let (slot, _) = ui.allocate_exact_size(vec2(ICON, ICON), Sense::hover());
icons::paint(ui, Rect::from_center_size(slot.center(), vec2(ICON, ICON)), icon, ICON, icon_color);
ui.add(egui::Label::new(egui::RichText::new(text).color(t.text)).wrap());
});
// The buttons wrap onto further rows when even the full width is too narrow for them all.
ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center).with_main_wrap(true), |ui| {
ui.set_min_height(ROW);
buttons(ui);
});
return;
}
ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.set_min_height(ROW);
buttons(ui);
+42 -4
View File
@@ -477,6 +477,12 @@ fn tab(ui: &mut egui::Ui, spec: TabSpec<'_>, close: &mut Option<usize>) -> egui:
theme::hand(resp).on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", &shown)]) } else { shown })
}
/// Width the print, save and info buttons and their gaps take in the mode bar: three buttons, the
/// 4 pt `item_spacing` after each of them (three gaps, not two), and the 8 pt spacer.
const MODE_BAR_RIGHT: f32 = 3.0 * 32.0 + 3.0 * 4.0 + 8.0;
/// Narrowest search field the mode bar shows; below it the field becomes an icon button.
const MODE_BAR_SEARCH_MIN: f32 = 140.0;
pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("mode_bar")
@@ -494,11 +500,36 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
// Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool
// in use. Read mode (the panes hidden) stays a View command and a default workspace.
for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] {
if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() {
app.switch_mode(mode, true);
let modes = [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")];
// What the right side needs (three 32 px buttons, gaps) and the least search box worth showing.
let avail = ui.available_width();
let tabs_w: f32 = modes.iter().map(|(_, l)| widgets::mode_tab_width(ui, tl!(*l))).sum::<f32>() + 2.0 * (modes.len() as f32 - 1.0);
let inline_tabs = avail >= tabs_w + MODE_BAR_RIGHT + MODE_BAR_SEARCH_MIN;
if inline_tabs {
for (mode, label) in modes {
if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() {
app.switch_mode(mode, true);
}
}
} else {
// Narrow window: the modes fold into one menu named after the current one.
// Read is not one of the four choices but can be active (View command): name it too.
let current = if app.mode == Mode::Read {
tl!("Read")
} else {
tl!(modes.iter().find(|(m, _)| *m == app.mode).map_or("All tools", |(_, l)| *l))
};
let resp = widgets::ghost_button(ui, "chevron-down", current);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(160.0);
for (mode, label) in modes {
if ui.add(egui::Button::selectable(app.mode == mode, tl!(label))).clicked() {
app.switch_mode(mode, true);
}
}
});
}
let left = ui.available_width();
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 4.0;
let has_doc = app.active.is_some();
@@ -523,7 +554,14 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
});
ui.add_space(8.0);
if widgets::search_box(ui, tl!("Find tools and commands"), 260.0).clicked() {
let search_w = (left - MODE_BAR_RIGHT).clamp(0.0, 260.0);
let opened = if search_w >= MODE_BAR_SEARCH_MIN {
widgets::search_box(ui, tl!("Find tools and commands"), search_w).clicked()
} else {
// Too narrow for the field: a button opens the same command search.
icons::button(ui, "search", 32.0, false, tl!("Find tools and commands")).clicked()
};
if opened {
app.palette_open = true;
}
});
+6
View File
@@ -67,6 +67,12 @@ mod widgets;
use pdfcraft_engine::{DocId, Session};
pub use canvas::{DocView, RasterMemory};
/// Smallest window the shell is laid out for, in egui points (logical pixels, so the OS scale
/// factor is already applied: 480 points are 600 px at 125 % and 720 px at 150 %). 480 is half of a
/// 1440-wide screen at 150 % scaling, the tightest common half-screen split on Windows; the mode
/// bar, tab strip and notice bars adapt below the old 820 minimum (#1913).
pub const MIN_WINDOW_SIZE: [f32; 2] = [480.0, 520.0];
pub use editing::{CloseRequest, SaveTarget};
pub use files::{ExtractDraft, FilePurpose, FileRequest, RotateDraft, SplitDraft, SplitMode, SplitPlan};
pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore, default_dictionary_dir};
+7
View File
@@ -113,6 +113,7 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
let t = Tokens::get(ui.ctx());
let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) };
let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x);
// Keep in step with `mode_tab_width`.
let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, label));
let press = theme::Press::track(ui, &resp);
@@ -133,6 +134,12 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response {
theme::hand(resp)
}
/// Width [`mode_tab`] takes for `label` (the semibold, active, form is the widest).
pub fn mode_tab_width(ui: &egui::Ui, label: &str) -> f32 {
let t = Tokens::get(ui.ctx());
ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), theme::semibold(13.5), t.text).size().x) + 22.0
}
/// Rounded pill button; `primary` fills with the accent.
pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response {
pill_button_marked(ui, label, primary, false)
+3 -1
View File
@@ -5824,7 +5824,9 @@ fn a_quick_flick_on_either_edge_rewraps_the_paragraph() {
assert_eq!(text, ["Page", "1"], "narrowed from the right edge");
// Flick the right edge outward, past the box and its handle: back onto one line.
let b = block(&h);
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(2.0, 0.0);
// 1 px outside the box: the grab zone's outer limit sits near 2 px, where a pixel of page
// scale (the canvas width depends on the rail) decides whether the press lands on the handle.
let edge = screen(b.rect[2], grip_y(&b)) + egui::vec2(1.0, 0.0);
flick(&mut h, edge, edge + egui::vec2(80.0 * k, 0.0));
let text: Vec<String> = lines(&h).iter().map(|l| l.text.clone()).collect();
assert_eq!(text, ["Page 1"], "widened by a fast drag that left the handle at once");
+1
View File
@@ -51,6 +51,7 @@ mod javascript;
mod layer_actions;
mod links;
mod marks;
mod min_window;
mod object_selection;
mod ocr;
mod open_action;
+102
View File
@@ -0,0 +1,102 @@
//! The smallest window (#1913): at `MIN_WINDOW_SIZE` the mode bar folds the modes into a menu
//! and keeps the command search reachable, instead of hiding them under the search field.
use egui::accesskit::Role;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_ui_egui::{MIN_WINDOW_SIZE, PdfCraftApp};
fn harness(size: egui::Vec2) -> Harness<'static, PdfCraftApp> {
let mut h = Harness::builder().with_size(size).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app
});
h.run_steps(8);
h
}
#[test]
fn at_the_minimum_width_the_modes_fold_into_a_menu_and_search_stays() {
let mut h = harness(egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1]));
assert!(h.query_by_role_and_label(Role::Button, "Convert").is_none(), "no room for the tabs");
assert!(h.query_by_role_and_label(Role::Button, "Find tools and commands").is_some(), "command search is reachable");
h.get_by_role_and_label(Role::Button, "All tools").click();
h.run_steps(4);
h.get_by_role_and_label(Role::Button, "E-Sign").click();
h.run_steps(4);
assert!(h.query_by_role_and_label(Role::Button, "E-Sign").is_some(), "the menu button is named after the mode");
assert!(h.query_by_role_and_label(Role::MenuItem, "File").is_some(), "main menu reachable");
}
#[test]
fn a_wide_window_keeps_the_mode_tabs() {
let h = harness(egui::vec2(1400.0, 900.0));
for mode in ["All tools", "Edit", "Convert", "E-Sign"] {
assert!(h.query_by_role_and_label(Role::Button, mode).is_some(), "{mode} tab");
}
}
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
for i in 0..n {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
fn protected(user: &str, owner: &str, permissions: i32) -> Vec<u8> {
protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner, permissions)
}
fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm,
user_password: user,
owner_password: owner,
permissions,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
}
/// A narrow window: the stacked notice bar keeps both buttons inside the window and apart.
#[test]
fn the_stacked_notice_keeps_its_buttons_in_view() {
let mut h = Harness::builder().with_size(egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1])).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap();
app
});
h.run_steps(8);
let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(MIN_WINDOW_SIZE[0], MIN_WINDOW_SIZE[1]));
let msg = h.get_by_label_contains("This document is secured").rect();
let (unlock, settings) = (h.get_by_label("Unlock…").rect(), h.get_by_label("Security settings").rect());
for (name, r) in [("unlock", unlock), ("settings", settings)] {
assert!(window.contains_rect(r), "{name} stays inside the window: {r:?}");
assert!(r.left() >= msg.left() - 30.0, "{name} is not cut off at the bar's left edge: {r:?} vs {msg:?}");
assert!(r.top() >= msg.bottom() - 0.5 || r.bottom() <= msg.top() + 0.5, "{name} does not cover the message");
}
assert!(!unlock.intersects(settings), "the buttons do not overlap: {unlock:?} {settings:?}");
}