Layers panel: Alt-click the line between two layers clips the upper one (#967) (#1066)

Holding Alt (Option) over the line between two layers in the Layers panel
shows the clip cursor; a click runs Layer > Create Clipping Mask on the upper
layer, or Release Clipping Mask when it is clipped already, as in Photoshop.
The line only counts between siblings that are next to each other in the
stack (not inside an open group or across filtered-out rows), and stays
clear of the eye column and the effects triangle. The cursor is the Lucide
corner-right-down icon.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-09 02:42:00 +07:00
committed by GitHub
parent e06c03344a
commit 938c079d68
7 changed files with 229 additions and 1 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ 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` (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/*.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/{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/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) |
+14
View File
@@ -0,0 +1,14 @@
<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>

After

Width:  |  Height:  |  Size: 277 B

+86
View File
@@ -0,0 +1,86 @@
//! Layers panel: ⌥-click the line between two layers to clip the upper one to the one below,
//! or to release it when it is clipped already (Photoshop, #967).
//!
//! The line is the top edge of a layer row, and the row drawn above it must be its sibling
//! 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.
use egui::{Pos2, Rangef, Rect, Sense, vec2};
use photocraft_doc::{Document, LayerContent, LayerId};
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;
/// The eye column, left of the band: ⌥-click there shows only that layer.
const EYE_W: f32 = 30.0;
/// A line between two layers.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ClipLine {
/// The layer above the line: the one clipped or released.
pub upper: LayerId,
/// It is clipped already, so the click releases it.
pub release: bool,
/// The line's hit band.
pub rect: Rect,
}
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 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> {
rows.windows(2).find_map(|pair| {
let [above, below] = pair else { return None };
// 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));
if !rect.contains(p) {
return None;
}
let upper = LayerId(above.layer);
let (up, low) = (doc.path_of(upper)?, doc.path_of(LayerId(below.layer))?);
let ((iu, up_parent), (il, low_parent)) = (up.split_last()?, low.split_last()?);
if up_parent != low_parent || il.checked_add(1) != Some(*iu) {
return None;
}
let l = doc.layer_at(&up)?;
// An open group's contents are listed between it and the layer below it.
if matches!(&l.content, LayerContent::Group(g) if g.expanded && !g.children.is_empty()) {
return None;
}
Some(ClipLine { upper, release: l.clipped, rect })
})
}
/// After the panel drew its rows: while ⌥ is held over a line, show the clip cursor and turn a
/// click there into Create / Release Clipping Mask.
pub fn show(ui: &mut egui::Ui, doc: &Document, actions: &mut Vec<(String, Value)>) {
let ctx = ui.ctx().clone();
let (alt, hover) = ctx.input(|i| (i.modifiers.alt, i.pointer.hover_pos()));
let Some(p) = hover.filter(|_| alt && ctx.dragged_id().is_none()) else { return };
let Some(line) = at(doc, &crate::layer_row_ui::recorded(&ctx), p).filter(|l| ui.rect_contains_pointer(l.rect)) else { return };
// Registered after the rows, so it is on top of them and gets the click.
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);
ctx.set_cursor_icon(egui::CursorIcon::None);
if resp.clicked() {
actions.push(line.command());
}
}
#[cfg(test)]
mod tests;
+124
View File
@@ -0,0 +1,124 @@
//! #967: ⌥-click the line between two layers clips / releases the upper one.
use egui::{Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::Harness;
use photocraft_doc::{Document, LayerContent, LayerId};
use serde_json::json;
use super::at;
use crate::PhotocraftApp;
use crate::layer_row_ui::{RowRects, recorded};
/// Background, `a` and `b` above it.
fn two_layers() -> (photocraft_engine::Session, LayerId, LayerId) {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
let a = s.execute("layer.new.layer", json!({"name": "a"})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.new.layer", json!({"name": "b"})).unwrap()["layer"].as_u64().unwrap();
(s, LayerId(a), LayerId(b))
}
fn harness(session: photocraft_engine::Session) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(session, crate::Services::default())
});
h.run_steps(8);
h
}
/// A real click (press and release on separate frames), modifiers held throughout.
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, modifiers: Modifiers) {
h.event(egui::Event::ModifiersChanged(modifiers));
h.hover_at(at);
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers });
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers });
h.run_steps(1);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(3);
}
fn clipped(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> bool {
h.state().session.active().unwrap().doc.layer(id).unwrap().clipped
}
#[test]
fn alt_click_between_two_layers_clips_and_releases_the_upper_one() {
let (s, a, b) = two_layers();
let mut h = harness(s);
let rows = recorded(&h.ctx);
let below = rows.iter().find(|r| r.layer == a.0).unwrap().row;
// On the line between `b` and `a`, right of the eye column.
let line = pos2(below.left() + 80.0, below.top());
let undo = h.state().session.active().unwrap().history.entries().len();
click(&mut h, line, Modifiers::ALT);
assert!(clipped(&h, b), "⌥-click clips the upper layer to the one below");
assert!(!clipped(&h, a));
let st = h.state().session.active().unwrap();
assert_eq!(st.history.entries().len(), undo + 1, "one history step");
assert_eq!(st.active_layer, Some(b), "the layer selection is unchanged");
click(&mut h, line, Modifiers::ALT);
assert!(!clipped(&h, b), "⌥-click on a clipped layer's line releases it");
// A plain click there selects a row as before.
click(&mut h, line, Modifiers::NONE);
assert!(!clipped(&h, b));
}
#[test]
fn the_clip_cursor_shows_only_with_alt_over_a_line() {
let (s, a, _) = two_layers();
let mut h = harness(s);
let below = recorded(&h.ctx).iter().find(|r| r.layer == a.0).unwrap().row;
let line = pos2(below.left() + 80.0, below.top());
h.hover_at(line);
h.run_steps(2);
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "no ⌥: the normal pointer");
h.event(egui::Event::ModifiersChanged(Modifiers::ALT));
h.run_steps(2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the clip cursor replaces the pointer");
h.hover_at(below.center());
h.run_steps(2);
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "mid-row: no line");
}
fn row(layer: LayerId, top: f32) -> RowRects {
RowRects { layer: layer.0, row: Rect::from_min_size(pos2(0.0, top), vec2(240.0, 32.0)), name: None, indicators: Vec::new() }
}
fn named(doc: &Document, name: &str) -> LayerId {
doc.walk().into_iter().find(|(_, _, l)| l.name == name).unwrap().2.id
}
#[test]
fn only_siblings_next_to_each_other_meet_at_a_line() {
// Background, `d`, and the group `g { c }` above it.
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
s.execute("layer.new.layer", json!({"name": "d"})).unwrap();
s.execute("layer.new.layer", json!({"name": "c"})).unwrap();
s.execute("layer.groupLayers", json!({"name": "g"})).unwrap();
let mut doc: Document = (*s.active().unwrap().doc).clone();
let [g, c, d, bg] = ["g", "c", "d", "Background"].map(|n| named(&doc, n));
let open = [row(g, 0.0), row(c, 32.0), row(d, 64.0), row(bg, 96.0)];
let x = 100.0;
assert_eq!(at(&doc, &open, pos2(x, 32.0)), None, "a group and its first child");
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!(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.
if let Some(LayerContent::Group(grp)) = doc.layer_mut(g).map(|l| &mut l.content) {
grp.expanded = false;
}
let closed = [row(g, 0.0), row(d, 32.0), row(bg, 64.0)];
assert_eq!(at(&doc, &closed, pos2(x, 32.0)).map(|l| l.upper), Some(g));
// A filter hiding `d` leaves `g` over the Background: not neighbours in the stack.
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})));
}
+1
View File
@@ -26,6 +26,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("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")),
+1
View File
@@ -39,6 +39,7 @@ pub mod channel_view;
pub mod channels_panel;
pub mod chrome_ui;
pub mod cjk_fonts;
pub mod clip_line_ui;
pub mod color_picker_ui;
pub mod color_range_ui;
pub mod comps_ui;
+2
View File
@@ -1528,6 +1528,8 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}
crate::smart_ui::filter_rows(app, ui, l, depth, &mut actions);
}
// ⌥-click the line between two layers: clip / release the upper one (#967).
crate::clip_line_ui::show(ui, &doc, &mut actions);
// A rename whose row is gone (deleted, filtered out, inside a closed group) ends,
// committed: nothing else could commit or cancel it.
if let Some(layer) = crate::layer_row_ui::renaming(ui.ctx())