Add blend modes to Gradient tool (#543)

This commit is contained in:
Sean Lavery 2026-10-07 04:43:36 -07:00 committed by GitHub
parent b4f9d56c93
commit 8aecbffb5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 100 additions and 7 deletions

View File

@ -2402,7 +2402,7 @@ fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
let bg = app.session.tools.background;
let _ = app.run(
"paint.gradient",
json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "colors": [hex(fg), hex(bg)], "opacity": o.fill_opacity, "target": paint_target(app)}),
json!({"from": [d.start[0], d.start[1]], "to": [end[0], end[1]], "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "colors": [hex(fg), hex(bg)], "opacity": o.fill_opacity, "mode": o.gradient_blend_mode.label(), "target": paint_target(app)}),
);
}
}

View File

@ -70,7 +70,7 @@ pub enum Outcome {
/// The fields `ui.set` reads. Anything else is rejected before a field is applied, so a typo or
/// a field the method doesn't have can't reply with success while nothing changes (#412).
pub const UI_SET_FIELDS: [&str; 16] = [
pub const UI_SET_FIELDS: [&str; 18] = [
"tool",
"panels",
"dock",
@ -87,6 +87,8 @@ pub const UI_SET_FIELDS: [&str; 16] = [
"brushTab",
"brushesView",
"brushSize",
"gradientBlendMode",
"gradientClassic",
];
fn ok(v: Value) -> Outcome {
@ -180,12 +182,36 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest
if let Some(field) = p.as_object().and_then(|o| o.keys().find(|k| !UI_SET_FIELDS.contains(&k.as_str()))) {
return err(format!("unknown field `{field}` (fields: {})", UI_SET_FIELDS.join(", ")));
}
let gradient_blend = if let Some(value) = p.get("gradientBlendMode") {
let Some(name) = value.as_str() else { return err("gradientBlendMode must be a blend mode name") };
let Some(mode) = photocraft_engine::commands::blend_from_str(name).filter(|m| photocraft_color::BlendMode::LAYER_MODES.contains(m)) else {
return err(format!("unknown gradient blend mode `{name}`"));
};
Some(mode)
} else {
None
};
if let Some(value) = p.get("gradientClassic")
&& !value.is_boolean()
{
return err("gradientClassic must be a boolean");
}
if let Some(t) = s("tool") {
match Tool::from_name(t) {
Some(t) => app.ui.tool = t,
None => return err(format!("unknown tool `{t}`")),
}
}
let gradient_before = app.ui.tool_options.clone();
if let Some(mode) = gradient_blend {
app.ui.tool_options.gradient_blend_mode = mode;
}
if let Some(classic) = p.get("gradientClassic").and_then(Value::as_bool) {
app.ui.tool_options.gradient_classic = classic;
}
if gradient_blend.is_some() {
crate::gradient_ui::options_changed(app, &gradient_before);
}
if let Some(panels) = p.get("panels") {
let mut cur = serde_json::to_value(&app.ui.panels).unwrap_or_default();
if let (Some(c), Some(n)) = (cur.as_object_mut(), panels.as_object()) {
@ -535,6 +561,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
json!({
"window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()},
"tool": app.ui.tool,
"toolOptions": app.ui.tool_options,
"textEdit": app.ui.text_edit,
"layerMenu": app.ui.layer_menu,
"panels": app.ui.panels,
@ -638,6 +665,19 @@ mod tests {
assert_eq!(call(&mut app, &ctx, "ui.set", Value::Null)["ok"], true);
}
#[test]
fn ui_set_gradient_blend_mode_validates_and_updates_options() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
let good = call(&mut app, &ctx, "ui.set", json!({"tool": "gradient", "gradientBlendMode": "Difference", "gradientClassic": true}));
assert_eq!(good["ok"], true, "{good}");
assert_eq!(app.ui.tool_options.gradient_blend_mode, photocraft_color::BlendMode::Difference);
assert!(app.ui.tool_options.gradient_classic);
let bad = call(&mut app, &ctx, "ui.set", json!({"gradientBlendMode": "nonsense", "gradientClassic": false}));
assert_eq!(bad["ok"], false, "{bad}");
assert!(app.ui.tool_options.gradient_classic, "invalid mode must not change options");
}
#[test]
fn ui_set_unknown_theme_error_names_every_theme_and_each_name_works() {
use crate::theme::ThemeKind;

View File

@ -116,7 +116,7 @@ fn edited_fill(app: &PhotocraftApp, layer: &Layer, canvas: Rect32, cmd: &str, p:
/// Options-bar params of a new live gradient.
fn create_params(app: &PhotocraftApp, from: [f32; 2], to: [f32; 2]) -> Value {
let o = &app.ui.tool_options;
json!({"from": from, "to": to, "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "opacity": o.fill_opacity})
json!({"from": from, "to": to, "style": o.gradient_style, "reverse": o.gradient_reverse, "dither": o.gradient_dither, "opacity": o.fill_opacity, "mode": o.gradient_blend_mode.label()})
}
fn dist(a: [f32; 2], b: [f32; 2]) -> f32 {
@ -571,16 +571,20 @@ pub fn preset_swatch(ui: &mut egui::Ui, stops: &[(f32, [f32; 4])]) {
let _ = resp.on_hover_text(tl!("The current gradient (pick one in Window › Gradients)"));
}
/// Live mode: style, reverse and dither changes in the options bar also edit the selected
/// Live mode: style, reverse, dither and blend mode changes in the options bar also edit the selected
/// gradient fill layer (one history step each), as in Photoshop.
pub fn options_changed(app: &mut PhotocraftApp, before: &crate::state::ToolOptions) {
let o = app.ui.tool_options.clone();
if o.gradient_classic
|| (o.gradient_style == before.gradient_style && o.gradient_reverse == before.gradient_reverse && o.gradient_dither == before.gradient_dither)
{
if app.ui.tool != Tool::Gradient || o.gradient_classic {
return;
}
let Some((l, ..)) = active_gradient(app) else { return };
if o.gradient_blend_mode != before.gradient_blend_mode {
let _ = app.run("layer.setProps", json!({"layer": l.id.0, "blend": o.gradient_blend_mode.label()}));
}
if o.gradient_style == before.gradient_style && o.gradient_reverse == before.gradient_reverse && o.gradient_dither == before.gradient_dither {
return;
}
let mut p = json!({"layer": l.id.0});
if o.gradient_style != before.gradient_style {
p["style"] = json!(o.gradient_style);
@ -945,6 +949,48 @@ mod tests {
assert_eq!(app.session.active().unwrap().doc.layers.len(), 1, "no fill layer in classic mode");
}
#[test]
fn gradient_blend_mode_reaches_live_and_classic_drags() {
use photocraft_color::BlendMode;
for mode in [BlendMode::Difference, BlendMode::Multiply, BlendMode::Screen, BlendMode::Exclusion] {
let mut live = app_with_gradient("linear");
live.ui.tool_options.gradient_blend_mode = mode;
drag(&mut live, [20.0, 60.0], [180.0, 60.0]);
let layer = live.session.active().unwrap().doc.layers.last().unwrap();
assert_eq!(layer.blend, mode, "live {mode:?}");
let mut classic = app_with_gradient("linear");
classic.ui.tool_options.gradient_classic = true;
classic.ui.tool_options.gradient_blend_mode = mode;
classic.session.tools.foreground = [1.0, 0.0, 0.0, 1.0];
classic.session.tools.background = [1.0, 0.0, 0.0, 1.0];
drag(&mut classic, [20.0, 60.0], [180.0, 60.0]);
let st = classic.session.active().unwrap();
let px = st.doc.layers[0].surface().unwrap().pixel(60, 60);
let expected = match mode {
BlendMode::Difference | BlendMode::Exclusion => [0.0, 1.0, 1.0],
BlendMode::Multiply => [1.0, 0.0, 0.0],
BlendMode::Screen => [1.0, 1.0, 1.0],
_ => [1.0, 1.0, 1.0],
};
for (actual, want) in px.iter().zip(expected) {
assert!((actual - want).abs() < 0.02, "classic {mode:?}: {px:?}");
}
}
}
#[test]
fn changing_live_gradient_mode_updates_selected_fill_layer() {
use photocraft_color::BlendMode;
let mut app = app_with_gradient("linear");
drag(&mut app, [20.0, 60.0], [180.0, 60.0]);
let before = app.ui.tool_options.clone();
app.ui.tool_options.gradient_blend_mode = BlendMode::Difference;
options_changed(&mut app, &before);
let layer = app.session.active().unwrap().doc.layers.last().unwrap();
assert_eq!(layer.blend, BlendMode::Difference);
}
#[test]
fn preview_shows_the_drag_before_commit() {
let mut app = app_with_gradient("radial");

View File

@ -653,6 +653,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
ui.spacing_mut().item_spacing.x = 8.0;
widgets::vline(ui, 22.0);
opt_label(ui, tl!("Mode"));
let opts: Vec<(BlendMode, &str)> = BlendMode::LAYER_MODES.iter().map(|m| (*m, m.label())).collect();
widgets::dropdown(ui, "gradient-blend-mode", &mut app.ui.tool_options.gradient_blend_mode, &opts, 96.0);
opt_label(ui, tl!("Opacity"));
widgets::value_field(ui, &mut app.ui.tool_options.fill_opacity, 1.0..=100.0, "%", 62.0);
widgets::checkbox(ui, &mut app.ui.tool_options.gradient_reverse, tl!("Reverse"));

View File

@ -368,6 +368,8 @@ pub struct ToolOptions {
/// Gradient tool mode: false = "Gradient" (live: a Gradient Fill layer, editable on canvas),
/// true = "Classic gradient" (paints the pixels).
pub gradient_classic: bool,
/// Blend mode used by both live and classic gradient drags.
pub gradient_blend_mode: photocraft_color::BlendMode,
pub fill_opacity: f32,
/// Paint Bucket fill source: false = Foreground colour, true = Pattern (Patterns panel selection).
pub bucket_fill_pattern: bool,
@ -473,6 +475,7 @@ impl Default for ToolOptions {
gradient_reverse: false,
gradient_dither: true,
gradient_classic: false,
gradient_blend_mode: photocraft_color::BlendMode::Normal,
fill_opacity: 100.0,
bucket_fill_pattern: false,
type_font: "Inter".into(),

View File

@ -37,6 +37,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520)
- `ui.menu.invoke {id, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
- `ui.dialog.open {kind, fields?}` (kinds `newDocument`, `about`, `layerStyle {effect?}`, `colorPicker {target: foreground|background}`, `command {command}`) / `ui.dialog.set {dialog, field, value}` / `ui.dialog.confirm {dialog, wait?}` / `ui.dialog.cancel {dialog}`. Like `ui.menu.invoke`, `ui.dialog.confirm` waits for a background job its command starts (a filter dialog's OK) and replies with the result; `"wait": false` replies `{job, pending: true}` at once
- Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) edits the sections in its `values` field. `ui.dialog.apply {dialog}` saves those values through `prefs.set` and keeps the same dialog and section open. `ui.dialog.confirm` saves and closes; `ui.dialog.cancel` discards only edits made since the last successful Apply. Invalid values return an error without closing the Apply dialog or changing the saved preferences. Settings marked for the next launch still require a restart.
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).