Merge pull request #1039 from storytold/fix/1002-noneditable-layer

#1002: new art is refused when no layer is shown and unlocked
This commit is contained in:
BFlat 2026-10-11 04:06:12 +07:00 committed by GitHub
commit b2800fb529
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 55 additions and 9 deletions

View File

@ -123,7 +123,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). |
| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). |
| 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) |
| 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day |
| 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections |

View File

@ -603,7 +603,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO);
let c = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y));
let size = natural_size(d, &name);
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let id = s.edit("Place Symbol Instance", |d, sel| {
let id = d.alloc_id();
d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.clone(), xf: place_xf(c, size) }))?;
@ -843,7 +843,8 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result<Value> {
let alt = bool_or(p, "alt", false);
let size = natural_size(&st.doc, &name);
let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set));
let parent = st.insertion_parent();
// Only a new Symbol Set needs a layer that takes new art.
let parent = if alt || set.is_some() { st.insertion_parent() } else { st.target_parent()? };
if alt {
// Remove instances of this symbol under the brush (within the set, or anywhere).
let d = &st.doc;

View File

@ -79,7 +79,7 @@ pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance:
/// Insert a new object looking like `look` (drawing modes apply); select it.
pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option<String>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let mode = s.draw_mode;
let inside = s.draw_inside;
let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied();
@ -298,7 +298,7 @@ fn flare(s: &mut Session, p: &Value) -> Result<Value> {
};
parts.push(("Ring", circle(at, rr), radial(at, rr, stops), 1.0));
}
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let id = s.edit("Flare", |d, sel| {
let children = parts
.into_iter()
@ -352,7 +352,7 @@ fn arc(s: &mut Session, p: &Value) -> Result<Value> {
}
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let look = s.new_art_look(vectorcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
let id = s.edit(label, |d, sel| {
let children = paths

View File

@ -346,7 +346,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
let off = s.prefs.paste_offset;
let st = s.doc()?;
let same_doc = clip.source_doc == Some(st.uid);
let parent = st.insertion_parent();
let parent = st.target_parent()?;
// A copied artboard comes back with its art: right of the last artboard, or where it was
// (Paste in Place, in Front, in Back). Paste on All Artboards pastes only the art.
let board = clip

View File

@ -831,7 +831,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]];
}
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let id = s.edit("Graph", |d, sel| {
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![]);

View File

@ -702,7 +702,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result<Value> {
if rects.is_empty() {
return Err(bad(C, "nothing to mark"));
}
let parent = s.doc()?.insertion_parent();
let parent = s.doc()?.target_parent()?;
let ids = s.edit("Create Trim Marks", |d, sel| {
let mut ids = vec![];
for r in &rects {

View File

@ -346,6 +346,14 @@ impl DocState {
// A sublayer takes new art only while it and the layers around it are shown and unlocked.
self.active_layer.filter(|l| self.doc.node(*l).is_some_and(|n| n.is_layer()) && self.doc.is_editable(*l)).or_else(|| self.doc.default_layer())
}
/// [`Self::insertion_parent`] for new art, which a locked or hidden layer never takes: an error
/// when no layer is shown and unlocked, so the art is refused rather than hidden or locked away.
pub fn target_parent(&self) -> Result<Option<NodeId>> {
match self.insertion_parent() {
Some(p) if !self.doc.is_editable(p) => Err(EngineError::Other("the target layer is locked or hidden".into())),
parent => Ok(parent),
}
}
/// The highlighted Layers panel rows that still exist (ids are reused after undo, so a
/// remembered row must still be in the document).
pub fn highlighted_rows(&self) -> Vec<NodeId> {

View File

@ -78,6 +78,36 @@ fn art_in_a_sublayer_is_selected_as_objects_not_the_sublayer() {
assert!(s.doc().unwrap().doc.node(a).is_none());
}
/// #1002: with no layer shown and unlocked, new art is refused (the document unchanged) rather
/// than put on a hidden or locked layer; another layer that takes art still gets it.
#[test]
fn new_art_is_refused_when_no_layer_is_shown_and_unlocked() {
for prop in ["visible", "locked"] {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id;
let copied = rect(&mut s, 0.0);
run(&mut s, "select.set", json!({"ids": [copied.0]}));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "select.none", json!({}));
run(&mut s, "layer.setProps", json!({"ids": [layer.0], prop: prop == "locked"}));
let before = s.doc().unwrap().doc.clone();
for (cmd, p) in [
("shape.ellipse", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
("shape.polarGrid", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
("text.create", json!({"x": 10, "y": 40, "text": "A"})),
("edit.paste", json!({})),
] {
let e = s.execute(cmd, &p).unwrap_err();
assert_eq!(e.to_string(), "the target layer is locked or hidden", "{prop} {cmd}");
}
assert_eq!(s.doc().unwrap().doc.layers, before.layers, "{prop}: nothing was added");
let other = id(&run(&mut s, "layer.new", json!({})));
run(&mut s, "layer.setCurrent", json!({"id": layer.0}));
let n = rect(&mut s, 50.0);
assert_eq!(parent(&s, n), Some(other), "{prop}");
}
}
#[test]
fn clicking_rows_at_any_depth_highlights_them_and_sets_the_current_layer() {
let mut s = session();

View File

@ -3656,6 +3656,7 @@ theme Appearance Erscheinungsbild
@msg the image's pixels don't match its size die Pixel des Bilds passen nicht zu seiner Größe
@msg the layer has no objects to clip by die Ebene hat keine Objekte, an denen beschnitten werden könnte
@msg the layer or group is locked die Ebene oder Gruppe ist gesperrt
@msg the target layer is locked or hidden die Zielebene ist gesperrt oder ausgeblendet
@msg the layer to flatten into is hidden die Ebene, auf die reduziert werden soll, ist ausgeblendet
@msg the object already has an opacity mask das Objekt hat bereits eine Deckkraftmaske
@msg the object has no geometry das Objekt hat keine Geometrie

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3759,6 +3759,7 @@ axis Both Ambos
@msg the image's pixels don't match its size los píxeles de la imagen no coinciden con su tamaño
@msg the layer has no objects to clip by la capa no tiene objetos con los que recortar
@msg the layer or group is locked la capa o el grupo está bloqueado
@msg the target layer is locked or hidden la capa de destino está bloqueada u oculta
@msg the layer to flatten into is hidden la capa en la que acoplar está oculta
@msg the object already has an opacity mask el objeto ya tiene una máscara de opacidad
@msg the object has no geometry el objeto no tiene geometría

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3756,6 +3756,7 @@ axis Both Les deux
@msg the image's pixels don't match its size les pixels de l’image ne correspondent pas à sa taille
@msg the layer has no objects to clip by le calque n’a aucun objet servant à l’écrêtage
@msg the layer or group is locked le calque ou le groupe est verrouillé
@msg the target layer is locked or hidden le calque cible est verrouillé ou masqué
@msg the layer to flatten into is hidden le calque de destination de l’aplatissement est masqué
@msg the object already has an opacity mask l’objet a déjà un masque d’opacité
@msg the object has no geometry l’objet n’a pas de géométrie

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3759,6 +3759,7 @@ axis Both Entrambi
@msg the image's pixels don't match its size i pixel dell'immagine non corrispondono alle sue dimensioni
@msg the layer has no objects to clip by il livello non ha oggetti con cui ritagliare
@msg the layer or group is locked il livello o il gruppo è bloccato
@msg the target layer is locked or hidden il livello di destinazione è bloccato o nascosto
@msg the layer to flatten into is hidden il livello in cui appiattire è nascosto
@msg the object already has an opacity mask l'oggetto ha già una maschera di opacità
@msg the object has no geometry l'oggetto non ha geometria

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3497,6 +3497,7 @@ theme Appearance 外観
@msg no selection bounds 選択範囲に境界がありません
@msg the layer has no objects to clip by レイヤーにクリッピングマスクとして使えるオブジェクトがありません
@msg the layer or group is locked レイヤーまたはグループがロックされています
@msg the target layer is locked or hidden ターゲットレイヤーがロックされているか非表示です
@msg the layer to flatten into is hidden 統合先のレイヤーが非表示になっています
@msg there are no objects to release レイヤーに分配するオブジェクトがありません
@msg no library `{id}` ライブラリ `{id}` が見つかりません

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3755,6 +3755,7 @@ axis Both Оба
@msg the image's pixels don't match its size пиксели изображения не соответствуют его размеру
@msg the layer has no objects to clip by в слое нет объектов для обрезки
@msg the layer or group is locked слой или группа заблокированы
@msg the target layer is locked or hidden целевой слой заблокирован или скрыт
@msg the layer to flatten into is hidden слой для объединения скрыт
@msg the object already has an opacity mask у объекта уже есть непрозрачная маска
@msg the object has no geometry у объекта нет геометрии

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3742,6 +3742,7 @@ axis Both Обидві
@msg the image's pixels don't match its size пікселі зображення не відповідають його розміру
@msg the layer has no objects to clip by у шарі немає об’єктів для обрізки
@msg the layer or group is locked шар чи група заблоковані
@msg the target layer is locked or hidden цільовий шар заблокований або прихований
@msg the layer to flatten into is hidden шар для об’єднання прихований
@msg the object already has an opacity mask об’єкт вже має маску непрозорості.
@msg the object has no geometry об’єкт не має геометрії

Can't render this file because it has a wrong number of fields in line 13.