Merge pull request #1149 from storytold/fix/1018-edit-toolbar-drawer
#1018: Edit Toolbar opens a drawer beside the toolbar; tools drag in and out
This commit is contained in:
commit
0089e7bb8b
File diff suppressed because one or more lines are too long
|
|
@ -1,137 +0,0 @@
|
|||
//! The All Tools drawer: every tool with its icon and name, under the headings of the toolbar's
|
||||
//! groups (Select, Shapes, Draw, Modify, Type, Navigate, Color) and of the tools the Basic toolbar
|
||||
//! leaves out (#807). The active tool is highlighted; picking one selects it and closes the drawer.
|
||||
|
||||
use egui::{Sense, vec2};
|
||||
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
/// A compact drawer two tools wide, however wide the window (#1018); the dialog frame narrows it
|
||||
/// further in a narrow window.
|
||||
pub(super) const SPEC: DialogSpec =
|
||||
DialogSpec { heading: |_| tl!("All Tools").into(), body, ok: None, min_width: WIDTH, max_width: Some(WIDTH), ..DialogSpec::FORM };
|
||||
|
||||
/// A tool's cell: its icon and name.
|
||||
const CELL: egui::Vec2 = vec2(176.0, 28.0);
|
||||
const ICON: f32 = 20.0;
|
||||
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
|
||||
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
/// Advanced toolbar shows.
|
||||
fn heading(id: &str) -> &'static str {
|
||||
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
|
||||
return cat;
|
||||
}
|
||||
match id {
|
||||
_ if id.starts_with("symbol") => "Symbols",
|
||||
_ if id.ends_with("Graph") => "Graph",
|
||||
_ if id.starts_with("perspective") => "Perspective",
|
||||
"artboard" => "Artboards",
|
||||
"slice" | "sliceSelection" => "Slices",
|
||||
_ => "More",
|
||||
}
|
||||
}
|
||||
|
||||
/// The headings in order, each with its tools in the toolbar's order.
|
||||
fn sections() -> Vec<(&'static str, Vec<&'static ToolInfo>)> {
|
||||
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
|
||||
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
|
||||
let h = heading(tool.id);
|
||||
match out.iter_mut().find(|(c, _)| *c == h) {
|
||||
Some((_, tools)) => tools.push(tool),
|
||||
None => out.push((h, vec![tool])),
|
||||
}
|
||||
}
|
||||
out.retain(|(_, tools)| !tools.is_empty());
|
||||
out
|
||||
}
|
||||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let active = app.session.tool_id();
|
||||
let mut picked = None;
|
||||
let room = (ui.ctx().content_rect().height() - 180.0).max(200.0);
|
||||
egui::ScrollArea::vertical().max_height(room).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (cat, tools) in sections() {
|
||||
widgets::subheader(ui, tl!(cat));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
for tool in tools {
|
||||
let (rect, resp) = ui.allocate_exact_size(CELL, Sense::click());
|
||||
let on = tool.id == active;
|
||||
if on {
|
||||
ui.painter().rect_filled(rect, 3.0, t.selection);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
let icon = egui::Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), icon, if on { t.text_strong } else { t.icon });
|
||||
let name = tl!(tool.label);
|
||||
let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name };
|
||||
let text_at = egui::pos2(icon.right() + 8.0, rect.center().y);
|
||||
ui.painter().with_clip_rect(rect).text(
|
||||
text_at,
|
||||
egui::Align2::LEFT_CENTER,
|
||||
name,
|
||||
egui::FontId::proportional(12.0),
|
||||
if on { t.text_strong } else { t.text },
|
||||
);
|
||||
if resp.on_hover_text(crate::toolbar::tip(tool)).clicked() {
|
||||
picked = Some(tool.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
if let Some(id) = picked {
|
||||
app.select_tool(id);
|
||||
}
|
||||
picked.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
|
||||
#[test]
|
||||
fn every_tool_is_listed_once_under_a_heading() {
|
||||
let s = sections();
|
||||
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
assert_eq!(listed.len(), all.len());
|
||||
assert!(all.iter().all(|id| listed.contains(id)));
|
||||
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
|
||||
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
|
||||
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
|
||||
}
|
||||
|
||||
/// The drawer stays two tools wide in a wide window and fits a narrow one (#1018).
|
||||
#[test]
|
||||
fn all_tools_dialog_has_a_bounded_width() {
|
||||
use egui::{Pos2, RawInput, Rect};
|
||||
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.ui.dialog = Some(Dialog::new("allTools", serde_json::json!({})));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
for width in [1280.0, 430.0] {
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 900.0));
|
||||
for _ in 0..6 {
|
||||
let mut out = ctx.run_ui(RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| super::super::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
let rect = ctx.memory(|m| m.area_rect(egui::Id::new(("dialog", "allTools")))).expect("drawer is shown");
|
||||
assert!(rect.width() <= width, "drawer exceeds viewport: {rect:?}");
|
||||
assert!(rect.width() < 3.0 * CELL.x, "drawer wider than two tools: {rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@
|
|||
//! Shortcuts, Workspaces, Find Font) draw their own window.
|
||||
|
||||
mod about;
|
||||
mod all_tools;
|
||||
pub mod artboard_options;
|
||||
pub mod blend_options;
|
||||
mod brush_art_options;
|
||||
|
|
@ -211,7 +210,6 @@ registry! {
|
|||
PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC,
|
||||
DocumentSetup: ["documentSetup"] => document_setup::SPEC,
|
||||
ArtboardOptions: [artboard_options::KIND] => artboard_options::SPEC,
|
||||
AllTools: ["allTools"] => all_tools::SPEC,
|
||||
ExportForScreens: ["exportForScreens"] => export_for_screens::SPEC,
|
||||
Recolor: [recolor::KIND] => recolor::SPEC,
|
||||
Command: ["command"] => command::SPEC,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ fn kinds_map_to_their_dialogs() {
|
|||
assert_eq!(DialogKind::of(kind), Some(want), "{kind}");
|
||||
}
|
||||
assert_eq!(DialogKind::of("nope"), None);
|
||||
assert!(spec("allTools").ok.is_none());
|
||||
assert!(spec("effect").preview && spec("recolor").preview && !spec("move").preview);
|
||||
assert!(spec("preferences").window.is_some() && spec("rectangle").window.is_none());
|
||||
}
|
||||
|
|
@ -89,7 +88,6 @@ fn every_dialog_draws() {
|
|||
for (kind, fields) in [
|
||||
("move", json!({"dx": "0 pt", "dy": "0 pt"})),
|
||||
("average", json!({"axis": "both"})),
|
||||
("allTools", json!({})),
|
||||
(crate::unsaved::KIND, json!({"index": 0, "name": "Untitled-1", "then": "close"})),
|
||||
("newWorkspace", json!({"name": "Mine"})),
|
||||
("shortcuts", json!({})),
|
||||
|
|
@ -107,10 +105,6 @@ fn enter_confirms_through_the_dialog_spec() {
|
|||
frame(&mut app, enter());
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(objects(&app), 1);
|
||||
// All Tools has no OK: Enter leaves it open.
|
||||
open(&mut app, "allTools", json!({}));
|
||||
frame(&mut app, enter());
|
||||
assert!(app.ui.dialog.is_some());
|
||||
// An unregistered kind still closes on OK without running anything.
|
||||
open(&mut app, "unregistered", json!({}));
|
||||
assert_eq!(confirm(&mut app), Ok(serde_json::Value::Null));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,812 @@
|
|||
//! Edit Toolbar (#1018): the All Tools drawer that the ••• button at the bottom of the toolbar
|
||||
//! opens beside it, and the toolbar edits it makes. The drawer lists every tool under the Basic
|
||||
//! toolbar's group headings (and headings for the tools only the Advanced toolbar shows); the tools
|
||||
//! already in the toolbar are dimmed. While it is open the toolbar can be edited: a tool dragged
|
||||
//! from the drawer onto the toolbar is added there (dropped between two tools, or onto one to join
|
||||
//! its group), one dragged from the toolbar into the drawer is removed, and the toolbar's tools
|
||||
//! drag to new places. Escape or a click outside closes it. Picking a tool in it selects the tool.
|
||||
//!
|
||||
//! Behaviour from the public documentation: <https://helpx.adobe.com/illustrator/using/tools.html>
|
||||
//! (Edit Toolbar, the All Tools drawer; tools are added, removed or rearranged only while the drawer
|
||||
//! is open, and the ones already in the toolbar show dimmed in it).
|
||||
//!
|
||||
//! Each toolbar (Basic, Advanced) keeps its own edits in `UiState::toolbar_edits`, saved with the
|
||||
//! preferences; `toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` make them.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use egui::{DragAndDrop, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info};
|
||||
|
||||
use crate::state::{ToolbarCategory, ToolbarEdits};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, icons, widgets};
|
||||
|
||||
/// A tool's cell in the drawer: its icon and name.
|
||||
const CELL: egui::Vec2 = vec2(176.0, 28.0);
|
||||
const ICON: f32 = 20.0;
|
||||
/// The drawer's width: two cells, the gap between them and room for the scroll bar.
|
||||
const WIDTH: f32 = 2.0 * CELL.x + 24.0;
|
||||
/// Gap between the toolbar and the drawer, and the drawer's least distance from the window's edges.
|
||||
const GAP: f32 = 4.0;
|
||||
/// Size of the tool following the pointer while it is dragged.
|
||||
const GHOST: f32 = 30.0;
|
||||
|
||||
/// A tool dragged between the drawer and the toolbar (egui's drag-and-drop payload).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ToolDrag {
|
||||
pub tool: &'static str,
|
||||
/// Dragged from the toolbar (dropping it in the drawer removes it).
|
||||
pub from_toolbar: bool,
|
||||
}
|
||||
|
||||
/// Where a tool dropped on a toolbar slot goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Place {
|
||||
/// Its own slot, before the slot (on the divider above it, or left of it in two columns).
|
||||
Before,
|
||||
/// Its own slot, after the slot.
|
||||
After,
|
||||
/// Into the slot's group (its flyout), last.
|
||||
Group,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
const ALL: [Self; 3] = [Self::Before, Self::After, Self::Group];
|
||||
|
||||
/// The `place` param (omitted: before).
|
||||
fn parse(p: &Value) -> Result<Self, String> {
|
||||
let key = "place";
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(Self::Before),
|
||||
Some(v) => Self::ALL
|
||||
.into_iter()
|
||||
.find(|place| v.as_str() == Some(place.name()))
|
||||
.ok_or_else(|| format!("`{key}` must be one of {}", Self::ALL.map(Self::name).join(", "))),
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Before => "before",
|
||||
Self::After => "after",
|
||||
Self::Group => "group",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the model ----------
|
||||
|
||||
/// A toolbar as it ships: the Basic toolbar's categories, or the Advanced toolbar's tool groups.
|
||||
fn shipped(advanced: bool) -> Vec<ToolbarCategory> {
|
||||
let strings = |s: &[&str]| s.iter().map(|id| id.to_string()).collect::<Vec<_>>();
|
||||
if advanced {
|
||||
let slots = TOOL_GROUPS.iter().map(|g| g.iter().map(|t| t.id.to_string()).collect()).collect();
|
||||
vec![ToolbarCategory { name: None, slots }]
|
||||
} else {
|
||||
crate::toolbar::BASIC
|
||||
.iter()
|
||||
.map(|(cat, slots)| ToolbarCategory { name: Some(cat.to_string()), slots: slots.iter().map(|s| strings(s)).collect() })
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn current(edits: &mut ToolbarEdits, advanced: bool) -> &mut Option<Vec<ToolbarCategory>> {
|
||||
if advanced { &mut edits.advanced } else { &mut edits.basic }
|
||||
}
|
||||
|
||||
/// The Basic toolbar category named `name`.
|
||||
fn category(name: &str) -> Option<&'static str> {
|
||||
crate::toolbar::BASIC.iter().map(|(c, _)| *c).find(|c| *c == name)
|
||||
}
|
||||
|
||||
/// The current toolbar's slots as Edit Toolbar left them, laid out as `toolbar::slots` lays them
|
||||
/// out (the category name on each category's first slot); `None` while it is as it ships.
|
||||
pub(crate) fn edited_slots(app: &VectorcraftApp) -> Option<Vec<(Option<&'static str>, Vec<&'static str>)>> {
|
||||
let edits = &app.ui.toolbar_edits;
|
||||
let cats = if app.ui.toolbar_advanced { &edits.advanced } else { &edits.basic };
|
||||
let mut out = vec![];
|
||||
for c in cats.as_ref()? {
|
||||
let mut name = c.name.as_deref().and_then(category);
|
||||
for s in &c.slots {
|
||||
let tools: Vec<&'static str> = s.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect();
|
||||
if !tools.is_empty() {
|
||||
out.push((name.take(), tools));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Drop the empty slots, then the empty categories.
|
||||
fn prune(cats: &mut Vec<ToolbarCategory>) {
|
||||
for c in cats.iter_mut() {
|
||||
c.slots.retain(|s| !s.is_empty());
|
||||
}
|
||||
cats.retain(|c| !c.slots.is_empty());
|
||||
}
|
||||
|
||||
/// Keep known tools, each once, and known category names (a hand-edited preferences file).
|
||||
pub(crate) fn sanitize(edits: &mut ToolbarEdits) {
|
||||
for cats in [&mut edits.basic, &mut edits.advanced].into_iter().flatten() {
|
||||
let mut seen = BTreeSet::new();
|
||||
for c in cats.iter_mut() {
|
||||
c.name = c.name.as_deref().and_then(category).map(str::to_string);
|
||||
for s in &mut c.slots {
|
||||
s.retain(|id| tool_info(id).is_some() && seen.insert(id.clone()));
|
||||
}
|
||||
}
|
||||
prune(cats);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the current toolbar with `f`: an edit that gives back the toolbar as it ships forgets
|
||||
/// the edits. Returns the toolbar's slots.
|
||||
fn edit(app: &mut VectorcraftApp, f: impl FnOnce(&mut Vec<ToolbarCategory>) -> Result<(), String>) -> Result<Value, String> {
|
||||
let advanced = app.ui.toolbar_advanced;
|
||||
let slot = current(&mut app.ui.toolbar_edits, advanced);
|
||||
let mut cats = slot.clone().unwrap_or_else(|| shipped(advanced));
|
||||
f(&mut cats)?;
|
||||
prune(&mut cats);
|
||||
*slot = (cats != shipped(advanced)).then_some(cats);
|
||||
Ok(slots_json(app))
|
||||
}
|
||||
|
||||
/// The current toolbar's slots, each a list of tool ids (`toolbar.*` commands return it).
|
||||
fn slots_json(app: &VectorcraftApp) -> Value {
|
||||
json!(crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// Put `tool` by the slot numbered `slot` (over the toolbar's slots, 0 = the first; none or null:
|
||||
/// at the end), moving it if it is in the toolbar already. A tool appended to a toolbar left without
|
||||
/// categories starts one (`named`: under its own heading).
|
||||
fn add(cats: &mut Vec<ToolbarCategory>, tool: &str, slot: Option<&Value>, place: Place, named: bool) -> Result<(), String> {
|
||||
let at: Vec<(usize, usize)> = cats.iter().enumerate().flat_map(|(c, cat)| (0..cat.slots.len()).map(move |s| (c, s))).collect();
|
||||
let bad_slot = || {
|
||||
let (key, min, max, x) = ("slot", 0, at.len().saturating_sub(1), slot.unwrap_or(&Value::Null));
|
||||
format!("`{key}` must be a whole number between {min} and {max} (got {x})")
|
||||
};
|
||||
let target = match slot {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(*v.as_u64().and_then(|k| usize::try_from(k).ok()).and_then(|k| at.get(k)).ok_or_else(bad_slot)?),
|
||||
};
|
||||
let holds =
|
||||
|cats: &[ToolbarCategory], (c, s): (usize, usize)| cats.get(c).and_then(|cat| cat.slots.get(s)).is_some_and(|s| s.iter().any(|t| t == tool));
|
||||
if place == Place::Group && target.is_some_and(|t| holds(cats, t)) {
|
||||
// Onto its own group: nothing to do.
|
||||
return Ok(());
|
||||
}
|
||||
// Out of where it was, leaving its slot in place (emptied ones go after) so `target` holds.
|
||||
for c in cats.iter_mut() {
|
||||
for s in &mut c.slots {
|
||||
s.retain(|t| t != tool);
|
||||
}
|
||||
}
|
||||
let new = vec![tool.to_string()];
|
||||
let Some((c, s)) = target else {
|
||||
match cats.last_mut() {
|
||||
Some(cat) => cat.slots.push(new),
|
||||
None => cats.push(ToolbarCategory { name: named.then(|| heading(tool)).and_then(category).map(str::to_string), slots: vec![new] }),
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let cat = cats.get_mut(c).ok_or_else(bad_slot)?;
|
||||
match place {
|
||||
Place::Group => cat.slots.get_mut(s).ok_or_else(bad_slot)?.push(tool.to_string()),
|
||||
Place::Before => cat.slots.insert(s.min(cat.slots.len()), new),
|
||||
Place::After => cat.slots.insert((s + 1).min(cat.slots.len()), new),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take `tool` out of the toolbar (`group`: with the rest of its group); one that isn't in it
|
||||
/// stays out.
|
||||
fn remove(cats: &mut [ToolbarCategory], tool: &str, group: bool) {
|
||||
if let Some(slot) = cats.iter_mut().flat_map(|c| c.slots.iter_mut()).find(|s| s.iter().any(|t| t == tool)) {
|
||||
if group {
|
||||
slot.clear();
|
||||
} else {
|
||||
slot.retain(|t| t != tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A known tool's id from the `tool` param.
|
||||
fn tool_param(p: &Value) -> Result<&'static str, String> {
|
||||
let tool = p.get("tool").and_then(Value::as_str).ok_or("tool (a tool id) is required")?;
|
||||
tool_info(tool).map(|t| t.id).ok_or_else(|| format!("unknown tool `{tool}`"))
|
||||
}
|
||||
|
||||
/// The Edit Toolbar commands; `None` for other ids.
|
||||
pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
Some(match id {
|
||||
"window.editToolbar" => crate::menus::opt_bool(p, "open").map(|open| {
|
||||
let open = open.unwrap_or(!app.ui.tools_drawer);
|
||||
app.ui.tools_drawer = open;
|
||||
if open {
|
||||
app.ui.flyout = None;
|
||||
}
|
||||
json!(open)
|
||||
}),
|
||||
"toolbar.addTool" => (|| {
|
||||
let tool = tool_param(p)?;
|
||||
let place = Place::parse(p)?;
|
||||
let named = !app.ui.toolbar_advanced;
|
||||
edit(app, |cats| add(cats, tool, p.get("slot"), place, named))
|
||||
})(),
|
||||
"toolbar.removeTool" => (|| {
|
||||
let tool = tool_param(p)?;
|
||||
let group = crate::menus::opt_bool(p, "group")?.unwrap_or(false);
|
||||
edit(app, |cats| {
|
||||
remove(cats, tool, group);
|
||||
Ok(())
|
||||
})
|
||||
})(),
|
||||
"toolbar.reset" => {
|
||||
*current(&mut app.ui.toolbar_edits, app.ui.toolbar_advanced) = None;
|
||||
Ok(slots_json(app))
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- dropping on the toolbar ----------
|
||||
|
||||
/// Where a tool dropped at `at` goes, given the toolbar's slot buttons (slot index, rect) in a
|
||||
/// toolbar `cols` columns wide: onto a button's middle joins its group, onto its edge (top or
|
||||
/// bottom; left or right in two columns) goes beside it; elsewhere it goes beside the nearest
|
||||
/// button. `(None, _)`: at the end (no buttons).
|
||||
pub(crate) fn drop_target(slots: &[(usize, Rect)], at: Pos2, cols: usize) -> (Option<usize>, Place) {
|
||||
let Some(&(k, r)) = slots.iter().min_by(|a, b| a.1.distance_sq_to_pos(at).total_cmp(&b.1.distance_sq_to_pos(at))) else {
|
||||
return (None, Place::Before);
|
||||
};
|
||||
if r.contains(at) {
|
||||
let f = if cols == 1 { (at.y - r.top()) / r.height().max(1.0) } else { (at.x - r.left()) / r.width().max(1.0) };
|
||||
let place = if f < 0.3 {
|
||||
Place::Before
|
||||
} else if f > 0.7 {
|
||||
Place::After
|
||||
} else {
|
||||
Place::Group
|
||||
};
|
||||
return (Some(k), place);
|
||||
}
|
||||
let before = at.y < r.top() || (at.y <= r.bottom() && at.x < r.center().x);
|
||||
(Some(k), if before { Place::Before } else { Place::After })
|
||||
}
|
||||
|
||||
/// While a tool is dragged over the toolbar (`bounds`): mark where it would go, and on the release
|
||||
/// put it there (`toolbar.addTool`).
|
||||
pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, Rect)], cols: usize, bounds: Rect) {
|
||||
let Some(drag) = DragAndDrop::payload::<ToolDrag>(ui.ctx()) else { return };
|
||||
let Some(at) = ui.ctx().pointer_interact_pos().filter(|p| bounds.contains(*p)) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (slot, place) = drop_target(slots, at, cols);
|
||||
if let Some(r) = slot.and_then(|k| slots.iter().find(|s| s.0 == k)).map(|s| s.1) {
|
||||
let line = Stroke::new(2.0, t.accent);
|
||||
match (place, cols == 1) {
|
||||
(Place::Group, _) => {
|
||||
ui.painter().rect_stroke(r.shrink(1.0), 2.0, line, StrokeKind::Inside);
|
||||
}
|
||||
(Place::Before, true) => {
|
||||
ui.painter().hline(r.x_range(), r.top(), line);
|
||||
}
|
||||
(Place::After, true) => {
|
||||
ui.painter().hline(r.x_range(), r.bottom(), line);
|
||||
}
|
||||
(Place::Before, false) => {
|
||||
ui.painter().vline(r.left(), r.y_range(), line);
|
||||
}
|
||||
(Place::After, false) => {
|
||||
ui.painter().vline(r.right(), r.y_range(), line);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.input(|i| i.pointer.any_released()) {
|
||||
DragAndDrop::clear_payload(ui.ctx());
|
||||
let p = json!({ "tool": drag.tool, "slot": slot, "place": place.name() });
|
||||
if let Err(e) = app.run("toolbar.addTool", p) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- the drawer ----------
|
||||
|
||||
/// Where the toolbar leaves its ••• button's rect and its own for the drawer, each frame.
|
||||
pub(crate) fn button_id() -> egui::Id {
|
||||
egui::Id::new("edit-toolbar-button")
|
||||
}
|
||||
|
||||
pub(crate) fn toolbar_id() -> egui::Id {
|
||||
egui::Id::new("edit-toolbar-toolbar")
|
||||
}
|
||||
|
||||
/// The drawer's area.
|
||||
pub(crate) fn drawer_id() -> egui::Id {
|
||||
egui::Id::new("tools-drawer")
|
||||
}
|
||||
|
||||
/// The heading a tool is listed under: its Basic toolbar group, else one for the tools only the
|
||||
/// Advanced toolbar shows.
|
||||
fn heading(id: &str) -> &'static str {
|
||||
if let Some((cat, _)) = crate::toolbar::BASIC.iter().find(|(_, slots)| slots.iter().any(|s| s.contains(&id))) {
|
||||
return cat;
|
||||
}
|
||||
match id {
|
||||
_ if id.starts_with("symbol") => "Symbols",
|
||||
_ if id.ends_with("Graph") => "Graph",
|
||||
_ if id.starts_with("perspective") => "Perspective",
|
||||
"artboard" => "Artboards",
|
||||
"slice" | "sliceSelection" => "Slices",
|
||||
_ => "More",
|
||||
}
|
||||
}
|
||||
|
||||
/// The headings in order, each with its tools in the toolbar's order.
|
||||
fn sections() -> &'static [(&'static str, Vec<&'static ToolInfo>)] {
|
||||
static SECTIONS: OnceLock<Vec<(&'static str, Vec<&'static ToolInfo>)>> = OnceLock::new();
|
||||
SECTIONS.get_or_init(|| {
|
||||
let mut out: Vec<(&'static str, Vec<&'static ToolInfo>)> = crate::toolbar::BASIC.iter().map(|(cat, _)| (*cat, vec![])).collect();
|
||||
for tool in TOOL_GROUPS.iter().flat_map(|g| g.iter()) {
|
||||
let h = heading(tool.id);
|
||||
match out.iter_mut().find(|(c, _)| *c == h) {
|
||||
Some((_, tools)) => tools.push(tool),
|
||||
None => out.push((h, vec![tool])),
|
||||
}
|
||||
}
|
||||
out.retain(|(_, tools)| !tools.is_empty());
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// The All Tools drawer beside the toolbar, its top by the ••• button (higher when that would put
|
||||
/// it past the window's bottom), while `tools_drawer` is on.
|
||||
pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !app.ui.tools_drawer {
|
||||
return;
|
||||
}
|
||||
let (Some(button), Some(bar)) = ctx.data(|d| (d.get_temp::<Rect>(button_id()), d.get_temp::<Rect>(toolbar_id()))) else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
let id = drawer_id();
|
||||
let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(WIDTH, 200.0), |r| r.size());
|
||||
let x = (bar.right() + GAP).min(screen.right() - size.x - GAP).max(screen.left());
|
||||
let y = button.top().min(screen.bottom() - size.y - GAP).max(screen.top() + GAP);
|
||||
let in_toolbar: BTreeSet<&str> = crate::toolbar::slots(app).into_iter().flat_map(|(_, s)| s).collect();
|
||||
let active = app.session.tool_id();
|
||||
let dragged = DragAndDrop::payload::<ToolDrag>(ctx).map(|d| *d);
|
||||
let pointer = ctx.pointer_interact_pos();
|
||||
let over = |r: Rect| pointer.is_some_and(|p| r.contains(p));
|
||||
let removing = dragged.is_some_and(|d| d.from_toolbar) && ctx.memory(|m| m.area_rect(id)).is_some_and(over);
|
||||
let mut picked = None;
|
||||
let mut reset = false;
|
||||
let resp = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {
|
||||
crate::toolbar::flyout_frame(&t, removing).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_width(WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(CELL.x));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("All Tools")).size(13.0).color(t.text_strong));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| reset = widgets::flat_button(ui, "Reset", 64.0).clicked());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
// As tall as its tools, up to the window's height (it scrolls then); the Area's size
|
||||
// from the last frame mustn't cap it.
|
||||
let room = (screen.height() - 2.0 * GAP - 68.0).max(120.0);
|
||||
egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (cat, tools) in sections() {
|
||||
widgets::subheader(ui, tl!(cat));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
for tool in tools {
|
||||
if cell(ui, &t, tool, tool.id == active, in_toolbar.contains(tool.id)) {
|
||||
picked = Some(tool.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let rect = resp.response.rect;
|
||||
// A tool dragged from the toolbar and dropped in the drawer leaves the toolbar.
|
||||
if let Some(d) = dragged.filter(|d| d.from_toolbar && over(rect))
|
||||
&& ctx.input(|i| i.pointer.any_released())
|
||||
{
|
||||
DragAndDrop::clear_payload(ctx);
|
||||
if let Err(e) = app.run("toolbar.removeTool", json!({ "tool": d.tool })) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
if reset && let Err(e) = app.run("toolbar.reset", json!({})) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
if let Some(id) = picked {
|
||||
app.select_tool(id);
|
||||
app.ui.tools_drawer = false;
|
||||
} else if resp.response.clicked_elsewhere() && !pointer.is_some_and(|p| bar.contains(p)) {
|
||||
// A click in the toolbar (picking a tool, or the ••• button that toggles the drawer) leaves
|
||||
// it open.
|
||||
app.ui.tools_drawer = false;
|
||||
}
|
||||
ghost(ctx, &t);
|
||||
}
|
||||
|
||||
/// A tool's cell in the drawer: icon and name, dimmed when the tool is in the toolbar already (it
|
||||
/// can't be dragged in again). Returns whether it was clicked.
|
||||
fn cell(ui: &mut Ui, t: &Tokens, tool: &'static ToolInfo, on: bool, dimmed: bool) -> bool {
|
||||
let (rect, resp) = ui.allocate_exact_size(CELL, if dimmed { Sense::click() } else { Sense::click_and_drag() });
|
||||
if !dimmed {
|
||||
resp.dnd_set_drag_payload(ToolDrag { tool: tool.id, from_toolbar: false });
|
||||
}
|
||||
if on {
|
||||
ui.painter().rect_filled(rect, 3.0, t.selection);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 3.0, t.hover);
|
||||
}
|
||||
// The active tool's cell is lit, dimmed or not.
|
||||
let color = if on {
|
||||
t.text_strong
|
||||
} else if dimmed {
|
||||
t.text_disabled
|
||||
} else {
|
||||
t.text
|
||||
};
|
||||
let icon = Rect::from_center_size(rect.left_center() + vec2(6.0 + ICON / 2.0, 0.0), vec2(ICON, ICON));
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), icon, if dimmed || on { color } else { t.icon });
|
||||
let name = tl!(tool.label);
|
||||
let name = if crate::i18n::current() == crate::i18n::Lang::EN { name.trim_end_matches(" Tool") } else { name };
|
||||
let text_at = pos2(icon.right() + 8.0, rect.center().y);
|
||||
ui.painter().with_clip_rect(rect).text(text_at, egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), color);
|
||||
resp.on_hover_text(crate::toolbar::tip(tool)).clicked()
|
||||
}
|
||||
|
||||
/// The tool being dragged, by the pointer (below and right of it, clear of the drop marker).
|
||||
fn ghost(ctx: &egui::Context, t: &Tokens) {
|
||||
let Some(d) = DragAndDrop::payload::<ToolDrag>(ctx) else { return };
|
||||
let (Some(at), Some(tool)) = (ctx.pointer_interact_pos(), tool_info(d.tool)) else { return };
|
||||
let r = Rect::from_min_size(at + vec2(8.0, 8.0), vec2(GHOST, GHOST));
|
||||
egui::Area::new(egui::Id::new("tool-drag-ghost")).order(egui::Order::Tooltip).fixed_pos(r.min).interactable(false).show(ctx, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(r.size(), Sense::hover());
|
||||
ui.painter().rect(r, 3.0, t.panel, Stroke::new(1.0, t.accent), StrokeKind::Inside);
|
||||
icons::paint(ui, icons::tool_icon(tool.icon), Rect::from_center_size(r.center(), vec2(18.0, 18.0)), t.text_strong);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use egui::{Color32, Event, PointerButton, RawInput};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Tall enough for the whole Basic toolbar and the whole drawer.
|
||||
const SCREEN: egui::Vec2 = vec2(1000.0, 2400.0);
|
||||
/// A toolbar tool button.
|
||||
const BUTTON: egui::Vec2 = vec2(36.0, 29.0);
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
fn slots(app: &VectorcraftApp) -> Vec<Vec<&'static str>> {
|
||||
crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
/// The slot holding `tool`.
|
||||
fn slot_of(app: &VectorcraftApp, tool: &str) -> Option<usize> {
|
||||
slots(app).iter().position(|s| s.contains(&tool))
|
||||
}
|
||||
|
||||
/// One headless frame of the toolbar, its keys handled first as in the app; returns the texts
|
||||
/// painted: (text, a point inside it, colour).
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<(String, Pos2, Color32)> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2, Color32)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => {
|
||||
let color = t.galley.job.sections.first().map_or(t.fallback_color, |s| s.format.color);
|
||||
out.push((t.galley.text().to_string(), t.pos + vec2(4.0, t.galley.size().y / 2.0), color));
|
||||
}
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let input = RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
crate::toolbar::show(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
shown
|
||||
}
|
||||
|
||||
fn ctx() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Default::default());
|
||||
ctx
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
|
||||
frame(app, ctx, time, vec![Event::PointerMoved(at), button(at, true)]);
|
||||
frame(app, ctx, time + 0.05, vec![button(at, false)]);
|
||||
frame(app, ctx, time + 0.1, vec![]);
|
||||
}
|
||||
|
||||
/// A press at `from` dragged to `to` in steps, one frame each, then released there.
|
||||
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) {
|
||||
frame(app, ctx, time, vec![Event::PointerMoved(from), button(from, true)]);
|
||||
for k in 1..=6 {
|
||||
frame(app, ctx, time + 0.05 * f64::from(k), vec![Event::PointerMoved(from + (to - from) * (k as f32 / 6.0))]);
|
||||
}
|
||||
frame(app, ctx, time + 0.4, vec![button(to, false)]);
|
||||
frame(app, ctx, time + 0.45, vec![]);
|
||||
}
|
||||
|
||||
fn drawer_rect(ctx: &egui::Context) -> Option<Rect> {
|
||||
ctx.memory(|m| m.area_rect(drawer_id()))
|
||||
}
|
||||
|
||||
fn drawer_shown(ctx: &egui::Context) -> bool {
|
||||
ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Foreground, drawer_id())))
|
||||
}
|
||||
|
||||
/// A point inside the drawer's cell of the tool named `name` (as the drawer shows it), and the
|
||||
/// colour of its name.
|
||||
fn cell_at(shown: &[(String, Pos2, Color32)], drawer: Rect, name: &str) -> (Pos2, Color32) {
|
||||
shown
|
||||
.iter()
|
||||
.find(|(s, p, _)| s == name && drawer.contains(*p))
|
||||
.map(|(_, p, c)| (*p, *c))
|
||||
.unwrap_or_else(|| panic!("no {name} in the drawer {drawer:?}"))
|
||||
}
|
||||
|
||||
/// Every tool is listed once, under a heading; the Basic toolbar's groups come first, in its order.
|
||||
#[test]
|
||||
fn every_tool_is_listed_once_under_a_heading() {
|
||||
let s = sections();
|
||||
let listed: Vec<&str> = s.iter().flat_map(|(_, tools)| tools.iter().map(|t| t.id)).collect();
|
||||
let all: Vec<&str> = TOOL_GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
assert_eq!(listed.len(), all.len());
|
||||
assert!(all.iter().all(|id| listed.contains(id)));
|
||||
let heads: Vec<&str> = s.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(&heads[..3], ["Select", "Shapes", "Draw"]);
|
||||
assert!(heads.contains(&"Symbols") && heads.contains(&"Graph"), "{heads:?}");
|
||||
assert!(!heads.contains(&"More"), "every tool has a named heading: {heads:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_add_move_remove_and_reset_tools() {
|
||||
let mut app = app();
|
||||
let shipped = slots(&app);
|
||||
// At the end; the toolbar's slots come back.
|
||||
let r = app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
|
||||
assert_eq!(r, json!(slots(&app)));
|
||||
assert_eq!(slots(&app).last().unwrap(), &["artboard"]);
|
||||
// Before the first slot, moved out of its group.
|
||||
app.run("toolbar.addTool", json!({"tool": "magicWand", "slot": 0})).unwrap();
|
||||
assert_eq!(&slots(&app)[..3], [&["magicWand"][..], &["selection"], &["directSelection", "groupSelection"]]);
|
||||
assert_eq!(slots(&app)[3], ["lasso"]);
|
||||
// After a slot, and into a group (last), moved out of the Rectangle group.
|
||||
app.run("toolbar.addTool", json!({"tool": "slice", "slot": 1, "place": "after"})).unwrap();
|
||||
assert_eq!(slots(&app)[2], ["slice"]);
|
||||
let ellipse = slot_of(&app, "ellipse").unwrap();
|
||||
app.run("toolbar.addTool", json!({"tool": "star", "slot": ellipse, "place": "group"})).unwrap();
|
||||
assert_eq!(slots(&app)[ellipse], ["ellipse", "star"]);
|
||||
assert!(!slots(&app)[slot_of(&app, "rectangle").unwrap()].contains(&"star"));
|
||||
// Onto its own group: nothing changes.
|
||||
let before = slots(&app);
|
||||
app.run("toolbar.addTool", json!({"tool": "ellipse", "slot": ellipse, "place": "group"})).unwrap();
|
||||
assert_eq!(slots(&app), before);
|
||||
// The category label still names the first slot of its category.
|
||||
assert_eq!(crate::toolbar::slots(&app)[0].0, Some("Select"));
|
||||
// Out: one tool, or its whole group; an emptied slot goes.
|
||||
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
|
||||
assert_eq!(slots(&app)[ellipse], ["star"]);
|
||||
app.run("toolbar.removeTool", json!({"tool": "addAnchor", "group": true})).unwrap();
|
||||
assert!(slot_of(&app, "pen").is_none() && slot_of(&app, "addAnchor").is_none());
|
||||
// Bad params change nothing.
|
||||
let before = slots(&app);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"tool": "nope"}),
|
||||
json!({"tool": "pen", "slot": 999}),
|
||||
json!({"tool": "pen", "slot": -1}),
|
||||
json!({"tool": "pen", "place": "inside"}),
|
||||
] {
|
||||
assert!(app.run("toolbar.addTool", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(app.run("toolbar.removeTool", json!({"tool": "pen"})).unwrap(), json!(before), "not in the toolbar: stays out");
|
||||
assert!(app.run("toolbar.addTool", json!({"tool": "pen", "slot": "first"})).unwrap_err().contains("between 0 and"));
|
||||
assert!(app.run("toolbar.removeTool", json!({"tool": "star", "group": "yes"})).is_err());
|
||||
assert_eq!(slots(&app), before);
|
||||
// The Advanced toolbar keeps its own edits.
|
||||
app.run("window.toolbarAdvanced", json!({})).unwrap();
|
||||
assert_eq!(slots(&app).len(), TOOL_GROUPS.len());
|
||||
app.run("toolbar.removeTool", json!({"tool": "selection"})).unwrap();
|
||||
assert!(app.ui.toolbar_edits.advanced.is_some() && slot_of(&app, "selection").is_none());
|
||||
app.run("window.toolbarAdvanced", json!({})).unwrap();
|
||||
assert_eq!(slots(&app)[0], ["magicWand"]);
|
||||
// Reset puts the current one back as it ships.
|
||||
assert_eq!(app.run("toolbar.reset", json!({})).unwrap(), json!(shipped));
|
||||
assert!(app.ui.toolbar_edits.basic.is_none() && app.ui.toolbar_edits.advanced.is_some());
|
||||
// An edit that gives back the shipped toolbar forgets the edits.
|
||||
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
|
||||
app.run("toolbar.removeTool", json!({"tool": "artboard"})).unwrap();
|
||||
assert!(app.ui.toolbar_edits.basic.is_none());
|
||||
// Everything out, then a tool back: it starts its own category.
|
||||
for tool in shipped.iter().flatten() {
|
||||
app.run("toolbar.removeTool", json!({"tool": tool})).unwrap();
|
||||
}
|
||||
assert!(slots(&app).is_empty());
|
||||
app.run("toolbar.addTool", json!({"tool": "pen"})).unwrap();
|
||||
assert_eq!(crate::toolbar::slots(&app), [(Some("Draw"), vec!["pen"])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_edited_toolbar_is_saved_and_loads_sanitized() {
|
||||
let mut app = app();
|
||||
app.run("toolbar.addTool", json!({"tool": "artboard", "slot": 0})).unwrap();
|
||||
let saved = serde_json::to_value(&app.ui).unwrap();
|
||||
let back: crate::state::UiState = serde_json::from_value(saved.clone()).unwrap();
|
||||
assert_eq!(back.toolbar_edits, app.ui.toolbar_edits);
|
||||
// Preferences saved before Edit Toolbar load the shipped toolbars.
|
||||
let mut old = saved;
|
||||
old.as_object_mut().unwrap().remove("toolbar_edits");
|
||||
assert_eq!(serde_json::from_value::<crate::state::UiState>(old).unwrap().toolbar_edits, ToolbarEdits::default());
|
||||
// A hand-edited file: unknown tools and names go, each tool once, emptied slots go.
|
||||
let cat = |name: Option<&str>, slots: &[&[&str]]| ToolbarCategory {
|
||||
name: name.map(str::to_string),
|
||||
slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(),
|
||||
};
|
||||
let ui = crate::state::UiState {
|
||||
toolbar_edits: ToolbarEdits {
|
||||
basic: Some(vec![cat(Some("Nope"), &[&["pen", "nope"], &["pen"], &["nope"]]), cat(Some("Draw"), &[&[]])]),
|
||||
advanced: None,
|
||||
},
|
||||
tools_drawer: true,
|
||||
..Default::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(ui.toolbar_edits.basic, Some(vec![cat(None, &[&["pen"]])]));
|
||||
assert!(!ui.tools_drawer, "the drawer starts closed");
|
||||
}
|
||||
|
||||
/// #1018: the ••• button opens the drawer beside the toolbar, by the button, not a centred
|
||||
/// dialog; Escape, a click outside and the button close it, a click in the toolbar doesn't.
|
||||
#[test]
|
||||
fn edit_toolbar_opens_a_drawer_beside_the_toolbar() {
|
||||
let mut app = app();
|
||||
let ctx = ctx();
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).last().copied().unwrap();
|
||||
click(&mut app, &ctx, 1.0, edit.center());
|
||||
frame(&mut app, &ctx, 1.2, vec![]);
|
||||
assert!(app.ui.tools_drawer && app.ui.dialog.is_none());
|
||||
assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(true));
|
||||
let drawer = drawer_rect(&ctx).expect("the drawer is shown");
|
||||
let bar = ctx.data(|d| d.get_temp::<Rect>(toolbar_id())).unwrap();
|
||||
assert!((drawer.left() - bar.right() - GAP).abs() < 1.0, "beside the toolbar: {drawer:?} {bar:?}");
|
||||
assert!((drawer.top() - edit.top()).abs() < 1.0 || drawer.bottom() >= SCREEN.y - GAP - 1.0, "by the button: {drawer:?} {edit:?}");
|
||||
assert!(drawer.y_range().contains(edit.center().y), "by the button: {drawer:?} {edit:?}");
|
||||
assert!(drawer.width() < 3.0 * CELL.x, "two tools wide: {drawer:?}");
|
||||
// A click on a toolbar tool picks it and leaves the drawer open.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
let ellipse = tools[slot_of(&app, "ellipse").unwrap()].center();
|
||||
click(&mut app, &ctx, 2.0, ellipse);
|
||||
assert!(app.ui.tools_drawer && app.session.tool_id() == "ellipse");
|
||||
// Escape closes it.
|
||||
let esc = |pressed| Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() };
|
||||
frame(&mut app, &ctx, 3.0, vec![esc(true), esc(false)]);
|
||||
assert!(!app.ui.tools_drawer);
|
||||
frame(&mut app, &ctx, 3.1, vec![]);
|
||||
assert!(!drawer_shown(&ctx));
|
||||
// A click outside closes it, and so does the ••• button.
|
||||
app.run("window.editToolbar", json!({"open": true})).unwrap();
|
||||
frame(&mut app, &ctx, 4.0, vec![]);
|
||||
click(&mut app, &ctx, 4.5, pos2(SCREEN.x - 20.0, 300.0));
|
||||
assert!(!app.ui.tools_drawer);
|
||||
click(&mut app, &ctx, 5.0, edit.center());
|
||||
assert!(app.ui.tools_drawer);
|
||||
click(&mut app, &ctx, 6.0, edit.center());
|
||||
assert!(!app.ui.tools_drawer);
|
||||
// Picking a tool in the drawer selects it and closes the drawer.
|
||||
app.run("window.editToolbar", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, 7.0, vec![]);
|
||||
let shown = frame(&mut app, &ctx, 7.1, vec![]);
|
||||
let (at, _) = cell_at(&shown, drawer_rect(&ctx).unwrap(), "Artboard");
|
||||
click(&mut app, &ctx, 8.0, at);
|
||||
assert!(!app.ui.tools_drawer && app.session.tool_id() == "artboard");
|
||||
}
|
||||
|
||||
/// The drawer dims the tools the toolbar holds.
|
||||
#[test]
|
||||
fn tools_in_the_toolbar_are_dimmed_in_the_drawer() {
|
||||
let mut app = app();
|
||||
let ctx = ctx();
|
||||
app.run("window.editToolbar", json!({"open": true})).unwrap();
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
let shown = frame(&mut app, &ctx, 0.1, vec![]);
|
||||
let drawer = drawer_rect(&ctx).unwrap();
|
||||
let dim = Tokens::get(&ctx).text_disabled;
|
||||
assert_eq!(cell_at(&shown, drawer, "Ellipse").1, dim);
|
||||
assert_ne!(cell_at(&shown, drawer, "Artboard").1, dim);
|
||||
app.run("toolbar.addTool", json!({"tool": "artboard"})).unwrap();
|
||||
app.run("toolbar.removeTool", json!({"tool": "ellipse"})).unwrap();
|
||||
let shown = frame(&mut app, &ctx, 0.2, vec![]);
|
||||
assert_ne!(cell_at(&shown, drawer, "Ellipse").1, dim);
|
||||
assert_eq!(cell_at(&shown, drawer, "Artboard").1, dim);
|
||||
}
|
||||
|
||||
/// #1018: tools drag from the drawer onto the toolbar (between two tools, or onto one to join
|
||||
/// its group), from the toolbar into the drawer, and within the toolbar, only while the drawer
|
||||
/// is open.
|
||||
#[test]
|
||||
fn tools_drag_between_the_drawer_and_the_toolbar() {
|
||||
let mut app = app();
|
||||
let ctx = ctx();
|
||||
app.run("window.editToolbar", json!({"open": true})).unwrap();
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
let shown = frame(&mut app, &ctx, 0.1, vec![]);
|
||||
let drawer = drawer_rect(&ctx).unwrap();
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
// Onto the divider under the Selection tool: its own slot, second.
|
||||
let (artboard, _) = cell_at(&shown, drawer, "Artboard");
|
||||
drag(&mut app, &ctx, 1.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0));
|
||||
assert_eq!(&slots(&app)[..2], [&["selection"][..], &["artboard"]]);
|
||||
assert!(app.ui.tools_drawer, "the drawer stays open");
|
||||
// Onto the middle of the Ellipse tool: into its group.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
let ellipse = slot_of(&app, "ellipse").unwrap();
|
||||
let (slice, _) = cell_at(&shown, drawer, "Slice");
|
||||
drag(&mut app, &ctx, 2.0, slice, tools[ellipse].center());
|
||||
assert_eq!(slots(&app)[ellipse], ["ellipse", "slice"]);
|
||||
// A dimmed tool (in the toolbar already) doesn't drag.
|
||||
let before = slots(&app);
|
||||
let (rectangle, _) = cell_at(&shown, drawer, "Rectangle");
|
||||
drag(&mut app, &ctx, 3.0, rectangle, tools[0].center_bottom() - vec2(0.0, 3.0));
|
||||
assert_eq!(slots(&app), before);
|
||||
// From the toolbar into the drawer: out of the toolbar.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
drag(&mut app, &ctx, 4.0, tools[1].center(), drawer.center());
|
||||
assert!(slot_of(&app, "artboard").is_none(), "{:?}", slots(&app));
|
||||
// Within the toolbar: Selection onto the divider over the Lasso.
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
drag(&mut app, &ctx, 5.0, tools[0].center(), tools[2].center_top() + vec2(0.0, 3.0));
|
||||
assert_eq!(&slots(&app)[..3], [&["directSelection", "groupSelection"][..], &["selection"], &["lasso", "magicWand"]]);
|
||||
// The drawer closed, the toolbar is locked: the same drag moves nothing.
|
||||
app.run("window.editToolbar", json!({"open": false})).unwrap();
|
||||
frame(&mut app, &ctx, 6.0, vec![]);
|
||||
let before = slots(&app);
|
||||
let tools = crate::toolbar::tests::widget_rects(&ctx, BUTTON);
|
||||
drag(&mut app, &ctx, 7.0, tools[0].center(), tools[2].center_bottom() - vec2(0.0, 3.0));
|
||||
assert_eq!(slots(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_targets_follow_the_buttons_edges() {
|
||||
let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON);
|
||||
let one = [(0, r(0.0)), (1, r(30.0))];
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 2.0), 1), (Some(0), Place::Before));
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 14.0), 1), (Some(0), Place::Group));
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 27.0), 1), (Some(0), Place::After));
|
||||
// Past the last button, or beside them: next to the nearest one.
|
||||
assert_eq!(drop_target(&one, pos2(20.0, 200.0), 1), (Some(1), Place::After));
|
||||
assert_eq!(drop_target(&one, pos2(44.5, 40.0), 1), (Some(1), Place::After));
|
||||
// Two columns: the left and right edges.
|
||||
let two = [(0, Rect::from_min_size(pos2(2.0, 0.0), BUTTON)), (1, Rect::from_min_size(pos2(38.0, 0.0), BUTTON))];
|
||||
assert_eq!(drop_target(&two, pos2(40.0, 10.0), 2), (Some(1), Place::Before));
|
||||
assert_eq!(drop_target(&two, pos2(36.0, 10.0), 2), (Some(0), Place::After));
|
||||
assert_eq!(drop_target(&[], pos2(0.0, 0.0), 1), (None, Place::Before));
|
||||
}
|
||||
}
|
||||
|
|
@ -2835,6 +2835,9 @@
|
|||
Tool Options… Werkzeugoptionen…
|
||||
Toolbar: Advanced / Basic Werkzeugleiste: Erweitert / Einfach
|
||||
Toolbar: Single/Double Column Werkzeugleiste: ein-/zweispaltig
|
||||
Toolbar: Add Tool Werkzeugleiste: Werkzeug hinzufügen
|
||||
Toolbar: Remove Tool Werkzeugleiste: Werkzeug entfernen
|
||||
Toolbar: Reset Werkzeugleiste: Zurücksetzen
|
||||
Toolbars Werkzeugleisten
|
||||
Tools Werkzeuge
|
||||
Top Oben
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2903,6 +2903,9 @@
|
|||
Tool Options… Opciones de herramienta…
|
||||
Toolbar: Advanced / Basic Barra de herramientas: avanzada / básica
|
||||
Toolbar: Single/Double Column Barra de herramientas: columna simple/doble
|
||||
Toolbar: Add Tool Barra de herramientas: añadir herramienta
|
||||
Toolbar: Remove Tool Barra de herramientas: quitar herramienta
|
||||
Toolbar: Reset Barra de herramientas: restablecer
|
||||
Toolbars Barras de herramientas
|
||||
Tools Herramientas
|
||||
Top Arriba
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2900,6 +2900,9 @@
|
|||
Tool Options… Options de l’outil…
|
||||
Toolbar: Advanced / Basic Barre d’outils : avancée / de base
|
||||
Toolbar: Single/Double Column Barre d’outils : une/deux colonnes
|
||||
Toolbar: Add Tool Barre d’outils : ajouter un outil
|
||||
Toolbar: Remove Tool Barre d’outils : retirer un outil
|
||||
Toolbar: Reset Barre d’outils : réinitialiser
|
||||
Toolbars Barres d’outils
|
||||
Tools Outils
|
||||
Top Haut
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2903,6 +2903,9 @@
|
|||
Tool Options… Opzioni strumento…
|
||||
Toolbar: Advanced / Basic Barra degli strumenti: avanzata / di base
|
||||
Toolbar: Single/Double Column Barra degli strumenti: colonna singola/doppia
|
||||
Toolbar: Add Tool Barra degli strumenti: aggiungi strumento
|
||||
Toolbar: Remove Tool Barra degli strumenti: rimuovi strumento
|
||||
Toolbar: Reset Barra degli strumenti: ripristina
|
||||
Toolbars Barre degli strumenti
|
||||
Tools Strumenti
|
||||
Top In alto
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2640,6 +2640,9 @@
|
|||
Tool Options… ツールオプション…
|
||||
Toolbar: Advanced / Basic ツールバー: 詳細/基本
|
||||
Toolbar: Single/Double Column ツールバー: 1 列/2 列
|
||||
Toolbar: Add Tool ツールバー: ツールを追加
|
||||
Toolbar: Remove Tool ツールバー: ツールを削除
|
||||
Toolbar: Reset ツールバー: リセット
|
||||
Top: 上:
|
||||
Total Width: 合計幅:
|
||||
Touch Type Tool 文字タッチツール
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2899,6 +2899,9 @@
|
|||
Tool Options… Параметры инструмента…
|
||||
Toolbar: Advanced / Basic Панель инструментов: расширенная / основная
|
||||
Toolbar: Single/Double Column Панель инструментов: один/два столбца
|
||||
Toolbar: Add Tool Панель инструментов: добавить инструмент
|
||||
Toolbar: Remove Tool Панель инструментов: убрать инструмент
|
||||
Toolbar: Reset Панель инструментов: сбросить
|
||||
Toolbars Панели инструментов
|
||||
Tools Инструменты
|
||||
Top Вверху
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2895,6 +2895,9 @@
|
|||
Tool Options… Параметри інструменту…
|
||||
Toolbar: Advanced / Basic Панель інструментів: розширена/основна
|
||||
Toolbar: Single/Double Column Панель інструментів: один/два стовпці
|
||||
Toolbar: Add Tool Панель інструментів: додати інструмент
|
||||
Toolbar: Remove Tool Панель інструментів: вилучити інструмент
|
||||
Toolbar: Reset Панель інструментів: скинути
|
||||
Toolbars Панелі інструментів
|
||||
Tools Інструменти
|
||||
Top Вгорі
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -2790,6 +2790,9 @@
|
|||
Tool Options… 工具选项…
|
||||
Toolbar: Advanced / Basic 工具栏:高级 / 基本
|
||||
Toolbar: Single/Double Column 工具列:单列/双列
|
||||
Toolbar: Add Tool 工具栏:添加工具
|
||||
Toolbar: Remove Tool 工具栏:移除工具
|
||||
Toolbar: Reset 工具栏:重置
|
||||
Toolbars 工具栏
|
||||
Tools 工具
|
||||
Top 上
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2881,6 +2881,9 @@
|
|||
Tool Options… 工具選項…
|
||||
Toolbar: Advanced / Basic 工具列:進階 / 基本
|
||||
Toolbar: Single/Double Column 工具列:單欄/雙欄
|
||||
Toolbar: Add Tool 工具列:新增工具
|
||||
Toolbar: Remove Tool 工具列:移除工具
|
||||
Toolbar: Reset 工具列:重設
|
||||
Toolbars 工具列
|
||||
Tools 工具
|
||||
Top 上
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -24,6 +24,7 @@ pub mod credits;
|
|||
pub mod cursors;
|
||||
pub mod dialogs;
|
||||
pub mod dock;
|
||||
mod edit_toolbar;
|
||||
pub mod find_font;
|
||||
pub mod floating;
|
||||
pub mod font_menu;
|
||||
|
|
|
|||
|
|
@ -256,6 +256,30 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
"",
|
||||
"{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
|
||||
),
|
||||
(
|
||||
"window.editToolbar",
|
||||
"Edit Toolbar",
|
||||
"",
|
||||
"{open?: bool} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar, as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state",
|
||||
),
|
||||
(
|
||||
"toolbar.addTool",
|
||||
"Toolbar: Add Tool",
|
||||
"",
|
||||
"{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\"} put `tool` in the current (Basic or Advanced) toolbar, moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)",
|
||||
),
|
||||
(
|
||||
"toolbar.removeTool",
|
||||
"Toolbar: Remove Tool",
|
||||
"",
|
||||
"{tool: id, group?: bool} take `tool` (with `group`, its whole group) out of the current toolbar (one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots",
|
||||
),
|
||||
(
|
||||
"toolbar.reset",
|
||||
"Toolbar: Reset",
|
||||
"",
|
||||
"{} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes; returns its slots",
|
||||
),
|
||||
("window.taskBar", "Contextual Task Bar", "", "{}"),
|
||||
(
|
||||
"window.taskBar.pin",
|
||||
|
|
@ -888,6 +912,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
if let Some(r) = crate::shortcut_editor::run_command(app, id, p)
|
||||
.or_else(|| crate::workspaces::run_command(app, id, p))
|
||||
.or_else(|| crate::floating::command(app, id, p))
|
||||
.or_else(|| crate::edit_toolbar::command(app, id, p))
|
||||
{
|
||||
return Some(r);
|
||||
}
|
||||
|
|
@ -1469,6 +1494,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
|||
"window.toolbar" => app.ui.toolbar,
|
||||
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
|
||||
"window.toolbarColumns" => app.ui.toolbar_double,
|
||||
"window.editToolbar" => app.ui.tools_drawer,
|
||||
"window.floatTools" => {
|
||||
let tool = p.get("tool").and_then(Value::as_str).unwrap_or("");
|
||||
app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool))
|
||||
|
|
|
|||
|
|
@ -227,6 +227,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
return;
|
||||
}
|
||||
let typing = ctx.egui_wants_keyboard_input();
|
||||
// Escape closes Edit Toolbar's drawer first (egui drops a drag from it on Escape already).
|
||||
if app.ui.tools_drawer && !typing && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
|
||||
app.ui.tools_drawer = false;
|
||||
}
|
||||
let view = app.view_info();
|
||||
// While an IME composes, Enter and Escape are its own (confirm / cancel).
|
||||
let composing = app.session.tool_composing();
|
||||
|
|
|
|||
|
|
@ -274,6 +274,12 @@ pub struct UiState {
|
|||
/// Tool flyouts torn off the toolbar into floating panels.
|
||||
#[serde(default)]
|
||||
pub floating_flyouts: Vec<FloatingFlyout>,
|
||||
/// The Basic and Advanced toolbars as Edit Toolbar left them (none: as they ship).
|
||||
#[serde(default)]
|
||||
pub toolbar_edits: ToolbarEdits,
|
||||
/// Edit Toolbar's All Tools drawer is open beside the toolbar (`window.editToolbar`).
|
||||
#[serde(skip)]
|
||||
pub tools_drawer: bool,
|
||||
/// Panels dragged out of the dock: each group floats as its own stack of tabs.
|
||||
#[serde(default)]
|
||||
pub floating_panels: Vec<FloatingPanels>,
|
||||
|
|
@ -426,6 +432,25 @@ pub struct FloatingFlyout {
|
|||
pub pos: [f32; 2],
|
||||
}
|
||||
|
||||
/// A category of an edited toolbar: its name (one of the Basic toolbar's; none in the Advanced
|
||||
/// toolbar) and its slots, each a tool group (the first tool is the slot's default).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolbarCategory {
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub slots: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The toolbars Edit Toolbar changed: each one is `None` while it is as it ships.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ToolbarEdits {
|
||||
#[serde(default)]
|
||||
pub basic: Option<Vec<ToolbarCategory>>,
|
||||
#[serde(default)]
|
||||
pub advanced: Option<Vec<ToolbarCategory>>,
|
||||
}
|
||||
|
||||
/// Where the Contextual Task Bar sits once its handle has moved it (`window.taskBar.pin`,
|
||||
/// `window.taskBar.reset`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
|
|
@ -531,6 +556,7 @@ impl UiState {
|
|||
pub fn sanitized(mut self) -> Self {
|
||||
self.dialog = None;
|
||||
self.flyout = None;
|
||||
self.tools_drawer = false;
|
||||
self.palette_open = false;
|
||||
self.status.clear();
|
||||
self.about = false;
|
||||
|
|
@ -545,6 +571,7 @@ impl UiState {
|
|||
f.tools.first().is_some_and(|k| seen.insert(k.clone()))
|
||||
});
|
||||
FloatingPanels::sanitize(&mut self.floating_panels, &mut self.toolbar_pos);
|
||||
crate::edit_toolbar::sanitize(&mut self.toolbar_edits);
|
||||
self.docking = self.docking.filter(crate::panel_docking::valid);
|
||||
self.docking_icons.retain(|id| ICON_PANELS.iter().any(|entry| entry.0 == id));
|
||||
// Overrides that can't fire (modifier-only chords recorded by older versions, #487) give
|
||||
|
|
@ -570,6 +597,8 @@ impl Default for UiState {
|
|||
task_bar_place: TaskBarPlace::default(),
|
||||
slot_tool: Default::default(),
|
||||
floating_flyouts: vec![],
|
||||
toolbar_edits: ToolbarEdits::default(),
|
||||
tools_drawer: false,
|
||||
floating_panels: vec![],
|
||||
docking: None,
|
||||
docking_hidden: Default::default(),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
//! 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.
|
||||
//! 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 egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -69,9 +70,12 @@ pub(crate) fn tip(t: &ToolInfo) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// Slots of the current layout: (category label for the first slot of a category, tool ids).
|
||||
fn slots(app: &VectorcraftApp) -> Vec<(Option<&'static str>, Vec<&'static str>)> {
|
||||
if app.ui.toolbar_advanced {
|
||||
/// 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()
|
||||
|
|
@ -122,6 +126,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
// layers in one frame).
|
||||
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`.
|
||||
|
|
@ -129,6 +134,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
|
|||
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());
|
||||
|
|
@ -164,6 +171,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
|
|||
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;
|
||||
|
|
@ -215,7 +226,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
|
|||
let c = rect.center() + vec2(12.5, 10.0);
|
||||
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE));
|
||||
}
|
||||
let press = if slot.len() > 1 { flyout_press(ui, &resp, rect) } else { 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]) {
|
||||
|
|
@ -242,6 +257,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
|
|||
});
|
||||
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);
|
||||
}
|
||||
|
|
@ -475,8 +493,11 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz
|
|||
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
|
||||
app.run("view.screenMode", json!({})).ok();
|
||||
}
|
||||
if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({})));
|
||||
// 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;
|
||||
|
|
@ -526,7 +547,7 @@ fn raise_floating(ctx: &egui::Context, key: &str) {
|
|||
}
|
||||
|
||||
/// A flyout's frame: drop shadow and border (the accent while it flashes).
|
||||
fn flyout_frame(t: &Tokens, flash: bool) -> egui::Frame {
|
||||
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) })
|
||||
|
|
|
|||
|
|
@ -246,6 +246,13 @@ that open its flyout raise the strip instead. The strips (`ui.floating_flyouts`
|
|||
and the strip's top-left corner) are saved with the preferences and in user workspaces; the built-in workspaces
|
||||
float none.
|
||||
|
||||
Edit Toolbar: the ••• button at the bottom of the toolbar (or `window.editToolbar {open?}`) opens the All Tools
|
||||
drawer beside the toolbar; Escape or a click outside closes it. While it is open, `ui.drag` a tool from it onto the
|
||||
toolbar to add it (onto a button's top or bottom edge: its own slot before or after; onto its middle: into its
|
||||
group), from the toolbar into it to remove it, or within the toolbar to move it. `toolbar.addTool {tool, slot?,
|
||||
place?}`, `toolbar.removeTool {tool, group?}` and `toolbar.reset` do the same for the current Basic or Advanced
|
||||
toolbar and return its slots; the edits (`ui.toolbar_edits`) are saved with the preferences.
|
||||
|
||||
The Contextual Task Bar: its handle (the grip at its left end) drags it anywhere on the canvas, which always holds
|
||||
it whole, also when the window shrinks. Unpinned, the bar keeps that offset as it follows the selection, until
|
||||
another document becomes active. Its More Options (…) menu has Hide Bar (`window.taskBar`); Pin Bar Position
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Direct Selection: whole-path drags by an anchor and Alt-drag copies, #1104; earlier: first checklist) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Edit Toolbar: the All Tools drawer beside the toolbar, tools dragged in and out, #1018; earlier: Direct Selection whole-path drags and Alt-drag copies, #1104) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's
|
||||
trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to,
|
||||
|
|
@ -147,6 +147,7 @@ Layers panel.
|
|||
| Panels float inside the window, stack, dock back | done | #495 |
|
||||
| Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` |
|
||||
| Panels outside the app window (another monitor) | missing | single OS window |
|
||||
| Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; New/Manage Toolbars missing |
|
||||
| Properties panel per context (every selection type's Quick Actions) | partial | |
|
||||
| Contextual task bar | done | |
|
||||
| Control bar with Transform and Stroke popovers | done | |
|
||||
|
|
@ -167,5 +168,6 @@ re-measure of the panels added since.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Edit Toolbar opens the All Tools drawer beside the toolbar instead of a centred dialog, dims the tools the toolbar holds, and edits the toolbar by drag and drop while it is open (#1018); score unchanged (estimated) |
|
||||
| 2026-10-11 | minor | Direct Selection: dragging a path selected whole by an anchor moves it whole; Alt-drag copies by an anchor too, toggles during the drag and snaps onto the original (#1104); score unchanged (estimated) |
|
||||
| 2026-10-10 | major | First checklist, built from the tool sources and tests against Illustrator 2026's public documentation |
|
||||
|
|
|
|||
Loading…
Reference in New Issue