Merge pull request #1809 from Messhias/feat/394-identify-objects-by

Part of #394: Identify Objects By (Object Name / XML ID)
This commit is contained in:
BFlat 2026-10-14 05:38:49 +07:00 committed by GitHub
commit a23f5711af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 198 additions and 61 deletions

View File

@ -48,6 +48,7 @@ pub use symbols::SymbolBoxes;
pub mod text; pub mod text;
pub mod trace; pub mod trace;
pub mod variables; pub mod variables;
pub mod xml_id;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::sync::Arc; use std::sync::Arc;

View File

@ -978,6 +978,12 @@ impl Node {
_ => format!("<{}>", self.kind_label()), _ => format!("<{}>", self.kind_label()),
} }
} }
/// [`Self::display_name`], or its XML id when Preferences › Units › Identify Objects By is XML ID.
pub fn identify_label(&self, xml_id: bool) -> String {
let name = self.display_name();
if xml_id { crate::xml_id::from_object_name(&name) } else { name }
}
/// Path data for path nodes. /// Path data for path nodes.
pub fn path_data(&self) -> Option<&PathData> { pub fn path_data(&self) -> Option<&PathData> {
match &self.kind { match &self.kind {

117
crates/doc/src/xml_id.rs Normal file
View File

@ -0,0 +1,117 @@
//! Object names ↔ XML IDs (Preferences › Units › Identify Objects By).
//!
//! Illustrator's public documentation: with XML ID selected, the Layers panel shows and edits
//! names that conform to XML naming (letter, underscore or colon first; no spaces). Underscores
//! stand for spaces and `_xHH_` / `_xHHHH_` escape other characters, as in `.ai` `AI10_ArtUID`
//! values ([edit dynamic objects](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/edit-dynamic-objects.html)).
/// An object name from its XML id: `_` is a space and `_xHH_` / `_xHHHH_` a character; ids made
/// unique end with `_` and 20 or more digits (stripped).
pub fn to_object_name(id: &str) -> String {
let id = strip_unique(id);
let mut out = String::with_capacity(id.len());
let mut rest = id;
while let Some(c) = rest.chars().next() {
if c == '_' {
if let Some((ch, n)) = escaped(rest) {
out.push(ch);
rest = rest.get(n..).unwrap_or_default();
continue;
}
out.push(' ');
} else {
out.push(c);
}
rest = rest.get(c.len_utf8()..).unwrap_or_default();
}
out
}
/// The XML id for an object name (the inverse of [`to_object_name`]).
pub fn from_object_name(name: &str) -> String {
let mut out = String::with_capacity(name.len().saturating_mul(2));
for (i, c) in name.chars().enumerate() {
if c == ' ' {
out.push('_');
} else if c == '_' {
out.push_str("_x5F_");
} else if i == 0 && c.is_ascii_digit() {
out.push_str(&format!("_x{:X}_", u32::from(c)));
} else if c.is_alphanumeric() || c == '.' || c == '-' || c == ':' {
out.push(c);
} else {
out.push_str(&format!("_x{:X}_", u32::from(c)));
}
}
if out.is_empty() || !starts_name(&out) {
out.insert(0, '_');
}
out
}
/// Whether `id` is a usable XML ID for Identify Objects By › XML ID: non-empty, no whitespace,
/// starts with a letter, `_` or `:`, and otherwise letters, digits, `.`, `-`, `_` or `:`.
pub fn is_valid(id: &str) -> bool {
let id = id.trim();
if id.is_empty() || id.chars().any(char::is_whitespace) {
return false;
}
let mut chars = id.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_alphabetic() || first == '_' || first == ':') {
return false;
}
chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-' | ':'))
}
fn starts_name(id: &str) -> bool {
id.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == ':')
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
let end = body.find('_')?;
let hex = body.get(..end)?;
if hex.is_empty() || hex.len() > 6 {
return None;
}
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
Some((c, end + 3))
}
/// `id` without the `_000…_` suffix that makes it unique.
fn strip_unique(id: &str) -> &str {
let Some(body) = id.strip_suffix('_') else {
return id;
};
let Some(at) = body.rfind('_') else {
return id;
};
let digits = body.get(at + 1..).unwrap_or_default();
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_ai_style_ids() {
for (name, id) in [
("Layer 1", "Layer_1"),
("My Rect #2", "My_Rect__x23_2"),
("1abc (paren)", "_x31_abc__x28_paren_x29_"),
("Ünï 日本", "Ünï_日本"),
("a_b", "a_x5F_b"),
] {
assert_eq!(from_object_name(name), id, "encode {name}");
assert_eq!(to_object_name(id), name, "decode {id}");
}
assert_eq!(to_object_name("S_00000092429200007574234970000005974767392385616558_"), "S");
assert!(is_valid("Layer_1") && is_valid("_x31_abc") && is_valid(":root"));
assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid(""));
}
}

View File

@ -80,7 +80,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Layer Options…", "Layer Options…",
["Window", "Layers"], ["Window", "Layers"],
None, None,
"{ids?|id? (default: the highlighted rows, else the current layer), name?, visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step", "{ids?|id? (default: the highlighted rows, else the current layer), name? (with prefs.identifyObjectsBy xmlId: a valid XML ID, stored as the object name), visible?, locked?, template?, printable?, preview?, dimImages?: 0..100|false, color?: index 0..26|\"#rrggbb\"|preset name} Layer Options; rows that are not layers take name, visible and locked. Template on also locks the layer and dims its images to 50% (unless given); off unlocks it and stops dimming. One undo step",
has_doc, has_doc,
set_props set_props
), ),
@ -297,7 +297,9 @@ fn layer_color_param(v: &Value) -> std::result::Result<LayerColor, String> {
} }
/// Layer Options in `p` applied to layer or row `n` (validated first by [`check_options`]). /// Layer Options in `p` applied to layer or row `n` (validated first by [`check_options`]).
fn apply_options(n: &mut Node, p: &Value) { /// `xml_id`: Preferences › Units › Identify Objects By › XML ID — `name` is an XML id and is
/// stored as the object name ([`vectorcraft_doc::xml_id`]).
fn apply_options(n: &mut Node, p: &Value, xml_id: bool) {
if let Some(v) = str_param(p, "name") { if let Some(v) = str_param(p, "name") {
if v.trim().is_empty() { if v.trim().is_empty() {
// A layer keeps a name; an object goes back to its generated `<Kind>` one. // A layer keeps a name; an object goes back to its generated `<Kind>` one.
@ -305,7 +307,8 @@ fn apply_options(n: &mut Node, p: &Value) {
n.name = None; n.name = None;
} }
} else { } else {
n.name = Some(v.chars().take(512).collect()); let name = if xml_id { vectorcraft_doc::xml_id::to_object_name(v) } else { v.to_string() };
n.name = Some(name.chars().take(512).collect());
} }
} }
if let Some(v) = p.get("visible").and_then(Value::as_bool) { if let Some(v) = p.get("visible").and_then(Value::as_bool) {
@ -354,7 +357,9 @@ fn apply_options(n: &mut Node, p: &Value) {
} }
/// Bad values in Layer Options params, as an error for `cmd`. /// Bad values in Layer Options params, as an error for `cmd`.
fn check_options(cmd: &str, p: &Value) -> Result<()> { /// `xml_id`: Preferences › Units › Identify Objects By › XML ID — a non-empty `name` must be a
/// valid XML id.
fn check_options(cmd: &str, p: &Value, xml_id: bool) -> Result<()> {
if let Some(v) = p.get("color") { if let Some(v) = p.get("color") {
layer_color_param(v).map_err(|e| bad(cmd, e))?; layer_color_param(v).map_err(|e| bad(cmd, e))?;
} }
@ -368,6 +373,12 @@ fn check_options(cmd: &str, p: &Value) -> Result<()> {
{ {
return Err(bad(cmd, "`dimImages` is a percentage or false")); return Err(bad(cmd, "`dimImages` is a percentage or false"));
} }
if xml_id
&& let Some(name) = str_param(p, "name").filter(|n| !n.trim().is_empty())
&& !vectorcraft_doc::xml_id::is_valid(name)
{
return Err(bad(cmd, "Identify Objects By is XML ID: the name must be a valid XML ID (start with a letter, underscore or colon; no spaces)"));
}
Ok(()) Ok(())
} }
@ -446,12 +457,13 @@ fn clip_toggle(s: &mut Session, p: &Value) -> Result<Value> {
/// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted. /// A new layer with the options in `p`, at `parent`/`index`; it becomes current and highlighted.
fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option<NodeId>, usize)>) -> Result<Value> { fn add_layer(s: &mut Session, p: &Value, cmd: &str, label: &str, place: impl FnOnce(&Document) -> Result<(Option<NodeId>, usize)>) -> Result<Value> {
check_options(cmd, p)?; let xml_id = s.prefs.identify_objects_by == "xmlId";
check_options(cmd, p, xml_id)?;
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string); let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
let id = s.edit(label, |d, _| { let id = s.edit(label, |d, _| {
let (parent, index) = place(d)?; let (parent, index) = place(d)?;
let mut n = make_layer(d, name.as_deref()); let mut n = make_layer(d, name.as_deref());
apply_options(&mut n, p); apply_options(&mut n, p, xml_id);
Ok(d.insert(parent, index, n)?) Ok(d.insert(parent, index, n)?)
})?; })?;
let st = s.doc_mut()?; let st = s.doc_mut()?;
@ -568,14 +580,15 @@ fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
fn set_props(s: &mut Session, p: &Value) -> Result<Value> { fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "layer.setProps"; const C: &str = "layer.setProps";
check_options(C, p)?; let xml_id = s.prefs.identify_objects_by == "xmlId";
check_options(C, p, xml_id)?;
let ids = rows_param(s, p, C)?; let ids = rows_param(s, p, C)?;
if ids.is_empty() { if ids.is_empty() {
return Err(bad(C, "no rows")); return Err(bad(C, "no rows"));
} }
s.edit("Layer Options", |d, sel| { s.edit("Layer Options", |d, sel| {
for id in &ids { for id in &ids {
apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p); apply_options(d.node_mut(*id).ok_or(EngineError::NoNode(*id))?, p, xml_id);
} }
sel.deselect_uneditable(d); sel.deselect_uneditable(d);
Ok(()) Ok(())

View File

@ -238,6 +238,25 @@ fn visibility_and_lock_per_row_are_undoable() {
assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name"); assert_eq!(node(&s, sub).display_name(), "Sub", "a layer keeps its name");
} }
/// Preferences › Units › Identify Objects By › XML ID (#394): `layer.setProps` takes a valid XML
/// id and stores the object name; Object Name (the default) keeps free text. The Layers label is
/// the XML id when that preference is on.
#[test]
fn identify_objects_by_xml_id_names_layers() {
let mut s = session();
let a = rect(&mut s, 0.0);
run(&mut s, "layer.setProps", json!({"id": a.0, "name": "My Rect #2"}));
assert_eq!(node(&s, a).display_name(), "My Rect #2");
assert_eq!(node(&s, a).identify_label(true), "My_Rect__x23_2");
run(&mut s, "prefs.set", json!({"key": "identifyObjectsBy", "value": "xmlId"}));
assert!(s.execute("layer.setProps", &json!({"id": a.0, "name": "My Rect"})).is_err(), "spaces are refused");
run(&mut s, "layer.setProps", json!({"id": a.0, "name": "My_Rect__x23_2"}));
assert_eq!(node(&s, a).display_name(), "My Rect #2", "the XML id is stored as the object name");
run(&mut s, "prefs.set", json!({"key": "identifyObjectsBy", "value": "objectName"}));
run(&mut s, "layer.setProps", json!({"id": a.0, "name": "My Rect"}));
assert_eq!(node(&s, a).display_name(), "My Rect");
}
#[test] #[test]
fn object_set_props_deselects_the_objects_it_hides_or_locks() { fn object_set_props_deselects_the_objects_it_hides_or_locks() {
let mut s = session(); let mut s = session();

View File

@ -123,46 +123,9 @@ pub(super) fn close(o: &mut Obj, stack: &mut Vec<V>, base: usize) {
} }
} }
/// An object name from its XML id (`AI10_ArtUID`): `_` stands for a space and `_xHH_` for a /// An object name from its XML id (`AI10_ArtUID`).
/// character; ids made unique end with `_` and 20 or more digits.
pub(super) fn xml_name(id: &str) -> String { pub(super) fn xml_name(id: &str) -> String {
let id = strip_unique(id); vectorcraft_doc::xml_id::to_object_name(id)
let mut out = String::with_capacity(id.len());
let mut rest = id;
while let Some(c) = rest.chars().next() {
if c == '_' {
if let Some((ch, n)) = escaped(rest) {
out.push(ch);
rest = rest.get(n..).unwrap_or_default();
continue;
}
out.push(' ');
} else {
out.push(c);
}
rest = rest.get(c.len_utf8()..).unwrap_or_default();
}
out
}
/// `_xHHHH_` at the start of `s`: the character and the escape's length.
fn escaped(s: &str) -> Option<(char, usize)> {
let body = s.strip_prefix("_x")?;
let end = body.find('_')?;
let hex = body.get(..end)?;
if hex.is_empty() || hex.len() > 6 {
return None;
}
let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?;
Some((c, end + 3))
}
/// `id` without the `_000…_` suffix that makes it unique.
fn strip_unique(id: &str) -> &str {
let Some(body) = id.strip_suffix('_') else { return id };
let Some(at) = body.rfind('_') else { return id };
let digits = body.get(at + 1..).unwrap_or_default();
if digits.len() >= 20 && digits.bytes().all(|b| b.is_ascii_digit()) { body.get(..at).unwrap_or(id) } else { id }
} }
#[cfg(test)] #[cfg(test)]

View File

@ -33,9 +33,10 @@ fn color_field(c: LayerColor) -> String {
} }
/// The fields for `n` (a layer or any other row). /// The fields for `n` (a layer or any other row).
fn fields_of(n: &Node) -> serde_json::Map<String, Value> { /// `xml_id`: Preferences › Units › Identify Objects By › XML ID.
fn fields_of(n: &Node, xml_id: bool) -> serde_json::Map<String, Value> {
let mut f = serde_json::Map::new(); let mut f = serde_json::Map::new();
f.insert("name".into(), json!(n.display_name())); f.insert("name".into(), json!(n.identify_label(xml_id)));
f.insert("visible".into(), json!(n.visible)); f.insert("visible".into(), json!(n.visible));
f.insert("locked".into(), json!(n.locked)); f.insert("locked".into(), json!(n.locked));
f.insert("layer".into(), json!(n.is_layer())); f.insert("layer".into(), json!(n.is_layer()));
@ -70,7 +71,8 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
} }
}; };
let first = ids.first().and_then(|id| st.doc.node(*id)).ok_or("no such layer or object")?; let first = ids.first().and_then(|id| st.doc.node(*id)).ok_or("no such layer or object")?;
let mut f = fields_of(first); let xml_id = app.session.prefs.identify_objects_by == "xmlId";
let mut f = fields_of(first, xml_id);
f.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())); f.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::<Vec<_>>()));
f.insert("mode".into(), json!("edit")); f.insert("mode".into(), json!("edit"));
app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f }); app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f });
@ -93,8 +95,10 @@ pub fn open_new(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
} }
let sublayer = p.get("sublayer").and_then(Value::as_bool).unwrap_or(false); let sublayer = p.get("sublayer").and_then(Value::as_bool).unwrap_or(false);
let color = LayerColor::Preset((layers.len() % LAYER_COLORS.len()) as u8); let color = LayerColor::Preset((layers.len() % LAYER_COLORS.len()) as u8);
let name = format!("Layer {i}");
let name = if app.session.prefs.identify_objects_by == "xmlId" { vectorcraft_doc::xml_id::from_object_name(&name) } else { name };
let fields = json!({ let fields = json!({
"mode": if sublayer { "newSublayer" } else { "new" }, "layer": true, "name": format!("Layer {i}"), "color": color_field(color), "mode": if sublayer { "newSublayer" } else { "new" }, "layer": true, "name": name, "color": color_field(color),
"template": false, "locked": false, "visible": true, "printable": true, "preview": true, "dimImages": false, "dimPercent": 50, "template": false, "locked": false, "visible": true, "printable": true, "preview": true, "dimImages": false, "dimPercent": 50,
}); });
app.ui.dialog = Some(Dialog::new(KIND, fields)); app.ui.dialog = Some(Dialog::new(KIND, fields));

