diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cbbbe069a5..ecb08019ba 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -84,6 +84,7 @@ The other built-in ICC profiles and the generated LUT looks are produced by code | `docs/brand/` (all files) | ArtCraft name, wordmark and mark | ArtCraft Team | getartcraft.com | Not open source; trademarks of the ArtCraft Team, [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) | | `docs/images/photocraft-*.jpg` | PhotoCraft screenshots | PhotoCraft contributors (UI) | Rendered offscreen with the `snapshot` example | MIT OR Apache-2.0 (UI); the artwork in each is public domain, listed below | | `docs/images/preferences-apply-*.png` | Preferences before and after adding Apply (no artwork) | PhotoCraft contributors | PhotoCraft control-channel capture and offscreen `snapshot` example | MIT OR Apache-2.0 | +| `docs/images/adjustment-scope-*.png` | Adjustment scope control before and after moving it above the editor (synthetic blank document, #5231) | PhotoCraft contributors | PhotoCraft offscreen `snapshot` example | MIT OR Apache-2.0 (UI); brand mark retains `docs/brand/LICENSE-brand.txt` | Artwork shown in the screenshots (all public domain, via Wikimedia Commons; details in [`docs/images/SOURCES.md`](docs/images/SOURCES.md)): diff --git a/crates/ui-egui/src/clipping_indicator_tests.rs b/crates/ui-egui/src/clipping_indicator_tests.rs index 7395a533de..92c5e0d2d8 100644 --- a/crates/ui-egui/src/clipping_indicator_tests.rs +++ b/crates/ui-egui/src/clipping_indicator_tests.rs @@ -2,6 +2,7 @@ use egui::{Event, Modifiers, PointerButton, pos2, vec2}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use serde_json::json; use crate::PhotocraftApp; @@ -80,6 +81,116 @@ fn alt_click_clipping_still_creates_releases_and_undoes_without_moving_rows() { assert!(!h.state().session.active().unwrap().doc.layer(active).unwrap().clipped); } +fn adjustment_harness(kind: &str, depth: u32, theme: crate::theme::ThemeKind, scale: f32, gpu: bool) -> Harness<'static, PhotocraftApp> { + let mut session = photocraft_engine::Session::new(); + session.execute("file.new", json!({"width": 16, "height": 8, "depth": depth})).unwrap(); + session.execute("edit.fill", json!({"color": "#b04020"})).unwrap(); + session.execute("layer.new.layer", json!({})).unwrap(); + session.execute("select.rect", json!({"x": 8, "y": 0, "width": 8, "height": 8})).unwrap(); + session.execute("edit.fill", json!({"color": "#208040"})).unwrap(); + session.execute("select.deselect", json!({})).unwrap(); + let mut app = PhotocraftApp::new(session, Default::default()); + app.ui.dock_tabs.properties = 1; + let builder = Harness::builder().with_size(vec2(250.0, 160.0)).with_pixels_per_point(scale); + let builder = if gpu { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|fonts| fonts.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::i18n::with_language(crate::i18n::Lang::EN, || { + egui::ScrollArea::vertical().show(ui, |ui| { + if app.ui.dock_tabs.properties == 1 { + super::adjustments_grid(app, ui); + } else { + super::properties_body(app, ui); + } + }); + }); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, theme); + h.run_steps(4); + h.get_by_label(kind).click(); + h.run_steps(4); + h +} + +#[test] +fn adjustment_scope_control_fits_compact_properties_before_scrolling() { + for theme in crate::theme::ThemeKind::ALL { + for scale in [1.0, 1.5, 2.0] { + for kind in ["Hue/Saturation", "Curves", "Selective Color", "Color Lookup"] { + let h = adjustment_harness(kind, 8, theme, scale, false); + let rect = h.get_by_label("Clip to layer below").rect(); + assert!(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(250.0, 160.0)).contains_rect(rect), "{theme:?}, {scale}x, {kind}: {rect:?}"); + } + } + } +} + +fn sample(h: &mut Harness<'_, PhotocraftApp>, x: i32) -> Vec { + serde_json::from_value(h.state_mut().run("document.pixel", json!({"x": x, "y": 4})).unwrap()).unwrap() +} + +#[test] +fn panel_adjustment_clips_to_selected_layer_and_scope_undoes_at_all_depths() { + for depth in [8, 16, 32] { + let mut h = adjustment_harness("Hue/Saturation", depth, crate::theme::ThemeKind::Pro, 1.0, false); + let id = h.state().session.active().unwrap().active_layer.unwrap(); + h.state_mut().run("layer.setAdjustment", json!({"layer": id.0, "saturation": -100})).unwrap(); + h.run_steps(4); + let all_layers = sample(&mut h, 2); + assert!((all_layers[0] - all_layers[1]).abs() < 0.01, "unclipped adjustment desaturates the lower layer"); + let adjusted_target = sample(&mut h, 12); + let steps = h.state().session.active().unwrap().history.past_len(); + h.get_by_label("Clip to layer below").click(); + h.run_steps(4); + assert!(h.state().session.active().unwrap().doc.layer(id).unwrap().clipped); + assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1); + let lower_layer = sample(&mut h, 2); + assert!(lower_layer[0] - lower_layer[1] > 0.2, "clipping restores the lower layer's colour"); + assert!(sample(&mut h, 12).iter().zip(&adjusted_target).all(|(a, b)| (a - b).abs() <= 1.0 / 255.0), "the selected layer stays adjusted"); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(sample(&mut h, 2), all_layers); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(sample(&mut h, 2), lower_layer); + h.run_steps(4); + h.get_by_label("Clip to layer below").click(); + h.run_steps(4); + assert!(!h.state().session.active().unwrap().doc.layer(id).unwrap().clipped); + assert_eq!(sample(&mut h, 2), all_layers, "scope can be released through the same control"); + } +} + +#[test] +#[ignore = "writes synthetic compact adjustment-panel screenshots for visual inspection"] +fn write_adjustment_scope_preview() { + let phase = std::env::var("PHOTOCRAFT_EVIDENCE_PHASE").unwrap_or_else(|_| "after".into()); + let output = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plan/evidence/5231"); + std::fs::create_dir_all(&output).unwrap(); + for theme in [crate::theme::ThemeKind::Pro, crate::theme::ThemeKind::Studio] { + let mut h = adjustment_harness("Hue/Saturation", 8, theme, 2.0, true); + h.ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default())); + h.step(); + h.step(); + let image = h + .ctx + .input(|input| { + input.events.iter().find_map(|event| match event { + Event::Screenshot { image, .. } => Some(image.clone()), + _ => None, + }) + }) + .unwrap(); + let rgba = image.pixels.iter().flat_map(|pixel| pixel.to_array()).collect(); + let image = photocraft_codecs::Image::from_u8(image.size[0] as u32, image.size[1] as u32, photocraft_codecs::ChannelLayout::Rgba, rgba).unwrap(); + let png = photocraft_codecs::encode(&image, photocraft_codecs::Format::Png, &Default::default()).unwrap(); + std::fs::write(output.join(format!("{phase}-{}.png", theme.id())), png).unwrap(); + } +} + #[test] #[ignore = "writes synthetic English Layers-panel screenshots for before/after inspection"] fn write_clipping_indicator_preview() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 04e775439e..89ce01a866 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -3656,20 +3656,20 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { } fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &photocraft_doc::Adjustment) { + // Keep the scope reachable before tall editors and within a compact dock (#5231). + // Adjustment layers affect the stack by default; clipping limits them to their base. + if let Some(mut clip) = app.session.active().and_then(|s| s.doc.layer(id).map(|l| l.clipped)) + && widgets::toggle(ui, &mut clip, tl!("Clip to layer below")).changed() + { + let _ = app.run("layer.setProps", json!({"layer": id.0, "clipped": clip})); + } + ui.add_space(6.0); crate::adjust_editors::layer_editor(app, ui, id, adj); ui.add_space(6.0); - let layer = app.session.active().and_then(|s| s.doc.layer(id).map(|l| (l.visible, l.clipped))); - if let Some((visible, clipped)) = layer { - ui.horizontal(|ui| { - let mut vis = visible; - if widgets::toggle(ui, &mut vis, tl!("Adjustment visible")).changed() { - let _ = app.run("layer.setProps", json!({"layer": id.0, "visible": vis})); - } - let mut clip = clipped; - if widgets::toggle(ui, &mut clip, tl!("Clip to layer below")).changed() { - let _ = app.run("layer.setProps", json!({"layer": id.0, "clipped": clip})); - } - }); + if let Some(mut vis) = app.session.active().and_then(|s| s.doc.layer(id).map(|l| l.visible)) + && widgets::toggle(ui, &mut vis, tl!("Adjustment visible")).changed() + { + let _ = app.run("layer.setProps", json!({"layer": id.0, "visible": vis})); } ui.add_space(6.0); if widgets::secondary_button(ui, tl!("Reset to defaults"), ui.available_width()).clicked() { diff --git a/docs/gaps.md b/docs/gaps.md index 4457a87dde..f043979518 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 (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-20 · **Change:** minor (G9: visible adjustment clipping in compact Properties, #5231; 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 @@ -195,6 +195,7 @@ what an agent can't finish alone. - **Estimate:** 60–110 h. ### G9. Panels, preferences and workspace +- **Progress (2026-10-13):** adjustment clipping is reachable above the Properties editor in a compact dock (#5231), with all-theme layout coverage and 8/16/32-bit scope and undo/redo tests. The normal adjustment-layer scope and remaining estimate stay unchanged. - **Progress (2026-10-12):** toolbar tool shortcuts can be reassigned, removed and reset (#4086), with saved bindings, matching toolbar hints and regression coverage (`ui-egui/src/tool_shortcuts.rs`). - **Missing:** 27 of 156 preferences do nothing (scorecard prefs audit); panel groups can't @@ -270,6 +271,7 @@ what an agent can't finish alone. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | G9: adjustment clipping remains visible in compact Properties (#5231); no readiness percentage or estimate changed | | 2026-10-18 | minor | G9: with no selection, Copy takes fill and adjustment layers whole and Paste recreates them (#2344); UI-217-23 stays partial (a single pixel layer copies its pixels only); estimate unchanged | | 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 | diff --git a/docs/images/adjustment-scope-after.png b/docs/images/adjustment-scope-after.png new file mode 100644 index 0000000000..a65da5e23c Binary files /dev/null and b/docs/images/adjustment-scope-after.png differ diff --git a/docs/images/adjustment-scope-before.png b/docs/images/adjustment-scope-before.png new file mode 100644 index 0000000000..8269ef2359 Binary files /dev/null and b/docs/images/adjustment-scope-before.png differ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..98ac82c854 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (adjustment clipping stays visible in compact Properties, #5231; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -219,6 +219,7 @@ Paragraph; the rest open as in-app floating windows. | Document tabs: overflow, context menu, reorder | done | UI-217-6; middle-click close #2570 | | Layers panel: wheel steps blend modes, drop on New/Trash | partial | UI-217-14 | | Layers panel filter-type dropdown (Kind, Name, Effect, Mode, Attribute, Color, Smart Object, Selected, Artboard) | partial | Kind, Name and Mode; the other six missing. `layer_filter_ui.rs`, `ui.set {layerFilter}` (#5048) | +| Adjustment scope: clipping control visible above tall Properties editors in a compact dock | done (2026-10-13) | `panels.rs`, `clipping_indicator_tests.rs`; #5231; stack-wide default retained | | Smart filter rows: click selects, drag onto another smart object moves, ⌥-drag copies, drag onto Delete removes | done | `smart_ui.rs`, `layer.smartFilter.transfer` (#4271) | | Layers panel: Clip Thumbnails to Layer Bounds (panel menu, `interface.clipThumbnailsToLayerBounds`) | done (2026-10-12) | `layer_thumb_bounds_tests.rs`; #4281. Thumbnail size and the Panel Options dialog to come | | Smart filter rows: drag up or down the stack to reorder (a line marks the drop; the bottom filter is applied first) | done | `smart_ui.rs` `reorder_drop`, `layer.smartFilter.move` | @@ -227,6 +228,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Adjustment clipping moved above Properties editors to remain visible in compact docks (#5231); scope, undo/redo and 8/16/32-bit pixel regressions; readiness estimate unchanged | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed |