mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
576811263e
commit
a273d3b27d
7 files changed
+173
-28
No files matched your search
+2
-1
@@ -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) |
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
Reference in new issue
Block a user