mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Merge PR #244 from pierrevbc: Canvas: right click selects the shape under the pointer
# Conflicts: # crates/engine/src/tests.rs
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (presenter keyboard navigation; 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 (SmartArt text on its text rectangle; 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)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (title bar Export button; 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)
|
||||
> **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
|
||||
@@ -143,6 +144,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) |
|
||||
| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
|
||||
| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
|
||||
| 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 |
|
||||
|
||||
@@ -958,3 +958,80 @@ fn plain_outlines_are_unchanged() {
|
||||
let p = crate::cmd::file::open_presentation("outline.txt", MD_OUTLINE.as_bytes()).unwrap();
|
||||
assert_eq!(p.slides.first().map(|s| s.title()).as_deref(), Some("# Intro"));
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
@@ -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!({}))
|
||||
@@ -837,6 +841,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(_)))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -394,7 +394,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))
|
||||
@@ -648,7 +652,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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+3
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Eyedropper in the colour menus (#367); previously 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 (Export button in the title bar; previous: minor, File tab / Backstage, #76) · **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)
|
||||
|
||||
@@ -85,7 +86,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) |
|
||||
@@ -152,6 +153,7 @@ imitate Microsoft's. No pixel comparison against PowerPoint screenshots has been
|
||||
|---|---|---|
|
||||
| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |
|
||||
| 2026-10-12 | minor | Title bar: an Export button next to Save opens the Export dialog, a quick-access shortcut beside File › Export |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user