From f6e335f3fe0c0ddc00f93b28a8c9f7499bcefa05 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 13 Oct 2026 21:49:37 +0200 Subject: [PATCH 01/10] UI: allow a half-screen window, and fold the mode bar when narrow (#1913) The viewport minimum was 820x520 points, wider than half of a 1440 px screen. It is now MIN_WINDOW_SIZE = 480x520 points. At that width the five mode tabs were hidden under the search field, so the mode bar folds them into one menu named after the current mode, shrinks the search field and turns it into an icon button when it gets too narrow. Notice bars stack their buttons under the message in a narrow document area. Co-Authored-By: Claude Sonnet 5.5 --- apps/pdfcraft/src/main.rs | 2 +- crates/ui-egui/src/canvas.rs | 16 +++++++++++ crates/ui-egui/src/chrome.rs | 43 +++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 6 +++++ crates/ui-egui/src/widgets.rs | 7 +++++ crates/ui-egui/tests/min_window.rs | 38 ++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/tests/min_window.rs diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb20432..edc0652d0e 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -309,7 +309,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); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 989b731b7e..72a8316ab7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4248,8 +4248,24 @@ 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 = 300.0; egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { let width = ui.available_width(); + if width < STACK_BELOW { + // 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()); + }); + ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |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); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index e8bf3b48dd..88165102ad 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -487,6 +487,11 @@ fn tab( }) } +/// Width the print, save and info buttons and their gaps take in the mode bar. +const MODE_BAR_RIGHT: f32 = 3.0 * 32.0 + 2.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") @@ -499,13 +504,32 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add_space(6.0); ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); ui.add_space(10.0); - for (mode, label) in - [(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (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::Read, "Read"), (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::() + 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 five modes fold into one menu named after the current one. + let current = modes.iter().find(|(m, _)| *m == app.mode).map_or("All tools", |(_, l)| *l); + let resp = widgets::ghost_button(ui, "chevron-down", tl!(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(); @@ -524,7 +548,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; } }); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d101d812e0..832ae30cb4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -149,6 +149,12 @@ mod open_worker_tests; 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}; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b6..cae7405ac5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -108,6 +108,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::WidgetType::Button, ui.is_enabled(), active, label)); let press = theme::Press::track(ui, &resp); @@ -128,6 +129,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 { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs new file mode 100644 index 0000000000..b06e81d12d --- /dev/null +++ b/crates/ui-egui/tests/min_window.rs @@ -0,0 +1,38 @@ +//! The smallest window (#1913): at `MIN_WINDOW_SIZE` the mode bar folds the five 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, "Read").is_none(), "no room for five 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::Button, "Menu").is_some(), "main menu reachable"); +} + +#[test] +fn a_wide_window_keeps_the_five_mode_tabs() { + let h = harness(egui::vec2(1400.0, 900.0)); + for mode in ["All tools", "Read", "Edit", "Convert", "E-Sign"] { + assert!(h.query_by_role_and_label(Role::Button, mode).is_some(), "{mode} tab"); + } +} From 03a4c21cfe7f73b890ee4d93a204fe38ad2c9e11 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 22:40:36 +0200 Subject: [PATCH 02/10] UI: stack notice buttons only below 200 pt so the 900-wide notice keeps its layout (#1913) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 72a8316ab7..ca1ff12337 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4249,7 +4249,7 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te const ROW: f32 = 28.0; const ICON: f32 = 16.0; /// Content width below which the buttons stack under the message. - const STACK_BELOW: f32 = 300.0; + const STACK_BELOW: f32 = 200.0; egui::Frame::NONE.fill(t.accent_soft).inner_margin(egui::Margin::symmetric(14, 7)).show(ui, |ui| { let width = ui.available_width(); if width < STACK_BELOW { From c3cf27558a6d0f6c8c245b5a4bad813e6ae5de30 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 22:53:33 +0200 Subject: [PATCH 03/10] UI: wrap stacked notice buttons and scroll the rail in short windows (#1913) Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/chrome.rs | 241 +++++++++++++++-------------- crates/ui-egui/tests/min_window.rs | 82 ++++++++++ 3 files changed, 211 insertions(+), 115 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ca1ff12337..349b4deded 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -4260,7 +4260,8 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te 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()); }); - ui.allocate_ui_with_layout(vec2(width, ROW), egui::Layout::right_to_left(egui::Align::Center), |ui| { + // 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); }); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 88165102ad..d4c6f09503 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -648,6 +648,9 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } +/// Height the rail's page-navigation cluster (zoom, fit, page box) needs; below it the rail scrolls. +const RAIL_BOTTOM_H: f32 = 316.0; + pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -668,122 +671,132 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .exact_size(48.0) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) .show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 4.0; - let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| { - let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector)); - let r = icons::button(ui, icon, 34.0, selected, tl!(tip)); - if has && !selected { - let c = r.rect.right_top() + vec2(-8.0, 8.0); - ui.painter().circle_filled(c, 3.0, t.accent); - } - if r.clicked() { - app.choose_right_panel(if selected { None } else { Some(panel) }); - } - }; - rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments); - rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline); - rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false); - rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields); - rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); - rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); - rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); - if has_check { - rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false); - } - rail_button(ui, RightPanel::Tags, "tag", "Tags", false); + let panel_h = ui.available_height(); + // A window too short for both clusters scrolls the rail instead of overlapping them (#1913). + egui::ScrollArea::vertical().auto_shrink(false).scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show( + ui, + |ui| { + ui.spacing_mut().item_spacing.y = 4.0; + let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| { + let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector)); + let r = icons::button(ui, icon, 34.0, selected, tl!(tip)); + if has && !selected { + let c = r.rect.right_top() + vec2(-8.0, 8.0); + ui.painter().circle_filled(c, 3.0, t.accent); + } + if r.clicked() { + app.choose_right_panel(if selected { None } else { Some(panel) }); + } + }; + rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments); + rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline); + rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false); + rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields); + rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); + rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); + rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); + if has_check { + rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false); + } + rail_button(ui, RightPanel::Tags, "tag", "Tags", false); - // Page navigation cluster at the bottom (as in Acrobat's rail). - let view = &mut app.views[index]; - let mut page_display = None; - ui.with_layout(Layout::bottom_up(Align::Center), |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { - view.zoom_step(false); - } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { - view.zoom_step(true); - } - // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. - let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); - let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); - page_display = egui::Popup::menu(&resp) - .show(|ui| { - ui.set_min_width(220.0); - rail_view_menu(ui, view) - }) - .and_then(|r| r.inner); - let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); - if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { - view.rotate_view(true); - } - // Not `columns-2` while fitting the page: that is the two-page view's icon. - let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; - if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { - view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; - view.goto = Some((view.current, 0.0)); - } - let zoom = format!("{:.0}%", view.zoom * 100.0); - ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { - ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); - }); - ui.add_space(6.0); - if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { - view.step_page(true); - } - if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { - view.step_page(false); - } - // History is distinct from stepping to the adjacent page, and belongs to this tab. - for (forward, icon, label, shortcut, enabled) in [ - (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), - (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), - ] { - let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); - if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { - view.view_history(forward); + // Page navigation cluster at the bottom (as in Acrobat's rail). + let view = &mut app.views[index]; + let mut page_display = None; + let cluster_w = ui.available_width(); + // Inside the scroll area `available_height` is unbounded: the panel's height was taken outside. + let cluster_h = (panel_h - ui.min_rect().height() - ui.spacing().item_spacing.y).max(RAIL_BOTTOM_H); + ui.allocate_ui_with_layout(vec2(cluster_w, cluster_h), Layout::bottom_up(Align::Center), |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); + if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { + view.zoom_step(false); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); + if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { + view.zoom_step(true); + } + // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. + let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); + let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); + page_display = egui::Popup::menu(&resp) + .show(|ui| { + ui.set_min_width(220.0); + rail_view_menu(ui, view) + }) + .and_then(|r| r.inner); + let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); + if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { + view.rotate_view(true); + } + // Not `columns-2` while fitting the page: that is the two-page view's icon. + let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; + if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { + view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; + view.goto = Some((view.current, 0.0)); + } + let zoom = format!("{:.0}%", view.zoom * 100.0); + ui.allocate_ui_with_layout(vec2(40.0, 16.0), Layout::top_down(Align::Center), |ui| { + ui.label(egui::RichText::new(zoom).font(theme::medium(11.0)).color(t.text_muted)); + }); + ui.add_space(6.0); + if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { + view.step_page(true); + } + if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { + view.step_page(false); + } + // History is distinct from stepping to the adjacent page, and belongs to this tab. + for (forward, icon, label, shortcut, enabled) in [ + (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), + (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), + ] { + let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { + view.view_history(forward); + } + } + ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted)); + let page_input_id = view.page_input_id(); + // Consume replacement selection once, including when the editor has lost focus. + let reselect = std::mem::take(&mut view.page_input_reselect); + // Navigation updates the idle display, never the text being edited. + if !ui.memory(|m| m.has_focus(page_input_id)) { + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); + } else if reselect { + // The replacement changed the label while egui retained this editor's cursor. + let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); + let end = egui::text::CCursor::new(view.page_input.chars().count()); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); + state.store(ui.ctx(), page_input_id); + } + let edit = egui::TextEdit::singleline(&mut view.page_input) + .id(page_input_id) + .desired_width(34.0) + .horizontal_align(Align::Center) + .font(theme::medium(12.0)) + .margin(vec2(2.0, 4.0)); + let r = egui::Frame::NONE + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(5)) + .show(ui, |ui| ui.add(edit)) + .inner + .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); + if r.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // A page label first (logical page numbers, as Acrobat), then a number. + let typed = view.page_input.clone(); + view.go_to_typed(&typed, &labels); + } + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); + } + }); + if let Some(id) = page_display { + app.execute(id); } - } - ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted)); - let page_input_id = view.page_input_id(); - // Consume replacement selection once, including when the editor has lost focus. - let reselect = std::mem::take(&mut view.page_input_reselect); - // Navigation updates the idle display, never the text being edited. - if !ui.memory(|m| m.has_focus(page_input_id)) { - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } else if reselect { - // The replacement changed the label while egui retained this editor's cursor. - let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); - let end = egui::text::CCursor::new(view.page_input.chars().count()); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); - state.store(ui.ctx(), page_input_id); - } - let edit = egui::TextEdit::singleline(&mut view.page_input) - .id(page_input_id) - .desired_width(34.0) - .horizontal_align(Align::Center) - .font(theme::medium(12.0)) - .margin(vec2(2.0, 4.0)); - let r = egui::Frame::NONE - .fill(t.field) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(5)) - .show(ui, |ui| ui.add(edit)) - .inner - .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); - if r.lost_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Enter)) { - // A page label first (logical page numbers, as Acrobat), then a number. - let typed = view.page_input.clone(); - view.go_to_typed(&typed, &labels); - } - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } - }); - if let Some(id) = page_display { - app.execute(id); - } + }, + ); }); } diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs index b06e81d12d..889d952520 100644 --- a/crates/ui-egui/tests/min_window.rs +++ b/crates/ui-egui/tests/min_window.rs @@ -36,3 +36,85 @@ fn a_wide_window_keeps_the_five_mode_tabs() { assert!(h.query_by_role_and_label(Role::Button, mode).is_some(), "{mode} tab"); } } + +fn fixture(n: usize) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (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 { + protected_with(pdfcraft_cos::Algorithm::Aes256, user, owner, permissions) +} + +fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { + 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:?}"); +} + +/// #1913 (1182 x 556): the rail's top buttons and its page-navigation cluster must not overlap. +#[test] +fn a_short_window_does_not_overlap_the_rail_clusters() { + let h = { + let mut h = Harness::builder().with_size(egui::vec2(1182.0, 556.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, fixture(2)).unwrap(); + app + }); + h.run_steps(8); + h + }; + let tags = h.get_by_label("Tags").rect(); + let next = h.get_by_label("Previous page").rect(); + assert!(next.top() >= tags.bottom(), "page navigation sits below the rail buttons: {tags:?} vs {next:?}"); +} From fb7f7252dbc470ef5736295da94134f2c3e5146f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 15 Oct 2026 23:33:25 +0200 Subject: [PATCH 04/10] UI: size the rail's bottom cluster for the history buttons, fix a pixel-fragile flick test (#1913) The rail's page-navigation cluster measures 360 pt since the two view-history buttons landed; RAIL_BOTTOM_H was 316, so in a short window the cluster overflowed its allocation and overlapped. The min-window test now uses the minimum height and checks the page box, history and zoom buttons. a_quick_flick_on_either_edge_rewraps_the_paragraph grabbed the widened edge 2 px outside the box, at the limit of the grab zone; the rail's scroll area moved the canvas width by 3 px and the press missed. It now grabs 1 px outside. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 4 ++-- crates/ui-egui/tests/editing.rs | 4 +++- crates/ui-egui/tests/min_window.rs | 12 ++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index d4c6f09503..51f269dc43 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -648,8 +648,8 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Height the rail's page-navigation cluster (zoom, fit, page box) needs; below it the rail scrolls. -const RAIL_BOTTOM_H: f32 = 316.0; +/// Height the rail's page-navigation cluster (zoom, fit, page, history buttons, page box) needs (360 pt measured); below it the rail scrolls. +const RAIL_BOTTOM_H: f32 = 364.0; pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db525751..cef7214485 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -3487,7 +3487,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 = 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"); diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs index 889d952520..8d28c6aff6 100644 --- a/crates/ui-egui/tests/min_window.rs +++ b/crates/ui-egui/tests/min_window.rs @@ -101,11 +101,11 @@ fn the_stacked_notice_keeps_its_buttons_in_view() { assert!(!unlock.intersects(settings), "the buttons do not overlap: {unlock:?} {settings:?}"); } -/// #1913 (1182 x 556): the rail's top buttons and its page-navigation cluster must not overlap. +/// #1913 (the minimum height): the rail's top buttons and its page-navigation cluster must not overlap. #[test] fn a_short_window_does_not_overlap_the_rail_clusters() { let h = { - let mut h = Harness::builder().with_size(egui::vec2(1182.0, 556.0)).build_eframe(|_cc| { + let mut h = Harness::builder().with_size(egui::vec2(1182.0, MIN_WINDOW_SIZE[1])).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.open_bytes("a.pdf", None, fixture(2)).unwrap(); @@ -117,4 +117,12 @@ fn a_short_window_does_not_overlap_the_rail_clusters() { let tags = h.get_by_label("Tags").rect(); let next = h.get_by_label("Previous page").rect(); assert!(next.top() >= tags.bottom(), "page navigation sits below the rail buttons: {tags:?} vs {next:?}"); + // The history buttons are the cluster's topmost buttons (above the page box). + let history = h.get_by_label_contains("Previous view").rect(); + assert!(history.top() >= tags.bottom(), "view history sits below the rail buttons: {tags:?} vs {history:?}"); + // The topmost thing in the cluster is the page box; the lowest is the zoom-out button. + let page_box = h.get_by_role(Role::TextInput).rect(); + assert!(page_box.top() >= tags.bottom(), "the page box sits below the rail buttons: {tags:?} vs {page_box:?}"); + let zoom_out = h.get_by_label_contains("Zoom out").rect(); + assert!(zoom_out.top() >= history.bottom(), "zoom sits below view history: {history:?} vs {zoom_out:?}"); } From d6d4999a355325e701506df85318ddf50f1689dd Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:39:14 +0200 Subject: [PATCH 05/10] Test: the rail now has two text fields (zoom and page box) After merging main's editable zoom field, get_by_role(TextInput) in the min-window test found two nodes. Check every text field of the rail sits below the rail buttons. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/min_window.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/min_window.rs b/crates/ui-egui/tests/min_window.rs index 8d28c6aff6..005c97dc5f 100644 --- a/crates/ui-egui/tests/min_window.rs +++ b/crates/ui-egui/tests/min_window.rs @@ -120,9 +120,11 @@ fn a_short_window_does_not_overlap_the_rail_clusters() { // The history buttons are the cluster's topmost buttons (above the page box). let history = h.get_by_label_contains("Previous view").rect(); assert!(history.top() >= tags.bottom(), "view history sits below the rail buttons: {tags:?} vs {history:?}"); - // The topmost thing in the cluster is the page box; the lowest is the zoom-out button. - let page_box = h.get_by_role(Role::TextInput).rect(); - assert!(page_box.top() >= tags.bottom(), "the page box sits below the rail buttons: {tags:?} vs {page_box:?}"); + // The cluster holds two text fields, the zoom field and the page box; the page box is the topmost thing in it, the lowest is the zoom-out button. + for field in h.get_all_by_role(Role::TextInput) { + let r = field.rect(); + assert!(r.top() >= tags.bottom(), "the rail's text fields sit below the rail buttons: {tags:?} vs {r:?}"); + } let zoom_out = h.get_by_label_contains("Zoom out").rect(); assert!(zoom_out.top() >= history.bottom(), "zoom sits below view history: {history:?} vs {zoom_out:?}"); } From 0f64d9589ed5469490bd5d176ce47101f2c1d45a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 24 Oct 2026 21:45:07 +0200 Subject: [PATCH 06/10] Run rustfmt on the mode bar after the upstream merge Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b08c48518d..e5b239ecbb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -507,8 +507,7 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.add_space(10.0); // 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. - let modes = - [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]; + 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::() + 2.0 * (modes.len() as f32 - 1.0); From 90fb9b8c9e37adb0663bacca887248672f68d8e4 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 19:18:16 +0100 Subject: [PATCH 07/10] Test the menu bar's File item instead of the removed Menu button Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/tests/it/min_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/min_window.rs b/crates/ui-egui/tests/it/min_window.rs index fc395e7cdf..f384eef0eb 100644 --- a/crates/ui-egui/tests/it/min_window.rs +++ b/crates/ui-egui/tests/it/min_window.rs @@ -26,7 +26,7 @@ fn at_the_minimum_width_the_modes_fold_into_a_menu_and_search_stays() { 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::Button, "Menu").is_some(), "main menu reachable"); + assert!(h.query_by_role_and_label(Role::MenuItem, "File").is_some(), "main menu reachable"); } #[test] From 88649a8955fa674adcaa35af48340de74209e294 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 20:35:42 +0100 Subject: [PATCH 08/10] Name Read mode in the folded mode menu The narrow-window mode menu fell back to "All tools" while Read was active. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index f831d26d9c..5e1fef8860 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -505,8 +505,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } else { // Narrow window: the modes fold into one menu named after the current one. - let current = modes.iter().find(|(m, _)| *m == app.mode).map_or("All tools", |(_, l)| *l); - let resp = widgets::ghost_button(ui, "chevron-down", tl!(current)); + // 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 { From 95f8eaa63f3a2a9661f2cd168ff704367338b3a8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:42:02 +0100 Subject: [PATCH 09/10] ui: count the third item gap in the mode bar's right-hand width The print, save and info buttons each have 4 pt after them (three gaps), so the reserved width is 116 pt, not 112; the search field no longer overlaps the tabs. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/chrome.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5e1fef8860..02b78d31fb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -470,8 +470,9 @@ fn tab( 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. -const MODE_BAR_RIGHT: f32 = 3.0 * 32.0 + 2.0 * 4.0 + 8.0; +/// 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; From 761fd4ff5112c454eaefa8fcc9b10452b599b1d6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 18:35:28 +0100 Subject: [PATCH 10/10] Notice bar: stack the buttons only where the message still has room Below about 100 pt of content width the stacked message wrapped to a column thousands of points tall and pushed the hotbar off screen, which broke the narrow-viewport hotbar picker test merged from main. That width is under the smallest supported window, so the single row stays there. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas/bars.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index e588013a74..3e12bd6ce3 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -253,9 +253,13 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te 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 width < STACK_BELOW { + 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| {