M3.35: gradient handles snap and honour Constrain Angle; double-clicking a tool button opens its options

Gradient tool handles (a drawn vector's start and end, the end handle and the moved start) snap to anchors, edges
and smart guides, with the guide overlays; Shift constrains to 45° steps from the Constrain Angle preference
(`ToolContext::constrain_angle`, `constrain_angle_from` in geom). Double-clicking a toolbar button runs the new UI
command `tool.options {tool}`: the Gradient tool opens the Gradient panel, the Eyedropper opens Eyedropper Options
(`eyedropper.setOptions {appearance?, transparency?}`, which `appearance.copyFrom` honours and its params override).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:18:34 +07:00
parent 93640efb36
commit 0358c4df95
12 changed files with 285 additions and 25 deletions

View File

@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Eyedropper",
[],
None,
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)",
"{source: id, ids?, appearance?: bool, transparency?: bool (default: eyedropper.setOptions)} copy fill, stroke, weight and effects (appearance) and opacity and blend (transparency) from `source` to ids (default: selection), the appearance also to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)",
has_doc,
copy_from
),
@ -478,11 +478,14 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
}
ap
};
s.paint.fill = super::gradient::unplaced(&appearance.fill_paint());
s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint());
s.remember_paint(&s.paint.fill.clone());
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
let opts = s.eyedropper.with(p);
if opts.appearance {
s.paint.fill = super::gradient::unplaced(&appearance.fill_paint());
s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint());
s.remember_paint(&s.paint.fill.clone());
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
}
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
@ -490,15 +493,20 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut targets = leaf_targets(s, &ids)?;
targets.retain(|id| *id != src_id);
if targets.is_empty() {
if targets.is_empty() || !(opts.appearance || opts.transparency) {
return Ok(json!({ "ids": [] }));
}
let (opacity, blend) = (src.opacity, src.blend);
s.edit("Eyedropper", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
n.opacity = opacity;
n.blend = blend;
if opts.transparency {
n.opacity = opacity;
n.blend = blend;
}
if !opts.appearance {
continue;
}
if let NodeKind::Text(t) = &mut n.kind {
let ap = placed(Some(t.local_bounds()));
for r in &mut t.runs {

View File

@ -346,3 +346,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
/// The Transparency panel's state ([`Session::transparency_info`]).
pub use opacitymask::TransparencyInfo;
/// What the Eyedropper copies (`Session::eyedropper`).
pub use xform::EyedropperOptions;

View File

@ -31,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
sample_color
),
cmd!(
"eyedropper.setOptions",
"Eyedropper Options",
[],
None,
"{appearance?: bool (fill, stroke and effects), transparency?: bool (opacity and blend mode)} what the Eyedropper copies (appearance.copyFrom); {} reads them → {appearance, transparency}",
always,
eyedropper_options
),
cmd!(
"artboard.move",
"Move Artboard",
@ -162,6 +171,35 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- eyedropper ----------
/// What the Eyedropper copies from the object it clicks (`appearance.copyFrom`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct EyedropperOptions {
/// The fill, stroke and effects (the appearance stack; type: its run paints).
pub appearance: bool,
/// The opacity and blend mode.
pub transparency: bool,
}
impl Default for EyedropperOptions {
fn default() -> Self {
Self { appearance: true, transparency: true }
}
}
impl EyedropperOptions {
/// These options with any `appearance` / `transparency` booleans in `p` applied.
pub(crate) fn with(mut self, p: &Value) -> Self {
self.appearance = bool_or(p, "appearance", self.appearance);
self.transparency = bool_or(p, "transparency", self.transparency);
self
}
}
fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
s.eyedropper = s.eyedropper.with(p);
Ok(serde_json::to_value(s.eyedropper).unwrap_or_default())
}
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.sampleColor";
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);

View File

@ -18,6 +18,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection};
use vectorcraft_geom::Affine;
use vectorcraft_tools::{PaintDefaults, Tool};
pub use cmd::EyedropperOptions;
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use tooling::{UiRequest, ViewInfo};
@ -459,6 +460,8 @@ pub struct Session {
pub recent_colors: Vec<Color>,
/// A paint applied by a live preview: remembered when the interaction commits.
pub(crate) pending_paint: Option<Paint>,
/// What the Eyedropper copies (`eyedropper.setOptions`); not saved.
pub eyedropper: EyedropperOptions,
}
impl Default for Session {
@ -490,6 +493,7 @@ impl Session {
last_gradient: GradientPaint::new(Default::default()),
recent_colors: vec![],
pending_paint: None,
eyedropper: Default::default(),
}
}

View File

