Give Image Statistics a source-file dialog and stack mode selector (#4778)

* Give Image Statistics a source-file dialog and stack mode selector

* Keep stack sources in vertical rows and translate the mode label

* Place the new label alongside stack-mode translations

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
This commit is contained in:
jonathanwxh-cell 2026-10-14 00:34:16 +08:00 committed by GitHub
parent d3d86da130
commit 907752daf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 174 additions and 17 deletions

View File

@ -101,10 +101,7 @@ pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &V
json!({"format": ["same", "png", "jpg", "psd", "tiff"]}),
)
}
"file.scripts.statistics" => {
let modes: Vec<&str> = photocraft_doc::StackMode::ALL.iter().map(|m| m.id()).collect();
form(app, id, "Image Statistics", json!({"mode": "median", "input": dir, "align": false}), json!({"mode": modes}))
}
"file.scripts.statistics" => json!({"dialog": crate::stack_dialog::open_statistics(app)}),
"file.scripts.browse" => {
return Some(app.pick_file_bytes_filtered(SCRIPT_EXTENSIONS, |app, name, bytes| {
let r = app.run("file.scripts.browse", json!({"script": String::from_utf8_lossy(&bytes)}));

View File

@ -387,6 +387,7 @@
Update Modified Content Aktualizovat změněný obsah
Update All Modified Content Aktualizovat veškerý změněný obsah
Stack Mode Režim zásobníku
Choose Stack Mode: Vyberte režim zásobníku:
Entropy Entropie
Kurtosis Špičatost
Maximum Maximum

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -386,6 +386,7 @@
Update Modified Content Geänderten Inhalt aktualisieren
Update All Modified Content Alle geänderten Inhalte aktualisieren
Stack Mode Stapelmodus
Choose Stack Mode: Stapelmodus auswählen:
Entropy Entropie
Kurtosis Kurtosis
Maximum Maximum

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1847,6 +1847,7 @@
Update Modified Content Ενημέρωση τροποποιημένου περιεχομένου
Update All Modified Content Ενημέρωση όλων των τροποποιημένων περιεχομένων
Stack Mode Λειτουργία στοίβας
Choose Stack Mode: Επιλογή λειτουργίας στοίβας:
Entropy Εντροπία
Kurtosis Κύρτωση
Maximum Μέγιστο

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -383,6 +383,7 @@
Update Modified Content Actualizar contenido modificado
Update All Modified Content Actualizar todo el contenido modificado
Stack Mode Modo de apilamiento
Choose Stack Mode: Elegir modo de apilamiento:
Entropy Entropía
Kurtosis Curtosis
Maximum Máximo

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -377,6 +377,7 @@
Update Modified Content Mettre à jour le contenu modifié
Update All Modified Content Mettre à jour tout le contenu modifié
Stack Mode Mode de pile
Choose Stack Mode: Choisir le mode de pile :
Entropy Entropie
Kurtosis Kurtosis
Maximum Maximum

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -400,6 +400,7 @@
Smooth… Simítás…
Solid Color… Egyszínű kitöltés…
Stack Mode Halommód
Choose Stack Mode: Halommód kiválasztása:
Standard Deviation Szórás
Subject Fő téma
Subtract Front Shape Elülső alakzat kivonása

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -370,6 +370,7 @@
Update Modified Content Perbarui Isi yang Diubah
Update All Modified Content Perbarui Semua Isi yang Diubah
Stack Mode Mode Stack
Choose Stack Mode: Pilih Mode Stack:
Entropy Entropi
Kurtosis Kurtosis
Maximum Maksimum

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -379,6 +379,7 @@
Update Modified Content Aggiorna contenuto modificato
Update All Modified Content Aggiorna tutto il contenuto modificato
Stack Mode Modalità pila
Choose Stack Mode: Scegli modalità pila:
Entropy Entropia
Kurtosis Curtosi
Maximum Massimo

Can't render this file because it has a wrong number of fields in line 16.

View File

@ -381,6 +381,7 @@
Update Modified Content 変更されたコンテンツを更新
Update All Modified Content 変更されたコンテンツをすべて更新
Stack Mode スタックモード
Choose Stack Mode: スタックモードを選択:
Entropy エントロピー
Kurtosis 尖度
Maximum 最大値

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -368,6 +368,7 @@
Update Modified Content 수정된 내용 업데이트
Update All Modified Content 수정된 모든 내용 업데이트
Stack Mode 스택 모드
Choose Stack Mode: 스택 모드 선택:
Entropy 엔트로피
Kurtosis 첨도
Maximum 최대값

Can't render this file because it has a wrong number of fields in line 11.

View File

@ -372,6 +372,7 @@
Update Modified Content Gewijzigde inhoud bijwerken
Update All Modified Content Alle gewijzigde inhoud bijwerken
Stack Mode Stapelmodus
Choose Stack Mode: Stapelmodus kiezen:
Entropy Entropie
Kurtosis Kurtosis
Maximum Maximum

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -379,6 +379,7 @@
Update Modified Content Aktualizuj zmodyfikowaną zawartość
Update All Modified Content Aktualizuj całą zmodyfikowaną zawartość
Stack Mode Tryb stosu
Choose Stack Mode: Wybierz tryb stosu:
Entropy Entropia
Kurtosis Kurtoza
Maximum Maksimum

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -372,6 +372,7 @@
Update Modified Content Atualizar conteúdo modificado
Update All Modified Content Atualizar todo o conteúdo modificado
Stack Mode Modo de pilha
Choose Stack Mode: Escolher modo de pilha:
Entropy Entropia
Kurtosis Curtose
Maximum Máximo

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1996,6 +1996,7 @@
Solid Color… Однотонный цвет…
Square Квадрат
Stack Mode Режим стопки
Choose Stack Mode: Выберите режим стопки:
Standard Deviation Стандартное отклонение
Statistics… Статистика…
Stylistic Alternates Стилистические варианты

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -367,6 +367,7 @@
Update Modified Content Ažuriraj izmenjeni sadržaj
Update All Modified Content Ažuriraj sav izmenjeni sadržaj
Stack Mode Režim steka
Choose Stack Mode: Izaberite režim steka:
Entropy Entropija
Kurtosis Kurtozis
Maximum Maksimum

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -363,6 +363,7 @@
Update Modified Content Değiştirilen İçeriği Güncelle
Update All Modified Content Değiştirilen Tüm İçeriği Güncelle
Stack Mode Yığın Modu
Choose Stack Mode: Yığın Modunu Seçin:
Entropy Entropi
Kurtosis Basıklık
Maximum Maksimum

Can't render this file because it contains an unexpected character in line 8 and column 34.

View File

@ -1975,6 +1975,7 @@
Solid Color… Суцільний колір…
Square Квадрат
Stack Mode Режим стосу
Choose Stack Mode: Виберіть режим стосу:
Standard Deviation Стандартне відхилення
Statistics… Статистика…
Stylistic Alternates Стилістичні альтернативи

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -385,6 +385,7 @@
Smart Objects Đối tượng thông minh
Solid Color… Màu đặc…
Stack Mode Chế độ chồng
Choose Stack Mode: Chọn chế độ chồng:
Standard Deviation Độ lệch chuẩn
Subtract Front Shape Trừ hình phía trước
Summation Tổng

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -386,6 +386,7 @@
Update Modified Content 更新已修改的内容
Update All Modified Content 更新所有已修改的内容
Stack Mode 堆栈模式
Choose Stack Mode: 选择堆栈模式:
Entropy 熵
Kurtosis 峰度
Maximum 最大值

Can't render this file because it has a wrong number of fields in line 15.

View File

@ -391,6 +391,7 @@
Update Modified Content 更新修改過的內容
Update All Modified Content 更新所有修改過的內容
Stack Mode 堆疊模式
Choose Stack Mode: 選擇堆疊模式:
Entropy 熵
Kurtosis 峰度
Maximum 最大

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -1,7 +1,8 @@
//! File › Scripts › Load Files into Stack… (#4671, #4696), laid out as Photoshop's: Source Files
//! File › Scripts › Load Files into Stack… and Statistics: shared Source Files controls
//! with Use (Files or Folder), the list of what will be loaded, Browse…, Remove, Add Open Files and
//! Sort By Name, then Attempt to Automatically Align Source Images and Create Smart Object after
//! Loading Layers. OK runs `file.scripts.loadFilesIntoStack` with the list or the folder.
//! Loading Layers. Statistics adds Stack Mode and always creates a smart object. OK runs the
//! corresponding engine command with the list or the folder.
use egui::{RichText, vec2};
use serde_json::{Map, Value, json};
@ -10,6 +11,7 @@ use crate::{PhotocraftApp, state::DialogKind, theme::Tokens, widgets};
const MARK: &str = "__loadStack";
pub const COMMAND: &str = "file.scripts.loadFilesIntoStack";
const STATISTICS: &str = "file.scripts.statistics";
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key(MARK)
@ -17,15 +19,30 @@ pub fn owns(fields: &Map<String, Value>) -> bool {
/// Open the dialog with an empty Files list. Returns its id.
pub fn open(app: &mut PhotocraftApp) -> u64 {
open_for(app, COMMAND, "Load Files into Stack…")
}
pub fn open_statistics(app: &mut PhotocraftApp) -> u64 {
open_for(app, STATISTICS, "Image Statistics")
}
fn statistics(f: &Map<String, Value>) -> bool {
f.get("__command").and_then(Value::as_str) == Some(STATISTICS)
}
fn open_for(app: &mut PhotocraftApp, command: &str, label: &str) -> u64 {
let mut f = Map::new();
f.insert(MARK.into(), json!(true));
f.insert("__command".into(), json!(COMMAND));
f.insert("__label".into(), json!("Load Files into Stack…"));
f.insert("__command".into(), json!(command));
f.insert("__label".into(), json!(label));
f.insert("use".into(), json!("files"));
f.insert("files".into(), json!([]));
f.insert("folder".into(), json!(""));
f.insert("align".into(), json!(false));
f.insert("smartObject".into(), json!(false));
if command == STATISTICS {
f.insert("mode".into(), json!("median"));
}
app.ui.open_dialog(DialogKind::Command, f)
}
@ -96,6 +113,17 @@ fn browse(app: &mut PhotocraftApp, id: u64, folder: bool) {
/// The dialog's body (dialog `id`); OK and Cancel are the standard ones below it.
pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Value>, id: u64) {
let t = Tokens::get(ui.ctx());
if statistics(f) {
ui.horizontal(|ui| {
ui.label(tl!("Choose Stack Mode:"));
let mut mode = f.get("mode").and_then(Value::as_str).unwrap_or("median").to_string();
let options: Vec<_> = photocraft_doc::StackMode::ALL.iter().map(|m| (m.id().to_string(), m.label())).collect();
if widgets::dropdown(ui, "statistics-stack-mode", &mut mode, &options, 180.0) {
f.insert("mode".into(), json!(mode));
}
});
ui.add_space(8.0);
}
ui.label(RichText::new(tl!("Source Files")).font(crate::theme::semibold(13.0)));
ui.add_space(4.0);
ui.horizontal(|ui| {
@ -122,15 +150,17 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
ui.set_min_size(vec2(300.0, 160.0));
ui.set_max_width(300.0);
egui::ScrollArea::vertical().id_salt("load-stack-files").max_height(160.0).auto_shrink([false, false]).show(ui, |ui| {
if folder_mode && !folder(f).is_empty() {
ui.label(RichText::new(folder(f)).color(t.text_dim));
}
for (i, p) in shown.iter().enumerate() {
let row = ui.add(egui::Button::selectable(!folder_mode && sel == Some(i), file_name(p))).on_hover_text(p.as_str());
if row.clicked() && !folder_mode {
f.insert("selected".into(), json!(i));
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
if folder_mode && !folder(f).is_empty() {
ui.label(RichText::new(folder(f)).color(t.text_dim));
}
}
for (i, p) in shown.iter().enumerate() {
let row = ui.add(egui::Button::selectable(!folder_mode && sel == Some(i), file_name(p))).on_hover_text(p.as_str());
if row.clicked() && !folder_mode {
f.insert("selected".into(), json!(i));
}
}
});
});
});
ui.add_space(8.0);
@ -162,6 +192,9 @@ pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, f: &mut Map<String, Valu
});
ui.add_space(8.0);
for (key, label) in [("align", tl!("Attempt to Automatically Align Source Images")), ("smartObject", tl!("Create Smart Object after Loading Layers"))] {
if statistics(f) && key == "smartObject" {
continue;
}
let mut on = f.get(key).and_then(Value::as_bool).unwrap_or(false);
if widgets::checkbox(ui, &mut on, label).changed() {
f.insert(key.into(), json!(on));
@ -193,7 +226,12 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
};
let align = f.get("align").and_then(Value::as_bool).unwrap_or(false);
let smart = f.get("smartObject").and_then(Value::as_bool).unwrap_or(false);
let r = app.run(COMMAND, json!({"paths": paths, "align": align, "createSmartObject": smart}));
let r = if statistics(f) {
let mode = f.get("mode").and_then(Value::as_str).unwrap_or("median");
app.run(STATISTICS, json!({"paths": paths, "align": align, "mode": mode}))
} else {
app.run(COMMAND, json!({"paths": paths, "align": align, "createSmartObject": smart}))
};
if r.is_ok() {
app.sync_views();
}

View File

@ -40,6 +40,107 @@ fn listed(h: &Harness<'_, PhotocraftApp>) -> Vec<String> {
dialog(h).map(|(_, f)| files(&f).iter().map(|p| file_name(p).to_string()).collect()).unwrap_or_default()
}
#[test]
fn statistics_menu_opens_a_source_file_list_with_stack_mode() {
let (mut h, _) = harness(vec![]);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, "file.scripts.statistics", json!({})).unwrap();
h.run_steps(4);
for label in ["Choose Stack Mode:", "Source Files", "Browse…", "Remove", "Add Open Files", "Sort By Name", "Attempt to Automatically Align Source Images"]
{
assert!(h.query_by_label(label).is_some(), "Statistics is missing {label}");
}
assert!(h.get_by_label("OK").accesskit_node().is_disabled(), "choose source files before running statistics");
assert!(h.query_by_label("Create Smart Object after Loading Layers").is_none(), "Statistics always creates its stack smart object");
assert!(!dialog(&h).unwrap().1.contains_key("input"), "no generic Input field");
h.get_by_label("Cancel").click();
h.run_steps(2);
assert!(dialog(&h).is_none());
assert!(h.state().session.documents().is_empty());
}
#[test]
fn statistics_browse_and_mode_selection_compute_the_chosen_statistic() {
let (_, paths) = pngs("statistics-mean", &["first.png", "second.png"]);
let mut source = photocraft_engine::Session::new();
source.execute("file.new", json!({"width": 8, "height": 6, "background": "#80a0c0"})).unwrap();
source.execute("file.saveACopy", json!({"path": paths[1]})).unwrap();
let (mut h, _) = harness(vec![Some(FileDialogAnswer::Paths(paths))]);
let ctx = h.ctx.clone();
crate::menus::invoke(h.state_mut(), &ctx, STATISTICS, json!({})).unwrap();
h.run_steps(3);
h.get_by_label("Browse…").click();
h.run_steps(4);
assert_eq!(listed(&h), ["first.png", "second.png"]);
assert!(h.get_by_label("first.png").rect().bottom() <= h.get_by_label("second.png").rect().top(), "source files must be vertical rows");
h.get_by_value("Median").click();
h.run_steps(2);
for mode in photocraft_doc::StackMode::ALL {
assert!(h.query_by_label(mode.label()).is_some(), "missing {}", mode.label());
}
h.get_by_label("Mean").click();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(4);
assert!(dialog(&h).is_none());
assert_eq!(h.state().session.documents().len(), 1);
let doc = &h.state().session.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
let photocraft_doc::LayerContent::Smart(smart) = &doc.layers[0].content else { panic!("expected a stack smart object") };
assert_eq!(smart.stack_mode, Some(photocraft_doc::StackMode::Mean));
let pixel = photocraft_compose::flatten(doc).px[0];
for (actual, expected) in pixel.iter().zip([96.0, 128.0, 160.0, 255.0]) {
assert!((actual * 255.0 - expected).abs() < 1.5, "{pixel:?}");
}
}
#[test]
fn statistics_explicit_parameters_bypass_the_dialog() {
let (_, paths) = pngs("statistics-script", &["one.png", "two.png"]);
let (mut h, _) = harness(vec![]);
let ctx = h.ctx.clone();
let result = crate::menus::invoke(h.state_mut(), &ctx, STATISTICS, json!({"input": paths, "mode": "maximum"})).unwrap();
assert_eq!(result["images"], 2);
assert_eq!(result["mode"], "maximum");
assert!(dialog(&h).is_none());
}
#[test]
fn statistics_folder_and_alignment_reach_the_engine() {
let (dir, paths) = pngs("statistics-folder", &["one.png", "two.png"]);
// Auto-Align needs identifiable detail, not a uniform tiny colour swatch.
let mut source = photocraft_engine::Session::new();
source.execute("file.new", json!({"width": 96, "height": 72, "background": "#606060"})).unwrap();
source
.edit("texture", |doc, active| {
let surface = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
for k in 0..40 {
let (x, y, v) = ((k * 37) % 88, (k * 23) % 66, (k % 7) as f32 / 6.0);
surface.fill_rect(photocraft_geom::Rect::new(x, y, x + 3 + k % 5, y + 2 + k % 4), &[v, 1.0 - v, 0.5, 1.0]);
}
Ok(())
})
.unwrap();
for path in paths {
source.execute("file.saveACopy", json!({"path": path})).unwrap();
}
let (mut h, _) = harness(vec![Some(FileDialogAnswer::FolderTo(dir))]);
let id = open_statistics(h.state_mut());
h.state_mut().ui.dialog_mut(id).unwrap().fields.insert("use".into(), json!("folder"));
h.run_steps(3);
h.get_by_label("Browse…").click();
h.run_steps(4);
h.get_by_label("Attempt to Automatically Align Source Images").click();
h.run_steps(2);
assert_eq!(dialog(&h).unwrap().1["align"], true);
let result = crate::dialogs::confirm(h.state_mut(), id).unwrap();
assert_eq!(result["images"], 2);
assert_eq!(result["mode"], "median");
let doc = &h.state().session.active().unwrap().doc;
let photocraft_doc::LayerContent::Smart(smart) = &doc.layers[0].content else { panic!("expected a stack smart object") };
assert_eq!(smart.stack_mode, Some(photocraft_doc::StackMode::Median));
}
/// #4671 / #4696: the menu opens Photoshop's dialog, Browse… adds files (once each), Remove,
/// Sort By Name and Add Open Files edit the list, and OK loads it.
#[test]
@ -71,6 +172,7 @@ fn source_files_list_is_built_with_browse_remove_sort_and_open_files() {
h.run_steps(4);
assert!(matches!(asked.borrow()[0], FileDialogRequest::Open { multiple: true, .. }), "{:?}", asked.borrow());
assert_eq!(listed(&h), ["b.png", "c.png"]);
assert!(h.get_by_label("b.png").rect().bottom() <= h.get_by_label("c.png").rect().top(), "source files must be vertical rows");
h.get_by_label("Browse…").click();
h.run_steps(4);
assert_eq!(listed(&h), ["b.png", "c.png", "a.png"], "b.png only once");