feat(ui): Liquid Layout panel in InDesign's layout; Create Alternate Layout in the Layout menu

Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (2:47,
7:24) uses the Liquid Layout panel: "Liquid Page Rule:" with "Page: n",
Content › Auto-Fit, and Object Constraints › Resize with Page (Height,
Width) and Pin (Top, Left, Bottom, Right), greyed until an object on an
object-based page is selected. DesignCraft's panel had a "Page 1" label,
the rule, six wrapped checkboxes ("Resize width", "Pin top"…) only once
an object was selected, and a Create Alternate Layout form.

- Liquid Layout panel: InDesign's layout and labels; the page is the one
  the Page tool selected (else the page in view); Auto-Fit (graphic
  frames) runs object.fittingOptions; the constraints apply on
  object-based pages (also through Controlled by Parent) and stay
  greyed otherwise. The page's liquid guides stay listed below for now.
- Layout menu: Create Alternate Layout… (as in InDesign).
- Translations for the new labels.

Builds on #864 (rule list, Controlled by Parent) and #862.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
moppeeerandClaude Opus 5.5 committed 2026-10-14 01:29:44 +02:00
1 parent 8864307d7c
commit 9da44a042f
9 files changed
+160 -45

No files matched your search

+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-14** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change.
- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change.
- **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
+8
View File
@@ -2498,6 +2498,14 @@ const TABLE: &[(&str, [&str; 5])] = &[
("Added", ["Added", "Added", "Added", "Added", "已添加"]),
("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]),
("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]),
("Page:", ["Seite:", "Page :", "Página:", "ページ:", "页面:"]),
("Content:", ["Inhalt:", "Contenu :", "Contenido:", "コンテンツ:", "内容:"]),
("Object Constraints:", ["Objektbeschränkungen:", "Contraintes d’objet :", "Restricciones de objeto:", "オブジェクトの制約:", "对象约束:"]),
(
"Resize with Page:",
["Mit Seite skalieren:", "Redimensionner avec la page :", "Cambiar tamaño con la página:", "ページと共にサイズ変更:", "随页面调整大小:"],
),
("Pin:", ["Fixieren:", "Épingler :", "Fijar:", "固定:", "固定:"]),
("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]),
("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]),
("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]),
+5
View File
@@ -709,6 +709,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Added", "مضاف"),
("Deleted", "محذوف"),
("Guide-based", "استنادًا إلى الأدلة"),
("Page:", "الصفحة:"),
("Content:", "المحتوى:"),
("Object Constraints:", "قيود الكائن:"),
("Resize with Page:", "تغيير الحجم مع الصفحة:"),
("Pin:", "تثبيت:"),
("Object-Based", "استنادًا إلى الكائنات"),
("Guide-Based", "استنادًا إلى الأدلة"),
("Resize Page", "تغيير حجم الصفحة"),
+5
View File
@@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Added", "Aggiunto"),
("Deleted", "Eliminato"),
("Guide-based", "Basato su guide"),
("Page:", "Pagina:"),
("Content:", "Contenuto:"),
("Object Constraints:", "Vincoli oggetto:"),
("Resize with Page:", "Ridimensiona con la pagina:"),
("Pin:", "Fissa:"),
("Object-Based", "Basato su oggetti"),
("Guide-Based", "Basato su guide"),
("Resize Page", "Ridimensiona pagina"),
+5
View File
@@ -1291,6 +1291,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Added", "追加済み"),
("Deleted", "削除済み"),
("Guide-based", "ガイド基準"),
("Page:", "ページ:"),
("Content:", "コンテンツ:"),
("Object Constraints:", "オブジェクトの制約:"),
("Resize with Page:", "ページと共にサイズ変更:"),
("Pin:", "固定:"),
("Object-Based", "オブジェクト基準"),
("Guide-Based", "ガイド基準"),
("Resize Page", "ページのサイズを変更"),
+5
View File
@@ -708,6 +708,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Added", "Adicionado"),
("Deleted", "Excluído"),
("Guide-based", "Baseado em guias"),
("Page:", "Página:"),
("Content:", "Conteúdo:"),
("Object Constraints:", "Restrições de objeto:"),
("Resize with Page:", "Redimensionar com a página:"),
("Pin:", "Fixar:"),
("Object-Based", "Baseado em objetos"),
("Guide-Based", "Baseado em guias"),
("Resize Page", "Redimensionar página"),
+5
View File
@@ -1292,6 +1292,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Added", "Додано"),
("Deleted", "Видалено"),
("Guide-based", "На основі напрямних"),
("Page:", "Сторінка:"),
("Content:", "Вміст:"),
("Object Constraints:", "Обмеження об’єкта:"),
("Resize with Page:", "Змінювати розмір зі сторінкою:"),
("Pin:", "Закріпити:"),
("Object-Based", "На основі об’єктів"),
("Guide-Based", "На основі напрямних"),
("Resize Page", "Змінити розмір сторінки"),
+1
View File
@@ -308,6 +308,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"<",
"cmd:layout.marginsAndColumns",
"cmd:layout.createGuides",
"cmd:layout.createAlternate",
"-",
"ui:view.goToPage",
"-",
+125 -45
View File
@@ -501,50 +501,104 @@ pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str {
LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l)
}
/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's
/// liquid guides, and Create Alternate Layout.
/// Whether page `abs` follows the object-based rule (its own, or its parent page's).
fn object_based(d: &designcraft_doc::Document, abs: usize) -> bool {
use designcraft_doc::LiquidRule as L;
let Some((si, pi)) = d.page_loc(abs) else { return false };
match d.spreads[si].pages[pi].liquid {
L::ObjectBased => true,
L::ControlledByParent => {
d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == L::ObjectBased)).unwrap_or(false)
}
_ => false,
}
}
/// Liquid Layout panel, laid out as InDesign's: the page's Liquid Page Rule (with "Page: n"),
/// Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top,
/// Left, Bottom, Right) for the selected object on an object-based page. The page's liquid
/// guides follow (Create Alternate Layout is in the Layout menu).
pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let Some(page) = crate::canvas::current_page(app) else {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No document.")).size(11.0).color(t.text_dim));
let lang = app.ui.language.clone();
// The Page tool's page, the page in view, or (before the canvas lays out) the first.
let Some(page) = app.session.active().map(|s| s.selection.page.or_else(|| crate::canvas::current_page(app)).unwrap_or(0)) else {
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No document.")).size(11.0).color(t.text_dim));
return;
};
let Some(d) = app.session.active().map(|d| d.doc.clone()) else { return };
let Some((si, pi)) = d.page_loc(page) else { return };
let pg = d.spreads[si].pages[pi].clone();
use designcraft_doc::LiquidRule as L;
crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong());
let mut rule = pg.liquid;
ui.horizontal(|ui| {
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:"));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page)));
});
});
egui::ComboBox::from_id_salt("liquid_rule")
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule))))
.selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid))))
.width(190.0)
.show_ui(ui, |ui| {
for (r, label) in LIQUID_RULES {
ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label)));
}
});
if rule != pg.liquid {
let _ = app.run("liquid.pageRule", json!({"rule": rule, "pages": [page + 1]}));
}
if rule == L::ObjectBased
&& let Some(id) = app.session.active().and_then(|s| s.selection.items.first().copied())
&& let Some(it) = d.item(id)
{
ui.separator();
let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() });
let mut v = [l.resize_width, l.resize_height, l.pin_top, l.pin_bottom, l.pin_left, l.pin_right];
let keys = ["resizeWidth", "resizeHeight", "pinTop", "pinBottom", "pinLeft", "pinRight"];
let labels = ["Resize width", "Resize height", "Pin top", "Pin bottom", "Pin left", "Pin right"];
ui.horizontal_wrapped(|ui| {
for ((on, key), label) in v.iter_mut().zip(keys).zip(labels) {
if ui.checkbox(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() {
let _ = app.run("liquid.object", json!({key: *on}));
if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() && r != pg.liquid {
let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [page + 1]}));
}
}
});
}
ui.separator();
// The selected object (one on this page).
let item = app
.session
.active()
.and_then(|s| s.selection.items.first().copied())
.and_then(|id| d.item(id))
.filter(|it| d.page_of_item(it.id) == Some(page));
// Content: Auto-Fit (graphic frames).
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Content:"));
let graphic = item.and_then(|it| if let designcraft_doc::Content::Graphic(g) = &it.content { Some(g.auto_fit) } else { None });
let mut auto = graphic.is_some_and(|f| f != designcraft_doc::Fitting::None);
ui.indent("liquid_content", |ui| {
let r = ui.add_enabled(graphic.is_some(), egui::Checkbox::new(&mut auto, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Auto-Fit"))));
if r.changed() {
let _ = app.run("object.fittingOptions", json!({"autoFit": auto}));
}
});
// Object Constraints: for an object on an object-based page.
crate::widgets::caption(ui, crate::i18n::tr(&lang, "Object Constraints:"));
let on = item.is_some() && object_based(&d, page);
let l = item
.filter(|_| on)
.map(|it| it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }))
.unwrap_or_default();
ui.indent("liquid_constraints", |ui| {
ui.add_enabled_ui(on, |ui| {
let rows: [(&str, [(&str, &str, bool); 2]); 2] = [
("Resize with Page:", [("Height", "resizeHeight", l.resize_height), ("Width", "resizeWidth", l.resize_width)]),
("Pin:", [("Top", "pinTop", l.pin_top), ("Left", "pinLeft", l.pin_left)]),
];
for (title, boxes) in rows {
crate::widgets::caption(ui, crate::i18n::tr(&lang, title));
let mut grid_boxes = vec![boxes.to_vec()];
if title == "Pin:" {
grid_boxes.push(vec![("Bottom", "pinBottom", l.pin_bottom), ("Right", "pinRight", l.pin_right)]);
}
egui::Grid::new(("liquid_grid", title)).num_columns(2).min_col_width(90.0).show(ui, |ui| {
for row in grid_boxes {
for (label, key, value) in row {
let mut v = value;
if ui.checkbox(&mut v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).changed() {
let _ = app.run("liquid.object", json!({key: v}));
}
}
ui.end_row();
}
});
}
});
});
if !pg.guides.is_empty() {
ui.separator();
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Liquid guides")).size(11.0).color(t.text_dim));
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Liquid guides")).size(11.0).color(t.text_dim));
for (gi, g) in pg.guides.iter().enumerate() {
let mut on = g.liquid;
let label = format!("{:?} at {:.1}", g.orientation, g.position);
@@ -553,22 +607,6 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) {
}
}
}
ui.separator();
let key = egui::Id::new("alt_layout");
let (mut name, mut w, mut h): (String, f64, f64) = ui.data(|x| x.get_temp(key)).unwrap_or(("Tablet H".into(), 1024.0, 768.0));
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Create Alternate Layout")).size(11.0).color(t.text_dim));
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut name).desired_width(90.0));
ui.add(egui::DragValue::new(&mut w).range(1.0..=15552.0).suffix(" pt"));
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "×"));
ui.add(egui::DragValue::new(&mut h).range(1.0..=15552.0).suffix(" pt"));
});
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Create"))).clicked()
&& let Err(e) = app.run("layout.createAlternate", json!({"name": name, "width": w, "height": h}))
{
app.status(format!("Alternate layout: {e}"));
}
ui.data_mut(|x| x.insert_temp(key, (name, w, h)));
}
/// Media panel: the selected video or sound's playback options and poster.
@@ -734,3 +772,45 @@ pub fn track_changes(app: &mut DesignApp, ui: &mut egui::Ui) {
}
});
}
#[cfg(test)]
mod liquid_panel_tests {
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use super::*;
#[test]
fn object_constraints_set_the_selected_objects_pins_and_resizing() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state(
|ui, app: &mut DesignApp| {
let key = egui::Id::new("test_fonts");
if ui.data(|d| d.get_temp::<bool>(key)).is_none() {
crate::theme::install_fonts(ui.ctx(), "");
ui.data_mut(|d| d.insert_temp(key, true));
return;
}
liquid(app, ui);
},
app,
);
h.run_steps(3);
let l = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid;
// With the rule off the constraints don't apply.
h.get_by_label("Right").click();
h.run_steps(2);
assert_eq!(l(&h), None);
h.state_mut().run("liquid.pageRule", json!({"rule": "objectBased"})).unwrap();
h.run_steps(2);
h.get_by_label("Right").click();
h.run_steps(2);
h.get_by_label("Width").click();
h.run_steps(2);
let v = l(&h).unwrap();
assert!(v.pin_right && !v.resize_width && v.resize_height, "{v:?}");
let _ = h.get_by_label("Auto-Fit");
}
}