diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0b71151fd3..5533e2c81e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1959,6 +1959,11 @@ Slice Select Tool Nástroj výběr řezu Slice Tool Nástroj řez Smart Filters Chytré filtry +# Smart Filter row actions (#1265) + Move Up Přesunout nahoru + Move Down Přesunout dolů + Delete Smart Filter Odstranit chytrý filtr + Smudge Tool Nástroj rozmazání Soft Měkké Soften Změkčení diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 30ad46a03d..8de749d09a 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1919,6 +1919,11 @@ Slice Select Tool Slice-Auswahl-Werkzeug Slice Tool Slice-Werkzeug Smart Filters Smart-Filter +# Smart Filter row actions (#1265) + Move Up Nach oben verschieben + Move Down Nach unten verschieben + Delete Smart Filter Smartfilter löschen + Smudge Tool Wischfinger-Werkzeug Soft Weich Soften Erweichen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8cde0d875d..c6544b205e 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -553,6 +553,11 @@ Smart Object Έξυπνο αντικείμενο Smart Objects Έξυπνα αντικείμενα Smart Filters Έξυπνα φίλτρα +# Smart Filter row actions (#1265) + Move Up Μετακίνηση πάνω + Move Down Μετακίνηση κάτω + Delete Smart Filter Διαγραφή έξυπνου φίλτρου + Adjustment Layer Επίπεδο προσαρμογής Adjustment Layers Επίπεδα προσαρμογής Pixel Layer Επίπεδο εικονοστοιχείων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1f41d57ea6..720121b7c1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1890,6 +1890,11 @@ Slice Select Tool Herramienta Selección de sector Slice Tool Herramienta Sector Smart Filters Filtros inteligentes +# Smart Filter row actions (#1265) + Move Up Mover arriba + Move Down Mover abajo + Delete Smart Filter Eliminar filtro inteligente + Smudge Tool Herramienta Dedo Soft Suave Soften Suavizar diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b1c0ce6e41..5ef0388da9 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1899,6 +1899,11 @@ Slice Select Tool Outil Sélection de tranche Slice Tool Outil Tranche Smart Filters Filtres intelligents +# Smart Filter row actions (#1265) + Move Up Déplacer vers le haut + Move Down Déplacer vers le bas + Delete Smart Filter Supprimer le filtre intelligent + Smudge Tool Outil Doigt Soft Doux Soften Adoucir diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index b79eb95f92..93d7870fdd 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1731,6 +1731,11 @@ fontWeight Thin Hajszálvékony Show the layer's effects ({key}-click: all layers) Réteg effektusainak megjelenítése ({key}+kattintás: minden réteg) Skip Kihagy Smart Filters Intelligens szűrők +# Smart Filter row actions. + Move Up Mozgatás felfelé + Move Down Mozgatás lefelé + Delete Smart Filter Intelligens szűrő törlése + Soft Light Lágy fény Soften Lágyítás Source Forrás diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index fddc0118ca..19dafcb014 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1907,6 +1907,11 @@ Slice Select Tool Alat Slice Select Slice Tool Alat Slice Smart Filters Smart Filter +# Smart Filter row actions (#1265) + Move Up Pindahkan ke Atas + Move Down Pindahkan ke Bawah + Delete Smart Filter Hapus Smart Filter + Smudge Tool Alat Smudge Soft Lembut Soften Lembutkan diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f03f0dd7a8..0e8d595a09 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1935,6 +1935,11 @@ Slice Select Tool Strumento Selezione sezioni Slice Tool Strumento Sezione Smart Filters Filtri avanzati +# Smart Filter row actions (#1265) + Move Up Sposta su + Move Down Sposta giù + Delete Smart Filter Elimina filtro avanzato + Smudge Tool Strumento Sfumino Soft Morbido Soften Ammorbidisci diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index cdebe57aeb..520d27522f 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1943,6 +1943,11 @@ Slice Select Tool スライス選択ツール Slice Tool スライスツール Smart Filters スマートフィルター +# Smart Filter row actions (#1265) + Move Up 上へ移動 + Move Down 下へ移動 + Delete Smart Filter スマートフィルターを削除 + Smudge Tool 指先ツール Soft ソフト Soften ぼかし diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2a3f7dab03..36f7e93279 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1919,6 +1919,11 @@ Slice Select Tool 분할 영역 선택 도구 Slice Tool 분할 영역 도구 Smart Filters 스마트 필터 +# Smart Filter row actions (#1265) + Move Up 위로 이동 + Move Down 아래로 이동 + Delete Smart Filter 스마트 필터 삭제 + Smudge Tool 손가락 도구 Soft 부드럽게 Soften 부드럽게 하기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 5adf0ceba6..c6b87e5199 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -1866,6 +1866,11 @@ Slice Select Tool Segmentselectiegereedschap Slice Tool Segmentgereedschap Smart Filters Slimme filters +# Smart Filter row actions (#1265) + Move Up Omhoog verplaatsen + Move Down Omlaag verplaatsen + Delete Smart Filter Slim filter verwijderen + Smudge Tool Uitsmeergereedschap Soft Zacht Soften Verzachten diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index e937cc45dc..9c1cd6a87d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1930,6 +1930,11 @@ Slice Select Tool Narzędzie Zaznaczanie plasterków Slice Tool Narzędzie Plasterek Smart Filters Filtry inteligentne +# Smart Filter row actions (#1265) + Move Up Przenieś w górę + Move Down Przenieś w dół + Delete Smart Filter Usuń filtr inteligentny + Smudge Tool Narzędzie Palec Soft Miękkie Soften Zmiękcz diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index ff7bbde9c0..4fd466e031 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1892,6 +1892,11 @@ Slice Select Tool Ferramenta Seleção de fatia Slice Tool Ferramenta Fatia Smart Filters Filtros inteligentes +# Smart Filter row actions (#1265) + Move Up Mover para cima + Move Down Mover para baixo + Delete Smart Filter Excluir filtro inteligente + Smudge Tool Ferramenta Borrar Soft Suave Soften Suavizar diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f621fb6e3b..76b81f69ca 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -420,6 +420,11 @@ New Video Layer from File… Новый видеослой из файла… New Blank Video Layer Новый пустой видеослой Smart Filters Смарт-фильтры +# Smart Filter row actions (#1265) + Move Up Переместить вверх + Move Down Переместить вниз + Delete Smart Filter Удалить смарт-фильтр + Delete Smart Filters Удалить смарт-фильтры Enable Smart Filters Включить смарт-фильтры Disable Smart Filters Отключить смарт-фильтры diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7f77d2cdbd..6861678a3b 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1906,6 +1906,11 @@ Slice Select Tool Alatka za izbor isečka Slice Tool Alatka za isecanje na isečke Smart Filters Pametni filteri +# Smart Filter row actions (#1265). + Move Up Pomeri nagore + Move Down Pomeri nadole + Delete Smart Filter Obriši pametni filter + Smudge Tool Alatka za razmazivanje Soft Meko Soften Omekšaj diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef7eca8806..dfaeec35cb 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1819,6 +1819,11 @@ Slice Select Tool Dilim Seçme Aracı Slice Tool Dilim Aracı Smart Filters Akıllı Filtreler +# Smart Filter row actions. + Move Up Yukarı Taşı + Move Down Aşağı Taşı + Delete Smart Filter Akıllı Filtreyi Sil + Smudge Tool Leke Aracı Soft Yumuşak Soften Yumuşat diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 77536d7d28..6936491ef3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -400,6 +400,11 @@ New Video Layer from File… Новий відеошар із файлу… New Blank Video Layer Новий порожній відеошар Smart Filters Смартфільтри +# Smart Filter row actions (#1265) + Move Up Перемістити вгору + Move Down Перемістити вниз + Delete Smart Filter Видалити смарт-фільтр + Delete Smart Filters Видалити смартфільтри Enable Smart Filters Увімкнути смартфільтри Disable Smart Filters Вимкнути смартфільтри diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index b3fb5d3cdb..8ac14760ba 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2728,6 +2728,11 @@ shape Appearance Diện mạo SemiBold Nửa đậm Show Extras Options Tuỳ chọn hiện phụ Smart Filters Các bộ lọc thông minh +# Smart Filter row actions. + Move Up Di chuyển lên + Move Down Di chuyển xuống + Delete Smart Filter Xóa bộ lọc thông minh + Soft Mềm Source: set Nguồn: bộ Square, 12 x 12 Vuông, 12 x 12 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 49e0dd5a3b..feffaa81f7 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1968,6 +1968,11 @@ Slice Select Tool 切片选择工具 Slice Tool 切片工具 Smart Filters 智能滤镜 +# Smart Filter row actions (#1265) + Move Up 上移 + Move Down 下移 + Delete Smart Filter 删除智能滤镜 + Smudge Tool 涂抹工具 Soft 软 Soften 柔化 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 38cc69e833..1fcbf4dd74 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1938,6 +1938,11 @@ Slice Select Tool 切片選取工具 Slice Tool 切片工具 Smart Filters 智慧型濾鏡 +# Smart Filter row actions (#1265) + Move Up 上移 + Move Down 下移 + Delete Smart Filter 刪除智慧型濾鏡 + Smudge Tool 指尖工具 Soft 柔邊 Soften 柔化 diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index d83c1cbbb4..924440b656 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -105,6 +105,7 @@ fn inactive_blend_rows(ui: &mut egui::Ui, pro: bool) { } pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + crate::smart_ui::context_menu::close_stale(app, ui.ctx()); // A new active layer opens its parent groups and is scrolled into view (#152). let reveal = crate::layer_reveal::track(app, ui.ctx()); let Some(st) = app.session.active() else { @@ -548,7 +549,12 @@ pub(super) fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } continue; } - let _ = app.run(&id, p); + let selection = crate::smart_ui::context_menu::selection_update(app, ui.ctx(), &id, &p); + if app.run(&id, p).is_ok() + && let Some(selection) = selection + { + selection.apply(ui.ctx()); + } } } diff --git a/crates/ui-egui/src/panels/smart_ui.rs b/crates/ui-egui/src/panels/smart_ui.rs index 8e531b47b6..19fa6c73b8 100644 --- a/crates/ui-egui/src/panels/smart_ui.rs +++ b/crates/ui-egui/src/panels/smart_ui.rs @@ -205,6 +205,9 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, doc: &Document, l { open_editor(app, ui.ctx(), l.id.0, sm, i); } + if let Some(i) = index { + context_menu::show(app, &resp, l.id, i, actions); + } if let (Some(i), Some(r)) = (index, blend) { let blend_resp = ui.interact(r, ui.scope_id().with(("sf-blend", l.id.0, i)), Sense::click()); icons::paint(ui, r, "sliders-horizontal", 13.0, if blend_resp.hovered() { t.text } else { t.icon }); @@ -849,3 +852,9 @@ mod tests { } } } + +pub(crate) mod context_menu; +#[cfg(test)] +mod context_tests; +#[cfg(test)] +mod menu_guard_tests; diff --git a/crates/ui-egui/src/panels/smart_ui/context_menu.rs b/crates/ui-egui/src/panels/smart_ui/context_menu.rs new file mode 100644 index 0000000000..7623699969 --- /dev/null +++ b/crates/ui-egui/src/panels/smart_ui/context_menu.rs @@ -0,0 +1,171 @@ +//! Per-filter reorder/delete convenience actions. The engine owns edits and Undo/Redo; a +//! transient weak snapshot prevents an open menu from following a different filter or document. + +use std::sync::{Arc, Weak}; + +use photocraft_doc::{DocId, Document, LayerContent, LayerId}; +use serde_json::{Value, json}; + +use crate::PhotocraftApp; + +#[derive(Clone)] +struct Target { + snapshot: Weak, + document: DocId, + revision: u64, + layer: LayerId, + index: usize, + popup: egui::Id, +} + +fn state_id() -> egui::Id { + egui::Id::unique("smart-filter-row-menu-target") +} + +#[derive(Clone)] +struct PendingSelection { + target: Target, + command: &'static str, + params: Value, + to: Option, +} + +fn pending_id() -> egui::Id { + egui::Id::unique("smart-filter-row-menu-selection") +} + +/// A menu edit changes storage indices. Apply its row selection only after the engine succeeds. +pub(crate) struct SelectionUpdate { + document: DocId, + before: super::FilterDrag, + after: Option, +} + +impl SelectionUpdate { + pub(crate) fn apply(self, ctx: &egui::Context) { + ctx.data_mut(|data| { + let key = super::selected_key(self.document); + if data.get_temp::(key) == Some(self.before) { + if let Some(after) = self.after { + data.insert_temp(key, after); + } else { + data.remove::(key); + } + } + }); + } +} + +/// Only choices queued by this row menu remap selection; other command origins keep their policy. +pub(crate) fn selection_update(app: &PhotocraftApp, ctx: &egui::Context, command: &str, params: &Value) -> Option { + let pending = ctx.data(|d| d.get_temp::(pending_id()))?; + if pending.command != command || &pending.params != params { + return None; + } + ctx.data_mut(|d| d.remove::(pending_id())); + let count = pending.target.count(app)?; + let before = ctx.data(|d| d.get_temp::(super::selected_key(pending.target.document)))?; + let (layer, Some(picked)) = before else { return None }; + if layer != pending.target.layer.0 || picked >= count { + return None; + } + let from = pending.target.index; + let next = match pending.to { + Some(to) if picked == from => Some(to), + Some(to) if from < to && picked > from && picked <= to => picked.checked_sub(1), + Some(to) if from > to && picked >= to && picked < from => picked.checked_add(1), + None if picked == from => None, + None if picked > from => picked.checked_sub(1), + _ => Some(picked), + }; + Some(SelectionUpdate { document: pending.target.document, before, after: next.map(|index| (layer, Some(index))) }) +} + +fn queue(ctx: &egui::Context, target: &Target, command: &'static str, params: Value, to: Option, actions: &mut Vec<(String, Value)>) { + ctx.data_mut(|d| d.insert_temp(pending_id(), PendingSelection { target: target.clone(), command, params: params.clone(), to })); + actions.push((command.into(), params)); +} + +impl Target { + fn count(&self, app: &PhotocraftApp) -> Option { + let st = app.session.active()?; + if st.doc.id != self.document + || st.revision != self.revision + || !self.snapshot.ptr_eq(&Arc::downgrade(&st.doc)) + || app.session.job_on(self.document).is_some() + || !crate::menus::modal_allows(app, "layer.smartFilter.move") + { + return None; + } + let LayerContent::Smart(sm) = &st.doc.layer(self.layer)?.content else { return None }; + sm.smart_filters.get(self.index)?; + Some(sm.smart_filters.len()) + } + + fn params(&self) -> Value { + json!({"layer": self.layer.0, "index": self.index}) + } + + fn enabled(&self, app: &mut PhotocraftApp, command: &str) -> bool { + // The immutable menu query checks the active layer. Query the named layer, like the + // engine's explicit-layer dispatch, then restore it without changing selection/history. + let Some(st) = app.session.active_mut() else { return false }; + let active = st.active_layer.replace(self.layer); + let enabled = app.session.is_enabled_with(command, &self.params()); + if let Some(st) = app.session.active_mut() { + st.active_layer = active; + } + enabled + } +} + +/// Runs even when the new document has no filter rows, so a stale popup cannot remain open. +pub(crate) fn close_stale(app: &PhotocraftApp, ctx: &egui::Context) { + let target: Option = ctx.data(|d| d.get_temp(state_id())); + if let Some(target) = target + && (target.count(app).is_none() || !egui::Popup::is_id_open(ctx, target.popup)) + { + egui::Popup::close_id(ctx, target.popup); + ctx.data_mut(|d| d.remove::(state_id())); + ctx.data_mut(|d| d.remove::(pending_id())); + } +} + +pub(crate) fn show(app: &mut PhotocraftApp, response: &egui::Response, layer: LayerId, index: usize, actions: &mut Vec<(String, Value)>) { + let ctx = &response.ctx; + let popup = egui::Id::unique(("smart-filter-row-menu", layer.0, index)); + if response.secondary_clicked() + && let Some(st) = app.session.active() + { + let target = Target { snapshot: Arc::downgrade(&st.doc), document: st.doc.id, revision: st.revision, layer, index, popup }; + if target.count(app).is_some() { + ctx.data_mut(|d| d.insert_temp(state_id(), target)); + } + } + let Some(target) = ctx.data(|d| d.get_temp::(state_id())).filter(|t| t.popup == popup) else { return }; + let Some(count) = target.count(app) else { + egui::Popup::close_id(ctx, popup); + ctx.data_mut(|d| d.remove::(state_id())); + return; + }; + egui::Popup::context_menu(response).id(popup).show(|ui| { + ui.set_min_width(180.0); + let enabled = target.enabled(app, "layer.smartFilter.move"); + // Filters display in reverse storage order: index + 1 moves the row visually up. + let up = index.checked_add(1).filter(|to| *to < count); + let down = index.checked_sub(1); + for (label, to) in [(tl!("Move Up"), up), (tl!("Move Down"), down)] { + if ui.add_enabled(enabled && to.is_some(), egui::Button::new(label)).clicked() + && let Some(to) = to + { + queue(ctx, &target, "layer.smartFilter.move", json!({"layer": target.layer.0, "index": target.index, "to": to}), Some(to), actions); + ui.close(); + } + } + ui.separator(); + if ui.add_enabled(target.enabled(app, "layer.smartFilter.delete"), egui::Button::new(tl!("Delete Smart Filter"))).clicked() { + queue(ctx, &target, "layer.smartFilter.delete", target.params(), None, actions); + ui.close(); + } + }); +} diff --git a/crates/ui-egui/src/panels/smart_ui/context_tests.rs b/crates/ui-egui/src/panels/smart_ui/context_tests.rs new file mode 100644 index 0000000000..1cd002e845 --- /dev/null +++ b/crates/ui-egui/src/panels/smart_ui/context_tests.rs @@ -0,0 +1,182 @@ +use super::*; +use egui::{Event, Modifiers, PointerButton, Pos2}; +use egui_kittest::{Harness, kittest::Queryable}; +use photocraft_doc::{LayerId, SmartFilter}; + +pub(super) fn app(depth: u32) -> (PhotocraftApp, LayerId) { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 32, "height": 24, "depth": depth})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Filtered source"})).unwrap(); + s.execute("select.rect", json!({"x": 0, "y": 0, "width": 16, "height": 24})).unwrap(); + s.execute("edit.fill", json!({"contents": "color", "color": "#d06030"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap(); + let layer = s.active().unwrap().active_layer.unwrap(); + s.execute("filter.other.offset", json!({"horizontal": 5, "undefinedAreas": "transparent"})).unwrap(); + s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap(); + s.execute("layer.new.layer", json!({"name": "Other active layer"})).unwrap(); + let mut app = PhotocraftApp::new(s, Default::default()); + app.background_jobs = false; + (app, layer) +} + +pub(super) fn smart(app: &PhotocraftApp, layer: LayerId) -> &SmartObject { + let LayerContent::Smart(sm) = &app.session.active().unwrap().doc.layer(layer).unwrap().content else { panic!("Smart Object") }; + sm +} + +pub(super) fn filters(app: &PhotocraftApp, layer: LayerId) -> Vec { + smart(app, layer).smart_filters.clone() +} + +pub(super) fn pixels(app: &PhotocraftApp, layer: LayerId) -> Vec { + let doc = &app.session.active().unwrap().doc; + smart(app, layer).cache.as_ref().unwrap().read_region(doc.bounds()) +} + +pub(super) fn text_position(h: &Harness<'_, PhotocraftApp>, name: &str) -> Pos2 { + fn text(shape: &egui::Shape, name: &str) -> Option { + match shape { + egui::Shape::Text(t) if t.galley.text() == name => Some(Rect::from_min_size(t.pos, t.galley.size()).center()), + egui::Shape::Vec(v) => v.iter().find_map(|s| text(s, name)), + _ => None, + } + } + h.output().shapes.iter().find_map(|s| text(&s.shape, name)).unwrap_or_else(|| panic!("row text {name} not drawn")) +} + +pub(super) fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, button: PointerButton) { + h.hover_at(at); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +pub(super) fn harness() -> (Harness<'static, PhotocraftApp>, LayerId) { + harness_at(8, 1.0) +} + +pub(super) fn harness_at(depth: u32, ppp: f32) -> (Harness<'static, PhotocraftApp>, LayerId) { + let (app, layer) = app(depth); + let mut h = + Harness::builder().with_size(vec2(1440.0, 900.0)).with_pixels_per_point(ppp).with_step_dt(1.0 / 60.0).with_max_steps(64).build_eframe(move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }); + let ctx = h.ctx.clone(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"dock": {"collapsed": ["color", "properties", "history", "navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(8); + (h, layer) +} + +#[test] +fn row_menu_moves_and_deletes_the_clicked_filter_with_one_undo_step() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for depth in [8, 16, 32] { + for ppp in [1.0, 2.0] { + let (mut h, layer) = harness_at(depth, ppp); + let before = filters(h.state(), layer); + let before_pixels = pixels(h.state(), layer); + let source = photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(); + let st = h.state().session.active().unwrap(); + let active = st.active_layer; + let selected = st.selected_layers.clone(); + let history = st.history.past_len(); + let other = st.doc.layer(active.unwrap()).unwrap().clone(); + let offset = text_position(&h, "Offset"); + click(&mut h, offset, PointerButton::Secondary); + assert!(h.query_by_label("Move Up").is_some(), "actual filter row has Move Up"); + h.get_by_label("Move Up").click(); + h.run_steps(4); + assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", "filter.other.offset"]); + assert!(text_position(&h, "Offset").y < text_position(&h, "Gaussian Blur").y); + assert_ne!(pixels(h.state(), layer), before_pixels); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 1); + assert_eq!(h.state().session.active().unwrap().active_layer, active); + assert_eq!(h.state().session.active().unwrap().selected_layers, selected); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(3); + assert_eq!(serde_json::to_value(filters(h.state(), layer)).unwrap(), serde_json::to_value(&before).unwrap()); + assert_eq!(pixels(h.state(), layer), before_pixels); + h.state_mut().run("edit.redo", json!({})).unwrap(); + h.run_steps(3); + let offset = text_position(&h, "Offset"); + click(&mut h, offset, PointerButton::Secondary); + h.get_by_label("Move Down").click(); + h.run_steps(4); + assert_eq!(serde_json::to_value(filters(h.state(), layer)).unwrap(), serde_json::to_value(&before).unwrap()); + assert_eq!(pixels(h.state(), layer), before_pixels); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2); + let offset = text_position(&h, "Offset"); + click(&mut h, offset, PointerButton::Secondary); + h.get_by_label("Delete Smart Filter").click(); + h.run_steps(4); + assert_eq!(filters(h.state(), layer).len(), 1); + assert_eq!(filters(h.state(), layer)[0].command, "filter.blur.gaussianBlur"); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 3); + assert_eq!(h.state().session.active().unwrap().active_layer, active); + assert_eq!(h.state().session.active().unwrap().selected_layers, selected); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&h.state().session.active().unwrap().doc, layer).unwrap(), source); + let unchanged = h.state().session.active().unwrap().doc.layer(other.id).unwrap(); + assert_eq!(unchanged, &other); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(h.state(), layer), before_pixels); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert_eq!(filters(h.state(), layer).len(), 1); + } + } +} + +#[test] +fn two_filter_row_context_offers_reorder_and_delete() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + let (mut h, layer) = harness(); + let top = text_position(&h, "Gaussian Blur"); + let bottom = text_position(&h, "Offset"); + assert!(top.y < bottom.y, "index1 is visually above index0"); + let before = h.state().session.active().unwrap().doc.clone(); + let history = h.state().session.active().unwrap().history.past_len(); + click(&mut h, bottom, PointerButton::Secondary); + assert!(std::sync::Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + let missing = ["Move Up", "Move Down", "Delete Smart Filter"].into_iter().filter(|label| h.query_by_label(label).is_none()).collect::>(); + assert!(missing.is_empty(), "filter row lacks context choices {missing:?}; stack has {} filters", smart(h.state(), layer).smart_filters.len()); +} + +#[test] +fn existing_filter_move_delete_commands_preserve_source_and_undo_at_every_depth() { + for depth in [8, 16, 32] { + let (mut app, layer) = app(depth); + let original = filters(&app, layer); + let original_pixels = pixels(&app, layer); + let source = photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(); + let active = app.session.active().unwrap().active_layer; + let history = app.session.active().unwrap().history.past_len(); + app.run("layer.smartFilter.move", json!({"layer": layer.0, "index": 0, "to": 1})).unwrap(); + assert_eq!(filters(&app, layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", "filter.other.offset"]); + assert_ne!(pixels(&app, layer), original_pixels, "noncommuting operations really rerender"); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 1); + assert_eq!(app.session.active().unwrap().active_layer, active); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(), source); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(serde_json::to_value(filters(&app, layer)).unwrap(), serde_json::to_value(&original).unwrap()); + assert_eq!(pixels(&app, layer), original_pixels); + app.run("edit.redo", json!({})).unwrap(); + let reordered_pixels = pixels(&app, layer); + app.run("layer.smartFilter.delete", json!({"layer": layer.0, "index": 1})).unwrap(); + assert_eq!(filters(&app, layer).len(), 1); + assert_eq!(filters(&app, layer)[0].command, "filter.blur.gaussianBlur"); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 2); + assert_eq!(app.session.active().unwrap().active_layer, active); + assert_eq!(photocraft_engine::smart_cmds::contents_of(&app.session.active().unwrap().doc, layer).unwrap(), source); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(pixels(&app, layer), reordered_pixels); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(filters(&app, layer).len(), 1); + } +} diff --git a/crates/ui-egui/src/panels/smart_ui/menu_guard_tests.rs b/crates/ui-egui/src/panels/smart_ui/menu_guard_tests.rs new file mode 100644 index 0000000000..80e8bb573d --- /dev/null +++ b/crates/ui-egui/src/panels/smart_ui/menu_guard_tests.rs @@ -0,0 +1,298 @@ +use super::context_tests::*; +use super::*; +use egui::{PointerButton, pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use std::sync::Arc; + +#[test] +fn row_menu_disables_edges_and_deletes_the_only_remaining_filter() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for ppp in [1.0, 2.0] { + let (mut h, layer) = harness_at(8, ppp); + let top = text_position(&h, "Gaussian Blur"); + click(&mut h, top, PointerButton::Secondary); + assert!(h.get_by_label("Move Up").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Move Down").accesskit_node().is_disabled()); + let before = h.state().session.active().unwrap().doc.clone(); + let history = h.state().session.active().unwrap().history.past_len(); + h.get_by_label("Move Up").click(); + h.run_steps(3); + assert!(Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + egui::Popup::close_all(&h.ctx); + h.state_mut().run("layer.smartFilter.delete", json!({"layer":layer.0,"index":0})).unwrap(); + h.run_steps(3); + let only = text_position(&h, "Gaussian Blur"); + click(&mut h, only, PointerButton::Secondary); + assert!(h.get_by_label("Move Up").accesskit_node().is_disabled()); + assert!(h.get_by_label("Move Down").accesskit_node().is_disabled()); + assert!(!h.get_by_label("Delete Smart Filter").accesskit_node().is_disabled()); + h.get_by_label("Delete Smart Filter").click(); + h.run_steps(4); + assert!(filters(h.state(), layer).is_empty()); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history + 2); + assert!(!egui::Popup::is_any_open(&h.ctx)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(filters(h.state(), layer).len(), 1); + h.state_mut().run("edit.redo", json!({})).unwrap(); + assert!(filters(h.state(), layer).is_empty()); + } +} + +#[test] +fn stale_row_menu_closes_on_edits_undo_redo_document_switch_and_same_id_reopen() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for change in ["move", "delete", "undo", "redo", "switch", "reopen", "close"] { + let (mut h, layer) = harness(); + let offset = text_position(&h, "Offset"); + click(&mut h, offset, PointerButton::Secondary); + assert!(h.query_by_label("Delete Smart Filter").is_some()); + match change { + "move" => { + h.state_mut().run("layer.smartFilter.move", json!({"layer":layer.0,"index":0,"to":1})).unwrap(); + } + "delete" => { + h.state_mut().run("layer.smartFilter.delete", json!({"layer":layer.0,"index":0})).unwrap(); + } + "undo" => { + h.state_mut().run("edit.undo", json!({})).unwrap(); + } + "redo" => { + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.state_mut().run("edit.redo", json!({})).unwrap(); + } + "switch" => { + h.state_mut().run("file.new", json!({"width":8,"height":8})).unwrap(); + } + "reopen" => { + let session = &mut h.state_mut().session; + let old = session.close(session.active_index().unwrap()).unwrap(); + let index = session.add_document((*old.doc).clone(), None); + assert_eq!(session.documents()[index].doc.id, old.doc.id); + session.active_mut().unwrap().revision = old.revision; + assert!(!Arc::ptr_eq(&session.active().unwrap().doc, &old.doc)); + } + "close" => { + let session = &mut h.state_mut().session; + session.close(session.active_index().unwrap()).unwrap(); + } + other => panic!("unrecognized test mutation {other}"), + } + let before = h.state().session.active().map(|st| (st.doc.clone(), st.revision, st.history.past_len())); + h.run_steps(4); + assert!(h.query_by_label("Delete Smart Filter").is_none(), "{change} closes stale choices"); + assert!(!egui::Popup::is_any_open(&h.ctx), "{change} closes popup memory too"); + if let Some((doc, revision, history)) = before { + let st = h.state().session.active().unwrap(); + assert!(Arc::ptr_eq(&doc, &st.doc), "{change} adds no accidental edit"); + assert_eq!((st.revision, st.history.past_len()), (revision, history)); + } + } +} + +#[test] +fn parameter_and_camera_raw_modals_close_the_row_menu_without_mutation() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for raw in [false, true] { + let (mut h, layer) = harness(); + let offset = text_position(&h, "Offset"); + click(&mut h, offset, PointerButton::Secondary); + let ctx = h.ctx.clone(); + if raw { + crate::camera_raw_ui::open(h.state_mut(), &ctx).unwrap(); + } else { + let sm = smart(h.state(), layer).clone(); + open_editor(h.state_mut(), &ctx, layer.0, &sm, 0); + assert!(h.state().ui.dialogs.last().unwrap().fields.contains_key("__smartFilter")); + } + let before = h.state().session.active().unwrap().doc.clone(); + let history = h.state().session.active().unwrap().history.past_len(); + h.run_steps(4); + assert!(h.query_by_label("Delete Smart Filter").is_none()); + assert!(!egui::Popup::is_any_open(&h.ctx)); + assert!(Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + } +} + +#[test] +fn pending_document_job_closes_row_menu_and_refuses_new_choices() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + let (mut h, _layer) = harness(); + let offset = text_position(&h, "Offset"); + click(&mut h, offset, PointerButton::Secondary); + let before = h.state().session.active().unwrap().doc.clone(); + let started = h + .state_mut() + .session + .start_job( + "test.wait", + json!({}), + "Waiting filter", + true, + |ctx| -> photocraft_engine::Result<()> { + loop { + ctx.check()?; + std::thread::sleep(std::time::Duration::from_millis(1)); + } + }, + |_, ()| Ok(Value::Null), + ) + .unwrap(); + let photocraft_engine::jobs::Started::Job(id) = started else { panic!("native worker") }; + h.run_steps(3); + assert!(h.query_by_label("Delete Smart Filter").is_none()); + assert!(!egui::Popup::is_any_open(&h.ctx)); + click(&mut h, offset, PointerButton::Secondary); + assert!(h.query_by_label("Delete Smart Filter").is_none()); + assert!(Arc::ptr_eq(&before, &h.state().session.active().unwrap().doc)); + h.state_mut().session.cancel_job(id); + h.state_mut().session.join_cancelled_jobs(); +} + +#[test] +fn existing_eye_and_name_double_click_remain_distinct_from_secondary_actions() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for ppp in [1.0, 2.0] { + let (mut h, layer) = harness_at(8, ppp); + let offset = text_position(&h, "Offset"); + let layer_row = crate::layer_row_ui::recorded(&h.ctx).into_iter().find(|r| r.layer == layer.0).unwrap().row; + let eye = pos2(layer_row.left() + 15.0, offset.y); + let history = h.state().session.active().unwrap().history.past_len(); + click(&mut h, eye, PointerButton::Primary); + assert!(!filters(h.state(), layer)[0].visible); + assert!(h.query_by_label("Delete Smart Filter").is_none()); + assert!(h.state().ui.dialogs.is_empty()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + h.run_steps(40); + click(&mut h, offset, PointerButton::Primary); + click(&mut h, offset, PointerButton::Primary); + let dialog = h.state().ui.dialogs.last().unwrap(); + assert_eq!(dialog.fields["__command"], json!("filter.other.offset")); + assert_eq!(dialog.fields["__smartFilter"], json!({"layer":layer.0,"index":0})); + assert_eq!(h.state().session.active().unwrap().history.past_len(), history); + } +} + +#[test] +#[ignore = "offscreen native popup captures; set PHOTOCRAFT_SMART_FILTER_CAPTURE_DIR"] +fn capture_native_popup_at_two_scales_and_in_french() { + let out = std::env::var("PHOTOCRAFT_SMART_FILTER_CAPTURE_DIR").expect("capture destination"); + for (language, ppp) in [("en", 1.0), ("fr", 2.0)] { + let (mut app, layer) = app(8); + app.run("prefs.set", json!({"path":"interface.language","value":language})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_pixels_per_point(ppp).with_max_steps(64).wgpu().build_eframe( + move |cc| { + PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); + app + }, + ); + let ctx = h.ctx.clone(); + let (req, _) = crate::control::ControlRequest::new("ui.set", json!({"zoom":12.0,"dock":{"collapsed":["color","properties","history","navigator"]}})); + crate::control::handle(h.state_mut(), &ctx, &req); + h.run_steps(12); + let label = filter_label(&filters(h.state(), layer)[0]); + let offset = text_position(&h, &label); + click(&mut h, offset, PointerButton::Secondary); + for label in [tl!("Move Up"), tl!("Move Down"), tl!("Delete Smart Filter")] { + assert!(h.query_by_label(label).is_some(), "localized popup item {label}"); + } + h.run_steps(12); + let file = std::path::Path::new(&out).join(format!("smart-filter-menu-{language}-{}x.png", ppp as u32)); + h.render().unwrap().save(&file).unwrap(); + println!("capture {} at {offset:?}, doc {:?}, inactive target {:?}", file.display(), h.state().session.active().unwrap().doc.id, layer); + } +} +/// Selecting a row is a primary action; its subsequent context edit keeps that filter selected. +#[test] +fn row_menu_reorder_keeps_the_selected_filter_identity() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for ppp in [1.0, 2.0] { + for (target, choice) in [("Offset", "Move Up"), ("Gaussian Blur", "Move Down")] { + let (mut h, layer) = harness_at(8, ppp); + let at = text_position(&h, "Offset"); + click(&mut h, at, PointerButton::Primary); + assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); + let st = h.state().session.active().unwrap(); + let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); + let at = text_position(&h, target); + click(&mut h, at, PointerButton::Secondary); + h.get_by_label(choice).click(); + h.run_steps(4); + assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur", "filter.other.offset"]); + assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True), "{target}/{choice} keeps Offset selected"); + assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); + let st = h.state().session.active().unwrap(); + assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history + 1)); + } + } +} + +#[test] +fn row_menu_delete_before_selection_keeps_the_remaining_filter_selected() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for ppp in [1.0, 2.0] { + let (mut h, layer) = harness_at(8, ppp); + let at = text_position(&h, "Gaussian Blur"); + click(&mut h, at, PointerButton::Primary); + assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + let st = h.state().session.active().unwrap(); + let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); + let at = text_position(&h, "Offset"); + click(&mut h, at, PointerButton::Secondary); + h.get_by_label("Delete Smart Filter").click(); + h.run_steps(4); + assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur"]); + assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + let st = h.state().session.active().unwrap(); + assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history + 1)); + } +} + +#[test] +fn row_menu_delete_selected_filter_clears_its_row_selection() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + for ppp in [1.0, 2.0] { + let (mut h, layer) = harness_at(8, ppp); + let at = text_position(&h, "Offset"); + click(&mut h, at, PointerButton::Primary); + assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + let st = h.state().session.active().unwrap(); + let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); + let at = text_position(&h, "Offset"); + click(&mut h, at, PointerButton::Secondary); + h.get_by_label("Delete Smart Filter").click(); + h.run_steps(4); + assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.blur.gaussianBlur"]); + assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); + let doc = h.state().session.active().unwrap().doc.id; + assert!(h.ctx.data(|d| d.get_temp::(selected_key(doc))).is_none()); + let st = h.state().session.active().unwrap(); + assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history + 1)); + } +} + +#[test] +fn row_menu_refused_edit_preserves_filter_order_and_selection() { + let _language = crate::i18n::language_scope(crate::i18n::Lang::EN); + let (mut h, layer) = harness(); + let at = text_position(&h, "Offset"); + click(&mut h, at, PointerButton::Primary); + assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + let before = h.state().session.active().unwrap().doc.clone(); + let st = h.state().session.active().unwrap(); + let (active, selected, history) = (st.active_layer, st.selected_layers.clone(), st.history.past_len()); + h.state_mut().session.authorize = + Some(|command, _| if command == "layer.smartFilter.move" { Err(photocraft_engine::EngineError::Other("refused menu edit".into())) } else { Ok(()) }); + let at = text_position(&h, "Offset"); + click(&mut h, at, PointerButton::Secondary); + h.get_by_label("Move Up").click(); + h.run_steps(4); + let st = h.state().session.active().unwrap(); + assert!(Arc::ptr_eq(&before, &st.doc)); + assert_eq!((st.active_layer, &st.selected_layers, st.history.past_len()), (active, &selected, history)); + assert_eq!(filters(h.state(), layer).iter().map(|f| f.command.as_str()).collect::>(), ["filter.other.offset", "filter.blur.gaussianBlur"]); + assert_eq!(h.get_by_label("Offset").accesskit_node().toggled(), Some(egui::accesskit::Toggled::True)); + assert_eq!(h.get_by_label("Gaussian Blur").accesskit_node().toggled(), Some(egui::accesskit::Toggled::False)); +} diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 51bac51773..49072bae9c 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -82,6 +82,7 @@ Photoshop's Generative Fill and Delete and Fill Selection rows are left out: Pho |---|---|---|---| | Layers panel row | Blending Options, Duplicate/Delete, Group, export, convert/rasterize, mask, clipping, link, style copy/paste/clear, merge/flatten, Rename; filter by layer kind and selection | Implemented in layer_menu_ui.rs; item enablement uses menu commands | PhotoCraft inventory. Adobe documents layer rename/delete, color tagging, rasterization, and flattening [here](https://helpx.adobe.com/photoshop/using/layers.html); exact combined ordering **inferred** without a live capture | | Layers panel color label / thumbnail / mask thumbnail / effect row | Color label; context actions specific to hit icon | Layer-mask thumbnail: Disable/Delete/Apply Layer Mask, Add/Subtract/Intersect Mask with Selection, Select and Mask… (layer_menu_ui.rs `show_mask`, #4575; Mask Options… missing). Generic row on the other targets; inspect before extending | Adobe explicitly documents right-click on layer/group to assign a color ([layers](https://helpx.adobe.com/photoshop/using/layers.html)); other icon menus need capture | +| Smart Filter row | Reorder or remove the clicked filter | Move Up, Move Down and Delete Smart Filter invoke the existing engine commands, preserve inactive-layer targeting and Undo/Redo, and refuse a stale popup or modal/job edits; edge moves are disabled | **Partial gesture parity.** Adobe [documents dragging filters to reorder and to the Delete icon](https://helpx.adobe.com/uk/photoshop/using/applying-smart-filters.html), with bottom-up evaluation. These context actions are PhotoCraft convenience controls. Existing selection, transfer between Smart Objects and drag-to-Trash behavior is retained; drag-to-reorder remains open. | | Channels panel row | Duplicate/Delete/Rename, channel display and overlay color, spot conversion; new/split/merge | Implemented in channels_panel.rs | PhotoCraft inventory; exact Photoshop order **inferred** | | Brushes and preset panels | Rename/Delete brush or group, export selected brushes, view/reset preset choices | Rename/Delete, and preset group/item menus exist | Adobe explicitly documents Export Selected Brushes from brush/group right-click ([brushes](https://helpx.adobe.com/photoshop/desktop/apply-painting-techniques/brushes-presets/create-brush-set-painting-options.html)); Photoshop pop-up-panel rename/delete/reset/view options are [documented](https://helpx.adobe.com/photoshop/using/panels-menus.html), though some are panel-button menus | | Paths, History, Actions, Swatches, Gradients, Patterns, Styles, Libraries, Properties | Target-specific duplicate/delete/rename and panel operations where a panel exists | Paths row menu added in this PR; other panels need audit | **Inferred** catalog; record a Photoshop capture before claiming parity | @@ -110,6 +111,8 @@ Each slice needs pure model tests (including every item dispatching a known comm | 2026-10-16 | minor | Crop canvas menu on a pending frame (#6561) | | 2026-10-15 | trivial | Pasteboard row: the colour menu exists since #2462 | | 2026-10-13 | minor | Layer-mask thumbnail menu (#4575) | +| 2026-10-13 | minor | Retain main's Smart Filter selection, transfer and drag-to-Trash behavior alongside the context actions; drag-to-reorder remains a separate gap | +| 2026-10-12 | minor | Smart Filter rows expose bounded move/delete actions through existing commands, with captured document/revision guards; Adobe drag/trash gesture parity remains partial (#1265) | | 2026-10-10 | trivial | Status line, revision history | | 2026-10-09 | minor | Selection canvas reference and tool matrix updates | | 2026-10-07 | minor | Pen canvas reference capture | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4780e9360a..3ca1b8b0b7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -261,6 +261,20 @@ Filtering narrows the same array without changing its elements: `serve`'s `engin desktop control channel's `engine.commands` takes no filter and always returns every command. The shape is pinned by a test in `crates/automation/src/rpc.rs` (#3684). +### Smart Filter row actions (#1265) + +Smart Filter rows offer Move Up, Move Down and Delete Smart Filter from their context menu. +They use the existing `layer.smartFilter.move {layer,index,to}` and +`layer.smartFilter.delete {layer,index}` commands. The displayed stack is reversed: moving +visually up uses `to:index+1`, down uses `to:index-1`. Explicit `layer` targets the clicked +Smart Object while preserving the active layer and selection. + +Popup actions close when the captured document snapshot or revision changes, and cannot +edit while a modal dialog or a job locks the document. CLI/MCP commands retain their existing +validation and Undo/Redo behavior. These context actions provide PhotoCraft convenience +controls. Existing Smart Filter selection, transfer between Smart Objects and drag-to-Trash +behavior is retained, including current drag-to-reorder. + ### Background jobs (#210) Long commands (every `filter.*`, `edit.contentAwareFill`, `edit.contentAwareScale`, `file.automate.photomerge`, `brush.presets.importAbr`), file opens and interactive saves (File › Save, Save As and the TIFF Options continuation, #2017) run as background jobs in the desktop app: the window keeps drawing, the status bar shows progress with a Cancel button, and jobs that lock the active document show a modal progress dialog (Esc cancels). `engine.execute`, `ui.menu.invoke`, `ui.dialog.confirm` and `app.save` still wait for the result by default; pass `"wait": false` to get `{job, pending: true}` at once, then poll `jobs.list` (`state`: running, done, failed, cancelled; `progress` 0–1; the result or error) and stop it with `jobs.cancel {job}`. A cancelled or failed job leaves the document unchanged. While a job runs, commands that would edit its document fail with "… is still running on this document". `ui.inspect` reports `jobs` (running jobs, opening files, saving files). Set `PHOTOCRAFT_INLINE_JOBS=1` to run everything inline.