mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* Layers panel: Shift+Plus/Minus cycle blend modes; effects dropped on the Trash are deleted (#3102) - blend_keys.rs: Shift+Plus / Shift+Minus step to the next / previous blend mode, wrapping: the brush's mode with the Brush or Pencil (tools.setBrush), the Gradient's with the Gradient tool, otherwise the active layer's (layer.setProps; groups include Pass Through, the Background is left alone). One undo step per press. - panels.rs: an effect row dragged onto the footer's Delete button deletes that effect, the "Effects" row clears the whole style (layer.layerStyle.clear). - engine: layer.layerStyle.clear takes an optional "effect" index ("Delete Effect" step); bad indices and layers return Err. - scorecard UI-217-14 -> done (its wheel, footer drop and thumbnail checker parts were already on main via #1961, #965). Co-Authored-By: Claude Opus 5.5 <[email protected]> * Layers panel: Shift+Plus/Minus step the blend mode on a German layout too (#3102) On layouts with a key of its own for `+` (German, Italian, Spanish) Shift+`+` types `*`, which egui has no key for, so egui-winit reports the physical key: `]` on German. That matched Shift+] (Increase Brush Hardness) before the blend keys ever saw it. Shift+`-` types `_` and arrives as the physical `/`. shortcuts::layout_symbols now turns such a fallback press (Shift, the key equal to its physical key, not a digit, next to the text `*` or `_`) into `+` / `-` before any shortcut is dispatched. A US Shift+] still arrives as `}` and keeps raising the hardness; US Shift+8 (`*`) stays a digit for the opacity keys. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Shortcuts: Ö / # are the brush keys on a German layout (#3109) Photoshop's German shortcuts step the brush size with Ö / # and the hardness with Shift+Ö / Shift+#, since [ and ] need AltGr there (and that press carries Alt on the Mac and Windows, which the shortcut match rejects). egui can't name ö or #, so they arrived as the physical ; and \ and matched nothing; Shift+# types ' and arrived as Quote. shortcuts::layout_symbols (from #3102) now reads a table of typed symbol and Shift: ö / Ö -> [, # / ' -> ], next to * -> + and _ -> -. It runs at the top of shortcuts::handle (unless a text field has focus), so Liquify's and the Magnetic Lasso's [ ] get the German keys too. Only presses whose key egui took from the physical key or from that very text change, and never digits. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Layers panel: a mask dragged onto the Trash asks to apply or delete it (#3113) A drag that started on a layer's mask thumbnail dragged the whole row, and dropping it on the Trash deleted the layer. Now a drag from the pixel mask thumbnail (where it was drawn last frame) drags the mask: it shows a "Layer Mask" ghost and starts neither a layer reorder nor a cross-document transfer. Dropped on the Trash it opens Photoshop's prompt "Apply mask to layer before removing?" with Apply (layer.layerMask.apply, greyed out where it can't run), Delete (layer.layerMask.delete) and Cancel. Clicking the Trash still deletes the targeted mask at once (#2197). The prompt (mask_trash_prompt.rs) is an ordinary dialog: ui.dialog.confirm applies, ui.dialog.set {"choice": "delete"} + confirm deletes, cancel keeps. Two new strings in all 16 catalogs. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Brush keys: layout table plus bracket-position fallback for every layout (#3109) Co-Authored-By: Claude Opus 5.5 <[email protected]> * layout_keys: drop unused field, fmt Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Claude Opus 5.5 <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
21 files changed
+206
-3
No files matched your search
@@ -232,6 +232,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields),
|
||||
DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields),
|
||||
DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields),
|
||||
DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields),
|
||||
DialogKind::Command if crate::rasterize_prompt::owns(&fields) => crate::rasterize_prompt::body(ui, &fields),
|
||||
DialogKind::Command if crate::tool_reset::owns(&fields) => crate::tool_reset::body(ui),
|
||||
DialogKind::Command if crate::crop_presets::owns(&fields) => crate::crop_presets::body(app, ui, &mut fields),
|
||||
@@ -315,6 +316,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
let clicked = if d.kind == DialogKind::Command && crate::prefs_ui::is_preferences(&fields) {
|
||||
let changed = crate::prefs_ui::preferences_changed(app, &fields);
|
||||
dialog_buttons(ui, &[ok, cancel, DialogButton::new(ButtonRole::Apply, tl!("Apply"), 84.0).enabled(changed)])
|
||||
} else if crate::mask_trash_prompt::owns(&fields) {
|
||||
// Photoshop: Apply / Cancel / Delete. Delete answers with the choice set.
|
||||
let apply = DialogButton::new(ButtonRole::Default, tl!("Apply"), 84.0).enabled(crate::mask_trash_prompt::can_apply(&fields));
|
||||
let delete = DialogButton::new(ButtonRole::Alternate, tl!("Delete"), 84.0);
|
||||
let hit = dialog_buttons(ui, &[apply, cancel, delete]);
|
||||
if hit == Some(ButtonRole::Alternate) {
|
||||
fields.insert("choice".into(), json!("delete"));
|
||||
} else if hit == Some(ButtonRole::Default) {
|
||||
fields.insert("choice".into(), json!("apply"));
|
||||
}
|
||||
hit
|
||||
} else {
|
||||
dialog_buttons(ui, &[ok, cancel])
|
||||
};
|
||||
@@ -468,6 +480,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
||||
DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::rasterize_prompt::owns(&d.fields) => crate::rasterize_prompt::confirm(app, &d.fields),
|
||||
DialogKind::Command if crate::tool_reset::owns(&d.fields) => crate::tool_reset::confirm(app),
|
||||
DialogKind::Command if crate::crop_presets::owns(&d.fields) => crate::crop_presets::confirm(app, &d.fields),
|
||||
|
||||
@@ -3647,3 +3647,5 @@ navigator Zoom Zvětšení
|
||||
Fill with {mode}-neutral color (black) Vyplnit neutrální barvou pro režim {mode} (černá)
|
||||
Fill with {mode}-neutral color (50% gray) Vyplnit neutrální barvou pro režim {mode} (50% šedá)
|
||||
(No neutral color exists for {mode} mode.) (Pro režim {mode} neexistuje neutrální barva.)
|
||||
Apply mask to layer before removing? Použít masku na vrstvu před odstraněním?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Vrstva nebo její maska se změnila. Přetáhněte masku do koše znovu.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2650,3 +2650,5 @@ cameraData Lens Objektiv
|
||||
Fill with {mode}-neutral color (black) Mit neutraler Farbe für {mode} füllen (Schwarz)
|
||||
Fill with {mode}-neutral color (50% gray) Mit neutraler Farbe für {mode} füllen (50 % Grau)
|
||||
(No neutral color exists for {mode} mode.) (Für den Modus {mode} gibt es keine neutrale Farbe.)
|
||||
Apply mask to layer before removing? Maske vor dem Entfernen auf die Ebene anwenden?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Die Ebene oder ihre Maske hat sich geändert. Ziehen Sie die Maske erneut in den Papierkorb.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3710,3 +3710,5 @@ cameraData Lens Φακός
|
||||
Fill with {mode}-neutral color (black) Γέμισμα με το ουδέτερο χρώμα της λειτουργίας {mode} (μαύρο)
|
||||
Fill with {mode}-neutral color (50% gray) Γέμισμα με το ουδέτερο χρώμα της λειτουργίας {mode} (50% γκρι)
|
||||
(No neutral color exists for {mode} mode.) (Δεν υπάρχει ουδέτερο χρώμα για τη λειτουργία {mode}.)
|
||||
Apply mask to layer before removing? Εφαρμογή της μάσκας στο επίπεδο πριν από την αφαίρεση;
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Το επίπεδο ή η μάσκα του άλλαξε. Σύρετε ξανά τη μάσκα στον Κάδο.
|
||||
|
Can't render this file because it contains an unexpected character in line 1000 and column 18.
|
@@ -2691,3 +2691,5 @@ cameraData Lens Objetivo
|
||||
Fill with {mode}-neutral color (black) Rellenar con el color neutro de {mode} (negro)
|
||||
Fill with {mode}-neutral color (50% gray) Rellenar con el color neutro de {mode} (gris al 50%)
|
||||
(No neutral color exists for {mode} mode.) (No existe un color neutro para el modo {mode}.)
|
||||
Apply mask to layer before removing? ¿Aplicar la máscara a la capa antes de eliminarla?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. La capa o su máscara ha cambiado. Arrastre de nuevo la máscara a la papelera.
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -3028,3 +3028,5 @@ cameraData Lens Objectif
|
||||
Fill with {mode}-neutral color (black) Remplir avec la couleur neutre de {mode} (noir)
|
||||
Fill with {mode}-neutral color (50% gray) Remplir avec la couleur neutre de {mode} (gris 50 %)
|
||||
(No neutral color exists for {mode} mode.) (Il n’existe aucune couleur neutre pour le mode {mode}.)
|
||||
Apply mask to layer before removing? Appliquer le masque au calque avant de le supprimer ?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Le calque ou son masque a changé. Faites de nouveau glisser le masque sur la corbeille.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3015,3 +3015,5 @@ cameraData Lens Lensa
|
||||
Fill with {mode}-neutral color (black) Isi dengan warna netral {mode} (hitam)
|
||||
Fill with {mode}-neutral color (50% gray) Isi dengan warna netral {mode} (abu-abu 50%)
|
||||
(No neutral color exists for {mode} mode.) (Tidak ada warna netral untuk mode {mode}.)
|
||||
Apply mask to layer before removing? Terapkan masker ke layer sebelum dihapus?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Layer atau maskernya berubah. Seret masker ke Tempat Sampah lagi.
|
||||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
@@ -2682,3 +2682,5 @@ cameraData Lens Obiettivo
|
||||
Fill with {mode}-neutral color (black) Riempi con il colore neutro di {mode} (nero)
|
||||
Fill with {mode}-neutral color (50% gray) Riempi con il colore neutro di {mode} (grigio 50%)
|
||||
(No neutral color exists for {mode} mode.) (Non esiste un colore neutro per il metodo {mode}.)
|
||||
Apply mask to layer before removing? Applicare la maschera al livello prima di rimuoverla?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Il livello o la sua maschera è cambiato. Trascina di nuovo la maschera nel cestino.
|
||||
|
Can't render this file because it contains an unexpected character in line 2588 and column 53.
|
@@ -3622,3 +3622,5 @@ cameraData Lens レンズ
|
||||
Fill with {mode}-neutral color (black) {mode} の中間色で塗りつぶす (黒)
|
||||
Fill with {mode}-neutral color (50% gray) {mode} の中間色で塗りつぶす (50% グレー)
|
||||
(No neutral color exists for {mode} mode.) ({mode} モードには中間色がありません。)
|
||||
Apply mask to layer before removing? 削除する前にマスクをレイヤーに適用しますか?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. レイヤーまたはそのマスクが変更されました。もう一度マスクをゴミ箱にドラッグしてください。
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3195,3 +3195,5 @@ cameraData Lens 렌즈
|
||||
Fill with {mode}-neutral color (black) {mode} 중립 색상으로 채우기(검정)
|
||||
Fill with {mode}-neutral color (50% gray) {mode} 중립 색상으로 채우기(50% 회색)
|
||||
(No neutral color exists for {mode} mode.) ({mode} 모드에는 중립 색상이 없습니다.)
|
||||
Apply mask to layer before removing? 제거하기 전에 마스크를 레이어에 적용하시겠습니까?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. 레이어 또는 마스크가 변경되었습니다. 마스크를 휴지통으로 다시 드래그하세요.
|
||||
|
Can't render this file because it contains an unexpected character in line 3097 and column 53.
|
@@ -3772,3 +3772,5 @@ cameraData Lens Lens
|
||||
Fill with {mode}-neutral color (black) Vullen met neutrale kleur voor {mode} (zwart)
|
||||
Fill with {mode}-neutral color (50% gray) Vullen met neutrale kleur voor {mode} (50% grijs)
|
||||
(No neutral color exists for {mode} mode.) (Voor de modus {mode} bestaat geen neutrale kleur.)
|
||||
Apply mask to layer before removing? Masker toepassen op laag voordat het wordt verwijderd?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. De laag of het masker is gewijzigd. Sleep het masker opnieuw naar de prullenbak.
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 34.
|
@@ -3624,3 +3624,5 @@ cameraData Lens Obiektyw
|
||||
Fill with {mode}-neutral color (black) Wypełnij kolorem neutralnym dla trybu {mode} (czarny)
|
||||
Fill with {mode}-neutral color (50% gray) Wypełnij kolorem neutralnym dla trybu {mode} (50% szarości)
|
||||
(No neutral color exists for {mode} mode.) (Dla trybu {mode} nie istnieje kolor neutralny.)
|
||||
Apply mask to layer before removing? Zastosować maskę do warstwy przed usunięciem?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Warstwa lub jej maska uległa zmianie. Przeciągnij maskę ponownie do kosza.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3802,3 +3802,5 @@ layerLabel No Color Sem cor
|
||||
Fill with {mode}-neutral color (black) Preencher com a cor neutra de {mode} (preto)
|
||||
Fill with {mode}-neutral color (50% gray) Preencher com a cor neutra de {mode} (cinza 50%)
|
||||
(No neutral color exists for {mode} mode.) (Não existe cor neutra para o modo {mode}.)
|
||||
Apply mask to layer before removing? Aplicar a máscara à camada antes de removê-la?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. A camada ou a máscara mudou. Arraste a máscara para a Lixeira novamente.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2957,3 +2957,5 @@ cameraData Lens Объектив
|
||||
Fill with {mode}-neutral color (black) Залить нейтральным цветом режима «{mode}» (черный)
|
||||
Fill with {mode}-neutral color (50% gray) Залить нейтральным цветом режима «{mode}» (50% серого)
|
||||
(No neutral color exists for {mode} mode.) (Для режима «{mode}» нет нейтрального цвета.)
|
||||
Apply mask to layer before removing? Применить маску к слою перед удалением?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Слой или его маска изменились. Перетащите маску в корзину ещё раз.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3710,3 +3710,5 @@ cameraData Lens Об'єктив
|
||||
Fill with {mode}-neutral color (black) Залити нейтральним кольором режиму «{mode}» (чорний)
|
||||
Fill with {mode}-neutral color (50% gray) Залити нейтральним кольором режиму «{mode}» (50% сірого)
|
||||
(No neutral color exists for {mode} mode.) (Для режиму «{mode}» немає нейтрального кольору.)
|
||||
Apply mask to layer before removing? Застосувати маску до шару перед видаленням?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. Шар або його маска змінилися. Перетягніть маску до кошика ще раз.
|
||||
|
Can't render this file because it contains an unexpected character in line 1356 and column 18.
|
@@ -2630,3 +2630,5 @@ cameraData Lens 镜头
|
||||
Fill with {mode}-neutral color (black) 使用{mode}中性色填充(黑色)
|
||||
Fill with {mode}-neutral color (50% gray) 使用{mode}中性色填充(50% 灰色)
|
||||
(No neutral color exists for {mode} mode.) ({mode}模式不存在中性色。)
|
||||
Apply mask to layer before removing? 移去之前将蒙版应用到图层吗?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. 图层或其蒙版已更改。请再次将蒙版拖到垃圾桶。
|
||||
|
Can't render this file because it contains an unexpected character in line 2530 and column 53.
|
@@ -2600,3 +2600,5 @@ cameraData Lens 鏡頭
|
||||
Fill with {mode}-neutral color (black) 使用{mode}中性色填滿(黑色)
|
||||
Fill with {mode}-neutral color (50% gray) 使用{mode}中性色填滿(50% 灰色)
|
||||
(No neutral color exists for {mode} mode.) ({mode}模式沒有中性色。)
|
||||
Apply mask to layer before removing? 移除之前將遮色片套用到圖層嗎?
|
||||
The layer or its mask changed. Drag the mask onto the Trash again. 圖層或其遮色片已變更。請再次將遮色片拖曳到垃圾桶。
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -109,6 +109,7 @@ pub mod liquify_ui;
|
||||
pub mod magnetic_lasso_ui;
|
||||
mod mask_props_ui;
|
||||
pub mod mask_thumbs_ui;
|
||||
pub mod mask_trash_prompt;
|
||||
pub mod menu_catalog;
|
||||
pub mod menu_nav;
|
||||
pub mod menus;
|
||||
@@ -863,6 +864,9 @@ impl PhotocraftApp {
|
||||
if let Some(result) = delete_layer_prompt::intercept(self, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
if let Some(result) = mask_trash_prompt::intercept(self, id, ¶ms) {
|
||||
return result;
|
||||
}
|
||||
// Automation input also gates every step a command runs on its behalf (`actions.play`).
|
||||
let gate = if self.automation_input && self.session.authorize.is_none() { self.services.automation_authorize } else { None };
|
||||
if gate.is_some() {
|
||||
|
||||
@@ -190,6 +190,59 @@ fn dragging_a_layer_row_to_trash_still_deletes_the_layer_with_a_mask_targeted()
|
||||
assert!(h.state().session.active().unwrap().doc.layer(photocraft_doc::LayerId(masked)).is_none());
|
||||
}
|
||||
|
||||
/// #3113: dragging the layer mask's thumbnail onto the Trash asks "Apply mask to layer before
|
||||
/// removing?" and keeps the layer; Apply applies the mask, Delete deletes it.
|
||||
#[test]
|
||||
fn dragging_the_mask_thumbnail_to_trash_asks_to_apply_or_delete_it() {
|
||||
let (mut s, masked, _) = session();
|
||||
// A mask that hides the left half, so applying it changes the pixels.
|
||||
s.execute("layer.layerMask.delete", json!({"layer": masked})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 100, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
s.execute("layer.layerMask.revealSelection", json!({"layer": masked})).unwrap();
|
||||
s.execute("select.deselect", json!({})).unwrap();
|
||||
let mut h = harness(s, 0, 1.0, 290.0);
|
||||
let drag_mask = |h: &mut Harness<'_, PhotocraftApp>| {
|
||||
let from = recorded(&h.ctx, masked).unwrap().0.into_iter().find(|(k, _)| *k == MaskKind::Pixel).unwrap().1.center();
|
||||
click_with(h, from, Modifiers::NONE);
|
||||
let to = h.get_by_label("Delete Layer Mask").rect().center();
|
||||
h.hover_at(from);
|
||||
h.run_steps(1);
|
||||
h.event(egui::Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for k in 1..=8 {
|
||||
h.event(egui::Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let d = h.state().ui.dialogs.iter().find(|d| crate::mask_trash_prompt::owns(&d.fields)).expect("the prompt is open");
|
||||
assert_eq!(d.fields["message"], "Apply mask to layer before removing?");
|
||||
assert!(layer(h, masked).mask.is_some(), "nothing changes before the answer");
|
||||
d.id
|
||||
};
|
||||
let pixels = |h: &Harness<'_, PhotocraftApp>| layer(h, masked).content;
|
||||
let before = pixels(&h);
|
||||
let past = h.state().session.active().unwrap().history.past_len();
|
||||
// Cancel keeps the mask.
|
||||
let dialog = drag_mask(&mut h);
|
||||
crate::dialogs::cancel(h.state_mut(), dialog).unwrap();
|
||||
assert!(layer(&h, masked).mask.is_some());
|
||||
// Apply: one step, the mask is gone and the layer's pixels are masked.
|
||||
let dialog = drag_mask(&mut h);
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
assert!(layer(&h, masked).mask.is_none());
|
||||
assert_ne!(pixels(&h), before, "applied, not deleted");
|
||||
assert_eq!(h.state().session.active().unwrap().history.past_len(), past + 1);
|
||||
h.state_mut().session.undo();
|
||||
h.run_steps(2);
|
||||
// Delete: the mask is gone and the pixels are as they were.
|
||||
let dialog = drag_mask(&mut h);
|
||||
h.state_mut().ui.dialog_mut(dialog).unwrap().fields.insert("choice".into(), json!("delete"));
|
||||
crate::dialogs::confirm(h.state_mut(), dialog).unwrap();
|
||||
assert!(layer(&h, masked).mask.is_none());
|
||||
assert_eq!(pixels(&h), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chain_toggles_linking_and_it_survives_psd() {
|
||||
let (s, masked, _) = session();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! A layer mask dragged onto the Layers panel's Trash asks first, as in Photoshop (#3113): "Apply
|
||||
//! mask to layer before removing?" Apply runs `layer.layerMask.apply`, Delete
|
||||
//! `layer.layerMask.delete`, Cancel keeps the mask. A click on the Trash still deletes the
|
||||
//! targeted mask at once (#2197).
|
||||
//!
|
||||
//! The prompt is an ordinary dialog in the UI state: `ui.inspect` shows it, `ui.dialog.confirm`
|
||||
//! applies, `ui.dialog.set` with `"choice": "delete"` then `ui.dialog.confirm` deletes, and
|
||||
//! `ui.dialog.cancel` keeps the mask.
|
||||
|
||||
use photocraft_doc::LayerId;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
use crate::state::DialogKind;
|
||||
|
||||
const MARK: &str = "__maskTrash";
|
||||
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.contains_key(MARK)
|
||||
}
|
||||
|
||||
/// The action a mask thumbnail dropped on the Trash pushes; [`intercept`] turns it into the prompt.
|
||||
pub fn drop_action(layer: u64) -> (String, Value) {
|
||||
("layer.layerMask.delete".into(), json!({"layer": layer, MARK: true}))
|
||||
}
|
||||
|
||||
/// Opens the prompt for a [`drop_action`]; `None` for every other command.
|
||||
pub fn intercept(app: &mut PhotocraftApp, command: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
if command != "layer.layerMask.delete" || params.get(MARK).and_then(Value::as_bool) != Some(true) {
|
||||
return None;
|
||||
}
|
||||
let layer = params.get("layer").and_then(Value::as_u64)?;
|
||||
let state = app.session.active()?;
|
||||
state.doc.layer(LayerId(layer)).filter(|l| l.mask.is_some())?;
|
||||
// Apply needs a pixel layer (Layer › Layer Mask › Apply is greyed out otherwise).
|
||||
let can_apply = app.session.is_enabled_with("layer.layerMask.apply", &json!({"layer": layer}));
|
||||
let fields = json!({MARK: true, "__label": "Delete Layer Mask", "message": "Apply mask to layer before removing?",
|
||||
"document": state.doc.id.0, "layer": layer, "canApply": can_apply, "choice": if can_apply { "apply" } else { "delete" }});
|
||||
if app.ui.dialogs.iter().any(|d| owns(&d.fields)) {
|
||||
return Some(Ok(json!({"confirmationPending": true})));
|
||||
}
|
||||
let id = app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default());
|
||||
Some(Ok(json!({"dialog": id, "confirmationPending": true})))
|
||||
}
|
||||
|
||||
pub fn body(ui: &mut egui::Ui, fields: &Map<String, Value>) {
|
||||
ui.label(tl!(fields.get("message").and_then(Value::as_str).unwrap_or("Apply mask to layer before removing?")));
|
||||
}
|
||||
|
||||
/// Apply is the default button; it's greyed out where the mask can't be applied.
|
||||
pub fn can_apply(fields: &Map<String, Value>) -> bool {
|
||||
fields.get("canApply").and_then(Value::as_bool).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Runs the answer: `choice` "delete" deletes the mask, anything else applies it.
|
||||
pub fn confirm(app: &mut PhotocraftApp, fields: &Map<String, Value>) -> Result<Value, String> {
|
||||
let state = app.session.active().ok_or(tl!("The document is closed"))?;
|
||||
let layer = fields.get("layer").and_then(Value::as_u64);
|
||||
let same = fields.get("document").and_then(Value::as_u64) == Some(state.doc.id.0);
|
||||
let Some(layer) = layer.filter(|id| same && state.doc.layer(LayerId(*id)).is_some_and(|l| l.mask.is_some())) else {
|
||||
return Err(tl!("The layer or its mask changed. Drag the mask onto the Trash again.").into());
|
||||
};
|
||||
let command = if fields.get("choice").and_then(Value::as_str) == Some("delete") { "layer.layerMask.delete" } else { "layer.layerMask.apply" };
|
||||
app.run_command(command, json!({"layer": layer}))
|
||||
}
|
||||
@@ -2196,6 +2196,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
// An fx drag that never saw its release (the panel was hidden) must not outlive the button.
|
||||
if !ctx.input(|i| i.pointer.primary_down() || i.pointer.any_released()) {
|
||||
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
|
||||
ctx.data_mut(|d| d.remove::<u64>(mask_drag_key()));
|
||||
}
|
||||
let held = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("layer-drag")).is_some() || d.get_temp::<FxDrag>(fx_drag_key()).is_some());
|
||||
let dragging = held && ctx.input(|i| i.pointer.primary_down());
|
||||
@@ -2268,13 +2269,20 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
let in_selection = selection.iter().any(|s| s.0 == id);
|
||||
(id, in_selection)
|
||||
});
|
||||
// An effect row can be dropped on Delete too (#3102).
|
||||
// An effect row can be dropped on Delete too (#3102), and a layer mask's thumbnail (#3113).
|
||||
let fx_footer_drag = ctx.data(|d| d.get_temp::<FxDrag>(fx_drag_key()));
|
||||
fx_drag_feedback(&ctx, &doc);
|
||||
let mask_footer_drag = ctx.data(|d| d.get_temp::<u64>(mask_drag_key()));
|
||||
if mask_footer_drag.is_some()
|
||||
&& let Some(p) = ctx.input(|i| i.pointer.interact_pos())
|
||||
{
|
||||
crate::layer_transfer::ghost(&ctx, p, tl!("Layer Mask"));
|
||||
}
|
||||
// End any layer drag after every row has had a chance to accept the drop.
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
ctx.data_mut(|d| d.remove::<u64>(egui::Id::new("layer-drag")));
|
||||
ctx.data_mut(|d| d.remove::<FxDrag>(fx_drag_key()));
|
||||
ctx.data_mut(|d| d.remove::<u64>(mask_drag_key()));
|
||||
}
|
||||
widgets::panel_footer(ui, |ui| {
|
||||
let active_layer = active.and_then(|id| doc.layer(id));
|
||||
@@ -2294,6 +2302,12 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete"));
|
||||
actions.extend(fx_trash_drop(ui, &trash, fx_footer_drag));
|
||||
// Photoshop asks "Apply mask to layer before removing?" (`mask_trash_prompt`).
|
||||
if let Some(layer) = mask_footer_drag
|
||||
&& dropped_on(ui, &trash)
|
||||
{
|
||||
actions.push(crate::mask_trash_prompt::drop_action(layer));
|
||||
}
|
||||
// A click follows the selected thumbnail; name that action for screen readers even
|
||||
// though dropping a whole layer row onto the same button always deletes the layer.
|
||||
trash.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(label)));
|
||||
@@ -2550,9 +2564,14 @@ fn layer_row(
|
||||
// Photoshop's default (medium) thumbnails: 32 pt rows.
|
||||
let row_h = if t.pro { 32.0 } else { 46.0 };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
|
||||
// A drag from the layer mask's thumbnail drags the mask, not the layer (#3113).
|
||||
let mask_drag = starts_mask_drag(ctx, l, &resp);
|
||||
if mask_drag {
|
||||
ctx.data_mut(|d| d.insert_temp(mask_drag_key(), l.id.0));
|
||||
}
|
||||
layer_drag_and_drop(app, ctx, ui, l, rect, &resp, actions);
|
||||
fx_drop(ctx, ui, l, rect, actions);
|
||||
if resp.drag_started() {
|
||||
if resp.drag_started() && !mask_drag {
|
||||
crate::layer_transfer::begin_from_panel(app, ctx, l.id);
|
||||
}
|
||||
// Rows are painted: name them for screen readers and UI tests.
|
||||
@@ -3521,7 +3540,9 @@ fn layer_drag_and_drop(
|
||||
) {
|
||||
let t = Tokens::get(ctx);
|
||||
let key = egui::Id::new("layer-drag");
|
||||
if resp.drag_started() {
|
||||
// A drag from the mask thumbnail (`starts_mask_drag`) moves the mask, not the layer.
|
||||
let mask_drag = ctx.data(|d| d.get_temp::<u64>(mask_drag_key())) == Some(l.id.0);
|
||||
if resp.drag_started() && !mask_drag {
|
||||
ctx.data_mut(|d| d.insert_temp(key, l.id.0));
|
||||
}
|
||||
let Some(dragged) = ctx.data(|d| d.get_temp::<u64>(key)) else { return };
|
||||
@@ -3640,6 +3661,21 @@ fn layer_drop_zone(
|
||||
/// row, all of them).
|
||||
type FxDrag = (u64, Option<usize>);
|
||||
|
||||
/// The layer whose mask thumbnail is being dragged (#3113).
|
||||
fn mask_drag_key() -> egui::Id {
|
||||
egui::Id::new("mask-drag")
|
||||
}
|
||||
|
||||
/// Does the row's drag start on its layer mask's thumbnail (where it was drawn last frame)?
|
||||
fn starts_mask_drag(ctx: &egui::Context, l: &Layer, resp: &egui::Response) -> bool {
|
||||
if !resp.drag_started() || l.mask.is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = ctx.input(|i| i.pointer.press_origin()) else { return false };
|
||||
crate::mask_thumbs_ui::recorded(ctx, l.id.0)
|
||||
.is_some_and(|(thumbs, _)| thumbs.iter().any(|(k, r)| *k == crate::mask_thumbs_ui::MaskKind::Pixel && r.contains(p)))
|
||||
}
|
||||
|
||||
fn fx_drag_key() -> egui::Id {
|
||||
egui::Id::new("fx-drag")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user