diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fdaf7627..2a0eaa75 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,10 +18,11 @@ next to them. | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | -| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg` and `direct-select.svg` (109 files) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/lasso-magnetic.svg` | Magnetic Lasso tool glyph, adapted from "Magnetic Lasso Tool" (redrawn on the 24 px toolbar grid) | Lil' Seal (Noun Project); adapted by PhotoCraft contributors | | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) | diff --git a/Cargo.lock b/Cargo.lock index 9dd34b93..599dc4d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4173,6 +4173,7 @@ dependencies = [ "photocraft-geom", "photocraft-raster", "proptest", + "rustix 1.1.5", "ruzstd", "serde", "serde_json", diff --git a/assets/icons/square-dot.svg b/assets/icons/adjustment-layer.svg similarity index 69% rename from assets/icons/square-dot.svg rename to assets/icons/adjustment-layer.svg index 898323f6..014b5852 100644 --- a/assets/icons/square-dot.svg +++ b/assets/icons/adjustment-layer.svg @@ -9,6 +9,6 @@ stroke-linecap="round" stroke-linejoin="round" > - - + + diff --git a/assets/icons/layer-mask.svg b/assets/icons/layer-mask.svg new file mode 100644 index 00000000..7f9d6200 --- /dev/null +++ b/assets/icons/layer-mask.svg @@ -0,0 +1,14 @@ + + + + diff --git a/assets/icons/link-2.svg b/assets/icons/link-2.svg new file mode 100644 index 00000000..627adb76 --- /dev/null +++ b/assets/icons/link-2.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/book/src/automation/mcp.md b/book/src/automation/mcp.md index 830208fc..c091f775 100644 --- a/book/src/automation/mcp.md +++ b/book/src/automation/mcp.md @@ -30,3 +30,9 @@ The current server does not issue scoped sessions or distinguish trusted tools f Filesystem capabilities are implemented. General tool and method capabilities remain [proposed](../security/automation-security.md). + +The five shared core tools are `command_list`, `command_run`, `command_batch`, `doc_inspect` +and `render_preview`. Existing documented tools, including `doc_render_preview`, remain +listed. `photocraft://document` and `photocraft://commands` expose live JSON. Direct headless +Render Video supports progress and cancellation using the granted write root; see +[`docs/mcp.md`](../../../docs/mcp.md) for arguments, cleanup and transport behavior. diff --git a/contributors/people.toml b/contributors/people.toml index afe2ac1b..d8f1c802 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -47,3 +47,6 @@ sync_github_name = true real_name = "Willy Foo" display_name = "Willy Foo" sync_github_name = false + +[people.Elvis33LE] +sync_github_name = true diff --git a/crates/automation/src/headless.rs b/crates/automation/src/headless.rs index 4c663086..b0612661 100644 --- a/crates/automation/src/headless.rs +++ b/crates/automation/src/headless.rs @@ -239,6 +239,9 @@ impl Headless { pub fn command_start(&mut self, id: &str, params: Value, wait: bool) -> Result { let params = if params.is_null() { json!({}) } else { params }; if !matches!(&self.filesystem, Filesystem::TrustedLocal) { + if id == "file.export.renderVideo" { + return self.render_video(params, |_, _| true); + } authorize_engine_command(id, ¶ms)?; } // Background jobs that finished since the last request (or batch step) are applied first. @@ -252,3 +255,32 @@ impl Headless { }) } } + +impl Headless { + /// A direct automation Render Video uses only the launch-time write capability. Nested + /// engine actions still reject this filesystem command until they can carry capabilities. + pub(crate) fn render_video(&mut self, params: Value, progress: impl FnMut(usize, usize) -> bool) -> Result { + self.sync_jobs(); + let Filesystem::Workspace(workspace) = &self.filesystem else { + return Err(AutomationError::BadRequest("automation filesystem access is not granted: write authority is absent".into())); + }; + let dir = params.get("dir").and_then(Value::as_str).ok_or_else(|| AutomationError::BadRequest("renderVideo needs `dir`".into()))?; + let mut outputs = workspace.export_sequence(dir)?; + let result = photocraft_engine::render_video_with( + &self.session, + ¶ms, + |name, bytes| outputs.write(name, bytes).map_err(|e| photocraft_engine::EngineError::Other(e.to_string())), + progress, + ); + match result { + Ok(value) => { + outputs.commit(); + Ok(value) + } + Err(error) => { + outputs.cleanup()?; + Err(error.into()) + } + } + } +} diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index 5611917b..fb8e31cb 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -8,7 +8,7 @@ use base64::Engine as _; use rmcp::handler::server::router::tool::ToolRouter; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{CallToolResult, ContentBlock as Content}; -use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_handler, tool_router}; +use rmcp::{ErrorData as McpError, ServerHandler, ServiceExt, tool, tool_router}; use schemars::JsonSchema; use serde::Deserialize; use serde_json::{Value, json}; @@ -38,6 +38,7 @@ pub struct PhotocraftMcp { // --------------------------------------------------------------------------- #[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct DocIndex { /// Document index from `session_list` (default: the active document). #[serde(default)] @@ -45,12 +46,14 @@ pub struct DocIndex { } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct OpenParams { /// Forward-slash relative path beneath the configured automation read root. pub path: String, } #[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct NewParams { #[serde(default)] pub width: Option, @@ -70,6 +73,7 @@ pub struct NewParams { } #[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct SaveParams { /// Forward-slash relative target beneath the configured automation write root. /// The extension selects the format. Omit to write back to the document's own file, which @@ -91,6 +95,7 @@ pub struct SaveParams { } #[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct PreviewParams { /// Headless mode only: document index (default: the active document). /// Bridge previews capture the app window and reject `index`. @@ -112,11 +117,13 @@ pub struct ScreenshotParams { } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct SelectParams { pub index: usize, } #[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ListParams { /// Only commands whose id or label contains this text (case-insensitive). #[serde(default)] @@ -127,6 +134,7 @@ pub struct ListParams { } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct RunParams { /// Command id, e.g. `layer.new.layer`, `filter.blur.gaussianBlur`. pub id: String, @@ -141,6 +149,7 @@ pub struct RunParams { } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct JobCancelParams { /// The job id from `command_run` / `jobs_list` (omit to cancel every running job). #[serde(default)] @@ -148,6 +157,7 @@ pub struct JobCancelParams { } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct BatchParams { /// Commands to run in order: `[{"id": "layer.new.layer", "params": {"name": "Ink"}}, …]`. A /// step with `"wait": false` starts a long command as a background job (its result is @@ -176,12 +186,14 @@ pub struct PointerParams { } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct MenuParams { /// Menu item / command id. pub id: String, } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct UiSetParams { /// Fields for the control method `ui.set`: tool, panels, dock, dockTabs, dockWidth, colorPanel /// ({background: bool} picks which swatch the Color panel edits), maskTarget, @@ -196,6 +208,7 @@ pub struct UiSetParams { } #[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct ControlParams { /// Any control-protocol method, e.g. `ui.dialog.open`. pub method: String, @@ -278,9 +291,7 @@ impl PhotocraftMcp { /// Serve MCP over stdin/stdout until the client disconnects. pub async fn serve_stdio(self) -> Result<(), AutomationError> { - let running = self.serve(rmcp::transport::stdio()).await.map_err(|e| AutomationError::Other(format!("MCP init: {e}")))?; - running.waiting().await.map_err(|e| AutomationError::Other(e.to_string()))?; - Ok(()) + self.serve_io(tokio::io::stdin(), tokio::io::stdout()).await } /// Run `f` on the headless session on a blocking thread. @@ -440,7 +451,16 @@ impl PhotocraftMcp { #[tool(description = "Document state as JSON: layer tree (top to bottom), history, selection, active layer.")] async fn doc_inspect(&self, Parameters(p): Parameters) -> Result { - if let Some(r) = self.headless_op(move |h| h.inspect(p.index)).await { + if let Some(r) = self + .headless_op(move |h| { + if p.index.is_none() && h.session.active_index().is_none() { + Ok(json!({"document": null, "session": h.session_list()})) + } else { + h.inspect(p.index) + } + }) + .await + { return to_result(r); } let Some(b) = self.bridge_client() else { @@ -468,6 +488,11 @@ impl PhotocraftMcp { self.screenshot(b, Some(max)).await } + #[tool(description = "Render a bounded flattened PNG preview (bridge mode: app window screenshot).")] + async fn render_preview(&self, p: Parameters) -> Result { + self.doc_render_preview(p).await + } + #[tool(description = "Make the document at `index` active.")] async fn doc_select(&self, Parameters(p): Parameters) -> Result { match self.headless_op(move |h| h.select(p.index)).await { @@ -713,8 +738,9 @@ impl PhotocraftMcp { } } -#[tool_handler(router = self.tool_router, name = "photocraft", instructions = "Photocraft image editor. Every edit is an engine command: call `command_list` to discover ids and parameter docs, then `command_run` (or `command_batch` for several at once). Use `doc_open`/`doc_new` first, `doc_inspect` for the layer tree, `doc_render_preview` to see the result, and `doc_save` (.pcraft is lossless native; .psd/.png/.jpg/.tif… export). In bridge mode the `ui_*` tools drive the live app (inspect, screenshot, pointer, menus).")] -impl ServerHandler for PhotocraftMcp {} +mod conventions; +mod progress; +mod transport; /// Used by the render helper in tests and the CLI. pub fn render_document_png(doc: &photocraft_doc::Document, max_side: u32) -> Result, AutomationError> { diff --git a/crates/automation/src/server/conventions.rs b/crates/automation/src/server/conventions.rs new file mode 100644 index 00000000..93a78ac7 --- /dev/null +++ b/crates/automation/src/server/conventions.rs @@ -0,0 +1,86 @@ +//! Catalog metadata, strict tool envelopes and live resources. +use super::*; +use rmcp::RoleServer; +use rmcp::model::*; +use rmcp::service::RequestContext; + +const DOCUMENT: &str = "photocraft://document"; +const COMMANDS: &str = "photocraft://commands"; +const INSTRUCTIONS: &str = "PhotoCraft image editor. Start with doc_new/doc_open, discover ids and parameter docs with command_list, edit with command_run or command_batch (one history step per command). Inspect with doc_inspect and render_preview; doc_save writes native .pcraft or exports by extension. File paths are relative to the read/write roots granted at launch. ui_inspect/ui_screenshot and other ui_* tools require bridge mode. Read photocraft://document and photocraft://commands for live JSON state."; + +fn annotated(mut tool: Tool) -> Tool { + let words = tool.name.replace('_', " "); + let mut chars = words.chars(); + let title = chars.next().map(|c| c.to_uppercase().collect::() + chars.as_str()).unwrap_or_default(); + let read = matches!( + tool.name.as_ref(), + "command_list" | "doc_inspect" | "session_list" | "render_preview" | "doc_render_preview" | "ui_inspect" | "ui_screenshot" + ); + let additive = matches!(tool.name.as_ref(), "doc_new" | "doc_open"); + tool.title = Some(title.clone()); + tool.annotations = Some(ToolAnnotations::from_raw(Some(title), Some(read), Some(!read && !additive), Some(read), Some(false))); + Arc::make_mut(&mut tool.input_schema).insert("additionalProperties".into(), json!(false)); + tool +} + +impl ServerHandler for PhotocraftMcp { + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().enable_resources().build()) + .with_server_info(Implementation::new("photocraft", env!("CARGO_PKG_VERSION"))) + .with_instructions(INSTRUCTIONS) + } + + async fn list_tools(&self, _: Option, _: RequestContext) -> Result { + let tools: Vec<_> = self.tool_router.list_all().into_iter().map(annotated).collect(); + serde_json::from_value(json!({"resultType":"complete", "ttlMs":600000, "cacheScope":"private", "tools":tools})) + .map_err(|e| McpError::internal_error(e.to_string(), None)) + } + + async fn call_tool(&self, request: CallToolRequestParams, context: RequestContext) -> Result { + if let Some(tool) = self.tool_router.get(&request.name) { + let properties = tool.input_schema.get("properties").and_then(Value::as_object); + if let Some(args) = &request.arguments + && let Some(key) = args.keys().find(|k| properties.is_none_or(|p| !p.contains_key(*k))) + { + return Err(McpError::invalid_params(format!("unknown argument `{key}` for {}", request.name), None)); + } + } + if request.name == "command_batch" { + let args = Value::Object(request.arguments.clone().unwrap_or_default()); + let _: BatchParams = serde_json::from_value(args).map_err(|e| McpError::invalid_params(e.to_string(), None))?; + } + if request.name == "command_run" && matches!(&*self.backend, Backend::Headless(_)) { + let args = Value::Object(request.arguments.clone().unwrap_or_default()); + let params: RunParams = serde_json::from_value(args).map_err(|e| McpError::invalid_params(e.to_string(), None))?; + if params.id == "file.export.renderVideo" { + return Ok(self.render_video_progress(params.params.unwrap_or_else(|| json!({})), context).await.into()); + } + } + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await + } + + async fn list_resources(&self, _: Option, _: RequestContext) -> Result { + serde_json::from_value(json!({"resultType":"complete", "ttlMs":600000, "cacheScope":"private", "resources":[ + {"uri":DOCUMENT, "name":"document", "title":"Active document", "mimeType":"application/json", "description":"Live doc_inspect state, or an empty session."}, + {"uri":COMMANDS, "name":"commands", "title":"Command catalog", "mimeType":"application/json", "description":"Live command_list including enabled state."} + ]})).map_err(|e| McpError::internal_error(e.to_string(), None)) + } + + async fn read_resource(&self, request: ReadResourceRequestParams, _: RequestContext) -> Result { + let result = match request.uri.as_str() { + DOCUMENT => self.doc_inspect(Parameters(DocIndex::default())).await?, + COMMANDS => self.command_list(Parameters(ListParams::default())).await?, + uri => return Err(McpError::resource_not_found(format!("unknown resource `{uri}`"), None)), + }; + let text = result.content.iter().filter_map(|c| c.as_text()).map(|t| t.text.as_str()).collect::>().join("\n"); + if result.is_error == Some(true) { + return Err(McpError::internal_error(text, None)); + } + let result: ReadResourceResult = serde_json::from_value(json!({"resultType":"complete", "ttlMs":0, "cacheScope":"private", "contents":[ + {"uri":request.uri, "mimeType":"application/json", "text":text} + ]})) + .map_err(|e| McpError::internal_error(e.to_string(), None))?; + Ok(result.into()) + } +} diff --git a/crates/automation/src/server/progress.rs b/crates/automation/src/server/progress.rs new file mode 100644 index 00000000..41827430 --- /dev/null +++ b/crates/automation/src/server/progress.rs @@ -0,0 +1,43 @@ +//! Render Video runs on the existing blocking worker; rmcp remains available for ping/cancel. +use super::*; +use rmcp::RoleServer; +use rmcp::model::ProgressNotificationParam; +use rmcp::service::RequestContext; +use std::time::{Duration, Instant}; + +impl PhotocraftMcp { + pub(super) async fn render_video_progress(&self, params: Value, context: RequestContext) -> CallToolResult { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + // rmcp may drop the request future on cancellation. The blocking worker owns the + // token too, so it still stops and cleans up without needing that future to be polled. + let cancel = context.ct.clone(); + let job = self.headless_op(move |h| { + h.render_video(params, move |done, total| { + let _ = tx.send((done, total)); + !cancel.is_cancelled() + }) + }); + tokio::pin!(job); + let token = context.meta.get_progress_token(); + let (mut last, mut sent_at): (Option, Option) = (None, None); + let result = loop { + tokio::select! { + biased; + Some((done, total)) = rx.recv() => { + let due = done == total || sent_at.is_none_or(|t| t.elapsed() >= Duration::from_millis(100)); + if let Some(token) = token.clone().filter(|_| due && last.is_none_or(|n| done > n) && !context.ct.is_cancelled()) { + last = Some(done); + sent_at = Some(Instant::now()); + let _ = context.peer.notify_progress(ProgressNotificationParam::new(token, done as f64).with_total(total as f64)).await; + } + } + result = &mut job => break result, + } + }; + match result { + Some(Ok(value)) => ok_json(&value), + Some(Err(error)) => fail(error), + None => no_backend(), + } + } +} diff --git a/crates/automation/src/server/transport.rs b/crates/automation/src/server/transport.rs new file mode 100644 index 00000000..7cececd5 --- /dev/null +++ b/crates/automation/src/server/transport.rs @@ -0,0 +1,61 @@ +//! Recover malformed JSON lines before passing valid messages to rmcp. +use super::*; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +impl PhotocraftMcp { + /// Serve a JSON-lines stream. A malformed line receives -32700/id:null; the next line + /// is still handled. A single writer serializes parse errors and SDK responses. + pub async fn serve_io( + self, + input: impl tokio::io::AsyncRead + Unpin + Send + 'static, + output: impl tokio::io::AsyncWrite + Unpin + Send + 'static, + ) -> Result<(), AutomationError> { + let (mut to_service, service_in) = tokio::io::duplex(1 << 20); + let (service_out, from_service) = tokio::io::duplex(1 << 20); + let (errors, mut error_rx) = tokio::sync::mpsc::channel::(16); + let reader = tokio::spawn(async move { + let mut lines = BufReader::new(input).lines(); + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(_) => to_service.write_all(format!("{line}\n").as_bytes()).await?, + Err(e) => { + let reply = json!({"jsonrpc":"2.0", "id":null, "error":{"code":-32700, "message":format!("parse error: {e}")}}); + if errors.send(reply.to_string()).await.is_err() { + break; + } + } + } + } + to_service.shutdown().await + }); + let writer = tokio::spawn(async move { + let mut output = output; + let mut replies = BufReader::new(from_service).lines(); + loop { + let line = tokio::select! { + biased; + Some(error) = error_rx.recv() => error, + reply = replies.next_line() => match reply? { Some(line) => line, None => break }, + }; + output.write_all(format!("{line}\n").as_bytes()).await?; + output.flush().await?; + } + Ok::<_, std::io::Error>(()) + }); + let result = match self.serve((service_in, service_out)).await { + Ok(running) => running.waiting().await.map(|_| ()).map_err(|e| AutomationError::Other(e.to_string())), + Err(e) => Err(AutomationError::Other(format!("MCP init: {e}"))), + }; + reader.abort(); + match reader.await { + Ok(r) => r.map_err(|e| AutomationError::Io(e.to_string()))?, + Err(e) if !e.is_cancelled() => return Err(AutomationError::Other(e.to_string())), + Err(_) => {} + } + writer.await.map_err(|e| AutomationError::Other(e.to_string()))?.map_err(|e| AutomationError::Io(e.to_string()))?; + result + } +} diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 2d46f3db..6d160bd6 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -68,7 +68,8 @@ impl AuthorizedWorkspace { options.write(true).create_new(true); let written = root.dir.open_with(&tmp, &options).and_then(|mut file| { file.write_all(bytes)?; - file.sync_all() + // Falls back to a plain fsync on shares that refuse a full flush (#1336). + photocraft_format::atomic::sync_file(&file.into_std()) }); let renamed = written.and_then(|()| photocraft_format::atomic::retry_rename(RenameRetry::platform(), || root.dir.rename(&tmp, &root.dir, &relative))); if let Err(e) = renamed { @@ -80,7 +81,7 @@ impl AuthorizedWorkspace { { let dir = if parent.as_os_str().is_empty() { PathBuf::from(".") } else { parent }; if let Ok(d) = root.dir.open(&dir) { - let _ = d.sync_all(); + let _ = photocraft_format::atomic::sync_file(&d.into_std()); } } Ok(()) @@ -281,6 +282,62 @@ fn command_error(id: &str) -> AutomationError { AutomationError::BadRequest(format!("automation command `{id}` uses ambient filesystem paths and is disabled; use capability-scoped document methods")) } +/// An export owns only files it created through this held directory capability. Refuse +/// collisions (including symlinks) so cancellation cannot delete or replace an earlier export. +pub(crate) struct ExportSequence { + dir: Dir, + created: Vec, +} + +impl AuthorizedWorkspace { + pub(crate) fn export_sequence(&self, path: &str) -> Result { + let relative = relative_path(path)?; + let root = self.write.as_ref().ok_or_else(|| AutomationError::BadRequest(format!("{DENIED}: write authority is absent")))?; + root.dir.create_dir_all(&relative).map_err(|e| file_error("mkdir", path, e))?; + let dir = root.dir.open_dir(&relative).map_err(|e| file_error("open export directory", path, e))?; + Ok(ExportSequence { dir, created: Vec::new() }) + } +} + +impl ExportSequence { + pub(crate) fn write(&mut self, name: &str, bytes: &[u8]) -> Result<(), AutomationError> { + let relative = relative_path(name)?; + if relative.components().count() != 1 { + return Err(path_error(name, "export filename must be a single component")); + } + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + let mut file = self.dir.open_with(&relative, &options).map_err(|e| file_error("create export", name, e))?; + // Record immediately after creation, before any fallible write, including partial writes. + self.created.push(name.to_owned()); + file.write_all(bytes).and_then(|()| file.sync_all()).map_err(|e| file_error("write export", name, e)) + } + + pub(crate) fn commit(&mut self) { + self.created.clear(); + } + + pub(crate) fn cleanup(&mut self) -> Result<(), AutomationError> { + let mut failed = Vec::new(); + self.created.retain(|name| match self.dir.remove_file(name) { + Ok(()) => false, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, + Err(e) => { + failed.push(format!("{name}: {e}")); + true + } + }); + if failed.is_empty() { Ok(()) } else { Err(AutomationError::Io(format!("export cleanup failed: {}", failed.join("; ")))) } + } +} + +impl Drop for ExportSequence { + fn drop(&mut self) { + // Also cover an escaped rendering panic; normal errors report cleanup failures above. + let _ = self.cleanup(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -296,6 +353,33 @@ mod tests { (inside, outside, workspace) } + #[test] + fn export_cleanup_owns_only_created_files_even_during_unwind() { + let (inside, _, workspace) = roots("export-cleanup"); + std::fs::create_dir(inside.join("frames")).unwrap(); + std::fs::write(inside.join("frames/clip_0009.png"), b"old").unwrap(); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut export = workspace.export_sequence("frames").unwrap(); + export.write("clip_0000.png", b"new").unwrap(); + assert!(export.write("clip_0009.png", b"overwrite").is_err()); + assert!(export.write("../escape.png", b"escape").is_err()); + panic!("rendering panic"); + })); + assert!(panic.is_err()); + assert_eq!(std::fs::read_dir(inside.join("frames")).unwrap().count(), 1); + assert_eq!(std::fs::read(inside.join("frames/clip_0009.png")).unwrap(), b"old"); + assert!(AuthorizedWorkspace::default().export_sequence("frames").is_err()); + } + + #[cfg(unix)] + #[test] + fn export_directory_symlinks_cannot_escape_the_granted_root() { + let (inside, outside, workspace) = roots("export-symlink"); + std::os::unix::fs::symlink(&outside, inside.join("escape")).unwrap(); + assert!(workspace.export_sequence("escape").is_err()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); + } + #[test] fn valid_read_and_nonexistent_output_write_stay_in_root() { let (inside, _, workspace) = roots("valid"); diff --git a/crates/automation/tests/mcp.rs b/crates/automation/tests/mcp.rs index 198134ba..46ab35d7 100644 --- a/crates/automation/tests/mcp.rs +++ b/crates/automation/tests/mcp.rs @@ -239,7 +239,7 @@ async fn errors_are_tool_errors_not_crashes() { let r = call(&client, "command_run", json!({"id": "no.such.command"})).await; assert_eq!(r.is_error, Some(true)); assert!(text(&r).contains("unknown command")); - let r = call(&client, "doc_inspect", json!({})).await; + let r = call(&client, "doc_inspect", json!({"index": 0})).await; assert_eq!(r.is_error, Some(true)); let r = call(&client, "doc_open", json!({"path": "/definitely/missing.png"})).await; assert_eq!(r.is_error, Some(true)); @@ -894,3 +894,61 @@ async fn command_batch_rejects_too_many_steps() { assert!(text(&r).contains("maximum is 256"), "{}", text(&r)); client.cancel().await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn convention_tools_have_titles_hints_and_strict_schemas() { + let client = connect(PhotocraftMcp::headless()).await; + let tools = client.list_all_tools().await.unwrap(); + assert!(tools.iter().any(|t| t.name == "render_preview")); + // The existing documented spelling remains listed. + assert!(tools.iter().any(|t| t.name == "doc_render_preview")); + for tool in tools { + let value = serde_json::to_value(&tool).unwrap(); + assert!(value["title"].as_str().is_some_and(|s| !s.is_empty()), "{value}"); + for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] { + assert!(value["annotations"][hint].is_boolean(), "{value}"); + } + assert_eq!(value["inputSchema"]["additionalProperties"], false, "{value}"); + } + client.cancel().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn convention_unknown_arguments_name_the_key_without_side_effects() { + let client = connect(PhotocraftMcp::headless()).await; + for (name, args) in [ + ("doc_new", json!({"widht": 8})), + ("session_list", json!({"surprise": true})), + ("command_run", json!({"id":"file.new", "surprise":true})), + ("command_batch", json!({"steps":[{"id":"file.new", "surprise":true}]})), + ] { + let key = if name == "doc_new" { "widht" } else { "surprise" }; + let result = client.call_tool(CallToolRequestParams::new(name).with_arguments(args.as_object().unwrap().clone())).await; + let error = result.expect_err("unknown key must fail before execution").to_string(); + assert!(error.contains("-32602") && error.contains(key), "{error}"); + } + assert_eq!(json_of(&call(&client, "session_list", json!({})).await)["documents"], json!([])); + client.cancel().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn convention_resources_are_live_json() { + let client = connect(PhotocraftMcp::headless()).await; + let resources = client.list_all_resources().await.unwrap(); + for uri in ["photocraft://document", "photocraft://commands"] { + assert!(resources.iter().any(|r| r.uri == uri)); + } + let read = async |uri: &str| { + let r = client.read_resource(rmcp::model::ReadResourceRequestParams::new(uri)).await.unwrap(); + let v = serde_json::to_value(r).unwrap(); + serde_json::from_str::(v["contents"][0]["text"].as_str().unwrap()).unwrap() + }; + assert!(read("photocraft://document").await["document"].is_null()); + json_of(&call(&client, "doc_new", json!({"width":8,"height":6})).await); + assert_eq!(read("photocraft://document").await["width"], 8); + assert_eq!(read("photocraft://commands").await, json_of(&call(&client, "command_list", json!({})).await)); + assert!(client.read_resource(rmcp::model::ReadResourceRequestParams::new("photocraft://missing")).await.is_err()); + let preview = call(&client, "render_preview", json!({"max_side":4})).await; + assert!(preview.content.iter().any(|c| c.as_image().is_some())); + client.cancel().await.unwrap(); +} diff --git a/crates/automation/tests/mcp_wire.rs b/crates/automation/tests/mcp_wire.rs new file mode 100644 index 00000000..a1cfcc15 --- /dev/null +++ b/crates/automation/tests/mcp_wire.rs @@ -0,0 +1,147 @@ +//! Exercise the actual line transport, including recovery and modern result fields. +use photocraft_automation::PhotocraftMcp; +use serde_json::{Value, json}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +#[tokio::test(flavor = "multi_thread")] +async fn malformed_line_recovers_and_modern_lists_are_complete() { + let (mut input, server_in) = tokio::io::duplex(1 << 20); + let (server_out, output) = tokio::io::duplex(1 << 20); + let server = tokio::spawn(PhotocraftMcp::headless().serve_io(server_in, server_out)); + let mut lines = BufReader::new(output).lines(); + let init = json!({"jsonrpc":"2.0", "id":1, "method":"initialize", "params":{"protocolVersion":"2025-06-18", "capabilities":{}, "clientInfo":{"name":"test", "version":"1"}}}); + input.write_all(format!("{init}\n").as_bytes()).await.unwrap(); + lines.next_line().await.unwrap().unwrap(); + input.write_all(b"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\nBROKEN\n").await.unwrap(); + let error: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(error["id"], Value::Null); + assert_eq!(error["error"]["code"], -32700); + let meta = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28", "io.modelcontextprotocol/clientInfo":{"name":"test", "version":"1"}, "io.modelcontextprotocol/clientCapabilities":{}}); + for (id, method, extra) in [(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri":"photocraft://document"}))] { + let mut params = extra; + params["_meta"] = meta.clone(); + input.write_all(format!("{}\n", json!({"jsonrpc":"2.0", "id":id, "method":method, "params":params})).as_bytes()).await.unwrap(); + let reply: Value = serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(reply["id"], id); + assert_eq!(reply["result"]["resultType"], "complete", "{reply}"); + assert!(reply["result"]["ttlMs"].is_number()); + assert_eq!(reply["result"]["cacheScope"], "private"); + } + drop(input); + tokio::time::timeout(std::time::Duration::from_secs(5), server).await.unwrap().unwrap().unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn video_export_progress_ping_cancel_and_scoped_cleanup() { + use photocraft_automation::AuthorizedWorkspace; + use std::time::Duration; + let root = std::env::temp_dir().join(format!("pc-conventions-video-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let workspace = AuthorizedWorkspace::new(None, Some(&root)).unwrap(); + let (mut input, server_in) = tokio::io::duplex(1 << 20); + let (server_out, output) = tokio::io::duplex(1 << 20); + let server = tokio::spawn(PhotocraftMcp::headless_with_workspace(workspace).serve_io(server_in, server_out)); + let mut lines = BufReader::new(output).lines(); + macro_rules! send { + ($value:expr) => { + input.write_all(format!("{}\n", $value).as_bytes()).await.unwrap() + }; + } + macro_rules! next { + () => { + serde_json::from_str::(&tokio::time::timeout(Duration::from_secs(30), lines.next_line()).await.unwrap().unwrap().unwrap()).unwrap() + }; + } + let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0", "id":id, "method":"tools/call", "params":{"name":name, "arguments":args}}); + send!( + json!({"jsonrpc":"2.0", "id":1, "method":"initialize", "params":{"protocolVersion":"2025-06-18", "capabilities":{}, "clientInfo":{"name":"test", "version":"1"}}}) + ); + next!(); + send!(json!({"jsonrpc":"2.0", "method":"notifications/initialized"})); + send!(call(2, "doc_new", json!({"width":64,"height":48,"name":"clip"}))); + assert_eq!(next!()["result"]["isError"], false); + send!(call(3, "command_run", json!({"id":"timeline.create", "params":{"duration":6,"fps":6}}))); + assert_eq!(next!()["result"]["isError"], false); + let render = |id, dir, format, token: Option| { + let mut value = call(id, "command_run", json!({"id":"file.export.renderVideo", "params":{"dir":dir,"format":format}})); + if let Some(token) = token { + value["params"]["_meta"] = json!({"progressToken":token}); + } + value + }; + for (id, dir, format, token) in [(4, "complete", "png", Some(json!("frames"))), (5, "quiet", "png", None), (6, "gif", "gif", Some(json!(77)))] { + send!(render(id, dir, format, token.clone())); + let mut progress = Vec::new(); + loop { + let reply = next!(); + if reply["id"] == id { + assert_eq!(reply["result"]["isError"], false, "{reply}"); + break; + } + assert!(token.is_some(), "no token: {reply}"); + assert_eq!(reply["params"]["progressToken"], token.clone().unwrap()); + assert_eq!(reply["params"]["total"].as_f64(), Some(6.0)); + progress.push(reply["params"]["progress"].as_f64().unwrap()); + } + if token.is_some() { + assert_eq!(progress.first(), Some(&0.0)); + assert_eq!(progress.last(), Some(&6.0)); + assert!(progress.windows(2).all(|w| w[0] < w[1])); + } + } + assert_eq!(std::fs::read_dir(root.join("complete")).unwrap().count(), 6); + assert!(photocraft_codecs::decode(&std::fs::read(root.join("gif/clip.gif")).unwrap()).is_ok()); + // A collision must preserve the earlier file and remove only frames from this failed run. + std::fs::create_dir(root.join("collision")).unwrap(); + std::fs::write(root.join("collision/clip_0001.png"), b"earlier export").unwrap(); + send!(render(7, "collision", "png", None)); + assert_eq!(next!()["result"]["isError"], true); + assert_eq!(std::fs::read_dir(root.join("collision")).unwrap().count(), 1); + assert_eq!(std::fs::read(root.join("collision/clip_0001.png")).unwrap(), b"earlier export"); + send!(render(8, "../outside", "png", None)); + assert_eq!(next!()["result"]["isError"], true); + send!(call(9, "doc_new", json!({"width":1600,"height":1200,"name":"clip"}))); + assert_eq!(next!()["result"]["isError"], false); + send!(call(10, "command_run", json!({"id":"timeline.create", "params":{"duration":100,"fps":30}}))); + assert_eq!(next!()["result"]["isError"], false); + std::fs::create_dir(root.join("cancel")).unwrap(); + std::fs::write(root.join("cancel/clip_9999.png"), b"unrelated").unwrap(); + send!(render(11, "cancel", "png", Some(json!(11)))); + let mut pinged = false; + loop { + let note = next!(); + assert!(note["id"].is_null(), "export finished early: {note}"); + if !pinged { + send!(json!({"jsonrpc":"2.0", "id":12, "method":"ping"})); + loop { + let reply = next!(); + assert_ne!(reply["id"], 11); + if reply["id"] == 12 { + break; + } + } + pinged = true; + } + if note["params"]["progress"].as_f64().unwrap_or(0.0) >= 1.0 { + break; + } + } + assert!(std::fs::read_dir(root.join("cancel")).unwrap().count() > 1); + send!(json!({"jsonrpc":"2.0", "method":"notifications/cancelled", "params":{"requestId":11}})); + // Inspect waits for the same session lock, so cleanup must have finished before its reply. + send!(call(13, "doc_inspect", json!({}))); + loop { + let reply = next!(); + assert_ne!(reply["id"], 11, "cancelled requests have no response: {reply}"); + if reply["id"] == 13 { + assert_eq!(reply["result"]["isError"], false); + break; + } + } + assert_eq!(std::fs::read_dir(root.join("cancel")).unwrap().count(), 1); + assert_eq!(std::fs::read(root.join("cancel/clip_9999.png")).unwrap(), b"unrelated"); + drop(input); + tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap().unwrap(); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/codecs/src/codecs/png.rs b/crates/codecs/src/codecs/png.rs index 97e63ac5..f4d299aa 100644 --- a/crates/codecs/src/codecs/png.rs +++ b/crates/codecs/src/codecs/png.rs @@ -161,9 +161,11 @@ fn read_text_metadata(bytes: &[u8], mut remaining: usize, meta: &mut Metadata) - remaining = remaining.checked_sub(keyword_len).and_then(|left| left.checked_sub(text.len())).ok_or_else(text_limit)?; let keyword: String = keyword.iter().map(|&b| char::from(b)).collect(); match kind { + // Some writers put XMP in Latin-1 text chunks instead of the usual iTXt. + // Treat the reserved keyword consistently, after the same budget checks. + _ if keyword == XMP_KEYWORD => meta.xmp = Some(text), b"tEXt" => plain.push((keyword, text)), b"zTXt" => compressed.push((keyword, text)), - _ if keyword == XMP_KEYWORD => meta.xmp = Some(text), _ => international.push((keyword, text)), } } diff --git a/crates/codecs/tests/limits.rs b/crates/codecs/tests/limits.rs index b3d89d2e..3613b087 100644 --- a/crates/codecs/tests/limits.rs +++ b/crates/codecs/tests/limits.rs @@ -257,6 +257,43 @@ fn png_duplicate_xmp_chunks_each_consume_budget() { } } +#[test] +fn png_xmp_in_all_text_chunk_types_is_preserved() { + const XMP: &str = ""; + for ty in [b"tEXt", b"zTXt", b"iTXt"] { + for after_idat in [false, true] { + let chunks = [png_text(ty, b"XML:com.adobe.xmp", XMP.as_bytes())]; + let bytes = if after_idat { png_with_text(&[], &chunks) } else { png_with_text(&chunks, &[]) }; + let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap(); + assert_eq!(image.meta.xmp.as_deref(), Some(XMP), "{ty:?}, after IDAT: {after_idat}"); + assert!(image.meta.text.is_empty(), "XMP must not also be retained as ordinary text"); + let saved = encode(&image, Format::Png, &EncodeOptions::default()).unwrap(); + assert_eq!(decode(&saved).unwrap().meta.xmp, image.meta.xmp); + } + } +} + +#[test] +fn png_mixed_xmp_chunk_types_use_last_packet_and_share_budget() { + for first_ty in [b"tEXt", b"zTXt", b"iTXt"] { + for last_ty in [b"tEXt", b"zTXt", b"iTXt"] { + let first = png_text(first_ty, b"XML:com.adobe.xmp", b"first"); + let last = png_text(last_ty, b"XML:com.adobe.xmp", b"last"); + let bytes = png_with_text(&[first], &[last]); + let budget = 2 * b"XML:com.adobe.xmp".len() + b"first".len() + b"last".len(); + // Pixel decoding needs a larger limit, so check the shared budget with larger packets. + let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap(); + assert_eq!(image.meta.xmp.as_deref(), Some("last")); + assert!(image.meta.text.is_empty()); + let first = png_text(first_ty, b"XML:com.adobe.xmp", &vec![b'A'; PNG_TEXT_BUDGET - budget]); + let last = png_text(last_ty, b"XML:com.adobe.xmp", b"last"); + let bytes = png_with_text(&[first], &[last]); + assert!(png_text_decode(&bytes, PNG_TEXT_BUDGET - b"first".len()).is_ok()); + assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - b"first".len() - 1))); + } + } +} + #[test] fn png_corrupt_text_is_still_skipped() { let valid = zlib(b"discard"); diff --git a/crates/compose/src/masks.rs b/crates/compose/src/masks.rs index e84c0e6f..70cc1fee 100644 --- a/crates/compose/src/masks.rs +++ b/crates/compose/src/masks.rs @@ -2,11 +2,12 @@ //! backends that sample masks from textures (the GPU compositor) and for feathered masks on the //! CPU. Rasterising a vector mask costs a path coverage pass and feathering a blur, so results //! are cached per mask state; unchanged masks return the same tiles (cheap to clone, and the -//! GPU sees no change). +//! GPU sees no change). Retention is bounded by both entry count and accounted pixel bytes; +//! [`purge_cache`] drops cached masks and prevents older builds from refilling the cache. use std::collections::HashMap; use std::hash::{Hash, Hasher}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock, Weak}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; use photocraft_doc::Layer; @@ -15,27 +16,143 @@ use photocraft_raster::Surface; const FORMAT: PixelFormat = PixelFormat { mode: ColorMode::Grayscale, sample: SampleType::F32, alpha: false }; -struct Cache { - map: HashMap, - tick: u64, -} - struct CacheEntry { surface: Surface, tick: u64, + bytes: usize, // The key contains input tile addresses. Keep those tiles alive until eviction: // edits must copy them, and the allocator cannot reuse their addresses for new tiles. _pixel_input: Option, } +#[derive(Default)] +struct Cache { + map: HashMap, + /// Builds in progress, shared by every caller that asks for the same cold mask. + in_flight: HashMap>>, + tick: u64, + bytes: usize, + generation: u64, +} + +impl Cache { + fn get(&mut self, key: u64) -> Option { + self.tick = self.tick.saturating_add(1); + let entry = self.map.get_mut(&key)?; + entry.tick = self.tick; + Some(entry.surface.clone()) + } + + fn insert(&mut self, key: u64, surface: Surface, pixel_input: Option, bytes: usize, budget: usize, generation: u64) { + // A large result can still be used by its caller without keeping it globally. A purge + // also invalidates admission by computations that were already running. + if bytes > budget || generation != self.generation { + return; + } + if let Some(previous) = self.map.remove(&key) { + self.bytes = self.bytes.saturating_sub(previous.bytes); + } + while self.map.len() >= CAPACITY || self.bytes > budget.saturating_sub(bytes) { + let Some(old) = self.map.iter().min_by_key(|(_, entry)| entry.tick).map(|(&key, _)| key) else { break }; + if let Some(entry) = self.map.remove(&old) { + self.bytes = self.bytes.saturating_sub(entry.bytes); + } + } + self.bytes = self.bytes.saturating_add(bytes); + self.tick = self.tick.saturating_add(1); + self.map.insert(key, CacheEntry { surface, tick: self.tick, bytes, _pixel_input: pixel_input }); + } + + fn clear(&mut self) -> usize { + let bytes = self.bytes; + self.map.clear(); + self.bytes = 0; + self.tick = 0; + self.generation = self.generation.wrapping_add(1); + bytes + } +} + fn cache() -> &'static Mutex { static C: OnceLock> = OnceLock::new(); - C.get_or_init(|| Mutex::new(Cache { map: HashMap::new(), tick: 0 })) + C.get_or_init(|| Mutex::new(Cache::default())) } /// Entries kept (least recently used dropped first). const CAPACITY: usize = 64; +/// Accounted pixel-byte limit of the combined-mask cache. One 36 MP float input and result +/// fit; larger individual results remain usable but are not retained in the cache. +pub const CACHE_BUDGET: usize = 512 << 20; + +enum CachedMask { + Hit(Surface), + /// A build slot, and the cache generation it may fill. + Pending(Arc>, u64), +} + +fn lookup(c: &Mutex, k: u64) -> CachedMask { + let mut c = c.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(surface) = c.get(k) { + return CachedMask::Hit(surface); + } + let generation = c.generation; + // The callers own each slot, so completed builds do not retain an extra + // surface after their last caller returns. Sweep dead weak slots on misses. + c.in_flight.retain(|_, slot| slot.strong_count() > 0); + if let Some(slot) = c.in_flight.get(&k).and_then(Weak::upgrade) { + return CachedMask::Pending(slot, generation); + } + let slot = Arc::new(OnceLock::new()); + c.in_flight.insert(k, Arc::downgrade(&slot)); + CachedMask::Pending(slot, generation) +} + +/// The slot's surface, built once by whichever caller gets there first. `build` returns the +/// mask and the pixel-mask input its key was made from (kept alive by the cache entry). +fn finish_mask(c: &Mutex, k: u64, slot: &Arc>, generation: u64, build: impl FnOnce() -> (Surface, Option)) -> Surface { + slot.get_or_init(|| { + // Never hold the global cache mutex while rasterizing or feathering. + let (surface, pixel_input) = build(); + let bytes = entry_bytes(&surface, pixel_input.as_ref()); + let mut c = c.lock().unwrap_or_else(|e| e.into_inner()); + if c.in_flight.get(&k).and_then(Weak::upgrade).is_some_and(|active| Arc::ptr_eq(&active, slot)) { + c.insert(k, surface.clone(), pixel_input, bytes, CACHE_BUDGET, generation); + } + surface + }) + .clone() +} + +fn cached_mask(c: &Mutex, k: u64, build: impl FnOnce() -> (Surface, Option)) -> Surface { + match lookup(c, k) { + CachedMask::Hit(surface) => surface, + CachedMask::Pending(slot, generation) => finish_mask(c, k, &slot, generation, build), + } +} + +fn surface_bytes(surface: &Surface) -> usize { + surface.tiles().fold(surface.default_bytes().len(), |bytes, (_, tile)| bytes.saturating_add(tile.bytes().len())) +} + +fn entry_bytes(surface: &Surface, pixel_input: Option<&Surface>) -> usize { + // Charge the whole source as well as the result, including off-canvas tiles. Shared tiles + // are deliberately charged per entry rather than relying on their current owners. + surface_bytes(surface).saturating_add(pixel_input.map_or(0, surface_bytes)) +} + +/// Pixel bytes accounted for by the combined-mask cache. This conservatively includes source +/// tiles and counts tiles shared across entries more than once; it is not process memory use. +pub fn cache_bytes() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).bytes +} + +/// Drop every cached combined mask (Edit › Purge › All), returning its accounted pixel bytes. +/// Callers may still hold returned surfaces, so this is not a measure of freed process memory. +pub fn purge_cache() -> usize { + cache().lock().unwrap_or_else(|e| e.into_inner()).clear() +} + fn key(layer: &Layer, canvas: Rect) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); layer.id.0.hash(&mut h); @@ -105,25 +222,20 @@ fn gaussian(v: &mut [f32], w: usize, h: usize, sigma: f32) { /// `None` when the layer has no enabled vector mask and no feathered pixel mask (use the pixel /// mask directly). Values outside the computed area are the surface's default. pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option { + if !layer.vector_mask.as_ref().is_some_and(|v| v.enabled) && !has_feather(layer) { + return None; + } + Some(cached_mask(cache(), key(layer, canvas), || (build_mask(layer, canvas), layer.mask.as_ref().map(|m| m.surface.clone())))) +} + +fn build_mask(layer: &Layer, canvas: Rect) -> Surface { let vm = layer.vector_mask.as_ref().filter(|v| v.enabled); let pixel = layer.mask.as_ref().filter(|m| m.enabled); let (sv, sp) = (vm.map_or(0.0, |v| feather_sigma(v.feather)), pixel.map_or(0.0, |m| feather_sigma(m.feather))); - if vm.is_none() && sp <= 0.0 { - return None; - } - let k = key(layer, canvas); - { - let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); - c.tick += 1; - let tick = c.tick; - if let Some(e) = c.map.get_mut(&k) { - e.tick = tick; - return Some(e.surface.clone()); - } - } + let compiled = vm.map(photocraft_vector::CompiledVectorMask::new); // Far outside the path the vector mask is constant. let far = Rect::from_xywh(canvas.x0 - 1_000_000, canvas.y0 - 1_000_000, 1, 1); - let v_out = vm.map_or(1.0, |vm| photocraft_vector::vector_mask_values(vm, far)[0]); + let v_out = compiled.as_ref().map_or(1.0, |vm| vm.render_sequential(far)[0]); let p_def = pixel.map_or(1.0, |m| { let d = m.surface.default_pixel().first().copied().unwrap_or(1.0); 1.0 - m.density * (1.0 - d) @@ -155,8 +267,10 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option { let mut s = Surface::with_default(FORMAT, &[p_def * v_out]); if !area.is_empty() { let (w, h) = (area.width() as usize, area.height() as usize); - let mut v = match vm { - Some(vm) => photocraft_vector::vector_mask_values(vm, area), + // A slot initializer must not enter Rayon: another tile requesting the + // same mask could be stolen onto its stack and wait for its own build. + let mut v = match &compiled { + Some(vm) => vm.render_sequential(area), None => vec![1.0; w * h], }; gaussian(&mut v, w, h, sv); @@ -170,15 +284,7 @@ pub fn combined_mask(layer: &Layer, canvas: Rect) -> Option { } s.write_region(area, &v); } - let mut c = cache().lock().unwrap_or_else(|e| e.into_inner()); - if c.map.len() >= CAPACITY - && let Some(old) = c.map.iter().min_by_key(|e| e.1.tick).map(|e| *e.0) - { - c.map.remove(&old); - } - let tick = c.tick; - c.map.insert(k, CacheEntry { surface: s.clone(), tick, _pixel_input: layer.mask.as_ref().map(|m| m.surface.clone()) }); - Some(s) + s } #[cfg(test)] @@ -187,6 +293,274 @@ mod tests { use photocraft_doc::LayerMask; use photocraft_doc::vector::{Knot, Path, Subpath, VectorMask}; + fn pending(c: &Mutex, k: u64) -> Arc> { + match lookup(c, k) { + CachedMask::Pending(slot, _) => slot, + CachedMask::Hit(_) => panic!("expected a cold mask"), + } + } + + /// A built mask (and no pixel-mask input), as `finish_mask`'s build returns it. + fn test_surface() -> (Surface, Option) { + let mut surface = Surface::new(FORMAT); + surface.fill_rect(Rect::new(0, 0, 2, 2), &[0.5]); + (surface, None) + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn cold_callers_share_one_build_without_retaining_a_second_cache() { + use std::sync::Barrier; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let c = Mutex::new(Cache::default()); + // Acquire all callers' slots before allowing any builder to finish. + let slots: Vec<_> = (0..8).map(|_| pending(&c, 7)).collect(); + assert!(slots.iter().all(|slot| Arc::ptr_eq(slot, &slots[0]))); + let barrier = Barrier::new(slots.len()); + let builds = AtomicUsize::new(0); + let outputs = std::thread::scope(|scope| { + let handles: Vec<_> = slots + .iter() + .map(|slot| { + let (c, barrier, builds) = (&c, &barrier, &builds); + scope.spawn(move || { + barrier.wait(); + finish_mask(c, 7, slot, 0, || { + builds.fetch_add(1, Ordering::SeqCst); + test_surface() + }) + }) + }) + .collect(); + handles.into_iter().map(|handle| handle.join().unwrap()).collect::>() + }); + assert_eq!(builds.load(Ordering::SeqCst), 1); + assert!(outputs.iter().all(|surface| Arc::ptr_eq(surface.tiles().next().unwrap().1, outputs[0].tiles().next().unwrap().1))); + + // Evicting the retained result cannot make existing callers build again. + c.lock().unwrap().map.clear(); + let again = finish_mask(&c, 7, &slots[0], 0, || panic!("rebuilt an active slot")); + assert!(Arc::ptr_eq(again.tiles().next().unwrap().1, outputs[0].tiles().next().unwrap().1)); + drop(slots); + let next = pending(&c, 8); + assert_eq!(c.lock().unwrap().in_flight.len(), 1); + drop(next); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn distinct_keys_build_while_another_key_is_paused() { + use std::sync::mpsc; + use std::time::Duration; + + let c = Mutex::new(Cache::default()); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (second_tx, second_rx) = mpsc::channel(); + std::thread::scope(|scope| { + let c = &c; + let first = scope.spawn(move || { + cached_mask(c, 1, || { + started_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(10)).unwrap(); + test_surface() + }) + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let second = scope.spawn(move || { + let surface = cached_mask(c, 2, test_surface); + second_tx.send(surface.sample_channel(0, 0, 0)).unwrap(); + }); + assert_eq!(second_rx.recv_timeout(Duration::from_secs(5)).unwrap(), 0.5); + release_tx.send(()).unwrap(); + first.join().unwrap(); + second.join().unwrap(); + }); + } + + #[test] + fn obsolete_in_flight_build_does_not_repopulate_the_cache() { + let c = Mutex::new(Cache::default()); + let old = pending(&c, 1); + c.lock().unwrap().in_flight.clear(); + let current = pending(&c, 1); + assert!(!Arc::ptr_eq(&old, ¤t)); + let old_surface = finish_mask(&c, 1, &old, 0, test_surface); + assert!(c.lock().unwrap().map.is_empty()); + let current_surface = finish_mask(&c, 1, ¤t, 0, test_surface); + assert!(!Arc::ptr_eq(old_surface.tiles().next().unwrap().1, current_surface.tiles().next().unwrap().1)); + assert_eq!(c.lock().unwrap().map.len(), 1); + } + + #[test] + fn a_purge_during_a_shared_build_keeps_the_cache_empty() { + let c = Mutex::new(Cache::default()); + let CachedMask::Pending(slot, generation) = lookup(&c, 1) else { panic!("expected a cold mask") }; + c.lock().unwrap().clear(); + finish_mask(&c, 1, &slot, generation, test_surface); + assert!(c.lock().unwrap().map.is_empty()); + assert_eq!(c.lock().unwrap().bytes, 0); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn parallel_feathered_pixel_mask_returns_the_same_tiles() { + let canvas = Rect::new(0, 0, 600, 400); + let mut layer = Layer::raster("cold shared mask", PixelFormat::RGBA8); + let mut mask = photocraft_doc::LayerMask::reveal_all(); + mask.surface.fill_rect(canvas, &[0.5]); + mask.feather = 8.0; + layer.mask = Some(mask); + let barrier = std::sync::Barrier::new(8); + let outputs = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| { + let (layer, barrier) = (&layer, &barrier); + scope.spawn(move || { + barrier.wait(); + combined_mask(layer, canvas).unwrap() + }) + }) + .collect(); + handles.into_iter().map(|handle| handle.join().unwrap()).collect::>() + }); + assert!(outputs.iter().all(|surface| Arc::ptr_eq(surface.tiles().next().unwrap().1, outputs[0].tiles().next().unwrap().1))); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn vector_mask_build_inside_a_small_rayon_pool_completes() { + use rayon::prelude::*; + use std::sync::mpsc; + use std::time::Duration; + + let (tx, rx) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let canvas = Rect::new(0, 0, 512, 512); + let mut layer = Layer::raster("rayon vector mask", PixelFormat::RGBA8); + let mut mask = VectorMask::new(photocraft_vector::shapes::ellipse(8.0, 8.0, 496.0, 496.0)); + mask.feather = 8.0; + layer.vector_mask = Some(mask); + let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); + let outputs: Vec<_> = pool.install(|| (0..8).into_par_iter().map(|_| combined_mask(&layer, canvas).unwrap()).collect()); + assert!(outputs.iter().all(|surface| Arc::ptr_eq(surface.tiles().next().unwrap().1, outputs[0].tiles().next().unwrap().1))); + tx.send(()).unwrap(); + }); + rx.recv_timeout(Duration::from_secs(10)).expect("mask initializer deadlocked inside Rayon"); + handle.join().unwrap(); + } + + fn one_tile(sample: SampleType) -> Surface { + let mut surface = Surface::with_default(PixelFormat { mode: ColorMode::Grayscale, sample, alpha: false }, &[1.0]); + surface.fill_rect(Rect::new(0, 0, 1, 1), &[0.0]); + surface + } + + #[test] + fn charges_input_and_output_at_every_depth_including_off_canvas_tiles() { + let result = one_tile(SampleType::F32); + for sample in SampleType::ALL { + let mut input = one_tile(sample); + input.fill_rect(Rect::new(1024, 1024, 1025, 1025), &[0.0]); + let tile_pixels = (photocraft_geom::TILE_SIZE * photocraft_geom::TILE_SIZE) as usize; + let expected = tile_pixels * (4 + 2 * sample.bytes()) + 4 + sample.bytes(); + let bytes = entry_bytes(&result, Some(&input)); + assert_eq!(bytes, expected, "{sample:?}"); + let mut cache = Cache::default(); + cache.insert(1, result.clone(), None, bytes, bytes * 2, cache.generation); + cache.insert(2, result.clone(), None, bytes, bytes * 2, cache.generation); + assert_eq!(cache.bytes, bytes * 2, "shared tiles are conservatively charged per entry"); + } + } + + #[test] + fn byte_budget_evicts_the_least_recently_used_entry() { + let surface = one_tile(SampleType::F32); + let bytes = entry_bytes(&surface, None); + let mut cache = Cache::default(); + cache.insert(1, surface.clone(), None, bytes, bytes * 2, cache.generation); + cache.insert(2, surface.clone(), None, bytes, bytes * 2, cache.generation); + assert!(cache.get(1).is_some()); + cache.insert(3, surface, None, bytes, bytes * 2, cache.generation); + assert!(cache.get(2).is_none()); + assert!(cache.get(1).is_some()); + assert!(cache.get(3).is_some()); + assert_eq!(cache.bytes, bytes * 2); + } + + #[test] + fn entry_limit_also_bounds_masks_with_no_tiles() { + let surface = Surface::new(FORMAT); + let bytes = entry_bytes(&surface, None); + let mut cache = Cache::default(); + for key in 0..CAPACITY as u64 { + cache.insert(key, surface.clone(), None, bytes, CACHE_BUDGET, cache.generation); + } + assert!(cache.get(0).is_some()); + cache.insert(CAPACITY as u64, surface, None, bytes, CACHE_BUDGET, cache.generation); + assert_eq!(cache.map.len(), CAPACITY); + assert!(cache.get(1).is_none()); + assert!(cache.get(0).is_some()); + assert_eq!(cache.bytes, bytes * CAPACITY); + } + + #[test] + fn oversized_results_are_usable_without_retention_or_other_eviction() { + let surface = one_tile(SampleType::F32); + let bytes = entry_bytes(&surface, None); + let mut cache = Cache::default(); + let small = Surface::new(FORMAT); + let small_bytes = entry_bytes(&small, None); + cache.insert(1, small, None, small_bytes, bytes - 1, cache.generation); + let tile = surface.tiles().next().unwrap().1.clone(); + let owners = Arc::strong_count(&tile); + cache.insert(2, surface.clone(), None, bytes, bytes - 1, cache.generation); + assert!(cache.get(2).is_none()); + assert!(cache.get(1).is_some()); + assert_eq!(cache.bytes, small_bytes); + assert_eq!(Arc::strong_count(&tile), owners); + assert_eq!(surface.pixel(0, 0), vec![0.0]); + } + + #[test] + fn replacement_and_purge_release_accounted_bytes_and_tile_references() { + let first = one_tile(SampleType::U8); + let second = one_tile(SampleType::F32); + let first_bytes = entry_bytes(&first, None); + let second_bytes = entry_bytes(&second, None); + let first_tile = first.tiles().next().unwrap().1.clone(); + let second_tile = second.tiles().next().unwrap().1.clone(); + let mut cache = Cache::default(); + cache.insert(1, first.clone(), None, first_bytes, CACHE_BUDGET, cache.generation); + assert_eq!(Arc::strong_count(&first_tile), 3); + cache.insert(1, second.clone(), None, second_bytes, CACHE_BUDGET, cache.generation); + assert_eq!(cache.bytes, second_bytes); + assert_eq!(cache.map.len(), 1); + assert_eq!(Arc::strong_count(&first_tile), 2); + assert_eq!(Arc::strong_count(&second_tile), 3); + assert_eq!(cache.clear(), second_bytes); + assert_eq!(cache.bytes, 0); + assert!(cache.map.is_empty()); + assert_eq!(Arc::strong_count(&second_tile), 2); + assert_eq!(cache.clear(), 0); + } + + #[test] + fn a_build_started_before_purge_cannot_refill_the_cache() { + let surface = one_tile(SampleType::F32); + let bytes = entry_bytes(&surface, None); + let mut cache = Cache::default(); + assert!(cache.get(1).is_none()); + let generation_before_purge = cache.generation; + cache.clear(); + cache.insert(1, surface.clone(), None, bytes, CACHE_BUDGET, generation_before_purge); + assert!(cache.map.is_empty()); + assert_eq!(cache.bytes, 0); + cache.insert(1, surface, None, bytes, CACHE_BUDGET, cache.generation); + assert!(cache.get(1).is_some()); + } + #[test] fn pixel_edits_refresh_cached_masks_at_every_depth() { let canvas = Rect::new(0, 0, 64, 64); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 92dd20c4..0f0fc1ae 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -739,6 +739,8 @@ pub type PsdGlobalBlock = ([u8; 4], [u8; 4], Arc>); pub struct Metadata { pub xmp: Option, pub exif: Option>>, + /// Free-form image text (PNG tEXt/zTXt/iTXt, TIFF ASCII tags), including duplicate keys. + pub text: Vec<(String, String)>, /// Raw PSD image resources we don't model yet: (id, name, data), for lossless round-trip. pub psd_resources: Vec<(u16, String, Arc>)>, /// Raw PSD global additional-layer-info blocks: (signature, key, data), diff --git a/crates/engine/src/edit_menu_cmds.rs b/crates/engine/src/edit_menu_cmds.rs index 994b84d6..bbf838ba 100644 --- a/crates/engine/src/edit_menu_cmds.rs +++ b/crates/engine/src/edit_menu_cmds.rs @@ -264,6 +264,11 @@ fn purge(s: &mut Session, what: &str) -> Result { freed += fx; items.push("effect cache"); } + let masks = photocraft_compose::masks::purge_cache(); + if masks > 0 { + freed = freed.saturating_add(masks); + items.push("mask cache"); + } } s.edit_state.fade = None; let msg = if items.is_empty() { "nothing to purge".to_string() } else { format!("purged {}", items.join(", ")) }; @@ -280,9 +285,9 @@ fn can_purge_histories(s: &Session) -> std::result::Result<(), String> { if s.documents().iter().any(|d| d.history.can_undo() || d.history.can_redo()) { Ok(()) } else { Err("no history to purge".into()) } } fn can_purge_all(s: &Session) -> std::result::Result<(), String> { - can_purge_histories(s) - .or_else(|_| can_purge_clipboard(s)) - .or_else(|_| if photocraft_compose::effect_cache_bytes() > 0 { Ok(()) } else { Err("nothing to purge".into()) }) + can_purge_histories(s).or_else(|_| can_purge_clipboard(s)).or_else(|_| { + if photocraft_compose::effect_cache_bytes() > 0 || photocraft_compose::masks::cache_bytes() > 0 { Ok(()) } else { Err("nothing to purge".into()) } + }) } // ------------------------------------------------------------------ Content-Aware Fill diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 76bdb7ce..01097aeb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -97,6 +97,7 @@ pub mod type_styles_cmds; mod variables_cmds; pub mod vector_cmds; mod video_cmds; +pub use video_cmds::render_video_with; pub mod vp_cmds; pub mod warp_cmds; pub mod web_cmds; diff --git a/crates/engine/src/video_cmds.rs b/crates/engine/src/video_cmds.rs index 1a6c92be..250ab861 100644 --- a/crates/engine/src/video_cmds.rs +++ b/crates/engine/src/video_cmds.rs @@ -320,8 +320,20 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result { /// sequence (`{dir}/{stem}_NNNN.{format}`). Clean-room; no proprietary codec. fn render_video(s: &mut Session, p: &Value) -> Result { let dir = p.get("dir").and_then(Value::as_str).ok_or_else(|| EngineError::BadParams { cmd: "file.export.renderVideo".into(), msg: "need `dir`".into() })?; - let format = p.get("format").and_then(Value::as_str).unwrap_or("png"); std::fs::create_dir_all(dir).map_err(|e| EngineError::Other(format!("mkdir `{dir}`: {e}")))?; + render_video_with(s, p, |name, bytes| crate::file_cmds::write_file(&format!("{dir}/{name}"), bytes), |_, _| true) +} + +/// Render through a caller-owned writer. The writer receives one filename, never an ambient +/// directory. Returning false from progress stops at a frame boundary; the caller owns cleanup. +pub fn render_video_with( + s: &Session, + p: &Value, + mut write: impl FnMut(&str, &[u8]) -> Result<()>, + mut progress: impl FnMut(usize, usize) -> bool, +) -> Result { + let dir = p.get("dir").and_then(Value::as_str).unwrap_or(""); + let format = p.get("format").and_then(Value::as_str).unwrap_or("png"); let st = s.active().ok_or(EngineError::NoDocument)?; let mut base = (*st.doc).clone(); store(&mut base); @@ -332,6 +344,11 @@ fn render_video(s: &mut Session, p: &Value) -> Result { let bounds = base.bounds(); let (w, h) = (bounds.width() as usize, bounds.height() as usize); + let cancelled = || EngineError::Other("render cancelled".into()); + if !progress(0, frames) { + return Err(cancelled()); + } + // Animated GIF: one file, every frame quantised to its own palette. if format.eq_ignore_ascii_case("gif") { use photocraft_algo::quantize::{self, Dither, Forced, PaletteKind}; @@ -349,10 +366,18 @@ fn render_video(s: &mut Session, p: &Value) -> Result { let pal = quantize::build_palette(&px, PaletteKind::Adaptive, 256, Forced::None).map_err(EngineError::Other)?; let idx = quantize::quantize(&mut px, w, &pal, Dither::None, 1.0, None); gframes.push(photocraft_codecs::web::GifFrame { indices: idx, palette: pal, transparent: None, delay_cs: delay }); + // The final report follows encoding/writing, so total means the output exists. + if !progress((f + 1).min(frames.saturating_sub(1)), frames) { + return Err(cancelled()); + } } let gif = photocraft_codecs::web::encode_gif_animated(w as u32, h as u32, &gframes, true).map_err(|e| EngineError::Other(e.to_string()))?; - let path = format!("{dir}/{stem}.gif"); - crate::file_cmds::write_file(&path, &gif)?; + let name = format!("{stem}.gif"); + write(&name, &gif)?; + if !progress(frames, frames) { + return Err(cancelled()); + } + let path = format!("{dir}/{name}"); return Ok(json!({"frames": frames, "file": path})); } @@ -365,10 +390,13 @@ fn render_video(s: &mut Session, p: &Value) -> Result { t.current = f; } sync(&mut doc); - let path = format!("{dir}/{stem}_{f:04}.{format}"); + let path = format!("{stem}_{f:04}.{format}"); let bytes = photocraft_io::export(&doc, format, &opts).map(|r| r.bytes).map_err(|e| EngineError::Other(format!("render frame {f}: {e}")))?; - crate::file_cmds::write_file(&path, &bytes)?; + write(&path, &bytes)?; files.push(path); + if !progress(f + 1, frames) { + return Err(cancelled()); + } } Ok(json!({"frames": files.len(), "dir": dir})) } diff --git a/crates/engine/src/video_cmds/tests.rs b/crates/engine/src/video_cmds/tests.rs index 7c597915..190aa306 100644 --- a/crates/engine/src/video_cmds/tests.rs +++ b/crates/engine/src/video_cmds/tests.rs @@ -174,3 +174,32 @@ fn edits_to_a_frame_survive_scrubbing_and_render() { let q = flat.pixel(2, 2); assert!(q[2] > 0.99 && q[0] < 0.01, "rendered frame 1 is blue: {q:?}"); } + +#[test] +fn render_writer_progress_and_cancellation_leave_session_unchanged() { + let mut s = session(); + s.execute("timeline.create", json!({"duration": 4, "fps": 4})).unwrap(); + let before = s.active().unwrap().doc.clone(); + for format in ["png", "gif"] { + let mut names = Vec::new(); + let mut progress = Vec::new(); + let r = render_video_with( + &s, + &json!({"format":format}), + |name, bytes| { + assert!(!bytes.is_empty()); + names.push(name.to_owned()); + Ok(()) + }, + |done, total| { + progress.push((done, total)); + done < 2 + }, + ); + assert!(r.unwrap_err().to_string().contains("cancelled")); + assert!(names.len() < 4); + assert_eq!(progress.first(), Some(&(0, 4))); + assert_eq!(progress.last(), Some(&(2, 4))); + assert_eq!(s.active().unwrap().doc.timeline, before.timeline); + } +} diff --git a/crates/engine/tests/mask_cache_purge.rs b/crates/engine/tests/mask_cache_purge.rs new file mode 100644 index 00000000..37f2de91 --- /dev/null +++ b/crates/engine/tests/mask_cache_purge.rs @@ -0,0 +1,41 @@ +//! One integration-test process keeps its global mask cache isolated from parallel unit tests. +use std::sync::Arc; + +use photocraft_color::{ColorMode, PixelFormat, SampleType}; +use photocraft_compose::masks; +use photocraft_doc::{Layer, LayerMask}; +use photocraft_engine::Session; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::json; + +#[test] +fn purge_all_is_enabled_by_cached_masks_and_releases_them_at_every_depth() { + let mut session = Session::new(); + assert!(!session.is_enabled("edit.purge.all")); + let canvas = Rect::new(0, 0, 32, 32); + for sample in SampleType::ALL { + let mut input = Surface::with_default(PixelFormat { mode: ColorMode::Grayscale, sample, alpha: false }, &[1.0]); + input.fill_rect(Rect::new(8, 8, 24, 24), &[0.0]); + let mut layer = Layer::raster("mask", PixelFormat::RGBA8); + layer.mask = Some(LayerMask { surface: input, feather: 2.0, ..LayerMask::reveal_all() }); + let before = masks::combined_mask(&layer, canvas).unwrap(); + let accounted = masks::cache_bytes(); + assert!(accounted > 0, "{sample:?}"); + assert!(session.is_enabled("edit.purge.all"), "only the mask cache enables purge"); + + let result = session.execute("edit.purge.all", json!({})).unwrap(); + assert_eq!(result["purged"], json!(["mask cache"])); + assert_eq!(result["bytes"].as_u64(), Some(accounted as u64)); + assert_eq!(masks::cache_bytes(), 0); + assert!(!session.is_enabled("edit.purge.all")); + + let after = masks::combined_mask(&layer, canvas).unwrap(); + assert_eq!(before, after, "purge preserves pixels at {sample:?}"); + let old_tile = before.tiles().next().unwrap().1; + let new_tile = after.tiles().next().unwrap().1; + assert!(!Arc::ptr_eq(old_tile, new_tile), "the result was rebuilt after purge"); + session.execute("edit.purge.all", json!({})).unwrap(); + assert_eq!(masks::cache_bytes(), 0); + } +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 8198032e..f29ccf86 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -22,5 +22,10 @@ flate2 = { workspace = true } blake3 = { version = "1.8", default-features = false, features = ["pure"] } ruzstd = "0.9" +# A plain fsync where a file system refuses the full flush (`sync_file`, atomic.rs). Already in +# Cargo.lock on Apple targets (cap-std), so this adds no new crate. +[target.'cfg(target_vendor = "apple")'.dependencies] +rustix = { version = "1.1", default-features = false, features = ["std", "fs"] } + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index c1c5a8d2..25d433db 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -13,6 +13,9 @@ //! if it does anyway, the save fails with a clear error. It never falls back to writing the //! destination in place. //! - **Unwritable folder:** the save fails with a clear error instead of writing in place. +//! - **Network shares (macOS):** `sync_all` asks for a full flush to the disk's platters +//! (`F_FULLFSYNC`), which SMB shares and some virtual file systems refuse while a plain `fsync` +//! works. [`sync_file`] falls back to the plain one there (#1336). //! //! On the web there is no file system: writes go through the platform services (downloads), so //! this module is never reached there; on `wasm32` every call returns the `std` "unsupported" @@ -27,7 +30,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// The file-system steps [`atomic_write_with`] performs. [`RealFs`] is the real thing; tests /// wrap it to inject failures. pub trait AtomicFs { - /// Create `tmp` (it must not exist), write all of `bytes` and `sync_all` it. + /// Create `tmp` (it must not exist), write all of `bytes` and flush it ([`sync_file`]). fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()>; /// Rename `from` over `to`, replacing it. fn rename(&self, from: &Path, to: &Path) -> io::Result<()>; @@ -48,7 +51,7 @@ impl AtomicFs for RealFs { fn write_new(&self, tmp: &Path, bytes: &[u8]) -> io::Result<()> { let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)?; f.write_all(bytes)?; - f.sync_all() + sync_file(&f) } fn rename(&self, from: &Path, to: &Path) -> io::Result<()> { std::fs::rename(from, to) @@ -59,7 +62,7 @@ impl AtomicFs for RealFs { fn sync_dir(&self, dir: &Path) -> io::Result<()> { #[cfg(unix)] { - std::fs::File::open(dir)?.sync_all() + sync_file(&std::fs::File::open(dir)?) } #[cfg(not(unix))] { @@ -73,6 +76,37 @@ impl AtomicFs for RealFs { } } +/// Flush `file` to stable storage (`sync_all`). On Apple platforms that is a full flush +/// (`F_FULLFSYNC`); where the file system can't do one (SMB shares refuse it with `ENOTSUP`, +/// #1336; Tart's shared folders with `ENOTTY`) this falls back to a plain `fsync`, as Apple's own +/// `removefile` does. Any other error is returned as it is. +pub fn sync_file(file: &std::fs::File) -> io::Result<()> { + #[cfg(target_vendor = "apple")] + { + or_plain_fsync(file.sync_all(), || rustix::fs::fsync(file).map_err(io::Error::from)) + } + #[cfg(not(target_vendor = "apple"))] + { + file.sync_all() + } +} + +/// `full`, or `fsync()` when `full` failed only because the file system can't do a full flush. +pub fn or_plain_fsync(full: io::Result<()>, fsync: impl FnOnce() -> io::Result<()>) -> io::Result<()> { + match full { + Err(e) if full_flush_unsupported(&e) => fsync(), + other => other, + } +} + +/// Whether `e` is a file system refusing a full flush (`F_FULLFSYNC`): `ENOTSUP` (45, SMB) or +/// `ENOTTY` (25) on Apple platforms. Never elsewhere: there `sync_all` is a plain `fsync`. +fn full_flush_unsupported(e: &io::Error) -> bool { + const ENOTTY: i32 = 25; + const ENOTSUP: i32 = 45; + cfg!(target_vendor = "apple") && matches!(e.raw_os_error(), Some(ENOTSUP | ENOTTY)) +} + /// How hard to retry a rename that fails with `PermissionDenied`. #[derive(Debug, Clone, Copy)] pub struct RenameRetry { diff --git a/crates/format/src/atomic/tests.rs b/crates/format/src/atomic/tests.rs index be89dbe1..c03c71cc 100644 --- a/crates/format/src/atomic/tests.rs +++ b/crates/format/src/atomic/tests.rs @@ -210,3 +210,40 @@ fn directory_target_or_bare_root_is_an_error_not_a_panic() { assert!(atomic_write(Path::new("/"), b"x").is_err()); assert!(atomic_write(Path::new(""), b"x").is_err()); } + +/// A share that refuses the full flush (`F_FULLFSYNC`, #1336) still saves through a plain fsync on +/// Apple platforms; every other sync error still fails the save. +#[test] +fn a_refused_full_flush_falls_back_to_a_plain_fsync_on_apple_only() { + let refused = |errno| Err(io::Error::from_raw_os_error(errno)); + for (errno, name) in [(45, "ENOTSUP, SMB"), (25, "ENOTTY")] { + let fsynced = Cell::new(false); + let r = or_plain_fsync(refused(errno), || { + fsynced.set(true); + Ok(()) + }); + let apple = cfg!(target_vendor = "apple"); + assert_eq!(r.is_ok(), apple, "{name}"); + assert_eq!(fsynced.get(), apple, "{name}: the plain fsync runs only on Apple platforms"); + } + // The plain fsync's own failure is reported. + if cfg!(target_vendor = "apple") { + let r = or_plain_fsync(refused(45), || Err(io::Error::from_raw_os_error(5))); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(5))); + } + // Real errors (EIO, ENOSPC, EACCES) never fall back, and success needs no fallback. + for errno in [5, 28, 13] { + let r = or_plain_fsync(refused(errno), || panic!("no fallback for errno {errno}")); + assert_eq!(r.map_err(|e| e.raw_os_error()), Err(Some(errno))); + } + assert!(or_plain_fsync(Ok(()), || panic!("no fallback after a full flush")).is_ok()); +} + +#[test] +fn sync_file_flushes_a_local_file() { + let dir = TempDir::new("sync"); + let path = dir.0.join("f.bin"); + // Opened for writing, as the saves do: Windows can't flush a read-only handle. + let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap(); + sync_file(&file).unwrap(); +} diff --git a/crates/format/src/convert.rs b/crates/format/src/convert.rs index 5cb845a9..6b509f0e 100644 --- a/crates/format/src/convert.rs +++ b/crates/format/src/convert.rs @@ -224,6 +224,7 @@ pub(crate) fn doc_m(d: &Document, sink: &mut dyn Sink) -> DocM { metadata: MetadataM { xmp: d.metadata.xmp.clone(), exif: opt_blob(&d.metadata.exif, sink), + text: d.metadata.text.clone(), psd_resources: d.metadata.psd_resources.iter().map(|(id, n, b)| (*id, n.clone(), sink.blob(b))).collect(), psd_global_blocks: d.metadata.psd_global_blocks.iter().map(|(s, k, b)| (hex(s), hex(k), sink.blob(b))).collect(), }, @@ -507,7 +508,13 @@ impl Loader<'_> { channels.push(self.channel(c)?); } let quick_mask = m.quick_mask.as_ref().map(|c| self.channel(c)).transpose()?; - let mut md = Metadata { xmp: m.metadata.xmp.clone(), exif: self.opt_blob(&m.metadata.exif)?, psd_resources: Vec::new(), psd_global_blocks: Vec::new() }; + let mut md = Metadata { + xmp: m.metadata.xmp.clone(), + exif: self.opt_blob(&m.metadata.exif)?, + text: m.metadata.text.clone(), + psd_resources: Vec::new(), + psd_global_blocks: Vec::new(), + }; for (id, n, h) in &m.metadata.psd_resources { md.psd_resources.push((*id, n.clone(), self.fetch.blob(h)?)); } diff --git a/crates/format/src/manifest.rs b/crates/format/src/manifest.rs index 0ffb7c23..11c17cb5 100644 --- a/crates/format/src/manifest.rs +++ b/crates/format/src/manifest.rs @@ -343,6 +343,8 @@ fn default_channel_opacity() -> f32 { pub struct MetadataM { pub xmp: Option, pub exif: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub text: Vec<(String, String)>, /// (id, name, blob) pub psd_resources: Vec<(u16, String, Hash)>, /// (signature hex, key hex, blob) diff --git a/crates/format/tests/common/mod.rs b/crates/format/tests/common/mod.rs index 59bb8f4b..8e8def3f 100644 --- a/crates/format/tests/common/mod.rs +++ b/crates/format/tests/common/mod.rs @@ -230,6 +230,7 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document { scribble(&mut sel, 14, false); d.selection = Some(sel); d.metadata.xmp = Some("".into()); + d.metadata.text = vec![("Author".into(), "Synthetic é 雪".into()), ("Comment".into(), "first".into()), ("Comment".into(), "second".into())]; d.metadata.exif = Some(blob(15, 64)); d.metadata.psd_resources.push((1036, "thumb".into(), blob(16, 20))); d.metadata.psd_global_blocks.push((*b"8BIM", *b"Patt", blob(17, 50))); diff --git a/crates/format/tests/corruption.rs b/crates/format/tests/corruption.rs index d3731d97..18667ec1 100644 --- a/crates/format/tests/corruption.rs +++ b/crates/format/tests/corruption.rs @@ -22,6 +22,22 @@ fn rebuild(bytes: &[u8], f: impl Fn(&str, Vec) -> Option>) -> Vec Result) -> Result Resu // Export As's Metadata: None: the packet lists the text of every type layer and one id // per placed document (#647). img.meta.xmp = None; + // Free-form descriptions can contain the same sensitive text as an XMP packet. + img.meta.text.clear(); } if img.layout().has_alpha() && !format.caps().alpha { // Flattened over white, as saving a transparent document without transparency does. @@ -452,7 +453,11 @@ fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> Ok(()) }); let bytes = codecs::encode_png_indexed(doc.size.width, doc.size.height, &idx, &table.colors, table.transparent)?; - Ok(Some(ExportResult { bytes, warnings: vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())] })) + let mut warnings = vec![format!("written as an 8-bit palette PNG ({} colours)", table.colors.len())]; + if opts.encode.embed_metadata && opts.xmp == XmpEmbed::All && !doc.metadata.text.is_empty() { + warnings.push("text metadata is not supported by the palette PNG exporter; it will be dropped".into()); + } + Ok(Some(ExportResult { bytes, warnings })) } ColorMode::Duotone => { let Some(d) = doc.duotone.as_ref() else { return Ok(None) }; diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a0be5d20..5c9c0128 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -120,16 +120,16 @@ pub struct ExportResult { pub warnings: Vec, } -/// Which part of the document's XMP packet a flat export embeds. Layered saves (PSD, PSB, -/// `.pcraft`) always keep everything. Save As and conversions keep the whole packet, as +/// Which part of the document's XMP packet and free-form text a flat export embeds. +/// PSD/PSB always keep XMP; `.pcraft` keeps both. Save As and conversions keep the whole packet, as /// Photoshop's Save As does; Export As starts at `None`, because the packet lists the text of /// every type layer and one id per placed document (#647). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum XmpEmbed { - /// Embed the document's whole XMP packet. + /// Embed the document's whole XMP packet and free-form text. #[default] All, - /// Embed no XMP. + /// Embed no XMP or free-form text. None, } @@ -145,8 +145,9 @@ pub struct ExportOptions { /// Save As sets it from its Layers option, which keeps them as Photoshop does. `false` is /// Photoshop's "Discard Layers and Save a Copy". pub tiff_layers: bool, - /// Which part of the document's XMP packet a flat export embeds (PSD/PSB/`.pcraft` - /// always keep everything). Everything by default, as Save As does; Export As offers None. + /// Which part of the document's XMP packet and free-form text a flat export embeds. + /// PSD/PSB always keep XMP; `.pcraft` keeps both. Everything by default, as Save As does; + /// Export As offers None. pub xmp: XmpEmbed, } @@ -233,6 +234,9 @@ pub fn export(doc: &Document, name_or_ext: &str, opts: &ExportOptions) -> Result if ext == "psd" || ext == "psb" { let o = PsdExportOptions { force_psb: opts.force_psb || ext == "psb", ..Default::default() }; let (mut file, mut warnings) = document_to_psd_with(doc, &o); + if !doc.metadata.text.is_empty() { + warnings.push("free-form text metadata is not supported by PSD/PSB; it will be dropped".into()); + } warnings.extend(tiff_layers::strip_foreign_order_blocks(&mut file)); // Never write a header the reader would refuse (e.g. a zero-sized canvas). file.header.validate()?; diff --git a/crates/io/src/tiff_layers.rs b/crates/io/src/tiff_layers.rs index 46e423bc..9eeaf35a 100644 --- a/crates/io/src/tiff_layers.rs +++ b/crates/io/src/tiff_layers.rs @@ -111,9 +111,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result Result Image { + let mut image = Image::from_u8(1, 1, ChannelLayout::Rgb, vec![31, 127, 223]).unwrap().convert(ChannelLayout::Rgb, sample); + image.meta.text = vec![ + ("Creation Time".into(), "Fri, 09 Oct 2026 07:00:00 GMT".into()), + ("Author".into(), "Synthetic author".into()), + ("Description".into(), "Synthetic description é".into()), + ("Title".into(), "Unicode 雪".into()), + ("Comment".into(), "first".into()), + ("Comment".into(), "second".into()), + ]; + image.meta.xmp = Some("".into()); + image +} + +#[test] +fn png_text_survives_native_and_png_saves_at_both_depths() { + for sample in [SampleType::U8, SampleType::U16] { + let image = source(sample); + let png = encode(&image, Format::Png, &EncodeOptions::default()).unwrap(); + let original = decode(&png).unwrap(); + let opened = import("synthetic.png", &png).unwrap(); + assert!(opened.warnings.is_empty(), "{:?}", opened.warnings); + let native = export(&opened.document, "pcraft", &ExportOptions::default()).unwrap(); + let reopened = import("synthetic.pcraft", &native.bytes).unwrap(); + let saved = export(&reopened.document, "png", &ExportOptions::default()).unwrap(); + assert!(saved.warnings.is_empty(), "{:?}", saved.warnings); + let back = decode(&saved.bytes).unwrap(); + assert_eq!(back.meta.text, original.meta.text, "duplicates and Unicode must survive"); + assert_eq!(back.meta.xmp, original.meta.xmp); + assert_eq!(back.sample_type(), sample); + let back = back.convert(original.layout(), sample); + assert_eq!(back.data(), original.data(), "pixels must not change"); + } +} + +#[test] +fn metadata_none_and_embed_flag_remove_png_text_without_mutating_document() { + let image = source(SampleType::U8); + let png = encode(&image, Format::Png, &EncodeOptions::default()).unwrap(); + let doc = import("synthetic.png", &png).unwrap().document; + for opts in [ + ExportOptions { xmp: XmpEmbed::None, ..Default::default() }, + ExportOptions { encode: EncodeOptions { embed_metadata: false, ..Default::default() }, ..Default::default() }, + ] { + let saved = export(&doc, "png", &opts).unwrap(); + let back = decode(&saved.bytes).unwrap(); + assert!(back.meta.text.is_empty()); + assert!(back.meta.xmp.is_none()); + } + let saved = export(&doc, "png", &ExportOptions::default()).unwrap(); + assert_eq!(decode(&saved.bytes).unwrap().meta.text, decode(&png).unwrap().meta.text); +} + +#[test] +fn formats_without_text_support_report_the_loss_on_export() { + let image = source(SampleType::U8); + let png = encode(&image, Format::Png, &EncodeOptions::default()).unwrap(); + let doc = import("synthetic.png", &png).unwrap().document; + for ext in ["jpg", "psd", "psb"] { + let saved = export(&doc, ext, &ExportOptions::default()).unwrap(); + assert!(saved.warnings.iter().any(|w| w.contains("text metadata")), "{ext}: {:?}", saved.warnings); + } +} + +#[test] +fn palette_png_reports_unsupported_text_only_when_embedding_is_requested() { + let image = source(SampleType::U8); + let png = encode(&image, Format::Png, &EncodeOptions::default()).unwrap(); + let mut doc = import("synthetic.png", &png).unwrap().document; + doc.mode = photocraft_color::ColorMode::Indexed; + doc.color_table = Some(photocraft_doc::ColorTable { colors: vec![[31, 127, 223]], transparent: None }); + let saved = export(&doc, "png", &ExportOptions::default()).unwrap(); + assert!(saved.warnings.iter().any(|w| w.contains("text metadata"))); + let opts = ExportOptions { xmp: XmpEmbed::None, ..Default::default() }; + let saved = export(&doc, "png", &opts).unwrap(); + assert!(!saved.warnings.iter().any(|w| w.contains("text metadata"))); +} + +#[test] +fn tiff_text_survives_flat_and_layered_saves_and_respects_metadata_none() { + let image = source(SampleType::U16); + let png = encode(&image, Format::Png, &EncodeOptions::default()).unwrap(); + let mut doc = import("synthetic.png", &png).unwrap().document; + // TIFF's supported ASCII tag names, rather than arbitrary PNG keywords. + doc.metadata.text = vec![("Description".into(), "Synthetic description".into()), ("Artist".into(), "Synthetic author".into())]; + let mut overlay = doc.layers[0].clone(); + overlay.id = photocraft_doc::LayerId::fresh(); + doc.layers.push(overlay); + for tiff_layers in [false, true] { + let opts = ExportOptions { tiff_layers, ..Default::default() }; + let saved = export(&doc, "tif", &opts).unwrap(); + let opened = import("synthetic.tif", &saved.bytes).unwrap(); + assert_eq!(opened.document.metadata.text, doc.metadata.text); + assert!(!opened.warnings.iter().any(|w| w.contains("text metadata")), "{:?}", opened.warnings); + let opts = ExportOptions { xmp: XmpEmbed::None, ..opts }; + let stripped = export(&doc, "tif", &opts).unwrap(); + assert!(decode(&stripped.bytes).unwrap().meta.text.is_empty()); + assert_eq!(doc.metadata.text.len(), 2, "export must not mutate the document"); + } +} diff --git a/crates/ui-egui/src/channels_panel.rs b/crates/ui-egui/src/channels_panel.rs index 8f06a543..d0c1af65 100644 --- a/crates/ui-egui/src/channels_panel.rs +++ b/crates/ui-egui/src/channels_panel.rs @@ -82,7 +82,7 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let shown_colors = if colors > 1 { colors } else { 0 }; let mut actions: Vec<(String, Value)> = Vec::new(); // The buttons sit in a footer at the panel's bottom, like Photoshop's. - let footer = 34.0; + let footer = widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY }; let mut mask_click = None; @@ -270,30 +270,25 @@ pub fn show(app: &mut PhotocraftApp, ui: &mut egui::Ui) { }); } }); - ui.add_space(4.0); - widgets::hairline(ui); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + // Photoshop's order from the left: Load as selection, Save selection, New, Delete. + widgets::panel_footer(ui, |ui| { let target_ref = match view.target { ChannelTarget::Alpha(i) => json!(i), ChannelTarget::Color(k) => json!({ "color": k }), ChannelTarget::Composite => json!("composite"), }; - if icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")).clicked() { - actions.push(("select.loadSelection".into(), json!({ "channel": target_ref }))); + if icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")).clicked() { + actions.push(("channel.delete".into(), json!({}))); + } + if icons::button(ui, "plus", 26.0, false, tl!("Create new channel")).clicked() { + actions.push(("channel.new".into(), json!({}))); } if icons::button(ui, "square-dashed", 26.0, false, tl!("Save selection as channel")).clicked() { actions.push(("select.saveSelection".into(), json!({}))); } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, "trash", 26.0, false, tl!("Delete current channel")).clicked() { - actions.push(("channel.delete".into(), json!({}))); - } - if icons::button(ui, "plus", 26.0, false, tl!("Create new channel")).clicked() { - actions.push(("channel.new".into(), json!({}))); - } - }); + if icons::button(ui, "circle-dashed", 26.0, false, tl!("Load channel as selection")).clicked() { + actions.push(("select.loadSelection".into(), json!({ "channel": target_ref }))); + } }); ctx.data_mut(|d| d.insert_temp(thumbs_id(), drawn)); if let Some(on) = mask_click { diff --git a/crates/ui-egui/src/color_picker_ui.rs b/crates/ui-egui/src/color_picker_ui.rs index e6e6b2d1..b8cfb98a 100644 --- a/crates/ui-egui/src/color_picker_ui.rs +++ b/crates/ui-egui/src/color_picker_ui.rs @@ -1,6 +1,6 @@ //! Photoshop's Color Picker (Foreground / Background Color) dialog: a 2D colour field and a slider -//! for the selected component (H, S, B, R, G or B radio), new/current swatches, and HSB, RGB, Lab, -//! CMYK and hex fields. The colour lives in the dialog fields (`color` as `#rrggbb`, plus the HSB +//! for the selected component (H, S, B, R, G, B, L, a or b radio), new/current swatches with the +//! out-of-gamut and web-colour warnings, and HSB, RGB, Lab, CMYK and hex fields. The colour lives in the dialog fields (`color` as `#rrggbb`, plus the HSB //! floats so hue survives greys), so `ui.dialog.set` drives it; OK runs `tools.setColors`. //! While it is the top dialog the image is its eyedropper, as in Photoshop: the pointer over the //! canvas is a pipette, and a click or drag there samples into the new colour ([`sample_at`]). @@ -15,6 +15,45 @@ use crate::widgets; /// Radio components, Photoshop order: H, S, B, R, G, B. pub const MODES: &[(&str, &str)] = &[("h", "H:"), ("s", "S:"), ("v", "B:"), ("r", "R:"), ("g", "G:"), ("b", "B:")]; +/// Lab radio components (Photoshop's right-hand column): L, a, b. +pub const LAB_MODES: &[(&str, &str)] = &[("lab_l", "L:"), ("lab_a", "a:"), ("lab_b", "b:")]; + +/// The Lab colour at field position (x, y) in 0..1 (y down) with the slider at `z`, for a Lab +/// `mode`, as Photoshop 25.4 lays them out: L shows a across and b upwards; a shows b across and L +/// upwards; b shows a across and L upwards (a and b run -128..127, L 0..100). +fn field_lab(mode: &str, z: f32, x: f32, y: f32) -> Option<[f32; 3]> { + let ab = |t: f32| t * 255.0 - 128.0; + match mode { + "lab_l" => Some([z * 100.0, ab(x), ab(1.0 - y)]), + "lab_a" => Some([(1.0 - y) * 100.0, ab(z), ab(x)]), + "lab_b" => Some([(1.0 - y) * 100.0, ab(x), ab(z)]), + _ => None, + } +} + +/// Lab → sRGB, clamped to what the field can show. +fn lab_rgb(lab: [f32; 3]) -> [f32; 3] { + photocraft_color::convert::lab_to_srgb(lab).map(|c| c.clamp(0.0, 1.0)) +} + +/// Colour differences the CMYK round trip leaves inside the printable gamut (ΔE ≤ 0.4, black ≈ 1.4 +/// with black-point compensation) stay below this; out-of-gamut colours move far more. +const GAMUT_DE: f32 = 3.0; + +/// The nearest printable colour, when `rgb` is outside the CMYK gamut (Photoshop's ⚠ warning). +pub fn out_of_gamut(rgb: [f32; 3]) -> Option<[f32; 3]> { + use photocraft_color::convert::{cmyk_to_rgb, rgb_to_cmyk, srgb_to_lab}; + let back = cmyk_to_rgb(rgb_to_cmyk(rgb)).map(|c| c.clamp(0.0, 1.0)); + let (a, b) = (srgb_to_lab(rgb), srgb_to_lab(back)); + let de = ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); + (de > GAMUT_DE).then_some(back) +} + +/// The nearest web colour (channels in steps of 51), when `rgb` isn't one. +pub fn not_web_safe(rgb: [f32; 3]) -> Option<[f32; 3]> { + let web = rgb.map(|v| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0); + (hex(web) != hex(rgb)).then_some(web) +} pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { let h = (h.rem_euclid(360.0)) / 60.0; @@ -65,6 +104,9 @@ pub fn parse_hex(s: &str) -> Option<[f32; 3]> { /// The colour at field position (x, y) in 0..1 (y down) for `mode` with the slider at `z` (0..1), /// given the current HSV/RGB (used for the components the field doesn't vary). pub fn field_color(mode: &str, z: f32, x: f32, y: f32) -> [f32; 3] { + if let Some(lab) = field_lab(mode, z, x, y) { + return lab_rgb(lab); + } match mode { "s" => hsv_to_rgb(x * 360.0, z, 1.0 - y), "v" => hsv_to_rgb(x * 360.0, 1.0 - y, z), @@ -76,9 +118,14 @@ pub fn field_color(mode: &str, z: f32, x: f32, y: f32) -> [f32; 3] { } /// Field position and slider value of a colour in `mode` (inverse of [`field_color`]). -pub fn locate(mode: &str, hsv: [f32; 3], rgb: [f32; 3]) -> (f32, f32, f32) { +pub fn locate(mode: &str, hsv: [f32; 3], rgb: [f32; 3], lab: [f32; 3]) -> (f32, f32, f32) { let h = hsv[0] / 360.0; + let ab = |v: f32| ((v + 128.0) / 255.0).clamp(0.0, 1.0); + let l = (lab[0] / 100.0).clamp(0.0, 1.0); match mode { + "lab_l" => (ab(lab[1]), 1.0 - ab(lab[2]), l), + "lab_a" => (ab(lab[2]), 1.0 - l, ab(lab[1])), + "lab_b" => (ab(lab[1]), 1.0 - l, ab(lab[2])), "s" => (h, 1.0 - hsv[2], hsv[1]), "v" => (h, 1.0 - hsv[1], hsv[2]), "r" => (rgb[2], 1.0 - rgb[1], rgb[0]), @@ -89,7 +136,10 @@ pub fn locate(mode: &str, hsv: [f32; 3], rgb: [f32; 3]) -> (f32, f32, f32) { } /// Colour for slider position `z` (0..1) in `mode`, other components from the current colour. -fn slider_color(mode: &str, z: f32, hsv: [f32; 3], rgb: [f32; 3]) -> [f32; 3] { +fn slider_color(mode: &str, z: f32, hsv: [f32; 3], rgb: [f32; 3], lab: [f32; 3]) -> [f32; 3] { + if let Some(l) = slider_lab(mode, z, lab) { + return lab_rgb(l); + } match mode { "s" => hsv_to_rgb(hsv[0], z, hsv[2]), "v" => hsv_to_rgb(hsv[0], hsv[1], z), @@ -100,6 +150,17 @@ fn slider_color(mode: &str, z: f32, hsv: [f32; 3], rgb: [f32; 3]) -> [f32; 3] { } } +/// The Lab colour with the slider of a Lab `mode` at `z`, the other components kept. +fn slider_lab(mode: &str, z: f32, lab: [f32; 3]) -> Option<[f32; 3]> { + let ab = z * 255.0 - 128.0; + match mode { + "lab_l" => Some([z * 100.0, lab[1], lab[2]]), + "lab_a" => Some([lab[0], ab, lab[2]]), + "lab_b" => Some([lab[0], lab[1], ab]), + _ => None, + } +} + fn c32(c: [f32; 3]) -> Color32 { let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; Color32::from_rgb(q(c[0]), q(c[1]), q(c[2])) @@ -293,8 +354,8 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let mode = f.get("__mode").and_then(Value::as_str).unwrap_or("h").to_string(); let now = current(f); - let (rgb, hsv) = (now.rgb, now.hsv); - let (fx, fy, fz) = locate(&mode, hsv, rgb); + let (rgb, hsv, lab) = (now.rgb, now.hsv, now.lab); + let (fx, fy, fz) = locate(&mode, hsv, rgb, lab); // Colour field. let (field, resp) = ui.allocate_exact_size(vec2(280.0, 280.0), Sense::click_and_drag()); ui.painter().add(grid_mesh(field, 32, |x, y| field_color(&mode, fz, x, y))); @@ -308,13 +369,13 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { "h" => Keep::Hsv([fz * 360.0, x, 1.0 - y]), "s" => Keep::Hsv([x * 360.0, fz, 1.0 - y]), "v" => Keep::Hsv([x * 360.0, 1.0 - y, fz]), - _ => Keep::Nothing, + m => field_lab(m, fz, x, y).map_or(Keep::Nothing, Keep::Lab), }; set_rgb(f, c, keep); } // Component slider (hue runs 360° at the top to 0° at the bottom, like Photoshop). let (strip, sresp) = ui.allocate_exact_size(vec2(20.0, 280.0), Sense::click_and_drag()); - ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb))); + ui.painter().add(grid_mesh(strip, 32, |_, y| slider_color(&mode, 1.0 - y, hsv, rgb, lab))); let sy = strip.top() + (1.0 - fz) * strip.height(); for (x, dir) in [(strip.left() - 1.0, 1.0f32), (strip.right() + 1.0, -1.0)] { let tri = vec![pos2(x, sy), pos2(x - 6.0 * dir, sy - 4.0), pos2(x - 6.0 * dir, sy + 4.0)]; @@ -328,9 +389,10 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { "v" => Some([hsv[0], hsv[1], z]), _ => None, }; - match h { - Some(h) => set_rgb(f, hsv_to_rgb(h[0], h[1], h[2]), Keep::Hsv(h)), - None => set_rgb(f, slider_color(&mode, z, hsv, rgb), Keep::Nothing), + match (h, slider_lab(&mode, z, lab)) { + (Some(h), _) => set_rgb(f, hsv_to_rgb(h[0], h[1], h[2]), Keep::Hsv(h)), + (None, Some(l)) => set_rgb(f, lab_rgb(l), Keep::Lab(l)), + (None, None) => set_rgb(f, slider_color(&mode, z, hsv, rgb, lab), Keep::Nothing), } } } @@ -338,22 +400,84 @@ fn color_field(ui: &mut egui::Ui, f: &mut Map) { fn swatches(ui: &mut egui::Ui, f: &mut Map) { let t = Tokens::get(ui.ctx()); let rgb = current(f).rgb; - ui.allocate_ui_with_layout(vec2(64.0, 104.0), egui::Layout::top_down(egui::Align::Center), |ui| { - // new / current swatches. - ui.label(egui::RichText::new(tl!("new")).size(11.0).color(t.text_dim)); - let (sw, _) = ui.allocate_exact_size(vec2(64.0, 72.0), Sense::hover()); - let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or(rgb); - ui.painter().rect_filled(Rect::from_min_size(sw.min, vec2(64.0, 36.0)), 0.0, c32(rgb)); - let cur = Rect::from_min_size(sw.min + vec2(0.0, 36.0), vec2(64.0, 36.0)); - ui.painter().rect_filled(cur, 0.0, c32(orig)); - ui.painter().rect_stroke(sw, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); - let click_cur = ui.interact(cur, ui.id().with("cp-current"), Sense::click()); - click_cur.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Click to restore the current colour"))); - if click_cur.on_hover_text(tl!("Click to restore the current colour")).clicked() { - set_rgb(f, orig, Keep::Nothing); + let orig = f.get("__orig").and_then(Value::as_str).and_then(parse_hex).unwrap_or(rgb); + let (area, _) = ui.allocate_exact_size(vec2(64.0 + 26.0, 104.0), Sense::hover()); + // new / current swatches, labelled above and below. + let sw = Rect::from_min_size(area.min + vec2(0.0, 16.0), vec2(64.0, 72.0)); + let p = ui.painter(); + let label = |text: &str, at: egui::Pos2, align: egui::Align2| { + p.text(at, align, text, egui::FontId::proportional(11.0), t.text_dim); + }; + label(tl!("new"), pos2(sw.center().x, sw.top() - 3.0), egui::Align2::CENTER_BOTTOM); + label(tl!("current"), pos2(sw.center().x, sw.bottom() + 3.0), egui::Align2::CENTER_TOP); + p.rect_filled(Rect::from_min_size(sw.min, vec2(64.0, 36.0)), 0.0, c32(rgb)); + let cur = Rect::from_min_size(sw.min + vec2(0.0, 36.0), vec2(64.0, 36.0)); + p.rect_filled(cur, 0.0, c32(orig)); + p.rect_stroke(sw, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + let click_cur = ui.interact(cur, ui.id().with("cp-current"), Sense::click()); + click_cur.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Click to restore the current colour"))); + if click_cur.on_hover_text(tl!("Click to restore the current colour")).clicked() { + set_rgb(f, orig, Keep::Nothing); + } + // Photoshop's warnings beside the swatch, each over a chip of the nearest colour that passes + // (a click takes it): out of gamut for printing on top, not a web colour below. + let x = sw.right() + 8.0; + let gamut = tl!("Out of gamut for printing: click for the nearest printable colour"); + let web = tl!("Not a web colour: click for the nearest web colour"); + for (slot, warning, tip) in [(0.0, out_of_gamut(rgb), gamut), (34.0, not_web_safe(rgb), web)] { + let Some(near) = warning else { continue }; + let icon = Rect::from_min_size(pos2(x, sw.top() + 2.0 + slot), vec2(13.0, 13.0)); + let chip = Rect::from_min_size(pos2(x + 1.5, icon.bottom() + 3.0), vec2(10.0, 10.0)); + let p = ui.painter(); + if slot == 0.0 { + warning_triangle(p, icon, t.text, t.chrome); + } else { + web_cube(p, icon, t.text); } - ui.label(egui::RichText::new(tl!("current")).size(11.0).color(t.text_dim)); - }); + p.rect_filled(chip, 0.0, c32(near)); + p.rect_stroke(chip, 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Outside); + let resp = ui.interact(icon.union(chip), ui.id().with(("cp-warning", slot as i32)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tip)); + if resp.on_hover_text(tip).clicked() { + set_rgb(f, near, Keep::Nothing); + } + } +} + +/// ⚠: a filled triangle with an exclamation mark cut out of it. +fn warning_triangle(p: &egui::Painter, r: Rect, ink: Color32, cut: Color32) { + let tri = vec![pos2(r.center().x, r.top()), pos2(r.right(), r.bottom()), pos2(r.left(), r.bottom())]; + p.add(egui::Shape::convex_polygon(tri, ink, Stroke::NONE)); + let x = r.center().x; + p.line_segment([pos2(x, r.top() + 4.5), pos2(x, r.bottom() - 4.0)], Stroke::new(1.5, cut)); + p.circle_filled(pos2(x, r.bottom() - 2.0), 0.9, cut); +} + +/// A cube seen corner-on: a hexagon with three edges meeting in the middle. +fn web_cube(p: &egui::Painter, r: Rect, ink: Color32) { + let (c, rad) = (r.center(), r.width() / 2.0); + let corner = |k: f32| { + let a = std::f32::consts::FRAC_PI_3 * k - std::f32::consts::FRAC_PI_2; + c + vec2(rad * a.cos(), rad * a.sin()) + }; + let hex: Vec<_> = (0..6).map(|k| corner(k as f32)).collect(); + let stroke = Stroke::new(1.2, ink); + p.add(egui::Shape::closed_line(hex, stroke)); + for k in [1.0, 3.0, 5.0] { + p.line_segment([c, corner(k)], stroke); + } +} + +/// A component radio: true when clicked. +fn radio(ui: &mut egui::Ui, on: bool, label: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); + ui.painter().circle_stroke(r.center(), 5.5, Stroke::new(1.2, if on { t.accent } else { t.text_faint })); + if on { + ui.painter().circle_filled(r.center(), 3.0, t.accent); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, ui.is_enabled(), on, label)); + resp.clicked() } /// Component radios and numeric fields: HSB, RGB, Lab, CMYK and hex. @@ -372,12 +496,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo for i in 0..6 { let (key, label) = MODES[i]; let on = mode == key; - let (r, resp) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::click()); - ui.painter().circle_stroke(r.center(), 5.5, Stroke::new(1.2, if on { t.accent } else { t.text_faint })); - if on { - ui.painter().circle_filled(r.center(), 3.0, t.accent); - } - if resp.clicked() { + if radio(ui, on, label) { new_mode = Some(key); } ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim)); @@ -422,9 +541,12 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo } }); }); - egui::Grid::new("cp-lab-cmyk").min_row_height(0.0).num_columns(3).spacing([6.0, 4.0]).show(ui, |ui| { - for (i, (label, v, range)) in [("L:", lab[0], 0.0..=100.0), ("a:", lab[1], -128.0..=127.0), ("b:", lab[2], -128.0..=127.0)].into_iter().enumerate() - { + egui::Grid::new("cp-lab-cmyk").min_row_height(0.0).num_columns(4).spacing([6.0, 4.0]).show(ui, |ui| { + for (i, (v, range)) in [(lab[0], 0.0..=100.0), (lab[1], -128.0..=127.0), (lab[2], -128.0..=127.0)].into_iter().enumerate() { + let (key, label) = LAB_MODES[i]; + if radio(ui, mode == key, label) { + new_mode = Some(key); + } ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim)); let mut x = v.round(); if widgets::value_field(ui, &mut x, range, "", 54.0).changed() { @@ -438,6 +560,7 @@ fn fields(ui: &mut egui::Ui, f: &mut Map, mode: &str, now: &Compo ui.allocate_exact_size(vec2(0.0, 6.0), Sense::hover()); ui.end_row(); for (i, label) in ["C:", "M:", "Y:", "K:"].into_iter().enumerate() { + ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); ui.label(egui::RichText::new(tl!(&label)).color(t.text_dim)); let mut x = (cmyk[i] * 100.0).round(); if widgets::value_field(ui, &mut x, 0.0..=100.0, "", 54.0).changed() { @@ -659,14 +782,46 @@ mod tests { #[test] fn field_and_locate_are_inverse_in_every_mode() { let rgb = [0.8, 0.3, 0.2]; - let hsv = rgb_to_hsv(rgb); - for (m, _) in MODES { - let (x, y, z) = locate(m, hsv, rgb); + let (hsv, lab) = (rgb_to_hsv(rgb), photocraft_color::convert::srgb_to_lab(rgb)); + for (m, _) in MODES.iter().chain(LAB_MODES) { + let (x, y, z) = locate(m, hsv, rgb, lab); let c = field_color(m, z, x, y); assert_eq!(hex(c), hex(rgb), "mode {m}"); } } + #[test] + fn lab_fields_are_laid_out_like_photoshops() { + // Photoshop 25.4. L: a across (green to red), b upwards (blue to yellow). + let l = |x, y| photocraft_color::convert::srgb_to_lab(field_color("lab_l", 0.6, x, y)); + assert!(l(0.0, 0.5)[1] < -40.0 && l(1.0, 0.5)[1] > 40.0, "{:?} {:?}", l(0.0, 0.5), l(1.0, 0.5)); + assert!(l(0.5, 1.0)[2] < -40.0 && l(0.5, 0.0)[2] > 40.0, "{:?} {:?}", l(0.5, 1.0), l(0.5, 0.0)); + // a: b across, L upwards (white at the top, black at the bottom). + assert!(field_color("lab_a", 0.5, 0.5, 0.0).iter().all(|&c| c > 0.99), "white at the top"); + assert!(field_color("lab_a", 0.5, 0.5, 1.0).iter().all(|&c| c < 0.01), "black at the bottom"); + let a = |x| photocraft_color::convert::srgb_to_lab(field_color("lab_a", 0.5, x, 0.5)); + assert!(a(0.0)[2] < -40.0 && a(1.0)[2] > 40.0); + // b: a across, L upwards; its slider runs from yellow (top) to blue. + let b = |x| photocraft_color::convert::srgb_to_lab(field_color("lab_b", 0.5, x, 0.5)); + assert!(b(0.0)[1] < -40.0 && b(1.0)[1] > 40.0); + let lab = [60.0, 0.0, 0.0]; + let top = photocraft_color::convert::srgb_to_lab(slider_color("lab_b", 1.0, [0.0; 3], [0.0; 3], lab)); + let bottom = photocraft_color::convert::srgb_to_lab(slider_color("lab_b", 0.0, [0.0; 3], [0.0; 3], lab)); + assert!(top[2] > 40.0 && bottom[2] < -40.0, "{top:?} {bottom:?}"); + } + + #[test] + fn gamut_and_web_warnings() { + // Photoshop 25.4: #714343 is printable but not a web colour; #fd0303 is neither. + let rgb = |h| parse_hex(h).unwrap(); + assert_eq!(out_of_gamut(rgb("#714343")), None); + assert!(out_of_gamut(rgb("#fd0303")).is_some()); + assert_eq!(out_of_gamut(rgb("#000000")), None, "black prints"); + assert_eq!(out_of_gamut(rgb("#ffffff")), None); + assert_eq!(not_web_safe(rgb("#714343")).map(hex).as_deref(), Some("#663333")); + assert_eq!(not_web_safe(rgb("#cc3366")), None); + } + #[test] fn hue_survives_grey_and_ok_sets_the_colour() { let mut f = Map::new(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 18d44c5d..faa3a997 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -242,6 +242,11 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) } "engine.execute" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; + // A control-channel menu click must respect the same modal gate as the native menu. + // engine.execute is deliberately not subject to the UI's menu-click semantics. + if req.method == "ui.menu.invoke" && !crate::menus::modal_allows(app, id) { + return err("menu command is unavailable while a modal dialog is open"); + } let params = p.get("params").cloned().unwrap_or(json!({})); if let Some(authorize) = app.services.automation_command.as_ref() && let Err(error) = authorize(id, ¶ms) @@ -791,6 +796,10 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value { "textEdit": app.ui.text_edit, "typeTransform": app.ui.type_transform, "layerMenu": app.ui.layer_menu, + "brushPicker": app.ui.brush_picker.map(|pos| json!({ + "pos": pos, + "list": app.ui.brush_picker_list, + })), "canvasToolMenu": app.ui.canvas_tool_menu.as_ref().map(|menu| { json!({ "pos": menu.pos, @@ -984,6 +993,38 @@ mod tests { assert_eq!(app.session.active().unwrap().revision, rev, "opening a dialog must not edit the document"); } + #[test] + fn menu_invoke_cannot_edit_document_behind_an_open_dialog() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + + let opened = call(&mut app, &ctx, "ui.menu.invoke", json!({"id": "image.imageSize"})); + assert_eq!(opened["ok"], true, "{opened}"); + let dialog = opened["result"]["dialog"].as_u64().unwrap(); + let revision = app.session.active().unwrap().revision; + let before = call(&mut app, &ctx, "ui.inspect", json!({})); + let fields = before["result"]["dialogs"][0]["fields"].clone(); + + for params in [json!({"id": "image.imageRotation.90cw"}), json!({"id": "image.imageRotation.90cw", "params": {}})] { + let blocked = call(&mut app, &ctx, "ui.menu.invoke", params); + assert_eq!(blocked["ok"], false, "{blocked}"); + assert_eq!(app.session.active().unwrap().revision, revision); + let snapshot = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(snapshot["result"]["dialogs"][0]["id"], dialog); + assert_eq!(snapshot["result"]["dialogs"][0]["fields"], fields); + } + + // View navigation stays permitted by the same policy as native menus and shortcuts. + assert!(crate::menus::modal_allows(&app, "view.zoomIn")); + assert!(!crate::menus::modal_allows(&app, "image.imageRotation.90cw")); + + assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog": dialog}))["ok"], true); + let rotated = call(&mut app, &ctx, "ui.menu.invoke", json!({"id": "image.imageRotation.90cw"})); + assert_eq!(rotated["ok"], true, "{rotated}"); + assert!(app.session.active().unwrap().revision > revision); + } + #[test] fn ui_set_rejects_unknown_fields_before_changing_anything() { use crate::theme::ThemeKind; @@ -1067,6 +1108,40 @@ mod tests { assert_eq!(app.ui.brush_picker, None); } + #[test] + fn ui_inspect_tracks_brush_picker_open_and_closed() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 128, "height": 128})).unwrap(); + + let closed = call(&mut app, &ctx, "ui.inspect", json!({})); + assert!(closed["result"]["brushPicker"].is_null()); + + // A right-click on the canvas with Brush opens the same picker as the options bar. + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tool": "brush"}))["ok"], true); + let click = call( + &mut app, + &ctx, + "ui.pointer", + json!({ + "button": "right", + "events": [{"kind": "down", "x": 64, "y": 64}, {"kind": "up", "x": 64, "y": 64}] + }), + ); + assert_eq!(click["ok"], true, "{click}"); + let open = call(&mut app, &ctx, "ui.inspect", json!({})); + assert!(open["result"]["brushPicker"]["pos"].is_array(), "{open}"); + assert_eq!(open["result"]["brushPicker"]["list"]["view"], "grid"); + + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPickerView": "list"}))["ok"], true); + let changed = call(&mut app, &ctx, "ui.inspect", json!({})); + assert_eq!(changed["result"]["brushPicker"]["list"]["view"], "list"); + + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"brushPicker": null}))["ok"], true); + let closed_again = call(&mut app, &ctx, "ui.inspect", json!({})); + assert!(closed_again["result"]["brushPicker"].is_null()); + } + #[test] fn ui_set_applies_all_fields_or_none() { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index d20bae74..eddf2341 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -144,16 +144,13 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { let ctx = ctx.clone(); app.after_file_dialog(move |app, saved| { let saved = saved.map_err(couldnt_save)?; - // Still the prompt that asked (quitting may have replaced it meanwhile). - if app.discard.as_ref().and_then(|p| p.docs.first()) == Some(&doc) { - advance(app, &ctx); - } + saved_document(app, &ctx, doc); Ok(saved) }); return false; } match saved { - Ok(_) => true, + Ok(_) => app.tiff_options.is_none(), // Backing out of the file dialog is the user's choice, not an error. Err(e) if e == crate::file_dialog::CANCELLED => false, Err(e) => { @@ -164,14 +161,26 @@ fn save(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) -> bool { } } +/// A completed save may release the close prompt. Choosing a path only starts a layered TIFF +/// save; TIFF Options calls this after the write. Copies and failed writes leave the doc dirty. +pub(crate) fn saved_document(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId) { + // Still the prompt that asked (quitting may have replaced it meanwhile). + if app.tiff_options.is_none() + && app.discard.as_ref().and_then(|p| p.docs.first()) == Some(&doc) + && index_of(app, doc).is_some_and(|i| !app.session.documents()[i].is_dirty()) + { + advance(app, ctx); + } +} + /// A save failure as reported ("cancelled" stays as it is: it isn't reported). fn couldnt_save(e: String) -> String { if e == crate::file_dialog::CANCELLED { e } else { format!("Couldn't save: {e}") } } pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { - // Hidden while Save's file dialog is up; it's back if that is cancelled. - if app.file_dialog_open() { + // Save can ask for a path and then TIFF Options. Wait for both; cancellation brings us back. + if app.file_dialog_open() || app.tiff_options.is_some() { return; } let Some(p) = &app.discard else { return }; @@ -525,6 +534,9 @@ mod tests { assert!(h.state().session.documents().is_empty()); } + #[path = "tiff_tests.rs"] + mod tiff_tests; + /// One frame with the window's close button pressed; the commands the guard sent. fn window_close_commands(app: &mut PhotocraftApp) -> Vec { let mut info = egui::ViewportInfo::default(); diff --git a/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs new file mode 100644 index 00000000..78dc0631 --- /dev/null +++ b/crates/ui-egui/src/discard_ui/tests/tiff_tests.rs @@ -0,0 +1,155 @@ +//! The two-modal save/close workflow, including the writer's success/failure boundary. +use super::*; +use crate::{FileDialogAnswer, tiff_options_ui}; +use egui_kittest::kittest::Queryable; +use std::{cell::RefCell, rc::Rc}; + +type Writes = Rc)>>>; + +fn tiff_prompt(command: &str) -> (Prompted, Writes, DocId, DocId) { + let mut app = app_with_docs(2); + make_dirty(&mut app, 0); + let (saved, other) = (doc_id(&app, 0), doc_id(&app, 1)); + app.session.active_mut().unwrap().path = Some("original.tif".into()); + let writes: Writes = Rc::default(); + let written = writes.clone(); + app.services.file_dialog = Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("kept.tif".into()))]).0); + app.services.export = Some(Box::new(move |doc, _, settings| Ok((serde_json::to_vec(&(doc.id, settings.tiff_layers)).unwrap(), Vec::new())))); + app.services.write = Some(Box::new(move |path, bytes| { + written.borrow_mut().push((path.to_string(), bytes.to_vec())); + Ok(()) + })); + let builder = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 600.0)); + let builder = if std::env::var_os("PHOTOCRAFT_TIFF_SNAPSHOTS").is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app| { + show(app, ui.ctx()); + tiff_options_ui::show(app, ui.ctx()); + app.poll_file_dialog(ui.ctx(), None); + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.ctx.set_os(egui::os::OperatingSystem::Windows); + assert!(intercept(h.state_mut(), command, &Value::Null)); + h.run_steps(2); + (h, writes, saved, other) +} + +fn capture(h: &mut Prompted, name: &str) { + if let Some(dir) = std::env::var_os("PHOTOCRAFT_TIFF_SNAPSHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +#[test] +fn closing_after_tiff_save_waits_for_options_and_writes_the_requested_document() { + for command in ["file.close", "file.closeAll", EXIT] { + let (mut h, writes, saved, other) = tiff_prompt(command); + capture(&mut h, "01-unsaved"); + h.key_press(Key::Y); + h.run_steps(3); + capture(&mut h, "02-tiff-options"); + assert!(h.state().tiff_options.is_some()); + assert_eq!(h.state().session.documents().len(), 2, "nothing closes before TIFF Options saves"); + assert_eq!(docs_left(&h), Some(1)); + assert!(!h.state().allow_close); + assert!(writes.borrow().is_empty()); + assert!(h.query_by_label("(Y)es").is_none(), "the close prompt waits behind TIFF Options"); + // Another window or a control request can change the active tab while the modal is up. + h.state_mut().refocus(other).unwrap(); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow().as_slice(), &[("kept.tif".into(), serde_json::to_vec(&(saved, true)).unwrap())]); + assert!(h.state().discard.is_none()); + assert!(h.state().tiff_options.is_none()); + match command { + EXIT => assert!(h.state().allow_close), + "file.closeAll" => assert!(h.state().session.documents().is_empty()), + _ => { + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(doc_id(h.state(), 0), other); + } + } + } +} + +#[test] +fn cancelling_failing_or_copying_a_tiff_keeps_the_original_unsaved() { + for outcome in ["cancel", "export error", "write error", "copy"] { + let (mut h, writes, saved, _) = tiff_prompt("file.close"); + h.key_press(Key::Y); + h.run_steps(3); + match outcome { + "export error" => h.state_mut().services.export = Some(Box::new(|_, _, _| Err("cannot encode".into()))), + "write error" => h.state_mut().services.write = Some(Box::new(|_, _| Err("disk full".into()))), + "copy" => { + h.get_by_label("Discard Layers and Save a Copy").click(); + h.run_steps(2); + } + _ => {} + } + h.key_press(if outcome == "cancel" { Key::Escape } else { Key::Enter }); + h.run_steps(3); + if outcome == "cancel" { + capture(&mut h, "03-cancelled"); + } + assert!(h.state().tiff_options.is_none()); + assert_eq!(h.state().session.documents().len(), 2, "{outcome}"); + assert_eq!(docs_left(&h), Some(1), "{outcome}"); + assert!(h.query_by_label("(Y)es").is_some()); + let original = &h.state().session.documents()[0]; + assert!(original.is_dirty()); + assert_eq!(original.path.as_deref(), Some("original.tif")); + if outcome == "copy" { + assert_eq!(writes.borrow().as_slice(), &[("kept.tif".into(), serde_json::to_vec(&(saved, false)).unwrap())]); + } else { + assert!(writes.borrow().is_empty()); + } + if outcome.ends_with("error") { + assert!(h.state().ui.status_error); + } + } +} + +#[test] +fn tiff_options_cannot_be_replaced_or_retarget_a_closed_document() { + let (mut h, writes, _, other) = tiff_prompt("file.close"); + h.key_press(Key::Y); + h.run_steps(3); + assert!(h.state_mut().save_as(Some("replacement.tif".into())).is_err()); + assert_eq!(h.state().tiff_options.as_ref().unwrap().path, "kept.tif"); + // A direct engine command bypasses the UI close guard. Never fall through to the next tab. + h.state_mut().run("file.close", json!({"document": 0})).unwrap(); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(writes.borrow().is_empty()); + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(doc_id(h.state(), 0), other); + assert!(h.state().ui.status_error); +} + +#[test] +fn close_all_waits_for_each_layered_tiff_write() { + let (mut h, writes, first, second) = tiff_prompt("file.closeAll"); + make_dirty(h.state_mut(), 1); + // Rebuild the prompt after making the second document dirty. + h.state_mut().discard = None; + assert!(intercept(h.state_mut(), "file.closeAll", &Value::Null)); + h.state_mut().services.file_dialog = + Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("first.tif".into())), Some(FileDialogAnswer::SaveTo("second.tif".into()))]).0); + for (i, doc) in [first, second].into_iter().enumerate() { + h.key_press(Key::Y); + h.run_steps(3); + assert_eq!(h.state().session.documents().len(), 2); + assert_eq!(writes.borrow().len(), i); + assert_eq!(docs_left(&h), Some(2 - i)); + h.key_press(Key::Enter); + h.run_steps(3); + assert_eq!(writes.borrow()[i].1, serde_json::to_vec(&(doc, true)).unwrap()); + } + assert!(h.state().session.documents().is_empty()); + assert!(h.state().discard.is_none()); +} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index a26d1224..4384d450 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2261,3 +2261,7 @@ fontWeight Italic Kurzíva All Layers No Adjustments Všechny vrstvy bez korekcí Current & Below No Adjustments Aktuální a pod ní bez korekcí Show Sampling Ring Zobrazit vzorkovací kroužek + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Mimo gamut tisku: klepnutím zvolíte nejbližší tisknutelnou barvu + Not a web colour: click for the nearest web colour Není webová barva: klepnutím zvolíte nejbližší webovou barvu diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 44842a66..7577722f 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2300,3 +2300,7 @@ fontWeight Italic Kursiv All Layers No Adjustments Alle Ebenen ohne Einstellungen Current & Below No Adjustments Aktuelle und darunter ohne Einstellungen Show Sampling Ring Aufnahmering anzeigen + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Außerhalb des Druckfarbumfangs: Klicken für die nächstliegende druckbare Farbe + Not a web colour: click for the nearest web colour Keine Webfarbe: Klicken für die nächstliegende Webfarbe diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 1696f7c6..a6a4ce96 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3374,3 +3374,7 @@ fontWeight Thin Πολύ λεπτά All Layers No Adjustments Όλα τα επίπεδα χωρίς προσαρμογές Current & Below No Adjustments Τρέχον και υποκείμενα χωρίς προσαρμογές Show Sampling Ring Εμφάνιση δακτυλίου δειγματοληψίας + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Εκτός γκάμας εκτύπωσης: κάντε κλικ για το πλησιέστερο εκτυπώσιμο χρώμα + Not a web colour: click for the nearest web colour Δεν είναι χρώμα web: κάντε κλικ για το πλησιέστερο χρώμα web diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3a1642aa..8540a4e8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2330,3 +2330,7 @@ fontWeight Italic Cursiva All Layers No Adjustments Todas las capas sin ajustes Current & Below No Adjustments Actual e inferiores sin ajustes Show Sampling Ring Mostrar anillo de muestreo + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Fuera de la gama de impresión: haga clic para el color imprimible más cercano + Not a web colour: click for the nearest web colour No es un color web: haga clic para el color web más cercano diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a9ec1941..92fe9775 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2684,3 +2684,7 @@ fontWeight Italic Italique All Layers No Adjustments Tous les calques sans ajustements Current & Below No Adjustments Actif et inférieurs sans ajustements Show Sampling Ring Afficher l'anneau d'échantillonnage + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Hors de la gamme d’impression : cliquez pour la couleur imprimable la plus proche + Not a web colour: click for the nearest web colour Pas une couleur web : cliquez pour la couleur web la plus proche diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 1b7f3553..8aafa194 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2671,3 +2671,7 @@ fontWeight Italic Miring All Layers No Adjustments Semua Layer Tanpa Penyesuaian Current & Below No Adjustments Saat Ini & Di Bawahnya Tanpa Penyesuaian Show Sampling Ring Tampilkan Cincin Sampel + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Di luar gamut cetak: klik untuk warna cetak terdekat + Not a web colour: click for the nearest web colour Bukan warna web: klik untuk warna web terdekat diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index eda2943c..cecb412d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2331,3 +2331,7 @@ fontWeight Italic Corsivo All Layers No Adjustments Tutti i livelli senza regolazioni Current & Below No Adjustments Corrente e inferiori senza regolazioni Show Sampling Ring Mostra anello di campionamento + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Fuori gamma di stampa: fai clic per il colore stampabile più vicino + Not a web colour: click for the nearest web colour Non è un colore web: fai clic per il colore web più vicino diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5b9720e5..3db94def 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2244,3 +2244,1051 @@ fontWeight Italic 斜体 All Layers No Adjustments すべてのレイヤー(色調補正なし) Current & Below No Adjustments 現在のレイヤーとその下(色調補正なし) Show Sampling Ring サンプリングリングを表示 + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour 印刷の色域外:クリックすると最も近い印刷可能な色になります + Not a web colour: click for the nearest web colour Web カラーではありません:クリックすると最も近い Web カラーになります + +# Generated labels: filter/gallery parameter names, option choices and gallery names (camelCase keys shown as words) (added 2026-10, checked against the code) + Accented Edges 輪郭の強調 + Adjust Interval For Loops ループ用に間隔を調整 + Adjustment Properties 色調補正のプロパティ + Alpha アルファ + Ambience アンビエンス + Ambient 環境光 + Amplitude Max 振幅 (最大) + Amplitude Min 振幅 (最小) + Angled Strokes 斜めストローク + Anisotropic 異方性 + Around Center 中心の周囲 + Arrangement 配置 + Artistic 芸術風 + Background Level 背景レベル + Bamboo 竹 + Bas Relief 浅浮き彫り + Base Tree Type 基本の樹木タイプ + Berries ベリー + Best 最高 + Black Clip 黒のクリップ + Black Intensity 黒の強さ + Blade Curvature 葉の湾曲 + Blast ブラスト + Blocks ブロック + Blur Angle ぼかしの角度 + Border Thickness 枠の太さ + Branches Color 枝の色 + Branches Height 枝の高さ + Branches Thickness 枝の太さ + Brick レンガ + Bristle Detail 毛のディテール + Brush Detail ブラシのディテール + Brush Size ブラシサイズ + Brush Stroke ブラシストローク + Brush Strokes ブラシストローク + Brush Type ブラシの種類 + Burlap 麻布 + Candle Light キャンドルの光 + Cell Size セルサイズ + Center X 中心 X + Center Y 中心 Y + Chalk & Charcoal チョーク・木炭 + Chalk Area チョークの範囲 + Channel1 チャンネル 1 + Channel2 チャンネル 2 + Channel3 チャンネル 3 + Channel4 チャンネル 4 + Charcoal 木炭 + Charcoal Area 木炭の範囲 + Charcoal Thickness 木炭の太さ + Chrome クローム + Circle 円 + Cleanliness 清潔さ + Clear Amount クリアの量 + Clumped 密集 + Coarse Dots 粗いドット + Colored Pencil 色鉛筆 + Contrasty 高コントラスト + Conté Crayon コンテ + Crack Brightness ひびの明るさ + Crack Depth ひびの深さ + Crack Spacing ひびの間隔 + Craquelure クラクリュール + Create By 作成基準 + Cross 十字 + Crosshatch クロスハッチ + Custom Branch Color カスタムの枝の色 + Cutout 切り抜き + Daisies デイジー + Dark Intensity 暗部の強さ + Dark Rough 暗く粗い + Dark Strokes 暗いストローク + Darken Only 暗くのみ + Darkness 暗さ + Default Foreground and Background Colors 前景色と背景色を既定に戻す + Default Leaves 既定の葉 + Definition 鮮明度 + Depth Map 深度マップ + Depth Mode 深度モード + Diffuse Glow 拡散光 + Direction Balance 方向のバランス + Distortion ゆがみ + Distribution 分布 + Dot ドット + Double Line 二重線 + Draft ドラフト + Dry Brush ドライブラシ + Duplication 複製 + Edge Brightness エッジの明るさ + Edge Fidelity エッジの忠実度 + Edge Intensity エッジの強さ + Edge Only エッジのみ + Edge Simplicity エッジの単純さ + Edge Thickness エッジの太さ + Edge Width エッジの幅 + Elevation 仰角 + Eliminate 除去 + End X 終了 X + End Y 終了 Y + Enlarged 拡大 + Even Fields 偶数フィールド + Fiber Length 繊維の長さ + Film Grain フィルムグレイン + Fine 細かい + Fine Dots 細かいドット + Flame Bottom Alignment 炎の下端の揃え + Flame Lines 炎の線 + Flame Shape 炎の形 + Flame Style 炎のスタイル + Flame Type 炎の種類 + Flat フラット + Flat Shading フラットシェーディング + Flower Color 花の色 + Focal Distance ピント距離 + Focus フォーカス + Foreground Level 前景レベル + Frame フレーム + Fresco フレスコ + From Left 左から + From Right 右から + Frosted すりガラス + Fuzziness あいまいさ + Gamma ガンマ + Gaussian ガウス + Generators ジェネレーター + Glass ガラス + Gloss 光沢 + Glow Amount 光の量 + Glow Brightness 光の明るさ + Glow Size 光の大きさ + Glowing Edges 輪郭の発光 + Good 良い + Grain Type 粒子の種類 + Graininess 粒状感 + Grainy Dots 粒状ドット + Graphic Pen グラフィックペン + Grout Width 目地の幅 + Halftone Pattern ハーフトーンパターン + Hearts ハート + Heptagon 七角形 + Hexagon 六角形 + High 高 + Highlight ハイライト + Highlight Amount ハイライトの量 + Highlight Area ハイライトの範囲 + Highlight Radius ハイライトの半径 + Highlight Strength ハイライトの強さ + Highlight Tone ハイライトのトーン + Horizontal Only 水平のみ + Hotspot ホットスポット + Hsb HSB + Hsl HSL + Image Balance 画像のバランス + Infinite 無限 + Ink Outlines インクの輪郭線 + Input Mode 入力モード + Intensity 強さ + Interpolation 補間 + Interval 間隔 + Ivy アイビー + Jag ギザギザ + Kernel カーネル + Layer Properties レイヤープロパティ + Leaf Color 葉の色 + Leaves Amount 葉の量 + Leaves Color 葉の色 + Leaves Size 葉の大きさ + Left Diagonal 左斜め + Length 長さ + Lens レンズ + Level Based レベル基準 + Light Dark Balance 明暗のバランス + Light Direction 光の方向 + Light Intensity 光の強さ + Light Rough 明るく粗い + Light Type 光源の種類 + Light X 光源 X + Light Y 光源 Y + Light Z 光源 Z + Lighten Grout 目地を明るく + Lighten Only 明るくのみ + Lighting ライティング + List リスト + Long Lines 長い線 + Long Strokes 長いストローク + Low 低 + Lower 下 + Map Document マップのドキュメント + Map Path マップのパス + Margin 余白 + Mask Incomplete Blocks 不完全なブロックをマスク + Max Offset 最大オフセット + Max Radius 最大半径 + Medium Dots 中くらいのドット + Medium Lines 中くらいの線 + Medium Strokes 中くらいのストローク + Metallic メタリック + Method 方法 + Midtone 中間調 + Monochromatic モノクロ + Mosaic Tiles モザイクタイル + Move Layer レイヤーを移動 + Movie Prime ムービープライム + Multiple Flames Along Path パスに沿った複数の炎 + Multiple Flames One Direction 一方向の複数の炎 + Multiple Flames Path Directions パス方向の複数の炎 + Multiple Flames Various Length 長さの異なる複数の炎 + Neon Glow ネオン光彩 + Neutralize 中和 + New Layer 新規レイヤー + Note Paper ノート用紙 + Number Of Levels レベル数 + Ocean Ripple 海の波紋 + Octagon 八角形 + Odd Fields 奇数フィールド + One Flame Along Path パスに沿った 1 つの炎 + Out From Center 中心から外へ + Oval 楕円 + Overlay Edge エッジをオーバーレイ + Paint Daubs 絵の具のなすり付け + Palette Knife パレットナイフ + Paper Brightness 紙の明るさ + Parallel 平行 + Patchwork パッチワーク + Pattern Type パターンの種類 + Pencil Width 鉛筆の幅 + Pentagon 五角形 + Photocopy コピー + Plaster 石膏 + Plastic Wrap ラップ + Point ポイント + Pointed とがった + Polar To Rectangular 極座標から直交座標へ + Pond Ripples 池の波紋 + Poster Edges ポスターエッジ + Posterization ポスタリゼーション + Preserve 保持 + Prime105 プライム 105mm + Prime35 プライム 35mm + Pyramids ピラミッド + Radius X 半径 X + Radius Y 半径 Y + Random ランダム + Randomize Length 長さをランダム化 + Randomize Shapes 形をランダム化 + Rectangular Selection 矩形選択 + Rectangular To Polar 直交座標から極座標へ + Reduce Color Noise カラーノイズを軽減 + Reduce Noise ノイズを軽減 + Relief レリーフ + Remove Jpeg Artifact JPEG アーティファクトを除去 + Reorder Layer レイヤーを並べ替え + Repeat 繰り返し + Reticulation 粒状化 + Rgb RGB + Ridges 尾根 + Right Diagonal 右斜め + Ring リング + Ripple Magnitude 波紋の振幅 + Ripple Size 波紋のサイズ + Rope ロープ + Roses バラ + Rough Pastels 粗いパステル + Row Order 行の順序 + Sandstone 砂岩 + Scaling 拡大率 + Scallops スカラップ + Search menus メニューを検索 + Seed シード + Select Layer レイヤーを選択 + Set Colors カラーを設定 + Settings 設定 + Shadow シャドウ + Shadow Amount シャドウの量 + Shadow Intensity シャドウの強さ + Shadow Radius シャドウの半径 + Shadow Tone シャドウのトーン + Sharpen Details ディテールをシャープに + Sharpness シャープネス + Shine 輝き + Short Lines 短い線 + Short Strokes 短いストローク + Simple シンプル + Sine 正弦波 + Sketch スケッチ + Smoothness 滑らかさ + Smudge Stick スマッジスティック + Snowflakes 雪の結晶 + Softness 柔らかさ + Solid Front Faces 前面を塗りつぶし + Sparkle きらめき + Spatter 飛沫 + Speckle 斑点 + Speed 速度 + Spin 回転 + Sponge スポンジ + Spot スポット + Spray Radius スプレー半径 + Sprayed Strokes スプレーストローク + Sprinkles 散らし + Square Size 正方形のサイズ + Squareness 四角さ + Stagger 互い違い + Stained Glass ステンドグラス + Stamp スタンプ + Star 星 + Stars 星 + Start X 開始 X + Start Y 開始 Y + Stippled 点描 + Strength 強さ + Stretch 引き伸ばし + Stroke Detail ストロークのディテール + Stroke Direction ストロークの方向 + Stroke Length ストロークの長さ + Stroke Pressure ストロークの筆圧 + Stroke Size ストロークのサイズ + Stroke Width ストロークの幅 + Stylization スタイル化 + Sumi-e 墨絵 + Switch Foreground and Background Colors 前景色と背景色を入れ替え + Taper 先細り + Target ターゲット + Target X ターゲット X + Target Y ターゲット Y + Texture Coverage テクスチャの適用範囲 + Texturizer テクスチャ化 + Thickness 太さ + Tile Size タイルのサイズ + Tiny Lens 小さなレンズ + To Center 中心へ + Torn Edges 破れたエッジ + Transition 遷移 + Triangle 三角形 + Turbulent 乱流 + Unaltered 変更なし + Undefined Areas 未定義の領域 + Underpainting 下塗り + Uniform 均一 + Upper 上 + Use Custom Color カスタムカラーを使用 + Use Selection In Source ソースの選択範囲を使用 + Use Selection In Target ターゲットの選択範囲を使用 + Vertical Only 垂直のみ + Vine Color つるの色 + Vine With Flowers 花つきのつる + Vine With Leaves 葉つきのつる + Violent 激しい + Warmth 暖かさ + Water Paper ウォーターペーパー + Watercolor 水彩画 + Wavelength Max 波長 (最大) + Wavelength Min 波長 (最小) + Waves 波 + White Clip 白のクリップ + White Intensity 白の強さ + White Is High 白を高く + Wide Blurry 広くぼやけた + Wide Sharp 広くシャープ + Wind 風 + Wrap 折り返し + Zigzag ジグザグ + +# Command, tool and menu names found in the code but not yet in this catalog (added 2026-10, checked against the code) + 32-bit Preview Options 32 ビットプレビューオプション + Adaptive Wide Angle 広角補正 + Add Noise ノイズを加える + Adjustment 色調補正 + Advanced Blending 高度な合成 + Apply Layer Comp レイヤーカンプを適用 + Arc 円弧 + Arch アーチ + Area 面積 + Artboard from Group グループからアートボード + Artboard from Layers レイヤーからアートボード + Artist アーティスト + Assign Profile プロファイルを割り当て + Auto-Align Layers レイヤーを自動整列 + Auto-Blend Layers レイヤーを自動合成 + Bold Italic 太字斜体 + Box Blur ぼかし(ボックス) + Bulge 膨らみ + Burn 焼き込み + Camera Raw Filter Camera Raw フィルター + Canvas Size カンバスサイズ + Circularity 真円度 + Clipping Path クリッピングパス + Clone Stamp コピースタンプ + Color Decontaminate カラーの混入を除去 + Color Halftone カラーハーフトーン + Color Range 色域指定 + Color Replacement 色の置き換え + Color Table カラーテーブル + Content-Aware Fill コンテンツに応じた塗りつぶし + Content-Aware Move コンテンツに応じた移動 + Convert to Linked リンクに変換 + Convert to Profile プロファイルを変換 + Crystallize 水晶 + Date and Time 日付と時刻 + De-Interlace インターレース解除 + Deep Blue 濃い青 + Deep Emerald 濃いエメラルド + Deep Red 濃い赤 + Deep Yellow 濃い黄 + Defringe フリンジ削除 + Delete Path パスを削除 + Diffuse 拡散 + Displace 置き換え + Dodge 覆い焼き + Duplicate Layer レイヤーを複製 + Duplicate Layer Comp レイヤーカンプを複製 + Duplicate Layers レイヤーを複製 + Duplicate Path パスを複製 + Dust & Scratches ダスト&スクラッチ + EXIF EXIF + Export As 書き出し形式を指定 + Export/Import Presets プリセットの書き出し/読み込み + Extrude 押し出し + F F + Fibers 繊維 + Field Blur フィールドぼかし + File Info ファイル情報 + Fill Path パスを塗りつぶし + Filter Gallery フィルターギャラリー + Fish 魚形 + Flag 旗 + Focus Area 焦点領域 + Gaussian Blur ぼかし(ガウス) + Global Light グローバル光源 + Gray Value (Maximum) グレー値 (最大) + Gray Value (Mean) グレー値 (平均) + Gray Value (Median) グレー値 (中央値) + Gray Value (Minimum) グレー値 (最小) + H H + HDR Toning HDR トーン + Hide 非表示 + High Pass ハイパス + History Brush ヒストリーブラシ + Import Brushes ブラシを読み込み + Inflate 膨張 + Integrated Density 積分濃度 + Interpret Footage 素材を解釈 + Iris Blur 虹彩絞りぼかし + Justification 行揃え + Label ラベル + Layer Comp レイヤーカンプ + Layer From Background 背景からレイヤーへ + Lens Blur ぼかし(レンズ) + Lens Correction レンズ補正 + Lens Flare レンズフレア + Lighting Effects 照明効果 + Liquify ゆがみ + Lock Layers レイヤーをロック + Magnetic Lasso マグネット投げ縄 + Make 作成 + Make Selection 選択範囲を作成 + Make Work Path 作業用パスを作成 + Match Color カラーを一致 + Mezzotint メゾティント + Mosaic モザイク + Motion Blur ぼかし(移動) + New Group 新規グループ + New Guide 新規ガイド + New Guide Layout 新規ガイドレイアウト + New Layer Comp 新規レイヤーカンプ + New Layer… 新規レイヤー… + New Video Layer from File ファイルから新規ビデオレイヤー + Oil Paint 油彩画 + Package パッケージ化 + Patch パッチ + Path Blur パスぼかし + Pencil 鉛筆 + Pencil Tool 鉛筆ツール + Perimeter 周囲長 + Photomerge 写真を貼り合わせ + Pinch つまみ + Place Embedded 埋め込みで配置 + Place Linked リンクで配置 + Place Scale Marker スケールマーカーを配置 + Pointillize ポインティリスト + Polar Coordinates 極座標 + Preserve Transparency 透明部分を保持 + Preset Manager プリセットマネージャー + ProPhoto ProPhoto + Punctuation 句読点 + Quick Selection クイック選択 + RGB RGB + Radial Blur ぼかし(円形) + Reading 読み取り + Recovery リカバリー + Rectangle 長方形 + Relink to File ファイルに再リンク + Rename Brush ブラシの名前を変更 + Rename Layer Comp レイヤーカンプの名前を変更 + Replace Color 色を置き換え + Replace Contents コンテンツを置き換え + Replace Footage 素材を置き換え + Ripple 波紋 + Rise 上昇 + Ruler 定規 + Save for Web (Legacy) Web 用に保存(従来方式) + Scale Effects 効果を拡大・縮小 + Scale Factor 拡大・縮小の倍率 + Scale Units スケール単位 + Script Events Manager スクリプトイベント管理 + Select and Mask 選択とマスク + Sepia セピア + Set Measurement Scale 測定スケールを設定 + Shadows/Highlights シャドウ・ハイライト + Shape Blur ぼかし(シェイプ) + Shear シアー + Shell Lower シェル (下) + Shell Upper シェル (上) + Slice Options スライスオプション + Smart Blur ぼかし(詳細) + Smart Sharpen スマートシャープ + Software ソフトウェア + Spherize 球面 + Spin Blur 回転ぼかし + Squeeze 絞り込み + Stroke Path パスのストローク + Surface Blur ぼかし(表面) + Symbols 記号 + Tiles タイル + Tilt-Shift チルトシフト + Trace Contour 輪郭のトレース + Trap トラップ + Tree 樹木 + Trim トリミング + Twirl 渦巻き + Twist ねじり + Underwater 水中 + Unknown 不明 + Unsharp Mask アンシャープマスク + Update Layer Comp レイヤーカンプを更新 + Vanishing Point 消点 + Video Frames to Layers ビデオフレームをレイヤーへ + W W + Warp Text 文字ワープ + Wave 波形 + X X + Y Y + ZigZag ジグザグ + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + shortcut ショートカット + Add to 追加 + Alternative 代替 + Apply Data Set データセットを適用 + Apply Image 画像を適用 + Arbitrary 任意 + Artboards to Files アートボードをファイルへ + At 位置 + Author 作成者 + Auto Erase 自動消去 + Auto Scale 自動スケール + Auto-Hide 自動的に隠す + Auto-Leading 自動行送り + Auto-leading % 自動行送り % + Baseline Shift ベースラインシフト + Batch バッチ処理 + Bleach Bypass 銀残し + Blend 合成 + Blue Yellow 青と黄 + Border 境界 + Browse 参照 + Calculations 演算 + Camera Raw Camera Raw + Caption キャプション + Category カテゴリー + Channel チャンネル + Choose… 選択… + Clone クローン + Color Lookup Tables カラールックアップテーブル + Color Settings カラー設定 + Columns 列 + Compose 構成 + Conditional Mode Change 条件付きカラーモード変更 + Constraints 制約 + Contract 縮小 + Cool クール + Cooling L B B 寒色フィルター (LBB) + Cooling80 寒色フィルター (80) + Cooling82 寒色フィルター (82) + Copyright Status 著作権の状態 + Copyrighted 著作権あり + Corners 角 + Correct C A 色収差を補正 + Correct Distortion ゆがみを補正 + Correct Vignette 周辺光量を補正 + Cross Process クロスプロセス + Current 現在 + Curve Darks カーブ (暗部) + Curve Highlights カーブ (ハイライト) + Curve Lights カーブ (明部) + Curve Shadows カーブ (シャドウ) + Curve Splits カーブ (分割点) + Data データ + Data Sets as Files データセットをファイルへ + Day For Night デイ・フォー・ナイト + Define Brush Preset ブラシプリセットを定義 + Define Custom Shape カスタムシェイプを定義 + Define Pattern パターンを定義 + Digits 数字 + Duplicate 複製 + Duration 継続時間 + Edge Extension エッジの拡張 + Errors エラー + Export Contents コンテンツを書き出し + Export Preferences 書き出しの環境設定 + Extensions 拡張機能 + Faded Film 色あせたフィルム + Fast 高速 + Find and Replace Text テキストを検索して置換 + First Line Indent 先頭行の字下げ + Fit Image 画像を枠に合わせる + Flame 炎 + Generic 汎用 + Glyph 字形 + Grade Balance グレーディングのバランス + Grade Blending グレーディングの合成 + Grade Global グレーディング (全体) + Grade Highlights グレーディング (ハイライト) + Grade Midtones グレーディング (中間調) + Grade Shadows グレーディング (シャドウ) + Grain Amount 粒子の量 + Grain Roughness 粒子の粗さ + Grain Size 粒子のサイズ + Graphic and web グラフィックと Web + Gray: グレー: + Grid (⌘') グリッド (⌘') + Group from Layers レイヤーからグループ + Hand 手のひら + Healing 修復 + Hsl Hue HSL 色相 + Hsl Lum HSL 輝度 + Hsl Sat HSL 彩度 + Image Processor 画像プロセッサー + Import… 読み込み… + In イン + In Black 入力の黒 + In White 入力の白 + Justify 均等配置 + Keywords キーワード + Layer Comps to Files レイヤーカンプをファイルへ + Layer Content Options レイヤーコンテンツのオプション + Layer from Background 背景からレイヤーへ + Layers to Files レイヤーをファイルへ + Legacy 従来 + Libraries ライブラリ + Load Files into Stack ファイルをスタックに読み込み + Load Selection 選択範囲を読み込む + Location 場所 + Loop ループ + Lum 輝度 + Lut LUT + Mac OS Mac OS + Map Layer マップレイヤー + Mask マスク + Measure 測定 + Mono Contrast モノコントラスト + New document 新規ドキュメント + Noise Color ノイズ (カラー) + Noise Color Detail ノイズ (カラーのディテール) + Noise Luminance ノイズ (輝度) + Noise Luminance Detail ノイズ (輝度のディテール) + Open As 形式を指定して開く + Opening 開く + Operation 操作 + Out アウト + Out Black 出力の黒 + Out White 出力の白 + Path: パス: + Paths to Illustrator パスをベクター形式へ + Pen ペン + Picture Frame ピクチャフレーム + Pins ピン + Pixel ピクセル + Plane 平面 + Planes 平面 + Point Curve ポイントカーブ + Point Curve Blue ポイントカーブ (青) + Point Curve Green ポイントカーブ (緑) + Point Curve Red ポイントカーブ (赤) + Profile プロファイル + Public Domain パブリックドメイン + Raw Defaults RAW の既定値 + Red Cyan 赤とシアン + Rename Group グループ名を変更 + Render Video ビデオを書き出し + Resolve Missing Fonts 不足しているフォントを解決 + Result 結果 + Sampler サンプラー + Sat 彩度 + Save As 名前を付けて保存 + Save Selection 選択範囲を保存 + Save a Copy コピーを保存 + Settings: 設定: + Sharpen Amount シャープの量 + Sharpen Detail シャープのディテール + Sharpen Masking シャープのマスク + Sharpen Radius シャープの半径 + Solid Color べた塗り + Source Layer ソースレイヤー + Spacing: 間隔: + Spot Healing スポット修復 + Spot: スポット: + Statistics 統計情報 + Step Backward 1 つ戻る + Stop 停止 + Stops ストップ + Strokes ストローク + Teal Orange ティールとオレンジ + Tilt チルト + Time 時間 + Timing タイミング + Tint Color 色合い + Tool ツール + Units 単位 + Variable Data Sets 変数データセット + Vignette Amount 周辺光量の量 + Vignette Feather 周辺光量のぼかし + Vignette Highlights 周辺光量のハイライト + Vignette Midpoint 周辺光量の中心点 + Vignette Roundness 周辺光量の丸み + Vignette Style 周辺光量のスタイル + Wand 自動選択 + Warm ウォーム + Warming L B A 暖色フィルター (LBA) + Warming81 暖色フィルター (81) + Warming85 暖色フィルター (85) + Wetness 湿り気 + Working 作業用 + sRGB sRGB + {zoom}% {zoom}% + +# Remaining UI strings: hints, preferences, metadata fields, colour spaces, snapshots (added 2026-10, checked against the code) + 10 fps 10 fps + 15 fps 15 fps + 24 fps 24 fps + 25 fps 25 fps + 30 fps 30 fps + 3D 3D + 3D… 3D… + ACES ACES + AI Integrations AI 連携 + Accurate 高精度 + Actual Color 実際のカラー + Add Pins ピンを追加 + Add a mask (from the selection; ⌥ inverts) マスクを追加 (選択範囲から。⌥ で反転) + Add to selection (⇧) 選択範囲に追加 (⇧) + Adds grain to the soft edges of the brush tip. ブラシ先端の柔らかい縁に粒子を加えます。 + Adjustment Layer 色調補正レイヤー + Adobe Every-line Composer Adobe 全行コンポーザー + Adobe PDF Presets… Adobe PDF プリセット… + Adobe RGB (1998) Adobe RGB (1998) + Adobe Single-line Composer Adobe 1 行コンポーザー + Advanced Controls 詳細コントロール + After (X) 後 (X) + Airbrush: keeps painting while the pointer rests. エアブラシ: ポインターを止めている間も描き続けます。 + Alignment 整列 + Alternates for Current Selection 現在の選択範囲の代替字形 + Aperture 絞り + Apple RGB Apple RGB + Art History Brush アートヒストリーブラシ + Artboards to PDF アートボードを PDF へ + Ask Before Saving Saved Embedded 保存済みの埋め込みを保存する前に確認 + Ask When Pasting ペースト時に確認 + Aspect Ratio 縦横比 + Audio オーディオ + Auto Clean Brush ブラシを自動的にクリーニング + Automatically Create First Snapshot 最初のスナップショットを自動的に作成 + Automatically Create New Snapshot When Saving 保存時に新規スナップショットを自動的に作成 + Avoid Widows/Orphans ウィドウ/オーファンを回避 + Background from Layer レイヤーから背景 + Blend RGB Colors Using Gamma 1.0 ガンマ 1.0 で RGB カラーを合成 + Brush Tip ブラシ先端 + Byline バイライン + Byline Title バイラインの肩書き + CIE RGB CIE RGB + CMYK: CMYK: + Camera Calibration カメラキャリブレーション + Camera Data カメラデータ + Camera Make カメラのメーカー + Camera Model カメラのモデル + Canvas rotation カンバスの回転 + Caption Credits キャプションのクレジット + Capture Comp カンプを取り込み + City 市区町村 + Clear History ヒストリーを消去 + Click the first point or press ↵ to close · Esc cancels 最初のポイントをクリックするか ↵ で閉じる · Esc で取り消し + Click to add a point · drag off or ⌘/Ctrl-click or Delete to remove クリックでポイントを追加 · 外へドラッグ、⌘/Ctrl+クリック、または Delete で削除 + Click to apply, ⌥-click to clear overrides, double-click for Style Options クリックで適用、⌥+クリックで上書きを解除、ダブルクリックでスタイルオプション + Click to zoom in · ⌥-click to zoom out クリックで拡大 · ⌥+クリックで縮小 + Click: corner · Drag: smooth · Click first point: close · ↩ finish · Esc cancel クリック: 角 · ドラッグ: 滑らか · 最初のポイントをクリック: 閉じる · ↩ 完了 · Esc 取り消し + Coerce to sRGB sRGB に強制変換 + Collapse group (⌥-click: all groups) グループを折りたたむ (⌥+クリック: すべてのグループ) + Color Management Policies カラー管理ポリシー + Color Noise Reduction カラーノイズの軽減 + Color Profile カラープロファイル + Color indicates カラーの意味 + ColorMatch RGB ColorMatch RGB + ColorSync ColorSync + Commit Perspective Warp (↩) 遠近法ワープを確定 (↩) + Commit Puppet Warp (↩) パペットワープを確定 (↩) + Commit any current edits (⌘↩) 現在の編集をすべて確定 (⌘↩) + Commit current crop operation (↵) 現在の切り抜き操作を確定 (↵) + Commit transform (↩) 変形を確定 (↩) + Commit warp (↩) ワープを確定 (↩) + Compatibility 互換性 + Condensed コンデンス + Convert Frames > Video Layers フレームを変換 > ビデオレイヤー + Convert to Grayscale グレースケールに変換 + Copyright Info URL 著作権情報の URL + Country 国 + Create Date 作成日 + Create Multiple Picture Packages 複数のピクチャパッケージを作成 + Create Plane 平面を作成 + Create Snapshot スナップショットを作成 + Create a new layer (⇧⌘N) 新規レイヤーを作成 (⇧⌘N) + Create new count group 新規カウントグループを作成 + Create new layer 新規レイヤーを作成 + Create new set 新規セットを作成 + Credit クレジット + Current Time 現在の時間 + Custom Pixel Aspect Ratio ピクセル縦横比をカスタム + Custom pattern カスタムパターン + Date Created 作成日 + DateTime Original 撮影日時 + Defaults restored. 既定値を復元しました。 + Delete Layer… レイヤーを削除… + Delete Pins ピンを削除 + Delete Preset… プリセットを削除… + Delete Smart Filters スマートフィルターを削除 + Delete Snapshot スナップショットを削除 + Desaturate Monitor Colors By モニターのカラーの彩度を下げる量 + Detect Faces 顔を検出 + Direction of Stroke ストロークの方向 + Do you want to save you made to “{name}” before quitting? 終了する前に "{name}" への変更を保存しますか? + Document Size ドキュメントサイズ + Dodge/Burn 覆い焼き/焼き込み + Drag (lasso) or click points (polygonal) · {add} add · {sub} subtract ドラッグ (投げ縄) またはクリックでポイント指定 (多角形) · {add} 追加 · {sub} 除外 + Drag a crop box · drag inside to move · edges resize ({ratio} ratio, {centre} centre) · Space moves while drawing · {commit} commits · Esc cancels 切り抜きボックスをドラッグ · 内側をドラッグで移動 · 縁でサイズ変更 ({ratio} で比率、{centre} で中心) · 描画中は Space で移動 · {commit} で確定 · Esc で取り消し + Drag to move the layer ドラッグしてレイヤーを移動 + Duplicated selection name 複製した選択範囲の名前 + Dynamics ダイナミクス + EXIF Version EXIF バージョン + Edit Plane 平面を編集 + Edit Toolbar ツールバーを編集 + Email メール + Embed profiles プロファイルを埋め込む + Enable Smart Filters スマートフィルターを有効にする + Enable Timeline Quick Config タイムラインのクイック設定を有効にする + Enter description… 説明を入力… + Error File エラーファイル + Expand All Groups すべてのグループを展開 + Expand group (⌥-click: all groups) グループを展開 (⌥+クリック: すべてのグループ) + Exposure Program 露出プログラム + Extra Bold エクストラボールド + Extra Light エクストラライト + File Naming ファイル名の付け方 + Fill Light フィルライト + First Color Readout 最初のカラー読み取り + Flash フラッシュ + Flatten Frames Into Clips フレームをクリップに統合 + Flatten Frames Into Layers フレームをレイヤーに統合 + Focal Length35mm 35mm 換算焦点距離 + Frame Duration フレームの長さ + Frame Sync フレーム同期 + GPS GPS + General Blend 一般的な合成 + General Brushes 一般のブラシ + Glyph Scaling 字形のスケーリング + Go to Layer レイヤーへ移動 + Grayscale Slider グレースケールスライダー + HDTV (Rec. 709) HDTV (Rec. 709) + HSB Color HSB カラー + HSL/Grayscale HSL/グレースケール + Hanging Punctuation ぶら下がり句読点 + Heal 修復 + Heal Auto 修復 (自動) + Heal Color 修復 (カラー) + Heal Off 修復オフ + Heal On 修復オン + Hide the layer's effects (⌥-click: all layers) レイヤーの効果を非表示 (⌥+クリック: すべてのレイヤー) + Humidity 湿度 + ISO ISO + ISO Speed ISO 感度 + Include ICC Profile ICC プロファイルを含める + Industry Standard 業界標準 + Info Options… 情報オプション… + Initial Direction of Stroke ストロークの初期の方向 + Install Plug-in プラグインをインストール + Intersect with 共通部分 + Intersect with selection (⇧⌥) 選択範囲との共通部分 (⇧⌥) + Join the ArtCraft Discord ArtCraft の Discord に参加 + Keep proportions 縦横比を固定 + Keep view in sync ビューを同期 + Keeps the current texture when switching presets. プリセットを切り替えても現在のテクスチャを維持します。 + Last Modified 最終更新 + Lens Corrections レンズ補正 + Lens Info レンズ情報 + Letter Spacing 文字間隔 + Ligature 合字 + Light Source 光源 + Limit Playback 再生を制限 + Load 3D LUT 3D LUT を読み込み + Localize Color Clusters カラークラスターをローカライズ + Log Errors To File エラーをファイルに記録 + Luminance Smoothness 輝度の滑らかさ + Mac Crisp Mac クリスプ + Mac LCD Mac LCD + Mac Sharp Mac シャープ + Mac Smooth Mac スムーズ + Mac Strong Mac ストロング + Masked Areas マスクされた領域 + Merge to HDR Pro HDR 合成(Pro) + Metering Mode 測光モード + Minimalistic ミニマル + Missing Profiles: Ask When Opening プロファイルなし: 開くときに確認 + Modify Date 更新日 + Mouse Controls マウス操作 + Mouse Coordinates マウス座標 + NTSC (1953) NTSC (1953) + Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or run PhotoCraft under XWayland with `WAYLAND_DISPLAY= photocraft`. Wayland ではネイティブのファイルのドラッグ&ドロップにまだ対応していません。ファイル › 開く を使うか、XWayland 上で `WAYLAND_DISPLAY= photocraft` として PhotoCraft を実行してください。 + New Brush 新規ブラシ + New Channel 新規チャンネル + New Layer from Brush ブラシから新規レイヤー + New Path 新規パス + New Selection 新規選択範囲 + New Snapshot 新規スナップショット + New Tool Preset 新規ツールプリセット + New Video Group 新規ビデオグループ + New Video Layer 新規ビデオレイヤー + New channel only 新規チャンネルのみ + New document… ⌘N 新規ドキュメント… ⌘N + Newspaper 新聞 + No Layer Comps レイヤーカンプなし + Nonlinear History 非線形ヒストリー + Note tool 注釈ツール + Object Name / Title オブジェクト名 / タイトル + Opacity Mask 不透明マスク + Open… ⌘O 開く… ⌘O + Override Action "Save" Commands アクションの "保存" コマンドを上書き + PAL/SECAM PAL/SECAM + PDF Presentation… PDF プレゼンテーション… + Panel Options… パネルオプション… + Path to Illustrator… パスを Illustrator へ… + Perspective Crop 遠近法の切り抜き + Phone 電話 + Photo (2 × 3 in) 写真 (2 × 3 in) + Photo (4 × 6 in) 写真 (4 × 6 in) + Photo (5 × 7 in) 写真 (5 × 7 in) + Photo (8 × 10 in) 写真 (8 × 10 in) + Picture Package… ピクチャパッケージ… + Play Once 1 回再生 + Preset Name: プリセット名: + Press keys キーを押してください + ProPhoto RGB ProPhoto RGB + Profile Mismatches: Ask When Opening プロファイルの不一致: 開くときに確認 + Province / State 都道府県 / 州 + Quick Sync Quick Sync + RGB: RGB: + Raw Data Raw データ + Recent Items 最近使用した項目 + Reference point location 基準点の位置 + Rename Tool Preset ツールプリセット名を変更 + Report an Issue 問題を報告 + Restore last viewed comp 最後に表示したカンプを復元 + Roman ローマン + Roman Hanging Punctuation 欧文ぶら下がり句読点 + Rotate Auto 回転 (自動) + Rotate Fixed 回転 (固定) + Ruler Units 定規の単位 + Rulers (⌘R) 定規 (⌘R) + SMPTE C SMPTE C + Saved Multichannel 保存済みマルチチャンネル + Scale Horizontally 水平方向に拡大・縮小 + Scale Vertically 垂直方向に拡大・縮小 + Scrubby Zoom スクラブズーム + Search commands (⌘K) コマンドを検索 (⌘K) + Search commands, tools and panels コマンド、ツール、パネルを検索 + Second Color Readout 2 番目のカラー読み取り + Select All Layers すべてのレイヤーを選択 + Select Folder… フォルダーを選択… + Select Plane 平面を選択 + Select Similar Colors 近似色を選択 + Selected Areas 選択された領域 + Semi Bold セミボールド + Set New Snapshot as Default 新規スナップショットを既定に設定 + Set Start Timecode… 開始タイムコードを設定… + Show Guides (⌘;) ガイドを表示 (⌘;) + Show New Snapshot Dialog by Default 既定で新規スナップショットのダイアログを表示 + Show the layer's effects (⌥-click: all layers) レイヤーの効果を表示 (⌥+クリック: すべてのレイヤー) + Shutter Speed シャッタースピード + Single Word Justification 単語単位の行揃え + Snapshot 1 スナップショット 1 + Snapshots スナップショット + Split Toning スプリットトーニング + Split at Playhead 再生ヘッドで分割 + Spot Channel スポットチャンネル + Spot Color 特色 + Status Information ステータス情報 + Step Forward 1 つ進む + Stop For Errors エラーで停止 + Subject Distance 被写体距離 + Subscript 下付き + Subtract from 除外 + Subtract from selection (⌥) 選択範囲から除外 (⌥) + Superscript 上付き + Supplemental Categories 補足カテゴリー + Technology Previews テクノロジープレビュー + Technology Previews… テクノロジープレビュー… + Timecode タイムコード + Toggle reference point 基準点を切り替え + Tone Curve トーンカーブ + Toolbars ツールバー + Total Ink インク総量 + Transmission Reference 送信リファレンス + URLs URL + Ultra Bold ウルトラボールド + Ultra Light ウルトラライト + Uncoated CMYK 非コート紙 CMYK + Unix Unix + Urgency 緊急度 + Use File › Open to open an image. ファイル › 開く で画像を開いてください。 + Uses the foreground and background colors. 前景色と背景色を使用します。 + View menu 表示メニュー + WIA Support WIA サポート + Watercolour look: lighter interior, darker rims. 水彩画風: 内側は明るく、縁は暗く。 + Web Color Web カラー + Web Photo Gallery… Web フォトギャラリー… + Wide Gamut RGB Wide Gamut RGB + Width and Height 幅と高さ + Word Spacing 単語間隔 + Working Gray Plate 作業用グレープレート + Working Space 作業用スペース + Working Spaces 作業用スペース + {mode} Color · {bits} bit {mode} カラー · {bits} ビット + ↑↓ navigate ↵ run esc close ↑↓ 移動 ↵ 実行 esc 閉じる + +# Command id overrides +@id select.all すべてを選択 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 21a0aee1..92e03276 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2840,3 +2840,7 @@ fontWeight Italic 기울임 All Layers No Adjustments 모든 레이어(조정 제외) Current & Below No Adjustments 현재 및 이하(조정 제외) Show Sampling Ring 샘플링 링 표시 + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour 인쇄 색역 밖: 클릭하면 가장 가까운 인쇄 가능한 색상으로 바뀝니다 + Not a web colour: click for the nearest web colour 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 02f269fb..7932a703 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3285,3 +3285,7 @@ cameraRaw Roughness Chropowatość All Layers No Adjustments Wszystkie warstwy bez korekt Current & Below No Adjustments Bieżąca i poniżej bez korekt Show Sampling Ring Pokaż pierścień próbkowania + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Poza gamą druku: kliknij, aby wybrać najbliższy kolor do druku + Not a web colour: click for the nearest web colour To nie jest kolor internetowy: kliknij, aby wybrać najbliższy kolor internetowy diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 773e41c5..1252a5f4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2691,3 +2691,7 @@ fontWeight Italic Itálico All Layers No Adjustments Todas as camadas sem ajustes Current & Below No Adjustments Atual e abaixo sem ajustes Show Sampling Ring Mostrar anel de amostragem + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Fora da gama de impressão: clique para a cor imprimível mais próxima + Not a web colour: click for the nearest web colour Não é uma cor da Web: clique para a cor da Web mais próxima diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e1c7a15b..6c9db594 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2533,3 +2533,7 @@ fontWeight Italic Курсив Current & Below No Adjustments Текущий и ниже без коррекции Show Sampling Ring Показать кольцо выборки Click to sample the foreground colour · {key}-click for background Клик — взять основной цвет · {key}-клик — фоновый цвет + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour Вне цветового охвата печати: щёлкните, чтобы выбрать ближайший печатаемый цвет + Not a web colour: click for the nearest web colour Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e33247da..759ba6af 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2269,3 +2269,7 @@ fontWeight Italic 斜体 All Layers No Adjustments 所有图层(不含调整) Current & Below No Adjustments 当前及下方图层(不含调整) Show Sampling Ring 显示采样环 + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour 超出打印色域:单击使用最接近的可打印颜色 + Not a web colour: click for the nearest web colour 不是 Web 颜色:单击使用最接近的 Web 颜色 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 64b409c7..39c08a87 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2238,3 +2238,7 @@ fontWeight Italic 斜體 All Layers No Adjustments 所有圖層(不含調整) Current & Below No Adjustments 目前及以下(不含調整) Show Sampling Ring 顯示取樣環 + +# Color Picker warnings + Out of gamut for printing: click for the nearest printable colour 超出列印色域:按一下以使用最接近的可列印顏色 + Not a web colour: click for the nearest web colour 不是網頁顏色:按一下以使用最接近的網頁顏色 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 1daee4f3..ae14ec28 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -1,6 +1,7 @@ //! Lucide icons (ISC licence), embedded and tinted at runtime. pub static ICONS: &[(&str, &[u8])] = &[ + ("adjustment-layer", include_bytes!("../../../assets/icons/adjustment-layer.svg")), ("align-center", include_bytes!("../../../assets/icons/align-center.svg")), ("align-justify", include_bytes!("../../../assets/icons/align-justify.svg")), ("align-left", include_bytes!("../../../assets/icons/align-left.svg")), @@ -55,8 +56,10 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("lasso", include_bytes!("../../../assets/icons/lasso.svg")), ("lasso-magnetic", include_bytes!("../../../assets/icons/lasso-magnetic.svg")), ("lasso-select", include_bytes!("../../../assets/icons/lasso-select.svg")), + ("layer-mask", include_bytes!("../../../assets/icons/layer-mask.svg")), ("layers", include_bytes!("../../../assets/icons/layers.svg")), ("link", include_bytes!("../../../assets/icons/link.svg")), + ("link-2", include_bytes!("../../../assets/icons/link-2.svg")), ("lock", include_bytes!("../../../assets/icons/lock.svg")), ("lock-open", include_bytes!("../../../assets/icons/lock-open.svg")), ("lollipop", include_bytes!("../../../assets/icons/lollipop.svg")), @@ -98,7 +101,6 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("square", include_bytes!("../../../assets/icons/square.svg")), ("square-dashed", include_bytes!("../../../assets/icons/square-dashed.svg")), ("square-dashed-mouse-pointer", include_bytes!("../../../assets/icons/square-dashed-mouse-pointer.svg")), - ("square-dot", include_bytes!("../../../assets/icons/square-dot.svg")), ("square-plus", include_bytes!("../../../assets/icons/square-plus.svg")), ("squares-subtract", include_bytes!("../../../assets/icons/squares-subtract.svg")), ("stamp", include_bytes!("../../../assets/icons/stamp.svg")), diff --git a/crates/ui-egui/src/layer_reveal_tests.rs b/crates/ui-egui/src/layer_reveal_tests.rs index e9c7cd1a..e2c65a32 100644 --- a/crates/ui-egui/src/layer_reveal_tests.rs +++ b/crates/ui-egui/src/layer_reveal_tests.rs @@ -55,7 +55,7 @@ fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option { /// Fully inside the rows viewport (below the strip and the blend/lock rows, above the footer). fn in_view(h: &Harness<'static, PhotocraftApp>, r: Rect) -> bool { let layers = last_rects(&h.ctx).into_iter().find(|(g, _)| *g == Group::Layers).unwrap().1; - r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - 36.0 + r.top() >= layers.top() + 26.0 && r.bottom() <= layers.bottom() - (crate::widgets::FOOTER_BAR + 1.0) } fn expanded(app: &PhotocraftApp, group: LayerId) -> bool { diff --git a/crates/ui-egui/src/layer_row_ui.rs b/crates/ui-egui/src/layer_row_ui.rs index 2696d6cb..d2656905 100644 --- a/crates/ui-egui/src/layer_row_ui.rs +++ b/crates/ui-egui/src/layer_row_ui.rs @@ -140,7 +140,7 @@ pub fn indicators( for &(kind, r) in &rects { match kind { Indicator::Lock => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "lock", 12.0, t.text_faint), - Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link", 12.0, t.text_faint), + Indicator::Link => icons::paint(ui, Rect::from_center_size(r.center(), vec2(ICON_W, ICON_W)), "link-2", 12.0, t.text_faint), Indicator::Fx => { if let Some(g) = fx_galley.clone() { painter.galley(pos2(r.left(), cy - g.size().y / 2.0), g, t.text_dim); diff --git a/crates/ui-egui/src/layer_row_ui/tests.rs b/crates/ui-egui/src/layer_row_ui/tests.rs index c18eccc8..c05caae8 100644 --- a/crates/ui-egui/src/layer_row_ui/tests.rs +++ b/crates/ui-egui/src/layer_row_ui/tests.rs @@ -358,3 +358,23 @@ fn alt_dragging_a_row_drops_a_copy_and_a_plain_drag_moves() { assert_eq!(names(&h), ["Background", "L0", "L2", "L0 copy", "L1"]); assert_eq!(h.state().session.active().unwrap().history.undo_label(), Some("Reorder Layers")); } + +/// Photoshop's Layers footer is a bar along the panel's bottom edge: its buttons sit in the middle +/// of the bar's height, from the right. They used to hang under the line with twice the gap below. +#[test] +fn the_footer_is_a_bar_along_the_panel_bottom_with_its_buttons_centred() { + for theme in ["promedium", "studio"] { + let mut s = photocraft_engine::Session::new(); + s.execute("file.new", json!({"width": 64, "height": 48})).unwrap(); + s.execute("layer.new.layer", json!({})).unwrap(); + let h = harness(s, 1.0, theme, 290.0); + let group = crate::dock::last_rects(&h.ctx).into_iter().find(|(g, _)| *g == crate::dock::Group::Layers).unwrap().1; + let delete = h.get_by_label("Delete layer").rect(); + let bar_middle = group.bottom() - crate::widgets::FOOTER_BAR / 2.0; + assert!((delete.center().y - bar_middle).abs() <= 1.5, "{theme}: button at {delete:?}, bar middle {bar_middle}, group {group:?}"); + assert!(group.right() - delete.right() <= 10.0, "{theme}: laid out from the right: {delete:?} in {group:?}"); + let new_layer = h.get_by_label("Create a new layer").rect(); + assert_eq!(new_layer.center().y, delete.center().y, "{theme}: one row"); + assert!(new_layer.right() <= delete.left(), "{theme}: New Layer left of Delete"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8b873c98..175e60fb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -889,6 +889,9 @@ impl PhotocraftApp { /// continues on a later frame, see `file_dialog`). Returns `{"path", "warnings"}` (the export /// warnings are also shown to the user). pub fn save_as(&mut self, path: Option) -> Result { + if self.tiff_options.is_some() { + return Err("Answer TIFF Options before starting another save".into()); + } // Edit Contents documents save back into their smart object. if path.is_none() && self.session.is_enabled("layer.smartObjects.saveContents") { self.run("layer.smartObjects.saveContents", serde_json::json!({}))?; @@ -919,28 +922,30 @@ impl PhotocraftApp { // A layered TIFF asks about its layers first (Preferences › File Handling); the save // continues from the prompt. if tiff_options_ui::wants_prompt(self, &path) { - tiff_options_ui::park(self, path.clone()); + tiff_options_ui::park(self, path.clone())?; return Ok(serde_json::json!({"path": path, "warnings": []})); } - let (path, warnings) = self.write_document(path, &ExportSettings::default())?; + let (path, warnings) = self.write_document(path, &ExportSettings::default(), false)?; Ok(serde_json::json!({"path": path, "warnings": warnings})) } /// Encodes the active document with `settings` and writes it to `path`, which becomes the - /// document's path. Returns the path and the export warnings (also shown to the user). - pub(crate) fn write_document(&mut self, path: String, settings: &ExportSettings) -> Result<(String, Vec), String> { + /// document's path unless saving a copy. A copy leaves the original's path and unsaved + /// changes intact. Returns the path and the export warnings (also shown to the user). + pub(crate) fn write_document(&mut self, path: String, settings: &ExportSettings, copy: bool) -> Result<(String, Vec), String> { let st = self.session.active().ok_or("no document")?; let export = self.services.export.as_ref().ok_or("no exporter configured")?; let (bytes, warnings) = export(&st.doc, &path, settings)?; let write = self.services.write.as_mut().ok_or("no writer configured")?; write(&path, &bytes)?; - if let Some(st) = self.session.active_mut() { + if !copy && let Some(st) = self.session.active_mut() { st.path = Some(path.clone()); st.saved_revision = st.revision; } self.ui.status = format!("Saved {path}"); // "Save Document" script events and File › Generate › Image Assets. - if let Some(i) = self.session.active_index() + if !copy + && let Some(i) = self.session.active_index() && let Some(r) = photocraft_engine::automate_cmds::document_saved(&mut self.session, i) { self.ui.status = format!("Saved {path}; {} image assets in {}", r["files"].as_array().map_or(0, Vec::len), r["dir"].as_str().unwrap_or("")); diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 41a6ec57..4c1d8bc3 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -176,9 +176,9 @@ fn paths_list_the_layer_path_above_a_bottom_footer() { let p = h.get_by_label("Badge Shape Path").rect().center(); click_with(&mut h, p, Modifiers::NONE); assert_eq!(h.state().ui.selected_path.as_deref(), Some("layer")); - // Footer: fill, stroke, load as selection, … (24 pt buttons, 2 pt apart). + // Footer: fill, stroke, load as selection, … (26 pt buttons, 2 pt apart). let footer = crate::vector_ui::paths_footer(&h.ctx).unwrap(); - let p = pos2(footer.left() + 2.0 * 26.0 + 12.0, footer.center().y); + let p = pos2(footer.left() + 2.0 * 28.0 + 13.0, footer.center().y); click_with(&mut h, p, Modifiers::NONE); let sel = h.state().session.active().unwrap().doc.selection.clone().expect("selection from the shape path"); let b = sel.content_bounds(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7bce021e..a45aa7a7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5,6 +5,16 @@ use serde_json::{Value, json}; use crate::PhotocraftApp; use crate::state::DialogKind; +/// Shared gate for native menu clicks and control-channel `ui.menu.invoke`. +/// Dialogs and unsaved-changes prompts block edits, but allow the four view-navigation +/// commands also usable through shortcuts. Camera Raw blocks menu commands entirely. +pub(crate) fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { + if app.camera_raw.is_some() { + return false; + } + (app.ui.dialogs.is_empty() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id) +} + /// Top-level menus in Photoshop order. pub const TOP_MENUS: [&str; 10] = ["File", "Edit", "Image", "Layer", "Type", "Select", "Filter", "View", "Window", "Help"]; diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index e828ea30..bdda5738 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -647,17 +647,6 @@ impl NativeMenu { } } -/// Dialogs, the unsaved-changes prompt and Camera Raw are modal, as they are for shortcuts -/// ([`crate::shortcuts::handle`]): while one is open, only the view navigation Photoshop keeps -/// live under a dialog may run from the menu bar. A rotation under Image Size used to leave the -/// dialog's stale size to be applied on OK (#1354). -fn modal_allows(app: &PhotocraftApp, id: &str) -> bool { - if app.camera_raw.is_some() { - return false; - } - (app.ui.dialogs.is_empty() && app.discard.is_none()) || crate::shortcuts::NAV_COMMANDS.contains(&id) -} - /// Run the native menu clicks that arrived since the last frame, like in-window menu clicks. pub fn run(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(menu) = app.services.native_menu.as_mut() else { return }; @@ -668,7 +657,7 @@ pub fn run(app: &mut PhotocraftApp, ctx: &egui::Context) { continue; } // The rows are disabled while a dialog is open; a click that raced the update is dropped. - if !modal_allows(app, &id) { + if !crate::menus::modal_allows(app, &id) { continue; } if let Err(e) = crate::menus::invoke(app, ctx, &id, serde_json::json!({})) { @@ -691,7 +680,7 @@ pub fn sync(app: &mut PhotocraftApp, ctx: &egui::Context) { let lang = crate::i18n::current(); let mut items = crate::menus::menu_items(app); for it in &mut items { - it.enabled &= modal_allows(app, &it.id); + it.enabled &= crate::menus::modal_allows(app, &it.id); } let layout = photocraft_layout(&items, lang, &app.session.prefs().interface.language); let Some(menu) = app.services.native_menu.as_mut() else { return }; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index f596200a..98f22f41 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -283,10 +283,10 @@ pub fn toolbar(app: &mut PhotocraftApp, ui: &mut egui::Ui) { pub fn toolbar_needs_double(slots: usize, sections: usize, bx: f32, pro: bool, avail_h: f32) -> bool { let pitch = bx + 3.0; let needed = if pro { - // margins + header + slots + "…" + gap + chips (38 + swap row) + gap + 2 buttons - 16.0 + 21.0 + (slots + 1) as f32 * pitch + 8.0 + 59.0 + 8.0 + 2.0 * pitch + // margins + header + slots + "…" + gap + colour chips + gap + 2 buttons + 16.0 + 21.0 + (slots + 1) as f32 * pitch + 8.0 + chips_height(true) + 8.0 + 2.0 * pitch } else { - 16.0 + slots as f32 * pitch + sections.saturating_sub(1) as f32 * 12.0 + 14.0 + 59.0 + 16.0 + slots as f32 * pitch + sections.saturating_sub(1) as f32 * 12.0 + 14.0 + chips_height(false) }; needed > avail_h } @@ -295,17 +295,45 @@ fn c32(c: [f32; 4]) -> Color32 { Color32::from_rgba_unmultiplied((c[0] * 255.0) as u8, (c[1] * 255.0) as u8, (c[2] * 255.0) as u8, (c[3] * 255.0) as u8) } +/// Colour chips: chip size and the background chip's offset (points). +fn chip_metrics(pro: bool) -> (f32, f32) { + if pro { (18.0, 10.0) } else { (21.0, 12.0) } +} + +/// Height of the default-colours and swap icons over the chips. +const CHIP_ICON: f32 = 13.0; + +/// Height of the colour chips block. +fn chips_height(pro: bool) -> f32 { + let (chip, step) = chip_metrics(pro); + CHIP_ICON + 3.0 + chip + step +} + +/// Photoshop's foreground / background colour chips: the Default Colors (D) and Switch Colors (X) +/// icons above them, the foreground chip over the background one, all centred in the toolbar +/// column and kept inside it. fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(36.0, 38.0), Sense::hover()); - let bg = Rect::from_min_size(rect.min + vec2(13.0, 13.0), vec2(21.0, 21.0)); - let fg = Rect::from_min_size(rect.min + vec2(2.0, 2.0), vec2(21.0, 21.0)); + let (chip, step) = chip_metrics(t.pro); + let group = chip + step; + let w = ui.available_width().max(group); + let (rect, _) = ui.allocate_exact_size(vec2(w, chips_height(t.pro)), Sense::hover()); + let left = (rect.left() + (w - group) / 2.0).round(); + let fg = Rect::from_min_size(pos2(left, rect.top() + CHIP_ICON + 3.0), vec2(chip, chip)); + let bg = fg.translate(vec2(step, step)); + let radius = t.radius_sm.min(3.0); let p = ui.painter(); - p.rect_filled(bg, 5.0, c32(app.session.tools.background)); - p.rect_stroke(bg, 5.0, Stroke::new(1.0, t.field_border), StrokeKind::Outside); - p.rect_filled(fg, 5.0, c32(app.session.tools.foreground)); - p.rect_stroke(fg, 5.0, Stroke::new(1.5, t.chrome), StrokeKind::Outside); - p.rect_stroke(fg, 5.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + let frame = |r: Rect| { + // A light line inside a dark one, so any colour reads against the toolbar. + p.rect_stroke(r, radius, Stroke::new(1.0, t.text.gamma_multiply(0.9)), StrokeKind::Inside); + p.rect_stroke(r, radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside); + }; + p.rect_filled(bg, radius, c32(app.session.tools.background)); + frame(bg); + // The foreground chip sits on the background one, a toolbar-coloured gap between them. + p.rect_filled(fg.expand(2.0), radius + 2.0, t.chrome); + p.rect_filled(fg, radius, c32(app.session.tools.foreground)); + frame(fg); // Photoshop: clicking a chip opens the Color Picker for that colour. let bg_resp = ui.interact(bg, ui.id().with("bgchip"), Sense::click()); let fg_resp = ui.interact(fg, ui.id().with("fgchip"), Sense::click()); @@ -314,15 +342,50 @@ fn color_chips(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } else if bg_resp.on_hover_text(tl!("Set background color")).clicked() { crate::color_picker_ui::open(app, "background"); } - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 0.0; - if icons::button(ui, "arrow-left-right", 18.0, false, tl!("Swap colours (X)")).clicked() { - let _ = app.run("tools.swapColors", json!({})); + // Default Colors at the left, Switch Colors at the right, over the chips. + let icon = |x: f32| Rect::from_min_size(pos2(x, rect.top()), vec2(CHIP_ICON, CHIP_ICON)); + let (dr, sr) = (icon(left), icon(left + group - CHIP_ICON)); + let d = ui.interact(dr, ui.id().with("default-colors"), Sense::click()); + let s = ui.interact(sr, ui.id().with("swap-colors"), Sense::click()); + let p = ui.painter(); + for (r, resp) in [(dr, &d), (sr, &s)] { + if resp.hovered() { + p.rect_filled(r.expand(2.0), radius, t.hover); } - if icons::button(ui, "contrast", 18.0, false, tl!("Default colours (D)")).clicked() { - let _ = app.run("tools.defaultColors", json!({})); - } - }); + } + let ink = |resp: &egui::Response| if resp.hovered() { t.text } else { t.icon }; + // Default Colors: a small black chip over a small white one. + let small = 7.0; + let (b, w) = (Rect::from_min_size(dr.min + vec2(1.0, 1.0), vec2(small, small)), Rect::from_min_size(dr.min + vec2(5.0, 5.0), vec2(small, small))); + p.rect_filled(w, 1.0, Color32::WHITE); + p.rect_stroke(w, 1.0, Stroke::new(1.0, ink(&d)), StrokeKind::Inside); + p.rect_filled(b.expand(1.0), 1.5, t.chrome); + p.rect_filled(b, 1.0, Color32::BLACK); + p.rect_stroke(b, 1.0, Stroke::new(1.0, ink(&d)), StrokeKind::Inside); + // Switch Colors: a quarter-circle arrow with a head at each end. + let stroke = Stroke::new(1.3, ink(&s)); + let (c, rad) = (pos2(sr.left() + 2.0, sr.bottom() - 1.0), sr.width() - 4.0); + let arc: Vec<_> = (0..=8) + .map(|i| { + let a = std::f32::consts::FRAC_PI_2 * i as f32 / 8.0; + c + vec2(rad * a.sin(), -rad * a.cos()) + }) + .collect(); + let (start, end) = (arc[0], arc[arc.len() - 1]); + p.add(egui::Shape::line(arc, stroke)); + let head = 3.0; + p.line_segment([start, start + vec2(head, -head)], stroke); + p.line_segment([start, start + vec2(head, head)], stroke); + p.line_segment([end, end + vec2(-head, -head)], stroke); + p.line_segment([end, end + vec2(head, -head)], stroke); + d.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Default colours (D)"))); + s.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Swap colours (X)"))); + if s.on_hover_text(tl!("Swap colours (X)")).clicked() { + let _ = app.run("tools.swapColors", json!({})); + } + if d.on_hover_text(tl!("Default colours (D)")).clicked() { + let _ = app.run("tools.defaultColors", json!({})); + } } // ----------------------------------------------------------------------------- title bar @@ -1416,7 +1479,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { ui.add_space(4.0); for (kind, icon, tip) in [ ("pixel", "image", tl!("Filter for pixel layers")), - ("adjustment", "contrast", tl!("Filter for adjustment layers")), + ("adjustment", "adjustment-layer", tl!("Filter for adjustment layers")), ("type", "type", tl!("Filter for type layers")), ("shape", "square", tl!("Filter for shape layers")), ("smart", "app-window", tl!("Filter for smart objects")), @@ -1511,7 +1574,7 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { // Top of the stack first, groups above their contents, closed groups' contents hidden (#126). let rows = crate::layer_tree_ui::display_rows(&doc, !app.ui.layer_filter.is_empty()); let ctx = ui.ctx().clone(); - let footer = 38.0; + let footer = widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY }; egui::ScrollArea::vertical() @@ -1582,79 +1645,73 @@ fn layers(app: &mut PhotocraftApp, ui: &mut egui::Ui) { if ctx.input(|i| i.pointer.any_released()) { ctx.data_mut(|d| d.remove::(egui::Id::new("layer-drag"))); } - ui.add_space(4.0); - widgets::hairline(ui); - ui.add_space(2.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete layer")); - if trash.clicked() { - actions.push(("layer.delete".into(), json!({}))); - } - actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete")); - let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")); - if new_layer.clicked() { - actions.push(("layer.new.layer".into(), json!({}))); - } - actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); - let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); - if group.clicked() { - actions.push(("layer.new.group".into(), json!({}))); - } - actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers")); - let adj = footer_menu_button(ui, "contrast", 26.0, tl!("Create new fill or adjustment layer")); - egui::Popup::menu(&adj).open_memory(footer_menu_right_click(&adj)).show(|ui| { - ui.set_min_width(190.0); - for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) { - if ui.button(tl!(c.label).trim_end_matches('…')).clicked() { - actions.push((c.id.into(), json!({}))); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Solid Color…")).clicked() { - actions.push(("layer.newFillLayer.solidColor".into(), json!({}))); + widgets::panel_footer(ui, |ui| { + let trash = icons::button(ui, "trash", 26.0, false, tl!("Delete layer")); + if trash.clicked() { + actions.push(("layer.delete".into(), json!({}))); + } + actions.extend(footer_drop(ui, &trash, footer_drag, "layer.delete")); + let new_layer = icons::button(ui, "square-plus", 26.0, false, &crate::shortcuts::tip_label(app, "Create a new layer", "layer.new.layer")); + if new_layer.clicked() { + actions.push(("layer.new.layer".into(), json!({}))); + } + actions.extend(footer_drop(ui, &new_layer, footer_drag, "layer.duplicate")); + let group = icons::button(ui, "folder", 26.0, false, tl!("Create a new group")); + if group.clicked() { + actions.push(("layer.new.group".into(), json!({}))); + } + actions.extend(footer_drop(ui, &group, footer_drag, "layer.groupLayers")); + let adj = footer_menu_button(ui, "adjustment-layer", 26.0, tl!("Create new fill or adjustment layer")); + egui::Popup::menu(&adj).open_memory(footer_menu_right_click(&adj)).show(|ui| { + ui.set_min_width(190.0); + for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("layer.newAdjustmentLayer.")) { + if ui.button(tl!(c.label).trim_end_matches('…')).clicked() { + actions.push((c.id.into(), json!({}))); ui.close(); } - if ui.button(tl!("Gradient…")).clicked() { - actions.push(("layer.newFillLayer.gradient".into(), json!({}))); - ui.close(); - } - }); - if icons::button( - ui, - "square-dot", - 26.0, - false, - &crate::i18n::fmt(tl!("Add a mask (from the selection; {key} inverts)"), &[("key", &crate::shortcuts::pretty("Alt"))]), - ) - .clicked() - { - let alt = ui.input(|i| i.modifiers.alt); - actions.push((crate::layer_menu_ui::add_mask_command(doc.selection.is_some(), alt).into(), json!({}))); } - let fx = fx_button(ui, 26.0, tl!("Add a layer style")); - egui::Popup::menu(&fx).open_memory(footer_menu_right_click(&fx)).show(|ui| { - ui.set_min_width(180.0); - if ui.button(tl!("Blending Options…")).clicked() { - crate::layer_style::open(app, None); - ui.close(); - } - ui.separator(); - for &(kind, label) in crate::layer_style::KINDS { - if ui.button(format!("{}…", tl!(label))).clicked() { - crate::layer_style::open(app, Some(kind)); - ui.close(); - } - } - }); - // Photoshop's footer starts with Link Layers (enabled with two or more layers selected). - let can_link = app.session.is_enabled("layer.linkLayers"); - if ui.add_enabled_ui(can_link, |ui| icons::button(ui, "link", 26.0, false, tl!("Link layers"))).inner.clicked() { - actions.push(("layer.linkLayers".into(), json!({}))); + ui.separator(); + if ui.button(tl!("Solid Color…")).clicked() { + actions.push(("layer.newFillLayer.solidColor".into(), json!({}))); + ui.close(); + } + if ui.button(tl!("Gradient…")).clicked() { + actions.push(("layer.newFillLayer.gradient".into(), json!({}))); + ui.close(); } }); + if icons::button( + ui, + "layer-mask", + 26.0, + false, + &crate::i18n::fmt(tl!("Add a mask (from the selection; {key} inverts)"), &[("key", &crate::shortcuts::pretty("Alt"))]), + ) + .clicked() + { + let alt = ui.input(|i| i.modifiers.alt); + actions.push((crate::layer_menu_ui::add_mask_command(doc.selection.is_some(), alt).into(), json!({}))); + } + let fx = fx_button(ui, 26.0, tl!("Add a layer style")); + egui::Popup::menu(&fx).open_memory(footer_menu_right_click(&fx)).show(|ui| { + ui.set_min_width(180.0); + if ui.button(tl!("Blending Options…")).clicked() { + crate::layer_style::open(app, None); + ui.close(); + } + ui.separator(); + for &(kind, label) in crate::layer_style::KINDS { + if ui.button(format!("{}…", tl!(label))).clicked() { + crate::layer_style::open(app, Some(kind)); + ui.close(); + } + } + }); + // Photoshop's footer starts with Link Layers (enabled with two or more layers selected). + let can_link = app.session.is_enabled("layer.linkLayers"); + if ui.add_enabled_ui(can_link, |ui| icons::button(ui, "link-2", 26.0, false, tl!("Link layers"))).inner.clicked() { + actions.push(("layer.linkLayers".into(), json!({}))); + } }); for (id, p) in actions { if id == "ui.maskTarget" { @@ -2099,7 +2156,7 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let mut target: Option = None; let all = entries.iter().map(|e| (e.clone(), false)).chain(redo.iter().map(|e| (e.clone(), true))); // The dock gives History a fixed height: the rows scroll above the footer. - let footer = if t.pro { 34.0 } else { 0.0 }; + let footer = if t.pro { widgets::footer_height(ui) + ui.spacing().item_spacing.y } else { 0.0 }; let max_h = (ui.available_height() - footer).max(40.0); egui::ScrollArea::vertical().id_salt("history-rows").max_height(max_h).auto_shrink([false, false]).show(ui, |ui| { for (i, (e, is_redo)) in all.enumerate() { @@ -2132,14 +2189,10 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }); if t.pro { - widgets::hairline(ui); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let _ = icons::button(ui, "trash", 24.0, false, tl!("Delete current state")); - let _ = icons::button(ui, "scan", 24.0, false, tl!("Create new snapshot")); - let _ = icons::button(ui, "file-plus", 24.0, false, tl!("Create new document from current state")); - }); + widgets::panel_footer(ui, |ui| { + let _ = icons::button(ui, "trash", 26.0, false, tl!("Delete current state")); + let _ = icons::button(ui, "scan", 26.0, false, tl!("Create new snapshot")); + let _ = icons::button(ui, "file-plus", 26.0, false, tl!("Create new document from current state")); }); } // An open Free Transform owns Undo (transform_tool::intercept): stepping the document's history under diff --git a/crates/ui-egui/src/tiff_options_ui.rs b/crates/ui-egui/src/tiff_options_ui.rs index 550961e7..0d868db1 100644 --- a/crates/ui-egui/src/tiff_options_ui.rs +++ b/crates/ui-egui/src/tiff_options_ui.rs @@ -9,6 +9,8 @@ use crate::{ExportSettings, PhotocraftApp}; /// A save waiting on the TIFF Options answer. pub struct Prompt { + /// The document that requested the save, even if tabs change while this is open. + doc: photocraft_doc::DocId, /// Where the document goes. pub path: String, /// "Discard Layers and Save a Copy". @@ -33,8 +35,10 @@ pub fn wants_prompt(app: &PhotocraftApp, path: &str) -> bool { } /// Parks the save behind the prompt. -pub fn park(app: &mut PhotocraftApp, path: String) { - app.tiff_options = Some(Prompt { path, discard_layers: false, dont_ask_again: false }); +pub fn park(app: &mut PhotocraftApp, path: String) -> Result<(), String> { + let doc = app.active_doc_id()?; + app.tiff_options = Some(Prompt { doc, path, discard_layers: false, dont_ask_again: false }); + Ok(()) } pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { @@ -61,15 +65,17 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { cancel |= modal.should_close(); p.discard_layers = discard; p.dont_ask_again = dont_ask; - if ok || ctx.input(|i| i.key_pressed(egui::Key::Enter)) { + if ok || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { let Some(p) = app.tiff_options.take() else { return }; if p.dont_ask_again { app.session.edit_prefs(|prefs| prefs.file_handling.ask_before_saving_layered_tiff = false); } let settings = ExportSettings { tiff_layers: !p.discard_layers, ..Default::default() }; - if let Err(e) = app.write_document(p.path.clone(), &settings) { + if let Err(e) = app.refocus(p.doc).and_then(|()| app.write_document(p.path, &settings, p.discard_layers)) { app.ui.status = crate::i18n::fmt(tl!("Save failed: {error}"), &[("error", &e)]); app.ui.status_error = true; + } else if !p.discard_layers { + crate::discard_ui::saved_document(app, ctx, p.doc); } } else if cancel { app.tiff_options = None; diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index e9ba7428..ca9a3adc 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -806,7 +806,7 @@ fn ctx_data_footer(ctx: &egui::Context, r: Rect) { ctx.data_mut(|d| d.insert_temp(footer_id(), r)); } -/// Where the Paths panel's button footer was drawn last frame. +/// Where the Paths panel's footer buttons were drawn last frame (the rect around them all). pub fn paths_footer(ctx: &egui::Context) -> Option { ctx.data(|d| d.get_temp(footer_id())) } @@ -863,7 +863,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer); let mut action: Option<(&str, Value)> = None; // The buttons sit in a footer at the panel's bottom, like Photoshop's. - let footer = 34.0; + let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; let rows_h = if fill { ui.available_height() - footer } else { f32::INFINITY }; egui::ScrollArea::vertical().id_salt("path-rows").max_height(rows_h).min_scrolled_height(if fill { rows_h } else { 0.0 }).auto_shrink([false, !fill]).show( @@ -931,12 +931,8 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { } }, ); - ui.add_space(4.0); - crate::widgets::hairline(ui); - ui.add_space(2.0); let sel = app.ui.selected_path.clone().filter(|s| s != "layer" || has_layer_path).unwrap_or_else(|| "work".into()); - let footer_rect = ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 2.0; + let buttons = crate::widgets::panel_footer(ui, |ui| { let fg = hex(app.session.tools.foreground); let mut n = doc.paths.len() + 1; while doc.paths.iter().any(|p| p.name == format!("Path {n}")) { @@ -957,14 +953,19 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let items = items.map(|(icon, tip, cmd, p)| { if cmd == "path.delete" && delete_layer_path { (icon, "Delete vector mask", "layer.vectorMask.delete", json!({})) } else { (icon, tip, cmd, p) } }); - for (icon, tip, cmd, p) in items { + // Laid out from the right, so the last button goes first. + let mut buttons = Rect::NOTHING; + for (icon, tip, cmd, p) in items.into_iter().rev() { let icon = if crate::icons::exists(icon) { icon } else { "square" }; - if crate::icons::button(ui, icon, 24.0, false, tip).clicked() { + let b = crate::icons::button(ui, icon, 26.0, false, tip); + if b.clicked() { action = Some((cmd, p)); } + buttons = buttons.union(b.rect); } + buttons }); - ctx_data_footer(ui.ctx(), footer_rect.response.rect); + ctx_data_footer(ui.ctx(), buttons); if let Some((cmd, p)) = action && let Err(e) = app.run(cmd, p) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0c3cf00d..6d654ade 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -59,11 +59,12 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla if t.pro { return pro_panel(ui, id, tabs, selected, collapsed, body); } + let m = body_margin(false); let frame = egui::Frame::NONE .fill(t.card) .stroke(Stroke::new(1.0, t.card_border)) .corner_radius(CornerRadius::same(t.radius as u8)) - .inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: if collapsed { 6 } else { 10 } }); + .inner_margin(egui::Margin { bottom: if collapsed { 6 } else { m.bottom }, ..m }); let out = frame .show(ui, |ui| { ui.set_width(ui.available_width()); @@ -88,6 +89,36 @@ pub fn card_ex(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, colla out } +/// Inner margin of a dock panel's body: a Pro panel's, or a Studio card's below its tab strip. +fn body_margin(pro: bool) -> egui::Margin { + if pro { egui::Margin::same(8) } else { egui::Margin { left: 8, right: 8, top: 6, bottom: 10 } } +} + +/// Height of a panel footer's bar ([`panel_footer`]), under its 1 px line. +pub const FOOTER_BAR: f32 = 30.0; + +/// The height a [`panel_footer`] takes from the panel body: its line, and its bar down to the +/// body's bottom margin (the bar runs on over the margin). +pub fn footer_height(ui: &Ui) -> f32 { + 1.0 + FOOTER_BAR - body_margin(Tokens::get(ui.ctx()).pro).bottom as f32 +} + +/// A panel footer as in Photoshop's Layers and History panels: a bar of buttons under a line that +/// runs from edge to edge of the panel, the buttons centred in the bar's height and laid out from +/// the right. Drawn last in a body that fills its group (leave it [`footer_height`]), the bar ends +/// at the panel's bottom edge. +pub fn panel_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + let m = body_margin(t.pro); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), footer_height(ui)), Sense::hover()); + let y = r.top() + 0.5; + ui.painter().line_segment([pos2(r.left() - m.left as f32, y), pos2(r.right() + m.right as f32, y)], Stroke::new(1.0, t.separator)); + let bar = Rect::from_min_size(pos2(r.left(), r.top() + 1.0), vec2(r.width(), FOOTER_BAR)); + let mut bar_ui = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); + bar_ui.spacing_mut().item_spacing.x = 2.0; + add(&mut bar_ui) +} + /// Photoshop-grammar panel group: dark tab strip with flat tabs, flat body, hamburger menu. fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collapsed: bool, body: impl FnOnce(&mut Ui, usize)) -> CardResponse { let t = Tokens::get(ui.ctx()); @@ -109,14 +140,10 @@ fn pro_panel(ui: &mut Ui, id: &str, tabs: &[&str], selected: &mut usize, collaps } // Body. if !collapsed { - egui::Frame::NONE - .fill(t.card) - .corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 }) - .inner_margin(egui::Margin { left: 8, right: 8, top: 8, bottom: 8 }) - .show(ui, |ui| { - ui.set_width(width - 16.0); - body(ui, *selected); - }); + egui::Frame::NONE.fill(t.card).corner_radius(CornerRadius { nw: 0, ne: 0, sw: 3, se: 3 }).inner_margin(body_margin(true)).show(ui, |ui| { + ui.set_width(width - 16.0); + body(ui, *selected); + }); } ui.add_space(2.0); CardResponse { strip: strip_resp, menu: mresp, tab_double_clicked: tabs_out.double_clicked, tabs: tabs_out.tabs, chevron: tabs_out.chevron } diff --git a/crates/vector/src/lib.rs b/crates/vector/src/lib.rs index 0f68282e..ffe90131 100644 --- a/crates/vector/src/lib.rs +++ b/crates/vector/src/lib.rs @@ -107,12 +107,33 @@ impl CompiledVectorMask { MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], MaskGeometry::Path(rasterizer) => rasterizer.render(rect), }; + self.apply_density(&mut v); + v + } + + /// Effective mask values without entering a thread pool. Use this while + /// initializing a shared result that other Rayon workers may wait for. + pub fn render_sequential(&self, rect: Rect) -> Vec { + let mut v = match &self.geometry { + MaskGeometry::Constant(value) => vec![*value; rect.width() as usize * rect.height() as usize], + MaskGeometry::Path(rasterizer) => { + let mut v = vec![0.0; rect.width() as usize * rect.height() as usize]; + if !v.is_empty() { + rasterizer.render_into(rect, &mut v); + } + v + } + }; + self.apply_density(&mut v); + v + } + + fn apply_density(&self, v: &mut [f32]) { if let Some(d) = self.density { - for x in &mut v { + for x in v { *x = 1.0 - d * (1.0 - *x); } } - v } } diff --git a/crates/vector/src/tests.rs b/crates/vector/src/tests.rs index 18a27c4a..0dc5514e 100644 --- a/crates/vector/src/tests.rs +++ b/crates/vector/src/tests.rs @@ -337,6 +337,37 @@ fn coverage_surface_and_vector_mask_values() { assert!(vector_mask_values(&empty, Rect::new(0, 0, 4, 4)).iter().all(|x| *x == 0.0)); } +#[test] +fn sequential_vector_masks_match_parallel_rendering() { + let rect = Rect::new(-3, -7, 510, 250); + let mut path = shapes::ellipse(10.25, 8.75, 470.0, 220.0); + path.subpaths.push(shapes::rect(200.5, 90.25, 45.0, 50.0).subpaths.remove(0).with_op(PathOp::Subtract)); + for inverted in [false, true] { + for density in [0.0, 0.25, 1.0] { + path.inverted = inverted; + let mut mask = VectorMask::new(path.clone()); + mask.density = density; + let compiled = CompiledVectorMask::new(&mask); + assert_eq!(compiled.render_sequential(rect), compiled.render(rect)); + } + } + let compiled = CompiledVectorMask::new(&VectorMask::new(path.clone())); + for empty in [Rect::EMPTY, Rect::new(1, 1, 1, 10), Rect::new(1, 1, 10, 1)] { + assert_eq!(compiled.render_sequential(empty), compiled.render(empty)); + } + let mut empty = VectorMask::new(Path::default()); + for inverted in [false, true] { + empty.path.inverted = inverted; + let compiled = CompiledVectorMask::new(&empty); + assert_eq!(compiled.render_sequential(rect), compiled.render(rect)); + } + let mut disabled = VectorMask::new(path); + disabled.enabled = false; + let compiled = CompiledVectorMask::new(&disabled); + assert_eq!(compiled.render_sequential(rect), compiled.render(rect)); + assert_eq!(compiled.render_sequential(Rect::EMPTY), compiled.render(Rect::EMPTY)); +} + fn iou(a: &[f32], b: &[f32]) -> f64 { let (mut i, mut u) = (0.0, 0.0); for (x, y) in a.iter().zip(b) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b87135ba..aa85544a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -36,7 +36,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI) - `engine.commands`: list commands with enablement -- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) +- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], list: {collapsed, filter, view, renaming}}` while the Brush Preset picker is open (coordinates in screen points). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`) - `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour) - `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state. - `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..523aa7a8 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,64 @@ +# MCP conventions + +PhotoCraft follows the same conventions as FilmCraft #28. Start a headless session: + +```sh +photocraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project +``` + +Paths are relative to the corresponding root; without a root that filesystem authority is +absent. Bridge mode uses `--bridge` and `--control-token-file` as described in +[development.md](development.md). UI tools require a running desktop app. + +## Tools and resources + +- `command_list`: discover engine ids, parameter docs and current enabled state; accepts + `filter` and `enabled_only`. +- `command_run`: `id`, optional `params` and `wait`. Command parameters remain engine-defined. +- `command_batch`: ordered `steps`, optional `stop_on_error`; one history step per command, + not an atomic transaction. Existing per-step results and reply budgets are preserved. +- `doc_inspect`: layer tree, history and selection; an empty session returns `document:null`. +- `render_preview`: bounded PNG content; `index` and `max_side` (default 1024, maximum 2048). +- `ui_inspect` and `ui_screenshot`: live app state/window in bridge mode. + +Existing document/session/job/UI tools remain listed, including the documented +`doc_render_preview` spelling. There are no hidden legacy aliases. Every tool has a title +and all four annotation hints. Generic command/control tools conservatively advertise writes; +file saves may replace existing targets. Hints do not grant permission. + +Unknown tool arguments return JSON-RPC `-32602`, naming the key, before execution. Batch +step envelopes are strict too. Nested engine `params` retain their command-specific handling. +Tool execution failures return `isError:true`. A worker panic becomes a tool error and the +existing poisoned-session recovery keeps serving. Invalid JSON lines receive `-32700` with +`id:null`; the next line is still processed. + +`photocraft://document` and `photocraft://commands` return live JSON matching `doc_inspect` +and `command_list`. Resource reads are uncached; tool/resource catalogs are private and cached +for ten minutes. List/read responses include the MCP 2026-07-28 result/cache fields. + +## Progress and cancellation + +A direct headless `command_run` of `file.export.renderVideo` reports frame counts when +`_meta.progressToken` is present (a string or number). Notifications increase strictly, +are throttled to at most ten per second except completion, and include `total`. No token +means no progress notifications. Ping remains responsive while rendering. + +```json +{"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"file.export.renderVideo","params":{"dir":"frames","format":"png"}},"_meta":{"progressToken":"export-20"}}} +{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":20}} +``` + +Create a document and timeline first (`doc_new`, then `timeline.create`). Export uses the +configured write root and creates `dir` beneath it. Filenames must be single components; +absolute paths, traversal and escaping symlinks are rejected. Existing target filenames +are refused, including symlinks, so a cancelled or failed job cannot replace an earlier export. +Only files actually created by this job are removed on cancellation, error or an escaped panic; +unrelated files and the destination directory remain. PNG sequences and animated GIF share +the engine renderer. The document and playhead are unchanged. + +Cancellation suppresses the request response and stops at a frame boundary (the current +frame/encoding may finish first). Unknown request ids are ignored. Render Video stays a +synchronous command even with `wait:false`; other job-capable commands keep their existing +`jobs_list`/`jobs_cancel` behavior. Batch exports do not emit MCP progress or support per-step +MCP cancellation. Nested engine actions and bridge Render Video remain denied by the existing +ambient-filesystem policy; the new scoped writer is available for direct headless commands.