mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-14 14:12:44 +00:00
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:
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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user