mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
Merge remote-tracking branch 'upstream/main' into fix/pdfa-xmp-exif-types
This commit is contained in:
commit
af583b77f2
25 files changed
+858
-128
No files matched your search
+2
-1
@@ -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)
|
||||
|
||||
@@ -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\""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-a-large-small"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m15 16 2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16" />
|
||||
<path d="M15.697 14h5.606" />
|
||||
<path d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" />
|
||||
<path d="M3.304 13h6.392" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 475 B |
@@ -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"))]
|
||||
|
||||
@@ -47,6 +47,9 @@ impl PdfCraftApp {
|
||||
("show_page_edge_shadows", _) => {
|
||||
self.show_page_edge_shadows = value.parse::<bool>().map_err(|_| "show_page_edge_shadows must be true or false")?;
|
||||
}
|
||||
("update_check_at_start", _) => {
|
||||
self.update_check_at_start = value.parse::<bool>().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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Item = String>) -> 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<f32> {
|
||||
let spare = room - natural.iter().sum::<f32>();
|
||||
let weight = grow.iter().sum::<f32>();
|
||||
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>, 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<R>(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<f32> = 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<f32> = 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::<f32>() - 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::<f32>() - 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<egui::Event>) -> Vec<egui::Rect> {
|
||||
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<egui::Event>) -> Vec<egui::Rect> {
|
||||
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<egui::Rect>) {
|
||||
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<f32> = 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Dialog>,
|
||||
@@ -371,6 +372,9 @@ pub struct PdfCraftApp {
|
||||
quit_session: Option<last_session::LastSession>,
|
||||
/// Preferences: reload a document when its file changes on disk (#431).
|
||||
pub reload_changed_files: bool,
|
||||
/// Preferences: ask for a newer release when the desktop app starts (#5737). Off, the app
|
||||
/// asks only from Help ▸ Check for updates.
|
||||
pub update_check_at_start: bool,
|
||||
/// Preferences: tell the user, above the pages, that a document is secured (its security
|
||||
/// settings restrict changes). Off only hides that notice (#4035); kept in the settings.
|
||||
pub show_secured_notice: bool,
|
||||
|
||||
@@ -21,7 +21,8 @@ pub(crate) struct Updates {
|
||||
updater: Updater,
|
||||
/// The Updates dialog is showing.
|
||||
pub(crate) open: bool,
|
||||
/// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network.
|
||||
/// Ask for a newer release at start, unless Preferences says not to; the desktop app turns
|
||||
/// it on, so tests never reach the network.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
startup_check: bool,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -73,10 +74,12 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(ctx) = self.ctx.clone() else { return };
|
||||
// Settled on the first frame: a preference turned on later waits for the next start.
|
||||
let at_start = std::mem::take(&mut self.updates.startup_check) && self.update_check_at_start;
|
||||
let updates = &mut self.updates;
|
||||
if std::mem::take(&mut updates.check_requested) {
|
||||
updates.updater.check_now(&ctx);
|
||||
} else if updates.startup_check {
|
||||
} else if at_start {
|
||||
updates.updater.check_at_start(&ctx);
|
||||
}
|
||||
updates.updater.tick(&ctx);
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving
|
||||
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
|
||||
#[cfg(test)]
|
||||
use menu::{RECENT_CUSTOM, same_colour};
|
||||
pub(crate) use menu::{attachment_items, context_menu, quick_bar_controls};
|
||||
pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls};
|
||||
pub(crate) use paint::paint_page;
|
||||
#[cfg(test)]
|
||||
use paint::{changing_points, drawn_shape, edited_in_view};
|
||||
|
||||
@@ -286,25 +286,32 @@ pub fn fill_picker(ui: &mut egui::Ui, current: Option<Rgb>) -> Option<Option<Rgb
|
||||
picked
|
||||
}
|
||||
|
||||
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
|
||||
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option<Rgb> {
|
||||
let style = prefs.style(tool);
|
||||
let mut selected_color = None;
|
||||
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
|
||||
prefs.pinned = !prefs.pinned;
|
||||
}
|
||||
pub(crate) fn quick_bar_colour(ui: &mut egui::Ui, current: Rgb) -> Option<Rgb> {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), 9.0, color32(style.color));
|
||||
ui.painter().circle_filled(r.center(), 9.0, color32(current));
|
||||
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour")));
|
||||
let resp = resp.on_hover_text(tl!("Colour"));
|
||||
let mut picked = None;
|
||||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(c) = swatch_grid(ui, Some(style.color)) {
|
||||
prefs.set_color(tool, c);
|
||||
selected_color = Some(c);
|
||||
if let Some(c) = swatch_grid(ui, Some(current)) {
|
||||
picked = Some(c);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
/// The comment tools' extra quick-bar controls: pin, colour, fill, opacity and thickness.
|
||||
pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &mut CommentPrefs) -> Option<Rgb> {
|
||||
let style = prefs.style(tool);
|
||||
if icons::button(ui, "pin", 32.0, prefs.pinned, if prefs.pinned { tl!("Keep tool selected: on") } else { tl!("Keep tool selected") }).clicked() {
|
||||
prefs.pinned = !prefs.pinned;
|
||||
}
|
||||
let selected_color = quick_bar_colour(ui, style.color);
|
||||
if let Some(c) = selected_color {
|
||||
prefs.set_color(tool, c);
|
||||
}
|
||||
if tool.has_fill() {
|
||||
let resp = fill_button(ui, style.fill);
|
||||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
|
||||
@@ -374,20 +374,22 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
|
||||
}
|
||||
continue;
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
let on = app.quick_tool == crate::QuickTool::Fill(if i == 0 { FillTool::Signature } else { FillTool::Initials });
|
||||
let wash = ui.painter().add(egui::Shape::Noop);
|
||||
let row = ui.horizontal(|ui| {
|
||||
let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click());
|
||||
let use_label = label("Use {what}");
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone()));
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, use_label.clone()));
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE);
|
||||
painter.rect_stroke(
|
||||
rect,
|
||||
CornerRadius::same(4),
|
||||
Stroke::new(1.0, if response.hovered() { t.accent } else { t.border }),
|
||||
Stroke::new(1.0, if on || response.hovered() { t.accent } else { t.border }),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
paint_saved(ui, rect.shrink(8.0), saved, &mut app.saved_signature_previews[i], &format!("saved-{what}"));
|
||||
if response.on_hover_text(label("Place saved {what}")).clicked() {
|
||||
if crate::theme::hand(response).on_hover_text(label("Place saved {what}")).clicked() {
|
||||
command = Some(use_id);
|
||||
}
|
||||
if crate::icons::button(ui, "pencil", 26.0, false, &label("Change {what}")).clicked() {
|
||||
@@ -397,6 +399,9 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
|
||||
command = Some(remove_id);
|
||||
}
|
||||
});
|
||||
if on {
|
||||
ui.painter().set(wash, egui::Shape::rect_filled(row.response.rect.expand(3.0), CornerRadius::same(6), t.accent_soft));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
command
|
||||
|
||||
@@ -187,6 +187,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
||||
("wrench", include_bytes!("../../../../assets/icons/wrench.svg")),
|
||||
("move-horizontal", include_bytes!("../../../../assets/icons/move-horizontal.svg")),
|
||||
("pilcrow", include_bytes!("../../../../assets/icons/pilcrow.svg")),
|
||||
("a-large-small", include_bytes!("../../../../assets/icons/a-large-small.svg")),
|
||||
("subscript", include_bytes!("../../../../assets/icons/subscript.svg")),
|
||||
("superscript", include_bytes!("../../../../assets/icons/superscript.svg")),
|
||||
("x", include_bytes!("../../../../assets/icons/x.svg")),
|
||||
|
||||
@@ -99,7 +99,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn shared_catalog_preserves_existing_icons_and_fallback_uri() {
|
||||
assert_eq!(ICONS.len(), 190);
|
||||
assert_eq!(ICONS.len(), 191);
|
||||
assert!(exists("minimize-2"));
|
||||
for (name, original) in ICONS {
|
||||
let expected =
|
||||
|
||||
@@ -3286,7 +3286,7 @@ fn hotbar_docked_left_does_not_override_quick_tools() {
|
||||
});
|
||||
h.run_steps(5);
|
||||
// Controls in the docked bar share one layout, so none may overlap (#5890).
|
||||
let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign", "Hotbar settings"];
|
||||
let labels = ["Drag hotbar", "Select (V)", "Hand (H)", "Add a sticky note", "Highlight", "Draw", "Fill & Sign: Add signature", "Hotbar settings"];
|
||||
let rects: Vec<_> = labels.iter().map(|&label| h.get_by_label(label).rect()).collect();
|
||||
for (i, a) in rects.iter().enumerate() {
|
||||
for (j, b) in rects.iter().enumerate().skip(i + 1) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use egui::{Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use pdfcraft_ui_egui::fill_sign::{FillTool, SavedSig};
|
||||
use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool};
|
||||
|
||||
@@ -164,7 +164,7 @@ fn fill_text_size_controls_new_text_and_survives_settings_restore() {
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("sign.fill.text");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Font size:");
|
||||
h.get_by_label("Font size");
|
||||
h.state_mut().fill_text_size = 24.0;
|
||||
click(&mut h, 50.0, 250.0);
|
||||
assert_eq!(h.state().views[0].fill_text.as_ref().map(|t| t.size), Some(24.0));
|
||||
@@ -201,6 +201,8 @@ fn fill_text_size_resizes_the_selected_text() {
|
||||
};
|
||||
let before = height(&h);
|
||||
let set_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| {
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click();
|
||||
h.run_steps(2);
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.find(|n| n.value().is_some_and(|v| v.ends_with(" pt")))
|
||||
.expect("the Font size control")
|
||||
@@ -211,6 +213,8 @@ fn fill_text_size_resizes_the_selected_text() {
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Button, "Font size").click();
|
||||
h.run_steps(2);
|
||||
};
|
||||
// Nothing selected: only the size for new text changes.
|
||||
h.state_mut().views[0].comments.selected = None;
|
||||
@@ -423,9 +427,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() {
|
||||
let original = format!("{:?}", h.state().session.get(h.state().views[0].id).unwrap().info.annotations);
|
||||
|
||||
// The quick toolbar exposes replacement without changing the normal placement action.
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Place saved signature").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Sign").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Change signature").click();
|
||||
h.run_steps(2);
|
||||
@@ -445,9 +449,9 @@ fn changing_saved_signatures_and_initials_preserves_placed_marks() {
|
||||
click(&mut h, 40.0, 200.0);
|
||||
assert_eq!(items(&h).len(), 2);
|
||||
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Place saved signature").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign").click();
|
||||
h.get_by_label("Fill & Sign: Sign").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Change initials").click();
|
||||
h.run_steps(2);
|
||||
@@ -1813,6 +1817,269 @@ fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() {
|
||||
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box");
|
||||
}
|
||||
|
||||
fn place_selected_text(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
|
||||
h.state_mut().execute("sign.fill.text");
|
||||
click(h, 50.0, 250.0);
|
||||
h.event(egui::Event::Text(text.into()));
|
||||
h.run_steps(1);
|
||||
commit_typed_text(h);
|
||||
let selected = {
|
||||
let s = h.state();
|
||||
s.session
|
||||
.get(s.views[0].id)
|
||||
.unwrap()
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.find(|a| a.contents.as_deref() == Some(text))
|
||||
.map(|a| (a.page, a.index))
|
||||
.unwrap()
|
||||
};
|
||||
h.state_mut().views[0].comments.selected = Some(selected);
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn placed_height(h: &Harness<'static, PdfCraftApp>, text: &str) -> f32 {
|
||||
let s = h.state();
|
||||
let a = s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap();
|
||||
a.rect[3] - a.rect[1]
|
||||
}
|
||||
|
||||
fn placed_color(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option<[f32; 3]> {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().color
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_text_size_flyout_opens_from_an_icon_and_leaves_a_stored_size_alone() {
|
||||
let mut h = harness();
|
||||
h.state_mut().fill_text_size = 9.1;
|
||||
place_selected_text(&mut h, "Keep me");
|
||||
let height = placed_height(&h, "Keep me");
|
||||
assert_eq!(h.query_all_by_role(egui::accesskit::Role::SpinButton).filter(|n| n.value().is_some_and(|v| v.ends_with(" pt"))).count(), 0);
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(4);
|
||||
assert!(
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton).any(|n| n.value().is_some_and(|v| v.ends_with(" pt"))),
|
||||
"the flyout holds the size"
|
||||
);
|
||||
assert_eq!(h.state().fill_text_size, 9.1, "opening the flyout does not round the size");
|
||||
assert_eq!(placed_height(&h, "Keep me"), height, "opening the flyout does not resize the selected text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_fill_text_size_slider_is_one_undo_step() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Drag me");
|
||||
let height = placed_height(&h, "Drag me");
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(2);
|
||||
let slider = h.get_by_role(egui::accesskit::Role::Slider).rect();
|
||||
let y = (slider.min.y + slider.max.y) / 2.0;
|
||||
let from = pos2(slider.min.x + 10.0, y);
|
||||
let to = pos2(slider.max.x - 10.0, y);
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.drag_at(from);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(placed_height(&h, "Drag me"), height, "nothing is applied before the release");
|
||||
h.drop_at(to);
|
||||
h.run_steps(3);
|
||||
assert!(placed_height(&h, "Drag me") > height, "the release resizes the selected text");
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_height(&h, "Drag me"), height, "one undo takes the whole drag back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pressing_the_fill_text_size_slider_without_moving_it_adds_no_undo_step() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Hold me");
|
||||
let height = placed_height(&h, "Hold me");
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(2);
|
||||
let slider = h.get_by_role(egui::accesskit::Role::Slider).rect();
|
||||
let at = pos2(slider.min.x + (slider.max.x - slider.min.x) * 0.75, (slider.min.y + slider.max.y) / 2.0);
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.hover_at(at);
|
||||
h.run_steps(1);
|
||||
h.drag_at(at);
|
||||
h.run_steps(1);
|
||||
h.drop_at(at);
|
||||
h.run_steps(3);
|
||||
};
|
||||
press(&mut h);
|
||||
let resized = placed_height(&h, "Hold me");
|
||||
assert!(resized > height, "the first press moves the size: {height} -> {resized}");
|
||||
press(&mut h);
|
||||
assert_eq!(placed_height(&h, "Hold me"), resized);
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_height(&h, "Hold me"), height, "one undo takes back the only change; the second press added no step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_fill_text_size_that_rounds_to_the_stored_one_adds_no_undo_step() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Round me");
|
||||
let height = placed_height(&h, "Round me");
|
||||
h.get_by_label("Font size").click();
|
||||
h.run_steps(2);
|
||||
let type_size = |h: &mut Harness<'static, PdfCraftApp>, value: &str| {
|
||||
h.query_all_by_role(egui::accesskit::Role::SpinButton)
|
||||
.find(|n| n.value().is_some_and(|v| v.ends_with(" pt")))
|
||||
.expect("the Font size control")
|
||||
.click();
|
||||
h.run_steps(2);
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||||
h.event(egui::Event::Text(value.into()));
|
||||
h.run_steps(1);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(4);
|
||||
};
|
||||
type_size(&mut h, "36.1234");
|
||||
let resized = placed_height(&h, "Round me");
|
||||
assert!(resized > height, "the first size applies: {height} -> {resized}");
|
||||
type_size(&mut h, "36.1231");
|
||||
assert_eq!(h.state().fill_text_size, 36.1231);
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_height(&h, "Round me"), height, "one undo takes back the only change; the rounded retype added no step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fill_colour_dot_recolours_the_selected_text() {
|
||||
let mut h = harness();
|
||||
place_selected_text(&mut h, "Paint me");
|
||||
let before = placed_color(&h, "Paint me");
|
||||
h.get_by_label("Colour").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Blue").click();
|
||||
h.run_steps(3);
|
||||
let color = h.state().fill_text_color.map(|v| v as f32);
|
||||
assert!(color[2] > color[0], "blue was selected: {color:?}");
|
||||
assert_ne!(before, Some(color));
|
||||
assert_eq!(placed_color(&h, "Paint me"), Some(color));
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
assert_eq!(placed_color(&h, "Paint me"), before, "one undo takes the colour back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quick_bar_fill_button_shows_the_tool_it_picks() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Fill & Sign: Add signature").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::Signature));
|
||||
|
||||
h.state_mut().dialog = None;
|
||||
h.state_mut().signature = Some(SavedSig::Typed("Ada Lovelace".into()));
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Place saved signature").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Signature));
|
||||
assert_eq!(h.state().dialog, None);
|
||||
h.get_by_label("Fill & Sign: Sign").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_label("Use signature").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "the active tool's card is marked");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
|
||||
h.state_mut().execute("sign.fill.check");
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Checkmark").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check), "the button keeps the last Fill & Sign tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restored_fill_tool_shows_without_replacing_the_last_selection() {
|
||||
let mut h = harness();
|
||||
h.state_mut().execute("sign.fill.date");
|
||||
h.run_steps(2);
|
||||
h.state_mut().apply_full_screen(true);
|
||||
h.run_steps(2);
|
||||
h.state_mut().execute("sign.fill.check");
|
||||
h.run_steps(2);
|
||||
h.state_mut().apply_full_screen(false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date));
|
||||
h.get_by_label("Fill & Sign: Date");
|
||||
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Checkmark").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Check));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_tool_selected_while_the_quick_bar_is_hidden_is_remembered() {
|
||||
let mut h = harness();
|
||||
let mode = h.state().mode;
|
||||
h.state_mut().set_option("mode", "read").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().set_option("quick", "fill-date").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.state_mut().mode = mode;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Date");
|
||||
|
||||
h.state_mut().initials = Some(SavedSig::Typed("AL".into()));
|
||||
h.state_mut().set_option("mode", "read").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().execute("sign.fill.initials.change");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Apply").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Initials));
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.state_mut().mode = mode;
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Place saved initials");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_quick_bar_fill_button_keeps_date_after_deselecting_and_across_documents() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Fill & Sign: Add signature").click_secondary();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Date").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date));
|
||||
h.get_by_label("Select (V)").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
|
||||
h.state_mut().open_bytes("second.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
h.state_mut().active = Some(1);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Date").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "a click picks the shown tool, not the default");
|
||||
assert_eq!(h.state().dialog, None, "the default tool's dialog stays closed");
|
||||
h.get_by_label("Select (V)").click();
|
||||
h.run_steps(2);
|
||||
|
||||
h.state_mut().close_tab(1);
|
||||
h.state_mut().close_tab(0);
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_bytes("third.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Fill & Sign: Date").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Date), "the tool outlives closing every document");
|
||||
}
|
||||
|
||||
fn commit_typed_text(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
let p = pos2(350.0, 850.0); // Blank canvas outside the page and the type box.
|
||||
h.hover_at(p);
|
||||
|
||||
@@ -100,6 +100,93 @@ fn nothing_is_asked_until_the_user_checks() {
|
||||
h.get_by_label_contains("PdfCraft 99.0.0 is available");
|
||||
}
|
||||
|
||||
/// The desktop app's start, with `settings` restored, and the count of what the source was asked.
|
||||
fn started_with(settings: &'static str) -> (Harness<'static, PdfCraftApp>, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = calls.clone();
|
||||
let h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_startup_update_check(true);
|
||||
app.restore(settings);
|
||||
let counted = counted.clone();
|
||||
app.set_update_source(Arc::new(move || {
|
||||
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(Some(release("v99.0.0")))
|
||||
}));
|
||||
app
|
||||
});
|
||||
(h, calls)
|
||||
}
|
||||
|
||||
/// Run frames for `time`, or until the source has been asked. The check at start shows nothing
|
||||
/// while it runs, so `settle` can't wait for it.
|
||||
fn run_until_asked(h: &mut Harness<'static, PdfCraftApp>, calls: &std::sync::atomic::AtomicUsize, time: std::time::Duration) {
|
||||
let deadline = std::time::Instant::now() + time;
|
||||
while calls.load(std::sync::atomic::Ordering::SeqCst) == 0 && std::time::Instant::now() < deadline {
|
||||
h.run_steps(2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_preference_keeps_the_check_at_start_from_reaching_the_network() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
assert!(PdfCraftApp::new().update_check_at_start, "on by default");
|
||||
|
||||
// The same start with the preference left on does ask, so a count of none below means
|
||||
// the preference held the check back and not that it hadn't run yet.
|
||||
let (mut h, calls) = started_with("{}");
|
||||
run_until_asked(&mut h, &calls, std::time::Duration::from_secs(30));
|
||||
assert_eq!(calls.load(SeqCst), 1, "on, the app asks at start");
|
||||
|
||||
let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#);
|
||||
run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500));
|
||||
assert_eq!(calls.load(SeqCst), 0, "nothing is asked at start");
|
||||
|
||||
let mut restarted = PdfCraftApp::new();
|
||||
restarted.restore(&h.state().persist());
|
||||
assert!(!restarted.update_check_at_start, "the choice is kept");
|
||||
|
||||
// Help ▸ Check for updates still asks.
|
||||
h.state_mut().execute("help.check_updates");
|
||||
settle(&mut h);
|
||||
assert_eq!(calls.load(SeqCst), 1);
|
||||
h.get_by_label_contains("PdfCraft 99.0.0 is available");
|
||||
}
|
||||
|
||||
/// The setting is about starting: turning it on asks nothing until the next start.
|
||||
#[test]
|
||||
fn turning_the_check_at_start_on_waits_for_the_next_start() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let (mut h, calls) = started_with(r#"{"update_check_at_start": false}"#);
|
||||
h.run_steps(3);
|
||||
h.state_mut().set_option("update_check_at_start", "true").unwrap();
|
||||
run_until_asked(&mut h, &calls, std::time::Duration::from_millis(500));
|
||||
assert_eq!(calls.load(SeqCst), 0, "nothing is asked this session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_turn_the_check_at_start_off() {
|
||||
let (mut h, _calls) = started_with(r#"{"update_check_at_start": true}"#);
|
||||
h.state_mut().execute("app.preferences");
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Check for updates when PdfCraft starts").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().update_check_at_start);
|
||||
// The control channel's option, as an agent sets it.
|
||||
h.state_mut().set_option("update_check_at_start", "true").unwrap();
|
||||
assert!(h.state().update_check_at_start);
|
||||
assert!(h.state_mut().set_option("update_check_at_start", "maybe").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_from_the_old_opt_in_leave_the_check_at_start_on() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.restore(r#"{"check_updates_at_start": false}"#);
|
||||
assert!(app.update_check_at_start, "a key of the removed option decides nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| {
|
||||
|
||||
@@ -308,6 +308,7 @@ Estimates unchanged.
|
||||
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
| 2026-11-02 | patch | M10 #5743: macOS print jobs carry the job name and owner some vendors' filters need; not yet checked on a physical printer |
|
||||
| 2026-11-02 | patch | M14 #5737, #5460: Preferences turns off the update check at start, on by default |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 2026-10-31 | patch | #1577: reconcile optional page shadows with current viewer/organizer and settings modules; CI validation pending. |
|
||||
| 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. |
|
||||
|
||||
@@ -1778,8 +1778,8 @@ milestone = "M3"
|
||||
status = "partial"
|
||||
commands = ["help.check_updates"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks"]
|
||||
notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved"
|
||||
evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "crates/ui-egui/tests/it/updates.rs::turning_the_check_at_start_on_waits_for_the_next_start", "crates/ui-egui/tests/it/updates.rs::the_preference_keeps_the_check_at_start_from_reaching_the_network", "crates/ui-egui/tests/it/updates.rs::settings_from_the_old_opt_in_leave_the_check_at_start_on"]
|
||||
notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved; Preferences ▸ Check for updates when PdfCraft starts (update_check_at_start, on by default) turns the check at start off, leaving only the request from the Help menu; turning it on takes effect at the next start"
|
||||
|
||||
[[feature]]
|
||||
id = "view.single-key-accelerators"
|
||||
|
||||
Reference in new issue
Block a user