mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 15:48:18 +00:00
Merge pull request #1490
Make layout/master placeholder formatting reach slides # Conflicts: # crates/engine/src/tests.rs
This commit is contained in:
commit
fb05eaa30f
5 files changed
+213
-16
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
Reference in new issue
Block a user