Compare commits

...
Author SHA1 Message Date
Brandon Thomas 6bf2626ff1 Merge remote-tracking branch 'origin/main' into finish-851 2026-10-07 23:30:59 -04:00
Brandon ThomasandClaude Opus 5.5 85ca0532d4 Tab labels: keep #832's full-title label on Photoshop-style tabs; the layer_transfer tests find a tab by it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 23:21:31 -04:00
Brandon Thomas 93d02b50dd Merge remote-tracking branch 'origin/main' into finish-851 2026-10-07 23:15:31 -04:00
Brandon Thomas fd124e452e Merge remote-tracking branch 'origin/main' into finish-851
# Conflicts:
#	crates/ui-egui/src/canvas.rs
2026-10-07 22:57:11 -04:00
Brandon Thomas 9c14f80f32 Merge remote-tracking branch 'origin/main' into finish-851
# Conflicts:
#	crates/ui-egui/src/canvas.rs
2026-10-07 21:33:50 -04:00
bflatasticandClaude Opus 5.5 b27989813d Drag layers onto another document's tab to copy them there (#589)
New engine command layer.copyToDocument copies layers into another
open document as one history step there, converting them to the
destination's colour profile and bit depth, and places them at the
same canvas position, centred, on a point, or at an offset.

In the UI, dragging layers from the Layers panel or with the Move tool
onto another document's tab shows that document (spring-loaded tabs),
and releasing over its canvas drops the copies under the pointer;
Shift keeps the position for same-size documents and centres them
otherwise, as in Photoshop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 05:26:44 +07:00
11 changed files with 702 additions and 14 deletions

No files matched your search

