From c071dcd4d0cfbc5b7b356257f4be6ff826ed0478 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 4 Oct 2026 18:22:05 +0700 Subject: [PATCH] M3.28: proxies look through groups like the paint commands (a group shows its contents' paint, and contents that differ make a "?" proxy), the toolbar and panels share the engine's Session::proxy_paints, Magic Wand and Select Text by Font bump the revision so the cached "?" state follows them; double-click proxy test Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fonts.rs | 2 +- crates/engine/src/cmd/paint.rs | 78 ++++++++++++++++++--------- crates/engine/src/cmd/wand.rs | 2 +- crates/engine/src/tests_paintproxy.rs | 63 ++++++++++++++++++++++ crates/ui-egui/src/panels/color.rs | 28 ++++++++++ crates/ui-egui/src/panels/mod.rs | 17 ++---- 6 files changed, 149 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 3b87a7e3..82a1365e 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -159,7 +159,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result { }) .collect(); let n = hits.len(); - s.doc_mut()?.selection.set(hits); + s.select(|_, sel| sel.set(hits))?; Ok(json!({ "count": n })) } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 66058c45..8a553d52 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -123,7 +123,7 @@ pub fn proxy_specs() -> Vec { "Fill and Stroke", [], None, - "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the first selected object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' fills (strokes) differ, shown as a \"?\" proxy}", + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}", always, proxies ), @@ -144,6 +144,19 @@ impl Session { } } + /// The fill and stroke the Fill/Stroke proxies show: the first selected object's (a group's + /// first painted object's; type: its first run's), else the defaults for new art. + pub fn proxy_paints(&self) -> (Paint, Paint) { + let mut nodes = vec![]; + if let Some(n) = self.active().and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) { + painted(n, true, &mut nodes); + } + match nodes.first() { + Some(n) => (proxy_paint(n, false), proxy_paint(n, true)), + None => (self.paint.fill.clone(), self.paint.stroke.clone()), + } + } + pub(crate) fn remember_paint_now(&mut self, p: &Paint) { match p { Paint::Solid { color, .. } => { @@ -158,13 +171,12 @@ impl Session { } } -/// The fill or stroke a proxy shows for `n` (type shows its first run's style). +/// The fill or stroke a proxy shows for `n` (type shows its first run's style, as +/// `TextNode::first_style` does). pub(crate) fn proxy_paint(n: &Node, stroke: bool) -> Paint { + let pick = |st: &CharStyle| if stroke { st.stroke.clone() } else { st.fill.clone() }; match &n.kind { - NodeKind::Text(t) => { - let st = t.runs.first().map(|r| &r.style); - st.map(|st| if stroke { st.stroke.clone() } else { st.fill.clone() }).unwrap_or_default() - } + NodeKind::Text(t) => t.runs.first().map_or_else(|| pick(&CharStyle::default()), |r| pick(&r.style)), _ if stroke => n.appearance.stroke_paint(), _ => n.appearance.fill_paint(), } @@ -180,40 +192,54 @@ fn same_in_proxy(a: &Paint, b: &Paint) -> bool { } } +/// The objects whose paints the proxies show for a selected `n`, the ones the paint commands +/// change ([`leaf_targets`]): groups and layers stand for their contents (and so do blends and +/// other containers inside them), a compound path for its parts. +fn painted<'a>(n: &'a Node, top: bool, out: &mut Vec<&'a Node>) { + let expand = match n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => true, + NodeKind::Compound { .. } => false, + _ => !top && n.is_container(), + }; + if expand { + n.children().into_iter().flatten().for_each(|c| painted(c, false, out)); + } else { + out.push(n); + } +} + impl DocState { /// Whether the selected objects' fills and strokes differ as the proxies show them (a "?" - /// proxy). One walk of the document, so callers that ask every frame cache it by revision. + /// proxy; a group whose contents differ counts). One walk of the document, so callers that ask + /// every frame cache it by revision. pub fn proxy_mixed(&self) -> (bool, bool) { - if self.selection.len() < 2 { + if self.selection.is_empty() { return (false, false); } let ids: std::collections::HashSet = self.selection.objects.iter().copied().collect(); - let mut first: Option<(Paint, Paint)> = None; - let mut mixed = (false, false); + let mut nodes = vec![]; self.doc.walk(|n| { - if (mixed.0 && mixed.1) || !ids.contains(&n.id) { - return; - } - let (f, s) = (proxy_paint(n, false), proxy_paint(n, true)); - match &first { - Some((f0, s0)) => { - mixed.0 |= !same_in_proxy(f0, &f); - mixed.1 |= !same_in_proxy(s0, &s); - } - None => first = Some((f, s)), + if ids.contains(&n.id) { + painted(n, true, &mut nodes); } }); + let mut paints = nodes.iter().map(|n| (proxy_paint(n, false), proxy_paint(n, true))); + let Some((f0, s0)) = paints.next() else { return (false, false) }; + let mut mixed = (false, false); + for (f, s) in paints { + mixed.0 |= !same_in_proxy(&f0, &f); + mixed.1 |= !same_in_proxy(&s0, &s); + if mixed.0 && mixed.1 { + break; + } + } mixed } } fn proxies(s: &mut Session, _: &Value) -> Result { - let st = s.doc().ok(); - let (fill, stroke) = match st.and_then(|st| st.selection.objects.first().and_then(|id| st.doc.node(*id))) { - Some(n) => (proxy_paint(n, false), proxy_paint(n, true)), - None => (s.paint.fill.clone(), s.paint.stroke.clone()), - }; - let (fill_mixed, stroke_mixed) = st.map_or((false, false), DocState::proxy_mixed); + let (fill, stroke) = s.proxy_paints(); + let (fill_mixed, stroke_mixed) = s.active().map_or((false, false), DocState::proxy_mixed); Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed})) } diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 8f6b118e..fd843800 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -159,7 +159,7 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result { other => return Err(bad("select.magicWand", format!("unknown mode `{other}` (set, add, subtract)"))), }; let n = ids.len(); - s.doc_mut()?.selection.set(ids); + s.select(|_, sel| sel.set(ids))?; Ok(json!({ "count": n })) } diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs index 50c83fdf..be0ee209 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests_paintproxy.rs @@ -196,6 +196,48 @@ fn a_mixed_selection_shows_a_question_mark_proxy() { assert_eq!(q(&mut s)["fillMixed"], false); } +#[test] +fn every_selection_change_bumps_the_revision() { + // The "?" proxy is cached per revision, so selecting by Magic Wand or font must bump it. + let mut s = session(); + let a = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let b = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#fa0000"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let rev = s.doc().unwrap().revision; + s.execute("select.magicWand", &json!({"id": a.0})).unwrap(); + assert_eq!(s.doc().unwrap().selection.len(), 2, "{a:?} and {b:?} are within the tolerance"); + assert!(s.doc().unwrap().revision > rev); + assert_eq!(s.doc().unwrap().proxy_mixed(), (true, false)); + let r = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "Hi"})).unwrap(); + let family = match &node(&s, NodeId(r["id"].as_u64().unwrap())).kind { + NodeKind::Text(t) => t.first_style().font_family, + _ => panic!("not text"), + }; + let rev = s.doc().unwrap().revision; + s.execute("select.font", &json!({"family": family})).unwrap(); + assert!(s.doc().unwrap().revision > rev); +} + +#[test] +fn proxies_show_the_first_selected_object_or_the_defaults() { + let mut s = session(); + let id = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap(); + assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#123456"); + s.execute("select.none", &json!({})).unwrap(); + s.paint.fill = Paint::solid(Color::WHITE); + assert_eq!(s.proxy_paints().0, Paint::solid(Color::WHITE)); + // Type shows its first run's paints. + let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Hi"})).unwrap(); + s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#654321"})).unwrap(); + assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#654321"); + assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fill"], json!(s.proxy_paints().0)); + let _ = id; +} + #[test] fn live_paint_and_appearance_items_feed_recent_colours() { let mut s = session(); @@ -210,3 +252,24 @@ fn live_paint_and_appearance_items_feed_recent_colours() { s.execute("livePaint.fill", &json!({"group": g.0, "point": [50, 30], "color": "#00ff00"})).unwrap(); assert_eq!(hexes(&s)[0], "#00ff00"); } + +#[test] +fn a_group_shows_its_contents_in_the_proxies() { + let mut s = session(); + let a = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let b = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // The group shows what the paint commands change: its contents' red fill, not mixed. + assert_eq!(s.doc().unwrap().selection.len(), 1); + assert_eq!(s.proxy_paints().0.color().unwrap().to_hex(), "#ff0000"); + assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false)); + // Contents that differ make even a single selected group a "?" proxy. + s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap(); + assert_eq!(s.execute("paint.proxies", &json!({})).unwrap()["fillMixed"], true); + // An empty selection shows the defaults. + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0, s.paint.fill); +} diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 44605737..6664ba4c 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -650,6 +650,34 @@ mod tests { assert!(matches!(crate::panels::current_paints(&app).0.color(), Some(Color::Cmyk { .. })), "CMYK converts the colour"); } + #[test] + fn double_clicking_a_proxy_opens_the_color_picker_for_it() { + let mut app = app(); + let ctx = egui::Context::default(); + // One frame of the proxy alone; returns the stroke square's rect. + let run = |app: &mut VectorcraftApp, time: f64, events: Vec| { + let input = egui::RawInput { time: Some(time), events, ..Default::default() }; + let mut rect = None; + let mut out = ctx.run_ui(input, |ui| { + crate::panels::proxy(app, ui, 40.0); + rect = ctx.read_response(ui.id().with("stroke-proxy")).map(|r| r.rect); + }); + out.textures_delta.clear(); + rect + }; + let pos = run(&mut app, 0.0, vec![]).expect("the stroke square is drawn").right_bottom() - vec2(3.0, 3.0); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + run(&mut app, 0.1, vec![egui::Event::PointerMoved(pos), button(true)]); + run(&mut app, 0.15, vec![button(false)]); + assert!(!app.session.fill_active, "a click brings the stroke to the front"); + assert!(app.ui.dialog.is_none()); + run(&mut app, 0.2, vec![button(true)]); + run(&mut app, 0.25, vec![button(false)]); + let d = app.ui.dialog.as_ref().expect("a double-click opens the Color Picker"); + assert_eq!(d.kind, "colorPicker"); + assert!(d.bool("stroke")); + } + #[test] fn hide_options_leaves_the_proxy_and_spectrum() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 7c77250a..a97e51d2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -38,7 +38,7 @@ pub mod transparency; use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_doc::Node; use crate::theme::Tokens; use crate::widgets::{Live, dim_label}; @@ -177,19 +177,10 @@ pub(crate) fn apply_click(app: &mut VectorcraftApp, ui: &Ui, mut params: Value) app.run(proxy_cmd(app, alt), params).ok(); } -/// Fill and stroke as the proxies show them: the first selected object's (text uses its first -/// run's style), else the defaults for new art. +/// Fill and stroke as the proxies show them ([`vectorcraft_engine::Session::proxy_paints`]: the +/// first selected object's, a group's first painted object's, else the defaults for new art). pub(crate) fn current_paints(app: &VectorcraftApp) -> (Paint, Paint) { - match first_selected(app) { - Some(n) => match &n.kind { - NodeKind::Text(t) => { - let s = t.first_style(); - (s.fill, s.stroke) - } - _ => (n.appearance.fill_paint(), n.appearance.stroke_paint()), - }, - None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()), - } + app.session.proxy_paints() } /// Whether the selected objects' fills and strokes differ (the proxies show "?"), cached per