From 6884809d0258567ed771bcfb0bc9cab0f0c090ab Mon Sep 17 00:00:00 2001 From: Zam <48171267+Zam233@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:05:26 +0800 Subject: [PATCH] Gradient Editor window: the Gradient tool's swatch opens a customizable gradient editor (#1983) Rebased onto main as one commit (the PR's net diff); the preset-drop mapping now uses ViewXform::active, which accounts for the display's pixels per point. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/presets/gradients.rs | 145 ++++++++++ crates/engine/src/presets/tests.rs | 112 ++++++++ crates/ui-egui/src/canvas.rs | 9 + crates/ui-egui/src/gradient_ui.rs | 375 ++++++++++++++++++++++--- crates/ui-egui/src/i18n/cs.tsv | 8 +- crates/ui-egui/src/i18n/de.tsv | 7 + crates/ui-egui/src/i18n/el.tsv | 7 + crates/ui-egui/src/i18n/es.tsv | 8 +- crates/ui-egui/src/i18n/fr.tsv | 8 +- crates/ui-egui/src/i18n/id.tsv | 8 +- crates/ui-egui/src/i18n/it.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 8 +- crates/ui-egui/src/i18n/ko.tsv | 7 + crates/ui-egui/src/i18n/nl.tsv | 7 + crates/ui-egui/src/i18n/pl.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/ru.tsv | 9 +- crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 +- crates/ui-egui/src/i18n/zh-hant.tsv | 8 +- crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/src/panels.rs | 7 +- crates/ui-egui/src/preset_panels.rs | 51 ++-- crates/ui-egui/src/state.rs | 6 + crates/ui-egui/src/view_sync_tests.rs | 46 +++ 25 files changed, 810 insertions(+), 66 deletions(-) create mode 100644 crates/ui-egui/src/view_sync_tests.rs diff --git a/crates/engine/src/presets/gradients.rs b/crates/engine/src/presets/gradients.rs index 1165164a0a..475f3b3f62 100644 --- a/crates/engine/src/presets/gradients.rs +++ b/crates/engine/src/presets/gradients.rs @@ -361,6 +361,9 @@ pub type FillStops = (Vec<(f32, Color)>, Vec<(f32, f32)>); const CMD_SELECT: &str = "gradient.presets.select"; +/// The Gradient tool's current gradient, as a one-stop edit target (`gradient.presets.stop`). +pub const STOP: &str = "gradient.presets.stop"; + /// Gradient for the Gradient tool: `"gradient": preset name`, else explicit `"stops"`, else the /// current gradient (Gradients panel selection); explicit `"transparency"` stops replace its /// own. `None` when the caller gave `colors` (the legacy evenly spaced form `paint.gradient` @@ -432,6 +435,138 @@ fn select(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// A preset stop colour: `"#rrggbb"`, `"foreground"` or `"background"`. +fn stop_color_param(v: &Value, cmd: &str) -> Result { + let s = v.as_str().ok_or_else(|| bad(cmd, "a stop colour is \"#rrggbb\", \"foreground\" or \"background\""))?; + match s { + "foreground" => Ok(StopColor::Foreground), + "background" => Ok(StopColor::Background), + h if h.trim_start_matches('#').len() == 6 && h.trim_start_matches('#').chars().all(|c| c.is_ascii_hexdigit()) => Ok(StopColor::Rgb(hex(h))), + o => Err(bad(cmd, format!("bad stop colour `{o}`"))), + } +} + +/// Applies a `gradient.presets.stop` edit to the Gradient tool's current gradient: the +/// `gradient.fill.stop` grammar (add|move|delete|color and the opacity kind) without a `layer`. +/// Midpoints are rejected — presets keep them at the centre; edit a Gradient Fill layer to move +/// them. Pure, so the editor previews a drag with exactly what the command commits. +pub fn apply_preset_stop(g: &GradientPreset, p: &Value, fg: [f32; 4], bg: [f32; 4], cmd: &str) -> Result { + let mut out = g.clone(); + let action = p.get("action").and_then(Value::as_str).ok_or_else(|| bad(cmd, "missing `action` (add|move|delete|color|opacity)"))?; + if action == "midpoint" { + return Err(bad(cmd, "preset gradients keep midpoints at the centre (edit a Gradient Fill layer to move them)")); + } + let opacity_kind = match p.get("kind").and_then(Value::as_str).unwrap_or("color") { + "color" => false, + "opacity" => true, + o => return Err(bad(cmd, format!("unknown kind `{o}` (color|opacity)"))), + }; + let index = || -> Result { + p.get("index").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).ok_or_else(|| bad(cmd, "missing `index` (a stop number from 0)")) + }; + let location = || { + p.get("location") + .and_then(Value::as_f64) + .map(|v| v as f32) + .filter(|v| v.is_finite()) + .map(|v| v.clamp(0.0, 1.0)) + .ok_or_else(|| bad(cmd, "missing `location` (0..1)")) + }; + let ramp = g.resolve(fg, bg); + if opacity_kind { + // Opacity stops: an empty list means "opaque" (two 100 % stops when first edited). + let mut os = if out.opacity.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { out.opacity.clone() }; + os.sort_by(|a, b| a.0.total_cmp(&b.0)); + let opacity = + || p.get("opacity").and_then(Value::as_f64).map(|v| (v as f32 / 100.0).clamp(0.0, 1.0)).ok_or_else(|| bad(cmd, "missing `opacity` (0..100)")); + match action { + "add" => { + let t = location()?; + let a = match p.get("opacity") { + Some(_) => opacity()?, + None => sample(&ramp, t, lerp4).map_or(1.0, |c| c[3]), + }; + os.push((t, a)); + } + "move" => { + let (i, t) = (index()?, location()?); + os.get_mut(i).ok_or_else(|| bad(cmd, format!("no opacity stop {i}")))?.0 = t; + } + "delete" => { + let i = index()?; + if i >= os.len() { + return Err(bad(cmd, format!("no opacity stop {i}"))); + } + if os.len() <= 2 { + return Err(bad(cmd, "a gradient keeps at least 2 opacity stops")); + } + os.remove(i); + } + "opacity" => { + let (i, a) = (index()?, opacity()?); + os.get_mut(i).ok_or_else(|| bad(cmd, format!("no opacity stop {i}")))?.1 = a; + } + o => return Err(bad(cmd, format!("unknown action `{o}` for opacity stops (add|move|delete|opacity)"))), + } + os.sort_by(|a, b| a.0.total_cmp(&b.0)); + if os.len() > 1024 { + return Err(bad(cmd, "too many opacity stops (max 1024)")); + } + // Fully opaque again (the materialized ends left after edits, say): store as no opacity + // stops, like `fill_stops` reads them, instead of pinning explicit 100 % ends. + if os.iter().all(|o| (o.1 - 1.0).abs() < 1e-6) { + os.clear(); + } + out.opacity = os; + return Ok(out); + } + let mut stops = out.stops.clone(); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + match action { + "add" => { + let t = location()?; + // A new stop takes the colour the gradient has there (Photoshop), unless given. + let c = match p.get("color") { + Some(v) => stop_color_param(v, cmd)?, + None => sample(&ramp, t, lerp4).map_or(StopColor::Rgb([0.0, 0.0, 0.0]), |c| StopColor::Rgb([c[0], c[1], c[2]])), + }; + stops.push((t, c)); + if stops.len() > 1024 { + return Err(bad(cmd, "too many colour stops (max 1024)")); + } + } + "move" => { + let (i, t) = (index()?, location()?); + stops.get_mut(i).ok_or_else(|| bad(cmd, format!("no colour stop {i}")))?.0 = t; + } + "delete" => { + let i = index()?; + if i >= stops.len() { + return Err(bad(cmd, format!("no colour stop {i}"))); + } + if stops.len() <= 2 { + return Err(bad(cmd, "a gradient keeps at least 2 colour stops")); + } + stops.remove(i); + } + "color" => { + let (i, c) = (index()?, p.get("color").ok_or_else(|| bad(cmd, "missing `color`")).and_then(|v| stop_color_param(v, cmd))?); + stops.get_mut(i).ok_or_else(|| bad(cmd, format!("no colour stop {i}")))?.1 = c; + } + o => return Err(bad(cmd, format!("unknown action `{o}` (add|move|delete|color)"))), + } + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + out.stops = stops; + Ok(out) +} + +fn stop(s: &mut Session, p: &Value) -> Result { + let g = apply_preset_stop(&s.presets.gradient, p, s.tools.foreground, s.tools.background, STOP)?; + s.presets.gradient = g; + s.presets_changed(); + Ok(json!({"current": s.presets.gradient.to_json()})) +} + fn apply(s: &mut Session, p: &Value) -> Result { const CMD: &str = "gradient.presets.apply"; let g = preset_param(s, p, CMD)?; @@ -513,6 +648,16 @@ pub fn specs() -> Vec { run: select, journal: true, }, + CommandSpec { + id: STOP, + label: "Edit Gradient Stop", + menu: &[], + shortcut: None, + params: r##"{"action":"add|move|delete|color|opacity","kind":"color|opacity"="color","index":stop number,"location":0..1,"color":"#rrggbb"|"foreground"|"background" (add: default the colour there),"opacity":0..100} → {"current"}. Edits the Gradient tool's current gradient (no layer, midpoints stay centred)."##, + enabled: always, + run: stop, + journal: true, + }, CommandSpec { id: "gradient.presets.apply", label: "New Gradient Fill Layer from Preset", diff --git a/crates/engine/src/presets/tests.rs b/crates/engine/src/presets/tests.rs index afbdac3e02..37544ed004 100644 --- a/crates/engine/src/presets/tests.rs +++ b/crates/engine/src/presets/tests.rs @@ -93,6 +93,118 @@ fn foreground_to_transparent_paints_alpha() { assert!((mid - 0.5).abs() < 0.05, "{mid}"); } +/// `gradient.presets.stop` edits the Gradient tool's current gradient in place (the stops the +/// Gradient Editor window shows): a new stop takes the colour under it, a "foreground" stop stays +/// live, opacity stops materialize on first edit, and the tool paints with the edited gradient. +#[test] +fn preset_stop_edits_the_current_gradient() { + let mut s = session(8); + // A new stop mid-way samples the ramp there (black → white: mid gray). + let v = s.execute("gradient.presets.stop", json!({"action": "add", "location": 0.5})).unwrap(); + let stops = v["current"]["stops"].as_array().unwrap(); + assert_eq!(stops.len(), 3, "{v}"); + assert_eq!(stops[1], json!([0.5, "#808080"]), "sorted by location, the ramp colour at 0.5"); + // Recolour it and repaint: the tool gradient changed (mid of the stroke is the new colour). + s.execute("gradient.presets.stop", json!({"action": "color", "index": 1, "color": "#ff0000"})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + let px = layer_px(&s, 32, 10); + assert!(px[0] > 0.9 && px[1] < 0.1, "{px:?}"); + // A "foreground" stop stays live: repaint after changing the foreground picks it up. + s.execute("gradient.presets.stop", json!({"action": "color", "index": 0, "color": "foreground"})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#0000ff"})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + let px = layer_px(&s, 0, 10); + assert!(px[2] > 0.9 && px[0] < 0.1, "{px:?}"); + // Opacity stops materialize the two opaque ends on first edit. + let v = s.execute("gradient.presets.stop", json!({"action": "add", "kind": "opacity", "location": 0.5, "opacity": 40})).unwrap(); + assert_eq!(v["current"]["transparency"], json!([[0.0, 100.0], [0.5, 40.0], [1.0, 100.0]]), "{v}"); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + // Paint on a fresh layer: the background already holds opaque paint to composite onto. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + let mid = layer_px(&s, 32, 10)[3]; + assert!((mid - 0.4).abs() < 0.05, "{mid}"); + // Guard rails: in-range indexes, known actions, two stops minimum, no midpoints. + assert!(s.execute("gradient.presets.stop", json!({"action": "move", "index": 99, "location": 0.5})).is_err()); + assert!(s.execute("gradient.presets.stop", json!({"action": "explode"})).is_err()); + assert!(s.execute("gradient.presets.stop", json!({"action": "delete", "index": 0})).is_ok()); + assert!(s.execute("gradient.presets.stop", json!({"action": "delete", "index": 0})).is_err(), "a gradient keeps at least 2 colour stops"); + assert!(s.execute("gradient.presets.stop", json!({"action": "midpoint", "index": 0, "location": 0.4})).is_err()); +} + +/// A many-stop gradient survives editor gymnastics: five adds build a seven-stop ramp, a stop +/// dragged past its neighbour keeps the list sorted, the paint follows the edited stops, and +/// trimming back down ends at a plain two-stop gradient. +#[test] +fn preset_stop_survives_a_complex_many_stop_ramp() { + let mut s = session(8); + s.execute("gradient.presets.select", json!({"stops": [[0.0, "#000000"], [1.0, "#ffffff"]]})).unwrap(); + for (i, c) in ["#ff0000", "#00ff00", "#0000ff", "#ffff00", "#ff00ff"].iter().enumerate() { + let t = (i + 1) as f64 / 6.0; + s.execute("gradient.presets.stop", json!({"action": "add", "location": t, "color": c})).unwrap(); + } + let v = s.execute("gradient.presets.list", json!({})).unwrap(); + assert_eq!(v["current"]["stops"].as_array().unwrap().len(), 7, "{v}"); + // Drag the yellow stop (index 4, at 4/6) past the magenta one (at 5/6). + s.execute("gradient.presets.stop", json!({"action": "move", "index": 4, "location": 0.95})).unwrap(); + let stops = s.execute("gradient.presets.list", json!({})).unwrap()["current"]["stops"].as_array().unwrap().clone(); + let locs: Vec = stops.iter().map(|s| s[0].as_f64().unwrap()).collect(); + let mut in_order = locs.clone(); + in_order.sort_by(|a, b| a.partial_cmp(b).unwrap()); + assert_eq!(locs, in_order, "stops stay sorted after a crossing move: {stops:?}"); + assert_eq!(stops[6], json!([1.0, "#ffffff"])); + // The paint follows: near the moved stop the stroke is the stop's yellow. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + let px = layer_px(&s, 60, 10); + assert!(px[0] > 0.95 && px[1] > 0.95 && px[2] < 0.3, "{px:?}"); + // Trim everything back down to the two end stops. + for _ in 0..5 { + s.execute("gradient.presets.stop", json!({"action": "delete", "index": 1})).unwrap(); + } + let stops = s.execute("gradient.presets.list", json!({})).unwrap()["current"]["stops"].as_array().unwrap().clone(); + assert_eq!(stops, json!([[0.0, "#000000"], [1.0, "#ffffff"]]).as_array().unwrap().clone(), "{stops:?}"); +} + +/// An opacity hole paints where it is put, follows a move, and once the gradient is fully +/// opaque again the stored opacity stops normalize away (no pinned 100 % ends). +#[test] +fn preset_stop_opacity_hole_moves_and_normalizes() { + let mut s = session(8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("gradient.presets.stop", json!({"action": "add", "kind": "opacity", "location": 0.5, "opacity": 0})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + assert!(layer_px(&s, 0, 5)[3] > 0.95); + assert!(layer_px(&s, 32, 5)[3] < 0.05); + assert!(layer_px(&s, 63, 5)[3] > 0.95); + // The hole follows the moved stop (index 1 is the 0 % stop between the opaque ends). + s.execute("gradient.presets.stop", json!({"action": "move", "kind": "opacity", "index": 1, "location": 0.25})).unwrap(); + // Paint on fresh layers: painting over earlier strokes would composite their alpha. + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + assert!(layer_px(&s, 20, 5)[3] < 0.15, "{:?}", layer_px(&s, 20, 5)); + let far = layer_px(&s, 48, 5)[3]; + assert!(far > 0.55 && far < 0.85, "{far}"); + // Deleting it returns the gradient to fully opaque, stored as no opacity stops at all. + s.execute("gradient.presets.stop", json!({"action": "delete", "kind": "opacity", "index": 1})).unwrap(); + let v = s.execute("gradient.presets.list", json!({})).unwrap(); + assert_eq!(v["current"]["transparency"], json!([]), "{v}"); +} + +/// The edited current gradient saves as a preset, and re-selecting it paints the same pixels. +#[test] +fn edited_preset_saves_and_roundtrips() { + let mut s = session(8); + s.execute("gradient.presets.stop", json!({"action": "add", "location": 0.5, "color": "#ff0000"})).unwrap(); + s.execute("gradient.presets.new", json!({"name": "My Gradient"})).unwrap(); + s.execute("gradient.presets.select", json!({"preset": "Red 01"})).unwrap(); + s.execute("gradient.presets.select", json!({"preset": "My Gradient"})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("paint.gradient", json!({"from": [0, 0], "to": [63, 0]})).unwrap(); + let px = layer_px(&s, 32, 10); + assert!(px[0] > 0.9 && px[1] < 0.1, "{px:?}"); +} + /// Issue #465: explicit `transparency` stops apply with `colors`, a `gradient` preset, explicit /// `stops` and the current gradient alike. #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e07bcf2c05..331818cc75 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -659,6 +659,15 @@ impl ViewXform { } } +/// Document point under a screen position on the main canvas, or `None` when the position is +/// outside it: the rulers are not part of the canvas, so drops on the ruler strip are not +/// canvas drops. Preset-panel drops and drag-and-drop placement share this so both land exactly +/// where the canvas draws. +pub fn doc_point_at(app: &PhotocraftApp, pos: Pos2) -> Option<[f64; 2]> { + let xf = ViewXform::active(app)?; + xf.rect.contains(pos).then(|| xf.to_doc(pos)) +} + /// Fit the document into `area` (egui points) for a display with `ppp` physical pixels per /// point; `View::zoom` is stored in physical pixels per document pixel. pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) { diff --git a/crates/ui-egui/src/gradient_ui.rs b/crates/ui-egui/src/gradient_ui.rs index 65f11e0162..8f8f0bcdad 100644 --- a/crates/ui-egui/src/gradient_ui.rs +++ b/crates/ui-egui/src/gradient_ui.rs @@ -17,6 +17,7 @@ use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use photocraft_compose::gradient_fill as gf; use photocraft_doc::{Document, Fill, GradientStyle, Layer, LayerContent, LayerId}; use photocraft_engine::gradient_fill_cmds as cmds; +use photocraft_engine::presets::gradients as gradient_presets; use serde_json::{Value, json}; use crate::PhotocraftApp; @@ -64,6 +65,9 @@ pub struct LiveGradient { last_click: Option<(Grab, f64)>, /// Pending edit from the Properties panel's stops editor (previewed like a canvas drag). panel: Option<(LayerId, &'static str, Value)>, + /// Pending edit from the Gradient Editor window's current-gradient strip + /// (`gradient.presets.stop`, previewed the same way). + preset_stop: Option, /// Cached preview document by key. preview: Option<(u64, Arc)>, } @@ -568,21 +572,74 @@ pub fn paint_thumbnail(ui: &egui::Ui, layer: LayerId, f: &Fill, rect: Rect) -> b } /// Options-bar swatch of the current gradient. Both live and classic modes use the same -/// engine-owned preset, so its popup reuses the Gradients browser instead of keeping an -/// independent copy of the chosen gradient in the UI (#1651). -pub fn preset_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) { +/// engine-owned preset (#1651). Clicking it opens the Gradient Editor window, as Photoshop's +/// swatch does. Returns whether it was clicked. +pub fn preset_swatch(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> bool { let t = Tokens::get(ui.ctx()); let stops = app.session.presets.gradient.resolve(app.session.tools.foreground, app.session.tools.background); let (r, resp) = ui.allocate_exact_size(vec2(96.0, 20.0), Sense::click()); paint_ramp(ui.painter(), r, |u| photocraft_algo::paint::sample_stops(&stops, u)); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); - let resp = resp.on_hover_text(tl!("The current gradient (pick one in Window › Gradients)")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Gradients"))); - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(260.0); - ui.set_max_width(330.0); - crate::preset_panels::gradients_panel(app, ui); - }); + // Name the control for accessibility (and so tests and agents can find it). + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Gradient picker"))); + resp.on_hover_text(tl!("Click to edit the gradient")).clicked() +} + +/// The selected layer when it is a gradient fill layer. Unlike [`active_gradient`] it does not +/// require the layer to be visible: the editor edits it either way. +fn selected_gradient(app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + let l = st.doc.layer(st.active_layer?)?; + fill_of(l)?; + Some(l.clone()) +} + +/// The Gradient Editor window the options-bar swatch opens, standing in for Photoshop's Gradient +/// Editor. A selected gradient fill layer gets the full editor — the same "Gradient" (stops) and +/// "Gradient Options" sections the Properties panel shows. With no gradient fill layer selected +/// there is nothing to edit yet (a live gradient is created by dragging on the canvas, and a +/// classic one is a preset), so the window says so and offers the Gradients panel. +pub fn editor_window(app: &mut PhotocraftApp, ctx: &egui::Context) { + if !app.ui.panels.gradient_editor { + return; + } + let mut open = true; + let mut show_panel = false; + // Resolved before the closure so `app` is borrowed only once inside it. + let layer = selected_gradient(app); + egui::Window::new(tl!("Gradient Editor")) + .id(egui::Id::new("gradient-editor")) + .open(&mut open) + .collapsible(false) + .resizable(true) + .default_width(330.0) + .show(ctx, |ui| match &layer { + Some(l) => properties(app, ui, l), + None => { + // Photoshop's editor edits the gradient the tool paints with, so with no gradient + // fill layer around the strip edits the current gradient in place. + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("The gradient the Gradient tool paints with")).color(t.text_dim)); + ui.add_space(6.0); + preset_stops_editor(app, ui); + ui.add_space(6.0); + ui.horizontal(|ui| { + if widgets::secondary_button(ui, tl!("Save As Preset"), 0.0) + .on_hover_text(tl!("Adds the current gradient to the Gradients panel")) + .clicked() + { + let _ = app.run("gradient.presets.new", json!({})); + } + show_panel = widgets::secondary_button(ui, tl!("Gradients"), 0.0).clicked(); + }); + } + }); + if show_panel { + let _ = crate::preset_panels::menu(app, "window.panel.gradients", &Value::Null); + } + if !open { + app.ui.panels.gradient_editor = false; + } } /// Live mode: style, reverse, dither and blend mode changes in the options bar also edit the selected @@ -714,22 +771,109 @@ enum Marker { Mid(usize), } +/// What a stops editor instance edits: a Gradient Fill layer (`gradient.fill.stop`) or the +/// Gradient tool's current gradient (`gradient.presets.stop`, the Gradient Editor window's +/// no-layer mode). Presets keep midpoints centred, so midpoint markers are layer-only. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum Sink { + Layer(LayerId), + Preset, +} + +impl Sink { + /// Uniqueness for the drag state. + fn key(self) -> egui::Id { + match self { + Sink::Layer(id) => egui::Id::new(("gradient-stops-drag", id.0)), + Sink::Preset => egui::Id::new(("gradient-stops-drag", "preset")), + } + } + fn cmd(self) -> &'static str { + match self { + Sink::Layer(_) => cmds::STOP, + Sink::Preset => gradient_presets::STOP, + } + } + /// Preset gradients keep midpoints at the centre (they are not stored), so only a layer's + /// editor shows draggable midpoint diamonds. + fn midpoints(self) -> bool { + matches!(self, Sink::Layer(_)) + } + /// The edit params with the layer added (the preset grammar has no `layer`). + fn params(self, mut p: Value) -> Value { + if let Sink::Layer(id) = self { + p["layer"] = json!(id.0); + } + p + } +} + /// Photoshop's Gradient Editor strip: opacity stops above the ramp, colour stops and midpoints /// below. Drag a stop to move it (off the strip to delete it), click above / below to add one, /// double-click a colour stop for the Color Picker. fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { - let Some(f) = fill_of(layer).cloned() else { return }; + if let Some(f) = fill_of(layer).cloned() { + stops_editor_for(app, ui, Sink::Layer(layer.id), f); + } +} + +/// The Gradient Editor window's no-layer mode: the strip edits the Gradient tool's current +/// gradient in place (`gradient.presets.stop`), as Photoshop's editor edits the tool gradient. +fn preset_stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + if let Some(f) = preset_fill(app) { + stops_editor_for(app, ui, Sink::Preset, f); + } +} + +/// The current gradient as a fill for the strip (foreground/background resolved now, the preset's +/// own opacity stops, empty = opaque, midpoints centred). +fn preset_fill(app: &PhotocraftApp) -> Option { + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + let (cs, os) = app.session.presets.gradient.fill_stops(fg, bg); + Some(Fill::Gradient { + stops: cs, + angle: 0.0, + scale: 1.0, + style: GradientStyle::Linear, + reverse: false, + opacity_stops: os, + midpoints: Vec::new(), + offset: (0.0, 0.0), + dither: false, + align: true, + }) +} + +/// Opens the Color Picker on the current gradient's colour stop `i`; OK runs +/// `gradient.presets.stop` (one journal step). +fn preset_stop_color(app: &mut PhotocraftApp, i: usize) { + let Some(f) = preset_fill(app) else { return }; + let Fill::Gradient { stops, .. } = &f else { return }; + let mut sorted = stops.clone(); + sorted.sort_by(|a, b| a.0.total_cmp(&b.0)); + let Some((_, c)) = sorted.get(i) else { return }; + let r = c.to_rgb(); + app.gradient.preset_stop = None; + crate::color_picker_ui::open_for_command(app, "Color Picker (Stop Color)", r, gradient_presets::STOP, json!({"action": "color", "index": i})); +} + +fn stops_editor_for(app: &mut PhotocraftApp, ui: &mut egui::Ui, sink: Sink, f: Fill) { let Fill::Gradient { stops, .. } = &f else { return }; let t = Tokens::get(ui.ctx()); - let id = layer.id; let w = ui.available_width().max(120.0); let (outer, resp) = ui.allocate_exact_size(vec2(w, 58.0), Sense::click_and_drag()); + // Name the strip for accessibility (and so tests and agents can find it). + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Gradient stops"))); let bar = Rect::from_min_max(pos2(outer.left() + 8.0, outer.top() + 16.0), pos2(outer.right() - 8.0, outer.top() + 40.0)); let x_of = |loc: f32| bar.left() + loc.clamp(0.0, 1.0) * bar.width(); let loc_of = |x: f32| ((x - bar.left()) / bar.width().max(1.0)).clamp(0.0, 1.0); // The ramp being shown (with a pending drag's edit applied). - let pending = app.gradient.panel.clone().filter(|(l, ..)| *l == id); - let shown = pending.as_ref().and_then(|(_, cmd, p)| edited_fill(app, layer, Rect32::new(0, 0, 1, 1), cmd, p)).unwrap_or_else(|| f.clone()); + let pending: Option = match sink { + Sink::Layer(id) => app.gradient.panel.clone().filter(|(l, ..)| *l == id).map(|(_, _, p)| p), + Sink::Preset => app.gradient.preset_stop.clone(), + }; + let (fg, bg) = (app.session.tools.foreground, app.session.tools.background); + let shown = pending.as_ref().and_then(|p| cmds::apply_stop(&f, p, fg, bg).ok()).unwrap_or_else(|| f.clone()); if let Some(ramp) = gf::Ramp::new(&shown) { paint_ramp(ui.painter(), bar, |u| ramp.sample(u)); } @@ -741,7 +885,7 @@ fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { let mut sorted = ss.clone(); sorted.sort_by(|a, b| a.0.total_cmp(&b.0)); let ops: Vec<(f32, f32)> = if os.is_empty() { vec![(0.0, 1.0), (1.0, 1.0)] } else { os.clone() }; - let key = egui::Id::new(("gradient-stops-drag", id.0)); + let key = sink.key(); let dragging: Option = ui.data(|d| d.get_temp(key)); // Markers. let p = ui.painter(); @@ -765,12 +909,14 @@ fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { ); p.rect_stroke(body, 1.0, Stroke::new(1.0, if sel { t.accent } else { t.text_dim }), StrokeKind::Outside); } - for i in 0..sorted.len().saturating_sub(1) { - let (a, b) = (sorted[i].0, sorted[i + 1].0); - if b - a > 1e-3 { - let m = mids.get(i).copied().unwrap_or(0.5); - let sel = dragging == Some(Marker::Mid(i)); - diamond(p, pos2(x_of(a + (b - a) * m), bar.bottom() + 6.0), 3.0, if sel { t.accent } else { t.text_faint }, t.text_faint); + if sink.midpoints() { + for i in 0..sorted.len().saturating_sub(1) { + let (a, b) = (sorted[i].0, sorted[i + 1].0); + if b - a > 1e-3 { + let m = mids.get(i).copied().unwrap_or(0.5); + let sel = dragging == Some(Marker::Mid(i)); + diamond(p, pos2(x_of(a + (b - a) * m), bar.bottom() + 6.0), 3.0, if sel { t.accent } else { t.text_faint }, t.text_faint); + } } } for (i, (loc, a)) in ops.iter().enumerate() { @@ -791,6 +937,9 @@ fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { if let Some(i) = sorted.iter().position(|(l, _)| (x_of(*l) - pos.x).abs() <= 6.0 && pos.y >= bar.bottom() + 3.0) { return Some(Marker::Color(i)); } + if !sink.midpoints() { + return None; + } return (0..sorted.len().saturating_sub(1)) .find(|i| { let (a, b) = (sorted[*i].0, sorted[*i + 1].0); @@ -823,16 +972,26 @@ fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { json!({"action": "midpoint", "index": i, "location": ((loc - a) / (b - a).max(1e-4)).clamp(0.05, 0.95)}) } }; - let mut edit = edit; - edit["layer"] = json!(id.0); if resp.dragged() { - app.gradient.panel = Some((id, cmds::STOP, edit)); + match sink { + Sink::Layer(id) => app.gradient.panel = Some((id, cmds::STOP, sink.params(edit))), + Sink::Preset => app.gradient.preset_stop = Some(edit), + } } } if resp.drag_stopped() { ui.data_mut(|d| d.remove::(key)); - if let Some((_, cmd, p)) = app.gradient.panel.take() { - let _ = app.run(cmd, p); + match sink { + Sink::Layer(_) => { + if let Some((_, cmd, p)) = app.gradient.panel.take() { + let _ = app.run(cmd, p); + } + } + Sink::Preset => { + if let Some(p) = app.gradient.preset_stop.take() { + let _ = app.run(sink.cmd(), p); + } + } } return; } @@ -840,7 +999,10 @@ fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { && let Some(pos) = resp.interact_pointer_pos() && let Some(Marker::Color(i)) = hit(pos) { - edit_stop_color(app, id, i); + match sink { + Sink::Layer(id) => edit_stop_color(app, id, i), + Sink::Preset => preset_stop_color(app, i), + } return; } if resp.clicked() @@ -849,11 +1011,11 @@ fn stops_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) { { let loc = loc_of(pos.x); let p = if pos.y < bar.center().y { - json!({"layer": id.0, "action": "add", "kind": "opacity", "location": loc}) + sink.params(json!({"action": "add", "kind": "opacity", "location": loc})) } else { - json!({"layer": id.0, "action": "add", "location": loc}) + sink.params(json!({"action": "add", "location": loc})) }; - let _ = app.run(cmds::STOP, p); + let _ = app.run(sink.cmd(), p); } } @@ -911,21 +1073,31 @@ mod tests { } #[test] - fn options_bar_swatch_opens_the_real_preset_browser_and_selects_presets() { + fn options_bar_swatch_opens_the_editor_and_reaches_the_gradients_panel() { use egui_kittest::{Harness, kittest::Queryable}; let app = app_with_gradient("linear"); - let mut h = Harness::builder().with_size(vec2(440.0, 560.0)).build_ui_state(|ui, app: &mut PhotocraftApp| preset_swatch(app, ui), app); + let mut h = Harness::builder().with_size(vec2(440.0, 560.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if preset_swatch(app, ui) { + app.ui.panels.gradient_editor = true; + } + let ctx = ui.ctx().clone(); + editor_window(app, &ctx); + }, + app, + ); PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro); h.run_steps(2); - let original = h.state().session.presets.gradient.name.clone(); + // The swatch opens the Gradient Editor window (Photoshop's behaviour), not a popup. + h.get_by_label("Gradient picker").click(); + h.run_steps(2); + assert!(h.state().ui.panels.gradient_editor, "the swatch opened the editor window"); + // Quick preset picking lives in the editor: its Gradients button shows the Gradients panel. h.get_by_label("Gradients").click(); h.run_steps(2); - // The popup uses the existing presets UI, not a detached swatch or an editor-only copy. - h.get_by_label("Foreground to Transparent").click(); - h.run_steps(2); - assert_eq!(h.state().session.presets.gradient.name, "Foreground to Transparent"); - assert_ne!(h.state().session.presets.gradient.name, original); - assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1, "choosing a tool preset does not edit document layers"); + assert!(h.state().ui.panels.color, "the Gradients panel opened"); + assert_eq!(h.state().ui.dock_tabs.color, 2, "on the Gradients tab"); + assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 1, "no document layers edited"); } #[test] @@ -1094,4 +1266,129 @@ mod tests { crate::canvas::tool_event(&mut app, ToolEvent::Up { x: 140.0, y: 60.0 }, mods()); assert!(display_doc(&mut app, 0).is_none()); } + + /// Regression: the options bar's gradient swatch could not open anything. In live mode it + /// sensed hover only, so `clicked()` was never true; in classic mode it sensed clicks and + /// promised "Click to edit the gradient" but never read the response. Both now open the + /// Gradient Editor window. + #[test] + fn the_options_bar_swatch_opens_the_gradient_editor_in_both_modes() { + use egui_kittest::kittest::Queryable; + + for classic in [false, true] { + let mut app = app_with_gradient("linear"); + app.ui.tool_options.gradient_classic = classic; + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + crate::panels::options_bar(app, ui); + let ctx = ui.ctx().clone(); + editor_window(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(4); + assert!(!h.state().ui.panels.gradient_editor, "classic={classic}: starts closed"); + h.get_by_label("Gradient picker").click(); + h.run_steps(3); + assert!(h.state().ui.panels.gradient_editor, "classic={classic}: the swatch opened the editor"); + } + } + + /// The editor itself renders in both states: with a gradient fill layer selected it shows the + /// full stops editor, and with none selected it must still draw (and say what to do) instead of + /// opening an empty window or panicking. + #[test] + fn the_editor_window_renders_with_and_without_a_gradient_layer() { + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + // `run_ui` hands back a `FullOutput` whose texture deltas the caller has to consume. + let run = |app: &mut PhotocraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| editor_window(app, ui.ctx())); + out.textures_delta.clear(); + }; + + // Nothing selected. + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.ui.panels.gradient_editor = true; + run(&mut app); + assert!(app.ui.panels.gradient_editor, "the window stays open"); + + // A committed live gradient fill layer: `selected_gradient` finds it, so the full editor + // (the same sections the Properties panel shows) is what gets built. + let mut app = app_with_gradient("linear"); + drag(&mut app, [10.0, 10.0], [150.0, 90.0]); + assert!(selected_gradient(&app).is_some(), "the drag left a gradient fill layer selected"); + app.ui.panels.gradient_editor = true; + run(&mut app); + assert!(app.ui.panels.gradient_editor); + + // Closed: the window draws nothing and leaves the flag alone. + app.ui.panels.gradient_editor = false; + run(&mut app); + assert!(!app.ui.panels.gradient_editor); + } + + /// With no gradient fill layer selected the editor edits the Gradient tool's current gradient + /// in place (Photoshop's editor edits the tool gradient): a click on the strip adds a stop to + /// it instead of only pointing at the Gradients panel's presets. + #[test] + fn the_editor_edits_the_current_gradient_without_a_layer() { + use egui_kittest::kittest::Queryable; + + let app = app_with_gradient("linear"); + let before = app.session.presets.gradient.stops.len(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + editor_window(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + // Open the window once the fonts are bound (the build's first frame runs without them). + h.run_steps(2); + h.state_mut().ui.panels.gradient_editor = true; + h.run_steps(4); + h.get_by_label("Gradient stops").click(); + h.run_steps(3); + assert_eq!(h.state().session.presets.gradient.stops.len(), before + 1, "the click added a stop to the current gradient"); + } + + /// The editor's Save As Preset button files the current gradient into the Gradients panel + /// under a fresh "Custom" name. + #[test] + fn the_editor_saves_the_current_gradient_as_a_preset() { + use egui_kittest::kittest::Queryable; + + let app = app_with_gradient("linear"); + let has_custom = |s: &PhotocraftApp| s.session.presets.gradients.iter().any(|g| g.items.iter().any(|p| p.name == "Custom")); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + let ctx = ui.ctx().clone(); + editor_window(app, &ctx); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.run_steps(2); + h.state_mut().ui.panels.gradient_editor = true; + h.run_steps(4); + assert!(!has_custom(h.state()), "no Custom preset yet"); + h.get_by_label("Save As Preset").click(); + h.run_steps(3); + assert!(has_custom(h.state()), "the button saved the current gradient as a preset"); + } + + /// A hidden gradient fill layer is still editable from the editor (the Properties panel edits + /// it whether or not it is drawn), so the window must not require visibility. + #[test] + fn the_editor_finds_a_hidden_gradient_layer() { + let mut app = app_with_gradient("linear"); + drag(&mut app, [10.0, 10.0], [150.0, 90.0]); + let id = app.session.active().unwrap().active_layer.unwrap(); + app.run("layer.setProps", json!({"layer": id.0, "visible": false})).unwrap(); + assert!(active_gradient(&app).is_none(), "the canvas widget skips a hidden layer"); + assert!(selected_gradient(&app).is_some(), "the editor still edits it"); + } } diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 10ed70b485..e6d0fb0415 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2016,7 +2016,13 @@ Align with layer Zarovnat s vrstvou Reset Alignment Obnovit zarovnání Centre the gradient (offset 0, 0) Vystředit přechod (posun 0, 0) - The current gradient (pick one in Window › Gradients) Aktuální přechod (vyberte v nabídce Okno › Přechody) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Editor přechodů + Gradient picker Výběr přechodu + Save As Preset Uložit jako preset + The gradient the Gradient tool paints with Přechod, který kreslí nástroj Přechod + Adds the current gradient to the Gradients panel Přidá aktuální přechod do panelu Přechody + Gradient stops Zarážky přechodu Select a type layer to edit its character and paragraph settings. Vyberte textovou vrstvu a upravte nastavení znaků a odstavce. Text color Barva textu Type Options Možnosti textu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ecbf70a4ee..dd5a3d2de1 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1988,6 +1988,13 @@ Align with layer An Ebene ausrichten Reset Alignment Ausrichtung zurücksetzen Centre the gradient (offset 0, 0) Verlauf zentrieren (Versatz 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Gradienten-Editor + Gradient picker Verlaufsauswahl + Gradient stops Verlaufsstufen + Save As Preset Als Vorgabe speichern + The gradient the Gradient tool paints with Der Verlauf, mit dem das Verlaufswerkzeug malt + Adds the current gradient to the Gradients panel Fügt den aktuellen Verlauf zum Bedienfeld „Verläufe" hinzu The current gradient (pick one in Window › Gradients) Der aktuelle Verlauf (wähle einen unter Fenster › Verläufe) Select a type layer to edit its character and paragraph settings. Wähle eine Textebene, um ihre Zeichen- und Absatzeinstellungen zu bearbeiten. Text color Textfarbe diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0abb0378fd..f9bac804cf 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1506,6 +1506,13 @@ Align with layer Στοίχιση με το επίπεδο Reset Alignment Επαναφορά στοίχισης Centre the gradient (offset 0, 0) Κεντράρισμα διαβάθμισης (μετατόπιση 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Επεξεργασία διαβάθμισης + Gradient picker Επιλογή διαβάθμισης + Gradient stops Σημεία διαβάθμισης + Save As Preset Αποθήκευση ως προεπιλογή + The gradient the Gradient tool paints with Η διαβάθμιση με την οποία ζωγραφίζει το εργαλείο Διαβάθμιση + Adds the current gradient to the Gradients panel Προσθέτει την τρέχουσα διαβάθμιση στον πίνακα Διαβαθμίσεις The current gradient (pick one in Window › Gradients) Η τρέχουσα διαβάθμιση (επιλέξτε μία από Παράθυρο › Διαβαθμίσεις) Select a type layer to edit its character and paragraph settings. Επιλέξτε επίπεδο κειμένου για επεξεργασία των ρυθμίσεων χαρακτήρων και παραγράφων. Text color Χρώμα κειμένου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 48bb0cf09b..909c190b5e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2000,7 +2000,13 @@ Align with layer Alinear con la capa Reset Alignment Restablecer la alineación Centre the gradient (offset 0, 0) Centrar el degradado (desplazamiento 0, 0) - The current gradient (pick one in Window › Gradients) El degradado actual (elija uno en Ventana › Degradados) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Editor de degradados + Gradient picker Selector de degradados + Save As Preset Guardar como preajuste + The gradient the Gradient tool paints with El degradado con el que pinta la herramienta Degradado + Adds the current gradient to the Gradients panel Añade el degradado actual al panel Degradados + Gradient stops Paradas de degradado Select a type layer to edit its character and paragraph settings. Seleccione una capa de texto para editar sus ajustes de carácter y párrafo. Text color Color del texto Type Options Opciones de texto diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ca2973eada..7b05780eda 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1966,7 +1966,13 @@ Align with layer Aligner sur le calque Reset Alignment Réinitialiser l'alignement Centre the gradient (offset 0, 0) Centrer le dégradé (décalage 0, 0) - The current gradient (pick one in Window › Gradients) Le dégradé actuel (choisissez-en un dans Fenêtre › Dégradés) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Éditeur de dégradé + Gradient picker Sélecteur de dégradé + Save As Preset Enregistrer comme préreglage + The gradient the Gradient tool paints with Le dégradé que l'outil Dégradé applique + Adds the current gradient to the Gradients panel Ajoute le dégradé actuel au panneau Dégradés + Gradient stops Arrêts de dégradé Select a type layer to edit its character and paragraph settings. Sélectionnez un calque de texte pour modifier ses paramètres de caractère et de paragraphe. Text color Couleur du texte Type Options Options de texte diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f640f6c3ca..b1b13921b9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1960,7 +1960,13 @@ Align with layer Sejajarkan dengan layer Reset Alignment Reset Penyelarasan Centre the gradient (offset 0, 0) Tengahkan gradien (offset 0, 0) - The current gradient (pick one in Window › Gradients) Gradien saat ini (pilih di Jendela › Gradien) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Editor Gradien + Gradient picker Pemilih gradien + Save As Preset Simpan sebagai Preset + The gradient the Gradient tool paints with Gradien yang digambar oleh alat Gradien + Adds the current gradient to the Gradients panel Menambahkan gradien saat ini ke panel Gradien + Gradient stops Titik gradien Select a type layer to edit its character and paragraph settings. Pilih layer teks untuk mengedit pengaturan karakter dan paragrafnya. Text color Warna teks Type Options Opsi Teks diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1841404538..2897d11733 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1998,6 +1998,13 @@ Align with layer Allinea con livello Reset Alignment Reimposta allineamento Centre the gradient (offset 0, 0) Centra sfumatura (offset 0,0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Editor sfumatura + Gradient picker Selettore sfumatura + Gradient stops Flag sfumatura + Save As Preset Salva come preimpostazione + The gradient the Gradient tool paints with La sfumatura con cui disegna lo strumento Sfumatura + Adds the current gradient to the Gradients panel Aggiunge la sfumatura corrente al pannello Sfumature The current gradient (pick one in Window › Gradients) Sfumatura corrente (scegline una in Finestra › Sfumature) Select a type layer to edit its character and paragraph settings. Seleziona un livello testo per modificarne impostazioni carattere e paragrafo. Text color Colore testo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 590a05ce7c..a999ef16ea 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2005,7 +2005,13 @@ Align with layer レイヤーに揃える Reset Alignment 位置をリセット Centre the gradient (offset 0, 0) グラデーションを中央に配置(オフセット 0, 0) - The current gradient (pick one in Window › Gradients) 現在のグラデーション(ウィンドウ › グラデーション で選択) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor グラデーションエディター + Gradient picker グラデーションピッカー + Save As Preset プリセットとして保存 + The gradient the Gradient tool paints with グラデーションツールで描画されるグラデーション + Adds the current gradient to the Gradients panel 現在のグラデーションをグラデーション パネルに追加します + Gradient stops グラデーション ストップ Select a type layer to edit its character and paragraph settings. 文字レイヤーを選択すると、文字と段落の設定を編集できます。 Text color 文字色 Type Options 文字オプション diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 417baf47cd..b238c26c83 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1964,6 +1964,13 @@ Centre the gradient (offset 0, 0) 그레이디언트를 중앙에 배치(오프셋 0, 0) The current gradient (pick one in Window › Gradients) 현재 그레이디언트(창 › 그레이디언트에서 선택) Select a type layer to edit its character and paragraph settings. 문자 및 단락 설정을 편집하려면 문자 레이어를 선택하세요. +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor 그라디언트 편집기 + Gradient picker 그라디언트 선택기 + Save As Preset 프리셋으로 저장 + The gradient the Gradient tool paints with 그라디언트 도구가 칠하는 그라디언트입니다 + Adds the current gradient to the Gradients panel 현재 그라디언트를 그라디언트 패널에 추가합니다 + Gradient stops 그라디언트 스톱 Text color 텍스트 색상 Type Options 문자 옵션 Anti-aliasing method 앤티 앨리어스 방식 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 1e6570d506..41658b2b9f 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1978,6 +1978,13 @@ Align with layer Uitlijnen met laag Reset Alignment Uitlijning herstellen Centre the gradient (offset 0, 0) Het verloop centreren (verschuiving 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Verloopeditor + Gradient picker Verloopkiezer + Gradient stops Verloopstops + Save As Preset Opslaan als voorinstelling + The gradient the Gradient tool paints with Het verloop waarmee het gereedschap Verloop tekent + Adds the current gradient to the Gradients panel Voegt het huidige verloop toe aan het paneel Verlopen The current gradient (pick one in Window › Gradients) Het huidige verloop (kies er een via Venster › Verlopen) Select a type layer to edit its character and paragraph settings. Selecteer een tekstlaag om de teken- en alinea-instellingen te bewerken. Text color Tekstkleur diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 2d40894f1b..66d83740b7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1993,6 +1993,13 @@ Align with layer Wyrównaj do warstwy Reset Alignment Resetuj wyrównanie Centre the gradient (offset 0, 0) Wyśrodkuj gradient (przesunięcie 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Edytor gradientu + Gradient picker Wybór gradientu + Gradient stops Punkty gradientu + Save As Preset Zapisz jako preset + The gradient the Gradient tool paints with Gradient, którym maluje narzędzie Gradient + Adds the current gradient to the Gradients panel Dodaje bieżący gradient do panelu Gradienty The current gradient (pick one in Window › Gradients) Bieżący gradient (wybierz w Okno › Gradienty) Select a type layer to edit its character and paragraph settings. Zaznacz warstwę tekstową, aby edytować jej ustawienia znaków i akapitów. Text color Kolor tekstu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ba1ffc9045..b1d2149fe7 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1965,6 +1965,13 @@ Align with layer Alinhar com a camada Reset Alignment Redefinir alinhamento Centre the gradient (offset 0, 0) Centralizar o degradê (deslocamento 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Editor de degradê + Gradient picker Seletor de degradê + Gradient stops Paradas do degradê + Save As Preset Salvar como predefinição + The gradient the Gradient tool paints with O degradê com que a ferramenta Degradê pinta + Adds the current gradient to the Gradients panel Adiciona o degradê atual ao painel Degradês The current gradient (pick one in Window › Gradients) O degradê atual (escolha um em Janela › Degradês) Select a type layer to edit its character and paragraph settings. Selecione uma camada de texto para editar as configurações de caractere e parágrafo. Text color Cor do texto diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 01bb866e99..1c3c6c30d2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2101,7 +2101,14 @@ Outside Снаружи Tetrahedral Тетраэдрическая Texture Each Tip Текстура на каждый отпечаток - The current gradient (pick one in Window › Gradients) Текущий градиент (выберите в Окно › Градиенты) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Редактор градиентов + Gradient picker Выбор градиента + Save As Preset Сохранить как набор + The gradient the Gradient tool paints with Градиент, которым рисует инструмент «Градиент» + Adds the current gradient to the Gradients panel Добавляет текущий градиент на панель «Градиенты» + Gradient stops Точки градиента + Click to edit the gradient Щёлкните, чтобы изменить градиент Tilt X Наклон X Tilt Y Наклон Y Toggle count group visibility Вкл/выкл видимость группы счёта diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5444b92c0f..c9747e7bde 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1901,6 +1901,13 @@ Capture Зберегти стан Center Crop Marks Центральні мітки обрізання Centre the gradient (offset 0, 0) Центрувати градієнт (зсув 0, 0) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor Редактор градієнтів + Gradient picker Вибір градієнта + Gradient stops Точки градієнта + Save As Preset Зберегти як набір + The gradient the Gradient tool paints with Градієнт, яким малює інструмент «Градієнт» + Adds the current gradient to the Gradients panel Додає поточний градієнт на панель «Градіенти» Change Screen Mode (F) Змінити режим екрана (F) Change To: Змінити на: Clear All Очистити все diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3310e0e37e..4ca174ff5c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2058,7 +2058,13 @@ cameraRaw Blending 混合 Align with layer 与图层对齐 Reset Alignment 重置对齐 Centre the gradient (offset 0, 0) 将渐变居中(偏移量 0, 0) - The current gradient (pick one in Window › Gradients) 当前渐变(在“窗口 › 渐变”中选择) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor 渐变编辑器 + Gradient picker 渐变拾色器 + Save As Preset 存储为预设 + The gradient the Gradient tool paints with 渐变工具所使用的渐变 + Adds the current gradient to the Gradients panel 将当前渐变添加到“渐变”面板 + Gradient stops 渐变色标 Select a type layer to edit its character and paragraph settings. 选择文字图层以编辑字符和段落设置。 Text color 文字颜色 Type Options 文字选项 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 289e9d4521..690f3a54a8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1998,7 +1998,13 @@ Align with layer 與圖層對齊 Reset Alignment 重設對齊 Centre the gradient (offset 0, 0) 置中漸層 (位移 0, 0) - The current gradient (pick one in Window › Gradients) 目前的漸層 (請在 視窗 › 漸層 挑選) +# Gradient Editor window, opened from the options bar's gradient swatch. + Gradient Editor 漸層編輯器 + Gradient picker 漸層挑選器 + Save As Preset 儲存為預設 + The gradient the Gradient tool paints with 漸變工具所使用的漸變 + Adds the current gradient to the Gradients panel 將目前漸變新增至「漸層」面板 + Gradient stops 漸層色標 Select a type layer to edit its character and paragraph settings. 選取文字圖層以編輯其字元和段落設定。 Text color 文字顏色 Type Options 文字選項 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index debd142dce..8a8d5deaa9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1270,6 +1270,7 @@ impl eframe::App for PhotocraftApp { panels::properties_window(self, &ctx); brush_panel::window(self, &ctx); preset_panels::windows(self, &ctx); + gradient_ui::editor_window(self, &ctx); type_panels_ui::windows(self, &ctx); analysis_ui::windows(self, &ctx); timeline_ui::windows(self, &ctx); @@ -1770,6 +1771,9 @@ mod marquee_tests; #[cfg(test)] mod caps_lock_tests; +#[cfg(test)] +mod view_sync_tests; + #[cfg(test)] mod stamp_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 800399a87d..1bdd892bf8 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -944,7 +944,12 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if widgets::dropdown(ui, "gradient-mode", &mut classic, &[(false, "Gradient"), (true, "Classic gradient")], 118.0) { app.ui.tool_options.gradient_classic = classic; } - crate::gradient_ui::preset_swatch(app, ui); + // Live "Gradient" (a Gradient Fill layer) or "Classic gradient" (pixels): + // both paint with the same engine-owned preset (#1651), and either swatch + // click opens the Gradient Editor window, as in Photoshop. + if crate::gradient_ui::preset_swatch(app, ui) { + app.ui.panels.gradient_editor = true; + } widgets::vline(ui, 22.0); ui.spacing_mut().item_spacing.x = 2.0; let before = app.ui.tool_options.clone(); diff --git a/crates/ui-egui/src/preset_panels.rs b/crates/ui-egui/src/preset_panels.rs index 9c1a6663ce..17737dec15 100644 --- a/crates/ui-egui/src/preset_panels.rs +++ b/crates/ui-egui/src/preset_panels.rs @@ -511,18 +511,10 @@ fn empty(ui: &mut egui::Ui, s: &str) { ui.label(egui::RichText::new(s).color(t.text_faint).size(11.5)); } -/// Document point under a screen position on the main canvas. -fn doc_point(app: &PhotocraftApp, pos: Pos2) -> Option<[f64; 2]> { - let i = app.session.active_index()?; - let v = app.ui.views.get(i)?; - let xf = crate::canvas::ViewXform { - rect: app.last_canvas_rect, - zoom: v.zoom / app.canvas_ppp(), - center: v.center, - flip: app.ui.view.flip_horizontal, - rotation: v.rotation, - }; - Some(xf.to_doc(pos)) +/// Where drops onto the canvas are accepted: the canvas proper, rulers excluded (a drop on the +/// ruler strip is not a canvas drop). +fn canvas_drop_rect(app: &PhotocraftApp) -> egui::Rect { + crate::rulers::content_rect(app, app.last_canvas_rect) } // ------------------------------------------------------------------ Gradients @@ -540,7 +532,7 @@ pub fn gradients_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .iter() .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }) .collect(); - let canvas = app.last_canvas_rect; + let canvas = canvas_drop_rect(app); let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 260.0); let events = browser(ui, &mut st, "gradients", &groups, Place { canvas, max_h, new_tip: tl!("Create new gradient") }, &mut |ui, r, gi, ii| { @@ -587,7 +579,7 @@ pub fn patterns_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .collect(); let pats: Vec> = app.session.presets.pattern_groups.iter().map(|g| g.items.iter().filter_map(|id| lib.iter().find(|p| &p.id == id).cloned()).collect()).collect(); - let canvas = app.last_canvas_rect; + let canvas = canvas_drop_rect(app); let mut st = std::mem::take(&mut app.ui.presets_ui); let max_h = (ui.available_height() - 40.0).clamp(90.0, 280.0); let ctx = ui.ctx().clone(); @@ -632,7 +624,7 @@ fn float_window( .corner_radius(CornerRadius::same(t.radius_lg as u8)) .shadow(egui::Shadow { offset: [0, 10], blur: 30, spread: 0, color: t.shadow }) .inner_margin(egui::Margin::same(8)); - let canvas = app.last_canvas_rect; + let canvas = canvas_drop_rect(app); let mut close = false; egui::Window::new(title) .id(egui::Id::new(("preset-window", key))) @@ -683,7 +675,7 @@ pub fn styles_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .iter() .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }) .collect(); - let canvas = app.last_canvas_rect; + let canvas = canvas_drop_rect(app); let mut st = std::mem::take(&mut app.ui.presets_ui); let ctx = ui.ctx().clone(); let t = Tokens::get(&ctx); @@ -731,7 +723,7 @@ pub fn shapes_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { .iter() .map(|g| GroupView { name: g.name.clone(), items: g.items.iter().map(|i| ItemView { key: i.name.clone(), name: i.name.clone() }).collect() }) .collect(); - let canvas = app.last_canvas_rect; + let canvas = canvas_drop_rect(app); let mut st = std::mem::take(&mut app.ui.presets_ui); let ctx = ui.ctx().clone(); let t = Tokens::get(&ctx); @@ -763,7 +755,7 @@ pub fn shapes_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { Ev::Drop(k, pos) => { app.ui.presets_ui.custom_shape = k.clone(); let mut p = json!({"preset": k, "fill": fill}); - if let (Some(c), Some(d)) = (doc_point(app, *pos), app.session.active()) { + if let (Some(c), Some(d)) = (crate::canvas::doc_point_at(app, *pos), app.session.active()) { let side = (d.doc.size.width.min(d.doc.size.height) as f64 * 0.3).max(8.0); p["rect"] = json!([c[0] - side / 2.0, c[1] - side / 2.0, side, side]); } @@ -1171,6 +1163,29 @@ mod tests { assert!(matches!(l.content, photocraft_doc::LayerContent::Shape(_))); } + /// Regression: preset drops mapped the pointer through the raw widget rect, ignoring the + /// rulers' 16 px inset — a dropped shape landed 8 screen px from the pointer, and a drop on + /// the ruler strip counted as a canvas drop. Drops now map through, and target, the same + /// content rect the canvas draws with. + #[test] + fn preset_drops_map_through_the_ruler_inset() { + let (mut app, _) = app(); + app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + app.ui.views[0] = crate::state::View { zoom: 2.0, center: [32.0, 24.0], fit_pending: false, fill_pending: false, doc_size: [64, 48], rotation: 0.0 }; + let p = egui::pos2(400.0, 300.0); + let off = crate::canvas::doc_point_at(&app, p).unwrap(); + assert_eq!(off, [32.0, 24.0], "without rulers the widget rect is the whole mapping"); + // With rulers the content rect shrinks by the inset on the left and top: the same screen + // point is 8 screen px (4 document px at zoom 2) up-left of where it was. + app.ui.extras.rulers = true; + let on = crate::canvas::doc_point_at(&app, p).unwrap(); + assert!((on[0] - (off[0] - 4.0)).abs() < 1e-3, "{on:?} vs {off:?}"); + assert!((on[1] - (off[1] - 4.0)).abs() < 1e-3, "{on:?} vs {off:?}"); + // The ruler strip is no longer a drop target; the canvas proper still is. + assert!(!canvas_drop_rect(&app).contains(egui::pos2(5.0, 300.0))); + assert!(canvas_drop_rect(&app).contains(p)); + } + #[test] fn clone_marker_follows_the_engine_slot() { let (mut app, _) = app(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5b8e4a162a..c5d8b02002 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -336,6 +336,11 @@ pub struct Panels { /// What Tab hid (toolbar, options bar, dock), so a second Tab brings back just those. #[serde(default, skip_serializing_if = "Option::is_none")] pub hidden_by_tab: Option<[bool; 3]>, + /// The Gradient tool's options-bar swatch opened the Gradient Editor window (`gradient_ui`). + /// Photoshop opens its Gradient Editor from that swatch; this is the same idea, drawn as a + /// floating window rather than a modal so the canvas stays usable while a gradient is edited. + #[serde(default)] + pub gradient_editor: bool, } impl Default for Panels { @@ -354,6 +359,7 @@ impl Default for Panels { toolbar_double: false, dock: true, hidden_by_tab: None, + gradient_editor: false, } } } diff --git a/crates/ui-egui/src/view_sync_tests.rs b/crates/ui-egui/src/view_sync_tests.rs new file mode 100644 index 0000000000..0cae764e62 --- /dev/null +++ b/crates/ui-egui/src/view_sync_tests.rs @@ -0,0 +1,46 @@ +//! Views follow their document, not their slot: closing a tab must not hand its zoom or centre +//! to the document that shifts into its place. + +use serde_json::json; + +use crate::PhotocraftApp; + +fn app_with_two_documents() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 48})).unwrap(); + app.run("file.new", json!({"width": 32, "height": 32})).unwrap(); + assert_eq!(app.ui.views.len(), 2); + // The first document is zoomed in; the second keeps its own default view. + app.ui.views[0].zoom = 3.2; + app.ui.views[0].fit_pending = false; + app +} + +/// Regression: views lived next to the documents by index alone, so closing a non-last tab +/// shifted the surviving document into the closed one's slot and it inherited that view — open a +/// 3200 % document, close it, and the next one came up at 3200 %. Views are matched by document +/// id now, so the survivor keeps its own (default) view. +#[test] +fn closing_a_tab_keeps_the_next_documents_own_view() { + let mut app = app_with_two_documents(); + app.run("file.close", json!({"document": 0})).unwrap(); + assert_eq!(app.session.documents().len(), 1); + assert_eq!(app.ui.views.len(), 1); + assert_eq!(app.ui.views[0].zoom, 1.0, "the survivor must not inherit the closed document's zoom"); +} + +/// Opening and closing documents in any order keeps one view per document, and re-showing a +/// document (undo of a close is out of scope, but reopening by path is not) starts fresh rather +/// than claiming another document's view. +#[test] +fn every_document_keeps_its_own_view_through_syncs() { + let mut app = app_with_two_documents(); + app.ui.views[1].zoom = 0.5; + // A third document joins at the end, then the middle one closes: the third keeps its zoom. + app.run("file.new", json!({"width": 16, "height": 16})).unwrap(); + app.ui.views[2].zoom = 4.0; + app.run("file.close", json!({"document": 1})).unwrap(); + assert_eq!(app.ui.views.len(), 2); + assert_eq!(app.ui.views[0].zoom, 3.2, "the first document's view is untouched"); + assert_eq!(app.ui.views[1].zoom, 4.0, "the last document's view follows it to its new index"); +}