From 4ab61338ca14d3c7bcd2e137f2bd9fbc861b0d69 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Mon, 12 Oct 2026 01:45:42 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/engine/src/layer_style.rs | 46 ++++++- crates/ui-egui/src/blend_keys.rs | 183 ++++++++++++++++++++++++++++ crates/ui-egui/src/fx_drag_tests.rs | 24 ++++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels.rs | 27 +++- crates/ui-egui/src/shortcuts.rs | 37 ++++++ docs/scorecard.md | 4 +- scorecard/ui.toml | 4 +- 8 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/blend_keys.rs diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index f4339ed28..bf2d55a5a 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -836,9 +836,12 @@ pub fn specs() -> Vec { 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 { ] } +/// `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 { + 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(); diff --git a/crates/ui-egui/src/blend_keys.rs b/crates/ui-egui/src/blend_keys.rs new file mode 100644 index 000000000..b6c0ae401 --- /dev/null +++ b/crates/ui-egui/src/blend_keys.rs @@ -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 { + 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 = 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 = 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); + } +} diff --git a/crates/ui-egui/src/fx_drag_tests.rs b/crates/ui-egui/src/fx_drag_tests.rs index 238063f89..949375f79 100644 --- a/crates/ui-egui/src/fx_drag_tests.rs +++ b/crates/ui-egui/src/fx_drag_tests.rs @@ -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] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index efffc00f3..7baa095d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index c3d4eb4a1..768d6160d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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::(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) -> 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 { diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 7436138d5..3a595c905 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -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)] diff --git a/docs/scorecard.md b/docs/scorecard.md index 46d522983..c6d67f8f9 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -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 | diff --git a/scorecard/ui.toml b/scorecard/ui.toml index 3bdcf5b6e..4a507004a 100644 --- a/scorecard/ui.toml +++ b/scorecard/ui.toml @@ -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"