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:
parent
ff9af2628a
commit
c071dcd4d0
|
|
@ -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 }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue