From 8817e0701364d4bf409dccfe9596df6b109dbe9b Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:19:56 +0000 Subject: [PATCH 1/2] fix(paths): path edits refuse a path on a locked layer or in a locked group (#735) --- crates/engine/src/cmd/anchors.rs | 5 +++++ crates/engine/src/cmd/object.rs | 34 ++++++++++++++++++++++++++------ crates/engine/src/cmd/paths.rs | 1 + 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/anchors.rs b/crates/engine/src/cmd/anchors.rs index c5015c7c4..ad18b8225 100644 --- a/crates/engine/src/cmd/anchors.rs +++ b/crates/engine/src/cmd/anchors.rs @@ -5,6 +5,7 @@ use designcraft_doc::{Document, ItemId, Shape}; use designcraft_geom::{Affine, Anchor, AnchorKind, Point}; use serde_json::{Value, json}; +use super::object::path_editable; use super::{CommandSpec, bad, cmd, has_doc, point_param}; use crate::{Result, Session}; @@ -69,6 +70,7 @@ fn erase_or_smooth(s: &mut Session, p: &Value, smooth: bool) -> Result { .map(|a| a.iter().filter_map(|v| Some(Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect()) .unwrap_or_default(); s.edit(|d, _| { + path_editable(d, id, cmd)?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let scale = inv.determinant().abs().sqrt().max(1e-9); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -135,6 +137,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { let at = point_param(p, "at").ok_or_else(|| bad("path.addAnchor", "missing at"))?; let tol = p.get("tolerance").and_then(Value::as_f64).unwrap_or(4.0); s.edit(|d, _| { + path_editable(d, id, "path.addAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let q = inv * at; @@ -154,6 +157,7 @@ fn add_anchor(s: &mut Session, p: &Value) -> Result { fn delete_anchor(s: &mut Session, p: &Value) -> Result { let (id, si, ai) = anchor_param(p, "path.deleteAnchor")?; s.edit(|d, sel| { + path_editable(d, id, "path.deleteAnchor")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.deleteAnchor", "no such subpath"))?; if ai >= sp.anchors.len() { @@ -181,6 +185,7 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result { let to = point_param(p, "to"); let corner = p.get("corner").and_then(Value::as_bool); s.edit(|d, _| { + path_editable(d, id, "path.convertAnchor")?; let inv = to_inner(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let sp = it.path.subpaths.get_mut(si).ok_or_else(|| bad("path.convertAnchor", "no such subpath"))?; diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index aa5a2cc01..a03e055ae 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1489,6 +1489,20 @@ fn unlocked(d: &Document, it: &Item) -> bool { !it.locked && d.layer(it.layer).is_none_or(|l| !l.locked) } +/// Refuses a path edit on `id` when it, a group it sits in, or its layer is locked. +pub(super) fn path_editable(d: &Document, id: ItemId, cmd: &str) -> Result<()> { + let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; + let mut items = d.spread(loc.spread).map(|sp| &sp.items[..]).unwrap_or_default(); + for &i in &loc.path { + let Some(it) = items.get(i) else { break }; + if !unlocked(d, it) { + return Err(bad(cmd, "the path is locked")); + } + items = it.children(); + } + Ok(()) +} + fn has_editable_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; let st = s.active().ok_or_else(|| super::NOTHING_SELECTED.to_string())?; @@ -2075,6 +2089,7 @@ fn path_append(s: &mut Session, p: &Value) -> Result { let a = p.get("anchor").and_then(anchor_from).ok_or_else(|| bad("path.appendAnchor", "missing anchor"))?; let subpath = p.get("subpath").and_then(Value::as_u64); s.edit(|d, _| { + path_editable(d, id, "path.appendAnchor")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; @@ -2110,12 +2125,10 @@ fn path_set_anchor(s: &mut Session, p: &Value) -> Result { let subpath = p.get("subpath").and_then(Value::as_u64); let which = p.get("anchor").cloned().unwrap_or(Value::Null); s.edit(|d, _| { + path_editable(d, id, ID)?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let inv = (d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default()).inverse(); let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; - if it.locked { - return Err(bad(ID, "the path is locked")); - } let sp = match subpath { Some(i) => usize::try_from(i).ok().and_then(|i| it.path.subpaths.get_mut(i)), None => it.path.subpaths.last_mut(), @@ -2157,12 +2170,19 @@ fn path_reverse(s: &mut Session, p: &Value) -> Result { { return Err(bad("path.reverse", "no such subpath")); } - let ids = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let ids: Vec = if only.is_some() { ids.into_iter().take(1).collect() } else { ids }; + let doc = &s.doc()?.doc; + let editable: Vec = ids.iter().copied().filter(|id| path_editable(doc, *id, "path.reverse").is_ok()).collect(); + if editable.is_empty() + && let Some(id) = ids.first() + { + path_editable(doc, *id, "path.reverse")?; + } s.edit(|d, _| { let mut reversed = 0; - for id in &ids { + for id in &editable { let Some(it) = d.item_mut(*id) else { continue }; - if it.locked || it.is_group() { + if it.is_group() { continue; } for (i, sp) in it.path.subpaths.iter_mut().enumerate() { @@ -2184,6 +2204,7 @@ fn path_close(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; let only = p.get("subpath").and_then(Value::as_u64).map(|i| usize::try_from(i).unwrap_or(usize::MAX)); s.edit(|d, _| { + path_editable(d, id, "path.close")?; let it = d.item_mut(id).ok_or(designcraft_doc::DocError::NoItem(id))?; if only.is_some_and(|i| i >= it.path.subpaths.len()) { return Err(bad("path.close", "no such subpath")); @@ -2206,6 +2227,7 @@ fn path_move_anchors(s: &mut Session, p: &Value) -> Result { let d0 = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let handle = str_param(p, "handle").map(str::to_string); s.edit(|d, _| { + path_editable(d, id, "path.moveAnchors")?; let loc = d.find(id).ok_or(designcraft_doc::DocError::NoItem(id))?; let m = d.parent_xf(&loc) * d.item_at(&loc).map(|i| i.xf).unwrap_or_default(); // Spread-space delta → inner-space delta (linear part only). diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..14609d18f 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -312,6 +312,7 @@ fn split_path(s: &mut Session, p: &Value) -> Result { let id = super::id_param(p, "id").ok_or_else(|| bad("path.split", "missing id"))?; let at = point_param(p, "at").ok_or_else(|| bad("path.split", "missing at"))?; s.edit(|d, sel| { + super::object::path_editable(d, id, "path.split")?; let (sr, xf) = spread_xf(d, id).ok_or(designcraft_doc::DocError::NoItem(id))?; let it = d.item(id).cloned().ok_or(designcraft_doc::DocError::NoItem(id))?; if !matches!(it.content, Content::Unassigned) { From f60699278264d63ce3b9df918bb297373dd24cc8 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 14:19:57 +0000 Subject: [PATCH 2/2] fix(properties): links to other panels open them when panel docking is on (#736) --- ROADMAP.md | 1 + crates/ui-egui/src/panel_docking_tests.rs | 27 ++++++++++++++++++++++ crates/ui-egui/src/panels/properties.rs | 28 +++++++++++++++-------- crates/ui-egui/src/widgets.rs | 1 + 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..9f768db16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Path edits (`path.setAnchor`, `path.moveAnchors`, `path.appendAnchor`, `path.reverse`, `path.close`, anchor add/delete/convert, Erase, Smooth and Scissors) refuse a path that is locked, sits in a locked group or on a locked layer (#735). Properties links to the Stroke, Character, Paragraph, Effects, Text Wrap and Info panels open them in a customized panel layout too (#736). No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index de08e3cf5..65879b1a0 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -301,6 +301,33 @@ fn populated_canvas_keeps_a_real_stroke_edit_after_floating_and_redocking() { } } +#[test] +fn properties_stroke_link_opens_the_stroke_panel_in_a_custom_workspace() { + let mut app = app(); + app.run("file.new", json!({"width":480,"height":360})).unwrap(); + app.run("frame.create", json!({"rect":[80,70,330,230],"content":"none"})).unwrap(); + app.run("window.panel.float", json!({"panel":"layers"})).unwrap(); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"stroke".into())); + let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).build_ui_state( + |ui, app: &mut DesignApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + let at = h.get_by_role_and_label(egui::accesskit::Role::Link, "Stroke").rect().center(); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + h.run_steps(2); + assert!(h.state().ui.docking.as_ref().unwrap().contains(&"stroke".into())); + assert_eq!(h.state().ui.docking_opened.as_deref(), Some("stroke")); +} + #[test] fn changing_workspace_during_a_drag_cancels_the_stale_gesture() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..f5acd8665 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -16,6 +16,14 @@ use crate::widgets::{ }; use crate::{DesignApp, icons}; +/// A link to another panel. `window.panel` shows it in the docked layout or as a flyout; it +/// toggles the flyout, so a flyout that is already open is left as it is. +fn show_panel(app: &mut DesignApp, panel: &str) { + if app.ui.open_panel.as_deref() != Some(panel) { + let _ = app.run("window.panel", json!({"panel": panel})); + } +} + fn units(app: &DesignApp) -> Unit { app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas) } @@ -467,7 +475,7 @@ fn transform_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } transform_angles(app, ui, rp); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("info".into()); + show_panel(app, "info"); } } @@ -654,7 +662,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let _ = app.run("object.stroke", json!({"swatch": n})); }); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Stroke"), 56.0)).clicked() { - app.ui.open_panel = Some("stroke".into()); + show_panel(app, "stroke"); } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -695,7 +703,7 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { let b = block(ui, FIELD_H); icons::paint(ui.painter(), sub(b, 3.0, 1.0, 19.0, 19.0), "opacity", t.icon); if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, "Opacity"), 56.0)).clicked() { - app.ui.open_panel = Some("effects".into()); + show_panel(app, "effects"); } if let Some(v) = place(ui, sub(b, 96.0, 0.0, 51.0, FIELD_H), |ui| { NumField::number("opacity", Some((i.opacity * 100.0).round()), "%", 0).width(51.0).range(0.0, 100.0).show(ui) @@ -707,13 +715,13 @@ fn appearance_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { ui.painter().rect(more, 0.0, if mresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.field_border), StrokeKind::Inside); icons::paint(ui.painter(), more.shrink(4.0), "chevron-right", t.icon); if mresp.clicked() { - app.ui.open_panel = Some("effects".into()); + show_panel(app, "effects"); } ui.add_space(6.0); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 10.0; if icon_toggle(ui, "frame-dashed", false, crate::i18n::tr(&app.ui.language, "Frame Fitting")).clicked() { - app.ui.open_panel = Some("textWrap".into()); + show_panel(app, "textWrap"); } if icon_toggle(ui, "fx", i.shadow, crate::i18n::tr(&app.ui.language, "Effects: Drop Shadow")).clicked() { let _ = app.run("object.dropShadow", json!({"on": !i.shadow})); @@ -801,7 +809,7 @@ fn wrap_section(app: &mut DesignApp, ui: &mut Ui, i: &SelInfo) { } }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("textWrap".into()); + show_panel(app, "textWrap"); } } @@ -1018,7 +1026,7 @@ fn text_appearance_section(app: &mut DesignApp, ui: &mut Ui) { ); } else { if place(ui, sub(b, 34.0, 0.0, 56.0, FIELD_H), |ui| link_label(ui, crate::i18n::tr(&app.ui.language, label), 56.0)).clicked() { - app.ui.open_panel = Some("stroke".into()); + show_panel(app, "stroke"); } const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 10.0]; if let Some(v) = place(ui, sub(b, 96.0, 0.0, 69.0, FIELD_H), |ui| { @@ -1125,7 +1133,7 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| language_menu(app, ui, &lang)); }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("character".into()); + show_panel(app, "character"); } } @@ -1157,7 +1165,7 @@ fn paragraph_section(app: &mut DesignApp, ui: &mut Ui) { } }); if more_options(ui, &app.ui.language).clicked() { - app.ui.open_panel = Some("paragraph".into()); + show_panel(app, "paragraph"); } } @@ -1175,7 +1183,7 @@ fn bullets_section(app: &mut DesignApp, ui: &mut Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); if outline_button(ui, crate::i18n::tr(&app.ui.language, "Options"), 66.0).clicked() { - app.ui.open_panel = Some("paragraph".into()); + show_panel(app, "paragraph"); } }); }); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bcb27ddf9..af5a7b46c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -364,6 +364,7 @@ pub fn link_label(ui: &mut Ui, text: &str, width: f32) -> Response { ui.painter().galley(pos, g, t.text); let y = r.center().y + 6.5; ui.painter().line_segment([pos2(r.min.x, y), pos2(r.min.x + w, y)], Stroke::new(1.0, if resp.hovered() { t.text_strong } else { t.text_dim })); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Link, true, text)); resp.on_hover_cursor(egui::CursorIcon::PointingHand) }