File › New from Clipboard (`file.newFromClipboard`, right after New…) opens the clipboard image as a document of its own: sized to the image, holding it as one layer, in the pixel format it was copied in. It has no default shortcut; one can be assigned in Keyboard Shortcuts. Edit › Paste (Ctrl+V) with no document open does the same, so a screenshot is one keystroke from the start screen. Both read the OS clipboard only when invoked, stay enabled from the clipboard service like Paste, and are a quiet no-op when it holds no image. Agents reach it through MCP and `serve` too: the command only uses the session clipboard, so it joins the automation allowlist of file commands. The menu label is in every catalog. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c0a7f8e7e4
commit
3c6310e559
|
|
@ -154,6 +154,7 @@ pub fn authorize_engine_command(id: &str, params: &Value) -> Result<(), Automati
|
|||
let safe_file_command = matches!(
|
||||
id,
|
||||
"file.new"
|
||||
| "file.newFromClipboard"
|
||||
| "file.close"
|
||||
| "file.closeAll"
|
||||
| "file.closeOthers"
|
||||
|
|
|
|||
|
|
@ -514,6 +514,31 @@ async fn bridge_command_batch_forwards_each_steps_wait() {
|
|||
app.abort();
|
||||
}
|
||||
|
||||
/// #368: agents can open the clipboard as a document of its own.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn new_from_clipboard_opens_the_copy_as_a_document() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
json_of(&call(&client, "doc_new", json!({"width": 40, "height": 30})).await);
|
||||
let r = json_of(
|
||||
&call(
|
||||
&client,
|
||||
"command_batch",
|
||||
json!({"steps": [
|
||||
{"id": "select.rect", "params": {"x": 5, "y": 5, "width": 12, "height": 7}},
|
||||
{"id": "edit.copy"},
|
||||
{"id": "file.newFromClipboard"}
|
||||
]}),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
assert_eq!(r["completed"], 3, "{r}");
|
||||
let doc = json_of(&call(&client, "doc_inspect", json!({})).await);
|
||||
assert_eq!((doc["width"].as_u64(), doc["height"].as_u64()), (Some(12), Some(7)), "{doc}");
|
||||
let sess = json_of(&call(&client, "session_list", json!({})).await);
|
||||
assert_eq!(sess["documents"].as_array().unwrap().len(), 2);
|
||||
client.cancel().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn command_batch_rejects_too_many_steps() {
|
||||
let client = connect(PhotocraftMcp::headless()).await;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
|||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||||
}
|
||||
|
||||
/// Paste and New from Clipboard need only a clipboard: with no document open, they make one.
|
||||
fn has_clip_only(s: &Session) -> std::result::Result<(), String> {
|
||||
s.clipboard.as_ref().map(|_| ()).ok_or_else(|| "the clipboard is empty".into())
|
||||
}
|
||||
|
||||
fn has_pixels(s: &Session) -> std::result::Result<(), String> {
|
||||
let d = s.active().ok_or("no document open")?;
|
||||
let l = d.active_layer.and_then(|id| d.doc.layer(id)).ok_or("no active layer")?;
|
||||
|
|
@ -114,7 +119,10 @@ pub(crate) fn clear_area(doc: &mut Document, id: LayerId, area: Rect, sel: Optio
|
|||
/// on `center` (the view centre from the UI) or the canvas, unless they already overlap the canvas.
|
||||
fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
|
||||
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let Some(d) = s.active() else {
|
||||
// Nothing open to paste into: the clipboard becomes a document of its own (#368).
|
||||
return new_from_clipboard(s);
|
||||
};
|
||||
let canvas = d.doc.bounds();
|
||||
let fmt = d.doc.pixel_format();
|
||||
let (dx, dy) = if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) {
|
||||
|
|
@ -139,6 +147,26 @@ fn paste(s: &mut Session, p: &Value, in_place: bool) -> Result<Value> {
|
|||
Ok(json!({"layer": id.0, "offset": [dx, dy]}))
|
||||
}
|
||||
|
||||
/// A new document the size of the clipboard image, holding it as its one layer, in the pixel
|
||||
/// format it was copied in (#368).
|
||||
fn new_from_clipboard(s: &mut Session) -> Result<Value> {
|
||||
let clip = s.clipboard.clone().ok_or(EngineError::Other("the clipboard is empty".into()))?;
|
||||
let b = clip.bounds;
|
||||
let (w, h) = (b.width(), b.height());
|
||||
if w == 0 || h == 0 {
|
||||
return Err(EngineError::Other("the clipboard image has no size".into()));
|
||||
}
|
||||
let fmt = clip.surface.format();
|
||||
let target = PixelFormat::new(fmt.mode, fmt.sample, true);
|
||||
let moved = if b.x0 == 0 && b.y0 == 0 { clip.surface } else { photocraft_algo::resample::translate_surface(&clip.surface, -b.x0, -b.y0) };
|
||||
let mut doc = Document::new("Untitled", photocraft_geom::Size::new(w, h), fmt.mode, fmt.sample);
|
||||
let mut l = Layer::raster(doc.next_layer_name("Layer"), target);
|
||||
*crate::pixels_mut(&mut l)? = if moved.format() == target { moved } else { moved.convert(target) };
|
||||
doc.layers.push(l);
|
||||
let i = s.add_document(doc, None);
|
||||
Ok(json!({"document": i, "width": w, "height": h}))
|
||||
}
|
||||
|
||||
fn layer_via(s: &mut Session, cut: bool) -> Result<Value> {
|
||||
let d = s.active().ok_or(EngineError::NoDocument)?;
|
||||
let id = active_id(s)?;
|
||||
|
|
@ -420,10 +448,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Paste",
|
||||
&["Edit"],
|
||||
Some("Cmd+V"),
|
||||
r##"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas)}"##,
|
||||
has_clip,
|
||||
r##"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas)} (with no document open: a new document from the clipboard)"##,
|
||||
has_clip_only,
|
||||
|s, p| paste(s, p, false)
|
||||
),
|
||||
spec!(
|
||||
"file.newFromClipboard",
|
||||
"New from Clipboard",
|
||||
&["File"],
|
||||
None,
|
||||
"{} (a new document the size of the clipboard image, holding it as one layer)",
|
||||
has_clip_only,
|
||||
|s, _| new_from_clipboard(s)
|
||||
),
|
||||
spec!("edit.pasteSpecial.pasteInPlace", "Paste in Place", &["Edit", "Paste Special"], Some("Cmd+Shift+V"), "{}", has_clip, |s, p| paste(s, p, true)),
|
||||
spec!("layer.new.layerViaCopy", "Layer via Copy", &["Layer", "New"], Some("Cmd+J"), "{}", has_doc, |s, _| layer_via(s, false)),
|
||||
spec!("layer.new.layerViaCut", "Layer via Cut", &["Layer", "New"], Some("Cmd+Shift+J"), "{}", has_pixels, |s, _| layer_via(s, true)),
|
||||
|
|
@ -465,6 +502,40 @@ mod tests {
|
|||
st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().content_bounds()
|
||||
}
|
||||
|
||||
/// #368: the clipboard as a document of its own, from New from Clipboard or a Paste with
|
||||
/// nothing open, at every depth.
|
||||
#[test]
|
||||
fn new_from_clipboard_and_paste_with_no_document() {
|
||||
for depth in [8, 16, 32] {
|
||||
let mut s = Session::new();
|
||||
assert!(!s.is_enabled("file.newFromClipboard") && !s.is_enabled("edit.paste"), "nothing copied yet");
|
||||
assert!(s.execute("file.newFromClipboard", json!({})).is_err());
|
||||
s.execute("file.new", json!({"width": 100, "height": 100, "depth": depth})).unwrap();
|
||||
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("select.rect", json!({"x": 10, "y": 20, "width": 30, "height": 15})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
// With a document open: a second document, sized to the copy, its pixels at the origin.
|
||||
let r = s.execute("file.newFromClipboard", json!({})).unwrap();
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(30), Some(15)), "{r}");
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
let d = &s.active().unwrap().doc;
|
||||
assert_eq!((d.size.width, d.size.height, d.layers.len()), (30, 15, 1));
|
||||
assert_eq!(d.pixel_format().sample, s.documents()[0].doc.pixel_format().sample, "{depth}-bit kept");
|
||||
assert_eq!(active_bounds(&s), Rect::new(0, 0, 30, 15));
|
||||
let px = d.layers[0].surface().unwrap().rgba(0, 0);
|
||||
assert!(px[0] > 0.99 && px[1] < 0.01 && px[3] > 0.99, "{px:?}");
|
||||
// With nothing open, Paste makes the document too.
|
||||
while s.active().is_some() {
|
||||
s.execute("file.close", json!({})).unwrap();
|
||||
}
|
||||
assert!(s.is_enabled("edit.paste") && !s.is_enabled("edit.pasteSpecial.pasteInPlace"));
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
assert_eq!(active_bounds(&s), Rect::new(0, 0, 30, 15));
|
||||
assert!(s.execute("edit.pasteSpecial.pasteInPlace", json!({})).is_ok(), "into the new document");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_paste_in_place_and_centred() {
|
||||
let mut s = session();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
Help Nápověda
|
||||
(coming soon) (připravujeme)
|
||||
New… Nový…
|
||||
New from Clipboard Nový ze schránky
|
||||
Open… Otevřít…
|
||||
Open As… Otevřít jako…
|
||||
Open Recent Otevřít nedávné
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -23,6 +23,7 @@
|
|||
Help Ayuda
|
||||
(coming soon) (próximamente)
|
||||
New… Nuevo…
|
||||
New from Clipboard Nuevo desde el portapapeles
|
||||
Open… Abrir…
|
||||
Open As… Abrir como…
|
||||
Open Recent Archivos recientes
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 4 and column 47.
|
|
|
@ -27,6 +27,7 @@
|
|||
Help ヘルプ
|
||||
(coming soon) (近日対応)
|
||||
New… 新規…
|
||||
New from Clipboard クリップボードから新規作成
|
||||
Open… 開く…
|
||||
Open As… 形式を指定して開く…
|
||||
Open Recent 最近使ったファイルを開く
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -31,6 +31,7 @@
|
|||
|
||||
# File
|
||||
New… Создать…
|
||||
New from Clipboard Создать из буфера обмена
|
||||
New Создать
|
||||
Open… Открыть…
|
||||
Open Открыть
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -22,6 +22,7 @@
|
|||
Help 帮助
|
||||
(coming soon) (即将推出)
|
||||
New… 新建…
|
||||
New from Clipboard 从剪贴板新建
|
||||
Open… 打开…
|
||||
Open As… 打开为…
|
||||
Open Recent 打开最近文件
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -31,6 +31,7 @@
|
|||
Help 說明
|
||||
(coming soon) (即將推出)
|
||||
New… 新增…
|
||||
New from Clipboard 從剪貼簿新增
|
||||
Open… 開啟…
|
||||
Open As… 開啟為…
|
||||
Open Recent 最近開啟的檔案
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -493,11 +493,11 @@ impl PhotocraftApp {
|
|||
}
|
||||
let t0 = gpu_canvas::now_ms();
|
||||
// The OS clipboard is read only on an explicit paste, never in the background (privacy, CPU).
|
||||
if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace") {
|
||||
if matches!(id, "edit.paste" | "edit.pasteSpecial.pasteInPlace" | "file.newFromClipboard") {
|
||||
if !clip_read {
|
||||
self.import_os_clipboard();
|
||||
}
|
||||
if self.session.clipboard.is_none() && self.session.active().is_some() && self.services.clipboard_get_image.is_some() {
|
||||
if self.session.clipboard.is_none() && self.services.clipboard_get_image.is_some() {
|
||||
// Enabled on the strength of the OS clipboard, which held no image: a quiet no-op.
|
||||
self.ui.status = "Nothing to paste: the clipboard holds no image".into();
|
||||
self.ui.status_error = false;
|
||||
|
|
@ -1327,6 +1327,40 @@ mod clipboard_tests {
|
|||
assert!(!crate::menus::is_enabled(&plain, "edit.paste"));
|
||||
}
|
||||
|
||||
/// #368: an image copied in another app opens as a document of its own, from File › New
|
||||
/// from Clipboard or from Paste with nothing open.
|
||||
#[test]
|
||||
fn os_clipboard_image_becomes_a_new_document() {
|
||||
let (os, reads) = (OsClip::default(), Arc::new(std::sync::atomic::AtomicUsize::new(0)));
|
||||
let (b, n) = (os.clone(), Arc::clone(&reads));
|
||||
let get = move || {
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
b.lock().unwrap().clone()
|
||||
};
|
||||
let mut app = PhotocraftApp::new(Session::new(), Services { clipboard_get_image: Some(Box::new(get)), ..Default::default() });
|
||||
let ctx = egui::Context::default();
|
||||
// Listed right after File › New…, enabled without reading the clipboard.
|
||||
let items = crate::menus::menu_items(&app);
|
||||
let at = items.iter().position(|i| i.id == "file.new").unwrap();
|
||||
assert_eq!(items[at + 1].id, "file.newFromClipboard");
|
||||
assert!(items[at + 1].enabled && crate::menus::is_enabled(&app, "edit.paste"));
|
||||
assert!(!crate::menus::is_enabled(&app, "edit.pasteSpecial.pasteInPlace"), "Paste in Place needs a document");
|
||||
assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
// An empty clipboard is a quiet no-op.
|
||||
let r = crate::menus::invoke(&mut app, &ctx, "file.newFromClipboard", serde_json::json!({})).unwrap();
|
||||
assert_eq!(r["pasted"], serde_json::json!(false));
|
||||
assert!(app.session.documents().is_empty() && !app.ui.status_error);
|
||||
// Paste with nothing open makes the document.
|
||||
*os.lock().unwrap() = Some((5, 3, [0u8, 0, 255, 255].repeat(15)));
|
||||
crate::menus::invoke(&mut app, &ctx, "edit.paste", serde_json::json!({})).unwrap();
|
||||
let d = &app.session.active().unwrap().doc;
|
||||
assert_eq!((d.size.width, d.size.height, d.layers.len()), (5, 3, 1));
|
||||
assert_eq!(app.ui.views.len(), 1, "the new document has a view");
|
||||
// New from Clipboard with a document open adds another.
|
||||
crate::menus::invoke(&mut app, &ctx, "file.newFromClipboard", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_an_empty_or_non_image_os_clipboard_is_a_quiet_no_op() {
|
||||
let (os, reads) = (OsClip::default(), Arc::default());
|
||||
|
|
|
|||
|
|
@ -466,10 +466,10 @@ pub fn is_enabled(app: &PhotocraftApp, id: &str) -> bool {
|
|||
"view.proofSetup.custom" => app.session.active().is_some(),
|
||||
"view.rulers" | "view.show.grid" | "view.show.guides" | "view.snap" | "view.lockGuides" => true,
|
||||
// An image copied in another app can only be seen by reading the OS clipboard, which happens
|
||||
// on an explicit paste: with a clipboard service, Paste stays enabled whenever a document is open.
|
||||
"edit.paste" | "edit.pasteSpecial.pasteInPlace" => {
|
||||
app.session.is_enabled(id) || (app.services.clipboard_get_image.is_some() && app.session.active().is_some())
|
||||
}
|
||||
// on an explicit paste: with a clipboard service these stay enabled. Paste and New from
|
||||
// Clipboard need no document (with none open, Paste makes one); Paste in Place does.
|
||||
"edit.paste" | "file.newFromClipboard" => app.session.is_enabled(id) || app.services.clipboard_get_image.is_some(),
|
||||
"edit.pasteSpecial.pasteInPlace" => app.session.is_enabled(id) || (app.services.clipboard_get_image.is_some() && app.session.active().is_some()),
|
||||
"select.selectAndMask" => app.session.is_enabled("select.refineEdge"),
|
||||
"select.transformSelection" => app.ui.transform.is_none() && app.session.is_enabled("select.transformSelection"),
|
||||
i if (i.starts_with("view.zoom") || i == "view.fitOnScreen" || i == "view.actualPixels") || i == "window.newWindowForDocument" => {
|
||||
|
|
@ -589,6 +589,9 @@ pub fn is_live(id: &str) -> bool {
|
|||
|| crate::timeline_ui::handles(id)
|
||||
}
|
||||
|
||||
/// Commands outside the catalogue that belong right after a catalogue item: `(id, after)`.
|
||||
const PLACE_AFTER: &[(&str, &str)] = &[("file.newFromClipboard", "file.new")];
|
||||
|
||||
pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
|
||||
// 1) Photoshop's full menu tree, in Photoshop order; live where we implement the command.
|
||||
let known = is_live;
|
||||
|
|
@ -631,9 +634,11 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {
|
|||
for e in extra {
|
||||
let dup = items.iter().any(|i| i.id == e.id || (i.path == e.path && i.label.trim_end_matches('…') == e.label.trim_end_matches('…')));
|
||||
if !dup {
|
||||
// Insert after the last item of the same top-level menu, keeping menus contiguous.
|
||||
// Insert after the item it belongs next to, else after the last item of the same
|
||||
// top-level menu, keeping menus contiguous.
|
||||
let top = e.path.first().cloned();
|
||||
let at = items.iter().rposition(|i| i.path.first() == top.as_ref()).map_or(items.len(), |p| p + 1);
|
||||
let after = PLACE_AFTER.iter().find(|(id, _)| *id == e.id).and_then(|(_, a)| items.iter().position(|i| i.id == *a));
|
||||
let at = after.or_else(|| items.iter().rposition(|i| i.path.first() == top.as_ref())).map_or(items.len(), |p| p + 1);
|
||||
items.insert(at, e);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue