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 <noreply@anthropic.com>
This commit is contained in:
ZamandClaude Opus 5.5 authored and GitHub committed 2026-10-09 20:05:26 -04:00
1 parent d502c5de27
commit 6884809d02
25 files changed
+810 -66

No files matched your search

+145
View File
@@ -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<Value> {
Ok(out)
}
/// A preset stop colour: `"#rrggbb"`, `"foreground"` or `"background"`.
fn stop_color_param(v: &Value, cmd: &str) -> Result<StopColor> {
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<GradientPreset> {
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<usize> {
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<Value> {
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<Value> {
const CMD: &str = "gradient.presets.apply";
let g = preset_param(s, p, CMD)?;
@@ -513,6 +648,16 @@ pub fn specs() -> Vec<CommandSpec> {
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",
+112
View File
@@ -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<f64> = 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]
+9
View File
@@ -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) {
+336 -39
View File
@@ -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<Value>,
/// Cached preview document by key.
preview: Option<(u64, Arc<Document>)>,
}
@@ -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<Layer> {
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<Fill> {
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<Value> = 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<Marker> = 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::<Marker>(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");
}
}
+7 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 Χρώμα κειμένου
Can't render this file because it contains an unexpected character in line 965 and column 18.
+7 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+7 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7 -1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+7
View File
@@ -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
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
+7 -1
View File
@@ -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 文字オプション
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 앤티 앨리어스 방식
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
1964
1965
1966 # Gradient Editor window, opened from the options bar's gradient swatch.
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8 -1
View File
@@ -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 Вкл/выкл видимость группы счёта
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 Очистити все
Can't render this file because it contains an unexpected character in line 1306 and column 18.
+7 -1
View File
@@ -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 文字选项
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2058
2059
2060 # Gradient Editor window, opened from the options bar's gradient swatch.
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
+7 -1
View File
@@ -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 文字選項
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -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;
+6 -1
View File
@@ -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();
+33 -18
View File
@@ -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<Vec<photocraft_doc::Pattern>> =
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();
+6
View File
@@ -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,
}
}
}
+46
View File
@@ -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");
}