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
co-authored by Claude Opus 5.5
parent 93640efb36
commit 0358c4df95
12 changed files with 285 additions and 25 deletions
+17 -9
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 {
+3
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;
+38
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);