diff --git a/crates/ui-egui/src/brush_panel.rs b/crates/ui-egui/src/brush_panel.rs index be1ef1391a..9e15704c3b 100644 --- a/crates/ui-egui/src/brush_panel.rs +++ b/crates/ui-egui/src/brush_panel.rs @@ -323,9 +323,13 @@ fn section_list(app: &mut PhotocraftApp, ui: &mut egui::Ui, b: &mut BrushSetting if let Some(lock) = section_lock(b, i) { let lr = egui::Rect::from_center_size(egui::pos2(r.right() - 11.0, r.center().y), vec2(18.0, 18.0)); let lresp = ui.interact(lr, ui.id().with(("brush-sec-lock", i)), Sense::click()); + lresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), *lock, format!("{}: {}", tl!("Lock"), tl!(*name)))); // Like Photoshop: lock icons stay visible on every lockable section row. let tint = if *lock { t.text } else { t.text_faint }; icons::paint(ui, lr, if *lock { "lock" } else { "lock-open" }, 12.0, tint); + if lresp.has_focus() { + ui.painter().rect_stroke(lr, t.radius_sm, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } let tip = if *lock { tl!("Unlock: picking a preset replaces these settings") } else { tl!("Lock: keep these settings when picking another preset") }; if lresp.on_hover_text(tip).clicked() { diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index 78394ad285..f0b6cc906b 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -284,6 +284,122 @@ fn section_locks_are_always_visible_and_toggle_on_click() { assert_eq!(h.state().ui.brush_section, 0); } +#[test] +fn section_lock_buttons_report_their_section_and_state_in_every_theme() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for theme in crate::theme::ThemeKind::ALL { + let mut h = harness(0, 3); + PhotocraftApp::setup_context(&h.ctx, theme); + let mut brush = h.state().session.tools.brush.clone(); + for i in 1..SECTIONS.len() { + *section_lock(&mut brush, i).unwrap() = i % 2 == 0; + } + h.state_mut().session.tools.brush = brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + h.run_steps(3); + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let label = format!("Lock: {name}"); + let node = h.get_by_role_and_label(Role::Button, &label); + assert!(!node.accesskit_node().is_disabled(), "locks remain available for disabled sections"); + assert_eq!(h.query_all_by_label(&label).count(), 1, "{theme:?}: {name} has one distinct control"); + assert_eq!(node.accesskit_node().toggled(), Some(if i % 2 == 0 { Toggled::True } else { Toggled::False }), "{theme:?}: {name}"); + let row = h.get_by_label(name).rect(); + assert!(row.contains_rect(node.rect()), "the named control is the lock's existing hit target"); + } + assert_eq!(h.state().session.tools.brush, brush, "describing a lock must not edit it"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 3); + } +} + +#[test] +fn section_lock_buttons_accept_keyboard_without_selecting_or_enabling_sections() { + use egui::accesskit::{Role, Toggled}; + use egui_kittest::kittest::{NodeT, Queryable}; + + for (i, &(name, _)) in SECTIONS.iter().enumerate().skip(1) { + let mut h = harness(0, 0); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let label = format!("Lock: {name}"); + let mut replay = photocraft_engine::Session::new(); + replay.tools.brush = before.clone(); + h.get_by_role_and_label(Role::Button, &label).focus(); + h.run_steps(2); + for locked in [true, false] { + h.key_press(egui::Key::Space); + h.run_steps(3); + let mut expected = before.clone(); + *section_lock(&mut expected, i).unwrap() = locked; + assert_eq!(h.state().session.tools.brush, expected, "{name}: only this section's lock changes"); + assert_eq!(h.state().ui.brush_section, 0); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.get_by_role_and_label(Role::Button, &label).accesskit_node().toggled(), Some(if locked { Toggled::True } else { Toggled::False })); + let (id, params) = last_journal(h.state()).unwrap(); + assert_eq!(id, "tools.setBrush"); + assert_eq!(params["brush"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().len(), 1); + assert_eq!(params["brush"]["locks"].as_object().unwrap().values().next(), Some(&json!(locked))); + replay.execute(&id, params).unwrap(); + assert_eq!(replay.tools.brush, expected, "command replay preserves the lock-only edit"); + } + assert_eq!(h.state().session.journal.len(), journal + 2, "one command per activation"); + assert_eq!(replay.tools.brush, before); + } +} + +#[test] +fn section_lock_buttons_show_focus_and_track_keyboard_navigation() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + + fn has_ring(shape: &egui::Shape, target: egui::Rect, color: egui::Color32) -> bool { + match shape { + egui::Shape::Rect(r) => r.rect == target && r.stroke == Stroke::new(1.0, color) && r.stroke_kind == egui::StrokeKind::Inside, + egui::Shape::Vec(v) => v.iter().any(|s| has_ring(s, target, color)), + _ => false, + } + } + for (index, theme) in crate::theme::ThemeKind::ALL.into_iter().enumerate() { + let mut h = harness(0, 0); + PhotocraftApp::setup_context(&h.ctx, theme); + h.state_mut().session.tools.brush.locks.scattering = index % 2 == 0; + h.state_mut().session.tools.brush.locks.texture = index % 2 != 0; + h.run_steps(2); + let before = h.state().session.tools.brush.clone(); + let presets = h.state().session.tools.presets.clone(); + let journal = h.state().session.journal.len(); + let first = h.get_by_role_and_label(Role::Button, "Lock: Scattering").rect(); + let second = h.get_by_role_and_label(Role::Button, "Lock: Texture").rect(); + let accent = Tokens::get(&h.ctx).accent; + let ring = |h: &Harness<'_, PhotocraftApp>, rect| h.output().shapes.iter().any(|s| s.clip_rect.contains_rect(rect) && has_ring(&s.shape, rect, accent)); + assert!(!ring(&h, first) && !ring(&h, second), "{theme:?}: unfocused locks have no focus ring"); + h.get_by_role_and_label(Role::Button, "Lock: Scattering").focus(); + h.run_steps(2); + assert!(h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(ring(&h, first) && !ring(&h, second), "{theme:?}: the focused lock needs a visible indicator"); + h.key_press(egui::Key::Tab); + h.run_steps(2); + assert!(!h.get_by_role_and_label(Role::Button, "Lock: Scattering").is_focused()); + assert!(!ring(&h, first), "Tab removes the old lock's indicator"); + h.get_by_role_and_label(Role::Button, "Lock: Texture").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && ring(&h, second), "the ring follows focus"); + h.get_by_label("Brush Tip Shape").focus(); + h.run_steps(2); + assert!(!ring(&h, first) && !ring(&h, second)); + assert_eq!(h.state().session.tools.brush, before, "focus must not edit settings"); + assert_eq!(h.state().session.tools.presets, presets); + assert_eq!(h.state().session.journal.len(), journal); + assert_eq!(h.state().ui.brush_section, 0); + } +} + /// A harness that shows the options bar with `tool` in the Studio (non-pro) theme. fn options_bar_harness(tool: crate::state::Tool) -> Harness<'static, PhotocraftApp> { let mut app = app(); diff --git a/docs/brush-parity.md b/docs/brush-parity.md index 47dcd752ce..787849908d 100644 --- a/docs/brush-parity.md +++ b/docs/brush-parity.md @@ -1,6 +1,6 @@ # Brush and painting parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (named, stateful Brush Settings locks and visible keyboard focus; independent Brush Pose tilt-axis overrides; Transfer Wetness/Mix tool applicability; dragged preset tip preview; Hue Jitter amount track; Brush Settings minimum fields follow their Control; checkbox-only rows and non-destructive Smoothing switch; shaped-tip cursor follows the view; Color Replacement is a live toolbar tool; no brush-feel estimate changed; 2026-10-11: Pattern Stamp and Background Eraser draw live, TOOL-213-7) · **Target:** Adobe Photoshop 2026 Painting is where Photoshop users notice differences first, so it gets its own checklist: the brush engine (`crates/paint/src/brush.rs`), the Brush Settings UI @@ -32,7 +32,7 @@ tablets against Photoshop side by side. | Brush Pose: separate Override Tilt X/Y flags, preserved through ABR import, presets and commands | done | #3825 (axis overrides only); imported single-axis flags retain the other live stylus component; dab/pixel tests cover 8/16/32-bit RGBA; legacy combined inputs migrate and command deltas contain only the edited axis. | | Noise, Wet Edges, Build-up, Protect Texture | done | | | Smoothing: amount, Pulled String, Stroke Catch-up, Catch-up on End, Adjust for Zoom | done | `brush.rs:428-438` | -| Section locks | done | #174 | +| Section locks | done | #174; `brush_panel_tests.rs` verifies section-specific accessible names and locked state for all 12 controls in 14 themes, plus visible themed focus, Tab blur, Space activation, minimal command patches and replay without selecting or enabling a section. | | Noise, Wet Edges, Build-up, Smoothing and Protect Texture: checkbox-only rows keep the selected controls open | done | `brush_panel_tests.rs`, #4283; Smoothing enable/disable preserves its amount and options, including imported ABR tool settings | | Brush tip from an image file | missing | TOOL-213-12 | | Erodible, Airbrush and Bristle tips (Photoshop's special tips) | partial | not audited in detail; "3D-based brushes don't work" (#1660) | @@ -128,4 +128,5 @@ tablets against Photoshop side by side. | 2026-10-13 | minor | Shape Dynamics and Transfer minimum inputs follow their Control and retain their values while disabled (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Hue Jitter uses the same themed amount slider as other jitter controls (#3825, partial). No brush-feel estimate changed. | | 2026-10-13 | minor | Transfer Wetness/Mix controls follow the active tool and retain their settings while disabled (#3825, partial). No brush-feel estimate changed. | +| 2026-10-14 | minor | The 12 existing Brush Settings section locks expose translated names and locked state to AccessKit and show themed keyboard focus (#3477 workflow); icons, hit targets and command behavior retained. No brush-feel estimate changed. | | 2026-10-13 | minor | Dragging a brush preset shows its cached tip thumbnail and name in the Brushes panel and preset picker (#4225, partial). No brush-feel estimate changed. | diff --git a/docs/gaps.md b/docs/gaps.md index 4773388927..aec6439143 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PhotoCraft falls short of Photoshop -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (G4: named, stateful section locks and visible keyboard focus in Brush Settings; G4: independent Brush Pose tilt-axis overrides, #3825 partial; G4: Transfer Mixer-only controls, #3825 partial; G9: Preferences › File Handling › Save in Background takes effect, #6207, 24 of 169 preferences do nothing; G7: Content-Aware Move previews every selection the command takes, coarse then exact, TOOL-213-4 done, #6680; G4: Pattern Stamp and Background Eraser draw live, TOOL-213-7; G9: Animated Zoom pref read: holding the Zoom tool zooms continuously, #6210, prefs that do nothing 25 of 166; G2: a cancelled 24 MP Gaussian Blur job stops in at most 44 ms, P25 under its 200 ms budget in local runs, baseline not re-run, #6205; G1: generated large-file fixtures, a 4.38 GB PSB opens on Windows through io, FILE-216-2 done, #5107; G9: fill and adjustment layers copy and paste whole, #2344; G1: smart filter masks show on PSD open, Photoshop-oracle floor 188/258; G10: DPX read and write, #1049, Cineon read; G1: CMYK Hue/Saturation and Color Balance keep the black ink; G1: CMYK Posterize on the stored values, psd-tools oracle floor 242/309; G1: CMYK adjustment layers composite on the document's inks, Photoshop-oracle floor 165/258; G1: CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; G1: modern Brightness/Contrast's brightness curve matches Photoshop, Photoshop-oracle floor 151/258; G1: Lab documents composite on their stored L, a, b, Photoshop-oracle floor 152/258; G1: Levels on whole levels at every step, Photoshop-oracle floor 146/258, psd-tools 238/309; G1: 32-bit Brightness/Contrast legacy, Channel Mixer, Photo Filter and Black & White tint unclipped as in Photoshop, Photoshop-oracle floor 149/258; G1: mask feathers blur with their exact variance, psd-tools oracle floor 240/309; G1: Threshold splits on Photoshop's luminosity, psd-tools oracle floor 241/309; G1: pass-through clipping base with an adjustment renders isolated, psd-tools oracle floor 240/309; G1: 16-bit Posterize as Photoshop bins it, psd-tools oracle floor 241/309; G1: Gradient Map driven by the W3C luminosity, Photoshop-oracle floor 147/258; G7: Field Blur's on-canvas pins, #3373; G4: Hue Jitter amount track, #3825 partial; G4: minimum field interaction, #3825 partial; G1: CMYK Invert, Posterize and Threshold on the inks, 16-bit Lab Invert; Photoshop-oracle floor 145/258; G11: `.atn` import maps 112 events: Select and Mask, Filter Gallery Spatter, Ripple, Crystallize, Pattern Fill layers, mask targeting, Step Backward, nudges in points, Bitmap mode; G5: Free Transform H/V skew fields; G9: toolbar tool shortcut editing; refreshed against main: G2, G5, G7, G9, G10, G13, G14, G17 items that landed; G10: DPX read and write, #1049; G4: checkbox-only Brush Settings rows; G11: Load/Save Actions `.atn` landed, AUTO-219-2 partial; G5: hold-a-key spring-loaded tools landed, #2672; G1: #2465 fixed; stale missing-tool rows corrected: Color Replacement, Red Eye and Add/Delete Anchor Point are live tools, so only Art History Brush is missing; no estimate changed) · **Target:** Adobe Photoshop 2026 Every known shortfall against Photoshop 2026 (27.11.0), one entry each, ranked by user impact on the way to beta. This is the work list agents pick from: take the highest-ranked entry that @@ -176,6 +176,11 @@ what an agent can't finish alone. only the selected component replaces live stylus input. Legacy combined JSON and command inputs retain their two-axis behavior, and minimal command patches preserve the other axis. Other Brush Settings and brush-feel gaps remain open. +- **Progress (2026-10-14, #3477 workflow):** section locks expose their section-specific + translated names and locked state to AccessKit. A themed outline follows keyboard focus; + focus changes do not edit settings. Keyboard activation preserves the selected + section and other brush settings, using the existing command path. Other panel and brush-feel + gaps remain open. - **Progress (2026-10-13, #3825 partial):** Hue Jitter shows a standard themed percentage-amount track rather than a hue spectrum. Other Brush Settings layout and brush-behaviour gaps remain open. @@ -306,6 +311,7 @@ what an agent can't finish alone. | 2026-10-15 | minor | G9: Save in Background off saves in the foreground under a modal progress dialog (#6207); preferences that do nothing 25 → 24 of 169 | | 2026-10-13 | minor | G4: Transfer Wetness/Mix tool applicability (#3825, partial); no readiness percentage or estimate changed | | 2026-10-14 | minor | G4: independent Brush Pose X/Y overrides across UI/import/painting/commands (#3825 partial); legacy combined input retained; no readiness percentage or estimate changed | +| 2026-10-14 | minor | G4: existing Brush Settings section locks expose accessible names/state and visible keyboard focus (#3477 workflow); no readiness percentage or estimate changed | | 2026-10-14 | minor | G1: a PSD smart object's filter mask shows when the file opens (Photoshop stores the filtered pixels unmasked); export stores them that way too; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | G14: D90 image area measured (4288 × 2848 at (10, 10)); gap remains open | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 |