Files
vectorcraft/crates/ui-egui/src/toolbar.rs
T
dotCooCoo 85d7913f10 Gradient: Cmd/Ctrl-click on the Gradient Fill box or the Gradient button resets to White, Black
The reference app resets a gradient to black and white when the
gradient button in the Tools panel is Cmd/Ctrl-clicked, and to the
default black and white linear gradient when the Gradient Fill box in
the Gradient panel is Cmd/Ctrl-clicked. In VectorCraft a Cmd/Ctrl-click
on either did what a plain click does, and the reset was only in the
Gradient panel menu.

A Cmd/Ctrl-click on the Gradient panel's thumbnail or on the Tools
panel's Gradient button now makes the gradient of the active Fill or
Stroke a linear gradient with the White, Black stops, through
paint.editGradient, as one undo step. A solid or no paint becomes that
gradient, and with nothing selected the default paint changes. A plain
click keeps its behavior, and the panel menu's Reset to White, Black
is unchanged.

Docs: docs/target-app-parity.md.
2026-10-29 10:03:12 +07:00

868 lines
44 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; the
//! double arrow at the top (or Window → Toolbars → Double Column, `window.toolbarColumns`) lays the
//! tools out in one or two columns.
//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar (its
//! All Tools drawer and the toolbar edits it makes: `edit_toolbar`).
use std::sync::Arc;
use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info};
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, icons, widgets};
const PITCH: f32 = 30.0;
const WIDTH: f32 = 48.0;
/// Seconds a press on a tool group's button is held before its flyout opens.
const LONG_PRESS: f64 = 0.35;
/// Width of the grab bar down a flyout's right side: dragging it tears the flyout off.
const BAR: f32 = 10.0;
/// Height of the × at the top of a floating flyout's bar.
const CLOSE: f32 = 14.0;
/// Points the pointer travels on a flyout's bar before the flyout tears off.
const TEAR: f32 = 3.0;
/// Seconds a floating flyout flashes when a press that would open its flyout raises it.
const FLASH: f64 = 0.4;
/// Size of a tool button in a floating flyout's strip.
const STRIP_BUTTON: egui::Vec2 = vec2(32.0, PITCH);
/// 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"],
&["dimension"],
],
),
("Type", &[&["areaType", "typeOnPath", "verticalAreaType", "verticalTypeOnPath"], &["type", "verticalType", "touchType"]]),
("Navigate", &[&["zoom"], &["hand", "printTiling"], &["rotateView"]]),
("Color", &[&["gradient", "mesh"], &["eyedropper", "measure"]]),
];
pub(crate) 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, as Edit Toolbar left it: (category label for the first slot of a
/// category, tool ids).
pub(crate) fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'static str>)> {
if let Some(edited) = crate::edit_toolbar::edited_slots(app) {
edited
} else 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());
// The user's choice alone: a window too short for the tools scrolls them.
let cols = if app.ui.toolbar_double { 2 } else { 1 };
// Two 36 pt buttons with 2 pt either side inside the border and separator line (at 76 they
// overran the right border).
let w = if cols == 2 { 80.0 } else { WIDTH };
// Where the panel docks: along the window's left edge, under the bars.
let edge = ui.available_rect_before_wrap();
ui.ctx().data_mut(|d| d.insert_temp(crate::tools_float::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height()))));
match app.ui.toolbar_pos {
None => {
let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 };
egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| {
crate::theme::card(ui, &t, margin, |ui| body(app, ui, cols, None));
});
}
Some(pos) => {
// Floating, kept inside the window, its tools scrolling when they don't fit under it.
let ctx = ui.ctx().clone();
let id = egui::Id::unique("toolbar-floating");
let screen = ctx.content_rect();
// Kept on screen by its title bar and first tools: the rest scrolls.
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(w, 160.0), |r| vec2(r.width(), r.height().min(160.0)));
let at = crate::tools_float::clamp(pos, size, screen);
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(&ctx, |ui| {
flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| {
ui.set_width(w);
ui.set_max_height(screen.bottom() - at.y - 12.0);
body(app, ui, cols, Some(at));
});
});
}
}
// Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two
// layers in one frame).
floating(app, ui.ctx());
crate::dialogs::dimension::floating(app, ui.ctx());
flyout(app, ui.ctx());
crate::edit_toolbar::drawer(app, ui.ctx());
}
/// The Tools panel's contents, docked or floating at `floating`.
fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egui::Pos2>) {
let t = Tokens::get(ui.ctx());
let all = slots(app);
let bounds = ui.max_rect();
// The drawer opens beside it; a click in it leaves the drawer open.
ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds));
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Title bar with » and a grip strip: a drag on either floats the panel or moves it.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click_and_drag());
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 },
);
let (grip, gresp) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::drag());
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),
);
}
crate::tools_float::drag_tools(app, ui.ctx(), &hresp.union(gresp.clone()), bounds, floating);
hresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Toggle single/double column")));
if hresp.on_hover_text(tl!("Toggle single/double column")).clicked()
// It only fails on bad params, which this never sends; show it all the same.
&& let Err(e) = app.run("window.toolbarColumns", json!({ "double": cols == 1 }))
{
app.ui.status = e;
}
let grip_tip = if floating.is_some() { tl!("Drag to the window's left edge to dock") } else { tl!("Drag to float the toolbar") };
gresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), grip_tip));
gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(grip_tip);
// 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::unique("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));
// While Edit Toolbar's drawer is open, the tools drag (to new places or into the drawer)
// instead of opening their flyouts.
let editing = app.ui.tools_drawer;
let mut buttons: Vec<(usize, egui::Rect)> = vec![];
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
// A group torn off into a floating flyout is raised instead of opening its flyout.
let mut raise: Option<&'static str> = None;
let labels = app.session.prefs.tool_group_labels;
let mut i = 0;
while i < all.len() {
if all[i].0.is_some() && !labels {
// User Interface › Show Tool Group Labels off: the tools run on from group to group,
// in one column or two (as the reference's Basic toolbar in two), with nothing
// between the groups.
} else 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; without labels,
// nothing does).
let mut row = vec![i];
while row.len() < cols && i + row.len() < all.len() && (all[i + row.len()].0.is_none() || !labels) {
row.push(i + row.len());
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
// Centred on the panel's inside (`bounds`: within its border and, docked, the
// separator line egui reserves on its right), not on `WIDTH`, which sat them 2½ pt
// right of centre docked; nor on this row, which the scroll area starts ½ pt in.
ui.add_space((bounds.center().x - cols as f32 * 18.0 - ui.cursor().min.x).max(0.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(30.0, 27.5));
if is_active {
ui.painter().rect_filled(well, CornerRadius::same(6), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(6), 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));
}
if editing {
buttons.push((k, rect));
resp.dnd_set_drag_payload(crate::edit_toolbar::ToolDrag { tool: shown.id, from_toolbar: true });
}
let press = if slot.len() > 1 && !editing { flyout_press(ui, &resp, rect) } else { None };
let alt = ui.input(|inp| inp.modifiers.alt);
if press.is_some() {
if is_floating(app, slot[0]) {
raise = Some(slot[0]);
} else {
open_flyout = Some((slot.clone(), rect));
}
if press == Some(FlyoutPress::Primary) {
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.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), is_active, tip(shown)));
resp.on_hover_text(tip(shown));
}
});
i += row.len();
}
if editing {
crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds);
}
if let Some(key) = raise {
raise_floating(ui.ctx(), key);
}
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
d.insert_temp(egui::Id::unique("flyout-anchor"), rect);
d.insert_temp(egui::Id::unique("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t, cols);
});
}
/// How a press on a tool group's button opens its flyout.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FlyoutPress {
/// A right press (it opens at once, before the release).
Secondary,
/// A primary press on the corner triangle, or one held [`LONG_PRESS`] seconds: its release
/// mustn't pick the button's tool.
Primary,
}
/// Whether this frame's pointer state on a tool button opens its group's flyout. A primary press
/// still short of a long one asks for the frame where it becomes long: egui draws only on input,
/// so a still mouse would otherwise never get there.
fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<FlyoutPress> {
if resp.hovered() && ui.input(|inp| inp.pointer.button_pressed(egui::PointerButton::Secondary)) {
return Some(FlyoutPress::Secondary);
}
if !resp.is_pointer_button_down_on() || !ui.input(|inp| inp.pointer.primary_down()) {
return None;
}
// The triangle's corner: the bottom-right third of the button.
let corner = egui::Rect::from_min_max(rect.center() + vec2(6.0, 4.0), rect.max);
let (origin, held) = ui.input(|inp| (inp.pointer.press_origin(), inp.pointer.press_start_time().map(|s| inp.time - s)));
if origin.is_some_and(|p| corner.contains(p)) {
return Some(FlyoutPress::Primary);
}
let held = held.unwrap_or(0.0);
if held >= LONG_PRESS {
return Some(FlyoutPress::Primary);
}
ui.ctx().request_repaint_after_secs((LONG_PRESS - held) as f32);
None
}
/// 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%, the Rotate, Scale,
/// Reflect and Shear tools' are their Object › Transform dialogs, the selection tools' are the
/// Move dialog, and the Pencil, Paintbrush, Smooth, Blob Brush, Eraser and Shape Builder tools' are
/// their Tool Options, as are the Line Segment, Arc, Rectangular Grid and Polar Grid tools'
/// ([`crate::dialogs::shapes::open_options`]). The Type on a Path and Vertical Type on a Path
/// tools' are Type on a Path Options for the selected type on a path.
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
match tool {
"dimension" => crate::dialogs::dimension::open(app),
"hand" => app.run("view.fitArtboard", json!({})),
"zoom" => app.run("view.actualSize", json!({})),
"rotate" | "scale" | "reflect" | "shear" | "selection" | "directSelection" | "groupSelection" => {
let dialog = if vectorcraft_tools::catalog::is_selection_tool(tool) { "move" } else { tool };
let id = format!("object.{dialog}");
if let Some(c) = vectorcraft_engine::find_command(&id) {
(c.enabled)(&app.session)?;
}
crate::menus::invoke(app, &id, json!({}));
Ok(json!({ "dialog": dialog }))
}
// The Gradient and Magic Wand tools: their panels.
"gradient" | "magicWand" if crate::panel_docking::showing(&app.ui, tool) => Ok(json!({ "open": tool })),
"gradient" | "magicWand" => app.run("window.panel", json!({ "panel": tool })),
"artboard" => crate::dialogs::artboard_options::open(app),
"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 }))
}
"shapeBuilder" => Ok(crate::dialogs::shape_builder_options::open(app)),
// The Line Segment, Arc and grid tools: their Tool Options, the options of their next
// drag and click.
_ if crate::dialogs::shapes::OPTION_TOOLS.contains(&tool) => crate::dialogs::shapes::open_options(app, tool),
// The Flare tool: its options, which draw the next flare (OK draws none).
"flare" => Ok(crate::dialogs::flare_options::open(app, None)),
// 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),
// The freehand tools: their Tool Options (Fidelity, fill, the tolerances, the brush size).
_ if crate::dialogs::freehand::TOOLS.contains(&tool) => crate::dialogs::freehand::open(app, tool),
// The Live Paint Selection tool: its Tool Options (what a click selects, the highlight).
"livePaintSelection" => crate::dialogs::live_paint_selection::open(app),
// The Symbolism tools: the brush they share.
_ if vectorcraft_tools::settings::SYMBOLISM.contains(&tool) => Ok(crate::dialogs::symbolism_options::open(app, tool)),
// The graph tools: Graph Type for the selected graph.
_ if vectorcraft_tools::extra::is_graph_tool(tool) => crate::menus::graph_dialog(app, "graph.setType"),
// The Type on a Path tools: Type on a Path Options for the selected type on a path, as
// Type › Type on a Path › Type on a Path Options… opens it.
"typeOnPath" | "verticalTypeOnPath" => {
let id = "type.pathOptions";
if let Some(c) = vectorcraft_engine::find_command(id) {
(c.enabled)(&app.session)?;
}
app.session.execute(id, &json!({})).map_err(|e| e.to_string())?;
crate::menus::invoke(app, id, json!({}));
Ok(json!({ "dialog": "command" }))
}
_ 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`). The Artboard tool's options are drawn by
/// [`crate::panels::artboards::control_bar`].
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if app.session.tool_id() == "dimension" {
return crate::dialogs::dimension::controls(app, 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() == vectorcraft_tools::cropimage::ID {
return crop_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();
}
/// Crop Image's box: its centre (X, Y) and size (W, H) in the general unit, set through the tool's
/// `rect` option, then Apply (Enter) and Cancel (Escape).
fn crop_options(app: &mut VectorcraftApp, ui: &mut Ui) {
let set = app.session.tool_options()["rect"].as_array().and_then(|v| match v.iter().filter_map(Value::as_f64).collect::<Vec<_>>()[..] {
[x, y, w, h] => Some(vectorcraft_geom::Rect::new(x, y, x + w, y + h)),
_ => None,
});
let Some((_, _, r)) = app.session.active().and_then(|st| vectorcraft_tools::cropimage::crop_box(&st.doc, &st.selection, set)) else { return };
let t = Tokens::get(ui.ctx());
let units = app.session.general_unit();
let c = r.center();
for (k, lbl, v) in [("x", "X:", c.x), ("y", "Y:", c.y), ("width", "W:", r.width()), ("height", "H:", r.height())] {
widgets::field_label(ui, egui::RichText::new(tl!(lbl)).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb-crop", k), Some(v), units, 80.0) {
let (mut c, mut w, mut h) = (c, r.width(), r.height());
match k {
"x" => c.x = nv,
"y" => c.y = nv,
"width" => w = nv.max(0.0),
_ => h = nv.max(0.0),
}
let rect = [c.x - w / 2.0, c.y - h / 2.0, w, h];
app.run("tool.setOption", json!({ "key": "rect", "value": rect })).ok();
}
}
ui.separator();
for (label, key) in [(tl!("Apply"), ToolKey::Enter), (tl!("Cancel"), ToolKey::Escape)] {
if widgets::flat_button(ui, label, 64.0).clicked() {
let view = app.view_info();
let r = app.session.tool_key(key, Mods::default(), view);
crate::canvas::apply_requests(app, r);
}
}
ui.separator();
}
/// The Fill/Stroke proxy, Color/Gradient/None, the drawing modes, screen mode and Edit Toolbar, as
/// large as the toolbar's `cols` (one or two) leave room for (#1129): in two columns the proxy and
/// buttons grow and the three drawing modes show side by side.
/// The All Tools drawer's Show section hides any of them but Edit Toolbar.
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usize) {
let two = cols == 2;
let show = app.ui.toolbar_show;
if show.fill_stroke {
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, if two { 54.0 } else { 36.0 }));
ui.add_space(5.0);
}
// Color (the last solid colour), Gradient (the last gradient) and None, as commands.
let (chip, gap) = if two { (17.0, 4.0) } else { (11.0, 2.0) };
if show.coloring {
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 3.0 * chip - 2.0 * gap) / 2.0);
ui.spacing_mut().item_spacing.x = gap;
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(chip, chip), 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),
_ => widgets::paint_chip(ui, r, &Paint::None),
}
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!(tip)));
if resp.on_hover_text(tl!(tip)).clicked() {
clicked = Some(cmd);
}
}
// Cmd/Ctrl-click on Gradient resets the gradient to White, Black.
if clicked == Some("paint.lastGradient") && ui.input(|i| i.modifiers.command) {
crate::panels::gradient::reset_to_linear_white_black(app);
} else if let Some(cmd) = clicked {
app.run(cmd, json!({})).ok();
}
});
ui.add_space(6.0);
}
let modes = [
(vectorcraft_engine::DrawMode::Normal, "dc-draw-normal", "Draw Normal", "normal"),
(vectorcraft_engine::DrawMode::Behind, "dc-draw-behind", "Draw Behind", "behind"),
(vectorcraft_engine::DrawMode::Inside, "dc-draw-inside", "Draw Inside", "inside"),
];
if two && show.draw_mode {
// Side by side, each one a click away.
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 3.0 * 22.0) / 2.0);
for (mode, icon, name, id) in modes {
if widgets::icon_button(ui, icon, &format!("{} (Shift+D)", tl!(name)), app.session.draw_mode == mode, 22.0).clicked() {
app.run("view.drawMode", json!({ "mode": id })).ok();
}
}
});
}
ui.vertical_centered(|ui| {
// One column: the current mode's button, which cycles them.
if !two
&& show.draw_mode
&& let Some((mode, icon, name, _)) = modes.into_iter().find(|m| m.0 == app.session.draw_mode)
&& widgets::icon_button(ui, icon, &format!("{} (Shift+D)", tl!(name)), mode != vectorcraft_engine::DrawMode::Normal, 26.0).clicked()
{
app.run("view.drawMode", json!({})).ok();
}
if show.screen_mode && widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
app.run("view.screenMode", json!({})).ok();
}
// Edit Toolbar: the All Tools drawer opens beside the toolbar, by this button.
let edit = widgets::icon_button(ui, "ellipsis", "Edit Toolbar", app.ui.tools_drawer, 26.0);
ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::button_id(), edit.rect));
if edit.clicked() {
app.run("window.editToolbar", json!({})).ok();
}
});
let _ = t;
}
/// Where `window.floatTools` puts a strip (the next one a row lower), right of the toolbar; it's
/// kept on screen and moved by its bar.
const FLOAT_AT: [f32; 2] = [90.0, 100.0];
/// Float the toolbar group (of the current layout) holding `tool` as a strip of tool buttons, put
/// it back in the toolbar, or toggle (`floating` omitted), as a flyout's tear-off bar and the
/// strip's × do (`window.floatTools`). Returns the new state.
pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>) -> Result<bool, String> {
let Some((_, group)) = slots(app).into_iter().find(|(_, s)| s.len() > 1 && s.contains(&tool)) else {
return Err(format!("{tool} isn't in a toolbar group of several tools"));
};
let key = group.first().copied().unwrap_or_default();
let was = is_floating(app, key);
let on = floating.unwrap_or(!was);
if on && !was {
let n = app.ui.floating_flyouts.len() as f32;
let tools = group.iter().map(|id| id.to_string()).collect();
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] });
app.ui.flyout = None;
} else if !on {
app.ui.floating_flyouts.retain(|f| f.tools.first().is_none_or(|k| k != key));
}
Ok(on)
}
/// Whether the group whose first tool is `key` floats as its own panel.
fn is_floating(app: &VectorcraftApp, key: &str) -> bool {
app.ui.floating_flyouts.iter().any(|f| f.tools.first().is_some_and(|k| k == key))
}
/// The area of the floating flyout of the group whose first tool is `key`.
fn floating_area(key: &str) -> egui::Id {
egui::Id::unique(("tool-floating", key))
}
/// Bring a floating flyout to the front and flash its border.
fn raise_floating(ctx: &egui::Context, key: &str) {
let id = floating_area(key);
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
let now = ctx.input(|i| i.time);
ctx.data_mut(|d| d.insert_temp(id, now));
}
/// A flyout's frame: drop shadow and border (the accent while it flashes).
pub(crate) fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame {
egui::Frame::NONE
.fill(t.panel)
.stroke(if flash { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.input_border) })
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) })
}
/// What a press on a flyout did.
#[derive(Default)]
struct FlyoutInput {
/// The tool row clicked.
chosen: Option<&'static str>,
/// The grab bar's response (its drag tears off or moves the flyout).
bar: Option<egui::Response>,
/// A floating flyout's × was clicked.
close: bool,
}
/// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their
/// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag
/// that tears a flyout off carries on moving the floating flyout it becomes.
fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool) -> FlyoutInput {
let mut out = FlyoutInput::default();
let key = tools.first().map_or("", String::as_str);
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
ui.horizontal(|ui| {
let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) };
out.chosen = rows.inner;
let (bar, _) = ui.allocate_exact_size(vec2(BAR, rows.response.rect.height()), Sense::hover());
ui.painter().rect_filled(bar, 0.0, t.tab_strip);
let grip = if floating {
let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE));
let xr = ui.interact(x, egui::Id::unique(("tool-flyout-close", key)), Sense::click());
icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text });
xr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Put back in the toolbar")));
out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked();
egui::Rect::from_min_max(bar.min + vec2(0.0, CLOSE), bar.max)
} else {
bar
};
let resp = ui.interact(grip, bar_id(key), Sense::drag());
let half = (grip.height() / 2.0 - 3.0).clamp(0.0, 9.0);
for k in 0..3 {
let x = grip.center().x - 2.0 + k as f32 * 2.0;
ui.painter().line_segment([pos2(x, grip.center().y - half), pos2(x, grip.center().y + half)], Stroke::new(0.6, t.text_disabled));
}
let tip = if floating { tl!("Drag to move") } else { tl!("Drag to float as a toolbar") };
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tip));
let resp = if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
resp
} else {
resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tip)
};
out.bar = Some(resp);
});
out
}
/// One row per tool: icon, name and shortcut, with a dot by the active tool. Returns the tool clicked.
fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> {
let mut chosen = None;
let lang = crate::i18n::current();
let color = |tool: &ToolInfo| if active == tool.id { t.flyout_active } else { t.text_strong };
let rows: Vec<(&ToolInfo, RowText)> =
tools.iter().filter_map(|id| tool_info(id)).map(|tool| (tool, row_text(ui, lang, tool, color(tool)))).collect();
let w = rows_width(rows.iter().map(|(_, text)| text));
for (tool, (name, shortcut)) in rows {
let is_active = active == 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 is_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 = color(tool);
ui.painter().galley(pos2(r.left() + NAME_X, r.center().y - name.size().y / 2.0), name, color);
if let Some(sc) = shortcut {
ui.painter().galley(pos2(r.right() - ROW_END - sc.size().x, r.center().y - sc.size().y / 2.0), sc, color);
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), is_active, tip(tool)));
if resp.clicked() {
chosen = Some(tool.id);
}
}
chosen
}
/// A flyout row's name and its shortcut, laid out.
type RowText = (Arc<egui::Galley>, Option<Arc<egui::Galley>>);
/// Where a flyout row's name starts, the least gap between it and the shortcut, and the room right
/// of the shortcut.
const NAME_X: f32 = 52.0;
const NAME_GAP: f32 = 14.0;
const ROW_END: f32 = 18.0;
/// The name of `tool` in `lang` and its shortcut in brackets, as a flyout row draws them.
fn row_text(ui: &Ui, lang: crate::i18n::Lang, tool: &ToolInfo, color: Color32) -> RowText {
let lay = |s: String| ui.painter().layout_no_wrap(s, egui::FontId::proportional(13.0), color);
(lay(crate::i18n::tr(lang, tool.label).to_string()), crate::shortcut_editor::tool_shortcut(tool.id).map(|sc| lay(format!("({sc})"))))
}
/// The width of a flyout's rows: 250 pt, or what its longest name needs to stay clear of its
/// shortcut (translated names are far longer than the English ones, #1616).
fn rows_width<'a>(rows: impl Iterator<Item = &'a RowText>) -> f32 {
rows.map(|(name, sc)| NAME_X + name.size().x + sc.as_ref().map_or(0.0, |sc| NAME_GAP + sc.size().x) + ROW_END).fold(250.0, f32::max)
}
/// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag
/// that tears the flyout off carries on moving it).
fn bar_id(key: &str) -> egui::Id {
egui::Id::unique(("tool-flyout-bar", key))
}
/// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged.
fn grab_id(key: &str) -> egui::Id {
bar_id(key).with("grab")
}
/// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the
/// tool clicked.
fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option<&'static str> {
let mut chosen = None;
for tool in tools.iter().filter_map(|id| tool_info(id)) {
let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click());
let well = r.shrink(1.0);
if active == tool.id {
ui.painter().rect_filled(well, CornerRadius::same(6), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(6), t.hover);
}
let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon });
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active == tool.id, tip(tool)));
if resp.on_hover_text(tip(tool)).clicked() {
chosen = Some(tool.id);
}
}
chosen
}
/// The width of a floating flyout's strip of `tools`, its bar included.
fn strip_width(tools: &[String]) -> f32 {
tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * STRIP_BUTTON.x + BAR
}
/// How far the pointer has moved since the press that's down, if one is.
fn press_travel(ctx: &egui::Context) -> Option<egui::Vec2> {
ctx.input(|i| i.pointer.press_origin().zip(i.pointer.interact_pos()).map(|(a, b)| b - a))
}
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::unique("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::unique("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 active = app.session.tool_id();
let mut input = FlyoutInput::default();
let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(
ctx,
|ui| {
flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false));
},
);
// Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the
// end of the long press that opened the flyout): it floats where the pointer is.
let at = ctx.input(|i| i.pointer.interact_pos());
let released_on_bar = input.bar.as_ref().is_some_and(|b| ctx.input(|i| i.pointer.primary_released()) && at.is_some_and(|p| b.rect.contains(p)));
let dragged_off = input.bar.as_ref().is_some_and(egui::Response::dragged) && press_travel(ctx).is_some_and(|d| d.length() > TEAR);
if released_on_bar || dragged_off {
// The strip is far narrower than the flyout: it floats with its bar under the pointer.
let at = at.unwrap_or(resp.response.rect.right_center());
// (Past the frame's 1 pt border.)
let grab = vec2(1.0 + strip_width(&tools) - BAR / 2.0, 1.0 + CLOSE + 4.0);
let p = at - grab;
if let Some(key) = tools.first() {
ctx.data_mut(|m| m.insert_temp(grab_id(key), grab));
}
app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] });
app.ui.flyout = None;
return;
}
if let Some(id) = input.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 flyouts torn off the toolbar, each floating where its bar was dragged; its × puts it back.
fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
if app.ui.floating_flyouts.is_empty() {
return;
}
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let now = ctx.input(|i| i.time);
let active = app.session.tool_id();
let mut chosen = None;
let mut closed = None;
for (k, f) in app.ui.floating_flyouts.iter_mut().enumerate() {
let Some(key) = f.tools.first() else { continue };
let id = floating_area(key);
let since = ctx.data(|d| d.get_temp::<f64>(id)).map(|t0| now - t0);
let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s));
if let Some(s) = since.filter(|_| flash) {
ctx.request_repaint_after_secs((FLASH - s) as f32);
}
// Kept on screen (a smaller window, or a position saved on a bigger one).
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(strip_width(&f.tools), PITCH), |r| r.size());
let pos = crate::tools_float::clamp(f.pos, size, screen);
let mut input = FlyoutInput::default();
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true));
});
// Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than
// each frame's motion, which a just torn-off flyout's first, unseen frame would drop).
let grab = grab_id(key);
if ctx.is_being_dragged(bar_id(key))
&& let Some(at) = ctx.input(|i| i.pointer.interact_pos())
{
let off = ctx.data_mut(|m| *m.get_temp_mut_or_insert_with(grab, || at - pos));
let p = at - off;
if p.x.is_finite() && p.y.is_finite() {
f.pos = [p.x, p.y];
}
} else if !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|m| m.remove::<egui::Vec2>(grab));
}
if input.close {
closed = Some(k);
}
chosen = chosen.or(input.chosen);
}
if let Some(k) = closed.filter(|k| *k < app.ui.floating_flyouts.len()) {
app.ui.floating_flyouts.remove(k);
}
if let Some(id) = chosen {
app.select_tool(id);
}
}
/// 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;