mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Merge PR #519 from NikitkaRa: UI: WordArt adjust handles drag a text warp
# Conflicts: # crates/engine/src/tests.rs # crates/engine/src/tools.rs # crates/geom/src/warp.rs
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Font dialog; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Paragraph dialog; previous: trivial, Font dialog; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **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
|
||||
@@ -215,6 +216,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | Paragraph dialog (Home › Paragraph launcher): alignment, first-line and hanging indents, spacing before and after, line spacing, and a Tabs dialog, each applied in one undo step. Breadth stays 79% |
|
||||
| 2026-10-14 | Font dialog (Home › Font, the shape menu, Cmd+T): family, size, bold, italic, underline, colour, strikethrough, baseline, caps and character spacing, applied in one undo step. Breadth stays 79% |
|
||||
| 2026-10-13 | Grid Options: spacing, snap to grid, snap to other objects, and whether gridlines and guides are drawn; the on-screen grid uses that spacing |
|
||||
| 2026-10-13 | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (one undo step) |
|
||||
| 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 |
|
||||
|
||||
@@ -2277,3 +2277,43 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?;
|
||||
Some((u32::from_be_bytes(w), u32::from_be_bytes(h)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wordart_adjust_handle_drags_and_undoes() {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
let id =
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 60.0, 420.0, 200.0], "text": "ARCHED"})).unwrap()["id"].as_u64().unwrap();
|
||||
let sid = model::ShapeId(id as u32);
|
||||
{
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = st.selection.slide;
|
||||
let mut doc = (*st.doc).clone();
|
||||
let sh = model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut doc.slides[slide]).shapes, sid).unwrap();
|
||||
sh.text.as_mut().unwrap().body.warp = Some("textArchUp".into());
|
||||
st.doc = std::sync::Arc::new(doc);
|
||||
}
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
let (at, top) = {
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.shape(sid).unwrap();
|
||||
let x = cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let hs = tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h);
|
||||
assert_eq!(hs.len(), 1);
|
||||
let at = x.affine() * hs[0].pos;
|
||||
(at, deckcraft_geom::Point::new(x.center().x, x.y))
|
||||
};
|
||||
assert!(matches!(s.hit_test(at, 4.0), Some(tools::Hit::Warp { index: 0, .. })));
|
||||
let ev = |kind, p: deckcraft_geom::Point| PointerEvent { kind, x: p.x, y: p.y, mods: Mods::default(), tol: 4.0 };
|
||||
s.pointer(ev(PointerKind::Down, at)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, top)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, top)).unwrap();
|
||||
let adj = s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body.warp_adj.clone();
|
||||
let deg = adj.first().copied().unwrap_or(0.0) / 60_000.0;
|
||||
assert!((deg - 270.0).abs() < 2.0, "{deg} from {adj:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let body = &s.doc().unwrap().shape(sid).unwrap().text.as_ref().unwrap().body;
|
||||
assert_eq!(body.warp.as_deref(), Some("textArchUp"));
|
||||
assert!(body.warp_adj.is_empty(), "undo restores the default adjusts");
|
||||
}
|
||||
|
||||
@@ -92,6 +92,11 @@ pub enum Hit {
|
||||
path: usize,
|
||||
index: usize,
|
||||
},
|
||||
/// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`.
|
||||
Warp {
|
||||
id: ShapeId,
|
||||
index: usize,
|
||||
},
|
||||
/// Line end 0/1 of a line-like shape.
|
||||
LineEnd {
|
||||
id: ShapeId,
|
||||
@@ -146,6 +151,14 @@ enum Drag {
|
||||
index: usize,
|
||||
out: bool,
|
||||
},
|
||||
/// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults).
|
||||
Warp {
|
||||
id: ShapeId,
|
||||
index: usize,
|
||||
name: String,
|
||||
base: Vec<f64>,
|
||||
orig: Xfrm,
|
||||
},
|
||||
Marquee {
|
||||
start: Point,
|
||||
add: bool,
|
||||
@@ -252,6 +265,45 @@ pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) {
|
||||
(pts, rot)
|
||||
}
|
||||
|
||||
/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the
|
||||
/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on.
|
||||
pub fn wordart_handles(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
sel: &crate::Selection,
|
||||
sh: &Shape,
|
||||
w: f64,
|
||||
h: f64,
|
||||
) -> Vec<deckcraft_geom::warp::WarpHandle> {
|
||||
if sh.text.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() };
|
||||
let bp = deckcraft_model::resolve::body(&ctx, sh);
|
||||
let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() };
|
||||
let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect;
|
||||
deckcraft_geom::warp::handles(name, &bp.warp_adj, tr)
|
||||
}
|
||||
|
||||
/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults).
|
||||
fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) {
|
||||
let Some(text) = sh.text.as_mut() else { return };
|
||||
if !value.is_finite() || index > 7 {
|
||||
return;
|
||||
}
|
||||
let defaults = deckcraft_geom::warp::adjust_defaults(name);
|
||||
let n = defaults.len().max(index + 1).min(8);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let mut adj = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) };
|
||||
adj.push(v);
|
||||
}
|
||||
text.body.warp = Some(name.to_string());
|
||||
text.body.warp_adj = adj;
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn set_tool(&mut self, kind: ToolKind) {
|
||||
self.tool.kind = kind;
|
||||
@@ -304,6 +356,11 @@ impl Session {
|
||||
return Some(Hit::Adjust { id: *id, index: hd.index });
|
||||
}
|
||||
}
|
||||
for hd in wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) {
|
||||
if (x.affine() * hd.pos - p).hypot() <= tol * 1.6 {
|
||||
return Some(Hit::Warp { id: *id, index: hd.index });
|
||||
}
|
||||
}
|
||||
let (pts, rot) = box_handles(&x);
|
||||
if (rot - p).hypot() <= tol * 1.8 && st.selection.text.is_none() {
|
||||
return Some(Hit::Rotate { id: *id });
|
||||
@@ -438,7 +495,7 @@ impl Session {
|
||||
{
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
if x.bounds().inflate(tol, tol).contains(p)
|
||||
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. }))
|
||||
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. }))
|
||||
{
|
||||
let pos = text_pos(st, sh, p);
|
||||
let shift = mods.shift;
|
||||
@@ -511,6 +568,18 @@ impl Session {
|
||||
self.begin("Adjust Shape");
|
||||
self.tool.drag = Some(Drag::Adjust { id, index, orig });
|
||||
}
|
||||
Some(Hit::Warp { id, index }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
let found = st.shape(id).and_then(|sh| {
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection)?;
|
||||
let bp = deckcraft_model::resolve::body(&ctx, sh);
|
||||
bp.warp.map(|n| (n, bp.warp_adj))
|
||||
});
|
||||
if let Some((name, base)) = found {
|
||||
self.begin("Adjust WordArt");
|
||||
self.tool.drag = Some(Drag::Warp { id, index, name, base, orig });
|
||||
}
|
||||
}
|
||||
Some(Hit::Shape { id, text }) => {
|
||||
let selected = st.selection.shapes.contains(&id);
|
||||
let toggle = mods.shift || mods.cmd;
|
||||
@@ -728,6 +797,23 @@ impl Session {
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
Drag::Warp { id, index, name, base, orig } => {
|
||||
let v = {
|
||||
let st = self.doc()?;
|
||||
let Some(sh) = st.shape(id) else { return Ok(Value::Null) };
|
||||
let tr = deckcraft_render::shape_geometry(sh, orig.w, orig.h).text_rect;
|
||||
let hs = deckcraft_geom::warp::handles(&name, &base, tr);
|
||||
let Some(h) = hs.iter().find(|h| h.index == index) else { return Ok(Value::Null) };
|
||||
h.value_at(orig.affine().inverse() * p)
|
||||
};
|
||||
self.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?;
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, id) {
|
||||
set_warp_adj(sh, &name, &base, index, v);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Drag::Marquee { start, .. } => {
|
||||
self.tool.marquee = Some(Rect::from_points(start, p));
|
||||
}
|
||||
|
||||
@@ -286,6 +286,194 @@ pub const CATALOG: &[Preset] = &[
|
||||
Preset { id: "textCascadeDown", label: "Cascade Down" },
|
||||
];
|
||||
|
||||
/// A yellow adjust handle for one warp guide value. `pos` is in the same space as `rect`
|
||||
/// (shape-local points). Dragging projects the pointer onto the handle's axis, or around the
|
||||
/// text rectangle's centre for an angle.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct WarpHandle {
|
||||
pub index: usize,
|
||||
pub pos: Point,
|
||||
kind: HandleKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum HandleKind {
|
||||
/// `pos = origin + dir * raw`. `value_at` projects onto that line and clamps.
|
||||
Line { origin: Point, dir: Vec2, min: f64, max: f64 },
|
||||
/// Angle about `center` (y down, 0° at +x). `invert` is arch-down, whose guide angle is `180° − adj`.
|
||||
Angle { center: Point, invert: bool },
|
||||
}
|
||||
|
||||
impl WarpHandle {
|
||||
/// The raw guide value (`adj` / `adjN`) for a pointer at `p`.
|
||||
pub fn value_at(&self, p: Point) -> f64 {
|
||||
match self.kind {
|
||||
HandleKind::Line { origin, dir, min, max } => {
|
||||
let l2 = dir.hypot2();
|
||||
if l2 < 1e-18 || !(p.x.is_finite() && p.y.is_finite()) {
|
||||
return min;
|
||||
}
|
||||
let v = (p - origin).dot(dir) / l2;
|
||||
if !v.is_finite() {
|
||||
return min;
|
||||
}
|
||||
let (lo, hi) = (min.min(max), max.max(min));
|
||||
v.clamp(lo, hi).round()
|
||||
}
|
||||
HandleKind::Angle { center, invert } => {
|
||||
let d = p - center;
|
||||
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut deg = d.y.atan2(d.x).to_degrees();
|
||||
if invert {
|
||||
deg = 180.0 - deg;
|
||||
}
|
||||
(deg.rem_euclid(360.0) * 60_000.0).round()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_raw(adj: &[f64], defaults: &[f64], i: usize) -> f64 {
|
||||
adj.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn unit(rect: Rect, u: f64, v: f64) -> Point {
|
||||
Point::new(rect.x0 + u * rect.width(), rect.y0 + v * rect.height())
|
||||
}
|
||||
|
||||
/// A fraction handle: unit position `(x, sign * frac)`, raw = frac × 100 000, clamped to `lo..=hi`.
|
||||
fn frac_handle(rect: Rect, index: usize, x: f64, frac: f64, lo: f64, hi: f64, sign: f64) -> WarpHandle {
|
||||
let frac = frac.clamp(lo, hi);
|
||||
WarpHandle {
|
||||
index,
|
||||
pos: unit(rect, x, sign * frac),
|
||||
kind: HandleKind::Line {
|
||||
origin: unit(rect, x, 0.0),
|
||||
dir: Vec2::new(0.0, sign * rect.height() / 100_000.0),
|
||||
min: lo * 100_000.0,
|
||||
max: hi * 100_000.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A fraction handle that slides horizontally: unit `(frac, y)`.
|
||||
fn frac_x_handle(rect: Rect, index: usize, y: f64, frac: f64, lo: f64, hi: f64) -> WarpHandle {
|
||||
let frac = frac.clamp(lo, hi);
|
||||
WarpHandle {
|
||||
index,
|
||||
pos: unit(rect, frac, y),
|
||||
kind: HandleKind::Line {
|
||||
origin: unit(rect, 0.0, y),
|
||||
dir: Vec2::new(rect.width() / 100_000.0, 0.0),
|
||||
min: lo * 100_000.0,
|
||||
max: hi * 100_000.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Angle handle, inset from the ellipse so it doesn't sit on a resize handle.
|
||||
fn angle_handle(rect: Rect, index: usize, deg: f64, invert: bool) -> WarpHandle {
|
||||
let geom = if invert { 180.0 - deg } else { deg };
|
||||
let rad = geom.to_radians();
|
||||
let c = rect.center();
|
||||
WarpHandle {
|
||||
index,
|
||||
pos: Point::new(c.x + rect.width() * 0.36 * rad.cos(), c.y + rect.height() * 0.36 * rad.sin()),
|
||||
kind: HandleKind::Angle { center: c, invert },
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner-ellipse radius (pour presets): the handle sits on the downward point of that ellipse.
|
||||
fn radius_handle(rect: Rect, r: f64) -> WarpHandle {
|
||||
let r = r.clamp(0.0, 0.99);
|
||||
WarpHandle {
|
||||
index: 1,
|
||||
pos: unit(rect, 0.5, 0.5 + 0.5 * r),
|
||||
kind: HandleKind::Line { origin: unit(rect, 0.5, 0.5), dir: Vec2::new(0.0, 0.5 * rect.height() / 100_000.0), min: 0.0, max: 99_000.0 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Wave phase: slides sideways around the middle of the lower half.
|
||||
fn shift_handle(rect: Rect, s: f64) -> WarpHandle {
|
||||
let s = s.clamp(-0.1, 0.1);
|
||||
WarpHandle {
|
||||
index: 1,
|
||||
pos: unit(rect, 0.5 + s, 0.78),
|
||||
kind: HandleKind::Line { origin: unit(rect, 0.5, 0.78), dir: Vec2::new(rect.width() / 100_000.0, 0.0), min: -10_000.0, max: 10_000.0 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Yellow handles for warp `name` over text rectangle `rect`. Empty when `name` isn't a drawn
|
||||
/// preset or `rect` isn't a usable box. Missing adjust values take the preset defaults.
|
||||
pub fn handles(name: &str, adj: &[f64], rect: Rect) -> Vec<WarpHandle> {
|
||||
if ![rect.x0, rect.y0, rect.x1, rect.y1].iter().all(|v| v.is_finite()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let rect = Rect::new(rect.x0.min(rect.x1), rect.y0.min(rect.y1), rect.x0.max(rect.x1), rect.y0.max(rect.y1));
|
||||
if rect.width() < 1e-3 || rect.height() < 1e-3 {
|
||||
return Vec::new();
|
||||
}
|
||||
let defaults = adjust_defaults(name);
|
||||
if defaults.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let raw = |i| stored_raw(adj, defaults, i);
|
||||
let frac = |i| raw(i) / 100_000.0;
|
||||
let deg = |i| (raw(i) / 60_000.0).rem_euclid(360.0);
|
||||
let y = |out: &mut Vec<WarpHandle>, x, lo, hi, sign| out.push(frac_handle(rect, 0, x, frac(0), lo, hi, sign));
|
||||
let xh = |out: &mut Vec<WarpHandle>, yy, lo, hi| out.push(frac_x_handle(rect, 0, yy, frac(0), lo, hi));
|
||||
let mut out = Vec::with_capacity(defaults.len());
|
||||
match name {
|
||||
"textPlain" => xh(&mut out, 0.28, 0.3, 0.7),
|
||||
"textStop" => y(&mut out, 0.5, 0.14286, 0.5, 1.0),
|
||||
"textTriangle" | "textTriangleInverted" => y(&mut out, 0.18, 0.0, 1.0, 1.0),
|
||||
"textChevron" => y(&mut out, 0.18, 0.0, 0.5, 1.0),
|
||||
"textChevronInverted" => y(&mut out, 0.18, 0.5, 1.0, 1.0),
|
||||
"textFadeRight" => y(&mut out, 0.82, 0.0, 0.49999, 1.0),
|
||||
"textFadeLeft" => y(&mut out, 0.18, 0.0, 0.49999, 1.0),
|
||||
"textFadeUp" => xh(&mut out, 0.22, 0.0, 0.49999),
|
||||
"textFadeDown" => xh(&mut out, 0.78, 0.0, 0.49999),
|
||||
"textSlantUp" => y(&mut out, 0.18, 0.44444, 1.0, 1.0),
|
||||
"textSlantDown" => y(&mut out, 0.18, 0.0, 0.55556, 1.0),
|
||||
"textCascadeUp" => y(&mut out, 0.82, 0.0, 1.0, 1.0),
|
||||
"textCascadeDown" => y(&mut out, 0.18, 0.0, 1.0, 1.0),
|
||||
"textInflate" => y(&mut out, 0.62, 0.0, 0.2, -1.0),
|
||||
"textDeflate" => y(&mut out, 0.62, 0.0, 0.375, 1.0),
|
||||
"textInflateBottom" => y(&mut out, 0.62, 0.6, 1.0, 1.0),
|
||||
"textDeflateBottom" => y(&mut out, 0.62, 0.0625, 1.0, 1.0),
|
||||
"textInflateTop" => y(&mut out, 0.62, 0.0, 0.5, 1.0),
|
||||
"textDeflateTop" => y(&mut out, 0.62, 0.0, 0.9375, 1.0),
|
||||
"textDeflateInflate" => y(&mut out, 0.5, 0.05, 0.95, 1.0),
|
||||
"textDeflateInflateDeflate" => y(&mut out, 0.5, 0.01, 1.0 / 3.0, 1.0),
|
||||
"textCurveUp" | "textCurveDown" => y(&mut out, 0.18, 0.0, 0.56818, 1.0),
|
||||
"textCanUp" => y(&mut out, 0.62, 0.5, 1.0, 1.0),
|
||||
"textCanDown" => y(&mut out, 0.62, 0.0, 0.5, 1.0),
|
||||
"textRingInside" | "textRingOutside" => y(&mut out, 0.5, 0.5, 0.99, 1.0),
|
||||
"textArchUp" | "textCircle" | "textButton" => out.push(angle_handle(rect, 0, deg(0), false)),
|
||||
"textArchDown" => out.push(angle_handle(rect, 0, deg(0), true)),
|
||||
"textArchUpPour" | "textCirclePour" | "textButtonPour" => {
|
||||
out.push(angle_handle(rect, 0, deg(0), false));
|
||||
out.push(radius_handle(rect, frac(1)));
|
||||
}
|
||||
"textArchDownPour" => {
|
||||
out.push(angle_handle(rect, 0, deg(0), true));
|
||||
out.push(radius_handle(rect, frac(1)));
|
||||
}
|
||||
"textWave1" | "textWave2" => {
|
||||
y(&mut out, 0.32, 0.0, 0.2, 1.0);
|
||||
out.push(shift_handle(rect, frac(1)));
|
||||
}
|
||||
"textDoubleWave1" | "textWave4" => {
|
||||
y(&mut out, 0.32, 0.0, 0.125, 1.0);
|
||||
out.push(shift_handle(rect, frac(1)));
|
||||
}
|
||||
_ => return Vec::new(),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Segments of a guide in unit coordinates (x, y in 0..1 over the text rectangle).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Seg {
|
||||
|
||||
@@ -277,3 +277,43 @@ fn hostile_adjusts_and_boxes_stay_finite() {
|
||||
}
|
||||
assert!(warp("textPlain", &[], Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_preset_has_one_handle_per_adjust() {
|
||||
let r = rect();
|
||||
for name in ALL {
|
||||
let hs = handles(name, &[], r);
|
||||
assert_eq!(hs.len(), adjust_count(name), "{name}");
|
||||
let defaults = adjust_defaults(name);
|
||||
for h in &hs {
|
||||
assert!(h.pos.x.is_finite() && h.pos.y.is_finite(), "{name}");
|
||||
let back = h.value_at(h.pos);
|
||||
let want = defaults[h.index];
|
||||
assert!((back - want).abs() <= 1.0, "{name} handle {}: {back} vs {want}", h.index);
|
||||
}
|
||||
}
|
||||
assert!(handles("textNoShape", &[], r).is_empty());
|
||||
assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, f64::NAN, 10.0)).is_empty());
|
||||
assert!(handles("textArchUp", &[], Rect::new(0.0, 0.0, 0.0, 0.0)).is_empty());
|
||||
// A hostile adjust still places a finite handle on the clamped default.
|
||||
let hs = handles("textInflate", &[f64::NAN], r);
|
||||
assert_eq!(hs.len(), 1);
|
||||
assert!(hs[0].pos.x.is_finite() && hs[0].pos.y.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arch_and_inflate_handles_follow_the_pointer() {
|
||||
let r = rect();
|
||||
let arch = &handles("textArchUp", &[], r)[0];
|
||||
let deg = arch.value_at(Point::new(r.center().x, r.y0)) / 60_000.0;
|
||||
assert!((deg - 270.0).abs() < 0.5, "{deg}");
|
||||
let down = &handles("textArchDown", &[], r)[0];
|
||||
// The arch-down handle starts on the left; a pointer at the bottom is 90°, which is adjust 90
|
||||
// (guide angle 180 − adj).
|
||||
let deg = down.value_at(Point::new(r.center().x, r.y1)) / 60_000.0;
|
||||
assert!((deg - 90.0).abs() < 0.5, "{deg}");
|
||||
let inf = &handles("textInflate", &[], r)[0];
|
||||
let more = inf.value_at(Point::new(inf.pos.x, r.y0 - 80.0));
|
||||
assert!(more > adjust_defaults("textInflate")[0], "{more}");
|
||||
assert!((more - 20_000.0).abs() < 1.0, "{more}");
|
||||
}
|
||||
|
||||
@@ -693,7 +693,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
_ => egui::CursorIcon::ResizeHorizontal,
|
||||
},
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Shape { text: true, .. })) => {
|
||||
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
egui::CursorIcon::Text
|
||||
@@ -806,6 +806,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
let q = xf.to_screen(a * hd.pos);
|
||||
painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00)));
|
||||
}
|
||||
for hd in deckcraft_engine::tools::wordart_handles(&st.doc, &st.selection, sh, x.w, x.h) {
|
||||
let q = xf.to_screen(a * hd.pos);
|
||||
painter.circle(q, 4.5, Color32::from_rgb(0xFF, 0xD2, 0x3F), Stroke::new(1.0, Color32::from_rgb(0x8A, 0x6A, 0x00)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Caret / text selection.
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles drag; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **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
|
||||
@@ -304,6 +305,14 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a *
|
||||
band round the face outline (not round the extruded silhouette); no command or pane to apply 3-D).
|
||||
- **Estimate:** 4–6 h (edit points), 2–4 h (warp gallery, command and handles), 1–2 h (fitting the other rigs and materials
|
||||
to PowerPoint renders), plus the Format Shape page.
|
||||
`prstTxWarp` presets render, their adjusts round-trip, and a selected warp shows a yellow handle
|
||||
per adjust that dragging sets; still no Transform gallery or command to apply one, and arc/circle
|
||||
placement can differ from PowerPoint by 1–3 % of the path length), "Bevel and 3-D rotation"
|
||||
(partial: bevels, camera presets and rotations and face lighting render as approximations and
|
||||
`scene3d`/`sp3d` round-trip as raw XML; no extrusion or side faces, only the three-point rig, and
|
||||
no command or pane to apply 3-D).
|
||||
- **Estimate:** 4–6 h (edit points), 1–2 h (Transform gallery and command), 6–10 h (3-D extrusion,
|
||||
rigs and materials, Format Shape page).
|
||||
|
||||
### Proofing: spelling, thesaurus, AutoCorrect
|
||||
- **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings
|
||||
@@ -461,6 +470,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-14 | minor | Paragraph dialog sets alignment, indentation, spacing and line spacing in one step; its Tabs… button opens a standalone Tabs dialog |
|
||||
| 2026-10-14 | minor | Font dialog sets typeface, size, style, underline, colour, strikethrough, baseline, caps and character spacing in one step |
|
||||
| 2026-10-13 | minor | Grid Options dialog sets spacing, snap and whether gridlines and guides show; the on-screen grid follows that spacing |
|
||||
| 2026-10-13 | minor | WordArt adjust handles: a selected text warp shows one yellow handle per adjust, and dragging it sets that guide value (gallery and command still open) |
|
||||
| 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
|
||||
| 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 |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (go to slide shows the typed number; previous: trivial, custom show playback) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (chart trendlines are drawn; the charts gap stays open for the data grid, chartex and secondary-axis export; previous: trivial, chart trendlines round-trip; the real-deck corpus stays open; core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options; previous: core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps: adjust handles drag; gallery and command remain) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
|
||||
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
|
||||
@@ -115,6 +116,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as
|
||||
| M7 | Slide show | mostly | second-display placement, laser, record |
|
||||
| M8 | Tables and charts | partly | chart data grid, elements, chartex types, draw table |
|
||||
| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp adjust handles |
|
||||
| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), warp gallery and command |
|
||||
| M10 | Views | done | grid options, New Window |
|
||||
| M9 | SmartArt, equations, WordArt | partly | SmartArt text pane and layouts, equations (PR #55), text warps |
|
||||
| M10 | Views | done | grid options, Arrange All |
|
||||
@@ -168,5 +170,6 @@ plan above.
|
||||
| 2026-10-14 | trivial | Present workflow notes presenter timer pause/reset, clock and notes size. Display placement stays open |
|
||||
| 2026-10-14 | trivial | Present workflow notes See All Slides and zoom |
|
||||
| 2026-10-13 | minor | M10: Grid Options dialog; New Window stays open |
|
||||
| 2026-10-13 | minor | M9: WordArt adjust handles drag; the Transform gallery and command stay open |
|
||||
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
|
||||
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Views: Grid Options landed; New Window remains; previous: hours to ~95% per audience added; ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (text-warp shortfall is the gallery and command; adjust handles drag) · **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;
|
||||
@@ -208,6 +209,7 @@ it is the Features row of the dimension table. Automation and Platforms are scor
|
||||
| Views | 4 | 90% | ~80% | 3–6 | Snap to grid and grid options, New Window | [ui-parity.md](ui-parity.md) |
|
||||
| Masters and themes | 7 | 85% | ~70% | 8–14 | Notes and Handout masters, theme variants, `.thmx` | [parity-checklist.md](parity-checklist.md) |
|
||||
| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, text warp, WordArt depth | [ui-parity.md](ui-parity.md) |
|
||||
| Shapes | 7 | 86% | ~75% | 8–14 | Edit points, warp gallery and command, WordArt depth | [ui-parity.md](ui-parity.md) |
|
||||
| Format (fill, line, effects) | 6 | 81% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) |
|
||||
| Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) |
|
||||
| Text | 15 | 80% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), Tab in text boxes (#78), IME, true vertical CJK, dictionaries, AutoCorrect, thesaurus, equations (PR #55 open) | [localization-parity.md](localization-parity.md) |
|
||||
@@ -349,6 +351,7 @@ scored done (16 open issues).
|
||||
| 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged |
|
||||
| 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged |
|
||||
| 2026-10-13 | minor | Views: Grid Options dialog landed (spacing, snap, display). Remaining in that row: New Window. Percentages unchanged |
|
||||
| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed: yellow adjust handles drag; the gallery and command remain |
|
||||
| 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 |
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (Trim Media dialog follows the selection; previous: File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (Grid Options dialog; previous: 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt adjust handles; previous: 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-12 · **Last updated:** 2026-10-14 · **Change:** minor (File › New starter decks; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Sorter sections: add, rename, collapse, drag a whole section; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -127,6 +128,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Shift-constrained move, Option-drag to duplicate | yes | yes |
|
||||
| Connector glue and rerouting | yes | yes |
|
||||
| Edit points on freeforms | yes | partial: Edit Shape ▸ Edit Points turns any shape into a freeform; drag vertices and their Bézier handles, Ctrl/Cmd-click to add or delete, right-click for Smooth/Straight/Corner Point and Delete Point; no Straight/Curved Segment or Open/Close Path yet |
|
||||
| Edit points on freeforms | yes | **no** |
|
||||
| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value |
|
||||
| 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 — 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 |
|
||||
@@ -235,6 +238,7 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| 2026-10-14 | minor | Paragraph dialog from the Home › Paragraph launcher; Tabs… opens a standalone Tabs dialog |
|
||||
| 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T |
|
||||
| 2026-10-13 | minor | Grid Options dialog: spacing, snap, and whether gridlines and guides are drawn; the on-screen grid uses that spacing |
|
||||
| 2026-10-13 | minor | WordArt adjust handles on a selected text warp; dragging one sets that `prstTxWarp` adjust |
|
||||
| 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