Files
photocraft/crates/ui-egui/src/view_cmds.rs
T
Gustavo Ziger d900daa0ba fix(web): File › Place Embedded is enabled in the browser and places the picked file's bytes (#1209, #5749) (#5585)
With a document open in the web app, Place Embedded was disabled because its menu enablement inherited the engine's native-filesystem check, although the menu path reads the picker's bytes and never touches a path. Place Embedded is now enabled whenever a document and a file-dialog service exist; Place Linked keeps the engine's native guard, since the web could only link to a bare file name (caught in #5817 by Higgs32584). An enablement test runs with the native check forced off, and a file-dialog test covers the browser answer shape, cancel, invalid bytes and the Free Transform handoff.

Fixes #1209. Fixes #5749.
2026-10-16 17:38:56 -07:00

1502 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shell-side View, Window and Type menu items: screen modes, Extras and the Show / Snap To
//! toggles, zoom presets, view flipping, document arrangement (tiles, n-up, floating windows,
//! Match Zoom/Location), panel shortcuts that pick the right dock tab, type UI preferences, and
//! the picker/dialog front ends of the engine's File commands.
//!
//! Everything here is view or window state ([`ViewOptions`], serde, part of `UiState`, so the
//! control channel reads and drives it). Anything that changes a document is an engine command;
//! this module only opens its dialog or file picker first.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::state::DialogKind;
/// View › Show flags (Photoshop defaults).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Show {
pub layer_edges: bool,
pub selection_edges: bool,
pub target_path: bool,
pub pixel_grid: bool,
pub smart_guides: bool,
pub brush_preview: bool,
pub slices: bool,
pub notes: bool,
pub mesh: bool,
pub edit_pins: bool,
pub count: bool,
pub canvas_guides: bool,
pub artboard_guides: bool,
}
impl Default for Show {
fn default() -> Self {
Self {
layer_edges: false,
selection_edges: true,
target_path: true,
pixel_grid: true,
smart_guides: true,
brush_preview: true,
slices: true,
notes: true,
mesh: false,
edit_pins: true,
count: true,
canvas_guides: true,
artboard_guides: true,
}
}
}
/// View › Snap To targets (snapping itself is the `view.snap` master switch).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct SnapTo {
pub guides: bool,
pub grid: bool,
pub layers: bool,
pub slices: bool,
pub document_bounds: bool,
}
impl Default for SnapTo {
fn default() -> Self {
Self { guides: true, grid: true, layers: true, slices: true, document_bounds: true }
}
}
/// View-, window- and type-preference state of the shell.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ViewOptions {
/// "standard" | "fullScreenWithMenuBar" | "fullScreen" (View › Screen Mode, F cycles).
pub screen_mode: String,
/// View › Extras (⌘H): master switch for guides, grid, edges and other overlays.
pub extras: bool,
pub show: Show,
pub snap_to: SnapTo,
/// View › Flip Horizontal: mirror the view only (the document is untouched).
pub flip_horizontal: bool,
/// View › Pixel Aspect Ratio preset id ("square", "d1DvNtsc", …) and its correction toggle.
pub pixel_aspect: String,
pub pixel_aspect_correction: bool,
pub pattern_preview: bool,
pub pixel_art_preview: bool,
/// Window › Arrange layout of the document area: "tabs" or a tile/n-up layout id.
pub arrange: String,
/// Type › Font Preview Size: "small" | "medium" | "large" | "extraLarge" | "huge".
pub font_preview_size: String,
/// Type › Language Options: "defaultFeatures" | "eastAsianFeatures" | "middleEasternFeatures",
/// and the Middle Eastern & South Asian composer.
pub language_features: String,
pub middle_eastern_composer: bool,
/// Parameters from the last successfully applied New Guide Layout dialog.
pub guide_layout: Value,
/// Developer overlay of the active layer's storage tiles (tile_overlay.rs), set with the
/// control channel's `ui.set {"tileGrid": true}`.
pub tile_grid: bool,
}
/// Factory New Guide Layout values (Alt+Reset and a fresh ViewOptions).
pub(crate) fn default_guide_layout() -> Value {
json!({"columns": 8, "gutter": 20, "rows": 0, "rowGutter": 0, "margin": 0, "centerColumns": false, "clearExisting": false})
}
impl Default for ViewOptions {
fn default() -> Self {
Self {
screen_mode: "standard".into(),
extras: true,
show: Show::default(),
snap_to: SnapTo::default(),
flip_horizontal: false,
pixel_aspect: "square".into(),
// Photoshop shows non-square pixels corrected until the user turns it off.
pixel_aspect_correction: true,
pattern_preview: false,
pixel_art_preview: false,
arrange: "tabs".into(),
font_preview_size: "medium".into(),
language_features: "defaultFeatures".into(),
middle_eastern_composer: false,
guide_layout: default_guide_layout(),
tile_grid: false,
}
}
}
impl ViewOptions {
/// Is an overlay with this Show flag visible (Extras on and the flag on)?
pub fn shows(&self, flag: bool) -> bool {
self.extras && flag
}
pub fn hides_chrome(&self) -> bool {
self.screen_mode == "fullScreen"
}
pub fn hides_tabs(&self) -> bool {
self.screen_mode != "standard"
}
/// Horizontal display scale of a document pixel: the pixel aspect ratio while View › Pixel
/// Aspect Ratio Correction is on, else 1 (square). The document's pixels never change.
pub fn display_aspect(&self) -> f32 {
let r = pixel_aspect_ratio(&self.pixel_aspect);
// `ui.set` can store any string; only the dialog's 0.1–10 range is drawn.
if self.pixel_aspect_correction && r.is_finite() { r.clamp(0.1, 10.0) } else { 1.0 }
}
/// Photoshop's full screen modes show no scroll bars.
pub fn shows_scrollbars(&self) -> bool {
self.screen_mode == "standard"
}
}
const SCREEN_MODES: [&str; 3] = ["standard", "fullScreenWithMenuBar", "fullScreen"];
const PIXEL_ASPECTS: [&str; 8] = ["square", "d1DvNtsc", "d1DvPal", "d1DvNtscWidescreen", "hdv1080", "d1DvPalWidescreen", "dvcproHd1080", "anamorphic2To1"];
const FONT_PREVIEW: [&str; 5] = ["small", "medium", "large", "extraLarge", "huge"];
const LANGUAGE: [&str; 3] = ["defaultFeatures", "eastAsianFeatures", "middleEasternFeatures"];
/// Window › Arrange layouts drawn by [`cells`].
const LAYOUTS: [&str; 10] = [
"tileAllVertically",
"tileAllHorizontally",
"twoUpVertical",
"twoUpHorizontal",
"threeUpVertical",
"threeUpHorizontal",
"threeUpStacked",
"fourUp",
"sixUp",
"tile",
];
/// Whether the window was maximized when a screen mode took it full screen (egui temp data).
fn was_maximized_id() -> egui::Id {
egui::Id::new("pc-full-screen-was-maximized")
}
/// The window commands for switching to screen mode `next`, and whether the window should be
/// maximized again when it leaves full screen. With `keep_apart` (Windows), full screen and
/// maximized never overlap: a maximized window is un-maximized before it goes full screen and
/// maximized again after it leaves. Why: winit on Windows keeps the maximized style on a window
/// that goes full screen from maximized, and the shell then draws the taskbar black instead of
/// hiding it; on the way out the window stays maximized at the monitor's full size, over the
/// taskbar (#3550, #4018, #4230). [OBSERVED 2026-10-12, Windows 11, eframe 0.36.2, winit 0.30.13,
/// a minimal eframe window; un-maximizing first gave a clean full screen and a work-area maximize.]
///
/// WORKAROUND(winit#4756): remove `keep_apart` once the winit in Cargo.lock (0.30.13 today) has
/// the fix; check first with https://gist.github.com/aweussom/3f80ffb82df7f0102341858afbbffd9b
/// (`--max`, no `--fix`: full screen must have no WS_MAXIMIZE and hide the taskbar).
fn screen_mode_commands(keep_apart: bool, full_screen: bool, maximized: bool, was_maximized: bool, next: &str) -> (Vec<egui::ViewportCommand>, bool) {
let want = next != "standard";
let mut commands = Vec::new();
let mut remember = was_maximized;
if keep_apart && want && !full_screen && maximized {
commands.push(egui::ViewportCommand::Maximized(false));
remember = true;
}
commands.push(egui::ViewportCommand::Fullscreen(want));
if !want {
if keep_apart && full_screen && remember {
commands.push(egui::ViewportCommand::Maximized(true));
}
remember = false;
}
(commands, remember)
}
/// Pixel aspect ratio (width / height of a pixel) of a preset id.
pub fn pixel_aspect_ratio(id: &str) -> f32 {
// View › Pixel Aspect Ratio › Custom: `custom:<ratio>:<name>` (see `workspace_ui`).
if let Some(r) = id.strip_prefix("custom:").and_then(|r| r.split(':').next()?.parse::<f32>().ok()) {
return r;
}
match id {
"d1DvNtsc" => 0.91,
"d1DvPal" => 1.09,
"d1DvNtscWidescreen" => 1.21,
"hdv1080" => 1.33,
"d1DvPalWidescreen" => 1.46,
"dvcproHd1080" => 1.5,
"anamorphic2To1" => 2.0,
_ => 1.0,
}
}
fn show_slot<'a>(s: &'a mut Show, key: &str) -> Option<&'a mut bool> {
Some(match key {
"layerEdges" => &mut s.layer_edges,
"selectionEdges" => &mut s.selection_edges,
"targetPath" => &mut s.target_path,
"pixelGrid" => &mut s.pixel_grid,
"smartGuides" => &mut s.smart_guides,
"brushPreview" => &mut s.brush_preview,
"slices" => &mut s.slices,
"notes" => &mut s.notes,
"mesh" => &mut s.mesh,
"editPins" => &mut s.edit_pins,
"count" => &mut s.count,
"canvasGuides" => &mut s.canvas_guides,
"artboardGuides" => &mut s.artboard_guides,
_ => return None,
})
}
fn snap_slot<'a>(s: &'a mut SnapTo, key: &str) -> Option<&'a mut bool> {
Some(match key {
"guides" => &mut s.guides,
"grid" => &mut s.grid,
"layers" => &mut s.layers,
"slices" => &mut s.slices,
"documentBounds" => &mut s.document_bounds,
_ => return None,
})
}
/// Every [`show_slot`] key.
const SHOW_KEYS: [&str; 13] = [
"layerEdges",
"selectionEdges",
"targetPath",
"pixelGrid",
"smartGuides",
"brushPreview",
"slices",
"notes",
"mesh",
"editPins",
"count",
"canvasGuides",
"artboardGuides",
];
/// Every [`snap_slot`] key.
const SNAP_KEYS: [&str; 5] = ["guides", "grid", "layers", "slices", "documentBounds"];
/// The flags of `now` that differ from `def`, by key (`None` when none do).
fn changed_flags<T: Copy>(now: T, def: T, keys: &[&str], slot: impl for<'a> Fn(&'a mut T, &str) -> Option<&'a mut bool>) -> Option<Value> {
let (mut now, mut def) = (now, def);
let diff: Map<String, Value> = keys
.iter()
.filter_map(|k| {
let v = *slot(&mut now, k)?;
(Some(v) != slot(&mut def, k).copied()).then(|| ((*k).to_string(), Value::Bool(v)))
})
.collect();
(!diff.is_empty()).then_some(Value::Object(diff))
}
/// View › Extras, Show and Snap To as remembered across restarts (`Preferences::view_extras`):
/// only what differs from the defaults, so a default changed in a later release still reaches
/// users who never touched that item. `Null` when nothing differs.
fn remembered(app: &PhotocraftApp) -> Value {
let (v, e, def) = (&app.ui.view, &app.ui.extras, crate::state::Extras::default());
let mut m = Map::new();
if v.extras != ViewOptions::default().extras {
m.insert("extras".into(), Value::Bool(v.extras));
}
// View › Show › Grid and Guides live in `UiState::extras` beside the rulers and snapping.
if e.grid != def.grid {
m.insert("grid".into(), Value::Bool(e.grid));
}
if e.guides != def.guides {
m.insert("guides".into(), Value::Bool(e.guides));
}
if let Some(show) = changed_flags(v.show, Show::default(), &SHOW_KEYS, show_slot) {
m.insert("show".into(), show);
}
if let Some(snap) = changed_flags(v.snap_to, SnapTo::default(), &SNAP_KEYS, snap_slot) {
m.insert("snapTo".into(), snap);
}
if m.is_empty() { Value::Null } else { Value::Object(m) }
}
/// Remember View › Extras, Show and Snap To in the preferences once the user lets go of the
/// pointer (#3762). Cheap: a small JSON compare per frame. Run every frame, so items set by the
/// control channel or the Show Extras Options dialog are remembered too.
pub fn persist(app: &mut PhotocraftApp, ctx: &egui::Context) {
if ctx.input(|i| i.pointer.any_down()) {
return;
}
let now = remembered(app);
if app.session.prefs().view_extras != now {
app.session.prefs.edit(|p| p.view_extras = now);
}
}
/// Restore the remembered View › Extras, Show and Snap To items at launch. A hand-edited file
/// can't break them: an unknown key or a value that isn't a boolean keeps its default.
pub fn restore(app: &mut PhotocraftApp) {
let saved = app.session.prefs().view_extras.clone();
if !saved.is_null() && !saved.is_object() {
log::warn!("Preferences: the remembered View › Show items are not an object; using the defaults");
return;
}
let flag = |k: &str| saved.get(k).and_then(Value::as_bool);
if let Some(on) = flag("extras") {
app.ui.view.extras = on;
}
if let Some(on) = flag("grid") {
app.ui.extras.grid = on;
}
if let Some(on) = flag("guides") {
app.ui.extras.guides = on;
}
for (k, v) in saved.get("show").and_then(Value::as_object).into_iter().flatten() {
if let (Some(slot), Some(on)) = (show_slot(&mut app.ui.view.show, k), v.as_bool()) {
*slot = on;
}
}
for (k, v) in saved.get("snapTo").and_then(Value::as_object).into_iter().flatten() {
if let (Some(slot), Some(on)) = (snap_slot(&mut app.ui.view.snap_to, k), v.as_bool()) {
*slot = on;
}
}
}
/// Window › <panel>: (panel, dock tab) for panels that live as tabs of a dock card.
fn panel_tab(app: &PhotocraftApp, id: &str) -> Option<(&'static str, usize)> {
let pro = app.ui.theme.is_pro();
Some(match id {
"window.panel.info" => ("navigator", 2),
"window.panel.histogram" => ("navigator", 1),
"window.panel.navigator" => ("navigator", 0),
"window.panel.actions" => ("history", 1),
"window.panel.layerComps" => ("history", 2),
"window.panel.history" => ("history", 0),
"window.panel.channels" => ("layers", 1),
"window.panel.paths" => ("layers", 2),
"window.panel.layers" => ("layers", 0),
"window.panel.adjustments" => ("properties", 1),
"window.panel.properties" => ("properties", 0),
// Their own Character | Paragraph group, so Properties stays open (#150).
"window.panel.character" | "type.panels.character" => ("character", 0),
"window.panel.paragraph" | "type.panels.paragraph" => ("character", 1),
"window.panel.swatches" => ("color", usize::from(pro)),
"window.panel.color" => ("color", usize::from(!pro)),
_ => return None,
})
}
fn panel_state<'a>(app: &'a mut PhotocraftApp, panel: &str) -> (&'a mut bool, &'a mut usize) {
let (p, t) = (&mut app.ui.panels, &mut app.ui.dock_tabs);
match panel {
"navigator" => (&mut p.navigator, &mut t.navigator),
"history" => (&mut p.history, &mut t.history),
"layers" => (&mut p.layers, &mut t.layers),
"color" => (&mut p.color, &mut t.color),
"character" => (&mut p.character, &mut t.character),
_ => (&mut p.properties, &mut t.properties),
}
}
/// Ids handled here (live menu items).
pub fn handles(id: &str) -> bool {
if let Some(k) = id.strip_prefix("view.show.") {
return show_slot(&mut Show::default(), k).is_some() || k == "all";
}
if let Some(k) = id.strip_prefix("view.snapTo.") {
return snap_slot(&mut SnapTo::default(), k).is_some() || k == "all" || k == "none";
}
if let Some(k) = id.strip_prefix("view.screenMode.") {
return SCREEN_MODES.contains(&k) || k == "cycle";
}
if let Some(k) = id.strip_prefix("view.pixelAspectRatio.") {
return PIXEL_ASPECTS.contains(&k);
}
if let Some(k) = id.strip_prefix("type.fontPreviewSize.") {
return FONT_PREVIEW.contains(&k);
}
if let Some(k) = id.strip_prefix("type.languageOptions.") {
return LANGUAGE.contains(&k) || k == "middleEasternAndSouthAsianComposer";
}
if let Some(k) = id.strip_prefix("window.arrange.") {
return LAYOUTS.contains(&k)
|| matches!(
k,
"consolidateAllToTabs"
| "cascade"
| "floatInWindow"
| "floatAllInWindows"
| "newWindowForDocument"
| "matchZoom"
| "matchLocation"
| "matchRotation"
| "matchAll"
);
}
matches!(
id,
"view.extras"
| "view.twoHundredPercent"
| "view.printSize"
| "view.fitLayersOnScreen"
| "view.fitArtboardOnScreen"
| "window.panel.layerComps"
| "view.flipHorizontal"
| "view.pixelAspectRatioCorrection"
| "view.patternPreview"
| "view.pixelArtPreview"
| "view.rotateView"
| "view.resetView"
| "window.panel.layers"
| "window.panel.history"
| "window.panel.navigator"
| "window.panel.properties"
| "window.panel.color"
| "window.panel.actions"
| "window.panel.channels"
| "window.panel.paths"
| "window.panel.character"
| "window.panel.paragraph"
| "window.panel.info"
| "window.panel.histogram"
| "window.panel.swatches"
| "window.panel.adjustments"
| "type.panels.character"
| "type.panels.paragraph"
)
}
pub fn is_enabled(app: &PhotocraftApp, id: &str) -> Option<bool> {
is_enabled_with_engine(app, id, || app.session.is_enabled(id))
}
/// Keep picker enablement testable with the engine's native filesystem check disabled.
fn is_enabled_with_engine(app: &PhotocraftApp, id: &str, engine_enabled: impl FnOnce() -> bool) -> Option<bool> {
if !handles(id) && !wraps(id) {
return None;
}
let doc = app.session.active().is_some();
Some(match id {
"view.screenMode.cycle" => app.ui.text_edit.is_none(),
"view.twoHundredPercent" | "view.printSize" | "view.fitLayersOnScreen" | "view.rotateView" | "view.resetView" => doc,
"view.fitArtboardOnScreen" => app.session.active().is_some_and(|d| d.doc.has_artboards()),
i if i.starts_with("window.arrange.") => match &i["window.arrange.".len()..] {
"consolidateAllToTabs" => true,
"floatInWindow" | "newWindowForDocument" => doc,
"matchZoom" | "matchLocation" | "matchRotation" | "matchAll" => app.session.documents().len() > 1 || !app.ui.windows.is_empty(),
_ => {
app.session.documents().len() > 1
|| (doc && matches!(&i["window.arrange.".len()..], "floatAllInWindows" | "cascade" | "tile" | "tileAllVertically" | "tileAllHorizontally"))
}
},
// Embedded placement reads the picker's bytes on every platform. Linked placement keeps
// the engine's native filesystem guard because its source must remain reachable.
"file.placeEmbedded" => doc && app.services.file_dialog.is_some(),
i if wraps(i) => engine_enabled(),
_ => true,
})
}
pub fn checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
let o = &app.ui.view;
if let Some(k) = id.strip_prefix("view.show.") {
let mut s = o.show;
return show_slot(&mut s, k).map(|b| *b);
}
if let Some(k) = id.strip_prefix("view.snapTo.") {
let mut s = o.snap_to;
return snap_slot(&mut s, k).map(|b| *b);
}
if let Some(k) = id.strip_prefix("view.screenMode.") {
return SCREEN_MODES.contains(&k).then(|| o.screen_mode == k);
}
if let Some(k) = id.strip_prefix("view.pixelAspectRatio.") {
return PIXEL_ASPECTS.contains(&k).then(|| o.pixel_aspect == k);
}
if let Some(k) = id.strip_prefix("type.fontPreviewSize.") {
return FONT_PREVIEW.contains(&k).then(|| o.font_preview_size == k);
}
if let Some(k) = id.strip_prefix("type.languageOptions.") {
return if k == "middleEasternAndSouthAsianComposer" {
Some(o.middle_eastern_composer)
} else {
LANGUAGE.contains(&k).then(|| o.language_features == k)
};
}
if let Some(k) = id.strip_prefix("window.arrange.")
&& LAYOUTS.contains(&k)
{
return Some(o.arrange == k);
}
if let Some((panel, tab)) = panel_tab(app, id) {
let (p, t) = (&app.ui.panels, &app.ui.dock_tabs);
let (vis, cur) = match panel {
"navigator" => (p.navigator, t.navigator),
"history" => (p.history, t.history),
"layers" => (p.layers, t.layers),
"color" => (p.color, t.color),
"character" => (p.character, t.character),
_ => (p.properties, t.properties),
};
return Some(vis && cur == tab);
}
if let Some(c) = type_checked(app, id) {
return Some(c);
}
Some(match id {
"view.extras" => o.extras,
"view.flipHorizontal" => o.flip_horizontal,
"view.pixelAspectRatioCorrection" => o.pixel_aspect_correction,
"view.patternPreview" => o.pattern_preview,
"view.pixelArtPreview" => o.pixel_art_preview,
_ => return None,
})
}
/// Checkmarks of the Type menu's anti-aliasing, orientation and OpenType items, for the layer the
/// options bar and Properties show: the one being edited, else the active or a selected type layer.
/// Anti-aliasing and orientation stay check items (`Some(false)`) without a type layer: a native
/// menu can't change an item's kind in place.
fn type_checked(app: &PhotocraftApp, id: &str) -> Option<bool> {
let t = crate::type_tool::target_text(app);
if let Some(k) = id.strip_prefix("type.antiAlias.") {
return Some(t.is_some_and(|t| photocraft_engine::type_extra_cmds::aa_name(t.antialias) == k));
}
if let Some(k) = id.strip_prefix("type.orientation.") {
return Some(t.is_some_and(|t| (t.orientation == photocraft_doc::text::Orientation::Vertical) == (k == "vertical")));
}
let t = t?;
let tag = match id.strip_prefix("type.openType.")? {
"standardLigatures" => "liga",
"contextualAlternates" => "calt",
"discretionaryLigatures" => "dlig",
"swash" => "swsh",
"oldstyle" => "onum",
"stylisticAlternates" => "salt",
"titlingAlternates" => "titl",
"ornaments" => "ornm",
"ordinals" => "ordn",
"fractions" => "frac",
_ => return None,
};
Some(t.char_runs().first().is_some_and(|r| photocraft_engine::type_extra_cmds::feature_on(&r.style, tag)))
}
/// Engine commands that get a dialog or file picker here when invoked without parameters.
fn wraps(id: &str) -> bool {
matches!(
id,
"file.openAs"
| "file.saveACopy"
| "file.placeEmbedded"
| "file.placeLinked"
| "layer.smartObjects.replaceContents"
| "layer.smartObjects.exportContents"
| "layer.smartObjects.convertToLinked"
| "file.closeAll"
| "file.closeOthers"
| "file.fileInfo"
| "file.automate.fitImage"
| "file.automate.conditionalModeChange"
| "file.automate.batch"
| "file.scripts.imageProcessor"
| "file.scripts.loadFilesIntoStack"
| "file.automate.photomerge"
| "file.automate.mergeToHdrPro"
| "file.automate.lensCorrection"
| "file.export.layersToFiles"
| "file.export.layerCompsToFiles"
| "file.export.artboardsToFiles"
| "file.export.artboardsToPdf"
| "layer.new.artboard"
| "file.export.colorLookupTables"
| "view.newGuideLayout"
| "type.warpText"
| "type.pasteLoremIpsum"
| "image.applyImage"
| "image.calculations"
| "select.saveSelection"
| "select.loadSelection"
)
}
fn no_params(p: &Value) -> bool {
p.as_object().is_none_or(|o| o.is_empty())
}
/// Run a View/Window/Type shell command, or front an engine command with its dialog. `None` when
/// `id` isn't ours.
pub fn invoke(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option<Result<Value, String>> {
if wraps(id) {
return front(app, ctx, id, params);
}
if !handles(id) {
return None;
}
Some(run(app, ctx, id, params))
}
fn flag_param(p: &Value, cur: bool) -> bool {
p.get("on").and_then(Value::as_bool).unwrap_or(!cur)
}
fn run(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, p: &Value) -> Result<Value, String> {
if let Some((panel, tab)) = panel_tab(app, id) {
let group = crate::dock::Group::from_key(panel);
let collapsed = group.is_some_and(|g| app.ui.dock.is_collapsed(g));
let (vis, cur) = panel_state(app, panel);
let requested = match p.get("show").or_else(|| p.get("on")) {
None => None,
Some(value) => Some(value.as_bool().ok_or("show must be a boolean")?),
};
if let Some(show) = requested {
*vis = show;
if show {
*cur = tab;
}
} else {
// Like Photoshop: choosing a visible panel's menu item again hides it. A collapsed
// group is expanded instead, so the menu item always brings the panel back (#129).
if *vis && *cur == tab && !collapsed && !id.starts_with("type.panels.") {
*vis = false;
} else {
*vis = true;
*cur = tab;
}
}
let visible = *vis;
if let Some(g) = group.filter(|_| visible) {
crate::dock::reveal(app, g);
}
return Ok(json!({"panel": panel, "tab": tab, "visible": visible}));
}
if id == "view.rotateView" {
return crate::rotate_view::command(app, p);
}
if id == "view.resetView" {
return crate::rotate_view::reset(app);
}
let o = &mut app.ui.view;
if let Some(k) = id.strip_prefix("view.show.") {
if k == "all" {
o.show = Show { layer_edges: true, mesh: true, ..Show::default() };
o.extras = true;
app.ui.extras.grid = true;
app.ui.extras.guides = true;
return Ok(json!(true));
}
let slot = show_slot(&mut o.show, k).ok_or("unknown Show item")?;
*slot = flag_param(p, *slot);
let v = *slot;
if v {
// Showing an item turns Extras back on, as in Photoshop.
o.extras = true;
}
return Ok(json!(v));
}
if let Some(k) = id.strip_prefix("view.snapTo.") {
if k == "all" || k == "none" {
let on = k == "all";
o.snap_to = SnapTo { guides: on, grid: on, layers: on, slices: on, document_bounds: on };
return Ok(json!(on));
}
let slot = snap_slot(&mut o.snap_to, k).ok_or("unknown Snap To item")?;
*slot = flag_param(p, *slot);
return Ok(json!(*slot));
}
if let Some(k) = id.strip_prefix("view.screenMode.") {
let next = if k == "cycle" {
let i = SCREEN_MODES.iter().position(|m| *m == o.screen_mode).unwrap_or(0);
SCREEN_MODES[(i + 1) % SCREEN_MODES.len()]
} else {
k
};
o.screen_mode = next.to_string();
// Both full-screen modes take the whole display; the menu bar stays in the first.
let (full_screen, maximized) = ctx.input(|i| (i.viewport().fullscreen.unwrap_or(false), i.viewport().maximized.unwrap_or(false)));
let was_maximized = ctx.data(|d| d.get_temp::<bool>(was_maximized_id())).unwrap_or(false);
let (commands, was_maximized) = screen_mode_commands(cfg!(target_os = "windows"), full_screen, maximized, was_maximized, next);
ctx.data_mut(|d| d.insert_temp(was_maximized_id(), was_maximized));
for command in commands {
ctx.send_viewport_cmd(command);
}
return Ok(json!({"screenMode": next}));
}
if let Some(k) = id.strip_prefix("view.pixelAspectRatio.") {
o.pixel_aspect = k.to_string();
return Ok(json!({"pixelAspectRatio": pixel_aspect_ratio(k)}));
}
if let Some(k) = id.strip_prefix("type.fontPreviewSize.") {
o.font_preview_size = k.to_string();
return Ok(json!(k));
}
if let Some(k) = id.strip_prefix("type.languageOptions.") {
if k == "middleEasternAndSouthAsianComposer" {
o.middle_eastern_composer = flag_param(p, o.middle_eastern_composer);
return Ok(json!(o.middle_eastern_composer));
}
o.language_features = k.to_string();
return Ok(json!(k));
}
if let Some(k) = id.strip_prefix("window.arrange.") {
return arrange(app, k, p);
}
match id {
"view.extras" => {
o.extras = flag_param(p, o.extras);
Ok(json!(o.extras))
}
"view.flipHorizontal" => {
o.flip_horizontal = flag_param(p, o.flip_horizontal);
Ok(json!(o.flip_horizontal))
}
"view.pixelAspectRatioCorrection" => {
o.pixel_aspect_correction = flag_param(p, o.pixel_aspect_correction);
Ok(json!(o.pixel_aspect_correction))
}
"view.patternPreview" => {
o.pattern_preview = flag_param(p, o.pattern_preview);
Ok(json!(o.pattern_preview))
}
"view.pixelArtPreview" => {
o.pixel_art_preview = flag_param(p, o.pixel_art_preview);
Ok(json!(o.pixel_art_preview))
}
"view.twoHundredPercent" | "view.printSize" => {
let i = app.session.active_index().ok_or("no document")?;
// Print Size shows the document at its print size for the screen resolution
// (Preferences ▸ Units & Rulers: Screen Resolution, 72 ppi by default).
let dpi = app.session.active().map_or(72.0, |d| d.doc.resolution_dpi.max(1.0));
let size = app.session.active().map_or([0, 0], |d| [d.doc.size.width, d.doc.size.height]);
let screen = app.session.prefs().units_and_rulers.screen_resolution.max(1.0) as f32;
let z = if id == "view.twoHundredPercent" { 2.0 } else { screen / dpi };
app.ui.views[i].zoom = crate::zoom_levels::clamp(z, size);
Ok(json!({"zoom": app.ui.views[i].zoom}))
}
"view.fitLayersOnScreen" => fit_layers(app),
"view.fitArtboardOnScreen" => crate::artboard_ui::fit_artboard(app),
_ => Err(format!("unhandled {id}")),
}
}
/// View › Fit Layer(s) on Screen: zoom and centre on the selected layers' bounds.
fn fit_layers(app: &mut PhotocraftApp) -> Result<Value, String> {
let i = app.session.active_index().ok_or("no document")?;
let st = app.session.active().ok_or("no document")?;
let mut b = photocraft_geom::Rect::EMPTY;
for id in st.selected_layers() {
if let Some(r) = st.doc.layer(id).and_then(|l| l.surface()).map(|s| s.content_bounds()) {
b = if b.is_empty() { r } else { b.union(&r) };
}
}
if b.is_empty() {
b = st.doc.bounds();
}
let size = [st.doc.size.width, st.doc.size.height];
let area = app.last_canvas_rect.size();
let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) };
// The area is in egui points; the stored zoom is device pixels per document pixel.
let ppp = app.canvas_ppp();
let points = ((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32);
let v = &mut app.ui.views[i];
v.zoom = crate::zoom_levels::clamp(points * ppp, size);
v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0];
v.fit_pending = false;
Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]}))
}
/// A finite `[x, y]` screen position within a sane range (window systems reject wild values).
fn screen_position(v: &Value) -> Option<[f32; 2]> {
const LIMIT: f64 = 1.0e6;
let a = v.as_array().filter(|a| a.len() == 2)?;
let n = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite() && x.abs() <= LIMIT).map(|x| x as f32);
Some([n(0)?, n(1)?])
}
pub(crate) fn float_window(app: &mut PhotocraftApp, doc: usize, offset: usize) -> u64 {
let wid = app.ui.alloc_id();
let mut view = app.ui.views.get(doc).cloned().unwrap_or_default();
view.fit_pending = offset > 0 || view.fit_pending;
app.ui.windows.push(crate::state::DocWindow { id: wid, document: doc, view, open: true, position: None });
wid
}
fn arrange(app: &mut PhotocraftApp, k: &str, p: &Value) -> Result<Value, String> {
if LAYOUTS.contains(&k) {
app.ui.view.arrange = k.to_string();
// Each document fits its new cell.
app.ui.views.iter_mut().for_each(|v| v.fit_pending = true);
return Ok(json!({"arrange": k}));
}
match k {
"consolidateAllToTabs" => {
app.ui.view.arrange = "tabs".into();
app.ui.windows.clear();
Ok(json!({"arrange": "tabs"}))
}
"newWindowForDocument" => {
let doc = app.session.active_index().ok_or("no document")?;
Ok(json!({"window": float_window(app, doc, 0)}))
}
// Optional `document` (index; default the active one) and `position` (`[x, y]`, the
// window's screen position in points): dragging a tab off the tab strip uses both (#3733).
"floatInWindow" => {
let doc = match p.get("document") {
None | Some(Value::Null) => app.session.active_index().ok_or("no document")?,
Some(v) => {
let i = v.as_u64().ok_or("document must be a document index")?;
usize::try_from(i).ok().filter(|&i| i < app.session.documents().len()).ok_or(format!("no document {i}"))?
}
};
let position = match p.get("position") {
None | Some(Value::Null) => None,
Some(v) => Some(screen_position(v).ok_or("position must be [x, y] in points")?),
};
let wid = float_window(app, doc, 0);
if let Some(w) = app.ui.windows.iter_mut().find(|w| w.id == wid) {
w.position = position;
}
app.session.set_active(doc);
Ok(json!({"window": wid}))
}
"floatAllInWindows" | "cascade" => {
let n = app.session.documents().len();
let ids: Vec<u64> = (0..n).map(|d| float_window(app, d, d)).collect();
Ok(json!({"windows": ids}))
}
"matchZoom" | "matchLocation" | "matchRotation" | "matchAll" => {
let i = app.session.active_index().ok_or("no document")?;
let src = app.ui.views.get(i).cloned().ok_or("no document")?;
let (zoom, loc, rot) =
(matches!(k, "matchZoom" | "matchAll"), matches!(k, "matchLocation" | "matchAll"), matches!(k, "matchRotation" | "matchAll"));
let apply = |v: &mut crate::state::View| {
if zoom {
v.zoom = src.zoom;
}
if loc {
v.center = src.center;
}
if rot {
v.rotation = src.rotation;
}
v.fit_pending = false;
};
app.ui.views.iter_mut().for_each(apply);
app.ui.windows.iter_mut().for_each(|w| apply(&mut w.view));
Ok(json!({"zoom": src.zoom, "center": src.center, "rotation": src.rotation}))
}
_ => Err(format!("unknown arrangement {k}")),
}
}
/// Screen rectangles for `n` documents in an arrangement (`None` = tabs). The active document
/// always gets a cell; n-up layouts show that many documents starting from the active one.
pub fn cells(layout: &str, rect: egui::Rect, n: usize) -> Option<Vec<egui::Rect>> {
use craft_ui::layout::{SplitAxis, SplitSize, split_rect};
if n < 2 || !LAYOUTS.contains(&layout) {
return None;
}
let grid = |cols: usize, rows: usize, count: usize| -> Vec<egui::Rect> {
let (w, h) = (rect.width() / cols as f32, rect.height() / rows as f32);
(0..count.min(cols * rows))
.map(|i| egui::Rect::from_min_size(rect.min + egui::vec2((i % cols) as f32 * w, (i / cols) as f32 * h), egui::vec2(w, h)))
.collect()
};
Some(match layout {
"tileAllVertically" => grid(n, 1, n),
"tileAllHorizontally" => grid(1, n, n),
"twoUpVertical" => {
let panes = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?;
vec![panes.first, panes.second]
}
"twoUpHorizontal" => {
let panes = split_rect(rect, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?;
vec![panes.first, panes.second]
}
"threeUpVertical" => grid(3, 1, 3),
"threeUpHorizontal" => grid(1, 3, 3),
"threeUpStacked" => {
let columns = split_rect(rect, SplitAxis::Horizontal, SplitSize::Ratio(0.5), 0.0, 0.0)?;
let rows = split_rect(columns.second, SplitAxis::Vertical, SplitSize::Ratio(0.5), 0.0, 0.0)?;
vec![columns.first, rows.first, rows.second]
}
"fourUp" => grid(2, 2, 4),
"sixUp" => grid(3, 2, 6),
_ => {
let cols = (n as f32).sqrt().ceil() as usize;
grid(cols, n.div_ceil(cols), n)
}
})
}
// ---------- dialogs and pickers in front of engine commands ----------
/// A generic form dialog that runs `command` with its fields on OK (rendered by [`form_body`]).
/// File › Open EXR Parts…: pick an EXR, then choose the parts and channel groups that become
/// the layers of one document (a Maya/Arnold render writes one part per AOV). The dialog is
/// a plain command form, so the control channel can drive the same selection headlessly.
fn open_exr_parts_dialog(app: &mut PhotocraftApp) -> Option<Result<Value, String>> {
Some(app.pick_file_bytes_filtered(&["exr"], |app, name, _bytes| {
let info = app.run("file.exrParts", json!({"path": name})).map_err(|e| e.to_string())?;
let entries = info.as_array().cloned().unwrap_or_default();
let mut fields = Map::new();
fields.insert("path".into(), json!(name));
fields.insert("view".into(), json!("both"));
fields.insert("precomp".into(), json!(false));
let mut labels = Map::new();
// The path field shows the picked file's name (data, verbatim); the rest translate.
if let Some(file) = name.rsplit(['/', '\\']).next() {
labels.insert("path".into(), json!(file));
}
labels.insert("view".into(), json!(crate::i18n::t("View")));
labels.insert("both".into(), json!(crate::i18n::t("Both")));
labels.insert("left".into(), json!(crate::i18n::t("Left")));
labels.insert("right".into(), json!(crate::i18n::t("Right")));
labels.insert("precomp".into(), json!(crate::i18n::t("Add beauty precomp from the AOVs")));
let mut any = false;
for e in &entries {
let kind = e["kind"].as_str().unwrap_or_default().to_string();
let ename = e["name"].as_str().unwrap_or_default().to_string();
let channels = e["channels"].as_u64().unwrap_or(0);
let key = format!("{kind}/{ename}");
match kind.as_str() {
"part" => {
labels.insert(key.clone(), json!(format!("{ename} ({}×{}, {channels} ch)", e["width"], e["height"])));
let on = !any && channels >= 3;
any |= on;
fields.insert(key, json!(on));
}
"group" => {
labels.insert(key.clone(), json!(format!("{ename} ({channels} ch)")));
fields.insert(key, json!(false));
}
_ => {}
}
}
// Cryptomatte layers are information here (the picker comes separately); the array is
// not rendered but rides along for the control channel.
let crypto: Vec<Value> = entries.iter().filter(|e| e["kind"] == "cryptomatte").cloned().collect();
if !crypto.is_empty() {
fields.insert("cryptomatte".into(), Value::Array(crypto));
}
fields.insert("__labels".into(), Value::Object(labels));
let choices = json!({"view": ["both", "left", "right"]});
Ok(json!({"dialog": form(app, "file.openExrParts", "Open EXR Parts…", Value::Object(fields), choices)}))
}))
}
fn form(app: &mut PhotocraftApp, command: &str, label: &str, fields: Value, choices: Value) -> u64 {
let mut f = Map::new();
f.insert("__command".into(), json!(command));
f.insert("__label".into(), json!(label));
f.insert("__form".into(), json!(true));
f.insert("__choices".into(), choices);
if let Value::Object(m) = fields {
f.extend(m);
}
// Snapshot user-facing params at open so Alt+Reset can restore them.
let orig: Map<String, Value> = f.iter().filter(|(k, _)| !k.starts_with("__")).map(|(k, v)| (k.clone(), v.clone())).collect();
f.insert("__formOrig".into(), Value::Object(orig));
app.ui.open_dialog(DialogKind::Command, f)
}
/// The Batch and Image Processor dialogs' "Include All Subfolders" checkbox reads as Photoshop's.
fn subfolders_label() -> Value {
json!({"includeSubfolders": tl!("Include All Subfolders")})
}
/// Alt+Cancel (Reset): restore the `__form` params captured at open.
pub fn reset_form(f: &mut Map<String, Value>) {
if !f.contains_key("__form") {
return;
}
let Some(Value::Object(orig)) = f.get("__formOrig").cloned() else { return };
f.retain(|k, _| k.starts_with("__"));
f.extend(orig);
}
fn label_of(key: &str) -> String {
// A name that already reads as text ("iPhone 14 Pro Max", "Web 1920") is not camelCase.
if key.contains(' ') {
return tl!(key).to_owned();
}
let mut out = String::new();
for (i, c) in key.chars().enumerate() {
if i == 0 {
out.extend(c.to_uppercase());
} else if c.is_uppercase() {
out.push(' ');
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
let translated = tl!(&out);
if translated != out {
return translated.to_owned();
}
// Fall back to the Title Case filter label's translation, but keep the sentence-case
// English when that is untranslated too (English and partial catalogs read as before).
let title = crate::filter_dialog::label(key);
if title == crate::filter_dialog::source_label(key) { out } else { title }
}
#[cfg(test)]
mod label_tests {
use super::label_of;
#[test]
fn untranslated_form_labels_stay_sentence_case() {
crate::i18n::with_language(crate::i18n::Lang::EN, || {
assert_eq!(label_of("useAntialias"), "Use antialias");
assert_eq!(label_of("radius"), "Radius");
assert_eq!(label_of("iPhone 14 Pro Max"), "iPhone 14 Pro Max");
});
}
}
#[cfg(test)]
mod form_reset_tests {
use super::*;
use serde_json::json;
#[test]
fn alt_reset_restores_form_orig() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let id = form(&mut app, "test.form", "Test", json!({"radius": 5.0, "on": true}), json!({}));
let f = &mut app.ui.dialog_mut(id).unwrap().fields;
f.insert("radius".into(), json!(99.0));
f.insert("on".into(), json!(false));
reset_form(f);
assert_eq!(f["radius"], json!(5.0));
assert_eq!(f["on"], json!(true));
assert!(f.contains_key("__formOrig"));
}
}
/// Body of a `__form` dialog: text fields, number fields, checkboxes and `__choices` dropdowns.
pub fn form_body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
let choices = f.get("__choices").cloned().unwrap_or(Value::Null);
// Data-driven labels (a per-part checkbox shows the part's name, not a catalogue entry)
// come from `__labels` verbatim; everything else goes through the catalogue.
let labels = f.get("__labels").cloned().unwrap_or(Value::Null);
let label = |k: &str| labels.get(k).and_then(Value::as_str).map_or_else(|| label_of(k), str::to_string);
let keys: Vec<String> = f.keys().filter(|k| !k.starts_with("__")).cloned().collect();
egui::Grid::new("form-dialog").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
for k in keys {
let v = f.get(&k).cloned().unwrap_or(Value::Null);
if k == "naming" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") {
let label = ui.label(tl!("File Naming"));
let mut naming = v.as_str().unwrap_or_default().to_owned();
ui.add(egui::TextEdit::singleline(&mut naming).desired_width(260.0).hint_text("{name}"))
.labelled_by(label.id)
.on_hover_text("{name} · {serial} · {date} (UTC)");
f.insert(k, json!(naming));
ui.end_row();
continue;
}
if k == "quality" && f.get("__command").and_then(Value::as_str) == Some("file.automate.batch") {
ui.label(label(&k));
let mut custom = v.as_f64().is_some();
let mut quality = v.as_f64().or_else(|| f.get("__batchQuality").and_then(Value::as_f64)).unwrap_or(8.0).clamp(0.0, 12.0) as f32;
ui.horizontal(|ui| {
crate::widgets::checkbox(ui, &mut custom, tl!("Custom"));
ui.add_enabled_ui(custom, |ui| crate::widgets::value_field(ui, &mut quality, 0.0..=12.0, "", 74.0));
});
// None preserves the command's existing JPEG and lossless WebP defaults.
// The inactive custom value is shell state, never a command parameter.
f.insert("__batchQuality".into(), json!(quality));
f.insert(k, if custom { json!(quality) } else { Value::Null });
ui.end_row();
continue;
}
let new = match &v {
Value::Array(_) | Value::Object(_) | Value::Null => continue,
Value::Bool(b) => {
ui.label("");
let mut b = *b;
ui.checkbox(&mut b, label(&k));
json!(b)
}
Value::String(s) => {
ui.label(label(&k));
let mut s = s.clone();
match choices.get(&k).and_then(Value::as_array) {
Some(opts) => {
let opts: Vec<(String, String)> = opts.iter().filter_map(Value::as_str).map(|o| (o.to_string(), label(o))).collect();
let refs: Vec<(String, &str)> = opts.iter().map(|(a, b)| (a.clone(), b.as_str())).collect();
crate::widgets::dropdown(ui, &format!("form-{k}"), &mut s, &refs, 200.0);
}
None => {
ui.add(egui::TextEdit::singleline(&mut s).desired_width(260.0));
}
}
json!(s)
}
Value::Number(n) => {
ui.label(label(&k));
if let Some(mut i) = n.as_i64() {
ui.add(egui::DragValue::new(&mut i).custom_parser(crate::widgets::parse_num));
json!(i)
} else {
let mut x = n.as_f64().unwrap_or(0.0);
ui.add(egui::DragValue::new(&mut x).speed(0.5).custom_parser(crate::widgets::parse_num));
json!(x)
}
}
};
f.insert(k, new);
ui.end_row();
}
});
camera_data(ui, f);
}
/// File Info's read-only Camera Data section (`__camera`, from the document's EXIF/XMP).
fn camera_data(ui: &mut egui::Ui, f: &Map<String, Value>) {
let Some(camera) = f.get("__camera").and_then(Value::as_object).filter(|c| !c.is_empty()) else { return };
ui.add_space(10.0);
ui.label(egui::RichText::new(tl!("Camera Data")).font(crate::theme::semibold(13.0)));
ui.add_space(4.0);
egui::Grid::new("form-camera-data").num_columns(2).spacing([12.0, 4.0]).show(ui, |ui| {
for (key, label) in photocraft_engine::file_cmds::CAMERA_FIELDS {
if let Some(v) = camera.get(key).and_then(Value::as_str) {
ui.label(crate::i18n::tr_ctx(crate::i18n::current(), "cameraData", label));
ui.add(egui::Label::new(v).selectable(true));
ui.end_row();
}
}
});
}
/// Folder the batch dialogs start from: next to the active document, else the working directory.
fn default_dir(app: &PhotocraftApp) -> String {
app.session.active().and_then(|d| d.path.as_deref()).and_then(|p| p.rfind(['/', '\\']).map(|i| p[..i].to_string())).unwrap_or_else(|| ".".into())
}
fn front(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option<Result<Value, String>> {
if id == "file.closeAll" || id == "file.closeOthers" {
// Keep the remaining document's view (views are index-aligned with documents).
let keep = app.session.active_index().and_then(|i| app.ui.views.get(i).cloned());
let r = app.run(id, params.clone());
if r.is_ok() {
app.ui.views = if id == "file.closeAll" { Vec::new() } else { keep.into_iter().collect() };
app.ui.windows.clear();
app.sync_views();
}
return Some(r);
}
if id == "type.pasteLoremIpsum" && no_params(params) {
// While typing, paste at the caret.
if let Some(te) = app.ui.text_edit.clone() {
let r = app.run(id, json!({"layer": te.layer, "at": te.caret, "coalesce": te.session}));
if let (Ok(v), Some(te)) = (&r, app.ui.text_edit.as_mut())
&& let Some(c) = v.get("caret").and_then(Value::as_u64)
{
te.caret = c as usize;
te.anchor = c as usize;
}
return Some(r);
}
return None;
}
if !no_params(params) {
return None;
}
let doc = app.session.active().map(|d| (d.doc.size.width, d.doc.size.height, d.doc.name.clone()));
let dir = default_dir(app);
let label = photocraft_engine::commands::find(id).map_or(id, |c| c.label);
let dialog = |app: &mut PhotocraftApp, fields: Value, choices: Value| Some(Ok(json!({"dialog": form(app, id, label, fields, choices)})));
match id {
"file.openAs" => Some(app.open_dialog_file()),
"file.openExrParts" => open_exr_parts_dialog(app),
"file.saveACopy" => Some(save_a_copy(app)),
"file.placeEmbedded" | "file.placeLinked" => {
if app.ui.transform.is_some() {
return Some(Err("Commit or cancel the transform first".into()));
}
let doc = match app.active_doc_id() {
Ok(doc) => doc,
Err(e) => return Some(Err(e)),
};
let linked = id == "file.placeLinked";
let ctx = ctx.clone();
Some(app.pick_file_bytes(move |app, name, bytes| {
if app.ui.transform.is_some() {
return Err("Commit or cancel the transform first".into());
}
app.refocus(doc)?;
let linked = linked.then(|| name.clone());
let result = app.place_bytes(&name, bytes, linked)?;
crate::transform_tool::begin_placed(app, &ctx)?;
Ok(result)
}))
}
"layer.smartObjects.replaceContents" => {
let st = app.session.active()?;
let (doc, layer) = (st.doc.id, st.active_layer?);
Some(app.pick_file_bytes(move |app, name, bytes| {
app.with_document(doc, |app| {
let r = photocraft_engine::smart_cmds::replace_contents_bytes(&mut app.session, layer, &name, bytes).map_err(|e| e.to_string());
app.sync_views();
r
})
}))
}
"layer.smartObjects.exportContents" => Some(export_contents(app)),
"layer.smartObjects.convertToLinked" => {
let st = app.session.active()?;
let (doc, layer) = (st.doc.id, st.active_layer?);
let (name, _) = match photocraft_engine::smart_cmds::contents_of(&st.doc, layer) {
Ok(c) => c,
Err(e) => return Some(Err(e.to_string())),
};
Some(app.pick_save(&name, move |app, path| {
app.with_document(doc, |app| app.run("layer.smartObjects.convertToLinked", json!({"layer": layer.0, "path": path})))
}))
}
"file.fileInfo" => {
let info = app.session.execute("file.fileInfo", json!({})).ok()?;
let kw = info["keywords"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join("; ")).unwrap_or_default();
dialog(
app,
json!({"title": info["title"], "author": info["author"], "authorTitle": info["authorTitle"], "description": info["description"], "keywords": kw, "copyright": info["copyright"], "copyrightStatus": info["copyrightStatus"], "copyrightUrl": info["copyrightUrl"], "__camera": info["camera"]}),
json!({"copyrightStatus": ["unknown", "copyrighted", "publicDomain"]}),
)
}
"file.automate.fitImage" => {
let (w, h, _) = doc?;
dialog(app, json!({"width": w, "height": h, "dontEnlarge": false, "__compact": true}), json!({}))
}
"file.automate.conditionalModeChange" => dialog(
app,
json!({"from": "any", "to": "rgb"}),
json!({"from": ["any", "rgb", "grayscale", "cmyk", "lab", "indexed", "bitmap", "duotone", "multichannel"], "to": ["rgb", "grayscale", "cmyk", "lab"]}),
),
"view.newGuideLayout" => Some(Ok(json!({"dialog": crate::guide_layout_ui::open(app)}))),
"type.warpText" => Some(Ok(json!({"dialog": crate::dialogs::open_command_dialog(app, id, label)}))),
"file.export.layersToFiles" => {
let (_, _, name) = doc?;
dialog(
app,
json!({"dir": dir, "prefix": name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a), "format": "png", "visibleOnly": true}),
json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}),
)
}
"file.export.layerCompsToFiles" | "file.export.artboardsToFiles" => {
let (_, _, name) = doc?;
let prefix = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string();
let fields = if id == "file.export.layerCompsToFiles" {
json!({"dir": dir, "prefix": prefix, "format": "png", "selectedOnly": false})
} else {
json!({"dir": dir, "prefix": prefix, "format": "png"})
};
dialog(app, fields, json!({"format": ["png", "jpg", "psd", "tiff", "webp", "bmp"]}))
}
"file.export.artboardsToPdf" => {
let (_, _, name) = doc?;
let stem = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string();
dialog(app, json!({"path": format!("{dir}/{stem}.pdf"), "quality": 10}), json!({}))
}
"layer.new.artboard" => {
let (w, h, _) = doc?;
let presets: Vec<&str> = std::iter::once("").chain(photocraft_engine::artboard_cmds::PRESETS.iter().map(|p| p.0)).collect();
dialog(
app,
json!({"name": "", "preset": "", "width": w, "height": h, "background": "white"}),
json!({"preset": presets, "background": ["white", "black", "transparent"]}),
)
}
"file.export.colorLookupTables" => {
let (_, _, name) = doc?;
let stem = name.rsplit_once('.').map_or(name.as_str(), |(a, _)| a).to_string();
// A selected adjustment is a common use case, but existing no-selection exports
// continue to bake the entire visible stack. The scope remains explicit in the UI.
let selected_adjustments = app.session.active().is_some_and(|st| {
let chosen = st.selected_layers();
!chosen.is_empty()
&& chosen.iter().all(|id| {
st.doc.layers.iter().any(|root| root.id == *id)
&& st.doc.layer(*id).is_some_and(|layer| layer.visible && matches!(layer.content, photocraft_doc::LayerContent::Adjustment(_)))
})
});
let scope = if selected_adjustments { "selected" } else { "all" };
dialog(app, json!({"path": format!("{dir}/{stem}.cube"), "size": 33, "title": stem, "scope": scope}), json!({"scope": ["all", "selected"]}))
}
"file.scripts.loadFilesIntoStack" => Some(Ok(json!({"dialog": crate::stack_dialog::open(app)}))),
// Photography automation (photo_cmds / lens_cmds): a folder (or the open documents).
"file.automate.photomerge" => dialog(
app,
json!({"paths": dir, "useOpenDocuments": false, "layout": "auto", "blend": true, "vignetteRemoval": false, "geometricCorrection": false, "contentAwareFill": false}),
json!({"layout": ["auto", "perspective", "cylindrical", "spherical", "collage", "reposition"]}),
),
"file.automate.mergeToHdrPro" => dialog(
app,
json!({"paths": dir, "useOpenDocuments": false, "align": true, "removeGhosts": false, "mode": "32", "method": "localAdaptation", "radius": 7.0, "strength": 0.52, "gamma": 1.0, "exposure": 0.0, "detail": 30.0, "saturation": 20.0}),
json!({"mode": ["32", "16", "8"], "method": ["localAdaptation", "exposureGamma", "highlightCompression", "equalizeHistogram"]}),
),
"file.automate.lensCorrection" => dialog(
app,
json!({"input": dir, "output": format!("{dir}/corrected"), "format": "same", "profile": "auto", "correctDistortion": true, "correctVignette": true, "correctCA": true, "autoScale": true, "edge": "transparency"}),
json!({"format": ["same", "png", "jpg", "psd", "tiff"], "profile": ["auto", "generic", "none"], "edge": ["transparency", "edgeExtension", "black", "white"]}),
),
"file.scripts.imageProcessor" => dialog(
app,
json!({"input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/processed"), "format": "jpg", "quality": 8, "width": 0, "height": 0, "convertToSrgb": true}),
json!({"format": ["jpg", "png", "psd", "tiff", "webp"]}),
),
"file.automate.batch" => {
let Some(action) = crate::actions::selected_action(app) else {
return Some(Err("record an action in the Actions panel first".into()));
};
let steps = crate::actions::action_steps(action);
let name = action.name.clone();
dialog(
app,
json!({"action": name, "steps": steps, "input": dir, "includeSubfolders": false, "__labels": subfolders_label(), "output": format!("{dir}/batch"), "format": "same", "quality": null, "__batchQuality": 8, "naming": "", "startingSerial": 1}),
json!({"format": ["same", "png", "jpg", "psd", "tiff", "webp"]}),
)
}
// Channel workflow dialogs (see channels_panel.rs for the channel references).
"select.saveSelection" | "select.loadSelection" | "image.applyImage" | "image.calculations" => {
let st = app.session.active()?;
let d = &st.doc;
let mode = d.pixel_format().mode;
let mut chans: Vec<String> = vec![photocraft_engine::channel_cmds::composite_name(mode).to_string()];
if mode.color_channels() > 1 {
chans.extend(photocraft_engine::channel_cmds::color_names(mode).iter().map(|n| n.to_string()));
}
chans.extend(d.channels.iter().map(|c| c.name.clone()));
let alphas: Vec<String> = std::iter::once("new".to_string()).chain(d.channels.iter().map(|c| c.name.clone())).collect();
let blends = [
"multiply",
"screen",
"normal",
"overlay",
"softLight",
"hardLight",
"darken",
"lighten",
"colorDodge",
"colorBurn",
"linearBurn",
"linearDodge",
"difference",
"exclusion",
"add",
"subtract",
];
// The active layer's transparency, and its mask only when it has one.
let layer = st.active_layer.and_then(|l| d.layer(l));
let mut load = chans.clone();
if layer.is_some() {
load.push("transparency".into());
}
if layer.is_some_and(|l| l.mask.is_some()) {
load.push("mask".into());
}
load.push("selection".into());
match id {
"select.saveSelection" => dialog(
app,
json!({"channel": "new", "name": "", "operation": "new"}),
json!({"channel": alphas, "operation": ["new", "replace", "add", "subtract", "intersect"]}),
),
"select.loadSelection" => {
let first = d.channels.first().map_or_else(|| chans[0].clone(), |c| c.name.clone());
dialog(
app,
json!({"channel": first, "invert": false, "operation": "new"}),
json!({"channel": load, "operation": ["new", "add", "subtract", "intersect"]}),
)
}
"image.applyImage" => {
let mut masks = vec!["none".to_string()];
masks.extend(load.iter().cloned());
// Source layer: Merged, then the pixel layers top to bottom (the engine takes
// the topmost layer of a name), shown by their own names.
let mut layers = vec!["merged".to_string()];
for (_, _, l) in d.walk().into_iter().rev() {
if l.surface().is_some() && !layers.contains(&l.name) {
layers.push(l.name.clone());
}
}
let names: Map<String, Value> = layers.iter().skip(1).map(|n| (n.clone(), json!(n))).collect();
// Scale and Offset only affect Add and Subtract.
dialog(
app,
json!({"sourceLayer": "merged", "sourceChannel": chans[0], "sourceInvert": false, "blending": "multiply", "opacity": 100.0, "scale": 1.0, "offset": 0, "preserveTransparency": false, "maskChannel": "none", "__labels": names}),
json!({"sourceLayer": layers, "sourceChannel": load, "blending": blends, "maskChannel": masks}),
)
}
_ => dialog(
app,
json!({"source1Channel": chans[0], "source1Invert": false, "source2Channel": chans[0], "source2Invert": false, "blending": "multiply", "opacity": 100.0, "result": "newChannel"}),
json!({"source1Channel": load.clone(), "source2Channel": load, "blending": blends, "result": ["newChannel", "newDocument", "selection"]}),
),
}
}
_ => None,
}
}
/// File › Save a Copy: pick a name, encode with the export service, write; the document's path
/// and saved state are untouched. An action being recorded keeps it as a `file.saveACopy` step
/// with its path (#4373).
fn save_a_copy(app: &mut PhotocraftApp) -> Result<Value, String> {
let doc = app.session.active().ok_or("no document")?.doc.clone();
let stem = doc.name.rsplit_once('.').map_or(doc.name.as_str(), |(a, _)| a);
let suggested = format!("{stem} copy.psd");
// The copy is of the document as it was when asked.
app.pick_save(&suggested, move |app, path| {
// An RGB Targa asks for its bits per pixel first; the copy is written from the prompt.
let parked = app.with_document(doc.id, |app| {
let ask = crate::targa_options_ui::wants_prompt(app, &path);
if ask {
crate::targa_options_ui::park(app, path.clone(), true, "file.saveACopy")?;
}
Ok(ask)
})?;
if parked {
return Ok(json!({"path": path, "warnings": []}));
}
let r = write_copy(app, &doc, &path, None)?;
app.session.journal.push(("file.saveACopy".into(), json!({"path": path})));
Ok(r)
})
}
/// Writes `doc` to `path` as a copy (Save a Copy, and its replayed action step): the document's
/// path and saved state are untouched. `tga_bits` is the Targa answer, when there is one.
pub(crate) fn write_copy(
app: &mut PhotocraftApp,
doc: &photocraft_doc::Document,
path: &str,
tga_bits: Option<photocraft_io::TgaBits>,
) -> Result<Value, String> {
let settings = crate::ExportSettings { tga_bits, ..app.save_settings() };
let export = app.services.export.as_ref().ok_or("no exporter configured")?;
let (bytes, warnings) = export(doc, path, &settings)?;
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(path, &bytes)?;
let template = "Saved a copy as {path}";
app.ui.status = crate::i18n::fmt(tl!(template), &[("path", path)]);
app.ui.status_error = false;
let what = crate::i18n::fmt(tl!(template), &[("path", &crate::file_open::display_name(path))]);
crate::notices::io_warnings(app, &what, &warnings);
Ok(json!({"path": path, "warnings": warnings}))
}
/// Layer › Smart Objects › Export Contents: saves the contents file under its own name by default.
fn export_contents(app: &mut PhotocraftApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let layer = st.active_layer.ok_or("no layer selected")?;
// The contents as they were when asked, like Save a Copy.
let (name, bytes) = photocraft_engine::smart_cmds::contents_of(&st.doc, layer).map_err(|e| e.to_string())?;
app.pick_save(&name.clone(), move |app, path| {
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, &bytes)?;
let template = "Exported contents to {path}";
app.ui.status = crate::i18n::fmt(tl!(template), &[("path", &path)]);
app.ui.status_error = false;
Ok(json!({"path": path, "fileName": name, "bytes": bytes.len()}))
})
}
#[cfg(test)]
#[path = "view_cmds/tests.rs"]
mod tests;