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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 18:22:05 +07:00
parent ff9af2628a
commit c071dcd4d0
6 changed files with 149 additions and 41 deletions

View File

@ -159,7 +159,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
})
.collect();
let n = hits.len();
s.doc_mut()?.selection.set(hits);
s.select(|_, sel| sel.set(hits))?;
Ok(json!({ "count": n }))
}

View File

@ -123,7 +123,7 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
"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<NodeId> = 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<Value> {
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}))
}

View File

@ -159,7 +159,7 @@ fn magic_wand(s: &mut Session, p: &Value) -> Result<Value> {
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 }))
}

View File

@ -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);
}

View File

@ -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<egui::Event>| {
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();

View File

@ -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