From 64d366e2aea733a2d5f281cda206b2c80ee42e70 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 00:41:47 +0700 Subject: [PATCH] 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 --- crates/engine/src/cmd/stroke.rs | 66 ++++++++++-- crates/engine/src/inspect.rs | 44 +++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_strokeux.rs | 110 ++++++++++++++++++++ crates/ui-egui/src/chrome.rs | 24 +++-- crates/ui-egui/src/panels/mod.rs | 19 ++++ crates/ui-egui/src/panels/properties.rs | 12 +-- crates/ui-egui/src/panels/stroke.rs | 63 +++++++---- crates/ui-egui/src/panels/tests_strokeux.rs | 88 ++++++++++++++++ crates/ui-egui/src/widgets.rs | 13 +-- docs/mcp.md | 3 +- 11 files changed, 390 insertions(+), 54 deletions(-) create mode 100644 crates/engine/src/tests_strokeux.rs create mode 100644 crates/ui-egui/src/panels/tests_strokeux.rs diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index c1ec48d1..b926fd4d 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -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 { vec![ @@ -17,7 +18,7 @@ pub fn specs() -> Vec { "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 { /// 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, cap: Option, @@ -129,7 +130,12 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { 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>> { 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)> = diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 34fcbf47..90c43273 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -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(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 { 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, +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 43a30faa..0e9fa29f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests_strokeux.rs b/crates/engine/src/tests_strokeux.rs new file mode 100644 index 00000000..16f81617 --- /dev/null +++ b/crates/engine/src/tests_strokeux.rs @@ -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::>() })); +} + +#[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}"); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 8be42e7d..87180ec4 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -5,6 +5,7 @@ use serde_json::json; use vectorcraft_color::Paint; use vectorcraft_doc::NodeKind; +use crate::panels::stroke as stroke_panel; use crate::state::{ZOOM_STOPS, zoom_label}; use crate::theme::{self, Tokens}; use crate::widgets::{self, paint_chip}; @@ -100,12 +101,10 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text)); ui.add_space(6.0); let (fill, stroke) = crate::panels::current_paints(app); - let (weight, opacity) = match &first { - Some(_) => { - (crate::panels::current_stroke(app).map_or(0.0, |s| s.width), crate::panels::current_transparency(app).map_or(1.0, |t| t.0)) - } - None => (app.session.paint.stroke_width, 1.0), - }; + let shown_stroke = crate::panels::current_stroke(app); + let mixed = crate::panels::stroke_mixed(app, ui.ctx()); + let weight = stroke_panel::shown_weight(app, shown_stroke.as_ref(), &mixed); + let opacity = if first.is_some() { crate::panels::current_transparency(app).map_or(1.0, |t| t.0) } else { 1.0 }; if chip_button(ui, &fill, false, "Fill").clicked() { app.run("paint.toggleActive", json!({})).ok(); app.session.fill_active = true; @@ -115,11 +114,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { app.session.fill_active = false; app.ui.open_panel = Some("swatches".into()); } - if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() { - app.ui.open_panel = Some("stroke".into()); - } - if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), vectorcraft_doc::Unit::Points, 64.0) { - app.run("stroke.set", json!({"weight": w})).ok(); + // The link opens the Stroke panel as a popover under it; then the weight spinner + // (with presets) and the width profile. + let link = ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).on_hover_text("Stroke options"); + stroke_panel::popover(app, &link); + stroke_panel::weight_field(app, ui, "cb-stroke", weight, 100.0); + let profile = vectorcraft_doc::WidthProfile::id_of(shown_stroke.as_ref().and_then(|s| s.profile.as_ref())); + if let Some(id) = stroke_panel::profile_dropdown(ui, profile) { + app.run("stroke.set", json!({"profile": id})).ok(); } ui.add_space(4.0); ui.separator(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index dd094901..343b8efd 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -40,6 +40,7 @@ use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::{Node, StrokeLayer}; +use vectorcraft_engine::inspect::StrokeMixed; use crate::theme::Tokens; use crate::widgets::{Live, dim_label}; @@ -214,6 +215,22 @@ pub(crate) fn current_stroke(app: &VectorcraftApp) -> Option { app.session.shown_stroke() } +/// Which Stroke panel values the selection doesn't share and whether Align Stroke applies +/// ([`vectorcraft_engine::DocState::stroke_mixed`]), cached per document revision. While the Type +/// tool edits text, the selected characters' stroke shows as it is. +pub(crate) fn stroke_mixed(app: &VectorcraftApp, ctx: &egui::Context) -> StrokeMixed { + let Some(st) = app.session.active().filter(|_| character::text_editing(app).is_none()) else { return StrokeMixed::default() }; + let key = (st.uid, st.revision); + match pstate::>(ctx, "stroke-mixed") { + Some((k, mixed)) if k == key => mixed, + _ => { + let mixed = st.stroke_mixed(); + set_pstate(ctx, "stroke-mixed", Some((key, mixed))); + mixed + } + } +} + /// Opacity and blend mode as the Transparency panel and Control bar show them: the Appearance /// panel's active item's, else the first selected object's. pub(crate) fn current_transparency(app: &VectorcraftApp) -> Option<(f32, BlendMode)> { @@ -364,3 +381,5 @@ mod tests_appearance; mod tests_effectedit; #[cfg(test)] mod tests_stroke; +#[cfg(test)] +mod tests_strokeux; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 36a8b857..3322e984 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -266,10 +266,7 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(n) = first_selected(app) else { return }; section_header(ui, "Appearance"); let (fill, stroke) = super::current_paints(app); - let weight = match &n.kind { - NodeKind::Text(tx) if app.session.appearance_item().is_none() => tx.first_style().stroke_width, - _ => super::current_stroke(app).map_or(0.0, |s| s.width), - }; + let weight = super::stroke::shown_weight(app, super::current_stroke(app).as_ref(), &super::stroke_mixed(app, ui.ctx())); for (label, paint, is_fill) in [("Fill", fill, true), ("Stroke", stroke, false)] { ui.horizontal(|ui| { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); @@ -287,12 +284,11 @@ fn appearance_section(app: &mut VectorcraftApp, ui: &mut Ui) { ui.label(egui::RichText::new(label).size(12.0)); if !is_fill { ui.add_space(8.0); - if let Some(w) = widgets::num_field(ui, "ap-w", Some(weight), Unit::Points, 70.0) { + if let Some(w) = widgets::num_field(ui, "ap-w", weight, app.session.stroke_unit(), 70.0) { app.run("stroke.set", json!({"weight": w})).ok(); } - if widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0).clicked() { - app.ui.open_panel = Some("stroke".into()); - } + let more = widgets::icon_button(ui, "ellipsis", "Stroke options", false, 22.0); + super::stroke::popover(app, &more); } }); } diff --git a/crates/ui-egui/src/panels/stroke.rs b/crates/ui-egui/src/panels/stroke.rs index 5e6aa68c..c52019b0 100644 --- a/crates/ui-egui/src/panels/stroke.rs +++ b/crates/ui-egui/src/panels/stroke.rs @@ -3,9 +3,10 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, Unit, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, ProfilePreset, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_engine::inspect::StrokeMixed; -use super::{character, current_stroke, pstate, set_pstate}; +use super::{character, current_stroke, pstate, set_pstate, stroke_mixed}; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; @@ -83,10 +84,34 @@ fn set(app: &mut VectorcraftApp, p: Value) { } } +/// The weight the Stroke panel, Control bar and Properties panel show: blank where the selected +/// objects differ, the default for new art without a stroke. +pub(crate) fn shown_weight(app: &VectorcraftApp, st: Option<&StrokeLayer>, mixed: &StrokeMixed) -> Option { + (!mixed.weight).then(|| st.map_or(app.session.paint.stroke_width, |s| s.width)) +} + +/// The weight spinner (Stroke panel, Control bar): Units > Stroke, presets, blank when mixed. +pub(crate) fn weight_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, weight: Option, width: f32) { + if let Some(w) = widgets::spin_field(ui, id, weight, app.session.stroke_unit(), width, 1.0, 0.0, &WEIGHT_PRESETS) { + set(app, json!({"weight": w})); + } +} + +/// The Stroke panel in a popover anchored to `resp` (the Control bar's and the Properties +/// panel's Stroke links), which a click on it toggles. +pub(crate) fn popover(app: &mut VectorcraftApp, resp: &egui::Response) { + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.set_width(260.0); + show(app, ui); + }); +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let st = current_stroke(app); - let weight = st.as_ref().map(|s| s.width).unwrap_or(app.session.paint.stroke_width); + let mixed = stroke_mixed(app, ui.ctx()); + let unit = app.session.stroke_unit(); + let weight = shown_weight(app, st.as_ref(), &mixed); let hidden: bool = pstate(ui.ctx(), "stroke-hide-options"); let label_w = 64.0; let row_label = |ui: &mut Ui, s: &str| { @@ -94,16 +119,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }; ui.horizontal(|ui| { row_label(ui, "Weight:"); - if let Some(w) = widgets::spin_field(ui, "stroke-weight", Some(weight), Unit::Points, 120.0, 1.0, 0.0, &WEIGHT_PRESETS) { - set(app, json!({"weight": w})); - } + weight_field(app, ui, "stroke-weight", weight, 120.0); }); if hidden { return; } - let cap = st.as_ref().map(|s| s.cap).unwrap_or_default(); - let join = st.as_ref().map(|s| s.join).unwrap_or_default(); - let align = st.as_ref().map(|s| s.align).unwrap_or_default(); + // Mixed values light no button. + let cap = st.as_ref().map(|s| s.cap).filter(|_| !mixed.cap); + let join = st.as_ref().map(|s| s.join).filter(|_| !mixed.join); + let align = st.as_ref().map(|s| s.align).filter(|_| !mixed.align); ui.horizontal(|ui| { row_label(ui, "Cap:"); for (v, icon, tip, name) in [ @@ -111,7 +135,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (LineCap::Round, "dc-cap-round", "Round Cap", "round"), (LineCap::Square, "dc-cap-square", "Projecting Cap", "square"), ] { - if widgets::icon_button(ui, icon, tip, cap == v, 24.0).clicked() { + if widgets::icon_button(ui, icon, tip, cap == Some(v), 24.0).clicked() { set(app, json!({"cap": name})); } } @@ -123,14 +147,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (LineJoin::Round, "dc-join-round", "Round Join", "round"), (LineJoin::Bevel, "dc-join-bevel", "Bevel Join", "bevel"), ] { - if widgets::icon_button(ui, icon, tip, join == v, 24.0).clicked() { + if widgets::icon_button(ui, icon, tip, join == Some(v), 24.0).clicked() { set(app, json!({"join": name})); } } widgets::dim_label(ui, "Limit:"); let lim = st.as_ref().map(|s| s.miter_limit).unwrap_or(10.0); - if join == LineJoin::Miter { - if let Some(v) = widgets::plain_field(ui, "stroke-limit", lim, " x", 0, 50.0) { + if !matches!(join, Some(LineJoin::Round | LineJoin::Bevel)) { + if let Some(v) = widgets::mixed_field(ui, "stroke-limit", (!mixed.miter_limit).then_some(lim), " x", 0, 50.0) { set(app, json!({"miterLimit": v.clamp(1.0, 500.0)})); } } else { @@ -144,7 +168,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { (StrokeAlign::Inside, "dc-stroke-inside", "Align Stroke to Inside", "inside"), (StrokeAlign::Outside, "dc-stroke-outside", "Align Stroke to Outside", "outside"), ] { - if widgets::icon_button(ui, icon, tip, align == v, 24.0).clicked() { + // Only closed paths take a stroke inside or outside (not open paths, not type). + let enabled = v == StrokeAlign::Center || mixed.can_align; + if widgets::icon_button_enabled(ui, icon, tip, align == Some(v), enabled, 24.0).clicked() { set(app, json!({"align": name})); } } @@ -153,6 +179,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Dashed line. let dash = st.as_ref().and_then(|s| s.dash.as_ref()); let (fields, align_corners) = dash_state(dash, pstate(ui.ctx(), "stroke-dash-last")); + let shown = if mixed.dash { [None; 6] } else { fields }; ui.horizontal(|ui| { if widgets::check(ui, "Dashed Line", dash.is_some(), st.is_some()) { if dash.is_some() { @@ -174,10 +201,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 3.0; let fw = ((ui.available_width() - 15.0) / 6.0).clamp(28.0, 40.0); - let mut nf = fields; + let mut nf = shown; let mut changed = false; - for (i, f) in fields.iter().enumerate() { - let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, fw)).inner; + for (i, f) in shown.iter().enumerate() { + let r = ui.add_enabled_ui(dash.is_some(), |ui| widgets::opt_field(ui, ("dash", i), *f, unit, fw)).inner; if let Some(x) = r { nf[i] = x.map(|v| v.max(0.0)); changed = true; @@ -349,7 +376,7 @@ pub fn paint_profile(ui: &Ui, r: Rect, prof: Option<&WidthProfile>, color: Color ui.painter().add(egui::Shape::mesh(mesh)); } -fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { +pub(crate) fn profile_dropdown(ui: &mut Ui, cur: &str) -> Option<&'static str> { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(100.0, 24.0), Sense::click()); ui.painter().rect_filled(r, 2, t.input); diff --git a/crates/ui-egui/src/panels/tests_strokeux.rs b/crates/ui-egui/src/panels/tests_strokeux.rs new file mode 100644 index 00000000..a6ecc918 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_strokeux.rs @@ -0,0 +1,88 @@ +//! Headless frames of the Stroke panel's reach: Units > Stroke in the panel, the Control bar and +//! the Properties panel, mixed values shown blank, Align Stroke for open paths and type, and the +//! weight presets. + +use serde_json::json; + +use super::tests_appearance::{app_with_rect, click, frame_events, run, text_rect}; +use super::*; + +type Texts = Vec<(String, egui::Rect)>; + +fn texts(ctx: &egui::Context, app: &mut VectorcraftApp, f: impl FnMut(&mut VectorcraftApp, &mut Ui)) -> Texts { + frame_events(ctx, app, vec![], f) +} + +fn shows(t: &Texts, s: &str) -> bool { + t.iter().any(|(x, _)| x == s) +} + +/// The centre of the `n`th (0-based) 24 px button right of a Stroke panel row label. +fn row_button(ctx: &egui::Context, t: &Texts, label: &str, n: usize) -> egui::Pos2 { + let r = text_rect(t, label); + let sp = ctx.global_style().spacing.item_spacing.x; + egui::pos2(r.right() + sp + n as f32 * (24.0 + sp) + 12.0, r.center().y) +} + +#[test] +fn weights_and_dashes_show_in_the_stroke_unit_everywhere() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut app = app_with_rect(); + app.session.prefs.units_stroke = "millimeters".into(); + run(&mut app, "stroke.set", json!({"dash": [12, 6]})); + let mm = vectorcraft_doc::Unit::Millimeters.format(1.0); + assert_eq!(mm, "0.353 mm"); + let t = texts(&ctx, &mut app, stroke::show); + assert!(shows(&t, &mm), "Stroke panel: {t:?}"); + assert!(shows(&t, "4.233") && shows(&t, "2.117"), "dash and gap in mm: {t:?}"); + assert!(shows(&texts(&ctx, &mut app, properties::show), &mm), "Properties panel"); + assert!(shows(&texts(&ctx, &mut app, crate::chrome::control_bar), &mm), "Control bar"); +} + +#[test] +fn mixed_weights_show_blank() { + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + let a = app.session.active().unwrap().selection.objects[0]; + let b = run(&mut app, "shape.rectangle", json!({"x": 120, "y": 10, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(&mut app, "stroke.set", json!({"ids": [b], "weight": 3})); + run(&mut app, "select.set", json!({ "ids": [a.0, b] })); + let t = texts(&ctx, &mut app, stroke::show); + assert!(!shows(&t, "1 pt") && !shows(&t, "3 pt"), "{t:?}"); + run(&mut app, "select.set", json!({ "ids": [b] })); + assert!(shows(&texts(&ctx, &mut app, stroke::show), "3 pt"), "the cache follows the selection"); +} + +#[test] +fn inside_and_outside_are_off_for_open_paths_and_type() { + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + let align = |app: &VectorcraftApp| current_stroke(app).unwrap().align; + let at = row_button(&ctx, &texts(&ctx, &mut app, stroke::show), "Align Stroke:", 1); + click(&ctx, &mut app, at, stroke::show); + assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Inside, "a closed path takes an inside stroke"); + run(&mut app, "shape.line", json!({"x1": 10, "y1": 150, "x2": 150, "y2": 150})); + click(&ctx, &mut app, at, stroke::show); + assert_eq!(align(&app), vectorcraft_doc::StrokeAlign::Center, "an open path doesn't"); + let id = run(&mut app, "text.create", json!({"x": 10, "y": 190, "text": "Hi"}))["id"].as_u64().unwrap(); + run(&mut app, "select.set", json!({ "ids": [id] })); + run(&mut app, "paint.setStroke", json!({"color": "#000000"})); + let undo = app.session.active().unwrap().history.undo.len(); + click(&ctx, &mut app, at, stroke::show); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo, "type doesn't"); +} + +#[test] +fn the_weight_presets_run_from_a_quarter_point_to_a_hundred() { + assert!(stroke::WEIGHT_PRESETS.contains(&0.25) && stroke::WEIGHT_PRESETS.contains(&100.0)); + let ctx = egui::Context::default(); + let mut app = app_with_rect(); + // The chevron at the right end of the 120 px weight spinner opens them. + let t = texts(&ctx, &mut app, stroke::show); + let r = text_rect(&t, "Weight:"); + let sp = ctx.global_style().spacing.item_spacing.x; + click(&ctx, &mut app, egui::pos2(r.right() + sp + 110.0, r.center().y), stroke::show); + let t = texts(&ctx, &mut app, stroke::show); + assert!(shows(&t, "0.25 pt") && shows(&t, "100 pt"), "{t:?}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 70b89b21..8c76851d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -911,12 +911,13 @@ pub fn menu_item(ui: &mut Ui, label: &str, enabled: bool, checked: bool) -> bool ui.add_enabled(enabled, egui::Button::new(egui::RichText::new(text).size(12.5)).frame(false)).clicked() } -/// A number field that may be empty (Stroke dash/gap). Returns `Some(new)` on commit, where -/// `new` is `None` when the field was cleared. -pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, width: f32) -> Option> { +/// A number field that may be empty (Stroke dash/gap): `value` (points) shows in `unit` without +/// its suffix, to fit narrow fields. Returns `Some(new)` (points) on commit, where `new` is `None` +/// when the field was cleared; typed units and arithmetic work as in [`num_field`]. +pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: Option, unit: Unit, width: f32) -> Option> { let t = Tokens::get(ui.ctx()); let id = ui.id().with(id); - let shown = value.map(|v| format!("{v}")).unwrap_or_default(); + let shown = value.map(|v| unit.format(v).trim_end_matches(unit.suffix()).trim_end().to_string()).unwrap_or_default(); let editing = ui.memory(|m| m.has_focus(id)); let mut buf: String = if editing { ui.data_mut(|d| d.get_temp::(id)).unwrap_or_else(|| shown.clone()) } else { shown.clone() }; let enabled = ui.is_enabled(); @@ -938,8 +939,8 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value: .inner; ui.data_mut(|d| d.insert_temp(id, buf.clone())); if resp.lost_focus() && buf != shown { - let s = buf.trim().trim_end_matches("pt").trim(); - if s.is_empty() { Some(None) } else { s.parse::().ok().map(Some) } + let s = buf.trim(); + if s.is_empty() { Some(None) } else { unit.parse(s).map(Some) } } else { None } diff --git a/docs/mcp.md b/docs/mcp.md index c40eb95c..1896f944 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -332,4 +332,5 @@ options go to every run (the object keeps no stroke of its own); an `item` still with `appearance.addStroke`, which takes every stroke option (dashes, profile, opacity, blend) like a path's and paints under or over the characters as its place relative to the Characters row says. To stroke some characters, use `text.setRangeStyle {id, start, end, strokeOptions: {weight?, cap?, join?, miterLimit?, dash?, -dashOffset?, alignDashes?}}`. +dashOffset?, alignDashes?}}`. `inspect_document` reports each object's stroke as `strokeOptions` (type: its first +run's) in `stroke.set` terms. `stroke.set` on a group leaves the images and symbol instances in it alone.