M3.28: Color panel behaviour — the mode follows the colour's own model (the last mode picked returns for colours in its model), Alt-clicking the spectrum, None/Black/White, swatches, recent colours or Color Guide chips paints the inactive proxy, Hide Options leaves the proxy and spectrum, RGB/HSB show the out-of-gamut warning (click to correct), Shift-clicking the spectrum cycles modes, Shift-dragging a slider moves the others in tandem, and a mixed selection draws "?" proxies (paint.proxies reports fillMixed/strokeMixed)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-04 18:01:58 +07:00
co-authored by Claude Opus 5.5
parent f21161ce8c
commit 6d2dc7cbbc
9 changed files with 542 additions and 171 deletions
+57 -1
View File
@@ -5,7 +5,7 @@ use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, NodeKind};
use super::*;
use crate::EngineError;
use crate::{DocState, EngineError};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -118,6 +118,15 @@ pub fn proxy_specs() -> Vec<CommandSpec> {
}))
}
),
cmd!(
query "paint.proxies",
"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}",
always,
proxies
),
]
}
@@ -161,6 +170,53 @@ pub(crate) fn proxy_paint(n: &Node, stroke: bool) -> Paint {
}
}
/// Do two paints look the same in a proxy? Solid colours compare without their swatch link and
/// gradients without their per-object geometry.
fn same_in_proxy(a: &Paint, b: &Paint) -> bool {
match (a, b) {
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => x == y,
(Paint::Gradient(x), Paint::Gradient(y)) => x.gradient == y.gradient,
_ => a == b,
}
}
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.
pub fn proxy_mixed(&self) -> (bool, bool) {
if self.selection.len() < 2 {
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);
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)),
}
});
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);
Ok(json!({"fill": fill, "stroke": stroke, "fillActive": s.fill_active, "fillMixed": fill_mixed, "strokeMixed": stroke_mixed}))
}
/// `stroke` param, defaulting to the proxy that is in front.
fn stroke_param(s: &Session, p: &Value) -> bool {
p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active)
+25
View File
@@ -170,3 +170,28 @@ fn focus_false_keeps_the_active_proxy() {
assert!(s.fill_active);
assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
}
#[test]
fn a_mixed_selection_shows_a_question_mark_proxy() {
let mut s = session();
let a = rect(&mut s);
let b = rect(&mut s);
let q = |s: &mut Session| s.execute("paint.proxies", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let r = q(&mut s);
assert_eq!((r["fillMixed"].as_bool(), r["strokeMixed"].as_bool()), (Some(false), Some(false)));
assert_eq!(r["fillActive"], true);
s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": [b.0]})).unwrap();
let r = q(&mut s);
assert_eq!((r["fillMixed"].as_bool(), r["strokeMixed"].as_bool()), (Some(true), Some(false)));
assert_eq!(r["fill"]["color"], json!(Color::rgb(1.0, 0.0, 0.0)), "the first selected object's fill");
// One selected object, or the same gradient fitted to different objects, is never mixed.
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
assert_eq!(q(&mut s)["fillMixed"], false);
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#000000"}, {"offset": 1, "color": "#ffffff"}]}})).unwrap();
assert_eq!(s.doc().unwrap().proxy_mixed(), (false, false));
s.execute("select.none", &json!({})).unwrap();
assert_eq!(q(&mut s)["fillMixed"], false);
}