Bulleted and Numbered List buttons give InDesign's default hanging indent

The Properties buttons and Type > Bulleted & Numbered Lists > Apply Bullets /
Apply Numbers (list.bullets, list.numbers) apply the list with Left Indent
0.25 in / First Line Indent -0.25 in when the paragraph has no indents, so the
text lines up after the bullet; pressed again, the list and that indent go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
paaspaas00
2026-10-21 16:04:31 +02:00
co-authored by Claude Opus 5.5
parent 3ce3a24f08
commit 189cccd2fc
16 changed files with 198 additions and 5 deletions
+1
View File
@@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **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-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change.
- **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
+123 -2
View File
@@ -1,13 +1,37 @@
//! Type › Bulleted & Numbered Lists › Define Lists: named lists whose numbers can continue from
//! story to story. Paragraphs join one with `listName` (and restart with `startAt`).
use designcraft_doc::NumberedList;
use designcraft_doc::{ListType, NumberedList};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
use super::{CommandSpec, bad, cmd, has_doc, has_text_or_frames, str_param};
use crate::{Result, Session};
/// The hanging indent a list gets from the Bulleted and Numbered List buttons, as InDesign's
/// default (Left Indent 0.25 in, First Line Indent −0.25 in): the bullet or number at the margin,
/// the text after its tab at the left indent.
pub const LIST_HANG: f64 = 18.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"list.bullets",
"Apply Bullets",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph has bullets, else on)} — the Bulleted List button: paragraphs with no indents get the default hanging indent (left 18 pt, first line −18 pt); taking bullets off removes that indent if it is still as applied",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Bullets)
),
cmd!(
"list.numbers",
"Apply Numbers",
["Type", "Bulleted & Numbered Lists"],
None,
"{on?: bool (default: off when every selected paragraph is numbered, else on)} — the Numbered List button, as list.bullets",
has_text_or_frames,
|s, p| apply_list(s, p, ListType::Numbers)
),
cmd!(
"list.define",
"Define Lists…",
@@ -48,12 +72,109 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Bullets or numbers on (or off) for the selected paragraphs, as InDesign's Bulleted and
/// Numbered List buttons: one undo step. Values equal to the paragraph style's are left to the
/// style rather than set locally, so a paragraph back to its style shows no override.
fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result<Value> {
let name = if kind == ListType::Bullets { "list.bullets" } else { "list.numbers" };
let on = match p.get("on") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_bool().ok_or_else(|| bad(name, "`on` must be true or false"))?),
};
let targets = super::text::format_targets_pub(s);
if targets.is_empty() {
return Err(bad(name, "select text or a text frame"));
}
let d = &s.doc()?.doc;
// Off when every selected paragraph already has this list, as the button shows it pressed.
let on = on.unwrap_or_else(|| {
!targets.iter().all(|t| {
d.text_story(t.story, t.cell).is_some_and(|st| {
let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start)));
st.paras.get(a..=b).is_some_and(|ps| ps.iter().all(|pf| d.styles.resolve_para(pf).0.list_type == kind))
})
})
});
s.edit(|d, _| {
let styles = d.styles.clone();
for t in &targets {
let Some(st) = d.text_story_mut(t.story, t.cell) else { continue };
st.format_paras(t.range.clone(), |pf| {
let (now, _) = styles.resolve_para(pf);
let (style, _) = styles.resolve_para_style(&pf.style);
// 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 });
};
let list = if on { kind } else { ListType::None };
set("listType", serde_json::to_value(list).unwrap_or(Value::Null), style.list_type == list);
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 && now.left_indent == LIST_HANG && now.first_line_indent == -LIST_HANG {
set("leftIndent", json!(0.0), style.left_indent == 0.0);
set("firstLineIndent", json!(0.0), style.first_line_indent == 0.0);
}
});
}
Ok(json!({"on": on}))
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
/// The Bulleted List button, as InDesign's: bullets with the default hanging indent (the
/// bullet at the margin, the text 18 pt in); again, bullets and that indent go, back to the
/// style. Numbers swap in keeping the indent; paragraphs with their own indents keep them.
#[test]
fn list_buttons_apply_the_default_hanging_indent() {
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\nMilk\nFlour"})).unwrap();
let sid = f["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 9})).unwrap();
let para = |s: &Session, i: usize| {
let d = &s.doc().unwrap().doc;
let st = d.story(designcraft_doc::StoryId(sid)).unwrap();
(d.styles.resolve_para(&st.paras[i]).0, st.paras[i].para.clone())
};
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("list.bullets", &json!({})).unwrap()["on"], true);
for i in 0..2 {
let (p, _) = para(&s, i);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::Bullets, 18.0, -18.0));
}
assert_eq!(para(&s, 2).0.list_type, designcraft_doc::ListType::None, "the paragraph outside the selection");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The number of a bullet's tab: the text starts at the left indent.
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
let line = &cs.frames[0].lines[0];
let text_x = line.glyphs.iter().filter(|g| g.len > 0).map(|g| g.x).next().unwrap();
assert!((text_x - line.x0 - 18.0).abs() < 0.5, "the text at the hanging indent: {} from {}", text_x, line.x0);
// Numbers instead: the indent stays.
s.execute("list.numbers", &json!({})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent), (designcraft_doc::ListType::Numbers, 18.0));
// Numbers off: list and hanging indent go, and nothing is left set on the paragraph.
assert_eq!(s.execute("list.numbers", &json!({})).unwrap()["on"], false);
let (p, own) = para(&s, 0);
assert_eq!((p.list_type, p.left_indent, p.first_line_indent), (designcraft_doc::ListType::None, 0.0, 0.0));
assert_eq!(own, designcraft_doc::ParaAttrs::default(), "back to the style");
// A paragraph with its own indents keeps them.
s.execute("type.para", &json!({"attrs": {"leftIndent": 36}})).unwrap();
s.execute("list.bullets", &json!({"on": true})).unwrap();
let (p, _) = para(&s, 0);
assert_eq!((p.left_indent, p.first_line_indent), (36.0, 0.0));
s.execute("list.bullets", &json!({"on": false})).unwrap();
assert_eq!(para(&s, 0).0.left_indent, 36.0);
assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err());
}
/// The digit the story's first line starts with (its list number).
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
let face = g.face.get();
+2
View File
@@ -1633,6 +1633,8 @@ const TABLE: &[(&str, [&str; 5])] = &[
],
),
("Define Lists…", ["Define Lists…", "Définir des listes…", "Define Lists…", "Define Lists…", "定义列表…"]),
("Apply Bullets", ["Aufzählungszeichen anwenden", "Appliquer des puces", "Aplicar viñetas", "箇条書きを適用", "应用项目符号"]),
("Apply Numbers", ["Nummerierung anwenden", "Appliquer la numérotation", "Aplicar números", "段落番号を適用", "应用编号"]),
(
"Define Text Variable…",
["Define Text Variable…", "Définir une variable de texte…", "Define Text Variable…", "Define Text Variable…", "定义文本变量…"],
+2
View File
@@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
("Apply Bullets", "تطبيق التعداد النقطي"),
("Apply Numbers", "تطبيق الترقيم"),
("Paste Vector Art", "لصق رسم متجه"),
("Baseline Options", "خيارات خط الأساس"),
("Fixed Number", "عدد ثابت"),
+2
View File
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Vyjmout"),
("Copy", "Kopírovat"),
("Paste", "Vložit"),
("Apply Bullets", "Použít odrážky"),
("Apply Numbers", "Použít číslování"),
("Reverse Path", "Obrátit cestu"),
("Set Anchor", "Nastavit kotvu"),
("Clear", "Vymazat"),
+2
View File
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Ausschneiden"),
("Copy", "Kopieren"),
("Paste", "Einfügen"),
("Apply Bullets", "Aufzählungszeichen anwenden"),
("Apply Numbers", "Nummerierung anwenden"),
("Inches Decimal", "Zoll dezimal"),
("Back", "Zurück"),
("Warning", "Warnung"),
+2
View File
@@ -28,6 +28,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
("Apply Bullets", "Applica punti elenco"),
("Apply Numbers", "Applica numeri"),
("Paste Vector Art", "Incolla grafica vettoriale"),
("Baseline Options", "Opzioni linea di base"),
("Fixed Number", "Numero fisso"),
+2
View File
@@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "カット"),
("Copy", "コピー"),
("Paste", "ペースト"),
("Apply Bullets", "箇条書きを適用"),
("Apply Numbers", "段落番号を適用"),
("Paste Vector Art", "ベクトルアートをペースト"),
("Baseline Options", "ベースラインオプション"),
("Column Rules", "段落境界線"),
+2
View File
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
("Apply Bullets", "მარკერების გამოყენება"),
("Apply Numbers", "ნუმერაციის გამოყენება"),
("Inches Decimal", "ინჩები (ათობითი)"),
("Back", "უკან"),
("Warning", "გაფრთხილება"),
+2
View File
@@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
("Apply Bullets", "Zastosuj wypunktowanie"),
("Apply Numbers", "Zastosuj numerowanie"),
("Clear", "Wyczyść"),
("Duplicate", "Powiel"),
("Select All", "Zaznacz wszystko"),
+2
View File
@@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
("Apply Bullets", "Aplicar marcadores"),
("Apply Numbers", "Aplicar números"),
("Paste Vector Art", "Colar arte vetorial"),
("Baseline Options", "Opções de linha de base"),
("Fixed Number", "Número fixo"),
+2
View File
@@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
("Apply Bullets", "Применить маркеры"),
("Apply Numbers", "Применить нумерацию"),
("Clear", "Очистить"),
("Duplicate", "Дублировать"),
("Select All", "Выделить все"),
+2
View File
@@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
("Apply Bullets", "Застосувати маркери"),
("Apply Numbers", "Застосувати нумерацію"),
("Paste Vector Art", "Вставити векторну графіку"),
("Baseline Options", "Параметри базової лінії"),
("Fixed Number", "Фіксована кількість"),
+16
View File
@@ -502,6 +502,9 @@ pub const MENUS: &[(&str, &[&str])] = &[
"<",
"-",
">Bulleted & Numbered Lists",
"cmd:list.bullets",
"cmd:list.numbers",
"-",
"cmd:list.define",
"<",
"-",
@@ -2377,6 +2380,19 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option<bool> {
"view.hiddenCharacters" => app.ui.hidden_characters,
"view.taggedFrames" => app.ui.tagged_frames,
"changes.track" => app.session.active().is_some_and(|d| d.doc.settings.track_changes),
// Pressed, as the Bulleted and Numbered List buttons: the selected paragraphs have it.
"list.bullets" | "list.numbers" => {
let kind = if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers };
app.session
.active()
.and_then(|st| {
let t = st.selection.text?;
let story = st.doc.text_story(t.story, t.cell)?;
let pf = story.paras.get(story.para_at(t.range().start))?;
Some(st.doc.styles.resolve_para(pf).0.list_type == kind)
})
.unwrap_or(false)
}
"window.controlBar" => app.ui.control_bar,
"window.split" => app.split,
"window.newWindow" => app.second_window,
+34 -2
View File
@@ -1366,10 +1366,13 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) {
let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 10.0;
for (icon, kind, tip) in [("bullets", "bullets", "Bulleted List"), ("numbering", "numbers", "Numbered List")] {
// As InDesign's: a list with the default hanging indent, or (pressed) no list.
for (icon, kind, cmd, tip) in
[("bullets", "bullets", "list.bullets", "Bulleted List"), ("numbering", "numbers", "list.numbers", "Numbered List")]
{
let on = lt == kind;
if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() {
let _ = app.run("type.para", json!({"attrs": {"listType": if on { "none" } else { kind }}}));
let _ = app.run(cmd, json!({"on": !on}));
}
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -3317,6 +3320,35 @@ mod tests {
h
}
/// The course's chapter 21: Properties' Bulleted List button gives the selected paragraphs
/// bullets with InDesign's hanging indent; pressed again, both go.
#[test]
fn the_bulleted_list_button_applies_a_hanging_indent() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let f = app
.run(
"frame.create",
json!({"rect": [72, 72, 300, 200], "content": "text", "text": "4 eggs
1 cup milk"}),
)
.unwrap();
app.run("text.select", json!({"story": f["story"], "anchor": 0, "focus": 15})).unwrap();
let mut h = panel(app);
h.get_by_label("Bulleted List").click();
h.run_steps(3);
let a = h.state_mut().run("type.selectionAttrs", json!({})).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))
);
assert_eq!(crate::menus::checked(h.state(), "list.bullets", &json!({})), Some(true), "Type › Apply Bullets shows it");
h.get_by_label("Bulleted List").click();
h.run_steps(3);
let a = h.state_mut().run("type.selectionAttrs", json!({})).unwrap();
assert_eq!((a["para"]["listType"].as_str(), a["para"]["leftIndent"].as_f64()), (Some("none"), Some(0.0)));
}
#[test]
fn several_shapes_show_pathfinder_and_intersect_combines_them() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+2 -1
View File
@@ -1,6 +1,6 @@
# DesignCraft parity with Adobe InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (list buttons with the default hanging indent; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; image clipping paths from IDML; anchored objects wrap text; Tabs panel listed as present (#343); spring-loaded Zoom navigation; recovery checkpoint retention; cancelable macOS Dock Quit; Based on Parent; object styles carry effects; colour management on craft-cms, Coated FOGRA39 default CMYK; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6.0.57, macOS)
The authoritative assessment of how close DesignCraft is to Adobe InDesign. [ROADMAP.md](../ROADMAP.md)
summarizes it; [gaps.md](gaps.md) lists every known shortfall one at a time; the feature-by-feature
@@ -401,6 +401,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 it is still as applied. 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.