mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
M3.41: Stroke panel follows Units > Stroke, shows mixed values blank and reaches the Control bar
- Weights and dash lengths show in Units > Stroke in the Stroke panel, the Control bar and the Properties panel (Session::stroke_unit). - Values the selected objects don't share show blank (weight, miter limit, dashes) or light no button (cap, join, align), from a per-revision cached DocState::stroke_mixed. - Align Stroke Inside/Outside are disabled for open paths and type. - The Control bar weight is a preset spinner with a width profile dropdown, and its Stroke link (and the Properties panel's) opens the Stroke panel as an anchored popover instead of docking it. - The panels show a group's first painted object's stroke, as the Stroke proxy does (Session::shown_stroke). - stroke.set on a group skips images and symbol instances. - document.inspect reports each object's strokeOptions (cap, join, miterLimit, align, dash, dashOffset, alignDashes, arrows, arrowAlign, profile) in stroke.set terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
323b178ea7
commit
64d366e2ae
@@ -3,12 +3,13 @@
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, NodeKind, StrokeLayer, Unit, WidthProfile};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, StrokeLayer, Unit, WidthProfile};
|
||||
|
||||
use super::appearance::{edit_items, item_target};
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
use super::*;
|
||||
use crate::inspect::{ALIGNS, CAPS, JOINS, node_stroke};
|
||||
use crate::DocState;
|
||||
use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -17,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range)",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Triangle|TriangleOpen|Circle|CircleOpen|Square|SquareOpen|Diamond|Bar|null, arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?} Without a stroke item, type takes weight, cap, join, miterLimit and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -35,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// The stroke attributes object and character strokes share, parsed from `stroke.set` params
|
||||
/// (also the `strokeOptions` of `text.setRangeStyle`).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct StrokeChange {
|
||||
pub(crate) weight: Option<f64>,
|
||||
cap: Option<LineCap>,
|
||||
@@ -129,7 +130,12 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.paint.stroke_width = w;
|
||||
}
|
||||
let item = item_target(s, p, C)?.of_kind(s, false);
|
||||
let ids = item.targets(s, p)?;
|
||||
let mut ids = item.targets(s, p)?;
|
||||
if item == ItemTarget::Top {
|
||||
// Images and symbol instances take no stroke from the panel.
|
||||
let d = &s.doc()?.doc;
|
||||
ids.retain(|id| !matches!(d.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::SymbolInstance { .. })));
|
||||
}
|
||||
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
|
||||
match p.get(k) {
|
||||
None => Ok(None),
|
||||
@@ -205,6 +211,54 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// Which Stroke panel values the selected objects' strokes (type: every run's) don't share,
|
||||
/// and whether Align Stroke applies to them. One walk of the selection, so callers that ask
|
||||
/// every frame cache it by revision.
|
||||
pub fn stroke_mixed(&self) -> StrokeMixed {
|
||||
let mut nodes = vec![];
|
||||
for id in &self.selection.objects {
|
||||
if let Some(n) = self.doc.node(*id) {
|
||||
painted(n, true, &mut nodes);
|
||||
}
|
||||
}
|
||||
let mut strokes = vec![];
|
||||
let mut can_align = true;
|
||||
for n in &nodes {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
can_align = false;
|
||||
strokes.extend(t.runs.iter().map(|r| r.style.stroke_layer()));
|
||||
}
|
||||
_ => {
|
||||
can_align &= encloses(n);
|
||||
strokes.extend(n.appearance.stroke().cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((first, rest)) = strokes.split_first() else { return StrokeMixed { can_align, ..Default::default() } };
|
||||
let differs = |same: fn(&StrokeLayer, &StrokeLayer) -> bool| rest.iter().any(|s| !same(first, s));
|
||||
StrokeMixed {
|
||||
weight: differs(|a, b| a.width == b.width),
|
||||
cap: differs(|a, b| a.cap == b.cap),
|
||||
join: differs(|a, b| a.join == b.join),
|
||||
miter_limit: differs(|a, b| a.miter_limit == b.miter_limit),
|
||||
align: differs(|a, b| a.align == b.align),
|
||||
dash: differs(|a, b| a.dash == b.dash),
|
||||
can_align,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `n` enclose an area a stroke can be aligned inside or outside of (no open path)?
|
||||
fn encloses(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => path.is_closed(),
|
||||
NodeKind::Compound { children, .. } => children.iter().all(|c| encloses(c)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror a width profile along the path (t → 1 − t) or across it (swap left/right widths).
|
||||
pub(crate) fn flip_profile(p: &WidthProfile, along: bool) -> WidthProfile {
|
||||
let mut pts: Vec<(f64, f64, f64)> =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Agent-friendly document summaries and view-models for panels.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer};
|
||||
use vectorcraft_doc::{ArrowAlign, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
@@ -30,7 +30,9 @@ pub fn node_summary(n: &Node) -> Value {
|
||||
v["stroke"] = json!(n.appearance.stroke_paint().label());
|
||||
v["strokeWidth"] = json!(n.appearance.stroke_width());
|
||||
}
|
||||
|
||||
if let Some(st) = node_stroke(n) {
|
||||
v["strokeOptions"] = stroke_options(&st);
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, .. } => {
|
||||
v["anchors"] = json!(path.anchor_count());
|
||||
@@ -77,12 +79,46 @@ pub const JOINS: [(LineJoin, &str); 3] = [(LineJoin::Miter, "miter"), (LineJoin:
|
||||
/// Stroke alignment names as `stroke.set` takes them.
|
||||
pub const ALIGNS: [(StrokeAlign, &str); 3] = [(StrokeAlign::Center, "center"), (StrokeAlign::Inside, "inside"), (StrokeAlign::Outside, "outside")];
|
||||
|
||||
fn name_of<T: PartialEq>(table: &[(T, &'static str)], v: T) -> &'static str {
|
||||
table.iter().find(|(k, _)| *k == v).map_or("", |(_, n)| n)
|
||||
}
|
||||
|
||||
/// The stroke an object's Stroke panel options describe: type's first run's character stroke,
|
||||
/// else the topmost stroke. None for containers and objects without a stroke.
|
||||
/// else the topmost stroke of its own (groups and layers too). None without a stroke.
|
||||
pub fn node_stroke(n: &Node) -> Option<StrokeLayer> {
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer())),
|
||||
_ if n.is_container() => None,
|
||||
_ => n.appearance.stroke().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when
|
||||
/// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile.
|
||||
pub fn stroke_options(st: &StrokeLayer) -> Value {
|
||||
json!({
|
||||
"cap": name_of(&CAPS, st.cap),
|
||||
"join": name_of(&JOINS, st.join),
|
||||
"miterLimit": st.miter_limit,
|
||||
"align": name_of(&ALIGNS, st.align),
|
||||
"dash": st.dash.as_ref().map(|d| &d.pattern),
|
||||
"dashOffset": st.dash.as_ref().map_or(0.0, |d| d.offset),
|
||||
"alignDashes": st.dash.as_ref().is_some_and(|d| d.align_corners),
|
||||
"startArrow": st.start_arrow,
|
||||
"endArrow": st.end_arrow,
|
||||
"arrowAlign": if st.arrow_align == ArrowAlign::Tip { "tip" } else { "extend" },
|
||||
"profile": WidthProfile::id_of(st.profile.as_ref()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which Stroke panel values the selected objects don't share (their fields show blank), and
|
||||
/// whether Align Stroke applies to them (no open path or type among them).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StrokeMixed {
|
||||
pub weight: bool,
|
||||
pub cap: bool,
|
||||
pub join: bool,
|
||||
pub miter_limit: bool,
|
||||
pub align: bool,
|
||||
pub dash: bool,
|
||||
pub can_align: bool,
|
||||
}
|
||||
|
||||
@@ -801,6 +801,8 @@ mod tests_prefs;
|
||||
#[cfg(test)]
|
||||
mod tests_strokegeom;
|
||||
#[cfg(test)]
|
||||
mod tests_strokeux;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
#[cfg(test)]
|
||||
mod tests_swatchcmds;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Stroke panel reach: `stroke.set` on groups, what the panel shows for a selection (mixed values,
|
||||
//! Align Stroke), Units > Stroke, and the stroke options `document.inspect` reports.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind, Unit};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 40, "height": 40}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
run(s, "select.set", json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_set_on_a_group_skips_images_and_symbol_instances() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 10.0), rect(&mut s, 60.0), rect(&mut s, 110.0));
|
||||
select(&mut s, &[b]);
|
||||
let image = NodeId(run(&mut s, "object.rasterize", json!({}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[c]);
|
||||
let instance = NodeId(run(&mut s, "symbol.new", json!({"name": "Box"}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[a, image, instance]);
|
||||
run(&mut s, "object.group", json!({}));
|
||||
run(&mut s, "stroke.set", json!({"weight": 5}));
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.node(a).unwrap().appearance.stroke_width(), 5.0);
|
||||
for id in [image, instance] {
|
||||
let n = d.node(id).unwrap();
|
||||
assert!(matches!(n.kind, NodeKind::Image(_) | NodeKind::SymbolInstance { .. }));
|
||||
assert!(n.appearance.stroke().is_none(), "{:?} gains no stroke", n.kind_label());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_values_and_align_stroke_follow_the_selection() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 60.0));
|
||||
run(&mut s, "stroke.set", json!({"ids": [b.0], "weight": 3, "join": "round"}));
|
||||
select(&mut s, &[a, b]);
|
||||
let m = s.active().unwrap().stroke_mixed();
|
||||
assert!(m.weight && m.join && !m.cap && !m.miter_limit && !m.align && !m.dash && m.can_align, "{m:?}");
|
||||
// An open path or type in the selection can't take an inside or outside stroke.
|
||||
let line = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[a, line]);
|
||||
assert!(!s.active().unwrap().stroke_mixed().can_align);
|
||||
let text = NodeId(run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}))["id"].as_u64().unwrap());
|
||||
select(&mut s, &[text]);
|
||||
assert!(!s.active().unwrap().stroke_mixed().can_align);
|
||||
// Grouped closed paths can.
|
||||
select(&mut s, &[a, b]);
|
||||
run(&mut s, "object.group", json!({}));
|
||||
let m = s.active().unwrap().stroke_mixed();
|
||||
assert!(m.can_align && m.weight);
|
||||
assert_eq!(s.shown_stroke().map(|st| st.width), Some(1.0), "a group shows its first painted object's stroke");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_unit_follows_the_preference() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.stroke_unit(), Unit::Points);
|
||||
s.prefs.units_stroke = "millimeters".into();
|
||||
assert_eq!(s.stroke_unit(), Unit::Millimeters);
|
||||
assert_eq!(s.stroke_unit().format(1.0), "0.353 mm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reports_stroke_options() {
|
||||
let mut s = session();
|
||||
let line = run(&mut s, "shape.line", json!({"x1": 10, "y1": 100, "x2": 90, "y2": 100}))["id"].as_u64().unwrap();
|
||||
run(
|
||||
&mut s,
|
||||
"stroke.set",
|
||||
json!({"ids": [line], "cap": "round", "join": "bevel", "miterLimit": 4, "dash": [6, 3], "alignDashes": true, "endArrow": "Triangle", "arrowAlign": "tip", "profile": "lens"}),
|
||||
);
|
||||
let doc = run(&mut s, "document.inspect", json!({}));
|
||||
let o = &doc["layers"][0]["children"][0]["strokeOptions"];
|
||||
assert_eq!(o["cap"], "round");
|
||||
assert_eq!(o["join"], "bevel");
|
||||
assert_eq!(o["miterLimit"], 4.0);
|
||||
assert_eq!(o["align"], "center");
|
||||
assert_eq!(o["dash"], json!([6.0, 3.0]));
|
||||
assert_eq!(o["alignDashes"], true);
|
||||
assert_eq!((o["startArrow"].clone(), o["endArrow"].clone(), o["arrowAlign"].clone()), (Value::Null, json!("Triangle"), json!("tip")));
|
||||
assert_eq!(o["profile"], "lens");
|
||||
// The options read back into stroke.set unchanged.
|
||||
let mut q = o.as_object().unwrap().clone();
|
||||
q.insert("ids".into(), json!([line]));
|
||||
let before = s.doc().unwrap().doc.node(NodeId(line)).unwrap().appearance.clone();
|
||||
run(&mut s, "stroke.set", Value::Object(q));
|
||||
assert_eq!(s.doc().unwrap().doc.node(NodeId(line)).unwrap().appearance, before);
|
||||
// Type reports its characters' stroke.
|
||||
run(&mut s, "text.create", json!({"x": 10, "y": 200, "text": "Hi"}));
|
||||
run(&mut s, "stroke.set", json!({"join": "round"}));
|
||||
let doc = run(&mut s, "document.inspect", json!({}));
|
||||
let text = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["kind"] == "Text" || c.get("text").is_some()).unwrap().clone();
|
||||
assert_eq!(text["strokeOptions"]["join"], "round", "{text}");
|
||||
}
|
||||
Reference in New Issue
Block a user