From b3896d812213ea904d5609cafe0e2742b26adebb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 17:30:14 +0200 Subject: [PATCH] List buttons: track the hanging indent they set (listHang) A paragraph attribute records that the Bulleted or Numbered List button set the hanging indent; turning the list off takes the indent away only then (and while it is still as applied), and clears the mark. A paragraph whose own indents were 18/-18 before the list keeps them. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/attrs.rs | 3 +++ crates/engine/src/cmd/lists.rs | 36 ++++++++++++++++++++++++++++------ docs/target-app-parity.md | 2 +- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..8151c6846 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -1270,6 +1270,9 @@ attr_set! { number_expression: String = "^#.^t".into(), /// Text after the bullet (metacharacters expand as in [`expand_list_text`]). list_separator: String = "\t".into(), + /// The hanging indent was set by the Bulleted or Numbered List button, which takes it away + /// again with the list (an indent the paragraph had before stays). + list_hang: bool = false, balance_ragged: bool = false, optical_margin: bool = false, /// None uses the legacy built-in rules; an empty set explicitly disables kinsoku. diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 24578bc42..74570f3fd 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -99,8 +99,8 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { return; } let (style, _) = styles.resolve_para_style(&pf.style); - // The hanging indent set on the paragraph itself (by a list button), not by its style. - let own_hang = pf.para.left_indent == Some(LIST_HANG) && pf.para.first_line_indent == Some(-LIST_HANG); + // The hanging indent this button set (and nothing has changed since). + let own_hang = pf.para.list_hang == Some(true) && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG; // A value: left to the style when it gives the same, else set here. let mut set = |key: &str, v: Value, same: bool| { let _ = pf.para.set_json(key, if same { &Value::Null } else { &v }); @@ -110,10 +110,15 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { if on && now.left_indent == 0.0 && now.first_line_indent == 0.0 { set("leftIndent", json!(LIST_HANG), style.left_indent == LIST_HANG); set("firstLineIndent", json!(-LIST_HANG), style.first_line_indent == -LIST_HANG); - } else if !on && now.list_type == kind && own_hang { - // Only the hanging indent set here: one the paragraph style gives stays. - set("leftIndent", json!(0.0), style.left_indent == 0.0); - set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0); + set("listHang", json!(true), false); + } else if !on && now.list_type == kind { + // Only the hanging indent set here: one the paragraph had before, or its style + // gives, stays. + if own_hang { + set("leftIndent", json!(0.0), style.left_indent == 0.0); + set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0); + } + set("listHang", Value::Null, true); } }); } @@ -320,6 +325,25 @@ Flour assert_eq!(kinds(&s), [ListType::Numbers; 4]); } + /// A paragraph's own hanging indent, the same as the button's, stays when the list goes off: + /// the button takes away only the indent it set. + #[test] + fn a_paragraphs_own_hanging_indent_stays_when_the_list_goes_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Eggs"})).unwrap(); + let sid = f["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 4})).unwrap(); + s.execute("type.para", &json!({"attrs": {"leftIndent": 18, "firstLineIndent": -18}})).unwrap(); + s.execute("list.bullets", &json!({"on": true})).unwrap(); + s.execute("list.bullets", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let pf = &d.story(designcraft_doc::StoryId(sid)).unwrap().paras[0]; + let p = d.styles.resolve_para(pf).0; + assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 18.0, -18.0)); + assert_eq!(pf.para.list_hang, None, "nothing of the list left"); + } + /// A hanging indent the paragraph style gives stays when the list goes off. #[test] fn a_style_hanging_indent_stays_when_the_list_goes_off() { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c26b0384f..a178320d6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -402,7 +402,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. -- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if the button set it and it is still as applied (a hanging indent the paragraph style gives stays). A selection ending right after a return leaves the next paragraph out. One undo step; values equal to the paragraph style's are left to the style. +- Bulleted and Numbered List buttons (Properties › Bullets and Numbering; Type › Bulleted & Numbered Lists › Apply Bullets / Apply Numbers, ✓ when the selection has them; `list.bullets`, `list.numbers`): as InDesign's, a paragraph with no indents gets the default hanging indent (Left Indent 0.25 in, First Line Indent −0.25 in), so the bullet or number sits at the margin and the text after its tab at the indent; pressed again, the list goes and so does that indent if the button set it (`listHang`) and it is still as applied (a hanging indent the paragraph had before, or its style gives, stays). A selection ending right after a return leaves the next paragraph out. One undo step; values equal to the paragraph style's are left to the style. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. - View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.