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 <noreply@anthropic.com>

* #651: list-button test starts legal numbering from plain text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cyberverse-cent0 2026-10-12 09:08:11 +00:00 committed by GitHub
parent 2f58d441b6
commit 6cf0aec0ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 62 additions and 3 deletions

View File

@ -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<NumRef>) -> 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 }
}

View File

@ -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.

View File

@ -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();