Double-clicking a mask thumbnail in the Layers panel now opens that mask's settings (#2654)

* Double-click a layer mask thumbnail to open its settings

Double-clicking a mask thumbnail in the Layers panel now opens that mask's settings in the Properties panel, matching Photoshop. When the Preferences ▸ Tools ▸ "Double Click Layer Mask Launches Select and Mask" option is on (the current default) a pixel mask still launches Select and Mask, exactly as before; with it off, double-clicking targets the mask and opens its Properties (Density/Feather). Vector masks now behave consistently too: double-clicking one targets it and opens a new Vector Mask Properties section.

* Fixed syntax issue from merge

* Fixed missing closed brace in ui tests
This commit is contained in:
Lyraedan authored and GitHub committed 2026-10-11 04:11:35 -04:00
1 parent ec60f43b5b
commit f11380d5bd
4 files changed
+154 -39

No files matched your search

@@ -110,6 +110,38 @@ fn text_name_still_renames_and_mask_double_click_does_not_edit_text() {
assert!(h.state().ui.dialogs.is_empty());
}
/// With the preference off, double-clicking a pixel mask targets it and opens its settings.
/// The default preference on launches Select and Mask instead (the test above).
#[test]
fn pixel_mask_double_click_with_preference_off_opens_properties() {
let mut s = session();
let id = s.execute("layer.new.layer", json!({"name": "Ink"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.layerMask.revealAll", json!({})).unwrap();
s.execute("prefs.set", json!({"values": {"tools.doubleClickLayerMaskLaunchesSelectAndMask": false}})).unwrap();
let mut h = harness(s, 1.0);
let masks = crate::mask_thumbs_ui::recorded(&h.ctx, id).unwrap().0;
let at = masks.iter().find(|(kind, _)| *kind == crate::mask_thumbs_ui::MaskKind::Pixel).unwrap().1.center();
double_click(&mut h, at);
assert!(h.state().ui.mask_target, "the mask is targeted");
assert!(h.state().ui.panels.properties, "Properties opened");
assert!(!h.state().ui.dock.collapsed.contains(&crate::dock::Group::Properties), "Properties un-collapsed");
assert!(h.state().ui.dialogs.is_empty(), "no Select and Mask dialog with the preference off");
}
#[test]
fn vector_mask_double_click_opens_its_properties() {
let mut s = session();
let id = s.execute("layer.new.layer", json!({"name": "Vector"})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.vectorMask.revealAll", json!({})).unwrap();
let mut h = harness(s, 1.0);
let masks = crate::mask_thumbs_ui::recorded(&h.ctx, id).unwrap().0;
let at = masks.iter().find(|(kind, _)| *kind == crate::mask_thumbs_ui::MaskKind::Vector).unwrap().1.center();
double_click(&mut h, at);
assert!(h.state().ui.vector_mask_target, "the vector mask is targeted");
assert!(h.state().ui.panels.properties, "Properties opened");
assert!(!h.state().ui.dock.collapsed.contains(&crate::dock::Group::Properties), "Properties un-collapsed");
}
#[test]
fn mask_double_click_launches_select_and_mask_with_preference() {
let mut s = session();
+62 -35
View File
@@ -1,4 +1,4 @@
//! Non-destructive pixel-mask controls, shared by docked and floating Properties.
//! Non-destructive layer-mask controls (pixel and vector), shared by docked and floating Properties.
use photocraft_doc::Layer;
use serde_json::json;
@@ -10,10 +10,18 @@ use crate::{PhotocraftApp, widgets};
/// Image › Adjustments › Invert, which inverts a targeted layer mask in place (#780).
const INVERT: &str = "image.adjustments.invert";
/// The Layers panel targets the active layer's pixel mask (not its vector mask).
pub fn targeted(app: &PhotocraftApp, layer: &Layer) -> bool {
app.ui.mask_target && !app.ui.vector_mask_target && layer.mask.is_some()
}
/// The Layers panel targets the active layer's vector mask.
pub fn vector_targeted(app: &PhotocraftApp, layer: &Layer) -> bool {
app.ui.vector_mask_target && layer.vector_mask.is_some()
}
/// The pixel-mask section: Density, Feather and the Refine row. Pixels stay intact; the two
/// properties ride on the mask and are applied by the compositor.
pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
let Some(mask) = &layer.mask else { return };
let Some(document) = app.session.active().map(|s| s.doc.id.0) else { return };
@@ -21,44 +29,63 @@ pub fn properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
if !crate::props_layout::section(ui, "pixel-mask", "Layer Mask") {
return;
}
// Like brush settings, track pointer presses even on frames without a value change.
// slider_row also merges numeric scrubbing, whose drag flags are not in its Response.
let key = ui.id().with("gesture");
let pass = ui.ctx().cumulative_pass_nr();
let pressed = ui.input(|i| i.pointer.any_pressed());
let gesture = ui.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(key);
if pressed && g.1 != pass {
g.0 = g.0.wrapping_add(1);
g.1 = pass;
}
g.0
});
for (field, label, mut value, max, unit) in
[("density", tl!("Density"), mask.density * 100.0, 100.0, "%"), ("feather", tl!("Feather"), mask.feather, 1000.0, "px")]
{
let response = widgets::slider_row(ui, label, &mut value, 0.0..=max, unit, None);
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(value), label));
if response.changed() {
let mut params = json!({"document": document, "layer": layer.id.0,
"coalesce": format!("pixel-mask:{document}:{}:{field}:{gesture}", layer.id.0)});
params[field] = json!(value);
if let Err(error) = app.run("layer.layerMask.edit", params) {
app.ui.status = error;
app.ui.status_error = true;
}
if !ui.input(|i| i.pointer.any_down()) {
ui.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(key);
g.0 = g.0.wrapping_add(1);
});
}
}
}
let (density, feather) = (mask.density, mask.feather);
sliders(app, ui, layer.id.0, document, "layer.layerMask.edit", "pixel-mask", density, feather);
refine_row(app, ui);
});
}
/// The vector-mask section: Density and Feather, likewise non-destructive.
pub fn vector_properties(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: &Layer) {
let Some(vm) = &layer.vector_mask else { return };
let Some(document) = app.session.active().map(|s| s.doc.id.0) else { return };
ui.push_id((document, layer.id, "vector-mask-properties"), |ui| {
if !crate::props_layout::section(ui, "vector-mask", "Vector Mask") {
return;
}
let (density, feather) = (vm.density, vm.feather);
sliders(app, ui, layer.id.0, document, "layer.vectorMask.edit", "vector-mask", density, feather);
});
}
/// Density (%) and Feather (px) sliders writing one undoable edit per drag: the value lives on the
/// mask, not in its pixels. `command` is the mask's edit command and `prefix` keys its coalescing.
#[allow(clippy::too_many_arguments)]
fn sliders(app: &mut PhotocraftApp, ui: &mut egui::Ui, layer: u64, document: u64, command: &str, prefix: &str, density: f32, feather: f32) {
// Like brush settings, track pointer presses even on frames without a value change.
// slider_row also merges numeric scrubbing, whose drag flags are not in its Response.
let key = ui.id().with("gesture");
let pass = ui.ctx().cumulative_pass_nr();
let pressed = ui.input(|i| i.pointer.any_pressed());
let gesture = ui.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(key);
if pressed && g.1 != pass {
g.0 = g.0.wrapping_add(1);
g.1 = pass;
}
g.0
});
for (field, label, mut value, max, unit) in [("density", tl!("Density"), density * 100.0, 100.0, "%"), ("feather", tl!("Feather"), feather, 1000.0, "px")] {
let response = widgets::slider_row(ui, label, &mut value, 0.0..=max, unit, None);
response.widget_info(|| egui::WidgetInfo::slider(ui.is_enabled(), f64::from(value), label));
if response.changed() {
let mut params = json!({"document": document, "layer": layer,
"coalesce": format!("{prefix}:{document}:{layer}:{field}:{gesture}")});
params[field] = json!(value);
if let Err(error) = app.run(command, params) {
app.ui.status = error;
app.ui.status_error = true;
}
if !ui.input(|i| i.pointer.any_down()) {
ui.data_mut(|d| {
let g = d.get_temp_mut_or_default::<(u64, u64)>(key);
g.0 = g.0.wrapping_add(1);
});
}
}
}
}
/// Photoshop's Refine row below Density and Feather: Select and Mask… and Color Range… open their
/// dialogs (the mask is Select and Mask's input when nothing is selected), and Invert flips the
/// mask. Invert names its target itself (`"target":"mask"`) rather than leaning on the shell's
+33
View File
@@ -117,6 +117,39 @@ fn uncommitted_mask_number_stays_with_its_layer() {
assert_ne!(doc.layer(first).unwrap().mask.as_ref().unwrap().density, 0.5, "uncommitted expression is not applied on selection change");
}
/// A targeted vector mask shows its own Density/Feather section and edits go through
/// `layer.vectorMask.edit` (the compositor already renders both).
#[test]
fn vector_mask_properties_edit_density_and_feather() {
let mut session = photocraft_engine::Session::new();
session.execute("file.new", json!({"width": 64, "height": 64})).unwrap();
let id = session.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
session.execute("layer.vectorMask.revealAll", json!({})).unwrap();
let mut app = PhotocraftApp::new(session, crate::Services::default());
app.ui.vector_mask_target = true;
app.ui.panels.properties = true;
app.last_canvas_rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(800.0, 600.0));
let mut h = Harness::builder().with_size(vec2(800.0, 600.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("semibold".into()))) {
if Tokens::get(ui.ctx()).pro {
properties_body(app, ui);
} else {
properties_window(app, ui.ctx());
}
}
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Pro);
h.run_steps(4);
assert!(h.query_all_by_label("Density").next().is_some(), "the vector mask section is shown");
assert!(h.query_all_by_label("Feather").next().is_some());
drag(&mut h, 0, &[1.0, 0.8, 0.5]);
let vm = h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().vector_mask.clone().unwrap();
assert!((vm.density - 0.5).abs() < 0.02, "density edited through the vector mask command: {}", vm.density);
}
/// Document-wide row of mask coverage (or of the layer's own red channel) across the middle.
fn row(h: &Harness<'_, PhotocraftApp>, of_mask: bool) -> Vec<f32> {
let s = h.state().session.active().unwrap();
+27 -4
View File
@@ -2271,6 +2271,13 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
app.sync_mask_targets();
continue;
}
// Double-clicking a mask thumbnail opens its settings: show Properties.
if id == "ui.properties" {
app.ui.panels.properties = true;
app.ui.dock_tabs.properties = 0;
app.ui.dock.collapsed.retain(|g| *g != crate::dock::Group::Properties);
continue;
}
if p.is_null() {
// Context-menu items behave like their menu-bar twins (dialogs included).
let ctx = ui.ctx().clone();
@@ -2601,11 +2608,16 @@ fn layer_row(
actions.push(done);
}
} else if let Some(kind) = pos.and_then(|p| masks.hit(p)) {
if kind == crate::mask_thumbs_ui::MaskKind::Pixel {
actions.push(("ui.maskTarget".into(), json!(true)));
match kind {
crate::mask_thumbs_ui::MaskKind::Pixel => actions.push(("ui.maskTarget".into(), json!(true))),
crate::mask_thumbs_ui::MaskKind::Vector => actions.push(("ui.vectorMaskTarget".into(), json!(true))),
}
if let Some((cmd, val)) = crate::mask_thumbs_ui::double_click_command(app, kind) {
// The preference launches Select and Mask on a pixel mask.
actions.push((cmd.into(), val));
} else {
// Otherwise double-click opens the mask's settings (Density/Feather).
actions.push(("ui.properties".into(), json!(true)));
}
} else if pos.and_then(|p| masks.hit(p)).is_none() {
let id = match &l.content {
@@ -2800,9 +2812,10 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some(id) = st.active_layer else { return };
let doc = st.doc.clone();
let Some(layer) = doc.layer(id) else { return };
// Pixel-mask controls also belong here when their thumbnail is targeted.
// Mask controls (pixel or vector) also belong here when their thumbnail is targeted.
let mask_target = crate::mask_props_ui::targeted(app, layer);
if !mask_target && !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) {
let vector_target = crate::mask_props_ui::vector_targeted(app, layer);
if !mask_target && !vector_target && !matches!(layer.content, LayerContent::Adjustment(_) | LayerContent::Fill(_)) {
return;
}
let t = Tokens::get(ctx);
@@ -2865,6 +2878,8 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
ui.push_id((doc.id, id, mask_target), |ui| {
if mask_target {
crate::mask_props_ui::properties(app, ui, layer);
} else if vector_target {
crate::mask_props_ui::vector_properties(app, ui, layer);
} else if let LayerContent::Adjustment(adj) = &layer.content {
adjustment_controls(app, ui, id, adj);
} else {
@@ -2953,6 +2968,14 @@ fn properties_body_inner(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
crate::mask_props_ui::properties(app, ui, layer);
return;
}
// The vector mask's own controls, when its thumbnail is targeted.
if let Some(layer) = st.active_layer.and_then(|id| doc.layer(id))
&& crate::mask_props_ui::vector_targeted(app, layer)
{
crate::props_layout::header(ui, layer);
crate::mask_props_ui::vector_properties(app, ui, layer);
return;
}
// Photoshop shows the Document properties when nothing or the Background layer is selected.
if crate::doc_props_ui::shows_document(&st.doc, st.active_layer) {
crate::doc_props_ui::properties(app, ui);