From 3cfdb58b903f6225553e4ef9d76b789573b885d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 18:54:45 +0700 Subject: [PATCH] Transparency: pattern editing mode and opacity mask editing can't be entered from each other Squashed from #4458 onto main. --- crates/engine/src/cmd/maskedit.rs | 43 ++++--- crates/engine/src/cmd/object/arrange.rs | 19 +++- crates/engine/src/cmd/opacitymask.rs | 26 +++-- crates/engine/src/cmd/patterncmds.rs | 22 +++- crates/engine/src/tests/opacitymask.rs | 117 ++++++++++++++++++++ crates/engine/src/tests/pattern.rs | 79 +++++++++++++ crates/engine/src/tooling.rs | 13 +++ crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels/tests_maskview.rs | 33 ++++++ crates/ui-egui/src/panels/transparency.rs | 2 +- crates/ui-egui/src/shortcuts/tests.rs | 19 ++++ docs/mcp.md | 13 ++- docs/target-app-parity.md | 2 + 37 files changed, 380 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 8ae1912fd..83c7abf7c 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -6,8 +6,8 @@ //! effect, as in the reference app). Leaving the mode removes the layer, and saving or exporting //! never writes it ([`Document::without_edit_modes`]). Meanwhile the Transparency panel's commands //! act on the masked object (see `opacitymask::transparency_targets`). Mask editing is not entered -//! in isolation mode ([`outside_isolation_mode`]), and isolation mode is not entered while a mask is -//! edited ([`outside_mask_editing`]). +//! in isolation mode or pattern editing mode ([`can_edit_mask`]), and neither mode is entered while +//! a mask is edited ([`outside_mask_editing`]). use std::sync::Arc; @@ -29,8 +29,8 @@ pub fn specs() -> Vec { "Edit Opacity Mask", ["Window", "Transparency"], None, - "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode → {layer}", - outside_isolation_mode, + "{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode and in pattern editing mode → {layer}", + can_edit_mask, enter ), cmd!( @@ -47,8 +47,8 @@ pub fn specs() -> Vec { "View Opacity Mask", ["Window", "Transparency"], None, - "{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode → {on, id}", - outside_isolation_mode, + "{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode and in pattern editing mode → {on, id}", + can_edit_mask, view_mask ), ] @@ -60,15 +60,27 @@ pub(crate) fn in_isolation_mode(st: &crate::DocState) -> bool { st.isolation.is_some() && st.doc.pattern_edit.is_none() && st.doc.mask_edit.is_none() } -/// A document is open and it is not in isolation mode ([`in_isolation_mode`]): opacity mask -/// editing is not entered in isolation mode. -pub(crate) fn outside_isolation_mode(s: &Session) -> std::result::Result<(), String> { - let st = s.active().ok_or("no document open")?; - if in_isolation_mode(st) { Err("exit isolation mode first".into()) } else { Ok(()) } +/// Why opacity mask editing is not entered now, if it is not: in isolation mode +/// ([`in_isolation_mode`]) and in pattern editing mode, whose tile layer the mask editing layer +/// would replace as the isolated layer. +pub(crate) fn mask_editing_refused(st: &crate::DocState) -> Option<&'static str> { + if st.doc.pattern_edit.is_some() { + Some("finish editing the pattern first") + } else if in_isolation_mode(st) { + Some("exit isolation mode first") + } else { + None + } } -/// A document is open and no opacity mask is being edited: isolation mode is not entered while -/// an opacity mask is edited. +/// A document is open and opacity mask editing can be entered ([`mask_editing_refused`]). +pub(crate) fn can_edit_mask(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + mask_editing_refused(st).map_or(Ok(()), |why| Err(why.into())) +} + +/// A document is open and no opacity mask is being edited: isolation mode and pattern editing mode +/// are not entered while an opacity mask is edited. pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> { let st = s.active().ok_or("no document open")?; if st.doc.mask_edit.is_some() { Err("stop editing the opacity mask first".into()) } else { Ok(()) } @@ -169,8 +181,11 @@ pub(crate) fn begin(d: &mut Document, sel: &mut Selection, id: NodeId) -> Result Ok(layer) } -fn leave(s: &mut Session, _: &Value) -> Result { +/// Leave mask editing in one undo step (`transparency.stopEditingOpacityMask`). `object.exitIsolation` runs it too +/// while a mask is edited. +pub(crate) fn leave(s: &mut Session, _: &Value) -> Result { let Some(me) = s.doc()?.doc.mask_edit else { return Err(bad("transparency.stopEditingOpacityMask", "not editing an opacity mask")) }; + s.end_text_edit(); s.edit("Stop Editing Opacity Mask", |d, sel| { finish(d, sel); Ok(()) diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 5649c957d..2be0c06a8 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -293,7 +293,24 @@ pub(super) fn isolate(s: &mut Session, p: &Value) -> Result { ok() } -pub(super) fn exit_isolation(s: &mut Session, _: &Value) -> Result { +pub(super) fn exit_isolation(s: &mut Session, p: &Value) -> Result { + // Opacity mask editing and pattern editing isolate their editing layer. Leaving isolation mode + // then leaves that mode, as Stop Editing Opacity Mask and the isolation bar's Done do. + let st = s.doc()?; + let leave_mode: Option Result> = if st.doc.mask_edit.is_some() { + Some(crate::cmd::maskedit::leave) + } else if st.doc.pattern_edit.is_some() { + Some(crate::cmd::patterncmds::pattern_done) + } else { + None + }; + if let Some(leave_mode) = leave_mode { + if let Some(i) = st.isolation { + crate::cmd::distortcmds::finish_edit_text(s.doc_mut()?, i); + } + leave_mode(s, p)?; + return ok(); + } let menu = s.menu.clone(); let st = s.doc_mut()?; if let Some(i) = st.isolation.take() { diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 37dadf5aa..50fcc349a 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Make Opacity Mask", ["Window", "Transparency"], None, - "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode → {id, editing}", + "{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}", can_make, make ), @@ -163,19 +163,20 @@ fn masked_targets(s: &Session, p: &Value) -> Result> { } /// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty -/// mask and takes into mask editing, is not in isolation mode -/// ([`super::maskedit::outside_isolation_mode`]). The selection is one object when it has one -/// subject root, which `make` masks without `ids`, and at most one outermost object, which it -/// masks with `ids` (a call's `ids` stand in for the selection here). `make` itself refuses any -/// single target in isolation mode, a layer included, so a selection with a target counts as one -/// object. +/// mask and takes into mask editing, is where mask editing can be entered +/// ([`super::maskedit::can_edit_mask`]: not in isolation mode or pattern editing mode). The +/// selection is one object when it has one subject root, which `make` masks without `ids`, and at +/// most one outermost object, which it masks with `ids` (a call's `ids` stand in for the selection +/// here). A targeted layer is one subject root however many objects it selects. `make` itself +/// refuses any single target in those modes, a layer included, so a selection with a target counts +/// as one object. fn can_make(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; let Some(st) = s.active() else { return Ok(()) }; let roots = super::appearance::subject_roots(s).map_or(0, |r| r.len()); let outermost = super::edit::outermost(&st.doc, st.selection.in_paint_order(&st.doc)).len(); let targeted = st.selection.target.is_some(); - if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::outside_isolation_mode(s) } else { Ok(()) } + if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::can_edit_mask(s) } else { Ok(()) } } fn make(s: &mut Session, p: &Value) -> Result { @@ -194,9 +195,12 @@ fn make(s: &mut Session, p: &Value) -> Result { None => super::appearance::subject_roots(s)?, }; let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) }; - // One target gets an empty mask and enters mask editing, which isolation mode does not allow. - if below.is_empty() && super::maskedit::in_isolation_mode(s.doc()?) { - return Err(bad(C, "exit isolation mode first")); + // One target gets an empty mask and enters mask editing, which isolation mode and pattern + // editing mode do not allow. + if below.is_empty() + && let Some(why) = super::maskedit::mask_editing_refused(s.doc()?) + { + return Err(bad(C, why)); } let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped); let invert = bool_or(p, "invert", s.menu.new_masks_inverted); diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index f412452ba..aea1aeb58 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -32,8 +32,8 @@ pub fn specs() -> Vec { "Make", ["Object", "Pattern"], None, - "{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode → {name}", - has_selection, + "{ids?, name? (trimmed; blank: New Pattern N), tileType?: grid|brickByRow|brickByColumn|hexByColumn|hexByRow, brickOffset?: 0..1 (0.5), width?, height?, edit?: bool (true)} make a pattern swatch from the selection and enter pattern editing mode; refused while an opacity mask is edited → {name}", + can_make_pattern, pattern_make ), cmd!( @@ -41,7 +41,7 @@ pub fn specs() -> Vec { "Edit Pattern", ["Object", "Pattern"], Some("Cmd+Shift+F8"), - "{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode", + "{name?} edit a pattern (default: the selected object's fill pattern) in pattern editing mode; refused while an opacity mask is edited", can_edit_pattern, pattern_edit ), @@ -161,11 +161,20 @@ fn in_pattern_edit(s: &Session) -> std::result::Result<(), String> { editing(s).map(|_| ()).ok_or_else(|| "not in pattern editing mode".into()) } +/// Make: a selection, and no opacity mask is being edited +/// ([`super::maskedit::outside_mask_editing`]), since the tile layer would replace the mask editing +/// layer as the isolated layer. +fn can_make_pattern(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + super::maskedit::outside_mask_editing(s) +} + fn can_edit_pattern(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if editing(s).is_some() { return Err("already editing a pattern".into()); } + super::maskedit::outside_mask_editing(s)?; if s.active().is_some_and(|st| st.doc.patterns.is_empty()) { Err("the document has no patterns".into()) } else { Ok(()) } } @@ -355,7 +364,12 @@ fn pattern_name(p: &Paint) -> Option { } } -fn pattern_done(s: &mut Session, _: &Value) -> Result { +/// Save the edited tile art into the pattern and leave pattern editing mode (`object.pattern.done`). +/// `object.exitIsolation` runs it too while a pattern is edited. +pub(crate) fn pattern_done(s: &mut Session, _: &Value) -> Result { + if editing(s).is_some() { + s.end_text_edit(); + } let name = s.edit("Done Editing Pattern", |d, sel| { let (pe, art) = leave_edit(d, sel)?; if let Some(def) = d.pattern_mut(&pe.pattern) { diff --git a/crates/engine/src/tests/opacitymask.rs b/crates/engine/src/tests/opacitymask.rs index 74d6770e3..2a8cbf4ca 100644 --- a/crates/engine/src/tests/opacitymask.rs +++ b/crates/engine/src/tests/opacitymask.rs @@ -6,6 +6,7 @@ use serde_json::{Value, json}; use vectorcraft_geom::Affine; use vectorcraft_render::{RenderOptions, Renderer}; +use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::cmd::maskedit::MASK_EDIT_LAYER; use crate::tests::{history, select_with_a_step_to_redo}; @@ -170,6 +171,52 @@ fn release_while_editing_leaves_editing_first() { assert!(mask(&s, obj).is_some() && doc(&s).mask_edit.is_some()); } +/// Exit Isolation Mode while a mask is edited leaves mask editing as Stop Editing Opacity Mask +/// does: the mask keeps the edit, the editing layer is removed, nothing is isolated, the masked +/// object is selected on its layer, and one undo step returns to mask editing. +#[test] +fn exit_isolation_while_editing_leaves_mask_editing() { + let mut s = session(); + let obj = editing_mask(&mut s); + s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.isolation.is_none() && !has_edit_layer(&st.doc)); + assert_eq!(st.selection.objects, vec![obj]); + assert_eq!(st.active_layer, Some(st.doc.layers[0].id)); + assert_eq!(st.history.undo.last().unwrap().label, "Stop Editing Opacity Mask"); + assert_eq!(mask(&s, obj).unwrap().art.geometric_bounds().unwrap().x0, 50.0); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let layer = st.doc.mask_edit.map(|m| m.layer); + assert!(layer.is_some() && st.isolation == layer, "back in mask editing"); +} + +/// Point type that a Type tool click placed in the mask and left empty is discarded when Stop +/// Editing Opacity Mask or Exit Isolation Mode leaves mask editing, so the mask does not keep it. +#[test] +fn empty_point_type_is_not_saved_into_the_mask() { + for cmd in ["transparency.stopEditingOpacityMask", "object.exitIsolation"] { + let mut s = session(); + s.prefs.placeholder_text = false; + let obj = editing_mask(&mut s); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 250.0, 250.0), v).unwrap(); + } + assert!(s.tool_wants_text(), "{cmd}: the click places point type to edit"); + s.execute(cmd, &json!({})).unwrap(); + assert!(!s.tool_wants_text(), "{cmd}"); + let art = &mask(&s, obj).unwrap().art; + assert!(art.children().is_none() && !matches!(art.kind, vectorcraft_doc::NodeKind::Text(_)), "{cmd}: the mask keeps only the square"); + assert_eq!(art.geometric_bounds().unwrap().x1, 100.0, "{cmd}"); + let labels = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect::>(); + // The mask took the type in when it was placed, so its removal is a step of its own. + assert_eq!(labels[labels.len() - 3..], ["Type", "Discard Empty Type", "Stop Editing Opacity Mask"], "{cmd}"); + } +} + /// Releasing the masks of a group and of an object inside it puts each mask's art directly above /// its object, as releasing one mask at a time does. #[test] @@ -194,6 +241,76 @@ fn release_of_a_masked_group_and_a_masked_object_inside_it_keeps_order_and_ids() assert!(*doc(&s) == before, "Undo gives back the document"); } +/// Opacity mask editing is not entered in pattern editing mode: Edit Opacity Mask and View Opacity +/// Mask are off for a masked object in the tile, and so is Make Opacity Mask for one object, which +/// would edit its new empty mask; a single target given in `ids` is refused. Make Opacity Mask with +/// two objects still masks one with the other. Pattern editing goes on, its layer still isolated. +/// After Done the mask is edited again. +#[test] +fn opacity_mask_editing_is_not_entered_in_pattern_editing_mode() { + let mut s = session(); + let (obj, m) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0)); + s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [obj.0]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + let tile = doc(&s).pattern_edit.as_ref().unwrap().layer; + let copy = doc(&s).node(tile).unwrap().children().unwrap()[0].id; + assert!(mask(&s, copy).is_some(), "the tile's copy has the mask"); + let why = "finish editing the pattern first"; + let off = |s: &mut Session, cmd: &str, p: Value| { + let err = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}"); + }; + off(&mut s, "transparency.editOpacityMask", json!({})); + off(&mut s, "transparency.editOpacityMask", json!({"id": copy.0})); + off(&mut s, "transparency.viewOpacityMask", json!({})); + off(&mut s, "transparency.viewOpacityMask", json!({"on": true, "id": copy.0})); + let one = rect(&mut s, 220.0, 30.0); + s.execute("select.set", &json!({"ids": [one.0]})).unwrap(); + off(&mut s, "transparency.makeOpacityMask", json!({})); + off(&mut s, "transparency.makeOpacityMask", json!({"ids": [one.0]})); + // With two objects selected the command is on, and one target given in `ids` is refused. + s.execute("select.set", &json!({"ids": [copy.0, one.0]})).unwrap(); + for target in [one, tile] { + let err = s.execute("transparency.makeOpacityMask", &json!({"ids": [target.0]})).unwrap_err(); + assert!(err.to_string().ends_with(why), "{err}"); + } + let st = s.doc().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.mask_view.is_none() && mask(&s, one).is_none()); + assert_eq!((st.isolation, st.active_layer), (Some(tile), Some(tile))); + // Two objects: the top one masks the other, without mask editing. + let top = rect(&mut s, 220.0, 10.0); + s.execute("select.set", &json!({"ids": [one.0, top.0]})).unwrap(); + let r = s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + assert_eq!(r, json!({"id": one.0, "editing": false})); + assert_eq!(s.doc().unwrap().isolation, Some(tile)); + assert!(doc(&s).pattern_edit.is_some()); + s.execute("object.pattern.done", &json!({})).unwrap(); + s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap(); + assert_eq!(doc(&s).mask_edit.map(|e| e.object), Some(obj)); +} + +/// In pattern editing mode a targeted sublayer holding two objects is one target, so Make Opacity +/// Mask is off for it. +#[test] +fn make_opacity_mask_is_off_for_a_targeted_sublayer_in_pattern_editing_mode() { + let mut s = session(); + let obj = rect(&mut s, 0.0, 200.0); + s.execute("select.set", &json!({"ids": [obj.0]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + let tile = doc(&s).pattern_edit.as_ref().unwrap().layer; + let sub = s.execute("layer.newSublayer", &json!({"parent": tile.0})).unwrap()["id"].as_u64().unwrap(); + let (a, b) = (rect(&mut s, 220.0, 30.0), rect(&mut s, 260.0, 30.0)); + s.execute("layer.move", &json!({"ids": [a.0, b.0], "target": sub})).unwrap(); + let kids = doc(&s).node(NodeId(sub)).unwrap().children().unwrap().iter().map(|n| n.id).collect::>(); + assert_eq!(kids, vec![a, b], "the new objects are drawn in the sublayer"); + s.execute("layer.target", &json!({"id": sub})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects.len(), 2, "the sublayer's two objects are selected"); + let err = s.execute("transparency.makeOpacityMask", &json!({})).unwrap_err(); + let why = "finish editing the pattern first"; + assert!(matches!(&err, EngineError::Disabled(id, w) if id == "transparency.makeOpacityMask" && w == why), "{err}"); +} + #[test] fn one_object_gets_an_empty_mask_drawn_in_editing_mode() { let mut s = session(); diff --git a/crates/engine/src/tests/pattern.rs b/crates/engine/src/tests/pattern.rs index f993372b8..17bb0071b 100644 --- a/crates/engine/src/tests/pattern.rs +++ b/crates/engine/src/tests/pattern.rs @@ -5,6 +5,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::pattern::{PATTERN_EDIT_LAYER, RepeatKind, TileType}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; +use vectorcraft_tools::{PointerEvent, PointerKind}; use crate::tests_menucmds::place_undecodable_png; use crate::*; @@ -87,6 +88,52 @@ fn done_saves_edited_art_and_leaves_mode() { assert!(s.doc().unwrap().isolation.is_none()); } +/// Exit Isolation Mode while a pattern is edited (the canvas context menu and the Layers panel +/// menu offer it) leaves pattern editing as Done does, with the drawn art saved into the pattern. +#[test] +fn exit_isolation_while_editing_a_pattern_saves_it_and_leaves_the_mode() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + rect(&mut s, 2.0, 2.0, 3.0, 3.0); + s.execute("object.exitIsolation", &json!({})).unwrap(); + let d = doc(&s); + assert!(d.pattern_edit.is_none()); + assert_eq!(d.pattern("P").unwrap().art.len(), 2); + assert!(d.layers.iter().all(|l| l.name.as_deref() != Some(PATTERN_EDIT_LAYER))); + let st = s.doc().unwrap(); + assert_eq!((st.isolation, st.active_layer), (None, st.doc.default_layer())); + assert_eq!(st.history.undo.last().unwrap().label, "Done Editing Pattern"); +} + +/// Point type that a Type tool click placed in the tile and left empty is discarded when Done or +/// Exit Isolation Mode leaves pattern editing, so the pattern does not keep it. +#[test] +fn empty_point_type_is_not_saved_into_the_pattern() { + for cmd in ["object.pattern.done", "object.exitIsolation"] { + let mut s = session(); + s.prefs.placeholder_text = false; + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + sel(&mut s, &[r]); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 200.0, 200.0), v).unwrap(); + } + assert!(s.tool_wants_text(), "{cmd}: the click places point type to edit"); + s.execute(cmd, &json!({})).unwrap(); + assert!(!s.tool_wants_text(), "{cmd}"); + let art = &doc(&s).pattern("P").unwrap().art; + assert_eq!(art.len(), 1, "{cmd}: the tile keeps only the square"); + assert!(!matches!(art[0].kind, NodeKind::Text(_)), "{cmd}"); + let labels = s.doc().unwrap().history.undo.iter().map(|e| e.label.as_str()).collect::>(); + assert_eq!(labels.last(), Some(&"Done Editing Pattern"), "{cmd}"); + assert!(!labels.contains(&"Type"), "{cmd}: {labels:?}"); + } +} + #[test] fn cancel_new_pattern_removes_it() { let mut s = session(); @@ -112,6 +159,38 @@ fn edit_then_cancel_restores_definition() { assert_eq!(def.tile.width(), 20.0); } +/// Pattern editing mode is not entered while an opacity mask is edited: Edit Pattern and Make are +/// off, with or without a name, and Make also with `edit: false`. Mask editing goes on, its layer +/// still isolated. After Stop Editing Opacity Mask, Edit Pattern works again. +#[test] +fn pattern_editing_is_not_entered_while_an_opacity_mask_is_edited() { + let mut s = session(); + make_dots(&mut s); + let obj = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let m = rect(&mut s, 100.0, 100.0, 50.0, 100.0); + s.execute("paint.setFill", &json!({"ids": [m.0], "color": "#ffffff"})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap(); + let layer = s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap()["layer"].as_u64().map(NodeId); + let why = "stop editing the opacity mask first"; + for (cmd, p) in [ + ("object.pattern.edit", json!({"name": "Dots"})), + ("object.pattern.edit", json!({})), + ("object.pattern.make", json!({})), + ("object.pattern.make", json!({"name": "Mask Art", "edit": false})), + ] { + let err = s.execute(cmd, &p).unwrap_err(); + assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}"); + } + let st = s.doc().unwrap(); + assert!(st.doc.pattern_edit.is_none() && st.doc.pattern("Mask Art").is_none() && st.doc.pattern("New Pattern 1").is_none()); + assert_eq!(st.doc.mask_edit.map(|e| (e.object, Some(e.layer))), Some((obj, layer))); + assert_eq!((st.isolation, st.active_layer), (layer, layer)); + s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap(); + s.execute("object.pattern.edit", &json!({"name": "Dots"})).unwrap(); + let st = s.doc().unwrap(); + assert_eq!(st.isolation, st.doc.pattern_edit.as_ref().map(|e| e.layer)); +} + #[test] fn edit_defaults_to_selected_objects_pattern() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index ce401f25a..b5c3f53f8 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -703,6 +703,19 @@ impl Session { } } + /// End the Type tool's text editing before a command saves the art of an editing layer (pattern + /// editing's Done, Stop Editing Opacity Mask): point type its click placed that is still empty is + /// discarded first, so it is not saved into the pattern tile or the mask. + pub(crate) fn end_text_edit(&mut self) { + if self.in_tool_actions || !self.tool.wants_text() { + return; + } + let acts = self.with_tool_cx(self.last_view, |t, cx| t.deactivate(cx)); + if let Err(e) = self.apply_actions(acts) { + log::warn!("ending the text edit: {e}"); + } + } + /// Tell the active tool that `edit.undo` or `edit.redo` ran, on its own or as a step of /// `command.batch`, so the Type tool puts the caret where the step changed the text; or that a /// `command.batch` began, ended or was rolled back (`batch.begin`, `batch.end`, diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index b23deea22..e3fd9153f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -4095,6 +4095,7 @@ axis Both И двете @msg not an EMF or WMF file не е EMF или WMF файл @msg not found не е открито @msg not in pattern editing mode не е в режим на редактиране на мостра +@msg finish editing the pattern first първо завършете редактирането на мострата @msg nothing encoded нищо не е кодирано @msg nothing selected нищо не е избрано @msg nothing selected is in a group нищо от избраното не е в група diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4022efa29..510fb90c5 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5177,6 +5177,7 @@ @msg not an RTF file: it doesn't start with {\\rtf no és un fitxer RTF: no comença amb {\\rtf @msg not found no s’ha trobat @msg not in pattern editing mode no esteu en mode d’edició de motius +@msg finish editing the pattern first acabeu primer l’edició del motiu @msg nothing encoded no s’ha codificat res @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for no s’ha aplicat res del conjunt de dades {name}: cap objecte està vinculat a una variable per a la qual tingui un valor @msg nothing selected no hi ha res seleccionat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index df84d88f1..d0f07304f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -4500,6 +4500,7 @@ imageTrace Outline Obrys @msg not an EMF or WMF file nejde o soubor EMF ani WMF @msg not found nenalezeno @msg not in pattern editing mode není aktivní režim úprav vzorku +@msg finish editing the pattern first nejprve dokončete úpravy vzorku @msg nothing encoded nic nebylo zakódováno @msg nothing selected nic není vybráno @msg nothing selected is in a group nic z výběru není ve skupině diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index ea732c3db..7c64e765a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -4200,6 +4200,7 @@ imageTrace Outline Kontur @msg not an EMF or WMF file ikke en EMF- eller WMF-fil @msg not found ikke fundet @msg not in pattern editing mode ikke i mønsterredigeringstilstand +@msg finish editing the pattern first afslut først redigeringen af mønsteret @msg nothing encoded intet kodet @msg nothing selected intet markeret @msg nothing selected is in a group intet af det markerede er i en gruppe diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 07f554cef..0b09e9e03 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4115,6 +4115,7 @@ theme Appearance Erscheinungsbild @msg not an EMF or WMF file keine EMF- oder WMF-Datei @msg not found nicht gefunden @msg not in pattern editing mode nicht im Musterbearbeitungsmodus +@msg finish editing the pattern first beenden Sie zuerst die Bearbeitung des Musters @msg nothing encoded nichts kodiert @msg nothing selected nichts ausgewählt @msg nothing selected is in a group nichts Ausgewähltes befindet sich in einer Gruppe diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index e2567667c..64798b589 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4157,6 +4157,7 @@ theme Appearance Εμφάνιση @msg not an EMF or WMF file δεν είναι αρχείο EMF ή WMF @msg not found δεν βρέθηκε @msg not in pattern editing mode δεν βρίσκεστε σε λειτουργία επεξεργασίας μοτίβου +@msg finish editing the pattern first ολοκληρώστε πρώτα την επεξεργασία του μοτίβου @msg nothing encoded δεν κωδικοποιήθηκε τίποτα @msg nothing selected δεν έχει επιλεγεί τίποτα @msg nothing selected is in a group τίποτα από τα επιλεγμένα δεν βρίσκεται σε ομάδα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 973405eb7..774745963 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4194,6 +4194,7 @@ axis Both Ambos @msg not an EMF or WMF file no es un archivo EMF ni WMF @msg not found no se encontró @msg not in pattern editing mode no está en el modo de edición de motivos +@msg finish editing the pattern first termina primero la edición del motivo @msg nothing encoded no se codificó nada @msg nothing selected no hay nada seleccionado @msg nothing selected is in a group nada de lo seleccionado está en un grupo diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 245460b83..c21d6986f 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -4412,6 +4412,7 @@ imageTrace Outline Ääriviiva @msg not an EMF or WMF file ei EMF- tai WMF-tiedosto @msg not found ei löytynyt @msg not in pattern editing mode ei kuvion muokkaustilassa +@msg finish editing the pattern first lopeta ensin kuvion muokkaus @msg nothing encoded mitään ei koodattu @msg nothing selected mitään ei ole valittu @msg nothing selected is in a group mikään valituista ei ole ryhmässä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 61b18bcd3..299fd2d90 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -4191,6 +4191,7 @@ axis Both Les deux @msg not an EMF or WMF file ce n’est pas un fichier EMF ou WMF @msg not found introuvable @msg not in pattern editing mode pas en mode d’édition de motif +@msg finish editing the pattern first terminez d’abord la modification du motif @msg nothing encoded rien n’a été encodé @msg nothing selected aucune sélection @msg nothing selected is in a group aucun élément sélectionné ne se trouve dans un groupe diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7f2b96613..4d52e4a72 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4867,6 +4867,7 @@ imageTrace Outline Körvonal @msg not an EMF or WMF file nem EMF- vagy WMF-fájl @msg not found nem található @msg not in pattern editing mode nem mintázatszerkesztő módban vagy +@msg finish editing the pattern first előbb fejezd be a mintázat szerkesztését @msg nothing encoded nincs kódolt adat @msg nothing selected nincs kijelölés @msg nothing selected is in a group a kijelölésből semmi sincs csoportban diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 82ee9a2ff..0e8f0ec6d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -4200,6 +4200,7 @@ imageTrace Outline Kontur @msg not an EMF or WMF file bukan berkas EMF atau WMF @msg not found tidak ditemukan @msg not in pattern editing mode tidak dalam mode pengeditan pola +@msg finish editing the pattern first selesaikan pengeditan pola terlebih dahulu @msg nothing encoded tidak ada yang dikodekan @msg nothing selected tidak ada yang dipilih @msg nothing selected is in a group tidak ada yang dipilih yang berada dalam grup diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 85719f2b3..e1e806ac1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4234,6 +4234,7 @@ axis Both Entrambi @msg not an EMF or WMF file non è un file EMF o WMF @msg not found non trovato @msg not in pattern editing mode non sei in modalità modifica motivo +@msg finish editing the pattern first termina prima la modifica del motivo @msg nothing encoded nessun dato codificato @msg nothing selected nessuna selezione @msg nothing selected is in a group nessun elemento selezionato si trova in un gruppo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a7584de3b..970e546a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -4147,6 +4147,7 @@ swatch Bright Violet ビビッドバイオレット @msg already editing a pattern すでにパターンを編集中です @msg no pattern `{name}` パターン `{name}` が見つかりません @msg not in pattern editing mode パターン編集モードではありません +@msg finish editing the pattern first 先にパターンの編集を終了してください @msg pattern missing パターンが見つかりません @msg select a repeat リピートを選択してください @msg select an Intertwine group 織り交ぜグループを選択してください diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4f9ac3e92..edc86a514 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3970,6 +3970,7 @@ font-technology Variable ცვლადი @msg not an EMF or WMF file ეს არ არის EMF ან WMF ფაილი @msg not found ვერ მოიძებნა @msg not in pattern editing mode პატერნის რედაქტირების რეჟიმი არ არის ჩართული +@msg finish editing the pattern first ჯერ დაასრულეთ პატერნის რედაქტირება @msg nothing encoded არაფერია კოდირებული @msg nothing selected არაფერია მონიშნული @msg nothing selected is in a group მონიშნულთაგან არაფერია ჯგუფში diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30b9325f6..d1f281de2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4079,6 +4079,7 @@ theme Appearance 외관 @msg already editing a pattern 이미 패턴을 편집 중입니다 @msg no pattern `{name}` `{name}` 패턴이 없습니다 @msg not in pattern editing mode 패턴 편집 모드가 아닙니다 +@msg finish editing the pattern first 먼저 패턴 편집을 마치세요 @msg pattern missing 패턴이 없습니다 @msg select a repeat 반복을 선택하세요 @msg select an Intertwine group 교차 그룹을 선택하세요 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 79854d177..f34d2eeeb 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -4212,6 +4212,7 @@ imageTrace Outline Contour @msg not an EMF or WMF file geen EMF- of WMF-bestand @msg not found niet gevonden @msg not in pattern editing mode niet in patroonbewerkingsmodus +@msg finish editing the pattern first rond eerst het bewerken van het patroon af @msg nothing encoded niets gecodeerd @msg nothing selected niets geselecteerd @msg nothing selected is in a group niets van de selectie zit in een groep diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f585fe72f..e03ee27f9 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -4326,6 +4326,7 @@ imageTrace Outline Kontur @msg not an EMF or WMF file to nie jest plik EMF ani WMF @msg not found nie znaleziono @msg not in pattern editing mode nie jest w trybie edycji wzorka +@msg finish editing the pattern first najpierw zakończ edycję wzorka @msg nothing encoded nic nie zakodowano @msg nothing selected nic nie zaznaczono @msg nothing selected is in a group nic z zaznaczenia nie należy do grupy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c79cdd79c..a52cba346 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3770,6 +3770,7 @@ axis Both Ambos @msg not an EMF or WMF file não é um arquivo EMF ou WMF @msg not found não encontrado @msg not in pattern editing mode não está no modo de edição de padrão +@msg finish editing the pattern first termine de editar o padrão primeiro @msg nothing encoded nada codificado @msg nothing selected nada selecionado @msg nothing selected is in a group nada do que está selecionado está em um grupo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index fe5ac8b39..f9dc8aed6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4230,6 +4230,7 @@ axis Both Оба @msg not an EMF or WMF file не является файлом EMF или WMF @msg not found не найдено @msg not in pattern editing mode не в режиме редактирования узора +@msg finish editing the pattern first сначала завершите редактирование узора @msg nothing encoded ничего не закодировано @msg nothing selected ничего не выделено @msg nothing selected is in a group ничего из выделенного не находится в группе diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 79b093c1f..10e6d3e18 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4108,6 +4108,7 @@ axis Both Båda @msg not an EMF or WMF file inte en EMF- eller WMF-fil @msg not found hittades inte @msg not in pattern editing mode inte i läget för mönsterredigering +@msg finish editing the pattern first slutför redigeringen av mönstret först @msg nothing encoded inget kodat @msg nothing selected inget markerat @msg nothing selected is in a group inget av det markerade ligger i en grupp diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f499fcec8..5a3857f48 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3965,6 +3965,7 @@ axis Both Her İkisi @msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor @msg not found bulunamadı @msg not in pattern editing mode desen düzenleme modunda değil +@msg finish editing the pattern first önce deseni düzenlemeyi bitir @msg nothing encoded kodlanmış hiçbir şey yok @msg nothing selected hiçbir şey seçilmedi @msg nothing selected is in a group seçilenlerin hiçbiri bir grupta değil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e3546462d..8bf10f6af 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4218,6 +4218,7 @@ axis Both Обидві @msg not an EMF or WMF file не є файлом EMF або WMF @msg not found не знайдено @msg not in pattern editing mode не в режимі редагування візерунка +@msg finish editing the pattern first спочатку завершіть редагування візерунка @msg nothing encoded нічого не закодовано @msg nothing selected нічого не виділено @msg nothing selected is in a group нічого з виділеного не перебуває в групі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 44a357d09..2ae608c7e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4518,6 +4518,7 @@ theme Appearance Giao diện @msg not an EMF or WMF file không phải tệp EMF hoặc WMF @msg not found không tìm thấy @msg not in pattern editing mode không ở chế độ chỉnh sửa họa tiết +@msg finish editing the pattern first hãy hoàn tất chỉnh sửa họa tiết trước @msg nothing encoded không có gì được mã hóa @msg nothing selected chưa chọn gì @msg nothing selected is in a group không mục nào được chọn nằm trong nhóm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b6d3ce81f..b7955137c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3880,6 +3880,7 @@ imageTrace Outline 轮廓 @msg already editing a pattern 已在编辑图案 @msg no pattern `{name}` 没有图案 `{name}` @msg not in pattern editing mode 不在图案编辑模式中 +@msg finish editing the pattern first 请先完成图案编辑 @msg pattern missing 缺少图案 @msg select a repeat 请选择一个重复对象 @msg select an Intertwine group 请选择一个交织组 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6337cc160..5012b8396 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1834,6 +1834,7 @@ file-filter Templates 範本 @msg already editing a pattern 已在編輯圖樣 @msg no pattern `{name}` 沒有圖樣 `{name}` @msg not in pattern editing mode 不在圖樣編輯模式中 +@msg finish editing the pattern first 請先完成圖樣編輯 @msg pattern missing 找不到圖樣 @msg select a repeat 請選取重複物件 @msg select an Intertwine group 請選取交織群組 diff --git a/crates/ui-egui/src/panels/tests_maskview.rs b/crates/ui-egui/src/panels/tests_maskview.rs index f9adcd8f7..cf71e0cba 100644 --- a/crates/ui-egui/src/panels/tests_maskview.rs +++ b/crates/ui-egui/src/panels/tests_maskview.rs @@ -23,6 +23,39 @@ fn click_mask(ctx: &egui::Context, app: &mut VectorcraftApp, modifiers: egui::Mo frame(ctx, app, vec![], egui::Modifiers::NONE); } +/// In pattern editing mode a click or an Alt-click on the mask thumbnail of a masked object in the +/// tile does not edit the mask, and the status bar says to finish editing the pattern first. Make +/// Mask is off for one object, which it would take into mask editing. After Done a click edits the +/// mask. +#[test] +fn the_mask_thumbnail_does_not_edit_the_mask_in_pattern_editing_mode() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let run = |app: &mut VectorcraftApp, id: &str, p: serde_json::Value| app.session.execute(id, &p).unwrap(); + run(&mut app, "file.new", json!({"width": 100, "height": 100})); + let a = run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + let b = run(&mut app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20}))["id"].clone(); + run(&mut app, "select.set", json!({"ids": [a, b]})); + run(&mut app, "transparency.makeOpacityMask", json!({})); + run(&mut app, "select.set", json!({"ids": [a]})); + run(&mut app, "object.pattern.make", json!({"name": "P"})); + let tile = app.session.active().unwrap().isolation; + let ctx = egui::Context::default(); + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::ALT] { + app.ui.status.clear(); + click_mask(&ctx, &mut app, modifiers); + let st = app.session.active().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.shown_mask().is_none(), "{modifiers:?}"); + assert!(st.doc.pattern_edit.is_some() && st.isolation == tile, "{modifiers:?}"); + assert!(app.ui.status.ends_with("finish editing the pattern first"), "{modifiers:?}: {}", app.ui.status); + } + run(&mut app, "shape.rectangle", json!({"x": 60, "y": 0, "width": 30, "height": 30})); + assert!(!crate::menus::enabled(&app, "transparency.makeOpacityMask"), "Make Mask is off for one object"); + run(&mut app, "object.pattern.done", json!({})); + run(&mut app, "select.set", json!({"ids": [a]})); + click_mask(&ctx, &mut app, egui::Modifiers::NONE); + assert_eq!(app.session.active().unwrap().doc.mask_edit.map(|e| e.object.0), Some(a)); +} + #[test] fn alt_clicking_the_mask_thumbnail_views_only_the_mask() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/transparency.rs b/crates/ui-egui/src/panels/transparency.rs index 5c943d928..5747ef82d 100644 --- a/crates/ui-egui/src/panels/transparency.rs +++ b/crates/ui-egui/src/panels/transparency.rs @@ -143,7 +143,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.vertical(|ui| { let label = if mask.is_some() { tl!("Release") } else { tl!("Make Mask") }; - // Make Mask is off in isolation mode for one object, which it would take into mask editing. + // Make Mask is off in isolation mode and pattern editing mode for one object, which it would take into mask editing. let enabled = mask.is_some() || (has && editing.is_none() && crate::menus::enabled(app, "transparency.makeOpacityMask")); let r = ui.add_enabled_ui(enabled, |ui| widgets::flat_button(ui, label, 96.0)).inner; if r.on_disabled_hover_text(tl!("Select the art (and, on top of it, the mask object)")).clicked() { diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 485410601..4d13e07d5 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -726,6 +726,25 @@ fn a_command_on_cmd_escape_runs_without_the_tools_escape() { assert_eq!(click_at(&mut app, 350.0), objects + 1, "nothing bound: Cmd+Escape ends the path as Escape does"); } +/// Escape while an opacity mask is edited leaves mask editing, as Stop Editing Opacity Mask does: +/// the editing layer is removed, nothing is isolated, and the masked object is selected. +#[test] +fn escape_while_an_opacity_mask_is_edited_leaves_mask_editing() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let obj = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let m = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 100})).unwrap()["id"].clone(); + app.session.execute("select.set", &json!({"ids": [obj, m]})).unwrap(); + app.session.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + app.session.execute("transparency.editOpacityMask", &json!({})).unwrap(); + app.select_tool("selection"); + frame(&mut app, press(Key::Escape, Modifiers::NONE)); + let st = app.session.active().unwrap(); + assert!(st.doc.mask_edit.is_none() && st.isolation.is_none(), "Escape leaves mask editing"); + assert_eq!(st.doc.layers.len(), 1, "the editing layer is removed"); + assert_eq!(st.selection.objects, vec![vectorcraft_doc::NodeId(obj)]); +} + /// Enter opens the tool's dialog: the Move dialog for the selection tools, the tool's own for /// Rotate, Scale, Reflect and Shear. The Enter that opens it isn't also its OK. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index df5ae1fc5..827f25a7a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1241,7 +1241,9 @@ Transparency and opacity-mask commands take `ids` (or `id`), so they need no sel (0..100) in every command (`transparency.set`, `object.setProps`, `appearance.setItem`). `transparency.info` returns the Transparency panel's values, with `null` where the objects differ. Making a mask from one object gives it an empty mask and enters mask editing: art drawn then becomes the mask, and the mask commands act on the masked object -until `transparency.stopEditingOpacityMask`. Saves and exports never include the editing layer. +until `transparency.stopEditingOpacityMask`. While a mask is edited, `object.exitIsolation` (Esc, the isolation bar's +back arrow, Exit Isolation Mode) leaves mask editing the same way, in one undo step. Saves and exports never include +the editing layer. ```json {"name":"run_command","arguments":{"command":"transparency.set","params":{"ids":[12],"opacity":40,"blend":"Multiply"}}} @@ -2118,7 +2120,8 @@ couldn't be decoded and is missing from the picture", the note raster exports gi In pattern editing mode the tile edge (`pattern.options {showTileEdge}`) and the swatch bounds (`pattern.options {showSwatchBounds}`: the part of the tiling the swatch repeats, dashed) are drawn in the preference -`patternTileEdgeColor`. +`patternTileEdgeColor`. There `object.exitIsolation` (the Exit Isolation Mode item of the canvas context menu and of the +Layers panel menu) saves the tile art into the pattern and leaves the mode, as `object.pattern.done` does. `object.pattern.make {name?}`, `object.pattern.saveCopy {name?}` and `pattern.options {newName?}` trim the white space around the name they are given. The first two number a name that a pattern, a swatch or a color group has ("Dots 2"), @@ -2133,6 +2136,12 @@ old name keeps it. A blank name gives "New Pattern N" in `object.pattern.make` a {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"patternTileEdgeColor","value":"#ff4f4f"}}} ``` +Pattern editing mode and opacity mask editing exclude each other. In pattern editing mode +`transparency.editOpacityMask` and `transparency.viewOpacityMask` are off, and `transparency.makeOpacityMask` refuses +one target, an object or a layer, which it would take into mask editing; a call returns an error that ends "finish +editing the pattern first". While a mask is edited, `object.pattern.edit` and `object.pattern.make` (also with +`edit: false`) are off, and a call returns an error that ends "stop editing the opacity mask first". + Pathfinder Options: `object.pathfinder.options {precision?, removeRedundantPoints?, removeUnpainted?}` (or `prefs.get`/`prefs.set` with key `pathfinder`) reads and sets the options every later Shape Mode and Pathfinder operation uses: `precision` (0.001–10 pt, default 0.01; a string with a unit also reads) is how closely results follow diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4080df73d..27ffba570 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -606,6 +606,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401). - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. + - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields. - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. @@ -832,6 +833,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `` and PDF soft masks. The constant alpha soft mask that PDF and `.ai` export draw a CMYK page with transparency through is white, so applications that read every soft mask by its luminosity show the page too (#1359, part of #1286). Isolation mode and opacity mask editing exclude each other ([transparency and blending modes](https://web.archive.org/web/20230102045151/https://helpx.adobe.com/illustrator/using/transparency-blending-modes.html)): in isolation mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Enter Isolation Mode and Object › Perspective › Edit Text are off, the Contextual Task Bar leaves out Isolate, and Quick Actions leave out Isolate Group. + Pattern editing mode and opacity mask editing exclude each other, by a rule of VectorCraft itself: in pattern editing mode a click or Alt-click on the mask thumbnail does not edit the mask, and Make Mask is off for one object; while a mask is edited, Object › Pattern › Make and Edit Pattern are off. Disable, Enable, Unlink and Link Opacity Mask and Opacity Mask Options (`transparency.setOpacityMask`) record no undo step when every mask they act on already has the flags they set. - **Image Trace, thin closed outlines:** generated `fit::snap_area_tests` verify nonzero interiors after straightening, opposite closed-boundary winding and Noise controls; see [quality notes](image-trace-quality.md#thin-closed-outlines-with-snap-curves-to-lines). Scores and hours unchanged; open-piece fitting is unchanged. - **Image Trace, Document Library color groups:** with Document Swatches, the panel's Colors menu and `"colorGroup"` trace with one color group's colors instead of every swatch, as the public panel documentation describes Document Library; code-drawn tests in `crates/engine` and the panel.