collective

Browse / github.com/storytold/cadcraft / patch

feat(pagesetup): apply a change to several layouts at once

feat(pagesetup): apply a change to several layouts at once ## What The Page Setup Manager lists every layout but changes one at a time, so giving a drawing with many sheets one plot style table (or one paper, orientation, …) meant repeating the same change per layout. The list now takes a multiple selection: **Ctrl/Cmd+click** adds or removes a layout, **Shift+click** picks the range from the layout shown, a plain click picks one, and a **Select all** button picks every layout. With several picked, the settings shown are those of the layout last clicked, a line says how many are selected, and **any change applies to all of them**. From JSON, `pages

Status
open
Score
0.00
Adopters
0 other operators
Author
lforgiarini (imported from pull request #2083, open)
Base
41d9b5e957b0719c04b9687c4f29088d77153c31
Size
+295 −17 in 6 files
Review
pending
Shared
2026-10-28
Id
0e9d96bcd5d586a1ae19b9f5

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/layout.rs crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/managers.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/pagesetup_multi.rs docs/gaps.md

diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs--- a/crates/engine/src/cmd/layout.rs+++ b/crates/engine/src/cmd/layout.rs@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {         CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup)             .menu(&["File", "Page Setup Manager..."])             .params(-                "{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool, plotTransparency?: bool}",+                "{layout?: current | layouts?: [names] | \"all\" (the same changes to each, in one step), device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's paper | \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool, plotTransparency?: bool}",             ),         CommandSpec::new("plot", "Print...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "plot")?, "plot"))             .menu(&["File", "Print..."])@@ -624,7 +624,57 @@ fn run_viewport_set(s: &mut Session, p: &Value) -> Result<Value> {  // ---------- page setup ---------- +/// PAGESETUP. With `layouts` (a list of layout names, or "all") the same changes are applied to+/// each of those layouts, as one step: if any of them refuses a change, none is changed. fn run_pagesetup(s: &mut Session, p: &Value) -> Result<Value> {+    let cmd = "pagesetup";+    let Some(list) = p.get("layouts").filter(|v| !v.is_null()) else { return pagesetup_one(s, p) };+    if p.get("layout").is_some() {+        return Err(bad(cmd, "pass `layout` or `layouts`, not both"));+    }+    let names: Vec<String> = match list {+        Value::String(a) if a.eq_ignore_ascii_case("all") => {+            let mut ls: Vec<&Layout> = s.doc()?.layouts.iter().collect();+            ls.sort_by_key(|l| l.tab_order);+            ls.iter().map(|l| l.name.clone()).collect()+        }+        Value::Array(a) => {+            let mut names = Vec::new();+            for n in a.iter().take(MAX_PLOT_SHEETS + 1) {+                let n = n.as_str().ok_or_else(|| bad(cmd, "`layouts` must be a list of layout names or \"all\""))?;+                let n = layout_name(cmd, s.doc()?, n)?;+                if !names.contains(&n) {+                    names.push(n);+                }+            }+            names+        }+        _ => return Err(bad(cmd, "`layouts` must be a list of layout names or \"all\"")),+    };+    if names.is_empty() || names.len() > MAX_PLOT_SHEETS {+        return Err(bad(cmd, format!("`layouts` needs between 1 and {MAX_PLOT_SHEETS} layouts")));+    }+    let before = s.state()?.doc.clone();+    let mut done = Vec::new();+    for n in &names {+        let mut one = p.clone();+        if let Some(o) = one.as_object_mut() {+            o.remove("layouts");+            o.insert("layout".into(), json!(n));+        }+        match pagesetup_one(s, &one) {+            Ok(r) => done.push(r),+            Err(e) => {+                s.state_mut()?.doc = before;+                return Err(e);+            }+        }+    }+    Ok(json!({ "layouts": done }))+}++/// PAGESETUP for one layout: the changes the options ask for, reported with the resulting page setup.+fn pagesetup_one(s: &mut Session, p: &Value) -> Result<Value> {     let cmd = "pagesetup";     let keys = [         "device",@@ -1054,6 +1104,52 @@ mod tests {         }     } +    /// The paper name of layout `name`.+    fn paper_of(s: &Session, name: &str) -> String {+        s.doc().unwrap().layout(name).unwrap().page.paper.clone()+    }++    /// One change given to several layouts is applied to each, as one undo step.+    #[test]+    fn pagesetup_applies_one_change_to_several_layouts_in_one_step() {+        let mut s = Session::new();+        s.execute("layout.new", &json!({"name": "Sheet 3"})).unwrap();+        let a1 = paper_of(&s, "Layout1");+        assert_ne!(a1, "ISO A3 (420.00 x 297.00 MM)", "the test needs another starting paper");+        let r = s.execute("pagesetup", &json!({"layouts": ["Layout1", "layout2"], "paper": "A3", "plotStyleTable": "monochrome.ctb"})).unwrap();+        assert_eq!(r["layouts"].as_array().map(Vec::len), Some(2));+        for n in ["Layout1", "Layout2"] {+            let page = &s.doc().unwrap().layout(n).unwrap().page;+            assert_eq!((page.width_mm.max(page.height_mm), page.plot_style_table.as_str()), (420.0, "monochrome.ctb"), "{n}");+        }+        assert_ne!(paper_of(&s, "Sheet 3"), paper_of(&s, "Layout1"), "a layout not named keeps its settings");+        // "all" takes every layout; one undo step reverts all of it.+        s.execute("pagesetup", &json!({"layouts": "all", "landscape": false})).unwrap();+        assert!(s.doc().unwrap().layouts.iter().all(|l| !l.page.landscape));+        s.undo().unwrap();+        assert!(s.doc().unwrap().layouts.iter().any(|l| l.page.landscape), "the whole change is one undo step");+    }++    /// A change a layout refuses leaves every layout as it was.+    #[test]+    fn pagesetup_for_several_layouts_is_all_or_nothing() {+        let mut s = Session::new();+        let before: Vec<String> = ["Layout1", "Layout2"].iter().map(|n| paper_of(&s, n)).collect();+        for p in [+            json!({"layouts": ["Layout1", "Nope"], "paper": "A3"}),+            json!({"layouts": ["Layout1", "Layout2"], "paper": "Z12"}),+            json!({"layouts": ["Layout1"], "layout": "Layout2", "paper": "A3"}),+            json!({"layouts": ["Model"], "paper": "A3"}),+            json!({"layouts": [], "paper": "A3"}),+            json!({"layouts": "Layout1", "paper": "A3"}),+            json!({"layouts": [7], "paper": "A3"}),+        ] {+            assert!(s.execute("pagesetup", &p).is_err(), "{p}");+        }+        let after: Vec<String> = ["Layout1", "Layout2"].iter().map(|n| paper_of(&s, n)).collect();+        assert_eq!(before, after, "a refused change leaves every layout as it was");+    }+     #[test]     fn pagesetup_paper_table() {         let mut s = Session::new();diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv--- a/crates/ui-egui/src/i18n/it.tsv+++ b/crates/ui-egui/src/i18n/it.tsv@@ -81,6 +81,8 @@ menu	Modify	Edita 	Array	Serie 	Attach	Associa 	Attribute	Attributo+	Select all	Seleziona tutti+	{n} layouts selected: a change applies to all of them	{n} layout selezionati: una modifica vale per tutti 	Attribute Definition	Definizione di attributo 	Attribute Display	Visualizzazione attributi 	Audit	Controlladiff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs--- a/crates/ui-egui/src/managers.rs+++ b/crates/ui-egui/src/managers.rs@@ -480,6 +480,13 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action         .or(shown)         .or_else(|| layouts.first().map(|l| l.1.clone()))         .unwrap_or_default();+    // The layouts a change applies to: `sel` (the one whose settings are shown) and the others+    // picked with Ctrl / Shift.+    let mut picked = temp::<Vec<String>>(ctx, "pagesetup_picked").unwrap_or_default();+    picked.retain(|n| layouts.iter().any(|l| l.1 == *n));+    if !picked.contains(&sel) {+        picked = vec![sel.clone()];+    }     let mut action = None;     let choices = PageChoices::new(ctx, d);     egui::Window::new("Page Setup Manager").open(open).default_size(vec2(560.0, 300.0)).resizable(true).show(ctx, |ui| {@@ -492,18 +499,28 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action             left.label(RichText::new("Layouts").strong());             egui::ScrollArea::vertical().id_salt("pagesetup_list").max_height(200.0).auto_shrink([false, true]).show(left, |ui| {                 for (_, name, _) in &layouts {-                    if ui.selectable_label(*name == sel, name).clicked() {-                        sel = name.clone();+                    let resp = ui.selectable_label(picked.contains(name), name);+                    if resp.clicked() {+                        let order: Vec<&str> = layouts.iter().map(|l| l.1.as_str()).collect();+                        click_layout(&mut picked, &mut sel, &order, name, ui.input(|i| i.modifiers));                     }                 }             });+            left.horizontal(|ui| {+                if ui.small_button("Select all").on_hover_text("Ctrl+click or Shift+click pick several layouts").clicked() {+                    picked = layouts.iter().map(|l| l.1.clone()).collect();+                }+            });             let Some((_, name, page)) = layouts.iter().find(|l| l.1 == sel) else { return };             right.label(RichText::new(format!("Page setup of {name}")).strong());+            if picked.len() > 1 {+                right.label(RichText::new(crate::tf!("{n} layouts selected: a change applies to all of them", n = picked.len())).color(t.text_dim));+            }             // Layout plotting always prints the whole sheet, so only that area is offered.             let rows = PageRows { areas: &["layout"], scale: true, center: true };             egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| {-                if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None) {-                    action = Some(("pagesetup", json!({ "layout": name, k: v })));+                if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None, picked.len() > 1) {+                    action = Some(("pagesetup", pagesetup_change(&picked, name, k, v)));                 }             });             let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };@@ -511,9 +528,45 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action         });     });     set_temp(ctx, "pagesetup_sel", sel);+    set_temp(ctx, "pagesetup_picked", picked);     action } +/// A click on layout `name` in the Page Setup Manager's list: Ctrl (or Cmd) adds or removes it,+/// Shift picks the range from the layout shown to it, a plain click picks just that one. `sel` is+/// the layout whose settings are shown, `order` the layouts in tab order.+fn click_layout(picked: &mut Vec<String>, sel: &mut String, order: &[&str], name: &str, modifiers: egui::Modifiers) {+    if modifiers.shift {+        let (a, b) = (order.iter().position(|n| *n == sel.as_str()), order.iter().position(|n| *n == name));+        if let (Some(a), Some(b)) = (a, b) {+            let (lo, hi) = (a.min(b), a.max(b));+            *picked = order.get(lo..=hi).unwrap_or_default().iter().map(|n| n.to_string()).collect();+        }+    } else if modifiers.command || modifiers.ctrl {+        if let Some(i) = picked.iter().position(|n| n == name) {+            // The last one stays: something must be shown.+            if picked.len() > 1 {+                picked.remove(i);+                if sel == name {+                    *sel = picked.last().cloned().unwrap_or_default();+                }+            }+        } else {+            picked.push(name.to_string());+            *sel = name.to_string();+        }+    } else {+        *picked = vec![name.to_string()];+        *sel = name.to_string();+    }+}++/// The `pagesetup` parameters for one changed setting: for the layout shown, or, with several+/// picked, for all of them in one step.+fn pagesetup_change(picked: &[String], shown: &str, key: &str, value: Value) -> Value {+    if picked.len() > 1 { json!({ "layouts": picked, key: value }) } else { json!({ "layout": shown, key: value }) }+}+ /// Which page-setup rows a dialog shows. struct PageRows {     /// Plot area choices (`plotArea` values).@@ -583,7 +636,8 @@ fn parse_ratio(t: &str) -> Option<f64> { /// transparency, plot style table) shared by the Page Setup Manager and the Plot dialog, inside a /// two-column grid. Returns the setting the user changed as a `pagesetup` parameter (`device`, /// `paper`, `landscape`, `plotArea`, `scaleToFit`, `scale`, `center`, `lineweights`,-/// `plotTransparency` or `plotStyleTable`).+/// `plotTransparency` or `plotStyleTable`). With `force`, picking the value already shown is a+/// change too: with several layouts picked, that is how the others are made to match the one shown. fn page_rows(     ui: &mut egui::Ui,     salt: egui::Id,@@ -591,19 +645,20 @@ fn page_rows(     rows: &PageRows,     choices: &PageChoices,     placement: Option<(&mut bool, &mut [f64; 2], bool)>,+    force: bool, ) -> Option<(&'static str, Value)> {     let mut change = None;     ui.label("Printer/plotter");     egui::ComboBox::from_id_salt(salt.with("device")).selected_text(device_label(page)).width(190.0).show_ui(ui, |ui| {         if ui.selectable_label(device_label(page) == "None", "None").on_hover_text("PDF on the paper chosen below").clicked()-            && device_label(page) != "None"+            && (force || device_label(page) != "None")         {             change = Some(("device", json!("None")));         }         for dev in choices.devices.iter() {             let on = dev.name.eq_ignore_ascii_case(&page.device);             if ui.selectable_label(on, &dev.name).on_hover_text(format!("{} ({})", dev.description, dev.output.as_str().to_uppercase())).clicked()-                && !on+                && (force || !on)             {                 change = Some(("device", json!(dev.name)));             }@@ -616,14 +671,14 @@ fn page_rows(     egui::ComboBox::from_id_salt(salt.with("paper")).selected_text(page.paper.as_str()).width(190.0).show_ui(ui, |ui| match device {         Some(dev) => {             for p in &dev.papers {-                if ui.selectable_label(p.name == page.paper, &p.name).clicked() && p.name != page.paper {+                if ui.selectable_label(p.name == page.paper, &p.name).clicked() && (force || p.name != page.paper) {                     change = Some(("paper", json!(p.name)));                 }             }         }         None => {             for p in cadcraft_render::PAPER_SIZES {-                if ui.selectable_label(p.name == page.paper, p.name).clicked() && p.name != page.paper {+                if ui.selectable_label(p.name == page.paper, p.name).clicked() && (force || p.name != page.paper) {                     change = Some(("paper", json!(p.name)));                 }             }@@ -632,10 +687,10 @@ fn page_rows(     ui.end_row();     ui.label("Orientation");     ui.horizontal(|ui| {-        if ui.radio(!page.landscape, "Portrait").clicked() && page.landscape {+        if ui.radio(!page.landscape, "Portrait").clicked() && (force || page.landscape) {             change = Some(("landscape", json!(false)));         }-        if ui.radio(page.landscape, "Landscape").clicked() && !page.landscape {+        if ui.radio(page.landscape, "Landscape").clicked() && (force || !page.landscape) {             change = Some(("landscape", json!(true)));         }     });@@ -644,7 +699,7 @@ fn page_rows(     ui.add_enabled_ui(rows.areas.len() > 1, |ui| {         egui::ComboBox::from_id_salt(salt.with("area")).selected_text(area_label(&page.plot_area)).show_ui(ui, |ui| {             for a in rows.areas {-                if ui.selectable_label(page.plot_area == *a, area_label(a)).clicked() && page.plot_area != *a {+                if ui.selectable_label(page.plot_area == *a, area_label(a)).clicked() && (force || page.plot_area != *a) {                     change = Some(("plotArea", json!(a)));                 }             }@@ -719,12 +774,12 @@ fn page_rows(     ui.end_row();     ui.label("Plot style table");     egui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| {-        if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" {+        if ui.selectable_label(style_label(page) == "None", "None").clicked() && (force || style_label(page) != "None") {             change = Some(("plotStyleTable", json!("None")));         }         for name in &choices.styles {             let on = name.eq_ignore_ascii_case(&page.plot_style_table);-            if ui.selectable_label(on, name).clicked() && !on {+            if ui.selectable_label(on, name).clicked() && (force || !on) {                 change = Some(("plotStyleTable", json!(name)));             }         }@@ -955,7 +1010,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {                     PageRows { areas: &["layout"], scale: false, center: false }                 };                 let placement = model.then_some((&mut st.center, &mut st.offset_mm, offset_supported));-                if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) {+                if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement, false) {                     apply_page_change(&mut st.page, k, &v);                 }                 if model && st.page.plot_area == "window" {@@ -1256,3 +1311,72 @@ mod plot_dialog_tests {         assert_eq!(temp::<PlotState>(&ctx, "plot_state").unwrap().window, [2.0, 3.0, 12.0, 9.0]);     } }++#[cfg(test)]+mod page_setup_selection_tests {+    use super::*;++    const ORDER: [&str; 5] = ["Layout1", "Layout2", "Sheet 3", "Sheet 4", "Sheet 5"];++    /// Modifier keys for a click.+    fn mods(shift: bool, command: bool) -> egui::Modifiers {+        egui::Modifiers { shift, command, ..Default::default() }+    }++    /// Ctrl alone counts like Cmd, as some key events set only `ctrl`.+    #[test]+    fn ctrl_alone_counts_like_command() {+        // Synthetic and some platforms' key events set `ctrl` without `command`.+        let ctrl = egui::Modifiers { ctrl: true, ..Default::default() };+        let (mut picked, mut sel) = (vec!["Layout1".to_string()], "Layout1".to_string());+        click_layout(&mut picked, &mut sel, &ORDER, "Sheet 3", ctrl);+        assert_eq!(picked, ["Layout1", "Sheet 3"]);+    }++    /// A click on layout `name` with modifiers `m`.+    fn click(picked: &mut Vec<String>, sel: &mut String, name: &str, m: egui::Modifiers) {+        click_layout(picked, sel, &ORDER, name, m);+    }++    /// Plain, Ctrl and Shift clicks pick one, toggle, and take a range.+    #[test]+    fn click_picks_one_ctrl_toggles_shift_takes_a_range() {+        let (mut picked, mut sel) = (vec!["Layout1".to_string()], "Layout1".to_string());+        click(&mut picked, &mut sel, "Sheet 3", mods(false, false));+        assert_eq!((picked.as_slice(), sel.as_str()), (&["Sheet 3".to_string()][..], "Sheet 3"));+        // Ctrl adds and shows the one just added.+        click(&mut picked, &mut sel, "Layout1", mods(false, true));+        click(&mut picked, &mut sel, "Sheet 5", mods(false, true));+        assert_eq!(picked, ["Sheet 3", "Layout1", "Sheet 5"]);+        assert_eq!(sel, "Sheet 5");+        // Ctrl on a picked layout removes it; the shown one moves to another picked.+        click(&mut picked, &mut sel, "Sheet 5", mods(false, true));+        assert_eq!((picked.as_slice(), sel.as_str()), (&["Sheet 3".to_string(), "Layout1".to_string()][..], "Layout1"));+        // The last picked layout can't be removed.+        click(&mut picked, &mut sel, "Layout1", mods(false, true));+        click(&mut picked, &mut sel, "Sheet 3", mods(false, true));+        assert_eq!(picked, ["Sheet 3"], "{picked:?}");+        // Shift takes the range from the shown layout, in tab order, whichever way it goes.+        click(&mut picked, &mut sel, "Sheet 5", mods(true, false));+        assert_eq!(picked, ["Sheet 3", "Sheet 4", "Sheet 5"]);+        click(&mut picked, &mut sel, "Layout2", mods(true, false));+        assert_eq!(picked, ["Layout2", "Sheet 3"], "from the shown layout (Sheet 3) back to Layout2");+    }++    /// A change for several picked layouts is one `pagesetup` with `layouts`.+    #[test]+    fn a_change_goes_to_all_picked_layouts_in_one_command() {+        let one = pagesetup_change(&["Layout1".to_string()], "Layout1", "paper", json!("A3"));+        assert_eq!(one, json!({ "layout": "Layout1", "paper": "A3" }));+        let many = pagesetup_change(&["Layout1".to_string(), "Sheet 3".to_string()], "Sheet 3", "plotStyleTable", json!("monochrome.ctb"));+        assert_eq!(many, json!({ "layouts": ["Layout1", "Sheet 3"], "plotStyleTable": "monochrome.ctb" }));+        // And the engine takes exactly that.+        let mut app = crate::CadApp::new(cadcraft_engine::Session::default(), crate::Services::default());+        app.run("layout.new", json!({ "name": "Sheet 3" })).unwrap();+        app.run("pagesetup", many).unwrap();+        let d = app.session.doc().unwrap();+        assert_eq!(d.layout("Layout1").map(|l| l.page.plot_style_table.as_str()), Some("monochrome.ctb"));+        assert_eq!(d.layout("Sheet 3").map(|l| l.page.plot_style_table.as_str()), Some("monochrome.ctb"));+        assert_ne!(d.layout("Layout2").map(|l| l.page.plot_style_table.as_str()), Some("monochrome.ctb"), "an unpicked layout is untouched");+    }+}diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -17,6 +17,7 @@ mod measure_distance_snapping; mod menubar_hover; mod mspace_viewport_view; mod options_dialog;+mod pagesetup_multi; mod parameters_rename; mod pickdrag; mod properties_units;diff --git a/crates/ui-egui/tests/it/pagesetup_multi.rs b/crates/ui-egui/tests/it/pagesetup_multi.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/pagesetup_multi.rs@@ -0,0 +1,54 @@+//! The Page Setup Manager with several layouts picked: choosing the value the layout shown+//! already has still applies it to the others, which is how mixed layouts are made to match.++use cadcraft_engine::Session;+use cadcraft_ui_egui::{CadApp, Services, menus};+use egui::vec2;+use egui_kittest::Harness;+use egui_kittest::kittest::Queryable;+use serde_json::json;++/// An app in a window, as the other integration tests build it.+fn harness(app: CadApp) -> Harness<'static, CadApp> {+    let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state(+        |ui, app: &mut CadApp| {+            app.logic(&ui.ctx().clone());+            app.ui(ui);+        },+        app,+    );+    h.run_steps(5);+    h+}++/// Whether layout `layout` is landscape.+fn landscape(app: &CadApp, layout: &str) -> bool {+    app.session.doc().unwrap().layout(layout).unwrap().page.landscape+}++/// Choosing the value the shown layout has makes the other picked layouts match.+#[test]+fn picking_the_value_shown_makes_the_other_picked_layouts_match() {+    let mut app = CadApp::new(Session::default(), Services::default());+    app.ui.start_tab = false;+    app.ui.in_window_menu = false;+    // Layout1 is shown and landscape; Layout2 is portrait.+    app.run("pagesetup", json!({ "layout": "Layout1", "landscape": true })).unwrap();+    app.run("pagesetup", json!({ "layout": "Layout2", "landscape": false })).unwrap();+    app.run("layout.set", json!({ "name": "Layout1" })).unwrap();+    let mut h = harness(app);+    menus::activate(h.state_mut(), "pagesetup");+    h.run_steps(5);++    // With one layout picked, choosing what it already has changes nothing.+    h.get_by_label("Landscape").click();+    h.run_steps(3);+    assert!(!landscape(h.state(), "Layout2"), "one layout picked: the other is left alone");++    // With both picked, the same click is a change for both.+    h.get_by_label("Select all").click();+    h.run_steps(3);+    h.get_by_label("Landscape").click();+    h.run_steps(3);+    assert!(landscape(h.state(), "Layout1") && landscape(h.state(), "Layout2"), "both layouts are landscape now");+}diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (The Page Setup Manager changes several layouts at once) · **Target:** Autodesk AutoCAD 2027  Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-24 | minor | The Page Setup Manager picks several layouts (Ctrl, Shift, Select all) and a change applies to all of them; PAGESETUP takes `layouts` (a list of names or "all") for the same: one undo step, all or nothing | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed |