From ea728ee28f33ca3daacf8f603e591a2c6714edbf Mon Sep 17 00:00:00 2001 From: zenjabba <679864+zenjabba@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:57:26 +0000 Subject: [PATCH] Add Smart Filter row move and delete actions --- crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/panels.rs | 1 + crates/ui-egui/src/smart_ui.rs | 11 + crates/ui-egui/src/smart_ui/context_menu.rs | 106 +++++++++ crates/ui-egui/src/smart_ui/context_tests.rs | 182 ++++++++++++++++ .../ui-egui/src/smart_ui/menu_guard_tests.rs | 205 ++++++++++++++++++ docs/context-menu-parity.md | 6 +- docs/control-protocol.md | 13 ++ 23 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/smart_ui/context_menu.rs create mode 100644 crates/ui-egui/src/smart_ui/context_tests.rs create mode 100644 crates/ui-egui/src/smart_ui/menu_guard_tests.rs diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index aecab4b54e..145c0ae1d1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -20,6 +20,11 @@ # dialog commands is kept. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Přesunout nahoru + Move Down Přesunout dolů + Delete Smart Filter Odstranit chytrý filtr + Columns Sloupce Rows Řádky Number Počet diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2b3f79feb4..f5be68aa30 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -16,6 +16,11 @@ # letters; the trailing "…" of dialog commands is kept. Plural rule: one|other (n != 1 -> other). # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Nach oben verschieben + Move Down Nach unten verschieben + Delete Smart Filter Smartfilter löschen + Columns Spalten Rows Zeilen Number Anzahl diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1579d5412a..7515085b48 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -4,6 +4,11 @@ # Covers every source label the i18n coverage check lists. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Μετακίνηση πάνω + Move Down Μετακίνηση κάτω + Delete Smart Filter Διαγραφή έξυπνου φίλτρου + Rows Γραμμές Number Πλήθος Top: Επάνω: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 13478a8cb2..6fc4698662 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -13,6 +13,11 @@ # letters; the trailing "…" of dialog commands is kept. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Mover arriba + Move Down Mover abajo + Delete Smart Filter Eliminar filtro inteligente + Columns Columnas Rows Filas Number Número diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1b86bce639..3c951b5817 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -20,6 +20,11 @@ # dialog commands is kept. # Guide layout dialog +# 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 + Rows Lignes Number Nombre Margin Marge diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 79582888eb..d120806482 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -13,6 +13,11 @@ # letters; the trailing "…" of dialog commands is kept. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Pindahkan ke Atas + Move Down Pindahkan ke Bawah + Delete Smart Filter Hapus Smart Filter + Rows Baris Number Jumlah Margin Margin diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9bb31450ba..9bc5e5ce1c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -13,6 +13,11 @@ # letters; the trailing … of dialog commands is kept. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Sposta su + Move Down Sposta giù + Delete Smart Filter Elimina filtro avanzato + Columns Colonne Rows Righe Number Numero diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3142d52ad4..b82b4c2f73 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -16,6 +16,11 @@ # letters; the trailing "…" of dialog commands is kept. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up 上へ移動 + Move Down 下へ移動 + Delete Smart Filter スマートフィルターを削除 + Rows 行 Number 数 Top: 上: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 30a7ca878c..aa4ef41325 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -8,6 +8,11 @@ # Product names and technology names retain their spelling. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up 위로 이동 + Move Down 아래로 이동 + Delete Smart Filter 스마트 필터 삭제 + Columns 열 Rows 행 Number 개수 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 28a0effa57..c0ad9c7557 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -9,6 +9,11 @@ # informally ("je") and uses sentence case. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Omhoog verplaatsen + Move Down Omlaag verplaatsen + Delete Smart Filter Slim filter verwijderen + Rows Rijen Number Aantal Top: Boven: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index d62fdb2536..62821f503a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -19,6 +19,11 @@ # Zapisz jako…), sentence case as in the English source. Quotes: „…”. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Przenieś w górę + Move Down Przenieś w dół + Delete Smart Filter Usuń filtr inteligentny + Rows Wiersze Number Liczba Top: Góra: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c218dfecec..fa0e1b5e5d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -16,6 +16,11 @@ # stay as they are; the trailing "…" of dialog commands is kept. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Mover para cima + Move Down Mover para baixo + Delete Smart Filter Excluir filtro inteligente + Rows Linhas Number Número Margin Margem diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 799ca6a175..2968cc2a3c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -20,6 +20,11 @@ # Menus # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Переместить вверх + Move Down Переместить вниз + Delete Smart Filter Удалить смарт-фильтр + Rows Строки Number Количество Margin Поля diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 177644f701..bd7dcac8f3 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -11,6 +11,11 @@ # Menus # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up Перемістити вгору + Move Down Перемістити вниз + Delete Smart Filter Видалити смарт-фільтр + Rows Рядки Number Кількість Top: Зверху: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c0218eef14..72d918f4cf 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -12,6 +12,11 @@ # Product and technology names retain their spelling. See docs/localization-zh-hans.md. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up 上移 + Move Down 下移 + Delete Smart Filter 删除智能滤镜 + Columns 列 Rows 行 Number 数量 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c51d29bb94..843aa0f9ad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -20,6 +20,11 @@ # separators as in the English. # Guide layout dialog +# Smart Filter row actions (#1265) + Move Up 上移 + Move Down 下移 + Delete Smart Filter 刪除智慧型濾鏡 + Columns 欄 Rows 列 Number 數量 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 6a87a83179..ae5063690b 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -2056,6 +2056,7 @@ fn opacity_row_layout(available: f32, label_width: f32, gap: f32) -> (f32, bool) } 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 { diff --git a/crates/ui-egui/src/smart_ui.rs b/crates/ui-egui/src/smart_ui.rs index 99deaa60fe..9240d27bf3 100644 --- a/crates/ui-egui/src/smart_ui.rs +++ b/crates/ui-egui/src/smart_ui.rs @@ -1,6 +1,8 @@ //! Layers-panel pieces for smart objects: the thumbnail badge and the Smart Filters sub-rows //! (eye toggles; double-click a filter to re-open its dialog with the recorded parameters). +pub(crate) mod context_menu; + use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; use photocraft_doc::{Layer, LayerContent, SmartObject}; use serde_json::{Value, json}; @@ -81,6 +83,9 @@ pub fn filter_rows(app: &mut PhotocraftApp, ui: &mut egui::Ui, l: &Layer, depth: { open_editor(app, ui.ctx(), l.id.0, sm, i); } + if let Some(i) = index { + context_menu::show(app, &resp, l.id, i, actions); + } } } @@ -121,3 +126,9 @@ pub fn confirm_command(fields: &serde_json::Map, command: String, None => (command, params), } } + +#[cfg(test)] +mod context_tests; + +#[cfg(test)] +mod menu_guard_tests; diff --git a/crates/ui-egui/src/smart_ui/context_menu.rs b/crates/ui-egui/src/smart_ui/context_menu.rs new file mode 100644 index 0000000000..514f586225 --- /dev/null +++ b/crates/ui-egui/src/smart_ui/context_menu.rs @@ -0,0 +1,106 @@ +//! 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::new("smart-filter-row-menu-target") +} + +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())); + } +} + +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::new(("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 + { + actions.push(("layer.smartFilter.move".into(), json!({"layer": target.layer.0, "index": target.index, "to": to}))); + ui.close(); + } + } + ui.separator(); + if ui.add_enabled(target.enabled(app, "layer.smartFilter.delete"), egui::Button::new(tl!("Delete Smart Filter"))).clicked() { + actions.push(("layer.smartFilter.delete".into(), target.params())); + ui.close(); + } + }); +} diff --git a/crates/ui-egui/src/smart_ui/context_tests.rs b/crates/ui-egui/src/smart_ui/context_tests.rs new file mode 100644 index 0000000000..1cd002e845 --- /dev/null +++ b/crates/ui-egui/src/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/smart_ui/menu_guard_tests.rs b/crates/ui-egui/src/smart_ui/menu_guard_tests.rs new file mode 100644 index 0000000000..cdc2033ee9 --- /dev/null +++ b/crates/ui-egui/src/smart_ui/menu_guard_tests.rs @@ -0,0 +1,205 @@ +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); + } +} diff --git a/docs/context-menu-parity.md b/docs/context-menu-parity.md index 425c91f42f..5ff9a49aa6 100644 --- a/docs/context-menu-parity.md +++ b/docs/context-menu-parity.md @@ -1,8 +1,8 @@ # Context-menu parity contract -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** trivial (status line and revision history added; linked from ui-parity.md) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Smart Filter row reorder/delete actions) · **Target:** Adobe Photoshop 2026 -Status: implementation contract, updated 2026-10-09. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). +Status: implementation contract, updated 2026-10-12. Scope: canvas, document tabs, and panels. “Right-click” means a secondary pointer click; macOS Control-click should also open the same menu where the platform sends a context-menu gesture. Photoshop changes a context menu with the active tool, selection, and panel item. Adobe documents that principle but does not publish a complete current menu catalog. **Documented** means an Adobe source explicitly names the gesture or action. **Inferred** means a PhotoCraft design target or an item known from a main menu whose presence in a Photoshop context menu still needs a current-product capture. Do not present inferred rows as exact Photoshop menus. [Adobe: panels and menus](https://helpx.adobe.com/photoshop/using/panels-menus.html), [Adobe: shortcuts and context menus](https://helpx.adobe.com/sa_en/photoshop/using/customizing-keyboard-shortcuts.html). ## Dispatch contract @@ -78,6 +78,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 | Generic row on some 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; drag/trash gestures remain 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 | @@ -103,3 +104,4 @@ Each slice needs pure model tests (including every item dispatching a known comm | 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 | +| 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) | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f2d3fe27a4..ab3f597f2e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -168,6 +168,19 @@ their usual file access. Source replacement, relinking and export commands remai double-clicking its thumbnail in Layers. Text input, Commit and Cancel use the existing Type tool editing session; non-type layers return an error without changing the tool or document. +### 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; Photoshop's drag-to-reorder and drag-to-Trash gestures remain separate parity gaps. + ### 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.