From 2c1973a6e49ca901fbea25dea9ac717b7328c45d Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:16:25 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Control=20panel=20(=C2=B6):=20Bulleted=20an?= =?UTF-8?q?d=20Numbered=20List,=20Shading=20and=20Border,=20as=20InDesign'?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/chrome.rs | 140 +++++++++++++++++++++++++++++++++++ crates/ui-egui/src/icons.rs | 14 ++++ docs/ui-parity.md | 2 +- 4 files changed, 156 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f8c5c2d83..0031bd5ed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · The ¶ (Paragraph Formatting Controls) mode of the text Control panel has InDesign's Bulleted List and Numbered List buttons (with the default hanging indent) and Shading and Border: a checkbox, a button opening Paragraph Borders and Shading on that tab, and the colour. Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The Bulleted and Numbered List buttons (Properties, and Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers) work as InDesign's: a paragraph with no indents gets the default hanging indent (0.25 in, −0.25 in), so the text lines up after the bullet instead of at the next default tab stop, and pressing the button again removes the list and that indent (`list.bullets`, `list.numbers`). Found in the Learn Skills Daily course, chapter 21. No readiness percentage change. - **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. - **2026-10-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 690d8fc32..3af8e9b91 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -912,6 +912,66 @@ fn control_paragraph( let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}})); } vsep(ui); + // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, + // pressed, none). + let lt = para["listType"].as_str().unwrap_or("none").to_string(); + let mut list = None; + rows(ui, |ui, row| { + let (icon, kind, cmd, tip) = if row == 0 { + ("bullets", "bullets", "list.bullets", "Bulleted List") + } else { + ("numbering", "numbers", "list.numbers", "Numbered List") + }; + let on = lt == kind; + if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { + list = Some((cmd, !on)); + } + }); + if let Some((cmd, on)) = list { + let _ = app.run(cmd, json!({"on": on})); + } + vsep(ui); + // Shading over Border: on or off, the Paragraph Borders and Shading dialog on its tab, and the + // colour, as InDesign's. + let doc = app.session.active().map(|d| d.doc.clone()); + let mut shade = None; + let mut dialog = None; + // Both checkboxes as wide as the wider label, so the icons and colours line up in columns. + let check_w = ["Shading", "Border"] + .iter() + .map(|l| ui.fonts_mut(|f| f.layout_no_wrap(tr(l), egui::FontId::proportional(11.5), Color32::WHITE).size().x)) + .fold(0.0, f32::max) + + 28.0; + rows(ui, |ui, row| { + let (on_key, color_key, label, icon, tab) = if row == 0 { + ("shadingOn", "shadingColor", "Shading", "para-shading", "shading") + } else { + ("borderOn", "borderColor", "Border", "para-border", "border") + }; + let mut on = para[on_key].as_bool().unwrap_or(false); + ui.allocate_ui_with_layout(vec2(check_w, crate::widgets::FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(check_w); + if crate::widgets::checkbox(ui, &mut on, &tr(label)).changed() { + shade = Some(json!({ on_key: on })); + } + }); + if crate::widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")).clicked() { + dialog = Some(tab); + } + if let Some(doc) = &doc { + let cur = para[color_key].as_str().unwrap_or("[Black]").to_string(); + if let Some(w) = swatch_field(ui, doc, &cur, 96.0, &tr(if row == 0 { "Paragraph Shading" } else { "Paragraph Border" })) { + shade = Some(json!({ color_key: w })); + } + } + }); + if let Some(attrs) = shade { + let _ = app.run("type.para", json!({ "attrs": attrs })); + } + if let Some(tab) = dialog { + let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab })); + } + vsep(ui); ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 3.0; let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string(); @@ -931,6 +991,35 @@ fn control_paragraph( }); } +/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name, +/// and the document's swatches in its menu. Returns the swatch picked. +fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click()); + let resp = resp.on_hover_text(label); + resp.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, label); + info.current_text_value = Some(cur.to_string()); + info + }); + let p = ui.painter(); + p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input }); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + let seg = egui::Rect::from_min_max(egui::pos2(r.max.x - crate::widgets::SEG_W, r.min.y), r.max); + p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border)); + icons::paint(p, egui::Rect::from_center_size(seg.center(), vec2(12.0, 12.0)), "chevron-down", t.icon); + let (c, g) = crate::widgets::swatch_colors(doc, cur, 1.0); + let chip = egui::Rect::from_min_size(egui::pos2(r.min.x + 4.0, r.center().y - 6.0), vec2(12.0, 12.0)); + crate::widgets::paint_chip(p, chip, c, g); + let clip = egui::Rect::from_min_max(r.min, egui::pos2(seg.min.x - 2.0, r.max.y)); + p.with_clip_rect(clip).text(egui::pos2(chip.max.x + 5.0, r.center().y), egui::Align2::LEFT_CENTER, cur, egui::FontId::proportional(11.5), t.text); + let mut picked = None; + egui::Popup::menu(&resp).show(|ui| { + picked = crate::panels::swatch_list(ui, doc, cur); + }); + picked +} + /// The widest a document tab grows: a longer name is shortened so its close button and the /// tabs after it stay in view. const DOC_TAB_MAX_W: f32 = 260.0; @@ -1719,6 +1808,57 @@ mod tests { assert!(has(&h, "Small Caps")); } + /// The course's chapter 21: the ¶ Control panel has InDesign's Bulleted and Numbered List + /// buttons (with the default hanging indent), and Shading and Border with their colours and + /// the Paragraph Borders and Shading dialog. + #[test] + fn the_paragraph_control_panel_has_lists_shading_and_border() { + let mut app = app(); + app.run("file.new", serde_json::json!({})).unwrap(); + let f = app + .run( + "frame.create", + serde_json::json!({"rect": [72, 72, 400, 300], "content": "text", "text": "4 eggs +1 cup milk"}), + ) + .unwrap(); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + app.session.set_tool("type"); + let mut h = test_window::open(app, vec2(2400.0, 700.0)); + let bar = test_window::panel_rect(&h, "control_bar"); + let in_bar = move |r: egui::Rect| bar.y_range().contains(r.center().y); + let click = |h: &mut egui_kittest::Harness<'static, test_window::Window>, label: &str, nth: usize| { + let r = h + .query_all_by_label(label) + .map(|n| n.rect()) + .filter(|r| in_bar(*r)) + .nth(nth) + .unwrap_or_else(|| panic!("no {label:?} in the Control panel")); + h.hover_at(r.center()); + h.drag_at(r.center()); + h.drop_at(r.center()); + h.run_steps(3); + }; + click(&mut h, "Paragraph Formatting Controls", 0); + click(&mut h, "Bulleted List", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!( + (a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64(), a["para"]["firstLineIndent"].as_f64()), + (Some("bullets"), Some(18.0), Some(-18.0)) + ); + click(&mut h, "Numbered List", 0); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["listType"], "numbers"); + click(&mut h, "Shading", 0); + click(&mut h, "Border", 0); + let a = crate::panels::text_attrs(&mut h.state_mut().app).unwrap(); + assert_eq!((a["para"]["shadingOn"].as_bool(), a["para"]["borderOn"].as_bool()), (Some(true), Some(true))); + assert!(h.query_all_by_label("Paragraph Shading").any(|n| in_bar(n.rect())), "the shading colour"); + // The icon beside Border opens the dialog on its Border tab. + click(&mut h, "Paragraph Borders and Shading…", 1); + let d = h.state().app.ui.dialog.clone().expect("the dialog"); + assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border")); + } + #[test] fn the_page_tool_has_its_own_control_panel() { // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 446b412fe..f33e40184 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1046,6 +1046,20 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32) { pen.line(&[(7.5, *y), (if k == 1 { 14.0 } else { 17.0 }, *y)]); } } + // Paragraph shading: a square with a checked fill (the Control panel's Shading). + "para-shading" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.frect(4.0, 4.0, 10.0, 10.0); + pen.frect(10.0, 10.0, 16.0, 16.0); + } + // Paragraph border: a box with its corner drawn heavy (the Control panel's Border). + "para-border" => { + pen.w = 1.0; + pen.rect(4.0, 4.0, 16.0, 16.0); + pen.w = 2.5; + pen.line(&[(5.0, 12.0), (5.0, 5.0), (12.0, 5.0)]); + } "pilcrow-menu" => { pen.text(9.0, 10.0, "¶", 14.0); pen.fill(&[(14.0, 15.0), (17.0, 15.0), (15.5, 17.0)]); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 70e42d176..b1facbefd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -26,7 +26,7 @@ basics of editing fail for too many users on Windows and Linux. | Area | InDesign 2026 | DesignCraft | Status | Evidence | |---|---|---|---|---| -| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls, and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 | +| Chrome: app bar, Control panel, Tools panel, dock, doc tabs, status bar | Medium Dark, measured sizes | measured look; the text Control panel switches between Character (A) and Paragraph (¶) Formatting Controls (¶ with the Bulleted and Numbered List buttons, Shading and Border), and Escape in a field reverts what was typed | ✓ | 11-observed-ui.md; Learn Skills Daily course, chapter 16 | | Properties panel and Contextual Task Bar | per selection | ✓, task bar movable (#143); ••• expands Character (scales, baseline shift, skew, language, case and position buttons) and Paragraph (indents, spacing, drop cap, baseline grid, hyphenation) in place | ✓ | Learn Skills Daily course, chapter 16; `properties.rs` `more_options_expand_character_and_paragraph_in_place` | | Selection tool: click, marquee, move, Alt-duplicate, resize handles, rotate from corners | ✓ | ✓ | ✓ | Cmd/Ctrl held with another tool borrows the selection tool used last (Selection or Direct Selection) for one gesture, #919 | | Direct Selection, Pen family, Pencil, Scissors | ✓ | ✓ | ~ | moving reference point "0" fails (#337) | From 874d2be248f046402172eaa0a83711cdbacd69c8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 01:20:59 +0200 Subject: [PATCH 2/2] Control panel and Properties: a negative Last Line Right Indent; list buttons pressed by every paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Last Line Right Indent field takes negative values, as the First Line Left Indent does. The Control panel's Bulleted and Numbered List buttons show pressed through `Session::selection_has_list`, as Type › Apply Bullets and Properties do. With #1483's fix merged, an explicit off no longer takes the other list type away. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/chrome.rs | 38 +++++++++++++++++++------ crates/ui-egui/src/panels/properties.rs | 3 +- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3af8e9b91..62b47cd7a 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -873,7 +873,8 @@ fn control_paragraph( set = Some((ka, v)); } } else { - let min = if kb == "firstLineIndent" { -10_000.0 } else { 0.0 }; + // The first and last line indents may be negative (a hanging line), as InDesign's. + let min = if matches!(kb, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }; if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) { set = Some((kb, v)); } @@ -913,16 +914,13 @@ fn control_paragraph( } vsep(ui); // Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or, - // pressed, none). - let lt = para["listType"].as_str().unwrap_or("none").to_string(); + // pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows. + use designcraft_doc::ListType; + let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k)); let mut list = None; rows(ui, |ui, row| { - let (icon, kind, cmd, tip) = if row == 0 { - ("bullets", "bullets", "list.bullets", "Bulleted List") - } else { - ("numbering", "numbers", "list.numbers", "Numbered List") - }; - let on = lt == kind; + let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") }; + let on = pressed.get(row).copied().unwrap_or(false); if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() { list = Some((cmd, !on)); } @@ -1857,6 +1855,28 @@ mod tests { click(&mut h, "Paragraph Borders and Shading…", 1); let d = h.state().app.ui.dialog.clone().expect("the dialog"); assert_eq!((d.id.as_str(), d.s("section").as_str()), ("paragraphLocal", "border")); + // The Last Line Right Indent takes a negative value, as the First Line Left Indent does. + h.state_mut().app.ui.dialog = None; + h.state_mut().app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap(); + h.state_mut().app.session.set_tool("type"); + h.run_steps(3); + // The field is labelled by its icon. + let at = h + .query_all_by_label("Last Line Right Indent") + .filter(|n| n.accesskit_node().role() == egui::accesskit::Role::TextInput) + .map(|n| n.rect()) + .find(|r| in_bar(*r)) + .expect("the last line indent") + .center(); + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(2); + h.event(egui::Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.event(egui::Event::Text("-0p6".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(crate::panels::text_attrs(&mut h.state_mut().app).unwrap()["para"]["lastLineIndent"].as_f64(), Some(-6.0)); } #[test] diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index c1f2f23a1..9d4052a87 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1497,7 +1497,8 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { for (i, (l, r)) in rows.into_iter().enumerate() { let y = i as f32 * (FIELD_H + 5.0); for (right, (icon, tip, key)) in [(false, l), (true, r)] { - let min = if key == "firstLineIndent" { -10_000.0 } else { 0.0 }; + // The first and last line indents may be negative (a hanging line), as InDesign's. + let min = if matches!(key, "firstLineIndent" | "lastLineIndent") { -10_000.0 } else { 0.0 }; let field = NumField::measure(key, p[key].as_f64(), u).spinner().range(min, 10_000.0); if let Some(v) = half_field(ui, b, right, y, half, icon, &tr(tip), field) { let _ = app.run("type.para", json!({"attrs": {key: v}}));