From c2d8947640dcb036339f8abf3854d07139aee2a2 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:14:33 -0600 Subject: [PATCH 01/22] feat(shell): route the selection verbs through the engine The Select menu's three verbs never reached the engine: Select All and Select Inverse read the editor's current cell (the active layer's cell in the current state) and copied its ids into the view's selection, while Deselect emptied the view and left the document's selection where the last bridged command had put it. CS6's dom.selectAll() covers every element of the page in the current frame, across layers. The shell covered one layer, so Select Inverse complemented a smaller set than CS6 does. The three verbs now dispatch selection.selectAll, selection.selectNone and selection.selectInverse and adopt the document's answer as the view's selection; Select Inverse syncs the view's selection into the document first, the idiom Select Parents already uses. The Layers panel gains "select this layer's objects" (selection.selectAllOnLayer). A deselect with nothing selected sends no command, so no undo step records a change that did not happen. Decision 0104. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 212 +++++++++++++++--- crates/app/src/shell/panels.rs | 7 + ...s-selection-verbs-go-through-the-engine.md | 81 +++++++ 3 files changed, 272 insertions(+), 28 deletions(-) create mode 100644 docs/decisions/0104-the-shells-selection-verbs-go-through-the-engine.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 5aaf469..c72b894 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -1585,19 +1585,8 @@ impl Shell { Ok(name) => self.status = format!("redid {name}"), Err(error) => self.status = format!("redo failed: {error}"), }, - menus::ShellAction::Deselect => { - self.editor.set_selection(Vec::new()); - self.status = "deselected".to_owned(); - } - menus::ShellAction::SelectAll => { - let ids = self - .editor - .current_cell() - .map_or_else(Vec::new, |cell| cell.elements.clone()); - let picked = ids.len(); - self.editor.set_selection(ids); - self.status = format!("selected {picked} object(s)"); - } + menus::ShellAction::Deselect => self.deselect(), + menus::ShellAction::SelectAll => self.select_all(), menus::ShellAction::SelectInverse => self.select_inverse(), menus::ShellAction::Copy => self.clipboard_copy(false), menus::ShellAction::Cut => self.clipboard_copy(true), @@ -1836,23 +1825,84 @@ impl Shell { } } - /// Select ▸ Select Inverse: everything in the current cell the - /// selection does not cover. The cell is the editor's scope (one - /// layer, one state) — the same scope Select All covers. + /// Select ▸ Select All: CS6's `dom.selectAll()` — every object of + /// the page in the current state, across layers. + /// + /// The scope belongs to the engine, so the selection goes through + /// `selection.selectAll`: the command door journals it, undo can + /// take it back, and the scripting host sees the same scope the + /// menu does. The view follows the document's selection. + fn select_all(&mut self) { + let payload = serde_json::json!({ + "page": self.editor.page, + "frame": self.editor.frame, + }); + if !self.run_command("selection.selectAll", payload) { + return; + } + self.editor.adopt_document_selection(); + let picked = self.editor.live_selection().len(); + self.status = format!("selected {picked} object(s)"); + } + + /// Select ▸ Deselect: CS6's `dom.selectNone()` — the object + /// selection goes empty. The engine clears it, so the document and + /// the view agree afterwards. + fn deselect(&mut self) { + if self.editor.live_selection().is_empty() + && self.editor.document().object_selection.is_empty() + { + // Nothing to clear — the command would be a no-op that + // only adds an undo step. + self.status = "deselected".to_owned(); + return; + } + if !self.run_command("selection.selectNone", serde_json::json!({})) { + return; + } + self.editor.adopt_document_selection(); + self.status = "deselected".to_owned(); + } + + /// Select ▸ Select Inverse: everything in the page-and-state scope + /// the selection does not cover. The complement is the engine's, + /// so the view's selection lands in the document first and the + /// document's answer comes back as the view's selection. fn select_inverse(&mut self) { - let selected = self.editor.live_selection(); - let ids: Vec = self - .editor - .current_cell() - .map_or_else(Vec::new, |cell| cell.elements.clone()) - .into_iter() - .filter(|id| !selected.contains(id)) - .collect(); - let picked = ids.len(); - self.editor.set_selection(ids); + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + let payload = serde_json::json!({ + "page": self.editor.page, + "frame": self.editor.frame, + }); + if !self.run_command("selection.selectInverse", payload) { + return; + } + self.editor.adopt_document_selection(); + let picked = self.editor.live_selection().len(); self.status = format!("selected {picked} object(s)"); } + /// The Layers panel's "select this layer's objects": CS6's + /// `dom.selectAllOnLayer(layerIndex, bRememberSelection, + /// bToggleSelection)` with the plain replace semantics. The + /// engine scopes it to one layer and state; the view follows. + pub(super) fn select_layer_objects(&mut self, layer: usize) { + let payload = serde_json::json!({ + "page": self.editor.page, + "layer": layer, + "frame": self.editor.frame, + }); + if !self.run_command("selection.selectAllOnLayer", payload) { + return; + } + self.editor.adopt_document_selection(); + let picked = self.editor.live_selection().len(); + self.status = format!("selected {picked} object(s) on layer {layer}"); + } + /// Edit ▸ Copy / Cut: the engine's clipboard commands read the /// document's selection, so the view's selection lands there /// first. A cut takes the objects with it, so the view's selection @@ -4600,9 +4650,10 @@ mod tests { } /// Select ▸ Select Inverse: the complement of the selection within - /// the cell the editor edits. + /// the page the editor edits — every object of the page and state + /// the selection does not cover. #[test] - fn select_inverse_complements_the_cell() { + fn select_inverse_complements_the_page() { let mut shell = gui_shell(gui_editor_two()); let ids = shell.editor.current_cell().unwrap().elements.clone(); shell.editor.set_selection(vec![ids[0]]); @@ -4610,6 +4661,111 @@ mod tests { assert_eq!(shell.editor.selection_set, vec![ids[1]]); } + /// A rect on the editor's active layer, for the cross-layer + /// selection tests. + fn rect_on_active_layer(editor: &mut Editor, x: f64, y: f64) -> doc::ObjectId { + editor.tool = Tool::Rect; + editor.begin_drag(geom::Point::new(x, y)); + editor.drag_to(geom::Point::new(x + 60.0, y + 40.0)); + editor.end_drag().unwrap(); + editor + .current_cell() + .unwrap() + .elements + .last() + .copied() + .unwrap() + } + + /// Select ▸ Select All: CS6's `dom.selectAll()` covers every object + /// of the page in the current state — across layers, not only the + /// active one — and the selection goes through the engine's + /// command door, so the journal carries it. + #[test] + fn select_all_covers_every_layer_of_the_page() { + let mut editor = gui_editor(); + let lower = editor.current_cell().unwrap().elements[0]; + editor + .apply("layer.add", serde_json::json!({ "page": 0 })) + .unwrap(); + editor.set_active_layer(1); + let upper = rect_on_active_layer(&mut editor, 60.0, 60.0); + assert_ne!(lower, upper); + + let mut shell = gui_shell(editor); + let journal_before = shell.editor.session.journal_len(); + shell.run_action(&menus::Action::Shell(menus::ShellAction::SelectAll)); + assert_eq!( + shell.editor.selection_set.len(), + 2, + "select all covers both layers" + ); + assert!( + shell.editor.selection_set.contains(&lower) + && shell.editor.selection_set.contains(&upper), + "select all picks the objects of every layer" + ); + assert!( + shell.editor.session.journal_len() > journal_before, + "the selection went through the command door" + ); + assert!( + shell + .editor + .session + .journal_lines() + .contains("selection.selectAll"), + "the journal names the command" + ); + } + + /// Select ▸ Deselect: the object selection goes empty in the + /// document as well as in the view — CS6's `dom.selectNone()`. + #[test] + fn deselect_clears_the_document_selection() { + let mut shell = gui_shell(gui_editor()); + shell.run_action(&menus::Action::Shell(menus::ShellAction::SelectAll)); + assert!(!shell.editor.document().object_selection.is_empty()); + shell.run_action(&menus::Action::Shell(menus::ShellAction::Deselect)); + assert!( + shell.editor.document().object_selection.is_empty(), + "the document's selection is empty" + ); + assert!( + shell.editor.selection_set.is_empty(), + "the view's selection is empty" + ); + } + + /// The Layers panel's "select this layer's objects": the engine's + /// `selection.selectAllOnLayer` scopes the selection to one layer + /// and state, whatever else the page carries. + #[test] + fn select_layer_objects_scopes_to_one_layer() { + let mut editor = gui_editor(); + let lower = editor.current_cell().unwrap().elements[0]; + editor + .apply("layer.add", serde_json::json!({ "page": 0 })) + .unwrap(); + editor.set_active_layer(1); + rect_on_active_layer(&mut editor, 60.0, 60.0); + let upper = editor.document().pages[0].layers.layers[1].cells[0].elements[0]; + + let mut shell = gui_shell(editor); + shell.select_layer_objects(0); + assert_eq!( + shell.editor.selection_set, + vec![lower], + "only the named layer's object is selected" + ); + shell.select_layer_objects(1); + assert_eq!( + shell.editor.selection_set, + vec![upper], + "the other layer's object replaces it" + ); + } + /// Modify ▸ Group / Ungroup: the group lands in the cell where the /// members were, and ungroup puts the members back. #[test] diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 257973d..99df82b 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -327,6 +327,13 @@ impl Shell { { self.editor.set_active_layer(layer_index); } + if ui + .small_button("◉") + .on_hover_text("Select this layer's objects") + .clicked() + { + self.select_layer_objects(layer_index); + } if ui .small_button("✎") .on_hover_text("Rename this layer") diff --git a/docs/decisions/0104-the-shells-selection-verbs-go-through-the-engine.md b/docs/decisions/0104-the-shells-selection-verbs-go-through-the-engine.md new file mode 100644 index 0000000..30a38d8 --- /dev/null +++ b/docs/decisions/0104-the-shells-selection-verbs-go-through-the-engine.md @@ -0,0 +1,81 @@ +# 0104 — The shell's selection verbs go through the engine's selection door + +Date: 2026-11-12 · Issue: [#138](https://github.com/aliasfoxkde/FireCraft/issues/138) · +Status: accepted + +## Context + +The Select menu's three verbs — Select All, Deselect, Select Inverse — +never reached the engine. `Shell::run_action` read the editor's *current +cell* (the active layer's cell in the current state), copied its element +ids into the view's selection, and stopped there. Two things were wrong +with that. + +**The scope was not CS6's.** CS6's `dom.selectAll()` selects every +element of the page in the current frame — "all elements on all layers", +decision 0024. The shell selected the active layer's cell: one layer, one +state. A user with two layers pressed Ctrl+A and got half the page. +Select Inverse had the same scope, so "select everything that is not +selected" left the other layers' objects out of the complement — the +complement of a smaller set is a smaller set. + +**The selection was view state only.** The engine's selection-scoped +commands (the clipboard family, the flatten family, the filter family) +read `Document::object_selection`; the shell tracks the selection as view +state (`Editor::selection_set`). The two were bridged in one direction +only — `Editor::sync_selection` pushes the view's selection into the +document — and only where a command needed it. Select All and Deselect +never crossed that bridge, so the document's selection kept whatever the +last bridged command left there: "Deselect" did not deselect the +document, and a later selection-scoped command could act on a selection +the user had already emptied on screen. + +The engine already carried the right commands, with CS6's scopes: +`selection.selectAll`, `selection.selectNone`, `selection.selectInverse` +and `selection.selectAllOnLayer`. + +## Decision + +- **The three menu verbs dispatch the engine's commands** and adopt the + document's answer as the view's selection. The shell keeps no scope of + its own — the scope is the engine's, which is also the scope the + scripting host sees. +- **Select Inverse syncs first.** The engine's complement is taken + against the document's selection, so the view's selection lands there + before the command and comes back after it. This is the idiom the + Select Parents / Select Children rows already use. +- **The Layers panel gains "select this layer's objects"** — + `selection.selectAllOnLayer` with the plain replace semantics. It is a + FireCraft extension, not a CS6 menu row: the menu census is + unchanged. +- **A no-op deselect sends no command.** Clearing an already-empty + selection would record a journal entry and an undo step that changes + nothing. + +## Evidence + +- The engine's four commands carry CS6's scopes and decision 0024's + page-and-frame rule; the shell was the only place that reimplemented + a selection scope, and it reimplemented it smaller. +- `Editor::sync_selection` and `Editor::adopt_document_selection` are + the two bridges between the view's selection and the document's, and + the Select Parents row already used them. +- The reachability gate measured `selection.selectAll`, + `selection.selectNone`, `selection.selectInverse` and + `selection.selectAllOnLayer` as unbound: the engine could execute + them, the shell's designed surface could not reach them. + +## Consequences + +- Select All covers every object of the page in the current state, + across layers — CS6's scope. +- A selection made from the menu is journaled: undo takes it back and + the History panel lists it. +- Deselect empties the document's selection as well as the view's, so + the two cannot disagree. +- The reachability floor rises by four commands. The three selection + commands the shell already had rows for stay one row each; nothing in + the menu table changed. + +See also decision [0024](0024-object-selection.md) — the scope rule this +change finally reaches the menu through. From a2e23cc8a95494357d040dbb7ca6c76ced7c8052 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:16:52 -0600 Subject: [PATCH 02/22] feat(panels): the Pages panel renames and deletes pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pages panel listed the document's pages and let the user click one to make it current. The engine has carried page.setName and page.delete all along, but nothing in the shell reached them: a multi-page document accumulated unnamed pages that could not be removed from inside the application. The panel now carries a rename (an inline name field, Enter applies — the shape the Layers panel already uses) and a delete per row. The delete goes through page.delete, so the engine's refusal stands (the last page cannot go) and undo carries the page back; the current page index follows the shorter list. Decision 0105. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 36 ++++++++++ crates/app/src/shell/panels.rs | 65 ++++++++++++++++--- ...e-pages-panel-renames-and-deletes-pages.md | 56 ++++++++++++++++ 3 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 docs/decisions/0105-the-pages-panel-renames-and-deletes-pages.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index c72b894..9d94f11 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -167,6 +167,9 @@ pub struct Shell { /// The layer whose name is being edited in the Layers panel /// (`None` = no inline rename open). rename_layer: Option, + /// The page whose name is being edited in the Pages panel + /// (`None` = no inline rename open). + rename_page: Option, /// View-menu toggles + the open grid dialog. Display state only — /// the guides and grid themselves live in the document, so a move /// drag's snapping reads them through the editor like every other @@ -466,6 +469,7 @@ impl Shell { ui_scale: None, style_name: String::new(), rename_layer: None, + rename_page: None, show_grid: false, snap_to_grid: false, show_guides: true, @@ -4766,6 +4770,38 @@ mod tests { ); } + /// The Pages panel's rename and delete: `page.setName` renames the + /// page and `page.delete` removes it — and the last page cannot + /// go. + #[test] + fn the_pages_panel_renames_and_deletes_pages() { + let mut shell = gui_shell(gui_editor()); + shell.run_command("page.add", serde_json::json!({ "index": null })); + assert_eq!(shell.editor.document().pages.len(), 2); + shell.run_command( + "page.setName", + serde_json::json!({ "page": 1, "name": "second" }), + ); + assert_eq!( + shell.editor.document().pages[1].name.as_deref(), + Some("second"), + "the page carries its new name" + ); + shell.delete_page(1); + assert_eq!(shell.editor.document().pages.len(), 1, "the page is gone"); + shell.delete_page(0); + assert_eq!( + shell.editor.document().pages.len(), + 1, + "the last page stays" + ); + assert!( + shell.status.contains("failed"), + "the refusal is reported: {}", + shell.status + ); + } + /// Modify ▸ Group / Ungroup: the group lands in the cell where the /// members were, and ungroup puts the members back. #[test] diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 99df82b..26b1fd5 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -220,7 +220,8 @@ impl Shell { self.status = self.screen_mode.label().to_owned(); } - /// Pages section: one selectable row per page. + /// Pages section: one selectable row per page, with a rename and + /// a delete button beside it. pub(super) fn pages_section(&mut self, ui: &mut egui::Ui) { let pages = self.editor.document().pages.len(); for index in 0..pages { @@ -228,14 +229,60 @@ impl Shell { .name .clone() .unwrap_or_else(|| format!("Page {}", index + 1)); - if ui - .selectable_label(self.editor.page == index, &name) - .clicked() - { - self.editor.page = index; - self.raster = None; - } + let mut rename = name.clone(); + ui.horizontal(|ui| { + if self.rename_page == Some(index) { + let field = ui + .add(egui::TextEdit::singleline(&mut rename).desired_width(96.0)) + .on_hover_text("rename (Enter applies)"); + let enter = ui.input(|input| input.key_pressed(egui::Key::Enter)); + if (field.lost_focus() || enter) && rename != name { + self.run_command( + "page.setName", + serde_json::json!({ "page": index, "name": rename }), + ); + } + if field.lost_focus() || enter { + self.rename_page = None; + } + } else if ui + .selectable_label(self.editor.page == index, &name) + .on_hover_text("Make this the current page") + .clicked() + { + self.editor.page = index; + self.raster = None; + } + if ui + .small_button("✎") + .on_hover_text("Rename this page") + .clicked() + { + self.rename_page = Some(index); + } + if ui + .small_button("×") + .on_hover_text("Delete this page") + .clicked() + { + self.delete_page(index); + } + }); + } + } + + /// Delete a page through the engine's `page.delete` — the last + /// page cannot go (the engine refuses it), and the current page + /// index follows the shorter list. + pub(super) fn delete_page(&mut self, index: usize) { + if !self.run_command("page.delete", serde_json::json!({ "page": index })) { + return; + } + let count = self.editor.document().pages.len(); + if self.editor.page >= count { + self.editor.page = count.saturating_sub(1); } + self.raster = None; } /// Layers section: name + display toggle per layer, bottom-first. @@ -328,7 +375,7 @@ impl Shell { self.editor.set_active_layer(layer_index); } if ui - .small_button("◉") + .small_button("all") .on_hover_text("Select this layer's objects") .clicked() { diff --git a/docs/decisions/0105-the-pages-panel-renames-and-deletes-pages.md b/docs/decisions/0105-the-pages-panel-renames-and-deletes-pages.md new file mode 100644 index 0000000..57c22f3 --- /dev/null +++ b/docs/decisions/0105-the-pages-panel-renames-and-deletes-pages.md @@ -0,0 +1,56 @@ +# 0105 — The Pages panel renames and deletes pages + +Date: 2026-11-12 · Issue: [#139](https://github.com/aliasfoxkde/FireCraft/issues/139) · +Status: accepted + +## Context + +The Pages panel listed the document's pages and nothing else: a row +per page, a click to make it current. The engine has carried +`page.add`, `page.delete` and `page.setName` from the start, and the +shell's menu could add a page — but the panel could neither rename nor +delete one. A multi-page Fireworks document therefore accumulated +unnamed pages that could not be removed except by editing the file +outside the application. + +The gap is a surface gap, not a capability gap: the reachability gate +measured `page.delete` and `page.setName` as carried by the engine and +unbound by the shell. + +## Decision + +- **The Pages panel carries a rename and a delete per row**, in the + same shape as the Layers panel's rename: a "✎" button opens an + inline name field (Enter applies), and a "×" button deletes the + page. +- **The delete goes through `page.delete`**, so the engine's refusal + stands: the last page of a document cannot be deleted, and the + refusal reaches the status line. +- **The current page index follows the shorter list.** Deleting the + page the user is on moves them to the last remaining page rather + than leaving the index past the end. +- **Undo restores the page.** The command's delta carries the whole + page, so a mistaken delete is one undo away — the same bargain the + Layers panel's delete makes. + +## Evidence + +- `page.delete` refuses to remove the last page (`cannot delete the + last page`), and `page.setName` writes the page's name field. +- The Layers panel already renames and deletes layers this way, with + no confirmation dialog: the panel is the deliberate surface, and the + engine's refusal plus undo carry the edge cases. +- The Pages panel's row is a snapshot of the document's page list, so + a rename or a delete is visible in the next frame. + +## Consequences + +- Two more engine commands are reachable from the shell's designed + surface: `page.setName` and `page.delete`. +- The page rename follows the document, so a saved file carries the + names the user gave its pages. +- The shell's `rename_page` field mirrors `rename_layer`: one inline + rename open at a time. + +See also decision [0104](0104-the-shells-selection-verbs-go-through-the-engine.md) +— the same week's other step in closing the measured surface gap. From 3c08b67d8824d5573aefcc45235c3588c6ab679a Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:19:16 -0600 Subject: [PATCH 03/22] feat(panels): the Styles panel redefines and scales styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Styles panel could apply a style and import a library, and nothing else. The engine has carried style.redefineByIndex and style.scaleStyles since the start — the CS6 DOM verbs redefineStyleByIndex and scaleStyles — and the shell could reach neither. Each style row now carries a "redefine" button that captures the selection's appearance into that style through style.redefineByIndex, and the panel carries a "text scale" factor with a "Scale Styles" button that dispatches style.scaleStyles, which multiplies every library entry's text size. The engine's refusals stand: a non-positive factor is refused, and scaling an empty library is a no-op. Two more engine commands become reachable from the shell's designed surface. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 30 +++++++++ crates/app/src/shell/panels.rs | 63 ++++++++++++++++--- ...tyles-panel-redefines-and-scales-styles.md | 63 +++++++++++++++++++ 3 files changed, 146 insertions(+), 10 deletions(-) create mode 100644 docs/decisions/0106-the-styles-panel-redefines-and-scales-styles.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 9d94f11..fcbbb73 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -164,6 +164,8 @@ pub struct Shell { ui_scale: Option, /// The Styles panel's new-style name field. style_name: String, + /// The Styles panel's text-scale factor. + style_scale: f64, /// The layer whose name is being edited in the Layers panel /// (`None` = no inline rename open). rename_layer: Option, @@ -468,6 +470,7 @@ impl Shell { recovery_path: Self::default_recovery_path(), ui_scale: None, style_name: String::new(), + style_scale: 1.0, rename_layer: None, rename_page: None, show_grid: false, @@ -4802,6 +4805,33 @@ mod tests { ); } + /// The Styles panel's redefine and scale: the selection's + /// appearance becomes the named style, and the scale multiplies + /// every style's text size. + #[test] + fn the_styles_panel_redefines_and_scales_styles() { + let mut shell = gui_shell(gui_editor()); + let id = shell.editor.current_cell().unwrap().elements[0]; + shell.editor.set_selection(vec![id]); + shell.run_command( + "style.redefineFromSelection", + serde_json::json!({ "name": "mine", "source": id }), + ); + assert_eq!( + shell.editor.document().styles.len(), + 1, + "the style is defined" + ); + shell.redefine_style(0, id); + let before = shell.editor.document().styles[0].text.size; + shell.scale_styles(2.0); + assert_eq!( + shell.editor.document().styles[0].text.size, + before * 2.0, + "the scale multiplied the text size" + ); + } + /// Modify ▸ Group / Ungroup: the group lands in the cell where the /// members were, and ungroup puts the members back. #[test] diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 26b1fd5..9742b05 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -546,16 +546,26 @@ impl Shell { .collect(); let targets: Vec = self.editor.selection_set.clone(); for (index, label) in &rows { - if ui - .selectable_label(false, label) - .on_hover_text("apply onto the selection") - .clicked() - { - self.run_command( - "style.apply", - serde_json::json!({ "index": index, "ids": targets }), - ); - } + ui.horizontal(|ui| { + if ui + .selectable_label(false, label) + .on_hover_text("apply onto the selection") + .clicked() + { + self.run_command( + "style.apply", + serde_json::json!({ "index": index, "ids": targets.clone() }), + ); + } + if ui + .small_button("redefine") + .on_hover_text("redefine this style from the selection's appearance") + .clicked() + && let Some(source) = targets.last().copied() + { + self.redefine_style(*index, source); + } + }); } if rows.is_empty() { ui.label("(empty — define one from a selection or import a library)"); @@ -580,6 +590,39 @@ impl Shell { if ui.button("Import Style Library...").clicked() { self.import_styles_dialog(); } + ui.separator(); + ui.horizontal(|ui| { + ui.label("text scale"); + ui.add( + egui::DragValue::new(&mut self.style_scale) + .speed(0.05) + .range(0.1..=10.0), + ); + if ui + .button("Scale Styles") + .on_hover_text("multiplies every style's text size by the factor") + .clicked() + { + let scale = self.style_scale; + self.scale_styles(scale); + } + }); + } + + /// The Styles panel's per-row redefine: the named object's + /// appearance becomes the style at `index` — CS6's + /// `redefineStyleByIndex`. + pub(super) fn redefine_style(&mut self, index: usize, source: doc::ObjectId) { + self.run_command( + "style.redefineByIndex", + serde_json::json!({ "index": index, "source": source }), + ); + } + + /// The Styles panel's scale: every style's text size by the + /// factor — CS6's `scaleStyles`. + pub(super) fn scale_styles(&mut self, scale: f64) { + self.run_command("style.scaleStyles", serde_json::json!({ "scale": scale })); } /// Styles panel ▸ Import: reads a CS6 style library (`.stl`) diff --git a/docs/decisions/0106-the-styles-panel-redefines-and-scales-styles.md b/docs/decisions/0106-the-styles-panel-redefines-and-scales-styles.md new file mode 100644 index 0000000..5606e92 --- /dev/null +++ b/docs/decisions/0106-the-styles-panel-redefines-and-scales-styles.md @@ -0,0 +1,63 @@ +# 0106 — The Styles panel redefines and scales styles + +Date: 2026-11-12 · Issue: [#140](https://github.com/aliasfoxkde/FireCraft/issues/140) · +Status: accepted + +## Context + +The Styles panel could apply a style to the selection and import a +style library, and nothing else. The engine has carried +`style.redefineByIndex`, `style.redefineByName` and `style.scaleStyles` +from the start — the CS6 DOM verbs `redefineStyleByIndex`, +`redefineStyleByName` and `scaleStyles` — and the shell could not reach +any of them. + +The consequence was a style library that could only be edited outside +the application. Redefining a style is how a user keeps a style in sync +with the object they have just formatted; scaling the library is how a +design is rescaled across a set of type styles. In FireCraft both +required leaving the app. + +## Decision + +- **Each style row carries a "redefine" button.** It captures the + appearance of the current selection into that style, through + `style.redefineByIndex` — the row knows its index, so the index is + the addressing the engine wants. +- **The panel carries a "text scale" factor and a "Scale Styles" + button.** The button dispatches `style.scaleStyles` with the factor, + which multiplies every library entry's text size. It is one factor + for the whole library, because the CS6 verb is one factor for the + whole library. +- **The scale lives in the shell, not the document.** It is a control + value, like the rename field, and it starts at 1.0. +- **The engine's refusals stand.** A non-positive or non-finite factor + is refused by `style.scaleStyles`; scaling an empty library is a + no-op. Neither is reimplemented in the panel. + +## Evidence + +- `style.redefineByIndex {index, source}` captures the named object's + appearance into the library entry at `index`; `style.scaleStyles + {scale}` multiplies every entry's `text.size` and refuses a + non-positive factor. +- The Styles panel already bound `style.apply` and + `style.redefineFromSelection`; the two new commands are the same + shape of binding — a button in the panel, a command through the + engine's door, the document's answer in the next frame. +- The reachability gate measured `style.redefineByIndex`, + `style.redefineByName` and `style.scaleStyles` as carried by the + engine and unbound by the shell. + +## Consequences + +- Two more engine commands are reachable from the shell's designed + surface: `style.redefineByIndex` and `style.scaleStyles`. +- A style library can be kept in sync with the canvas and rescaled + without leaving the application. +- `style.redefineByName` remains unbound: it is the same capability as + the index form, addressed by name, and the panel addresses styles by + index. It stays in the registry for the scripting host. + +See also decision [0104](0104-the-shells-selection-verbs-go-through-the-engine.md) +— the same week's other step in closing the measured surface gap. From d3e94f8216c64f58e50f4632c84ec0de48ef0bc6 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:21:56 -0600 Subject: [PATCH 04/22] feat(panels): the States panel reorders the states and fans the selection The States panel could add, duplicate and delete a state, set its delay and turn onion-skinning on. It could not move a state, and it could not put the selection onto the page's other states. "Move left" and "Move right" now dispatch frame.reorder for the current state. The selection gains three fans: selection.duplicateToFrames ("next", "all"), selection.duplicateToFrameRange (the "from"/"to" row) and selection.distributeToFrames. Every fan syncs the view's selection into the document first, the same bridge the Select menu's verbs use. The active layer's cell in the current state gains its two flags through cell.setVisible and cell.setLocked. Six more engine commands become reachable from the shell's designed surface. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 6 + crates/app/src/shell/timeline.rs | 311 ++++++++++++++++++ ...s-panel-reorders-and-fans-the-selection.md | 78 +++++ 3 files changed, 395 insertions(+) create mode 100644 docs/decisions/0107-the-states-panel-reorders-and-fans-the-selection.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index fcbbb73..88f771b 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -166,6 +166,10 @@ pub struct Shell { style_name: String, /// The Styles panel's text-scale factor. style_scale: f64, + /// The States panel's range fan: the first and last state the + /// selection's range fan covers (`-1` = the current state). + fan_first: i64, + fan_last: i64, /// The layer whose name is being edited in the Layers panel /// (`None` = no inline rename open). rename_layer: Option, @@ -471,6 +475,8 @@ impl Shell { ui_scale: None, style_name: String::new(), style_scale: 1.0, + fan_first: 0, + fan_last: 0, rename_layer: None, rename_page: None, show_grid: false, diff --git a/crates/app/src/shell/timeline.rs b/crates/app/src/shell/timeline.rs index 4578b46..a22fef6 100644 --- a/crates/app/src/shell/timeline.rs +++ b/crates/app/src/shell/timeline.rs @@ -174,6 +174,118 @@ impl Shell { } }); + // The state's place in the strip: CS6's reorderFrame, as a + // move one place left and a move one place right. + ui.horizontal(|ui| { + if ui + .button("Move left") + .on_hover_text("move this state one place left in the strip") + .clicked() + { + self.move_state(page, current, -1); + } + if ui + .button("Move right") + .on_hover_text("move this state one place right in the strip") + .clicked() + { + self.move_state(page, current, 1); + } + }); + + // CS6's selection fans: the selection goes onto the page's + // other states. + ui.horizontal(|ui| { + if ui + .button("Duplicate to next") + .on_hover_text( + "CS6's duplicateSelectionToFrames: the selection is copied onto the next state", + ) + .clicked() + { + self.duplicate_selection_to_frames(page, current, "next"); + } + if ui + .button("Duplicate to all") + .on_hover_text("the selection is copied onto every state of the page") + .clicked() + { + self.duplicate_selection_to_frames(page, current, "all"); + } + if ui + .button("Distribute selection") + .on_hover_text( + "CS6's distributeSelectionToFrames: the selected items move one per state", + ) + .clicked() + { + self.distribute_selection_to_frames(page, current); + } + }); + ui.horizontal(|ui| { + let mut first = self.fan_first; + let mut last = self.fan_last; + let first_field = ui.add( + egui::DragValue::new(&mut first) + .speed(1.0) + .range(-1..=64) + .prefix("from "), + ); + let last_field = ui.add( + egui::DragValue::new(&mut last) + .speed(1.0) + .range(-1..=64) + .prefix("to "), + ); + if first_field.changed() { + self.fan_first = first; + } + if last_field.changed() { + self.fan_last = last; + } + if ui + .button("Duplicate to range") + .on_hover_text("CS6's duplicateSelectionToFrameRange: the selection is copied across the range") + .clicked() + { + let (first, last) = (self.fan_first, self.fan_last); + self.duplicate_selection_to_frame_range(page, current, first, last); + } + }); + + // The active layer's cell in the current state: visible and + // locked. The flags live on the cell, so they belong to the + // layer, not to the page. + let layer = self.editor.active_layer(); + let flags = self + .editor + .document() + .pages + .get(page) + .and_then(|page| page.layers.layers.get(layer)) + .and_then(|layer| layer.cells.get(current)) + .map(|cell| (cell.visible, cell.locked)); + if let Some((visible, locked)) = flags { + ui.horizontal(|ui| { + let mut visible = visible; + if ui + .checkbox(&mut visible, "visible") + .on_hover_text("show the active layer's cell in this state") + .changed() + { + self.set_cell_visible(page, layer, current, visible); + } + let mut locked = locked; + if ui + .checkbox(&mut locked, "locked") + .on_hover_text("lock the active layer's cell in this state") + .changed() + { + self.set_cell_locked(page, layer, current, locked); + } + }); + } + // The onion-skinning counts: how many states ghost behind the // current one on each side. Zero on both sides is off. ui.horizontal(|ui| { @@ -284,6 +396,107 @@ impl Shell { serde_json::json!({ "page": page, "layer": layer, "cell": current }), ); } + + /// CS6's reorderFrame: the state at `current` moves one place + /// left (`step` -1) or right (`step` 1) in the strip. + /// + /// The engine's `before` is an index in the strip's own + /// numbering, and it shifts the list by one when the moved state + /// came first — so a move to the right names the state *after* + /// the target, and a move to the left names the target itself. + fn move_state(&mut self, page: usize, current: usize, step: i32) { + let total = self + .editor + .document() + .pages + .get(page) + .map_or(1, |page| page.info.frame_count.max(1) as usize); + let before = if step < 0 { + if current == 0 { + return; + } + current - 1 + } else { + if current + 1 >= total { + return; + } + current + 2 + }; + self.run_command( + "frame.reorder", + serde_json::json!({ + "page": page, "from": current, "before": before, "make_copy": false, + }), + ); + } + + /// CS6's duplicateSelectionToFrames: the selection is copied onto + /// the states the fan names — "next", "previous", "end" or + /// "all". + fn duplicate_selection_to_frames(&mut self, page: usize, current: usize, which: &str) { + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command( + "selection.duplicateToFrames", + serde_json::json!({ "page": page, "frame": current, "which": which }), + ); + } + + /// CS6's duplicateSelectionToFrameRange: the selection is copied + /// across an inclusive range of states. + fn duplicate_selection_to_frame_range( + &mut self, + page: usize, + current: usize, + first: i64, + last: i64, + ) { + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command( + "selection.duplicateToFrameRange", + serde_json::json!({ + "page": page, "frame": current, "first": first, "last": last, + }), + ); + } + + /// CS6's distributeSelectionToFrames: the selected items move + /// one per state, starting at the current one. + fn distribute_selection_to_frames(&mut self, page: usize, current: usize) { + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command( + "selection.distributeToFrames", + serde_json::json!({ "page": page, "frame": current }), + ); + } + + /// The active layer's cell in one state: shown or hidden. + fn set_cell_visible(&mut self, page: usize, layer: usize, cell: usize, visible: bool) { + self.run_command( + "cell.setVisible", + serde_json::json!({ + "page": page, "layer": layer, "cell": cell, "visible": visible, + }), + ); + } + + /// The active layer's cell in one state: locked or unlocked. + fn set_cell_locked(&mut self, page: usize, layer: usize, cell: usize, locked: bool) { + self.run_command( + "cell.setLocked", + serde_json::json!({ + "page": page, "layer": layer, "cell": cell, "locked": locked, + }), + ); + } } #[cfg(test)] @@ -497,4 +710,102 @@ mod tests { assert_eq!(state_count(&shell), 1); assert_eq!(elements(&shell, layer, 0).len(), 1); } + + /// The cell flags of one layer's one state. + fn flags(shell: &Shell, layer: usize, state: usize) -> (bool, bool) { + let cell = &shell.editor.document().pages[0].layers.layers[layer].cells[state]; + (cell.visible, cell.locked) + } + + #[test] + fn moving_a_state_left_swaps_it_with_the_one_before() { + let mut shell = shell_with_states(3); + let layer = shell.editor.active_layer(); + put_rect(&mut shell, layer, 0, 10.0); + put_rect(&mut shell, layer, 2, 40.0); + let moved = elements(&shell, layer, 2)[0]; + shell.move_state(0, 2, -1); + // The state that sat third now sits second, and the empty + // state takes its place. + assert_eq!(elements(&shell, layer, 1), vec![moved]); + assert!(elements(&shell, layer, 2).is_empty()); + } + + #[test] + fn moving_a_state_right_swaps_it_with_the_one_after() { + let mut shell = shell_with_states(3); + let layer = shell.editor.active_layer(); + put_rect(&mut shell, layer, 0, 10.0); + put_rect(&mut shell, layer, 1, 40.0); + let moved = elements(&shell, layer, 1)[0]; + shell.move_state(0, 1, 1); + assert_eq!(elements(&shell, layer, 2), vec![moved]); + assert!(elements(&shell, layer, 1).is_empty()); + } + + #[test] + fn the_first_and_last_states_do_not_move_off_the_strip() { + let mut shell = shell_with_states(2); + let layer = shell.editor.active_layer(); + put_rect(&mut shell, layer, 0, 10.0); + let first = elements(&shell, layer, 0)[0]; + shell.move_state(0, 0, -1); + shell.move_state(0, 1, 1); + assert_eq!(elements(&shell, layer, 0), vec![first]); + } + + #[test] + fn a_cell_hides_and_locks_through_the_commands() { + let mut shell = shell_with_states(2); + let layer = shell.editor.active_layer(); + assert_eq!(flags(&shell, layer, 0), (true, false)); + shell.set_cell_visible(0, layer, 0, false); + shell.set_cell_locked(0, layer, 0, true); + assert_eq!(flags(&shell, layer, 0), (false, true)); + // The other state keeps its own flags. + assert_eq!(flags(&shell, layer, 1), (true, false)); + } + + #[test] + fn the_selection_is_copied_onto_the_next_state() { + let mut shell = shell_with_states(2); + let layer = shell.editor.active_layer(); + put_rect(&mut shell, layer, 0, 10.0); + let id = elements(&shell, layer, 0)[0]; + shell.editor.set_selection(vec![id]); + shell.duplicate_selection_to_frames(0, 0, "next"); + // The copy is a new object on the next state. + assert_eq!(elements(&shell, layer, 0).len(), 1); + assert_eq!(elements(&shell, layer, 1).len(), 1); + assert_ne!(elements(&shell, layer, 1)[0], id); + } + + #[test] + fn the_selection_distributes_one_item_per_state() { + let mut shell = shell_with_states(1); + let layer = shell.editor.active_layer(); + put_rect(&mut shell, layer, 0, 10.0); + put_rect(&mut shell, layer, 0, 40.0); + let ids = elements(&shell, layer, 0); + shell.editor.set_selection(ids.clone()); + shell.distribute_selection_to_frames(0, 0); + // Two items, one per state — the page grew to hold them. + assert_eq!(state_count(&shell), 2); + assert_eq!(elements(&shell, layer, 0), vec![ids[0]]); + assert_eq!(elements(&shell, layer, 1), vec![ids[1]]); + } + + #[test] + fn the_selection_is_copied_across_a_range_of_states() { + let mut shell = shell_with_states(3); + let layer = shell.editor.active_layer(); + put_rect(&mut shell, layer, 0, 10.0); + let id = elements(&shell, layer, 0)[0]; + shell.editor.set_selection(vec![id]); + shell.duplicate_selection_to_frame_range(0, 0, 1, 2); + // The range skipped the source state and covered the other two. + assert_eq!(elements(&shell, layer, 0).len(), 1); + assert_eq!(elements(&shell, layer, 1).len(), 1); + assert_eq!(elements(&shell, layer, 2).len(), 1); + } } diff --git a/docs/decisions/0107-the-states-panel-reorders-and-fans-the-selection.md b/docs/decisions/0107-the-states-panel-reorders-and-fans-the-selection.md new file mode 100644 index 0000000..c88e6b2 --- /dev/null +++ b/docs/decisions/0107-the-states-panel-reorders-and-fans-the-selection.md @@ -0,0 +1,78 @@ +# 0107 — The States panel reorders the states and fans the selection + +Date: 2026-11-12 · Issue: [#139](https://github.com/aliasfoxkde/FireCraft/issues/139) · +Status: accepted + +## Context + +The States panel could add, duplicate and delete a state, set its +delay and turn onion-skinning on. It could not move a state, and it +could not put the *selection* onto the page's other states. + +The engine has carried all of it: `frame.reorder` is CS6's +`dom.reorderFrame`; `selection.duplicateToFrames` is +`dom.duplicateSelectionToFrames`; `selection.duplicateToFrameRange` is +`dom.duplicateSelectionToFrameRange`; `selection.distributeToFrames` +is `dom.distributeSelectionToFrames`; and `cell.setVisible` / +`cell.setLocked` carry the two flags a cell can hold. The panel had no +row for any of them. + +The two selection fans matter most for the animation workflow the +panel exists for: an animator draws a run of instances on one state +and wants them spread across the states, or wants one drawing copied +onto every state. The panel's "Distribute" button did the first of +those for a *layer* (`frame.distributeLayer`), never for the +selection the user actually has on the canvas. + +## Decision + +- **The strip gains a move.** "Move left" and "Move right" dispatch + `frame.reorder` for the current state. The engine's `before` is an + index in the strip's own numbering, and it shifts the list by one + when the moved state came first — so a move to the right names the + state *after* the target, and a move to the left names the target + itself. The panel computes that, not the engine. +- **The selection gains three fans.** "Duplicate to next" and + "Duplicate to all" dispatch `selection.duplicateToFrames`; the + range row ("from" / "to") dispatches + `selection.duplicateToFrameRange`; "Distribute selection" dispatches + `selection.distributeToFrames`. +- **Every fan syncs the view's selection into the document first.** + The engine's fans read the document's selection, and the view's + selection is the one the user sees — the same bridge the Select + menu's verbs use (decision 0104). +- **The active layer's cell in the current state gains its two + flags.** A "visible" checkbox and a "locked" checkbox dispatch + `cell.setVisible` and `cell.setLocked`. The flags live on the cell, + so they belong to a layer rather than to the page — the panel says + so, and offers them only for a cell the active layer actually has. + +## Evidence + +- `frame.reorder` refuses an out-of-range `from` or `before`, and + treats a move onto its own slot as CS6's no-op. +- `selection.duplicateToFrames` takes a fan — "all", "previous", + "next" or "end" — and `selection.duplicateToFrameRange` takes an + inclusive range whose `-1` names the current frame; the range never + creates frames. +- `selection.distributeToFrames` moves the items one per state from + the source state and adds frames when the run outruns the page; one + item is the documented no-op. +- The reachability gate measured all six commands as carried by the + engine and unbound by the shell. + +## Consequences + +- Six more engine commands are reachable from the shell's designed + surface: `frame.reorder`, `cell.setVisible`, `cell.setLocked`, + `selection.duplicateToFrames`, `selection.duplicateToFrameRange` + and `selection.distributeToFrames`. +- The animation workflow can be driven from the panel alone: draw, + fan, reorder. +- The panel carries two more control values — the range fan's "from" + and "to" — in the shell's state, not the document's, because they + are a control, not content. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md) +and [0106](0106-the-styles-panel-redefines-and-scales-styles.md) — the +same week's other steps in closing the measured surface gap. From 7b8ded50b4a448bd9d2d79ee5b91ce04b4f5cc81 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:25:21 -0600 Subject: [PATCH 05/22] feat(panels): the Properties panel writes the text object's layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text properties showed characters, font, size and the style flags, and nothing of the layout. A text box in FireCraft behaved as a fixed rectangle: no paragraph indent, no space above or below a paragraph, and no way to tighten the frame around the text. The panel now carries the paragraph's indent, spacing before and spacing after, each read from the object's text style and written through text.setTextParaIndent, text.setTextParaSpacingBefore and text.setTextParaSpacingAfter. A text item on a path carries its offset through text.setTextOnPathOffset, and "Fit box to text" dispatches text.setTextRectangleAuto — CS6's smallest box around the text. The two selection-scoped setters sync the view's selection into the document first, the same bridge the Select menu's verbs use. Five more engine commands become reachable from the shell's designed surface. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 37 +++++ crates/app/src/shell/panels.rs | 142 ++++++++++++++++++ ...properties-panel-writes-the-text-layout.md | 69 +++++++++ 3 files changed, 248 insertions(+) create mode 100644 docs/decisions/0108-the-properties-panel-writes-the-text-layout.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 88f771b..af4ac98 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -4838,6 +4838,43 @@ mod tests { ); } + /// The Properties panel's text layout: the paragraph's indent + /// and spacing reach the text's style. + #[test] + fn the_text_properties_write_the_paragraph_layout() { + let (mut shell, id) = text_shell("hello"); + shell.set_paragraph_indent(id, 12.0); + shell.set_paragraph_spacing_before(id, 4.0); + shell.set_paragraph_spacing_after(id, 6.0); + let style = match shell.editor.document().registry.get(id) { + Some(ObjectData::Text(text)) => text.style.clone(), + _ => None, + }; + let style = style.expect("the text carries a style"); + assert_eq!(style.paragraph_indent, Some(12.0)); + assert_eq!(style.space_before, Some(4.0)); + assert_eq!(style.space_after, Some(6.0)); + } + + /// "Fit box to text": the frame shrinks to the text it holds. + #[test] + fn the_text_box_fits_its_text() { + let (mut shell, id) = text_shell("hello"); + let frame = + |shell: &Shell, id: doc::ObjectId| match shell.editor.document().registry.get(id) { + Some(ObjectData::Text(text)) => text.frame, + _ => panic!("the text is there"), + }; + let before = frame(&shell, id); + shell.fit_text_box(); + let after = frame(&shell, id); + assert!( + after.width() < before.width(), + "the frame shrank to the text: {before:?} -> {after:?}" + ); + assert!(after.width() > 0.0, "the frame still holds the text"); + } + /// Modify ▸ Group / Ungroup: the group lands in the cell where the /// members were, and ungroup puts the members back. #[test] diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 9742b05..1a1c687 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -1502,9 +1502,151 @@ impl Shell { name: text.name.clone(), }, ); + self.text_layout_fields(ui, id, text); self.geometry_fields(ui, id); } + /// The text object's layout: the paragraph's indent and spacing, + /// the on-path offset, and the frame that fits the text. + /// + /// The paragraph values live on the text style (`PINf`, `PSBf`, + /// `PSAf`), so the panel reads them from the style and writes + /// them through the three paragraph setters. + pub(super) fn text_layout_fields( + &mut self, + ui: &mut egui::Ui, + id: doc::ObjectId, + text: &TextObject, + ) { + let mut indent = text + .style + .as_ref() + .and_then(|style| style.paragraph_indent) + .unwrap_or(0.0); + let mut before = text + .style + .as_ref() + .and_then(|style| style.space_before) + .unwrap_or(0.0); + let mut after = text + .style + .as_ref() + .and_then(|style| style.space_after) + .unwrap_or(0.0); + ui.horizontal(|ui| { + let indent_field = ui.add( + egui::DragValue::new(&mut indent) + .speed(1.0) + .range(-1000.0..=1000.0) + .prefix("indent "), + ); + let before_field = ui.add( + egui::DragValue::new(&mut before) + .speed(1.0) + .range(-1000.0..=1000.0) + .prefix("before "), + ); + let after_field = ui.add( + egui::DragValue::new(&mut after) + .speed(1.0) + .range(-1000.0..=1000.0) + .prefix("after "), + ); + if indent_field.changed() { + self.set_paragraph_indent(id, indent); + } + if before_field.changed() { + self.set_paragraph_spacing_before(id, before); + } + if after_field.changed() { + self.set_paragraph_spacing_after(id, after); + } + }); + // The on-path offset: where the text starts along its guide + // path (`CHOf`). + if let Some(on_path) = text.on_path.as_ref() { + let mut offset = on_path.offset; + if ui + .add( + egui::DragValue::new(&mut offset) + .speed(1.0) + .range(-10_000.0..=10_000.0) + .prefix("offset "), + ) + .on_hover_text("where the text starts along its path") + .changed() + { + self.set_on_path_offset(offset); + } + } + if ui + .button("Fit box to text") + .on_hover_text("CS6's setTextRectangleAuto: the frame shrinks to the text it holds") + .clicked() + { + self.fit_text_box(); + } + } + + /// The paragraph's indent, in pixels (`PINf`). + pub(super) fn set_paragraph_indent(&mut self, id: doc::ObjectId, indent: f64) { + let _ = self.editor.apply( + "text.setTextParaIndent", + serde_json::json!({ "id": id, "para_indent": indent }), + ); + } + + /// The space above the paragraph, in pixels (`PSBf`). + pub(super) fn set_paragraph_spacing_before(&mut self, id: doc::ObjectId, before: f64) { + let _ = self.editor.apply( + "text.setTextParaSpacingBefore", + serde_json::json!({ "id": id, "para_space_before": before }), + ); + } + + /// The space below the paragraph, in pixels (`PSAf`). + pub(super) fn set_paragraph_spacing_after(&mut self, id: doc::ObjectId, after: f64) { + let _ = self.editor.apply( + "text.setTextParaSpacingAfter", + serde_json::json!({ "id": id, "para_space_after": after }), + ); + } + + /// Where the text starts along its guide path (`CHOf`). + pub(super) fn set_on_path_offset(&mut self, offset: f64) { + // The setter walks the document's selection, so the view's + // selection lands there first. + if self.sync_document_selection() { + let _ = self.editor.apply( + "text.setTextOnPathOffset", + serde_json::json!({ "offset": offset }), + ); + } + } + + /// CS6's setTextRectangleAuto: the frame shrinks to the text it + /// holds. + pub(super) fn fit_text_box(&mut self) { + // The setter reads the document's selection, so the view's + // selection lands there first. + if self.sync_document_selection() { + let _ = self + .editor + .apply("text.setTextRectangleAuto", serde_json::json!({})); + } + } + + /// The text commands that read the document's selection need the + /// view's selection to land there first — the same bridge the + /// Select menu's verbs use (decision 0104). + pub(super) fn sync_document_selection(&mut self) -> bool { + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return false; + } + true + } + /// Document properties (22.4): the field set the inspector shows /// when nothing is selected — CS6's document tab. The canvas /// geometry, the resolution with its unit, and the canvas diff --git a/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md b/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md new file mode 100644 index 0000000..2f630b4 --- /dev/null +++ b/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md @@ -0,0 +1,69 @@ +# 0108 — The Properties panel writes the text object's layout + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +The Properties panel showed a text object's characters, its font, its +size and its bold/italic/underline flags. It showed nothing of the +*layout*: a paragraph's indent, the space above and below it, where a +text-on-a-path item starts along its path, and the frame the text +flows inside. + +The engine has carried all of it: `text.setTextParaIndent`, +`text.setTextParaSpacingBefore` and `text.setTextParaSpacingAfter` +write the `TFSv` paragraph attributes (`PINf`, `PSBf`, `PSAf`), +`text.setTextOnPathOffset` writes the `CHOf` start offset, and +`text.setTextRectangleAuto` measures the laid-out block and shrinks +the frame around it. + +A text box in FireCraft therefore behaved as a fixed rectangle: the +user could not indent a paragraph, could not space paragraphs apart, +and could not tighten the frame around the text — the three things +every word processor offers in the same place. + +## Decision + +- **The text properties carry the paragraph's three values.** Each is + read from the object's text style and written through the matching + paragraph setter — the engine owns the values, the panel owns the + field. +- **A text item on a path carries its offset.** The offset field is + offered only for a text object that has a path, and writes + `text.setTextOnPathOffset`. +- **"Fit box to text" measures and writes the frame.** + `text.setTextRectangleAuto` is CS6's `dom.setTextRectangleAuto()`: + the frame becomes the smallest box that encloses the text. +- **The two selection-scoped setters sync the view's selection into + the document first.** `text.setTextOnPathOffset` and + `text.setTextRectangleAuto` read the *document's* selection, while + the panel shows the *view's* selection — so the panel bridges them + the same way the Select menu's verbs do (decision 0104). + +## Evidence + +- The paragraph setters write `style.paragraph_indent`, + `style.space_before` and `style.space_after` on the object's + `TextStyle` — the same fields the panel reads. +- `text.setTextRectangleAuto` measures the block with no width + constraint and writes the frame left/top-anchored; the fit refuses + a block that lays out to no box. +- `text.setTextOnPathOffset` walks the document's selection and skips + items that are not on a path. + +## Consequences + +- Five more engine commands are reachable from the shell's designed + surface: `text.setTextParaIndent`, + `text.setTextParaSpacingBefore`, `text.setTextParaSpacingAfter`, + `text.setTextOnPathOffset` and `text.setTextRectangleAuto`. +- The text box behaves like a word processor's: paragraphs are laid + out, and the frame can follow the text. +- The panel's custom anti-alias fields stay unbound: the engine has + no command for the anti-alias *preset*, so a sharpness or strength + field would have nothing to switch it on with. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md) +and [0107](0107-the-states-panel-reorders-and-fans-the-selection.md) — +the same week's other steps in closing the measured surface gap. From 22e282271030a248c7538d162b4ddf2c4908dd48 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:26:48 -0600 Subject: [PATCH 06/22] docs(tasks): record the reachability campaign on the live board The board's refresh rule asks for the same change that lands the work. The reachability campaign's row names the branch, the five decision records and the fourteen commands the branch binds. Co-Authored-By: Claude Code --- docs/TASKS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/TASKS.md b/docs/TASKS.md index f16e778..69ef9e4 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,6 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds fourteen of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), and the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)). Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight From 48062e2a00abcdd22d2025c8344fadee89b52079 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:34:01 -0600 Subject: [PATCH 07/22] feat(panels): the text dials, guides, layer move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reachability gate measured the engine commands the shell's designed surface could not reach. This batch binds nine more of them, with no menu row added. The Properties panel gains the text object's rendering: the three custom anti-alias dials (sharpness, strength, oversampling), the run list, the frame the text flows inside, and the fitted box anchored at the frame's corner (decisions 0109). Decision 0108 had recorded the anti-alias dials as unbound because the engine carries no anti-alias preset setter; that was wrong — the three custom dials have setters of their own — so 0108's consequence is amended. The Pages panel lists the page's guides, each movable and removable, and the Layers panel moves the selection onto another layer (decision 0110). Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 98 ++++++ crates/app/src/shell/panels.rs | 295 ++++++++++++++++++ ...properties-panel-writes-the-text-layout.md | 8 +- ...xt-properties-write-the-rendering-dials.md | 84 +++++ ...des-and-the-layer-move-reach-the-engine.md | 66 ++++ 5 files changed, 548 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/0109-the-text-properties-write-the-rendering-dials.md create mode 100644 docs/decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index af4ac98..e6b41f1 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -4875,6 +4875,104 @@ mod tests { assert!(after.width() > 0.0, "the frame still holds the text"); } + /// The text properties' anti-alias dials: the custom sharpness, + /// strength and oversampling reach the text object. + #[test] + fn the_text_properties_write_the_anti_alias_dials() { + let (mut shell, id) = text_shell("hello"); + shell.set_custom_anti_alias_sharpness(id, 200); + shell.set_custom_anti_alias_strength(id, 64); + shell.set_custom_anti_alias_over_sample(id, 16); + let text = match shell.editor.document().registry.get(id) { + Some(ObjectData::Text(text)) => text.clone(), + _ => panic!("the text is there"), + }; + assert_eq!(text.anti_alias_sharpness, Some(200)); + assert_eq!(text.anti_alias_strength, Some(64)); + assert_eq!(text.anti_alias_oversample, Some(16)); + } + + /// The text properties' run field: one line per run, in order. + #[test] + fn the_text_properties_write_the_runs() { + let (mut shell, id) = text_shell("hello"); + shell.set_text_runs(id, "one\ntwo"); + let text = match shell.editor.document().registry.get(id) { + Some(ObjectData::Text(text)) => text.clone(), + _ => panic!("the text is there"), + }; + let runs: Vec<&str> = text.runs.iter().map(|run| run.text.as_str()).collect(); + assert_eq!(runs, vec!["one", "two"], "the runs are in order"); + } + + /// The text properties' frame: the box the text flows inside is + /// written, and the object's own placement stays put. + #[test] + fn the_text_properties_write_the_text_frame() { + let (mut shell, id) = text_shell("hello"); + shell.set_text_rectangle(id, 120.0, 30.0); + let text = match shell.editor.document().registry.get(id) { + Some(ObjectData::Text(text)) => text.clone(), + _ => panic!("the text is there"), + }; + assert_eq!(text.frame.width(), 120.0, "the frame carries the width"); + assert_eq!(text.frame.height(), 30.0, "the frame carries the height"); + assert_eq!(text.frame.x0, 10.0, "the frame kept its corner"); + } + + /// The Pages panel's guide rows: a guide moves and goes away. + #[test] + fn the_guides_move_and_go_away() { + let mut shell = gui_shell(gui_editor()); + shell.run_command( + "page.addGuide", + serde_json::json!({ "page": 0, "position": 100.0, "kind": "horizontal" }), + ); + assert_eq!( + shell.editor.document().pages[0].guides.horizontal, + vec![100.0], + "the guide is on the page" + ); + shell.change_guide(0, "horizontal", 100.0, 150.0); + assert_eq!( + shell.editor.document().pages[0].guides.horizontal, + vec![150.0], + "the guide moved" + ); + shell.remove_guide(0, "horizontal", 150.0); + assert!( + shell.editor.document().pages[0] + .guides + .horizontal + .is_empty(), + "the guide is gone" + ); + } + + /// The Layers panel's "move the selection here": the object + /// leaves its layer for the named one. + #[test] + fn the_selection_moves_to_another_layer() { + let mut editor = gui_editor(); + let id = editor.current_cell().unwrap().elements[0]; + editor.set_selection(vec![id]); + editor + .apply("layer.add", serde_json::json!({ "page": 0 })) + .unwrap(); + let mut shell = gui_shell(editor); + shell.move_selection_to_layer(1); + let page = &shell.editor.document().pages[0]; + assert!( + page.layers.layers[0].cells[0].elements.is_empty(), + "the object left the lower layer" + ); + assert_eq!( + page.layers.layers[1].cells[0].elements, + vec![id], + "the object landed on the upper layer" + ); + } + /// Modify ▸ Group / Ungroup: the group lands in the cell where the /// members were, and ungroup puts the members back. #[test] diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 1a1c687..cfbff39 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -269,6 +269,7 @@ impl Shell { } }); } + self.guides_section(ui); } /// Delete a page through the engine's `page.delete` — the last @@ -285,6 +286,110 @@ impl Shell { self.raster = None; } + /// The page's guides: each one can be moved or removed. + /// + /// The guide list lives in the document (`page.guides`), so the + /// rows read it there and write through the engine's + /// `page.changeGuide` and `page.removeGuide`. + pub(super) fn guides_section(&mut self, ui: &mut egui::Ui) { + let page = self.editor.page; + let Some(guides) = self + .editor + .document() + .pages + .get(page) + .map(|page| page.guides.clone()) + else { + return; + }; + ui.label("Guides"); + for (kind, positions) in [ + ("horizontal", guides.horizontal.clone()), + ("vertical", guides.vertical.clone()), + ] { + for position in positions { + let mut value = position; + ui.horizontal(|ui| { + let field = ui.add( + egui::DragValue::new(&mut value) + .speed(1.0) + .prefix(format!("{kind} ")), + ); + if field.changed() { + self.change_guide(page, kind, position, value); + } + if ui + .small_button("×") + .on_hover_text("Remove this guide") + .clicked() + { + self.remove_guide(page, kind, position); + } + }); + } + } + } + + /// Move one guide to a new position. + pub(super) fn change_guide(&mut self, page: usize, kind: &str, current: f64, new: f64) { + self.run_command( + "page.changeGuide", + serde_json::json!({ + "page": page, "current": current, "new": new, "kind": kind, + }), + ); + } + + /// Remove one guide. + pub(super) fn remove_guide(&mut self, page: usize, kind: &str, position: f64) { + self.run_command( + "page.removeGuide", + serde_json::json!({ + "page": page, "position": position, "kind": kind, + }), + ); + } + + /// The selection moves onto another layer of the current page, in + /// the current state. + /// + /// The engine's `object.moveToCell` moves one object at a time, + /// so the panel moves the selection one by one and skips the + /// objects that already live there. + pub(super) fn move_selection_to_layer(&mut self, layer: usize) { + let ids = self.editor.live_selection(); + if ids.is_empty() { + self.status = "nothing selected".to_owned(); + return; + } + let page = self.editor.page; + let cell = self.editor.frame; + let already: Vec = self + .editor + .document() + .pages + .get(page) + .and_then(|page| page.layers.layers.get(layer)) + .and_then(|layer| layer.cells.get(cell)) + .map(|cell| cell.elements.clone()) + .unwrap_or_default(); + let mut moved = 0usize; + for id in ids { + if already.contains(&id) { + continue; + } + if self.run_command( + "object.moveToCell", + serde_json::json!({ + "id": id, "page": page, "layer": layer, "cell": cell, "index": null, + }), + ) { + moved += 1; + } + } + self.status = format!("moved {moved} object(s) to layer {layer}"); + } + /// Layers section: name + display toggle per layer, bottom-first. /// The active layer's row is highlighted and can be changed by /// clicking it — that row is where every placement command lands @@ -402,6 +507,13 @@ impl Shell { { self.reorder_layer(layer_index, false); } + if ui + .small_button("⇔") + .on_hover_text("Move the selection onto this layer") + .clicked() + { + self.move_selection_to_layer(layer_index); + } }); } ui.horizontal(|ui| { @@ -1503,6 +1615,7 @@ impl Shell { }, ); self.text_layout_fields(ui, id, text); + self.text_render_fields(ui, id, text); self.geometry_fields(ui, id); } @@ -1647,6 +1760,188 @@ impl Shell { true } + /// The text object's rendering: the custom anti-alias dials, the + /// run list, and the frame the text is laid out in. + /// + /// The three dials are MX 2004's custom anti-alias mode (`TA1i` + /// sharpness, `TA2i` strength, `TOSi` oversampling); the runs + /// are the structured string's segments, one line per run; the + /// frame is the box the text flows inside — a different box from + /// the object's own bounds. + pub(super) fn text_render_fields( + &mut self, + ui: &mut egui::Ui, + id: doc::ObjectId, + text: &TextObject, + ) { + // The custom anti-alias dials. + let mut sharpness = text.anti_alias_sharpness.unwrap_or(0); + let mut strength = text.anti_alias_strength.unwrap_or(0); + ui.horizontal(|ui| { + let sharp = ui.add( + egui::DragValue::new(&mut sharpness) + .speed(1.0) + .range(0..=255) + .prefix("sharpness "), + ); + let strong = ui.add( + egui::DragValue::new(&mut strength) + .speed(1.0) + .range(0..=255) + .prefix("strength "), + ); + if sharp.changed() { + self.set_custom_anti_alias_sharpness(id, sharpness); + } + if strong.changed() { + self.set_custom_anti_alias_strength(id, strength); + } + }); + let mut over_sample = text.anti_alias_oversample.unwrap_or(4); + egui::ComboBox::from_label("oversample") + .selected_text(over_sample.to_string()) + .show_ui(ui, |ui| { + for value in [4i64, 8, 16] { + if ui + .selectable_label(over_sample == value, value.to_string()) + .clicked() + { + self.set_custom_anti_alias_over_sample(id, value); + } + } + }); + // The runs: one line per run. The base style rides along + // unchanged — the command writes the run list alone. + let mut runs = text + .runs + .iter() + .map(|run| run.text.clone()) + .collect::>() + .join("\n"); + let field = ui.add( + egui::TextEdit::multiline(&mut runs) + .hint_text("runs, one per line (click away to apply)"), + ); + if field.changed() && field.lost_focus() { + self.set_text_runs(id, &runs); + } + // The frame the text flows inside. + let mut width = text.frame.width() as f32; + let mut height = text.frame.height() as f32; + ui.horizontal(|ui| { + let wide = ui.add( + egui::DragValue::new(&mut width) + .speed(1.0) + .prefix("text W "), + ); + let tall = ui.add( + egui::DragValue::new(&mut height) + .speed(1.0) + .prefix("text H "), + ); + if wide.changed() || tall.changed() { + self.set_text_rectangle(id, width as f64, height as f64); + } + }); + if ui + .button("Fit box at the corner") + .on_hover_text( + "CS6's setTextRectangleAutoFromPoint: the fitted box anchors at the frame's top-left corner", + ) + .clicked() + { + self.fit_text_box_at(id); + } + } + + /// The custom anti-alias sharpness (`TA1i`), 0..=255. + pub(super) fn set_custom_anti_alias_sharpness(&mut self, id: doc::ObjectId, sharpness: i64) { + self.run_command( + "text.setTextCustomAntiAliasSharpness", + serde_json::json!({ "id": id, "sharpness": sharpness }), + ); + } + + /// The custom anti-alias strength (`TA2i`), 0..=255. + pub(super) fn set_custom_anti_alias_strength(&mut self, id: doc::ObjectId, strength: i64) { + self.run_command( + "text.setTextCustomAntiAliasStrength", + serde_json::json!({ "id": id, "strength": strength }), + ); + } + + /// The custom anti-alias oversampling (`TOSi`): 4, 8 or 16. + pub(super) fn set_custom_anti_alias_over_sample( + &mut self, + id: doc::ObjectId, + over_sample: i64, + ) { + self.run_command( + "text.setTextCustomAntiAliasOverSample", + serde_json::json!({ "id": id, "over_sample": over_sample }), + ); + } + + /// The structured string's runs: one line per run, written in + /// order. The base style stays as it is. + pub(super) fn set_text_runs(&mut self, id: doc::ObjectId, text: &str) { + let runs: Vec = if text.is_empty() { + Vec::new() + } else { + text.split('\n') + .map(|characters| serde_json::json!({ "characters": characters })) + .collect() + }; + self.run_command( + "text.setTextRuns", + serde_json::json!({ "id": id, "runs": runs }), + ); + } + + /// The frame the text flows inside. The command reads the + /// document's selection, so the view's selection lands there + /// first. + pub(super) fn set_text_rectangle(&mut self, id: doc::ObjectId, width: f64, height: f64) { + if !self.sync_document_selection() { + return; + } + let Some(frame) = self.text_frame(id) else { + return; + }; + self.run_command( + "text.setTextRectangle", + serde_json::json!({ + "rect": { + "left": frame.x0, "top": frame.y0, + "right": frame.x0 + width, "bottom": frame.y0 + height, + } + }), + ); + } + + /// CS6's setTextRectangleAutoFromPoint: the fitted box anchors at + /// the frame's top-left corner. + pub(super) fn fit_text_box_at(&mut self, id: doc::ObjectId) { + if !self.sync_document_selection() { + return; + } + let Some(frame) = self.text_frame(id) else { + return; + }; + self.run_command( + "text.setTextRectangleAutoFromPoint", + serde_json::json!({ "anchor": { "x": frame.x0, "y": frame.y0 } }), + ); + } + + /// The frame of one text object. + fn text_frame(&self, id: doc::ObjectId) -> Option { + match self.editor.document().registry.get(id) { + Some(ObjectData::Text(text)) => Some(text.frame), + _ => None, + } + } + /// Document properties (22.4): the field set the inspector shows /// when nothing is selected — CS6's document tab. The canvas /// geometry, the resolution with its unit, and the canvas diff --git a/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md b/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md index 2f630b4..28c236a 100644 --- a/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md +++ b/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md @@ -60,9 +60,11 @@ every word processor offers in the same place. `text.setTextOnPathOffset` and `text.setTextRectangleAuto`. - The text box behaves like a word processor's: paragraphs are laid out, and the frame can follow the text. -- The panel's custom anti-alias fields stay unbound: the engine has - no command for the anti-alias *preset*, so a sharpness or strength - field would have nothing to switch it on with. +- The panel's custom anti-alias fields stay unbound *in this + decision*: they are bound by decision + [0109](0109-the-text-properties-write-the-rendering-dials.md), which + found that the three custom dials have setters of their own and + need no anti-alias preset to switch on first. See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md) and [0107](0107-the-states-panel-reorders-and-fans-the-selection.md) — diff --git a/docs/decisions/0109-the-text-properties-write-the-rendering-dials.md b/docs/decisions/0109-the-text-properties-write-the-rendering-dials.md new file mode 100644 index 0000000..2a9901c --- /dev/null +++ b/docs/decisions/0109-the-text-properties-write-the-rendering-dials.md @@ -0,0 +1,84 @@ +# 0109 — The Properties panel writes the text object's rendering + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +Decision [0108](0108-the-properties-panel-writes-the-text-layout.md) gave the +Properties panel the text object's *layout*: the paragraph's indent and +spacing, the on-path offset, and the frame that fits the text. + +Three things the panel showed about a text object were still inert, and +the engine carried all of them: + +- The custom anti-alias dials — MX 2004's `TA1i` sharpness, `TA2i` + strength and `TOSi` oversampling — sit on the text object and had no + field. +- The structured string's *runs*. The panel showed only the first + run's characters, so a text object with several styled runs looked + like a single line, and the user could not edit the run structure. +- The frame the text flows inside. The panel's geometry row moved the + object's *bounds* (`object.setBounds`); the text box is a different + rectangle, the one the shaper wraps at, and it had no field. + +Decision 0108 recorded that the anti-alias dials would stay unbound +because the engine carries no anti-alias *preset* setter. That was +wrong about the engine: the three custom dials have setters of their +own and write their fields directly, with no preset to switch on +first. + +## Decision + +- **The three custom anti-alias dials get fields.** Sharpness and + strength are 0..=255; the oversampling is a choice of 4, 8 or 16, + the only values the engine accepts. They write + `text.setTextCustomAntiAliasSharpness`, + `text.setTextCustomAntiAliasStrength` and + `text.setTextCustomAntiAliasOverSample`. +- **The run list gets a field.** One line per run, written through + `text.setTextRuns`. The base style rides along unchanged — the + command writes the run list alone — but a run's own style changes + are *not* carried over: the field writes characters, so editing the + list gives every run the same style. The field says so in its hint. +- **The text frame gets its own width and height.** They write + `text.setTextRectangle`, the rectangle the text is laid out in. + The object's bounds keep their own row. +- **"Fit box at the corner" anchors the fitted box.** + `text.setTextRectangleAutoFromPoint` is CS6's + `dom.setTextRectangleAutoFromPoint(anchorPoint)`; the panel anchors + at the frame's top-left corner, which is where a left-top-anchored + text item already sits. + +## Evidence + +- `text.setTextCustomAntiAliasSharpness` and + `text.setTextCustomAntiAliasStrength` write `anti_alias_sharpness` + and `anti_alias_strength`; both refuse a value outside 0..=255. +- `text.setTextCustomAntiAliasOverSample` writes + `anti_alias_oversample` and refuses anything outside {4, 8, 16}. +- `text.setTextRuns` replaces the run list and leaves the base style + alone when `initialAttrs` is absent; an empty run list clears the + text. +- `text.setTextRectangle` and `text.setTextRectangleAutoFromPoint` + read the *document's* selection and refuse a selection that is not + exactly one text object, so the panel syncs the view's selection + into the document first — the same bridge decision 0108 established. + +## Consequences + +- Six more engine commands are reachable from the shell's designed + surface: `text.setTextCustomAntiAliasSharpness`, + `text.setTextCustomAntiAliasStrength`, + `text.setTextCustomAntiAliasOverSample`, `text.setTextRuns`, + `text.setTextRectangle` and `text.setTextRectangleAutoFromPoint`. +- A text item can be anti-aliased by hand, edited as a run + structure, and laid out in a frame of the user's choosing — the + three things CS6's Text panel offers and FireCraft did not. +- Decision 0108's consequence about the anti-alias fields is + superseded: the dials are bound. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0107](0107-the-states-panel-reorders-and-fans-the-selection.md) and +[0108](0108-the-properties-panel-writes-the-text-layout.md) — the same +week's other steps in closing the measured surface gap. diff --git a/docs/decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md b/docs/decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md new file mode 100644 index 0000000..b46f3dc --- /dev/null +++ b/docs/decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md @@ -0,0 +1,66 @@ +# 0110 — The Pages panel lists the guides; the Layers panel moves the selection + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +Two things FireCraft's shell could *make* but could not *undo or +move*: + +- A guide could be dragged out from a canvas edge — the guide draft + commits through `page.addGuide` — but the page's guide list had no + rows. A guide could not be nudged, and it could not be removed at + all. The engine carried `page.changeGuide` and `page.removeGuide` + for exactly that. +- A layer could be renamed, locked, reordered and made active, and + its objects could be selected — but the selection could not be + *moved* onto another layer. The engine carried `object.moveToCell` + for it. + +Both are ordinary CS6 moves. Fireworks moves a guide by dragging it +in the Guides dialog and removes it with the same gesture; it moves +objects between layers from the Layers panel. + +## Decision + +- **The Pages panel lists the page's guides.** Each guide is a row + with its position and a remove button. Editing the position writes + `page.changeGuide`; the button writes `page.removeGuide`. The + panel reads the list from the document (`page.guides`) rather than + from view state, so a guide the user dragged out on the canvas + shows up here like any other. +- **The Layers panel gains "move the selection here."** The button + on a layer row moves the selected objects onto that layer, in the + current state, through `object.moveToCell`. The engine's command + moves one object at a time, so the panel moves the selection one + by one and skips the objects that already live on the target + layer — a move that changes nothing does not become an undo step. + +## Evidence + +- `page.addGuide` appends a position to the page's horizontal or + vertical list; `page.changeGuide` replaces the first guide sitting + at the given position and *refuses* a position that carries no + guide; `page.removeGuide` succeeds without touching the document + when the position carries no guide (CS6 documents the absent case + as a no-op). +- `object.moveToCell` takes the target page, layer and cell, and an + optional insert index (absent appends). A move within the source + cell folds into one cell delta. +- The reachability gate measured all three commands as carried by + the engine and unbound by the shell. + +## Consequences + +- Three more engine commands are reachable from the shell's designed + surface: `page.changeGuide`, `page.removeGuide` and + `object.moveToCell`. +- A guide can be corrected or discarded without opening a file, and + an object can be handed to another layer from the panel that owns + the layers. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0108](0108-the-properties-panel-writes-the-text-layout.md) and +[0109](0109-the-text-properties-write-the-rendering-dials.md) — the +same week's other steps in closing the measured surface gap. From a935042a8b71a81207f0af1d04e31d243f868486 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:34:08 -0600 Subject: [PATCH 08/22] docs(tasks): record the batch F bindings Co-Authored-By: Claude Code --- docs/TASKS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TASKS.md b/docs/TASKS.md index 69ef9e4..42b418e 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds fourteen of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), and the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)). Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds twenty-three of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), and the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), and the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)). Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight From 6862d8a40e250bffe3df825d963a863e4a1b9c99 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:42:23 -0600 Subject: [PATCH 09/22] feat(panels): the symbol family reaches the engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The symbol instance's panel grows four sections, each owning one part of the family the reachability gate measured as unreachable: the button's four behavior flags and its three export options, the animation span's six writes, the instance's parameters and custom data, and the definition's own contents (lift an object out, or move it within the symbol's z-order). "Duplicate for alias" gets its door too. Sixteen commands, all through the engine's registry. The animation verbs and the alias duplicate read the document's selection, so the panel syncs the view's selection first — decision 0104's bridge. Local cargo is closed on this host (the CI runner compiles here), so the gate is the GitForge receipt. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 184 +++++++++++ crates/app/src/shell/panels.rs | 587 ++++++++++++++++++++++++++++++++- 2 files changed, 769 insertions(+), 2 deletions(-) diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index e6b41f1..2f6987d 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -6653,4 +6653,188 @@ mod tests { assert_eq!(shell.tabs.len(), tabs_before, "close refused"); assert!(shell.status.contains("unsaved"), "{}", shell.status); } + + /// A shell whose selection is one instance of a fresh definition + /// of the given kind. The instance is the view's selection too, + /// so the selection-scoped verbs reach it. + fn symbol_instance_shell(kind: &str) -> (Shell, doc::ObjectId) { + let mut shell = gui_shell(gui_editor()); + shell + .editor + .apply( + "library.add", + serde_json::json!({ "name": "def", "symbol_type": kind }), + ) + .expect("the definition was added"); + let symbol = shell.editor.document().symbols[0].symbol_number; + shell + .editor + .apply( + "symbol.createInstance", + serde_json::json!({ + "page": 0, "layer": 0, "cell": 0, "symbol": symbol, + "x": 10.0, "y": 10.0, + }), + ) + .expect("the instance was created"); + let elements = &shell.editor.current_cell().expect("a cell").elements; + let id = elements[elements.len() - 1]; + shell.editor.selection_set = vec![id]; + shell.editor.selection = Some(id); + (shell, id) + } + + /// The animation carrier of an instance. + fn animation_of(shell: &Shell, id: doc::ObjectId) -> doc::SymbolAnimation { + match shell.editor.document().registry.get(id) { + Some(doc::ObjectData::SymbolInstance(instance)) => instance + .animation + .clone() + .expect("the instance carries an animation"), + _ => panic!("not a symbol instance"), + } + } + + /// The instance's custom-data store. + fn custom_data_of( + shell: &Shell, + id: doc::ObjectId, + ) -> std::collections::BTreeMap { + match shell.editor.document().registry.get(id) { + Some(doc::ObjectData::SymbolInstance(instance)) => instance.custom_data.clone(), + _ => panic!("not a symbol instance"), + } + } + + /// The animation panel's six writes reach the instance's `ANNi` + /// family: the frame count, the travel, the rotation, the + /// scaling and the opacity ramp. + #[test] + fn the_animation_fields_write_the_span() { + let (mut shell, id) = symbol_instance_shell("animation"); + shell.set_anim_num_frames(5); + assert_eq!(animation_of(&shell, id).frames, 5); + // CS6's deprecated spelling writes the same field. + shell.set_anim_loop_count(3); + assert_eq!(animation_of(&shell, id).frames, 3); + shell.set_anim_offset(2.0, -3.0); + let animation = animation_of(&shell, id); + assert_eq!(animation.x_shift, 2.0); + assert_eq!(animation.y_shift, -3.0); + shell.set_anim_rotation(45.0); + assert_eq!(animation_of(&shell, id).rotation, Some(45)); + shell.set_anim_scale(200.0); + assert_eq!(animation_of(&shell, id).scaling, 200.0); + // The wire speaks 0–100; the model stores per mille. + shell.set_anim_opacity(20.0, 80.0); + let animation = animation_of(&shell, id); + assert_eq!(animation.start_opacity, Some(200)); + assert_eq!(animation.end_opacity, Some(800)); + } + + /// The button's four state flags live on the definition; the + /// panel writes them through the engine. + #[test] + fn the_button_flags_write_the_definition() { + let (mut shell, _id) = symbol_instance_shell("button"); + let number = shell.editor.document().symbols[0].symbol_number; + shell.set_button_auto_slice(number, true); + shell.set_button_include_down_state(number, true); + shell.set_button_show_down_on_load(number, true); + shell.set_button_include_over_while_down(number, true); + let definition = &shell.editor.document().symbols[0]; + assert_eq!(definition.auto_slice, Some(true)); + assert_eq!(definition.include_down_state, Some(true)); + assert_eq!(definition.show_down_on_load, Some(true)); + assert_eq!(definition.include_over_while_down, Some(true)); + } + + /// The instance's parameters and custom data reach the engine's + /// `currentValues` and `DTAv` stores. + #[test] + fn the_instance_carries_parameters_and_custom_data() { + let (mut shell, id) = symbol_instance_shell("graphic"); + shell.set_symbol_param(id, "size", "text", "42"); + let values: Vec = serde_json::from_str( + custom_data_of(&shell, id) + .get("currentValues") + .expect("currentValues"), + ) + .expect("the parameter array parses"); + assert_eq!(values[0]["name"], serde_json::json!("size")); + assert_eq!(values[0]["value"], serde_json::json!("42")); + shell.set_symbol_custom_data(id, "colour", "red"); + assert_eq!( + custom_data_of(&shell, id).get("colour").map(String::as_str), + Some("red") + ); + shell.remove_symbol_custom_data(id, "colour"); + assert!(custom_data_of(&shell, id).get("colour").is_none()); + } + + /// The definition's content: the panel lifts an object out of the + /// symbol and moves one within the symbol's z-order. + #[test] + fn the_definition_content_detaches_and_reorders() { + let mut editor = gui_editor(); + let first = editor.current_cell().unwrap().elements[0]; + let second = rect_on_active_layer(&mut editor, 100.0, 100.0); + editor.set_selection(vec![first, second]); + editor + .apply( + "symbol.create", + serde_json::json!({ + "ids": vec![first, second], + "name": "def", + "symbol_type": "graphic", + }), + ) + .expect("the definition was created"); + let mut shell = gui_shell(editor); + let symbol = shell.editor.document().symbols[0].symbol_number; + assert_eq!( + shell.editor.document().symbols[0].layers.layers[0].cells[0].elements, + vec![first, second], + "the definition holds both objects" + ); + // Move the lower object up: the engine's target index is the + // position after the object leaves the list. + shell.reorder_symbol_object(symbol, first, 1); + assert_eq!( + shell.editor.document().symbols[0].layers.layers[0].cells[0].elements, + vec![second, first], + "the object moved up" + ); + // Lift the top object out of the symbol. + shell.detach_symbol_object(symbol, first); + assert_eq!( + shell.editor.document().symbols[0].layers.layers[0].cells[0].elements, + vec![second], + "the object left the symbol" + ); + } + + /// "Duplicate for alias": the selected instance points at a copy + /// of the definition it named. + #[test] + fn the_instance_can_be_duplicated_for_an_alias() { + let (mut shell, id) = symbol_instance_shell("graphic"); + let number = shell.editor.document().symbols[0].symbol_number; + let before = shell.editor.document().symbols.len(); + shell.duplicate_symbol_for_alias(); + assert_eq!( + shell.editor.document().symbols.len(), + before + 1, + "the library gained the copy" + ); + match shell.editor.document().registry.get(id) { + Some(doc::ObjectData::SymbolInstance(instance)) => { + assert_ne!( + instance.symbol_number, number, + "the instance points at the copy" + ); + } + _ => panic!("not a symbol instance"), + } + } } diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index cfbff39..1787f0a 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -7,7 +7,7 @@ use super::*; use compose::abgr_pack; -use doc::{GroupObject, ImageObject, SliceObject}; +use doc::{GroupObject, ImageObject, SliceObject, SymbolAnimation}; impl Shell { /// Edit ▸ Paste as Mask / Paste Inside: the clipboard becomes the @@ -1408,9 +1408,37 @@ impl Shell { layer.cells.iter().map(|cell| cell.elements.len()).collect() }), definition.nine_slice.is_some(), + definition.auto_slice, + definition.include_down_state, + definition.show_down_on_load, + definition.include_over_while_down, + definition + .layers + .layers + .iter() + .map(|layer| { + layer + .cells + .iter() + .map(|cell| cell.elements.clone()) + .collect::>>() + }) + .collect::>>>(), ) }); - let Some((name, kind, kind_raw, state_parts, sliced)) = facts else { + let Some(( + name, + kind, + kind_raw, + state_parts, + sliced, + auto_slice, + include_down_state, + show_down_on_load, + include_over_while_down, + cells, + )) = facts + else { ui.label(format!("instance of missing symbol {number}")); return; }; @@ -1481,6 +1509,550 @@ impl Shell { self.status = format!("nine-slice failed: {error}"); } } + + // The button's behavior: the four state flags and the export + // options that ride the button's auto-slice. + self.symbol_button_fields( + ui, + number, + kind_raw, + auto_slice, + include_down_state, + show_down_on_load, + include_over_while_down, + ); + + // The animation span — CS6's Animation panel, for an + // instance that already carries one or names an animated + // definition. + if matches!(kind_raw, SymbolType::Animation) || instance.animation.is_some() { + self.symbol_animation_fields(ui, instance); + } + + // The instance's own parameters and custom data. + self.symbol_data_fields(ui, id, instance); + + // The definition's content: detach an object from the + // symbol, or move it within the symbol's z-order. + self.symbol_content_fields(ui, number, &cells); + + // "Duplicate for alias": the selected instances point at + // fresh copies of the definitions they name. + if ui + .button("Duplicate for alias") + .on_hover_text( + "CS6's duplicateSymbolForAlias: the selected instances point at copies of \ + the definitions they name", + ) + .clicked() + { + self.duplicate_symbol_for_alias(); + } + } + + /// The button's behavior: the four state flags (`ATSb`, `IDSb`, + /// `DVLb`, `IODb`) and the export options that ride the button's + /// linked slices (`A2Ts`, `ALTs`, `OBNs`). + /// + /// The flags live on the *definition*, so the write names the + /// symbol number; the options live on the definition's linked + /// slices, so the write names the symbol number too and the + /// engine applies the payload to every linked slice. + pub(super) fn symbol_button_fields( + &mut self, + ui: &mut egui::Ui, + number: i64, + kind: SymbolType, + auto_slice: Option, + include_down_state: Option, + show_down_on_load: Option, + include_over_while_down: Option, + ) { + let is_button = matches!(kind, SymbolType::Button); + if !is_button + && auto_slice.is_none() + && include_down_state.is_none() + && show_down_on_load.is_none() + && include_over_while_down.is_none() + { + return; + } + ui.heading("Button"); + let mut flag = auto_slice.unwrap_or(false); + if ui.checkbox(&mut flag, "auto-slice").changed() { + self.set_button_auto_slice(number, flag); + } + let mut flag = include_down_state.unwrap_or(false); + if ui.checkbox(&mut flag, "include down state").changed() { + self.set_button_include_down_state(number, flag); + } + let mut flag = show_down_on_load.unwrap_or(false); + if ui.checkbox(&mut flag, "show down on load").changed() { + self.set_button_show_down_on_load(number, flag); + } + let mut flag = include_over_while_down.unwrap_or(false); + if ui.checkbox(&mut flag, "include over while down").changed() { + self.set_button_include_over_while_down(number, flag); + } + // The export options: the first linked slice is the read + // side; the write reaches every slice linked to the symbol. + let linked = self.linked_slice_facts(number); + let mut link = linked.link; + let mut alt = linked.alt; + let mut slice_name = linked.slice_name; + ui.horizontal(|ui| { + let link_field = ui.add(egui::TextEdit::singleline(&mut link).hint_text("link URL")); + let alt_field = ui.add(egui::TextEdit::singleline(&mut alt).hint_text("alt text")); + let name_field = + ui.add(egui::TextEdit::singleline(&mut slice_name).hint_text("slice name")); + if link_field.lost_focus() && link_field.changed() { + self.set_button_options(number, Some(link.clone()), None, None); + } + if alt_field.lost_focus() && alt_field.changed() { + self.set_button_options(number, None, Some(alt.clone()), None); + } + if name_field.lost_focus() && name_field.changed() { + self.set_button_options(number, None, None, Some(slice_name.clone())); + } + }); + if !linked.found { + ui.label("no slice is linked to this symbol yet"); + } + } + + /// The first slice linked to `number`, read for the button's + /// export options. + fn linked_slice_facts(&self, number: i64) -> LinkedSliceFacts { + let mut facts = LinkedSliceFacts::default(); + for (_, data) in self.editor.document().registry.iter() { + let ObjectData::Slice(slice) = data else { + continue; + }; + if slice.symbol_number != Some(number) { + continue; + } + facts.found = true; + if facts.link.is_empty() { + facts.link = slice.link.clone().unwrap_or_default(); + } + if facts.alt.is_empty() { + facts.alt = slice.alt.clone().unwrap_or_default(); + } + if facts.slice_name.is_empty() { + facts.slice_name = slice.name.clone().unwrap_or_default(); + } + } + facts + } + + /// Write the button's auto-slice flag (`ATSb`). + pub(super) fn set_button_auto_slice(&mut self, number: i64, value: bool) { + self.run_command( + "symbol.setButtonAutoSlice", + serde_json::json!({ "symbol": number, "value": value }), + ); + } + + /// Write the button's include-down-state flag (`IDSb`). + pub(super) fn set_button_include_down_state(&mut self, number: i64, value: bool) { + self.run_command( + "symbol.setButtonIncludeDownState", + serde_json::json!({ "symbol": number, "value": value }), + ); + } + + /// Write the button's show-down-on-load flag (`DVLb`). + pub(super) fn set_button_show_down_on_load(&mut self, number: i64, value: bool) { + self.run_command( + "symbol.setButtonShowDownOnLoad", + serde_json::json!({ "symbol": number, "value": value }), + ); + } + + /// Write the button's include-over-while-down flag (`IODb`). + pub(super) fn set_button_include_over_while_down(&mut self, number: i64, value: bool) { + self.run_command( + "symbol.setButtonIncludeOverWhileDownState", + serde_json::json!({ "symbol": number, "value": value }), + ); + } + + /// Write one of the button's export options. The engine applies + /// every present field to every slice linked to the symbol and + /// leaves the absent ones untouched, so the panel sends one field + /// at a time. + pub(super) fn set_button_options( + &mut self, + number: i64, + link: Option, + alt: Option, + slice_name: Option, + ) { + self.run_command( + "symbol.setButtonOptions", + serde_json::json!({ + "symbol": number, + "link": link, + "alt": alt, + "slice_name": slice_name, + }), + ); + } + + /// The animation span: the `ANNi` family's six writes. CS6's + /// `setAnimInstance*` verbs address the *selected instances*, so + /// the panel syncs the view's selection into the document first + /// (decision 0104's bridge). + pub(super) fn symbol_animation_fields( + &mut self, + ui: &mut egui::Ui, + instance: &SymbolInstanceObject, + ) { + let animation = instance.animation.clone().unwrap_or(SymbolAnimation { + frames: 1, + x_shift: 0.0, + y_shift: 0.0, + scaling: 100.0, + rotation: None, + start_opacity: None, + end_opacity: None, + }); + let mut frames = animation.frames; + let mut loop_count = animation.frames; + let mut shift_x = animation.x_shift; + let mut shift_y = animation.y_shift; + let mut rotation = animation.rotation.unwrap_or(0) as f64; + let mut scale = animation.scaling; + // The model stores opacity per mille; the wire speaks 0–100. + let mut start = animation.start_opacity.unwrap_or(1000) as f64 / 10.0; + let mut end = animation.end_opacity.unwrap_or(1000) as f64 / 10.0; + ui.heading("Animation"); + ui.horizontal(|ui| { + let frame_field = ui.add( + egui::DragValue::new(&mut frames) + .speed(1.0) + .range(1..=1000) + .prefix("frames "), + ); + let loop_field = ui.add( + egui::DragValue::new(&mut loop_count) + .speed(1.0) + .range(1..=1000) + .prefix("loop count "), + ); + if frame_field.changed() { + self.set_anim_num_frames(frames); + } + if loop_field.changed() { + self.set_anim_loop_count(loop_count); + } + }); + ui.horizontal(|ui| { + let x_field = ui.add( + egui::DragValue::new(&mut shift_x) + .speed(1.0) + .prefix("offset x "), + ); + let y_field = ui.add( + egui::DragValue::new(&mut shift_y) + .speed(1.0) + .prefix("offset y "), + ); + if x_field.changed() || y_field.changed() { + self.set_anim_offset(shift_x, shift_y); + } + }); + ui.horizontal(|ui| { + let rotation_field = ui.add( + egui::DragValue::new(&mut rotation) + .speed(1.0) + .prefix("rotation "), + ); + let scale_field = ui.add( + egui::DragValue::new(&mut scale) + .speed(1.0) + .range(0.01..=1000.0) + .prefix("scale % "), + ); + if rotation_field.changed() { + self.set_anim_rotation(rotation); + } + if scale_field.changed() { + self.set_anim_scale(scale); + } + }); + ui.horizontal(|ui| { + let start_field = ui.add( + egui::DragValue::new(&mut start) + .speed(1.0) + .range(0.0..=100.0) + .prefix("start opacity "), + ); + let end_field = ui.add( + egui::DragValue::new(&mut end) + .speed(1.0) + .range(0.0..=100.0) + .prefix("end opacity "), + ); + if start_field.changed() || end_field.changed() { + self.set_anim_opacity(start, end); + } + }); + } + + /// The animation verbs address the document's selection, so the + /// view's selection is synced into the document first. + fn sync_for_animation(&mut self) -> bool { + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return false; + } + true + } + + /// Write the animation's frame count (`ANNi`). + pub(super) fn set_anim_num_frames(&mut self, frames: i64) { + if !self.sync_for_animation() { + return; + } + self.run_command( + "symbol.setAnimInstanceNumFrames", + serde_json::json!({ "frames": frames }), + ); + } + + /// Write the animation's loop count (CS6's deprecated spelling of + /// the frame count). + pub(super) fn set_anim_loop_count(&mut self, loop_count: i64) { + if !self.sync_for_animation() { + return; + } + self.run_command( + "symbol.setAnimInstanceLoopCount", + serde_json::json!({ "loop_count": loop_count }), + ); + } + + /// Write the animation's travel across the span (`ANXf`/`ANYf`). + pub(super) fn set_anim_offset(&mut self, x: f64, y: f64) { + if !self.sync_for_animation() { + return; + } + self.run_command( + "symbol.setAnimInstanceOffsetDist", + serde_json::json!({ "offset": { "x": x, "y": y } }), + ); + } + + /// Write the animation's rotation across the span (`ANRi`). + pub(super) fn set_anim_rotation(&mut self, rotation: f64) { + if !self.sync_for_animation() { + return; + } + self.run_command( + "symbol.setAnimInstanceRotationAmount", + serde_json::json!({ "rotation": rotation }), + ); + } + + /// Write the animation's scaling across the span (`ASCf`). + pub(super) fn set_anim_scale(&mut self, scale: f64) { + if !self.sync_for_animation() { + return; + } + self.run_command( + "symbol.setAnimInstanceScaleAmount", + serde_json::json!({ "scale": scale }), + ); + } + + /// Write the animation's opacity ramp (`ASOi`/`AEOi`). + pub(super) fn set_anim_opacity(&mut self, start: f64, end: f64) { + if !self.sync_for_animation() { + return; + } + self.run_command( + "symbol.setAnimInstanceStartEndOpacity", + serde_json::json!({ "start": start, "end": end }), + ); + } + + /// The instance's parameters (`currentValues`) and its custom data + /// (`DTAv`). + pub(super) fn symbol_data_fields( + &mut self, + ui: &mut egui::Ui, + id: doc::ObjectId, + instance: &SymbolInstanceObject, + ) { + ui.heading("Parameters"); + let mut name = String::new(); + let mut kind = "text".to_owned(); + let mut value = String::new(); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut name) + .desired_width(96.0) + .hint_text("name"), + ); + ui.add( + egui::TextEdit::singleline(&mut kind) + .desired_width(64.0) + .hint_text("type"), + ); + ui.add( + egui::TextEdit::singleline(&mut value) + .desired_width(96.0) + .hint_text("value"), + ); + }); + if ui.button("Set parameter").clicked() { + self.set_symbol_param(id, &name, &kind, &value); + } + ui.heading("Custom data"); + let mut key = String::new(); + let mut text = String::new(); + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut key) + .desired_width(96.0) + .hint_text("key"), + ); + ui.add( + egui::TextEdit::singleline(&mut text) + .desired_width(96.0) + .hint_text("value"), + ); + }); + if ui.button("Set custom data").clicked() { + self.set_symbol_custom_data(id, &key, &text); + } + for (existing, stored) in &instance.custom_data { + ui.horizontal(|ui| { + ui.label(format!("{existing}: {stored}")); + if ui.small_button("×").clicked() { + self.remove_symbol_custom_data(id, existing); + } + }); + } + } + + /// Write one parameter of the instance (`currentValues`). + pub(super) fn set_symbol_param( + &mut self, + id: doc::ObjectId, + name: &str, + kind: &str, + value: &str, + ) { + self.run_command( + "symbol.setParam", + serde_json::json!({ + "id": id, "name": name, "kind": kind, "value": value, + }), + ); + } + + /// Write one custom-data entry of the instance (`DTAv`). + pub(super) fn set_symbol_custom_data(&mut self, id: doc::ObjectId, key: &str, value: &str) { + self.run_command( + "symbol.setCustomData", + serde_json::json!({ "id": id, "key": key, "value": value }), + ); + } + + /// Remove one custom-data entry of the instance. + pub(super) fn remove_symbol_custom_data(&mut self, id: doc::ObjectId, key: &str) { + self.run_command( + "symbol.setCustomData", + serde_json::json!({ "id": id, "key": key, "value": null }), + ); + } + + /// The definition's own content: every object in the symbol's + /// cells, with the two edits CS6 offers on them — lift the object + /// out of the symbol, or move it within the symbol's z-order. + pub(super) fn symbol_content_fields( + &mut self, + ui: &mut egui::Ui, + number: i64, + cells: &[Vec>], + ) { + if cells.is_empty() { + return; + } + ui.heading("Definition content"); + for (layer_index, layer) in cells.iter().enumerate() { + for (cell_index, cell) in layer.iter().enumerate() { + if cell.is_empty() { + continue; + } + ui.label(format!("layer {layer_index}, state {cell_index}")); + for (position, element) in cell.iter().enumerate() { + let top = cell.len().saturating_sub(1); + ui.horizontal(|ui| { + ui.label(format!("object {}", element.index())); + if position < top + && ui + .small_button("▲") + .on_hover_text("Move the object up in the symbol") + .clicked() + { + self.reorder_symbol_object(number, *element, position + 1); + } + if position > 0 + && ui + .small_button("▼") + .on_hover_text("Move the object down in the symbol") + .clicked() + { + self.reorder_symbol_object(number, *element, position - 1); + } + if ui + .small_button("×") + .on_hover_text("Lift the object out of the symbol") + .clicked() + { + self.detach_symbol_object(number, *element); + } + }); + } + } + } + } + + /// Move an object within its definition's cell. The engine's + /// target index is the position *after* the object leaves the + /// list, so a move up is `position + 1` and a move down is + /// `position - 1`. + pub(super) fn reorder_symbol_object(&mut self, number: i64, id: doc::ObjectId, to: usize) { + self.run_command( + "symbol.editReorder", + serde_json::json!({ "symbol": number, "id": id, "to": to }), + ); + } + + /// Lift an object out of its definition's cell. + pub(super) fn detach_symbol_object(&mut self, number: i64, id: doc::ObjectId) { + self.run_command( + "symbol.editDetach", + serde_json::json!({ "symbol": number, "id": id }), + ); + } + + /// The selected instances point at fresh copies of the definitions + /// they name. The engine reads the document's selection, so the + /// view's selection is synced first. + pub(super) fn duplicate_symbol_for_alias(&mut self) { + if self.editor.live_selection().is_empty() { + self.status = "nothing selected".to_owned(); + return; + } + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command("symbol.duplicateSymbolForAlias", serde_json::json!({})); } /// Path-object properties, then the shared display controls. @@ -2724,3 +3296,14 @@ fn object_label(object: &ObjectData) -> String { _ => format!("(unnamed {kind})"), } } + +/// The button's export options, read from the first slice linked to +/// the symbol. `found` records whether any slice is linked at all — +/// the engine refuses `symbol.setButtonOptions` when none is. +#[derive(Default)] +struct LinkedSliceFacts { + found: bool, + link: String, + alt: String, + slice_name: String, +} From c8dbb2ceaef264c19e92fd9c9a43185ecf88305b Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:42:28 -0600 Subject: [PATCH 10/22] docs(tasks): record the symbol family bindings Decision 0111 records the four new panel sections and the sixteen engine commands they bind. The live board now reads 212 bound commands of 253 (83.8 %), up from 168 (66.4 %) on main. Co-Authored-By: Claude Code --- docs/TASKS.md | 2 +- ...-symbol-panel-carries-the-symbol-family.md | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0111-the-symbol-panel-carries-the-symbol-family.md diff --git a/docs/TASKS.md b/docs/TASKS.md index 42b418e..dfe0ab6 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds twenty-three of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), and the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), and the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)). Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds forty-four of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **212 of 253 (83.8 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight diff --git a/docs/decisions/0111-the-symbol-panel-carries-the-symbol-family.md b/docs/decisions/0111-the-symbol-panel-carries-the-symbol-family.md new file mode 100644 index 0000000..04fdab5 --- /dev/null +++ b/docs/decisions/0111-the-symbol-panel-carries-the-symbol-family.md @@ -0,0 +1,96 @@ +# 0111 — The symbol instance's panel carries the whole symbol family + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +The symbol family was the largest group the reachability gate +measured as unreachable: sixteen commands the engine could execute +and the shell's designed surface could not. The engine carried every +piece of it, and the properties panel already showed a symbol +instance's definition, its type, its states and its nine-slice +guides — but the panel could not *change* any of the family's +behavior: + +- A button's four behavior flags (`ATSb`, `IDSb`, `DVLb`, `IODb`) and + its export options (`A2Ts`, `ALTs`, `OBNs`) were invisible. +- A multi-frame instance's animation span — its frame count, travel, + rotation, scaling and opacity ramp (`ANNi` and its family) — had no + fields at all. +- A rich symbol's parameters (`currentValues`) and its per-instance + custom data (`DTAv`) could not be read or written. +- The objects *inside* a definition could not be lifted out + (`editDetach`) or reordered within the symbol (`editReorder`). +- The one CS6 verb that duplicates a symbol for an alias + (`duplicateSymbolForAlias`) had no door. + +The family is also the one a daily driver needs most: a button is +the common case in real design work, and a button that cannot be +told to auto-slice, to carry a link, or to hold its down state is a +button that has to be rebuilt. + +## Decision + +- **The instance's panel grows four sections, each owning one part + of the family.** + - *Button* — the four behavior flags and the three export options. + The flags live on the definition, so the write names the symbol + number; the options live on the definition's linked slices, so the + panel reads the first linked slice and the engine applies the + payload to every slice linked to the symbol. + - *Animation* — the six `setAnimInstance*` writes, shown for an + instance that already carries an animation or names an animated + definition. + - *Parameters* and *Custom data* — the instance's `currentValues` + entries and its `DTAv` store, with a remove button for a + custom-data key. + - *Definition content* — every object in the symbol's cells, with + the two edits CS6 offers on them: lift it out of the symbol, or + move it within the symbol's z-order. +- **"Duplicate for alias" sits at the foot of the panel.** The engine + reads the document's selection, so the panel syncs the view's + selection into the document first (decision 0104's bridge). +- **The animation verbs are selection-scoped, so the panel syncs + the selection before each write.** CS6's `setAnimInstance*` verbs + address "the selected instances", so a write reaches every selected + instance, not only the one the panel shows. +- **The opacity fields speak the wire's scale.** The model stores + opacity per mille; the engine's payload is 0–100 and converts. The + panel reads per mille and shows 0–100. + +## Evidence + +- The four flag commands address the *definition* by `MSNi` number + and write one presence-tracked boolean each; the options command + refuses when no slice is linked to the symbol, and the panel says + so rather than pretending the write landed. +- The animation commands materialize the carrier with the Objects + panel's defaults (one frame, no shift, 100 % scale, no rotation or + opacity ramp) when the instance is plain, so a write to a plain + instance is not a no-op. +- `symbol.editReorder`'s target index is the position *after* the + object leaves the cell, so a move up is `position + 1` and a move + down is `position - 1`; the panel offers a button only where the + move exists. +- `symbol.duplicateSymbolForAlias` appends one copy per distinct + pointed-to number and re-points the selected instances to the + copies. +- The reachability gate measured all sixteen commands as carried by + the engine and unbound by the shell — the largest family it + measured. + +## Consequences + +- Sixteen more engine commands are reachable from the shell's + designed surface, and the symbol family is fully bound: the + measurement moves from 196 bound commands of 253 to **212 of 253** + (77.5 % → 83.8 %). +- A button can be told what to do, an animation can be tuned, a rich + symbol's parameters can be set, and a definition's contents can be + edited — from the panel that owns them. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0108](0108-the-properties-panel-writes-the-text-layout.md) and +[0110](0110-the-guides-and-the-layer-move-reach-the-engine.md) — the +same week's other steps in closing the measured surface gap. From 765989ba26cb67037cea2ece159b1e7e5de6e6df Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:44:02 -0600 Subject: [PATCH 11/22] feat(panels): the markup verbs and the button text CS6's Format menu restyles the runs, not the base style; the text panel now carries those four markup verbs and their inverses. The button section carries CS6's two button-text replacement verbs, one across the library and one across the definitions the selected instances reference. Six commands, all through the engine's registry. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 94 ++++++++++++++++++++++ crates/app/src/shell/panels.rs | 141 +++++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+) diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 2f6987d..96cc058 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -6837,4 +6837,98 @@ mod tests { _ => panic!("not a symbol instance"), } } + + /// A text object's first run's stated changes. + fn run_changes(shell: &Shell, id: doc::ObjectId) -> doc::TextStyle { + match shell.editor.document().registry.get(id) { + Some(doc::ObjectData::Text(text)) => text.runs.first().expect("a run").changes.clone(), + _ => panic!("not a text object"), + } + } + + /// The markup verbs restyle the *runs*, not the base style. + #[test] + fn the_markup_verbs_restyle_the_runs() { + let (mut shell, id) = text_shell("hello"); + shell.apply_character_markup(id, "b"); + assert_eq!(run_changes(&shell, id).bold, Some(true)); + shell.remove_character_markup(id, "b"); + assert_eq!(run_changes(&shell, id).bold, Some(false)); + shell.apply_character_markup(id, "fwplain"); + assert_eq!(run_changes(&shell, id).bold, Some(false)); + shell.apply_font_markup(id, "size", "24pt"); + assert_eq!(run_changes(&shell, id).size, Some(24.0)); + shell.remove_font_markup(id, "size"); + assert_eq!(run_changes(&shell, id).size, None); + } + + /// A shell with one button definition carrying a text item, and + /// one instance of it. + fn button_with_text_shell() -> (Shell, doc::ObjectId) { + let mut editor = gui_editor(); + editor + .apply( + "text.createText", + serde_json::json!({ + "page": 0, "layer": editor.active_layer(), "cell": 0, + "x": 10.0, "y": 10.0, "width": 200.0, "height": 40.0, + "text": "Old", "name": null, + }), + ) + .expect("the text was created"); + let text_id = editor.current_cell().expect("a cell").elements[1]; + editor.set_selection(vec![text_id]); + editor + .apply( + "symbol.create", + serde_json::json!({ + "ids": vec![text_id], "name": "button", "symbol_type": "button", + }), + ) + .expect("the button was created"); + let symbol = editor.document().symbols[0].symbol_number; + editor + .apply( + "symbol.createInstance", + serde_json::json!({ + "page": 0, "layer": 0, "cell": 0, "symbol": symbol, + "x": 40.0, "y": 40.0, + }), + ) + .expect("the instance was created"); + let elements = &editor.current_cell().expect("a cell").elements; + let id = elements[elements.len() - 1]; + editor.selection_set = vec![id]; + editor.selection = Some(id); + (gui_shell(editor), id) + } + + /// The button definition's text item's characters. + fn button_text_of(shell: &Shell) -> String { + let definition = &shell.editor.document().symbols[0]; + let cell = &definition.layers.layers[0].cells[0]; + let id = cell.elements[0]; + match shell.editor.document().registry.get(id) { + Some(doc::ObjectData::Text(text)) => text + .runs + .first() + .map(|run| run.text.clone()) + .unwrap_or_default(), + _ => panic!("not a text object"), + } + } + + /// The button's text: one verb across the library's button + /// definitions, one across the definitions the selected instances + /// reference. + #[test] + fn the_button_text_reaches_the_library_and_the_instances() { + let (mut shell, id) = button_with_text_shell(); + shell.replace_button_text("Library"); + assert_eq!(button_text_of(&shell), "Library"); + shell.editor.selection_set = vec![id]; + shell.editor.selection = Some(id); + shell.replace_button_text_in_instances("Instance"); + assert_eq!(button_text_of(&shell), "Instance"); + } } diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 1787f0a..bb91056 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -1618,6 +1618,46 @@ impl Shell { if !linked.found { ui.label("no slice is linked to this symbol yet"); } + // The button's text: CS6's replacement verbs — one across the + // library's button definitions, one across the definitions the + // selected instances reference. + let mut button_text = String::new(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut button_text).hint_text("button text")); + if ui.button("in library").clicked() { + self.replace_button_text(&button_text); + } + if ui.button("in instances").clicked() { + self.replace_button_text_in_instances(&button_text); + } + }); + } + + /// Replace the button text of every button definition in the + /// library. + pub(super) fn replace_button_text(&mut self, text: &str) { + self.run_command( + "text.replaceButtonTextStrings", + serde_json::json!({ "text": text, "uniform_attrs": false }), + ); + } + + /// Replace the button text of the definitions the selected + /// instances reference. The engine reads the document's selection, + /// so the view's selection is synced first. + pub(super) fn replace_button_text_in_instances(&mut self, text: &str) { + if self.editor.live_selection().is_empty() { + self.status = "nothing selected".to_owned(); + return; + } + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command( + "text.replaceButtonTextStringsInInstances", + serde_json::json!({ "text": text, "uniform_attrs": false }), + ); } /// The first slice linked to `number`, read for the button's @@ -2188,9 +2228,110 @@ impl Shell { ); self.text_layout_fields(ui, id, text); self.text_render_fields(ui, id, text); + self.text_markup_fields(ui, id, size, &font); self.geometry_fields(ui, id); } + /// The markup verbs: CS6's character and font markup, which + /// restyle the *runs* rather than the base style. + /// + /// The panel's bold/italic/underline checkboxes write the base + /// style (`text.setAttrs`); these are the sibling verbs that + /// reach the runs, which is what CS6's Format ▸ Text menu does + /// to a mixed-format text item. + pub(super) fn text_markup_fields( + &mut self, + ui: &mut egui::Ui, + id: doc::ObjectId, + size: f64, + face: &str, + ) { + ui.heading("Markup"); + ui.horizontal(|ui| { + if ui.button("bold").clicked() { + self.apply_character_markup(id, "b"); + } + if ui.button("italic").clicked() { + self.apply_character_markup(id, "i"); + } + if ui.button("underline").clicked() { + self.apply_character_markup(id, "u"); + } + if ui + .button("plain") + .on_hover_text("CS6's fwplain: no character markup, base included") + .clicked() + { + self.apply_character_markup(id, "fwplain"); + } + }); + ui.horizontal(|ui| { + if ui.button("clear bold").clicked() { + self.remove_character_markup(id, "b"); + } + if ui.button("clear italic").clicked() { + self.remove_character_markup(id, "i"); + } + if ui.button("clear underline").clicked() { + self.remove_character_markup(id, "u"); + } + }); + ui.horizontal(|ui| { + if ui.button("apply size").clicked() { + self.apply_font_markup(id, "size", &format!("{size}pt")); + } + if ui.button("apply face").clicked() { + self.apply_font_markup(id, "face", face); + } + }); + ui.horizontal(|ui| { + if ui.button("clear size").clicked() { + self.remove_font_markup(id, "size"); + } + if ui.button("clear color").clicked() { + self.remove_font_markup(id, "color"); + } + if ui.button("clear face").clicked() { + self.remove_font_markup(id, "face"); + } + }); + } + + /// Apply one character markup tag to the text's runs. + pub(super) fn apply_character_markup(&mut self, id: doc::ObjectId, tag: &str) { + self.run_command( + "text.applyCharacterMarkup", + serde_json::json!({ "id": id, "tag": tag }), + ); + } + + /// Reapply the default for one character markup tag. + pub(super) fn remove_character_markup(&mut self, id: doc::ObjectId, tag: &str) { + self.run_command( + "text.removeCharacterMarkup", + serde_json::json!({ "id": id, "tag": tag }), + ); + } + + /// Apply one font markup attribute to the text's runs. The size + /// takes the dom's `"NNNpt"` form; the face takes a family name. + pub(super) fn apply_font_markup(&mut self, id: doc::ObjectId, attribute: &str, value: &str) { + self.run_command( + "text.applyFontMarkup", + serde_json::json!({ + "id": id, "font_attribute": attribute, "value": value, + }), + ); + } + + /// Reapply the default for one font markup attribute. + pub(super) fn remove_font_markup(&mut self, id: doc::ObjectId, attribute: &str) { + self.run_command( + "text.removeFontMarkup", + serde_json::json!({ "id": id, "font_attribute": attribute }), + ); + } + /// The text object's layout: the paragraph's indent and spacing, /// the on-path offset, and the frame that fits the text. /// From 2542ba1acb161a7ce0208daaa824ce41aae64e2f Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:44:03 -0600 Subject: [PATCH 12/22] docs(tasks): record the markup and button-text bindings Decision 0112 records the markup section and the two button-text verbs. The live board now reads 218 bound commands of 253 (86.2 %). Co-Authored-By: Claude Code --- docs/TASKS.md | 2 +- ...12-the-markup-verbs-and-the-button-text.md | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0112-the-markup-verbs-and-the-button-text.md diff --git a/docs/TASKS.md b/docs/TASKS.md index dfe0ab6..8e67cbf 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds forty-four of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **212 of 253 (83.8 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds forty-four of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **218 of 253 (86.2 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight diff --git a/docs/decisions/0112-the-markup-verbs-and-the-button-text.md b/docs/decisions/0112-the-markup-verbs-and-the-button-text.md new file mode 100644 index 0000000..7bfd1ab --- /dev/null +++ b/docs/decisions/0112-the-markup-verbs-and-the-button-text.md @@ -0,0 +1,65 @@ +# 0112 — The text panel carries the markup verbs; the button carries its text + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +The text panel wrote the base style: `text.setAttrs` for the font, +the size and the bold/italic/underline flags. CS6's Format ▸ Text +menu does something else — it applies *markup*, which restyles the +**runs**, so that a text item with mixed formatting changes only the +part the user selected. The engine carried four verbs for that +(`text.applyCharacterMarkup`, `text.removeCharacterMarkup`, +`text.applyFontMarkup`, `text.removeFontMarkup`) and the shell +reached none of them. + +The button family had the same gap on its text side: CS6's +`dom.replaceButtonTextStrings` and +`dom.replaceButtonTextStringsInInstances` — "change every button's +label" and "change the labels of the buttons these instances +reference" — were unreachable. + +## Decision + +- **The text panel grows a Markup section.** Character markup + (`bold`, `italic`, `underline`, and CS6's `fwplain` for "no + character markup, base included") and font markup (`size`, `face`). + Each verb has its inverse — "clear bold", "clear size" — so the + markup can be taken back as well as applied. +- **The font markup carries the dom's own value forms.** The size + takes the `"NNNpt"` form the dom refuses to do without; the face + takes a family name. The panel offers the text item's current base + size and family, so the common case is one click. +- **The button section grows the two button-text verbs.** "In library" + replaces the text of every button definition; "in instances" + replaces the text of the definitions the selected instances + reference, and syncs the view's selection into the document first + (decision 0104's bridge). + +## Evidence + +- `restyle_runs` computes each run's *delta* against the previous + effective style, so a markup verb states only what changed — the + base style and the runs stay in step. +- `text.applyFontMarkup` refuses a bare number for the size ("a + simple numeric value is not allowed"), so the panel sends the + `"NNNpt"` form. +- The button-text verbs reach only *button* definitions — the + instances-scoped one skips a definition that is not a button — and + replace the runs with one run of the replacement characters. +- The reachability gate measured all six commands as carried by the + engine and unbound by the shell. + +## Consequences + +- Six more engine commands are reachable from the shell's designed + surface, and the text family is fully bound: the measurement moves + from 212 bound commands of 253 to **218 of 253** (83.8 % → 86.2 %). +- A mixed-format text item can be formatted the way CS6 formats it, + and a button's label can be changed from the panel that owns it. + +See also decisions [0108](0108-the-properties-panel-writes-the-text-layout.md), +[0109](0109-the-text-properties-write-the-rendering-dials.md) and +[0111](0111-the-symbol-panel-carries-the-symbol-family.md) — the same +week's other steps in closing the measured surface gap. From 42bc46615508f5518925ef66a6e85a59296bd10d Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:46:40 -0600 Subject: [PATCH 13/22] feat(panels): the filters panel bakes and renames CS6's setEffectName renames an applied filter, and the bake family applies a filter to the selection and bakes it into the pixels: by name, by catalog name, by GUID, as a raw convolution kernel, or as a motion blur. The panel carries all of it; the bake verbs sync the selection first, because they read the document's selection. Six commands, all through the engine's registry. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 41 ++++++ crates/app/src/shell/panels.rs | 261 +++++++++++++++++++++++++++++++-- 2 files changed, 293 insertions(+), 9 deletions(-) diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 96cc058..b9cbee0 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -6931,4 +6931,45 @@ mod tests { shell.replace_button_text_in_instances("Instance"); assert_eq!(button_text_of(&shell), "Instance"); } + + /// One effect of an object. + fn effect_of(shell: &Shell, id: doc::ObjectId, index: usize) -> doc::Effect { + match shell.editor.document().registry.get(id) { + Some(doc::ObjectData::Path(path)) => path.effects[index].clone(), + _ => panic!("not a path"), + } + } + + /// CS6's `setEffectName`: the effect keeps its category and takes + /// the new internal name. + #[test] + fn the_effect_can_be_renamed() { + let (mut shell, id) = two_path_shell(); + shell.apply_filter_preset(id, FILTER_FAMILIES[0].1[0]); + let effect = effect_of(&shell, id, 0); + let category = effect.category.clone(); + let old_name = effect.internal_name.clone(); + shell.rename_filter_effect(&category, &old_name, "renamed"); + assert_eq!(effect_of(&shell, id, 0).internal_name, "renamed"); + } + + /// The bake verbs carry the dom's own payload shapes: the + /// convolution kernel's count must match its sides, and the + /// motion blur's kind must be one the dom knows. + #[test] + fn the_bake_verbs_carry_the_dom_payloads() { + let (mut shell, _id) = two_path_shell(); + shell.convolve_selection(3, 3, vec![0, 0, 0, 0], false); + assert!( + shell.status.contains("filter.convolveSelection failed"), + "{}", + shell.status + ); + shell.motion_blur_selection("sideways", 0.0, 10, 8); + assert!( + shell.status.contains("filter.motionBlurSelection failed"), + "{}", + shell.status + ); + } } diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index bb91056..5bbaae1 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -1840,9 +1840,10 @@ impl Shell { }); } - /// The animation verbs address the document's selection, so the - /// view's selection is synced into the document first. - fn sync_for_animation(&mut self) -> bool { + /// The animation and effect verbs address the document's + /// selection, so the view's selection is synced into the + /// document first. + fn sync_document_selection(&mut self) -> bool { if let Err(error) = self.editor.sync_selection() { self.status = format!("selection failed: {error}"); return false; @@ -1852,7 +1853,7 @@ impl Shell { /// Write the animation's frame count (`ANNi`). pub(super) fn set_anim_num_frames(&mut self, frames: i64) { - if !self.sync_for_animation() { + if !self.sync_document_selection() { return; } self.run_command( @@ -1864,7 +1865,7 @@ impl Shell { /// Write the animation's loop count (CS6's deprecated spelling of /// the frame count). pub(super) fn set_anim_loop_count(&mut self, loop_count: i64) { - if !self.sync_for_animation() { + if !self.sync_document_selection() { return; } self.run_command( @@ -1875,7 +1876,7 @@ impl Shell { /// Write the animation's travel across the span (`ANXf`/`ANYf`). pub(super) fn set_anim_offset(&mut self, x: f64, y: f64) { - if !self.sync_for_animation() { + if !self.sync_document_selection() { return; } self.run_command( @@ -1886,7 +1887,7 @@ impl Shell { /// Write the animation's rotation across the span (`ANRi`). pub(super) fn set_anim_rotation(&mut self, rotation: f64) { - if !self.sync_for_animation() { + if !self.sync_document_selection() { return; } self.run_command( @@ -1897,7 +1898,7 @@ impl Shell { /// Write the animation's scaling across the span (`ASCf`). pub(super) fn set_anim_scale(&mut self, scale: f64) { - if !self.sync_for_animation() { + if !self.sync_document_selection() { return; } self.run_command( @@ -1908,7 +1909,7 @@ impl Shell { /// Write the animation's opacity ramp (`ASOi`/`AEOi`). pub(super) fn set_anim_opacity(&mut self, start: f64, end: f64) { - if !self.sync_for_animation() { + if !self.sync_document_selection() { return; } self.run_command( @@ -3132,10 +3133,225 @@ impl Shell { ); } }); + // The effect's own name: CS6's `setEffectName` + // renames it under its category. + let mut new_name = effect.internal_name.clone(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut new_name).desired_width(96.0)); + if ui.small_button("rename").clicked() { + self.rename_filter_effect( + &effect.category, + &effect.internal_name, + &new_name, + ); + } + }); self.filter_param_rows(ui, id, index, effect); }); } self.filter_add_menu(ui, id); + self.filter_bake_fields(ui); + } + + /// Rename the effect under its category — CS6's + /// `dom.setEffectName(category, oldName, newName)`. The verb + /// walks the *selection*, so the view's selection is synced into + /// the document first. + pub(super) fn rename_filter_effect(&mut self, category: &str, old_name: &str, new_name: &str) { + if new_name.is_empty() { + self.status = "the new name is empty".to_owned(); + return; + } + if !self.sync_document_selection() { + return; + } + self.run_command( + "filter.setEffectName", + serde_json::json!({ + "category": category, "old_name": old_name, "new_name": new_name, + }), + ); + } + + /// The bake section: CS6's `dom.filterSelection*` verbs, which + /// apply a filter to the selection and bake it into the pixels. + /// + /// The bake verbs read the document's selection, so the panel + /// syncs the view's selection into the document first. + pub(super) fn filter_bake_fields(&mut self, ui: &mut egui::Ui) { + ui.heading("Bake"); + let mut category = String::new(); + let mut name = String::new(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut category).hint_text("category")); + ui.add(egui::TextEdit::singleline(&mut name).hint_text("filter name")); + }); + ui.horizontal(|ui| { + if ui.button("bake").clicked() { + self.bake_filter(&category, &name); + } + if ui + .button("bake by catalog name") + .on_hover_text( + "CS6's filterSelectionByName: the filter's GUID comes from the catalog", + ) + .clicked() + { + self.bake_filter_by_name(&category, &name); + } + }); + let mut guid = String::new(); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut guid).hint_text("EffectMoaID")); + if ui.button("bake by id").clicked() { + self.bake_filter_by_id(&guid); + } + }); + // The convolution kernel: raw integer coefficients, row + // major, exactly as the dom takes them. + let mut width = 3i64; + let mut height = 3i64; + let mut values = "0,0,0,0,1,0,0,0,0".to_owned(); + let mut affects_alpha = false; + ui.horizontal(|ui| { + ui.add( + egui::DragValue::new(&mut width) + .speed(1.0) + .range(1..=7) + .prefix("w "), + ); + ui.add( + egui::DragValue::new(&mut height) + .speed(1.0) + .range(1..=7) + .prefix("h "), + ); + ui.add( + egui::TextEdit::singleline(&mut values) + .desired_width(160.0) + .hint_text("kernel values"), + ); + }); + ui.horizontal(|ui| { + ui.checkbox(&mut affects_alpha, "affects alpha"); + if ui.button("convolve").clicked() { + match parse_kernel(&values, width, height) { + Ok(kernel) => self.convolve_selection(width, height, kernel, affects_alpha), + Err(detail) => self.status = format!("kernel refused: {detail}"), + } + } + }); + // The motion blur: the dom's three kinds. + let mut blur_type = "linear".to_owned(); + let mut angle = 0.0f64; + let mut distance = 10u32; + let mut samples = 8u32; + ui.horizontal(|ui| { + egui::ComboBox::from_label("blur") + .selected_text(blur_type.clone()) + .show_ui(ui, |ui| { + for choice in ["linear", "radial", "zoom"] { + if ui.selectable_label(blur_type == choice, choice).clicked() { + blur_type = choice.to_owned(); + } + } + }); + ui.add(egui::DragValue::new(&mut angle).speed(1.0).prefix("angle ")); + ui.add( + egui::DragValue::new(&mut distance) + .speed(1.0) + .range(0..=400) + .prefix("distance "), + ); + ui.add( + egui::DragValue::new(&mut samples) + .speed(1.0) + .range(0..=100) + .prefix("samples "), + ); + }); + if ui.button("motion blur").clicked() { + self.motion_blur_selection(&blur_type, angle, distance, samples); + } + } + + /// Bake the named filter into the selection. + pub(super) fn bake_filter(&mut self, category: &str, name: &str) { + if !self.sync_document_selection() { + return; + } + self.run_command( + "filter.selection", + serde_json::json!({ "category": category, "name": name, "params": [] }), + ); + } + + /// Bake the named filter into the selection, taking the filter's + /// GUID from the catalog. + pub(super) fn bake_filter_by_name(&mut self, category: &str, name: &str) { + if !self.sync_document_selection() { + return; + } + self.run_command( + "filter.selectionByName", + serde_json::json!({ "category": category, "name": name, "params": [] }), + ); + } + + /// Bake the filter with the given `EffectMoaID` into the + /// selection. + pub(super) fn bake_filter_by_id(&mut self, guid: &str) { + if !self.sync_document_selection() { + return; + } + self.run_command( + "filter.selectionByID", + serde_json::json!({ "id": guid, "params": [] }), + ); + } + + /// Bake an arbitrary integer kernel into the selection. + pub(super) fn convolve_selection( + &mut self, + width: i64, + height: i64, + values: Vec, + affects_alpha: bool, + ) { + if !self.sync_document_selection() { + return; + } + self.run_command( + "filter.convolveSelection", + serde_json::json!({ + "kernel_width": width, + "kernel_height": height, + "kernel_values": values, + "affects_alpha": affects_alpha, + }), + ); + } + + /// Bake a motion blur into the selection. + pub(super) fn motion_blur_selection( + &mut self, + blur_type: &str, + angle: f64, + distance: u32, + samples: u32, + ) { + if !self.sync_document_selection() { + return; + } + self.run_command( + "filter.motionBlurSelection", + serde_json::json!({ + "blur_type": blur_type, + "angle": angle, + "distance": distance, + "samples": samples, + }), + ); } /// The family-grouped quick-add menu — the same table the Filters @@ -3448,3 +3664,30 @@ struct LinkedSliceFacts { alt: String, slice_name: String, } + +/// The convolution kernel's coefficients: the dom takes them raw, +/// row major, and their count must match the kernel's sides. +fn parse_kernel(text: &str, width: i64, height: i64) -> Result, String> { + let cells = width * height; + if cells <= 0 { + return Err("the kernel has no cells".to_owned()); + } + let mut values = Vec::new(); + for part in text.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + match trimmed.parse::() { + Ok(value) => values.push(value), + Err(_) => return Err(format!("{trimmed:?} is not an integer")), + } + } + if values.len() as i64 != cells { + return Err(format!( + "{} values for a {width}x{height} kernel", + values.len() + )); + } + Ok(values) +} From 7a787ec5e5eeb2cfe1847fa6dafde9ce028bb493 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:46:40 -0600 Subject: [PATCH 14/22] docs(tasks): record the filter bake and rename bindings Decision 0113 records the rename row and the Bake section. The live board now reads 224 bound commands of 253 (88.5 %). Co-Authored-By: Claude Code --- docs/TASKS.md | 2 +- ...113-the-filters-panel-bakes-and-renames.md | 73 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0113-the-filters-panel-bakes-and-renames.md diff --git a/docs/TASKS.md b/docs/TASKS.md index 8e67cbf..47c0d5f 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds forty-four of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **218 of 253 (86.2 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds forty-four of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **224 of 253 (88.5 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight diff --git a/docs/decisions/0113-the-filters-panel-bakes-and-renames.md b/docs/decisions/0113-the-filters-panel-bakes-and-renames.md new file mode 100644 index 0000000..d25165d --- /dev/null +++ b/docs/decisions/0113-the-filters-panel-bakes-and-renames.md @@ -0,0 +1,73 @@ +# 0113 — The Filters panel bakes and renames + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +The Filters panel carried the *live* effect stack: `filter.add` to +push a preset onto the selection, `filter.setParam` to tune it, +`filter.remove` to drop it. Two CS6 verbs had no door: + +- `dom.setEffectName(category, oldName, newName)` — Fireworks MX's + way of naming an applied filter. +- The bake family — `dom.filterSelection`, `dom.filterSelectionByID` + and `dom.filterSelectionByName`, plus `dom.convolveSelection` and + `dom.motionBlurSelection` — which apply a filter to the selection + and bake it into the pixels. + +The bake family is the one a daily driver wants: it is how a user +applies a filter by its CS6 name, with its CS6 parameters, without +the live stack standing between the request and the bitmap. + +## Decision + +- **Every effect row gains a rename field.** The write names the + effect's category and its current internal name, and carries the + replacement — CS6's three-name contract, unchanged. +- **The panel gains a Bake section** with CS6's own verbs: + - *bake* — the filter by category and name; + - *bake by catalog name* — the same, with the filter's GUID taken + from the catalog (`filterSelectionByName`); + - *bake by id* — the filter by its `EffectMoaID` + (`filterSelectionByID`); + - *convolve* — an arbitrary integer kernel, taken as the dom takes + it: raw coefficients, row major, no normalization; + - *motion blur* — the dom's three kinds (linear, radial, zoom) with + their angle, distance and tap count. +- **The kernel's coefficients are entered as text.** The dom takes + them raw and unnormalized, so a numeric grid would invite the + mistake of "summing to one". The panel parses the text, refuses a + count that does not match the kernel's sides, and says so. +- **The bake verbs sync the selection first.** They read the + *document's* selection, so the panel bridges the view's selection + into the document (decision 0104's bridge) before the write. + +## Evidence + +- `filter.setEffectName` matches on both the category and the + internal name, and renames every matching effect on the selected + objects; no match is the no-op. +- The bake commands resolve their targets completely before any + mutation, and refuse when the filter does not resolve to a pixel + kernel — the engine's no-passthrough floor law. +- `filter.convolveSelection` checks that the coefficient count + equals the kernel's cell count, and `filter.motionBlurSelection` + refuses a kind outside `linear`/`radial`/`zoom` — the refusals the + panel's own tests assert. +- The reachability gate measured all six commands as carried by the + engine and unbound by the shell. + +## Consequences + +- Six more engine commands are reachable from the shell's designed + surface, and the filter family is fully bound: the measurement + moves from 218 bound commands of 253 to **224 of 253** (86.2 % → + 88.5 %). +- A filter can be named, and a filter can be applied to the pixels + by name, by GUID, by kernel or as a motion blur. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0109](0109-the-text-properties-write-the-rendering-dials.md) and +[0111](0111-the-symbol-panel-carries-the-symbol-family.md) — the same +week's other steps in closing the measured surface gap. From a904fbf08a61d6bda05144e67403f296b17cd7cc Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:53:46 -0600 Subject: [PATCH 15/22] fix(engine): read a uniform mask's coverage through the renderer's view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mask.add` mints a uniform mask as a solid matte — the file's flat color for the tile — and the engine documents that matte as the "reveal all" mask: opaque. But `mask_gray_plane` read the mask's pixels through `gather`, the editable-pixels view, which skips a matte slot entirely, so the mask the user sees and the mask the engine measured disagreed. Two consequences, both under the newly bound mask family: - `mask.setMode` "mask to path" found no coverage and refused on the very masks `mask.add` mints; - `mask.remove` "apply" baked an all-zero coverage into the content — a "reveal all" mask erased the artwork it covered. The coverage now reads through `assemble_rgba`, the renderer's view, which is what the clipboard's own mask reader already does (decision 0058). `gather` keeps its editable-pixels contract: a matte still occupies its grid slot there. Co-Authored-By: Claude Code --- crates/engine/src/catalog/mask.rs | 106 +++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/catalog/mask.rs b/crates/engine/src/catalog/mask.rs index e5cd355..2b16ea3 100644 --- a/crates/engine/src/catalog/mask.rs +++ b/crates/engine/src/catalog/mask.rs @@ -22,7 +22,7 @@ use geom::{Point, Vec2}; use super::object::{Owner, attach_cell, content_bounds, detach, find_owner, translate_object}; use crate::access; -use crate::bitmap::{check_editable, gather, scatter, touched_tiles}; +use crate::bitmap::{assemble_rgba, check_editable, gather, scatter, touched_tiles}; use crate::command::TypedCommand; use crate::delta::{Delta, ObjectInverse}; use crate::error::EngineError; @@ -299,17 +299,16 @@ fn mask_gray_plane( detail: "mask image and content do not overlay (locations differ)".to_owned(), }); } - let flat = gather(mask, &document.tiles, name)?; + // The coverage is read through the renderer's view: a + // solid matte slot is the file's flat color for the tile, + // and the renderer bakes it to that color (decision + // 0034), so a uniform mask reads as the gray it stands + // for — a "reveal all" mask is opaque, not empty. + let flat = assemble_rgba(document, mask, name)?; Ok(SelectionMask { width: dims.0, height: dims.1, - alpha: flat - .pixels - .as_chunks::<4>() - .0 - .iter() - .map(|pixel| pixel[0]) - .collect(), + alpha: flat.chunks_exact(4).map(|pixel| pixel[0]).collect(), }) } ObjectData::Path(path) => { @@ -1282,16 +1281,15 @@ impl TypedCommand for SetMaskMode { } }; let (width, height) = check_editable(&image, NAME)?; - let flat = gather(&image, &document.tiles, NAME)?; + // The renderer's view again: a uniform matte mask is + // the solid color it stands for, and its coverage is + // that gray. + let flat = assemble_rgba(document, &image, NAME)?; let mut plane = SelectionMask::empty( u32::try_from(width).unwrap_or(0), u32::try_from(height).unwrap_or(0), ); - for (slot, pixel) in plane - .alpha - .iter_mut() - .zip(flat.pixels.as_chunks::<4>().0.iter()) - { + for (slot, pixel) in plane.alpha.iter_mut().zip(flat.chunks_exact(4)) { *slot = pixel[0]; } let contours = plane_to_path(&plane, image.location); @@ -2350,6 +2348,84 @@ mod tests { assert!(document.registry.get(mask_id).is_none()); } + /// A uniform mask is a solid matte — the file's flat color for + /// the tile — and the renderer bakes a matte to that color, so + /// the mask's coverage must read the same way: a "reveal all" + /// mask is opaque, not empty. + #[test] + fn a_uniform_mask_reads_its_coverage_through_the_matte() { + let (mut document, image_id) = image_document(); + AddMask + .apply_json( + &mut document, + serde_json::json!({ "id": image_id, "mode": "reveal all" }), + ) + .unwrap(); + let group_id = cell_element(&document, 0).unwrap(); + let ObjectData::Group(group) = document.registry.get(group_id).unwrap() else { + panic!("cell should reference the mask group"); + }; + let ObjectData::Image(content) = document.registry.get(image_id).unwrap() else { + panic!("the content should be an image"); + }; + let coverage = mask_gray_plane(&document, NAME, group.children[0], content).unwrap(); + assert!( + coverage.alpha.iter().all(|&gray| gray == 255), + "a reveal-all mask is opaque" + ); + } + + /// The uniform mask converts to a vector mask: its coverage is + /// the whole rectangle, so the traced boundary is the rectangle. + #[test] + fn a_uniform_mask_converts_to_a_vector_mask() { + let (mut document, image_id) = image_document(); + AddMask + .apply_json( + &mut document, + serde_json::json!({ "id": image_id, "mode": "reveal all" }), + ) + .unwrap(); + let group_id = cell_element(&document, 0).unwrap(); + SetMaskMode + .apply_json( + &mut document, + serde_json::json!({ "id": image_id, "mode": "mask to path" }), + ) + .unwrap(); + let ObjectData::Group(group) = document.registry.get(group_id).unwrap() else { + panic!("group vanished"); + }; + assert_eq!(group.group_type, SHAPE_MASK); + let ObjectData::Path(path) = document.registry.get(group.children[0]).unwrap() else { + panic!("the replacement mask should be a path"); + }; + assert_eq!(path.contours.len(), 1, "the rectangle's boundary"); + } + + /// A "hide all" mask is empty coverage, and an empty coverage has + /// no boundary to trace — the conversion refuses, as it must. + #[test] + fn an_empty_mask_has_no_boundary_to_trace() { + let (mut document, image_id) = image_document(); + AddMask + .apply_json( + &mut document, + serde_json::json!({ "id": image_id, "mode": "hide all" }), + ) + .unwrap(); + let error = SetMaskMode + .apply_json( + &mut document, + serde_json::json!({ "id": image_id, "mode": "mask to path" }), + ) + .unwrap_err(); + assert!( + matches!(error, EngineError::InvalidState { .. }), + "{error:?}" + ); + } + #[test] fn path_to_marquee_and_marquee_to_path_round_trip_structure() { let (mut document, image_id) = image_document(); From 599bbab2eb250745bfba437dbb7b72e93a31bce4 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:53:54 -0600 Subject: [PATCH 16/22] feat(panels): the properties panel carries the element mask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CS6's Modify menu moves a mask and its content separately, converts a mask between its raster and vector forms, and shows or hides the mask's own fill and stroke. The engine carried all four verbs and the shell reached none of them. The Properties panel grows a Mask section, shown only when the selection actually carries an element mask — every mask verb refuses on an unmasked object, so an always-present section would only offer dead buttons. The panel asks the engine who owns the mask rather than re-deriving the ownership rule in the shell. The two nudges are separate rows, because the two verbs are not interchangeable: `mask.moveBy` translates the mask and leaves the content alone, `mask.moveContentsBy` does the reverse. The conversion button follows the mask's kind, and the attributes button is its own inverse. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 112 +++++++++++++++++++ crates/app/src/shell/panels.rs | 197 +++++++++++++++++++++++++++++++-- 2 files changed, 302 insertions(+), 7 deletions(-) diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index b9cbee0..891507c 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -6972,4 +6972,116 @@ mod tests { shell.status ); } + + /// A shell whose selection carries an element mask. + fn masked_shell(mode: &str) -> (Shell, doc::ObjectId) { + let mut editor = gui_editor(); + let id = editor.current_cell().expect("a cell").elements[0]; + editor + .apply( + "mask.add", + serde_json::json!({ "id": id, "mode": mode, "enter_edit_mode": false }), + ) + .expect("the mask was added"); + editor.set_selection(vec![id]); + (gui_shell(editor), id) + } + + /// The mask group that owns the masked object. + fn mask_group_of(shell: &Shell, id: doc::ObjectId) -> (doc::ObjectId, doc::GroupObject) { + for (group_id, data) in shell.editor.document().registry.iter() { + let doc::ObjectData::Group(group) = data else { + continue; + }; + if group.children.get(1) == Some(&id) { + return (group_id, group.clone()); + } + } + panic!("object {id:?} carries no mask group"); + } + + /// The mask image's location. + fn mask_location(shell: &Shell, group: &doc::GroupObject) -> geom::Point { + let mask_id = group.children.first().copied().expect("a mask child"); + match shell.editor.document().registry.get(mask_id) { + Some(doc::ObjectData::Image(image)) => image.location, + _ => panic!("the mask child is not an image"), + } + } + + /// The masked content's first anchor. + fn content_anchor(shell: &Shell, id: doc::ObjectId) -> geom::Point { + match shell.editor.document().registry.get(id) { + Some(doc::ObjectData::Path(path)) => path + .contours + .first() + .and_then(|contour| contour.points.first()) + .map(|point| point.anchor) + .expect("a contour"), + _ => panic!("the content is not a path"), + } + } + + /// The mask panel's two nudges move different things: the mask + /// image follows `mask.moveBy`, and the content it hides follows + /// `mask.moveContentsBy`. + #[test] + fn the_mask_nudges_move_the_mask_and_the_content() { + let (mut shell, id) = masked_shell("reveal all"); + let facts = shell.mask_facts(id).expect("the selection carries a mask"); + assert!(facts.raster, "a minted mask is a grayscale mask"); + assert!(facts.enabled, "a fresh mask is enabled"); + assert!(!facts.hidden, "the mask's attributes start visible"); + + let (_group, group) = mask_group_of(&shell, id); + let mask_before = mask_location(&shell, &group); + let content_before = content_anchor(&shell, id); + shell.mask_nudge(id, 3.0, 0.0, false); + let (_group, group) = mask_group_of(&shell, id); + let mask_moved = mask_location(&shell, &group); + assert_eq!(mask_moved.x, mask_before.x + 3.0, "the mask moved"); + assert_eq!( + content_anchor(&shell, id), + content_before, + "the content stayed where it was" + ); + + shell.mask_nudge(id, 0.0, 4.0, true); + assert_eq!( + content_anchor(&shell, id).y, + content_before.y + 4.0, + "the content moved" + ); + let (_group, group) = mask_group_of(&shell, id); + assert_eq!( + mask_location(&shell, &group), + mask_moved, + "the mask stayed where it was" + ); + } + + /// The mask converts between CS6's raster and vector forms, and + /// the conversion rewrites the mask child and the group's kind. + #[test] + fn the_mask_converts_between_raster_and_vector() { + let (mut shell, id) = masked_shell("reveal all"); + shell.mask_set_mode(id, "mask to path"); + let facts = shell.mask_facts(id).expect("the selection carries a mask"); + assert!(!facts.raster, "the mask is a vector mask now"); + shell.mask_set_mode(id, "mask to image"); + let facts = shell.mask_facts(id).expect("the selection carries a mask"); + assert!(facts.raster, "the mask is a raster mask again"); + } + + /// The mask's own fill and stroke can be hidden and shown again. + #[test] + fn the_mask_attributes_can_be_hidden_and_shown() { + let (mut shell, id) = masked_shell("reveal all"); + shell.mask_set_show_attrs(id, false); + let facts = shell.mask_facts(id).expect("the selection carries a mask"); + assert!(facts.hidden, "the mask's attributes are hidden"); + shell.mask_set_show_attrs(id, true); + let facts = shell.mask_facts(id).expect("the selection carries a mask"); + assert!(!facts.hidden, "the mask's attributes are shown again"); + } } diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 5bbaae1..685957d 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -1102,37 +1102,200 @@ impl Shell { let effects = match object { Some(ObjectData::Path(path)) => { self.path_properties(ui, id, &path); - path.effects + Some(path.effects) } Some(ObjectData::Text(text)) => { self.text_properties(ui, id, &text); - text.effects + Some(text.effects) } Some(ObjectData::SymbolInstance(instance)) => { self.symbol_instance_properties(ui, id, &instance); - return; + None } Some(ObjectData::Group(group)) => { self.group_properties(ui, id, &group); - group.effects + Some(group.effects) } Some(ObjectData::Image(image)) => { self.image_properties(ui, id, &image); - image.effects + Some(image.effects) } Some(ObjectData::Slice(slice)) => { // A slice carries no `EFLv` spine, so the effect list // below has nothing to read. self.slice_properties(ui, id, &slice); - return; + None } None => { ui.label("stale selection"); return; } }; + // The element mask the selection carries, if any. + self.mask_panel(ui, id); // The live effect list: every kind that carries an `EFLv` spine. - self.filter_panel(ui, id, &effects); + if let Some(effects) = effects { + self.filter_panel(ui, id, &effects); + } + } + + /// The element mask the selection carries: CS6's own mask + /// section — the conversion between a raster and a vector mask, + /// the two nudges (the mask itself, and the content it hides), + /// and the mask's own fill and stroke visibility. + /// + /// The panel draws nothing when the selection carries no mask: + /// the mask verbs all refuse on an unmasked object, so an + /// always-present section would only offer dead buttons. + pub(super) fn mask_panel(&mut self, ui: &mut egui::Ui, id: doc::ObjectId) { + let Some(facts) = self.mask_facts(id) else { + return; + }; + ui.separator(); + ui.heading("Mask"); + ui.label(format!( + "{} mask in group {} (gen {})", + if facts.raster { "raster" } else { "vector" }, + facts.group_id.index(), + facts.group_id.generation() + )); + ui.horizontal(|ui| { + // Only an enabled mask converts: the conversion rewrites + // the mask child, and a disabled mask has no `GRTi` kind + // to follow. + if facts.enabled { + let mode = if facts.raster { + "mask to path" + } else { + "mask to image" + }; + if ui.button(mode).clicked() { + self.mask_set_mode(id, mode); + } + } + let label = if facts.hidden { + "show mask attributes" + } else { + "hide mask attributes" + }; + if ui.button(label).clicked() { + self.mask_set_show_attrs(id, !facts.hidden); + } + }); + if facts.enabled { + for (contents, what) in [(false, "mask"), (true, "content")] { + self.mask_nudge_row(ui, id, contents, what); + } + } + } + + /// The element mask the selection carries: the owning mask + /// group, whether the mask is raster (an image) or vector (a + /// path), whether the mask is enabled, and whether the mask's + /// own fill and stroke are hidden. + /// + /// The engine resolves the owner, so the panel asks it rather + /// than re-deriving the ownership rule. + pub(super) fn mask_facts(&self, id: doc::ObjectId) -> Option { + let document = self.editor.document(); + let Some(enabled) = engine::catalog::element_mask_state(document, id) else { + return None; + }; + let mask_kind = |group: &GroupObject| { + group + .children + .first() + .and_then(|child| document.registry.get(child)) + .is_some_and(|data| matches!(data, ObjectData::Image(_) | ObjectData::Path(_))) + }; + let is_mask_group = |group: &GroupObject| { + group.group_type == MASK_GROUP_GRAYSCALE + || group.group_type == MASK_GROUP_SHAPE + || (group.group_type == MASK_GROUP_REGULAR && mask_kind(group)) + }; + let mut owner: Option<(doc::ObjectId, GroupObject)> = None; + if let Some(ObjectData::Group(group)) = document.registry.get(id) + && is_mask_group(group) + { + owner = Some((id, group.clone())); + } + if owner.is_none() { + for (group_id, data) in document.registry.iter() { + let ObjectData::Group(group) = data else { + continue; + }; + if is_mask_group(group) && group.children.get(1) == Some(&id) { + owner = Some((group_id, group.clone())); + break; + } + } + } + let (group_id, group) = owner?; + let raster = match group + .children + .first() + .and_then(|child| document.registry.get(child)) + { + Some(ObjectData::Image(_)) => true, + Some(ObjectData::Path(_)) => false, + _ => return None, + }; + Some(MaskFacts { + group_id, + raster, + enabled, + hidden: group.mask_hidden, + }) + } + + /// `mask.setMode` — convert the mask between CS6's raster and + /// vector forms. + pub(super) fn mask_set_mode(&mut self, id: doc::ObjectId, mode: &str) { + self.run_command( + "mask.setMode", + serde_json::json!({ "id": id, "mode": mode }), + ); + } + + /// `mask.setShowAttrs` — show or hide the mask's own fill and + /// stroke. + pub(super) fn mask_set_show_attrs(&mut self, id: doc::ObjectId, show: bool) { + self.run_command( + "mask.setShowAttrs", + serde_json::json!({ "id": id, "show": show }), + ); + } + + /// `mask.moveBy` / `mask.moveContentsBy` — nudge the mask itself, + /// or the content it hides, by a pixel delta. + pub(super) fn mask_nudge(&mut self, id: doc::ObjectId, dx: f64, dy: f64, contents: bool) { + let command = if contents { + "mask.moveContentsBy" + } else { + "mask.moveBy" + }; + self.run_command( + command, + serde_json::json!({ "ids": [id], "dx": dx, "dy": dy }), + ); + } + + /// One nudge row: the arrows that move the mask, or the content + /// it hides, one pixel at a time. + fn mask_nudge_row(&mut self, ui: &mut egui::Ui, id: doc::ObjectId, contents: bool, what: &str) { + ui.horizontal(|ui| { + ui.label(format!("move {what}")); + for (dx, dy, label) in [ + (-1.0, 0.0, "◀"), + (0.0, -1.0, "▲"), + (0.0, 1.0, "▼"), + (1.0, 0.0, "▶"), + ] { + if ui.small_button(label).clicked() { + self.mask_nudge(id, dx, dy, contents); + } + } + }); } /// The in-progress shape's panel. CS6's Pen, Polygon Slice, @@ -3654,6 +3817,26 @@ fn object_label(object: &ObjectData) -> String { } } +/// The mask group's `GRTi` values: an ordinary group, a grayscale +/// (raster) mask, and a shape (vector) mask. +const MASK_GROUP_REGULAR: i64 = 0; +const MASK_GROUP_GRAYSCALE: i64 = 1; +const MASK_GROUP_SHAPE: i64 = 2; + +/// The element mask the selection carries. +#[derive(Clone, Copy)] +pub(super) struct MaskFacts { + /// The group that owns the mask and the content. + pub(super) group_id: doc::ObjectId, + /// The mask is an image (raster), not a path (vector). + pub(super) raster: bool, + /// The mask is enabled — a `GRTi` 1/2 group, not the disabled + /// `GRTi` 0 structure. + pub(super) enabled: bool, + /// The mask's own fill and stroke are hidden (`MNAb`). + pub(super) hidden: bool, +} + /// The button's export options, read from the first slice linked to /// the symbol. `found` records whether any slice is linked at all — /// the engine refuses `symbol.setButtonOptions` when none is. From 471ca153f6f8691abfe74d2af5161967acdfe133 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 14:53:55 -0600 Subject: [PATCH 17/22] docs(tasks): record the mask bindings Decision 0114 records the mask section and the engine's coverage read. The reachability measurement moves from 224 bound commands of 253 to 228 of 253 (90.1 %), and the mask family is fully bound. Co-Authored-By: Claude Code --- docs/TASKS.md | 2 +- ...4-the-properties-panel-carries-the-mask.md | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0114-the-properties-panel-carries-the-mask.md diff --git a/docs/TASKS.md b/docs/TASKS.md index 47c0d5f..99ae9e8 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds forty-four of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **224 of 253 (88.5 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds sixty of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)), and the Properties panel carries the element mask ([0114](decisions/0114-the-properties-panel-carries-the-mask.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **228 of 253 (90.1 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight diff --git a/docs/decisions/0114-the-properties-panel-carries-the-mask.md b/docs/decisions/0114-the-properties-panel-carries-the-mask.md new file mode 100644 index 0000000..8e91b4a --- /dev/null +++ b/docs/decisions/0114-the-properties-panel-carries-the-mask.md @@ -0,0 +1,93 @@ +# 0114 — The Properties panel carries the element mask + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +CS6's Modify ▸ Mask menu carried the mask's own verbs: mask the +selection, enable or disable the mask, link or unlink it, apply or +discard it. The shell bound those through the menu, but the engine +carried four more mask verbs with no door at all: + +- `mask.moveBy` — move the *mask*, leaving the content it hides + where it is; +- `mask.moveContentsBy` — move the *content*, leaving the mask in + place; +- `mask.setMode` — convert the mask between CS6's raster (a + grayscale bitmap) and vector (a shape) form; +- `mask.setShowAttrs` — show or hide the mask's own fill and stroke. + +The two moves are the ones a daily driver reaches for: a mask that +does not line up with the artwork is corrected by nudging the mask +or the content, not by rebuilding the mask. + +## Decision + +- **The Properties panel grows a Mask section**, shown only when the + selection actually carries an element mask. Every mask verb + refuses on an unmasked object, so an always-present section would + only offer dead buttons. +- **The panel asks the engine who owns the mask** + (`element_mask_state`) rather than re-deriving the ownership rule + in the shell; the engine's rule is the one the commands apply. +- **The section carries the two nudges as arrow rows** — the mask's + own and the content's — so the two doors are visibly distinct. +- **The conversion button follows the mask's kind**: a raster mask + offers "mask to path", a vector mask offers "mask to image". +- **The attributes button is its own inverse** — "hide mask + attributes" / "show mask attributes" — and writes `MNAb`. + +## Evidence + +- `mask.moveBy` refuses when no target carries a mask and translates + the mask child; `mask.moveContentsBy` leaves the mask in place and + is a no-op when nothing matched. The two verbs are therefore not + interchangeable, and the panel's own test asserts that the mask + nudge leaves the content alone and the content nudge leaves the + mask alone. +- `mask.setMode` needs the matching kind ("mask to image" needs a + path mask, "mask to path" needs an image mask) and rewrites the + mask child and the group's `GRTi` to follow. +- `mask.setShowAttrs` is the one mask verb that also accepts a + *disabled* mask — the `GRTi` 0 structure — so the panel offers it + for a disabled mask too, and offers the conversion and the nudges + only for an enabled one. +- The reachability gate measured all four commands as carried by the + engine and unbound by the shell. + +## The engine's uniform mask read its coverage through the wrong view + +Binding the family exposed a defect under it. `mask.add` mints a +uniform mask as a **solid matte** — the file's flat color for the +tile, with no payload — and the engine documents that matte as the +"reveal all" mask: opaque. But `mask_gray_plane` read the mask's +pixels through `gather`, the *editable-pixels* view, which skips a +matte slot entirely. The renderer bakes a matte to the color it +stands for (decision 0034), so the mask the user sees and the mask +the engine measured disagreed: + +- `mask.setMode` "mask to path" found no coverage and refused on + the very masks `mask.add` mints; +- `mask.remove` "apply" baked an all-zero coverage into the + content — a "reveal all" mask erased the artwork it covered. + +The fix reads the coverage through `assemble_rgba`, the renderer's +view, which is what the clipboard's own mask reader +(`clipboard::mask_coverage`, decision 0058) already does. The engine +keeps both views: `gather` stays the editable-pixels view, and a +matte still occupies its grid slot there. + +## Consequences + +- Four more engine commands are reachable from the shell's designed + surface, and the mask family is fully bound: the measurement moves + from 224 bound commands of 253 to **228 of 253** (88.5 % → 90.1 %). +- A mask can be aligned with its content, converted between CS6's + two mask forms, and its own attributes hidden or shown — all from + the panel that owns the object. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0111](0111-the-symbol-panel-carries-the-symbol-family.md) and +[0113](0113-the-filters-panel-bakes-and-renames.md) — the same week's +other steps in closing the measured surface gap. From cd39489dfbe960a6890ff7e1907560bde0a84916 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 15:13:27 -0600 Subject: [PATCH 18/22] feat(panels): the master page and the shared layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Pages panel marks the master page with a star and carries its own layer list; the Layers panel shares the active layer with every page, adds or removes a page's link, detaches this page's copy and unshares. The compose walk now paints the master page's layers behind every other page's. Three mints — the shared links and the master layer — inherited Layer's derive-default display flag (smart-reference display), so the shared content and the master layers rendered nowhere; they now mint display layers, and the reader defaults a link slot to visible. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 110 +++++++ crates/app/src/shell/panels.rs | 284 +++++++++++++++++- crates/compose/src/render.rs | 76 +++-- crates/compose/tests/master_page_render.rs | 182 +++++++++++ crates/doc/src/fwmap/reader.rs | 7 + crates/doc/src/fwmap/tests.rs | 3 + crates/engine/src/catalog/layer.rs | 74 +++++ crates/engine/src/catalog/page.rs | 10 + docs/TASKS.md | 2 +- ...-the-master-and-the-layers-panel-shares.md | 98 ++++++ 10 files changed, 818 insertions(+), 28 deletions(-) create mode 100644 crates/compose/tests/master_page_render.rs create mode 100644 docs/decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 891507c..cb39823 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -7084,4 +7084,114 @@ mod tests { let facts = shell.mask_facts(id).expect("the selection carries a mask"); assert!(!facts.hidden, "the mask's attributes are shown again"); } + + /// The Pages panel's master star: one master per document, the + /// master's own layer list grows through the engine, and clearing + /// the master hands the page back. + #[test] + fn the_pages_panel_marks_the_master_page() { + let mut shell = gui_shell(gui_editor()); + shell.set_master_page(0); + assert!(shell.editor.document().pages[0].is_master); + + // A second master demotes the first. + shell + .editor + .apply("page.add", serde_json::json!({ "index": null })) + .unwrap(); + shell.set_master_page(1); + assert!(!shell.editor.document().pages[0].is_master); + assert!(shell.editor.document().pages[1].is_master); + + // The master's own layer list grows through the engine. + let before = shell.editor.document().pages[1].layers.layers.len(); + shell.add_master_layer(); + assert_eq!( + shell.editor.document().pages[1].layers.layers.len(), + before + 1 + ); + + shell.unset_master_page(); + assert!(!shell.editor.document().pages[1].is_master); + } + + /// The Layers panel's sharing section: the active layer shares + /// with every page, a page's link can leave and come back, a page + /// can detach its own copy, and unsharing gives the pages their + /// own layers again. + #[test] + fn the_layers_panel_shares_layers() { + let mut shell = gui_shell(gui_editor()); + shell + .editor + .apply("page.add", serde_json::json!({ "index": null })) + .unwrap(); + shell.share_layer(0); + + let uid = shell.editor.document().shared_layer_defs[0].uid.clone(); + assert_eq!( + shell.editor.document().shared_layer_defs.len(), + 1, + "one definition" + ); + assert_eq!( + shell.editor.document().pages[0].layers.layers[0] + .shared_link + .as_ref() + .map(|link| link.uid.clone()), + Some(uid.clone()), + "the owner slot became the link" + ); + assert!( + shell.editor.document().pages[1].layers.layers[0] + .shared_link + .as_ref() + .is_some_and(|link| link.uid == uid), + "the other page carries the link" + ); + + // The link can leave a page and come back. + shell.share_layer_with_page(0, 0, 1, false); + assert!( + shell.editor.document().pages[1] + .layers + .layers + .iter() + .all(|layer| layer.shared_link.is_none()), + "the link left the page" + ); + shell.share_layer_with_page(0, 0, 1, true); + assert!( + shell.editor.document().pages[1] + .layers + .layers + .iter() + .any(|layer| layer.shared_link.is_some()), + "the link came back" + ); + + // A page can detach its own copy. + shell.detach_shared_layer(1, 0); + assert!( + shell.editor.document().pages[1].layers.layers[0] + .shared_link + .is_none(), + "the page holds its own copy" + ); + assert_eq!( + shell.editor.document().shared_layer_defs.len(), + 1, + "the definition stays" + ); + + // Unsharing gives the pages their own layers back. + shell.unshare_layer(0, 0); + assert!(shell.editor.document().shared_layer_defs.is_empty()); + assert!( + shell.editor.document().pages[0].layers.layers[0] + .shared_link + .is_none(), + "the owner holds a real layer" + ); + } } diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index 685957d..d520abd 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -9,6 +9,16 @@ use super::*; use compose::abgr_pack; use doc::{GroupObject, ImageObject, SliceObject, SymbolAnimation}; +/// A shared-layer definition and the page slots that carry its link. +/// A link slot holds no content of its own — it resolves through the +/// definition (decision 0022). +struct SharedLayer { + /// The definition's layer name. + name: String, + /// `(page, layer)` slots carrying the link, in page order. + slots: Vec<(usize, usize)>, +} + impl Shell { /// Edit ▸ Paste as Mask / Paste Inside: the clipboard becomes the /// selection's mask. Both commands read the document's selection, @@ -229,6 +239,7 @@ impl Shell { .name .clone() .unwrap_or_else(|| format!("Page {}", index + 1)); + let is_master = self.editor.document().pages[index].is_master; let mut rename = name.clone(); ui.horizontal(|ui| { if self.rename_page == Some(index) { @@ -260,6 +271,23 @@ impl Shell { { self.rename_page = Some(index); } + // The master page is the document's shared base: its + // layers stand behind every other page's. + if is_master { + if ui + .small_button("★") + .on_hover_text("Unset the master page") + .clicked() + { + self.unset_master_page(); + } + } else if ui + .small_button("☆") + .on_hover_text("Make this the master page") + .clicked() + { + self.set_master_page(index); + } if ui .small_button("×") .on_hover_text("Delete this page") @@ -269,9 +297,69 @@ impl Shell { } }); } + self.master_layers_section(ui); self.guides_section(ui); } + /// Pages panel: the master page's own layer list — the shared + /// base every other page stands on. The engine keeps those + /// layers on the master page itself, so the section reads that + /// page's list. + pub(super) fn master_layers_section(&mut self, ui: &mut egui::Ui) { + let Some(master) = self + .editor + .document() + .pages + .iter() + .position(|page| page.is_master) + else { + return; + }; + let names: Vec = self.editor.document().pages[master] + .layers + .layers + .iter() + .enumerate() + .map(|(index, layer)| { + if layer.name.is_empty() { + format!("Layer {}", index + 1) + } else { + layer.name.clone() + } + }) + .collect(); + ui.separator(); + ui.label("Master page layers"); + for name in &names { + ui.label(name); + } + if ui + .button("+") + .on_hover_text("Add a layer to the master page") + .clicked() + { + self.add_master_layer(); + } + } + + /// Pages panel: designate the page as the document's master. The + /// engine allows one master at a time — a previous master is + /// demoted. + pub(super) fn set_master_page(&mut self, index: usize) { + self.run_command("page.setMaster", serde_json::json!({ "page": index })); + } + + /// Pages panel: clear the document's master page. + pub(super) fn unset_master_page(&mut self) { + self.run_command("page.unsetMaster", serde_json::json!({})); + } + + /// Pages panel: add an empty layer to the master page's layer + /// list — the shared base every other page stands on. + pub(super) fn add_master_layer(&mut self) { + self.run_command("page.addMasterLayer", serde_json::json!({ "index": null })); + } + /// Delete a page through the engine's `page.delete` — the last /// page cannot go (the engine refuses it), and the current page /// index follows the shorter list. @@ -401,13 +489,22 @@ impl Shell { return; }; let frame = self.editor.frame; + let defs = &self.editor.document().shared_layer_defs; let layers: Vec<(String, bool, bool)> = page .layers .layers .iter() .enumerate() .map(|(index, layer)| { - let label = if layer.name.is_empty() { + let label = if let Some(link) = &layer.shared_link { + // A link slot carries no name of its own — the + // definition names the shared layer. + defs.iter() + .find(|def| def.uid == link.uid) + .map(|def| def.layer.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("Shared layer {}", index + 1)) + } else if layer.name.is_empty() { format!("Layer {}", index + 1) } else { layer.name.clone() @@ -542,6 +639,191 @@ impl Shell { self.editor.set_active_layer(active.saturating_sub(1)); } }); + self.sharing_section(ui); + } + + /// Layers panel: the shared layers — one definition, a link per + /// page. The page toggles add or remove a page's link; *unshare* + /// gives every page its own copy; *detach here* gives this page + /// one and leaves the definition and the other pages alone. + pub(super) fn sharing_section(&mut self, ui: &mut egui::Ui) { + let shared = self.shared_layers(); + let page_count = self.editor.document().pages.len(); + let current_page = self.editor.page; + let active = self.editor.active_layer(); + let shareable = self + .editor + .document() + .pages + .get(current_page) + .and_then(|page| page.layers.layers.get(active)) + .is_some_and(|layer| layer.shared_link.is_none() && !layer.is_special); + ui.separator(); + ui.label("Sharing"); + if shareable + && ui + .button("share the active layer") + .on_hover_text("Share this layer with every page") + .clicked() + { + self.share_layer(active); + } + for entry in &shared { + ui.label(format!("{} (shared)", entry.name)); + ui.horizontal(|ui| { + for target in 0..page_count { + let carried = entry.slots.iter().any(|(page, _)| *page == target); + let label = format!("P{}", target + 1); + if carried && entry.slots.len() == 1 { + // The last link: removing it would orphan + // the definition — nothing left to address it + // by. + ui.add_enabled(false, egui::Button::selectable(true, label)) + .on_hover_text("The last link — unshare the layer instead"); + continue; + } + let tip = if carried { + "Remove this page's link to the shared layer" + } else { + "Add this page's link to the shared layer" + }; + if ui + .selectable_label(carried, label) + .on_hover_text(tip) + .clicked() + { + // The command addresses the definition through + // a link slot, so the source must survive the + // change: a removal names another page's link. + let source = entry + .slots + .iter() + .find(|(page, _)| !carried || *page != target) + .copied(); + if let Some((page, layer)) = source { + self.share_layer_with_page(page, layer, target, !carried); + } + } + } + }); + ui.horizontal(|ui| { + if let Some((page, layer)) = entry.slots.first().copied() + && ui + .button("unshare") + .on_hover_text("Give every page its own copy of this layer") + .clicked() + { + self.unshare_layer(page, layer); + } + let here = entry + .slots + .iter() + .find(|(page, _)| *page == current_page) + .copied(); + if let Some((page, layer)) = here + && ui + .button("detach here") + .on_hover_text("Give this page its own copy of this layer") + .clicked() + { + self.detach_shared_layer(page, layer); + } + }); + } + } + + /// The document's shared-layer definitions, each with the page + /// slots that carry its link. + fn shared_layers(&self) -> Vec { + let document = self.editor.document(); + document + .shared_layer_defs + .iter() + .map(|def| { + let name = if def.layer.name.is_empty() { + "Shared layer".to_owned() + } else { + def.layer.name.clone() + }; + let mut slots = Vec::new(); + for (page_index, page) in document.pages.iter().enumerate() { + for (layer_index, layer) in page.layers.layers.iter().enumerate() { + if layer + .shared_link + .as_ref() + .is_some_and(|link| link.uid == def.uid) + { + slots.push((page_index, layer_index)); + } + } + } + SharedLayer { name, slots } + }) + .collect() + } + + /// Layers panel: share the layer across every page — one + /// definition, one link per page. + pub(super) fn share_layer(&mut self, layer: usize) { + self.run_command( + "layer.setSharing", + serde_json::json!({ + "page": self.editor.page, + "layer": layer, + "shared": true, + "unshare_copies_to_all_frames": null, + }), + ); + } + + /// Layers panel: unshare the shared layer — every page's link + /// becomes that page's own copy. CS6 asks whether the copies go + /// to every state; the copies fill the page's frame span. + pub(super) fn unshare_layer(&mut self, page: usize, layer: usize) { + self.run_command( + "layer.setSharing", + serde_json::json!({ + "page": page, + "layer": layer, + "shared": false, + "unshare_copies_to_all_frames": true, + }), + ); + } + + /// Layers panel: add or remove the shared layer's link on a page. + pub(super) fn share_layer_with_page( + &mut self, + page: usize, + layer: usize, + target: usize, + add: bool, + ) { + let payload = if add { + serde_json::json!({ + "page": page, + "layer": layer, + "add_to_pages": [target], + "delete_from_pages": null, + }) + } else { + serde_json::json!({ + "page": page, + "layer": layer, + "add_to_pages": null, + "delete_from_pages": [target], + }) + }; + self.run_command("layer.shareToPages", payload); + } + + /// Layers panel: give this page its own copy of the shared layer; + /// the definition and the other pages keep theirs. + pub(super) fn detach_shared_layer(&mut self, page: usize, layer: usize) { + self.run_command( + "layer.detachShared", + serde_json::json!({ "page": page, "layer": layer }), + ); } /// Layers-panel reorder: one slot up (toward the top of the diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index 91d8088..8737e38 100644 --- a/crates/compose/src/render.rs +++ b/crates/compose/src/render.rs @@ -298,23 +298,42 @@ pub(crate) fn render_scaled_inner( } let mut text_engine = None; let page_layers = &document.pages[page].layers.layers; - walk_layers( - page_layers, - &mut Walk { - document, - frame, - canvas: (width, height), - cache, - text_engine: &mut text_engine, - ctx: &mut ctx, - stack: &[], - }, - true, - )?; + let mut walk = Walk { + document, + frame, + canvas: (width, height), + cache, + text_engine: &mut text_engine, + ctx: &mut ctx, + stack: &[], + }; + // The master page's layers stand behind every other page's — + // CS6's master page is the document's shared base (decision + // 0115). It walks first, in its own state. + if let Some((base_layers, base_frame)) = master_layers(document, page, frame) { + walk.frame = base_frame; + walk_layers(base_layers, &mut walk, true)?; + walk.frame = frame; + } + walk_layers(page_layers, &mut walk, true)?; ctx.reset_transform(); finish(ctx, resources, width, height) } +/// The master page's layer list and the state it renders in, when the +/// document has a master page other than the one being rendered. +/// +/// A master page with fewer states than the page repeats its last +/// one, so the shared base never vanishes on a later state. +fn master_layers(document: &Document, page: usize, frame: usize) -> Option<(&[Layer], usize)> { + let master = document.pages.iter().position(|p| p.is_master)?; + if master == page { + return None; + } + let states = document.pages[master].info.frame_count.max(1) as usize; + Some((&document.pages[master].layers.layers, frame.min(states - 1))) +} + /// Rounds and range-checks a page's canvas to the rasterizer's pixel /// extent — the shared preamble of every render entry point. fn canvas_extent(document: &Document, page: usize) -> Result<(u16, u16), ComposeError> { @@ -384,19 +403,24 @@ fn render_inner( // never pay for the font database. let mut text_engine = None; let page_layers = &document.pages[page].layers.layers; - walk_layers( - page_layers, - &mut Walk { - document, - frame, - canvas: (width, height), - cache, - text_engine: &mut text_engine, - ctx: &mut ctx, - stack: &[], - }, - true, - )?; + let mut walk = Walk { + document, + frame, + canvas: (width, height), + cache, + text_engine: &mut text_engine, + ctx: &mut ctx, + stack: &[], + }; + // The master page's layers stand behind every other page's — + // CS6's master page is the document's shared base (decision + // 0115). It walks first, in its own state. + if let Some((base_layers, base_frame)) = master_layers(document, page, frame) { + walk.frame = base_frame; + walk_layers(base_layers, &mut walk, true)?; + walk.frame = frame; + } + walk_layers(page_layers, &mut walk, true)?; finish(ctx, resources, width, height) } diff --git a/crates/compose/tests/master_page_render.rs b/crates/compose/tests/master_page_render.rs new file mode 100644 index 0000000..c5b7500 --- /dev/null +++ b/crates/compose/tests/master_page_render.rs @@ -0,0 +1,182 @@ +//! The master page's layers stand behind every other page's +//! (decision 0115): CS6's master page is the document's shared base. +//! The base walks first, so the page's own content paints over it; +//! the master page renders its own layers once. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use compose::{Render, render}; +use doc::{ + Canvas, Cell, Document, Layer, LayerList, ObjectData, Page, PageInfo, PaintStyle, PathObject, + PathPoint, +}; +use geom::Point; + +fn page(layers: Vec) -> Page { + Page { + info: PageInfo { + version: None, + revision: None, + frame_count: 1, + frame_count_duplicate: 1, + request_id: 0, + canvas: Canvas { + x: 0.0, + y: 0.0, + width: 16.0, + height: 16.0, + resolution: 72.0, + resolution_unit: 0, + background_raw: 0xFF_FF_FF, + }, + onion_before: 0, + onion_after: 0, + frame_durations: Vec::new(), + }, + name: None, + is_master: false, + is_linked: false, + page_id: None, + last_export_dir: None, + last_export_file: None, + layers: LayerList { layers }, + grid: None, + guides: doc::Guides::default(), + export: None, + } +} + +fn layer(name: &str, cells: Vec) -> Layer { + Layer { + name: name.to_owned(), + is_background: false, + has_url: false, + is_special: false, + frame_changing: false, + display: true, + nesting_level: None, + cells, + visibility_per_frame: None, + shared_link: None, + } +} + +fn cell(ids: Vec) -> Cell { + Cell { + locked: false, + visible: true, + elements: ids, + } +} + +/// A `side`×`side` square at (2,2), solid-filled `raw`. +fn filled_square(registry: &mut doc::Registry, side: f64, raw: i64) -> doc::ObjectId { + registry.insert(ObjectData::Path(PathObject { + contours: vec![doc::Contour { + closed: true, + point_count: 4, + points: [ + (2.0, 2.0), + (2.0 + side, 2.0), + (2.0 + side, 2.0 + side), + (2.0, 2.0 + side), + ] + .into_iter() + .map(|(x, y)| PathPoint { + anchor: Point::new(x, y), + primary: None, + secondary: None, + is_curve: false, + }) + .collect(), + }], + primitive: None, + even_odd: false, + paint: Some(PaintStyle { + stroke_raw: 0, + fill_raw: raw, + fill: None, + }), + fill_start: None, + fill_end: None, + fill_third: None, + brush_placement: None, + fill_on_top: None, + locked: false, + visible: true, + opacity: 1000, + blend: 0, + name: None, + effects: Vec::new(), + })) +} + +/// Opaque red ABGR (`FCLi` layout: alpha in the top byte). +const RED_RAW: i64 = 0xFF_00_00_FF; +/// Opaque green. +const GREEN_RAW: i64 = 0xFF_00_FF_00; + +fn pixel(render: &Render, x: u32, y: u32) -> [u8; 4] { + let stride = u32::from(render.width) * 4; + let at = (y * stride + x * 4) as usize; + render.pixels[at..at + 4].try_into().expect("four channels") +} + +/// A master page carrying one red square, and an ordinary page. +fn document_with_master() -> Document { + let mut document = Document::default(); + let red = filled_square(&mut document.registry, 8.0, RED_RAW); + document.pages = vec![page(vec![layer("master", vec![cell(vec![red])])])]; + document.pages[0].is_master = true; + document + .pages + .push(page(vec![layer("base", vec![cell(Vec::new())])])); + document +} + +#[test] +fn the_master_pages_layers_stand_behind_every_other_page() { + let document = document_with_master(); + let rendered = render(&document, 1, 0).expect("renders"); + assert_eq!( + pixel(&rendered, 5, 5), + [255, 0, 0, 255], + "the master's content shows on the other page" + ); +} + +#[test] +fn the_master_page_renders_its_own_layers_once() { + let document = document_with_master(); + let rendered = render(&document, 0, 0).expect("renders"); + assert_eq!( + pixel(&rendered, 5, 5), + [255, 0, 0, 255], + "the master page draws its own content" + ); +} + +#[test] +fn the_pages_own_content_paints_over_the_master() { + let mut document = document_with_master(); + let green = filled_square(&mut document.registry, 8.0, GREEN_RAW); + document.pages[1].layers.layers[0].cells = vec![cell(vec![green])]; + let rendered = render(&document, 1, 0).expect("renders"); + assert_eq!( + pixel(&rendered, 5, 5), + [0, 255, 0, 255], + "the base stands in front of the master" + ); +} + +#[test] +fn a_document_without_a_master_renders_only_its_own_layers() { + let mut document = document_with_master(); + document.pages[0].is_master = false; + let rendered = render(&document, 1, 0).expect("renders"); + assert_eq!( + pixel(&rendered, 5, 5), + [255, 255, 255, 255], + "no master, no shared base" + ); +} diff --git a/crates/doc/src/fwmap/reader.rs b/crates/doc/src/fwmap/reader.rs index a19f122..ec0f830 100644 --- a/crates/doc/src/fwmap/reader.rs +++ b/crates/doc/src/fwmap/reader.rs @@ -950,6 +950,13 @@ impl Reader { uid: uid.unwrap_or_default(), is_primary: is_primary.unwrap_or(false), }), + // A link slot carries no layer body, so it has + // no `DISb` to read: it defaults to a display + // layer, the way `layer.add` mints one. The + // derive-default is `false` (smart-reference + // display), which would render the shared + // content nowhere. + display: true, ..Layer::default() }); } diff --git a/crates/doc/src/fwmap/tests.rs b/crates/doc/src/fwmap/tests.rs index 85adc2d..c250182 100644 --- a/crates/doc/src/fwmap/tests.rs +++ b/crates/doc/src/fwmap/tests.rs @@ -2964,6 +2964,9 @@ fn shared_layers_round_trip_losslessly() { uid: "fc00dead-beef-4000-8000-000000000001".into(), is_primary: false, }), + // A link slot carries no body, so it has no `DISb` on the + // wire: the reader defaults it to a display layer. + display: true, ..Layer::default() }); diff --git a/crates/engine/src/catalog/layer.rs b/crates/engine/src/catalog/layer.rs index 64a7cde..a510032 100644 --- a/crates/engine/src/catalog/layer.rs +++ b/crates/engine/src/catalog/layer.rs @@ -626,6 +626,12 @@ impl TypedCommand for SetLayerSharing { uid: uid.clone(), is_primary: false, }), + // A link slot is a display layer (CS6's `DISb`): + // the derive-default is `false` (smart-reference + // display), which would render the shared content + // nowhere — the link's flag gates the definition's + // body in the walk. + display: true, ..Layer::default() }; } else { @@ -645,6 +651,10 @@ impl TypedCommand for SetLayerSharing { uid: uid.clone(), is_primary: false, }), + // A link slot renders its definition's content + // — it must be a display layer (see the owner + // slot above). + display: true, ..Layer::default() }, ); @@ -748,6 +758,9 @@ impl TypedCommand for ShareLayerToPages { uid: uid.clone(), is_primary: false, }), + // A link slot renders its definition's content — + // it must be a display layer. + display: true, ..Layer::default() }, ); @@ -908,6 +921,67 @@ mod sharing_tests { ); } + #[test] + fn shared_links_are_display_layers() { + let (mut document, _) = testdoc::sample_document(); + SetLayerSharing + .apply_json( + &mut document, + json!({ "page": 0, "layer": 0, "shared": true }), + ) + .unwrap(); + // The renderer gates the definition's body on the link's own + // display flag: a link minted with the derive-default + // (`false`, smart-reference display) would render the shared + // content nowhere. + for page in &document.pages { + for layer in &page.layers.layers { + if layer.shared_link.is_some() { + assert!( + layer.display, + "a link slot renders its definition's content" + ); + } + } + } + // A link inserted later is a display layer too. + ShareLayerToPages + .apply_json( + &mut document, + json!({ + "page": 0, "layer": 0, + "add_to_pages": null, "delete_from_pages": [1], + }), + ) + .unwrap(); + assert!( + document.pages[1] + .layers + .layers + .iter() + .all(|layer| layer.shared_link.is_none()), + "the link left the page" + ); + ShareLayerToPages + .apply_json( + &mut document, + json!({ + "page": 0, "layer": 0, + "add_to_pages": [1], "delete_from_pages": null, + }), + ) + .unwrap(); + let inserted = document.pages[1] + .layers + .layers + .iter() + .find(|layer| layer.shared_link.is_some()); + assert!( + inserted.is_some_and(|layer| layer.display), + "a re-added link renders its definition's content" + ); + } + #[test] fn set_sharing_noops_and_refuses() { let (mut document, _) = testdoc::sample_document(); diff --git a/crates/engine/src/catalog/page.rs b/crates/engine/src/catalog/page.rs index 8350332..3774ac9 100644 --- a/crates/engine/src/catalog/page.rs +++ b/crates/engine/src/catalog/page.rs @@ -834,6 +834,11 @@ impl TypedCommand for AddMasterLayer { }) .collect(); let mut layer = Layer { + // A new layer is a display layer (CS6's `DISb`): the + // derive-default is `false` (smart-reference display), + // which would render nothing — `layer.add` mints the same + // way. + display: true, cells, ..Layer::default() }; @@ -1384,6 +1389,11 @@ mod structure_tests { .iter() .all(|cell| cell.visible && !cell.locked) ); + assert!( + layers[0].display, + "a master layer is a display layer — the derive-default \ + (smart-reference display) would render nothing" + ); delta.apply_inverse(&mut document).unwrap(); assert_eq!(document.pages[0].layers.layers.len(), 2); } diff --git a/docs/TASKS.md b/docs/TASKS.md index 99ae9e8..0366700 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds sixty of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)), and the Properties panel carries the element mask ([0114](decisions/0114-the-properties-panel-carries-the-mask.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **228 of 253 (90.1 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds sixty of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)), and the Properties panel carries the element mask ([0114](decisions/0114-the-properties-panel-carries-the-mask.md)), and the Pages panel marks the master page while the Layers panel shares layers ([0115](decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **234 of 253 (92.5 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight diff --git a/docs/decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md b/docs/decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md new file mode 100644 index 0000000..89f2593 --- /dev/null +++ b/docs/decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md @@ -0,0 +1,98 @@ +# 0115 — The Pages panel marks the master page and the Layers panel shares layers + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +The reachability gate measured two families the engine could execute and +the shell's designed surface could not reach: + +- the **master page** — `page.setMaster` (CS6's `dom.setMasterPage`), + `page.unsetMaster` (`dom.unsetMasterPage`) and `page.addMasterLayer` + (`dom.addMasterPageLayer`); +- the **shared layer** — `layer.setSharing` + (`dom.setLayerSharing`), `layer.shareToPages` + (`dom.shareLayerToPages`) and `layer.detachShared` + (`dom.detachSharedLayer`). + +The master page is the document's shared base: one page's content stands +behind every other page's. The shared layer is the other half of the +same idea — one definition, a link per page, so a change to the layer +shows everywhere at once. Both are how a Fireworks user avoids +re-drawing the same furniture on every page. + +## Decision + +- **The Pages panel carries the master as a star per page.** A filled + ★ marks the master; ☆ designates the page. The engine allows one + master per document, so designating a second demotes the first. +- **The Pages panel gains a Master page layers section** — the + master's own layer list, with a button that adds a layer to it + (`page.addMasterLayer`). The engine keeps those layers on the master + page itself, so the section reads that page's list; the Layers panel + edits them when the master page is the current page. +- **The Layers panel gains a Sharing section.** + - *share the active layer* — one definition, a link on every page + (`layer.setSharing`, CS6's "share to all pages"); + - a **page toggle per shared layer** — adds or removes that page's + link (`layer.shareToPages`, the index-based deviation decision 0022 + records); + - *unshare* — every page's link becomes that page's own copy. CS6 + asks whether the copies go to every state; the copies fill the + page's frame span; + - *detach here* — this page alone gets its own copy; the definition + and the other pages keep theirs (`layer.detachShared`). +- **The last link cannot be removed.** The command addresses the + definition *through* a link slot, so a document whose only link went + away would keep an orphaned definition that nothing can address. The + toggle is disabled on the last link and says why; *unshare* is the + way out. +- **A link slot carries no name of its own**, so the Layers panel + resolves the row's label from the definition. + +## The master page's layers stand behind every other page's + +The engine modelled the master page as a flag and a layer list; the +renderer walked only the page's own list, so the shared base drew +nothing. The compose walk now visits the master page's layers first — +behind the page's own, which paint over it. A master page with fewer +states than the page repeats its last one, so the shared base never +vanishes on a later state. + +## Three layers minted a display flag of `false` + +Binding the family exposed a defect under it. `Layer`'s derive-default +is `display: false` — CS6's *smart-reference display* — and three +places minted a layer without overriding it: + +- `layer.setSharing` minted the link slots, both the owner's and the + other pages'; +- `layer.shareToPages` minted the link it inserted; +- `page.addMasterLayer` minted the master's layer. + +The renderer's walk gates a link's definition on the link's own display +flag, so every shared layer and every master layer rendered *nowhere* — +the feature was reachable and invisible. The reader had the same +default for a link slot, so a saved document lost its shared content +on the way back in. All four now mint a display layer, the way +`layer.add` already did. + +The link slot's *visibility* is a different matter: `LSAv` carries only +the uid and the primary flag (decision 0022), so a page's link +visibility does not survive a save. The reader therefore defaults a +link to visible, and the loss is recorded rather than papered over. + +## Consequences + +- Six more engine commands are reachable from the shell's designed + surface: the measurement moves from 228 bound commands of 253 to + **234 of 253** (90.1 % → 92.5 %). +- A document can have a shared base and shared layers — and both + actually draw. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0111](0111-the-symbol-panel-carries-the-symbol-family.md), +[0113](0113-the-filters-panel-bakes-and-renames.md) and +[0114](0114-the-properties-panel-carries-the-mask.md) — the same week's +other steps in closing the measured surface gap. From cead811e2f82a71bde84eb87e7eab3aaa6c2b7d8 Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 15:20:20 -0600 Subject: [PATCH 19/22] feat(panels): the rectangle, mask, and pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Properties panel now edits the rectangle primitive's own source — the corner radius in pixels and as a fraction of the short side, and the untransformed rect the contours derive from. It carries the bitmap's pixel mask (a full-coverage plane, a clear, and the four nudges that slide the selection without its pixels). The Fill & Stroke panel gains the pattern fill: an image in the document becomes the tile the selection's fill repeats. Five more engine commands reach the shell's designed surface — the measurement moves from 234 bound commands of 253 to 239 (92.5 % -> 94.5 %). Decision 0116 records the binding and one defect it surfaced: the primitive's transform is stored and round-tripped but never composed into the drawn contours, and the corpus evidence that would settle where the fix belongs is not yet in hand. Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 116 ++++++++ crates/app/src/shell/panels.rs | 256 ++++++++++++++++++ docs/TASKS.md | 2 +- ...ectangle-the-pixel-mask-and-the-pattern.md | 91 +++++++ 4 files changed, 464 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index cb39823..0a55ea3 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -7194,4 +7194,120 @@ mod tests { "the owner holds a real layer" ); } + + /// The Properties panel's rectangle section: the corner radius + /// and the source rectangle travel through the engine's primitive + /// commands. + #[test] + fn the_properties_panel_rounds_a_rectangle() { + let mut shell = gui_shell(gui_editor()); + let id = shell.editor.current_cell().unwrap().elements[0]; + let primitive = shell_path(&shell, id).primitive.clone().unwrap(); + assert_eq!(primitive.radius, 0.0, "the tool draws a sharp rectangle"); + + // The radius, in pixels. + shell.set_rect_radius(id, 8.0); + let primitive = shell_path(&shell, id).primitive.clone().unwrap(); + assert_eq!(primitive.radius, 8.0, "the radius reached the source"); + + // The radius, as a fraction of the short side: the plate is + // 80x40, so half roundness is half the stadium radius. + shell.set_rect_roundness(id, 0.5); + let primitive = shell_path(&shell, id).primitive.clone().unwrap(); + let side = primitive.rect.width().min(primitive.rect.height()); + assert_eq!(primitive.radius, side / 4.0); + + // The source rectangle moves. + shell.set_rect_sides(id, [0.0, 0.0, 20.0, 10.0]); + let primitive = shell_path(&shell, id).primitive.clone().unwrap(); + assert_eq!(primitive.rect.x0, 0.0); + assert_eq!(primitive.rect.width(), 20.0); + assert_eq!(primitive.rect.height(), 10.0); + } + + /// The Properties panel's pixel-mask section: the mask is set, + /// moved, and cleared over the bitmap. + #[test] + fn the_bitmap_panel_moves_the_pixel_mask() { + let mut shell = gui_shell(gui_editor()); + shell + .editor + .apply( + "bitmap.addImage", + serde_json::json!({ + "x": 0.0, "y": 0.0, "width": 16.0, "height": 16.0, + "page": 0, "layer": 0, "cell": 0, "name": "tile", + }), + ) + .unwrap(); + let image_id = shell + .editor + .document() + .registry + .iter() + .find_map(|(id, object)| match object { + ObjectData::Image(_) => Some(id), + _ => None, + }) + .unwrap(); + + shell.select_all_pixels(image_id, 16, 16); + let selection = shell.editor.document().selection.clone().unwrap(); + assert_eq!((selection.width, selection.height), (16, 16)); + assert!( + selection.alpha.iter().all(|&byte| byte == 255), + "every pixel is covered" + ); + + // The selection slides without its pixels: the left column + // leaves the frame. + shell.move_pixel_mask(image_id, 1, 0); + let moved = shell.editor.document().selection.clone().unwrap(); + assert_eq!(moved.alpha[0], 0, "the left column slid away"); + assert_eq!(moved.alpha[1], 255, "the rest stayed"); + + shell.clear_pixel_mask(image_id); + assert!( + shell.editor.document().selection.is_none(), + "the mask is gone" + ); + } + + /// The Fill & Stroke panel's pattern section: an image in the + /// document becomes the selection's repeating fill. + #[test] + fn the_fill_panel_applies_a_pattern() { + let mut shell = gui_shell(gui_editor()); + shell + .editor + .apply( + "bitmap.addImage", + serde_json::json!({ + "x": 0.0, "y": 0.0, "width": 16.0, "height": 16.0, + "page": 0, "layer": 0, "cell": 0, "name": "tile", + }), + ) + .unwrap(); + let image_id = shell + .editor + .document() + .registry + .iter() + .find_map(|(id, object)| match object { + ObjectData::Image(_) => Some(id), + _ => None, + }) + .unwrap(); + let plate = shell.editor.current_cell().unwrap().elements[0]; + + shell.fill_with_pattern(plate, image_id); + let path = shell_path(&shell, plate); + let fill = path + .paint + .as_ref() + .and_then(|paint| paint.fill.clone()) + .unwrap(); + assert_eq!(fill.shape, doc::FillShape::Pattern, "the fill is a pattern"); + assert!(fill.pattern.is_some(), "the pattern carries its tile"); + } } diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index d520abd..ed8a139 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -1217,6 +1217,77 @@ impl Shell { } }); } + // The pattern fill: only paths carry one. + let paths: Vec = ids + .iter() + .copied() + .filter(|id| { + matches!( + self.editor.document().registry.get(*id), + Some(ObjectData::Path(_)) + ) + }) + .collect(); + if !paths.is_empty() { + self.pattern_fill_section(ui, &paths); + } + } + + /// The pattern fill: an image in the document becomes the tile + /// the selection's fill repeats (CS6's Fill panel's pattern + /// swatch). + pub(super) fn pattern_fill_section(&mut self, ui: &mut egui::Ui, ids: &[doc::ObjectId]) { + ui.heading("Pattern"); + let images: Vec<(doc::ObjectId, String)> = self + .editor + .document() + .registry + .iter() + .filter_map(|(id, object)| match object { + ObjectData::Image(image) => Some(( + id, + image + .name + .clone() + .unwrap_or_else(|| format!("image {}", id.index())), + )), + _ => None, + }) + .collect(); + if images.is_empty() { + ui.label("(no image in the document to fill with)"); + return; + } + let mut source = images[0].0; + egui::ComboBox::from_label("tile") + .selected_text( + images + .iter() + .find(|(id, _)| *id == source) + .map(|(_, name)| name.clone()) + .unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (id, name) in &images { + if ui.selectable_label(*id == source, name.as_str()).clicked() { + source = *id; + } + } + }); + if ui.button("fill the selection with it").clicked() { + for id in ids { + self.fill_with_pattern(*id, source); + } + } + } + + /// A path's fill becomes the image's raster, repeating as a + /// pattern. + pub(super) fn fill_with_pattern(&mut self, id: doc::ObjectId, source: doc::ObjectId) { + self.run_command( + "path.setPatternFill", + serde_json::json!({ "id": id, "source": source }), + ); } /// Window ▸ Swatches: the same ABGR palette the Properties @@ -2550,6 +2621,9 @@ impl Shell { ) { ui.label(format!("object {} (gen {})", id.index(), id.generation())); self.geometry_fields(ui, id); + if path.primitive.is_some() { + self.rectangle_primitive_section(ui, id); + } self.display_controls( ui, id, @@ -2563,6 +2637,103 @@ impl Shell { ); } + /// The rectangle primitive's own parameters: the corner radius + /// (in pixels and as a fraction of the short side) and the + /// untransformed source rectangle the contours derive from. + /// + /// CS6's Rectangle tool keeps the drawn rectangle editable through + /// its source, so the panel edits the source rather than the + /// contours. + pub(super) fn rectangle_primitive_section(&mut self, ui: &mut egui::Ui, id: doc::ObjectId) { + let Some(primitive) = + self.editor + .document() + .registry + .get(id) + .and_then(|object| match object { + ObjectData::Path(path) => path.primitive.clone(), + _ => None, + }) + else { + return; + }; + ui.heading("Rectangle"); + let side = primitive + .rect + .width() + .abs() + .min(primitive.rect.height().abs()); + // The corner radius in pixels. + let mut radius = primitive.radius; + if ui + .add( + egui::DragValue::new(&mut radius) + .speed(0.5) + .prefix("radius "), + ) + .changed() + { + self.set_rect_radius(id, radius); + } + // The same radius as a fraction of the short side — CS6 shows + // both units, and the fraction is the one that survives a + // resize. + let mut percent = if side > 0.0 { + (primitive.radius / (side / 2.0)).clamp(0.0, 1.0) + } else { + 0.0 + }; + if ui + .add(egui::Slider::new(&mut percent, 0.0..=1.0).text("roundness")) + .changed() + { + self.set_rect_roundness(id, percent); + } + // The source rectangle: the untransformed rect the primitive + // derives its contours from. + let mut x = primitive.rect.x0; + let mut y = primitive.rect.y0; + let mut w = primitive.rect.width(); + let mut h = primitive.rect.height(); + ui.horizontal(|ui| { + let x_field = ui.add(egui::DragValue::new(&mut x).speed(1.0).prefix("X ")); + let y_field = ui.add(egui::DragValue::new(&mut y).speed(1.0).prefix("Y ")); + let w_field = ui.add(egui::DragValue::new(&mut w).speed(1.0).prefix("W ")); + let h_field = ui.add(egui::DragValue::new(&mut h).speed(1.0).prefix("H ")); + let edited = [x_field, y_field, w_field, h_field] + .into_iter() + .any(|field| field.changed()); + if edited && w > 0.0 && h > 0.0 { + self.set_rect_sides(id, [x, y, x + w, y + h]); + } + }); + } + + /// The corner radius of a rectangle primitive, in pixels. + pub(super) fn set_rect_radius(&mut self, id: doc::ObjectId, radius: f64) { + self.run_command( + "primitive.setRectRoundness", + serde_json::json!({ "id": id, "roundness": radius.max(0.0), "mode": "pixels" }), + ); + } + + /// The corner radius of a rectangle primitive, as a fraction of + /// its short side (1.0 is the stadium limit). + pub(super) fn set_rect_roundness(&mut self, id: doc::ObjectId, percent: f64) { + self.run_command( + "primitive.setRectRoundness", + serde_json::json!({ "id": id, "roundness": percent.clamp(0.0, 1.0), "mode": "percent" }), + ); + } + + /// The untransformed source rectangle of a rectangle primitive. + pub(super) fn set_rect_sides(&mut self, id: doc::ObjectId, rect: [f64; 4]) { + self.run_command( + "primitive.setRectSides", + serde_json::json!({ "id": id, "rect": rect }), + ); + } + /// Text-object properties: characters, font, size, style flags, /// then the shared display controls. pub(super) fn text_properties( @@ -3362,6 +3533,7 @@ impl Shell { "at ({:.1}, {:.1}), offset ({:.1}, {:.1})", image.location.x, image.location.y, image.offset.x, image.offset.y )); + self.pixel_mask_section(ui, id, image); self.display_controls( ui, id, @@ -3375,6 +3547,90 @@ impl Shell { ); } + /// The bitmap's pixel mask: the pixel selection riding on the + /// image, and the moves CS6's marquee makes. + /// + /// The mask is a coverage plane over the whole image, so the + /// section reports its extent rather than drawing it. + pub(super) fn pixel_mask_section( + &mut self, + ui: &mut egui::Ui, + id: doc::ObjectId, + image: &ImageObject, + ) { + ui.heading("Pixel mask"); + let selection = self.editor.document().selection.clone(); + match &selection { + Some(mask) => ui.label(format!("selection {}x{} px", mask.width, mask.height)), + None => ui.label("(no pixel selection)"), + } + let (Ok(width), Ok(height)) = + (u32::try_from(image.pixels.0), u32::try_from(image.pixels.1)) + else { + return; + }; + // A full mask travels as one coverage byte per pixel, so the + // button refuses past a megabyte of plane. + let plane = u64::from(width) * u64::from(height); + if plane <= 1_048_576 { + if ui.button("select all").clicked() { + self.select_all_pixels(id, width, height); + } + } else { + ui.label("the image is too large for a full mask"); + } + ui.horizontal(|ui| { + if ui.button("clear").clicked() { + self.clear_pixel_mask(id); + } + for (label, dx, dy) in [ + ("←", -1_i64, 0_i64), + ("→", 1_i64, 0_i64), + ("↑", 0_i64, -1_i64), + ("↓", 0_i64, 1_i64), + ] { + if ui + .add_enabled(selection.is_some(), egui::Button::new(label).small()) + .clicked() + { + self.move_pixel_mask(id, dx, dy); + } + } + }); + } + + /// The bitmap's pixel selection, replaced by a full-coverage + /// plane. + pub(super) fn select_all_pixels(&mut self, id: doc::ObjectId, width: u32, height: u32) { + let Some(count) = usize::try_from(u64::from(width) * u64::from(height)).ok() else { + return; + }; + self.run_command( + "bitmap.setPixelMask", + serde_json::json!({ + "id": id, + "mask": { "width": width, "height": height, "alpha": vec![255_u8; count] }, + "mode": "replace", + }), + ); + } + + /// The bitmap's pixel selection, cleared. + pub(super) fn clear_pixel_mask(&mut self, id: doc::ObjectId) { + self.run_command( + "bitmap.setPixelMask", + serde_json::json!({ "id": id, "mask": serde_json::Value::Null }), + ); + } + + /// The bitmap's pixel selection, moved without its pixels. + pub(super) fn move_pixel_mask(&mut self, id: doc::ObjectId, dx: i64, dy: i64) { + self.run_command( + "bitmap.movePixelMaskBy", + serde_json::json!({ "id": id, "dx": dx, "dy": dy }), + ); + } + /// Slice properties: the name, the slice type, the frame /// geometry, and the anchor link and alt text. /// diff --git a/docs/TASKS.md b/docs/TASKS.md index 0366700..596b54b 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds sixty of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)), and the Properties panel carries the element mask ([0114](decisions/0114-the-properties-panel-carries-the-mask.md)), and the Pages panel marks the master page while the Layers panel shares layers ([0115](decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **234 of 253 (92.5 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds seventy-one of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)), and the Properties panel carries the element mask ([0114](decisions/0114-the-properties-panel-carries-the-mask.md)), and the Pages panel marks the master page while the Layers panel shares layers ([0115](decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md)), and the panel edits the rectangle primitive, the bitmap's pixel mask, and the pattern fill ([0116](decisions/0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **239 of 253 (94.5 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight diff --git a/docs/decisions/0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md b/docs/decisions/0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md new file mode 100644 index 0000000..1cbdc23 --- /dev/null +++ b/docs/decisions/0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md @@ -0,0 +1,91 @@ +# 0116 — The panel edits the rectangle, the pixel mask, and the pattern + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +The reachability gate measured five more engine commands the shell's +designed surface could not reach: + +- the **rectangle primitive** — `primitive.setRectRoundness` and + `primitive.setRectSides` (CS6's `dom.setRectRoundness` / + `dom.setRectSides`, decision 0027); +- the **bitmap's pixel mask** — `bitmap.setPixelMask` and + `bitmap.movePixelMaskBy` (CS6's `dom.setSelectionMask` and + `dom.movePixelMaskBy`); +- the **pattern fill** — `path.setPatternFill`, the command surface + for the Fill panel's pattern authoring. + +Each one is a property a Fireworks user reaches for while drawing: a +rounded rectangle, a masked bitmap, a tiled fill. The engine could do +all three; the shell showed nothing. + +## Decision + +- **The Properties panel gains a Rectangle section** for a path that + carries a primitive source. It edits the *source*, not the + contours — that is what keeps the rectangle editable: + - *radius* — the corner radius in pixels + (`primitive.setRectRoundness`, `pixels` mode); + - *roundness* — the same radius as a fraction of the short side + (`percent` mode; 1.0 is the stadium limit, `min(w, h) / 2`); + - *X / Y / W / H* — the untransformed source rectangle the + contours derive from (`primitive.setRectSides`). +- **The Properties panel gains a Pixel mask section** for a bitmap. + The mask is a coverage plane over the whole image, so the section + reports its extent and offers: + - *select all* — a full-coverage plane (`bitmap.setPixelMask`); + - *clear* — no plane at all (`bitmap.setPixelMask` with a null + mask); + - the four nudge arrows — the selection slides without its pixels + (`bitmap.movePixelMaskBy`). + A full mask travels as one byte per pixel, so *select all* refuses + past a megabyte of plane and says so. +- **The Fill & Stroke panel gains a Pattern section** for a selection + that holds paths. An image in the document is the tile: the user + picks one and the selection's fill becomes that raster, repeating + (`path.setPatternFill`). + +## The primitive's transform is not composed into the drawn shape + +A defect surfaced under this binding, and it is **not** fixed here. + +`PathObject.primitive` carries the `MTXv` matrix, the file writes it, +and the reader reads it back — but the renderer walks the *contours*, +and the engine's derivation (`rounded_rect_contour`) never composes +the matrix into them. So a rectangle primitive whose source carries a +transform draws as its untransformed rectangle, and editing its +roundness re-derives the contours without the transform. + +Every primitive FireCraft itself mints has an identity transform, so +the defect is latent for engine-authored documents; it reaches a +document that carries a transformed rectangle from elsewhere. + +**The open question is what CS6 stores**, and it needs corpus +evidence rather than a guess: + +- does a skewed rectangle in a CS6 file keep *untransformed* + contours plus an `MTXv` the renderer applies (so the shape is + drawn transformed), or +- does it store *transformed* contours and keep `MTXv` only as the + editable source? + +The two readings put the fix in different places — the renderer in the +first, the engine's derivation in the second. Until a corpus sample +settles it, the derivation stays as it is and the limitation is +recorded here rather than papered over. + +## Consequences + +- Five more engine commands are reachable from the shell's designed + surface: the measurement moves from 234 bound commands of 253 to + **239 of 253** (92.5 % → 94.5 %). +- A user can round a rectangle, mask a bitmap, and tile a fill + without leaving the app. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0113](0113-the-filters-panel-bakes-and-renames.md), +[0114](0114-the-properties-panel-carries-the-mask.md) and +[0115](0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md) +— the same week's other steps in closing the measured surface gap. From 98e6caccf850bd8b774d8b9de58ef15d5ea5db2a Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 15:30:31 -0600 Subject: [PATCH 20/22] feat(panels): the slices, styles, and channel verbs The Slices panel's inspector now carries the web object's rectangle and its background knobs, and its header can turn the selection into a hotspot or a slice. The Styles panel can update a named style in place, and the Properties panel can paste a channel from the clipboard onto a bitmap. Five more engine commands are reachable from the shell's designed surface: the reachability measurement moves from 239 of 253 bound commands (94.5 %) to 244 of 253 (96.4 %). Co-Authored-By: Claude Code --- crates/app/src/shell/mod.rs | 232 +++++++++++++++ crates/app/src/shell/panels.rs | 86 ++++++ crates/app/src/shell/panels_web.rs | 271 ++++++++++++++++++ docs/TASKS.md | 2 +- ...-web-object-and-the-style-panel-updates.md | 102 +++++++ 5 files changed, 692 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0117-the-slices-panel-moves-the-web-object-and-the-style-panel-updates.md diff --git a/crates/app/src/shell/mod.rs b/crates/app/src/shell/mod.rs index 0a55ea3..b0ec4f3 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -166,6 +166,10 @@ pub struct Shell { style_name: String, /// The Styles panel's text-scale factor. style_scale: f64, + /// The Properties panel's channel pair: the clipboard channel + /// pasted in, and the image channel it lands in. + channel_from: &'static str, + channel_to: &'static str, /// The States panel's range fan: the first and last state the /// selection's range fan covers (`-1` = the current state). fan_first: i64, @@ -475,6 +479,8 @@ impl Shell { ui_scale: None, style_name: String::new(), style_scale: 1.0, + channel_from: "red", + channel_to: "alpha", fan_first: 0, fan_last: 0, rename_layer: None, @@ -4617,6 +4623,39 @@ mod tests { } } + /// The image behind a name, for the channel assertions. + fn shell_image(shell: &Shell, name: &str) -> doc::ObjectId { + shell + .editor + .document() + .registry + .iter() + .find_map(|(id, object)| match object { + ObjectData::Image(image) if image.name.as_deref() == Some(name) => Some(id), + _ => None, + }) + .unwrap_or_else(|| panic!("no image named {name:?}")) + } + + /// The alpha byte of an image's first tile — the coverage a + /// channel paste writes. + fn image_alpha(shell: &Shell, id: doc::ObjectId) -> Option { + let image = match shell.editor.document().registry.get(id) { + Some(ObjectData::Image(image)) => image, + _ => return None, + }; + match image.tiles.first() { + Some(doc::TileRef::Tile(tile_id)) => shell + .editor + .document() + .tiles + .get(*tile_id) + .map(|tile| tile.pixels()[3]), + Some(doc::TileRef::Matte(argb)) => Some(((*argb >> 24) & 0xff) as u8), + None => None, + } + } + /// Edit ▸ Copy / Paste: the clipboard crosses the engine's command /// door — the view's selection lands in the document first, the /// paste re-mints onto the active layer, and the pasted object @@ -7310,4 +7349,197 @@ mod tests { assert_eq!(fill.shape, doc::FillShape::Pattern, "the fill is a pattern"); assert!(fill.pattern.is_some(), "the pattern carries its tile"); } + + /// The Slices panel's editors: the web object's rectangle moves, + /// and its background knobs carry their own fields. + #[test] + fn the_slices_panel_edits_the_web_object() { + let mut shell = gui_shell(gui_editor()); + shell + .editor + .apply( + "slice.create", + serde_json::json!({ + "shape": "rect", "x": 10.0, "y": 20.0, + "width": 100.0, "height": 50.0, "page": 0, "name": "band", + }), + ) + .unwrap(); + let id = shell + .editor + .document() + .registry + .iter() + .find_map(|(id, object)| match object { + ObjectData::Slice(slice) if slice.name.as_deref() == Some("band") => Some(id), + _ => None, + }) + .unwrap(); + + // The rectangle moves the web object. + shell.set_hotspot_rectangle(id, 0.0, 0.0, 40.0, 30.0, false); + assert!(!shell.status.contains("failed"), "{}", shell.status); + let frame = match shell.editor.document().registry.get(id) { + Some(ObjectData::Slice(slice)) => slice.frame, + _ => panic!("the slice is gone"), + }; + assert_eq!( + (frame.x0, frame.y0, frame.x1, frame.y1), + (0.0, 0.0, 40.0, 30.0), + "the web object moved" + ); + + // The background knobs carry their own fields. + shell.set_slice_background(id, "is_background", serde_json::json!(true)); + shell.set_slice_background(id, "repeat", serde_json::json!(2)); + shell.set_slice_background(id, "h_position", serde_json::json!(3)); + shell.set_slice_background(id, "h_value", serde_json::json!(12)); + let background = match shell.editor.document().registry.get(id) { + Some(ObjectData::Slice(slice)) => slice.background.clone().unwrap(), + _ => panic!("the slice is gone"), + }; + assert_eq!(background.is_background, Some(true)); + assert_eq!(background.repeat, Some(2)); + assert_eq!(background.h_position, Some(3)); + assert_eq!(background.h_value, Some(12)); + assert_eq!( + background.v_position, None, + "the field the user did not touch stays unset" + ); + } + + /// The Slices panel's "Selection → hotspot": the selection becomes + /// a web object of its own. + #[test] + fn the_slices_panel_makes_a_web_object_from_the_selection() { + let mut shell = gui_shell(gui_editor()); + let id = shell.editor.current_cell().unwrap().elements[0]; + shell.editor.set_selection(vec![id]); + let before = shell + .editor + .document() + .registry + .iter() + .filter(|(_, object)| matches!(object, ObjectData::Slice(_))) + .count(); + + shell.copy_selection_to_web_object("hotspot"); + assert!(!shell.status.contains("failed"), "{}", shell.status); + let after = shell + .editor + .document() + .registry + .iter() + .filter(|(_, object)| matches!(object, ObjectData::Slice(_))) + .count(); + assert_eq!(after, before + 1, "the selection became a web object"); + } + + /// The Styles panel's "Update Named": the existing style of the + /// typed name takes the selection's appearance, and a name with no + /// style is refused rather than creating one. + #[test] + fn the_styles_panel_updates_a_named_style() { + let mut shell = gui_shell(gui_editor()); + let id = shell.editor.current_cell().unwrap().elements[0]; + shell + .editor + .apply( + "style.redefineFromSelection", + serde_json::json!({ "name": "band", "source": id }), + ) + .unwrap(); + assert_eq!(shell.editor.document().styles.len(), 1, "one style"); + + // The selection takes a fill; the style follows it. + shell + .editor + .apply( + "object.setFill", + serde_json::json!({ "id": id, "color_raw": 0x00ff_0000 }), + ) + .unwrap(); + shell.style_name = "band".to_owned(); + shell.update_named_style(id); + assert!(!shell.status.contains("failed"), "{}", shell.status); + assert_eq!( + shell.editor.document().styles.len(), + 1, + "the update replaced the style instead of adding one" + ); + assert_eq!(shell.editor.document().styles[0].name, "band"); + assert_eq!( + shell.editor.document().styles[0].paint.fill_raw, + 0x00ff_0000, + "the style carries the new fill" + ); + + // A name with no style is refused — an update never creates. + shell.style_name = "absent".to_owned(); + shell.update_named_style(id); + assert!(shell.status.contains("failed"), "{}", shell.status); + assert_eq!(shell.editor.document().styles.len(), 1, "no style appeared"); + } + + /// The Properties panel's channel section: the clipboard's channel + /// lands in the image's own. + #[test] + fn the_properties_panel_pastes_a_channel() { + let mut shell = gui_shell(gui_editor()); + // A source bitmap, opaque. + shell + .editor + .apply( + "bitmap.addImage", + serde_json::json!({ + "x": 0.0, "y": 0.0, "width": 16.0, "height": 16.0, + "page": 0, "layer": 0, "cell": 0, "name": "source", + }), + ) + .unwrap(); + let source = shell_image(&shell, "source"); + shell + .editor + .apply( + "bitmap.fill", + serde_json::json!({ "id": source, "color_raw": -1 }), + ) + .unwrap(); + assert_eq!( + image_alpha(&shell, source), + Some(255), + "the source bitmap is opaque" + ); + + // A target bitmap, empty. + shell + .editor + .apply( + "bitmap.addImage", + serde_json::json!({ + "x": 100.0, "y": 100.0, "width": 16.0, "height": 16.0, + "page": 0, "layer": 0, "cell": 0, "name": "target", + }), + ) + .unwrap(); + let target = shell_image(&shell, "target"); + assert_eq!( + image_alpha(&shell, target), + Some(0), + "the target bitmap starts empty" + ); + + // The source goes to the clipboard. + shell.editor.set_selection(vec![source]); + shell.editor.sync_selection().unwrap(); + shell + .editor + .apply("clipboard.copy", serde_json::json!({})) + .unwrap(); + + // The clipboard's alpha lands in the target's alpha. + shell.paste_channel(target, "alpha", "alpha"); + assert!(!shell.status.contains("failed"), "{}", shell.status); + assert_eq!(image_alpha(&shell, target), Some(255), "the channel landed"); + } } diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index ed8a139..e7d5838 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -980,6 +980,17 @@ impl Shell { serde_json::json!({ "name": self.style_name, "source": source }), ); } + if ui + .button("Update Named") + .on_hover_text( + "redefines the existing style of this name, and refuses when there is none", + ) + .clicked() + && let Some(source) = targets.last().copied() + && !self.style_name.is_empty() + { + self.update_named_style(source); + } }); if ui.button("Import Style Library...").clicked() { self.import_styles_dialog(); @@ -1013,6 +1024,19 @@ impl Shell { ); } + /// The Styles panel's "Update Named": the existing style of the + /// typed name takes the selection's appearance — CS6's + /// `redefineStyleByName`. + /// + /// Unlike "New From Selection" this never creates: a name with no + /// style is refused, so an update cannot quietly add an entry. + pub(super) fn update_named_style(&mut self, source: doc::ObjectId) { + self.run_command( + "style.redefineByName", + serde_json::json!({ "name": self.style_name, "source": source }), + ); + } + /// The Styles panel's scale: every style's text size by the /// factor — CS6's `scaleStyles`. pub(super) fn scale_styles(&mut self, scale: f64) { @@ -3534,6 +3558,7 @@ impl Shell { image.location.x, image.location.y, image.offset.x, image.offset.y )); self.pixel_mask_section(ui, id, image); + self.channel_section(ui, id); self.display_controls( ui, id, @@ -3547,6 +3572,67 @@ impl Shell { ); } + /// The bitmap's channels: the clipboard's channel pasted into the + /// image's own. Moving an alpha channel from one bitmap to + /// another is a CS6 habit, and the engine's + /// `clipboard.pasteFromChannelToChannel` does exactly that. + /// + /// The verb reads the document's clipboard and writes onto the + /// single selected image, so the image is selected first. + pub(super) fn channel_section(&mut self, ui: &mut egui::Ui, id: doc::ObjectId) { + ui.heading("Channels"); + const CHANNELS: [&str; 4] = ["red", "green", "blue", "alpha"]; + let mut from = self.channel_from; + let mut to = self.channel_to; + ui.horizontal(|ui| { + egui::ComboBox::from_id_salt(("channel_from", id)) + .selected_text(from) + .show_ui(ui, |ui| { + for channel in CHANNELS { + if ui.selectable_label(channel == from, channel).clicked() { + from = channel; + } + } + }); + ui.label("→"); + egui::ComboBox::from_id_salt(("channel_to", id)) + .selected_text(to) + .show_ui(ui, |ui| { + for channel in CHANNELS { + if ui.selectable_label(channel == to, channel).clicked() { + to = channel; + } + } + }); + }); + self.channel_from = from; + self.channel_to = to; + if ui + .button("paste the channel") + .on_hover_text("copy the clipboard's channel into this image") + .clicked() + { + self.paste_channel(id, from, to); + } + } + + /// Pastes the clipboard's channel into the image's own + /// (`clipboard.pasteFromChannelToChannel`). + /// + /// The verb writes onto the single selected image, so the image + /// is selected before it runs. + pub(super) fn paste_channel(&mut self, id: doc::ObjectId, from: &str, to: &str) { + self.editor.set_selection(vec![id]); + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command( + "clipboard.pasteFromChannelToChannel", + serde_json::json!({ "from_channel": from, "to_channel": to }), + ); + } + /// The bitmap's pixel mask: the pixel selection riding on the /// image, and the moves CS6's marquee makes. /// diff --git a/crates/app/src/shell/panels_web.rs b/crates/app/src/shell/panels_web.rs index b5f4194..ea26e02 100644 --- a/crates/app/src/shell/panels_web.rs +++ b/crates/app/src/shell/panels_web.rs @@ -32,6 +32,10 @@ use crate::shell::slice_editor::{ /// panel. const INSERT_SIZE: f64 = 120.0; +/// The background slice's repeat modes (`CBRi`): no repeat, repeat +/// both ways, repeat across, repeat down. +const REPEAT_LABELS: [&str; 4] = ["no repeat", "repeat both", "repeat x", "repeat y"]; + thread_local! { /// The Behaviors panel's editor draft: the CS6 event/action pair /// the panel is set to add next. CS6 keeps this choice in its @@ -632,6 +636,7 @@ impl Shell { /// reads the same selection the canvas writes. pub(super) fn slices_panel(&mut self, ui: &mut egui::Ui) { ui.weak("Click a slice or hotspot to select it on the canvas."); + self.web_object_from_selection(ui); ui.separator(); let rows = slice_rows(self.editor.document()); if rows.is_empty() { @@ -738,6 +743,10 @@ impl Shell { // The anchor link and the alt text. self.slice_link_editor(ui, row.id, row.link.as_ref(), row.alt.as_ref(), true); + // The web object's own rectangle and its background knobs. + self.slice_rectangle_editor(ui, row.id); + self.slice_background_editor(ui, row.id); + ui.label(row.behavior_count()); if ui .button("Show HTML preview") @@ -748,6 +757,268 @@ impl Shell { } } + /// The web object's own rectangle — CS6's hotspot rectangle. The + /// fields move (or copy) the object to the rectangle the user + /// types (`hotspot.setRectangle`). + fn slice_rectangle_editor(&mut self, ui: &mut egui::Ui, id: doc::ObjectId) { + let Some(frame) = self + .editor + .document() + .registry + .get(id) + .and_then(|object| match object { + doc::ObjectData::Slice(slice) => Some(slice.frame), + _ => None, + }) + else { + return; + }; + ui.heading("Rectangle"); + let mut left = frame.x0; + let mut top = frame.y0; + let mut right = frame.x1; + let mut bottom = frame.y1; + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut left).speed(1.0).prefix("L ")); + ui.add(egui::DragValue::new(&mut top).speed(1.0).prefix("T ")); + ui.add(egui::DragValue::new(&mut right).speed(1.0).prefix("R ")); + ui.add(egui::DragValue::new(&mut bottom).speed(1.0).prefix("B ")); + }); + ui.horizontal(|ui| { + if ui + .button("Move there") + .on_hover_text("move the web object to this rectangle") + .clicked() + { + self.set_hotspot_rectangle(id, left, top, right, bottom, false); + } + if ui + .button("Copy there") + .on_hover_text("copy the web object to this rectangle") + .clicked() + { + self.set_hotspot_rectangle(id, left, top, right, bottom, true); + } + }); + } + + /// Moves the selected web object to the rectangle — or copies it + /// there (`hotspot.setRectangle`). + /// + /// The verb addresses the document's selection, so the object is + /// selected before it runs. + pub(super) fn set_hotspot_rectangle( + &mut self, + id: doc::ObjectId, + left: f64, + top: f64, + right: f64, + bottom: f64, + make_copy: bool, + ) { + self.editor.set_selection(vec![id]); + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command( + "hotspot.setRectangle", + serde_json::json!({ + "rect": { + "left": left, "top": top, "right": right, "bottom": bottom, + }, + "make_copy": make_copy, + }), + ); + } + + /// The background-slice knobs (`CBTb` and the cell-background + /// family): the slice's own background, how it repeats, how it + /// attaches, and where it sits. + /// + /// Each control writes one field and leaves the rest alone — the + /// engine's tri-state fields make that a one-field payload. + fn slice_background_editor(&mut self, ui: &mut egui::Ui, id: doc::ObjectId) { + let Some(background) = + self.editor + .document() + .registry + .get(id) + .and_then(|object| match object { + doc::ObjectData::Slice(slice) => Some(slice.background.clone()), + _ => None, + }) + else { + return; + }; + ui.heading("Background"); + let mut is_background = background + .as_ref() + .and_then(|background| background.is_background) + .unwrap_or(false); + if ui + .checkbox(&mut is_background, "background slice") + .on_hover_text("mark the slice as a background slice") + .changed() + { + self.set_slice_background(id, "is_background", serde_json::json!(is_background)); + } + let repeat = background + .as_ref() + .and_then(|background| background.repeat) + .unwrap_or(0); + self.background_combo(ui, id, "repeat", repeat, 3, &REPEAT_LABELS); + let attachment = background + .as_ref() + .and_then(|background| background.attachment) + .unwrap_or(0); + self.background_combo(ui, id, "attachment", attachment, 1, &["fixed", "scroll"]); + let h_position = background + .as_ref() + .and_then(|background| background.h_position) + .unwrap_or(0); + self.background_combo( + ui, + id, + "h_position", + h_position, + 3, + &["left", "center", "right", "value"], + ); + if h_position == 3 { + let h_value = background + .as_ref() + .and_then(|background| background.h_value) + .unwrap_or(0); + let mut value = h_value; + if ui + .add(egui::DragValue::new(&mut value).speed(1.0).prefix("x ")) + .changed() + { + self.set_slice_background(id, "h_value", serde_json::json!(value)); + } + } + let v_position = background + .as_ref() + .and_then(|background| background.v_position) + .unwrap_or(0); + self.background_combo( + ui, + id, + "v_position", + v_position, + 3, + &["top", "center", "bottom", "value"], + ); + if v_position == 3 { + let v_value = background + .as_ref() + .and_then(|background| background.v_value) + .unwrap_or(0); + let mut value = v_value; + if ui + .add(egui::DragValue::new(&mut value).speed(1.0).prefix("y ")) + .changed() + { + self.set_slice_background(id, "v_value", serde_json::json!(value)); + } + } + } + + /// One background knob: a dropdown over `0..=max`, labeled with + /// `labels`, writing the field only when the choice changes. + fn background_combo( + &mut self, + ui: &mut egui::Ui, + id: doc::ObjectId, + field: &str, + current: i64, + max: i64, + labels: &[&str], + ) { + let current = current.clamp(0, max); + let mut value = current; + egui::ComboBox::from_id_salt((field, id)) + .selected_text(labels[value as usize]) + .show_ui(ui, |ui| { + for option in 0..=max { + if ui + .selectable_label(value == option, labels[option as usize]) + .clicked() + { + value = option; + } + } + }); + if value != current { + self.set_slice_background(id, field, serde_json::json!(value)); + } + } + + /// Writes one background field (`slice.setBackground`). + /// + /// The engine's fields are tri-state — absent leaves the field + /// alone, a value sets it — so the payload carries the one field + /// the user changed. + pub(super) fn set_slice_background( + &mut self, + id: doc::ObjectId, + field: &str, + value: serde_json::Value, + ) { + let mut body = serde_json::Map::new(); + body.insert("id".to_owned(), serde_json::json!(id)); + body.insert(field.to_owned(), value); + self.run_command("slice.setBackground", serde_json::Value::Object(body)); + } + + /// The selection becomes a web object — CS6's "Copy as Hotspot" + /// and "Copy as Slice" (`hotspot.copyToHotspot`). + fn web_object_from_selection(&mut self, ui: &mut egui::Ui) { + ui.horizontal(|ui| { + if ui + .button("Selection → hotspot") + .on_hover_text("make a hotspot covering the selection") + .clicked() + { + self.copy_selection_to_web_object("hotspot"); + } + if ui + .button("Selection → slice") + .on_hover_text("make a slice covering the selection") + .clicked() + { + self.copy_selection_to_web_object("slice"); + } + }); + } + + /// Copies the selection into a new web object of the given kind + /// (`hotspot.copyToHotspot`). + /// + /// CS6 asks what to do when several objects are selected; the + /// headless path has no one to ask, so the panel states its + /// choice — one web object per selected object. + pub(super) fn copy_selection_to_web_object(&mut self, kind: &str) { + if self.editor.live_selection().is_empty() { + self.status = "nothing selected".to_owned(); + return; + } + if let Err(error) = self.editor.sync_selection() { + self.status = format!("selection failed: {error}"); + return; + } + self.run_command( + "hotspot.copyToHotspot", + serde_json::json!({ + "page": self.editor.page, + "hotspot_type": kind, + "what_if_multiple": "multiple", + "make_rectangular": true, + }), + ); + } + /// Sets a web object's type (`slice.setType`): a foreground /// slice, a background slice, or a hotspot. fn set_slice_kind(&mut self, id: doc::ObjectId, kind: SliceKind) -> bool { diff --git a/docs/TASKS.md b/docs/TASKS.md index 596b54b..226f5b4 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -50,7 +50,7 @@ | P2 | Next-release asset breadth and platform verification | Open | [T23](tasks/T23-release-assets.md); recipes exist, but non-Linux artifacts need real platform build/launch receipts. | | P2 | Coverage and test quality ratchet | Open | Replace the old aspirational 99% label with measured per-crate targets and snapshots; [NEXT.md](NEXT.md). | | P2 | Aegis gate: content-keyed baseline, so a doc edit no longer fails the gate | **Done — landed on `main`** | [decisions/0074-aegis-content-keyed-baseline.md](decisions/0074-aegis-content-keyed-baseline.md). The baseline keys on content (pattern, file, digest) rather than on line, and every rule class stays armed. | -| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds seventy-one of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)), and the Properties panel carries the element mask ([0114](decisions/0114-the-properties-panel-carries-the-mask.md)), and the Pages panel marks the master page while the Layers panel shares layers ([0115](decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md)), and the panel edits the rectangle primitive, the bitmap's pixel mask, and the pattern fill ([0116](decisions/0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **239 of 253 (94.5 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | +| P1 | Close the measured reachability gap — bind the engine commands the shell never reached | **In progress — open PR #143** | The reachability gate measured 85 registry commands the engine could execute and the shell's designed surface could not. The branch binds seventy-six of them, family by family, with no menu row added: the Select verbs go through the engine's selection door ([0104](decisions/0104-the-shells-selection-verbs-go-through-the-engine.md)), the Pages panel renames and deletes pages ([0105](decisions/0105-the-pages-panel-renames-and-deletes-pages.md)), the Styles panel redefines and scales styles ([0106](decisions/0106-the-styles-panel-redefines-and-scales-styles.md)), the States panel reorders the states and fans the selection onto them ([0107](decisions/0107-the-states-panel-reorders-and-fans-the-selection.md)), the Properties panel writes the text object's paragraph layout and fits its box ([0108](decisions/0108-the-properties-panel-writes-the-text-layout.md)), its rendering dials and run list ([0109](decisions/0109-the-text-properties-write-the-rendering-dials.md)), the Pages panel lists the guides while the Layers panel moves the selection ([0110](decisions/0110-the-guides-and-the-layer-move-reach-the-engine.md)), and the symbol instance's panel carries the whole symbol family — the button's flags and options, the animation span, the parameters and custom data, the definition's contents ([0111](decisions/0111-the-symbol-panel-carries-the-symbol-family.md)) — while the text panel carries the markup verbs and the button carries its text ([0112](decisions/0112-the-markup-verbs-and-the-button-text.md)), and the Filters panel bakes and renames ([0113](decisions/0113-the-filters-panel-bakes-and-renames.md)), and the Properties panel carries the element mask ([0114](decisions/0114-the-properties-panel-carries-the-mask.md)), and the Pages panel marks the master page while the Layers panel shares layers ([0115](decisions/0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md)), and the panel edits the rectangle primitive, the bitmap's pixel mask, and the pattern fill ([0116](decisions/0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md)), and the Slices panel moves the web object and edits its background, the Styles panel updates the named style, and the Properties panel pastes a channel ([0117](decisions/0117-the-slices-panel-moves-the-web-object-and-the-style-panel-updates.md)). The measurement moves from 168 bound commands of 253 (66.4 %) to **244 of 253 (96.4 %)**. Issues [#136](https://github.com/aliasfoxkde/FireCraft/issues/136)–[#141](https://github.com/aliasfoxkde/FireCraft/issues/141). | ### Release & tooling lanes in flight diff --git a/docs/decisions/0117-the-slices-panel-moves-the-web-object-and-the-style-panel-updates.md b/docs/decisions/0117-the-slices-panel-moves-the-web-object-and-the-style-panel-updates.md new file mode 100644 index 0000000..74191ed --- /dev/null +++ b/docs/decisions/0117-the-slices-panel-moves-the-web-object-and-the-style-panel-updates.md @@ -0,0 +1,102 @@ +# 0117 — The Slices panel moves the web object, the Styles panel updates the named style + +Date: 2026-11-12 · Issue: [#136](https://github.com/aliasfoxkde/FireCraft/issues/136) · +Status: accepted + +## Context + +After the panel bindings of decisions 0114–0116, the reachability gate +measured fourteen engine commands the shell's designed surface still +could not reach. Five of them are properties a user reaches for +routinely, and the panels that own them had no way to say so: + +- the **web object's rectangle** — `hotspot.setRectangle`, CS6's + `dom.setHotspotRectangle`; +- the **background slice's knobs** — `slice.setBackground` + (`CBTb` and the cell-background family); +- the **selection as a web object** — `hotspot.copyToHotspot`, CS6's + `dom.copyToHotspot`; +- the **named style** — `style.redefineByName`, CS6's + `redefineStyleByName`; +- the **bitmap's channel** — `clipboard.pasteFromChannelToChannel`. + +The rest were counted for a reason, and the reasons are recorded +below rather than papered over with a button that does nothing. + +## Decision + +- **The Slices panel's inspector carries the web object's rectangle.** + Four fields (left, top, right, bottom) and two buttons: *Move there* + moves the object, *Copy there* copies it and leaves the copy + selected (`hotspot.setRectangle`). The verb addresses the + document's selection, so the object is selected before it runs. +- **The Slices panel's inspector carries the background knobs** — the + background flag, the repeat mode, the attachment, and the horizontal + and vertical position with their values. Each control writes the one + field the user changed: the engine's fields are tri-state, so an + absent field leaves the stored value alone and the file keeps its + own record of which knobs were ever set (`slice.setBackground`). +- **The Slices panel's header carries *Selection → hotspot* and + *Selection → slice*.** The selection becomes a web object of its + own (`hotspot.copyToHotspot`). CS6 asks what to do when several + objects are selected; the headless path has no one to ask, so the + panel states its choice — one web object per selected object. +- **The Styles panel gains *Update Named*.** The existing style of the + typed name takes the selection's appearance (`style.redefineByName`). + It never creates: a name with no style is refused, where *New From + Selection* defines or replaces one. +- **The Properties panel's bitmap section gains a Channels section.** + The clipboard's channel lands in the image's own + (`clipboard.pasteFromChannelToChannel`) — the habit CS6 users have of + moving an alpha channel from one bitmap to another. The verb writes + onto the single selected image, so the image is selected first. + +## What stays out, and why + +The remaining nine commands are not bound by this decision, and each +one has a reason that is a design question rather than an oversight: + +- **`object.resize`** — the transform path refuses images, and the + Properties panel's X/Y/W/H strip already moves *and* resizes the + selection. The engine's verb only scales in place, and no + user-facing action yet distinguishes "scale in place" from "set the + bounds" — the CS6 *Transform ▸ Scale ▸ To…* dialog that would is + not built. A second W/H row in the same panel would be a near-duplicate + of the first, which is the shape of a fake binding. +- **`object.setGroupType`** — the engine accepts only the plain group + type and refuses the mask-group spellings, so the only action it + could carry would leave the document unchanged. +- **`document.deleteAllInDocument`** — destructive, with no CS6 menu + row of its own. The command palette carries it; a panel button that + empties a document needs a confirmed action that does not exist yet. +- **`autoshape.rect` and `autoshape.redraw`** — the Auto Shapes + library has no rounded or chamfered rectangle entry yet, and the + redraw has no faithful source: FireCraft bakes a shape's geometry at + insert, so the parameters a redraw would re-run are not persisted + anywhere. Re-generating from the panel's current parameters would + move the shape rather than redraw it. +- **`path.warp` and `path.cropSelection`** — both are canvas gestures + (the push tool and the crop tool), not panel fields. They need the + drag interaction the canvas owns. +- **`slice.appendPoint` and `hotspot.appendPoint`** — appending a + point to a polygon web object needs a polyline editor the shell does + not have. + +The gate's ratchet stays where the measurements put it; these nine +remain measured gaps with reasons, not silent ones. + +## Consequences + +- Five more engine commands are reachable from the shell's designed + surface: the measurement moves from 239 bound commands of 253 to + **244 of 253** (94.5 % → 96.4 %). +- A web object can be moved and copied by rectangle, carry its + background knobs, and be made from an ordinary selection; a named + style can be updated in place; a bitmap can receive a channel. + +See also decisions [0104](0104-the-shells-selection-verbs-go-through-the-engine.md), +[0113](0113-the-filters-panel-bakes-and-renames.md), +[0114](0114-the-properties-panel-carries-the-mask.md), +[0115](0115-the-pages-panel-marks-the-master-and-the-layers-panel-shares.md) and +[0116](0116-the-panel-edits-the-rectangle-the-pixel-mask-and-the-pattern.md) — +the same week's other steps in closing the measured surface gap. From 6d9bda244409be4f1336102f895e2577bb49f14d Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 15:35:35 -0600 Subject: [PATCH 21/22] fix(shell): the panels compile again, and the queue is measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch L left the panels with a duplicated selection bridge, two registry lookups that borrowed their id, a match used as a statement, and a needless `mut` — the four compile errors the backup CI caught on the branch. The CI runbook now records the queue as it measures it: one online runner with capacity four, about twelve job starts an hour, ten jobs to a pipeline, and the `julianday` comparison the ISO timestamps need. Co-Authored-By: Claude Code --- crates/app/src/shell/panels.rs | 19 ++++--------------- docs/HANDOFF.md | 28 +++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/crates/app/src/shell/panels.rs b/crates/app/src/shell/panels.rs index e7d5838..278e981 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -1582,7 +1582,7 @@ impl Shell { group .children .first() - .and_then(|child| document.registry.get(child)) + .and_then(|child| document.registry.get(*child)) .is_some_and(|data| matches!(data, ObjectData::Image(_) | ObjectData::Path(_))) }; let is_mask_group = |group: &GroupObject| { @@ -1611,7 +1611,7 @@ impl Shell { let raster = match group .children .first() - .and_then(|child| document.registry.get(child)) + .and_then(|child| document.registry.get(*child)) { Some(ObjectData::Image(_)) => true, Some(ObjectData::Path(_)) => false, @@ -2380,17 +2380,6 @@ impl Shell { }); } - /// The animation and effect verbs address the document's - /// selection, so the view's selection is synced into the - /// document first. - fn sync_document_selection(&mut self) -> bool { - if let Err(error) = self.editor.sync_selection() { - self.status = format!("selection failed: {error}"); - return false; - } - true - } - /// Write the animation's frame count (`ANNi`). pub(super) fn set_anim_num_frames(&mut self, frames: i64) { if !self.sync_document_selection() { @@ -3151,7 +3140,7 @@ impl Shell { self.set_custom_anti_alias_strength(id, strength); } }); - let mut over_sample = text.anti_alias_oversample.unwrap_or(4); + let over_sample = text.anti_alias_oversample.unwrap_or(4); egui::ComboBox::from_label("oversample") .selected_text(over_sample.to_string()) .show_ui(ui, |ui| { @@ -3649,7 +3638,7 @@ impl Shell { match &selection { Some(mask) => ui.label(format!("selection {}x{} px", mask.width, mask.height)), None => ui.label("(no pixel selection)"), - } + }; let (Ok(width), Ok(height)) = (u32::try_from(image.pixels.0), u32::try_from(image.pixels.1)) else { diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 70e19db..4121190 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -322,14 +322,32 @@ the absolute repo path. this fleet's problem. Read the truth from the store: `sudo sqlite3 /var/lib/gitforge/gitforge.db "SELECT id,name,status, capacity,last_heartbeat FROM runners;"`. +- **Measured throughput (2026-11-12, read from the store):** one + online runner (`swarmone-docker`, capacity 4 — so four jobs + concurrently, no more), `fedora-docker` offline since 15:13 and + `t5500-docker` offline since 2026-10-11. Job *starts* measured with + `julianday(started_at) > julianday('now') - N/1440.0` (compare + timestamps with `julianday`, never with `datetime()` string + comparison — the `T` separator breaks lexical ordering and + over-counts): 10 starts in the last hour, 37 in three hours, 53 in + six. That is **≈12 job starts per hour, one start every ~5 + minutes** — the runner is saturated, and the queue is the + bottleneck, not the code. + A pipeline is **ten** jobs (`detect-changes`, `fmt-layers`, `layers`, + `clippy-test`, `wasm-web`, `corpus-golden`, `perf-budgets`, `fuzz`, + `generated-docs`, `release`), so one pipeline costs ~50 minutes of + queue time and a merge campaign of nine PR branches costs ~7 hours. + Budget for that when sequencing a campaign; do not read a long + queue as a hung runner. - **Find the run:** `GET /api/pipeline-runs?limit=15` — match `commit_hash` (field name is `commit_hash`, *not* `sha`; both are - `None` in the list view — fetch jobs to identify a run by its 7-job - shape). + `None` in the list view — fetch jobs to identify a run by its + ten-job shape). - **Wait + jobs:** `GET /api/pipeline-runs/{id}` until status is - terminal; `GET /api/pipeline-runs/{id}/jobs` for the 7 jobs - (fmt-layers, clippy-test, wasm-web, corpus-golden, perf-budgets, - fuzz, generated-docs). All must be `succeeded`. ~15–18 min. + terminal; `GET /api/pipeline-runs/{id}/jobs` for the ten jobs + (detect-changes, fmt-layers, layers, clippy-test, wasm-web, + corpus-golden, perf-budgets, fuzz, generated-docs, release). All + must be `succeeded`. ~15–18 min once the pipeline has the runner. - **Job logs:** `GET /api/jobs/{job_id}/logs` — walk `data.logs[].chunk` or `data.receipt.step_results[].stdout/stderr` (helper: `/var/tmp/getjoblog.py`). From 15db5ef52019e8b697487443b989d362b6f5216d Mon Sep 17 00:00:00 2001 From: Michael Kinney Date: Thu, 12 Nov 2026 15:41:51 -0600 Subject: [PATCH 22/22] feat(shapes): the rounded and chamfered rectangles The Auto Shapes gallery carried the circle-bound and line-bound families but not the two corner-cut rectangles, so the engine's autoshape.rect generator had no path to the shell. Both new tools share that generator and differ only in the corner treatment: the rounded box cuts quarter-circle arcs, the chamfered one cuts edge-parallel handles. The panel carries the corner radius as a knob, and the payload clamps the cut to half the shorter side. Co-Authored-By: Claude Code --- crates/app/src/editor.rs | 53 +++++++++++++++++++++++++--- crates/app/src/shell/glyphs.rs | 56 ++++++++++++++++++++++++++++++ crates/app/src/shell/panels_web.rs | 40 ++++++++++++++++++--- crates/app/tests/editor_surface.rs | 2 +- 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/crates/app/src/editor.rs b/crates/app/src/editor.rs index 7e02c59..0c9290b 100644 --- a/crates/app/src/editor.rs +++ b/crates/app/src/editor.rs @@ -290,11 +290,15 @@ pub enum AutoShape { Measure, /// `autoshape.connectorLine`. ConnectorLine, + /// `autoshape.rect` — the Rounded Rectangle tool. + RoundedRect, + /// `autoshape.rect` — the Chamfer Rectangle tool. + ChamferRect, } impl AutoShape { /// Menu order. - pub const ALL: [AutoShape; 10] = [ + pub const ALL: [AutoShape; 12] = [ AutoShape::Star, AutoShape::Polygon, AutoShape::Spiral, @@ -305,6 +309,8 @@ impl AutoShape { AutoShape::ArrowLine, AutoShape::Measure, AutoShape::ConnectorLine, + AutoShape::RoundedRect, + AutoShape::ChamferRect, ]; /// Menu label. @@ -321,6 +327,8 @@ impl AutoShape { AutoShape::ArrowLine => "Arrow Line", AutoShape::Measure => "Measure", AutoShape::ConnectorLine => "Connector Line", + AutoShape::RoundedRect => "Rounded Rectangle", + AutoShape::ChamferRect => "Chamfer Rectangle", } } @@ -338,6 +346,9 @@ impl AutoShape { AutoShape::ArrowLine => "autoshape.arrowLine", AutoShape::Measure => "autoshape.measure", AutoShape::ConnectorLine => "autoshape.connectorLine", + // The three rectangle tools share one generator and + // differ only in the corner treatment they ask for. + AutoShape::RoundedRect | AutoShape::ChamferRect => "autoshape.rect", } } @@ -345,7 +356,14 @@ impl AutoShape { /// shapes take the frame center + radius, frame-based shapes the /// normalized frame, line-based shapes the raw endpoints. #[must_use] - fn payload(self, drag: &Drag, page: usize, layer: usize, frame: usize) -> serde_json::Value { + fn payload( + self, + drag: &Drag, + page: usize, + layer: usize, + frame: usize, + corner: f64, + ) -> serde_json::Value { let (sx, sy) = (drag.start.x, drag.start.y); let (cx, cy) = (drag.current.x, drag.current.y); // Frame origin/size, normalized so drags in any direction @@ -358,7 +376,7 @@ impl AutoShape { start: (sx, sy), end: (cx, cy), }; - self.payload_from(&placement, page, layer, frame) + self.payload_from(&placement, page, layer, frame, corner) } /// The payload for a shape inserted without a drag — the Auto @@ -373,6 +391,7 @@ impl AutoShape { x: f64, y: f64, size: f64, + corner: f64, ) -> serde_json::Value { let mid = y + size / 2.0; let placement = Placement { @@ -383,7 +402,7 @@ impl AutoShape { start: (x, mid), end: (x + size, mid), }; - self.payload_from(&placement, page, layer, frame) + self.payload_from(&placement, page, layer, frame, corner) } /// The registry payload for one shape placement. Every shape @@ -397,6 +416,7 @@ impl AutoShape { page: usize, layer: usize, frame: usize, + corner: f64, ) -> serde_json::Value { let frame_json = serde_json::json!({ "page": page, "layer": layer, "cell": frame, "name": null, @@ -461,6 +481,19 @@ impl AutoShape { "elbowX": cx, "elbowY": (sy + cy) / 2.0, "corner1": 4.0, "corner2": 4.0, "vertical": false, }), + // The three rectangle tools share one generator; only the + // corner treatment differs. The radius is clamped so the + // cut never exceeds half the shorter side. + AutoShape::RoundedRect => serde_json::json!({ + "x": x, "y": y, "width": width, "height": height, + "corner": corner.min(0.5 * width.min(height)).max(0.0), + "kind": "round", + }), + AutoShape::ChamferRect => serde_json::json!({ + "x": x, "y": y, "width": width, "height": height, + "corner": corner.min(0.5 * width.min(height)).max(0.0), + "kind": "chamfer", + }), }; if let (Some(object), Some(place)) = (value.as_object_mut(), frame_json.as_object()) { for (key, field) in place { @@ -1014,6 +1047,9 @@ pub struct Editor { pub wand_tolerance: u8, /// The feather radius the panel's slider applies. pub feather_radius: u8, + /// The corner radius the rectangle tools cut with (view state — the + /// engine re-reads it from every placement's payload). + pub rect_corner: f64, } impl Editor { @@ -1088,6 +1124,7 @@ impl Editor { pixel_mode: PixelSelectMode::Replace, wand_tolerance: 32, feather_radius: 2, + rect_corner: 12.0, } } @@ -2413,7 +2450,13 @@ impl Editor { Tool::AutoShape(shape) => { self.apply( shape.command(), - shape.payload(&drag, self.page, self.active_layer(), self.frame), + shape.payload( + &drag, + self.page, + self.active_layer(), + self.frame, + self.rect_corner, + ), )?; true } diff --git a/crates/app/src/shell/glyphs.rs b/crates/app/src/shell/glyphs.rs index 2ef6c5e..58983f7 100644 --- a/crates/app/src/shell/glyphs.rs +++ b/crates/app/src/shell/glyphs.rs @@ -200,6 +200,62 @@ pub(crate) fn draw_autoshape_glyph( stroke, )); } + AutoShape::RoundedRect => { + // The rounded box: four quarter-circle arcs joined by the + // straight edges. + let (half_w, half_h, radius) = (6.0f32, 6.0f32, 3.0f32); + let mut points = Vec::new(); + // The four arc centers, clockwise from the top-left. + let centers = [ + (c.x - half_w + radius, c.y - half_h + radius), + (c.x + half_w - radius, c.y - half_h + radius), + (c.x + half_w - radius, c.y + half_h - radius), + (c.x - half_w + radius, c.y + half_h - radius), + ]; + // Each arc runs from its start angle to its end angle. + let arcs = [ + (std::f64::consts::PI, 1.5 * std::f64::consts::PI), + (1.5 * std::f64::consts::PI, 2.0 * std::f64::consts::PI), + (0.0, 0.5 * std::f64::consts::PI), + (0.5 * std::f64::consts::PI, std::f64::consts::PI), + ]; + for (index, (center, (start, end))) in centers.iter().zip(arcs.iter()).enumerate() { + for step in 0..=6 { + let angle = start + (end - start) * step as f64 / 6.0; + points.push(Pos2::new( + (center.0 + (radius as f64 * angle.cos()) as f32), + (center.1 + (radius as f64 * angle.sin()) as f32), + )); + } + // The straight edge to the next arc's start. + let next = centers[(index + 1) % 4]; + let next_angle = arcs[(index + 1) % 4].0; + points.push(Pos2::new( + (next.0 + (radius as f64 * next_angle.cos()) as f32), + (next.1 + (radius as f64 * next_angle.sin()) as f32), + )); + } + points.push(points[0]); + painter.add(egui::Shape::line(points, stroke)); + } + AutoShape::ChamferRect => { + // The chamfered box: eight straight sides. + let (half_w, half_h, cut) = (6.0f32, 6.0f32, 3.0f32); + painter.add(egui::Shape::line( + vec![ + Pos2::new(c.x - half_w + cut, c.y - half_h), + Pos2::new(c.x + half_w - cut, c.y - half_h), + Pos2::new(c.x + half_w, c.y - half_h + cut), + Pos2::new(c.x + half_w, c.y + half_h - cut), + Pos2::new(c.x + half_w - cut, c.y + half_h), + Pos2::new(c.x - half_w + cut, c.y + half_h), + Pos2::new(c.x - half_w, c.y + half_h - cut), + Pos2::new(c.x - half_w, c.y - half_h + cut), + Pos2::new(c.x - half_w + cut, c.y - half_h), + ], + stroke, + )); + } } } diff --git a/crates/app/src/shell/panels_web.rs b/crates/app/src/shell/panels_web.rs index ea26e02..4f8e675 100644 --- a/crates/app/src/shell/panels_web.rs +++ b/crates/app/src/shell/panels_web.rs @@ -129,6 +129,17 @@ impl Shell { ui.end_row(); } }); + ui.separator(); + // The rectangle tools' corner cut — CS6's tool options strip + // carries the same knob for the rounded and chamfered shapes. + ui.horizontal(|ui| { + ui.label("Corner"); + ui.add( + egui::DragValue::new(&mut self.editor.rect_corner) + .range(0.0..=200.0) + .speed(0.5), + ); + }); } /// Inserts the panel's shape at the canvas center. @@ -149,7 +160,8 @@ impl Shell { }), ), ShapeKind::Auto(shape) => { - let payload = shape.payload_in_rect(page, layer, cell, x, y, size); + let payload = + shape.payload_in_rect(page, layer, cell, x, y, size, self.editor.rect_corner); self.run_command(shape.command(), payload) } }; @@ -1426,7 +1438,7 @@ mod tests { /// line-bound ones. #[test] fn the_panel_payload_places_every_shape_family() { - let star = AutoShape::Star.payload_in_rect(0, 1, 2, 10.0, 20.0, 100.0); + let star = AutoShape::Star.payload_in_rect(0, 1, 2, 10.0, 20.0, 100.0, 12.0); assert_eq!(star["centerX"], 60.0); assert_eq!(star["centerY"], 70.0); assert_eq!(star["outer"], 50.0); @@ -1434,19 +1446,39 @@ mod tests { assert_eq!(star["layer"], 1); assert_eq!(star["cell"], 2); - let lshape = AutoShape::LShape.payload_in_rect(0, 0, 0, 10.0, 20.0, 100.0); + let lshape = AutoShape::LShape.payload_in_rect(0, 0, 0, 10.0, 20.0, 100.0, 12.0); assert_eq!(lshape["x"], 10.0); assert_eq!(lshape["y"], 20.0); assert_eq!(lshape["width"], 100.0); assert_eq!(lshape["height"], 100.0); - let measure = AutoShape::Measure.payload_in_rect(0, 0, 0, 10.0, 20.0, 100.0); + let measure = AutoShape::Measure.payload_in_rect(0, 0, 0, 10.0, 20.0, 100.0, 12.0); assert_eq!(measure["startX"], 10.0); assert_eq!(measure["startY"], 70.0); assert_eq!(measure["endX"], 110.0); assert_eq!(measure["endY"], 70.0); } + /// The two corner-cut rectangles carry their own corner radius + /// and their own corner treatment, and the cut never exceeds + /// half the shorter side. + #[test] + fn the_rectangle_tools_carry_their_corner() { + let rounded = AutoShape::RoundedRect.payload_in_rect(0, 0, 0, 10.0, 20.0, 100.0, 8.0); + assert_eq!(rounded["kind"], "round"); + assert_eq!(rounded["corner"], 8.0); + assert_eq!(rounded["x"], 10.0); + assert_eq!(rounded["width"], 100.0); + + let chamfer = AutoShape::ChamferRect.payload_in_rect(0, 0, 0, 10.0, 20.0, 100.0, 8.0); + assert_eq!(chamfer["kind"], "chamfer"); + assert_eq!(chamfer["corner"], 8.0); + + // A cut wider than half the box is clamped. + let clamped = AutoShape::RoundedRect.payload_in_rect(0, 0, 0, 0.0, 0.0, 40.0, 500.0); + assert_eq!(clamped["corner"], 20.0); + } + /// An empty field clears the stored value; a filled one is sent /// as-is. #[test] diff --git a/crates/app/tests/editor_surface.rs b/crates/app/tests/editor_surface.rs index a607565..773d54c 100644 --- a/crates/app/tests/editor_surface.rs +++ b/crates/app/tests/editor_surface.rs @@ -61,7 +61,7 @@ fn autoshape_labels_commands_and_defaults() { assert_eq!(shape.label(), label, "{label}"); assert_eq!(shape.command(), command, "{label}"); } - assert_eq!(AutoShape::ALL.len(), 10); + assert_eq!(AutoShape::ALL.len(), 12); } #[test]