Color Wheel: the remembered hue stays with its appearance item, characters and gradient

The wheel kept the hue of its last pick while the selected objects, the
proxy, the slot and the color were unchanged. Switching to another fill
row in the Appearance panel, another range of characters in the Type
tool, or a new gradient with the same stop color kept that hue too, so a
pick there could paint the earlier hue. The wheel now also checks the
active appearance item, the selected characters and the gradient.
This commit is contained in:
dotCooCoo
2026-11-08 18:43:49 -08:00
parent ae8f0c3a14
commit 66d7d9315a
2 changed files with 82 additions and 4 deletions
+13 -4
View File
@@ -23,16 +23,22 @@ const MAX_WHEEL: f32 = 200.0;
/// Panel state: the HSB picked last, its [`Owner`] and the display sRGB of the color it stored.
const MEMORY: &str = "color-wheel-hsb";
/// What the HSB picked last belongs to: the document, the selected objects, the proxy, the
/// gradient stop or freeform point, and the color the pick stored. The wheel shows that HSB only
/// while all of them are unchanged.
/// What the HSB picked last belongs to: the document, the selected objects or text characters,
/// the appearance item, the proxy, the gradient stop or freeform point, its paint, and the color
/// the pick stored. The wheel shows that HSB only while all of them are unchanged.
#[derive(Clone, Debug, PartialEq)]
struct Owner {
doc: Option<u64>,
selection: Vec<NodeId>,
/// The Type tool's selected characters, when it edits the paint.
text_selection: Option<(NodeId, usize, usize)>,
/// The Appearance panel's active fill or stroke row.
appearance_item: Option<usize>,
fill: bool,
/// The selected gradient stop (`Some((false, i))`) or freeform point (`Some((true, i))`).
slot: Option<(bool, usize)>,
/// The gradient behind a selected stop or point, including its other colors.
gradient: Option<Paint>,
color: Color,
}
@@ -47,8 +53,11 @@ impl Owner {
Self {
doc: st.map(|d| d.uid),
selection: st.map(|d| d.selection.objects.clone()).unwrap_or_default(),
text_selection: super::character::text_editing(app),
appearance_item: app.session.appearance_item(),
fill: app.session.fill_active,
slot,
gradient: slot.map(|_| super::active_paint(app)),
color,
}
}
@@ -150,8 +159,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let behind = super::alt_held(ui);
let cmyk_doc = app.session.active().is_some_and(|d| d.doc.color_mode == ColorMode::Cmyk);
let c = stored(&cms, hsb, replaced(app, &tgt, behind).as_ref(), cmyk_doc);
apply(app, &tgt, c, phase, behind);
if !behind {
set_pstate(ui.ctx(), MEMORY, Some((Owner::of(app, &tgt, c), cms.display_rgb(&c), hsb)));
}
apply(app, &tgt, c, phase, behind);
}
@@ -308,6 +308,75 @@ fn the_remembered_hue_stays_with_its_object() {
assert!(!(1.0..359.0).contains(&hue), "the white object's own hue is 0°, not 120°: {hue}");
}
/// Equal colors on two appearance fills have separate remembered hues. A square pick on the
/// second white fill uses its own 0° hue after the first fill was changed from green to white.
#[test]
fn the_remembered_hue_stays_with_its_appearance_item() {
let mut app = app(json!("#00ff00"));
let id = app.session.active().unwrap().selection.objects[0];
app.run("appearance.addFill", json!({})).unwrap(); // [Fill, Stroke, Fill]
app.run("paint.setFill", json!({"item": 2, "color": "#ffffff"})).unwrap();
app.run("appearance.setActiveItem", json!({"index": 0})).unwrap();
let mut h = harness(app);
let sq = rect_of(&h, "Saturation and brightness");
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE);
let item_fill = |app: &VectorcraftApp, index| {
app.session.doc().unwrap().doc.node(id).unwrap().appearance.fill_at(Some(index)).unwrap().paint.color().unwrap()
};
assert_eq!(item_fill(h.state(), 0), Color::WHITE);
assert_eq!(item_fill(h.state(), 2), Color::WHITE);
h.state_mut().run("appearance.setActiveItem", json!({"index": 2})).unwrap();
h.run_steps(1);
click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE);
let hue = hsb(&item_fill(h.state(), 2))[0];
assert!(!(1.0..359.0).contains(&hue), "the second fill's own hue is 0°, not 120°: {hue}");
assert_eq!(item_fill(h.state(), 0), Color::WHITE);
}
/// The Type tool can recolor a selected character range without changing the selected object.
/// The next range of the same color starts with its own hue.
#[test]
fn the_remembered_hue_stays_with_its_text_selection() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let id = app.run("text.create", json!({"x": 10, "y": 100, "text": "AB", "color": "#00ff00"})).unwrap()["id"].as_u64().unwrap();
app.run("text.setRangeStyle", json!({"id": id, "start": 1, "end": 2, "fill": "#ffffff"})).unwrap();
app.select_tool("type");
app.session.set_tool_option("textSelection", &json!({"id": id, "start": 0, "end": 1}));
let mut h = harness(app);
let sq = rect_of(&h, "Saturation and brightness");
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE);
h.state_mut().session.set_tool_option("textSelection", &json!({"id": id, "start": 1, "end": 2}));
h.run_steps(1);
click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE);
let node = h.state().session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap();
let vectorcraft_doc::NodeKind::Text(t) = &node.kind else { panic!("still text") };
assert_eq!(t.runs.first().unwrap().style.fill.color(), Some(Color::WHITE));
let hue = hsb(&t.runs.last().unwrap().style.fill.color().unwrap())[0];
assert!(!(1.0..359.0).contains(&hue), "the second character's own hue is 0°, not 120°: {hue}");
}
/// Replacing a gradient can keep the same selected stop and color while changing the paint it
/// belongs to. The new gradient starts with that stop's own hue.
#[test]
fn the_remembered_hue_stays_with_its_gradient() {
let mut app = app(json!("#00ff00"));
let first = json!([{"offset": 0, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]);
app.run("paint.setFill", json!({"gradient": {"stops": first}})).unwrap();
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
let mut h = harness(app);
let sq = rect_of(&h, "Saturation and brightness");
drag(&mut h, &[in_square(sq, 0.995, 0.995), Pos2::new(sq.left() - 5.0, sq.top() - 3.0)], Modifiers::NONE);
let second = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]);
h.state_mut().run("paint.setFill", json!({"gradient": {"stops": second}})).unwrap();
assert_eq!(h.state().session.selected_stop(), Some(0));
h.run_steps(1);
click(&mut h, in_square(sq, 0.995, 0.995), Modifiers::NONE);
let Paint::Gradient(g) = crate::panels::current_paints(h.state()).0 else { panic!("still a gradient") };
let hue = hsb(&g.gradient.stops[0].color)[0];
assert!(!(1.0..359.0).contains(&hue), "the new gradient's white stop has hue 0°, not 120°: {hue}");
}
/// The remembered HSB holds only while the color displays as it did when picked: in another
/// working RGB space the wheel shows the color's own HSB there.
#[test]