diff --git a/crates/app/src/editor.rs b/crates/app/src/editor.rs index 7e02c598..0c9290ba 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 2ef6c5e2..58983f72 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/mod.rs b/crates/app/src/shell/mod.rs index 5aaf4697..b0ec4f3e 100644 --- a/crates/app/src/shell/mod.rs +++ b/crates/app/src/shell/mod.rs @@ -164,9 +164,22 @@ 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 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, + fan_last: i64, /// 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 @@ -465,7 +478,13 @@ impl Shell { recovery_path: Self::default_recovery_path(), 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, + rename_page: None, show_grid: false, snap_to_grid: false, show_guides: true, @@ -1585,19 +1604,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 +1844,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 @@ -4554,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 @@ -4600,9 +4702,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 +4713,305 @@ 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" + ); + } + + /// 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 + ); + } + + /// 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" + ); + } + + /// 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"); + } + + /// 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] @@ -6290,4 +6692,854 @@ 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"), + } + } + + /// 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"); + } + + /// 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 + ); + } + + /// 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"); + } + + /// 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" + ); + } + + /// 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"); + } + + /// 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 257973db..278e9818 100644 --- a/crates/app/src/shell/panels.rs +++ b/crates/app/src/shell/panels.rs @@ -7,7 +7,17 @@ use super::*; use compose::abgr_pack; -use doc::{GroupObject, ImageObject, SliceObject}; +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 @@ -220,7 +230,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 +239,243 @@ 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 is_master = self.editor.document().pages[index].is_master; + 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); + } + // 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") + .clicked() + { + self.delete_page(index); + } + }); + } + 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. + 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; + } + + /// 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. @@ -249,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() @@ -327,6 +576,13 @@ impl Shell { { self.editor.set_active_layer(layer_index); } + if ui + .small_button("all") + .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") @@ -348,6 +604,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| { @@ -376,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 @@ -492,16 +940,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)"); @@ -522,10 +980,67 @@ 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(); } + 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 "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) { + self.run_command("style.scaleStyles", serde_json::json!({ "scale": scale })); } /// Styles panel ▸ Import: reads a CS6 style library (`.stl`) @@ -726,6 +1241,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 @@ -893,44 +1479,207 @@ 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 in-progress shape's panel. CS6's Pen, Polygon Slice, - /// Polygon Hotspot and Polygonal Marquee all build their shape - /// with clicks, and the shape is only real once it is finished — - /// so the panel carries the finish button and the point count. - pub(super) fn draft_panel(&mut self, ui: &mut egui::Ui) { + /// 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, + /// Polygon Hotspot and Polygonal Marquee all build their shape + /// with clicks, and the shape is only real once it is finished — + /// so the panel carries the finish button and the point count. + pub(super) fn draft_panel(&mut self, ui: &mut egui::Ui) { let polygon_points = self .editor .polygon @@ -1199,9 +1948,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; }; @@ -1272,6 +2049,580 @@ 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 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 + /// 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); + } + }); + } + + /// Write the animation's frame count (`ANNi`). + pub(super) fn set_anim_num_frames(&mut self, frames: i64) { + if !self.sync_document_selection() { + 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_document_selection() { + 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_document_selection() { + 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_document_selection() { + 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_document_selection() { + 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_document_selection() { + 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. @@ -1283,6 +2634,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, @@ -1296,6 +2650,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( @@ -1371,41 +2822,467 @@ impl Shell { serde_json::json!({ "id": id, "bold": bold }), ); } - let mut italic = text - .style - .as_ref() - .and_then(|style| style.italic) - .unwrap_or(false); - if ui.checkbox(&mut italic, "italic").changed() { - let _ = self.editor.apply( - "text.setAttrs", - serde_json::json!({ "id": id, "italic": italic }), - ); + let mut italic = text + .style + .as_ref() + .and_then(|style| style.italic) + .unwrap_or(false); + if ui.checkbox(&mut italic, "italic").changed() { + let _ = self.editor.apply( + "text.setAttrs", + serde_json::json!({ "id": id, "italic": italic }), + ); + } + let mut underline = text + .style + .as_ref() + .and_then(|style| style.underline) + .unwrap_or(false); + if ui.checkbox(&mut underline, "underline").changed() { + let _ = self.editor.apply( + "text.setAttrs", + serde_json::json!({ "id": id, "underline": underline }), + ); + } + }); + self.display_controls( + ui, + id, + DisplayState { + opacity: text.opacity, + blend: text.blend, + visible: text.visible, + locked: text.locked, + name: text.name.clone(), + }, + ); + 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. + /// + /// 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 + } + + /// 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 underline = text - .style - .as_ref() - .and_then(|style| style.underline) - .unwrap_or(false); - if ui.checkbox(&mut underline, "underline").changed() { - let _ = self.editor.apply( - "text.setAttrs", - serde_json::json!({ "id": id, "underline": underline }), - ); + }); + 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| { + 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); } }); - self.display_controls( - ui, - id, - DisplayState { - opacity: text.opacity, - blend: text.blend, - visible: text.visible, - locked: text.locked, - name: text.name.clone(), - }, + 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 }), ); - self.geometry_fields(ui, id); + } + + /// 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 @@ -1669,6 +3546,8 @@ 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.channel_section(ui, id); self.display_controls( ui, id, @@ -1682,6 +3561,151 @@ 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. + /// + /// 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. /// @@ -1885,10 +3909,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 @@ -2190,3 +4429,61 @@ fn object_label(object: &ObjectData) -> String { _ => format!("(unnamed {kind})"), } } + +/// 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. +#[derive(Default)] +struct LinkedSliceFacts { + found: bool, + link: String, + 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) +} diff --git a/crates/app/src/shell/panels_web.rs b/crates/app/src/shell/panels_web.rs index b5f4194e..4f8e675a 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 @@ -125,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. @@ -145,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) } }; @@ -632,6 +648,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 +755,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 +769,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 { @@ -1155,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); @@ -1163,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/src/shell/timeline.rs b/crates/app/src/shell/timeline.rs index 4578b465..a22fef63 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/crates/app/tests/editor_surface.rs b/crates/app/tests/editor_surface.rs index a607565d..773d54cd 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] diff --git a/crates/compose/src/render.rs b/crates/compose/src/render.rs index 91d80888..8737e381 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 00000000..c5b7500c --- /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 a19f122b..ec0f830b 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 85adc2db..c2501829 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 64a7cded..a5100322 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/mask.rs b/crates/engine/src/catalog/mask.rs index e5cd355a..2b16ea30 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(); diff --git a/crates/engine/src/catalog/page.rs b/crates/engine/src/catalog/page.rs index 8350332f..3774ac91 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/HANDOFF.md b/docs/HANDOFF.md index 70e19db9..41211901 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`). diff --git a/docs/TASKS.md b/docs/TASKS.md index f16e7781..226f5b4e 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 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/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 00000000..30a38d85 --- /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. 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 00000000..57c22f37 --- /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. 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 00000000..5606e92e --- /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. 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 00000000..c88e6b21 --- /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. 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 00000000..28c236af --- /dev/null +++ b/docs/decisions/0108-the-properties-panel-writes-the-text-layout.md @@ -0,0 +1,71 @@ +# 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 *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) — +the same week's other steps in closing the measured surface gap. 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 00000000..2a9901cd --- /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 00000000..b46f3dc6 --- /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. 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 00000000..04fdab55 --- /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. 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 00000000..7bfd1ab5 --- /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. 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 00000000..d25165d2 --- /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. 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 00000000..8e91b4a4 --- /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. 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 00000000..89f2593c --- /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. 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 00000000..1cbdc234 --- /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. 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 00000000..74191edb --- /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.