Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102) (#3106)

* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102)

- blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping:
  the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient
  tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the
  Background is left alone). One undo step per press.
- panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the
  "Effects" row clears the whole style (layer.layerStyle.clear).
- engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step);
  bad indices and layers return Err.
- scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already
  on main via #1961, #965).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102)

On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+`
types `*`, which egui has no key for, so egui-winit reports the physical key:
`]` on German. That matched Shift+] (Increase Brush Hardness) before the blend
keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`.

shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal
to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-`
before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps
raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-11 19:45:42 -04:00
1 parent 886372812d
commit 4ab61338ca
8 files changed
+317 -9

No files matched your search

+45 -1
View File
@@ -836,9 +836,12 @@ pub fn specs() -> Vec<CommandSpec> {
label: "Clear Layer Style",
menu: &["Layer", "Layer Style"],
shortcut: None,
params: r##"{"layer":id? (no layer: every selected layer that has a style)}"##,
params: r##"{"layer":id? (no layer: every selected layer that has a style),"effect":index? (only that effect of "layer", as an effect row dropped on the Layers panel's Delete button)}"##,
enabled: has_layer,
run: |s, p| {
if let Some(effect) = p.get("effect").filter(|v| !v.is_null()) {
return delete_effect(s, p, effect);
}
let ids = crate::extra_cmds::style_targets(s, p, |l| !l.effects.items.is_empty())?;
s.edit("Clear Layer Style", |doc, _| {
for id in ids {
@@ -854,6 +857,30 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// `layer.layerStyle.clear` with an `effect` index: deletes that one effect of `layer` (the active
/// layer without one), as Photoshop does for an effect row dropped on the Delete button.
fn delete_effect(s: &mut Session, p: &Value, effect: &Value) -> Result<Value> {
const CMD: &str = "layer.layerStyle.clear";
let index = effect.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(CMD, "`effect` must be an effect index"))?;
let d = s.active().ok_or(EngineError::NoDocument)?;
let id = match p.get("layer").filter(|v| !v.is_null()) {
Some(v) => photocraft_doc::LayerId(v.as_u64().ok_or_else(|| bad(CMD, "`layer` must be a layer id"))?),
None => d.active_layer.ok_or_else(|| bad(CMD, "no active layer"))?,
};
let count = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.effects.items.len();
if index >= count {
return Err(bad(CMD, format!("the layer has {count} effect(s), no effect {index}")));
}
s.edit("Delete Effect", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if index < l.effects.items.len() {
l.effects.items.remove(index);
}
Ok(())
})?;
Ok(Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -946,6 +973,23 @@ mod tests {
assert_eq!(effects(&s).len(), 1);
}
/// #3102: an effect row dropped on the Delete button deletes only that effect, in one step.
#[test]
fn clear_one_effect_by_index() {
let mut s = session();
s.execute("layer.layerStyle.dropShadow", json!({})).unwrap();
s.execute("layer.layerStyle.colorOverlay", json!({"color": "#ff0000"})).unwrap();
let layer = s.active().unwrap().active_layer.unwrap().0;
s.execute("layer.layerStyle.clear", json!({"layer": layer, "effect": 0})).unwrap();
assert!(matches!(effects(&s)[..], [Effect::ColorOverlay { .. }]));
for bad in [json!({"effect": 5}), json!({"effect": -1}), json!({"effect": "x"}), json!({"effect": 0, "layer": 999_999})] {
assert!(s.execute("layer.layerStyle.clear", bad).is_err());
}
assert_eq!(effects(&s).len(), 1, "bad params change nothing");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(effects(&s).len(), 2);
}
#[test]
fn color_overlay_renders() {
let mut s = Session::new();
+183
View File
@@ -0,0 +1,183 @@
//! ⇧+ and ⇧− step through the blend modes, as in Photoshop (#3102): the Brush's or Pencil's mode
//! while one of them is active, the Gradient's with the Gradient tool, and otherwise (Move, the
//! selection tools…) the active layer's. The list wraps at either end. Each press is one call of
//! an engine command (`tools.setBrush`, `layer.setProps`), so it records and undoes like a pick
//! from the dropdown.
use egui::{Event, Key};
use photocraft_color::BlendMode;
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::Tool;
/// The mode `step` places after (or before, when negative) `current` in `modes`, wrapping around.
/// A mode that isn't listed steps from the top of the list.
pub fn stepped(modes: &[BlendMode], current: BlendMode, step: i32) -> Option<BlendMode> {
let n = i32::try_from(modes.len()).ok().filter(|n| *n > 0)?;
let at = modes.iter().position(|m| *m == current).and_then(|i| i32::try_from(i).ok());
let next = match at {
Some(i) => (i + step).rem_euclid(n),
None if step > 0 => 0,
None => n - 1,
};
modes.get(usize::try_from(next).ok()?).copied()
}
/// Consumes this frame's ⇧+ / ⇧− presses (⇧= on a US layout is `+`; on a German one
/// `shortcuts::layout_symbols` has already turned ⇧+ and ⇧- into `+` and `-`) and applies them.
pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
let steps: Vec<i32> = ctx.input_mut(|i| {
let mut out = Vec::new();
i.events.retain(|e| match e {
Event::Key { key, pressed: true, modifiers, .. } if modifiers.shift && !modifiers.command && !modifiers.ctrl && !modifiers.alt => {
let step = match key {
Key::Plus | Key::Equals => 1,
Key::Minus => -1,
_ => return true,
};
out.push(step);
false
}
_ => true,
});
out
});
for step in steps {
press(app, step);
}
}
/// One ⇧+ (`step` 1) or ⇧− (`step` -1) press.
pub fn press(app: &mut PhotocraftApp, step: i32) {
match app.ui.tool {
Tool::Brush | Tool::Pencil => {
let current = app.session.tools.brush.mode;
if let Some(mode) = stepped(&BlendMode::LAYER_MODES, current, step)
&& let Ok(mode) = serde_json::to_value(mode)
{
let _ = app.run("tools.setBrush", json!({ "brush": { "mode": mode } }));
}
}
Tool::Gradient => {
let before = app.ui.tool_options.clone();
if let Some(mode) = stepped(&BlendMode::LAYER_MODES, before.gradient_blend_mode, step) {
app.ui.tool_options.gradient_blend_mode = mode;
// Live mode: a selected gradient fill layer follows, as with the dropdown.
crate::gradient_ui::options_changed(app, &before);
}
}
// Their blend mode isn't the brush's (the Eraser has none): leave them alone.
t if t.is_brushlike() || t == Tool::MagicEraser || t == Tool::PaintBucket => {}
_ => {
let Some(st) = app.session.active() else { return };
let Some(layer) = st.active_layer.and_then(|id| st.doc.layer(id)) else { return };
// The locked Background has no blend mode.
if crate::doc_props_ui::is_background(&st.doc, layer) {
return;
}
let modes: Vec<BlendMode> = std::iter::once(BlendMode::PassThrough).filter(|_| layer.is_group()).chain(BlendMode::LAYER_MODES).collect();
if let Some(mode) = stepped(&modes, layer.blend, step) {
let id = layer.id.0;
let _ = app.run("layer.setProps", json!({ "layer": id, "blend": mode.label() }));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn app(tool: Tool) -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.ui.tool = tool;
app
}
fn layer_blend(app: &PhotocraftApp) -> BlendMode {
let st = app.session.active().unwrap();
st.doc.layer(st.active_layer.unwrap()).unwrap().blend
}
/// Feeds one key press with `modifiers` through the app's shortcut handling.
fn key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: egui::Modifiers) {
let raw = egui::RawInput {
events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }],
..Default::default()
};
ctx.begin_pass(raw);
crate::shortcuts::handle(app, ctx);
ctx.end_pass().textures_delta.clear();
}
/// #3102: on a German layout ⇧+ types `*`, which egui reports as the physical `]`, and ⇧- types
/// `_` as the physical `/`. With the Brush they step its mode, not its hardness; a US ⇧] (`}`)
/// still raises the hardness.
#[test]
fn german_shift_plus_and_minus_step_the_mode_and_us_shift_bracket_keeps_the_hardness() {
let ctx = egui::Context::default();
let mut a = app(Tool::Brush);
a.session.tools.brush.hardness = 0.5;
let shift = egui::Modifiers::SHIFT;
let typed = |a: &mut PhotocraftApp, key: Key, physical_key: Key, text: &str| {
let press = egui::Event::Key { key, physical_key: Some(physical_key), pressed: true, repeat: false, modifiers: shift };
let raw = egui::RawInput { events: vec![egui::Event::ModifiersChanged(shift), press, egui::Event::Text(text.into())], ..Default::default() };
ctx.begin_pass(raw);
crate::shortcuts::handle(a, &ctx);
ctx.end_pass().textures_delta.clear();
};
typed(&mut a, Key::CloseBracket, Key::CloseBracket, "*");
assert_eq!(a.session.tools.brush.mode, BlendMode::Dissolve);
assert_eq!(a.session.tools.brush.hardness, 0.5, "not Increase Brush Hardness");
typed(&mut a, Key::Slash, Key::Slash, "_");
assert_eq!(a.session.tools.brush.mode, BlendMode::Normal);
// US: ⇧] arrives as `}` from the physical `]`.
typed(&mut a, Key::CloseCurlyBracket, Key::CloseBracket, "}");
assert!(a.session.tools.brush.hardness > 0.5);
assert_eq!(a.session.tools.brush.mode, BlendMode::Normal);
}
#[test]
fn stepping_wraps_at_both_ends() {
let m = BlendMode::LAYER_MODES;
assert_eq!(stepped(&m, BlendMode::Normal, 1), Some(BlendMode::Dissolve));
assert_eq!(stepped(&m, BlendMode::Normal, -1), m.last().copied());
assert_eq!(stepped(&m, m[m.len() - 1], 1), Some(BlendMode::Normal));
assert_eq!(stepped(&[], BlendMode::Normal, 1), None);
}
/// #3102: with the Move tool ⇧+ / ⇧− step the active layer's mode, one undo step each; with
/// the Brush they step the brush's mode and leave the layer alone.
#[test]
fn shift_plus_and_minus_step_the_layer_or_the_brush_mode() {
let ctx = egui::Context::default();
let mut a = app(Tool::Move);
let undo = a.session.active().unwrap().history.past_len();
key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT);
assert_eq!(layer_blend(&a), BlendMode::Dissolve);
key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT);
assert_eq!(layer_blend(&a), BlendMode::Darken);
key(&mut a, &ctx, Key::Minus, egui::Modifiers::SHIFT);
assert_eq!(layer_blend(&a), BlendMode::Dissolve);
assert_eq!(a.session.active().unwrap().history.past_len(), undo + 3, "one undo step per press");
// Unshifted or ⌘ presses are not blend-mode keys.
key(&mut a, &ctx, Key::Minus, egui::Modifiers::NONE);
key(&mut a, &ctx, Key::Plus, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT);
assert_eq!(layer_blend(&a), BlendMode::Dissolve);
a.ui.tool = Tool::Brush;
key(&mut a, &ctx, Key::Minus, egui::Modifiers::SHIFT);
assert_eq!(a.session.tools.brush.mode, BlendMode::LAYER_MODES[BlendMode::LAYER_MODES.len() - 1]);
assert_eq!(layer_blend(&a), BlendMode::Dissolve, "the layer is untouched");
// The Background keeps Normal; no document is a no-op.
a.ui.tool = Tool::Move;
let bg = a.session.active().unwrap().doc.layers[0].id;
a.run("layer.select", json!({"layer": bg.0})).unwrap();
key(&mut a, &ctx, Key::Plus, egui::Modifiers::SHIFT);
assert_eq!(a.session.active().unwrap().doc.layers[0].blend, BlendMode::Normal);
let mut none = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
press(&mut none, 1);
}
}
+24
View File
@@ -85,6 +85,30 @@ fn dragging_effects_onto_a_layer_moves_them_and_alt_copies() {
assert!(row(&h, b).row.top() < row(&h, a).row.top());
}
/// The Layers panel footer's Delete button.
fn trash_button(h: &Harness<'_, PhotocraftApp>) -> Pos2 {
h.query_all_by_role(Role::Button).find(|n| n.accesskit_node().label().as_deref() == Some("Delete layer")).unwrap().rect().center()
}
/// #3102: an effect row dropped on the footer's Delete button deletes that effect, the "Effects"
/// row all of them; the layer stays.
#[test]
fn dropping_effects_on_the_trash_deletes_them() {
let (mut h, a, _) = harness();
let layers = h.state().session.active().unwrap().doc.layers.len();
let steps = h.state().session.active().unwrap().history.past_len();
let from = effect_row(&h, a, "Drop Shadow").center();
let trash = trash_button(&h);
drag(&mut h, from, trash, Modifiers::NONE);
assert_eq!(effects(&h, a), ["Color Overlay"]);
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "one history step");
let from = effect_row(&h, a, "Effects").center();
let trash = trash_button(&h);
drag(&mut h, from, trash, Modifiers::NONE);
assert!(effects(&h, a).is_empty());
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers, "no layer was deleted");
}
/// #2846: right-clicking an effect row opens Photoshop's layer style menu for that layer; Create
/// Layer turns its effects into layers of their own.
#[test]
+1
View File
@@ -24,6 +24,7 @@ pub mod analysis_ui;
pub mod anchor_tools;
pub mod artboard_tool;
pub mod artboard_ui;
mod blend_keys;
pub(crate) mod blend_preview;
mod brand;
pub mod brush_panel;
+23 -4
View File
@@ -2268,6 +2268,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let in_selection = selection.iter().any(|s| s.0 == id);
(id, in_selection)
});
// An effect row can be dropped on Delete too (#3102).
let fx_footer_drag = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key()));
fx_drag_feedback(&ctx, &doc);
// End any layer drag after every row has had a chance to accept the drop.
if ctx.input(|i| i.pointer.any_released()) {
@@ -2291,6 +2293,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
actions.push((delete.into(), params));
}
actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete"));
actions.extend(fx_trash_drop(ui, &trash, fx_footer_drag));
// A click follows the selected thumbnail; name that action for screen readers even
// though dropping a whole layer row onto the same button always deletes the layer.
trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label)));
@@ -2403,16 +2406,32 @@ fn footer_drop(ui: &egui::Ui, button: &egui::Response, drag: Option<(u64, bool)>
// Named for screen readers (and tests) after the command a drop runs.
button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, footer_label(command)));
let (layer, in_selection) = drag?;
let p = ui.ctx().input(|i| i.pointer.interact_pos())?;
dropped_on(ui, button).then(|| (command.into(), if in_selection { json!({}) } else { json!({"layer": layer}) }))
}
/// While something is dragged over a footer `button`: highlights it, and is true on the release.
fn dropped_on(ui: &egui::Ui, button: &egui::Response) -> bool {
let Some(p) = ui.ctx().input(|i| i.pointer.interact_pos()) else { return false };
if !button.rect.contains(p) {
return None;
return false;
}
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(button.rect, t.radius_sm, Stroke::new(2.0, t.accent), StrokeKind::Inside);
if !ui.ctx().input(|i| i.pointer.any_released()) {
ui.ctx().input(|i| i.pointer.any_released())
}
/// Effects dragged onto the footer's Delete button, as in Photoshop (#3102): the "Effects" row
/// clears the layer style, an effect's row deletes just that effect.
fn fx_trash_drop(ui: &egui::Ui, button: &egui::Response, drag: Option<FxDrag>) -> Option<(String, Value)> {
let (layer, effect) = drag?;
if !dropped_on(ui, button) {
return None;
}
Some((command.into(), if in_selection { json!({}) } else { json!({"layer": layer}) }))
let params = match effect {
Some(i) => json!({"layer": layer, "effect": i}),
None => json!({"layer": layer}),
};
Some(("layer.layerStyle.clear".into(), params))
}
fn footer_label(command: &str) -> &'static str {
+37
View File
@@ -147,6 +147,40 @@ pub fn key_matches(sc: &KeyboardShortcut, key: Key, mods: Modifiers) -> bool {
mods.alt == sc.modifiers.alt && shift_ok && mods.cmd_ctrl_matches(sc.modifiers)
}
/// Gives a ⇧ press the key its layout's unshifted symbol stands for, where egui can't name what it
/// typed (#3102). On layouts with a key of its own for `+` (German, Italian, Spanish…) ⇧+ types
/// `*`, which egui has no key for, so it reports the physical key instead: `]` on German, which
/// would be ⇧] (Increase Brush Hardness). ⇧- types `_` and arrives as the physical `/`. Such
/// presses become `+` and `-`, as ⇧= arrives as `+` on a US layout, so ⇧+ / ⇧− step the blend
/// mode on every layout. A US ⇧] still arrives as `}` and ⇧8 (`*` too) as a digit.
///
/// What was typed comes as the text event next to the key press: right after it natively, right
/// before it on the web.
fn layout_symbols(ctx: &egui::Context) {
use egui::Event;
ctx.input_mut(|i| {
for at in 0..i.events.len() {
let text_at = if cfg!(target_arch = "wasm32") { at.checked_sub(1) } else { at.checked_add(1) };
let symbol = match text_at.and_then(|t| i.events.get(t)) {
Some(Event::Text(t)) if t == "*" => Key::Plus,
Some(Event::Text(t)) if t == "_" => Key::Minus,
_ => continue,
};
if let Some(Event::Key { key, physical_key, pressed: true, modifiers, .. }) = i.events.get_mut(at)
&& modifiers.shift
&& !modifiers.command
&& !modifiers.ctrl
&& !modifiers.alt
// egui fell back to the physical key: it had no name for the typed symbol.
&& *physical_key == Some(*key)
&& !matches!(key, Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9)
{
*key = symbol;
}
}
});
}
/// Consume a press of `sc` (see [`key_matches`]); true when one was found.
pub fn consume(ctx: &egui::Context, sc: &KeyboardShortcut) -> bool {
ctx.input_mut(|i| {
@@ -351,6 +385,7 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
dispatch_pressed(app, ctx, focus, false);
return;
}
layout_symbols(ctx);
// Liquify / Puppet Warp / Perspective Warp: ↩ commits, Esc cancels.
if crate::distort_ui::keys(app, ctx) {
return;
@@ -545,6 +580,8 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
}
// 1–0 set opacity, ⇧1–0 flow or fill (`opacity_keys`).
crate::opacity_keys::handle(app, ctx);
// ⇧+ / ⇧− step the blend mode (`blend_keys`).
crate::blend_keys::handle(app, ctx);
}
#[cfg(test)]
+2 -2
View File
@@ -9,7 +9,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 6 | 13 | 8 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 27 | 14 | 2 | |
| [Workspace and UI](#workspace-and-ui) | 10 | 7 | 6 | settings that do nothing: 30 of 154 (target 0) |
| [Workspace and UI](#workspace-and-ui) | 11 | 6 | 6 | settings that do nothing: 30 of 154 (target 0) |
| [Type](#type) | 4 | 2 | 3 | |
| [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) |
| [Reliability](#reliability) | 5 | 2 | 0 | never-crash lints on 28 of 28 crates; panic_hunt present |
@@ -195,7 +195,7 @@ Unread: `general.color_picker`, `general.animated_zoom`, `general.zoom_resizes_w
| UI-217-11 | Soft dark and soft light themes with picker previews; outlined knobs in light themes | missing | [#217](https://github.com/storytold/photocraft/issues/217) | theme.rs ThemeKind has twelve themes (the four Pro brightness levels, Studio and Studio Light, Classic, Adwaita and Adwaita Dark, Solarized Dark, Breeze Light and Breeze Dark); no Soft dark or Soft light, and no outlined knobs in the light themes |
| UI-217-12 | Save custom New Document presets; a real Recent category | partial | [#217](https://github.com/storytold/photocraft/issues/217) | document_preset_cmds.rs and new_doc_ui.rs: named presets save/select/delete through preferences; Recent remains a static list plus Clipboard |
| UI-217-13 | Numeric fields accept arithmetic and units; scrubby labels; double-click resets a slider | done | [#217](https://github.com/storytold/photocraft/issues/217) | widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) converted at the document resolution (#3099); widgets::scrub_label makes options-bar, slider-row and Character labels scrubby (Shift x10, Alt fine); slider rows with a known default reset on double-click |
| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | partial | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); trash is click-only, no drop on New/Trash, no checker behind art |
| UI-217-14 | Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art | done | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102) |
| UI-217-15 | Curves: smoothing / pencil mode, black/grey/white eyedroppers, tooltips | partial | [#217](https://github.com/storytold/photocraft/issues/217) | adjust_dialog.rs adds black/gray/white eyedroppers; smoothing and pencil modes remain missing |
| UI-217-16 | Hue/Saturation: Saturation and Lightness sliders get hue-following colour tracks | done | [#217](https://github.com/storytold/photocraft/issues/217) | saturation_track/lightness_track (adjust_editors.rs): grey -> the hue and black -> the hue -> white, recolouring as the Hue slider moves; saturation_and_lightness_tracks_follow_the_hue |
| UI-217-17 | Color panel: wheel plus RGB/HSB/CMYK/Lab/Gray/Web slider modes; resizes with its group | missing | [#217](https://github.com/storytold/photocraft/issues/217) | panels.rs:1837 single field + hex/RGB readout |
+2 -2
View File
@@ -99,9 +99,9 @@ note = "widgets::value_field: arithmetic plus units (1in, 5mm, 72pt, 50%) conver
[[item]]
id = "UI-217-14"
target = "Layers panel: wheel steps blend modes, drop on New/Trash duplicates/deletes, checker behind art"
status = "partial"
status = "done"
issue = 217
note = "panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); trash is click-only, no drop on New/Trash, no checker behind art"
note = "panels.rs: the wheel steps the blend-mode dropdown (widgets::dropdown_wheel_hovered, #1747); rows dropped on New Layer/Group/Delete duplicate/group/delete them (footer_drop, #736), effect rows dropped on Delete delete that effect or the whole style (fx_trash_drop, #3102); pixel thumbnails draw widgets::checker behind the art; Shift+Plus/Minus step the layer's or the painting tool's blend mode (blend_keys.rs, #3102)"
[[item]]
id = "UI-217-15"