+1
View File
@@ -1027,6 +1027,7 @@ fn build() -> Vec<CommandSpec> {
v.extend(crate::type_caret_cmds::specs());
v.extend(crate::smart_cmds::specs());
v.extend(crate::layer_multi_cmds::specs());
v.extend(crate::layer_copy_cmds::specs());
v.extend(crate::prefs::specs());
v.extend(crate::edit_menu_cmds::specs());
v.extend(crate::fill_key_cmds::specs());
+178
View File
@@ -0,0 +1,178 @@
//! Copy layers into another open document (#589): what Photoshop does when layers are dragged from
//! the Layers panel, or with the Move tool from the canvas, onto another document's tab or window.
//! Photoshop has no menu item for it; `layer.copyToDocument` is the command the drag dispatches and
//! agents call.
//!
//! The copies keep their names, content, masks, styles and blending; they land above the
//! destination's active layer as one history step there, and the destination becomes the active
//! document. Pixels are converted to the destination's colour profile (Color Settings' intent and
//! black point compensation) and bit depth, as Photoshop converts dragged layers.
use photocraft_color::ColorMode;
use photocraft_doc::{Document, LayerId};
use photocraft_geom::Rect;
use serde_json::{Value, json};
use crate::commands::CommandSpec;
use crate::{EngineError, Result, Session};
const CMD: &str = "layer.copyToDocument";
fn bad(msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: CMD.into(), msg: msg.into() }
}
fn enabled(s: &Session) -> std::result::Result<(), String> {
let d = s.active().ok_or("no document open")?;
d.active_layer.ok_or("no active layer")?;
if s.documents().len() < 2 { Err("no other document is open".into()) } else { Ok(()) }
}
/// A document index param (`None` when absent), checked against the open documents.
fn doc_index(s: &Session, p: &Value, key: &str) -> Result<Option<usize>> {
let Some(v) = p.get(key) else { return Ok(None) };
let i = v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(format!("`{key}` must be a document index")))?;
if i >= s.documents().len() {
return Err(bad(format!("no document {i} (there are {})", s.documents().len())));
}
Ok(Some(i))
}
/// An `[x, y]` param of finite numbers within ±10⁷ pixels.
fn point(p: &Value, key: &str) -> Result<Option<[f64; 2]>> {
let Some(v) = p.get(key) else { return Ok(None) };
let xy = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some([a[0].as_f64()?, a[1].as_f64()?]));
match xy {
Some(xy) if xy.iter().all(|c| c.is_finite() && c.abs() <= 1e7) => Ok(Some(xy)),
_ => Err(bad(format!("`{key}` must be [x, y] (pixels)"))),
}
}
/// Modes whose layers convert through the colour engine.
fn layered(mode: ColorMode) -> bool {
matches!(mode, ColorMode::Rgb | ColorMode::Grayscale | ColorMode::Cmyk | ColorMode::Lab)
}
fn center(r: Rect) -> [f64; 2] {
[(f64::from(r.x0) + f64::from(r.x1)) / 2.0, (f64::from(r.y0) + f64::from(r.y1)) / 2.0]
}
/// `layer.copyToDocument`.
fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
let dest = doc_index(s, p, "document")?.ok_or_else(|| bad("missing `document` (the destination's index)"))?;
let src = match doc_index(s, p, "source")? {
Some(i) => i,
None => s.active_index().ok_or(EngineError::NoDocument)?,
};
if src == dest {
return Err(bad("the destination is the source document (use layer.duplicate)"));
}
let (from, to) = (&s.documents()[src], &s.documents()[dest]);
let (sdoc, ddoc) = (from.doc.clone(), to.doc.clone());
let ids: Vec<LayerId> = match p.get("layers") {
None => from.selected_layers(),
Some(v) => v
.as_array()
.map(|a| a.iter().map(|id| id.as_u64().map(LayerId)).collect::<Option<Vec<_>>>())
.and_then(|ids| ids)
.ok_or_else(|| bad("`layers` must be an array of layer ids"))?,
};
if let Some(missing) = ids.iter().find(|id| sdoc.layer(**id).is_none()) {
return Err(EngineError::NoLayer(*missing));
}
let ids = crate::layer_multi_cmds::top_level(&sdoc, &ids);
if ids.is_empty() {
return Err(bad("no layers to copy"));
}
for (doc, role) in [(&sdoc, "source"), (&ddoc, "destination")] {
if !layered(doc.mode) {
return Err(EngineError::Other(format!("layers can't be copied with a {:?} {role} document", doc.mode)));
}
}
// The copies, bottom to top, in a scratch document of the source's colour so they convert
// the way a whole document does.
let mut copies = Document::new("", sdoc.size, sdoc.mode, sdoc.depth);
copies.icc_profile = sdoc.icc_profile.clone();
// Type re-lays out at the source's resolution, so it keeps its size in pixels.
copies.resolution_dpi = sdoc.resolution_dpi;
for id in &ids {
let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?;
let mut l = src_layer.duplicate();
// A dragged Background arrives as an ordinary layer (the destination keeps its own).
if crate::extra_cmds::is_background(src_layer) && sdoc.layers.first().is_some_and(|b| b.id == *id) {
l.name = ddoc.next_layer_name("Layer");
l.locks = Default::default();
}
copies.layers.push(l);
}
if (copies.mode, &copies.icc_profile) != (ddoc.mode, &ddoc.icc_profile) {
let profile = crate::color_cmds::document_profile(&ddoc);
crate::color_cmds::convert_document(&mut copies, &profile, s.color.settings.intent(), s.color.settings.bpc)?;
}
if copies.depth != ddoc.depth {
crate::image_cmds::for_each_surface(&mut copies.layers, true, &mut |surf, _| {
let f = surf.format().with_sample(ddoc.depth);
*surf = surf.convert(f);
});
}
// Placement: centred on the destination's canvas, centred on a point, or offset from where
// the layers are in the source (default: the same canvas position).
let bounds = copies.layers.iter().filter_map(crate::layer_multi_cmds::layer_bounds).fold(Rect::EMPTY, |a, b| a.union(&b));
let content = if bounds.is_empty() { sdoc.bounds() } else { bounds };
let (dx, dy) = if p.get("center").and_then(Value::as_bool).unwrap_or(false) {
let (c, d) = (center(content), center(ddoc.bounds()));
(d[0] - c[0], d[1] - c[1])
} else if let Some(at) = point(p, "at")? {
let c = center(content);
(at[0] - c[0], at[1] - c[1])
} else {
point(p, "offset")?.map_or((0.0, 0.0), |o| (o[0], o[1]))
};
let (dx, dy) = (dx.round() as i32, dy.round() as i32);
if dx != 0 || dy != 0 {
let snapshot = copies.clone();
for l in &mut copies.layers {
crate::commands::translate_layer(&snapshot, l, dx, dy);
crate::vector_cmds::translate_vectors(&snapshot, l, f64::from(dx), f64::from(dy));
}
}
let patterns: Vec<_> = sdoc.patterns.iter().filter(|pat| !ddoc.patterns.iter().any(|d| d.id == pat.id)).cloned().collect();
s.set_active(dest);
let label = if ids.len() == 1 { "Duplicate Layer" } else { "Duplicate Layers" };
let edited = s.edit(label, |doc, active| {
doc.patterns.extend(patterns);
let mut above = *active;
let mut new = Vec::with_capacity(copies.layers.len());
for l in copies.layers {
let id = doc.insert_above(above, l);
above = Some(id);
new.push(id);
}
*active = above;
Ok(new)
});
// Nothing changed: the source stays the active document.
if edited.is_err() {
s.set_active(src);
}
let new = edited?;
let last = new.last().copied();
crate::layer_multi_cmds::reselect(s, new.clone(), last);
Ok(json!({"document": dest, "layers": new.iter().map(|l| l.0).collect::<Vec<_>>(), "offset": [dx, dy]}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: CMD,
label: "Copy Layers to Document",
menu: &[],
shortcut: None,
params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source)}"##,
enabled,
run: copy_to_document,
journal: true,
}]
}
#[cfg(test)]
mod tests;
+157
View File
@@ -0,0 +1,157 @@
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::{Document, LayerId, Pattern};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
use crate::Session;
/// Document 0 (`w`×`h`, `depth`) with a red 10×10 square at (4, 6) on layer "paint" above the
/// Background, and document 1 (60×50, 8-bit RGB). Document 0 is active.
fn two_docs(w: u32, h: u32, depth: u32) -> (Session, LayerId) {
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "depth": depth})).unwrap();
let paint = LayerId(s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap());
s.execute("select.rect", json!({"x": 4, "y": 6, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("file.new", json!({"width": 60, "height": 50})).unwrap();
s.set_active(0);
(s, paint)
}
fn doc(s: &Session, i: usize) -> &Document {
&s.documents()[i].doc
}
fn copied(r: &Value) -> LayerId {
LayerId(r["layers"][0].as_u64().unwrap())
}
fn bounds(s: &Session, i: usize, id: LayerId) -> Rect {
crate::layer_multi_cmds::layer_bounds(doc(s, i).layer(id).unwrap()).unwrap()
}
#[test]
fn copies_the_selected_layers_above_the_destinations_active_layer_in_one_step() {
let (mut s, paint) = two_docs(40, 30, 8);
let source_before = doc(&s, 0).clone();
let r = s.execute("layer.copyToDocument", json!({"document": 1})).unwrap();
assert_eq!(r["document"], 1);
assert_eq!(s.active_index(), Some(1), "the destination becomes the active document");
let d = doc(&s, 1);
let id = copied(&r);
assert_ne!(id, paint, "a copy has its own id");
assert_eq!(d.layers.len(), 2);
assert_eq!(d.layers[1].id, id, "above the Background, the destination's active layer");
assert_eq!(d.layers[1].name, "paint");
assert_eq!(s.active().unwrap().active_layer, Some(id));
assert_eq!(bounds(&s, 1, id), Rect::new(4, 6, 14, 16), "same canvas position by default");
assert_eq!(d.layers[1].surface().unwrap().read_region(Rect::new(5, 7, 6, 8)), vec![1.0, 0.0, 0.0, 1.0]);
assert_eq!(*doc(&s, 0), source_before, "the source is untouched");
// One history step in the destination.
assert!(s.undo());
assert_eq!(doc(&s, 1).layers.len(), 1);
}
#[test]
fn converts_to_the_destinations_depth_and_colour_mode() {
for depth in [8, 16, 32] {
let (mut s, _) = two_docs(40, 30, depth);
s.set_active(1);
s.execute("image.mode.grayscale", json!({})).unwrap();
s.execute("image.mode.bits16", json!({})).unwrap();
s.set_active(0);
let r = s.execute("layer.copyToDocument", json!({"document": 1})).unwrap();
let id = copied(&r);
let surf = doc(&s, 1).layer(id).unwrap().surface().unwrap();
assert_eq!(surf.format(), PixelFormat::new(ColorMode::Grayscale, SampleType::U16, true), "from {depth}-bit RGB");
let px = surf.read_region(Rect::new(5, 7, 6, 8));
assert!(px[0] > 0.1 && px[0] < 0.9 && px[1] == 1.0, "red as a mid grey, opaque: {px:?}");
}
}
#[test]
fn places_the_copies_centred_on_the_canvas_on_a_point_or_offset() {
let (mut s, paint) = two_docs(40, 30, 8);
let place = |s: &mut Session, p: Value| {
s.set_active(0);
let mut p = p;
p["document"] = json!(1);
p["layers"] = json!([paint.0]);
let r = s.execute("layer.copyToDocument", p).unwrap();
bounds(s, 1, copied(&r))
};
assert_eq!(place(&mut s, json!({"center": true})), Rect::new(25, 20, 35, 30));
assert_eq!(place(&mut s, json!({"at": [10.0, 40.0]})), Rect::new(5, 35, 15, 45));
assert_eq!(place(&mut s, json!({"offset": [-4, 20]})), Rect::new(0, 26, 10, 36));
assert_eq!(doc(&s, 1).layers.len(), 4);
}
#[test]
fn a_copied_background_is_an_ordinary_layer_and_several_layers_keep_their_order() {
let (mut s, paint) = two_docs(40, 30, 8);
let bg = doc(&s, 0).layers[0].id;
let r = s.execute("layer.copyToDocument", json!({"document": 1, "layers": [paint.0, bg]})).unwrap();
let d = doc(&s, 1);
let names: Vec<&str> = d.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Background", "Layer 1", "paint"]);
assert!(!d.layers[1].locks.transparency && !d.layers[1].locks.position);
assert_eq!(r["layers"].as_array().unwrap().len(), 2);
assert_eq!(s.active().unwrap().selected_layers().len(), 2, "the copies are selected");
}
#[test]
fn the_sources_patterns_come_along_once() {
let (mut s, _) = two_docs(40, 30, 8);
let mut tile = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true));
tile.fill_rect(Rect::new(0, 0, 2, 2), &[0.0, 1.0, 0.0, 1.0]);
let pat = Pattern::new("dots", tile, 2, 2);
s.edit("add pattern", |d, _| {
d.patterns.push(pat.clone());
Ok(())
})
.unwrap();
s.execute("layer.copyToDocument", json!({"document": 1})).unwrap();
assert_eq!(doc(&s, 1).patterns.iter().filter(|p| p.id == pat.id).count(), 1);
s.set_active(0);
s.execute("layer.copyToDocument", json!({"document": 1})).unwrap();
assert_eq!(doc(&s, 1).patterns.iter().filter(|p| p.id == pat.id).count(), 1, "not twice");
}
#[test]
fn bad_params_and_states_fail_without_changing_anything() {
let (mut s, _) = two_docs(40, 30, 8);
let cmd = "layer.copyToDocument";
for p in [
json!({}),
json!({"document": 0}),
json!({"document": 7}),
json!({"document": -1}),
json!({"document": "1"}),
json!({"document": 1, "source": 9}),
json!({"document": 1, "layers": [999_999]}),
json!({"document": 1, "layers": "all"}),
json!({"document": 1, "layers": []}),
json!({"document": 1, "at": [1.0]}),
json!({"document": 1, "at": [f64::MAX, 0.0]}),
json!({"document": 1, "offset": ["a", 2]}),
Value::Null,
] {
assert!(s.execute(cmd, p.clone()).is_err(), "{p}");
assert_eq!(s.active_index(), Some(0), "{p}: the source stays active");
assert_eq!(doc(&s, 1).layers.len(), 1, "{p}");
}
// An Indexed destination has no layers to add to.
s.set_active(1);
s.edit("indexed", |d, _| {
d.mode = ColorMode::Indexed;
Ok(())
})
.unwrap();
s.set_active(0);
assert!(s.execute(cmd, json!({"document": 1})).is_err());
// A single open document: nothing to copy to.
s.close(1);
assert!(!s.is_enabled(cmd));
}
+1 -1
View File
@@ -60,7 +60,7 @@ pub fn set_selection(s: &mut Session, ids: Vec<LayerId>, active: Option<LayerId>
/// After a structural edit, make `ids` the selection (dropping any that no longer exist), which
/// is also what the edit's history state targets.
fn reselect(s: &mut Session, ids: Vec<LayerId>, active: Option<LayerId>) {
pub(crate) fn reselect(s: &mut Session, ids: Vec<LayerId>, active: Option<LayerId>) {
if let Some(st) = s.active_mut() {
st.selected_layers = ids;
if let Some(a) = active {
+1
View File
@@ -44,6 +44,7 @@ pub mod group_view_cmds;
pub mod image_cmds;
pub mod inspect;
pub mod jobs;
pub mod layer_copy_cmds;
pub mod layer_menu_cmds;
pub mod layer_multi_cmds;
pub mod layer_nav_cmds;
+39 -6
View File
@@ -941,8 +941,25 @@ pub(crate) fn retain_gpu_documents(app: &mut PhotocraftApp) {
}
}
/// Tabs + canvas for the active document, or the start screen.
/// Tabs + canvas for the active document, or the start screen; then drops layers dragged onto
/// another document (`layer_transfer`).
pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
documents(app, ui);
crate::layer_transfer::finish(app, ui.ctx());
}
/// Window › Arrange tiles: each document shown with its tile in `rect`, or `None` when the
/// active document fills the area.
pub(crate) fn arranged_cells(app: &PhotocraftApp, rect: Rect) -> Option<Vec<(usize, Rect)>> {
let idx = app.session.active_index()?;
let n = app.session.documents().len();
let cells = crate::view_cmds::cells(&app.ui.view.arrange, rect, n)?;
let mut shown: Vec<(usize, Rect)> = (0..n).map(|k| (idx + k) % n).zip(cells).collect();
shown.sort_by_key(|(d, _)| *d);
Some(shown)
}
fn documents(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
app.drop_canvas_rect = None;
app.tab_strip = None;
retain_gpu_documents(app);
@@ -971,13 +988,9 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let rect = ui.available_rect_before_wrap();
app.last_canvas_rect = rect;
app.drop_canvas_rect = Some(rect);
let n = app.session.documents().len();
// Window › Arrange: tiled / n-up layouts show several documents side by side; the active one
// takes input, a click elsewhere activates that document.
if let Some(cells) = crate::view_cmds::cells(&app.ui.view.arrange, rect, n) {
let order: Vec<usize> = (0..n).map(|k| (idx + k) % n).collect();
let mut shown: Vec<(usize, Rect)> = order.into_iter().zip(cells).collect();
shown.sort_by_key(|(d, _)| *d);
if let Some(shown) = arranged_cells(app, rect) {
let t = crate::theme::Tokens::get(ui.ctx());
for (d, cell) in shown {
let cell = cell.shrink(1.0);
@@ -1011,6 +1024,9 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let tab_count = app.session.documents().len();
let (mut focus_open, mut cancel_open) = (None, None);
let focused_open = app.jobs.focus.is_some();
// Layers dragged over a tab show its document (`layer_transfer`).
let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx());
let mut drag_over = None;
let mut doc_tabs = Vec::with_capacity(tab_count);
let frame = egui::Frame::NONE.fill(t.canvas).inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 4 }).show(ui, |ui| {
ui.horizontal(|ui| {
@@ -1023,6 +1039,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let meta_g = ui.painter().layout_no_wrap(meta, egui::FontId::proportional(10.5), t.text_faint);
let w = name_g.size().x + meta_g.size().x + 44.0;
let (r, resp) = ui.allocate_exact_size(egui::vec2(w, 26.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, &st.doc.name));
doc_tabs.push(r);
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.card);
@@ -1030,6 +1047,10 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.hovered() {
ui.painter().rect_filled(r, t.radius_sm, t.hover.gamma_multiply(0.5));
}
if dragging.is_some_and(|p| r.contains(p)) {
drag_over = Some(i);
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
let color = if sel { t.text } else { t.text_dim };
let ny = r.center().y - name_g.size().y / 2.0;
ui.painter().galley_with_override_text_color(egui::pos2(r.left() + 10.0, ny), name_g.clone(), color);
@@ -1081,6 +1102,9 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
});
});
open_tab_clicks(app, activate, focus_open, cancel_open);
if let Some(i) = drag_over {
crate::layer_transfer::over_tab(app, ui.ctx(), i);
}
if let Some(i) = close {
let _ = crate::menus::invoke(app, ui.ctx(), "file.close", json!({"document": i}));
}
@@ -1143,6 +1167,8 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let (mut activate, mut close) = (None, None);
let mut tab_action = None;
let tab_count = app.session.documents().len();
let dragging = crate::layer_transfer::pointer_if_armed(app, ui.ctx());
let mut drag_over = None;
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
let (strip, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 26.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
@@ -1168,6 +1194,10 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.35));
}
if dragging.is_some_and(|p| r.contains(p)) {
drag_over = Some(i);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
}
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.separator));
let (xr, at) = pro_tab_layout(r, g.size().y, mac);
let xresp = ui.interact(xr, ui.id().with(("ptabx", i)), Sense::click());
@@ -1213,6 +1243,9 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
x = r.right();
}
open_tab_clicks(app, activate, focus_open, cancel_open);
if let Some(i) = drag_over {
crate::layer_transfer::over_tab(app, ui.ctx(), i);
}
if let Some(i) = close {
let _ = crate::menus::invoke(app, ui.ctx(), "file.close", json!({"document": i}));
}
+180
View File
@@ -0,0 +1,180 @@
//! Dragging layers to another document (#589), as in Photoshop: drag layers from the Layers panel,
//! or with the Move tool from the canvas, onto another document's tab. The tab shows its document
//! while the pointer is over it (spring-loaded), and releasing over that document's canvas copies
//! the layers there with `layer.copyToDocument` (one history step in the destination).
//!
//! Where the copies land: a Move-tool drag puts the pixel that was grabbed under the pointer, and
//! a Layers panel drag centres the layers on it. With ⇧, or when released on the tab itself, they
//! keep their place when both documents have the same pixel size and are centred otherwise
//! (Photoshop's ⇧-drag). With Window › Arrange tiling several documents, releasing over another
//! document's tile drops there.
//!
//! A Move-tool drag starting inside a selection or moving a floating piece stays a move: Photoshop
//! would copy just the selected pixels, which is not supported yet.
use photocraft_doc::{DocId, LayerId};
use serde_json::json;
use crate::PhotocraftApp;
use crate::state::Tool;
/// The layers being dragged and where from.
#[derive(Clone, Debug)]
struct Transfer {
source: DocId,
layers: Vec<u64>,
/// The document point a Move-tool drag grabbed (`None` for a Layers panel drag).
grab: Option<[f64; 2]>,
/// What the ghost by the pointer says.
label: String,
}
fn key() -> egui::Id {
egui::Id::new("pc-layer-transfer")
}
fn get(ctx: &egui::Context) -> Option<Transfer> {
ctx.data(|d| d.get_temp::<Transfer>(key()))
}
fn clear(ctx: &egui::Context) {
ctx.data_mut(|d| d.remove::<Transfer>(key()));
}
/// The layers `id` stands for: the selection when it is one of the selected layers, else itself.
fn begin(app: &PhotocraftApp, ctx: &egui::Context, id: LayerId, grab: Option<[f64; 2]>) -> bool {
let Some(st) = app.session.active() else { return false };
let layers = if st.is_layer_selected(id) { st.selected_layers() } else { vec![id] };
let label = match layers.as_slice() {
[one] => st.doc.layer(*one).map_or_else(String::new, |l| l.name.clone()),
many => crate::i18n::trn(crate::i18n::current(), many.len() as u64, "{n} layer", "{n} layers"),
};
let t = Transfer { source: st.doc.id, layers: layers.iter().map(|l| l.0).collect(), grab, label };
ctx.data_mut(|d| d.insert_temp(key(), t));
true
}
/// A Layers panel row drag began on `layer`: it can be dropped on another document.
pub fn begin_from_panel(app: &PhotocraftApp, ctx: &egui::Context, layer: LayerId) {
begin(app, ctx, layer, None);
}
/// A Move-tool drag of whole layers (not a selection or a floating piece) under way.
fn move_drag(app: &PhotocraftApp) -> Option<[f64; 2]> {
let d = app.drag.as_ref().filter(|d| d.tool == Tool::Move && d.sel_move.is_none())?;
let st = app.session.active()?;
let plain = app.ui.transform.is_none() && app.guide_drag.is_none() && st.doc.selection.is_none() && st.floating.is_none();
plain.then_some(d.start)
}
/// The pointer, while layers are dragged (or a Move drag could become a transfer): tabs only
/// hit-test it then.
pub fn pointer_if_armed(app: &PhotocraftApp, ctx: &egui::Context) -> Option<egui::Pos2> {
if get(ctx).is_none() && move_drag(app).is_none() {
return None;
}
ctx.input(|i| i.pointer.latest_pos())
}
/// The pointer is over document tab `index` while layers are dragged: a Move drag becomes a
/// transfer (the source layers stay where they were), the tab's document is shown, and a release
/// right here drops the layers into it.
pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) {
if get(ctx).is_none() {
let Some(grab) = move_drag(app) else { return };
let Some(active) = app.session.active().and_then(|st| st.active_layer) else { return };
if app.session.active_index() == Some(index) {
return;
}
// The Move drag ends without moving anything; an ⌥-drag's copy is taken back.
app.drag = None;
app.move_preview = None;
crate::move_mods::abandon(app);
let active = app.session.active().and_then(|st| st.active_layer).unwrap_or(active);
if !begin(app, ctx, active, Some(grab)) {
return;
}
}
if app.session.active_index() != Some(index) {
app.session.set_active(index);
app.jobs.focus = None;
// The Layers panel now lists the other document: its rows don't take the drag.
ctx.data_mut(|d| d.remove::<u64>(egui::Id::new("layer-drag")));
}
if ctx.input(|i| i.pointer.any_released()) {
drop(app, ctx, index, None);
}
}
/// Every frame after the document area: a release over another document's canvas drops the
/// layers there; while the button is held a ghost follows the pointer.
pub fn finish(app: &mut PhotocraftApp, ctx: &egui::Context) {
let Some(t) = get(ctx) else { return };
let (pos, released, down) = ctx.input(|i| (i.pointer.latest_pos(), i.pointer.any_released(), i.pointer.primary_down()));
let target = pos.and_then(|p| target_at(app, p));
if !released {
if !down {
clear(ctx);
} else if let Some(p) = pos
&& app.session.active().is_some_and(|st| st.doc.id != t.source)
{
ghost(ctx, p, &t.label);
if target.is_some() {
ctx.set_cursor_icon(egui::CursorIcon::Copy);
}
}
return;
}
if let Some((dest, at)) = target {
drop(app, ctx, dest, at);
}
clear(ctx);
}
/// The document under screen point `p` that isn't the source, and the document point there
/// (`None` in a tile: tiles keep their own views).
fn target_at(app: &PhotocraftApp, p: egui::Pos2) -> Option<(usize, Option<[f64; 2]>)> {
if !app.last_canvas_rect.contains(p) {
return None;
}
match crate::canvas::arranged_cells(app, app.last_canvas_rect) {
Some(cells) => cells.into_iter().find(|(_, r)| r.contains(p)).map(|(d, _)| (d, None)),
None => Some((app.session.active_index()?, crate::canvas::ViewXform::active(app).map(|xf| xf.to_doc(p)))),
}
}
/// Copy the dragged layers into document `dest`, at document point `at` (see the module docs).
fn drop(app: &mut PhotocraftApp, ctx: &egui::Context, dest: usize, at: Option<[f64; 2]>) {
let Some(t) = get(ctx) else { return };
clear(ctx);
let docs = app.session.documents();
let Some(src) = docs.iter().position(|d| d.doc.id == t.source) else { return };
let Some(to) = docs.get(dest).filter(|_| dest != src) else { return };
let same_size = to.doc.size == docs[src].doc.size;
let mut p = json!({"document": dest, "source": src, "layers": t.layers});
match at.filter(|_| !ctx.input(|i| i.modifiers.shift)) {
Some(at) => match t.grab {
Some(g) => p["offset"] = json!([(at[0] - g[0]).round(), (at[1] - g[1]).round()]),
None => p["at"] = json!(at),
},
None => p["center"] = json!(!same_size),
}
if let Err(e) = app.run("layer.copyToDocument", p) {
app.ui.status = e;
app.ui.status_error = true;
}
}
/// The dragged layers' name next to the pointer (also the Layers panel's reorder drag).
pub fn ghost(ctx: &egui::Context, p: egui::Pos2, label: &str) {
let t = crate::theme::Tokens::get(ctx);
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("layer-transfer-ghost")));
let g = painter.layout_no_wrap(label.to_string(), egui::FontId::proportional(12.0), t.text);
let r = egui::Rect::from_min_size(p + egui::vec2(12.0, -10.0), g.size() + egui::vec2(16.0, 8.0));
painter.rect_filled(r, t.radius_sm, t.card.gamma_multiply(0.95));
painter.rect_stroke(r, t.radius_sm, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
painter.galley(r.min + egui::vec2(8.0, 4.0), g, t.text);
}
#[cfg(test)]
mod tests;
+132
View File
@@ -0,0 +1,132 @@
//! #589: layers dragged onto another document's tab, then dropped on its canvas, are copied there.
use egui::accesskit::Role;
use egui::{Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::LayerId;
use serde_json::json;
use crate::PhotocraftApp;
use crate::canvas::ViewXform;
use crate::state::Tool;
/// "source-doc" (40×30) with a red 10×10 square at (4, 6) on layer "paint", and "dest-doc"
/// (60×50). The source is active.
fn harness() -> (Harness<'static, PhotocraftApp>, LayerId) {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 40, "height": 30, "name": "source-doc"})).unwrap();
let paint = LayerId(s.execute("layer.new.layer", json!({"name": "paint"})).unwrap()["layer"].as_u64().unwrap());
s.execute("select.rect", json!({"x": 4, "y": 6, "width": 10, "height": 10})).unwrap();
s.execute("edit.fill", json!({"color": "#ff0000"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("file.new", json!({"width": 60, "height": 50, "name": "dest-doc"})).unwrap();
s.set_active(0);
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
PhotocraftApp::new(s, crate::Services::default())
});
h.run_steps(8);
(h, paint)
}
/// A document tab, found by its accessible title ("name @ 100% (…)", see `pro_tabs`).
fn tab(h: &Harness<'_, PhotocraftApp>, name: &str) -> Pos2 {
h.get_by_label_contains(&format!("{name} @")).rect().center()
}
/// Press at `path[0]`, move through the rest (a few frames at each) and release at the last
/// point with `release` held.
fn drag_along(h: &mut Harness<'_, PhotocraftApp>, path: &[Pos2], release: Modifiers) {
let first = path[0];
h.event(egui::Event::PointerMoved(first));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: first, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.run_steps(1);
for w in path.windows(2) {
for k in 1..=4 {
h.event(egui::Event::PointerMoved(w[0] + (w[1] - w[0]) * (k as f32 / 4.0)));
h.run_steps(1);
}
h.run_steps(2);
}
let last = path[path.len() - 1];
h.event(egui::Event::ModifiersChanged(release));
h.event(egui::Event::PointerButton { pos: last, button: PointerButton::Primary, pressed: false, modifiers: release });
h.run_steps(3);
h.event(egui::Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(1);
}
/// The copy in "dest-doc" (its active layer) and its bounds.
fn copy(h: &Harness<'_, PhotocraftApp>) -> (String, Rect) {
let st = h.state().session.active().unwrap();
assert_eq!(st.doc.name, "dest-doc", "the destination is shown");
let l = st.active_layer.and_then(|id| st.doc.layer(id)).unwrap();
let b = photocraft_engine::layer_multi_cmds::layer_bounds(l).unwrap();
(l.name.clone(), Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32)))
}
fn source_square(h: &Harness<'_, PhotocraftApp>, paint: LayerId) -> photocraft_geom::Rect {
let doc = &h.state().session.documents()[0].doc;
photocraft_engine::layer_multi_cmds::layer_bounds(doc.layer(paint).unwrap()).unwrap()
}
#[test]
fn a_layers_panel_drag_onto_a_tab_drops_the_layer_where_it_is_released() {
let (mut h, _) = harness();
let row = h.get_by_role_and_label(Role::Button, "paint").rect().center();
let over_tab = tab(&h, "dest-doc");
let drop = h.state().last_canvas_rect.center();
drag_along(&mut h, &[row, over_tab, drop], Modifiers::NONE);
let at = ViewXform::active(h.state()).unwrap().to_doc(drop);
let (name, r) = copy(&h);
assert_eq!(name, "paint");
assert!((r.center().x - at[0] as f32).abs() <= 1.0 && (r.center().y - at[1] as f32).abs() <= 1.0, "centred on the drop point {at:?}: {r:?}");
assert_eq!(h.state().session.documents()[0].doc.layers.len(), 2, "the source keeps its layers");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), 2);
}
#[test]
fn shift_centres_the_copy_when_the_documents_differ_in_size() {
let (mut h, _) = harness();
let row = h.get_by_role_and_label(Role::Button, "paint").rect().center();
let over_tab = tab(&h, "dest-doc");
let drop = h.state().last_canvas_rect.center() + vec2(80.0, 60.0);
drag_along(&mut h, &[row, over_tab, drop], Modifiers::SHIFT);
assert_eq!(copy(&h).1, Rect::from_min_max(egui::pos2(25.0, 20.0), egui::pos2(35.0, 30.0)));
}
#[test]
fn a_move_tool_drag_onto_a_tab_copies_the_layer_under_the_pointer_and_leaves_the_source() {
let (mut h, paint) = harness();
h.state_mut().ui.tool = Tool::Move;
h.run_steps(2);
let before = source_square(&h, paint);
let steps = h.state().session.documents()[0].history.entries().len();
let grab = ViewXform::active(h.state()).unwrap().to_screen(9.0, 11.0);
let grab_doc = ViewXform::active(h.state()).unwrap().to_doc(grab);
let over_tab = tab(&h, "dest-doc");
let drop = h.state().last_canvas_rect.center();
drag_along(&mut h, &[grab, over_tab, drop], Modifiers::NONE);
let at = ViewXform::active(h.state()).unwrap().to_doc(drop);
let (name, r) = copy(&h);
assert_eq!(name, "paint");
// The grabbed pixel lands under the pointer.
let want = egui::pos2((9.0 + at[0] - grab_doc[0]) as f32, (11.0 + at[1] - grab_doc[1]) as f32);
assert!((r.center() - want).length() <= 1.5, "{r:?} around {want:?}");
assert_eq!(source_square(&h, paint), before, "the source layer did not move");
assert_eq!(h.state().session.documents()[0].history.entries().len(), steps, "no Move step in the source");
}
#[test]
fn dropping_back_on_the_source_copies_nothing() {
let (mut h, _) = harness();
let row = h.get_by_role_and_label(Role::Button, "paint").rect().center();
let (dest_tab, source_tab) = (tab(&h, "dest-doc"), tab(&h, "source-doc"));
let drop = h.state().last_canvas_rect.center();
drag_along(&mut h, &[row, dest_tab, source_tab, drop], Modifiers::NONE);
let docs = h.state().session.documents();
assert_eq!((docs[0].doc.layers.len(), docs[1].doc.layers.len()), (2, 1));
assert_eq!(h.state().session.active_index(), Some(0));
}
+1
View File
@@ -73,6 +73,7 @@ pub mod layer_props_ui;
mod layer_reveal;
pub mod layer_row_ui;
pub mod layer_style;
mod layer_transfer;
pub mod layer_tree_ui;
pub mod links;
pub mod liquify_ui;
+8
View File
@@ -153,6 +153,14 @@ pub fn finish(app: &mut PhotocraftApp) {
}
}
/// Call when a Move drag ends without moving (its layers were dragged to another document): an
/// ⌥-drag's copy is taken back.
pub fn abandon(app: &mut PhotocraftApp) {
if std::mem::take(&mut app.move_mods).dup_from.is_some() {
app.session.undo();
}
}
/// Arrow keys with the Move tool (or while Free Transform is active): nudge 1 px, ⇧ 10 px;
/// ⌥ duplicates the layers first. Returns true when a key was used.
pub fn arrow_keys(app: &mut PhotocraftApp, ctx: &egui::Context) -> bool {
+4 -7
View File
@@ -1714,6 +1714,9 @@ fn layer_row(
let row_h = if t.pro { 32.0 } else { 46.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click_and_drag());
layer_drag_and_drop(ctx, ui, l, rect, &resp, actions);
if resp.drag_started() {
crate::layer_transfer::begin_from_panel(app, ctx, l.id);
}
// Rows are painted: name them for screen readers and UI tests.
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &l.name));
// A row scrolled out of view only keeps its place (#125): a layout's hundreds of rows would
@@ -2452,13 +2455,7 @@ fn layer_drag_and_drop(ctx: &egui::Context, ui: &egui::Ui, l: &Layer, rect: Rect
if dragged == l.id.0 {
// Ghost label following the pointer.
if let Some(p) = pointer {
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("layer-drag-ghost"));
let painter = ctx.layer_painter(layer);
let g = painter.layout_no_wrap(l.name.clone(), egui::FontId::proportional(12.0), t.text);
let r = Rect::from_min_size(p + vec2(12.0, -10.0), g.size() + vec2(16.0, 8.0));
painter.rect_filled(r, t.radius_sm, t.card.gamma_multiply(0.95));
painter.rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.accent), StrokeKind::Inside);
painter.galley(r.min + vec2(8.0, 4.0), g, t.text);
crate::layer_transfer::ghost(ctx, p, &l.name);
}
return;
}