Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-10 · **Last updated:** 2026-10-12 · **Change:** minor (right click selects; previous: 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)

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
Expand Down Expand Up @@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).

| Date | What landed |
|---|---|
| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
Expand Down
77 changes: 77 additions & 0 deletions crates/engine/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -835,3 +835,80 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap();
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
}

fn text_box_xfrm(s: &Session, id: u64) -> deckcraft_geom::Xfrm {
let st = s.doc().unwrap();
cmd::xfrm_of(&st.doc, &st.selection, st.shape(deckcraft_model::ShapeId(id as u32)).unwrap())
}

fn right_click(s: &mut Session, x: f64, y: f64) -> serde_json::Value {
s.pointer(PointerEvent { kind: PointerKind::ContextClick, x, y, mods: Mods::default(), tol: 3.0 }).unwrap()
}

/// A blank slide with a rectangle A (100,100 200×100) and a text box B (400,100 200 wide),
/// nothing selected.
fn two_shapes() -> (Session, deckcraft_model::ShapeId, deckcraft_model::ShapeId) {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0], "text": "Shape A"})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("insert.textBox", &json!({"rect": [400.0, 100.0, 200.0, 40.0], "text": "Box B"})).unwrap()["id"].as_u64().unwrap();
s.select(|_, sel| {
sel.shapes.clear();
sel.text = None;
})
.unwrap();
(s, deckcraft_model::ShapeId(a as u32), deckcraft_model::ShapeId(b as u32))
}

#[test]
fn right_click_selects_the_shape_under_the_pointer() {
let (mut s, a, b) = two_shapes();
let undo_before = s.doc().unwrap().history.undo.len();
// An unselected shape: selected alone, no text editing, no drag.
let r = right_click(&mut s, 150.0, 150.0);
assert_eq!(r["selected"], json!([a]));
let st = s.doc().unwrap();
assert_eq!(st.selection.shapes, vec![a]);
assert!(st.selection.text.is_none());
assert!(!s.tool.dragging());
s.pointer(PointerEvent { kind: PointerKind::Drag, x: 190.0, y: 190.0, mods: Mods::default(), tol: 3.0 }).unwrap();
assert_eq!(text_box_xfrm(&s, a.0 as u64).x, 100.0);
// A text box, which a left click would start editing: only selected.
right_click(&mut s, 450.0, 115.0);
let st = s.doc().unwrap();
assert_eq!(st.selection.shapes, vec![b]);
assert!(st.selection.text.is_none());
// A multiple selection stays when the right click lands on one of its shapes.
s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
right_click(&mut s, 450.0, 115.0);
assert_eq!(s.doc().unwrap().selection.shapes, vec![a, b]);
// Empty slide: nothing selected (the slide menu).
let r = right_click(&mut s, 700.0, 450.0);
assert!(r["hit"].is_null(), "{r}");
assert!(s.doc().unwrap().selection.shapes.is_empty());
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before);
}

