mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
UI: Font dialog sets character formatting in one step
format.fontDialog applies family, size, bold, italic, underline, colour, strikethrough, baseline, caps and spacing together, so one undo restores them. With no fields it asks the UI to open the dialog, seeded from the selection. Home › Font, the shape menu and Cmd+T open it. 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 (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)
|
||||
|
||||
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 | 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 | 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: Font dialog |
|
||||
| 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) |
|
||||
|
||||
@@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spacing
|
||||
),
|
||||
cmd!("format.clear", "Clear All Formatting", ["Home", "Font"], Some("Cmd+Space"), "{}", has_text_or_shapes, clear),
|
||||
cmd!(
|
||||
"format.fontDialog",
|
||||
"Font…",
|
||||
["Home", "Font"],
|
||||
Some("Cmd+T"),
|
||||
"{family?, size?: pt, bold?: bool, italic?: bool, underline?: none|sng|dbl|heavy|dotted|dash|wavy, color?, strike?: none|sng|dbl, baseline?: none|super|sub|<fraction>, caps?: none|small|all, spacing?: pt} — with none of these, opens the Font dialog",
|
||||
has_text_or_shapes,
|
||||
font_dialog
|
||||
),
|
||||
cmd!("format.textShadow", "Text Shadow", ["Home", "Font"], None, "{on?: bool}", has_text_or_shapes, text_shadow),
|
||||
cmd!(
|
||||
"format.textOutline",
|
||||
@@ -309,6 +318,213 @@ fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = f64_param(p, "pt").unwrap_or(0.0).clamp(-100.0, 400.0);
|
||||
format_run(s, &move |r| r.spacing = Some(v))
|
||||
}
|
||||
|
||||
/// Character formatting applied together, so the Font dialog is one undo step.
|
||||
struct FontEdits {
|
||||
family: Option<String>,
|
||||
size: Option<f64>,
|
||||
bold: Option<bool>,
|
||||
italic: Option<bool>,
|
||||
underline: Option<String>,
|
||||
color: Option<deckcraft_model::ColorRef>,
|
||||
strike: Option<Strike>,
|
||||
baseline: Option<f64>,
|
||||
caps: Option<Caps>,
|
||||
spacing: Option<f64>,
|
||||
}
|
||||
|
||||
impl FontEdits {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.family.is_none()
|
||||
&& self.size.is_none()
|
||||
&& self.bold.is_none()
|
||||
&& self.italic.is_none()
|
||||
&& self.underline.is_none()
|
||||
&& self.color.is_none()
|
||||
&& self.strike.is_none()
|
||||
&& self.baseline.is_none()
|
||||
&& self.caps.is_none()
|
||||
&& self.spacing.is_none()
|
||||
}
|
||||
|
||||
fn write(&self, r: &mut RunProps) {
|
||||
if let Some(f) = &self.family {
|
||||
r.font = Some(f.clone());
|
||||
}
|
||||
if let Some(v) = self.size {
|
||||
r.size = Some(v);
|
||||
}
|
||||
if let Some(v) = self.bold {
|
||||
r.bold = Some(v);
|
||||
}
|
||||
if let Some(v) = self.italic {
|
||||
r.italic = Some(v);
|
||||
}
|
||||
if let Some(u) = &self.underline {
|
||||
r.underline = Some(u.clone());
|
||||
}
|
||||
if let Some(c) = &self.color {
|
||||
r.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
if let Some(v) = self.strike {
|
||||
r.strike = Some(v);
|
||||
}
|
||||
if let Some(v) = self.baseline {
|
||||
r.baseline = Some(v);
|
||||
}
|
||||
if let Some(v) = self.caps {
|
||||
r.caps = Some(v);
|
||||
}
|
||||
if let Some(v) = self.spacing {
|
||||
r.spacing = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn font_bool(p: &Value, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a bool"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_str<'a>(p: &'a Value, key: &str) -> Result<Option<&'a str>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s)),
|
||||
Some(_) => Err(bad("format.fontDialog", format!("`{key}` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_edits(p: &Value) -> Result<FontEdits> {
|
||||
let family = match font_str(p, "family")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
let t = s.trim();
|
||||
if t.is_empty() || t.chars().count() > 200 {
|
||||
return Err(bad("format.fontDialog", "`family` must be 1–200 characters"));
|
||||
}
|
||||
Some(t.to_string())
|
||||
}
|
||||
};
|
||||
let size = match p.get("size") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`size` must be a finite number"))?;
|
||||
Some(n.clamp(1.0, 4000.0))
|
||||
}
|
||||
};
|
||||
let underline = match font_str(p, "underline")? {
|
||||
None => None,
|
||||
Some(s) => {
|
||||
if !matches!(s, "none" | "sng" | "dbl" | "heavy" | "dotted" | "dash" | "wavy") {
|
||||
return Err(bad("format.fontDialog", "`underline` must be none, sng, dbl, heavy, dotted, dash or wavy"));
|
||||
}
|
||||
Some(s.to_string())
|
||||
}
|
||||
};
|
||||
let color =
|
||||
if p.get("color").is_some() { Some(color_param(p, "color").ok_or_else(|| bad("format.fontDialog", "invalid `color`"))?) } else { None };
|
||||
let strike = match font_str(p, "strike")? {
|
||||
None => None,
|
||||
Some("none") => Some(Strike::None),
|
||||
Some("sng" | "single") => Some(Strike::Single),
|
||||
Some("dbl" | "double") => Some(Strike::Double),
|
||||
Some(_) => return Err(bad("format.fontDialog", "`strike` must be none, sng or dbl")),
|
||||
};
|
||||
let baseline = match p.get("baseline") {
|
||||
None => None,
|
||||
Some(Value::String(s)) => match s.as_str() {
|
||||
"none" => Some(0.0),
|
||||
"super" | "superscript" => Some(0.3),
|
||||
"sub" | "subscript" => Some(-0.25),
|
||||
_ => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
|
||||
},
|
||||
Some(Value::Number(n)) => {
|
||||
let f = n.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad("format.fontDialog", "`baseline` must be finite"))?;
|
||||
Some(f.clamp(-1.0, 1.0))
|
||||
}
|
||||
Some(_) => return Err(bad("format.fontDialog", "`baseline` must be none, super, sub or a fraction")),
|
||||
};
|
||||
let caps = match font_str(p, "caps")? {
|
||||
None => None,
|
||||
Some("none") => Some(Caps::None),
|
||||
Some("small") => Some(Caps::Small),
|
||||
Some("all") => Some(Caps::All),
|
||||
Some(_) => return Err(bad("format.fontDialog", "`caps` must be none, small or all")),
|
||||
};
|
||||
let spacing = match p.get("spacing") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v.as_f64().filter(|n| n.is_finite()).ok_or_else(|| bad("format.fontDialog", "`spacing` must be a finite number"))?;
|
||||
Some(n.clamp(-100.0, 400.0))
|
||||
}
|
||||
};
|
||||
Ok(FontEdits { family, size, bold: font_bool(p, "bold")?, italic: font_bool(p, "italic")?, underline, color, strike, baseline, caps, spacing })
|
||||
}
|
||||
|
||||
/// A colour as a `format.fontDialog` / `format.color` parameter, including transforms.
|
||||
fn color_command_value(c: &deckcraft_model::ColorRef) -> Value {
|
||||
use deckcraft_model::ColorBase;
|
||||
let mut o = serde_json::Map::new();
|
||||
for m in &c.mods {
|
||||
if let Some(v) = m.value() {
|
||||
o.insert(m.xml_name().to_string(), json!(v));
|
||||
}
|
||||
}
|
||||
match &c.base {
|
||||
ColorBase::Rgb { rgb } if o.is_empty() => json!(format!("#{}", rgb.hex())),
|
||||
ColorBase::Rgb { rgb } => {
|
||||
o.insert("rgb".into(), json!(format!("#{}", rgb.hex())));
|
||||
Value::Object(o)
|
||||
}
|
||||
ColorBase::Scheme { slot } => {
|
||||
o.insert("scheme".into(), json!(slot.xml_name()));
|
||||
Value::Object(o)
|
||||
}
|
||||
ColorBase::Preset { name } => json!(name),
|
||||
ColorBase::System { last, .. } => json!(format!("#{}", last.hex())),
|
||||
}
|
||||
}
|
||||
|
||||
fn font_dialog_params(s: &Session) -> Value {
|
||||
let r = current_run(s);
|
||||
let family = s.doc().ok().and_then(|st| super::text::ctx_for(&st.doc, &st.selection).map(|c| resolve::font_family(&c, &r))).unwrap_or_default();
|
||||
let baseline = r.baseline.unwrap_or(0.0);
|
||||
let mut v = json!({
|
||||
"family": family,
|
||||
"size": r.size.unwrap_or(18.0),
|
||||
"bold": r.bold.unwrap_or(false),
|
||||
"italic": r.italic.unwrap_or(false),
|
||||
"underline": r.underline.as_deref().filter(|u| *u != "none").unwrap_or("none"),
|
||||
"strike": match r.strike {
|
||||
Some(Strike::Double) => "dbl",
|
||||
Some(Strike::Single) => "sng",
|
||||
_ => "none",
|
||||
},
|
||||
"baseline": if baseline > 0.0 { "super" } else if baseline < 0.0 { "sub" } else { "none" },
|
||||
"caps": match r.caps {
|
||||
Some(Caps::Small) => "small",
|
||||
Some(Caps::All) => "all",
|
||||
_ => "none",
|
||||
},
|
||||
"spacing": r.spacing.unwrap_or(0.0),
|
||||
});
|
||||
if let Some(Fill::Solid { color }) = &r.fill {
|
||||
v["color"] = color_command_value(color);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn font_dialog(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let edits = font_edits(p)?;
|
||||
if edits.is_empty() {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "font".into(), params: font_dialog_params(s) });
|
||||
return ok();
|
||||
}
|
||||
format_run(s, &|r| edits.write(r))
|
||||
}
|
||||
fn clear(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
format_run(s, &|r| *r = RunProps { lang: r.lang.clone(), link: r.link.clone(), ..Default::default() })
|
||||
}
|
||||
@@ -682,3 +898,124 @@ fn state(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
"painter": s.painter.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_model::text::{Caps, Strike};
|
||||
use deckcraft_model::{ColorBase, Fill, SchemeSlot};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::{Session, UiRequest};
|
||||
|
||||
fn shape() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 240.0, 80.0], "text": "Hello"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn props(s: &Session) -> RunProps {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.selected_shapes()
|
||||
.first()
|
||||
.and_then(|sh| sh.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.unwrap()
|
||||
.props
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_dialog_applies_character_formatting_in_one_undo() {
|
||||
let mut s = shape();
|
||||
let before = s.doc().unwrap().history.undo.len();
|
||||
s.execute(
|
||||
"format.fontDialog",
|
||||
&json!({
|
||||
"family": "Liberation Serif",
|
||||
"size": 28,
|
||||
"bold": true,
|
||||
"italic": false,
|
||||
"underline": "wavy",
|
||||
"color": "#112233",
|
||||
"strike": "dbl",
|
||||
"baseline": "super",
|
||||
"caps": "small",
|
||||
"spacing": 3
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Font…");
|
||||
let r = props(&s);
|
||||
assert_eq!(r.font.as_deref(), Some("Liberation Serif"));
|
||||
assert_eq!(r.size, Some(28.0));
|
||||
assert_eq!(r.bold, Some(true));
|
||||
assert_eq!(r.italic, Some(false));
|
||||
assert_eq!(r.underline.as_deref(), Some("wavy"));
|
||||
assert!(matches!(r.fill, Some(Fill::Solid { color }) if matches!(color.base, ColorBase::Rgb { .. })));
|
||||
assert_eq!(r.strike, Some(Strike::Double));
|
||||
assert_eq!(r.baseline, Some(0.3));
|
||||
assert_eq!(r.caps, Some(Caps::Small));
|
||||
assert_eq!(r.spacing, Some(3.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let r = props(&s);
|
||||
assert!(r.font.is_none() && r.size.is_none() && r.bold.is_none() && r.underline.is_none() && r.fill.is_none());
|
||||
assert_eq!(r.strike, None);
|
||||
assert_eq!(r.caps, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_dialog_changes_only_the_fields_it_is_given() {
|
||||
let mut s = shape();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
s.execute("format.fontDialog", &json!({"size": 22, "color": {"scheme": "accent2", "lumMod": 50000}})).unwrap();
|
||||
let r = props(&s);
|
||||
assert_eq!(r.bold, Some(true));
|
||||
assert_eq!(r.size, Some(22.0));
|
||||
assert!(r.font.is_none() && r.italic.is_none());
|
||||
let Some(Fill::Solid { color }) = r.fill else { panic!("solid fill") };
|
||||
assert!(matches!(color.base, ColorBase::Scheme { slot: SchemeSlot::Accent2 }));
|
||||
assert!(color.mods.iter().any(|m| m.xml_name() == "lumMod"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let r = props(&s);
|
||||
assert_eq!(r.bold, Some(true));
|
||||
assert!(r.size.is_none() && r.fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_dialog_with_no_fields_asks_the_ui_to_open() {
|
||||
let mut s = shape();
|
||||
s.execute("format.italic", &json!({"on": true})).unwrap();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.ui_requests.clear();
|
||||
s.execute("format.fontDialog", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") };
|
||||
assert_eq!(id, "font");
|
||||
assert_eq!(params["italic"], true);
|
||||
assert_eq!(params["bold"], false);
|
||||
assert!(params["family"].as_str().is_some_and(|f| !f.is_empty()));
|
||||
assert!(params["size"].as_f64().is_some_and(|n| n > 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_dialog_rejects_bad_fields_without_editing() {
|
||||
let mut s = shape();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let err = s.execute("format.fontDialog", &json!({"underline": "sparkle"})).unwrap_err();
|
||||
assert!(err.to_string().contains("underline"), "{err}");
|
||||
assert!(s.execute("format.fontDialog", &json!({"size": "large"})).is_err());
|
||||
assert!(s.execute("format.fontDialog", &json!({"bold": 1})).is_err());
|
||||
assert!(s.execute("format.fontDialog", &json!({"family": " "})).is_err());
|
||||
assert!(s.execute("format.fontDialog", &json!({"color": "not-a-color"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
assert!(props(&s).underline.is_none());
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
assert!(s.execute("format.fontDialog", &json!({"bold": true})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,6 +635,10 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
let _ = app.run("text.edit", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Font…").clicked() {
|
||||
let _ = app.run("format.fontDialog", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
ui.menu_button("Bring to Front", |ui| {
|
||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringToFront, "Bring to Front", "arrange.bringToFront", json!({}));
|
||||
crate::ribbon::menu_item(app, ui, crate::icons::Icon::BringForward, "Bring Forward", "arrange.bringForward", json!({}));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Dialogs (Insert Table, Slide Size, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
|
||||
//! Dialogs (Insert Table, Slide Size, Font, Header & Footer, Hyperlink, Set Up Show, Zoom, …) and the
|
||||
//! About window. The start screen is File › Home (`backstage.rs`).
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
@@ -43,6 +43,7 @@ fn title(id: &str) -> &'static str {
|
||||
"renameShape" => "Rename",
|
||||
"renameLayout" => "Rename Layout",
|
||||
"paragraph" => "Paragraph",
|
||||
"font" => "Font",
|
||||
"about" => "About DeckCraft",
|
||||
"preferences" => "Preferences",
|
||||
"export" => "Export",
|
||||
@@ -382,6 +383,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"font" => font_dialog(app, ui, d),
|
||||
"paragraph" => {
|
||||
field(ui, d, "Before text (in):", "left", "0");
|
||||
field(ui, d, "Special — hanging (in):", "hang", "0");
|
||||
@@ -829,3 +831,211 @@ pub fn paint_logo(p: &egui::Painter, r: Rect) {
|
||||
Stroke::NONE,
|
||||
));
|
||||
}
|
||||
|
||||
fn seed_once(d: &mut Dialog, key: &str, val: String) {
|
||||
d.fields.entry(key.to_string()).or_insert(val);
|
||||
}
|
||||
|
||||
fn json_text(v: Option<&Value>, default: &str) -> String {
|
||||
match v {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Number(n)) => n.as_f64().map(|x| crate::widgets::fmt_num(x, "")).unwrap_or_else(|| default.to_string()),
|
||||
Some(Value::Bool(true)) => "1".into(),
|
||||
Some(Value::Bool(false)) => "0".into(),
|
||||
_ => default.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn color_label(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Object(o) => o.get("scheme").or_else(|| o.get("rgb")).and_then(Value::as_str).unwrap_or("").to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_font_fields(d: &mut Dialog) {
|
||||
seed_once(d, "family", json_text(d.params.get("family"), ""));
|
||||
seed_once(d, "size", json_text(d.params.get("size"), "18"));
|
||||
seed_once(d, "bold", json_text(d.params.get("bold"), "0"));
|
||||
seed_once(d, "italic", json_text(d.params.get("italic"), "0"));
|
||||
seed_once(d, "underline", json_text(d.params.get("underline"), "none"));
|
||||
seed_once(d, "strike", json_text(d.params.get("strike"), "none"));
|
||||
seed_once(d, "baseline", json_text(d.params.get("baseline"), "none"));
|
||||
seed_once(d, "caps", json_text(d.params.get("caps"), "none"));
|
||||
seed_once(d, "spacing", json_text(d.params.get("spacing"), "0"));
|
||||
let shown = d.params.get("color").map(color_label).unwrap_or_default();
|
||||
seed_once(d, "color", shown.clone());
|
||||
seed_once(d, "colorSeed", shown);
|
||||
}
|
||||
|
||||
fn choice(ui: &mut Ui, d: &mut Dialog, salt: &str, key: &str, choices: &[(&str, &str)]) {
|
||||
let cur = d.get(key, "");
|
||||
let label = choices.iter().find(|(v, _)| *v == cur).map(|(_, l)| *l).unwrap_or(cur.as_str());
|
||||
egui::ComboBox::from_id_salt(salt).selected_text(label).width(140.0).show_ui(ui, |ui| {
|
||||
for (v, l) in choices {
|
||||
if ui.selectable_label(cur == *v, *l).clicked() {
|
||||
d.fields.insert((*key).into(), (*v).into());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn labeled_text(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, width: f32) -> String {
|
||||
let mut v = d.get(key, "");
|
||||
let l = ui.label(label);
|
||||
ui.add(egui::TextEdit::singleline(&mut v).desired_width(width)).labelled_by(l.id);
|
||||
d.fields.insert(key.into(), v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
fn finite_pt(text: &str) -> Option<f64> {
|
||||
text.trim().trim_end_matches("pt").trim().parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// Home › Font › Font…, the shape menu and Cmd+T. One OK applies every field together.
|
||||
fn font_dialog(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.set_min_width(460.0);
|
||||
seed_font_fields(d);
|
||||
let mut fams = crate::ribbon::font_families(app);
|
||||
let family = d.get("family", "");
|
||||
if !family.is_empty() && !fams.iter().any(|f| f == &family) {
|
||||
fams.insert(0, family.clone());
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Font"));
|
||||
egui::ComboBox::from_id_salt("font_dialog_family").selected_text(if family.is_empty() { " " } else { family.as_str() }).width(300.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| {
|
||||
for f in &fams {
|
||||
if ui.selectable_label(family == *f, f).clicked() {
|
||||
d.fields.insert("family".into(), f.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
let _ = labeled_text(ui, d, "Size", "size", 64.0);
|
||||
ui.add_space(16.0);
|
||||
check(ui, d, "Bold", "bold", false);
|
||||
check(ui, d, "Italic", "italic", false);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Underline"));
|
||||
choice(
|
||||
ui,
|
||||
d,
|
||||
"font_dialog_underline",
|
||||
"underline",
|
||||
&[("none", "None"), ("sng", "Single"), ("dbl", "Double"), ("heavy", "Heavy"), ("dotted", "Dotted"), ("dash", "Dash"), ("wavy", "Wavy")],
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let _ = labeled_text(ui, d, "Color", "color", 110.0);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Strikethrough"));
|
||||
choice(ui, d, "font_dialog_strike", "strike", &[("none", "None"), ("sng", "Single"), ("dbl", "Double")]);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Baseline"));
|
||||
choice(ui, d, "font_dialog_baseline", "baseline", &[("none", "None"), ("super", "Superscript"), ("sub", "Subscript")]);
|
||||
ui.add_space(8.0);
|
||||
ui.label("Caps");
|
||||
choice(ui, d, "font_dialog_caps", "caps", &[("none", "None"), ("small", "Small caps"), ("all", "All caps")]);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(88.0, 20.0), egui::Label::new("Spacing"));
|
||||
let mut spacing = d.get("spacing", "0");
|
||||
let edited = ui.add(egui::TextEdit::singleline(&mut spacing).desired_width(64.0).hint_text("pt"));
|
||||
let mut picked = None;
|
||||
egui::ComboBox::from_id_salt("font_dialog_spacing")
|
||||
.selected_text(match spacing.as_str() {
|
||||
"-3" => "Very tight",
|
||||
"-1.5" => "Tight",
|
||||
"0" => "Normal",
|
||||
"3" => "Loose",
|
||||
"6" => "Very loose",
|
||||
other => other,
|
||||
})
|
||||
.width(120.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (v, l) in [("-3", "Very tight"), ("-1.5", "Tight"), ("0", "Normal"), ("3", "Loose"), ("6", "Very loose")] {
|
||||
if ui.selectable_label(spacing == v, l).clicked() {
|
||||
picked = Some(v.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
let _ = edited;
|
||||
if let Some(v) = picked {
|
||||
d.fields.insert("spacing".into(), v);
|
||||
} else {
|
||||
d.fields.insert("spacing".into(), spacing);
|
||||
}
|
||||
});
|
||||
let bold = d.get("bold", "0") == "1";
|
||||
let italic = d.get("italic", "0") == "1";
|
||||
let underline = d.get("underline", "none");
|
||||
let strike = d.get("strike", "none");
|
||||
let shown = finite_pt(&d.get("size", "18")).unwrap_or(18.0).clamp(12.0, 28.0) as f32;
|
||||
let mut sample = egui::RichText::new("AaBbCcDd").size(shown);
|
||||
if bold {
|
||||
sample = sample.strong();
|
||||
}
|
||||
if italic {
|
||||
sample = sample.italics();
|
||||
}
|
||||
if underline != "none" {
|
||||
sample = sample.underline();
|
||||
}
|
||||
if strike != "none" {
|
||||
sample = sample.strikethrough();
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.label(sample);
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let family = d.get("family", "");
|
||||
let family = family.trim();
|
||||
if family.is_empty() {
|
||||
app.set_status("Choose a font");
|
||||
return false;
|
||||
}
|
||||
let Some(size) = finite_pt(&d.get("size", "")) else {
|
||||
app.set_status("Font size must be a number");
|
||||
return false;
|
||||
};
|
||||
let Some(spacing) = finite_pt(&d.get("spacing", "0")) else {
|
||||
app.set_status("Character spacing must be a number");
|
||||
return false;
|
||||
};
|
||||
let mut params = json!({
|
||||
"family": family,
|
||||
"size": size,
|
||||
"bold": d.get("bold", "0") == "1",
|
||||
"italic": d.get("italic", "0") == "1",
|
||||
"underline": d.get("underline", "none"),
|
||||
"strike": d.get("strike", "none"),
|
||||
"baseline": d.get("baseline", "none"),
|
||||
"caps": d.get("caps", "none"),
|
||||
"spacing": spacing,
|
||||
});
|
||||
let color = d.get("color", "");
|
||||
let seed = d.get("colorSeed", "");
|
||||
if color == seed {
|
||||
if let Some(c) = d.params.get("color").filter(|v| !v.is_null()) {
|
||||
params["color"] = c.clone();
|
||||
}
|
||||
} else if !color.trim().is_empty() {
|
||||
params["color"] = json!(color.trim());
|
||||
}
|
||||
let _ = app.run("format.fontDialog", params);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "font_dialog_tests.rs"]
|
||||
mod font_dialog_tests;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The Font dialog: Home › Font › Font… and Cmd+T open it, OK applies one undo step, Cancel does not.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 320.0, 80.0], "text": "Hello"})).unwrap();
|
||||
// A selected shape, not a caret: the dialog then formats the text that is already there.
|
||||
let _ = s.execute("text.exit", &json!({}));
|
||||
let app = SlideApp::new(s, Services::default());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
fn bold_of(h: &Harness<'_, SlideApp>) -> Option<bool> {
|
||||
let st = h.state().session.active().unwrap();
|
||||
st.selected_shapes()
|
||||
.first()
|
||||
.and_then(|sh| sh.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.and_then(|r| r.props.bold)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_button_applies_bold_from_the_dialog() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("Font…").click();
|
||||
// The opening frame ignores the click that summoned the window; the next one is live.
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font"));
|
||||
// A pointer click on this checkbox does not register through kittest's per-event frames;
|
||||
// the accessibility click does, and it is the same toggle a pointer click runs.
|
||||
h.get_by_label("Bold").click_accesskit();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(1);
|
||||
assert!(h.state().dialog.is_none(), "status {:?}", h.state().status);
|
||||
assert_eq!(bold_of(&h), Some(true));
|
||||
assert_eq!(h.state().session.active().unwrap().history.undo.last().unwrap().label, "Font…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_t_opens_the_font_dialog_and_cancel_keeps_the_text() {
|
||||
let mut h = harness();
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("font"), "status {:?}", h.state().status);
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(1);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(bold_of(&h), None);
|
||||
}
|
||||
@@ -222,6 +222,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
|
||||
(
|
||||
"Format",
|
||||
vec §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 | Font dialog; command registry 227; 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 |
|
||||
|
||||
+5
-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 (Font dialog; previous: minor, 3-D bevel, camera and face lighting rendered) · **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,9 @@ 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. The Font dialog is in (family, size, style, underline,
|
||||
colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets
|
||||
and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h.
|
||||
- **Command palette defaults to an Export entry that fails** (#79). 0.5 h.
|
||||
|
||||
## Hardware
|
||||
@@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 | 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; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **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 | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged |
|
||||
| 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 |
|
||||
|
||||
+6
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
> **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-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)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,
|
||||
direct manipulation, keyboard and precision. Observations of PowerPoint are kept locally in
|
||||
@@ -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`): Font, 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 |
|
||||
|
||||
## Direct manipulation
|
||||
|
||||
@@ -91,8 +91,8 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
## Keyboard shortcuts
|
||||
|
||||
Every common PowerPoint for Mac shortcut is bound (checklist row done): new slide, duplicate,
|
||||
group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, show from
|
||||
start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag
|
||||
group/ungroup, bold/italic/underline, alignment, font size, list levels, Format Painter, the Font
|
||||
dialog (Cmd+T), show from start/current, view switching. Not yet: Cmd+F Find opening a find bar on the slide, Option+drag
|
||||
precision without snapping, F6 pane cycling, ribbon KeyTips (Windows).
|
||||
|
||||
### Text editing keys
|
||||
@@ -137,7 +137,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|---|---|
|
||||
| Native macOS menu bar from `menu_tree()`, filled out to PowerPoint's menu items | 3–5 |
|
||||
| Format Shape pane depth | 6–10 |
|
||||
| Missing dialogs (Font, Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 8–14 |
|
||||
| Missing dialogs (Bullets and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object) | 6–12 |
|
||||
| Window chrome, system appearance, palette fixes (#41, #71, #79) | 3–5 |
|
||||
| Grid Options, edit points UI, crop depth | 4–7 |
|
||||
| Multiple windows | 3–5 |
|
||||
@@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Font dialog from Home › Font, the shape menu and Cmd+T |
|
||||
| 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