Merge branch 'list-buttons-hanging-indent' into control-panel-lists-shading

This commit is contained in:
moppeeer
2026-10-22 01:12:39 +02:00
3 changed files with 74 additions and 25 deletions
+63 -9
View File
@@ -85,22 +85,18 @@ fn apply_list(s: &mut Session, p: &Value, kind: ListType) -> Result<Value> {
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<Value> {
})
}
/// 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();
+3 -11
View File
@@ -2380,18 +2380,10 @@ 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.
// 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,
+8 -5
View File
@@ -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}));
}