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 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Sonnet 5.5 committed 2026-10-15 23:16:29 +02:00
1 parent 99941daa5a
commit f6e335f3fe
6 files changed
+105 -7

No files matched your search

+1 -1
View File
@@ -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);
+16
View File
@@ -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);
+37 -6
View File
@@ -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::<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 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;
}
});
+6
View File
@@ -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};
+7
View File
@@ -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());
+38
View File
@@ -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");
}
}