#[test]
fn right_click_in_edited_text_keeps_the_text_selection() {
let (mut s, a, b) = two_shapes();
s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap();
s.execute("text.move", &json!({"to": "wordRight", "extend": true})).unwrap();
let before = s.doc().unwrap().selection.text.clone().unwrap();
assert!(before.is_range());
// Anywhere in the box, even away from the selected word.
let r = right_click(&mut s, 280.0, 185.0);
assert_eq!(r["editing"], true);
assert_eq!(s.doc().unwrap().selection.text.as_ref(), Some(&before));
// The context menu's Copy and Cut then act on the selected text.
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["text"].as_str().unwrap().trim(), "Shape");
s.execute("edit.cut", &json!({})).unwrap();
assert_eq!(s.execute("text.get", &json!({"id": a})).unwrap()["text"].as_str().unwrap().trim(), "A");
// A right click on another shape leaves editing and selects that shape.
right_click(&mut s, 450.0, 115.0);
let st = s.doc().unwrap();
assert!(st.selection.text.is_none());
assert_eq!(st.selection.shapes, vec![b]);
}
43 changes: 43 additions & 0 deletions crates/engine/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ pub enum PointerKind {
Move,
DoubleClick,
TripleClick,
/// Secondary-button press (right click), before the context menu opens: selects what's under
/// the pointer the way PowerPoint does, without editing text or starting a drag.
ContextClick,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -299,6 +302,7 @@ impl Session {
PointerKind::Drag => self.drag(p, ev.mods, tol),
PointerKind::Up => self.up(p, ev.mods),
PointerKind::DoubleClick => self.double_click(p, tol),
PointerKind::ContextClick => self.context_click(p, tol),
PointerKind::TripleClick => {
if self.active().is_some_and(|d| d.selection.text.is_some()) {
self.execute("text.selectParagraph", &json!({}))
Expand Down Expand Up @@ -826,6 +830,45 @@ impl Session {
}
}

/// Right-button press: the selection the context menu will act on, as in PowerPoint. In the
/// box being edited, the caret and text selection stay (the menu then acts on the text). On a
/// selected shape (or its handles) the selection stays, multiple selection included; on
/// another shape, that shape alone is selected; on empty slide, nothing is. Never edits text,
/// starts a drag or adds an undo step.
fn context_click(&mut self, p: Point, tol: f64) -> Result<Value> {
if !matches!(self.tool.kind, ToolKind::Select) || self.tool.drag.is_some() {
return Ok(Value::Null);
}
let st = self.doc()?;
// The same zone in which a left press places the caret (`down`).
if let Some(t) = &st.selection.text
&& !t.notes
&& t.cell.is_none()
&& let Some(sh) = st.shape(t.shape)
&& xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p)
{
return Ok(json!({"hit": {"kind": "text", "id": t.shape}, "selected": [t.shape], "editing": true}));
}
let hit = self.hit_test(p, tol);
let id = match &hit {
Some(Hit::Shape { id, .. } | Hit::Resize { id, .. } | Hit::Rotate { id } | Hit::Adjust { id, .. } | Hit::LineEnd { id, .. }) => Some(*id),
None => None,
};
if !id.is_some_and(|id| st.selection.shapes.contains(&id)) {
self.select(|_, sel| {
sel.text = None;
sel.cells = None;
sel.shapes = id.into_iter().collect();
})?;
}
let st = self.doc()?;
Ok(json!({
"hit": serde_json::to_value(&hit).unwrap_or_default(),
"selected": st.selection.shapes,
"editing": st.selection.text.is_some(),
}))
}

fn is_picture(&self, id: ShapeId) -> bool {
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
}
Expand Down
4 changes: 3 additions & 1 deletion crates/mcp/src/headless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub fn pointer_kind(k: &str) -> Option<PointerKind> {
"move" => PointerKind::Move,
"doubleclick" | "dblclick" => PointerKind::DoubleClick,
"tripleclick" => PointerKind::TripleClick,
"contextclick" | "rightclick" => PointerKind::ContextClick,
_ => return None,
})
}
Expand Down Expand Up @@ -84,7 +85,8 @@ impl Headless {
let mut last = Value::Null;
for e in events {
let kind = s(e, "kind").unwrap_or("");
let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?;
let kind = pointer_kind(kind)
.ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick|tripleclick|contextclick)"))?;
let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs `x`")?;
let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs `y`")?;
let mods = e.get("mods").map(mods_from).unwrap_or(base);
Expand Down
4 changes: 2 additions & 2 deletions crates/mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,9 +240,9 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"pointer",
"Pointer gesture",
"Drive the active tool with pointer events in slide points: down/drag/up/move/doubleclick, exactly like the mouse (draws, moves, resizes, rotates, places the caret).",
"Drive the active tool with pointer events in slide points: down/drag/up/move/doubleclick/contextclick, exactly like the mouse (draws, moves, resizes, rotates, places the caret; contextclick selects like a right click).",
obj(
json!({"events": {"type": "array", "items": obj(json!({"kind": string("down|drag|up|move|doubleclick"), "x": num("x (pt)"), "y": num("y (pt)"), "mods": mods_schema()}), &["kind", "x", "y"])}, "mods": mods_schema()}),
json!({"events": {"type": "array", "items": obj(json!({"kind": string("down|drag|up|move|doubleclick|tripleclick|contextclick"), "x": num("x (pt)"), "y": num("y (pt)"), "mods": mods_schema()}), &["kind", "x", "y"])}, "mods": mods_schema()}),
&["events"],
),
false,
Expand Down
30 changes: 28 additions & 2 deletions crates/ui-egui/src/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,11 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
if resp.hovered() && !app.session.tool.dragging() {
let _ = app.session.pointer(ev(PointerKind::Move));
}
let r = if resp.triple_clicked() {
let secondary = resp.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Secondary));
let r = if secondary && !app.session.tool.dragging() {
// Select what's under the pointer before the context menu opens (on release).
app.session.pointer(ev(PointerKind::ContextClick))
} else if resp.triple_clicked() {
app.session.pointer(ev(PointerKind::TripleClick))
} else if resp.double_clicked() {
app.session.pointer(ev(PointerKind::DoubleClick))
Expand Down Expand Up @@ -630,7 +634,29 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
}
}
ui.separator();
if has_sel {
// Right-clicking the text being edited keeps it (`PointerKind::ContextClick`): text items.
let editing = app.session.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.notes));
if editing {
if ui.button("Paragraph…").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("paragraph"));
ui.close();
}
for (l, id) in [("Bullets", "format.bullets"), ("Numbering", "format.numbering")] {
if ui.add_enabled(crate::ribbon::enabled(app, id), egui::Button::new(l)).clicked() {
let _ = app.run(id, json!({}));
ui.close();
}
}
if ui.button("Link…").clicked() {
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
ui.close();
}
ui.separator();
if ui.button("Format Shape…").clicked() {
let _ = app.run("view.pane", json!({"pane": "format"}));
ui.close();
}
} else if has_sel {
if ui.button("Edit Text").clicked() {
let _ = app.run("text.edit", json!({}));
ui.close();
Expand Down
1 change: 1 addition & 0 deletions crates/ui-egui/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ fn deckcraft_mcp_kind(k: &str) -> Option<deckcraft_engine::PointerKind> {
"move" => Move,
"doubleclick" | "dblclick" => DoubleClick,
"tripleclick" => TripleClick,
"contextclick" | "rightclick" => ContextClick,
_ => return None,
})
}
Expand Down
2 changes: 1 addition & 1 deletion docs/control-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, documents, perf (fps, render ms) |
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse |
| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick\|contextclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse (`contextclick`: the right-button press that selects before the context menu) |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) |
| `ui.ime` | `{preedit, active?: [start, end]}` / `{commit}` | An input method's composition through egui, as a real one arrives: shown in place on the slide or in a focused field; `{"preedit": ""}` cancels |
| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane |
Expand Down
5 changes: 3 additions & 2 deletions docs/ui-parity.md
Original file line number Diff line number Diff line change
@@ -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-10 · **Last updated:** 2026-10-12 · **Change:** minor (right click selects the shape under the pointer; previous: minor, File tab / Backstage, #76) · **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)

Expand Down Expand Up @@ -83,7 +83,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
| Exact size/position, lock aspect | yes | yes |
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter |
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
| Drag and drop of files and between thumbnails | yes | yes |
| Image paste from the clipboard | yes | macOS yes; Windows and Linux no (#53) |
| Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) |
Expand Down Expand Up @@ -148,6 +148,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been

| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | A right click selects the shape under the pointer before its context menu opens, as in PowerPoint; edited text keeps its selection and gets text items |
| 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 |
Expand Down