@ -1,5 +1,6 @@
//! Gradient panel and gradient behaviour: sampling a colour into a gradient stop (M3.33) and
//! copied appearances placing their gradients on the target's own bounds (M3.34).
//! copied appearances placing their gradients on the target's own bounds (M3.34) and the
//! Gradient tool's snapping and Eyedropper Options (M3.35).
use serde_json::json;
use vectorcraft_color::{GradientGeom, Paint};
@ -113,3 +114,40 @@ fn graphic_styles_keep_gradients_relative_to_the_bounds() {
s.execute("graphicStyle.apply", &json!({"name": "Old"})).unwrap();
assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored");
}
#[test]
fn eyedropper_options_choose_what_copy_from_takes() {
let mut s = session();
let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("transparency.set", &json!({"opacity": 40})).unwrap();
let dst = rect(&mut s, 200.0, 10.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
assert_eq!(s.execute("eyedropper.setOptions", &json!({})).unwrap(), json!({"appearance": true, "transparency": true}));
assert_eq!(s.execute("eyedropper.setOptions", &json!({"appearance": false})).unwrap(), json!({"appearance": false, "transparency": true}));
let n = |s: &Session| s.doc().unwrap().doc.node(dst).unwrap().clone();
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap();
assert_eq!((n(&s).opacity, n(&s).appearance.fill_paint().color().unwrap().to_hex()), (0.4, "#ffffff".into()));
// Params override the options for one call.
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "appearance": true})).unwrap();
assert_eq!(n(&s).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
// Nothing to copy: no edit.
let undo = s.doc().unwrap().history.undo.len();
assert_eq!(s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "transparency": false})).unwrap(), json!({"ids": []}));
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn the_gradient_tool_reads_the_constrain_angle_preference() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("prefs.set", &json!({"key": "constrainAngle", "value": 30})).unwrap();
s.select_tool("gradient", ViewInfo::default()).unwrap();
let ev = |kind, x, y, shift| vectorcraft_tools::PointerEvent::new(kind, x, y).with_mods(vectorcraft_tools::Mods { shift, ..Default::default() });
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
s.pointer(&ev(Down, 120.0, 180.0, false), ViewInfo::default()).unwrap();
s.pointer(&ev(Drag, 190.0, 140.0, true), ViewInfo::default()).unwrap();
s.pointer(&ev(Up, 190.0, 140.0, true), ViewInfo::default()).unwrap();
let id = s.doc().unwrap().selection.objects[0];
assert!((fill_geom(&s, id).unwrap().angle_deg() - 30.0).abs() < 1e-9);
}

View File

@ -81,6 +81,7 @@ impl Session {
fill_active: self.fill_active,
gradient_stop: self.selected_stop(),
appearance_item: self.appearance_item(),
constrain_angle: self.prefs.constrain_angle,
};
f(self.tool.as_mut(), &cx)
}

View File

@ -42,12 +42,18 @@ pub fn normalize_deg(mut a: f64) -> f64 {
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
constrain_angle_from(v, step_deg, 0.0)
}
/// [`constrain_angle`] in steps counted from `base_deg` (the Constrain Angle preference,
/// counter-clockwise as seen on the y-down page).
pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 {
let len = v.hypot();
if len < EPS {
return v;
}
let step = step_deg.to_radians();
let a = (v.y.atan2(v.x) / step).round() * step;
let (step, base) = (step_deg.to_radians(), -base_deg.to_radians());
let a = base + ((v.y.atan2(v.x) - base) / step).round() * step;
Vec2::new(a.cos() * len, a.sin() * len)
}
@ -83,6 +89,15 @@ mod tests {
assert!((v.x - v.y).abs() < 1e-9);
}
#[test]
fn constrain_from_a_base_angle() {
// 30° base: 45° steps land on 30°, 75°, … (counter-clockwise on the page, y down).
let deg = |v: Vec2| (-v.y).atan2(v.x).to_degrees();
assert!((deg(constrain_angle_from(Vec2::new(10.0, -4.0), 45.0, 30.0)) - 30.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(3.0, -10.0), 45.0, 30.0)) - 75.0).abs() < 1e-9);
assert!((deg(constrain_angle_from(Vec2::new(10.0, 1.0), 45.0, 30.0)) + 15.0).abs() < 1e-9);
}
#[test]
fn reference_points() {
let r = Rect::new(0.0, 0.0, 10.0, 20.0);

View File

@ -163,6 +163,8 @@ pub struct ToolContext<'a> {
/// The Appearance panel's active fill/stroke (paint-order index into the first selected
/// object's stack); paint tools show and edit that item.
pub appearance_item: Option<usize>,
/// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it.
pub constrain_angle: f64,
}
impl ToolContext<'_> {
@ -346,6 +348,7 @@ pub(crate) mod testutil {
fill_active: true,
gradient_stop: None,
appearance_item: None,
constrain_angle: 0.0,
}
}
}

