collective

Browse / github.com/storytold/deckcraft / patch

feat(format-painter): paint text in another box by dragging across it (#1216)

Format Painter: distinguish whole-shape clicks from double-click text selection ## Summary Explore and implement gesture-aware Format Painter handling so a double-click on text in a shape that is not already open for text editing can paint the selected word instead of painting the whole shape on the first press. Requested by @fchinu as a follow-up to PR #1076. This is an intentional behavior change to evaluate separately, not a regression introduced by that PR. ## Current behavior In `crates/engine/src/tools.rs`, `Session::down` handles `Hit::Shape` by calling `format.painterApply` with the shape ID when Format Painter is armed. This paints the whole shape immediately

Status
open
Score
0.00
Adopters
0 other operators
Author
fchinu (imported from pull request #1459, open)
Base
ee875abffa1a31cb760c843bb29e79391a90a766
Size
+204 −3 in 5 files
Review
pending
Shared
2026-10-28
Id
9ebb8deb5e4616d8503caee0

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/engine/src/tests_keys.rs crates/engine/src/tools/hit.rs crates/engine/src/tools/pointer.rs docs/ui-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -2,7 +2,7 @@  **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Format Painter onto text in another box, #1216; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | What landed | |---|---|+| 2026-10-27 | Format Painter onto text in another box: with formatting picked up, a drag across the text of any box (or table cell) paints just that text, a double-click a word and a triple-click a paragraph; a plain click puts the caret there and keeps the painter armed, and a press on the box's frame, or on a shape without text, still paints the shape whole. Before, the first press on another box painted all of it and used up the painter (#1216) | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`).  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | trivial | Progress log: Format Painter onto text in another box (#1216). Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |diff --git a/crates/engine/src/tests_keys.rs b/crates/engine/src/tests_keys.rs--- a/crates/engine/src/tests_keys.rs+++ b/crates/engine/src/tests_keys.rs@@ -562,6 +562,167 @@ fn format_painter_ignores_a_double_click_that_selects_nothing() {     assert!(bulleted(&mut s, 0)); } +/// Box A ("Hello\nworld", from y 60, lines at y 70 and 90) with "Hello" bold and the painter+/// armed from it, and box B ("alpha beta\ngamma", plain, from y 230, lines at y 240 and 260)+/// not being edited. Returns the session and the ids of A and B.+fn painter_and_box_b(sticky: bool) -> (Session, u32, u32) {+    let (mut s, a) = editing_in("Hello\nworld", (0, 0), [60.0, 60.0, 600.0, 150.0]);+    s.execute("insert.textBox", &json!({"rect": [60.0, 230.0, 600.0, 80.0], "text": "x"})).unwrap();+    let b = s.doc().unwrap().selection.shapes[0].0;+    s.execute("text.set", &json!({"id": b, "text": "alpha beta\ngamma"})).unwrap();+    s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap();+    s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();+    s.execute("format.bold", &json!({"on": true})).unwrap();+    s.execute("format.painter", &json!({"sticky": sticky})).unwrap();+    (s, a, b)+}++/// Each paragraph of shape `id`, one character per character of text: `B` bold, `.` not.+fn bold_map(s: &Session, id: u32) -> Vec<String> {+    let st = s.doc().unwrap();+    let body = st.shape(deckcraft_model::ShapeId(id)).and_then(|sh| sh.text.as_ref()).unwrap();+    body.paragraphs+        .iter()+        .map(|p| p.runs.iter().flat_map(|r| r.text.chars().map(move |_| if r.props.bold == Some(true) { 'B' } else { '.' })).collect())+        .collect()+}++/// A drag across the text of another box, one not being edited, paints only the dragged text,+/// in one undo step, and uses up a one-shot painter (#1216).+#[test]+fn format_painter_drag_paints_text_in_another_box() {+    use crate::tools::PointerKind::{Down, Drag, Up};+    let (mut s, _, b) = painter_and_box_b(false);+    let steps = s.doc().unwrap().history.undo.len();+    pointer(&mut s, Down, 68.0, 240.0);+    pointer(&mut s, Drag, 90.0, 240.0);+    pointer(&mut s, Up, 90.0, 240.0);+    let map = bold_map(&s, b);+    assert!(map[0].starts_with('B') && map[0].ends_with('.'), "only the dragged text is bold: {map:?}");+    assert_eq!(map[1], ".....", "the next line is untouched");+    assert!(s.painter.is_none(), "the one-shot painter is used up");+    assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");+    assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(b)).unwrap().fill.is_none(), "the box's fill is left alone");+    s.execute("edit.undo", &json!({})).unwrap();+    assert_eq!(bold_map(&s, b), ["..........", "....."]);+}++/// A sticky painter paints text in one box after another and stays armed.+#[test]+fn format_painter_sticky_paints_text_in_box_after_box() {+    use crate::tools::PointerKind::{Down, Drag, Up};+    let (mut s, a, b) = painter_and_box_b(true);+    for (x, y) in [(68.0, 240.0), (68.0, 90.0)] {+        pointer(&mut s, Down, x, y);+        pointer(&mut s, Drag, x + 22.0, y);+        pointer(&mut s, Up, x + 22.0, y);+    }+    assert!(bold_map(&s, b)[0].starts_with('B'), "box B was painted");+    assert!(bold_map(&s, a)[1].starts_with('B'), "then box A's second line");+    assert!(s.painter.is_some(), "a sticky painter stays armed");+}++/// A plain click on another box's text puts the caret there and leaves the painter armed; a+/// double-click then paints just the word (#1216).+#[test]+fn format_painter_click_waits_then_double_click_paints_the_word_in_another_box() {+    use crate::tools::PointerKind::{DoubleClick, Down, Up};+    let (mut s, _, b) = painter_and_box_b(false);+    pointer(&mut s, Down, 75.0, 240.0);+    pointer(&mut s, Up, 75.0, 240.0);+    let t = s.doc().unwrap().selection.text.clone().unwrap();+    assert_eq!(t.shape.0, b, "the caret is in box B");+    assert!(!t.is_range());+    assert!(s.painter.is_some(), "the painter waits");+    assert_eq!(bold_map(&s, b), ["..........", "....."], "nothing painted yet");+    pointer(&mut s, DoubleClick, 75.0, 240.0);+    assert_eq!(bold_map(&s, b), ["BBBBB.....", "....."], "only the word");+    assert!(s.painter.is_none());+}++/// A triple-click in another box paints its whole paragraph.+#[test]+fn format_painter_triple_click_paints_a_paragraph_in_another_box() {+    use crate::tools::PointerKind::{Down, TripleClick, Up};+    let (mut s, _, b) = painter_and_box_b(false);+    pointer(&mut s, Down, 75.0, 240.0);+    pointer(&mut s, Up, 75.0, 240.0);+    pointer(&mut s, TripleClick, 75.0, 240.0);+    assert_eq!(bold_map(&s, b), ["BBBBBBBBBB", "....."]);+}++/// The frame band of a box, edited or not, still paints the box whole.+#[test]+fn format_painter_on_a_frame_paints_the_box_whole() {+    use crate::tools::PointerKind::{Down, Up};+    // Box B is not being edited: the press is on its left edge.+    let (mut s, _, b) = painter_and_box_b(false);+    pointer(&mut s, Down, 61.0, 270.0);+    pointer(&mut s, Up, 61.0, 270.0);+    assert_eq!(bold_map(&s, b), ["BBBBBBBBBB", "BBBBB"]);+    assert!(s.painter.is_none());+    // Box A is being edited: its frame paints it whole too.+    let (mut s, a, _) = painter_and_box_b(false);+    // Text boxes shrink to their text, so A is about 50 tall: y 72 is on its left edge, clear of+    // the corner and middle resize handles.+    pointer(&mut s, Down, 61.0, 72.0);+    pointer(&mut s, Up, 61.0, 72.0);+    assert_eq!(bold_map(&s, a), ["BBBBB", "BBBBB"]);+    assert!(s.painter.is_none());+}++/// A shape without text has no text to select: a press paints the shape, as before.+#[test]+fn format_painter_on_a_shape_without_text_paints_it() {+    use crate::tools::PointerKind::{Down, Up};+    let (mut s, a, _) = painter_and_box_b(false);+    s.execute("shape.insert", &json!({"preset": "rect", "rect": [700.0, 60.0, 150.0, 100.0]})).unwrap();+    s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap();+    s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();+    s.execute("format.painter", &json!({})).unwrap();+    pointer(&mut s, Down, 775.0, 110.0);+    pointer(&mut s, Up, 775.0, 110.0);+    assert!(s.painter.is_none(), "the press painted the shape");+}++/// In a table, a drag inside one cell paints only that text.+#[test]+fn format_painter_drag_in_a_table_cell_paints_that_text() {+    use crate::tools::PointerKind::{Down, Drag, Up};+    let (mut s, a, _) = painter_and_box_b(false);+    let data = json!([["hello world", "other"]]);+    let r = s.execute("insert.table", &json!({"rows": 1, "cols": 2, "rect": [60.0, 330.0, 600.0, 60.0], "data": data})).unwrap();+    let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);+    s.execute("text.edit", &json!({"id": a, "at": [0, 0]})).unwrap();+    s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();+    s.execute("format.painter", &json!({})).unwrap();+    pointer(&mut s, Down, 72.0, 345.0);+    pointer(&mut s, Drag, 95.0, 345.0);+    pointer(&mut s, Up, 95.0, 345.0);+    assert!(s.painter.is_none());+    let st = s.doc().unwrap();+    let Some(deckcraft_model::ShapeKind::Table(t)) = st.shape(id).map(|sh| &sh.kind) else { panic!("not a table") };+    let bold = |c| t.cell(0, c).unwrap().text.paragraphs[0].runs.iter().map(|r| r.props.bold == Some(true)).collect::<Vec<_>>();+    assert!(bold(0).first() == Some(&true) && bold(0).last() == Some(&false), "part of the first cell: {:?}", bold(0));+    assert!(!bold(1).contains(&true), "the other cell is untouched");+}++/// Shift- or Ctrl-clicking another box's text with the painter armed extends the shape selection+/// and paints nothing.+#[test]+fn format_painter_shift_click_on_another_box_only_selects_it() {+    use crate::tools::PointerKind::{Down, Up};+    for mods in [SHIFT, CTRL] {+        let (mut s, _, b) = painter_and_box_b(false);+        for kind in [Down, Up] {+            s.pointer(crate::tools::PointerEvent { kind, x: 75.0, y: 240.0, mods, tol: 4.0, ..Default::default() }).unwrap();+        }+        assert!(s.doc().unwrap().selection.shapes.iter().any(|x| x.0 == b));+        assert_eq!(bold_map(&s, b), ["..........", "....."]);+        assert!(s.painter.is_some());+    }+}+ #[test] fn format_painter_ignores_a_shift_press_that_leaves_the_selection() {     use crate::tools::PointerKind::{Down, Up};diff --git a/crates/engine/src/tools/hit.rs b/crates/engine/src/tools/hit.rs--- a/crates/engine/src/tools/hit.rs+++ b/crates/engine/src/tools/hit.rs@@ -1,9 +1,24 @@ use super::*; +/// Whether shape `id` is a placeholder or text box: a click on it takes the caret straight away. pub(super) fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool {     st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box) } +/// Whether a press at `p` with Format Painter armed selects text in shape `id`, so that a drag,+/// double-click or triple-click paints just that text, instead of painting the shape whole: a+/// shape (not a line or group) with text, pressed inside its frame band, or a cell of a table.+pub(super) fn painter_selects_text_at(st: &crate::DocState, id: ShapeId, p: Point, tol: f64) -> bool {+    let Some(sh) = st.shape(id) else { return false };+    match sh.kind {+        ShapeKind::Table(_) => in_table_cell(st, id, p),+        ShapeKind::Shape if !sh.is_line() => {+            sh.text.as_ref().is_some_and(|t| !t.is_empty()) && edit_zone(&xfrm_of(&st.doc, &st.selection, sh), p, tol) == EditZone::Inside+        }+        _ => false,+    }+}+ /// The topmost text-holding shape under `p` inside group `sh`, recursing into nested groups. /// Group children live in the group's child space (`ShapeKind::Group::child`), so the point is /// mapped through `group_child_affine`, exactly as `hit_shape` does for group hit-testing.diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs--- a/crates/engine/src/tools/pointer.rs+++ b/crates/engine/src/tools/pointer.rs@@ -1,4 +1,4 @@-use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_table_cell, is_text_first, shape_in_front};+use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_table_cell, is_text_first, painter_selects_text_at, shape_in_front}; use super::*;  impl Session {@@ -333,6 +333,11 @@ impl Session {                     sel.cells = None;                 })?;                 self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None });+                // Format Painter on the frame paints the box whole, as on the frame of a box not+                // being edited.+                if self.painter.is_some() {+                    let _ = self.execute("format.painterApply", &json!({"ids": [id]}));+                }             }             Some(Hit::Warp { id, index }) => {                 let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();@@ -361,6 +366,22 @@ impl Session {                     self.tool.drag = Some(Drag::TextSelect { shape: id, before, press });                 }             }+            Some(Hit::Shape { id, .. })+                if self.painter.is_some()+                    && self.anim_painter.is_none()+                    && !(mods.shift || mods.cmd || mods.alt)+                    && painter_selects_text_at(st, id, p, tol) =>+            {+                // Format Painter over the text of a box not being edited: the press starts a text+                // selection there, as in the box being edited, so a drag, double-click or+                // triple-click paints only that text (#1216). A plain click leaves the caret and+                // the painter armed; the frame band still paints the box whole.+                let before = self.text_selection();+                if self.edit_text_at(id, p)? {+                    let press = self.doc()?.selection.text.clone();+                    self.tool.drag = Some(Drag::TextSelect { shape: id, before, press });+                }+            }             Some(Hit::Shape { id, text }) => {                 let selected = st.selection.shapes.contains(&id);                 let toggle = mods.shift || mods.cmd;diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (Format Painter paints text in another box: drag across it, double-click a word or triple-click a paragraph, #1216; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)  How DeckCraft looks and feels next to PowerPoint: the shell, the ribbon, menus, panes, dialogs,@@ -101,6 +101,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. Adjust handles: see WordArt adjust handles | | Rulers with indent markers and tab stops | yes | yes: indent markers and tab stops drag on the ruler (a click adds a tab, the corner selector picks its kind); Tabs dialog from Home › Paragraph and the Paragraph dialog | | Drag and drop of files and between thumbnails | yes | yes |+| Format Painter onto text in another box: drag across it, double-click a word, triple-click a paragraph | yes | yes, also in a table cell; a plain click on that text puts the caret there and the painter waits, and the box's frame (or a shape without text) takes the formatting whole (#1216) | | Slide Sorter sections: a bar per section, add, rename in place, collapse, drag the whole section (its slides move with it) | yes | yes | | Image paste from the clipboard | yes | macOS pastes a picture when the paste carries no text (TIFF), and rich text copies and pastes as HTML (bold, italic, underline). Windows and Linux image paste stays #53 | | Font menu: every installed family with its faces | yes | families yes; only Regular/Bold/Italic faces (#69) |@@ -169,6 +170,7 @@ consistency is open (#73, #56).  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | trivial | Format Painter: a drag, double-click or triple-click on the text of another box paints just that text; the frame paints the box whole (#1216) | | 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells | | 2026-10-24 | trivial | Edit Points: Shift, Ctrl/Cmd or Alt/Option held while dragging a handle makes the point smooth, straight or corner | | 2026-10-24 | trivial | Columns dialog: the number of text columns and the spacing between them. Dialog count 41 → 42 |