mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-31 21:37:08 -06:00
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:
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user