View File

@ -1,13 +1,14 @@
//! Gradient tool (G) and the gradient annotator.
//!
//! Drag across selected objects to set the gradient vector of the paint behind the active proxy
//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift
//! constrains the angle to 45°. With nothing selected, the press targets the object under the
//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. With nothing
//! selected, the press targets the object under the
//! pointer; a click inside selected art applies the gradient from that point.
//!
//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle
//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past
//! the end to rotate it (Shift snaps to 45°). Colour stops sit under the bar: click the bar to add
//! the end to rotate it. Dragged handles snap to anchors, edges and smart guides; Shift instead
//! constrains the vector to 45° steps from the Constrain Angle preference. Colour stops sit under the bar: click the bar to add
//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
@ -18,9 +19,10 @@ use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
use vectorcraft_doc::NodeId;
use vectorcraft_doc::hit::hit_test;
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from};
use super::paint_owner;
use crate::guides::snap_draw;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
const BAR: [u8; 3] = [0x20, 0x20, 0x20];
@ -181,6 +183,8 @@ struct Gesture {
#[derive(Default)]
pub struct GradientTool {
gesture: Option<Gesture>,
/// Smart-guide feedback of the handle being dragged.
guides: Vec<Overlay>,
}
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop.
@ -260,7 +264,9 @@ impl GradientTool {
Grab::Art
}
};
self.gesture = Some(Gesture { grab, at: p, began: false, vector: None, stop: None });
// A vector drawn on the art starts on the snapped point.
let at = if grab == Grab::Art { snap_draw(cx, p, &[]).0 } else { p };
self.gesture = Some(Gesture { grab, at, began: false, vector: None, stop: None });
out
}
@ -274,20 +280,33 @@ impl GradientTool {
g.began = true;
out.push(Action::Begin("Gradient".into()));
}
let snap = |from: Point, to: Point| if mods.shift { from + vectorcraft_geom::constrain_angle(to - from, 45.0) } else { to };
let constrain = |v: Vec2| constrain_angle_from(v, 45.0, cx.constrain_angle);
// Where a handle dragged to `to` lands: Shift constrains the vector from `from`, else it
// snaps (keeping the guides to show).
let mut guides = vec![];
let mut place = |from: Point, to: Point| {
if mods.shift {
from + constrain(to - from)
} else {
let (q, o) = snap_draw(cx, to, &[]);
guides = o;
q
}
};
let (cmd, params) = match &g.grab {
Grab::Art => {
let end = snap(g.at, p);
let end = place(g.at, p);
g.vector = Some((g.at, end));
("paint.setGradientGeom", Self::geom_params(cx, g.at, end))
}
Grab::Move { geom, .. } => {
let d = p - g.at;
("paint.setGradientGeom", Self::geom_params(cx, geom.start + d, geom.end + d))
// The start handle snaps; Shift constrains the move.
let start = place(geom.start, geom.start + (p - g.at));
("paint.setGradientGeom", Self::geom_params(cx, start, start + (geom.end - geom.start)))
}
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, snap(geom.start, p))),
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, place(geom.start, p))),
Grab::Rotate(geom) => {
let dir = snap(geom.start, p) - geom.start;
let dir = if mods.shift { constrain(p - geom.start) } else { p - geom.start };
let len = dir.hypot();
let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) };
("paint.setGradientGeom", Self::geom_params(cx, geom.start, end))
@ -313,11 +332,13 @@ impl GradientTool {
("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m)))
}
};
self.guides = guides;
out.push(Action::Preview(cmd.into(), params));
out
}
fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
self.guides.clear();
let Some(g) = self.gesture.take() else { return vec![] };
if g.began {
let mut out = vec![Action::Commit];
@ -386,6 +407,7 @@ impl Tool for GradientTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() {
self.gesture = None;
self.guides.clear();
return vec![Action::Cancel];
}
if self.busy() || !self.claims_key(cx, key) {
@ -411,14 +433,16 @@ impl Tool for GradientTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
let mut o = match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
(Some(a), _) => annotator_overlays(cx, &a),
// A vector drawn on a paint that isn't a gradient yet.
(None, Some((s, e))) => {
annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops))
}
_ => vec![],
}
};
o.extend(self.guides.iter().cloned());
o
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
@ -581,6 +605,39 @@ mod tests {
assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}");
}
#[test]
fn handles_snap_to_anchors_and_shift_uses_the_constrain_angle() {
let (d, s) = graded(two());
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
// The end handle dragged near the rectangle's corner lands on it, with a guide label.
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(202.0, 197.0)))).clone();
assert_eq!(point(&v["end"]), Point::new(200.0, 200.0));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor")));
t.pointer(&cx, &ev(PointerKind::Up, Point::new(202.0, 197.0)));
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { .. })), "the guides go with the drag");
// A vector drawn from near a corner starts on it.
let (d2, id) = doc_with_rect();
let mut s2 = Selection::default();
s2.add(id);
let cx2 = crate::testutil::cx(&d2, &s2, &p);
t.pointer(&cx2, &ev(PointerKind::Down, Point::new(101.0, 102.0)));
let v = preview(&t.pointer(&cx2, &ev(PointerKind::Drag, Point::new(150.0, 130.0)))).clone();
assert_eq!(point(&v["start"]), Point::new(100.0, 100.0));
t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0)));
// Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping).
cx.constrain_angle = 30.0;
let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0 }.angle_deg();
for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] {
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone();
assert!((angle(&v) - want).abs() < 1e-9, "{want}: {v}");
t.pointer(&cx, &ev(PointerKind::Up, to));
}
}
#[test]
fn a_bar_click_adds_a_stop_and_selects_it() {
let (d, s) = graded(two());

View File

@ -189,6 +189,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"",
"{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke",
),
(
"tool.options",
"Tool Options…",
"",
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `command` running eyedropper.setOptions, fields appearance, transparency)",
),
];
/// Handle a UI command. `None` = not a UI command (the engine handles it).
@ -524,6 +530,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
crate::dialogs::new_color_group::open(app, names.unwrap_or_default())
}
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
_ => return None,
};
Some(r)

