Files
vectorcraft/crates/ui-egui/src/toolbar.rs
T

608 lines
32 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 Tools panel. Default: Illustrator 2026's categorized single-column toolbar (Select, Shapes,
//! Draw, Modify, Type, Navigate, Color). Window → Toolbars → Advanced shows every tool group.
//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar.
use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, icons, widgets};
const PITCH: f32 = 30.0;
const WIDTH: f32 = 48.0;
/// The Basic toolbar: (category, slots); each slot is a flyout group (first = default).
pub const BASIC: &[(&str, &[&[&str]])] = &[
("Select", &[&["selection"], &["directSelection", "groupSelection"], &["lasso", "magicWand"]]),
(
"Shapes",
&[
&["rectangle", "roundedRectangle", "star", "lineSegment", "arc", "spiral", "rectangularGrid", "polarGrid", "flare"],
&["ellipse"],
&["polygon"],
&["shaper"],
],
),
(
"Draw",
&[
&["pencil", "smooth", "pathEraser", "join"],
&["eraser", "scissors", "knife", "mirrorCut", "lineCut", "rectCut"],
&["paintbrush", "blobBrush"],
&["pen", "addAnchor", "deleteAnchor", "anchorPoint"],
&["curvature"],
],
),
(
"Modify",
&[
&["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"],
&["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"],
&["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"],
],
),
("Type", &[&["areaType", "typeOnPath", "verticalAreaType", "verticalTypeOnPath"], &["type", "verticalType", "touchType"]]),
("Navigate", &[&["zoom"], &["hand", "printTiling"], &["rotateView"]]),
("Color", &[&["gradient", "mesh"], &["eyedropper", "measure"]]),
];
fn tip(t: &ToolInfo) -> String {
match crate::shortcut_editor::tool_shortcut(t.id) {
Some(s) => format!("{} ({})", tl!(t.label), s),
None => tl!(t.label).to_string(),
}
}
/// Slots of the current layout: (category label for the first slot of a category, tool ids).
fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'static str>)> {
if app.ui.toolbar_advanced {
TOOL_GROUPS.iter().map(|g| (None, g.iter().map(|t| t.id).collect())).collect()
} else {
BASIC.iter().flat_map(|(cat, ss)| ss.iter().enumerate().map(move |(i, s)| (if i == 0 { Some(*cat) } else { None }, s.to_vec()))).collect()
}
}
/// Remember the tool shown in the slot that contains `id`.
pub fn remember(app: &mut VectorcraftApp, id: &str) {
for (_, s) in slots(app) {
if s.contains(&id) {
app.ui.slot_tool.insert(s[0].to_string(), id.to_string());
}
}
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let all = slots(app);
let avail = ui.available_height();
let labels = all.iter().filter(|s| s.0.is_some()).count() as f32 * 20.0;
let need = all.len() as f32 * PITCH + labels + 190.0;
let cols = if app.ui.toolbar_double || avail < need { 2 } else { 1 };
let w = if cols == 2 { 76.0 } else { WIDTH };
egui::Panel::left("toolbar")
.resizable(false)
.exact_size(w)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Dock header with » and a grip strip.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(
ui,
if cols == 2 { "chevrons-left" } else { "chevrons-right" },
egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)),
if hresp.hovered() { t.text_strong } else { t.text },
);
if hresp.on_hover_text(tl!("Toggle single/double column")).clicked() {
app.ui.toolbar_double = !app.ui.toolbar_double;
}
let (grip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::hover());
for k in 0..6 {
ui.painter().line_segment(
[pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)],
Stroke::new(0.4, t.text_disabled),
);
}
// The tools and the controls under them scroll in a window too short for them.
widgets::strip_scroll(ui, "toolbar", |ui| {
let active = app.session.tool_id();
// The tool button whose long press opened its flyout, until the next press.
let held_id = egui::Id::new("toolbar-held");
if ui.input(|inp| inp.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
let mut i = 0;
while i < all.len() {
if let Some(cat) = all[i].0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
// A long name is cut to its first four characters in the single column.
let cat = tl!(cat);
let label = if cols == 1 && cat.chars().count() > 6 {
format!("{}...", cat.chars().take(4).collect::<String>())
} else {
cat.to_string()
};
ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text);
}
// One row = `cols` slots (a category label always starts a new row).
let mut row = vec![i];
while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() {
row.push(i + row.len());
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
active.to_string()
} else {
app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string())
};
let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue };
let is_active = slot.contains(&active);
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag());
let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5));
if is_active {
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
}
let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });
if slot.len() > 1 {
let c = rect.center() + vec2(12.5, 10.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)],
t.icon,
Stroke::NONE,
));
}
let held_for = resp
.is_pointer_button_down_on()
.then(|| ui.input(|inp| inp.pointer.press_start_time().map(|s| inp.time - s)))
.flatten();
if slot.len() > 1
&& let Some(seconds) = held_for
&& seconds < 0.35
{
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
}
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35);
let alt = ui.input(|inp| inp.modifiers.alt);
if (resp.secondary_clicked() || long_press) && slot.len() > 1 {
open_flyout = Some((slot.clone(), rect));
if long_press {
ui.data_mut(|d| d.insert_temp(held_id, resp.id));
}
} else if resp.clicked() && held == Some(resp.id) {
// Releasing the long press that opened the flyout leaves it open.
} else if resp.clicked() && alt && slot.len() > 1 {
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
app.select_tool(slot[(idx + 1) % slot.len()]);
} else if resp.double_clicked() {
app.select_tool(shown.id);
app.run("tool.options", json!({ "tool": shown.id })).ok();
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tip(shown));
}
});
i += row.len();
}
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
d.insert_temp(egui::Id::new("flyout-anchor"), rect);
d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t);
});
});
flyout(app, ui.ctx());
}
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog, a Liquify tool's its Tool
/// Options dialog, the Blend tool's Blend
/// Options; the Print Tiling tool's resets the print tiling. As in the reference app, the Hand
/// tool's fits the artboard in the window, the Zoom tool's shows it at 100%, and the Rotate, Scale,
/// Reflect and Shear tools' are their Object › Transform dialogs.
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
match tool {
"hand" => app.run("view.fitArtboard", json!({})),
"zoom" => app.run("view.actualSize", json!({})),
"rotate" | "scale" | "reflect" | "shear" => {
let id = format!("object.{tool}");
if let Some(c) = vectorcraft_engine::find_command(&id) {
(c.enabled)(&app.session)?;
}
crate::menus::invoke(app, &id, json!({}));
Ok(json!({ "dialog": tool }))
}
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
"eyedropper" => {
crate::dialogs::eyedropper::open(app);
Ok(json!({ "dialog": crate::dialogs::eyedropper::KIND }))
}
"blend" => {
crate::dialogs::blend_options::open(app)?;
Ok(json!({ "dialog": crate::dialogs::blend_options::KIND }))
}
"perspectiveGrid" => {
crate::dialogs::perspective_options::open(app);
Ok(json!({ "dialog": crate::dialogs::perspective_options::KIND }))
}
// A double click on the Print Tiling tool puts the pages back where the placement puts them.
"printTiling" => app.run("print.tiling.set", json!({ "reset": true })),
// The Liquify tools: their Tool Options (the Global Brush Dimensions and the tool's own).
_ if vectorcraft_tools::settings::LIQUIFY.contains(&tool) => crate::dialogs::liquify::open(app, tool),
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
_ => Err(format!("the {tool} tool has no options")),
}
}
/// 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`).
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
/// (value, label) of each choice.
type Choices = &'static [(&'static str, &'static str)];
const MIRROR: [(&str, &str, Choices); 2] = [
("axis", "Axis:", &[("free", "Free"), ("vertical", "Vertical"), ("horizontal", "Horizontal")]),
("keep", "Keep:", &[("left", "Left"), ("right", "Right"), ("top", "Top"), ("bottom", "Bottom")]),
];
if app.session.tool_id() == "puppetWarp" {
return puppet_warp_options(app, ui);
}
if app.session.tool_id() != "mirrorCut" {
return;
}
let t = Tokens::get(ui.ctx());
let opts = app.session.tool_options();
for (key, label, choices) in MIRROR {
ui.label(egui::RichText::new(tl!(label)).size(12.0).color(t.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)) {
app.run("tool.setOption", json!({ "key": key, "value": value })).ok();
}
}
ui.separator();
}
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
ui.add_space(5.0);
// Color (the last solid colour), Gradient (the last gradient) and None, as commands.
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 27.0) / 2.0);
ui.spacing_mut().item_spacing.x = 2.0;
let mut clicked = None;
for (tip, cmd) in [("Color (,)", "paint.lastColor"), ("Gradient (.)", "paint.lastGradient"), ("None (/)", "paint.none")] {
let (r, resp) = ui.allocate_exact_size(vec2(7.5, 7.5), Sense::click());
match cmd {
"paint.lastColor" => widgets::paint_chip(ui, r, &Paint::solid(app.session.last_solid)),
"paint.lastGradient" => widgets::gradient_chip(ui, r, &app.session.last_gradient.gradient),
_ => widgets::paint_chip(ui, r, &Paint::None),
}
if resp.on_hover_text(tl!(tip)).clicked() {
clicked = Some(cmd);
}
}
if let Some(cmd) = clicked {
app.run(cmd, json!({})).ok();
}
});
ui.add_space(6.0);
ui.vertical_centered(|ui| {
let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"];
let names = ["Draw Normal", "Draw Behind", "Draw Inside"];
let m = match app.session.draw_mode {
vectorcraft_engine::DrawMode::Normal => 0,
vectorcraft_engine::DrawMode::Behind => 1,
vectorcraft_engine::DrawMode::Inside => 2,
};
if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", tl!(names[m])), m != 0, 26.0).clicked() {
app.run("view.drawMode", json!({})).ok();
}
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
app.run("view.screenMode", json!({})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() {
app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({})));
}
});
let _ = t;
}
fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.flyout.is_none() {
return;
}
let tools: Vec<String> = ctx.data(|d| d.get_temp(egui::Id::new("flyout-tools"))).unwrap_or_default();
if tools.is_empty() {
app.ui.flyout = None;
return;
}
let anchor: egui::Rect =
ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0)));
let t = Tokens::get(ctx);
let mut chosen = None;
let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(
ctx,
|ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.input_border))
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) })
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
let w = 250.0;
for id in &tools {
let Some(tool) = tool_info(id) else { continue };
let active = app.session.tool_id() == tool.id;
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
if active {
ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text);
}
let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon);
let color = if active { t.flyout_active } else { t.text_strong };
ui.painter().text(
r.left_center() + vec2(52.0, 0.0),
egui::Align2::LEFT_CENTER,
tl!(tool.label),
egui::FontId::proportional(13.0),
color,
);
if let Some(sc) = crate::shortcut_editor::tool_shortcut(tool.id) {
ui.painter().text(
r.right_center() - vec2(18.0, 0.0),
egui::Align2::RIGHT_CENTER,
format!("({sc})"),
egui::FontId::proportional(13.0),
color,
);
}
if resp.clicked() {
chosen = Some(tool.id);
}
}
});
},
);
if let Some(id) = chosen {
app.select_tool(id);
} else if resp.response.clicked_elsewhere() && !ctx.input(|i| i.pointer.interact_pos().is_some_and(|p| anchor.contains(p))) {
// A click on the flyout's own tool button (the right-click that opened it, or the end of a
// long press) leaves it to that button.
app.ui.flyout = None;
}
let _ = theme::semibold;
}
/// 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) {
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 show = opts["showMesh"].as_bool().unwrap_or(true);
if 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() {
app.run("tool.setOption", json!({ "key": "selectAllPins", "value": true })).ok();
}
ui.separator();
}
#[cfg(test)]
pub(crate) mod tests {
use egui::{Event, PointerButton, Pos2};
use vectorcraft_engine::Session;
use super::*;
/// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<egui::Rect> {
frame_in(app, ctx, time, events, 1200.0)
}
/// [`frame`] in a window `height` points tall.
fn frame_in(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>, height: f32) -> Vec<egui::Rect> {
let input = egui::RawInput {
time: Some(time),
events,
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, height))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| show(app, ui));
out.textures_delta.clear();
widget_rects(ctx, vec2(36.0, PITCH - 1.0))
}
/// The rects of the last frame's widgets of `size`, top to bottom.
pub(crate) fn widget_rects(ctx: &egui::Context, size: egui::Vec2) -> Vec<egui::Rect> {
let mut r: Vec<egui::Rect> =
ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect());
r.sort_by(|a, b| a.top().total_cmp(&b.top()));
r
}
/// A mouse wheel turn over `at`, scrolling the content up by `dy` points.
pub(crate) fn wheel(at: Pos2, dy: f32) -> Vec<Event> {
let unit = egui::MouseWheelUnit::Point;
vec![Event::PointerMoved(at), Event::MouseWheel { unit, delta: vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: Default::default() }]
}
#[test]
fn the_toolbar_scrolls_in_a_short_window() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let ctx = egui::Context::default();
// 500 pt: even two columns don't fit, so Edit Toolbar (the last button) starts below the window.
let tools = frame_in(&mut app, &ctx, 0.0, vec![], 500.0);
let edit_toolbar = || widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap();
assert!(edit_toolbar().bottom() > 500.0);
frame_in(&mut app, &ctx, 0.1, wheel(tools[0].center(), 2000.0), 500.0);
for k in 2..40 {
frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0);
}
assert!(edit_toolbar().bottom() <= 500.0, "scrolled into view: {:?}", edit_toolbar());
// Back up to the top.
frame_in(&mut app, &ctx, 4.0, wheel(tools[0].center(), -2000.0), 500.0);
for k in 41..80 {
frame_in(&mut app, &ctx, f64::from(k) * 0.1, vec![], 500.0);
}
assert_eq!(frame_in(&mut app, &ctx, 8.0, vec![], 500.0)[0], tools[0]);
}
/// A double-click at `at`, `time` seconds in (a second apart from the last).
fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)]);
frame(app, ctx, time + 0.1, vec![]);
}
#[test]
fn double_clicking_a_tool_button_opens_its_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let ctx = egui::Context::default();
let buttons = frame(&mut app, &ctx, 0.0, vec![]);
// The Color category's slots close the list: Gradient, then Eyedropper.
let (gradient, eyedropper) = (buttons[buttons.len() - 2], buttons[buttons.len() - 1]);
double_click(&mut app, &ctx, 1.0, gradient.center());
assert_eq!((app.session.tool_id(), app.ui.open_panel.as_deref()), ("gradient", Some("gradient")));
// Again: the panel stays open.
double_click(&mut app, &ctx, 2.0, gradient.center());
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
double_click(&mut app, &ctx, 3.0, eyedropper.center());
let d = app.ui.dialog.clone().expect("Eyedropper Options");
assert_eq!((app.session.tool_id(), d.kind.as_str()), ("eyedropper", crate::dialogs::eyedropper::KIND));
// OK applies the options.
app.ui.dialog.as_mut().unwrap().fields.insert("apply".into(), json!({"appearance": {"transparency": false}}));
crate::dialogs::confirm(&mut app).unwrap();
let o = app.session.prefs.eyedropper;
assert!(o.pick_up.appearance.transparency && !o.apply.appearance.transparency && o.apply.appearance.fill.color);
assert!(app.run("tool.options", json!({"tool": "lasso"})).is_err());
}
#[test]
fn double_clicking_the_hand_zoom_and_transform_tools() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
app.canvas_rect = Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(660.0, 460.0)));
app.run("view.setZoom", json!({"zoom": 333, "center": [10, 10]})).unwrap();
// Hand: the artboard fits the window. Zoom: 100%.
app.run("tool.options", json!({"tool": "hand"})).unwrap();
let v = *app.view().unwrap();
assert_eq!((v.center.x, v.center.y, v.zoom), (150.0, 100.0, 2.0));
app.run("tool.options", json!({"tool": "zoom"})).unwrap();
assert_eq!(app.view().unwrap().zoom, 1.0);
// The transform tools open their dialogs, like Object › Transform: not with nothing selected.
assert_eq!(app.run("tool.options", json!({"tool": "rotate"})), Err("nothing selected".into()));
assert!(app.ui.dialog.is_none());
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap();
for tool in ["rotate", "scale", "reflect", "shear"] {
app.run("tool.options", json!({"tool": tool})).unwrap();
assert_eq!(app.ui.dialog.take().map(|d| d.kind), Some(tool.to_string()));
}
// OK in Rotate turns the selection about its centre.
app.run("tool.options", json!({"tool": "rotate"})).unwrap();
app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(90));
crate::dialogs::confirm(&mut app).unwrap();
let b = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
assert!((b.x0 - 20.0).abs() < 1e-9 && (b.y0 - 0.0).abs() < 1e-9 && (b.width() - 20.0).abs() < 1e-9, "{b:?}");
}
#[test]
fn right_clicking_a_tool_group_opens_its_flyout() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let ctx = egui::Context::default();
let buttons = frame(&mut app, &ctx, 0.0, vec![]);
// Selection, Direct Selection, Lasso, then the Shapes group (Rectangle and its sub-tools).
let at = buttons[3].center();
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), b(true)]);
frame(&mut app, &ctx, 1.05, vec![b(false)]);
frame(&mut app, &ctx, 1.1, vec![]);
assert!(app.ui.flyout.is_some(), "the right-click opens the flyout and the same click doesn't close it");
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).expect("the flyout is shown");
assert_eq!(app.session.tool_id(), "selection", "a right-click only opens the flyout");
// Its second row is Rounded Rectangle (30 pt rows).
let row = egui::pos2(menu.left() + 60.0, menu.top() + 45.0);
let p = |pressed| Event::PointerButton { pos: row, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), p(true)]);
frame(&mut app, &ctx, 2.05, vec![p(false)]);
assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None));
// A long press opens it too, and releasing the press (a click, being short of 0.8 s) leaves
// it open without choosing the button's tool.
let p = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 3.0, vec![Event::PointerMoved(at), p(true)]);
frame(&mut app, &ctx, 3.5, vec![]);
assert!(app.ui.flyout.is_some(), "a long press opens the flyout");
frame(&mut app, &ctx, 3.6, vec![p(false)]);
frame(&mut app, &ctx, 3.7, vec![]);
assert!(app.ui.flyout.is_some(), "releasing the long press leaves the flyout open");
// A click anywhere else closes it.
let away = egui::pos2(300.0, 1000.0);
let c = |pressed| Event::PointerButton { pos: away, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, &ctx, 4.0, vec![Event::PointerMoved(away), c(true)]);
frame(&mut app, &ctx, 4.05, vec![c(false)]);
assert_eq!((app.session.tool_id(), app.ui.flyout), ("roundedRectangle", None));
}
#[test]
fn type_button_long_press_selects_vertical_type_in_both_layouts() {
for advanced in [false, true] {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.ui.toolbar_advanced = advanced;
let all = slots(&app);
let index = all.iter().position(|(_, tools)| tools.contains(&"type")).unwrap();
assert!(all[index].1.contains(&"verticalType"));
for id in ["type", "verticalType", "areaType", "verticalAreaType", "typeOnPath", "verticalTypeOnPath"] {
assert_eq!(all.iter().filter(|(_, tools)| tools.contains(&id)).count(), 1);
}
let ctx = egui::Context::default();
let at = frame_in(&mut app, &ctx, 0.0, vec![], 2000.0)[index].center();
let pointer = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame_in(&mut app, &ctx, 1.0, vec![Event::PointerMoved(at), pointer(at, true)], 2000.0);
frame_in(&mut app, &ctx, 1.36, vec![], 2000.0);
frame_in(&mut app, &ctx, 1.4, vec![pointer(at, false)], 2000.0);
frame_in(&mut app, &ctx, 1.45, vec![], 2000.0);
assert_eq!(app.session.tool_id(), "selection");
let tools: Vec<String> = ctx.data(|d| d.get_temp(egui::Id::new("flyout-tools"))).unwrap();
let row_index = tools.iter().position(|id| id == "verticalType").unwrap();
let menu = ctx.memory(|m| m.area_rect(egui::Id::new("tool-flyout"))).unwrap();
let row = egui::pos2(menu.left() + 60.0, menu.top() + 15.0 + row_index as f32 * 30.0);
frame_in(&mut app, &ctx, 2.0, vec![Event::PointerMoved(row), pointer(row, true)], 2000.0);
frame_in(&mut app, &ctx, 2.05, vec![pointer(row, false)], 2000.0);
assert_eq!(app.session.tool_id(), "verticalType");
assert_eq!(app.ui.flyout, None);
}
}
}