diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 31fa965a..f623671b 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "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 { } 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 { }; 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 { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0bba76c..866e1011 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -346,3 +346,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result Vec { 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 { // ---------- 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 { + 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 { const C: &str = "paint.sampleColor"; let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 09a5b3a7..104d3418 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, /// A paint applied by a live preview: remembered when the interaction commits. pub(crate) pending_paint: Option, + /// 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(), } } diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests_gradpanel.rs index 37bebce0..51e66cb5 100644 --- a/crates/engine/src/tests_gradpanel.rs +++ b/crates/engine/src/tests_gradpanel.rs @@ -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); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 9f040abc..54129edc 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -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) } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 8145625d..bc828e71 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 605403fe..b26b9289 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -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, + /// 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, } } } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 2c823f0f..f854374d 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -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, + /// Smart-guide feedback of the handle being dragged. + guides: Vec, } /// 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 { + 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 { 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 { - 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()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9f09c495..247bf02b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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 crate::dialogs::open_color_picker(app, p), + "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 461bc9aa..c53780d1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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 { + 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) -> Vec { + 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 = 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()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 789017dd..32604f9a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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.