mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 14:12:44 +00:00
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:
1 parent
d502c5de27
commit
6884809d02
25 files changed
+810
-66
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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 앤티 앨리어스 방식
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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 文字选项
|
||||
|
||||
|
@@ -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.
|
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user