From 6cf0aec0ffa61c73e124f540797dfdee582e916f Mon Sep 17 00:00:00 2001 From: Cyberverse-cent0 Date: Mon, 12 Oct 2026 09:08:11 +0000 Subject: [PATCH] Fix bulleted list toolbar button active state (#651) * Fix bulleted list toolbar button active state (#642) Add para.state command to return paragraph formatting state (bullets, numbering, multilevel) and update ribbon to use it for list button active states. Previously, the bullets, numbering, and multilevel list buttons never showed as active even when the cursor was inside a list item. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * Review fixes for #651: list buttons from the paragraph's own level - The Bullets/Numbering state reads the paragraph's list level (ilvl), not level 0, and Multilevel is on when the list numbers its levels together (a label showing another level's number, or legal numbering); the old level-0 '%2.' check was never true. - Computed from the paragraph props the ribbon already resolves (cmd::para::list_buttons) instead of a new para.state command. - Engine test. Co-Authored-By: Claude Opus 5.5 * #651: list-button test starts legal numbering from plain text Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/para.rs | 26 ++++++++++++++++++++++++++ crates/engine/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 7 ++++--- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 1bb4831..bc2a1bd 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -1146,3 +1146,29 @@ fn own_format(st: &Style) -> Value { pub fn touch_all(s: &mut Session) { s.dirty = true; } + +/// Which Home › Paragraph list buttons show as on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ListButtons { + pub bullets: bool, + pub numbering: bool, + pub multilevel: bool, +} + +/// The list buttons for a paragraph with resolved `numbering` (the ribbon passes the paragraph +/// props it already resolved). The paragraph's own level decides Bullets (a bulleted level) or +/// Numbering (a numbered one); Multilevel is on too when the list numbers its levels together: +/// some level's label shows another level's number (`1.1.`) or uses legal numbering. +pub fn list_buttons(doc: &wordcraft_doc::Document, numbering: Option) -> ListButtons { + let Some(n) = numbering.filter(|n| n.num != 0) else { return ListButtons::default() }; + let Some(level) = doc.numbering.level(n.num, n.level) else { return ListButtons::default() }; + let bullets = level.format == wordcraft_doc::section::NumFormat::Bullet; + let joined = |i: u8| { + doc.numbering.level(n.num, i).is_some_and(|l| { + let own = u32::from(i) + 1; + l.legal || l.text.split('%').skip(1).filter_map(|t| t.chars().next()?.to_digit(10)).any(|k| k != own) + }) + }; + let multilevel = (0..9u8).any(joined); + ListButtons { bullets, numbering: !bullets, multilevel } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e292a8f..09b6dbf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3597,6 +3597,38 @@ fn enclose_characters_make_an_eq_field_and_come_back_out() { assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string()))); } +#[test] +fn list_buttons_follow_the_paragraphs_own_level() { + use crate::cmd::para::{ListButtons, list_buttons}; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "item"})); + let state = |s: &Session| { + let rp = s.doc.para_at(&s.sel.focus).map(|p| s.doc.styles.resolve_para(&p.props)); + list_buttons(&s.doc, rp.and_then(|r| r.numbering)) + }; + assert_eq!(state(&s), ListButtons::default(), "plain text: no list button on"); + run(&mut s, "para.bullets", json!({})); + assert_eq!(state(&s), ListButtons { bullets: true, numbering: false, multilevel: false }); + // A bulleted list whose second level is numbered: at level 2 the paragraph is numbered. + let num = s.doc.para_at(&s.sel.focus).unwrap().props.numbering.unwrap().num; + let numbered = wordcraft_doc::numbering::Level { text: "%2.".into(), ..Default::default() }; + s.doc.numbering.nums.iter_mut().find(|n| n.id == num).unwrap().level_overrides.push((1, numbered)); + run(&mut s, "para.listLevel", json!({"level": 1})); + assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }); + run(&mut s, "para.numbering", json!({})); + assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: false }, "1. a. i. is not multilevel"); + // Legal numbering (1. 1.1. 1.1.1.) numbers levels together, even on its first level. (Its + // first level reads like "1." numbering, which the button would toggle off, so start plain.) + run(&mut s, "para.numbering", json!({"off": true})); + run(&mut s, "para.multilevel", json!({"kind": "legal"})); + run(&mut s, "para.listLevel", json!({"level": 0})); + assert_eq!(state(&s), ListButtons { bullets: false, numbering: true, multilevel: true }); + run(&mut s, "para.listLevel", json!({"level": 2})); + assert!(state(&s).multilevel); + run(&mut s, "para.numbering", json!({"off": true})); + assert_eq!(state(&s), ListButtons::default()); +} + #[test] fn replace_all_splits_paragraphs_at_a_paragraph_mark_in_the_replacement() { // Issue #423: untracked Replace All left `\r` in the paragraph instead of splitting it. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index b374376..50b8f8c 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -560,9 +560,10 @@ fn home(app: &mut WordApp, ui: &mut Ui) { // The buttons show alignment as seen on the page (Align Right is a right-to-left paragraph's start). let align = rp.as_ref().map(|r| r.align.visual(r.bidi)).unwrap_or_default(); let rtl = rp.as_ref().is_some_and(|r| r.bidi); + let lists = wordcraft_engine::cmd::para::list_buttons(&app.session.doc, rp.as_ref().and_then(|r| r.numbering)); stack(ui, |ui| { crate::widgets::row(ui, |ui| { - split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), false, None, |ui, app| { + split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), lists.bullets, None, |ui, app| { ui.label(egui::RichText::new(tl!("Bullet Library")).small().weak()); ui.horizontal(|ui| { for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] { @@ -583,7 +584,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { }); mi(ui, app, "None", "para.bullets", json!({"off": true})); }); - split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), false, None, |ui, app| { + split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), lists.numbering, None, |ui, app| { for (l, k) in [ ("1. 2. 3.", "numbered"), ("1) 2) 3)", "numberedParen"), @@ -598,7 +599,7 @@ fn home(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Restart at 1", "para.restartNumbering", json!({})); mi(ui, app, "None", "para.numbering", json!({"off": true})); }); - split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), false, None, |ui, app| { + split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), lists.multilevel, None, |ui, app| { mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"})); mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"})); ui.separator();