Merge main into fix/a-part-with-nan-opacity-neither-casts-a-shadow

This commit is contained in:
dotCooCoo
2026-11-15 01:49:16 -08:00
49 changed files with 943 additions and 220 deletions
+5 -1
View File
@@ -146,7 +146,11 @@ fn text(k: &str, v: &Value, multiline: bool) -> Result<String> {
return Err(bad(C, format!("{k} is too long (at most {MAX_TEXT} characters)")));
}
if s.chars().any(|c| c.is_control() && !(multiline && matches!(c, '\n' | '\r' | '\t'))) {
return Err(bad(C, format!("{k} has control characters{}", if multiline { "" } else { " or line breaks" })));
return Err(if multiline {
bad(C, format!("{k} has control characters"))
} else {
bad(C, format!("{k} has control characters or line breaks"))
});
}
Ok(s.to_string())
}
+23
View File
@@ -81,6 +81,29 @@ fn junk_is_rejected() {
assert_eq!(s.doc().unwrap().doc.metadata, DocMetadata { created: s.doc().unwrap().doc.metadata.created, ..Default::default() });
}
/// A control character inside a value is refused with the field's rule: Description and Copyright
/// Notice take line breaks and tabs, the other text fields and each keyword in a list take none.
#[test]
fn control_characters_are_refused_with_the_field_rule() {
let mut s = session();
for (params, msg) in [
(json!({"description": "a\u{7}b"}), "description has control characters"),
(json!({"copyrightNotice": "a\u{0}b"}), "copyrightNotice has control characters"),
(json!({"title": "a\nb"}), "title has control characters or line breaks"),
(json!({"author": "a\rb"}), "author has control characters or line breaks"),
(json!({"authorTitle": "a\tb"}), "authorTitle has control characters or line breaks"),
(json!({"copyrightUrl": "a\u{7}b"}), "copyrightUrl has control characters or line breaks"),
(json!({"keywords": ["ok", "a\nb"]}), "keywords has control characters or line breaks"),
] {
match s.execute("file.info", &params) {
Err(EngineError::BadParams { cmd, msg: got }) => assert_eq!((cmd.as_str(), got.as_str()), ("file.info", msg), "{params}"),
other => panic!("{params}: {other:?}"),
}
}
s.execute("file.info", &json!({"description": "two\nlines\tand a tab", "copyrightNotice": "a\r\nb"})).unwrap();
assert_eq!(s.doc().unwrap().doc.metadata.description, "two\nlines\tand a tab");
}
#[test]
fn file_info_round_trips_and_old_files_load() {
let mut s = session();
+133 -75
View File
@@ -8,6 +8,7 @@ use serde_json::{Value, json};
use vectorcraft_geom::shapes::EllipseMode;
use vectorcraft_tools::addons::box3d::{self, CustomShape};
use crate::chrome::Fit;
use crate::menus::Item;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
@@ -17,8 +18,10 @@ pub fn menu() -> Vec<Item> {
}
/// Draw context-sensitive shape customization controls in the top Control bar when a shape tool is
/// active. Returns `true` when controls were drawn.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
/// active. Returns `true` when a shape tool is active. Each button, and each label with its field,
/// shows only while the row has room for it and `reserve` after it, and the controls after one left
/// out are left out too.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool {
let tool = app.session.tool_id().to_string();
if !matches!(tool.as_str(), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "spiral" | "box3d") {
return false;
@@ -29,6 +32,7 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
st.selection.objects.iter().find_map(|&id| st.doc.node(id).and_then(|n| CustomShape::read_for_update(n, active_shape == "spiral")))
});
let fit = &mut Fit::new(reserve);
if tool == "box3d" {
for (s_id, lbl, icon) in [
("box3d", "3D Box", "box"),
@@ -38,24 +42,35 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
("star", "Star", "star"),
("spiral", "Spiral", "tornado"),
] {
if widgets::icon_button(ui, icon, tl!(lbl), active_shape == s_id, 22.0).clicked() {
if fit.room(ui, &[22.0]) && widgets::icon_button(ui, icon, tl!(lbl), active_shape == s_id, 22.0).clicked() {
app.run("tool.setOption", json!({ "key": "shape", "value": s_id })).ok();
}
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
match active_shape.as_str() {
"ellipse" => draw_ellipse_controls(app, ui, &opts, selected_custom),
"rectangle" | "roundedRectangle" => draw_rectangle_controls(app, ui, &opts, selected_custom),
"polygon" | "star" => draw_polygon_controls(app, ui, &opts, &active_shape, selected_custom),
"spiral" => draw_spiral_controls(app, ui, &opts, selected_custom),
"box3d" => draw_box3d_controls(app, ui, &opts, selected_custom),
"ellipse" => draw_ellipse_controls(app, ui, fit, &opts, selected_custom),
"rectangle" | "roundedRectangle" => draw_rectangle_controls(app, ui, fit, &opts, selected_custom),
"polygon" | "star" => draw_polygon_controls(app, ui, fit, &opts, &active_shape, selected_custom),
"spiral" => draw_spiral_controls(app, ui, fit, &opts, selected_custom),
"box3d" => draw_box3d_controls(app, ui, fit, &opts, selected_custom),
_ => return false,
}
true
}
/// Whether `fit` has room in the row `ui` for the label `text` with a field `field` wide after it.
fn field_room(ui: &Ui, fit: &mut Fit, text: &egui::RichText, field: f32) -> bool {
fit.room(ui, &[widgets::text_width(ui, text.clone()), field])
}
/// The shape options' Reset button, at least 52 points wide and wider for a longer translated label,
/// while `fit` has room for it. Whether it was clicked.
fn reset_button(ui: &mut Ui, fit: &mut Fit) -> bool {
fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Reset"), 52.0)]) && widgets::flat_button_fit(ui, tl!("Reset"), 52.0).clicked()
}
fn apply_shape(app: &mut VectorcraftApp, tool_kvs: &[(&str, Value)], live_patch: Value) {
for (k, v) in tool_kvs {
app.run("tool.setOption", json!({ "key": *k, "value": v.clone() })).ok();
@@ -65,22 +80,28 @@ fn apply_shape(app: &mut VectorcraftApp, tool_kvs: &[(&str, Value)], live_patch:
}
}
fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, start_deg: f64, end_deg: f64, mode: &str) {
fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, start_deg: f64, end_deg: f64, mode: &str) {
let t = Tokens::get(ui.ctx());
widgets::field_label(ui, egui::RichText::new(tl!("Start:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-start"), start_deg, "°", 1, 52.0) {
let next_mode = if mode == "whole" { "pie" } else { mode };
apply_shape(app, &[("arcStart", json!(v)), ("arcMode", json!(next_mode))], json!({ "startAngle": v, "arcMode": next_mode }));
let start = egui::RichText::new(tl!("Start:")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &start, 52.0) {
widgets::field_label(ui, start);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-start"), start_deg, "°", 1, 52.0) {
let next_mode = if mode == "whole" { "pie" } else { mode };
apply_shape(app, &[("arcStart", json!(v)), ("arcMode", json!(next_mode))], json!({ "startAngle": v, "arcMode": next_mode }));
}
}
widgets::field_label(ui, egui::RichText::new(tl!("End:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-end"), end_deg, "°", 1, 52.0) {
let next_mode = if mode == "whole" { "pie" } else { mode };
apply_shape(app, &[("arcEnd", json!(v)), ("arcMode", json!(next_mode))], json!({ "endAngle": v, "arcMode": next_mode }));
let end = egui::RichText::new(tl!("End:")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &end, 52.0) {
widgets::field_label(ui, end);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "arc-end"), end_deg, "°", 1, 52.0) {
let next_mode = if mode == "whole" { "pie" } else { mode };
apply_shape(app, &[("arcEnd", json!(v)), ("arcMode", json!(next_mode))], json!({ "endAngle": v, "arcMode": next_mode }));
}
}
for (m_id, tip_label, icon) in
[("pie", "Slice", "circle-dot"), ("chord", "Closed", "circle-off"), ("arc", "Arc", "dc-arc"), ("whole", "Open", "dc-ellipse")]
{
if widgets::icon_button(ui, icon, tl!(tip_label), mode == m_id, 22.0).clicked() {
if fit.room(ui, &[22.0]) && widgets::icon_button(ui, icon, tl!(tip_label), mode == m_id, 22.0).clicked() {
let (s0, e0) = if m_id == "whole" {
(0.0, 0.0)
} else if (end_deg - start_deg).abs() < 1e-4 || ((end_deg - start_deg).abs() - 360.0).abs() < 1e-4 {
@@ -97,19 +118,19 @@ fn draw_arc_controls(app: &mut VectorcraftApp, ui: &mut Ui, start_deg: f64, end_
}
}
fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
fn draw_ellipse_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option<CustomShape>) {
let (start_deg, end_deg, mode) = match selected_custom {
Some(CustomShape::Ellipse { pie: (a0, a1), arc_mode, .. }) => (a0, a1, mode_str(arc_mode)),
_ => (opts["arcStart"].as_f64().unwrap_or(0.0), opts["arcEnd"].as_f64().unwrap_or(0.0), opts["arcMode"].as_str().unwrap_or("whole")),
};
draw_arc_controls(app, ui, start_deg, end_deg, mode);
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
draw_arc_controls(app, ui, fit, start_deg, end_deg, mode);
if reset_button(ui, fit) {
apply_shape(app, &[("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))], json!({ "reset": true }));
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option<CustomShape>) {
let t = Tokens::get(ui.ctx());
let unit = app.session.general_unit();
let sym = opts["cornerSymmetry"].as_bool().unwrap_or(true);
@@ -129,19 +150,26 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value,
end_deg = a1;
mode = mode_str(arc_mode);
}
if widgets::icon_button(ui, if sym { "link" } else { "unlink" }, tl!("Link"), sym, 22.0).clicked() {
if fit.room(ui, &[22.0]) && widgets::icon_button(ui, if sym { "link" } else { "unlink" }, tl!("Link"), sym, 22.0).clicked() {
let next_sym = !sym;
apply_shape(app, &[("cornerSymmetry", json!(next_sym))], if next_sym { json!({ "radius": radii[0] }) } else { json!({ "radii": radii }) });
}
if sym {
widgets::field_label(ui, egui::RichText::new(tl!("Radius:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::num_field(ui, ("cb-shape", "rect-r"), Some(radii[0]), unit, 54.0) {
let r = v.max(0.0);
apply_shape(app, &[("cornerRadius", json!(r)), ("cornerRadii", json!([r, r, r, r]))], json!({ "radius": r }));
let text = egui::RichText::new(tl!("Radius:")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &text, 54.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::num_field(ui, ("cb-shape", "rect-r"), Some(radii[0]), unit, 54.0) {
let r = v.max(0.0);
apply_shape(app, &[("cornerRadius", json!(r)), ("cornerRadii", json!([r, r, r, r]))], json!({ "radius": r }));
}
}
} else {
for (i, lbl) in [(0, "TL"), (1, "TR"), (2, "BR"), (3, "BL")] {
widgets::field_label(ui, egui::RichText::new(lbl).size(11.0).color(t.text_dim));
let text = egui::RichText::new(lbl).size(11.0).color(t.text_dim);
if !field_room(ui, fit, &text, 44.0) {
break;
}
widgets::field_label(ui, text);
if let Some(v) = widgets::num_field(ui, ("cb-shape-r", i), Some(radii[i]), unit, 44.0) {
let mut next = radii;
next[i] = v.max(0.0);
@@ -149,9 +177,9 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value,
}
}
}
ui.separator();
draw_arc_controls(app, ui, start_deg, end_deg, mode);
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
fit.separator(ui);
draw_arc_controls(app, ui, fit, start_deg, end_deg, mode);
if reset_button(ui, fit) {
apply_shape(
app,
&[
@@ -164,10 +192,17 @@ fn draw_rectangle_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value,
json!({ "reset": true }),
);
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, active_shape: &str, selected_custom: Option<CustomShape>) {
fn draw_polygon_controls(
app: &mut VectorcraftApp,
ui: &mut Ui,
fit: &mut Fit,
opts: &Value,
active_shape: &str,
selected_custom: Option<CustomShape>,
) {
let t = Tokens::get(ui.ctx());
let mut is_star = if active_shape == "star" { true } else { opts["polyIsStar"].as_bool().unwrap_or(false) };
let mut count = if is_star { opts["points"].as_u64().unwrap_or(5) } else { opts["sides"].as_u64().unwrap_or(6) } as u32;
@@ -188,47 +223,57 @@ fn draw_polygon_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, ac
end_deg = a1;
mode = mode_str(arc_mode);
}
if widgets::icon_button(ui, "dc-polygon", tl!("Polygon"), !is_star, 22.0).clicked() {
if fit.room(ui, &[22.0]) && widgets::icon_button(ui, "dc-polygon", tl!("Polygon"), !is_star, 22.0).clicked() {
apply_shape(app, &[("polyIsStar", json!(false))], json!({ "isStar": false }));
}
if widgets::icon_button(ui, "star", tl!("Star"), is_star, 22.0).clicked() {
if fit.room(ui, &[22.0]) && widgets::icon_button(ui, "star", tl!("Star"), is_star, 22.0).clicked() {
apply_shape(app, &[("polyIsStar", json!(true))], json!({ "isStar": true, "spokeRatio": spoke }));
}
widgets::field_label(ui, egui::RichText::new(if is_star { tl!("Points:") } else { tl!("Sides:") }).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-corners"), f64::from(count), "", 0, 44.0) {
let c = (v.round() as i64).clamp(3, 1000) as u32;
apply_shape(app, &[("sides", json!(c)), ("points", json!(c))], json!({ "sides": c }));
let text = egui::RichText::new(if is_star { tl!("Points:") } else { tl!("Sides:") }).size(12.0).color(t.text_dim);
if field_room(ui, fit, &text, 44.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-corners"), f64::from(count), "", 0, 44.0) {
let c = (v.round() as i64).clamp(3, 1000) as u32;
apply_shape(app, &[("sides", json!(c)), ("points", json!(c))], json!({ "sides": c }));
}
}
if is_star {
widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim));
let text = egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim);
if is_star && field_room(ui, fit, &text, 46.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-spoke"), spoke, "", 2, 46.0) {
let sr = v.clamp(0.01, 1.0);
apply_shape(app, &[("starRatio", json!(sr))], json!({ "spokeRatio": sr }));
}
}
widgets::field_label(ui, egui::RichText::new(tl!("Round")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-round"), rounded, "", 2, 46.0) {
let rnd = v.clamp(-10.0, 10.0);
apply_shape(app, &[("rounded", json!(rnd))], json!({ "rounded": rnd }));
let text = egui::RichText::new(tl!("Round")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &text, 46.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-round"), rounded, "", 2, 46.0) {
let rnd = v.clamp(-10.0, 10.0);
apply_shape(app, &[("rounded", json!(rnd))], json!({ "rounded": rnd }));
}
}
widgets::field_label(ui, egui::RichText::new(tl!("Random")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-rand"), randomized, "", 2, 46.0) {
let rand = v.clamp(-10.0, 10.0);
apply_shape(app, &[("randomized", json!(rand))], json!({ "randomized": rand }));
let text = egui::RichText::new(tl!("Random")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &text, 46.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "poly-rand"), randomized, "", 2, 46.0) {
let rand = v.clamp(-10.0, 10.0);
apply_shape(app, &[("randomized", json!(rand))], json!({ "randomized": rand }));
}
}
ui.separator();
draw_arc_controls(app, ui, start_deg, end_deg, mode);
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
fit.separator(ui);
draw_arc_controls(app, ui, fit, start_deg, end_deg, mode);
if reset_button(ui, fit) {
apply_shape(
app,
&[("rounded", json!(0.0)), ("randomized", json!(0.0)), ("arcMode", json!("whole")), ("arcStart", json!(0.0)), ("arcEnd", json!(0.0))],
json!({ "reset": true }),
);
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option<CustomShape>) {
let t = Tokens::get(ui.ctx());
let (mut turns, mut divergence, mut inner_r) =
(opts["turns"].as_f64().unwrap_or(3.0), opts["divergence"].as_f64().unwrap_or(1.0), opts["innerRadius"].as_f64().unwrap_or(0.0));
@@ -237,28 +282,37 @@ fn draw_spiral_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sel
divergence = div_v;
inner_r = ir_v;
}
widgets::field_label(ui, egui::RichText::new(tl!("Segments")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-turns"), turns, "", 2, 50.0) {
let tv = v.clamp(0.01, 1024.0);
apply_shape(app, &[("turns", json!(tv))], json!({ "turns": tv }));
let text = egui::RichText::new(tl!("Segments")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &text, 50.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-turns"), turns, "", 2, 50.0) {
let tv = v.clamp(0.01, 1024.0);
apply_shape(app, &[("turns", json!(tv))], json!({ "turns": tv }));
}
}
widgets::field_label(ui, egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-div"), divergence, "", 2, 50.0) {
let dv = v.clamp(0.0, 1000.0);
apply_shape(app, &[("divergence", json!(dv))], json!({ "divergence": dv }));
let text = egui::RichText::new(tl!("Ratio")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &text, 50.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-div"), divergence, "", 2, 50.0) {
let dv = v.clamp(0.0, 1000.0);
apply_shape(app, &[("divergence", json!(dv))], json!({ "divergence": dv }));
}
}
widgets::field_label(ui, egui::RichText::new(tl!("Inner Radius:")).size(12.0).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-inner"), inner_r, "", 2, 50.0) {
let iv = v.clamp(0.0, 0.999);
apply_shape(app, &[("innerRadius", json!(iv))], json!({ "innerRadius": iv }));
let text = egui::RichText::new(tl!("Inner Radius:")).size(12.0).color(t.text_dim);
if field_room(ui, fit, &text, 50.0) {
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-shape", "spiral-inner"), inner_r, "", 2, 50.0) {
let iv = v.clamp(0.0, 0.999);
apply_shape(app, &[("innerRadius", json!(iv))], json!({ "innerRadius": iv }));
}
}
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
if reset_button(ui, fit) {
apply_shape(app, &[("turns", json!(3.0)), ("divergence", json!(1.0)), ("innerRadius", json!(0.0))], json!({ "reset": true }));
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, selected_custom: Option<CustomShape>) {
fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, fit: &mut Fit, opts: &Value, selected_custom: Option<CustomShape>) {
let t = Tokens::get(ui.ctx());
let (mut ax, mut ay, mut az) =
(opts["angleX"].as_f64().unwrap_or(30.0), opts["angleY"].as_f64().unwrap_or(30.0), opts["angleZ"].as_f64().unwrap_or(0.0));
@@ -275,16 +329,20 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele
for (lbl, a_key, p_key, a_val, p_val) in
[("X:", "angleX", "parallelX", ax, px), ("Y:", "angleY", "parallelY", ay, py), ("Angle:", "angleZ", "parallelZ", az, pz)]
{
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
let text = egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim);
if !field_room(ui, fit, &text, 52.0) {
break;
}
widgets::field_label(ui, text);
if let Some(v) = widgets::plain_field(ui, ("cb-box3d", a_key), a_val, "°", 1, 52.0) {
apply_shape(app, &[(a_key, json!(v))], json!({ a_key: v }));
}
if widgets::icon_button(ui, "dc-line", tl!("Link"), p_val, 20.0).clicked() {
if fit.room(ui, &[20.0]) && widgets::icon_button(ui, "dc-line", tl!("Link"), p_val, 20.0).clicked() {
let next = !p_val;
apply_shape(app, &[(p_key, json!(next))], json!({ p_key: next }));
}
}
if widgets::flat_button(ui, tl!("Reset"), 52.0).clicked() {
if reset_button(ui, fit) {
apply_shape(
app,
&[
@@ -298,7 +356,7 @@ fn draw_box3d_controls(app: &mut VectorcraftApp, ui: &mut Ui, opts: &Value, sele
json!({ "reset": true }),
);
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
fn mode_str(m: EllipseMode) -> &'static str {
+1 -1
View File
@@ -24,7 +24,7 @@ pub use control_bar::{
};
#[cfg(test)]
use control_bar::{CONTROL_BAR_FLOAT_AT, ISOLATION_BUTTON};
pub(crate) use control_bar::{group_separator, left_out};
pub(crate) use control_bar::{Fit, group_separator, left_out, link_reserve};
#[cfg(test)]
use hints::hint_segments;
pub use hints::{hint_bar, hint_strings};
+69 -9
View File
@@ -227,6 +227,64 @@ fn row_width(ui: &Ui, widths: &[f32]) -> f32 {
widths.iter().map(|w| w + ui.spacing().item_spacing.x).sum()
}
/// The room left in the Control bar's row for a group that another module draws in it (a tool's
/// options, an image's, an Image Trace object's, an envelope's or symbol instances' controls, or the
/// Artboard tool's), so that none of its controls runs into the panel menu button. The group asks
/// [`Fit::room`] before each control, or each label with its field, and draws it only on a yes. Once
/// one has no room, the rest of the group is left out too, so the group loses controls from its
/// right end.
pub(crate) struct Fit {
reserve: f32,
full: bool,
}
impl Fit {
/// A fit that leaves `reserve` after the group's controls: the Transform link's room
/// ([`transform_reserve`]), or none.
pub(crate) fn new(reserve: f32) -> Self {
Self { reserve, full: false }
}
/// Whether the row `ui` has room for controls `widths` wide side by side, with the spacing after
/// each and the reserve after them, and no control before them in the group was left out. A
/// control left out is noted with [`left_out`].
pub(crate) fn room(&mut self, ui: &Ui, widths: &[f32]) -> bool {
self.fits(ui, row_width(ui, widths))
}
/// [`Fit::room`] for controls that end the row, with no spacing after the last of them.
pub(crate) fn room_at_end(&mut self, ui: &Ui, widths: &[f32]) -> bool {
self.fits(ui, row_width(ui, widths) - ui.spacing().item_spacing.x)
}
fn fits(&mut self, ui: &Ui, width: f32) -> bool {
self.full = self.full || ui.available_width() < width + self.reserve;
if self.full {
left_out(ui);
}
!self.full
}
/// Leaves `reserve` after the group's controls still to come, in place of the reserve before.
pub(crate) fn set_reserve(&mut self, reserve: f32) {
self.reserve = reserve;
}
/// A separator between two parts of the group ([`group_separator`]), while no control before it
/// was left out. It stays only while a control follows it.
pub(crate) fn separator(&self, ui: &mut Ui) {
if !self.full {
group_separator(ui);
}
}
}
/// The room the Transform link takes with the separator ahead of it, which the Artboard tool's
/// controls ahead of the link leave for it.
pub(crate) fn link_reserve(ui: &Ui) -> f32 {
separator_width(ui) + crate::panels::transform::link_width(ui)
}
/// The size of the Control bar's Fill, Stroke and Graphic Style chips.
const PAINT_CHIP: f32 = 22.0;
@@ -299,7 +357,7 @@ fn left_out_key(ui: &Ui) -> egui::Id {
/// Notes that a control of the Control bar's row `ui` was left out in this pass for want of room,
/// so the controls the bar leaves out first stay out too ([`any_left_out`]).
pub(crate) fn left_out(ui: &mut Ui) {
pub(crate) fn left_out(ui: &Ui) {
let (key, pass) = (left_out_key(ui), ui.ctx().cumulative_pass_nr());
ui.data_mut(|d| d.insert_temp(key, pass));
}
@@ -938,22 +996,24 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
if ui.available_width() >= 6.0 {
ui.add_space(6.0);
}
let reserve = transform_reserve(app, ui, !sel.is_empty());
// An image or an Image Trace object shows its own controls in place of Fill and Stroke,
// and leaves Fill and Stroke out while the Image Options group is off too.
// and leaves Fill and Stroke out while the Image Options group is off too. These groups,
// the tool options, the envelope's and the symbol instances' each show a control only while
// the Transform link's room is left after it.
let image = if group_on(app, "imageOptions") {
crate::place::control_bar_details(app, ui) || crate::panels::image_trace::control_bar(app, ui)
crate::place::control_bar_details(app, ui, reserve) || crate::panels::image_trace::control_bar(app, ui, reserve)
} else {
crate::place::selected_image_info(app).is_some() || crate::panels::image_trace::selected_trace(app).is_some()
};
if group_on(app, "toolOptions") {
crate::toolbar::control_bar_options(app, ui);
crate::toolbar::control_bar_options(app, ui, reserve);
}
if object_options {
crate::dialogs::envelope::control_bar(app, ui);
crate::panels::symbols::control_bar(app, ui);
crate::dialogs::envelope::control_bar(app, ui, reserve);
crate::panels::symbols::control_bar(app, ui, reserve);
}
let opacity = if has_first { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 };
let reserve = transform_reserve(app, ui, !sel.is_empty());
if !image && group_on(app, "fillStroke") {
fill_and_stroke(app, ui, reserve);
}
@@ -969,8 +1029,8 @@ fn contents(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, bar: egui::Rect,
}
// The Transform link opens the whole Transform panel (reference point, rotate,
// shear, options) in a popover; X/Y/W/H follow inline while the bar has room. The
// groups from Fill and Stroke on keep its room, but the controls ahead of them can
// still take it.
// groups after the object's name keep its room, but the name and the anchor and
// isolation buttons ahead of them can still take it.
if ui.available_width() < crate::panels::transform::link_width(ui) {
erase_trailing(ui);
return;
+190 -3
View File
@@ -3044,7 +3044,7 @@ fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, sc
control_bar_frame_in(app, ctx, screen, vec![]);
let out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::control_bar(app, ui));
let nodes: Vec<_> = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default();
let menu_left = nodes.iter().find(|n| n.label().is_some_and(|l| l == "Panel menu")).and_then(|n| n.bounds())?.x0 as f32;
let menu_left = nodes.iter().find(|n| n.label().is_some_and(|l| l == tl!("Panel menu"))).and_then(|n| n.bounds())?.x0 as f32;
// The bar's frame spans the whole bar, the panel menu button included.
let bar_width = if app.ui.control_bar_floating { screen.width().min(800.0) } else { screen.width() };
let shapes = out.shapes.iter().filter_map(|cs| {
@@ -3057,8 +3057,12 @@ fn control_bar_overflow(app: &mut crate::VectorcraftApp, ctx: &egui::Context, sc
}
/// In a narrow window, the Control bar leaves out a control it has no room for, Fill and Stroke,
/// Opacity and Character included: every control it shows is drawn whole, left of the panel menu button, which
/// shows in every width, with the bar docked to the top or the bottom or floating.
/// Opacity and Character included, and the groups other modules draw in it: a shape tool's, the
/// Dimension, Mirror & Cut, Puppet Warp and Crop Image tools' options, a linked or embedded image's
/// controls, an Image Trace object's, a warp or mesh envelope's, a symbol instance's and the Artboard
/// tool's, in English and in German. Every control it shows is drawn whole, left of the panel menu
/// button, which shows in every width, with the bar docked to the top or the bottom or floating. In a
/// wide window, each of those groups shows its controls.
#[test]
fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() {
use crate::tests_removeanchors::{control_frame, has};
@@ -3098,8 +3102,113 @@ fn the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window() {
let bar = control_frame(&mut app, &ctx, vec![]);
assert!(has(&bar, "Character:"), "the Character group shows in a wide window: {bar:?}");
check(&mut app, "type selected");
let area = app.run("text.create", json!({"x": 20, "y": 150, "text": "Area", "area": {"width": 150, "height": 40}})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [area] })).unwrap();
check(&mut app, "area type selected");
app.run("select.set", json!({ "ids": [] })).unwrap();
check(&mut app, "nothing selected");
// A wide window shows the group's controls named `names`.
let wide = |app: &mut crate::VectorcraftApp, case: &str, names: &[&str]| {
control_frame(app, &ctx, vec![]);
let bar = control_frame(app, &ctx, vec![]);
let missing: Vec<_> = names.iter().filter(|n| !has(&bar, n)).collect();
assert!(missing.is_empty(), "{case}: {missing:?} missing in a wide window: {bar:?}");
};
// The tools' options, with a rectangle selected and with nothing selected.
app.run("select.set", json!({ "ids": [id] })).unwrap();
for tool in ["rectangle", "ellipse", "polygon", "star", "spiral", "box3d", "dimension", "mirrorCut", "puppetWarp"] {
app.select_tool(tool);
check(&mut app, tool);
}
app.select_tool("polygon");
wide(&mut app, "Polygon", &["Round", "Random", "Reset"]);
app.select_tool("mirrorCut");
wide(&mut app, "Mirror & Cut", &["Axis:", "Keep:"]);
app.select_tool("puppetWarp");
wide(&mut app, "Puppet Warp", &["Expand:", "Show Mesh", "Select All Pins"]);
app.select_tool("dimension");
wide(&mut app, "Dimension", &["Linear", "Radial", "Tool Options…"]);
app.run("select.set", json!({ "ids": [] })).unwrap();
app.select_tool("spiral");
check(&mut app, "spiral, nothing selected");
wide(&mut app, "Spiral", &["Segments", "Inner Radius:", "Reset"]);
app.select_tool("selection");
// An embedded and a linked image, an Image Trace object and Crop Image's options.
let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let selected = |app: &crate::VectorcraftApp| app.session.active().unwrap().selection.objects.iter().map(|n| n.0).collect::<Vec<_>>();
app.run("file.place", json!({"name": "half.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap();
check(&mut app, "an embedded image");
wide(&mut app, "an embedded image", &["Unembed…", "Image Trace", "Mask", "Crop Image", "Remove Background"]);
app.select_tool(vectorcraft_tools::cropimage::ID);
check(&mut app, "Crop Image");
wide(&mut app, "Crop Image", &["X:", "Y:", "W:"]);
app.select_tool("selection");
app.run("imageTrace.make", json!({"preset": "Default"})).unwrap();
let trace = selected(&app);
check(&mut app, "an Image Trace object");
wide(&mut app, "an Image Trace object", &["Preset:", "View:", "Expand"]);
let dir = vectorcraft_testkit::temp_dir("control-bar-narrow-linked");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("half.png");
std::fs::write(&path, &png).unwrap();
app.run("file.place", json!({"path": path, "link": true})).unwrap();
let linked = selected(&app);
check(&mut app, "a linked image");
wide(&mut app, "a linked image", &["Embed", "Edit Original", "Remove Background"]);
// A warp and a mesh envelope, and a symbol instance.
let shape = |app: &mut crate::VectorcraftApp, x: f64| {
let id = app.run("shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [id] })).unwrap();
id
};
shape(&mut app, 60.0);
app.run("object.envelope.makeWithWarp", json!({})).unwrap();
check(&mut app, "a warp envelope");
wide(&mut app, "a warp envelope", &["Style:", "Horizontal", "Vertical", "Bend:", "V:", "Reset"]);
shape(&mut app, 110.0);
app.run("object.envelope.makeWithMesh", json!({"rows": 2, "cols": 2})).unwrap();
let mesh = selected(&app);
check(&mut app, "a mesh envelope");
wide(&mut app, "a mesh envelope", &["Rows:", "Columns:", "Reset"]);
let art = shape(&mut app, 160.0);
let instance = app.run("symbol.new", json!({"name": "Mark", "ids": [art]})).unwrap()["id"].clone();
app.run("select.set", json!({ "ids": [instance] })).unwrap();
check(&mut app, "a symbol instance");
wide(&mut app, "a symbol instance", &["Replace:", "Reset"]);
// The Artboard tool.
app.select_tool("artboard");
check(&mut app, "the Artboard tool");
wide(&mut app, "the Artboard tool", &["Preset:", "Name:", "Transform"]);
// German labels, which are longer.
crate::i18n::set_current(crate::i18n::Lang::from_code("de").expect("de registered"));
check(&mut app, "the Artboard tool, in German");
app.select_tool("selection");
app.run("select.set", json!({ "ids": [instance] })).unwrap();
check(&mut app, "a symbol instance, in German");
shape(&mut app, 210.0);
app.run("object.envelope.makeWithWarp", json!({})).unwrap();
check(&mut app, "a warp envelope, in German");
app.select_tool("puppetWarp");
check(&mut app, "Puppet Warp, in German");
app.select_tool("dimension");
check(&mut app, "Dimension, in German");
app.select_tool("selection");
for (ids, case) in [(&trace, "an Image Trace object"), (&linked, "a linked image"), (&mesh, "a mesh envelope")] {
app.run("select.set", json!({ "ids": ids })).unwrap();
check(&mut app, &format!("{case}, in German"));
}
app.run("file.place", json!({"name": "half.png", "dataBase64": vectorcraft_format::base64_encode(&png), "link": false})).unwrap();
check(&mut app, "an embedded image, in German");
app.select_tool(vectorcraft_tools::cropimage::ID);
check(&mut app, "Crop Image, in German");
for tool in ["polygon", "spiral", "box3d", "mirrorCut"] {
app.select_tool(tool);
check(&mut app, &format!("{tool}, in German"));
}
app.select_tool("selection");
crate::i18n::set_current(crate::i18n::Lang::EN);
assert!(faults.is_empty(), "a control drawn past the bar's room: {faults:#?}");
}
@@ -3158,6 +3267,84 @@ fn a_narrow_control_bar_shows_preferences_without_document_setup() {
assert!(preferences_only, "some width has room for Preferences but not Document Setup");
}
/// A narrow Control bar shows a linked image's Embed button without Edit Original, and the Artboard
/// tool's Transform link after an Artboard tool option it had no room for.
#[test]
fn a_narrow_control_bar_shows_embed_without_edit_original_and_the_artboard_link_without_its_options() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let dir = vectorcraft_testkit::temp_dir("control-bar-embed-alone");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("half.png");
std::fs::write(&path, &png).unwrap();
app.run("file.place", json!({"path": path, "link": true})).unwrap();
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name));
// Whether some width shows `first` and `second`, and whether some width shows `first` alone.
let sweep = |app: &mut crate::VectorcraftApp, first: &str, second: &str| {
let (mut both, mut first_only) = (false, false);
for w in (150..=1400).step_by(2) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
control_bar_frame_in(app, &ctx, screen, vec![]);
let nodes = control_bar_frame_in(app, &ctx, screen, vec![]);
let (a, b) = (shows(&nodes, first), shows(&nodes, second));
both |= a && b;
first_only |= a && !b;
}
(both, first_only)
};
let (both, embed_only) = sweep(&mut app, "Embed", "Edit Original");
assert!(both, "a wide bar shows Embed and Edit Original");
assert!(embed_only, "some width has room for Embed but not Edit Original");
app.run("select.set", json!({ "ids": [] })).unwrap();
app.select_tool("artboard");
let (both, link_only) = sweep(&mut app, "Transform", "Rearrange All Artboards");
assert!(both, "a wide bar shows the Artboard tool's Transform link and Rearrange All Artboards");
assert!(link_only, "some width has room for the Transform link but not Rearrange All Artboards");
}
/// The Control bar's Select Similar Objects button takes only the room every control ahead of it
/// left, so it never shows while the bar left out one of a selected image's controls for room.
#[test]
fn select_similar_stays_out_while_an_image_control_is_left_out() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
let img = image::RgbaImage::from_fn(20, 20, |x, _| image::Rgba(if x < 10 { [0, 0, 0, 255] } else { [255, 255, 255, 255] }));
let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let dir = vectorcraft_testkit::temp_dir("control-bar-similar-image");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("half.png");
std::fs::write(&path, &png).unwrap();
app.run("file.place", json!({"path": path, "link": false})).unwrap();
// With these groups off, the image's controls come right before the button.
for group in ["opacity", "graphicStyle", "alignDistribute", "transform"] {
app.run("window.control.options", json!({"group": group, "visible": false})).unwrap();
}
let ctx = egui::Context::default();
ctx.enable_accesskit();
crate::theme::install_fonts(&ctx);
let shows = |nodes: &[egui::accesskit::Node], name: &str| nodes.iter().any(|n| n.label().is_some_and(|l| l == name));
let mut seen = false;
for w in (150..=1600).step_by(2) {
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w as f32, 300.0));
control_bar_frame_in(&mut app, &ctx, screen, vec![]);
let nodes = control_bar_frame_in(&mut app, &ctx, screen, vec![]);
if shows(&nodes, "Select Similar Objects") {
assert!(shows(&nodes, "Remove Background"), "{w}: shown while Remove Background is left out");
seen = true;
}
}
assert!(seen, "a wide bar shows the button after the image's controls");
}
/// The Anchor Points group holds Convert and Anchors for direct-selected anchors; off, the bar keeps
/// its Anchor Point label.
#[test]
+1
View File
@@ -685,6 +685,7 @@ pub fn cancel(app: &mut VectorcraftApp) {
if d.kind == script_run::NOTICE_KIND {
script_run::notice_cancel(app, &d);
}
missing_links::cancelled(app, &d);
// Toolbar-only Flare options start no preview and must not cancel a gesture
// that was already active before opening them. The generic parameter dialog has a spec
// without a preview, yet previews some commands (Grid Repeat Options) when asked to.
+16 -5
View File
@@ -232,11 +232,15 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
Ok(r)
}
/// The Dimension tool's strip: the kind of dimension, and Tool Options.
pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The Dimension tool's strip: the kind of dimension, and Tool Options. In the Control bar
/// (`reserve` given), each button shows only while the row has room for it and `reserve` after it,
/// and the buttons after one left out are left out too; the floating strip (`None`) shows them all.
pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui, reserve: Option<f32>) {
let mut fit = reserve.map(crate::chrome::Fit::new);
let mut room = |ui: &Ui, text: &str| fit.as_mut().is_none_or(|f| f.room(ui, &[button_width(ui, text)]));
let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned();
for (id, label) in [("linear", "Linear"), ("angular", "Angular"), ("radial", "Radial")] {
if ui.selectable_label(mode == id, tl!(label)).clicked() {
if room(ui, tl!(label)) && ui.selectable_label(mode == id, tl!(label)).clicked() {
// Switching away cancels previews and unfinished picks.
if let Err(e) = app
.run("tool.select", json!({"tool":"selection"}))
@@ -247,13 +251,20 @@ pub fn controls(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
}
if ui.button(tl!("Tool Options…")).clicked()
if room(ui, tl!("Tool Options…"))
&& ui.button(tl!("Tool Options…")).clicked()
&& let Err(e) = open(app)
{
app.ui.status = e;
}
}
/// The width `ui.button` and `ui.selectable_label` give `text`: the text and the button padding on
/// both sides.
fn button_width(ui: &Ui, text: &str) -> f32 {
widgets::text_width(ui, RichText::new(text).text_style(egui::TextStyle::Button)) + 2.0 * ui.spacing().button_padding.x
}
/// Always available while measuring, including workspaces with the main Control bar hidden.
pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.session.tool_id() != "dimension" || app.ui.dialog.is_some() {
@@ -265,7 +276,7 @@ pub fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
ui.horizontal(|ui| controls(app, ui));
ui.horizontal(|ui| controls(app, ui, None));
let mode = app.session.tool_options()["mode"].as_str().unwrap_or("linear").to_owned();
let hint = match mode.as_str() {
"angular" => "Drag out from a corner, or click two straight lines, then click to place.",
+28 -15
View File
@@ -251,18 +251,28 @@ fn bar_of(app: &VectorcraftApp) -> Option<(bool, Bar)> {
/// The Control bar's envelope controls (as in the reference app) while an envelope, or content
/// whose envelope is being edited, is selected: Edit Envelope / Edit Contents; a warp's style,
/// orientation, bend and distortions, or a mesh's rows and columns; Reset Envelope Shape; and
/// Envelope Options.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
/// Envelope Options. The two editing buttons, each label with its menu or field, each radio button
/// and each other button show only while the row has room for them and `reserve` after them, and
/// the controls after one left out are left out too.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui, reserve: f32) {
let Some((editing, bar)) = bar_of(app) else { return };
let mut run: Option<(&str, Value)> = None;
let mut fit = crate::chrome::Fit::new(reserve);
crate::chrome::group_separator(ui);
for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] {
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
if fit.room(ui, &[24.0, 24.0]) {
for (contents, icon, tip) in [(false, "dc-mesh", tl!("Edit Envelope")), (true, "shapes", tl!("Edit Contents"))] {
if widgets::icon_button(ui, icon, tip, editing == contents, 24.0).clicked() && editing != contents {
run = Some(("object.envelope.editContents", json!({ "editing": contents })));
}
}
if ui.available_width() >= 4.0 {
ui.add_space(4.0);
}
}
ui.add_space(4.0);
let field = |ui: &mut egui::Ui, key: &str, label: &str, value: f64, suffix: &str| {
let field = |ui: &mut egui::Ui, fit: &mut crate::chrome::Fit, key: &str, label: &str, value: f64, suffix: &str| {
if !fit.room(ui, &[widgets::dim_label_width(ui, label), 46.0]) {
return None;
}
widgets::dim_label(ui, label);
widgets::plain_field(ui, ("cb-envelope", key), value, suffix, 0, 46.0)
};
@@ -270,24 +280,26 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
Bar::Warp { style, bend, h, v, horizontal } => {
let styles: Vec<&str> = WARP_STYLES.iter().map(|(_, l)| l.trim_end_matches('…')).collect();
let current = WARP_STYLES.iter().find(|(id, _)| id == style).map_or(style.as_str(), |(_, l)| l.trim_end_matches('…'));
widgets::dim_label(ui, tl!("Style:"));
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
run = Some((OPTIONS_CMD, json!({ "style": id })));
if fit.room(ui, &[widgets::dim_label_width(ui, tl!("Style:")), 110.0]) {
widgets::dim_label(ui, tl!("Style:"));
if let Some((id, _)) = widgets::dropdown(ui, "cb-envelope-style", current, &styles, 110.0).and_then(|i| WARP_STYLES.get(i)) {
run = Some((OPTIONS_CMD, json!({ "style": id })));
}
}
for (h, label) in [(true, tl!("Horizontal")), (false, tl!("Vertical"))] {
if widgets::radio(ui, label, *horizontal == h, true) {
if fit.room(ui, &[widgets::choice_width(ui, label)]) && widgets::radio(ui, label, *horizontal == h, true) {
run = Some((OPTIONS_CMD, json!({ "horizontal": h })));
}
}
for (key, label, value) in [("bend", tl!("Bend:"), *bend), ("h", tl!("H:"), *h), ("v", tl!("V:"), *v)] {
if let Some(n) = field(ui, key, label, value, "%") {
if let Some(n) = field(ui, &mut fit, key, label, value, "%") {
run = Some((OPTIONS_CMD, json!({ key: n.round().clamp(-100.0, 100.0) })));
}
}
}
Bar::Mesh { rows, cols } => {
for (key, label, value) in [("rows", tl!("Rows:"), *rows), ("cols", tl!("Columns:"), *cols)] {
if let Some(n) = field(ui, key, label, f64::from(value), "") {
if let Some(n) = field(ui, &mut fit, key, label, f64::from(value), "") {
run = Some((RESET_MESH, json!({ key: n.round().clamp(1.0, 50.0), "maintainShape": true })));
}
}
@@ -301,11 +313,12 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
Bar::TopObject => None,
};
if let Some(r) = reset
&& widgets::flat_button(ui, tl!("Reset"), 50.0).on_hover_text(tl!("Reset Envelope Shape")).clicked()
&& fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Reset"), 50.0)])
&& widgets::flat_button_fit(ui, tl!("Reset"), 50.0).on_hover_text(tl!("Reset Envelope Shape")).clicked()
{
run = Some(r);
}
if widgets::icon_button(ui, "dc-options", tl!("Envelope Options"), false, 24.0).clicked() {
if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "dc-options", tl!("Envelope Options"), false, 24.0).clicked() {
crate::menus::invoke(app, OPTIONS_CMD, json!({}));
}
if let Some((cmd, p)) = run
@@ -154,6 +154,42 @@ fn files_not_relinked_go_back_to_linked_file_not_found() {
assert_eq!(d.fields["notes"], json!(["a note"]));
super::super::cancel(&mut app);
assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 2);
let opened = crate::io::opened_with_notes(1, "a note");
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), vec!["a note".to_string()]))));
}
/// Relink, and Cancel, in the Find Linked Files dialog that Linked File Not Found's Find in Folder…
/// opened: the status bar shows the open's notes again and keeps them for its hover. Cancel of the
/// Links panel's Find in Folder… leaves the message as it is.
#[test]
fn the_last_find_in_folder_answer_keeps_the_open_notes() {
let root = folder("notes");
let mut app = app_for(&root);
let bytes = opened_without(&mut app, &root, &["a.png"]);
let found = at(&root, "found/a.png");
write(&found, &bytes[0]);
let missing = rows(app.ui.dialog.as_ref().unwrap(), "missing");
let notes = vec!["a note".to_string(), "another note".to_string()];
let opened = crate::io::opened_with_notes(2, "a note");
let find = |app: &mut VectorcraftApp, back: bool| {
let mut d = dialog(app, missing.clone(), vec![], back).unwrap();
d.fields.insert("notes".into(), json!(notes));
d.fields.insert("picks".into(), json!([found]));
app.ui.dialog = Some(d);
app.status("Something else");
};
find(&mut app, false);
super::super::cancel(&mut app);
assert_eq!(app.ui.status, "Something else");
find(&mut app, true);
super::super::cancel(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Cancel");
find(&mut app, true);
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.run("links.check", json!({})).unwrap()["missing"], 0);
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Relink");
}
/// A file is chosen when the choice is clear: the only same file, else the only file. A row the
+129 -7
View File
@@ -8,8 +8,8 @@
//! [`vectorcraft_engine::link_watch`]).
//!
//! Fields: `missing` (`[{name, path, ids}]`, the one asked about first), `modified` (the ids of
//! images whose file was modified), `notes` (the open's notes, shown with the line about the
//! modified files), `applyToAll`, `path` (the replacement file; picked when empty), `discard`
//! images whose file was modified), `notes` (the open's notes, shown again after the last question
//! or Cancel), `applyToAll`, `path` (the replacement file; picked when empty), `discard`
//! (Ignore) and `searchFolder` (set it and confirm for Find in Folder… on that folder).
use serde_json::{Value, json};
@@ -117,15 +117,27 @@ pub(super) fn notes(d: &Dialog) -> Vec<String> {
notes.filter_map(Value::as_str).map(str::to_string).collect()
}
/// Cancel of the dialog `d`: when it is this dialog, or the Find Linked Files dialog its Find in
/// Folder… opened, the rest of the questions are not asked and the status bar shows the open's
/// notes again ([`crate::io::show_opened`]), after the line about the modified files when Update
/// Links doesn't ask about them.
pub(super) fn cancelled(app: &mut VectorcraftApp, d: &Dialog) {
if d.kind == KIND || (d.kind == super::find_links::KIND && d.bool("back")) {
let modified = d.fields.get("modified").and_then(Value::as_array).map_or(0, Vec::len);
let notice = (modified > 0 && app.session.prefs.update_links != "askWhenModified").then(|| changed_on_disk_status(modified));
crate::io::show_opened(app, notice, notes(d));
}
}
/// Ask about the missing files `rest`, one at a time, then offer the modified ones `modified` for
/// update. A line about the modified files is followed by the open's `notes`
/// ([`crate::io::show_opened`]).
/// update. After the last question the status bar shows the open's `notes` again, after the line
/// about the modified files when there is one ([`crate::io::show_opened`]), so their hover lists
/// them whatever message the answers showed.
pub(crate) fn ask_next(app: &mut VectorcraftApp, rest: Vec<Value>, modified: Vec<Value>, notes: Vec<String>) {
if rest.is_empty() {
app.ui.dialog = None;
if let Some(notice) = ask_update(app, modified) {
crate::io::show_opened(app, Some(notice), notes);
}
let notice = ask_update(app, modified);
crate::io::show_opened(app, notice, notes);
} else {
open(app, rest, modified, &notes);
}
@@ -476,6 +488,116 @@ mod tests {
}
}
/// After the last question of the Linked File Not Found dialog the status bar shows the open's
/// notes again and keeps them for its hover, also when another message was shown meanwhile:
/// after Ignore, Ignore with Apply to All and Cancel, with either Update Links choice, and with
/// Ask When Modified while the update question opens. An open without notes leaves the message
/// as it is.
#[test]
fn the_last_missing_link_answer_keeps_the_open_notes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
let notes = vec!["a note".to_string(), "another note".to_string()];
let opened = crate::io::opened_with_notes(2, "a note");
let row = |n: u64| json!({ "name": format!("{n}.png"), "path": format!("/gone/{n}.png"), "ids": [n] });
let answer = |app: &mut VectorcraftApp, fields: &[(&str, bool)]| {
app.status("Something else");
let d = app.ui.dialog.as_mut().unwrap();
for (k, v) in fields {
d.fields.insert(k.to_string(), json!(v));
}
super::super::confirm(app).unwrap();
};
for prefs in ["manually", "askWhenModified"] {
app.session.prefs.update_links = prefs.into();
after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), notes.clone(), false);
answer(&mut app, &[("discard", true)]);
assert!(app.ui.dialog.is_none());
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Ignore, {prefs}");
after_open_for(&mut app, &json!({ "missingLinks": [row(1), row(2)] }), notes.clone(), false);
answer(&mut app, &[("discard", true), ("applyToAll", true)]);
assert!(app.ui.dialog.is_none());
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Apply to All, {prefs}");
after_open_for(&mut app, &json!({ "missingLinks": [row(1), row(2)], "modifiedLinks": [{ "ids": [3] }] }), notes.clone(), false);
app.status("Something else");
super::super::cancel(&mut app);
assert!(app.ui.dialog.is_none(), "Cancel asks nothing more");
let cancelled =
if prefs == "manually" { crate::io::opened_after_notice(&changed_on_disk_status(1), 2, "a note") } else { opened.clone() };
assert_eq!((&app.ui.status, &app.ui.status_notes), (&cancelled, &Some((cancelled.clone(), notes.clone()))), "Cancel, {prefs}");
}
after_open_for(&mut app, &json!({ "missingLinks": [row(1)], "modifiedLinks": [{ "ids": [3] }] }), notes.clone(), false);
answer(&mut app, &[("discard", true)]);
let d = app.ui.dialog.as_ref().expect("asks");
assert_eq!((d.kind.as_str(), d.str("__command")), (super::super::confirm::KIND, "links.update".to_string()));
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))));
app.ui.dialog = None;
after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), vec![], false);
answer(&mut app, &[("discard", true)]);
assert_eq!(app.ui.status, "Something else");
after_open_for(&mut app, &json!({ "missingLinks": [row(1)] }), vec![], false);
app.status("Something else");
super::super::cancel(&mut app);
assert_eq!(app.ui.status, "Something else");
app.session.prefs.update_links = "manually".into();
after_open_for(&mut app, &json!({ "missingLinks": [row(1)], "modifiedLinks": [{ "ids": [3] }] }), vec![], false);
app.status("Something else");
super::super::cancel(&mut app);
assert_eq!(app.ui.status, changed_on_disk_status(1), "Cancel keeps the line about the modified files");
}
/// Replace with a file given by path, with a picked file and with Apply to All: after the last
/// one the status bar shows the open's notes again. A canceled file picker leaves the question
/// open and the message as it is.
#[test]
fn the_last_replace_keeps_the_open_notes() {
let notes = vec!["a note".to_string(), "another note".to_string()];
let opened = crate::io::opened_with_notes(2, "a note");
let (mut app, dir, _) = opened_without_links("notes");
let found = dir.join("found");
std::fs::create_dir_all(&found).unwrap();
for n in ["a.png", "b.png"] {
std::fs::write(found.join(n), png([0, 0, 200])).unwrap();
}
app.ui.dialog.as_mut().unwrap().fields.insert("notes".into(), json!(notes));
app.ui.dialog.as_mut().unwrap().fields.insert("path".into(), json!(found.join("a.png").to_string_lossy()));
super::super::confirm(&mut app).unwrap();
assert_eq!(app.ui.dialog.as_ref().expect("asks about the next").fields["missing"][0]["name"], "b.png");
app.services.pick_open = Some(Box::new(|_: &crate::FilePick| None));
app.status("Something else");
assert_eq!(super::super::confirm(&mut app), Err("cancelled".to_string()));
assert_eq!(app.ui.dialog.as_ref().expect("still asks").fields["missing"][0]["name"], "b.png");
assert_eq!(app.ui.status, "Something else");
let b = found.join("b.png").to_string_lossy().into_owned();
app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(b.clone())));
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "picked");
let _ = std::fs::remove_dir_all(dir);
let (mut app, dir, _) = opened_without_links("notes-all");
let found = dir.join("found");
std::fs::create_dir_all(&found).unwrap();
for n in ["a.png", "b.png"] {
std::fs::write(found.join(n), png([0, 0, 200])).unwrap();
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("notes".into(), json!(notes));
d.fields.insert("applyToAll".into(), json!(true));
d.fields.insert("path".into(), json!(found.join("a.png").to_string_lossy()));
app.status("Something else");
super::super::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "both relinked");
assert_eq!((&app.ui.status, &app.ui.status_notes), (&opened, &Some((opened.clone(), notes.clone()))), "Apply to All");
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn apply_to_all_replaces_the_others_from_the_same_folder() {
let (mut app, dir, _) = opened_without_links("all");
+2
View File
@@ -2483,6 +2483,8 @@ digits Malayalam Malaiàlam
Save Variable Library… Desa la biblioteca de variables…
@msg a variable library holds text variables, and the document has none una biblioteca de variables conté variables de text, i el document no en té cap
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` la variable `{name}` no es pot desar en una biblioteca de variables: canvieu-li el nom perquè comenci amb una lletra o `_`, sense espais ni símbols que no siguin `-`, `_` i `.`
@msg {k} has control characters {k} conté caràcters de control
@msg {k} has control characters or line breaks {k} conté caràcters de control o salts de línia
@msg data set `{name}` holds a control character, which a variable library can't hold el conjunt de dades `{name}` conté un caràcter de control, que una biblioteca de variables no pot contenir
@msg data set `{name}` starts or ends with white space, which a variable library can't hold el conjunt de dades `{name}` comença o acaba amb un espai en blanc, que una biblioteca de variables no pot contenir
Loaded Texture Textura carregada
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6277,6 +6277,8 @@ mark-style Japanese Japonské
Save Variable Library… Uložit knihovnu proměnných…
@msg a variable library holds text variables, and the document has none knihovna proměnných obsahuje textové proměnné a dokument žádné nemá
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` proměnnou `{name}` nelze uložit do knihovny proměnných: přejmenujte ji tak, aby začínala písmenem nebo `_` a neobsahovala mezery ani jiné symboly než `-`, `_` a `.`
@msg {k} has control characters {k} obsahuje řídicí znaky
@msg {k} has control characters or line breaks {k} obsahuje řídicí znaky nebo zalomení řádků
@msg data set `{name}` holds a control character, which a variable library can't hold sada dat `{name}` obsahuje řídicí znak, který knihovna proměnných nemůže obsahovat
@msg data set `{name}` starts or ends with white space, which a variable library can't hold sada dat `{name}` začíná nebo končí prázdným znakem, který knihovna proměnných nemůže obsahovat
Make Graph Dynamic Nastavit graf jako dynamický
Can't render this file because it is too large.
+2
View File
@@ -6311,6 +6311,8 @@ mark-style Japanese Japansk
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` variablen `{name}` kan ikke gemmes i et variabelbibliotek: omdøb den, så den begynder med et bogstav eller `_`, uden mellemrum eller andre symboler end `-`, `_` og `.`
@msg data set `{name}` holds a control character, which a variable library can't hold datasættet `{name}` indeholder et kontroltegn, som et variabelbibliotek ikke kan indeholde
@msg data set `{name}` starts or ends with white space, which a variable library can't hold datasættet `{name}` begynder eller slutter med mellemrum, som et variabelbibliotek ikke kan indeholde
@msg {k} has control characters {k} indeholder kontroltegn
@msg {k} has control characters or line breaks {k} indeholder kontroltegn eller linjeskift
@msg variables that aren't text were left out: {kinds} variabler, der ikke er tekst, blev udeladt: {kinds}
@msg a new document won't read these variables back with their kind: {names} et nyt dokument læser ikke disse variabler tilbage med deres type: {names}
@msg a new document refuses this file because of these variables: {names} et nyt dokument afviser denne fil på grund af disse variabler: {names}
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2 -1
View File
@@ -8994,7 +8994,8 @@ breakText Glyphs Glyphen
@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind}-Dateien haben keine Transparenz, und die Datei hätte {what}: Reduzieren Sie sie zuerst (object.flattenTransparency)
@msg {kind} images can't be opened (see document.formats) {kind}-Bilder können nicht geöffnet werden (siehe document.formats)
@msg {k} `{v}` is not one of the choices {k} `{v}` ist keine der Auswahlmöglichkeiten
@msg {k} has control characters{_1} {k} enthält Steuerzeichen{_1}
@msg {k} has control characters {k} enthält Steuerzeichen
@msg {k} has control characters or line breaks {k} enthält Steuerzeichen oder Zeilenumbrüche
@msg {k} is too long (at most {MAX_TEXT} characters) {k} ist zu lang (höchstens {MAX_TEXT} Zeichen)
@msg {k} must be > 0 {k} muss > 0 sein
@msg {k} must be a number (pt) or a length such as "210 mm" {k} muss eine Zahl (pt) oder eine Länge wie „210 mm“ sein
Can't render this file because it is too large.
+2
View File
@@ -6134,6 +6134,8 @@ effect Points Σημεία
Save Variable Library… Αποθήκευση βιβλιοθήκης μεταβλητών…
@msg a variable library holds text variables, and the document has none μια βιβλιοθήκη μεταβλητών περιέχει μεταβλητές κειμένου και το έγγραφο δεν έχει καμία
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` η μεταβλητή `{name}` δεν μπορεί να αποθηκευτεί σε βιβλιοθήκη μεταβλητών: μετονομάστε την ώστε να ξεκινά με γράμμα ή `_`, χωρίς κενά ή σύμβολα εκτός από `-`, `_` και `.`
@msg {k} has control characters {k} περιέχει χαρακτήρες ελέγχου
@msg {k} has control characters or line breaks {k} περιέχει χαρακτήρες ελέγχου ή αλλαγές γραμμής
@msg data set `{name}` holds a control character, which a variable library can't hold το σύνολο δεδομένων `{name}` περιέχει χαρακτήρα ελέγχου, τον οποίο δεν μπορεί να περιέχει μια βιβλιοθήκη μεταβλητών
@msg data set `{name}` starts or ends with white space, which a variable library can't hold το σύνολο δεδομένων `{name}` ξεκινά ή τελειώνει με κενό διάστημα, το οποίο δεν μπορεί να περιέχει μια βιβλιοθήκη μεταβλητών
Filter Φίλτρο
Can't render this file because it is too large.
+2
View File
@@ -6222,6 +6222,8 @@ mark-style Japanese Japonesas
Save Variable Library… Guardar biblioteca de variables…
@msg a variable library holds text variables, and the document has none una biblioteca de variables contiene variables de texto y el documento no tiene ninguna
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` la variable `{name}` no se puede guardar en una biblioteca de variables: cámbiele el nombre para que empiece por una letra o `_`, sin espacios ni símbolos distintos de `-`, `_` y `.`
@msg {k} has control characters {k} contiene caracteres de control
@msg {k} has control characters or line breaks {k} contiene caracteres de control o saltos de línea
@msg data set `{name}` holds a control character, which a variable library can't hold el conjunto de datos `{name}` contiene un carácter de control, que una biblioteca de variables no puede contener
@msg data set `{name}` starts or ends with white space, which a variable library can't hold el conjunto de datos `{name}` empieza o termina con espacio en blanco, que una biblioteca de variables no puede contener
Make Graph Dynamic Hacer gráfico dinámico
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6145,6 +6145,8 @@ mark-style Japanese Japanilainen
Save Variable Library… Tallenna muuttujakirjasto…
@msg a variable library holds text variables, and the document has none muuttujakirjasto sisältää tekstimuuttujia, eikä asiakirjassa ole yhtään
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` muuttujaa `{name}` ei voi tallentaa muuttujakirjastoon: nimeä se uudelleen niin, että se alkaa kirjaimella tai merkillä `_` eikä sisällä välilyöntejä tai muita symboleja kuin `-`, `_` ja `.`
@msg {k} has control characters {k} sisältää ohjausmerkkejä
@msg {k} has control characters or line breaks {k} sisältää ohjausmerkkejä tai rivinvaihtoja
@msg data set `{name}` holds a control character, which a variable library can't hold tietojoukko `{name}` sisältää ohjausmerkin, jota muuttujakirjasto ei voi sisältää
@msg data set `{name}` starts or ends with white space, which a variable library can't hold tietojoukko `{name}` alkaa tai päättyy tyhjeeseen, jota muuttujakirjasto ei voi sisältää
Filter Suodata
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -6272,6 +6272,8 @@ theme Appearance Apparence
Save Variable Library… Enregistrer une bibliothèque de variables…
@msg a variable library holds text variables, and the document has none une bibliothèque de variables contient des variables de texte, et le document n’en a aucune
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` la variable `{name}` ne peut pas être enregistrée dans une bibliothèque de variables : renommez-la pour qu’elle commence par une lettre ou `_`, sans espaces ni symboles autres que `-`, `_` et `.`
@msg {k} has control characters {k} contient des caractères de contrôle
@msg {k} has control characters or line breaks {k} contient des caractères de contrôle ou des sauts de ligne
@msg data set `{name}` holds a control character, which a variable library can't hold le jeu de données `{name}` contient un caractère de contrôle, qu’une bibliothèque de variables ne peut pas contenir
@msg data set `{name}` starts or ends with white space, which a variable library can't hold le jeu de données `{name}` commence ou se termine par une espace, qu’une bibliothèque de variables ne peut pas contenir
Make Graph Dynamic Rendre le graphe dynamique
Can't render this file because it is too large.
+2 -1
View File
@@ -5939,7 +5939,8 @@ antiAlias Sharp शार्प
@msg {id} is not a slice {id} एक स्लाइस नहीं है
@msg {id} opens another app: only the person at the keyboard can run it {id} एक अन्य ऐप खोलता है: केवल कीबोर्ड पर व्यक्ति इसे चला सकता है
@msg {k} `{v}` is not one of the choices {k} `{v}` ऑप्शन में से एक नहीं है
@msg {k} has control characters{_1} {k} में नियंत्रण वर्ण हैं{_1}
@msg {k} has control characters {k} में नियंत्रण वर्ण हैं
@msg {k} has control characters or line breaks {k} में नियंत्रण वर्ण या लाइन ब्रेक हैं
@msg {k} is too long (at most {MAX_TEXT} characters) {k} बहुत लंबा है (अधिकांश {MAX_TEXT} वर्णों पर)
@msg {k} must be {{size?, position?}} {k} को {{size?, position?}} होना चाहिए
@msg {k} must be > 0 {k} होना चाहिए > 0
Can't render this file because it is too large.
+2
View File
@@ -6260,6 +6260,8 @@ imageTrace Outline Körvonal
Save Variable Library… Változókönyvtár mentése…
@msg a variable library holds text variables, and the document has none a változókönyvtár szöveges változókat tartalmaz, a dokumentumban pedig nincs ilyen
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` a(z) `{name}` változó nem menthető változókönyvtárba: nevezze át úgy, hogy betűvel vagy `_` jellel kezdődjön, szóközök és a `-`, `_` és `.` jeltől eltérő szimbólumok nélkül
@msg {k} has control characters {k} vezérlőkaraktereket tartalmaz
@msg {k} has control characters or line breaks {k} vezérlőkaraktereket vagy sortöréseket tartalmaz
@msg data set `{name}` holds a control character, which a variable library can't hold a(z) `{name}` adatkészlet vezérlőkaraktert tartalmaz, amelyet a változókönyvtár nem tartalmazhat
@msg data set `{name}` starts or ends with white space, which a variable library can't hold a(z) `{name}` adatkészlet szóközzel kezdődik vagy végződik, amelyet a változókönyvtár nem tartalmazhat
Make Graph Dynamic Diagram dinamikussá tétele
Can't render this file because it is too large.
+2 -1
View File
@@ -9044,7 +9044,8 @@ breakText Glyphs Glifi
@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) i file {kind} non hanno trasparenza, e il file avrebbe {what}: appiattiscila prima (object.flattenTransparency)
@msg {kind} images can't be opened (see document.formats) le immagini {kind} non possono essere aperte (vedi document.formats)
@msg {k} `{v}` is not one of the choices {k} `{v}` non è una delle scelte disponibili
@msg {k} has control characters{_1} {k} contiene caratteri di controllo{_1}
@msg {k} has control characters {k} contiene caratteri di controllo
@msg {k} has control characters or line breaks {k} contiene caratteri di controllo o interruzioni di riga
@msg {k} is too long (at most {MAX_TEXT} characters) {k} è troppo lungo (al massimo {MAX_TEXT} caratteri)
@msg {k} must be > 0 {k} deve essere > 0
@msg {k} must be a number (pt) or a length such as "210 mm" {k} deve essere un numero (pt) o una lunghezza come "210 mm"
Can't render this file because it is too large.
+2
View File
@@ -5941,6 +5941,8 @@ lighting Light ライト
Save Variable Library… 変数ライブラリを保存…
@msg a variable library holds text variables, and the document has none 変数ライブラリにはテキスト変数を保存しますが、このドキュメントにはテキスト変数がありません
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 変数 `{name}` は変数ライブラリに保存できません: 英字または `_` で始まり、スペースや `-`、`_`、`.` 以外の記号を含まない名前に変更してください
@msg {k} has control characters {k} に制御文字が含まれています
@msg {k} has control characters or line breaks {k} に制御文字または改行が含まれています
@msg data set `{name}` holds a control character, which a variable library can't hold データセット `{name}` に変数ライブラリに保存できない制御文字が含まれています
@msg data set `{name}` starts or ends with white space, which a variable library can't hold データセット `{name}` の先頭または末尾に変数ライブラリに保存できない空白があります
Make Graph Dynamic グラフを動的にする
Can't render this file because it is too large.
+2
View File
@@ -5939,6 +5939,8 @@ imageTrace Outline კონტური
Save Variable Library… ცვლადების ბიბლიოთეკის შენახვა…
@msg a variable library holds text variables, and the document has none ცვლადების ბიბლიოთეკა ტექსტურ ცვლადებს შეიცავს, დოკუმენტს კი არცერთი არ აქვს
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` ცვლადი `{name}` ვერ შეინახება ცვლადების ბიბლიოთეკაში: გადაარქვით სახელი ისე, რომ იწყებოდეს ასოთი ან `_`-ით, ჰარეების და `-`, `_` და `.`-ის გარდა სხვა სიმბოლოების გარეშე
@msg {k} has control characters {k} შეიცავს მმართველ სიმბოლოებს
@msg {k} has control characters or line breaks {k} შეიცავს მმართველ სიმბოლოებს ან სტრიქონის წყვეტებს
@msg data set `{name}` holds a control character, which a variable library can't hold მონაცემთა ნაკრები `{name}` შეიცავს მმართველ სიმბოლოს, რომელსაც ცვლადების ბიბლიოთეკა ვერ შეინახავს
@msg data set `{name}` starts or ends with white space, which a variable library can't hold მონაცემთა ნაკრები `{name}` იწყება ან მთავრდება ჰარით, რომელსაც ცვლადების ბიბლიოთეკა ვერ შეინახავს
Make Graph Dynamic დიაგრამის დინამიკურად ქცევა
Can't render this file because it is too large.
+2
View File
@@ -5858,6 +5858,8 @@ lighting Light 광원
Save Variable Library… 변수 라이브러리 저장…
@msg a variable library holds text variables, and the document has none 변수 라이브러리에는 텍스트 변수가 들어가는데 문서에 텍스트 변수가 없습니다
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 변수 `{name}`은(는) 변수 라이브러리에 저장할 수 없습니다: 문자나 `_`로 시작하고 공백이나 `-`, `_`, `.` 이외의 기호가 없도록 이름을 바꾸세요
@msg {k} has control characters {k}에 제어 문자가 있습니다
@msg {k} has control characters or line breaks {k}에 제어 문자 또는 줄 바꿈이 있습니다
@msg data set `{name}` holds a control character, which a variable library can't hold 데이터 세트 `{name}`에 변수 라이브러리에 담을 수 없는 제어 문자가 있습니다
@msg data set `{name}` starts or ends with white space, which a variable library can't hold 데이터 세트 `{name}`의 앞이나 뒤에 변수 라이브러리에 담을 수 없는 공백이 있습니다
Make Graph Dynamic 그래프를 동적으로 만들기
Can't render this file because it is too large.
+2
View File
@@ -8103,6 +8103,8 @@ mark-style Japanese Japans
Save Variable Library… Variabelenbibliotheek opslaan…
@msg a variable library holds text variables, and the document has none een variabelenbibliotheek bevat tekstvariabelen, en het document heeft er geen
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` variabele `{name}` kan niet in een variabelenbibliotheek worden opgeslagen: geef haar een naam die met een letter of `_` begint, zonder spaties of andere symbolen dan `-`, `_` en `.`
@msg {k} has control characters {k} bevat stuurtekens
@msg {k} has control characters or line breaks {k} bevat stuurtekens of regeleinden
@msg data set `{name}` holds a control character, which a variable library can't hold gegevensset `{name}` bevat een stuurteken, dat een variabelenbibliotheek niet kan bevatten
@msg data set `{name}` starts or ends with white space, which a variable library can't hold gegevensset `{name}` begint of eindigt met witruimte, die een variabelenbibliotheek niet kan bevatten
Make Graph Dynamic Grafiek dynamisch maken
Can't render this file because it is too large.
+2 -1
View File
@@ -6131,6 +6131,8 @@ mark-style Japanese Japońskie
Save Variable Library… Zapisz bibliotekę zmiennych…
@msg a variable library holds text variables, and the document has none biblioteka zmiennych zawiera zmienne tekstowe, a dokument nie ma żadnej
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` nie można zapisać zmiennej `{name}` w bibliotece zmiennych: zmień jej nazwę tak, aby zaczynała się literą lub `_`, bez spacji i symboli innych niż `-`, `_` i `.`
@msg {k} has control characters {k} zawiera znaki sterujące
@msg {k} has control characters or line breaks {k} zawiera znaki sterujące lub podziały wierszy
@msg data set `{name}` holds a control character, which a variable library can't hold zestaw danych `{name}` zawiera znak sterujący, którego biblioteka zmiennych nie może zawierać
@msg data set `{name}` starts or ends with white space, which a variable library can't hold zestaw danych `{name}` zaczyna się lub kończy białym znakiem, którego biblioteka zmiennych nie może zawierać
Make Graph Dynamic Uczyń wykres dynamicznym
@@ -9282,7 +9284,6 @@ breakText Glyphs Glify
@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) pliki {kind} nie obsługują przezroczystości, a plik zawierałby {what}: najpierw spłaszcz przezroczystość (object.flattenTransparency)
@msg {kind} images can't be opened (see document.formats) obrazów {kind} nie można otwierać (patrz document.formats)
@msg {k} `{v}` is not one of the choices {k} `{v}` nie jest jedną z dostępnych opcji
@msg {k} has control characters{_1} {k} zawiera znaki sterujące{_1}
@msg {k} is too long (at most {MAX_TEXT} characters) {k} jest zbyt długie (najwyżej {MAX_TEXT} znaków)
@msg {k} must be > 0 {k} musi być > 0
@msg {k} must be a number (pt) or a length such as "210 mm" {k} musi być liczbą (pt) lub długością, na przykład "210 mm"
Can't render this file because it is too large.
+2 -1
View File
@@ -9588,7 +9588,8 @@ swatch Yellow Green Verde-amarelado
@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) os arquivos {kind} não têm transparência, e o arquivo teria {what}: achate-a primeiro (object.flattenTransparency)
@msg {kind} images can't be opened (see document.formats) as imagens {kind} não podem ser abertas (veja document.formats)
@msg {k} `{v}` is not one of the choices {k} `{v}` não é uma das opções
@msg {k} has control characters{_1} {k} tem caracteres de controle{_1}
@msg {k} has control characters {k} tem caracteres de controle
@msg {k} has control characters or line breaks {k} tem caracteres de controle ou quebras de linha
@msg {k} is too long (at most {MAX_TEXT} characters) {k} é longo demais (no máximo {MAX_TEXT} caracteres)
@msg {k} must be > 0 {k} deve ser > 0
@msg {k} must be a number (pt) or a length such as "210 mm" {k} deve ser um número (pt) ou um comprimento como "210 mm"
Can't render this file because it is too large.
+2
View File
@@ -5884,6 +5884,8 @@ lighting Light Свет
Save Variable Library… Сохранить библиотеку переменных…
@msg a variable library holds text variables, and the document has none библиотека переменных содержит текстовые переменные, а в документе их нет
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` переменную `{name}` нельзя сохранить в библиотеке переменных: переименуйте её так, чтобы имя начиналось с буквы или `_` и не содержало пробелов и символов, кроме `-`, `_` и `.`
@msg {k} has control characters {k} содержит управляющие символы
@msg {k} has control characters or line breaks {k} содержит управляющие символы или разрывы строк
@msg data set `{name}` holds a control character, which a variable library can't hold набор данных `{name}` содержит управляющий символ, который не может храниться в библиотеке переменных
@msg data set `{name}` starts or ends with white space, which a variable library can't hold набор данных `{name}` начинается или заканчивается пробелом, который не может храниться в библиотеке переменных
Make Graph Dynamic Сделать диаграмму динамической
Can't render this file because it is too large.
+2 -1
View File
@@ -7575,7 +7575,8 @@ digits Hindi Hindi
@msg {g} is not a Live Paint group {g} är inte en direktmålningsgrupp
@msg {id} is not a slice {id} är inte en skiva
@msg {k} `{v}` is not one of the choices {k} `{v}` är inte ett av valen
@msg {k} has control characters{_1} {k} innehåller styrtecken{_1}
@msg {k} has control characters {k} innehåller styrtecken
@msg {k} has control characters or line breaks {k} innehåller styrtecken eller radbrytningar
@msg {k} is too long (at most {MAX_TEXT} characters) {k} är för lång (högst {MAX_TEXT} tecken)
@msg {k} must be {{size?, position?}} {k} måste vara {{size?, position?}}
@msg {k} must be > 0 {k} måste vara > 0
Can't render this file because it is too large.
+36
View File
@@ -2194,6 +2194,42 @@ fn recolor_notes_on_objects_left_unchanged_translate_with_their_counts() {
}
}
/// `file.info`'s refusal of a value with control characters is two whole messages, one for the
/// fields that take line breaks and one for the fields that don't, and each is translated in every
/// complete-message language with the field's key kept as written.
#[test]
fn file_info_control_character_refusals_translate_in_every_complete_catalog() {
use serde_json::json;
let literals = message_literals_in(&["engine"]);
for key in ["{k} has control characters", "{k} has control characters or line breaks"] {
assert!(literals.contains(key), "the message scan doesn't find {key:?}");
}
let mut s = vectorcraft_engine::Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let refusals = [
(json!({"description": "a\u{7}b"}), "description", "has control characters"),
(json!({"copyrightNotice": "a\u{7}b"}), "copyrightNotice", "has control characters"),
(json!({"title": "a\nb"}), "title", "has control characters or line breaks"),
(json!({"author": "a\nb"}), "author", "has control characters or line breaks"),
(json!({"authorTitle": "a\rb"}), "authorTitle", "has control characters or line breaks"),
(json!({"copyrightUrl": "a\u{7}b"}), "copyrightUrl", "has control characters or line breaks"),
(json!({"keywords": ["ok", "a\tb"]}), "keywords", "has control characters or line breaks"),
];
for (params, field, rule) in refusals {
let error = s.execute("file.info", &params).unwrap_err().to_string();
assert_eq!(error, format!("invalid parameters for `file.info`: {field} {rule}"));
for code in COMPLETE_MESSAGES {
let shown = message(Lang::from_code(code).expect("registered"), &error);
// Whole runs of Latin letters, so that Spanish's "control" is not read as English and
// Korean's `description에` still shows the key.
let words: Vec<&str> = shown.split(|c: char| !c.is_ascii_alphabetic()).collect();
let english = ["has", "characters", "or", "line", "breaks"];
assert!(words.iter().all(|w| !english.contains(w)), "{code}: {shown}");
assert!(words.contains(&field), "{code}: {shown}");
}
}
}
/// Every complete-message catalog has a row for each error that `type.compositeFonts.set`,
/// `.export` and `.import`, the composite font checks behind them and the Composite Fonts dialog
/// give, and translates them with the font name, the key and the class key kept, and the reason
+2
View File
@@ -6382,6 +6382,8 @@ theme Appearance Görünüm
Save Variable Library… Değişken Kitaplığını Kaydet…
@msg a variable library holds text variables, and the document has none değişken kitaplığı metin değişkenleri içerir ve belgede hiç metin değişkeni yok
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` `{name}` değişkeni değişken kitaplığına kaydedilemiyor: adını bir harf veya `_` ile başlayacak, boşluk ya da `-`, `_` ve `.` dışında simge içermeyecek şekilde değiştirin
@msg {k} has control characters {k} denetim karakterleri içeriyor
@msg {k} has control characters or line breaks {k} denetim karakterleri veya satır sonları içeriyor
@msg data set `{name}` holds a control character, which a variable library can't hold `{name}` veri kümesi, değişken kitaplığının tutamayacağı bir denetim karakteri içeriyor
@msg data set `{name}` starts or ends with white space, which a variable library can't hold `{name}` veri kümesi, değişken kitaplığının tutamayacağı bir boşlukla başlıyor veya bitiyor
Long Uzun
Can't render this file because it is too large.
+2 -1
View File
@@ -5877,6 +5877,8 @@ lighting Light Світло
Save Variable Library… Зберегти бібліотеку змінних…
@msg a variable library holds text variables, and the document has none бібліотека змінних містить текстові змінні, а в документі їх немає
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` змінну `{name}` не можна зберегти в бібліотеці змінних: перейменуйте її так, щоб ім’я починалося з літери або `_` і не містило пробілів і символів, крім `-`, `_` і `.`
@msg {k} has control characters {k} містить керівні символи
@msg {k} has control characters or line breaks {k} містить керівні символи або розриви рядків
@msg data set `{name}` holds a control character, which a variable library can't hold набір даних `{name}` містить керівний символ, який бібліотека змінних не може містити
@msg data set `{name}` starts or ends with white space, which a variable library can't hold набір даних `{name}` починається або закінчується пробілом, який бібліотека змінних не може містити
Make Graph Dynamic Зробити діаграму динамічною
@@ -9117,7 +9119,6 @@ breakText Glyphs Гліфи
@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) файли {kind} не мають прозорості, а файл містив би таке: {what}; спочатку зведіть прозорість (object.flattenTransparency)
@msg {kind} images can't be opened (see document.formats) зображення {kind} не можна відкрити (див. document.formats)
@msg {k} `{v}` is not one of the choices {k} `{v}` не є одним із варіантів
@msg {k} has control characters{_1} {k} містить керівні символи{_1}
@msg {k} is too long (at most {MAX_TEXT} characters) {k} задовгий (не більше {MAX_TEXT} символів)
@msg {k} must be > 0 {k} має бути > 0
@msg {k} must be a number (pt) or a length such as "210 mm" {k} має бути числом (pt) або довжиною на зразок "210 mm"
Can't render this file because it is too large.
+2
View File
@@ -5882,6 +5882,8 @@ theme Appearance Giao diện
Save Variable Library… Lưu thư viện biến số…
@msg a variable library holds text variables, and the document has none thư viện biến số chứa các biến số văn bản, và tài liệu không có biến số văn bản nào
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` không thể lưu biến số `{name}` vào thư viện biến số: hãy đổi tên để bắt đầu bằng một chữ cái hoặc `_`, không có khoảng trắng hay ký hiệu nào ngoài `-`, `_` và `.`
@msg {k} has control characters {k} chứa ký tự điều khiển
@msg {k} has control characters or line breaks {k} chứa ký tự điều khiển hoặc ngắt dòng
@msg data set `{name}` holds a control character, which a variable library can't hold tập dữ liệu `{name}` chứa ký tự điều khiển mà thư viện biến số không thể chứa
@msg data set `{name}` starts or ends with white space, which a variable library can't hold tập dữ liệu `{name}` bắt đầu hoặc kết thúc bằng khoảng trắng mà thư viện biến số không thể chứa
Make Graph Dynamic Tạo biểu đồ động
Can't render this file because it contains an unexpected character in line 205 and column 5.
+2 -1
View File
@@ -6119,6 +6119,8 @@ lighting Light 光源
Save Variable Library… 保存变量库…
@msg a variable library holds text variables, and the document has none 变量库包含文本变量,而文档中没有文本变量
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 无法将变量 `{name}` 保存到变量库中:请重命名,使其以字母或 `_` 开头,且不含空格或 `-`、`_`、`.` 以外的符号
@msg {k} has control characters {k} 包含控制字符
@msg {k} has control characters or line breaks {k} 包含控制字符或换行
@msg data set `{name}` holds a control character, which a variable library can't hold 数据集 `{name}` 包含变量库无法保存的控制字符
@msg data set `{name}` starts or ends with white space, which a variable library can't hold 数据集 `{name}` 的开头或结尾有变量库无法保存的空白字符
Make Graph Dynamic 使图表动态化
@@ -9268,7 +9270,6 @@ breakText Glyphs 字形
@msg {kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency) {kind} 文件不支持透明度,而该文件会包含 {what}:请先拼合 (object.flattenTransparency)
@msg {kind} images can't be opened (see document.formats) 无法打开 {kind} 图像 (参见 document.formats)
@msg {k} `{v}` is not one of the choices {k} `{v}` 不在可选项之中
@msg {k} has control characters{_1} {k} 含有控制字符{_1}
@msg {k} is too long (at most {MAX_TEXT} characters) {k} 过长 (最多 {MAX_TEXT} 个字符)
@msg {k} must be > 0 {k} 必须 > 0
@msg {k} must be a number (pt) or a length such as "210 mm" {k} 必须是数字 (pt) 或 "210 mm" 这样的长度
Can't render this file because it is too large.
+2
View File
@@ -5953,6 +5953,8 @@ lighting Light 光源
Save Variable Library… 儲存變數資料庫…
@msg a variable library holds text variables, and the document has none 變數資料庫包含文字變數,但文件中沒有文字變數
@msg variable `{name}` can't be saved in a variable library: rename it to start with a letter or `_`, without spaces or symbols other than `-`, `_` and `.` 無法將變數 `{name}` 儲存到變數資料庫:請重新命名,使其以字母或 `_` 開頭,且不含空格或 `-`、`_`、`.` 以外的符號
@msg {k} has control characters {k} 包含控制字元
@msg {k} has control characters or line breaks {k} 包含控制字元或換行
@msg data set `{name}` holds a control character, which a variable library can't hold 資料集 `{name}` 包含變數資料庫無法儲存的控制字元
@msg data set `{name}` starts or ends with white space, which a variable library can't hold 資料集 `{name}` 的開頭或結尾有變數資料庫無法儲存的空白字元
Make Graph Dynamic 使圖表動態化
Can't render this file because it is too large.
+52 -23
View File
@@ -41,6 +41,13 @@ fn art_option_boxes(app: &mut VectorcraftApp, ui: &mut Ui, compact: bool) {
}
}
/// The widths [`art_option_boxes`] gives Move and Scale Artwork with Artboard, or with `compact` the
/// boxes alone.
fn art_option_widths(ui: &Ui, compact: bool) -> [f32; 2] {
let label = |text: &str| widgets::choice_width(ui, if compact { "" } else { text });
[label(tl!("Move Artwork with Artboard")), label(tl!("Scale Artwork with Artboard"))]
}
/// The room Move and Scale Artwork with Artboard need with their labels in the Control bar; with
/// less, the boxes show alone.
const ART_OPTIONS_WIDTH: f32 = 370.0;
@@ -56,15 +63,17 @@ const NAME_FIELD_ROOM: f32 = 330.0;
/// The Control bar while the Artboard tool is in use, in place of the object controls: the active
/// artboard's preset and orientation, New Artboard and Delete Artboard, its name (in a narrower
/// field when the bar is short of room), Artboard Options, Rearrange All Artboards and the Transform
/// link (the Transform panel in a popover), which all fit in the narrowest window, left of the bar's
/// panel menu button; then Move and Scale Artwork with Artboard (the boxes alone, with their
/// names in tooltips, when the bar is short of room) and, while the bar has room, the reference
/// point the Transform panel also uses with the artboard's X and Y, and its W and H with the
/// Constrain Width and Height Proportions link. Each control runs the command its Properties panel
/// or Transform panel counterpart runs. With `options` false (the bar's panel menu has its Artboard
/// Options group off), the controls other than the Transform link and the fields are left out; with
/// `transform` false (its Transform group off), the Transform link and the fields are. Returns
/// false, and draws nothing, while another tool is in use.
/// link (the Transform panel in a popover), left of the bar's panel menu button; then Move and Scale
/// Artwork with Artboard (the boxes alone, with their names in tooltips, when the bar is short of
/// room) and, while the bar has room, the reference point the Transform panel also uses with the
/// artboard's X and Y, and its W and H with the Constrain Width and Height Proportions link. Each
/// control, and each label with its field or menu, shows only while the row has room for it, with
/// the Transform link's room left after the controls ahead of the link, and the controls after one
/// left out are left out too, except the Transform link, which shows whenever it fits. Each control runs the command its Properties panel or Transform panel
/// counterpart runs. With `options` false (the bar's panel menu has its Artboard Options group off),
/// the controls other than the Transform link and the fields are left out; with `transform` false
/// (its Transform group off), the Transform link and the fields are. Returns false, and draws
/// nothing, while another tool is in use.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool, transform: bool) -> bool {
if app.session.tool_id() != "artboard" {
return false;
@@ -74,38 +83,58 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, options: bool,
let i = selected(app, n);
let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() else { return true };
let r = ab.rect;
let mut fit = crate::chrome::Fit::new(if transform { crate::chrome::link_reserve(ui) } else { 0.0 });
if options {
super::properties::artboard_size_row(app, ui, i, r.width(), r.height());
if widgets::icon_button(ui, "dc-new-item", tl!("New Artboard"), false, 24.0).clicked() {
if fit.room(ui, &super::properties::artboard_size_row_widths(ui)) {
super::properties::artboard_size_row(app, ui, i, r.width(), r.height());
}
if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "dc-new-item", tl!("New Artboard"), false, 24.0).clicked() {
add_new(app);
}
if widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() {
if fit.room(ui, &[24.0]) && widgets::icon_button_enabled(ui, "trash-2", tl!("Delete Artboard"), false, n > 1, 24.0).clicked() {
delete_selected(app);
}
ui.separator();
fit.separator(ui);
// The field shows the name as it is (names are never translated). A new name is applied on Enter
// or a click elsewhere, and an empty field or Escape keeps the old one, as in the Properties panel.
ui.label(egui::RichText::new(tl!("Name:")).size(12.0).color(t.text));
let width = if ui.available_width() < NAME_FIELD_ROOM { 100.0 } else { 120.0 };
if let Some(name) = widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), width, 1).filter(|n| !n.is_empty()) {
app.run("artboard.setProps", json!({"index": i, "name": name})).ok();
let label = egui::RichText::new(tl!("Name:")).size(12.0).color(t.text);
let label_width = widgets::text_width(ui, label.clone());
// The room after the label decides the field's width.
let width = if ui.available_width() - label_width - ui.spacing().item_spacing.x < NAME_FIELD_ROOM { 100.0 } else { 120.0 };
if fit.room(ui, &[label_width, width]) {
ui.label(label);
if let Some(name) =
widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), width, 1).filter(|n| !n.is_empty())
{
app.run("artboard.setProps", json!({"index": i, "name": name})).ok();
}
}
if widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 24.0).clicked() {
if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 24.0).clicked() {
app.run("tool.options", json!({"tool": "artboard"})).ok();
}
if widgets::icon_button_enabled(ui, "dc-rearrange", tl!("Rearrange All Artboards"), false, n > 1, 24.0).clicked() {
if fit.room(ui, &[24.0]) && widgets::icon_button_enabled(ui, "dc-rearrange", tl!("Rearrange All Artboards"), false, n > 1, 24.0).clicked() {
open_rearrange(app);
}
ui.separator();
fit.separator(ui);
}
if transform {
fit.set_reserve(0.0);
// The link is its own group: it shows whenever it fits, even after an option was left out.
if transform && crate::chrome::Fit::new(0.0).room(ui, &[super::transform::link_width(ui)]) {
super::transform::link(app, ui);
crate::chrome::group_separator(ui);
}
if options {
art_option_boxes(app, ui, ui.available_width() < ART_OPTIONS_WIDTH);
let full = art_option_widths(ui, false);
let spacing = ui.spacing().item_spacing.x;
let compact = ui.available_width() < ART_OPTIONS_WIDTH || ui.available_width() < full.iter().map(|w| w + spacing).sum::<f32>();
// The fields after the boxes need far more room than the spacing after them, so the boxes
// can end the row.
if fit.room_at_end(ui, &if compact { art_option_widths(ui, true) } else { full }) {
art_option_boxes(app, ui, compact);
}
}
if !transform || ui.available_width() < INLINE_FIELDS_WIDTH {
// With no room left for a control above, the fields after it are left out too.
if !transform || !fit.room(ui, &[]) || ui.available_width() < INLINE_FIELDS_WIDTH {
return true;
}
if options {
+14 -8
View File
@@ -349,19 +349,25 @@ pub(crate) fn preset_dropdown(app: &mut VectorcraftApp, ui: &mut Ui, traced_with
}
/// The Control bar for one selected Image Trace object: its preset, view, the Image Trace panel
/// and Expand. Whether it is one.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
/// and Expand. Whether it is one. Each label with its menu, and each button, shows only while the row
/// has room for it and `reserve` after it, and the controls after one left out are left out too.
pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) -> bool {
let Some((preset, view)) = selected_trace(app) else { return false };
widgets::dim_label(ui, tl!("Preset:"));
preset_dropdown(app, ui, &preset, 150.0);
view_row(app, ui, view, 190.0);
if widgets::icon_button(ui, "image", tl!("Image Trace"), false, 24.0).clicked() {
let mut fit = crate::chrome::Fit::new(reserve);
if fit.room(ui, &[widgets::dim_label_width(ui, tl!("Preset:")), 150.0]) {
widgets::dim_label(ui, tl!("Preset:"));
preset_dropdown(app, ui, &preset, 150.0);
}
if fit.room(ui, &[widgets::dim_label_width(ui, tl!("View:")), 190.0]) {
view_row(app, ui, view, 190.0);
}
if fit.room(ui, &[24.0]) && widgets::icon_button(ui, "image", tl!("Image Trace"), false, 24.0).clicked() {
open_settings(app);
}
if widgets::flat_button(ui, tl!("Expand"), 64.0).clicked() {
if fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Expand"), 64.0)]) && widgets::flat_button_fit(ui, tl!("Expand"), 64.0).clicked() {
app.run("imageTrace.expand", json!({})).ok();
}
crate::chrome::group_separator(ui);
fit.separator(ui);
true
}
@@ -1051,7 +1051,7 @@ fn the_control_bar_and_properties_show_a_renamed_or_changed_preset_as_the_panel_
if properties {
crate::panels::properties::show(app, ui);
} else {
ui.horizontal(|ui| control_bar(app, ui));
ui.horizontal(|ui| control_bar(app, ui, 0.0));
}
});
out.textures_delta.clear();
+15 -2
View File
@@ -356,7 +356,7 @@ fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(4.0);
let w = (ui.available_width() - 6.0) / 2.0;
let links = crate::panels::links::ID;
ui.horizontal(|ui| crate::place::link_buttons(app, ui, id, linked, &name, Some(w)));
ui.horizontal(|ui| crate::place::link_buttons(app, ui, id, linked, &name, crate::place::LinkButtons::Width(w)));
ui.horizontal(|ui| {
if app.services.pick_open.is_some() && widgets::flat_button(ui, tl!("Replace…"), w).clicked() {
crate::panels::links::replace(app, vec![id]);
@@ -631,7 +631,7 @@ fn artboard_size_controls_inner(
let mut fit = None;
ui.horizontal(|ui| {
dim_label(ui, tl!("Preset:"));
preset = widgets::combo(ui, "ab-preset", &current, 140.0, |ui| {
preset = widgets::combo(ui, "ab-preset", &current, PRESET_MENU_WIDTH, |ui| {
let mut chosen = None;
if let Some(enabled) = fits {
for (selection, label) in [(false, tl!("Fit to Artwork Bounds")), (true, tl!("Fit to Selected Art"))] {
@@ -664,6 +664,19 @@ fn artboard_size_controls_inner(
(preset.map(|(w, h)| ArtboardSizeChoice::Preset(w, h)).or_else(|| orientation.map(ArtboardSizeChoice::Orientation)), fit)
}
/// The width of the artboard size row's Preset menu.
const PRESET_MENU_WIDTH: f32 = 140.0;
/// The width of the artboard size row's Portrait and Landscape buttons, 26 points each, with the
/// 2 points between them.
const ORIENTATION_BUTTONS_WIDTH: f32 = 2.0 * 26.0 + 2.0;
/// The widths of the parts of [`artboard_size_row`] in a row, as [`crate::chrome::Fit::room`] takes
/// them: the Preset label, its menu, and the Portrait and Landscape buttons.
pub(crate) fn artboard_size_row_widths(ui: &Ui) -> [f32; 3] {
[widgets::dim_label_width(ui, tl!("Preset:")), PRESET_MENU_WIDTH, ORIENTATION_BUTTONS_WIDTH]
}
/// Artboard `i`'s Fill, its background `color`: the chip shows it (the None paint when the artboard
/// has none) and a click on it opens the Color Picker for the artboard. The list sets None, White or
/// Black (in the document's color mode) or opens the Color Picker.
+23 -13
View File
@@ -251,35 +251,45 @@ fn selected_instance_symbols(app: &VectorcraftApp) -> Option<Vec<String>> {
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/edit-symbols.html>),
/// and the Reset button, which gives them their symbol's scale, rotation and shear again
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html>).
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
/// Edit Symbol, Replace with its menu and Reset each show only while the row has room for them and
/// `reserve` after them, and the controls after one left out are left out too.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
let Some(symbols) = selected_instance_symbols(app) else { return };
let Some(st) = app.session.active() else { return };
let names: Vec<String> = st.doc.symbols.iter().map(|s| s.name.clone()).collect();
let shared = symbols.first().filter(|first| symbols.iter().all(|s| s == *first)).cloned().unwrap_or_default();
let mut fit = crate::chrome::Fit::new(reserve);
// Edit Symbol: the instances' symbol in symbol editing mode, when they share one.
if !shared.is_empty()
&& fit.room(ui, &[24.0])
&& widgets::icon_button(ui, "dc-place-symbol", tl!("Edit Symbol"), false, 24.0).clicked()
&& let Err(e) = app.run("symbol.edit", json!({}))
{
app.status(e);
}
ui.label(egui::RichText::new(tl!("Replace:")).size(12.0).color(Tokens::get(ui.ctx()).text));
let options: Vec<&str> = names.iter().map(String::as_str).collect();
if let Some(name) = widgets::dropdown_names(ui, "cb-symbol-replace", &shared, &options, 140.0).and_then(|i| names.get(i))
&& *name != shared
&& let Err(e) = app.run("symbol.replace", json!({ "name": name }))
{
app.status(e);
let replace = egui::RichText::new(tl!("Replace:")).size(12.0).color(Tokens::get(ui.ctx()).text);
if fit.room(ui, &[widgets::text_width(ui, replace.clone()), 140.0]) {
ui.label(replace);
let options: Vec<&str> = names.iter().map(String::as_str).collect();
if let Some(name) = widgets::dropdown_names(ui, "cb-symbol-replace", &shared, &options, 140.0).and_then(|i| names.get(i))
&& *name != shared
&& let Err(e) = app.run("symbol.replace", json!({ "name": name }))
{
app.status(e);
}
}
// Reset: Reset Transformation, enabled while an instance's scale, rotation or shear differs
// from its symbol's.
let reset = transformed_instances_selected(app);
if ui.add_enabled_ui(reset, |ui| widgets::flat_button_fit(ui, tl!("Reset"), 48.0).on_hover_text(tl!("Reset Transformation"))).inner.clicked()
if fit.room(ui, &[widgets::flat_button_fit_width(ui, tl!("Reset"), 48.0)])
&& ui.add_enabled_ui(reset, |ui| widgets::flat_button_fit(ui, tl!("Reset"), 48.0).on_hover_text(tl!("Reset Transformation"))).inner.clicked()
&& let Err(e) = app.run("symbol.instance.resetTransform", json!({}))
{
app.status(e);
}
ui.add_space(4.0);
if ui.available_width() >= 4.0 {
ui.add_space(4.0);
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -1325,7 +1335,7 @@ mod tests {
let bar = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
ui.horizontal(|ui| control_bar(app, ui));
ui.horizontal(|ui| control_bar(app, ui, 0.0));
});
out.textures_delta.clear();
out
@@ -1505,7 +1515,7 @@ mod tests {
fn bar(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<egui::Shape> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
ui.horizontal(|ui| control_bar(app, ui));
ui.horizontal(|ui| control_bar(app, ui, 0.0));
});
out.textures_delta.clear();
out.shapes.into_iter().map(|c| c.shape).collect()
@@ -1601,7 +1611,7 @@ mod tests {
let bar = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
ui.horizontal(|ui| control_bar(app, ui));
ui.horizontal(|ui| control_bar(app, ui, 0.0));
});
out.textures_delta.clear();
out
+3 -2
View File
@@ -501,9 +501,10 @@ fn link_text(ui: &Ui) -> egui::RichText {
egui::RichText::new(tl!("Transform")).size(12.0).color(Tokens::get(ui.ctx()).text).underline()
}
/// The width of the Transform [`link`] in `ui`.
/// The width of the Transform [`link`] in `ui`: its text, and the point that the underline's stroke
/// and the text's placement on the pixel grid can add at its right end.
pub fn link_width(ui: &Ui) -> f32 {
widgets::text_width(ui, link_text(ui))
widgets::text_width(ui, link_text(ui)) + 1.0
}
/// The width and height of [`constrain_link`]'s button.
+49 -19
View File
@@ -714,36 +714,61 @@ pub fn image_summary(i: &Value) -> (String, String) {
/// The Control bar for the one selected image: Linked File or Embedded, its file name, colour mode
/// and effective resolution, Embed or Unembed, Edit Original (a linked file), Image Trace, Mask,
/// Crop Image and Remove Background. Whether one image is selected.
pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool {
/// Crop Image and Remove Background. Whether one image is selected. A file name the row has no room
/// for is left out alone; after it, each control shows only while the row has room for it and
/// `reserve` after it, and the controls after one left out are left out too.
pub fn control_bar_details(app: &mut VectorcraftApp, ui: &mut egui::Ui, reserve: f32) -> bool {
let Some(i) = selected_image_info(app) else { return false };
let (name, details) = image_summary(i);
let (Some(id), linked) = (i["id"].as_u64(), i["linked"] == true) else { return false };
let t = Tokens::get(ui.ctx());
// The file name is a link: its menu relinks (replaces) the image and reaches the Links
// panel's other actions for it.
let resp = ui
.add(egui::Label::new(egui::RichText::new(&name).size(12.0).color(t.accent).underline()).sense(egui::Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand);
egui::Popup::menu(&resp).show(|ui| link_menu(app, ui, id, linked));
ui.separator();
ui.label(egui::RichText::new(details).size(12.0).color(t.text_dim));
ui.separator();
link_buttons(app, ui, id, linked, &name, None);
crate::panels::image_trace::trace_button(app, ui, crate::panels::image_trace::TRACE_BUTTON_W);
let name_text = egui::RichText::new(&name).size(12.0).color(t.accent).underline();
if crate::chrome::Fit::new(reserve).room(ui, &[widgets::text_width(ui, name_text.clone())]) {
let resp = ui.add(egui::Label::new(name_text).sense(egui::Sense::click())).on_hover_cursor(egui::CursorIcon::PointingHand);
egui::Popup::menu(&resp).show(|ui| link_menu(app, ui, id, linked));
crate::chrome::group_separator(ui);
}
let mut fit = crate::chrome::Fit::new(reserve);
let details = egui::RichText::new(details).size(12.0).color(t.text_dim);
if fit.room(ui, &[widgets::text_width(ui, details.clone())]) {
ui.label(details);
}
fit.separator(ui);
link_buttons(app, ui, id, linked, &name, LinkButtons::Fit(&mut fit));
// The Image Trace button grows past its usual width for a longer translated label.
let arrow = widgets::SPLIT_ARROW;
let trace = widgets::flat_button_fit_width(ui, tl!("Image Trace"), crate::panels::image_trace::TRACE_BUTTON_W - arrow) + arrow;
if fit.room(ui, &[trace]) {
crate::panels::image_trace::trace_button(app, ui, trace);
}
for (label, cmd, w) in [
(tl!("Mask"), "object.maskImage", 52.0),
(tl!("Crop Image"), "ui.cropImage", 84.0),
(tl!("Remove Background"), crate::addons::image::REMOVE_BACKGROUND, 128.0),
] {
if widgets::flat_button(ui, label, w).clicked() {
if fit.room(ui, &[widgets::flat_button_fit_width(ui, label, w)]) && widgets::flat_button_fit(ui, label, w).clicked() {
crate::menus::invoke(app, cmd, json!({}));
}
}
crate::chrome::group_separator(ui);
fit.separator(ui);
true
}
/// How [`link_buttons`] sizes its buttons.
pub(crate) enum LinkButtons<'a> {
/// Each button is this wide (Properties).
Width(f32),
/// Each button fits its label and shows only while the Control bar's row has room for it.
Fit(&'a mut crate::chrome::Fit),
}
/// The least widths of the Embed, Edit Original and Unembed… buttons fitted to their labels.
const EMBED_WIDTH: f32 = 60.0;
const EDIT_ORIGINAL_WIDTH: f32 = 92.0;
const UNEMBED_WIDTH: f32 = 80.0;
/// The menu of the Control bar's file name for image `id`: Replace… (another file takes the
/// image's place with its own aspect ratio; an embedded image becomes linked), Go To Link, Edit
/// Original, Update Link and Placement Options….
@@ -771,17 +796,22 @@ fn link_menu(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool)
}
/// Embed (and Edit Original) for linked image `id`, Unembed… for an embedded one named `name`
/// (Control bar, Properties). The buttons are `width` wide, or fit their labels.
pub fn link_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool, name: &str, width: Option<f32>) {
let w = |fit: f32| width.unwrap_or(fit);
/// (Control bar, Properties), sized as `size` says.
pub(crate) fn link_buttons(app: &mut VectorcraftApp, ui: &mut egui::Ui, id: u64, linked: bool, name: &str, mut size: LinkButtons) {
let mut button = |ui: &mut egui::Ui, label: &str, least: f32| match &mut size {
LinkButtons::Width(w) => widgets::flat_button(ui, label, *w).clicked(),
LinkButtons::Fit(fit) => {
fit.room(ui, &[widgets::flat_button_fit_width(ui, label, least)]) && widgets::flat_button_fit(ui, label, least).clicked()
}
};
if linked {
if widgets::flat_button(ui, tl!("Embed"), w(60.0)).clicked() {
if button(ui, tl!("Embed"), EMBED_WIDTH) {
app.run("links.embed", json!({ "ids": [id] })).ok();
}
if widgets::flat_button(ui, tl!("Edit Original"), w(92.0)).clicked() {
if button(ui, tl!("Edit Original"), EDIT_ORIGINAL_WIDTH) {
crate::menus::invoke(app, "links.editOriginal", json!({ "id": id }));
}
} else if widgets::flat_button(ui, tl!("Unembed…"), w(80.0)).clicked() {
} else if button(ui, tl!("Unembed…"), UNEMBED_WIDTH) {
crate::panels::links::unembed(app, id, name);
}
}
+46 -24
View File
@@ -496,13 +496,15 @@ pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::
/// The active tool's options in the Control bar: Mirror & Cut's axis and the side it keeps, Puppet
/// Warp's mesh and pins (set through `tool.setOption`). The Artboard tool's options are drawn by
/// [`crate::panels::artboards::control_bar`].
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::addons::shapes::control_bar(app, ui) {
/// [`crate::panels::artboards::control_bar`]. Each label with its field or menu, and each button,
/// shows only while the row has room for it and `reserve` after it, and the controls after one left
/// out are left out too ([`crate::chrome::Fit`]).
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
if crate::addons::shapes::control_bar(app, ui, reserve) {
return;
}
if app.session.tool_id() == "dimension" {
return crate::dialogs::dimension::controls(app, ui);
return crate::dialogs::dimension::controls(app, ui, Some(reserve));
}
/// (value, label) of each choice.
type Choices = &'static [(&'static str, &'static str)];
@@ -511,31 +513,40 @@ pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
("keep", "Keep:", &[("left", "Left"), ("right", "Right"), ("top", "Top"), ("bottom", "Bottom")]),
];
if app.session.tool_id() == "puppetWarp" {
return puppet_warp_options(app, ui);
return puppet_warp_options(app, ui, reserve);
}
if app.session.tool_id() == vectorcraft_tools::cropimage::ID {
return crop_options(app, ui);
return crop_options(app, ui, reserve);
}
if app.session.tool_id() != "mirrorCut" {
return;
}
let t = Tokens::get(ui.ctx());
let opts = app.session.tool_options();
let mut fit = crate::chrome::Fit::new(reserve);
for (key, label, choices) in MIRROR {
ui.label(egui::RichText::new(tl!(label)).size(12.0).color(t.text));
let text = egui::RichText::new(tl!(label)).size(12.0).color(t.text);
if !fit.room(ui, &[widgets::text_width(ui, text.clone()), MIRROR_DROPDOWN_WIDTH]) {
break;
}
ui.label(text);
let cur = opts[key].as_str().unwrap_or_default();
let shown = choices.iter().find(|(v, _)| *v == cur).map_or(cur, |(_, l)| *l);
let labels: Vec<&str> = choices.iter().map(|(_, l)| *l).collect();
if let Some((value, _)) = widgets::dropdown(ui, ("cb-tool", key), shown, &labels, 96.0).and_then(|i| choices.get(i)) {
if let Some((value, _)) = widgets::dropdown(ui, ("cb-tool", key), shown, &labels, MIRROR_DROPDOWN_WIDTH).and_then(|i| choices.get(i)) {
app.run("tool.setOption", json!({ "key": key, "value": value })).ok();
}
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
/// The width of Mirror & Cut's Axis and Keep menus in the Control bar.
const MIRROR_DROPDOWN_WIDTH: f32 = 96.0;
/// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's
/// `rect` option, then Apply (Enter) and Cancel (Escape).
fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) {
/// `rect` option, then Apply (Enter) and Cancel (Escape), each while the row has room for it and
/// `reserve` after it.
fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
let set = app.session.tool_options()["rect"].as_array().and_then(|v| match v.iter().filter_map(Value::as_f64).collect::<Vec<_>>()[..] {
[x, y, w, h] => Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h)),
_ => None,
@@ -544,8 +555,13 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let units = app.session.general_unit();
let c = r.center();
let mut fit = crate::chrome::Fit::new(reserve);
for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", r.width()), ("height", "H:", r.height())] {
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
let text = egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim);
if !fit.room(ui, &[widgets::text_width(ui, text.clone()), 80.0]) {
break;
}
widgets::field_label(ui, text);
if let Some(nv) = widgets::num_field(ui, ("cb-crop", k), Some(v), units, 80.0) {
let (mut c, mut w, mut h) = (c, r.width(), r.height());
match k {
@@ -558,15 +574,15 @@ fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) {
app.run("tool.setOption", json!({ "key": "rect", "value": rect })).ok();
}
}
ui.separator();
fit.separator(ui);
for (label, key) in [(tl!("Apply"), ToolKey::Enter), (tl!("Cancel"), ToolKey::Escape)] {
if widgets::flat_button(ui, label, 64.0).clicked() {
if fit.room(ui, &[widgets::flat_button_fit_width(ui, label, 64.0)]) && widgets::flat_button_fit(ui, label, 64.0).clicked() {
let view = app.view_info();
let r = app.session.tool_key(key, Mods::default(), view);
crate::canvas::apply_requests(app, r);
}
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
/// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as
@@ -1141,23 +1157,29 @@ fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Optio
}
/// The Puppet Warp tool's Control bar: Expand (how far the mesh reaches past the art), Show Mesh
/// and Select All Pins.
fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui) {
/// and Select All Pins, each while the row has room for it and `reserve` after it.
fn puppet_warp_options(app: &mut VectorcraftApp, ui: &mut Ui, reserve: f32) {
let t = Tokens::get(ui.ctx());
let opts = app.session.tool_options();
ui.label(egui::RichText::new(tl!("Expand:")).size(12.0).color(t.text));
let unit = app.session.general_unit();
if let Some(v) = widgets::num_field(ui, ("cb-tool", "expand"), opts["expand"].as_f64(), unit, 64.0) {
app.run("tool.setOption", json!({ "key": "expand", "value": v })).ok();
let mut fit = crate::chrome::Fit::new(reserve);
let text = egui::RichText::new(tl!("Expand:")).size(12.0).color(t.text);
if fit.room(ui, &[widgets::text_width(ui, text.clone()), 64.0]) {
ui.label(text);
let unit = app.session.general_unit();
if let Some(v) = widgets::num_field(ui, ("cb-tool", "expand"), opts["expand"].as_f64(), unit, 64.0) {
app.run("tool.setOption", json!({ "key": "expand", "value": v })).ok();
}
}
let show = opts["showMesh"].as_bool().unwrap_or(true);
if widgets::check(ui, "Show Mesh", show, true) {
if fit.room(ui, &[widgets::choice_width(ui, "Show Mesh")]) && widgets::check(ui, "Show Mesh", show, true) {
app.run("tool.setOption", json!({ "key": "showMesh", "value": !show })).ok();
}
if widgets::flat_button(ui, "Select All Pins", 104.0).clicked() {
if fit.room(ui, &[widgets::flat_button_fit_width(ui, "Select All Pins", 104.0)])
&& widgets::flat_button_fit(ui, "Select All Pins", 104.0).clicked()
{
app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok();
}
crate::chrome::group_separator(ui);
fit.separator(ui);
}
#[cfg(test)]
+3 -2
View File
@@ -30,7 +30,7 @@ pub use buttons::{
SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button,
primary_button_enabled, secondary_button, split_button, split_button_named, toggle_icon,
};
pub(crate) use buttons::{button_width, icon_button_info, primary_button_width, secondary_button_width};
pub(crate) use buttons::{button_width, flat_button_fit_width, icon_button_info, primary_button_width, secondary_button_width};
#[cfg(test)]
use chips::gradient_chip_colors;
pub use chips::{ProxyClicks, chip_button, chip_button_named, fill_stroke_proxy, gradient_chip, paint_chip, proxy_chip, swatch_tile};
@@ -53,7 +53,7 @@ pub use dropdowns::{
#[cfg(test)]
pub(crate) use dropdowns::{menu_floor, menu_room_either};
pub use labels::{dim_label, dim_name, divider, field_label, label_row, link_label, section_header, section_toggle, section_toggle_name, subheader};
pub(crate) use labels::{label_field, take_previous_label, text_width};
pub(crate) use labels::{dim_label_width, label_field, take_previous_label, text_width};
pub(crate) use numeric::{ANY, round_to, set_bare_numbers_are_points, typed_unit};
pub use numeric::{
SpinPick, mixed_field, mixed_field_named, num_field, num_field_live, num_field_named, num_field_refusing, num_field_within,
@@ -73,6 +73,7 @@ pub use text::{
exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets,
text_presets_named,
};
pub(crate) use toggles::choice_width;
pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button};
#[cfg(test)]
pub(crate) use wheel_stream::WHEEL_STREAM_GAP;
+8 -2
View File
@@ -40,8 +40,7 @@ pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response {
/// A [`flat_button`] that expands to fit localized labels wider than `min_width`.
pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), t.text_strong);
let width = (galley.size().x + 24.0).max(min_width);
let width = flat_button_fit_width(ui, text, min_width);
let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click());
let rect = rect.shrink2(vec2(0.0, 0.5));
ui.painter().rect_filled(rect, CornerRadius::same(5), t.button);
@@ -51,6 +50,13 @@ pub fn flat_button_fit(ui: &mut Ui, text: &str, min_width: f32) -> Response {
resp
}
/// The width [`flat_button_fit`] gives `text`, translated: its label and padding, at least
/// `min_width`.
pub(crate) fn flat_button_fit_width(ui: &Ui, text: &str, min_width: f32) -> f32 {
let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), egui::FontId::proportional(12.5), Color32::PLACEHOLDER);
(galley.size().x + 24.0).max(min_width)
}
/// Width of a [`split_button`]'s arrow.
pub const SPLIT_ARROW: f32 = 18.0;
+5
View File
@@ -6,6 +6,11 @@ pub(crate) fn text_width(ui: &Ui, text: egui::RichText) -> f32 {
egui::WidgetText::from(text).into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x
}
/// The width [`dim_label`] gives `text`, translated.
pub(crate) fn dim_label_width(ui: &Ui, text: &str) -> f32 {
text_width(ui, egui::RichText::new(tl!(text)).size(12.5))
}
/// Bold section header (Properties panel).
pub fn section_header(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
+6
View File
@@ -1,5 +1,11 @@
use super::{Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Vec2, pos2, vec2};
/// The width a [`check`] or [`radio`] row takes with `label`, translated: the box and the text
/// after it, as [`choice_row`] allocates them.
pub(crate) fn choice_width(ui: &Ui, label: &str) -> f32 {
18.0 + ui.painter().layout_no_wrap(tl!(label).to_string(), egui::FontId::proportional(12.5), Color32::PLACEHOLDER).size().x
}
/// The row of a checkbox or radio button: allocates a 13 pt box plus `text` (already translated),
/// draws the text and returns the box, the response and the box's border colour.
fn choice_row(ui: &mut Ui, text: &str, enabled: bool) -> (Rect, Response, Color32) {
+3
View File
@@ -671,6 +671,7 @@ since 2026-10-01.
- **Control bar panel menu turns groups of options on or off:** the Control bar's panel menu lists the bar's groups of options (Snapping, Anchor Points, Tool Options, Object Options, Image Options, Fill and Stroke, Opacity, Graphic Style, Character, Paragraph, Vertical Alignment, Align and Distribute, Transform, Artboard Options and Document Options) above Dock to Top and Dock to Bottom, each checked while it is on; a click turns that group off or on (`window.control.options {group, visible?}`), and a group that is off stays out of the bar even while its tool or object is selected, as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). The list of groups and what each holds, every group on to start, a group that is off leaving its room to the groups after it, and keeping the groups with the preferences and the workspace are VectorCraft's own rules. Regression tests: `the_control_bar_panel_menu_turns_each_group_on_or_off`, `the_control_bar_keeps_its_label_and_menu_with_every_group_off`, `the_control_bar_panel_menu_scrolls_in_a_short_window`, `the_control_bar_options_command_turns_a_group_on_or_off`, `groups_that_are_off_leave_their_room_to_the_later_groups`, `the_artboard_tool_s_control_bar_follows_its_two_groups` and `the_anchor_points_group_holds_convert_and_anchors` in `crates/ui-egui/src/chrome/tests.rs`, `the_image_options_group_holds_the_image_and_trace_controls` in `crates/ui-egui/src/tests/place.rs`, `the_character_and_paragraph_groups_hold_the_type_controls` in `crates/ui-egui/src/panels/paragraph/tests.rs`, `saved_workspaces_restore_the_control_bar_groups` in `crates/ui-egui/src/workspaces.rs`.
- **Control bar separators stand only between groups:** the Control bar draws a separator between two groups of options only when a control follows it in the bar. When the groups after a separator are off in the panel menu, do not apply to the selection or the tool, or have no room in the window, the bar ends with the last control and no separator; two separators with nothing between them show as one. Each separator keeps its room, so the groups keep their places. This is VectorCraft's own rule. Regression test: `no_separator_is_left_at_the_end_of_the_control_bar` in `crates/ui-egui/src/chrome/tests.rs`.
- **Fill and Stroke and Opacity stay inside a narrow Control bar:** the Control bar leaves out a Fill and Stroke, Opacity or Graphic Style control it has no room for, whole, instead of drawing it past the panel menu button or under it. The bar draws its controls from left to right, each only when the room left still holds it, so a narrower control can still show after a wider one that was left out: the Opacity link with its field can show when the Stroke link with its weight field did not fit, the Style link with its chip can show when the Opacity link with its field did not, and with nothing selected Preferences can show when Document Setup did not fit. With something selected, the Fill and Stroke, Opacity, Graphic Style and Character controls show only while the Transform link still has room after them, so the bar keeps the link and leaves those controls out first. The Character controls show whole or not at all, and while they are left out the Paragraph, vertical alignment and Align controls after them are left out too. The Snapping menu, the selection's label, the Convert and Anchors groups, the isolation button, Document Setup and Preferences also show only while they fit, and a group separator only while it has room. This is VectorCraft's own rule. Regression tests: `the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window`, `a_narrow_control_bar_shows_the_style_link_without_the_opacity_field`, `a_narrow_control_bar_shows_preferences_without_document_setup` and `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`, `the_control_bar_trims_its_paragraph_section_after_the_controls_to_its_right` in `crates/ui-egui/src/panels/paragraph/tests.rs`.
- **Tool, image, trace, envelope, symbol and artboard controls stay inside a narrow Control bar:** a shape tool's options, the Dimension, Mirror & Cut, Puppet Warp and Crop Image tools' options, a linked or embedded image's controls, an Image Trace object's, a warp or mesh envelope's, symbol instances' and the Artboard tool's controls each show a control, or a label with its field or menu, only while the room left in the bar holds it and, with something selected, the Transform link's room after it. Once one is left out, the rest of its group is left out too, so each group loses controls from its right end; an image's file name too long for the room is left out alone, and the Artboard tool's Transform link shows whenever it fits. The panel menu button shows in every width, docked or floating. Buttons in these groups grow to hold a longer translated label instead of cutting it off, and the Transform link keeps room for its underline. While one of these controls is left out, Select Similar Objects is left out too. This is VectorCraft's own rule. Regression tests: `the_control_bar_s_controls_stay_inside_the_bar_in_a_narrow_window` (in English and German, docked to the top or bottom and floating), `a_narrow_control_bar_shows_embed_without_edit_original_and_the_artboard_link_without_its_options` and `select_similar_stays_out_while_an_image_control_is_left_out` in `crates/ui-egui/src/chrome/tests.rs`.
- **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged.
- **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged.
- **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`.
@@ -990,6 +991,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`).
- The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`).
- The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`).
- File Info's (`file.info`) error for a value with control characters shows in the status bar in the interface language for every field: "description has control characters" for Description and Copyright Notice, which take line breaks and tabs, and "title has control characters or line breaks" for the other text fields and each keyword in a list. Each is its own message with a row in every catalog that translates all status and error messages, and the field's key stays as written (`file_info_control_character_refusals_translate_in_every_complete_catalog`, `control_characters_are_refused_with_the_field_rule`).
- A parameter schema's errors show in the status bar with what the value must be in the interface language, for every type a schema has (a number with each kind of range and with a unit, an integer and its range, true or false, a string, a list of names, an object id, a list of object ids, a color, a point, an object and a list), in every catalog that translates all status and error messages; the path, the refused value, the bounds, the unit and the names stay as the English gives them. The error for a value that is not one of its names is also translated in Bulgarian, Bosnian, Danish, Indonesian and Dutch (`parameter_schema_expected_phrases_are_translated`, `bracketed_message_parts_translate_only_as_exact_messages`).
- The untranslated report lists the status and error messages of the effects, swatch libraries, raster sizes and encoders, 3D solids and path operations that a catalog lacks, the messages that a `.map_err` closure or a named error handler returns, and the phrases a parameter schema's error builds for what a value must be (`untranslated_report_checks_every_message_crate_and_schema_phrase`, `message_scan_reads_map_err_closures`, `message_scan_reads_named_map_err_handlers`).
- Spanish messages (2026-11-13): the Spanish catalog (`crates/ui-egui/src/i18n/es.tsv`) has an `@msg` row for 114 of the 167 status and error messages that the untranslated report (`untranslated_report`) lists as missing in all 23 complete-message languages: the errors of the image encoders, color profiles, swatch exchange files and color books, 3D and Revolve, path operations, DXF import, the script engine and the parameter checks. Open #7211 and #7378 translate 51 of the others (the composite font and SVG filter errors), and 2 stay in English. The rows keep the placeholders, backticked names, accepted values and limits of their English (`spanish_translates_encoder_profile_swatch_revolve_and_parameter_errors`); native review remains; scores and estimates unchanged.
@@ -1947,6 +1949,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- The Links panel menu's Sort by Size ([Links panel](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/import-from-other-apps/links-panel-in-illustrator.html)) lists the links by file size, smallest first (`links.list {sort: "size"}`). VectorCraft's own rules: a file's size is the one Link Info shows, rows without one (embedded images, a missing file whose size the document does not keep) come last, and equal sizes go by name.
- When a document opens with notes and with linked files that changed on disk (Update Links: Manually) or, on the web, can't be read, the status bar shows the line about the linked files, then counts the notes and names the first, and hovering it lists the notes. The same holds when the line about modified files follows the Linked File Not Found dialog, and File › New from Template… shows the notes of the file it opens as File › Open does (`an_open_with_modified_links_keeps_link_notice_and_import_notes_reachable`, `new_from_template_preserves_open_warnings`, `an_open_with_unreadable_links_keeps_import_notes_reachable`, `modified_links_after_the_missing_ones_keep_the_open_notes`, `combined_open_and_link_status_lists_notes_on_hover_and_for_accessibility`).
- On the web, when a document opens with linked files that can't be read and linked files that changed on disk (Update Links: Manually), the status bar shows the line about the unreadable files, and the line about the changed files is the first of the notes its hover lists, ahead of the open's notes. With Ask When Modified the update question opens and the line about the unreadable files stays (`a_web_open_with_unreadable_and_modified_links_keeps_both_lines`).
- After the last answer in the Linked File Not Found dialog (Replace, with a path or a picked file, Ignore, either with Apply to All, or Relink in the Find Linked Files dialog its Find in Folder… opens), and after Cancel in either dialog, the status bar shows the open's notes again, after the line about modified files when Update Links is Manually, and hovering it lists them, also when the relink or another action showed a message meanwhile. A canceled file picker leaves the question open (`the_last_missing_link_answer_keeps_the_open_notes`, `the_last_replace_keeps_the_open_notes`, `the_last_find_in_folder_answer_keeps_the_open_notes`).
- Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id.
- Linked images and placed documents in the art of the stored brushes (a scatter or art brush's art, a pattern brush's tiles) get Links panel rows of their own the same way, one per linked or placed object in each brush tile, naming the brush; Relink, Update, Embed, Placement Options, Link Info and Go To act on that art and write it back into the brush library, and the ids they are listed by are kept there. Evidence: `links_in_stored_brush_art_are_listed_and_checked_with_ids`, `links_commands_act_on_images_in_stored_brush_art_by_id`, `links_in_stored_brush_art_open_by_id_and_unreadable_entries_stay`.
- Saving a `.vectorcraft` file reads the images and placed documents in the art of the stored brushes as it reads those in the layers. A linked image shown only in a brush's art is saved as its preview, or with Include Linked Files as its file's bytes, and an image key that the layers or a brush's art shows embedded keeps its full bytes. Saving for an older version replaces the placed documents in a brush's art with groups of the art they show, with that art's resources, before a precision rounds it, so the preview and PDF match the file. Data Recovery copies keep the same images. These are VectorCraft's own rules for its native format; the [help page](https://helpx.adobe.com/illustrator/using/saving-artwork.html) describes Include Linked Files as embedding the files linked to the artwork. Evidence: `brush_only_link_saves_preview_unless_included`, `shared_brush_image_key_keeps_full_bytes_for_embedded_use` (vectorcraft-format), `saved_for_older_apps_placed_documents_in_brush_art_become_their_art`, `recovery_brush_links_keep_proxy_and_embedded_full_bytes` (vectorcraft-engine).