View File

@ -169,7 +169,8 @@ fn drag_ghost(ui: &Ui, view: &View, src: NodeId, pos: egui::Pos2) {
if view.opts.thumb(n) { if view.opts.thumb(n) {
x = row_thumb(&ui, view, n, r, x).right() + 4.0; x = row_thumb(&ui, view, n, r, x).right() + 4.0;
} }
let name = painted_name(n, &n.display_name(), crate::i18n::current()); let label = n.identify_label(view.identify_xml_id);
let name = if view.identify_xml_id { label } else { painted_name(n, &label, crate::i18n::current()) };
ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text); ui.painter().text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(13.0), t.text);
} }
@ -329,6 +330,8 @@ struct View<'a> {
column: Option<ColumnDrag>, column: Option<ColumnDrag>,
/// The row to scroll into view. /// The row to scroll into view.
reveal: Option<NodeId>, reveal: Option<NodeId>,
/// Preferences › Units › Identify Objects By › XML ID.
identify_xml_id: bool,
} }
/// What one frame of the panel decided. /// What one frame of the panel decided.
@ -390,6 +393,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
h: app.ui.layers_panel.row(), h: app.ui.layers_panel.row(),
column: ui.data(|d| d.get_temp(key("column"))), column: ui.data(|d| d.get_temp(key("column"))),
reveal, reveal,
identify_xml_id: app.session.prefs.identify_objects_by == "xmlId",
}; };
let mut out = Out::default(); let mut out = Out::default();
ui.add_space(6.0); ui.add_space(6.0);
@ -685,8 +689,9 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
x = th.right() + 4.0; x = th.right() + 4.0;
th th
}); });
// Name. // Name (Preferences › Units › Identify Objects By: Object Name or XML ID).
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.display_name() }; let xml_id = view.identify_xml_id;
let name = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { "<Opacity Mask>".to_string() } else { n.identify_label(xml_id) };
let font = egui::FontId::proportional(13.0); let font = egui::FontId::proportional(13.0);
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id())); let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id()));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0)); let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.center().y - 10.0), egui::pos2(r.right() - 44.0, r.center().y + 10.0));
@ -713,10 +718,13 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
_ => { _ => {
let painter = ui.painter().with_clip_rect(name_rect); let painter = ui.painter().with_clip_rect(name_rect);
// Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English. // Generated names ("<Path>", "<Opacity Mask>") are translated where painted; the stored name stays English.
// XML IDs stay as ids (not translated).
let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) { let shown = if doc.mask_edit.is_some_and(|m| m.layer == n.id) {
tl!("<Opacity Mask>").to_string() tl!("<Opacity Mask>").to_string()
} else if xml_id {
name.clone()
} else { } else {
painted_name(n, &name, crate::i18n::current()) painted_name(n, &n.display_name(), crate::i18n::current())
}; };
let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if item.dim { t.text_dim } else { t.text }); let text = painter.text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, shown, font, if item.dim { t.text_dim } else { t.text });
name_end = text.right(); name_end = text.right();
@ -758,7 +766,7 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) {
let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left()); let on_name = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x <= name_end.max(name_rect.left() + 20.0) && p.x >= name_rect.left());
let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p))); let on_thumb = thumb_rect.is_some_and(|th| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| th.contains(p)));
if on_name && !on_thumb { if on_name && !on_thumb {
ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.display_name()))); ui.data_mut(|d| d.insert_temp(rename_id(), (n.id.0, n.identify_label(view.identify_xml_id))));
} else { } else {
out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]})));
} }

