diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index c70d84e624..e8fa4adac6 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
-## In this repository (256)
+## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -263,6 +263,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 9638749929..f998b6fc3d 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -3200,6 +3200,17 @@ kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
+[[asset]]
+path = "assets/icons/a-large-small.svg"
+title = "Lucide icon \"a-large-small\""
+author = "Lucide Contributors (portions Cole Bemis, Feather)"
+source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg)"
+licence = "ISC AND MIT"
+licence_file = "assets/icons/LICENSE-lucide.txt"
+kind = "icon"
+usage = "UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs"
+sha256 = "d4985fc71174d108e322c3b25cb6abe2837a115fe3f239a1918645db436743ec"
+
[[asset]]
path = "assets/icons/move-horizontal.svg"
title = "Lucide icon \"move-horizontal\""
diff --git a/ROADMAP.md b/ROADMAP.md
index 00dd8fdee0..bf752c80d4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -235,6 +235,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
+- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
+
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
diff --git a/assets/icons/a-large-small.svg b/assets/icons/a-large-small.svg
new file mode 100644
index 0000000000..7716db3cf2
--- /dev/null
+++ b/assets/icons/a-large-small.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 43a1e5ce1d..5ac073960f 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -108,6 +108,7 @@ impl PdfCraftApp {
ui_scale_applied: None,
flatten_fill_sign_on_save: false,
fill_text_size: fill_sign::TEXT_SIZE,
+ fill_tool: fill_sign::FillTool::Signature,
fill_text_color: [0.0; 3],
dialog: None,
updates: updates::Updates::default(),
@@ -128,6 +129,7 @@ impl PdfCraftApp {
last_session: Default::default(),
quit_session: None,
reload_changed_files: true,
+ update_check_at_start: true,
show_secured_notice: true,
always_default_view: false,
#[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs
index 0309042bf1..5d51adfdb4 100644
--- a/crates/ui-egui/src/app/control.rs
+++ b/crates/ui-egui/src/app/control.rs
@@ -47,6 +47,9 @@ impl PdfCraftApp {
("show_page_edge_shadows", _) => {
self.show_page_edge_shadows = value.parse::().map_err(|_| "show_page_edge_shadows must be true or false")?;
}
+ ("update_check_at_start", _) => {
+ self.update_check_at_start = value.parse::().map_err(|_| "update_check_at_start must be true or false")?;
+ }
("scroll_speed", _) => {
self.scroll_speed = scroll_speed::ScrollSpeed::parse(value).ok_or("scroll_speed must be slow, normal, or fast")?;
}
@@ -333,9 +336,12 @@ impl PdfCraftApp {
field if field.starts_with("field-") => QuickTool::Field(
prepare::FieldTool::from_command(&format!("form.add.{}", &field[6..])).ok_or_else(|| format!("unknown tool {field}"))?,
),
- fill if fill.starts_with("fill-") => QuickTool::Fill(
- fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?,
- ),
+ fill if fill.starts_with("fill-") => {
+ let tool =
+ fill_sign::FillTool::from_command(&format!("sign.fill.{}", &fill[5..])).ok_or_else(|| format!("unknown tool {fill}"))?;
+ self.fill_tool = tool;
+ QuickTool::Fill(tool)
+ }
other => {
let t = comments::CommentTool::from_command(&format!("comment.{other}")).ok_or_else(|| format!("unknown tool {other}"))?;
self.comment_prefs.group_tool[t.group()] = t;
diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs
index 08a20afb45..0df7d6f30b 100644
--- a/crates/ui-egui/src/app/prefs.rs
+++ b/crates/ui-egui/src/app/prefs.rs
@@ -147,6 +147,9 @@ impl PdfCraftApp {
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
+ // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and
+ // settings written then would turn this off unasked.
+ v["update_check_at_start"] = self.update_check_at_start.into();
v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default();
v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned);
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
@@ -219,6 +222,9 @@ impl PdfCraftApp {
if let Some(on) = v["reload_changed_files"].as_bool() {
self.reload_changed_files = on;
}
+ if let Some(on) = v["update_check_at_start"].as_bool() {
+ self.update_check_at_start = on;
+ }
if let Some(on) = v["show_secured_notice"].as_bool() {
self.show_secured_notice = on;
}
diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs
index dd3fe8c498..fbd4200217 100644
--- a/crates/ui-egui/src/canvas/bars.rs
+++ b/crates/ui-egui/src/canvas/bars.rs
@@ -315,7 +315,7 @@ mod tests {
#[test]
fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() {
- // Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it.
+ // A 70 pt bar centres by that, and 72 pt no longer holds it.
assert_eq!(quick_bar_offset(120.0, 70.0), 15.0);
assert_eq!(quick_bar_offset(72.0, 70.0), 14.0);
}
diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs
index 083ada4253..3549418ae3 100644
--- a/crates/ui-egui/src/commands.rs
+++ b/crates/ui-egui/src/commands.rs
@@ -697,6 +697,7 @@ impl PdfCraftApp {
}
fill if crate::fill_sign::FillTool::from_command(fill).is_some() => {
let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) };
+ self.fill_tool = tool;
self.quick_tool = crate::QuickTool::Fill(tool);
let initials = tool == crate::fill_sign::FillTool::Initials;
if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) {
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
index 84c36f41aa..0e7e2c0ed0 100644
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -132,7 +132,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
Dialog::Print => 820.0,
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
Dialog::Preferences => 610.0,
- Dialog::About => 780.0,
+ Dialog::About => 820.0,
Dialog::PinTool => 540.0,
_ => 520.0,
});
@@ -1352,6 +1352,7 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending)
app.signature = d.saved();
crate::fill_sign::FillTool::Signature
};
+ app.fill_tool = tool;
app.quick_tool = crate::QuickTool::Fill(tool);
app.toast = None;
}
diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs
index ad45ec77b6..d69d545b3f 100644
--- a/crates/ui-egui/src/dialogs/credits.rs
+++ b/crates/ui-egui/src/dialogs/credits.rs
@@ -266,12 +266,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let counts = crate::i18n::fmt(crate::i18n::t("{n} contributors · {c} commits"), &[("n", &list.len().to_string()), ("c", &group(TOTAL_COMMITS))]);
ui.label(RichText::new(counts).small().color(t.text_muted));
ui.separator();
- egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
- if list.is_empty() {
- ui.label(crate::i18n::t("No contributor data was built into this copy."));
- } else if v.table {
- table(ui, &list, &mut v);
- } else {
+ if list.is_empty() {
+ ui.label(crate::i18n::t("No contributor data was built into this copy."));
+ } else if v.table {
+ // The table scrolls vertically itself, under a fixed header; it only scrolls sideways
+ // once its columns are dragged wider than the dialog.
+ ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| {
+ ui.set_max_height(LIST_HEIGHT);
+ egui::ScrollArea::horizontal().id_salt("credits_list").auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
+ });
+ } else {
+ egui::ScrollArea::vertical().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -280,43 +285,154 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
- }
- });
+ });
+ }
ui.data_mut(|d| d.insert_temp(id, v));
}
-fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
- egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
- for k in SortKey::ALL {
- let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
- if ui
- .button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong())
- .on_hover_text(crate::i18n::t(k.label().0))
- .clicked()
- {
- if v.key == k {
- v.ascending = !v.ascending;
- } else {
- v.key = k;
- v.ascending = k.default_ascending();
- }
+/// How the contributor table's spare width is shared out: all of it to Name.
+const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
+
+/// How the model table's spare width is shared out: to Company and Model, the text columns.
+const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
+
+/// The narrowest a column can be dragged.
+const MIN_COLUMN: f32 = 28.0;
+
+/// The width a column needs for its header and its widest cell.
+fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator- ) -> f32 {
+ let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
+ let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
+ widest + 2.0 * ui.spacing().button_padding.x
+}
+
+/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
+/// so the columns fill `room` exactly. When there is too little room, only the growing columns
+/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
+fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec {
+ let spare = room - natural.iter().sum::();
+ let weight = grow.iter().sum::();
+ let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
+ let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
+ natural
+ .iter()
+ .zip(grow)
+ .map(|(w, g)| {
+ if *g > 0.0 && spare >= 0.0 {
+ w + spare * g / weight
+ } else if *g > 0.0 && flexible > 0.0 {
+ w - (-spare).min(flexible) * slack(*w) / flexible
+ } else {
+ *w
}
- }
- ui.end_row();
- for c in list {
- ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
- ui.label(group(c.prs));
- ui.label(group(c.commits));
- ui.label(group(c.lines_added));
- ui.label(group(c.lines_deleted));
- ui.label(signed(c.lines_delta()));
- ui.label(group(c.binary_added));
- ui.label(group(c.binary_deleted));
- ui.label(day(c.first_commit));
- ui.label(day(c.last_commit));
- ui.end_row();
- }
- });
+ })
+ .collect()
+}
+
+/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
+/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
+/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
+/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
+/// dropping any dragged widths.
+fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
+ // Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
+ let s = ui.spacing();
+ let gaps = s.item_spacing.x * natural.len() as f32;
+ let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
+ let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
+ // The table keeps its own column widths, so a change in what they were fitted to has to clear
+ // them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
+ // width, so this compares the natural widths and the clip rect, which scrolling never moves.
+ let fit = egui::Id::unique(("credits_fit", id));
+ let measured = (natural.to_vec(), ui.clip_rect().width());
+ let refit = ui.data_mut(|d| d.get_temp::<(Vec, f32)>(fit)).as_ref() != Some(&measured);
+ ui.data_mut(|d| d.insert_temp(fit, measured));
+ let mut t =
+ egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
+ if refit {
+ t.reset();
+ }
+ for w in widths {
+ t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
+ }
+ t
+}
+
+/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
+fn aligned(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
+ let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+ ui.with_layout(layout, add).inner
+}
+
+fn row_height(ui: &egui::Ui) -> f32 {
+ ui.text_style_height(&egui::TextStyle::Body) + 6.0
+}
+
+/// One contributor's table cells after the name, in `SortKey::ALL` order.
+fn cells(c: &Contributor) -> [String; 9] {
+ [
+ group(c.prs),
+ group(c.commits),
+ group(c.lines_added),
+ group(c.lines_deleted),
+ signed(c.lines_delta()),
+ group(c.binary_added),
+ group(c.binary_deleted),
+ day(c.first_commit).to_string(),
+ day(c.last_commit).to_string(),
+ ]
+}
+
+/// A contributor-table header. The sorted column is highlighted; its direction shows on the
+/// toolbar's sort-order button, so no arrow widens the column.
+fn header_text(k: SortKey) -> RichText {
+ RichText::new(crate::i18n::t(k.label().1)).strong()
+}
+
+fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
+ let h = row_height(ui);
+ let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
+ let widths: Vec = SortKey::ALL
+ .iter()
+ .enumerate()
+ .map(|(i, &k)| match i.checked_sub(1) {
+ None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
+ Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
+ })
+ .collect();
+ fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
+ .header(h + 4.0, |mut header| {
+ for k in SortKey::ALL {
+ header.col(|ui| {
+ let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k)))
+ .on_hover_text(crate::i18n::t(k.label().0));
+
+ if r.clicked() {
+ if v.key == k {
+ v.ascending = !v.ascending;
+ } else {
+ v.key = k;
+ v.ascending = k.default_ascending();
+ }
+ }
+ });
+ }
+ })
+ .body(|mut body| {
+ for (c, cells) in list.iter().zip(rows) {
+ body.row(h, |mut row| {
+ row.col(|ui| {
+ aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
+ .on_hover_text(c.summary());
+ });
+ for text in cells {
+ row.col(|ui| {
+ aligned(ui, true, |ui| ui.label(text));
+ });
+ }
+ });
+ }
+ });
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -326,21 +442,50 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
- egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
- egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
- for h in MODEL_COLUMNS {
- ui.label(RichText::new(crate::i18n::t(h)).strong());
- }
- ui.end_row();
- for m in MODELS {
- ui.label(m.company);
- ui.label(m.model);
- ui.label(m.version);
- ui.label(group(m.commits));
- ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
- ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
- ui.end_row();
- }
+ let h = row_height(ui);
+ let titles = MODEL_COLUMNS;
+ // Company, Model and Version read as text; the counts line up on the right.
+ let right = [false, false, false, true, true, true];
+ let rows: Vec<[String; 6]> = MODELS
+ .iter()
+ .map(|m| {
+ [
+ m.company.to_string(),
+ m.model.to_string(),
+ m.version.to_string(),
+ group(m.commits),
+ format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
+ format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
+ ]
+ })
+ .collect();
+ let widths: Vec = titles
+ .iter()
+ .enumerate()
+ .map(|(i, title)| natural(ui, RichText::new(crate::i18n::t(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
+ .collect();
+ ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| {
+ ui.set_max_height(LIST_HEIGHT);
+ egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
+ fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
+ .header(h + 4.0, |mut header| {
+ for (title, r) in titles.into_iter().zip(right) {
+ header.col(|ui| {
+ aligned(ui, r, |ui| ui.label(RichText::new(crate::i18n::t(title)).strong()));
+ });
+ }
+ })
+ .body(|mut body| {
+ for cells in rows {
+ body.row(h, |mut row| {
+ for (text, r) in cells.into_iter().zip(right) {
+ row.col(|ui| {
+ aligned(ui, r, |ui| ui.label(text));
+ });
+ }
+ });
+ }
+ });
});
});
}
@@ -421,4 +566,131 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
+
+ #[test]
+ fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
+ let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
+ assert!((w.iter().sum::() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
+ }
+
+ #[test]
+ fn too_little_room_shrinks_only_growing_columns() {
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
+ assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
+ assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
+ }
+
+ #[test]
+ fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
+ let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
+ assert!((w.iter().sum::() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
+ assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
+ assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
+ }
+
+ const WINDOW: f32 = 640.5;
+ const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
+ const GROW: [f32; 3] = [1.0, 0.0, 0.0];
+
+ /// One frame of a table long enough to scroll; the rects of its header cells.
+ fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec) -> Vec {
+ scrolled_frame(ctx, width, natural, 0.0, events)
+ }
+
+ /// [`table_frame`], scrolled `offset` points sideways.
+ fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec) -> Vec {
+ let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
+ let raw = egui::RawInput { screen_rect, events, ..Default::default() };
+ let mut cells = Vec::new();
+ let out = ctx.run_ui(raw, |ui| {
+ cells.clear();
+ egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
+ fitted_table(ui, "test", natural, &GROW)
+ .header(20.0, |mut header| {
+ for _ in natural {
+ header.col(|ui| cells.push(ui.max_rect()));
+ }
+ })
+ .body(|mut body| {
+ for _ in 0..40 {
+ body.row(20.0, |mut row| {
+ for _ in natural {
+ row.col(|ui| {
+ ui.label("x");
+ });
+ }
+ });
+ }
+ });
+ });
+ });
+ out.drop_without_applying_deltas();
+ cells
+ }
+
+ /// A new context with the table open and settled, and the rects of its header cells.
+ fn opened_table() -> (egui::Context, Vec) {
+ let ctx = egui::Context::default();
+ table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ (ctx, cells)
+ }
+
+ /// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
+ fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
+ use egui::{Event, Modifiers, PointerButton};
+ let start = egui::pos2(cell.right() + 4.0, cell.center().y);
+ let halfway = start + egui::vec2(by / 2.0, 0.0);
+ let end = start + egui::vec2(by, 0.0);
+ let moved = Event::PointerMoved;
+ let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
+ for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
+ table_frame(ctx, WINDOW, &NATURAL, vec![event]);
+ }
+ }
+
+ #[test]
+ fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
+ let (_, opened) = opened_table();
+ for (dragged, cell) in opened.iter().enumerate() {
+ let (ctx, _) = opened_table();
+ drag_edge(&ctx, *cell, -30.0);
+ table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
+ let want = if column == dragged { was.width() - 30.0 } else { was.width() };
+ assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
+ }
+ }
+ }
+
+ #[test]
+ fn a_changed_fit_replaces_the_column_widths() {
+ let (ctx, opened) = opened_table();
+ drag_edge(&ctx, opened[0], -30.0);
+ let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
+ assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
+ let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
+ let widths: Vec = wider.iter().map(|c| c.width().round()).collect();
+ assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
+ assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
+ let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
+ assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
+ }
+
+ #[test]
+ fn scrolling_sideways_keeps_dragged_widths() {
+ let (ctx, opened) = opened_table();
+ drag_edge(&ctx, opened[0], 100.0);
+ table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
+ assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
+ for offset in [13.3, 27.77, 41.1, 0.6] {
+ scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
+ let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
+ assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
+ }
+ }
}
diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs
index 9e3182e416..ea985f3207 100644
--- a/crates/ui-egui/src/dialogs/preferences.rs
+++ b/crates/ui-egui/src/dialogs/preferences.rs
@@ -659,6 +659,8 @@ fn card_general(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ctx: &mut
ctx.checkbox(ui, &mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"), t);
#[cfg(not(target_arch = "wasm32"))]
ctx.checkbox(ui, &mut app.reload_changed_files, tl!("Reload documents when their files change on disk"), t);
+ #[cfg(not(target_arch = "wasm32"))]
+ ctx.checkbox(ui, &mut app.update_check_at_start, tl!("Check for updates when PdfCraft starts"), t);
ctx.checkbox(ui, &mut app.show_secured_notice, tl!("Show a notice when a document is secured"), t);
#[cfg(not(target_arch = "wasm32"))]
diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs
index 1fd5dbd75c..269f0a12c2 100644
--- a/crates/ui-egui/src/hotbar.rs
+++ b/crates/ui-egui/src/hotbar.rs
@@ -239,8 +239,20 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_
QuickTool::Fill(f) => Some(f),
_ => None,
};
- let shown = current_fill.unwrap_or(crate::fill_sign::FillTool::Text);
- let resp = icons::button(ui, if current_fill.is_some() { shown.icon() } else { "pen-line" }, cell, current_fill.is_some(), tl!("Fill & Sign"));
+ let shown = current_fill.unwrap_or(app.fill_tool);
+ let tip = match shown {
+ crate::fill_sign::FillTool::Signature | crate::fill_sign::FillTool::Initials if current_fill.is_none() => {
+ let initials = shown == crate::fill_sign::FillTool::Initials;
+ let saved = if initials { app.initials.is_some() } else { app.signature.is_some() };
+ crate::i18n::fmt(
+ tl!(if saved { "Place saved {what}" } else { "Add {what}" }),
+ &[("what", tl!(if initials { "initials" } else { "signature" }))],
+ )
+ }
+ _ => tl!(shown.label()).to_string(),
+ };
+ let tip = format!("{}: {tip}", tl!("Fill & Sign"));
+ let resp = icons::button(ui, shown.icon(), cell, current_fill.is_some(), &tip);
let r = resp.rect;
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
ui.painter().add(egui::Shape::convex_polygon(tri.to_vec(), if current_fill.is_some() { t.accent } else { t.text_faint }, Stroke::NONE));
@@ -287,6 +299,30 @@ fn core_tool_buttons(ui: &mut egui::Ui, app: &mut PdfCraftApp, cell: f32, popup_
});
}
+fn controls_divider(ui: &mut egui::Ui, t: &Tokens, vertical: bool) {
+ let (r, _) = if vertical {
+ ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
+ } else {
+ ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
+ };
+ if vertical {
+ ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
+ } else {
+ ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
+ }
+}
+
+fn selected_fill_text(app: &PdfCraftApp) -> Option<(usize, usize)> {
+ let (index, id) = app.active_ids()?;
+ let (page, annotation) = app.views.get(index)?.comments.selected?;
+ let doc = app.session.get(id).filter(|doc| doc.allows_annotation())?;
+ doc.info
+ .annotations
+ .iter()
+ .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
+ .then_some((page, annotation))
+}
+
/// Extra controls rendered below/beside the buttons when a comment or fill tool is active.
fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, vertical: bool) {
let current_fill = match app.quick_tool {
@@ -294,47 +330,44 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve
_ => None,
};
if matches!(current_fill, Some(crate::fill_sign::FillTool::Text | crate::fill_sign::FillTool::Date)) {
- if vertical {
- ui.label(tl!("Font size:"));
- }
- let size = ui.add(
- egui::DragValue::new(&mut app.fill_text_size)
- .range(crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
- .speed(0.25)
- .suffix(" pt"),
- );
- if ((size.changed() && !size.dragged()) || size.drag_stopped())
- && let Some((index, id)) = app.active_ids()
- && let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
- && app.session.get(id).is_some_and(|doc| {
- doc.allows_annotation()
- && doc
- .info
- .annotations
- .iter()
- .any(|a| a.page == page && a.index == annotation && a.fill_sign && a.subtype == "FreeText" && !a.locked)
- })
- {
- app.apply_edit(Edit::FontSizeAnnotation { page, index: annotation, size: app.fill_text_size });
- }
- ui.menu_button(tl!("Colour"), |ui| {
- if let Some(color) = comments::swatch_grid(ui, Some(app.fill_text_color)) {
- app.fill_text_color = color;
- ui.close();
+ controls_divider(ui, t, vertical);
+ if let Some(color) = comments::quick_bar_colour(ui, app.fill_text_color) {
+ app.fill_text_color = color;
+ if let Some((page, index)) = selected_fill_text(app) {
+ app.apply_edit(Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None });
}
- });
+ }
+ let resp = icons::button(ui, "a-large-small", 32.0, false, tl!("Font size"));
+ let resized = egui::Popup::menu(&resp)
+ .align(egui::RectAlign::RIGHT_START)
+ .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
+ .show(|ui| {
+ ui.set_min_width(180.0);
+ ui.label(egui::RichText::new(tl!("Font size")).font(theme::semibold(12.0)));
+ let size = ui.add(
+ egui::Slider::new(&mut app.fill_text_size, crate::fill_sign::MIN_TEXT_SIZE..=crate::fill_sign::MAX_TEXT_SIZE)
+ .logarithmic(true)
+ .suffix(" pt"),
+ );
+ // #1274: the size also applies to the Fill & Sign text that is selected,
+ // once per change (a typed value, or the end of a drag), so a drag adds
+ // one undo step and not one per frame.
+ (size.changed() && !size.dragged()) || size.drag_stopped()
+ })
+ .is_some_and(|popup| popup.inner);
+ let size = app.fill_text_size;
+ if resized
+ && let Some((page, index)) = selected_fill_text(app)
+ && app
+ .active_ids()
+ .and_then(|(_, id)| app.session.get(id)?.comment_props(page, index)?.font_size)
+ .is_none_or(|stored| format!("{stored:.3}") != format!("{size:.3}"))
+ {
+ app.apply_edit(Edit::FontSizeAnnotation { page, index, size });
+ }
}
if let QuickTool::Comment(tool) = app.quick_tool {
- let (r, _) = if vertical {
- ui.allocate_exact_size(vec2(crate::canvas::bars::QUICK_BAR_WIDTH, 9.0), Sense::hover())
- } else {
- ui.allocate_exact_size(vec2(9.0, crate::canvas::bars::QUICK_BAR_WIDTH), Sense::hover())
- };
- if vertical {
- ui.painter().hline(r.x_range().shrink(6.0), r.center().y, Stroke::new(1.0, t.divider));
- } else {
- ui.painter().vline(r.center().x, r.y_range().shrink(6.0), Stroke::new(1.0, t.divider));
- }
+ controls_divider(ui, t, vertical);
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
&& let Some((index, id)) = app.active_ids()
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index c0acc17a30..4ab5b4343f 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -332,6 +332,7 @@ pub struct PdfCraftApp {
pub flatten_fill_sign_on_save: bool,
/// Point size for new Fill & Sign text and dates, kept across launches.
pub fill_text_size: f64,
+ pub fill_tool: fill_sign::FillTool,
/// Colour for new Fill & Sign text and dates, kept across launches.
pub fill_text_color: [f64; 3],
pub dialog: Option