mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
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:
8 files changed
+183
-6
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -51,6 +51,7 @@ mod javascript;
|
||||
mod layer_actions;
|
||||
mod links;
|
||||
mod marks;
|
||||
mod min_window;
|
||||
mod object_selection;
|
||||
mod ocr;
|
||||
mod open_action;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
Reference in new issue
Block a user