mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
UI: Size and Position dialog sets geometry in one step
Shape Format › Size and the shape menu open a dialog for position, size, rotation, scale and lock aspect ratio. One command writes them together, so undo restores the whole change. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Size and Position dialog; breadth stays 79%, ready stays ~55%; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-14 | Size and Position dialog: height, width, rotation, scale and lock aspect ratio in one undo step, from Shape Format › Size and the shape menu. Breadth stays 79% |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | Progress log: Size and Position dialog. Breadth stays 79%, ready stays ~55% |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
|
||||
@@ -34,6 +34,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
|
||||
cmd!("shape.rotate", "Rotation", ["Shape Format", "Arrange", "Rotate"], None, "{deg?: absolute, by?: relative, ids?}", has_selection, rotate),
|
||||
cmd!(
|
||||
"shape.sizePosition",
|
||||
"Size and Position…",
|
||||
["Shape Format", "Size"],
|
||||
None,
|
||||
"{x?, y?: pt, w?, h?: pt, rot?: deg, scaleW?, scaleH?: percent of the current size (100 = unchanged), lockAspect?: bool, ids?} — with none of those keys, opens the dialog",
|
||||
has_selection,
|
||||
size_position
|
||||
),
|
||||
cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip),
|
||||
cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust),
|
||||
cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change),
|
||||
@@ -173,6 +182,159 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
const SIZE_POS: &str = "shape.sizePosition";
|
||||
/// Smallest and largest side the dialog will write, in points (220 in).
|
||||
const MIN_PT: f64 = 0.01;
|
||||
const MAX_PT: f64 = 15_840.0;
|
||||
const POS_LIMIT: f64 = 100_000.0;
|
||||
const MAX_SCALE: f64 = 10_000.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SizeSpec {
|
||||
x: Option<f64>,
|
||||
y: Option<f64>,
|
||||
w: Option<f64>,
|
||||
h: Option<f64>,
|
||||
rot: Option<f64>,
|
||||
scale_w: Option<f64>,
|
||||
scale_h: Option<f64>,
|
||||
lock: bool,
|
||||
}
|
||||
|
||||
impl SizeSpec {
|
||||
fn width_set(self) -> bool {
|
||||
self.w.is_some() || self.scale_w.is_some()
|
||||
}
|
||||
fn height_set(self) -> bool {
|
||||
self.h.is_some() || self.scale_h.is_some()
|
||||
}
|
||||
fn any(self) -> bool {
|
||||
self.x.is_some() || self.y.is_some() || self.width_set() || self.height_set() || self.rot.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_key(p: &Value, key: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v.as_f64() {
|
||||
Some(n) if n.is_finite() => Ok(Some(n)),
|
||||
_ => Err(bad(SIZE_POS, format!("`{key}` must be a finite number"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(v: Option<f64>, key: &str, lo: f64, hi: f64) -> Result<Option<f64>> {
|
||||
if let Some(n) = v
|
||||
&& !(lo..=hi).contains(&n)
|
||||
{
|
||||
return Err(bad(SIZE_POS, format!("`{key}` is out of range")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn size_spec(p: &Value) -> Result<SizeSpec> {
|
||||
let lock = match p.get("lockAspect") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(SIZE_POS, "`lockAspect` must be true or false")),
|
||||
};
|
||||
Ok(SizeSpec {
|
||||
x: in_range(finite_key(p, "x")?, "x", -POS_LIMIT, POS_LIMIT)?,
|
||||
y: in_range(finite_key(p, "y")?, "y", -POS_LIMIT, POS_LIMIT)?,
|
||||
w: in_range(finite_key(p, "w")?, "w", MIN_PT, MAX_PT)?,
|
||||
h: in_range(finite_key(p, "h")?, "h", MIN_PT, MAX_PT)?,
|
||||
rot: finite_key(p, "rot")?,
|
||||
scale_w: in_range(finite_key(p, "scaleW")?, "scaleW", f64::MIN_POSITIVE, MAX_SCALE)?,
|
||||
scale_h: in_range(finite_key(p, "scaleH")?, "scaleH", f64::MIN_POSITIVE, MAX_SCALE)?,
|
||||
lock,
|
||||
})
|
||||
}
|
||||
|
||||
fn norm_deg(r: f64) -> f64 {
|
||||
let r = r % 360.0;
|
||||
if r < 0.0 { r + 360.0 } else { r }
|
||||
}
|
||||
|
||||
/// Width wins when both sides are set with the aspect locked, matching `shape.resize`.
|
||||
fn apply_size(x: &mut deckcraft_geom::Xfrm, spec: SizeSpec) {
|
||||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||||
let mut nw = x.w;
|
||||
let mut nh = x.h;
|
||||
if let Some(p) = spec.scale_w {
|
||||
nw = x.w * p / 100.0;
|
||||
}
|
||||
if let Some(p) = spec.scale_h {
|
||||
nh = x.h * p / 100.0;
|
||||
}
|
||||
if let Some(w) = spec.w {
|
||||
nw = w;
|
||||
}
|
||||
if let Some(h) = spec.h {
|
||||
nh = h;
|
||||
}
|
||||
if spec.lock && (spec.width_set() || spec.height_set()) {
|
||||
if spec.width_set() {
|
||||
nh = if ar > 0.0 { nw / ar } else { nh };
|
||||
} else {
|
||||
nw = nh * ar;
|
||||
}
|
||||
}
|
||||
x.w = nw.clamp(MIN_PT, MAX_PT);
|
||||
x.h = nh.clamp(MIN_PT, MAX_PT);
|
||||
if let Some(px) = spec.x {
|
||||
x.x = px;
|
||||
}
|
||||
if let Some(py) = spec.y {
|
||||
x.y = py;
|
||||
}
|
||||
if let Some(r) = spec.rot {
|
||||
x.rot = norm_deg(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn size_position(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let spec = size_spec(p)?;
|
||||
if !spec.any() {
|
||||
let (x, y, w, h, rot) = {
|
||||
let st = s.doc()?;
|
||||
let id = targets(s, p)?.first().copied().ok_or_else(|| bad(SIZE_POS, "nothing selected"))?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad(SIZE_POS, "no such shape"))?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
(x.x, x.y, x.w, x.h, x.rot)
|
||||
};
|
||||
s.ui_requests.push(crate::UiRequest::Dialog {
|
||||
id: "sizePosition".into(),
|
||||
params: json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "scaleW": 100.0, "scaleH": 100.0, "lockAspect": false}),
|
||||
});
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let changed = {
|
||||
let ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let free: Vec<_> = ids.iter().copied().filter(|id| !st.shape(*id).is_some_and(|sh| sh.locked)).collect::<Vec<_>>();
|
||||
if !ids.is_empty() && free.is_empty() {
|
||||
return Err(bad(SIZE_POS, "the object is locked"));
|
||||
}
|
||||
free.iter().any(|id| {
|
||||
st.shape(*id).is_some_and(|sh| {
|
||||
let mut x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let before = x;
|
||||
apply_size(&mut x, spec);
|
||||
x != before
|
||||
})
|
||||
})
|
||||
};
|
||||
if !changed {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
edit_geometry(s, p, SIZE_POS, |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
apply_size(x, spec);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||||
@@ -860,3 +1022,126 @@ fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod size_position_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn deck() -> (Session, u32) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn geom(s: &mut Session, id: u32) -> (f64, f64, f64, f64, f64) {
|
||||
let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();
|
||||
let b = &v["box"];
|
||||
(b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["w"].as_f64().unwrap(), b["h"].as_f64().unwrap(), b["rot"].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn undos(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sets_position_size_and_rotation_in_one_undo() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({"x": 80.0, "y": 90.0, "w": 240.0, "h": 60.0, "rot": 370.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (80.0, 90.0, 240.0, 60.0, 10.0));
|
||||
assert_eq!(undos(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_aspect_and_scale_share_one_step() {
|
||||
let (mut s, id) = deck();
|
||||
s.execute("shape.sizePosition", &json!({"w": 300.0, "lockAspect": true})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 300.0, 150.0, 0.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.sizePosition", &json!({"h": 50.0, "lockAspect": true})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.sizePosition", &json!({"scaleW": 25.0, "scaleH": 150.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 50.0, 150.0, 0.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.sizePosition", &json!({"scaleH": 50.0, "lockAspect": true})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));
|
||||
// An explicit width wins over a scale on the same axis.
|
||||
s.execute("shape.sizePosition", &json!({"w": 80.0, "scaleW": 50.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id).2, 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_fields_leave_the_rest_and_a_repeat_does_not_undo() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({"y": 12.0, "rot": -90.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 12.0, 200.0, 100.0, 270.0));
|
||||
assert_eq!(undos(&s), n + 1);
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({"x": 40.0, "y": 12.0, "w": 200.0, "h": 100.0, "rot": 270.0})).unwrap();
|
||||
assert_eq!(undos(&s), n, "identical geometry is not an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fields_opens_the_dialog_without_editing() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
s.execute("shape.sizePosition", &json!({})).unwrap();
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||||
assert_eq!(undos(&s), n);
|
||||
let req = s.ui_requests.last().unwrap();
|
||||
match req {
|
||||
crate::UiRequest::Dialog { id, params } => {
|
||||
assert_eq!(id, "sizePosition");
|
||||
assert_eq!(params["w"], 200.0);
|
||||
assert_eq!(params["h"], 100.0);
|
||||
assert_eq!(params["x"], 40.0);
|
||||
assert_eq!(params["rot"], 0.0);
|
||||
assert_eq!(params["scaleW"], 100.0);
|
||||
assert_eq!(params["lockAspect"], false);
|
||||
}
|
||||
other => panic!("expected a dialog, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_fields_do_not_edit() {
|
||||
let (mut s, id) = deck();
|
||||
let n = undos(&s);
|
||||
for p in [
|
||||
json!({"w": "wide"}),
|
||||
json!({"h": -5.0}),
|
||||
json!({"scaleW": 0.0}),
|
||||
json!({"x": 1e9}),
|
||||
json!({"rot": "spin"}),
|
||||
json!({"lockAspect": "yes"}),
|
||||
] {
|
||||
let err = s.execute("shape.sizePosition", &p).unwrap_err();
|
||||
assert!(err.to_string().contains("sizePosition"), "{err}");
|
||||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||||
assert_eq!(undos(&s), n);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_is_per_shape_and_nothing_selected_is_disabled() {
|
||||
let (mut s, a) = deck();
|
||||
let b = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||||
s.execute("shape.sizePosition", &json!({"ids": [a, b], "scaleW": 200.0})).unwrap();
|
||||
assert_eq!(geom(&mut s, a).2, 400.0);
|
||||
assert_eq!(geom(&mut s, a).3, 100.0);
|
||||
assert_eq!(geom(&mut s, b).2, 80.0);
|
||||
assert_eq!(geom(&mut s, b).3, 80.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");
|
||||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||||
let err = s.execute("shape.sizePosition", &json!({})).unwrap_err();
|
||||
assert!(err.to_string().contains("nothing selected"), "{err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -664,6 +664,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
let _ = app.run("shape.setDefault", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Size and Position…").clicked() {
|
||||
let _ = app.run("shape.sizePosition", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Format Shape…").clicked() {
|
||||
let _ = app.run("view.pane", json!({"pane": "format"}));
|
||||
ui.close();
|
||||
|
||||
@@ -32,6 +32,7 @@ fn title(id: &str) -> &'static str {
|
||||
match id {
|
||||
"table" => "Insert Table",
|
||||
"slideSize" => "Slide Size",
|
||||
"sizePosition" => "Size and Position",
|
||||
"headerFooter" => "Header and Footer",
|
||||
"hyperlink" => "Insert Hyperlink",
|
||||
"setupShow" => "Set Up Slide Show",
|
||||
@@ -156,6 +157,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"sizePosition" => size_position_dialog(app, ui, d),
|
||||
"headerFooter" => {
|
||||
let hf = app.session.active().map(|s| s.doc.header_footer.clone()).unwrap_or_default();
|
||||
let date = check(ui, d, "Date and time", "date", hf.date);
|
||||
@@ -738,6 +740,245 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn num_text(v: f64) -> String {
|
||||
crate::widgets::fmt_num(v, "")
|
||||
}
|
||||
|
||||
fn field_of(d: &Dialog, key: &str) -> String {
|
||||
d.fields.get(key).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parse_shown(s: &str) -> Option<f64> {
|
||||
let t = s.trim().trim_end_matches(['%', '"']).trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
t.parse().ok().filter(|v: &f64| v.is_finite())
|
||||
}
|
||||
|
||||
fn put_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) {
|
||||
d.fields.insert(key.into(), shown.clone());
|
||||
d.fields.insert(format!("seed{key}"), shown.clone());
|
||||
d.fields.insert(format!("prev{key}"), shown);
|
||||
d.fields.insert(format!("{key}Pt"), exact.to_string());
|
||||
}
|
||||
|
||||
fn publish_dim(d: &mut Dialog, key: &str, exact: f64, shown: String) {
|
||||
d.fields.insert(key.into(), shown.clone());
|
||||
d.fields.insert(format!("seed{key}"), shown);
|
||||
d.fields.insert(format!("{key}Pt"), exact.to_string());
|
||||
}
|
||||
|
||||
fn edited(d: &Dialog, key: &str) -> bool {
|
||||
field_of(d, key) != field_of(d, &format!("seed{key}"))
|
||||
}
|
||||
|
||||
fn exact_of(d: &Dialog, key: &str) -> Option<f64> {
|
||||
parse_shown(&field_of(d, &format!("{key}Pt")))
|
||||
}
|
||||
|
||||
fn selection_xfrm(app: &SlideApp) -> Option<deckcraft_geom::Xfrm> {
|
||||
let st = app.session.active()?;
|
||||
let id = *st.selection.shapes.first()?;
|
||||
let sh = st.shape(id)?;
|
||||
Some(deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh))
|
||||
}
|
||||
|
||||
fn seed_size_position(app: &SlideApp, d: &mut Dialog) {
|
||||
if d.fields.contains_key("baseW") {
|
||||
return;
|
||||
}
|
||||
let num = |k: &str| d.params.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
let sel = selection_xfrm(app);
|
||||
let x = num("x").or_else(|| sel.map(|b| b.x)).unwrap_or(0.0);
|
||||
let y = num("y").or_else(|| sel.map(|b| b.y)).unwrap_or(0.0);
|
||||
let w = num("w").or_else(|| sel.map(|b| b.w)).unwrap_or(72.0);
|
||||
let h = num("h").or_else(|| sel.map(|b| b.h)).unwrap_or(72.0);
|
||||
let rot = num("rot").or_else(|| sel.map(|b| b.rot)).unwrap_or(0.0);
|
||||
let sw = num("scaleW").unwrap_or(100.0);
|
||||
let sh = num("scaleH").unwrap_or(100.0);
|
||||
let lock = d.params.get("lockAspect").and_then(Value::as_bool).unwrap_or(false);
|
||||
put_dim(d, "x", x, num_text(x / 72.0));
|
||||
put_dim(d, "y", y, num_text(y / 72.0));
|
||||
put_dim(d, "w", w, num_text(w / 72.0));
|
||||
put_dim(d, "h", h, num_text(h / 72.0));
|
||||
put_dim(d, "rot", rot, num_text(rot));
|
||||
put_dim(d, "scaleW", sw, num_text(sw));
|
||||
put_dim(d, "scaleH", sh, num_text(sh));
|
||||
d.fields.insert("baseW".into(), w.to_string());
|
||||
d.fields.insert("baseH".into(), h.to_string());
|
||||
let bit = if lock { "1" } else { "0" };
|
||||
d.fields.insert("lock".into(), bit.into());
|
||||
d.fields.insert("prevlock".into(), bit.into());
|
||||
}
|
||||
|
||||
fn scale_of(pt: f64, base: f64) -> f64 {
|
||||
if base > 0.0 { pt / base * 100.0 } else { 100.0 }
|
||||
}
|
||||
|
||||
fn current_points(d: &Dialog, key: &str) -> Option<f64> {
|
||||
if edited(d, key) { parse_shown(&field_of(d, key)).map(|inches| inches * 72.0) } else { exact_of(d, key) }
|
||||
}
|
||||
|
||||
/// Keep scale tied to size, and the other side tied when aspect is locked.
|
||||
fn reconcile_size_fields(d: &mut Dialog) {
|
||||
let lock = field_of(d, "lock") == "1";
|
||||
let was_locked = field_of(d, "prevlock") == "1";
|
||||
let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0);
|
||||
let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0);
|
||||
let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 };
|
||||
let (w_s, h_s, sw_s, sh_s) = (field_of(d, "w"), field_of(d, "h"), field_of(d, "scaleW"), field_of(d, "scaleH"));
|
||||
let (prev_w, prev_h, prev_sw, prev_sh) = (field_of(d, "prevw"), field_of(d, "prevh"), field_of(d, "prevscaleW"), field_of(d, "prevscaleH"));
|
||||
if w_s != prev_w {
|
||||
if let Some(inches) = parse_shown(&w_s) {
|
||||
let w_pt = inches * 72.0;
|
||||
d.fields.insert("wPt".into(), w_pt.to_string());
|
||||
let pct = scale_of(w_pt, base_w);
|
||||
publish_dim(d, "scaleW", pct, num_text(pct));
|
||||
if lock {
|
||||
let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt };
|
||||
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
|
||||
let hp = scale_of(h_pt, base_h);
|
||||
publish_dim(d, "scaleH", hp, num_text(hp));
|
||||
}
|
||||
}
|
||||
} else if h_s != prev_h {
|
||||
if let Some(inches) = parse_shown(&h_s) {
|
||||
let h_pt = inches * 72.0;
|
||||
d.fields.insert("hPt".into(), h_pt.to_string());
|
||||
let pct = scale_of(h_pt, base_h);
|
||||
publish_dim(d, "scaleH", pct, num_text(pct));
|
||||
if lock {
|
||||
let w_pt = h_pt * ar;
|
||||
publish_dim(d, "w", w_pt, num_text(w_pt / 72.0));
|
||||
let wp = scale_of(w_pt, base_w);
|
||||
publish_dim(d, "scaleW", wp, num_text(wp));
|
||||
}
|
||||
}
|
||||
} else if sw_s != prev_sw {
|
||||
if let Some(pct) = parse_shown(&sw_s) {
|
||||
let w_pt = base_w * pct / 100.0;
|
||||
d.fields.insert("scaleWPt".into(), pct.to_string());
|
||||
publish_dim(d, "w", w_pt, num_text(w_pt / 72.0));
|
||||
if lock {
|
||||
let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt };
|
||||
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
|
||||
publish_dim(d, "scaleH", pct, num_text(pct));
|
||||
}
|
||||
}
|
||||
} else if sh_s != prev_sh {
|
||||
if let Some(pct) = parse_shown(&sh_s) {
|
||||
let h_pt = base_h * pct / 100.0;
|
||||
d.fields.insert("scaleHPt".into(), pct.to_string());
|
||||
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
|
||||
if lock {
|
||||
let w_pt = h_pt * ar;
|
||||
publish_dim(d, "w", w_pt, num_text(w_pt / 72.0));
|
||||
publish_dim(d, "scaleW", pct, num_text(pct));
|
||||
}
|
||||
}
|
||||
} else if lock
|
||||
&& !was_locked
|
||||
&& let Some(w_pt) = current_points(d, "w")
|
||||
{
|
||||
let h_pt = if ar > 0.0 { w_pt / ar } else { w_pt };
|
||||
publish_dim(d, "h", h_pt, num_text(h_pt / 72.0));
|
||||
let hp = scale_of(h_pt, base_h);
|
||||
publish_dim(d, "scaleH", hp, num_text(hp));
|
||||
let wp = scale_of(w_pt, base_w);
|
||||
publish_dim(d, "scaleW", wp, num_text(wp));
|
||||
}
|
||||
for key in ["x", "y", "w", "h", "rot", "scaleW", "scaleH"] {
|
||||
let v = field_of(d, key);
|
||||
d.fields.insert(format!("prev{key}"), v);
|
||||
}
|
||||
d.fields.insert("prevlock".into(), if lock { "1".into() } else { "0".into() });
|
||||
}
|
||||
|
||||
fn shown_points(d: &Dialog, key: &str, label: &str) -> Result<f64, String> {
|
||||
if edited(d, key) {
|
||||
let n = parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"))?;
|
||||
return Ok(n * 72.0);
|
||||
}
|
||||
exact_of(d, key).ok_or_else(|| format!("{label} must be a number"))
|
||||
}
|
||||
|
||||
fn shown_number(d: &Dialog, key: &str, label: &str) -> Result<f64, String> {
|
||||
if edited(d, key) {
|
||||
return parse_shown(&field_of(d, key)).ok_or_else(|| format!("{label} must be a number"));
|
||||
}
|
||||
exact_of(d, key).ok_or_else(|| format!("{label} must be a number"))
|
||||
}
|
||||
|
||||
fn size_position_value(d: &Dialog) -> Result<Value, String> {
|
||||
let x = shown_points(d, "x", "Horizontal")?;
|
||||
let y = shown_points(d, "y", "Vertical")?;
|
||||
let rot = shown_number(d, "rot", "Rotation")?;
|
||||
let base_w = parse_shown(&field_of(d, "baseW")).unwrap_or(1.0);
|
||||
let base_h = parse_shown(&field_of(d, "baseH")).unwrap_or(1.0);
|
||||
let mut w = if edited(d, "w") {
|
||||
shown_points(d, "w", "Width")?
|
||||
} else if edited(d, "scaleW") {
|
||||
base_w * shown_number(d, "scaleW", "Scale width")? / 100.0
|
||||
} else {
|
||||
shown_points(d, "w", "Width")?
|
||||
};
|
||||
let mut h = if edited(d, "h") {
|
||||
shown_points(d, "h", "Height")?
|
||||
} else if edited(d, "scaleH") {
|
||||
base_h * shown_number(d, "scaleH", "Scale height")? / 100.0
|
||||
} else {
|
||||
shown_points(d, "h", "Height")?
|
||||
};
|
||||
if field_of(d, "lock") == "1" {
|
||||
let ar = if base_h > 0.0 { base_w / base_h } else { 1.0 };
|
||||
let width_drives = edited(d, "w") || edited(d, "scaleW") || (!edited(d, "h") && !edited(d, "scaleH"));
|
||||
if width_drives {
|
||||
h = if ar > 0.0 { w / ar } else { h };
|
||||
} else {
|
||||
w = h * ar;
|
||||
}
|
||||
}
|
||||
Ok(json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "lockAspect": field_of(d, "lock") == "1"}))
|
||||
}
|
||||
|
||||
fn text_row(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str) {
|
||||
let mut v = field_of(d, key);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(148.0, 20.0), egui::Label::new(label));
|
||||
ui.add(egui::TextEdit::singleline(&mut v).desired_width(96.0));
|
||||
});
|
||||
d.fields.insert(key.into(), v);
|
||||
}
|
||||
|
||||
fn size_position_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
seed_size_position(app, d);
|
||||
ui.label(egui::RichText::new("Size").strong());
|
||||
text_row(ui, d, "Height (in):", "h");
|
||||
text_row(ui, d, "Width (in):", "w");
|
||||
text_row(ui, d, "Rotation (°):", "rot");
|
||||
text_row(ui, d, "Scale height (%):", "scaleH");
|
||||
text_row(ui, d, "Scale width (%):", "scaleW");
|
||||
check(ui, d, "Lock aspect ratio", "lock", false);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new("Position").strong());
|
||||
ui.label("From the top-left corner of the slide");
|
||||
text_row(ui, d, "Horizontal (in):", "x");
|
||||
text_row(ui, d, "Vertical (in):", "y");
|
||||
reconcile_size_fields(d);
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
match size_position_value(d) {
|
||||
Ok(p) => return app.run("shape.sizePosition", p).is_ok(),
|
||||
Err(msg) => {
|
||||
app.set_status(msg);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel
|
||||
}
|
||||
|
||||
fn pretty_equation(s: &str) -> String {
|
||||
let sup = |c: char| match c {
|
||||
'0' => '⁰',
|
||||
@@ -829,3 +1070,7 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) {
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "size_position_tests.rs"]
|
||||
mod size_position_tests;
|
||||
|
||||
@@ -231,6 +231,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
("Align Right", "format.alignRight"),
|
||||
("Justify", "format.justify"),
|
||||
("-", ""),
|
||||
("Size and Position…", "shape.sizePosition"),
|
||||
("Right-to-Left Text Direction", "format.rtl"),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2044,6 +2044,9 @@ fn size_group(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() {
|
||||
run(app, "shape.sizePosition", json!({}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Size and Position dialog: the Shape Format launcher opens it, and OK applies one undo step.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(deckcraft_engine::Session::with_new(), Services::default()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn box_of(app: &SlideApp, id: u64) -> (f64, f64, f64, f64, f64) {
|
||||
let st = app.session.active().unwrap();
|
||||
let x = st.shape(deckcraft_model::ShapeId(id as u32)).and_then(|s| s.xfrm).unwrap();
|
||||
(x.x, x.y, x.w, x.h, x.rot)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_button_sets_width_in_one_undo() {
|
||||
let mut h = harness();
|
||||
let id = h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.state_mut().ui.tab = "Shape Format".into();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Size and Position…").click();
|
||||
h.run_steps(2);
|
||||
let dlg = h.state().dialog.as_ref().unwrap();
|
||||
assert_eq!(dlg.id, "sizePosition");
|
||||
assert_eq!(dlg.fields.get("w").map(String::as_str), Some("2.78"));
|
||||
assert_eq!(dlg.fields.get("h").map(String::as_str), Some("1.39"));
|
||||
assert_eq!(dlg.fields.get("lock").map(String::as_str), Some("0"));
|
||||
h.state_mut().dialog.as_mut().unwrap().fields.insert("w".into(), "4".into());
|
||||
h.run_steps(1);
|
||||
h.get_by_label("OK").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(box_of(h.state(), id), (40.0, 50.0, 288.0, 100.0, 0.0));
|
||||
let undo = &h.state().session.active().unwrap().history.undo;
|
||||
assert_eq!(undo.last().unwrap().label, "Size and Position…");
|
||||
let n = undo.len();
|
||||
h.state_mut().session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(box_of(h.state(), id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||||
assert_eq!(h.state().session.active().unwrap().history.undo.len(), n - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_aspect_from_the_dialog_scales_height() {
|
||||
let mut h = harness();
|
||||
let id = h.state_mut().session.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.state_mut().run("shape.sizePosition", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().id, "sizePosition");
|
||||
{
|
||||
let fields = &mut h.state_mut().dialog.as_mut().unwrap().fields;
|
||||
fields.insert("lock".into(), "1".into());
|
||||
fields.insert("w".into(), "4".into());
|
||||
}
|
||||
h.get_by_label("OK").click_accesskit();
|
||||
h.run_steps(2);
|
||||
assert_eq!(box_of(h.state(), id), (40.0, 50.0, 288.0, 144.0, 0.0));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# DeckCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (transition frames: perspective meshes, slide parts, headless compositor; previous: created from the code on main at d556ff9) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Size and Position dialog, 227 commands, 28 dialogs; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never
|
||||
break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what
|
||||
@@ -20,8 +20,8 @@ nothing below L6 uses egui, eframe, winit or rfd.
|
||||
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
|
||||
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
|
||||
| L4 | `pdf` | PDF export through krilla |
|
||||
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (226 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 27 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (227 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 28 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
|
||||
|
||||
The media codecs and containers are clean-room pure Rust, 59k lines, shared in spirit with
|
||||
@@ -78,5 +78,6 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs |
|
||||
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
|
||||
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Size and Position dialog; previous: minor, 3-D bevel, camera and face lighting) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -203,8 +203,8 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
|
||||
2–4 h.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
|
||||
- **Dialogs**: 27 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Replace Fonts, Action Settings, Print, Insert Object…). 8–14 h.
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Replace Fonts, Action Settings, Print, Insert Object…). Size and Position is one of the 28. 8–14 h.
|
||||
- **Command palette defaults to an Export entry that fails** (#79). 0.5 h.
|
||||
|
||||
## Hardware
|
||||
@@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Size and Position dialog landed (position, size, rotation, scale, lock aspect, one undo). The dialog gap remains Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object and the rest of the ~60 |
|
||||
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DeckCraft vs Microsoft PowerPoint: parity assessment
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready recomputed from written weights, 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; replaces the parity sections of ROADMAP.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; breadth stays 79%, ready stays ~55%; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
|
||||
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
|
||||
@@ -239,7 +239,7 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
|
||||
- **Files:** `.deckcraft` (zip + JSON), PPTX import/export (also opens `.potx`/`.ppsx`), PDF
|
||||
export (slides, notes, handouts 1–9 per page, text layer, links, bookmarks), PNG/JPEG, outline
|
||||
text, AutoRecover.
|
||||
- **Automation:** 226 commands, every one reachable from `deckcraft-cli`, the JSON control channel
|
||||
- **Automation:** 227 commands, every one reachable from `deckcraft-cli`, the JSON control channel
|
||||
and MCP.
|
||||
- **Platforms:** signed releases for macOS (universal), Windows x64/x86/arm64, Linux
|
||||
(AppImage/deb/rpm/tar.gz/Flatpak, x86_64 and aarch64), FreeBSD and the web (v0.4.0, 2026-10-09).
|
||||
@@ -276,6 +276,7 @@ scored done (16 open issues).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | trivial | Size and Position dialog. Command count 226 → 227. Breadth stays 79%, ready stays ~55%, UI stays ~60% |
|
||||
| 2026-10-10 | minor | Added the readiness-by-audience table: each number gets its own hours to ~95% (full 340–600, mainstream 150–250, essentials 50–90). The full number stays an additive weighted sum; no change to the percentages |
|
||||
| 2026-10-10 | minor | Added the mainstream practitioner (~53%) and essentials user (~62%) numbers with written weights, discounts and user evidence. Features dimension recomputed from written area weights (66% judgement → 62.4% weighted mean), which moves ready for real work from ~56% to ~55%; stage unchanged |
|
||||
| 2026-10-10 | minor | Stage checked against the core-workflow gate (all six pass); stays alpha |
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (File tab / Backstage, #76; previous: off-slide objects in Normal view, #270) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Size and Position dialog; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
@@ -64,7 +64,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
||||
| Comments pane (threads) | yes | yes |
|
||||
| Design Ideas (Designer) | yes (cloud) | no |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 27 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
| Dialogs | ~60 (Font, Paragraph, Bullets and Numbering, Find, Replace, Replace Fonts, Hyperlink, Action Settings, Header and Footer, Slide Size, Set Up Show, Custom Shows, Print, Page Setup, Insert Object, Symbol, Equation, Chart data sheet, SmartArt chooser, Preferences…) | 28 (`crates/ui-egui/src/dialogs.rs`): Table, Slide Size, Size and Position, Header and Footer, Hyperlink, Set Up Show, Zoom, Comment, Alt Text, Outline, Rename (section, shape, layout), Paragraph, About, Preferences, Export, Symbol, Equation (placeholder), Spelling, Accessibility, Language, Custom Shows, Chart Data, Trim Media, SmartArt chooser, Quit, Notes Pages |
|
||||
|
||||
## Direct manipulation
|
||||
|
||||
@@ -81,7 +81,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Edit points on freeforms | yes | **no** |
|
||||
| Crop handles, crop to shape, aspect ratio | yes | partial |
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
|
||||
| Exact size/position, lock aspect | yes | yes |
|
||||
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
|
||||
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
|
||||
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter |
|
||||
| Drag and drop of files and between thumbnails | yes | yes |
|
||||
@@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Size and Position dialog on Shape Format › Size and the shape context menu. Breadth stays 79%; UI ready stays ~60% |
|
||||
| 2026-10-12 | minor | File tab (Backstage) observed in PowerPoint for Windows and matched (#76); the start screen is File › Home |
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
|
||||
Reference in New Issue
Block a user