mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
594 lines
30 KiB
Rust
594 lines
30 KiB
Rust
//! Transform panel: reference point, X/Y/W/H with the constrain link, rotate and shear, flips,
|
|
//! live-shape properties and the Scale Corners / Scale Strokes & Effects options. While the
|
|
//! Artboard tool is in use it shows and edits the active artboard's X/Y/W/H instead (a new W or H
|
|
//! resizes every selected artboard), and with only ruler guides selected their X (vertical guides)
|
|
//! and Y (horizontal ones).
|
|
|
|
use egui::Ui;
|
|
use serde_json::json;
|
|
use vectorcraft_doc::{LiveShape, Node, NodeKind};
|
|
use vectorcraft_geom::Rect;
|
|
use vectorcraft_geom::shapes::CornerKind;
|
|
|
|
use super::{corner_radius_row, first_selected, pstate, set_pstate};
|
|
use crate::dialogs::corners::kind_label as corner_kind;
|
|
use crate::theme::Tokens;
|
|
use crate::widgets::{self, menu_item};
|
|
use crate::{VectorcraftApp, icons};
|
|
|
|
pub const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0];
|
|
/// The shear angle's presets: steep enough to be useful, short of the degenerate ±90°.
|
|
pub const SHEAR_PRESETS: [f64; 9] = [-60.0, -45.0, -30.0, -15.0, 0.0, 15.0, 30.0, 45.0, 60.0];
|
|
|
|
/// Proportional size: the other dimension when one changes with the link on.
|
|
pub fn constrained(w: f64, h: f64, new_w: Option<f64>, new_h: Option<f64>) -> (f64, f64) {
|
|
match (new_w, new_h) {
|
|
(Some(nw), _) if w.abs() > 1e-9 => (nw, h * nw / w),
|
|
(_, Some(nh)) if h.abs() > 1e-9 => (w * nh / h, nh),
|
|
(a, b) => (a.unwrap_or(w), b.unwrap_or(h)),
|
|
}
|
|
}
|
|
|
|
/// The artboard the Artboard tool has active, while that tool is in use.
|
|
fn tool_artboard(app: &VectorcraftApp) -> Option<(usize, Rect)> {
|
|
if app.session.tool_id() != "artboard" {
|
|
return None;
|
|
}
|
|
let i = usize::try_from(app.session.tool_options().get("active")?.as_u64()?).ok()?;
|
|
app.session.active()?.doc.artboards.get(i).map(|a| (i, a.rect))
|
|
}
|
|
|
|
/// Where an artboard goes when the panel sets its X, Y, W or H: the reference point `refi` moves to
|
|
/// the new X/Y or stays put while the size changes (both sides together when `link` is on).
|
|
pub fn artboard_rect(r: Rect, refi: usize, x: Option<f64>, y: Option<f64>, w: Option<f64>, h: Option<f64>, link: bool) -> Rect {
|
|
let rp = vectorcraft_geom::reference_point(r, refi);
|
|
let (w0, h0) = (r.width(), r.height());
|
|
let (nw, nh) = if link { constrained(w0, h0, w, h) } else { (w.unwrap_or(w0), h.unwrap_or(h0)) };
|
|
// Where the reference point sits across the box (0, ½ or 1 of each side).
|
|
let fx = if w0 > 1e-9 { (rp.x - r.x0) / w0 } else { 0.0 };
|
|
let fy = if h0 > 1e-9 { (rp.y - r.y0) / h0 } else { 0.0 };
|
|
let (x0, y0) = (x.unwrap_or(rp.x) - fx * nw, y.unwrap_or(rp.y) - fy * nh);
|
|
Rect::new(x0, y0, x0 + nw, y0 + nh)
|
|
}
|
|
|
|
/// The reference point the Transform panel and the Control bar share (0 to 8, the center by default).
|
|
pub fn reference_index(ui: &Ui) -> usize {
|
|
ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4)
|
|
}
|
|
|
|
/// The shared reference point's locator; a click on it chooses another point.
|
|
pub fn reference_locator(ui: &mut Ui, refi: usize) {
|
|
if let Some(i) = widgets::reference_point(ui, refi) {
|
|
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
|
|
}
|
|
}
|
|
|
|
/// Set artboard `index` to a new X, Y, W or H from reference point `refi` ([`artboard_rect`]) with
|
|
/// `artboard.setProps`, passing the Artboard tool's Move and Scale Artwork with Artboard options as
|
|
/// `moveArt` and `scaleArt`; an error is shown in the status bar. A new X or Y moves artboard
|
|
/// `index` alone; a new W or H resizes every selected artboard about its own reference point
|
|
/// ([`crate::panels::artboards::resized_with`]), in one undo step.
|
|
pub fn set_artboard(app: &mut VectorcraftApp, index: usize, refi: usize, xywh: [Option<f64>; 4], link: bool) {
|
|
let [x, y, w, h] = xywh;
|
|
let scale_art = crate::panels::artboards::scale_art(app);
|
|
let move_art = crate::panels::artboards::move_art(app);
|
|
let indices = if x.is_none() && y.is_none() { crate::panels::artboards::resized_with(app, index) } else { vec![index] };
|
|
let common = json!({"scaleArt": scale_art, "moveArt": move_art});
|
|
let r = crate::panels::artboards::set_props(app, &indices, common, |i, r| {
|
|
let n = artboard_rect(r, refi, x, y, w, h, link);
|
|
Some(json!({"index": i, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height()}))
|
|
});
|
|
if let Err(e) = r {
|
|
app.status(e);
|
|
}
|
|
}
|
|
|
|
/// The Artboard tool's view of the panel: the active artboard's position and size ([`set_artboard`]).
|
|
fn artboard_fields(app: &mut VectorcraftApp, ui: &mut Ui, index: usize, r: Rect) {
|
|
let units = app.session.general_unit();
|
|
let origin = app.ruler_origin();
|
|
let refi = reference_index(ui);
|
|
let link = proportional(app, ui);
|
|
let rp = vectorcraft_geom::reference_point(r, refi);
|
|
let set = |app: &mut VectorcraftApp, x, y, w, h| set_artboard(app, index, refi, [x, y, w, h], link);
|
|
ui.horizontal(|ui| {
|
|
reference_locator(ui, refi);
|
|
ui.add_space(4.0);
|
|
egui::Grid::new("xfp-ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
|
widgets::dim_label(ui, "X:");
|
|
if let Some(v) = widgets::num_field(ui, "xfp-ab-x", Some(rp.x - origin.x), units, 80.0) {
|
|
set(app, Some(v + origin.x), None, None, None);
|
|
}
|
|
widgets::dim_label(ui, "W:");
|
|
if let Some(v) = widgets::size_field(ui, "xfp-ab-w", Some(r.width()), units, 80.0) {
|
|
set(app, None, None, Some(v), None);
|
|
}
|
|
ui.end_row();
|
|
widgets::dim_label(ui, "Y:");
|
|
if let Some(v) = widgets::num_field(ui, "xfp-ab-y", Some(rp.y - origin.y), units, 80.0) {
|
|
set(app, None, Some(v + origin.y), None, None);
|
|
}
|
|
widgets::dim_label(ui, "H:");
|
|
if let Some(v) = widgets::size_field(ui, "xfp-ab-h", Some(r.height()), units, 80.0) {
|
|
set(app, None, None, None, Some(v));
|
|
}
|
|
ui.end_row();
|
|
});
|
|
constrain_link(app, ui);
|
|
});
|
|
}
|
|
|
|
/// With ruler guides selected and no art: the X of the vertical ones and the Y of the horizontal
|
|
/// ones (the least of each; none where no such guide is selected).
|
|
fn selected_guides(app: &VectorcraftApp) -> Option<(Option<f64>, Option<f64>)> {
|
|
let st = app.session.active()?;
|
|
if !st.selection.objects.is_empty() || st.selection.guides.is_empty() {
|
|
return None;
|
|
}
|
|
let least = |vertical: bool| {
|
|
st.selection.guides.iter().filter_map(|i| st.doc.guides.get(*i)).filter(|g| g.vertical == vertical).map(|g| g.pos).min_by(f64::total_cmp)
|
|
};
|
|
Some((least(true), least(false)))
|
|
}
|
|
|
|
/// The selected ruler guides' view of the panel (#1597): X moves the vertical ones and Y the
|
|
/// horizontal ones, together.
|
|
fn guide_fields(app: &mut VectorcraftApp, ui: &mut Ui, (x, y): (Option<f64>, Option<f64>)) {
|
|
let units = app.session.general_unit();
|
|
let origin = app.ruler_origin();
|
|
egui::Grid::new("xfp-guide-grid").num_columns(2).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
|
for (label, id, pos, origin, by) in [("X:", "xfp-guide-x", x, origin.x, "dx"), ("Y:", "xfp-guide-y", y, origin.y, "dy")] {
|
|
widgets::dim_label(ui, label);
|
|
ui.add_enabled_ui(pos.is_some(), |ui| {
|
|
if let (Some(v), Some(pos)) = (widgets::num_field(ui, id, pos.map(|p| p - origin), units, 80.0), pos)
|
|
&& let Err(e) = app.run("guide.move", json!({ by: v + origin - pos }))
|
|
{
|
|
app.status(e);
|
|
}
|
|
});
|
|
ui.end_row();
|
|
}
|
|
});
|
|
}
|
|
|
|
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let t = Tokens::get(ui.ctx());
|
|
if app.session.active().is_none() {
|
|
widgets::dim_label(ui, tl!("No document"));
|
|
return;
|
|
}
|
|
if let Some((i, r)) = tool_artboard(app) {
|
|
artboard_fields(app, ui, i, r);
|
|
return;
|
|
}
|
|
if let Some(guides) = selected_guides(app) {
|
|
guide_fields(app, ui, guides);
|
|
return;
|
|
}
|
|
let units = app.session.general_unit();
|
|
let origin = app.ruler_origin();
|
|
// The bounding box, rotated with rotated objects: W/H are its own sides, X/Y its reference
|
|
// point on the page.
|
|
let bx = app.selection_box();
|
|
let bounds = bx.map(|b| b.rect);
|
|
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
|
let link = proportional(app, ui);
|
|
let copy = alt_enter_copies(app, ui);
|
|
let has = bounds.is_some();
|
|
let rp = bx.map(|b| b.reference_point(refi));
|
|
ui.horizontal(|ui| {
|
|
if let Some(i) = widgets::reference_point(ui, refi) {
|
|
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
|
|
}
|
|
ui.add_space(4.0);
|
|
ui.add_enabled_ui(has, |ui| {
|
|
egui::Grid::new("xfp-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
|
widgets::dim_label(ui, "X:");
|
|
if let Some(v) = widgets::num_field(ui, "xfp-x", rp.map(|p| p.x - origin.x), units, 80.0) {
|
|
app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi, "copy": copy})).ok();
|
|
}
|
|
widgets::dim_label(ui, "W:");
|
|
if let Some(v) = widgets::size_field(ui, "xfp-w", bounds.map(|b| b.width()), units, 80.0) {
|
|
app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link, "copy": copy})).ok();
|
|
}
|
|
ui.end_row();
|
|
widgets::dim_label(ui, "Y:");
|
|
if let Some(v) = widgets::num_field(ui, "xfp-y", rp.map(|p| p.y - origin.y), units, 80.0) {
|
|
app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi, "copy": copy})).ok();
|
|
}
|
|
widgets::dim_label(ui, "H:");
|
|
if let Some(v) = widgets::size_field(ui, "xfp-h", bounds.map(|b| b.height()), units, 80.0) {
|
|
app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link, "copy": copy})).ok();
|
|
}
|
|
ui.end_row();
|
|
});
|
|
});
|
|
constrain_link(app, ui);
|
|
});
|
|
ui.add_space(4.0);
|
|
let origin = rp.map(|p| json!([p.x, p.y]));
|
|
ui.horizontal(|ui| {
|
|
ui.add_enabled_ui(has, |ui| {
|
|
let icon = icons::icon(ui, "rotate-ccw", 16.0, t.icon).on_hover_text(tl!("Rotate"));
|
|
crate::scrub::note_label(ui, icon.rect);
|
|
// The bounding box's angle: a new value turns the selection to it.
|
|
let angle = bx.map_or(0.0, |b| b.angle);
|
|
if let Some(a) = widgets::spin_plain(ui, "xfp-rot", angle, "°", 2, 96.0, 15.0, -360.0, &ANGLE_PRESETS)
|
|
&& a != angle
|
|
{
|
|
app.run("object.rotate", json!({"angle": a, "absolute": true, "origin": origin, "copy": copy})).ok();
|
|
}
|
|
ui.add_space(4.0);
|
|
let icon = icons::icon(ui, "dc-shear", 16.0, t.icon).on_hover_text(tl!("Shear"));
|
|
crate::scrub::note_label(ui, icon.rect);
|
|
// Shear is relative: the field rests at 0° and a new value shears by it.
|
|
if let Some(a) = widgets::spin_plain(ui, "xfp-shear", 0.0, "°", 2, 96.0, 15.0, -89.0, &SHEAR_PRESETS)
|
|
&& a != 0.0
|
|
&& a.abs() < 90.0
|
|
{
|
|
app.run("object.shear", json!({"angle": a, "axis": "horizontal", "origin": origin, "copy": copy})).ok();
|
|
}
|
|
});
|
|
});
|
|
ui.horizontal(|ui| {
|
|
// Lucide's flip icons are named for their mirror line: flip-vertical-2 draws a vertical
|
|
// line (the left-right flip), flip-horizontal-2 a horizontal one (top-bottom).
|
|
if widgets::icon_button_enabled(ui, "flip-vertical-2", tl!("Flip Horizontal"), false, has, 24.0).clicked() {
|
|
app.run("object.reflect", json!({"axis": "vertical", "origin": origin})).ok();
|
|
}
|
|
if widgets::icon_button_enabled(ui, "flip-horizontal-2", tl!("Flip Vertical"), false, has, 24.0).clicked() {
|
|
app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok();
|
|
}
|
|
});
|
|
// Live shape properties (a path with live corners is no shape).
|
|
if let Some(n) = first_selected(app)
|
|
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
|
|
&& !matches!(**live, vectorcraft_doc::LiveShape::Path { .. })
|
|
{
|
|
widgets::divider(ui);
|
|
match &**live {
|
|
vectorcraft_doc::LiveShape::Rectangle { .. } => {
|
|
widgets::subheader(ui, tl!("Rectangle Properties:"));
|
|
corner_radius_row(app, ui, &n, "xfp-radius");
|
|
}
|
|
vectorcraft_doc::LiveShape::Polygon { .. } => {
|
|
widgets::subheader(ui, tl!("Polygon Properties:"));
|
|
polygon_rows(app, ui, &n, live, "xfp-polygon");
|
|
}
|
|
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => {
|
|
widgets::subheader(ui, tl!("Ellipse Properties:"));
|
|
pie_rows(app, ui, *pie, "xfp-pie");
|
|
}
|
|
_ => {
|
|
widgets::subheader(ui, tl!("Shape Properties:"));
|
|
widgets::dim_label(ui, tl!(n.kind_label()));
|
|
}
|
|
}
|
|
}
|
|
if pstate::<bool>(ui.ctx(), "xf-hide-options") {
|
|
return;
|
|
}
|
|
widgets::divider(ui);
|
|
let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes);
|
|
if widgets::check(ui, tl!("Scale Corners"), sc, true) {
|
|
set_pref(app, "scaleCorners", !sc);
|
|
}
|
|
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
|
|
set_pref(app, "scaleStrokes", !ss);
|
|
}
|
|
}
|
|
|
|
/// An ellipse's Pie Start and End Angle (degrees, counterclockwise from 3 o'clock; 0 to 360 is the
|
|
/// whole ellipse) and Invert Pie, which shows the other part of it (Transform and Properties panels).
|
|
pub(crate) fn pie_rows(app: &mut VectorcraftApp, ui: &mut Ui, pie: (f64, f64), id: &str) {
|
|
for (label, key, v) in [(tl!("Pie Start Angle:"), "pieStart", pie.0), (tl!("Pie End Angle:"), "pieEnd", pie.1)] {
|
|
ui.horizontal(|ui| {
|
|
widgets::dim_label(ui, label);
|
|
if let Some(a) = widgets::plain_field(ui, (id, key), v, "°", 1, 60.0) {
|
|
app.run("object.setLiveShape", json!({ key: a })).ok();
|
|
}
|
|
});
|
|
}
|
|
if widgets::flat_button(ui, tl!("Invert Pie"), 90.0).clicked() {
|
|
app.run("object.setLiveShape", json!({"invertPie": true})).ok();
|
|
}
|
|
}
|
|
|
|
/// A live polygon's Polygon Properties (Transform and Properties panels): its side count, angle
|
|
/// (counterclockwise), corner type and radius, radius (centre to vertex) and side length, and Make
|
|
/// Sides Equal, which an uneven scale enables.
|
|
pub(crate) fn polygon_rows(app: &mut VectorcraftApp, ui: &mut Ui, n: &Node, live: &LiveShape, id: &str) {
|
|
let LiveShape::Polygon { sides, .. } = live else { return };
|
|
let (units, fw) = (app.session.general_unit(), super::field_width(ui));
|
|
let radius = live.polygon_radius();
|
|
let side = radius.map(|r| 2.0 * r * (std::f64::consts::PI / f64::from((*sides).max(3))).sin());
|
|
let kind = super::corner_style(app, n).1;
|
|
let kinds: Vec<&str> = CornerKind::ALL.iter().map(|k| corner_kind(*k)).collect();
|
|
// The `object.setLiveShape` param an edited field sets.
|
|
let mut set = None;
|
|
egui::Grid::new((id, "grid")).num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| {
|
|
widgets::dim_label(ui, tl!("Sides:"));
|
|
if let Some(s) = widgets::plain_field(ui, (id, "sides"), f64::from(*sides), "", 0, fw) {
|
|
set = Some(("sides", json!(s.round().max(3.0) as u64)));
|
|
}
|
|
ui.end_row();
|
|
widgets::dim_label(ui, tl!("Angle:"));
|
|
if let Some(a) = widgets::plain_field(ui, (id, "angle"), live.polygon_angle().unwrap_or(0.0), "°", 1, fw) {
|
|
set = Some(("polygonAngle", json!(a)));
|
|
}
|
|
ui.end_row();
|
|
widgets::dim_label(ui, tl!("Corner:"));
|
|
if let Some(k) = widgets::dropdown_names(ui, (id, "kind"), kind.map_or("", corner_kind), &kinds, fw).and_then(|i| CornerKind::ALL.get(i)) {
|
|
set = Some(("kind", json!(k)));
|
|
}
|
|
ui.end_row();
|
|
widgets::dim_label(ui, tl!("Corner Radius:"));
|
|
super::corner_radius_field(app, ui, n, (id, "cornerRadius"), fw);
|
|
ui.end_row();
|
|
widgets::dim_label(ui, tl!("Radius:"));
|
|
if let Some(r) = widgets::num_field(ui, (id, "radius"), radius, units, fw) {
|
|
set = Some(("polygonRadius", json!(r)));
|
|
}
|
|
ui.end_row();
|
|
widgets::dim_label(ui, tl!("Side Length:"));
|
|
if let Some(l) = widgets::num_field(ui, (id, "side"), side, units, fw) {
|
|
set = Some(("sideLength", json!(l)));
|
|
}
|
|
ui.end_row();
|
|
});
|
|
if ui.add_enabled_ui(!live.polygon_sides_equal(), |ui| widgets::flat_button(ui, tl!("Make Sides Equal"), 130.0)).inner.clicked() {
|
|
set = Some(("makeSidesEqual", json!(true)));
|
|
}
|
|
if let Some((key, v)) = set {
|
|
app.run("object.setLiveShape", json!({ key: v })).ok();
|
|
}
|
|
}
|
|
|
|
/// The Control bar's underlined "Transform" link: a click toggles the Transform panel in a
|
|
/// popover under it, as the Stroke link does with the Stroke panel.
|
|
pub fn link(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let resp = ui.link(egui::RichText::new(tl!("Transform")).size(12.0).color(t.text).underline()).on_hover_text(tl!("Transform options"));
|
|
widgets::popover(&resp, resp.clicked(), |ui| {
|
|
ui.set_width(300.0);
|
|
show(app, ui);
|
|
});
|
|
}
|
|
|
|
/// The link between W and H (Transform panel, Properties panel, Control bar): one toggle, the
|
|
/// `constrainProportions` preference, which the size fields pass on as `proportional`.
|
|
pub fn constrain_link(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let on = app.session.prefs.constrain_proportions;
|
|
if widgets::icon_button(ui, if on { "link" } else { "link-2-off" }, tl!("Constrain Width and Height Proportions"), on, 22.0).clicked() {
|
|
set_pref(app, "constrainProportions", !on);
|
|
}
|
|
}
|
|
|
|
/// The `copy` param of a command that a field of the Transform panel, the Properties panel's
|
|
/// Transform section or the Control bar runs: true when Alt+Enter (Option+Return) applied the value
|
|
/// and no anchors are selected with Direct Selection.
|
|
pub fn alt_enter_copies(app: &VectorcraftApp, ui: &Ui) -> bool {
|
|
widgets::enter_modifiers(ui).is_some_and(|m| m.alt) && app.session.active().is_some_and(|st| st.selection.anchors.is_empty())
|
|
}
|
|
|
|
/// The `proportional` param of the W and H fields of the Transform panel (the artboard's too), the
|
|
/// Properties panel's Transform section and the Control bar: the Constrain Width and Height
|
|
/// Proportions link, or true when Ctrl/Cmd+Enter applied the value.
|
|
pub fn proportional(app: &VectorcraftApp, ui: &Ui) -> bool {
|
|
app.session.prefs.constrain_proportions || widgets::enter_modifiers(ui).is_some_and(|m| m.command)
|
|
}
|
|
|
|
/// Toggle a boolean preference (through `prefs.set`, as agents do).
|
|
pub fn set_pref(app: &mut VectorcraftApp, key: &str, on: bool) {
|
|
if let Err(e) = app.run("prefs.set", json!({ "key": key, "value": on })) {
|
|
app.status(e);
|
|
}
|
|
}
|
|
|
|
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|
let hidden: bool = pstate(ui.ctx(), "xf-hide-options");
|
|
let has = super::selection_len(app) > 0;
|
|
// The flips mirror across the panel's reference point, as its flip buttons do.
|
|
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
|
|
let origin = app.selection_box().map(|b| b.reference_point(refi)).map(|p| json!([p.x, p.y]));
|
|
if menu_item(ui, if hidden { tl!("Show Options") } else { tl!("Hide Options") }, true, false) {
|
|
set_pstate(ui.ctx(), "xf-hide-options", !hidden);
|
|
}
|
|
ui.separator();
|
|
if menu_item(ui, tl!("Flip Horizontal"), has, false) {
|
|
app.run("object.reflect", json!({"axis": "vertical", "origin": origin})).ok();
|
|
}
|
|
if menu_item(ui, tl!("Flip Vertical"), has, false) {
|
|
app.run("object.reflect", json!({"axis": "horizontal", "origin": origin})).ok();
|
|
}
|
|
ui.separator();
|
|
let ss = app.session.prefs.scale_strokes;
|
|
if menu_item(ui, tl!("Scale Strokes & Effects"), true, ss) {
|
|
set_pref(app, "scaleStrokes", !ss);
|
|
}
|
|
ui.separator();
|
|
menu_item(ui, tl!("Transform Object Only"), false, true);
|
|
menu_item(ui, tl!("Transform Pattern Only"), false, false);
|
|
menu_item(ui, tl!("Transform Both"), false, false);
|
|
ui.separator();
|
|
menu_item(ui, tl!("Use Registration Point for Symbol"), false, false);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// One headless frame of the panel (or the Align panel's menu): the texts drawn.
|
|
fn texts(app: &mut VectorcraftApp, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<String> {
|
|
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
|
|
match s {
|
|
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
|
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
|
_ => {}
|
|
}
|
|
}
|
|
let ctx = egui::Context::default();
|
|
crate::theme::install_fonts(&ctx);
|
|
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| draw(app, ui));
|
|
out.textures_delta.clear();
|
|
let mut v = vec![];
|
|
out.shapes.iter().for_each(|c| walk(&c.shape, &mut v));
|
|
v
|
|
}
|
|
|
|
#[test]
|
|
fn use_preview_bounds_shows_the_visual_size_and_checks_the_align_flyout() {
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
|
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
|
app.run("stroke.set", json!({"weight": 10})).unwrap();
|
|
assert!(texts(&mut app, show).iter().any(|t| t == "100 pt"));
|
|
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == " Use Preview Bounds"));
|
|
set_pref(&mut app, "usePreviewBounds", true);
|
|
let shown = texts(&mut app, show);
|
|
assert!(shown.iter().any(|t| t == "110 pt") && shown.iter().any(|t| t == "60 pt"), "{shown:?}");
|
|
assert!(texts(&mut app, crate::panels::align::menu).iter().any(|t| t == "✓ Use Preview Bounds"));
|
|
}
|
|
|
|
#[test]
|
|
fn rotation_field_shows_the_angle_the_box_keeps() {
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
|
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50})).unwrap();
|
|
app.run("object.rotate", json!({"angle": 45, "absolute": true})).unwrap();
|
|
let shown = texts(&mut app, show);
|
|
// The angle stays, and W/H are the rectangle's own sides.
|
|
assert!(shown.iter().any(|t| t == "45°"), "{shown:?}");
|
|
assert!(shown.iter().any(|t| t == "100 pt") && shown.iter().any(|t| t == "50 pt"), "{shown:?}");
|
|
app.run("object.resetBoundingBox", json!({})).unwrap();
|
|
let shown = texts(&mut app, show);
|
|
assert!(shown.iter().any(|t| t == "0°") && !shown.iter().any(|t| t == "100 pt"), "{shown:?}");
|
|
}
|
|
|
|
/// #812: a selected live polygon shows its Polygon Properties in the Transform panel and the
|
|
/// Properties panel's Transform section, and Make Sides Equal evens it out after a stretch.
|
|
#[test]
|
|
fn a_polygon_shows_its_polygon_properties() {
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
|
app.run("shape.polygon", json!({"cx": 150, "cy": 150, "radius": 50, "sides": 6})).unwrap();
|
|
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), crate::panels::properties::transform_section] {
|
|
let shown = texts(&mut app, draw);
|
|
// A hexagon's side is as long as its radius.
|
|
for t in ["Sides:", "6", "Angle:", "0°", "Corner:", "Round", "Corner Radius:", "Radius:", "Side Length:", "50 pt", "Make Sides Equal"] {
|
|
assert!(shown.iter().any(|s| s == t), "{t} in {shown:?}");
|
|
}
|
|
}
|
|
assert!(texts(&mut app, show).iter().any(|s| s == "Polygon Properties:"));
|
|
app.run("object.scale", json!({"sx": 200, "sy": 100})).unwrap();
|
|
let live = |app: &VectorcraftApp| match &first_selected(app).unwrap().kind {
|
|
NodeKind::Path { live: Some(l), .. } => l.clone(),
|
|
k => panic!("{k:?}"),
|
|
};
|
|
assert!(!live(&app).polygon_sides_equal());
|
|
app.run("object.setLiveShape", json!({"makeSidesEqual": true})).unwrap();
|
|
assert!(live(&app).polygon_sides_equal());
|
|
}
|
|
|
|
#[test]
|
|
fn the_artboard_tool_shows_the_active_artboard() {
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
|
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
|
assert!(texts(&mut app, show).iter().any(|t| t == "40 pt"), "the selection first");
|
|
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
|
|
let shown = texts(&mut app, show);
|
|
// Centre reference point: X 150, Y 100, W 300, H 200; no rotate/shear for artboards.
|
|
for v in ["150 pt", "100 pt", "300 pt", "200 pt"] {
|
|
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
|
|
}
|
|
assert!(!shown.iter().any(|t| t == "40 pt" || t == "0°"), "{shown:?}");
|
|
}
|
|
|
|
/// #1597: with only ruler guides selected the panel shows their X or Y, and nothing of the art.
|
|
#[test]
|
|
fn selected_guides_show_their_position() {
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
|
app.run("guide.add", json!({"vertical": true, "pos": 120})).unwrap();
|
|
app.run("guide.add", json!({"vertical": false, "pos": 45})).unwrap();
|
|
app.run("guide.select", json!({"indexes": [0]})).unwrap();
|
|
assert_eq!(selected_guides(&app), Some((Some(120.0), None)));
|
|
let shown = texts(&mut app, show);
|
|
assert!(shown.iter().any(|t| t == "120 pt") && !shown.iter().any(|t| t == "W:" || t == "45 pt"), "{shown:?}");
|
|
app.run("guide.select", json!({"indexes": [0, 1]})).unwrap();
|
|
let shown = texts(&mut app, show);
|
|
assert!(shown.iter().any(|t| t == "120 pt") && shown.iter().any(|t| t == "45 pt"), "{shown:?}");
|
|
// The field's edit: the vertical guide moves, the horizontal one stays.
|
|
app.run("guide.move", json!({"dx": 30})).unwrap();
|
|
assert_eq!(selected_guides(&app), Some((Some(150.0), Some(45.0))));
|
|
// Art selected with them: the art's box, as before.
|
|
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap();
|
|
assert_eq!(selected_guides(&app), None);
|
|
}
|
|
|
|
/// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position,
|
|
/// and a new X moves it alone.
|
|
#[test]
|
|
fn an_anchor_selection_shows_and_moves_its_own_box() {
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
|
let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true});
|
|
let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap();
|
|
let shown = texts(&mut app, show);
|
|
assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}");
|
|
app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap();
|
|
let shown = texts(&mut app, show);
|
|
for v in ["120 pt", "30 pt", "0 pt"] {
|
|
assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}");
|
|
}
|
|
assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}");
|
|
app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap();
|
|
let n = first_selected(&app).unwrap();
|
|
let Some(path) = n.path_data() else { panic!("a path") };
|
|
let xs: Vec<f64> = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect();
|
|
assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]);
|
|
}
|
|
|
|
/// #2398: the panel menu's Flip Horizontal and Flip Vertical mirror the selection across the
|
|
/// reference point, as the panel's flip buttons do, not across its centre.
|
|
#[test]
|
|
fn menu_flips_keep_the_reference_point() {
|
|
use crate::panels::tests_appearance::{click, frame_events};
|
|
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
|
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
|
app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap();
|
|
let ctx = egui::Context::default();
|
|
crate::theme::install_fonts(&ctx);
|
|
// Top-right reference point: (120, 30).
|
|
ctx.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), 2usize));
|
|
let flip = |app: &mut VectorcraftApp, item: &str| {
|
|
let texts = frame_events(&ctx, app, vec![], menu);
|
|
let r = texts.iter().find(|(t, _)| t.trim_start() == item).unwrap_or_else(|| panic!("{item} in {texts:?}")).1;
|
|
click(&ctx, app, r.center(), menu);
|
|
app.selection_box().unwrap().rect
|
|
};
|
|
assert_eq!(flip(&mut app, "Flip Horizontal"), Rect::new(120.0, 30.0, 220.0, 80.0));
|
|
assert_eq!(flip(&mut app, "Flip Vertical"), Rect::new(120.0, -20.0, 220.0, 30.0));
|
|
}
|
|
|
|
#[test]
|
|
fn artboard_fields_keep_the_reference_point() {
|
|
let r = Rect::new(0.0, 0.0, 300.0, 200.0);
|
|
// Centre: a new X centres it there, a new W grows it both ways.
|
|
assert_eq!(artboard_rect(r, 4, Some(200.0), None, None, None, false), Rect::new(50.0, 0.0, 350.0, 200.0));
|
|
assert_eq!(artboard_rect(r, 4, None, None, Some(400.0), None, false), Rect::new(-50.0, 0.0, 350.0, 200.0));
|
|
// Top-left: a new W keeps the left edge; linked, H follows.
|
|
assert_eq!(artboard_rect(r, 0, None, None, Some(600.0), None, true), Rect::new(0.0, 0.0, 600.0, 400.0));
|
|
// Bottom-right: a new Y puts the bottom edge there.
|
|
assert_eq!(artboard_rect(r, 8, None, Some(500.0), None, None, false), Rect::new(0.0, 300.0, 300.0, 500.0));
|
|
}
|
|
|
|
#[test]
|
|
fn constrain_proportions() {
|
|
assert_eq!(constrained(100.0, 50.0, Some(200.0), None), (200.0, 100.0));
|
|
assert_eq!(constrained(100.0, 50.0, None, Some(25.0)), (50.0, 25.0));
|
|
assert_eq!(constrained(0.0, 50.0, Some(10.0), None), (10.0, 50.0));
|
|
}
|
|
}
|