mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Canvas: a right-click on locked objects offers Unlock with each object's name
A right-click on the canvas now records the locked objects under the
pointer with vectorcraft_doc::hit::locked_objects_at, topmost first and
at most 20. A locked group is listed instead of the objects it holds.
Hidden objects and objects on hidden, locked or template layers are not
listed, and in isolation mode only the isolated group's contents are
listed. While the menu is open, it has an Unlock submenu after Undo
and Redo that lists each of them by the name the Layers panel shows.
Choosing a name runs layer.setProps {ids: [id], locked: false}, as the
Layers panel's lock column does, in one undo step. ui.contextMenu.list
shows the same items.
Docs: docs/control-protocol.md, docs/target-app-parity.md.
This commit is contained in:
@@ -169,6 +169,80 @@ fn objects_at_repeated(doc: &Document, p: Point, opt: HitOptions, scope: Option<
|
||||
tops
|
||||
}
|
||||
|
||||
/// The most objects [`locked_objects_at`] lists.
|
||||
pub const MAX_LOCKED_AT: usize = 20;
|
||||
|
||||
/// The locked objects under `p`, topmost first, at most [`MAX_LOCKED_AT`]: each object whose own
|
||||
/// lock is on and that a click at `p` would hit if it were not locked. A locked group is listed,
|
||||
/// not what it holds. Hidden objects and everything on hidden, locked and template layers are left
|
||||
/// out. While a pattern is edited only its tile layer is searched, and in isolation mode
|
||||
/// (`opt.scope`) only what is inside the isolated group or object.
|
||||
pub fn locked_objects_at(doc: &Document, p: Point, opt: HitOptions) -> Vec<NodeId> {
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut out = Vec::new();
|
||||
if let Some(scope) = opt.scope
|
||||
&& let Some(isolated) = doc.node(scope)
|
||||
{
|
||||
if isolated.is_container() {
|
||||
locked_children(isolated, p, opt, &symbols, &mut out);
|
||||
} else if isolated.visible && isolated.locked && locked_hit(isolated, p, opt, &symbols) {
|
||||
out.push(isolated.id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || layer.locked || layer.is_template() || doc.pattern_edit.as_ref().is_some_and(|e| e.layer != layer.id) {
|
||||
continue;
|
||||
}
|
||||
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
|
||||
locked_children(layer, p, opt, &symbols, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`locked_objects_at`] through the children of `parent`, front first: a locked child is tested
|
||||
/// whole, and an unlocked layer, group, blend or envelope in Edit Contents is searched inside.
|
||||
fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, out: &mut Vec<NodeId>) {
|
||||
let Some(children) = parent.children() else { return };
|
||||
if parent.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
|
||||
return;
|
||||
}
|
||||
for i in hit_order(parent, children, p) {
|
||||
if out.len() >= MAX_LOCKED_AT {
|
||||
return;
|
||||
}
|
||||
let Some(c) = children.get(i) else { continue };
|
||||
if !may_hit(parent, i, c, p, opt, symbols, false) || c.is_layer() && (c.locked || c.is_template()) {
|
||||
continue;
|
||||
}
|
||||
if c.locked {
|
||||
if locked_hit(c, p, opt, symbols) {
|
||||
out.push(c.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match &c.kind {
|
||||
NodeKind::Layer { preview: false, .. } => locked_children(c, p, HitOptions { outline: true, ..opt }, symbols, out),
|
||||
NodeKind::Layer { .. }
|
||||
| NodeKind::Group { .. }
|
||||
| NodeKind::Intertwine { .. }
|
||||
| NodeKind::Blend { .. }
|
||||
| NodeKind::Envelope { editing: true, .. } => locked_children(c, p, opt, symbols, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Would a click at `p` hit the locked object `n` if it were not locked?
|
||||
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
|
||||
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
|
||||
match &n.kind {
|
||||
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
|
||||
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
|
||||
_ => hit_leaf(n, p, opt, symbols).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
|
||||
/// measured by their art ([`Node::geometric_bounds_with`]).
|
||||
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
|
||||
@@ -901,6 +975,69 @@ world",
|
||||
assert!(hit_test(&d, Point::new(10.0, 10.0), HitOptions::default()).is_none());
|
||||
}
|
||||
|
||||
/// Insert `n` locked at the top of `parent`.
|
||||
fn insert_locked(d: &mut Document, parent: NodeId, mut n: Node) -> NodeId {
|
||||
let id = n.id;
|
||||
n.locked = true;
|
||||
d.insert(Some(parent), usize::MAX, n).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// `locked_objects_at`: the locked objects under the point, topmost first. A locked group is
|
||||
/// listed rather than its contents, and a locked object in an unlocked group is listed itself.
|
||||
/// Hidden objects, locked layers and the art outside an isolated group are left out.
|
||||
#[test]
|
||||
fn locked_objects_at_lists_the_locked_stack() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let rect =
|
||||
|d: &mut Document, x: f64| Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 0.0, x + 50.0, 50.0)), Appearance::default_art());
|
||||
let (a, b) = (rect(&mut d, 0.0), rect(&mut d, 10.0));
|
||||
let (a, b) = (insert_locked(&mut d, l, a), insert_locked(&mut d, l, b));
|
||||
let (mut inner, locked_group) = (rect(&mut d, 20.0), d.alloc_id());
|
||||
inner.locked = true;
|
||||
let inner_id = inner.id;
|
||||
let mut g = Node::group(locked_group, vec![Arc::new(inner)]);
|
||||
g.locked = true;
|
||||
d.insert(Some(l), usize::MAX, g).unwrap();
|
||||
let open_group = d.alloc_id();
|
||||
let mut child = rect(&mut d, 25.0);
|
||||
child.locked = true;
|
||||
let child_id = child.id;
|
||||
let free = rect(&mut d, 25.0);
|
||||
d.insert(Some(l), usize::MAX, Node::group(open_group, vec![Arc::new(child), Arc::new(free)])).unwrap();
|
||||
let at = |d: &Document, x: f64, scope| locked_objects_at(d, Point::new(x, 25.0), HitOptions { scope, ..HitOptions::default() });
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, b, a]);
|
||||
assert!(!at(&d, 30.0, None).contains(&inner_id), "the locked group stands for what it holds");
|
||||
assert_eq!(at(&d, 5.0, None), [a]);
|
||||
assert!(at(&d, 150.0, None).is_empty());
|
||||
// Isolated, only the group's own objects are looked at.
|
||||
assert_eq!(at(&d, 30.0, Some(open_group)), [child_id]);
|
||||
// A hidden object is left out, and nothing on a locked or hidden layer is listed.
|
||||
d.node_mut(b).unwrap().visible = false;
|
||||
assert_eq!(at(&d, 30.0, None), [child_id, locked_group, a]);
|
||||
d.node_mut(l).unwrap().locked = true;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
d.node_mut(l).unwrap().locked = false;
|
||||
d.node_mut(l).unwrap().visible = false;
|
||||
assert!(at(&d, 30.0, None).is_empty());
|
||||
}
|
||||
|
||||
/// `locked_objects_at` lists at most `MAX_LOCKED_AT` objects, the topmost ones.
|
||||
#[test]
|
||||
fn locked_objects_at_stops_at_its_limit() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let ids: Vec<NodeId> = (0..MAX_LOCKED_AT + 5)
|
||||
.map(|_| {
|
||||
let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Appearance::default_art());
|
||||
insert_locked(&mut d, l, n)
|
||||
})
|
||||
.collect();
|
||||
let found = locked_objects_at(&d, Point::new(25.0, 25.0), HitOptions::default());
|
||||
assert_eq!(found, ids.iter().rev().take(MAX_LOCKED_AT).copied().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_sampling_reads_locked_art_and_layers_but_excludes_hidden_art() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -1473,9 +1473,20 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) {
|
||||
}
|
||||
|
||||
/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick it
|
||||
/// (Selection & Anchor Display › Tolerance and Object Selection by Path Only, Type › Type Object
|
||||
/// Selection by Path Only).
|
||||
/// ([`hit_with`]).
|
||||
fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::hit::Hit> {
|
||||
hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test)
|
||||
}
|
||||
|
||||
/// `test` on the active document at document point `p`, with the options the selection tools hit
|
||||
/// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only,
|
||||
/// Type › Type Object Selection by Path Only, isolation mode).
|
||||
fn hit_with<T>(
|
||||
app: &VectorcraftApp,
|
||||
p: Point,
|
||||
zoom: f64,
|
||||
test: impl FnOnce(&vectorcraft_doc::Document, Point, vectorcraft_doc::hit::HitOptions) -> Option<T>,
|
||||
) -> Option<T> {
|
||||
let prefs = &app.session.prefs;
|
||||
let opt = vectorcraft_doc::hit::HitOptions {
|
||||
tol: prefs.selection_tolerance / zoom,
|
||||
@@ -1484,23 +1495,32 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
|
||||
type_path_only: prefs.type_selection_by_path_only,
|
||||
scope: app.session.active().and_then(|st| st.isolation),
|
||||
};
|
||||
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
|
||||
test(&app.session.active()?.doc, p, opt)
|
||||
}
|
||||
|
||||
/// Right-click: the object under the pointer is selected first unless it already is, then the
|
||||
/// context menu lists what applies to the selection ([`crate::menus::context_items`]).
|
||||
/// context menu lists what applies to the selection ([`crate::menus::context_items`]). The locked
|
||||
/// objects under the pointer ([`vectorcraft_doc::hit::locked_objects_at`]) are kept in
|
||||
/// `ui.context_locked` while the menu is open, for its Unlock submenu.
|
||||
fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(p) = resp.interact_pointer_pos()
|
||||
&& let Some(st) = app.session.active()
|
||||
&& let Some(top) = hit_at(app, xf.to_doc(p), xf.zoom).map(|h| h.top_object(st.isolation))
|
||||
&& !st.selection.contains(top)
|
||||
{
|
||||
// A locked or hidden object can't be selected; the menu is then for the selection as is.
|
||||
let _ = app.run("select.set", json!({ "ids": [top.0] }));
|
||||
let p = xf.to_doc(p);
|
||||
let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt)));
|
||||
app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked);
|
||||
if let Some(st) = app.session.active()
|
||||
&& let Some(top) = hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation))
|
||||
&& !st.selection.contains(top)
|
||||
{
|
||||
// A locked or hidden object can't be selected; the menu is then for the selection as is.
|
||||
let _ = app.run("select.set", json!({ "ids": [top.0] }));
|
||||
}
|
||||
}
|
||||
let mut clicked = None;
|
||||
resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked));
|
||||
if resp.context_menu(|ui| crate::menus::context_menu_body(app, ui, &mut clicked)).is_none() {
|
||||
app.ui.context_locked = None;
|
||||
}
|
||||
if let Some((id, p)) = clicked {
|
||||
crate::menus::invoke(app, &id, p);
|
||||
}
|
||||
|
||||
@@ -1883,6 +1883,18 @@ fn effect_item_label(id: &str, p: &Value) -> Option<String> {
|
||||
(!shown.is_empty()).then(|| shown.to_string())
|
||||
}
|
||||
|
||||
/// The label of an item of the canvas context menu's Unlock submenu (`layer.setProps` with one id
|
||||
/// and `locked: false`): the object's name as the Layers panel shows it in `lang`
|
||||
/// ([`crate::panels::layers::shown_name`]). `None` for any other item, or when the object is gone.
|
||||
fn unlock_item_label(app: &VectorcraftApp, id: &str, p: &Value, lang: crate::i18n::Lang) -> Option<String> {
|
||||
if id != "layer.setProps" || p.get("locked") != Some(&Value::Bool(false)) {
|
||||
return None;
|
||||
}
|
||||
let [one] = p.get("ids")?.as_array()?.as_slice() else { return None };
|
||||
let n = app.session.active()?.doc.node(vectorcraft_doc::NodeId(one.as_u64()?))?;
|
||||
Some(crate::panels::layers::shown_name(app, n, lang))
|
||||
}
|
||||
|
||||
/// Menu slots that are left out while they have nothing to show (unused saved views, recent files,
|
||||
/// User Defined swatch and graphic style libraries).
|
||||
fn hidden_when_disabled(id: &str) -> bool {
|
||||
@@ -2015,7 +2027,10 @@ pub fn entry<'a>(app: &VectorcraftApp, it: &'a Item) -> Option<Entry<'a>> {
|
||||
Entry::Item(Row {
|
||||
command: Some((id, p)),
|
||||
source: label,
|
||||
label: effect_item_label(id, p).unwrap_or_else(|| display_label(app, id, label)).into(),
|
||||
label: effect_item_label(id, p)
|
||||
.or_else(|| unlock_item_label(app, id, p, crate::i18n::current()))
|
||||
.unwrap_or_else(|| display_label(app, id, label))
|
||||
.into(),
|
||||
shortcut: item_shortcut(id, p),
|
||||
enabled,
|
||||
checked,
|
||||
@@ -2923,6 +2938,20 @@ pub fn context_items(app: &VectorcraftApp) -> Vec<Item> {
|
||||
if st.isolation.is_some() {
|
||||
v.extend([c("Exit Isolation Mode", "object.exitIsolation"), Sep]);
|
||||
}
|
||||
// The locked objects under the pointer at the right-click, each unlocked as the Layers panel's
|
||||
// lock column does. `unlock_item_label` shows each object's name.
|
||||
let unlock: Vec<Item> = app
|
||||
.ui
|
||||
.context_locked
|
||||
.iter()
|
||||
.filter(|(uid, _)| *uid == st.uid)
|
||||
.flat_map(|(_, ids)| ids)
|
||||
.filter(|id| st.doc.node(**id).is_some_and(|n| n.locked))
|
||||
.map(|id| cp("Unlock", "layer.setProps", json!({ "ids": [id.0], "locked": false })))
|
||||
.collect();
|
||||
if !unlock.is_empty() {
|
||||
v.extend([sub("Unlock", unlock), Sep]);
|
||||
}
|
||||
if roots.is_empty() {
|
||||
v.extend([
|
||||
c("Paste", "edit.paste"),
|
||||
@@ -3521,7 +3550,7 @@ fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Ve
|
||||
let Some((enabled, _)) = shown_state(app, id, p) else { continue };
|
||||
out.push(MenuEntry {
|
||||
path: path.clone(),
|
||||
label: dynamic_label(app, id, l),
|
||||
label: unlock_item_label(app, id, p, crate::i18n::Lang::EN).unwrap_or_else(|| dynamic_label(app, id, l)),
|
||||
command: Some(id.to_string()),
|
||||
params: p.clone(),
|
||||
enabled,
|
||||
@@ -3768,6 +3797,7 @@ pub fn item_shortcut(id: &str, p: &Value) -> Option<&'static str> {
|
||||
pub const CONTEXT_LABELS: &[&str] = &[
|
||||
"Isolate Selected Group",
|
||||
"Exit Isolation Mode",
|
||||
"Unlock",
|
||||
"Make Clipping Mask",
|
||||
"Release Clipping Mask",
|
||||
"Make Compound Path",
|
||||
|
||||
@@ -210,6 +210,14 @@ fn painted_name(n: &Node, name: &str, lang: crate::i18n::Lang) -> String {
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// The name the panel shows for `n` in `lang`: its XML ID while Identify Objects By is XML ID,
|
||||
/// otherwise [`painted_name`].
|
||||
pub(crate) fn shown_name(app: &VectorcraftApp, n: &Node, lang: crate::i18n::Lang) -> String {
|
||||
let xml_id = app.session.prefs.identify_objects_by == "xmlId";
|
||||
let label = n.identify_label(xml_id);
|
||||
if xml_id { label } else { painted_name(n, &label, lang) }
|
||||
}
|
||||
|
||||
/// Whether `n` gets rows for what it holds (a compound path's paths are one object).
|
||||
fn opens(n: &Node, opts: &PanelOptions) -> bool {
|
||||
match n.kind {
|
||||
|
||||
@@ -289,6 +289,11 @@ pub struct UiState {
|
||||
/// starts under the selection at every launch.
|
||||
#[serde(skip)]
|
||||
pub task_bar_place: TaskBarPlace,
|
||||
/// The locked objects under the pointer at the last right-click on the canvas, with the
|
||||
/// document's `uid`: the context menu's Unlock submenu lists them while the menu is open. Not
|
||||
/// saved.
|
||||
#[serde(skip)]
|
||||
pub context_locked: Option<(u64, Vec<vectorcraft_doc::NodeId>)>,
|
||||
/// Last tool shown in each toolbar slot (keyed by the slot's first tool id).
|
||||
#[serde(default)]
|
||||
pub slot_tool: std::collections::BTreeMap<String, String>,
|
||||
@@ -720,6 +725,7 @@ impl Default for UiState {
|
||||
toolbar_advanced: false,
|
||||
task_bar: true,
|
||||
task_bar_place: TaskBarPlace::default(),
|
||||
context_locked: None,
|
||||
slot_tool: Default::default(),
|
||||
floating_flyouts: vec![],
|
||||
toolbar_edits: ToolbarEdits::default(),
|
||||
|
||||
@@ -110,6 +110,38 @@ fn right_click_on_empty_canvas_offers_the_view_and_a_dismissing_click_does_nothi
|
||||
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
|
||||
}
|
||||
|
||||
/// A right-click over a locked object adds an Unlock submenu that lists the object by its name.
|
||||
/// Choosing the name unlocks the object in one undo step; once the menu is closed, the menu lists
|
||||
/// no Unlock items.
|
||||
#[test]
|
||||
fn right_click_on_a_locked_object_offers_unlock_by_its_name() {
|
||||
let mut app = app();
|
||||
let id = rect(&mut app, 50.0, 50.0);
|
||||
app.run("layer.setProps", json!({"ids": [id.0], "name": "Sky", "locked": true})).unwrap();
|
||||
let steps = app.session.active().unwrap().history.undo.len();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let p = screen(&app, 100.0, 100.0);
|
||||
let texts = click(&mut app, &ctx, p, PointerButton::Secondary);
|
||||
assert!(has(&texts, "Unlock"), "{texts:?}");
|
||||
// Agents see the item under the Unlock path, by the object's name.
|
||||
let entries = menus::context_entries(&app);
|
||||
let item = entries.iter().find(|e| e.path == ["Unlock"]).unwrap();
|
||||
assert_eq!((item.label.as_str(), item.command.as_deref()), ("Sky", Some("layer.setProps")));
|
||||
assert_eq!(item.params, json!({"ids": [id.0], "locked": false}));
|
||||
// Hovering Unlock opens the submenu.
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(at(&texts, "Unlock"))]);
|
||||
let texts = frame(&mut app, &ctx, vec![]);
|
||||
let texts = click(&mut app, &ctx, at(&texts, "Sky"), PointerButton::Primary);
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(!st.doc.node(id).unwrap().locked, "the object is unlocked");
|
||||
assert_eq!(st.history.undo.len(), steps + 1, "in one undo step");
|
||||
assert!(!has(&texts, "Unlock"), "the menu closed: {texts:?}");
|
||||
// Locked again, it is not listed until the next right-click over it.
|
||||
app.run("layer.setProps", json!({"ids": [id.0], "locked": true})).unwrap();
|
||||
assert!(menus::context_entries(&app).iter().all(|e| e.path.first().map(String::as_str) != Some("Unlock")));
|
||||
}
|
||||
|
||||
/// #528: right-clicking a ruler offers the document units, the current one checked, and choosing one
|
||||
/// swaps them — the same effect as Preferences ▸ Units ▸ General (`document.setUnits`).
|
||||
#[test]
|
||||
@@ -199,6 +231,11 @@ fn every_context_label_is_a_menu_string() {
|
||||
shown.extend(labels(&menus::context_items(&app)));
|
||||
menus::invoke(&mut app, "object.isolate", json!({}));
|
||||
shown.extend(labels(&menus::context_items(&app)));
|
||||
// The Unlock submenu that a right-click over a locked object adds.
|
||||
menus::invoke(&mut app, "object.exitIsolation", json!({}));
|
||||
app.run("layer.setProps", json!({"ids": [a.0], "locked": true})).unwrap();
|
||||
app.ui.context_locked = Some((app.session.active().unwrap().uid, vec![a]));
|
||||
shown.extend(labels(&menus::context_items(&app)));
|
||||
let missing: Vec<_> = shown.iter().filter(|l| !strings.contains(**l)).collect();
|
||||
assert!(missing.is_empty(), "context menu labels missing from menu_strings: {missing:?}");
|
||||
for label in menus::CONTEXT_LABELS {
|
||||
|
||||
@@ -89,7 +89,7 @@ the two can still win that race.
|
||||
| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, the key object (`key`, null without one), history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does. Each node reports `opacity` below 1 and `blend` when it isn't Normal; objects and compound paths report `fill`, `stroke` and `strokeWidth`, a compound path's members `compoundMember: true` instead |
|
||||
| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) |
|
||||
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected |
|
||||
| `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected. While a menu opened over locked objects is open, it has an `Unlock` submenu with one item for each locked object under the pointer, topmost first and at most 20, labeled with the object's name, which runs `layer.setProps {ids: [id], locked: false}`. A locked group gets one item, and the locked objects inside it get none. Hidden objects, objects on hidden, locked or template layers, and in isolation mode objects outside the isolated group get no item |
|
||||
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
|
||||
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse: `mods.cmd` held at a press with any tool but a selection tool drags with the selection tool chosen last (Direct Selection with the Pen until one is), and the release gives the tool back as it was; the reply lists `selection`, `key` (the key object, null without one) and `tool` |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | synthetic keyboard input |
|
||||
|
||||
@@ -435,6 +435,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. The DMG opens on a designed Finder window, VectorCraft.app beside the `Applications` link over a background with the app icon (#493).
|
||||
- Swatch and graphic style libraries, Libraries panel files, presets and plug-ins open wherever they are dropped on the window, as File › Open opens them.
|
||||
- A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas.
|
||||
- A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed.
|
||||
- Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified.
|
||||
- Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close).
|
||||
- Four brightness themes, persistent preferences.
|
||||
|
||||
Reference in New Issue
Block a user