Merge approved PhotoCraft PR #3967: Add move and delete actions to Smart Filter rows

Port behavior and regression coverage into current modules; preserve current upstream behavior.
Port guarded filter context actions and selection updates into current Layers/Smart UI modules. Register all new modules and regressions; preserve current filter blend button and drag-to-reorder routing, document/modal/job ownership.
This commit is contained in:
FredApps
2026-10-29 06:13:32 +01:00
27 changed files with 784 additions and 1 deletions
+5
View File
@@ -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í
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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 Επίπεδο εικονοστοιχείων
Can't render this file because it contains an unexpected character in line 47 and column 53.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 79 and column 53.
+5
View File
@@ -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 ぼかし
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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 부드럽게 하기
Can't render this file because it contains an unexpected character in line 63 and column 53.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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 Отключить смарт-фильтры
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+5
View File
@@ -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 Вимкнути смартфільтри
Can't render this file because it contains an unexpected character in line 152 and column 53.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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 柔化
Can't render this file because it contains an unexpected character in line 63 and column 53.
+5
View File
@@ -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 柔化
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7 -1
View File
@@ -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());
}
}
}
+9
View File
@@ -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;
@@ -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>,
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<usize>,
}
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<super::FilterDrag>,
}
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::<super::FilterDrag>(key) == Some(self.before) {
if let Some(after) = self.after {
data.insert_temp(key, after);
} else {
data.remove::<super::FilterDrag>(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<SelectionUpdate> {
let pending = ctx.data(|d| d.get_temp::<PendingSelection>(pending_id()))?;
if pending.command != command || &pending.params != params {
return None;
}
ctx.data_mut(|d| d.remove::<PendingSelection>(pending_id()));
let count = pending.target.count(app)?;
let before = ctx.data(|d| d.get_temp::<super::FilterDrag>(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<usize>, 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<usize> {
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<Target> = 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::<Target>(state_id()));
ctx.data_mut(|d| d.remove::<PendingSelection>(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::<Target>(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::<Target>(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();
}
});
}
@@ -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<SmartFilter> {
smart(app, layer).smart_filters.clone()
}
pub(super) fn pixels(app: &PhotocraftApp, layer: LayerId) -> Vec<f32> {
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<Pos2> {
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::<Vec<_>>(), ["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::<Vec<_>>();
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::<Vec<_>>(), ["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);
}
}
@@ -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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<Vec<_>>(), ["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::<FilterDrag>(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::<Vec<_>>(), ["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));
}
+3
View File
@@ -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 |
+14
View File
@@ -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.