Files
vectorcraft/crates/ui-egui/src/toolbar.rs
T
BFlat a2adbf61e6 Merge pull request #7106 from dotCooCoo/feat/custom-toolbar-dock
Toolbars: toolbars made with New Toolbar dock along the window's edges
2026-11-13 23:32:06 +07:00

1165 lines
58 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::panels::learn::locate as learn_locate;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, icons, widgets};
pub(crate) const PITCH: f32 = 30.0;
pub(crate) 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), crate::shortcut_editor::as_typed(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()
}
}
/// The tool a slot's button shows: the active tool when the slot holds it, else the one chosen
/// last from the slot's group, else its first.
pub(crate) fn shown_tool(app: &VectorcraftApp, slot: &[&'static str]) -> &'static str {
let active = app.session.tool_id();
if slot.contains(&active) {
return active;
}
let first = slot.first().copied().unwrap_or_default();
app.ui.slot_tool.get(first).and_then(|id| slot.iter().copied().find(|s| s == id)).unwrap_or(first)
}
/// 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());
}
}
crate::custom_toolbars::remember(&mut app.ui, id);
}
/// The docked Tools panel's rect as last laid out; removed while it floats.
pub(crate) fn docked_rect_id() -> egui::Id {
egui::Id::unique("toolbar-docked-rect")
}
fn panel_width(ui: &crate::state::UiState) -> f32 {
if ui.toolbar_double { 80.0 } else { WIDTH }
}
/// Space reserved inside the protected canvas panel for a visible Tools panel docked beside the
/// canvas (`UiState::tools_beside_canvas`) or on the canvas's right (`UiState::toolbar_right`);
/// none while Tools docks at the window's left edge.
pub(crate) fn docked_width(ui: &crate::state::UiState) -> f32 {
if (ui.tools_beside_canvas || ui.toolbar_right) && ui.toolbar && ui.screen_mode < 3 && ui.toolbar_pos.is_none() {
panel_width(ui) + 6.0
} else {
0.0
}
}
/// With `UiState::tools_beside_canvas` on, the canvas-edge redocking target, registered even
/// while Tools floats, and the docked panel, which follows the canvas's left edge as dock columns
/// resize, collapse or hide. With it off, `app.rs` draws Tools before the docking layout.
///
/// The canvas's right edge, left of the panels, is a redocking target too, and Tools docked there
/// (`UiState::toolbar_right`) is drawn at it whichever way the setting is.
pub(crate) fn show_beside_canvas(app: &mut VectorcraftApp, ui: &mut Ui) {
if !app.ui.toolbar || app.ui.screen_mode >= 3 {
return;
}
let canvas = ui.available_rect_before_wrap();
crate::tools_float::set_right_dock_zone(ui.ctx(), canvas, WIDTH);
if app.ui.tools_beside_canvas {
crate::tools_float::set_dock_zones(ui.ctx(), canvas, WIDTH);
}
if app.ui.toolbar_pos.is_none() && (app.ui.tools_beside_canvas || app.ui.toolbar_right) {
show(app, ui);
}
// The lane where toolbars made with New Toolbar dock on the left, right of Tools.
crate::custom_toolbars::lane_beside_canvas(app, ui);
}
/// Is the Tools panel docked on the right, between the canvas and the panels?
pub(crate) fn docked_right(ui: &crate::state::UiState) -> bool {
ui.toolbar_right && ui.toolbar_pos.is_none()
}
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 = panel_width(&app.ui);
match app.ui.toolbar_pos {
None => {
// The card's outer gap faces the edge it docks at.
let (panel, margin) = if app.ui.toolbar_right {
(egui::Panel::right("toolbar"), egui::Margin { left: 0, right: 6, top: 6, bottom: 6 })
} else {
(egui::Panel::left("toolbar"), egui::Margin { left: 6, right: 0, top: 6, bottom: 6 })
};
let docked = panel.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));
});
ui.ctx().data_mut(|d| d.insert_temp(docked_rect_id(), docked.response.rect));
}
Some(pos) => {
ui.ctx().data_mut(|d| d.remove::<egui::Rect>(docked_rect_id()));
// 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(), false);
crate::dialogs::dimension::floating(app, ui.ctx());
crate::custom_toolbars::show(app, ui.ctx());
flyout(app, ui.ctx());
crate::edit_toolbar::drawer(app, ui.ctx());
}
/// The toolbars made with New Toolbar while the Tools panel is hidden (Window › Tools off): their
/// flyouts and the groups torn off them, and the All Tools drawer beside the one it edits.
pub fn show_custom(app: &mut VectorcraftApp, ctx: &egui::Context) {
floating(app, ctx, true);
crate::custom_toolbars::show(app, ctx);
// A flyout of the hidden Tools panel closes with it.
if ctx.data(|d| d.get_temp::<Option<String>>(flyout_toolbar_id())).flatten().is_none() {
app.ui.flyout = None;
}
flyout(app, ctx);
if crate::custom_toolbars::drawer_target(&app.ui).is_some() {
crate::edit_toolbar::drawer(app, 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();
let editing = app.ui.tools_drawer && app.ui.drawer_toolbar.is_none();
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 },
);
// Not focusable (`Sense::drag()` is): only the pointer drags the grip, so Tab does not stop on it.
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 or right 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 edits this toolbar, the tools drag (to new places or into the
// drawer) instead of opening their flyouts.
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 locate = learn_locate::wanted(&app.ui, None);
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 Some(shown) = tool_info(shown_tool(app, slot)).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));
}
// A Learn link locating a tool of this slot: the button is marked when it shows
// the tool, else the group's flyout opens on it (a strip torn off marks it).
if let Some(tool) = locate.filter(|tool| slot.contains(tool))
&& !(slot.len() > 1 && is_floating(app, None, slot[0]))
{
if !ui.clip_rect().contains_rect(rect) && learn_locate::scroll_once(&mut app.ui) {
ui.scroll_to_rect(rect, None);
ui.ctx().request_repaint();
} else if shown.id == tool {
learn_locate::mark(app, ui.ctx(), ui.layer_id(), ui.clip_rect(), rect);
} else if learn_locate::reveal_once(&mut app.ui) {
open_flyout = Some((slot.clone(), rect));
}
}
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, None, 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, None);
}
if let Some(key) = raise {
raise_floating(ui.ctx(), None, 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>>());
d.insert_temp(flyout_toolbar_id(), None::<String>);
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t, cols);
});
// While the drawer edits this toolbar (also from the ••• click just made), it opens beside it; a
// click in it leaves the drawer open.
if app.ui.tools_drawer && app.ui.drawer_toolbar.is_none() {
ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds));
}
}
/// How a press on a tool group's button opens its flyout.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) 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.
pub(crate) 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, Shape Builder and Objects
/// on Path 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" => crate::panel_docking::reveal_panel(app, 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 Objects on Path tool: the Pivot and Rotate of the objects it attaches next.
"objectsOnPath" => Ok(crate::dialogs::objects_on_path_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 Live Paint Bucket: its options (what a click paints, the highlight).
"livePaintBucket" => crate::dialogs::live_paint_bucket::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" }))
}
// The add-ons' tools.
_ if crate::addons::pathtools::has_options(tool) => crate::addons::pathtools::open_options(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`). The Artboard tool's options are drawn by
/// [`crate::panels::artboards::control_bar`].
pub fn control_bar_options(app: &mut VectorcraftApp, ui: &mut Ui) {
if crate::addons::shapes::control_bar(app, ui) {
return;
}
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();
}
}
crate::chrome::group_separator(ui);
}
/// 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);
}
}
crate::chrome::group_separator(ui);
}
/// 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 (, or Shift+,)", "paint.lastColor"), ("Gradient (. or Shift+.)", "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 editing = |app: &VectorcraftApp| app.ui.tools_drawer && app.ui.drawer_toolbar.is_none();
let edit = widgets::icon_button(ui, "ellipsis", "Edit Toolbar", editing(app), 26.0);
if edit.clicked() {
app.run("window.editToolbar", json!({})).ok();
}
// After the click, which may have opened the drawer here.
if editing(app) {
ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::button_id(), edit.rect));
}
});
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`; `toolbar`: the group of the toolbar made with New Toolbar
/// called that). Returns the new state.
pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>, toolbar: Option<&str>) -> Result<bool, String> {
let groups: Vec<Vec<&'static str>> = match toolbar {
None => slots(app).into_iter().map(|(_, s)| s).collect(),
Some(name) => crate::custom_toolbars::find(&app.ui, name)
.map(|b| b.slots.iter().map(|s| s.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect()).collect())
.unwrap_or_default(),
};
let Some(group) = groups.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, toolbar, key);
let on = floating.unwrap_or(!was);
let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return Err(format!("no toolbar named `{}`", toolbar.unwrap_or_default())) };
if on && !was {
let n = strips.len() as f32;
let tools = group.iter().map(|id| id.to_string()).collect();
strips.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] });
app.ui.flyout = None;
} else if !on {
strips.retain(|f| f.tools.first().is_none_or(|k| k != key));
}
Ok(on)
}
/// The groups torn off the Tools panel's toolbar (`toolbar` none) or off the toolbar made with New
/// Toolbar called `toolbar` (none when there is no such toolbar).
pub(crate) fn strips<'a>(ui: &'a crate::state::UiState, toolbar: Option<&str>) -> &'a [crate::state::FloatingFlyout] {
match toolbar {
None => &ui.floating_flyouts,
Some(name) => crate::custom_toolbars::find(ui, name).map_or(&[], |b| b.floating.as_slice()),
}
}
fn strips_mut<'a>(ui: &'a mut crate::state::UiState, toolbar: Option<&str>) -> Option<&'a mut Vec<crate::state::FloatingFlyout>> {
match toolbar {
None => Some(&mut ui.floating_flyouts),
Some(name) => crate::custom_toolbars::find_mut(ui, name).map(|b| &mut b.floating),
}
}
/// Whether the group whose first tool is `key`, of the Tools panel's toolbar (`toolbar` none) or of
/// the toolbar made with New Toolbar called `toolbar`, floats as its own panel.
pub(crate) fn is_floating(app: &VectorcraftApp, toolbar: Option<&str>, key: &str) -> bool {
strips(&app.ui, toolbar).iter().any(|f| f.tools.first().is_some_and(|k| k == key))
}
/// The area of the floating flyout of the Tools panel's group whose first tool is `key`.
fn floating_area(key: &str) -> egui::Id {
egui::Id::unique(("tool-floating", key))
}
/// The area of the floating flyout of the group whose first tool is `key`, torn off the toolbar
/// made with New Toolbar called `toolbar` (none: the Tools panel's).
fn strip_area(toolbar: Option<&str>, key: &str) -> egui::Id {
match toolbar {
None => floating_area(key),
Some(name) => floating_area(key).with(crate::custom_toolbars::key(name)),
}
}
/// Bring a floating flyout to the front and flash its border.
pub(crate) fn raise_floating(ctx: &egui::Context, toolbar: Option<&str>, key: &str) {
let id = strip_area(toolbar, 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,
/// Where the tool a Learn link locates is drawn.
located: Option<egui::Rect>,
/// What a docked flyout's rows are clipped to (they scroll in a group taller than the window).
located_clip: Option<egui::Rect>,
}
/// 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 (`toolbar`: the toolbar
/// made with New Toolbar the group is in, none for the Tools panel's; `locate`: the tool a Learn
/// link locates, whose place it returns; `reveal_row`: a located row outside the flyout's view
/// scrolls into it, and is returned once it shows).
#[allow(clippy::too_many_arguments)]
fn flyout_body(
ui: &mut Ui,
t: &Tokens,
active: &str,
tools: &[String],
floating: bool,
toolbar: Option<&str>,
locate: Option<&str>,
reveal_row: bool,
) -> FlyoutInput {
let mut out = FlyoutInput::default();
let key = tools.first().map_or("", String::as_str);
let bar_id = bar_id(toolbar, key);
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, locate))
} else {
// A group taller than the window scrolls its rows (inside the frame's 1 pt border). The
// rows' height is given, since the row this sits in starts one widget tall.
let max = ui.ctx().content_rect().height() - 2.0;
let height = (tools.iter().filter(|id| tool_info(id).is_some()).count() as f32 * ROW_HEIGHT).min(max);
ui.vertical(|ui| {
egui::ScrollArea::vertical()
.id_salt(bar_id.with("rows"))
.max_height(max)
.min_scrolled_height(height)
.show(ui, |ui| {
let (chosen, located) = tool_rows(ui, t, active, tools, locate);
// The mark is clipped to the rows' view, not to the whole flyout.
out.located_clip = Some(ui.clip_rect());
match located.filter(|r| reveal_row && !ui.clip_rect().contains_rect(*r)) {
Some(r) => {
ui.scroll_to_rect_animation(r, Some(egui::Align::Center), egui::style::ScrollAnimation::none());
(chosen, None)
}
None => (chosen, located),
}
})
.inner
})
};
(out.chosen, out.located) = 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, bar_id.with("close"), 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
};
// Not focusable (`Sense::drag()` is): only the pointer drags the bar, so Tab does not stop on it.
let resp = ui.interact(grip, bar_id, 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
/// and the row of `locate`.
fn tool_rows(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option<egui::Rect>) {
let mut chosen = None;
let mut located = 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, ROW_HEIGHT), 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);
}
if locate == Some(tool.id) {
located = Some(r);
}
}
(chosen, located)
}
/// 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 height of a flyout row.
const ROW_HEIGHT: f32 = 30.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!("({})", crate::shortcut_editor::as_typed(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), in the toolbar made with New Toolbar called
/// `toolbar` (none: the Tools panel's).
fn bar_id(toolbar: Option<&str>, key: &str) -> egui::Id {
let id = egui::Id::unique(("tool-flyout-bar", key));
match toolbar {
None => id,
Some(name) => id.with(crate::custom_toolbars::key(name)),
}
}
/// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged.
fn grab_id(toolbar: Option<&str>, key: &str) -> egui::Id {
bar_id(toolbar, key).with("grab")
}
/// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the
/// tool clicked and the button of `locate`.
fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], locate: Option<&str>) -> (Option<&'static str>, Option<egui::Rect>) {
let mut chosen = None;
let mut located = 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);
}
if locate == Some(tool.id) {
located = Some(r);
}
}
(chosen, located)
}
/// 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))
}
/// Where the toolbar's flyouts and drawer may open: the window, or with the toolbar docked right
/// the part left of it, so they open toward the canvas instead of over the panels.
pub(crate) fn room(app: &VectorcraftApp, ctx: &egui::Context) -> egui::Rect {
let mut r = ctx.content_rect();
if docked_right(&app.ui)
&& let Some(x) = ctx.data(|d| d.get_temp::<egui::Rect>(docked_rect_id())).map(|bar| bar.right())
{
r.max.x = r.max.x.min(x);
}
r
}
/// The left edge of something `width` wide opened beside `anchor` (a tool, the toolbar), `gap`
/// from it: on its right when it fits in `screen`, else on its left (a toolbar docked right),
/// kept inside the screen either way.
pub(crate) fn beside(anchor: egui::Rect, width: f32, gap: f32, screen: egui::Rect) -> f32 {
let right = anchor.right() + gap;
let x = if right + width <= screen.right() { right } else { anchor.left() - gap - width };
x.min(screen.right() - width).max(screen.left())
}
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 toolbar = ctx.data(|d| d.get_temp::<Option<String>>(flyout_toolbar_id())).flatten();
let locate = learn_locate::wanted(&app.ui, toolbar.as_deref()).filter(|tool| tools.iter().any(|id| id == tool));
let reveal_row = learn_locate::unmarked(&app.ui);
let mut input = FlyoutInput::default();
// Beside the tool, on the side with room for it: toward the canvas from a toolbar docked right.
let size = ctx.memory(|m| m.area_rect(egui::Id::unique("tool-flyout"))).map_or(vec2(160.0, 0.0), |r| r.size());
let x = beside(anchor, size.x, 8.0, room(app, ctx));
// The area stays inside the window (egui's `Area::constrain`, on by default): a flyout opened
// near the bottom moves up, and a group taller than the window scrolls a located row into view.
let resp =
egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(pos2(x, anchor.top() - 1.0)).show(ctx, |ui| {
flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref(), locate, reveal_row));
});
if let Some(rect) = input.located {
let clip = input.located_clip.map_or(resp.response.rect, |c| c.intersect(resp.response.rect));
learn_locate::mark(app, ctx, resp.response.layer_id, clip, rect);
}
// 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(toolbar.as_deref(), key), grab));
}
// A toolbar made with New Toolbar that has gone since its flyout opened keeps no strip.
if let Some(strips) = strips_mut(&mut app.ui, toolbar.as_deref()) {
strips.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;
}
if app.ui.flyout.is_some() {
mark_flyout_open(ctx);
}
let _ = theme::semibold;
}
/// The pass in which a tool group's flyout or the All Tools drawer last stayed open.
fn flyout_open_pass_id() -> egui::Id {
egui::Id::unique("flyout-open-pass")
}
/// Records that a tool group's flyout or the All Tools drawer drew in this pass and stays open.
pub(crate) fn mark_flyout_open(ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(flyout_open_pass_id(), pass));
}
/// Whether a tool group's flyout or the All Tools drawer is on screen in the main window this pass.
/// One left set while its toolbar is hidden does not draw, so it does not count. The toolbars draw
/// before the panels, which read this.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn flyout_open(ctx: &egui::Context) -> bool {
let pass = ctx.cumulative_pass_nr();
ctx.data(|d| d.get_temp::<u64>(flyout_open_pass_id())) == Some(pass)
}
/// The toolbar made with New Toolbar whose group the open flyout shows (none: the Tools panel's).
pub(crate) fn flyout_toolbar_id() -> egui::Id {
egui::Id::unique("flyout-toolbar")
}
/// The flyouts torn off the toolbar, each floating where its bar was dragged; its × puts it back.
/// `custom_only`: the Tools panel is hidden, and only those torn off toolbars made with New Toolbar
/// show.
fn floating(app: &mut VectorcraftApp, ctx: &egui::Context, custom_only: bool) {
// A group torn off a toolbar made with New Toolbar shows while that toolbar does.
let owners: Vec<Option<String>> = (!custom_only)
.then_some(None)
.into_iter()
.chain(app.ui.custom_toolbars.iter().filter(|b| b.shown && app.ui.tab_hid.is_none() && !b.floating.is_empty()).map(|b| Some(b.name.clone())))
.collect();
for owner in owners {
floating_strips(app, ctx, owner.as_deref());
}
}
/// The strips torn off one toolbar (`toolbar` none: the Tools panel's).
fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Option<&str>) {
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;
// A Learn link locating a tool of a strip raises the strip once, then marks the tool in it.
let locate = learn_locate::wanted(&app.ui, toolbar);
let raise = locate.is_some_and(|tool| strips(&app.ui, toolbar).iter().any(|f| f.tools.iter().any(|id| id == tool)))
&& learn_locate::reveal_once(&mut app.ui);
let mut located = None;
let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return };
for (k, f) in strips.iter_mut().enumerate() {
let Some(key) = f.tools.first() else { continue };
let id = strip_area(toolbar, key);
let locate = locate.filter(|tool| f.tools.iter().any(|t| t == tool));
if raise && locate.is_some() {
raise_floating(ctx, toolbar, 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();
let area = 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, toolbar, locate, false));
});
if let Some(rect) = input.located {
located = Some((area.response.layer_id, area.response.rect, rect));
}
// 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(toolbar, key);
if ctx.is_being_dragged(bar_id(toolbar, 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 < strips.len()) {
strips.remove(k);
}
if let Some((layer, clip, rect)) = located {
learn_locate::mark(app, ctx, layer, clip, rect);
}
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();
}
crate::chrome::group_separator(ui);
}
#[cfg(test)]
pub(crate) mod tests;