View File

@ -97,6 +97,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
return; return;
}; };
// Owned snapshots: the rows below run commands, which borrow the app mutably. // Owned snapshots: the rows below run commands, which borrow the app mutably.
let xml_id = app.session.prefs.identify_objects_by == "xmlId";
let vars: Vec<VarRow> = st let vars: Vec<VarRow> = st
.doc .doc
.variables .variables
@ -106,7 +107,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let bound: Vec<u64> = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect(); let bound: Vec<u64> = st.doc.variables.objects_of(&v.name).iter().map(|i| i.0).collect();
let object = match bound.as_slice() { let object = match bound.as_slice() {
[] => String::new(), [] => String::new(),
[one] => st.doc.node(NodeId(*one)).map(|n| n.display_name()).unwrap_or_default(), [one] => st.doc.node(NodeId(*one)).map(|n| n.identify_label(xml_id)).unwrap_or_default(),
_ => tl!("Multiple objects").to_string(), _ => tl!("Multiple objects").to_string(),
}; };
VarRow { name: v.name.clone(), kind: v.kind, bound, object } VarRow { name: v.name.clone(), kind: v.kind, bound, object }

File diff suppressed because one or more lines are too long

View File

@ -1991,7 +1991,10 @@ three: General (rulers, positions and sizes, the Info panel, dialog distances, c
`numbersWithoutUnitsArePoints` (on by default) makes the desktop app's length fields in picas read a number typed `numbersWithoutUnitsArePoints` (on by default) makes the desktop app's length fields in picas read a number typed
with no unit in points (`12` is 12 pt, `2p6` and `1p` stay picas, `12 mm` millimetres); off, it is in picas. Fields in with no unit in points (`12` is 12 pt, `2p6` and `1p` stay picas, `12 mm` millimetres); off, it is in picas. Fields in
other units read it in their unit either way, and the Preferences dialog dims the option unless a unit is Picas. other units read it in their unit either way, and the Preferences dialog dims the option unless a unit is Picas.
Command params are in points either way. Command params are in points either way. `identifyObjectsBy` (`objectName` by default, or `xmlId`) sets how Layers,
Variables and Layer Options show and edit object names: Object Name keeps free text; XML ID shows AI-style XML ids
(`My Rect #2` as `My_Rect__x23_2`) and `layer.setProps` / rename require a valid XML ID (letter, underscore or colon
first; no spaces), stored back as the object name.
`prefs.list` marks the length preferences (`keyboardIncrement`, `cornerRadius`, `pasteOffset`, `gridlineEvery`, `prefs.list` marks the length preferences (`keyboardIncrement`, `cornerRadius`, `pasteOffset`, `gridlineEvery`,
`typeSizeIncrement`, `baselineShiftIncrement`) with `measure: "general"|"type"`; they are kept in points and also take `typeSizeIncrement`, `baselineShiftIncrement`) with `measure: "general"|"type"`; they are kept in points and also take
a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `Centimeters`, `Pixels`, `Feet & Inches`, a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `Centimeters`, `Pixels`, `Feet & Inches`,

File diff suppressed because one or more lines are too long