From 66d7d9315a1920d8e311f4fbc60f50fc4eac07ae Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 09:34:15 -0700 Subject: [PATCH] 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. --- crates/ui-egui/src/panels/color_wheel.rs | 17 +++-- .../ui-egui/src/panels/tests_color_wheel.rs | 69 +++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/color_wheel.rs b/crates/ui-egui/src/panels/color_wheel.rs index 7011c6c70..a2108d8cf 100644 --- a/crates/ui-egui/src/panels/color_wheel.rs +++ b/crates/ui-egui/src/panels/color_wheel.rs @@ -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, selection: Vec, + /// 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, 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, 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); } diff --git a/crates/ui-egui/src/panels/tests_color_wheel.rs b/crates/ui-egui/src/panels/tests_color_wheel.rs index be9856ed4..798a842e4 100644 --- a/crates/ui-egui/src/panels/tests_color_wheel.rs +++ b/crates/ui-egui/src/panels/tests_color_wheel.rs @@ -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]