diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index cad62f27..cf89273f 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -85,22 +85,18 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { 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)) - }) - }) - }); + let on = on.unwrap_or_else(|| !s.doc().is_ok_and(|st| all_have(&st.doc, &targets, 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); + // Off takes away this list only: a paragraph with the other list keeps it. + if !on && now.list_type != kind { + return; + } 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| { @@ -121,6 +117,26 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result { }) } +/// Whether every paragraph of `targets` has list `kind`. +fn all_have(d: &designcraft_doc::Document, targets: &[super::text::Target], kind: ListType) -> bool { + 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)) + }) + }) +} + +impl Session { + /// Whether the Bulleted or Numbered List button (and Type › Apply Bullets or Numbers) shows + /// pressed: every paragraph the list commands apply to has list `kind`, through the selected + /// text, table cells or text frames. + pub fn selection_has_list(&self, kind: ListType) -> bool { + let targets = super::text::format_targets_pub(self); + !targets.is_empty() && self.doc().is_ok_and(|st| all_have(&st.doc, &targets, kind)) + } +} + #[cfg(test)] mod tests { use serde_json::json; @@ -175,6 +191,44 @@ mod tests { assert!(s.execute("list.bullets", &json!({"on": "yes"})).is_err()); } + /// Pressed means every targeted paragraph has the list: a selection reaching an unbulleted + /// paragraph, or a frame with one, isn't. Explicitly off, a list button takes away its own + /// list only: a bulleted paragraph keeps bullets and indent when Numbers go off. + #[test] + fn a_list_is_pressed_and_turned_off_for_its_own_paragraphs() { + use designcraft_doc::ListType; + 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(); + let select = |s: &mut Session, a: usize, b: usize| { + s.execute("text.select", &json!({"story": sid, "anchor": a, "focus": b})).unwrap(); + }; + select(&mut s, 0, 3); + s.execute("list.bullets", &json!({})).unwrap(); + assert!(s.selection_has_list(ListType::Bullets)); + assert!(!s.selection_has_list(ListType::Numbers)); + // From the bulleted Eggs into the plain Milk: not pressed, and a press bullets both. + select(&mut s, 0, 7); + assert!(!s.selection_has_list(ListType::Bullets)); + // The frame selected: its whole story, Flour unbulleted. + s.execute("selection.set", &json!({"ids": [f["id"]]})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + // Eggs bulleted, Milk numbered; Numbers off over both leaves Eggs' bullets and indent. + select(&mut s, 5, 7); + s.execute("list.numbers", &json!({"on": true})).unwrap(); + select(&mut s, 0, 7); + s.execute("list.numbers", &json!({"on": false})).unwrap(); + let d = &s.doc().unwrap().doc; + let st = d.story(designcraft_doc::StoryId(sid)).unwrap(); + let p = |i: usize| d.styles.resolve_para(&st.paras[i]).0; + assert_eq!((p(0).list_type, p(0).left_indent, p(0).first_line_indent), (ListType::Bullets, 18.0, -18.0)); + assert_eq!((p(1).list_type, p(1).left_indent), (ListType::None, 0.0)); + // With no text or frame selected, nothing is pressed. + s.execute("selection.set", &json!({"ids": []})).unwrap(); + assert!(!s.selection_has_list(ListType::Bullets)); + } + /// The digit the story's first line starts with (its list number). fn digit(g: &designcraft_compose::PlacedGlyph) -> char { let face = g.face.get(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dde5ca03..0bdae7d1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2380,18 +2380,10 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { "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. + // Pressed, as the Bulleted and Numbered List buttons: every paragraph the command applies + // to (selected text, cells or text frames) has 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) + app.session.selection_has_list(if id == "list.bullets" { designcraft_doc::ListType::Bullets } else { designcraft_doc::ListType::Numbers }) } "window.controlBar" => app.ui.control_bar, "window.split" => app.split, diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 3e68ead7..c1f2f23a 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1530,15 +1530,18 @@ fn paragraph_more(app: &mut DesignApp, ui: &mut Ui, p: &Value, fw: f32) { } fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { - let Some(a) = text_attrs(app) else { return }; - let lt = a["para"]["listType"].as_str().unwrap_or("none").to_string(); + if text_attrs(app).is_none() { + return; + } ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; - // As InDesign's: a list with the default hanging indent, or (pressed) no list. + // As InDesign's: a list with the default hanging indent, or (pressed: every selected + // paragraph has it, as Type › Apply Bullets shows) no list. + use designcraft_doc::ListType; for (icon, kind, cmd, tip) in - [("bullets", "bullets", "list.bullets", "Bulleted List"), ("numbering", "numbers", "list.numbers", "Numbered List")] + [("bullets", ListType::Bullets, "list.bullets", "Bulleted List"), ("numbering", ListType::Numbers, "list.numbers", "Numbered List")] { - let on = lt == kind; + let on = app.session.selection_has_list(kind); if icon_toggle(ui, icon, on, crate::i18n::tr(&app.ui.language, tip)).clicked() { let _ = app.run(cmd, json!({"on": !on})); }