Files
vectorcraft/crates/ui-egui/src/dialogs/gradient_stop.rs
T
WildfootWandClaude Fable 5.1 902c3ae8c1 i18n: UI localisation framework and a Traditional Chinese (zh-hant) catalog
Strings in code stay English and are the lookup keys; `crates/ui-egui/src/i18n`
maps them to display text at render time from one `*.tsv` catalog per language
(`LANGUAGES` registry, `tl!`/`t`/`tr`/`tr_ctx`/`tr_id`/`tn`/`fmt`). Command ids,
menu paths, `ui.menu.list`, the control channel, the CLI, MCP, status messages
and undo labels never see translated text, so agents, scripts and the existing
tests are unaffected (under `cargo test` the UI is always English).

Wired: in-window menus and the macOS native menu (rebuilt on a language change),
dynamic labels (Undo X, Reset X, Last Effect: X as templates), the command
palette (matches English and translated text), dock tabs, panel tooltips, the
toolbar (category names now cut by characters, not bytes), the dialog frame, the
shared widgets, the Preferences dialog, the shortcut editor, the hint and status
bars, the Home screen, and the literals of every panel and dialog.

Preference `interfaceLanguage` (User Interface › Language): `auto` or a code.
`auto` follows `VECTORCRAFT_LOCALE`, `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
the macOS preferred languages and, on Windows (which sets no `LANG`), the user
locale from `HKCU\Control Panel\International`. `zh-TW`/`zh-HK`/`zh-MO`/
`zh-Hant-*` resolve to `zh-hant`; Simplified locales stay English until a
`zh-hans` catalog exists.

`zh-hant.tsv` (~2,850 rows) is clean-room, in the vocabulary used in Taiwan;
terms shared with PhotoCraft reuse its wording, vector terms use everyday
vector-drawing vocabulary. Tests: catalog well-formedness (placeholders,
ellipses, command ids, no Simplified characters), locale resolution, and two
coverage tests that fail when a menu string, panel/tool/preference label,
dialog button, hint-bar fragment or `tl!` literal has no row in a language
marked `complete_menus`. `VECTORCRAFT_I18N_DUMP=file cargo test -p
vectorcraft-ui-egui dump_source_strings` lists the strings for translators.

Also: two clippy findings under rustc 1.97 fixed in passing.

Integrated with main (squashed onto it, 42 commits on): the Czech interface
(#213) moves from its Rust table to `i18n/cs.tsv` on the registry (all 556
rows, Czech plural rule, its menu-coverage and glyph tests kept); Find Font's
resolution notes and the canvas context menu's labels are translated and
covered by the catalog tests (`menus::CONTEXT_LABELS`); the glyph test checks
CJK text only when the craft-fonts build input is embedded (#220 moved the
Japanese fonts out of this repository); a language saved by an older version
carries over to `interfaceLanguage`; Windows' locale lookup runs reg.exe by its
full path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:37:05 +07:00

367 lines
17 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, which stays linked when it is a global or spot colour), opacity and
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and
//! closes on Escape or a click elsewhere. On a freeform gradient it edits the selected point
//! (`paint.freeform.selectPoint`) instead: its colour, opacity and spread, through
//! `paint.freeform.setPoint`.
//!
//! 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` (a point: `spread`) as percentages,
//! and confirm.
use egui::{Sense, vec2};
use serde_json::{Value, json};
use vectorcraft_color::gradient::move_stop;
use vectorcraft_color::{Color, FreeformPoint, GradientKind, GradientStop, Paint};
use vectorcraft_geom::Point;
use vectorcraft_tools::params::color_json;
use super::{DialogResult, DialogSpec};
use crate::VectorcraftApp;
use crate::canvas::Xf;
use crate::panels::active_paint;
use crate::panels::gradient::{edit_point, set_stops, shown_points};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets::{self, Live};
pub(super) const SPEC: DialogSpec = DialogSpec::window(show, confirm);
/// What the popover edits: the selected stop of the gradient behind the active proxy (with all
/// its stops), or the selected point of a freeform gradient.
enum Target {
Stop(Vec<GradientStop>, usize),
Point(FreeformPoint, usize),
}
impl Target {
fn color(&self) -> Color {
match self {
Target::Stop(stops, i) => stops[*i].color,
Target::Point(p, _) => p.color,
}
}
fn opacity(&self) -> f32 {
match self {
Target::Stop(stops, i) => stops[*i].opacity,
Target::Point(p, _) => p.opacity,
}
}
}
fn selected(app: &VectorcraftApp) -> Option<Target> {
let Paint::Gradient(g) = active_paint(app) else { return None };
if g.gradient.kind == GradientKind::Freeform {
let f = shown_points(&g);
let i = app.session.selected_freeform_point().filter(|i| *i < f.points.len())?;
return Some(Target::Point(f.points[i], i));
}
let i = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len())?;
Some(Target::Stop(g.gradient.stops, i))
}
/// `stops` with stop `i` given `color`, linked to `link` (a global swatch and tint) or unlinked.
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color, link: Option<(String, f32)>) -> Vec<GradientStop> {
stops[i].set_color(color, link);
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 or point went away (undo, another object selected): nothing left to edit.
let Some(target) = 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", tl!("Color"), !swatches), ("swatch-book", tl!("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, &target);
} else {
// The Color panel edits the selected stop (or point) of the active gradient.
crate::panels::color::show(app, ui);
}
ui.add_space(6.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, tl!("Opacity:"));
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", target.opacity() as f64 * 100.0, "%", 0, 54.0) {
apply(app, &target, &json!({ "opacity": v }));
}
let (label, key, value) = match &target {
Target::Stop(stops, i) => (tl!("Location:"), "location", stops[*i].offset),
Target::Point(p, _) => (tl!("Spread:"), "spread", p.spread),
};
widgets::dim_label(ui, label);
if let Some(v) = widgets::plain_field(ui, ("stop-pop", key), value as f64 * 100.0, "%", 1, 54.0) {
apply(app, &target, &json!({ key: v }));
}
});
});
})
.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 (or point) that colour.
fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, target: &Target) {
let colors = swatch_colors(app);
if colors.is_empty() {
widgets::dim_label(ui, tl!("The document has no colour swatches."));
return;
}
let current = target.color();
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), current == *c, resp.hovered());
if resp.on_hover_text(name).clicked() {
pick = Some((name.clone(), *c));
}
}
});
let Some((name, c)) = pick else { return };
match target {
// A global or spot colour (or a tint of one) stays linked.
Target::Stop(stops, i) => {
let link = crate::panels::gradient::dropped_link(app, &json!({ "swatch": name }));
set_stops(app, &recolor(stops.clone(), *i, c, link), Some(*i), Live::Released);
}
Target::Point(_, i) => edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": color_json(&c) })),
}
}
/// Apply the `color` (hex), `opacity` and `location` or `spread` (percentages) set in `f` to the
/// target as one edit; returns the stop's (or point's) index after it.
fn apply(app: &mut VectorcraftApp, target: &Target, f: &Value) -> usize {
let pct = |k: &str| f.get(k).and_then(Value::as_f64).map(|v| (v / 100.0).clamp(0.0, 1.0));
let color = f.get("color").and_then(Value::as_str).and_then(crate::panels::color::parse_hex);
match target {
Target::Stop(stops, i) => {
let (mut stops, mut i) = (stops.clone(), *i);
if let Some(c) = color {
stops = recolor(stops, i, c, None);
}
if let Some(o) = pct("opacity") {
stops[i].opacity = o as f32;
}
if let Some(l) = f.get("location").and_then(Value::as_f64) {
(stops, i) = move_stop(&stops, i, (l / 100.0) as f32);
}
set_stops(app, &stops, Some(i), Live::Released);
i
}
Target::Point(_, i) => {
let mut p = json!({ "index": i });
if let Some(c) = color {
p["color"] = color_json(&c);
}
for k in ["opacity", "spread"] {
if let Some(v) = pct(k) {
p[k] = json!(v);
}
}
edit_point(app, "paint.freeform.setPoint", p);
*i
}
}
}
/// OK (agents): apply the `color`, `opacity` and `location` (a point: `spread`) fields that are
/// set, then close.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
let target = selected(app).ok_or("no gradient stop or freeform point is selected")?;
if let Some(hex) = d.fields.get("color").and_then(Value::as_str) {
crate::panels::color::parse_hex(hex).ok_or_else(|| format!("bad colour `{hex}` (#rrggbb)"))?;
}
let i = apply(app, &target, &Value::Object(d.fields.clone()));
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> {
match selected(app) {
Some(Target::Stop(stops, _)) => stops,
_ => panic!("no stop selected"),
}
}
/// 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);
}
#[test]
fn on_a_freeform_gradient_it_edits_the_selected_point() {
let mut app = app();
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
app.run("paint.freeform.addPoint", json!({"at": [150, 150], "color": "#0000ff"})).unwrap();
let point = |app: &VectorcraftApp| match selected(app) {
Some(Target::Point(p, i)) => (p, i),
_ => panic!("no point selected"),
};
let (p, i) = point(&app);
// Double-clicking the point opens the popover beside it.
let view = app.view_info();
crate::canvas::dispatch(&mut app, &PointerEvent { kind: PointerKind::DoubleClick, pos: p.at, 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()), ("gradientStop", json!(i)));
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(), "both tabs draw");
// Agents set its colour, opacity and spread.
for (k, v) in [("color", json!("#ff0000")), ("opacity", json!(40)), ("spread", json!(25))] {
app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), v);
}
assert_eq!(super::super::confirm(&mut app).unwrap(), json!({"index": i}));
let (p, _) = point(&app);
assert_eq!((p.color.to_hex(), p.opacity, p.spread), ("#ff0000".to_string(), 0.4, 0.25));
assert!(app.ui.dialog.is_none());
}
}