From 446bc528ad43864a89d68bd6b574f89e1d6cc194 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:45:57 +0000 Subject: [PATCH 1/2] Make layout/master placeholder formatting reach slides Fixes #1487 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text/formatting.rs | 32 ++++++++++++++++++++++++ crates/engine/src/tests.rs | 26 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs index 158af096..b58cb69e 100644 --- a/crates/engine/src/cmd/text/formatting.rs +++ b/crates/engine/src/cmd/text/formatting.rs @@ -31,8 +31,10 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } let cells = st.selection.cells; + let on_master = editing_master(st); edit_shapes(s, &json!({"ids": ids}), "format", |sh| { let id = sh.id; + let inherited = on_master && sh.ph.is_some(); match &mut sh.kind { ShapeKind::Table(t) => { // Only the selected cells, when a range of them is selected. @@ -48,6 +50,13 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy _ => { let body = sh.text.get_or_insert_with(TextBody::default); ed::format_all(body, f); + if inherited { + for l in 0..LIST_LEVELS { + let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); + f(&mut lvl.run); + body.list_style.set(l, lvl); + } + } } } Ok(()) @@ -58,6 +67,15 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy ok() } +/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it. +const LIST_LEVELS: u8 = 9; + +/// Formatting a placeholder on a layout or master (Slide Master view) also sets its list style, +/// which is what the placeholders on slides inherit; its own runs may be few or none. +fn editing_master(st: &DocState) -> bool { + matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. }) +} + /// What `format_run` formats while editing: the selection, or for a bare caret the word it's in /// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink /// edits this same span, so the dialog reads it too. @@ -128,8 +146,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } let cells = st.selection.cells; + let on_master = editing_master(st); edit_shapes(s, &json!({"ids": ids}), "format", |sh| { let id = sh.id; + let inherited = on_master && sh.ph.is_some(); match &mut sh.kind { ShapeKind::Table(t) => { let range = crate::cmd::table::selected_range(cells, id, t); @@ -147,6 +167,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex body.paragraphs.push(Default::default()); } body.paragraphs.iter_mut().for_each(f); + if inherited { + for l in 0..LIST_LEVELS { + let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); + let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() }; + f(&mut p); + // A level change (indent/outdent) means nothing for a list-style level. + if p.level == l { + lvl.para = p.props; + body.list_style.set(l, lvl); + } + } + } } } Ok(()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 85cbe9d8..5b3dcb03 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5956,3 +5956,29 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn master_layout_placeholder_format_inherits() { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + let title_of = |shapes: &[model::Shape]| shapes.iter().find(|sh| sh.ph.as_ref().is_some_and(|p| p.kind.is_title())).map(|sh| sh.id.0).unwrap(); + let existing = title_of(&s.doc().unwrap().doc.slides[0].shapes); + s.execute("view.slideMaster", &json!({"master": 0, "layout": 0})).unwrap(); + let layout_title = title_of(&s.doc().unwrap().doc.masters[0].layouts[0].shapes); + s.execute("edit.select", &json!({"ids": [layout_title]})).unwrap(); + s.execute("format.font", &json!({"family": "Montserrat"})).unwrap(); + s.execute("format.align", &json!({"align": "right"})).unwrap(); + let st = s.execute("format.state", &json!({})).unwrap(); + assert_eq!(st["fontRef"], "Montserrat", "layout placeholder: {st}"); + s.execute("view.closeMaster", &json!({})).unwrap(); + s.execute("slide.new", &json!({"layout": "title"})).unwrap(); + let slide = s.doc().unwrap().selection.slide; + let fresh = title_of(&s.doc().unwrap().doc.slides[slide].shapes); + for (index, id) in [(slide, fresh), (0, existing)] { + s.execute("slide.go", &json!({"index": index})).unwrap(); + s.execute("edit.select", &json!({"ids": [id]})).unwrap(); + let st = s.execute("format.state", &json!({})).unwrap(); + assert_eq!(st["fontRef"], "Montserrat", "slide placeholder {id}: {st}"); + assert_eq!(st["align"], "r", "slide placeholder {id}: {st}"); + } +} From adaa4e6fa1286794cf4cc0bd182f1e200ac76fb6 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:06:29 +0100 Subject: [PATCH 2/2] fix: keep per-level formatting when formatting layout/master placeholders Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/cmd/format/font.rs | 18 +++- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/cmd/text/formatting.rs | 126 +++++++++++++++++++---- crates/engine/src/tests.rs | 49 +++++++++ 5 files changed, 168 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 0f81cfaf..4e273dc1 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, use serde_json::{Value, json}; use std::sync::Arc; -use super::text::{format_body, format_para, format_run}; +use super::text::{format_body, format_para, format_run, format_run_levels}; use super::{ CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text, has_text_or_shapes, ids_param, ok, str_param, diff --git a/crates/engine/src/cmd/format/font.rs b/crates/engine/src/cmd/format/font.rs index 11af99b7..fa30e93c 100644 --- a/crates/engine/src/cmd/format/font.rs +++ b/crates/engine/src/cmd/format/font.rs @@ -1,6 +1,6 @@ use super::{ Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or, - bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param, + bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param, }; pub(super) fn bold(s: &mut Session, p: &Value) -> Result { @@ -72,10 +72,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result { let ctx_sizes = current_run(s).size.unwrap_or(18.0); let _ = st; let base = ctx_sizes; - format_run(s, &move |r| { - let cur = r.size.unwrap_or(base); - r.size = Some(step(cur, up)); - }) + // A layout/master level steps from its own effective size, not the first run's. + format_run_levels( + s, + &move |r| { + let cur = r.size.unwrap_or(base); + r.size = Some(step(cur, up)); + }, + &move |r, eff| { + let cur = r.size.or(eff.size).unwrap_or(18.0); + r.size = Some(step(cur, up)); + }, + ) } pub(super) fn grow(s: &mut Session, _p: &Value) -> Result { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 6f85df87..d095d7fa 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -30,7 +30,7 @@ use editing::{edit, exit}; pub(crate) use editing::{insert_field, insert_text, paste_body}; pub use formatting::format_range; use formatting::{fonts_used, replace_font}; -pub(crate) use formatting::{format_body, format_para, format_run, link_text}; +pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text}; pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines}; use ime::{commit, compose}; pub(crate) use layout::ctx_for; diff --git a/crates/engine/src/cmd/text/formatting.rs b/crates/engine/src/cmd/text/formatting.rs index b58cb69e..d942ee28 100644 --- a/crates/engine/src/cmd/text/formatting.rs +++ b/crates/engine/src/cmd/text/formatting.rs @@ -8,22 +8,34 @@ use super::{ /// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour), /// or sets the formatting for what is typed next at a word boundary. pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result { + format_run_levels(s, f, &|r, _| f(r)) +} + +/// Like [`format_run`], with a separate change for the list-style levels of a layout or master +/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at +/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value. +pub(crate) fn format_run_levels( + s: &mut Session, + f: &(dyn Fn(&mut RunProps) + Send + Sync), + lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), +) -> Result { let st = s.doc()?; if let Some(t) = st.selection.text.clone() { let (a, b) = format_range(st, &t); - let collapsed = a == b; + // Only the levels of the paragraphs the selected range touches. + let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) }; edit_text(s, |body, _| { - if collapsed { - // Next typed text: put the props on an empty paragraph's end mark or keep pending. + if levels.is_empty() { ed::format(body, a, b, f); } else { - ed::format(body, a, b, f); + format_by_level(body, a, b, lf, &levels); + } + for (l, eff) in &levels { + let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default(); + lf(&mut lvl.run, eff); + body.list_style.set(*l, lvl); } })?; - if collapsed { - // Remember as pending typing props by inserting nothing: handled via end mark for - // empty paragraphs; mid-paragraph carets keep surrounding formatting. - } return ok(); } let ids = st.selection.shapes.clone(); @@ -31,10 +43,21 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy return Err(bad("format", SELECT_TEXT_OR_SHAPE)); } let cells = st.selection.cells; - let on_master = editing_master(st); + // Effective run props of every level of the selected placeholders, resolved before editing. + let effective: Vec<(ShapeId, Vec)> = if editing_master(st) { + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection); + ids.iter() + .filter_map(|id| { + let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?; + let ctx = ctx.as_ref()?; + Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect())) + }) + .collect() + } else { + Vec::new() + }; edit_shapes(s, &json!({"ids": ids}), "format", |sh| { let id = sh.id; - let inherited = on_master && sh.ph.is_some(); match &mut sh.kind { ShapeKind::Table(t) => { // Only the selected cells, when a range of them is selected. @@ -49,11 +72,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy } _ => { let body = sh.text.get_or_insert_with(TextBody::default); - ed::format_all(body, f); - if inherited { - for l in 0..LIST_LEVELS { + let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e); + match eff { + Some(eff) => { + let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect(); + format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels); + } + None => ed::format_all(body, f), + } + // The whole shape is selected: every level of its list style. + if let Some(eff) = eff { + for (l, eff) in (0..LIST_LEVELS).zip(eff) { let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); - f(&mut lvl.run); + lf(&mut lvl.run, eff); body.list_style.set(l, lvl); } } @@ -67,6 +98,25 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy ok() } +/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so +/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared +/// value. `a`/`b` are clamped to the body. +fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) { + let Some(last) = body.paragraphs.len().checked_sub(1) else { return }; + let (first, end) = (a.0.min(last), b.0.min(last)); + for pi in first..=end { + let p = &body.paragraphs[pi]; + let from = if pi == first { a.1.min(p.char_len()) } else { 0 }; + let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() }; + if from == to && p.char_len() > 0 { + continue; + } + let lvl = p.level.min(LIST_LEVELS - 1); + let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue }; + ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff)); + } +} + /// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it. const LIST_LEVELS: u8 = 9; @@ -76,6 +126,27 @@ fn editing_master(st: &DocState) -> bool { matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. }) } +/// The distinct list-style levels of the paragraphs `range` touches in a layout or master +/// placeholder, with each level's effective run props. Empty anywhere else. +fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> { + if !editing_master(st) || t.notes || t.cell.is_some() { + return Vec::new(); + } + let (Some(sh), Some(body), Some(ctx)) = + (st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection)) + else { + return Vec::new(); + }; + let mut levels: Vec = + body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect(); + if body.paragraphs.is_empty() { + levels.push(0); + } + levels.sort_unstable(); + levels.dedup(); + levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect() +} + /// What `format_run` formats while editing: the selection, or for a bare caret the word it's in /// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink /// edits this same span, so the dialog reads it too. @@ -128,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex let st = s.doc()?; if let Some(t) = st.selection.text.clone() { let (a, b) = t.ordered(); + // Only the list-style levels of the paragraphs touched, on a layout or master placeholder. + let levels: Vec = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect(); edit_text(s, |body, _| { if body.paragraphs.is_empty() { body.paragraphs.push(Default::default()); @@ -138,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex f(p); } } + for l in levels { + format_list_level(body, l, f); + } })?; return ok(); } @@ -167,16 +243,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex body.paragraphs.push(Default::default()); } body.paragraphs.iter_mut().for_each(f); + // The whole shape is selected: every level of its list style. if inherited { for l in 0..LIST_LEVELS { - let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); - let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() }; - f(&mut p); - // A level change (indent/outdent) means nothing for a list-style level. - if p.level == l { - lvl.para = p.props; - body.list_style.set(l, lvl); - } + format_list_level(body, l, f); } } } @@ -189,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex ok() } +/// Apply a paragraph change to one level of a body's list style. +fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) { + let mut lvl = body.list_style.level(l).cloned().unwrap_or_default(); + let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() }; + f(&mut p); + // A level change (indent/outdent) means nothing for a list-style level. + if p.level == l { + lvl.para = p.props; + body.list_style.set(l, lvl); + } +} + /// Apply body (text box) changes to the edited or selected shapes. pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result { let st = s.doc()?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5b3dcb03..b3276046 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5982,3 +5982,52 @@ fn master_layout_placeholder_format_inherits() { assert_eq!(st["align"], "r", "slide placeholder {id}: {st}"); } } + +#[test] +fn master_body_placeholder_format_keeps_per_level_formatting() { + let mut s = Session::new(); + sample::open_sample(&mut s).unwrap(); + s.execute("view.slideMaster", &json!({"master": 0})).unwrap(); + let body_idx = + s.doc().unwrap().doc.masters[0].shapes.iter().position(|sh| sh.ph.as_ref().is_some_and(|p| p.kind == model::PhType::Body)).unwrap(); + let id = { + let st = s.doc_mut().unwrap(); + let sh = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).masters[0]).shapes[body_idx]; + let mut body = model::TextBody::from_text("Alpha\nBeta"); + body.paragraphs[1].level = 1; + for (l, size) in [(0u8, 32.0), (1, 28.0)] { + let mut lvl = model::text::LevelStyle::default(); + lvl.run.size = Some(size); + body.list_style.set(l, lvl); + } + sh.text = Some(body); + sh.id + }; + let level = + |s: &Session, l: u8| s.doc().unwrap().doc.masters[0].shapes[body_idx].text.as_ref().unwrap().list_style.level(l).cloned().unwrap_or_default(); + // Bolding the level-2 paragraph reaches level 2 only. + s.doc_mut().unwrap().selection.shapes = vec![id]; + s.doc_mut().unwrap().selection.text = Some(TextSel { shape: id, anchor: (1, 0), caret: (1, 4), cell: None, notes: false, line_end: None }); + s.execute("format.bold", &json!({"on": true})).unwrap(); + assert_eq!(level(&s, 1).run.bold, Some(true)); + for l in [0, 2, 3, 8] { + assert_eq!(level(&s, l).run.bold, None, "level {}", l + 1); + } + // Grow and shrink step each level from its own size. + s.doc_mut().unwrap().selection.text = None; + // The text itself: a run's explicit size, else its level's effective size. + let shown = |s: &Session| -> Vec> { + let st = s.doc().unwrap(); + let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap(); + let sh = &st.doc.masters[0].shapes[body_idx]; + let body = sh.text.as_ref().unwrap(); + body.paragraphs.iter().map(|p| p.runs[0].props.size.or(deckcraft_model::resolve::level_style(&ctx, sh, p.level).run.size)).collect() + }; + s.execute("format.grow", &json!({})).unwrap(); + assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(36.0), Some(32.0))); + assert_eq!(shown(&s), vec![Some(36.0), Some(32.0)]); + s.execute("format.shrink", &json!({})).unwrap(); + s.execute("format.shrink", &json!({})).unwrap(); + assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0))); + assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]); +}