Layers panel: ⌥-click on the line between layers, measured against Photoshop (#1642)

#1066 added the gesture; this makes it behave as Photoshop 25.4 does (measured by sweeping
⌥-hover and ⌥-click a pixel at a time across each line, results read back by script):

- the line takes the pointer from 3 px above it to 5 px below it (was ±3);
- a release frees the layer above the line together with the clipped layers stacked above it,
  in one history step, and makes it the active layer (was: that layer alone); a clip keeps the
  active layer, as before;
- clipping and releasing have their own cursors: PhotoCraft's original clip-below (a hooked
  arrow onto the layer below) and clip-release (the same arrow with a small "no" badge),
  replacing Lucide corner-right-down for both.

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
UnemarsandClaude Opus 5.5 authored and GitHub committed 2026-10-09 16:05:52 +07:00
1 parent 576811263e
commit a273d3b27d
7 files changed
+173 -28

No files matched your search

+2 -1
View File
@@ -18,9 +18,10 @@ next to them.
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (110 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from <https://github.com/lucide-icons/lucide> `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own clipping-mask cursor (not from an icon set): a hooked arrow down onto the layer below. -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

+18
View File
@@ -0,0 +1,18 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- PhotoCraft's own release-clipping-mask cursor: clip-below.svg unchanged, plus a small "no" badge (thinner strokes keep it legible at cursor size). -->
<path d="M5 4h4.5a5.5 5.5 0 0 1 5.5 5.5V16" />
<path d="m11.5 12.5 3.5 3.5 3.5-3.5" />
<path d="M5 20.5h14" />
<circle cx="20" cy="4" r="3.25" stroke-width="1.5" />
<path d="m17.7 1.7 4.6 4.6" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 591 B

-14
View File
@@ -1,14 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m10 15 5 5 5-5" />
<path d="M4 4h7a4 4 0 0 1 4 4v12" />
</svg>

Before

Width:  |  Height:  |  Size: 277 B

+47 -10
View File
@@ -5,6 +5,15 @@
//! directly above in the stack: a line inside an open group, under a group's last child or
//! across rows hidden by a filter clips nothing. While ⌥ is held over a line the pointer is the
//! clip cursor, and a click there goes to the line, not to the rows on either side of it.
//!
//! Measured on Photoshop 25.4 (Background, a pixel layer, an adjustment layer and a layer on
//! top; ⌥-hover and ⌥-click swept a pixel at a time across each line, the result read back by
//! script):
//! - the line takes the pointer from 3 px above it to 5 px below it;
//! - a click that clips keeps the active layer;
//! - a click that releases frees the layer above the line together with the clipped layers
//! stacked above it (in one history step), and makes it the active layer;
//! - the release cursor is the clip cursor struck through.
use egui::{Pos2, Rangef, Rect, Sense, vec2};
use photocraft_doc::{Document, LayerContent, LayerId};
@@ -12,10 +21,13 @@ use serde_json::{Value, json};
use crate::layer_row_ui::{RIGHT_PAD, RowRects};
/// Half the height of a line's hit band (the thumbnails stay clear of it).
const BAND: f32 = 3.0;
/// How far above and below a line its hit band reaches (Photoshop: 3 px up, 5 px down).
const ABOVE: f32 = 3.5;
const BELOW: f32 = 5.0;
/// The eye column, left of the band: ⌥-click there shows only that layer.
const EYE_W: f32 = 30.0;
/// Size of the clip cursors (points).
const CURSOR_SIZE: f32 = 22.0;
/// A line between two layers.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -29,13 +41,35 @@ pub struct ClipLine {
}
impl ClipLine {
/// The Layer menu command a click runs.
pub fn command(&self) -> (String, Value) {
let id = if self.release { "layer.releaseClippingMask" } else { "layer.createClippingMask" };
(id.into(), json!({"layer": self.upper.0}))
/// The commands a click runs: Create Clipping Mask on the upper layer, or Release Clipping
/// Mask on it and the clipped layers stacked above it (`key` makes that one history step)
/// before it becomes the active layer.
pub fn commands(&self, doc: &Document, key: &str) -> Vec<(String, Value)> {
if !self.release {
return vec![("layer.createClippingMask".into(), json!({"layer": self.upper.0}))];
}
let mut out: Vec<(String, Value)> =
release_chain(doc, self.upper).into_iter().map(|id| ("layer.releaseClippingMask".into(), json!({"layer": id.0, "coalesce": key}))).collect();
out.push(("layer.select".into(), json!({"layer": self.upper.0, "mode": "replace"})));
out
}
}
/// The layers a release on `upper` frees: it and the clipped layers stacked directly above it.
fn release_chain(doc: &Document, upper: LayerId) -> Vec<LayerId> {
let mut out = vec![upper];
let Some(mut path) = doc.path_of(upper) else { return out };
while let Some(last) = path.pop() {
let Some(next) = last.checked_add(1) else { break };
path.push(next);
match doc.layer_at(&path) {
Some(l) if l.clipped => out.push(l.id),
_ => break,
}
}
out
}
/// The line under `p` among the layer rows the panel drew (top to bottom), if two layers that
/// can clip meet there.
pub fn at(doc: &Document, rows: &[RowRects], p: Pos2) -> Option<ClipLine> {
@@ -44,7 +78,7 @@ pub fn at(doc: &Document, rows: &[RowRects], p: Pos2) -> Option<ClipLine> {
// Between the eye column and the right-hand indicators (the effects triangle takes clicks).
let right = above.indicators.iter().chain(&below.indicators).map(|(_, r)| r.left()).fold(below.row.right() - RIGHT_PAD, f32::min);
let y = below.row.top();
let rect = Rect::from_x_y_ranges(Rangef::new(below.row.left() + EYE_W, right), Rangef::new(y - BAND, y + BAND));
let rect = Rect::from_x_y_ranges(Rangef::new(below.row.left() + EYE_W, right), Rangef::new(y - ABOVE, y + BELOW));
if !rect.contains(p) {
return None;
}
@@ -74,11 +108,14 @@ pub fn show(ui: &mut egui::Ui, doc: &Document, actions: &mut Vec<(String, Value)
let resp = ui.interact(line.rect, ui.id().with("clip-line"), Sense::click());
let label = if line.release { "Release Clipping Mask" } else { "Create Clipping Mask" };
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
// The arrow's tip, (15, 20) of its 24-unit box, points at the line.
crate::icons::cursor(&ctx, "corner-right-down", p, vec2(15.0, 20.0) / 24.0, 18.0);
// PhotoCraft's own cursors: a hooked arrow onto the layer below, and the same arrow with a
// small "no" badge to release. The arrow's tip, (15, 16) of the 24-unit box, is the hot spot.
let icon = if line.release { "clip-release" } else { "clip-below" };
crate::icons::cursor(&ctx, icon, p, vec2(15.0, 16.0) / 24.0, CURSOR_SIZE);
ctx.set_cursor_icon(egui::CursorIcon::None);
if resp.clicked() {
actions.push(line.command());
let key = format!("clip-line:{}:{}", line.upper.0, ctx.cumulative_pass_nr());
actions.extend(line.commands(doc, &key));
}
}
+88 -2
View File
@@ -107,7 +107,7 @@ fn only_siblings_next_to_each_other_meet_at_a_line() {
assert_eq!(at(&doc, &open, pos2(x, 64.0)), None, "a group's last child and the layer below the group");
let line = at(&doc, &open, pos2(x, 96.0)).expect("two top-level layers");
assert_eq!((line.upper, line.release), (d, false));
assert_eq!(line.command(), ("layer.createClippingMask".to_string(), json!({"layer": d.0})));
assert_eq!(line.commands(&doc, "k"), vec![("layer.createClippingMask".to_string(), json!({"layer": d.0}))]);
assert_eq!(at(&doc, &open, pos2(10.0, 96.0)), None, "the eye column");
assert_eq!(at(&doc, &open, pos2(x, 90.0)), None, "inside a row");
// A closed group sits directly above the layer below it, and can be clipped to it.
@@ -120,5 +120,91 @@ fn only_siblings_next_to_each_other_meet_at_a_line() {
assert_eq!(at(&doc, &[row(g, 0.0), row(bg, 32.0)], pos2(x, 32.0)), None);
doc.layer_mut(d).unwrap().clipped = true;
let line = at(&doc, &closed, pos2(x, 64.0)).unwrap();
assert_eq!(line.command(), ("layer.releaseClippingMask".to_string(), json!({"layer": d.0})));
assert_eq!(
line.commands(&doc, "k"),
vec![
("layer.releaseClippingMask".to_string(), json!({"layer": d.0, "coalesce": "k"})),
("layer.select".to_string(), json!({"layer": d.0, "mode": "replace"})),
]
);
}
#[test]
fn the_band_reaches_three_above_and_five_below_the_line() {
// Photoshop 25.4: the line takes the pointer from 3 px above it to 5 px below it.
let (s, a, b) = two_layers();
let doc = s.active().unwrap().doc.clone();
let bg = doc.layers[0].id;
let rows = [row(b, 0.0), row(a, 32.0), row(bg, 64.0)];
let x = 100.0;
for y in [61.0, 64.0, 68.9] {
assert_eq!(at(&doc, &rows, pos2(x, y)).map(|l| l.upper), Some(a), "y {y}");
}
for y in [60.0, 69.5] {
assert_eq!(at(&doc, &rows, pos2(x, y)), None, "y {y}");
}
}
/// Background, Layer 1, CB (Color Balance), Top; Top active.
fn chain() -> (photocraft_engine::Session, [LayerId; 3]) {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let l1 = s.execute("layer.new.layer", json!({"name": "Layer 1"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.newAdjustmentLayer.colorBalance", json!({})).unwrap();
let cb = s.active().unwrap().active_layer.unwrap().0;
let top = s.execute("layer.new.layer", json!({"name": "Top"})).unwrap()["layer"].as_u64().unwrap();
(s, [LayerId(l1), LayerId(cb), LayerId(top)])
}
/// A point on the line along the top of `layer`'s row, `dy` below it, right of the eye column.
fn on_line(h: &Harness<'_, PhotocraftApp>, layer: LayerId, dy: f32) -> Pos2 {
let r = recorded(&h.ctx).into_iter().find(|r| r.layer == layer.0).unwrap().row;
pos2(r.left() + 80.0, r.top() + dy)
}
fn active(h: &Harness<'_, PhotocraftApp>) -> Option<LayerId> {
h.state().session.active().unwrap().active_layer
}
fn steps(h: &Harness<'_, PhotocraftApp>) -> usize {
h.state().session.active().unwrap().history.entries().len()
}
#[test]
fn releasing_frees_the_clipped_layers_above_too_and_selects_the_layer() {
// Photoshop 25.4: a release in the middle of a clipping chain frees the layer above the line
// and every clipped layer stacked above it, in one history step, and makes it active.
let (s, [l1, cb, top]) = chain();
let mut h = harness(s);
// Clip CB onto Layer 1 (from inside the band, 4 px below the line), then Top onto CB.
let p = on_line(&h, l1, 4.0);
click(&mut h, p, Modifiers::ALT);
assert!(clipped(&h, cb) && !clipped(&h, top));
assert_eq!(active(&h), Some(top), "clipping keeps the active layer");
let p = on_line(&h, cb, -2.5);
click(&mut h, p, Modifiers::ALT);
assert!(clipped(&h, top) && clipped(&h, cb));
let before = steps(&h);
let p = on_line(&h, l1, 2.0);
click(&mut h, p, Modifiers::ALT);
assert!(!clipped(&h, cb) && !clipped(&h, top), "the clipped layer above is released too");
assert_eq!(active(&h), Some(cb), "the released layer becomes active");
assert_eq!(steps(&h), before + 1, "one history step");
assert!(h.state_mut().session.undo());
assert!(clipped(&h, cb) && clipped(&h, top), "one Undo brings the chain back");
// Releasing the top of a chain leaves the layers below it clipped.
let p = on_line(&h, cb, 0.0);
click(&mut h, p, Modifiers::ALT);
assert!(!clipped(&h, top) && clipped(&h, cb));
}
#[test]
fn outside_the_band_an_alt_click_does_not_clip() {
let (s, [l1, cb, _]) = chain();
let mut h = harness(s);
for dy in [-4.5, 6.0] {
let p = on_line(&h, l1, dy);
click(&mut h, p, Modifiers::ALT);
assert!(!clipped(&h, cb), "{dy} px from the line");
}
}
+2 -1
View File
@@ -21,12 +21,13 @@ pub static ICONS: &[(&str, &[u8])] = &[
("circle", include_bytes!("../../../assets/icons/circle.svg")),
("circle-dashed", include_bytes!("../../../assets/icons/circle-dashed.svg")),
("circle-dot", include_bytes!("../../../assets/icons/circle-dot.svg")),
("clip-below", include_bytes!("../../../assets/icons/clip-below.svg")),
("clip-release", include_bytes!("../../../assets/icons/clip-release.svg")),
("clock", include_bytes!("../../../assets/icons/clock.svg")),
("cloud", include_bytes!("../../../assets/icons/cloud.svg")),
("compass", include_bytes!("../../../assets/icons/compass.svg")),
("contrast", include_bytes!("../../../assets/icons/contrast.svg")),
("copy", include_bytes!("../../../assets/icons/copy.svg")),
("corner-right-down", include_bytes!("../../../assets/icons/corner-right-down.svg")),
("crop", include_bytes!("../../../assets/icons/crop.svg")),
("diamond", include_bytes!("../../../assets/icons/diamond.svg")),
("direct-select", include_bytes!("../../../assets/icons/direct-select.svg")),