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
@@ -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();@@ -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 Controlla@@ -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");+ }+}@@ -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;new file mode 100644@@ -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");+}@@ -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 |