View File

@ -157,6 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} 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);
}
@ -178,6 +181,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
flyout(app, ui.ctx());
}
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog.
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
match tool {
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
"eyedropper" => {
let o = app.session.eyedropper;
let fields = json!({
"__command": "eyedropper.setOptions",
"__label": "Eyedropper Options",
"appearance": o.appearance,
"transparency": o.transparency,
});
app.ui.dialog = Some(crate::state::Dialog::new("command", fields));
Ok(json!({ "dialog": "command" }))
}
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
_ => Err(format!("the {tool} tool has no options")),
}
}
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
ui.add_space(5.0);
@ -289,3 +314,59 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
let _ = theme::semibold;
}
#[cfg(test)]
mod tests {
use egui::{Event, PointerButton, Pos2};
use vectorcraft_engine::Session;
use super::*;
/// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<egui::Rect> {
let input = egui::RawInput {
time: Some(time),
events,
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))),
..Default::default()
};
let mut out = ctx.run_ui(input, |ui| show(app, ui));
out.textures_delta.clear();
let mut r: Vec<egui::Rect> = ctx.viewport(|vp| {
let size = vec2(36.0, PITCH - 1.0);
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()
});
r.sort_by(|a, b| a.top().total_cmp(&b.top()));
r
}
/// A double-click at `at`, `time` seconds in (a second apart from the last).
fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
frame(app, ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)]);
frame(app, ctx, time + 0.1, vec![]);
}
#[test]
fn double_clicking_a_tool_button_opens_its_options() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
let ctx = egui::Context::default();
let buttons = frame(&mut app, &ctx, 0.0, vec![]);
// The Color category's slots close the list: Gradient, then Eyedropper.
let (gradient, eyedropper) = (buttons[buttons.len() - 2], buttons[buttons.len() - 1]);
double_click(&mut app, &ctx, 1.0, gradient.center());
assert_eq!((app.session.tool_id(), app.ui.open_panel.as_deref()), ("gradient", Some("gradient")));
// Again: the panel stays open.
double_click(&mut app, &ctx, 2.0, gradient.center());
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
double_click(&mut app, &ctx, 3.0, eyedropper.center());
let d = app.ui.dialog.clone().expect("Eyedropper Options");
assert_eq!((app.session.tool_id(), d.kind.as_str(), d.str("__command")), ("eyedropper", "command", "eyedropper.setOptions".into()));
// OK applies the options.
app.ui.dialog.as_mut().unwrap().fields.insert("transparency".into(), json!(false));
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.session.eyedropper.appearance && !app.session.eyedropper.transparency);
assert!(app.run("tool.options", json!({"tool": "zoom"})).is_err());
}
}

View File

@ -43,3 +43,8 @@ tool option `stop` (the tool to return to; `tool.setOption {key: "stop", value:
samples the colour there into the selected stop (`paint.sampleColor {color, stop}`) and switches back. Dragging a
swatch or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the active proxy) with
the swatch's params and the object's `ids`.
Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel;
for `eyedropper` it opens Eyedropper Options, a `command` dialog (fields `appearance`, `transparency`) whose
`ui.dialog.confirm` runs `eyedropper.setOptions` (what `appearance.copyFrom` copies). Gradient tool handles snap to
anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference.