graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: a page's size is its Width and Height, and a preset writes them
Preset had a Custom entry, the only choice under which Width and Height
were read. The size is Width by Height in Units now, always; picking a
preset writes its size there (a sheet turned by Orientation, a raster
size as it lies), Orientation swaps the two, and Units converts them so
the sheet keeps its size. What a pick overwrites is part of its undo
step. A save from before is carried over once in the template merge: a
named preset is written into Width and Height, and a Custom page keeps
its size and names Letter.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 23 ++++++++--
nodes/page.json | 10 ++---
src/app.rs | 25 +++++++++++
src/main.rs | 126 +++++++++++++++++++++++++++++++++++++++++++++------
src/page.rs | 137 ++++++++++++++++++++++++++++++++++++++++++++------------
src/window.rs | 15 ++++++-
6 files changed, 285 insertions(+), 51 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index d6a21d9..7ee5c0a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1966,12 +1966,29 @@ and not of each node, because a chain whose text was placed in pixels and
whose border was inset in inches is a chain nobody can read. Inside, a page
is still inches (`Page::size`), and a pixel image's physical size is its
pixels over its Resolution. A node with no Units row is in inches, which is
-every save from before it. The presets are the four paper sizes and three
-raster ones (`HD`, `4K`, `Square`), which are their pixels whatever Units
-says and are not turned by Orientation. The length rows are `float` — a
+every save from before it. The length rows are `float` — a
number with no range — where they were sliders over a range in inches: a
slider clamps, and no one range holds both 0.25 inches and 1920 pixels.
+**The size IS Width and Height, and a preset writes them** (since
+2026-09-30). `page_node_frame` reads Width by Height in Units and nothing
+else; Preset, Orientation and Units are rows that SET those two when they
+are picked (`page::follow_page_rows`, called by the params pane's
+write-back and MCP's `set_param`, and what it overwrites is part of the
+undo step). Preset writes its size in the page's Units — the four paper
+sizes turned by Orientation, the three raster ones (`HD`, `4K`, `Square`)
+as they lie, Orientation set to say which way; Orientation swaps the two
+when they lie the other way; Units converts them, so the sheet keeps its
+size. There is no `Custom` preset: until then Width and Height were read
+only under it, a second way to say the size that the presets could not
+share. Preset names what was last picked, and a size typed in afterwards
+is the size. A save from before is carried over ONCE in the template merge
+(`page::migrate_preset_rows`), recognised by the Width row's `show_when`
+still reading `Preset == Custom`, which the merge then replaces: a named
+preset is written into Width and Height as it was drawn, and a Custom page
+keeps its size and names Letter. `picking_a_page_preset_writes_its_size`
+is the test.
+
**`page_shape`** draws a rectangle (with a corner radius), an ellipse, a
line or a polygon of N sides, turned by Rotation, filled and stroked, each
with an opacity. Coverage comes from the signed DISTANCE to the outline in
diff --git a/nodes/page.json b/nodes/page.json
index dba4a62..bf0a354 100644
--- a/nodes/page.json
+++ b/nodes/page.json
@@ -4,11 +4,11 @@
"inputs": 0,
"outputs": 1,
"params": [
- { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,HD,4K,Square,Custom", "default": "Letter", "description": "Size of the page. Letter, A4, Legal and Tabloid are paper sizes; HD, 4K and Square are fixed pixel sizes; Custom uses Width and Height." },
- { "name": "Units", "type": "choice:Inches,Millimetres,Centimetres,Pixels", "default": "Inches", "description": "Unit that Width and Height, and every length on the nodes drawing onto this page, are written in. Pixels are converted to inches by Resolution." },
- { "name": "Width", "type": "float", "default": "8.5", "show_when": "Preset == Custom", "description": "Page width for the Custom preset, in Units. In Pixels it is the image's pixel width; its physical size then depends on Resolution." },
- { "name": "Height", "type": "float", "default": "11.0", "show_when": "Preset == Custom", "description": "Page height for the Custom preset, in Units. In Pixels it is the image's pixel height; its physical size then depends on Resolution." },
- { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait", "show_when": "Preset == Letter|A4|Legal|Tabloid", "description": "Portrait keeps the paper size upright; Landscape swaps its width and height. Only paper presets can be turned." },
+ { "name": "Preset", "type": "choice:Letter,A4,Legal,Tabloid,HD,4K,Square", "default": "Letter", "description": "Picking one writes its size into Width and Height, in Units: Letter, A4, Legal and Tabloid are paper sizes, HD, 4K and Square pixel sizes. Shows the last one picked." },
+ { "name": "Units", "type": "choice:Inches,Millimetres,Centimetres,Pixels", "default": "Inches", "description": "Unit Width and Height, and every length on the nodes drawing onto this page, are written in. Changing it converts Width and Height, so the page keeps its size." },
+ { "name": "Width", "type": "float", "default": "8.5", "description": "Page width in Units; with Height, the page's size. A preset writes it, and any other width can be typed. In Pixels its physical size depends on Resolution." },
+ { "name": "Height", "type": "float", "default": "11.0", "description": "Page height in Units; with Width, the page's size. A preset writes it, and any other height can be typed. In Pixels its physical size depends on Resolution." },
+ { "name": "Orientation", "type": "choice:Portrait,Landscape", "default": "Portrait", "description": "Portrait makes the page taller than wide, Landscape wider than tall, by swapping Width and Height. A paper preset is picked in this orientation." },
{ "name": "Resolution", "type": "spinbox", "default": "300", "min": 18, "max": 1200, "step": 6, "description": "Pixels per inch (DPI). More pixels for a paper size; for pixel presets it sets the physical size. Stored in exported PNGs so prints come out at size." },
{ "name": "Color", "type": "float3", "default": "1.00:1.00:1.00", "min": 0.0, "max": 1.0, "description": "Background colour the whole page is filled with before anything is drawn on it." },
{ "name": "Opacity", "type": "slider", "default": "1.00", "min": 0.0, "max": 1.0, "step": 0.01, "description": "Opacity of the background fill, 0 to 1. At 0 the page is clear and only what is drawn on it shows; clear areas are see-through in the viewport and PNG." },
diff --git a/src/app.rs b/src/app.rs
index 5c138d4..f946e87 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1175,6 +1175,12 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
if node.node_type == "camera" {
node.params.retain(|p| p.name != "Square Aspect" && p.name != "Show Camera Pivot");
}
+ // A page's size is its Width and Height now, and Preset has no
+ // Custom: a save from before is carried over once, ahead of the
+ // merge that would take the old rows' conditions away.
+ if node.node_type == "page" {
+ crate::page::migrate_preset_rows(node);
+ }
// Visualize's Mix blend was Set under another name (Opacity fades
// every blend alike) and is retired; a save holding it is Set, or
// it would load as a choice the row no longer offers.
@@ -4056,6 +4062,21 @@ impl State {
}
}
+ // A page's Preset, Orientation and Units set its Width
+ // and Height; what they overwrite is part of the step.
+ let mut followed = false;
+ if child.node_type == "page" {
+ let setters: Vec<ParamDef> = was.iter().filter(|w| matches!(w.name.as_str(), "Preset" | "Orientation" | "Units")).cloned().collect();
+ for w in setters {
+ for r in crate::page::follow_page_rows(child, &w) {
+ followed = true;
+ if !was.iter().any(|x| x.name == r.name) {
+ was.push(r);
+ }
+ }
+ }
+ }
+
// One step per gesture: a drag writes back on every
// motion. A button or the Open dropdown ends as it
// began, and is no edit.
@@ -4093,6 +4114,10 @@ impl State {
self.sync_grid_settings();
self.rebuild_scene_geometry();
self.sync_nodes();
+ // Width and Height moved under the pane's feet.
+ if followed {
+ self.sync_parameters_pane();
+ }
for btn_name in triggered_buttons {
self.execute_menu_action(&btn_name);
diff --git a/src/main.rs b/src/main.rs
index afe2885..244570d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -16685,8 +16685,8 @@ mod tests {
}
/// The generator's size is in the unit its Units row names: pixels are
- /// pixels exactly, a metric sheet is its millimetres, and a named raster
- /// size is that many pixels whatever the row says.
+ /// pixels exactly, and a metric sheet is its millimetres. (What a
+ /// preset writes is `picking_a_page_preset_writes_its_size`.)
#[test]
fn an_image_is_sized_in_pixels_or_in_real_units() {
use crate::page::{resolve_page, PageUnit};
@@ -16695,31 +16695,130 @@ mod tests {
resolve_page(&root, &root.children[0], &mut Vec::new()).expect("no page")
};
- let px = make(&[("Preset", "Custom"), ("Units", "Pixels"), ("Width", "640"), ("Height", "360"), ("Resolution", "96")]);
+ let px = make(&[("Units", "Pixels"), ("Width", "640"), ("Height", "360"), ("Resolution", "96")]);
assert_eq!((px.width, px.height), (640, 360), "a pixel size is that many pixels");
assert_eq!(px.unit, PageUnit::Pixels);
assert!((px.size[0] - 640.0 / 96.0).abs() < 1e-4, "its physical size is its pixels over its resolution");
- let mm = make(&[("Preset", "Custom"), ("Units", "Millimetres"), ("Width", "210"), ("Height", "297"), ("Resolution", "100")]);
+ let mm = make(&[("Units", "Millimetres"), ("Width", "210"), ("Height", "297"), ("Resolution", "100")]);
assert!((mm.size[0] - 210.0 / 25.4).abs() < 1e-4 && (mm.size[1] - 297.0 / 25.4).abs() < 1e-4);
assert_eq!((mm.width, mm.height), (827, 1169), "A4 in millimetres at 100 DPI");
- let cm = make(&[("Preset", "Custom"), ("Units", "Centimetres"), ("Width", "2.54"), ("Height", "5.08"), ("Resolution", "50")]);
+ let cm = make(&[("Units", "Centimetres"), ("Width", "2.54"), ("Height", "5.08"), ("Resolution", "50")]);
assert_eq!((cm.width, cm.height), (50, 100));
- let hd = make(&[("Preset", "HD"), ("Units", "Inches"), ("Orientation", "Landscape"), ("Resolution", "72")]);
- assert_eq!((hd.width, hd.height), (1920, 1080), "a raster preset is its pixels, and is not turned");
-
// A page from before the Units row is in inches, as it was.
- let old = make(&[("Preset", "Custom"), ("Width", "2"), ("Height", "1"), ("Resolution", "50")]);
+ let old = make(&[("Width", "2"), ("Height", "1"), ("Resolution", "50")]);
assert_eq!((old.width, old.height, old.unit), (100, 50, PageUnit::Inches));
// Opacity is the sheet's alpha, and Position where it stands.
- let clear = make(&[("Preset", "Custom"), ("Width", "1"), ("Height", "1"), ("Resolution", "10"), ("Opacity", "0.25"), ("Position", "1.00:2.00:3.00")]);
+ let clear = make(&[("Width", "1"), ("Height", "1"), ("Resolution", "10"), ("Opacity", "0.25"), ("Position", "1.00:2.00:3.00")]);
assert!((clear.pixels[0][3] - 0.25).abs() < 1e-6);
assert_eq!(clear.origin, [1.0, 2.0, 3.0]);
}
+ /// A page's size is its Width and Height, always: Preset has no Custom,
+ /// and picking a preset WRITES its size there, in the page's Units — a
+ /// sheet turned by Orientation, a raster size as it lies, with
+ /// Orientation set to say which way. Orientation swaps the two, Units
+ /// converts them, and a save from before carries over once.
+ #[test]
+ fn picking_a_page_preset_writes_its_size() {
+ use crate::page::{follow_page_rows, migrate_preset_rows, resolve_page};
+ let size = |node: &FsNode| {
+ let row = |n: &str| node.params.iter().find(|p| p.name == n).unwrap().text().to_string();
+ (row("Width"), row("Height"), row("Orientation"))
+ };
+ let pick = |node: &mut FsNode, row: &str, value: &str| -> Vec<String> {
+ let p = node.params.iter_mut().find(|p| p.name == row).unwrap();
+ let was = p.clone();
+ p.set_text(value);
+ follow_page_rows(node, &was).into_iter().map(|p| p.name).collect()
+ };
+ let templates_root = crate::app::load_fs_tree();
+ let template = templates_root.children.iter().find(|t| t.node_type == "page").unwrap().clone();
+ let preset = template.params.iter().find(|p| p.name == "Preset").unwrap();
+ assert!(!preset.choice_options().iter().any(|o| o == "Custom"), "{:?}", preset.choice_options());
+ for row in ["Width", "Height", "Orientation"] {
+ assert!(template.params.iter().find(|p| p.name == row).unwrap().show_when.is_empty(), "{row} is always shown");
+ }
+
+ let mut page = template.clone();
+ assert_eq!(pick(&mut page, "Preset", "A4"), ["Width", "Height"], "what a pick overwrites is handed back, for undo");
+ assert_eq!(size(&page), ("8.268".into(), "11.693".into(), "Portrait".into()));
+ assert_eq!(pick(&mut page, "Orientation", "Landscape"), ["Width", "Height"]);
+ assert_eq!(size(&page), ("11.693".into(), "8.268".into(), "Landscape".into()));
+ // A sheet is picked in the orientation the row names.
+ pick(&mut page, "Preset", "Letter");
+ assert_eq!(size(&page), ("11.00".into(), "8.50".into(), "Landscape".into()));
+ // Units converts: the sheet keeps its size.
+ pick(&mut page, "Units", "Millimetres");
+ assert_eq!(size(&page), ("279.40".into(), "215.90".into(), "Landscape".into()));
+ // A raster size is written as it lies, in the page's unit, and
+ // Orientation follows it.
+ pick(&mut page, "Orientation", "Portrait");
+ pick(&mut page, "Units", "Pixels");
+ pick(&mut page, "Preset", "HD");
+ assert_eq!(size(&page), ("1920".into(), "1080".into(), "Landscape".into()));
+ let root = image_root(vec![page.clone()]);
+ let img = resolve_page(&root, &root.children[0], &mut Vec::new()).unwrap();
+ assert_eq!((img.width, img.height), (1920, 1080));
+ // Another row changes nothing; nor does turning a page already turned.
+ assert!(pick(&mut page, "Resolution", "72").is_empty());
+ assert!(pick(&mut page, "Orientation", "Landscape").is_empty());
+ // A size typed in is the size, whatever Preset still names.
+ pick(&mut page, "Width", "640");
+ let root = image_root(vec![page.clone()]);
+ assert_eq!(resolve_page(&root, &root.children[0], &mut Vec::new()).unwrap().width, 640);
+
+ // A save from before: its rows carry the old conditions. A named
+ // preset is written into Width and Height, turned as it was drawn;
+ // a Custom one keeps its size and names Letter.
+ let old = |preset: &str, w: &str, h: &str, orientation: &str| {
+ let mut n = template.clone();
+ for (row, v) in [("Preset", preset), ("Width", w), ("Height", h), ("Orientation", orientation)] {
+ let p = n.params.iter_mut().find(|p| p.name == row).unwrap();
+ p.set_type("text");
+ p.set_text(v);
+ }
+ for row in ["Width", "Height"] {
+ n.params.iter_mut().find(|p| p.name == row).unwrap().show_when = "Preset == Custom".into();
+ }
+ n
+ };
+ let mut tabloid = old("Tabloid", "8.5", "11.0", "Landscape");
+ migrate_preset_rows(&mut tabloid);
+ assert_eq!(size(&tabloid), ("17.00".into(), "11.00".into(), "Landscape".into()));
+ let mut custom = old("Custom", "3", "2", "Portrait");
+ migrate_preset_rows(&mut custom);
+ assert_eq!(size(&custom), ("3".into(), "2".into(), "Portrait".into()));
+ assert_eq!(custom.params.iter().find(|p| p.name == "Preset").unwrap().text(), "Letter");
+ // Once: the merge takes the old conditions away, so a page loaded
+ // a second time is left as it is.
+ let templates = crate::app::flatten_node_templates(&templates_root);
+ let mut loaded = image_root(vec![old("Custom", "3", "2", "Portrait")]);
+ crate::app::merge_template_defs(&mut loaded, &templates);
+ assert_eq!(size(&loaded.children[0]), size(&custom));
+ crate::app::merge_template_defs(&mut loaded, &templates);
+ assert_eq!(size(&loaded.children[0]), size(&custom), "a second load wrote Letter over a typed size");
+ let mut merged = image_root(vec![old("A4", "8.5", "11.0", "Portrait")]);
+ crate::app::merge_template_defs(&mut merged, &templates);
+ assert_eq!(size(&merged.children[0]), ("8.268".into(), "11.693".into(), "Portrait".into()));
+ assert!(merged.children[0].params.iter().all(|p| p.invalid().is_none()));
+
+ // Through MCP, and undone as one step.
+ let mut state = State::new(false);
+ let mut redraw = false;
+ let slot = state.new_image().expect("the Page template is missing");
+ let before = size(&state.current_dir().children[slot]);
+ state
+ .apply_action(crate::app::McpAction::SetParam { slot, name: "Preset".into(), value: "Tabloid".into() }, &mut redraw)
+ .unwrap();
+ assert_eq!(size(&state.current_dir().children[slot]), ("11.00".into(), "17.00".into(), "Portrait".into()));
+ state.run_command("undo");
+ assert_eq!(size(&state.current_dir().children[slot]), before, "the pick and what it wrote are one step");
+ }
+
/// A node drawing on an image is written in the image's unit: the same
/// rows on a pixel image and on an inch sheet put ink in different
/// places, and on each where the unit says.
@@ -16728,7 +16827,7 @@ mod tests {
use crate::page::resolve_page;
let chain = |units: &str, w: &str, h: &str, dpi: &str| {
let root = image_root(vec![
- image_node("page1", "page", &[("Preset", "Custom"), ("Units", units), ("Width", w), ("Height", h), ("Resolution", dpi), ("Color", "1.00:1.00:1.00")]),
+ image_node("page1", "page", &[("Units", units), ("Width", w), ("Height", h), ("Resolution", dpi), ("Color", "1.00:1.00:1.00")]),
image_node(
"shape1",
"page_shape",
@@ -16867,7 +16966,6 @@ mod tests {
let slot = state.new_image().expect("the Page template is missing");
let node = &mut state.current_dir_mut().children[slot];
for (name, value) in [
- ("Preset", "Custom".to_string()),
("Units", "Pixels".to_string()),
("Width", w.to_string()),
("Height", h.to_string()),
@@ -17068,7 +17166,7 @@ mod tests {
let page_slot = state.current_dir().children.iter().position(|c| c.name == "page1").unwrap();
{
let node = &mut state.current_dir_mut().children[page_slot];
- for (row, v) in [("Preset", "Custom"), ("Units", "Pixels"), ("Width", "300"), ("Height", "200"), ("Resolution", "96")] {
+ for (row, v) in [("Units", "Pixels"), ("Width", "300"), ("Height", "200"), ("Resolution", "96")] {
node.params.iter_mut().find(|p| p.name == row).unwrap().set_text(v);
}
}
@@ -17105,7 +17203,7 @@ mod tests {
fn a_page_frame_is_the_page_without_its_pixels() {
use crate::page::{resolve_frame, resolve_page};
let root = image_root(vec![
- image_node("page1", "page", &[("Preset", "Custom"), ("Units", "Millimetres"), ("Width", "120"), ("Height", "80"), ("Resolution", "127"), ("Position", "1.00:2.00:3.00")]),
+ image_node("page1", "page", &[("Units", "Millimetres"), ("Width", "120"), ("Height", "80"), ("Resolution", "127"), ("Position", "1.00:2.00:3.00")]),
image_node("shape1", "page_shape", &[("Input", "page1")]),
image_node("text1", "page_text", &[("Input", "shape1")]),
image_node("lost1", "page_text", &[("Input", "nothing")]),
diff --git a/src/page.rs b/src/page.rs
index 2379a40..4ef1954 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -193,15 +193,21 @@ impl PageFrame {
/// A number for a row in the page's unit: whole pixels, and thousandths
/// of anything longer.
pub fn row(&self, value: f32) -> String {
- if self.unit == PageUnit::Pixels {
- format!("{}", value.round())
- } else {
- let s = format!("{value:.3}");
- let s = s.trim_end_matches('0');
- // Two decimals at the least, as the templates write them.
- let decimals = s.len() - s.find('.').map_or(s.len(), |i| i + 1);
- format!("{s}{}", "0".repeat(2usize.saturating_sub(decimals)))
- }
+ row_text(self.unit, value)
+ }
+}
+
+/// A number for a row in `unit`: whole pixels, and thousandths of anything
+/// longer ([`PageFrame::row`]).
+pub fn row_text(unit: PageUnit, value: f32) -> String {
+ if unit == PageUnit::Pixels {
+ format!("{}", value.round())
+ } else {
+ let s = format!("{value:.3}");
+ let s = s.trim_end_matches('0');
+ // Two decimals at the least, as the templates write them.
+ let decimals = s.len() - s.find('.').map_or(s.len(), |i| i + 1);
+ format!("{s}{}", "0".repeat(2usize.saturating_sub(decimals)))
}
}
@@ -781,33 +787,108 @@ fn toggle_of(node: &FsNode, name: &str) -> bool {
crate::geometry::node_param_bool(node, name, false)
}
-/// The frame a `page` node describes.
+/// The frame a `page` node describes: Width by Height in its Units. The
+/// Preset, Orientation and Units rows do not enter into it — they set
+/// Width and Height when they are picked ([`follow_page_rows`]).
fn page_node_frame(target: &FsNode) -> PageFrame {
- let preset = node_param_str(target, "Preset", "Letter");
let unit = PageUnit::parse(&node_param_str(target, "Units", "Inches"));
- let dpi = node_param_f32(target, "Resolution", 300.0).round().clamp(1.0, 2400.0);
- // A named size is what it is whatever the Units row says; the row
- // is what Width and Height — and every node downstream — are in.
- let size = preset_size(&preset)
- .or_else(|| preset_pixels(&preset).map(|[w, h]| [w / dpi, h / dpi]))
- .unwrap_or_else(|| {
- [
- unit.to_inches(node_param_f32(target, "Width", 8.5), dpi),
- unit.to_inches(node_param_f32(target, "Height", 11.0), dpi),
- ]
- });
- // Landscape is the same sheet turned, not a different sheet: swap the
- // axes rather than asking for a second pair of numbers. A raster
- // preset is named as it lies, and Custom says its own two numbers.
- let turned = preset_size(&preset).is_some()
- && node_param_str(target, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape");
- let size = if turned { [size[1], size[0]] } else { size };
+ let dpi = page_dpi(target);
+ let size = [
+ unit.to_inches(node_param_f32(target, "Width", 8.5), dpi),
+ unit.to_inches(node_param_f32(target, "Height", 11.0), dpi),
+ ];
let mut frame = PageFrame::new(size, dpi as u32);
frame.unit = unit;
frame.origin = node_param_vec3(target, "Position", Vec3::ZERO).to_array();
frame
}
+fn page_dpi(node: &FsNode) -> f32 {
+ node_param_f32(node, "Resolution", 300.0).round().clamp(1.0, 2400.0)
+}
+
+/// A preset's size in inches: a sheet turned by `landscape`, a raster
+/// size as it lies, at `dpi`.
+fn preset_inches(name: &str, dpi: f32, landscape: bool) -> Option<[f32; 2]> {
+ if let Some([w, h]) = preset_size(name) {
+ return Some(if landscape { [h, w] } else { [w, h] });
+ }
+ preset_pixels(name).map(|[w, h]| [w / dpi, h / dpi])
+}
+
+/// What a `page` node's Width and Height become when one of the rows that
+/// SET them was just changed, `was` being that row as it stood before:
+///
+/// - **Preset** writes the preset's size, in the node's Units. A sheet is
+/// turned by Orientation; a raster size (HD, 4K, Square) is written as
+/// it lies, and Orientation is set to say which way that is.
+/// - **Orientation** swaps Width and Height when they lie the other way.
+/// - **Units** converts them from the old unit, so the sheet keeps its
+/// size and only the numbers change.
+///
+/// A Width or Height that is an expression is left to it. Returns the rows
+/// rewritten, as they were, for undo; empty when `was` is none of the
+/// three or nothing moved. There is no Custom preset: a size typed into
+/// Width and Height is the size, and Preset names what was last picked.
+pub fn follow_page_rows(node: &mut FsNode, was: &crate::app::ParamDef) -> Vec<crate::app::ParamDef> {
+ let unit = PageUnit::parse(&node_param_str(node, "Units", "Inches"));
+ let dpi = page_dpi(node);
+ let (w, h) = (node_param_f32(node, "Width", 8.5), node_param_f32(node, "Height", 11.0));
+ let landscape = node_param_str(node, "Orientation", "Portrait").eq_ignore_ascii_case("Landscape");
+ let mut orientation = None;
+ let (nw, nh) = match was.name.as_str() {
+ "Preset" => {
+ let preset = node_param_str(node, "Preset", "Letter");
+ let Some([iw, ih]) = preset_inches(&preset, dpi, landscape) else { return Vec::new() };
+ if preset_size(&preset).is_none() && iw != ih {
+ orientation = Some(if iw > ih { "Landscape" } else { "Portrait" });
+ }
+ (unit.from_inches(iw, dpi), unit.from_inches(ih, dpi))
+ }
+ "Orientation" if w != h && landscape != (w > h) => (h, w),
+ "Units" => {
+ let old = PageUnit::parse(was.text());
+ (unit.from_inches(old.to_inches(w, dpi), dpi), unit.from_inches(old.to_inches(h, dpi), dpi))
+ }
+ _ => return Vec::new(),
+ };
+ let mut rewritten = Vec::new();
+ let mut set = |name: &str, text: String| {
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
+ if !p.is_expr() && p.text() != text {
+ rewritten.push(p.clone());
+ p.set_text(text);
+ }
+ }
+ };
+ set("Width", row_text(unit, nw));
+ set("Height", row_text(unit, nh));
+ if let Some(o) = orientation {
+ set("Orientation", o.to_string());
+ }
+ rewritten
+}
+
+/// A `page` node from before Width and Height were always the size: its
+/// named preset is written into them once, and a Custom one — whose Width
+/// and Height already were the size — names Letter. Told apart by the
+/// Width row's `show_when`, which such a save carries as `Preset ==
+/// Custom` until the template merge replaces it.
+pub fn migrate_preset_rows(node: &mut FsNode) {
+ let old = node.params.iter().any(|p| p.name == "Width" && p.show_when.contains("Custom"));
+ if !old {
+ return;
+ }
+ let Some(preset) = node.params.iter().find(|p| p.name == "Preset").cloned() else { return };
+ if preset.text().trim().eq_ignore_ascii_case("custom") {
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == "Preset") {
+ p.set_text("Letter".to_string());
+ }
+ } else {
+ follow_page_rows(node, &preset);
+ }
+}
+
/// The frame of the page `target` draws on: [`resolve_page`]'s walk up the
/// chain, without the drawing. None where that would compose nothing — no
/// page at the bottom, or a wire that comes back to itself.
diff --git a/src/window.rs b/src/window.rs
index 3a59016..fe780ab 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -367,7 +367,20 @@ impl State {
if as_expr {
p.set_expr(true);
}
- state.record_param_edit(&node_id, before);
+ // A page's Preset, Orientation and Units set its
+ // Width and Height, and the step holds all of it.
+ let followed = if child.node_type == "page" {
+ crate::page::follow_page_rows(child, &before)
+ } else {
+ Vec::new()
+ };
+ if followed.is_empty() {
+ state.record_param_edit(&node_id, before);
+ } else {
+ let what = before.name.clone();
+ let params = std::iter::once(before).chain(followed).collect();
+ state.record_params(crate::edit_history::ParamSnapshot { node_id, what, params }, false);
+ }
// Same sequence as the interactive param-pane
// path, so settings params (viewport flags,
// grid) actually take effect via automation.