diff --git a/crates/ui-egui/src/layer_row_ui/edit_tests.rs b/crates/ui-egui/src/layer_row_ui/edit_tests.rs index 6e33e044f5..829df351c7 100644 --- a/crates/ui-egui/src/layer_row_ui/edit_tests.rs +++ b/crates/ui-egui/src/layer_row_ui/edit_tests.rs @@ -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(); diff --git a/crates/ui-egui/src/mask_props_ui.rs b/crates/ui-egui/src/mask_props_ui.rs index bfbeeb5098..91a3f33bc0 100644 --- a/crates/ui-egui/src/mask_props_ui.rs +++ b/crates/ui-egui/src/mask_props_ui.rs @@ -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 diff --git a/crates/ui-egui/src/mask_props_ui_tests.rs b/crates/ui-egui/src/mask_props_ui_tests.rs index d65ac275ab..d02af4f257 100644 --- a/crates/ui-egui/src/mask_props_ui_tests.rs +++ b/crates/ui-egui/src/mask_props_ui_tests.rs @@ -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 { let s = h.state().session.active().unwrap(); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ae1b57b31a..a0e620766d 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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);