Keep adjustment clipping visible in compact Properties panels

This commit is contained in:
JGF
2026-10-20 20:02:35 -04:00
parent 872748fb5b
commit a05fee0719
7 changed files with 130 additions and 14 deletions
+1
View File
@@ -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)):
@@ -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<f32> {
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() {
+12 -12
View File
@@ -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() {
+3 -1
View File
@@ -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 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+3 -1
View File
@@ -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 |