Merge main into #1745 (catalogs: keep both blocks)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
This commit is contained in:
commit
ac9c7b057b
|
|
@ -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) | <https://github.com/rsms/inter> | 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 | <https://github.com/JetBrains/JetBrainsMono> | 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 | <https://github.com/lucide-icons/lucide> (`icons/<name>.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 | <https://github.com/feathericons/feather> 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 <https://github.com/lucide-icons/lucide> `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 <https://github.com/lucide-icons/lucide> `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 | <https://thenounproject.com/icon/magnetic-lasso-tool-177513/> | CC BY 3.0, [`assets/icons/LICENSE-noun-magnetic-lasso.txt`](assets/icons/LICENSE-noun-magnetic-lasso.txt) |
|
||||
|
|
|
|||
|
|
@ -4173,6 +4173,7 @@ dependencies = [
|
|||
"photocraft-geom",
|
||||
"photocraft-raster",
|
||||
"proptest",
|
||||
"rustix 1.1.5",
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@
|
|||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 3a9 9 0 0 1 0 18z" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 300 B |
|
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<circle cx="12" cy="12" r="4" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 316 B |
|
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
|
||||
<line x1="8" x2="16" y1="12" y2="12" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -239,6 +239,9 @@ impl Headless {
|
|||
pub fn command_start(&mut self, id: &str, params: Value, wait: bool) -> Result<Value, AutomationError> {
|
||||
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<Value, AutomationError> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<u32>,
|
||||
|
|
@ -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<DocIndex>) -> Result<CallToolResult, McpError> {
|
||||
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<PreviewParams>) -> Result<CallToolResult, McpError> {
|
||||
self.doc_render_preview(p).await
|
||||
}
|
||||
|
||||
#[tool(description = "Make the document at `index` active.")]
|
||||
async fn doc_select(&self, Parameters(p): Parameters<SelectParams>) -> Result<CallToolResult, McpError> {
|
||||
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<Vec<u8>, AutomationError> {
|
||||
|
|
|
|||
|
|
@ -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::<String>() + 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<PaginatedRequestParams>, _: RequestContext<RoleServer>) -> Result<ListToolsResult, McpError> {
|
||||
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<RoleServer>) -> Result<CallToolResponse, McpError> {
|
||||
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<PaginatedRequestParams>, _: RequestContext<RoleServer>) -> Result<ListResourcesResult, McpError> {
|
||||
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<RoleServer>) -> Result<ReadResourceResponse, McpError> {
|
||||
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::<Vec<_>>().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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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<RoleServer>) -> 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<usize>, Option<Instant>) = (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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<String>(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::<Value>(&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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
impl AuthorizedWorkspace {
|
||||
pub(crate) fn export_sequence(&self, path: &str) -> Result<ExportSequence, AutomationError> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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::<Value>(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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::<Value>(&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<Value>| {
|
||||
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();
|
||||
}
|
||||
|
|
@ -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)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "<x:xmpmeta xmlns:x='adobe:ns:meta/'/>";
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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<u64, CacheEntry>,
|
||||
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<Surface>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
map: HashMap<u64, CacheEntry>,
|
||||
/// Builds in progress, shared by every caller that asks for the same cold mask.
|
||||
in_flight: HashMap<u64, Weak<OnceLock<Surface>>>,
|
||||
tick: u64,
|
||||
bytes: usize,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
fn get(&mut self, key: u64) -> Option<Surface> {
|
||||
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<Surface>, 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<Cache> {
|
||||
static C: OnceLock<Mutex<Cache>> = 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<OnceLock<Surface>>, u64),
|
||||
}
|
||||
|
||||
fn lookup(c: &Mutex<Cache>, 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<Cache>, k: u64, slot: &Arc<OnceLock<Surface>>, generation: u64, build: impl FnOnce() -> (Surface, Option<Surface>)) -> 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<Cache>, k: u64, build: impl FnOnce() -> (Surface, Option<Surface>)) -> 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<Surface> {
|
||||
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<Surface> {
|
|||
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<Surface> {
|
|||
}
|
||||
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<Cache>, k: u64) -> Arc<OnceLock<Surface>> {
|
||||
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<Surface>) {
|
||||
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::<Vec<_>>()
|
||||
});
|
||||
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::<Vec<_>>()
|
||||
});
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -739,6 +739,8 @@ pub type PsdGlobalBlock = ([u8; 4], [u8; 4], Arc<Vec<u8>>);
|
|||
pub struct Metadata {
|
||||
pub xmp: Option<String>,
|
||||
pub exif: Option<Arc<Vec<u8>>>,
|
||||
/// 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<Vec<u8>>)>,
|
||||
/// Raw PSD global additional-layer-info blocks: (signature, key, data),
|
||||
|
|
|
|||
|
|
@ -264,6 +264,11 @@ fn purge(s: &mut Session, what: &str) -> Result<Value> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -320,8 +320,20 @@ fn frames_to_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
/// sequence (`{dir}/{stem}_NNNN.{format}`). Clean-room; no proprietary codec.
|
||||
fn render_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
|||
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<Value> {
|
|||
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<Value> {
|
|||
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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -343,6 +343,8 @@ fn default_channel_opacity() -> f32 {
|
|||
pub struct MetadataM {
|
||||
pub xmp: Option<String>,
|
||||
pub exif: Option<Hash>,
|
||||
#[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)
|
||||
|
|
|
|||
|
|
@ -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("<x:xmpmeta/>".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)));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,22 @@ fn rebuild(bytes: &[u8], f: impl Fn(&str, Vec<u8>) -> Option<Vec<u8>>) -> Vec<u8
|
|||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_bundles_without_text_metadata_still_load() {
|
||||
let mut doc = rich_doc(ColorMode::Rgb, SampleType::U8);
|
||||
let bytes = save_to_bytes(&doc, &SaveOptions::default()).unwrap();
|
||||
let old = rebuild(&bytes, |name, data| {
|
||||
if name != "manifest.json" {
|
||||
return Some(data);
|
||||
}
|
||||
let mut manifest: serde_json::Value = serde_json::from_slice(&data).unwrap();
|
||||
assert!(manifest["document"]["metadata"].as_object_mut().unwrap().remove("text").is_some());
|
||||
Some(serde_json::to_vec(&manifest).unwrap())
|
||||
});
|
||||
doc.metadata.text.clear();
|
||||
assert_eq!(load_from_bytes(&old).unwrap(), doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncation_never_panics_and_fails() {
|
||||
let b = sample();
|
||||
|
|
|
|||
|
|
@ -96,13 +96,11 @@ pub(crate) fn image_to_document(name: &str, img: &Image) -> Result<ImportResult,
|
|||
doc.icc_profile = img.icc.clone().map(Arc::new);
|
||||
doc.metadata.exif = img.meta.exif.clone().map(Arc::new);
|
||||
doc.metadata.xmp = img.meta.xmp.clone();
|
||||
doc.metadata.text = img.meta.text.clone();
|
||||
if let Some((x, y)) = img.meta.dpi {
|
||||
doc.resolution_dpi = x;
|
||||
warnings.extend(crate::unequal_resolution_warning(f64::from(x), f64::from(y)));
|
||||
}
|
||||
if !img.meta.text.is_empty() {
|
||||
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
|
||||
}
|
||||
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +232,7 @@ pub fn document_to_image(doc: &Document, warnings: &mut Vec<String>) -> Result<I
|
|||
let meta = codecs::Metadata {
|
||||
exif: doc.metadata.exif.as_ref().map(|e| e.to_vec()),
|
||||
xmp: doc.metadata.xmp.clone(),
|
||||
text: doc.metadata.text.clone(),
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
..Default::default()
|
||||
};
|
||||
|
|
@ -292,6 +291,8 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> 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) };
|
||||
|
|
|
|||
|
|
@ -120,16 +120,16 @@ pub struct ExportResult {
|
|||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
|
|
|
|||
|
|
@ -111,9 +111,7 @@ pub(crate) fn import_layered(name: &str, img: &Image, layers: &[u8]) -> Result<I
|
|||
if doc.icc_profile.is_none() {
|
||||
doc.icc_profile = img.icc.clone().map(Arc::new);
|
||||
}
|
||||
if !img.meta.text.is_empty() {
|
||||
warnings.push(format!("{} text metadata entries are not kept in the document", img.meta.text.len()));
|
||||
}
|
||||
doc.metadata.text = img.meta.text.clone();
|
||||
Ok(ImportResult { document: doc, warnings, source_read_only: false, preview_only: false })
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +223,7 @@ pub(crate) fn export_layered(doc: &Document, opts: &ExportOptions) -> Result<Exp
|
|||
// codec a second copy in `meta.exif` bought nothing and made the export claim it would
|
||||
// drop metadata it had in fact kept (#1545).
|
||||
xmp: doc.metadata.xmp.clone().filter(|_| opts.xmp == crate::XmpEmbed::All),
|
||||
text: if opts.xmp == crate::XmpEmbed::All { doc.metadata.text.clone() } else { Vec::new() },
|
||||
dpi: Some((doc.resolution_dpi, doc.resolution_dpi)),
|
||||
photoshop_resources: Some(resources),
|
||||
photoshop_layers: Some(layers),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
//! PNG text survives open, native save/reopen and PNG save without a false loss warning.
|
||||
|
||||
use photocraft_codecs::{ChannelLayout, EncodeOptions, Format, Image, SampleType, decode, encode};
|
||||
use photocraft_io::{ExportOptions, XmpEmbed, export, import};
|
||||
|
||||
fn source(sample: SampleType) -> 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("<x:xmpmeta xmlns:x='adobe:ns:meta/'/>".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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<String, Value>) {
|
|||
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<String, Value>) {
|
|||
"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<String, Value>) {
|
|||
"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<String, Value>) {
|
|||
fn swatches(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
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<String, Value>, 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<String, Value>, 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<String, Value>, 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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<egui::ViewportCommand> {
|
||||
let mut info = egui::ViewportInfo::default();
|
||||
|
|
|
|||
|
|
@ -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<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
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());
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 5.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 14.
|
File diff suppressed because it is too large
Load Diff
|
|
@ -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 웹 색상이 아님: 클릭하면 가장 가까운 웹 색상으로 바뀝니다
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 10.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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 Не веб-цвет: щёлкните, чтобы выбрать ближайший веб-цвет
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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 颜色
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 14.
|
|
|
@ -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 不是網頁顏色:按一下以使用最接近的網頁顏色
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fn row(h: &Harness<'static, PhotocraftApp>, name: &str) -> Option<Rect> {
|
|||
/// 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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Result<Value, String> {
|
||||
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>), 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>), 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(""));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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::<u64>(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<isize> = 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Rect> {
|
||||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<R>(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 }
|
||||
|
|
|
|||
|
|
@ -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<f32> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue