Merge pull request #7795 from dotCooCoo/fix/tool-image-trace-envelope-symbol-type-and

Control bar: tool, image, trace, envelope, symbol and artboard controls stay inside the bar in a narrow window
This commit is contained in:
BFlat
2026-11-15 16:40:54 +07:00
committed by GitHub
19 changed files with 663 additions and 204 deletions
+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]
+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
+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) {
+1
View File
@@ -670,6 +670,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`.