Merge pull request #1490

Make layout/master placeholder formatting reach slides

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-31 00:49:37 -04:00
5 changed files with 213 additions and 16 deletions
+1 -1
View File
@@ -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,
+13 -5
View File
@@ -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<Value> {
@@ -72,10 +72,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
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<Value> {
+1 -1
View File
@@ -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;
+123 -9
View File
@@ -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<Value> {
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<Value> {
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,6 +43,19 @@ 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;
// Effective run props of every level of the selected placeholders, resolved before editing.
let effective: Vec<(ShapeId, Vec<RunProps>)> = 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;
match &mut sh.kind {
@@ -47,7 +72,22 @@ 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);
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();
lf(&mut lvl.run, eff);
body.list_style.set(l, lvl);
}
}
}
}
Ok(())
@@ -58,6 +98,55 @@ 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;
/// 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 { .. })
}
/// 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<u8> =
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.
@@ -110,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<u8> = 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());
@@ -120,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();
}
@@ -128,8 +222,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 +243,12 @@ 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 {
format_list_level(body, l, f);
}
}
}
}
Ok(())
@@ -157,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<Value> {
let st = s.doc()?;
+75
View File
@@ -5976,3 +5976,78 @@ fn highlight_with_an_invalid_colour_is_rejected_and_keeps_the_highlight() {
s.execute("format.highlight", &json!({})).unwrap();
assert!(!has_highlight(&mut s), "an absent colour clears the highlight");
}
#[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}");
}
}
#[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<Option<f64>> {
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)]);
}