vectorcraft/crates/ui-egui/src/panels/properties.rs

1274 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The Properties panel: context-sensitive sections like Illustrator's.
use egui::Ui;
use serde_json::json;
use vectorcraft_doc::{LiveShape, NodeKind, Unit};
use vectorcraft_engine::cmd::newdoc;
use super::{corner_radius_row, first_selected, pstate, set_pstate};
use crate::theme::Tokens;
use crate::widgets::{self, dim_label, divider, section_header};
use crate::{VectorcraftApp, icons};
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else {
dim_label(ui, tl!("No document open"));
return;
};
let n_sel = st.selection.len();
let anchors = crate::chrome::anchor_controls(app);
let first = first_selected(app);
// Edit Artboards (the Artboard tool): the active artboard, whatever is selected.
let editing_artboards = app.session.tool_id() == "artboard";
let label = match (&first, n_sel) {
_ if editing_artboards => tl!("Artboard").to_string(),
(None, _) => tl!("Document").to_string(),
(_, n) if n > 1 => crate::i18n::tn(n as u64, "{n} Object", "{n} Objects"),
(Some(n), _) if crate::panels::image_trace::is_trace(n) => tl!("Image Tracing").to_string(),
(Some(n), _) => tl!(n.kind_label()).to_string(),
};
ui.label(egui::RichText::new(label).size(11.5).color(t.text_dim));
ui.add_space(4.0);
if editing_artboards {
artboard_sections(app, ui);
return;
}
if first.is_none() {
document_sections(app, ui);
return;
}
transform_section(app, ui);
divider(ui);
// The Gradient tool: the gradient's type and, for a freeform one, how clicks add points (#921).
if app.session.tool_id() == "gradient" {
section_header(ui, tl!("Gradient"));
super::gradient::type_row(app, ui);
super::gradient::draw_radios(app, ui);
divider(ui);
}
let is_image = n_sel == 1 && matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Image(_)));
if is_image {
image_section(app, ui);
divider(ui);
}
if let Some((preset, view)) = crate::panels::image_trace::selected_trace(app) {
trace_section(app, ui, &preset, view);
divider(ui);
}
if matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Text(_))) {
type_sections(app, ui);
divider(ui);
}
appearance_section(app, ui);
divider(ui);
section_header(ui, tl!("Align"));
ui.horizontal(|ui| {
super::align::align_buttons(app, ui, 26.0);
// The rest (Distribute Objects, Distribute Spacing, Align To) is the Align panel's.
if widgets::icon_button(ui, "ellipsis", tl!("More Align options"), false, 26.0).clicked() {
app.ui.open_panel = Some("align".into());
}
});
if n_sel > 1 {
divider(ui);
section_header(ui, tl!("Pathfinder"));
ui.horizontal(|ui| {
for (icon, tip, op) in [
("squares-unite", tl!("Unite"), "unite"),
("squares-subtract", tl!("Minus Front"), "minusFront"),
("squares-intersect", tl!("Intersect"), "intersect"),
("squares-exclude", tl!("Exclude"), "exclude"),
] {
if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() {
app.run(&format!("object.pathfinder.{op}"), json!({})).ok();
}
}
if widgets::icon_button(ui, "ellipsis", tl!("More Pathfinder options"), false, 28.0).clicked() {
app.ui.open_panel = Some("pathfinder".into());
}
});
}
if anchors != crate::chrome::AnchorControls::None {
divider(ui);
section_header(ui, tl!("Anchor Point"));
crate::chrome::anchor_buttons(app, ui, anchors);
}
divider(ui);
section_header(ui, tl!("Quick Actions"));
let is_group = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Group { .. }));
let is_live = matches!(first.as_ref().map(|n| &n.kind), Some(NodeKind::Path { live: Some(l), .. }) if !matches!(**l, LiveShape::Path { .. }));
let mut actions: Vec<(&str, &str)> = vec![];
if n_sel > 1 {
actions.push((tl!("Group"), "object.group"));
actions.push((tl!("Make Clipping Mask"), "object.clippingMask.make"));
}
if is_group {
actions.push((tl!("Ungroup"), "object.ungroup"));
actions.push((tl!("Isolate Group"), "object.isolate"));
}
if is_live {
actions.push((tl!("Expand Shape"), "object.expandShape"));
}
if multi_color(app, ui.ctx()) {
actions.push((tl!("Recolor"), "ui.recolorDialog"));
}
if is_image {
crate::panels::image_trace::trace_button(app, ui, ui.available_width());
actions.push((tl!("Crop Image"), "ui.cropImage"));
actions.push((tl!("Mask"), "object.maskImage"));
}
actions.push((tl!("Offset Path"), "object.path.offsetPath"));
actions.push((tl!("Simplify"), "object.path.simplify"));
actions.push((tl!("Arrange: Bring to Front"), "object.arrange.bringToFront"));
actions.push((tl!("Lock"), "object.lock"));
let w = (ui.available_width() - 6.0) / 2.0;
for pair in actions.chunks(2) {
ui.horizontal(|ui| {
for (label, id) in pair {
if widgets::flat_button(ui, label, w).clicked() {
crate::menus::invoke(app, id, json!({}));
}
}
});
}
}
/// The one selected image: its file (or Embedded), colour mode and resolution, and the Links
/// panel's actions for it.
fn image_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Some(i) = crate::place::selected_image_info(app) else { return };
let (name, details) = crate::place::image_summary(i);
let (Some(id), linked) = (i["id"].as_u64(), i["linked"] == true) else { return };
section_header(ui, if linked { tl!("Linked File") } else { tl!("Embedded Image") });
ui.add(egui::Label::new(egui::RichText::new(&name).size(12.0).color(t.text_strong)).truncate());
dim_label(ui, &details);
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| {
if app.services.pick_open.is_some() && widgets::flat_button(ui, tl!("Relink…"), w).clicked() {
crate::panels::links::relink(app, vec![id]);
}
if widgets::flat_button(ui, tl!("Links"), w).clicked() {
app.ui.open_panel = Some(links.into());
}
});
}
/// The one selected Image Trace object: its preset (choosing another traces it again with that
/// one), view, Expand and Release.
fn trace_section(app: &mut VectorcraftApp, ui: &mut Ui, preset: &str, view: vectorcraft_doc::TraceView) {
section_header(ui, tl!("Image Trace"));
ui.horizontal(|ui| {
dim_label(ui, tl!("Preset:"));
crate::panels::image_trace::preset_dropdown(app, ui, preset, ui.available_width());
});
ui.horizontal(|ui| crate::panels::image_trace::view_row(app, ui, view, ui.available_width()));
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
for (label, id) in [(tl!("Expand"), "imageTrace.expand"), (tl!("Release"), "imageTrace.release")] {
if widgets::flat_button(ui, label, w).clicked() {
app.run(id, json!({})).ok();
}
}
});
}
/// Whether the selection uses more than one colour (`recolor.colors`), which offers the Recolor
/// quick action; cached per document revision.
fn multi_color(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool {
let Some(st) = app.session.active() else { return false };
let key = (st.uid, st.revision);
match pstate::<Option<((u64, u64), bool)>>(ctx, "props-multi-color") {
Some((k, multi)) if k == key => multi,
_ => {
let r = app.session.execute("recolor.colors", &json!({})).unwrap_or_default();
let multi = r["colors"].as_array().is_some_and(|c| c.len() > 1);
set_pstate(ctx, "props-multi-color", Some((key, multi)));
multi
}
}
}
/// Edit Artboards (the Artboard tool): the active artboard's name, position and size, its preset and
/// orientation (#671) (with Scale Artwork with Artboard on, a new size scales its art, #602), Move
/// and Scale Artwork with Artboard, New Artboard and Delete Artboard, and Quick Actions: Rearrange All (#681) and
/// Exit back to the Selection tool (#530).
fn artboard_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = app.session.general_unit();
let origin = app.ruler_origin();
let n = app.session.active().map_or(0, |d| d.doc.artboards.len());
let i = super::artboards::selected(app, n);
let scale_art = super::artboards::scale_art(app);
let move_art = super::artboards::move_art(app);
if let Some(ab) = app.session.active().and_then(|d| d.doc.artboards.get(i)).cloned() {
// Its name as it is (names are never translated).
ui.label(egui::RichText::new(&ab.name).size(13.0).color(Tokens::get(ui.ctx()).text));
let fw = super::field_width(ui);
egui::Grid::new("ab-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
let r = ab.rect;
for (row, [(l1, k1, v1), (l2, k2, v2)]) in
[[("X:", "x", r.x0), ("W:", "width", r.width())], [("Y:", "y", r.y0), ("H:", "height", r.height())]].into_iter().enumerate()
{
for (label, key, v) in [(l1, k1, v1), (l2, k2, v2)] {
dim_label(ui, label);
let offset = match key {
"x" => origin.x,
"y" => origin.y,
_ => 0.0,
};
if let Some(v) = widgets::num_field(ui, ("ab", key, row), Some(v - offset), units, fw) {
app.run("artboard.setProps", json!({"index": i, key: v + offset, "scaleArt": scale_art, "moveArt": move_art})).ok();
}
}
ui.end_row();
}
});
artboard_size_row(app, ui, i, ab.rect.width(), ab.rect.height());
ui.add_space(2.0);
super::artboards::art_options(app, ui);
}
ui.add_space(4.0);
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
if widgets::flat_button(ui, tl!("New Artboard"), w).clicked() && app.run("artboard.new", json!({})).is_ok() {
super::artboards::select(app, n);
}
if ui.add_enabled_ui(n > 1, |ui| widgets::flat_button(ui, tl!("Delete Artboard"), w)).inner.clicked() {
super::artboards::delete_selected(app);
}
});
divider(ui);
section_header(ui, tl!("Quick Actions"));
ui.horizontal(|ui| {
rearrange_button(app, ui, n, w);
if widgets::flat_button(ui, tl!("Exit"), w).clicked() {
app.select_tool("selection");
}
});
}
/// Rearrange All (#681): opens Rearrange All Artboards; disabled with fewer than two artboards.
fn rearrange_button(app: &mut VectorcraftApp, ui: &mut Ui, artboards: usize, w: f32) {
if ui.add_enabled_ui(artboards > 1, |ui| widgets::flat_button(ui, tl!("Rearrange All"), w)).inner.clicked() {
super::artboards::open_rearrange(app);
}
}
/// Artboard `i`'s size preset (New Document's saved and built-in presets, by category, either way
/// round; Custom when its `w` × `h` points match none) and its orientation. A preset sizes it in its
/// current orientation; the other orientation swaps its width and height. Its top-left corner stays
/// put either way.
fn artboard_size_row(app: &mut VectorcraftApp, ui: &mut Ui, i: usize, w: f64, h: f64) {
let landscape = w > h;
// A preset `pw` × `ph` turned to the artboard's orientation.
let turned = |pw: f64, ph: f64| if (pw > ph) == landscape || pw == ph { (pw, ph) } else { (ph, pw) };
let same = |s: &newdoc::DocSettings| {
let (pw, ph) = turned(s.width, s.height);
(pw - w).abs() < 0.01 && (ph - h).abs() < 0.01
};
// Recent holds documents made, not presets.
let cats: Vec<(&str, Vec<newdoc::DocSettings>)> = newdoc::category_names()
.filter(|c| !c.eq_ignore_ascii_case("Recent"))
.filter_map(|c| Some((c, newdoc::category(&app.session, c)?)))
.filter(|(_, l)| !l.is_empty())
.collect();
let current = cats.iter().flat_map(|(_, l)| l).find(|s| same(s)).map_or(tl!("Custom"), |s| crate::dialogs::preset_name(&s.name)).to_string();
let mut size = None;
ui.horizontal(|ui| {
dim_label(ui, tl!("Preset:"));
size = widgets::combo(ui, "ab-preset", &current, 140.0, false, |ui| {
let mut chosen = None;
for (cat, list) in &cats {
ui.menu_button(tl!(cat), |ui| {
widgets::menu_scroll(ui, |ui| {
for s in list {
if widgets::menu_item_name(ui, crate::dialogs::preset_name(&s.name), true, same(s)) {
chosen = Some(turned(s.width, s.height));
}
}
});
});
}
chosen
});
ui.spacing_mut().item_spacing.x = 2.0;
for (is_landscape, tip) in [(false, tl!("Portrait")), (true, tl!("Landscape"))] {
if widgets::orientation_button(ui, is_landscape, landscape == is_landscape, tip) && landscape != is_landscape {
size = Some((h, w));
}
}
});
if let Some((width, height)) = size {
let scale_art = super::artboards::scale_art(app);
app.run("artboard.setProps", json!({"index": i, "width": width, "height": height, "scaleArt": scale_art})).ok();
}
}
fn document_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let units = app.session.general_unit();
section_header(ui, tl!("Document"));
ui.horizontal(|ui| {
dim_label(ui, tl!("Units"));
let labels: Vec<&str> = Unit::ALL.iter().map(|u| u.label()).collect();
if let Some(i) = widgets::dropdown(ui, "units", units.label(), &labels, 140.0) {
app.run("document.setUnits", json!({"units": labels[i]})).ok();
}
});
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
if widgets::flat_button(ui, tl!("Document Setup"), w).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, tl!("Edit Artboards"), w).clicked() {
app.select_tool("artboard");
}
});
let artboards = app.session.active().map_or(0, |d| d.doc.artboards.len());
if artboards > 1 {
rearrange_button(app, ui, artboards, w);
}
divider(ui);
section_header(ui, tl!("Appearance"));
fill_stroke_rows(app, ui);
divider(ui);
section_header(ui, tl!("Rulers & Grids"));
ui.horizontal(|ui| {
let v = app.ui.view.clone();
if widgets::icon_button(ui, "ruler", tl!("Show Rulers (⌘R)"), v.rulers, 26.0).clicked() {
app.run("view.rulers", json!({})).ok();
}
if widgets::icon_button(ui, "grid-3x3", tl!("Show Grid (⌘')"), v.grid, 26.0).clicked() {
app.run("view.grid", json!({})).ok();
}
let transparency_grid = app.session.active().is_some_and(|d| d.transparency_grid);
if widgets::icon_button(ui, "square-dashed", tl!("Show Transparency Grid"), transparency_grid, 26.0).clicked() {
app.run("view.transparencyGrid", json!({})).ok();
}
});
divider(ui);
section_header(ui, tl!("Guides"));
ui.horizontal(|ui| {
let v = app.ui.view.clone();
if widgets::icon_button(ui, "layout-grid", tl!("Show Guides"), v.guides, 26.0).clicked() {
app.run("view.guides", json!({})).ok();
}
if widgets::icon_button(ui, "sparkles", tl!("Smart Guides (⌘U)"), v.smart_guides, 26.0).clicked() {
app.run("view.smartGuides", json!({})).ok();
}
});
divider(ui);
section_header(ui, tl!("Snap Options"));
if widgets::check(ui, tl!("Snap to Point"), app.ui.view.snap_to_point, true) {
app.ui.view.snap_to_point ^= true;
}
if widgets::check(ui, tl!("Snap to Grid"), app.ui.view.snap_to_grid, true) {
app.ui.view.snap_to_grid ^= true;
}
divider(ui);
section_header(ui, tl!("Preferences"));
let fw = super::field_width(ui);
ui.horizontal(|ui| {
dim_label(ui, tl!("Keyboard Increment"));
if let Some(v) = widgets::num_field(ui, "kbinc", Some(app.session.prefs.keyboard_increment), units, fw) {
app.session.prefs.keyboard_increment = v.max(0.001);
}
});
let ss = app.session.prefs.scale_strokes;
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
super::transform::set_pref(app, "scaleStrokes", !ss);
}
divider(ui);
section_header(ui, tl!("Quick Actions"));
let w = (ui.available_width() - 6.0) / 2.0;
ui.horizontal(|ui| {
if widgets::flat_button(ui, tl!("Document Setup"), w).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, tl!("Preferences"), w).clicked() {
app.run("edit.preferences", json!({})).ok();
}
});
}
pub fn transform_section(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.session.active().is_none() {
return;
}
let units = app.session.general_unit();
let origin = app.ruler_origin();
// The bounding box, rotated with rotated objects (as in the Transform panel).
let Some(bx) = app.selection_box() else {
dim_label(ui, tl!("No Selection"));
return;
};
let b = bx.rect;
let refi: usize = ui.data(|d| d.get_temp(egui::Id::new("refpt"))).unwrap_or(4);
let rp = bx.reference_point(refi);
section_header(ui, tl!("Transform"));
let fw = super::field_width(ui);
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(6.0);
let link = app.session.prefs.constrain_proportions;
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
dim_label(ui, "X:");
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x - origin.x), units, fw) {
app.run("object.setBounds", json!({"x": v + origin.x, "reference": refi})).ok();
}
dim_label(ui, "W:");
if let Some(v) = widgets::num_field(ui, "tw", Some(b.width()), units, fw) {
app.run("object.setBounds", json!({"width": v, "reference": refi, "proportional": link})).ok();
}
ui.end_row();
dim_label(ui, "Y:");
if let Some(v) = widgets::num_field(ui, "ty", Some(rp.y - origin.y), units, fw) {
app.run("object.setBounds", json!({"y": v + origin.y, "reference": refi})).ok();
}
dim_label(ui, "H:");
if let Some(v) = widgets::num_field(ui, "th", Some(b.height()), units, fw) {
app.run("object.setBounds", json!({"height": v, "reference": refi, "proportional": link})).ok();
}
ui.end_row();
});
super::transform::constrain_link(app, ui);
});
let fw = super::field_width(ui);
ui.horizontal(|ui| {
let icon = icons::icon(ui, "rotate-ccw", 16.0, Tokens::get(ui.ctx()).icon);
crate::scrub::note_label(ui, icon.rect);
// The bounding box's angle: a new value turns the selection to it.
if let Some(a) = widgets::plain_field(ui, "rot", bx.angle, "°", 2, fw) {
app.run("object.rotate", json!({"angle": a, "absolute": true})).ok();
}
ui.add_space(10.0);
// 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(ui, "flip-vertical-2", tl!("Flip Along Horizontal Axis"), false, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "vertical"})).ok();
}
if widgets::icon_button(ui, "flip-horizontal-2", tl!("Flip Along Vertical Axis"), false, 24.0).clicked() {
app.run("object.reflect", json!({"axis": "horizontal"})).ok();
}
// More Options: the Transform panel's Scale Corners and Scale Strokes & Effects, open
// while they are toggled.
let more = widgets::icon_button(ui, "ellipsis", tl!("More Options"), false, 24.0);
egui::Popup::menu(&more).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes);
if widgets::check(ui, tl!("Scale Corners"), sc, true) {
super::transform::set_pref(app, "scaleCorners", !sc);
}
if widgets::check(ui, tl!("Scale Strokes & Effects"), ss, true) {
super::transform::set_pref(app, "scaleStrokes", !ss);
}
});
});
// Live shape properties.
if let Some(n) = first_selected(app)
&& let NodeKind::Path { live: Some(live), .. } = &n.kind
{
match &**live {
vectorcraft_doc::LiveShape::Rectangle { .. } => corner_radius_row(app, ui, &n, "radius"),
vectorcraft_doc::LiveShape::Polygon { .. } => super::transform::polygon_rows(app, ui, &n, live, "props-polygon"),
vectorcraft_doc::LiveShape::Ellipse { pie, .. } => super::transform::pie_rows(app, ui, *pie, "props-pie"),
_ => {}
}
}
}
fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(n) = first_selected(app) else { return };
section_header(ui, tl!("Appearance"));
fill_stroke_rows(app, ui);
let fw = super::field_width(ui);
ui.horizontal(|ui| {
widgets::field_label(ui, egui::RichText::new(tl!("Opacity")).size(12.0));
ui.add_space(8.0);
if let Some(o) = widgets::plain_field(ui, "ap-op", n.opacity as f64 * 100.0, "%", 0, fw) {
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0)})).ok();
}
if widgets::icon_button(ui, "ellipsis", tl!("Transparency"), false, 22.0).clicked() {
app.ui.open_panel = Some("transparency".into());
}
});
ui.horizontal(|ui| {
// The fx button opens the effect menu, as the Appearance panel's does.
let r = widgets::flat_button(ui, "fx", 34.0).on_hover_text(tl!("Add New Effect"));
egui::Popup::menu(&r).show(|ui| super::appearance::fx_menu(app, ui));
if widgets::icon_button(ui, "ellipsis", tl!("Appearance panel"), false, 22.0).clicked() {
app.ui.open_panel = Some("appearance".into());
}
});
}
/// The Fill and Stroke rows, with the Control bar's widgets: the chips (a click opens the
/// swatches, Shift-click the mixer), the Stroke link that opens the Stroke panel as a popover
/// and the weight spinner with its presets. With nothing selected they set up the next object.
fn fill_stroke_rows(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx()));
let fw = super::field_width(ui);
ui.horizontal(|ui| {
super::paint_chip(app, ui, false, 22.0, true);
ui.label(egui::RichText::new(tl!("Fill")).size(12.0).color(t.text));
});
ui.horizontal(|ui| {
super::paint_chip(app, ui, true, 22.0, true);
super::stroke::link(app, ui, tl!("Stroke"));
ui.add_space(8.0);
super::stroke::weight_field(app, ui, "ap-w", weight, fw);
});
}
pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some(n) = first_selected(app) else {
dim_label(ui, tl!("Select a text object"));
return;
};
let NodeKind::Text(tx) = &n.kind else {
dim_label(ui, tl!("Select a text object"));
return;
};
let s = tx.first_style();
section_header(ui, tl!("Character"));
// The font, Font Size, Leading, Kerning and Tracking as in the Character panel.
let w = ui.available_width();
super::character::font_pickers(app, ui, &s, ("font", "font-style"), (w - 4.0, w - 4.0));
super::character::metrics_grid(app, ui, "props-char", &s, w, false);
section_header(ui, tl!("Paragraph"));
// As in the Paragraph panel: the seven alignments and Hyphenate (#775); the rest is there.
if let Some((_, para)) = super::character::text_style(app) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 3.0;
super::paragraph::alignment_buttons(app, ui, &para, 24.0);
if widgets::icon_button(ui, "ellipsis", tl!("More Paragraph options"), false, 24.0).clicked() {
app.ui.open_panel = Some("paragraph".into());
}
});
super::paragraph::hyphenate_check(app, ui, &para);
}
}
#[cfg(test)]
mod tests {
use egui::{Event, PointerButton, Pos2, Rect, vec2};
use vectorcraft_engine::Session;
use super::*;
/// One frame of the panel with `events` → the texts painted, with their rects.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>) -> Vec<(String, Rect)> {
panel_frame(app, ctx, events, show)
}
fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<Event>, draw: fn(&mut VectorcraftApp, &mut Ui)) -> Vec<(String, Rect)> {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
out.textures_delta.clear();
out.shapes
.iter()
.filter_map(|s| {
if let egui::Shape::Text(t) = &s.shape {
Some((t.galley.text().to_string(), Rect::from_min_size(t.pos, t.galley.size())))
} else {
None
}
})
.collect()
}
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, texts: &[(String, Rect)], label: &str) {
let at = texts.iter().find(|(t, _)| t == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"));
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, vec![Event::PointerMoved(at), b(true)]);
frame(app, ctx, vec![b(false)]);
}
/// The Snap Options and Scale Strokes & Effects checkboxes keep a visible box at rest in every
/// theme, as the Preferences ones do (#1080), and toggle when clicked.
#[test]
fn the_document_checkboxes_show_their_boxes_and_toggle() {
for brightness in crate::theme::Brightness::ALL {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx, brightness);
frame(&mut app, &ctx, vec![]);
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| show(&mut app, ui));
out.textures_delta.clear();
let boxes = out
.shapes
.iter()
.filter(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.stroke.width > 0.0 && (r.rect.width() - 13.0).abs() < 0.5 && (r.rect.height() - 13.0).abs() < 0.5))
.count();
assert!(boxes >= 3, "{brightness:?}: {boxes} checkbox outlines");
}
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let texts = frame(&mut app, &ctx, vec![]);
let (grid, strokes) = (app.ui.view.snap_to_grid, app.session.prefs.scale_strokes);
click(&mut app, &ctx, &texts, "Snap to Grid");
assert_eq!(app.ui.view.snap_to_grid, !grid);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
assert_eq!(app.session.prefs.scale_strokes, !strokes);
}
/// #921: with the Gradient tool, a Gradient section: the type, and for a freeform gradient
/// Draw as Points or Lines radio buttons.
#[test]
fn the_gradient_tool_shows_the_gradient_type_and_draw() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.run("paint.setFill", json!({"gradient": {}})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
let texts = frame(&mut app, &ctx, vec![]);
assert!(!has(&texts, "Gradient"), "only with the Gradient tool");
app.select_tool("gradient");
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Gradient") && has(&texts, "Type:") && !has(&texts, "Draw:"), "{texts:?}");
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Draw:") && has(&texts, "Points") && has(&texts, "Lines"), "{texts:?}");
click(&mut app, &ctx, &texts, "Lines");
let fill = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
let vectorcraft_color::Paint::Gradient(g) = fill else { panic!("{fill:?}") };
assert_eq!(super::super::gradient::shown_points(&g).mode, vectorcraft_color::FreeformMode::Lines);
}
/// #696: the number fields are all as wide: the Transform fields, the rotation, the corner
/// radius, Opacity and the stroke weight.
#[test]
fn the_number_fields_are_all_as_wide() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
// The field each value is shown in: the narrowest widget around its text that isn't the
// text alone.
let field = |value: &str| {
let at = texts.iter().find(|(t, _)| t == value).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{value}: {texts:?}"));
ctx.viewport(|vp| {
vp.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.rect.contains(at) && w.rect.width() > 30.0 && w.rect.height() < 40.0)
.map(|w| w.rect.width())
.fold(f32::INFINITY, f32::min)
})
};
let widths = [field("35 pt"), field("0°"), field("0 pt"), field("100%")];
assert!(widths.iter().all(|w| (w - widths[0]).abs() < 1.0), "X, rotation, corner radius, opacity: {widths:?}");
}
/// #1512: the link behind Corner Radius. On, one field sets every corner; off, a field per
/// corner shows each corner's own radius.
#[test]
fn the_corner_radius_link_shows_a_field_per_corner() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("object.setLiveShape", json!({"radius": 7, "corners": [2]})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(app.session.prefs.single_corner_radius, "linked at first");
assert!(has(&texts, "Corner Radius:") && !has(&texts, "Top Left:") && !has(&texts, "7 pt"), "{texts:?}");
app.run("prefs.set", json!({"key": "singleCornerRadius", "value": false})).unwrap();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
for l in ["Corner Radius:", "Top Left:", "Top Right:", "Bottom Right:", "Bottom Left:"] {
assert!(has(&texts, l), "{l}: {texts:?}");
}
assert_eq!(texts.iter().filter(|(t, _)| t == "7 pt").count(), 1, "the bottom-right corner: {texts:?}");
// The fields sit in line, in the order top left, top right, bottom right, bottom left.
let at = |s: &str| texts.iter().find(|(t, _)| t == s).unwrap().1;
assert!(at("Top Left:").top() < at("Top Right:").top() && at("Top Right:").top() < at("Bottom Right:").top());
assert!((at("7 pt").top() - at("Bottom Right:").top()).abs() < 6.0, "{texts:?}");
}
/// The Transform section's More Options holds Scale Corners and Scale Strokes & Effects, and
/// stays open while they are toggled.
#[test]
fn transform_more_options_toggles_scale_corners_and_strokes() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap();
let ctx = egui::Context::default();
let has = |texts: &[(String, Rect)], s: &str| texts.iter().any(|(t, _)| t == s);
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
assert!(!has(&texts, "Scale Corners"), "closed at first");
// The button is an icon at the end of the angle row, under the H: field: sweep that row.
let h = texts.iter().find(|(t, _)| t == "H:").unwrap().1;
let mut open = None;
'sweep: for dy in (12..60).step_by(6) {
for x in (120..300).step_by(6) {
let at = egui::pos2(x as f32, h.bottom() + dy as f32);
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(at), b(true)]);
frame(&mut app, &ctx, vec![b(false)]);
let texts = frame(&mut app, &ctx, vec![]);
if has(&texts, "Scale Corners") {
open = Some(texts);
break 'sweep;
}
}
}
let texts = open.expect("More Options opens the popup");
let (sc, ss) = (app.session.prefs.scale_corners, app.session.prefs.scale_strokes);
click(&mut app, &ctx, &texts, "Scale Corners");
assert_eq!(app.session.prefs.scale_corners, !sc);
let texts = frame(&mut app, &ctx, vec![]);
assert!(has(&texts, "Scale Strokes & Effects"), "still open after a toggle");
click(&mut app, &ctx, &texts, "Scale Strokes & Effects");
assert_eq!(app.session.prefs.scale_strokes, !ss);
}
/// Both panels forward the Move Artwork toggle when their actual X field is edited.
#[test]
fn artboard_coordinate_fields_move_art_in_properties_and_transform() {
for draw in [show as fn(&mut VectorcraftApp, &mut Ui), super::super::transform::show] {
for move_art in [false, true] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 20, "width": 30, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.select_tool("artboard");
app.session.set_tool_option("moveArt", &json!(move_art));
let ctx = egui::Context::default();
panel_frame(&mut app, &ctx, vec![], draw);
let texts = panel_frame(&mut app, &ctx, vec![], draw);
let label = texts.iter().find(|(t, _)| t == "X:").unwrap().1;
let at = egui::pos2(label.right() + 40.0, label.center().y);
let press = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
panel_frame(&mut app, &ctx, vec![Event::PointerMoved(at), press(true)], draw);
panel_frame(&mut app, &ctx, vec![press(false)], draw);
panel_frame(
&mut app,
&ctx,
vec![Event::Key { key: egui::Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }],
draw,
);
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect.x0, 1.0, "the focused X field steps its position");
assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, if move_art { 11.0 } else { 10.0 });
app.run("edit.undo", json!({})).unwrap();
let d = &app.session.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect.x0, 0.0);
assert_eq!(d.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0, 10.0);
}
}
}
/// #530: Edit Artboards shows the active artboard, with New Artboard, Delete Artboard and Exit.
/// #775: type's Paragraph section has Hyphenate, and its checkbox turns it on.
#[test]
fn type_paragraph_section_hyphenates() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("text.create", json!({"x": 10, "y": 50, "text": "Paragraph"})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Hyphenate");
let hyphenate = |app: &VectorcraftApp| crate::panels::character::text_style(app).unwrap().1.hyphenate;
assert!(hyphenate(&app));
}
#[test]
fn edit_artboards_adds_artboards_and_exits() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Edit Artboards");
assert_eq!(app.session.tool_id(), "artboard");
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Artboard 1") && texts.iter().any(|(t, _)| t == "200 pt"), "{texts:?}");
click(&mut app, &ctx, &texts, "New Artboard");
let boards = |app: &VectorcraftApp| app.session.active().unwrap().doc.artboards.len();
assert_eq!(boards(&app), 2);
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Artboard 2"), "the new artboard is the active one: {texts:?}");
click(&mut app, &ctx, &texts, "Delete Artboard");
assert_eq!(boards(&app), 1);
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Exit");
assert_eq!(app.session.tool_id(), "selection");
}
fn artboard_deletion_app(count: usize) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100, "artboards": count})).unwrap();
for i in 0..count {
app.run("shape.rectangle", json!({"x": i * 250 + 10, "y": 10, "width": 30, "height": 20})).unwrap();
}
app.select_tool("artboard");
super::super::artboards::select(&mut app, 0);
app
}
fn artboard_deletion_click(
app: &mut VectorcraftApp,
ctx: &egui::Context,
draw: fn(&mut VectorcraftApp, &mut Ui),
label: Option<&str>,
modifiers: egui::Modifiers,
) {
let at = if let Some(label) = label {
panel_frame(app, ctx, vec![], draw);
let texts = panel_frame(app, ctx, vec![], draw);
texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}"))
} else {
// The trash is the last 24-point footer button. Use the drawn panel bounds, as the Asset Export bin test uses its footer.
let mut bounds = Rect::NOTHING;
for _ in 0..2 {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
draw(app, ui);
bounds = ui.min_rect();
});
out.textures_delta.clear();
}
assert!(bounds.width() >= 24.0 && bounds.height() >= 24.0);
bounds.right_bottom() - vec2(12.0, 12.0)
};
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers };
panel_frame(app, ctx, vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(at), button(true)], draw);
panel_frame(app, ctx, vec![button(false)], draw);
panel_frame(app, ctx, vec![Event::ModifiersChanged(egui::Modifiers::NONE)], draw);
}
fn select_artboard_rows(app: &mut VectorcraftApp, ctx: &egui::Context, indices: &[usize]) {
for (at, i) in indices.iter().enumerate() {
let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::SHIFT };
artboard_deletion_click(app, ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers);
}
let count = app.session.doc().unwrap().doc.artboards.len();
assert_eq!(super::super::artboards::picked(app, count), indices, "the rows are selected before deletion");
}
fn assert_artboard_deletion(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) {
for indices in [vec![0, 2], vec![2], vec![3], vec![2, 3], vec![0, 1, 2, 3]] {
let mut app = artboard_deletion_app(4);
let ctx = egui::Context::default();
select_artboard_rows(&mut app, &ctx, &indices);
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let undo = app.session.doc().unwrap().history.undo.len();
let all_selected = indices == [0, 1, 2, 3];
let expected: Vec<_> = if all_selected {
assert_eq!(app.view().unwrap().artboard, 3, "the active last board must not replace the first-board survivor oracle");
vec![&before.artboards[0]]
} else {
before.artboards.iter().enumerate().filter(|(i, _)| !indices.contains(i)).map(|(_, a)| a).collect()
};
let expected = serde_json::to_value(expected).unwrap();
let artwork = serde_json::to_value(&before.layers).unwrap();
let commands = app.run_count;
artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE);
let st = app.session.doc().unwrap();
assert_eq!(
serde_json::to_value(&st.doc.artboards).unwrap(),
expected,
"selected deletion keeps the expected IDs; all-selected keeps original board 1"
);
assert_eq!(app.run_count, commands.wrapping_add(1), "one command from the actual deletion control");
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork, "artwork IDs, geometry and appearance stay intact");
assert_eq!(st.history.undo.len(), undo + 1, "one deletion is one undo step");
assert!(st.is_dirty());
let active = app.view().unwrap().artboard;
if all_selected {
assert_eq!(active, 0);
}
assert!(active < st.doc.artboards.len(), "the active artboard remains in range");
assert_eq!(app.session.tool_options()["active"], json!(active));
assert_eq!(super::super::artboards::picked(&app, st.doc.artboards.len()), [active]);
assert_eq!(app.session.tool_options()["selected"], json!([active]), "raw tool selection contains only the remaining active board");
app.run("edit.undo", json!({})).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap());
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork);
assert_eq!(st.history.undo.len(), undo);
assert!(!st.is_dirty(), "one undo returns to the saved document");
app.run("edit.redo", json!({})).unwrap();
let st = app.session.doc().unwrap();
assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), expected, "redo removes the same artboard IDs");
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), artwork);
assert_eq!(st.history.undo.len(), undo + 1);
assert!(st.is_dirty());
}
}
fn assert_artboard_deletion_guard(draw: fn(&mut VectorcraftApp, &mut Ui), label: Option<&str>) {
let mut app = artboard_deletion_app(1);
// Leave a redo entry to ensure a disabled action preserves both history branches.
app.run("artboard.setProps", json!({"index": 0, "name": "Temporary"})).unwrap();
app.run("edit.undo", json!({})).unwrap();
let ctx = egui::Context::default();
select_artboard_rows(&mut app, &ctx, &[0]);
app.session.doc_mut().unwrap().mark_saved();
let before = app.session.doc().unwrap().doc.clone();
let st = app.session.doc().unwrap();
let history = (st.history.undo.len(), st.history.redo.len());
assert!(history.1 > 0);
let active = app.view().unwrap().artboard;
let options = app.session.tool_options();
let commands = app.run_count;
artboard_deletion_click(&mut app, &ctx, draw, label, egui::Modifiers::NONE);
assert_eq!(app.run_count, commands, "the disabled last-board control dispatches no command");
let st = app.session.doc().unwrap();
assert_eq!(serde_json::to_value(&st.doc.artboards).unwrap(), serde_json::to_value(&before.artboards).unwrap());
assert_eq!(serde_json::to_value(&st.doc.layers).unwrap(), serde_json::to_value(&before.layers).unwrap());
assert_eq!((st.history.undo.len(), st.history.redo.len()), history);
assert!(!st.is_dirty(), "deleting the last artboard is a no-op");
assert_eq!(app.view().unwrap().artboard, active);
assert_eq!(app.session.tool_options(), options);
}
#[test]
fn artboard_menu_deletes_selected_artboards_in_one_undo_step() {
assert_artboard_deletion(super::super::artboards::menu, Some("Delete Artboards"));
}
#[test]
fn properties_deletes_selected_artboards_in_one_undo_step() {
assert_artboard_deletion(show, Some("Delete Artboard"));
}
#[test]
fn artboard_menu_preserves_last_artboard() {
assert_artboard_deletion_guard(super::super::artboards::menu, Some("Delete Artboards"));
}
#[test]
fn properties_preserves_last_artboard() {
assert_artboard_deletion_guard(show, Some("Delete Artboard"));
}
#[test]
fn artboard_trash_deletes_selected_artboards_in_one_undo_step() {
assert_artboard_deletion(super::super::artboards::show, None);
}
#[test]
fn artboard_trash_preserves_last_artboard() {
assert_artboard_deletion_guard(super::super::artboards::show, None);
}
/// Click the actual panel footer's New button (24 points, with a 4-point gap before Trash).
fn artboard_history_new(app: &mut VectorcraftApp, ctx: &egui::Context) {
let mut bounds = Rect::NOTHING;
for _ in 0..2 {
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
super::super::artboards::show(app, ui);
bounds = ui.min_rect();
});
out.textures_delta.clear();
}
let count = app.session.doc().unwrap().doc.artboards.len();
let commands = app.run_count;
let at = bounds.right_bottom() - vec2(40.0, 12.0);
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
panel_frame(app, ctx, vec![Event::PointerMoved(at), button(true)], super::super::artboards::show);
panel_frame(app, ctx, vec![button(false)], super::super::artboards::show);
assert_eq!(app.run_count, commands.wrapping_add(1), "the actual New button dispatches once");
assert_eq!(app.session.doc().unwrap().doc.artboards.len(), count + 1);
assert_eq!(app.session.tool_options()["active"], json!(count));
assert_eq!(app.view().unwrap().artboard, count);
}
/// Keep the panel and its natural focus in the same context throughout Undo and deletion.
fn artboard_history_key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) {
for pressed in [true, false] {
let events = vec![Event::ModifiersChanged(modifiers), Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }];
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(300.0, 700.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::super::artboards::show(app, ui);
});
out.textures_delta.clear();
}
}
fn artboard_history_delete_after_undo(key: Option<egui::Key>, duplicate: bool) {
let mut app = artboard_deletion_app(2);
app.session.set_tool_option("moveArt", &json!(false));
app.session.set_tool_option("scaleArt", &json!(true));
app.session.doc_mut().unwrap().mark_saved();
let st = app.session.doc().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
assert!(!selection.is_empty(), "selected artwork catches a Clear fallthrough");
let undo = st.history.undo.len();
let ctx = egui::Context::default();
if duplicate {
let commands = app.run_count;
artboard_deletion_click(&mut app, &ctx, super::super::artboards::menu, Some("Duplicate Artboards"), egui::Modifiers::NONE);
assert_eq!(app.run_count, commands.wrapping_add(1));
assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 3);
assert_eq!(app.session.tool_options()["active"], json!(2));
assert_eq!(app.view().unwrap().artboard, 2);
} else {
artboard_history_new(&mut app, &ctx);
}
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before, "the real Undo shortcut restores the original document");
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
// Capture the state before another panel or pointer can repair it. Still exercise deletion
// on RED: keyboard deletion used to do nothing; Properties already clamped its own target.
let after_undo = (app.view().unwrap().artboard, app.session.tool_options());
if let Some(key) = key {
artboard_history_key(&mut app, &ctx, key, egui::Modifiers::NONE);
} else {
let texts = panel_frame(&mut app, &ctx, vec![], show);
assert!(texts.iter().any(|(label, _)| label == &before.artboards[1].name));
artboard_deletion_click(&mut app, &ctx, show, Some("Delete Artboard"), egui::Modifiers::NONE);
}
let st = app.session.doc().unwrap();
assert_eq!(st.doc.artboards, vec![before.artboards[0].clone()], "deletion after Undo removes the shown second board");
assert_eq!(st.doc.layers, before.layers, "ordinary artwork survives without geometry or style changes");
assert_eq!(st.selection, selection, "deletion never falls through to Clear");
assert_eq!(after_undo.0, 1, "Undo normalizes the navigator before another gesture");
assert_eq!(after_undo.1, json!({"active": 1, "selected": [1], "moveArt": false, "scaleArt": true}));
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
assert_eq!(st.history.undo.last().unwrap().label, "Delete Artboard");
assert!(st.is_dirty());
assert_eq!(app.view().unwrap().artboard, 0);
assert_eq!(app.session.tool_options(), json!({"active": 0, "selected": [0], "moveArt": false, "scaleArt": true}));
let deleted = st.doc.clone();
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
// Valid current selection stays put; history does not restore an earlier artboard selection.
assert_eq!(app.session.tool_options()["selected"], json!([0]));
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, deleted);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
assert!(st.is_dirty());
}
#[test]
fn artboard_history_new_undo_delete_uses_remaining_board() {
artboard_history_delete_after_undo(Some(egui::Key::Delete), false);
}
#[test]
fn artboard_history_new_undo_backspace_uses_remaining_board() {
artboard_history_delete_after_undo(Some(egui::Key::Backspace), false);
}
#[test]
fn artboard_history_new_undo_properties_agrees_with_tool() {
artboard_history_delete_after_undo(None, false);
}
#[test]
fn artboard_history_duplicate_undo_delete_uses_remaining_board() {
artboard_history_delete_after_undo(Some(egui::Key::Delete), true);
}
#[test]
fn artboard_history_undo_prunes_only_removed_indices() {
for order in [[0, 1, 2], [0, 2, 1]] {
let mut app = artboard_deletion_app(2);
app.session.set_tool_option("moveArt", &json!(false));
app.session.set_tool_option("scaleArt", &json!(true));
app.session.doc_mut().unwrap().mark_saved();
let st = app.session.doc().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
let undo = st.history.undo.len();
let ctx = egui::Context::default();
artboard_history_new(&mut app, &ctx);
let created = app.session.doc().unwrap().doc.clone();
for (at, i) in order.into_iter().enumerate() {
let modifiers = if at == 0 { egui::Modifiers::NONE } else { egui::Modifiers::SHIFT };
artboard_deletion_click(&mut app, &ctx, super::super::artboards::show, Some(&format!("Artboard {}", i + 1)), modifiers);
}
let options = app.session.tool_options();
let expected = if order[2] == 2 { json!([0, 1]) } else { json!([1, 0]) };
assert_eq!(options["selected"], if order[2] == 2 { json!([2, 0, 1]) } else { json!([1, 0, 2]) });
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
let mut normalized = options;
normalized["active"] = expected[0].clone();
normalized["selected"] = expected;
assert_eq!(app.session.tool_options(), normalized, "only the removed index is pruned, preserving surviving order and options");
assert_eq!(json!(app.view().unwrap().artboard), normalized["active"]);
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
assert_eq!(app.session.doc().unwrap().doc, created);
assert_eq!(app.session.doc().unwrap().selection, selection);
assert_eq!(app.session.tool_options(), normalized, "Redo does not replace an already valid multiselection");
}
}
#[test]
fn artboard_history_valid_multiselection_survives_undo_redo() {
let mut app = artboard_deletion_app(3);
app.session.set_tool_option("moveArt", &json!(false));
app.session.set_tool_option("scaleArt", &json!(true));
let ctx = egui::Context::default();
select_artboard_rows(&mut app, &ctx, &[0, 1]);
let options = app.session.tool_options();
assert_eq!(options["selected"], json!([1, 0]));
let view = app.view().unwrap().artboard;
app.session.doc_mut().unwrap().mark_saved();
let st = app.session.doc().unwrap();
let before = st.doc.clone();
let selection = st.selection.clone();
let undo = st.history.undo.len();
app.run("object.move", json!({"dx": 7, "dy": 9})).unwrap();
let moved = app.session.doc().unwrap().doc.clone();
assert_ne!(moved, before);
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND);
app.sync_views();
let st = app.session.doc().unwrap();
assert_eq!(st.doc, before);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, 1));
assert!(!st.is_dirty());
assert_eq!(app.session.tool_options(), options);
assert_eq!(app.view().unwrap().artboard, view);
artboard_history_key(&mut app, &ctx, egui::Key::Z, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
app.sync_views();
let st = app.session.doc().unwrap();
assert_eq!(st.doc, moved);
assert_eq!(st.selection, selection);
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo + 1, 0));
assert!(st.is_dirty());
assert_eq!(app.session.tool_options(), options);
assert_eq!(app.view().unwrap().artboard, view);
}
/// #681: Rearrange All opens Rearrange All Artboards in one click, from the Document section
/// (only with several artboards) and from Edit Artboards' Quick Actions.
#[test]
fn rearrange_all_opens_the_dialog() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let ctx = egui::Context::default();
assert!(!frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Rearrange All"), "one artboard: nothing to rearrange");
app.run("artboard.new", json!({})).unwrap();
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Rearrange All");
let kind = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.kind.clone());
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND));
app.ui.dialog = None;
app.select_tool("artboard");
let texts = frame(&mut app, &ctx, vec![]);
click(&mut app, &ctx, &texts, "Rearrange All");
assert_eq!(kind(&app).as_deref(), Some(crate::dialogs::rearrange_artboards::KIND));
}
/// #671: Edit Artboards shows the artboard's preset (Custom when it matches none) and its
/// orientation; the other orientation swaps its width and height, its corner staying put.
#[test]
fn edit_artboards_shows_the_preset_and_flips_the_orientation() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"preset": "Letter"})).unwrap();
app.run("artboard.setProps", json!({"index": 0, "x": 10, "y": 20})).unwrap();
app.select_tool("artboard");
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Preset:") && texts.iter().any(|(t, _)| t == "Letter"), "{texts:?}");
// The row's clickable widgets, left to right: the preset combo, Portrait, Landscape.
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
let buttons: Vec<Rect> = ctx.viewport(|vp| {
let mut r: Vec<Rect> = vp
.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.collect();
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r
});
let landscape = buttons.last().unwrap().center();
let press = |pressed| Event::PointerButton { pos: landscape, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(landscape), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
let rect = app.session.active().unwrap().doc.artboards[0].rect;
assert_eq!((rect.x0, rect.y0, rect.width(), rect.height()), (10.0, 20.0, 792.0, 612.0), "landscape, same corner");
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Letter"), "still Letter, turned: {texts:?}");
app.run("artboard.setProps", json!({"index": 0, "width": 333})).unwrap();
assert!(frame(&mut app, &ctx, vec![]).iter().any(|(t, _)| t == "Custom"));
}
/// #602: Edit Artboards shows Move and Scale Artwork with Artboard; with Scale on, a new size
/// (here the other orientation) takes the art on the artboard along.
#[test]
fn edit_artboards_scales_the_art_with_scale_artwork_with_artboard() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
let r = app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap();
app.select_tool("artboard");
let ctx = egui::Context::default();
let texts = frame(&mut app, &ctx, vec![]);
assert!(texts.iter().any(|(t, _)| t == "Move Artwork with Artboard"), "{texts:?}");
assert_eq!(app.session.tool_options()["scaleArt"], false);
click(&mut app, &ctx, &texts, "Scale Artwork with Artboard");
assert_eq!(app.session.tool_options()["scaleArt"], true);
// Portrait: 100 × 200, the art scaled by ½ across and 2 down.
let texts = frame(&mut app, &ctx, vec![]);
let row = texts.iter().find(|(t, _)| t == "Preset:").map(|(_, r)| *r).unwrap();
let portrait = ctx.viewport(|vp| {
let mut r: Vec<Rect> = vp
.prev_pass
.widgets
.layers()
.flat_map(|(_, w)| w.iter())
.filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right())
.map(|w| w.rect)
.collect();
r.sort_by(|a, b| a.left().total_cmp(&b.left()));
r[r.len() - 2].center()
});
let press = |pressed| Event::PointerButton { pos: portrait, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, vec![Event::PointerMoved(portrait), press(true)]);
frame(&mut app, &ctx, vec![press(false)]);
let d = &app.session.active().unwrap().doc;
let ab = d.artboards[0].rect;
assert_eq!((ab.width(), ab.height()), (100.0, 200.0));
let b = d.node(vectorcraft_doc::NodeId(r)).unwrap().geometric_bounds().unwrap();
assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 40.0, 20.0, 80.0));
}
}