Files
vectorcraft/crates/ui-egui/src/dialogs/gradient_stop.rs
T
bflatasticandClaude Opus 5.5 b2ececc7b4 M3.59: tints of global and spot colours, tint swatches and Global colour balance
A colour linked to a global or spot swatch keeps a tint (Paint::Solid.tint, 1 by default and
left out of files at 100 %): paint.setFill/setStroke {swatch, tint} apply the swatch at that
percentage, swatch edits recolour every linked colour at its own tint, and Color::tinted
resolves it (CMYK and Gray inks scale, RGB mixes toward white). swatch.new saves a tint as a
tint swatch ("Ink 40%") linked to its base. Spot tints separate as that percentage of their
plate (Separations Preview, PDF Separation value) instead of an ink-ratio guess. Adjust Color
Balance's Global mode (tint) shifts the tints of linked colours. The Color panel shows Tint
mode (the swatch chip, its name and a T slider) for linked colours; the Swatches panel
highlights the tint swatch in use. Gradient stops get the same link and tint fields (the
swatch walks cover them), so GradientStop is no longer Copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:29:17 +07:00

267 lines
12 KiB
Rust

//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator (or the
//! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls,
//! or a document swatch), opacity and location. It edits the selected stop
//! (`gradient.selectStop`) through `paint.editGradient`, and closes on Escape or a click elsewhere.
//!
//! Fields: `index`, `x`, `y` (the chip, document coordinates) or `screen` ([x, y], screen points,
//! from the panel), and `tab` (`color` or `swatches`).
//! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm.
use egui::{Sense, vec2};
use serde_json::{Value, json};
use vectorcraft_color::gradient::move_stop;
use vectorcraft_color::{Color, GradientStop, Paint};
use vectorcraft_geom::Point;
use super::{DialogResult, DialogSpec};
use crate::VectorcraftApp;
use crate::canvas::Xf;
use crate::panels::active_paint;
use crate::panels::gradient::set_stops;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets::{self, Live};
pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm);
/// The stops of the gradient behind the active proxy and the selected stop's index.
fn selected(app: &VectorcraftApp) -> Option<(Vec<GradientStop>, usize)> {
let Paint::Gradient(g) = active_paint(app) else { return None };
let i = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len())?;
Some((g.gradient.stops, i))
}
/// `stops` with stop `i` given `color`.
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
stops[i].color = color;
stops
}
/// Solid colours of the document's swatches (groups included), with their names.
fn swatch_colors(app: &VectorcraftApp) -> Vec<(String, Color)> {
let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc;
d.swatches.iter().chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter())).filter_map(|s| Some((s.name.clone(), s.paint.color()?))).collect()
}
fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(d) = app.ui.dialog.clone() else { return };
// The stop went away (undo, another object selected): nothing left to edit.
let Some((stops, i)) = selected(app) else {
app.ui.dialog = None;
return;
};
let t = Tokens::get(ctx);
// Opened from the Gradient panel: at `screen`; from the annotator: beside the chip.
let screen =
d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
let pos = screen.unwrap_or_else(|| {
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0))
});
let swatches = d.str("tab") == "swatches";
let mut tab = None;
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
.order(egui::Order::Foreground)
.fixed_pos(pos)
.constrain(true)
.show(ctx, |ui| {
egui::Frame::popup(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
ui.set_width(250.0);
ui.horizontal(|ui| {
for (icon, tip, on) in [("palette", "Color", !swatches), ("swatch-book", "Swatches", swatches)] {
if widgets::icon_button(ui, icon, tip, on, 24.0).clicked() {
tab = Some(if icon == "palette" { "color" } else { "swatches" });
}
}
});
ui.add_space(6.0);
if swatches {
swatch_grid(app, ui, &stops, i);
} else {
// The Color panel edits the selected stop of the active gradient.
crate::panels::color::show(app, ui);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let s = &stops[i];
widgets::dim_label(ui, "Opacity:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) {
let mut v2 = stops.clone();
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
set_stops(app, &v2, Some(i), Live::Released);
}
widgets::dim_label(ui, "Location:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) {
let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32);
set_stops(app, &v2, Some(ni), Live::Released);
}
});
});
})
.response;
if let (Some(tab), Some(d)) = (tab, app.ui.dialog.as_mut()) {
d.fields.insert("tab".into(), json!(tab));
}
// The double-click that opened the popover lands on its chip, outside it: only a later click
// elsewhere closes it.
if resp.clicked_elsewhere() && !ctx.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)) {
app.ui.dialog = None;
}
}
/// Document swatches as tiles: a click gives the stop that colour.
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientStop], i: usize) {
let colors = swatch_colors(app);
if colors.is_empty() {
widgets::dim_label(ui, "The document has no colour swatches.");
return;
}
let mut pick = None;
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
for (name, c) in &colors {
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(*c), stops[i].color == *c, resp.hovered());
if resp.on_hover_text(name).clicked() {
pick = Some(*c);
}
}
});
if let Some(c) = pick {
set_stops(app, &recolor(stops.to_vec(), i, c), Some(i), Live::Released);
}
}
/// OK (agents): apply the `color`, `opacity` and `location` fields that are set, then close.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let (mut stops, mut i) = selected(app).ok_or("no gradient stop is selected")?;
if let Some(hex) = d.fields.get("color").and_then(Value::as_str) {
let c = crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?;
stops = recolor(stops, i, c);
}
if d.fields.contains_key("opacity") {
stops[i].opacity = (d.f64("opacity", 100.0) / 100.0).clamp(0.0, 1.0) as f32;
}
if d.fields.contains_key("location") {
(stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32);
}
set_stops(app, &stops, Some(i), Live::Released);
app.ui.dialog = None;
Ok(json!({ "index": i }))
}
#[cfg(test)]
mod tests {
use vectorcraft_engine::Session;
use vectorcraft_tools::{PointerEvent, PointerKind};
use super::*;
/// A rectangle with a three-stop gradient from (100, 150) to (200, 150), the Gradient tool
/// and stop 1 selected.
fn app() -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#000000"}]);
app.run("paint.setFill", json!({"gradient": {"stops": stops, "start": [100, 150], "end": [200, 150]}})).unwrap();
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
app
}
fn stops(app: &VectorcraftApp) -> Vec<GradientStop> {
selected(app).unwrap().0
}
/// One headless frame of the dialog layer and the shortcuts.
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
crate::shortcuts::handle(app, ui.ctx());
super::super::show(app, ui.ctx());
});
out.textures_delta.clear();
}
fn key(k: egui::Key) -> egui::Event {
egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
}
#[test]
fn double_clicking_a_stop_opens_the_popover_which_draws_both_tabs() {
let mut app = app();
let view = app.view_info();
let chip = Point::new(150.0, 150.0 + 10.0 / view.zoom);
crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: chip, mods: Default::default(), pressure: 1.0 }, view);
let d = app.ui.dialog.clone().expect("the popover opened");
assert_eq!((d.kind.as_str(), d.fields["index"].clone(), d.str("tab")), ("gradientStop", json!(1), "color".into()));
assert_eq!(app.session.selected_stop(), Some(1));
frame(&mut app, vec![]);
app.ui.dialog.as_mut().unwrap().fields.insert("tab".into(), json!("swatches"));
frame(&mut app, vec![]);
assert!(app.ui.dialog.is_some(), "still open");
// Escape closes it.
frame(&mut app, vec![key(egui::Key::Escape)]);
assert!(app.ui.dialog.is_none());
}
#[test]
fn confirm_applies_colour_opacity_and_location() {
let mut app = app();
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160, "color": "#ff0000", "opacity": 40, "location": 80})));
assert_eq!(super::super::confirm(&mut app).unwrap(), json!({"index": 1}));
let s = stops(&app);
assert_eq!((s[1].color.to_hex(), s[1].opacity, (s[1].offset * 100.0).round()), ("#ff0000".to_string(), 0.4, 80.0));
assert!(app.ui.dialog.is_none());
// Moving it past the last stop keeps it selected at its new index.
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"location": 100})));
super::super::confirm(&mut app).unwrap();
assert_eq!((app.session.selected_stop(), stops(&app)[2].color.to_hex()), (Some(2), "#ff0000".to_string()));
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"color": "red"})));
assert!(super::super::confirm(&mut app).is_err());
}
fn click(at: egui::Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
#[test]
fn the_opening_double_click_keeps_it_open_and_a_later_click_elsewhere_closes_it() {
let mut app = app();
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160})));
// The frame the double-click lands in (the popover opens beside the pointer, not under it).
let at = egui::pos2(5.0, 5.0);
let double = vec![egui::Event::PointerMoved(at), click(at, true), click(at, false), click(at, true), click(at, false)];
frame(&mut app, double);
assert!(app.ui.dialog.is_some(), "the opening double-click must not close it");
frame(&mut app, vec![egui::Event::PointerMoved(at), click(at, true), click(at, false)]);
assert!(app.ui.dialog.is_none(), "a click elsewhere closes it");
}
#[test]
fn the_popover_closes_when_its_stop_goes_away() {
let mut app = app();
app.ui.dialog = Some(Dialog::new("gradientStop", json!({"index": 1, "x": 150, "y": 160})));
app.run("gradient.selectStop", json!({"index": null})).unwrap();
frame(&mut app, vec![]);
assert!(app.ui.dialog.is_none());
}
#[test]
fn delete_removes_the_selected_stop_instead_of_the_object() {
let mut app = app();
frame(&mut app, vec![key(egui::Key::Delete)]);
assert_eq!(stops(&app).len(), 2);
frame(&mut app, vec![key(egui::Key::Backspace)]);
assert_eq!(stops(&app).len(), 2, "never below two stops");
assert_eq!(app.session.doc().unwrap().selection.objects.len(), 1, "the rectangle stays");
// The arrows nudge it.
let before = stops(&app)[1].offset;
frame(&mut app, vec![key(egui::Key::ArrowLeft)]);
assert!((stops(&app)[1].offset - (before - 0.01)).abs() < 1e-6);
}
}