diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 38b3c83d7..8b3a3932f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -433,6 +433,7 @@ impl VectorcraftApp { pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) { let t = theme::Tokens::get(ui.ctx()); ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); + self.canvas.windows.strip = None; if self.ui.screen_mode < 3 { chrome::doc_tabs(self, ui); } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c65d0f515..eecaa22fd 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -30,7 +30,7 @@ use status_bar::{StatusReadoutWidths, status_readouts}; #[cfg(test)] use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_title}; pub use tabs::{doc_tabs, isolation_bar, step_document}; -pub(crate) use tabs::{document_window_title, show_document}; +pub(crate) use tabs::{document_window_title, show_document, window_tab}; /// The application bar's height, in points (compact, as in PhotoCraft). pub const APP_BAR_HEIGHT: f32 = 32.0; diff --git a/crates/ui-egui/src/chrome/tabs.rs b/crates/ui-egui/src/chrome/tabs.rs index d8b214e9f..cac594916 100644 --- a/crates/ui-egui/src/chrome/tabs.rs +++ b/crates/ui-egui/src/chrome/tabs.rs @@ -25,6 +25,51 @@ pub(crate) fn document_window_title(app: &VectorcraftApp, i: usize) -> Option (f32, f32) { + if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) } +} + +/// The tab at the top of document `i`'s floating OS window ([`crate::doc_windows`]): a strip with +/// the document's tab, as the main window's strip draws it but without its ×. → the tab's +/// response, which senses clicks and drags. +pub(crate) fn window_tab(app: &VectorcraftApp, ui: &mut Ui, i: usize) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (height, title_size) = tab_size(app); + let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); + ui.painter().rect_filled(strip, 0.0, t.tab_strip); + ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); + let title = document_window_title(app, i).unwrap_or_default(); + let galley = ui.painter().layout_no_wrap(title, theme::semibold(title_size), t.text_strong); + let r = egui::Rect::from_min_size(strip.min, vec2((galley.size().x + 28.0).min(strip.width()), strip.height() - 1.0)); + ui.painter().rect_filled(r, 0.0, t.panel); + ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.5, t.border)); + ui.painter().galley(egui::pos2(r.left() + 14.0, r.center().y - galley.size().y / 2.0), galley, t.text); + ui.interact(r, ui.scope_id().with("window-tab"), Sense::click_and_drag()) +} + +/// While document tab `r` is dragged off the tab `strip` far enough to float its document, an +/// outline under the pointer where its window will open; → the window's top-left corner when the +/// drag ends there. +fn tear_off(ui: &Ui, resp: &egui::Response, strip: egui::Rect) -> Option { + use crate::doc_windows::{GRAB, torn_off}; + if resp.dragged() + && let Some(p) = resp.interact_pointer_pos().filter(|p| torn_off(strip, *p)) + { + let t = Tokens::get(ui.ctx()); + let ghost = egui::Rect::from_min_size(p - GRAB, vec2(240.0, 160.0)); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, ui.scope_id().with("tab-tear-off"))); + painter.rect_filled(ghost, 0.0, t.accent.gamma_multiply(0.12)); + painter.rect_stroke(ghost, 0.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + if !resp.drag_stopped() { + return None; + } + let p = resp.interact_pointer_pos().or_else(|| ui.input(|i| i.pointer.latest_pos()))?; + torn_off(strip, p).then_some(p - GRAB) +} + /// Whether a document tab's × comes before its title (macOS) rather than after it (Windows, /// Linux and the web). pub(super) const CLOSE_BEFORE_TITLE: bool = cfg!(target_os = "macos"); @@ -44,13 +89,16 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option, room: f32) /// taller, with larger titles. Tabs that don't fit are reached from a » button at the strip's /// right end, which lists every open document, and the active tab is always in view (#746). /// Documents in floating windows have no tab ([`crate::doc_windows`]); while the current document -/// is in one, the tab of the document the strip shows is the highlighted one. +/// is in one, the tab of the document the strip shows is the highlighted one. A tab dragged more +/// than [`crate::doc_windows::TEAR_OFF`] above or below the strip floats its document in a window +/// under the pointer. pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { /// The width of the » button. const MORE: f32 = 30.0; let t = Tokens::get(ui.ctx()); - let (height, title_size) = if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) }; + let (height, title_size) = tab_size(app); let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover()); + app.canvas.windows.strip = Some(strip); ui.painter().rect_filled(strip, 0.0, t.tab_strip); ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border)); // On the Home screen no tab is the current one. @@ -74,6 +122,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { let mut x = strip.left(); let mut activate = None; let mut close = None; + let mut float = None; let mut hovering_art = false; for (k, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) { if x + w > strip.left() + room && Some(k) != active { @@ -82,7 +131,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(&i) = docked.get(k) else { break }; let is_active = Some(k) == active; let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(*w, strip.height() - 1.0)); - let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("tab", i)), Sense::click_and_drag()); if is_active { ui.painter().rect_filled(r, 0.0, t.panel); } else if resp.hovered() { @@ -101,6 +150,8 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { close = Some(i); } else if resp.clicked() { activate = Some(i); + } else if let Some(at) = tear_off(ui, &resp, strip) { + float = Some((i, at)); } x += w; } @@ -131,6 +182,10 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { if let Err(e) = crate::unsaved::close(app, i) { app.status(e); } + } else if let Some((i, at)) = float { + if let Err(e) = app.run("window.arrange.floatInWindow", json!({"index": i, "at": [at.x, at.y]})) { + app.status(e); + } } else if let Some(i) = activate { show_document(app, i); } diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 24fa105c3..2fb682e77 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -5,11 +5,21 @@ //! ([`crate::panel_docking`]). The current document's window holds the canvas //! ([`crate::canvas::show`]); each other window draws its document as a Tile pane does //! ([`crate::canvas::pane`]), and a press in it makes its document the current one. +//! +//! A document tab dragged off the tab strip floats its document in a window under the pointer, +//! and a document window dragged onto the tab strip by its title bar (an OS window by the tab at +//! its top) goes back under the tabs. use serde_json::{Value, json}; use crate::VectorcraftApp; +/// How far above or below the tab strip, in points, a dragged document tab must be released to +/// float its document. +pub(crate) const TEAR_OFF: f32 = 24.0; +/// Where a torn-off document's window opens: this far up and left of the pointer, so the pointer +/// is over its title bar. +pub(crate) const GRAB: egui::Vec2 = egui::vec2(40.0, 12.0); /// How far apart Cascade sets the windows, down and to the right, in points. pub(crate) const CASCADE_STEP: f32 = 30.0; /// How far the first cascaded window is from the document area's top-left corner, in points. @@ -24,6 +34,9 @@ pub struct DocWindow { pub uid: u64, /// Where the window goes, in the main window's points. pub place: egui::Rect, + /// The window's own number ([`Windows::opened`]): a document floated again gets a new window, + /// which opens where it is asked to, not where its last window was. + number: u32, /// Counts the moves Cascade gives the window after it opened. generation: u32, /// The OS window's position on the screen when it opened or last moved, and the generation of @@ -36,6 +49,9 @@ pub struct DocWindow { /// The size of the body its document was last fitted to, and the zoom and center that fit /// gave: a window resized while its view is still there fits its document again. fit: Option, + /// While its OS window's tab is dragged: where the pointer is from the window's top-left + /// corner, on the screen. + grab: Option, } /// [`DocWindow::fit`]: a body size, and a view's zoom and center. @@ -43,7 +59,7 @@ type Fit = (egui::Vec2, f64, vectorcraft_geom::Point); impl DocWindow { fn new(uid: u64, place: egui::Rect) -> Self { - Self { uid, place, generation: 0, screen: None, focused: false, body: None, fit: None } + Self { uid, place, number: 0, generation: 0, screen: None, focused: false, body: None, fit: None, grab: None } } } @@ -57,6 +73,15 @@ pub struct Windows { current: Option, /// While Presentation Mode is on: the floating documents (their uids) it has shown. presented: Option>, + /// The main window's tab strip in its last frame, while it was drawn. + pub(crate) strip: Option, + /// Counts the windows opened ([`DocWindow::number`]). + opened: u32, +} + +/// Whether a document tab dragged from `strip` to `p` has left it far enough to float. +pub(crate) fn torn_off(strip: egui::Rect, p: egui::Pos2) -> bool { + p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF } /// Whether document `i` is in a floating window. @@ -125,7 +150,10 @@ fn float(app: &mut VectorcraftApp, docs: &[usize]) { continue; } let place = places.get(first + k).copied().unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, MIN_SIZE)); - app.ui.floating.push(DocWindow::new(uid, place)); + app.canvas.windows.opened = app.canvas.windows.opened.wrapping_add(1); + let mut w = DocWindow::new(uid, place); + w.number = app.canvas.windows.opened; + app.ui.floating.push(w); if let Some(v) = app.views.get_mut(*i) { v.fitted = false; } @@ -140,15 +168,60 @@ fn floating_indices(app: &VectorcraftApp) -> Vec { app.ui.floating.iter().filter_map(|w| app.session.documents().iter().position(|d| d.uid == w.uid)).collect() } -/// `window.arrange.floatInWindow`: the current document leaves the tab strip for a floating window -/// of its own. A document already floating stays where it is. Returns `{floating}`. -pub(crate) fn float_in_window(app: &mut VectorcraftApp) -> Result { - if let Some(i) = app.session.active_index().filter(|i| !is_floating(app, *i)) { +/// The open document `p`'s `index` names: `Ok(None)` without one. +fn document_param(app: &VectorcraftApp, p: &Value) -> Result, String> { + match p.get("index") { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_u64() + .and_then(|i| usize::try_from(i).ok()) + .filter(|i| *i < app.session.documents().len()) + .map(Some) + .ok_or_else(|| "no such document".to_string()), + } +} + +/// `window.arrange.floatInWindow`: the current document, or document `index`, leaves the tab strip +/// for a floating window of its own, with its top-left corner at `at` (`[x, y]` in the main +/// window's points) when given, else at the next cascade place. A document already floating stays +/// where it is. Document `index` becomes the current one. Returns `{floating}`. +pub(crate) fn float_in_window(app: &mut VectorcraftApp, p: &Value) -> Result { + let index = document_param(app, p)?; + let at = p + .get("at") + .and_then(Value::as_array) + .and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32))) + .filter(|at| at.is_finite()); + if let Some(i) = index.or(app.session.active_index()).filter(|i| !is_floating(app, *i)) { float(app, &[i]); + if let (Some(at), Some(w)) = (at, app.ui.floating.last_mut()) { + w.place = egui::Rect::from_min_size(at, w.place.size()); + } + } + if let Some(i) = index { + crate::chrome::show_document(app, i); } Ok(json!({ "floating": floating_indices(app) })) } +/// `window.arrange.consolidateAllWindows` with an `index`: document `index`, floating or not, +/// goes back under its tab, fitted there, and becomes the current one; the other windows and Tile +/// stay as they are. Returns `{tiled, floating}`. +pub(crate) fn dock(app: &mut VectorcraftApp, p: &Value) -> Result { + let Some(i) = document_param(app, p)? else { + return Err("no such document".into()); + }; + if is_floating(app, i) { + let uid = app.session.documents().get(i).map(|d| d.uid); + app.ui.floating.retain(|w| Some(w.uid) != uid); + if let Some(v) = app.views.get_mut(i) { + v.fitted = false; + } + } + crate::chrome::show_document(app, i); + Ok(json!({ "tiled": app.ui.tiled, "floating": floating_indices(app) })) +} + /// `window.arrange.floatAllInWindows`: every document under the tab strip goes to a floating window /// of its own. Returns `{floating}`. pub(crate) fn float_all(app: &mut VectorcraftApp) -> Result { @@ -216,10 +289,10 @@ fn viewport_id(uid: u64) -> egui::ViewportId { egui::ViewportId::from_hash_of(("vectorcraft-document-window", uid)) } -/// The id of document window `w` inside the main window: a new one for each Cascade move, so egui -/// opens it at its new place and size. +/// The id of document window `w` inside the main window: a new one for each window and each +/// Cascade move, so egui opens it at its new place and size. fn window_id(w: &DocWindow) -> egui::Id { - egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation)) + egui::Id::unique(("vectorcraft-document-window", w.uid, w.number, w.generation)) } /// What happened in a document window this frame. @@ -229,6 +302,10 @@ struct Shown { close: bool, /// Its document asks to become the current one. activate: bool, + /// It is being dragged over the main window's tab strip. + over_strip: bool, + /// It was dropped on the main window's tab strip: its document goes back under the tabs. + dock: bool, /// Its OS window has the keyboard focus. focused: bool, /// Where it drew its document. @@ -297,6 +374,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let blocked = crate::panel_docking::modal_open(app, ctx); let mut close = None; let mut activate = None; + let mut dock = None; + let mut over_strip = false; for w in app.ui.floating.clone() { let Some(i) = app.session.documents().iter().position(|d| d.uid == w.uid) else { continue }; let shown = if native { @@ -316,12 +395,22 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { win.body = shown.body; win.fit = shown.fit; } + over_strip |= shown.over_strip; if shown.close { close = Some(i); + } else if shown.dock { + dock = Some(i); } else if shown.activate && app.session.active_index() != Some(i) { activate = Some(i); } } + // The tab strip a dragged window would go back under shows where it lands. + if let Some(strip) = app.canvas.windows.strip.filter(|_| over_strip) { + let t = crate::theme::Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("vectorcraft-document-dock"))); + painter.rect_filled(strip, 0.0, t.accent.gamma_multiply(0.12)); + painter.rect_stroke(strip, 0.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } if let Some(i) = close { // The save prompt opens in the main window. if let Err(e) = crate::unsaved::close(app, i) { @@ -330,6 +419,13 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if native && app.ui.dialog.is_some() { ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); } + } else if let Some(i) = dock { + if let Err(e) = app.run("window.arrange.consolidateAllWindows", json!({ "index": i })) { + app.status(e); + } + if native { + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } } else if let Some(i) = activate { crate::chrome::show_document(app, i); app.canvas.windows.current = app.session.active().map(|d| d.uid); @@ -367,7 +463,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, (i, blocked): (usize, bool) } /// Document `i`'s window `w` as an OS window of its own, `blocked` by a modal or not; `raise` -/// brings it to the front. +/// brings it to the front. The document's tab is at the top of the window, above its body. fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown { let vid = viewport_id(w.uid); let origin = ctx.input(|inp| inp.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(egui::Pos2::ZERO); @@ -392,6 +488,8 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b let builder = egui::ViewportBuilder::default().with_title(title).with_position(screen).with_inner_size(w.place.size()).with_min_inner_size(MIN_SIZE); let mut forwarded = (Vec::new(), None); + // The main window's tab strip on the screen. + let strip = app.canvas.windows.strip.map(|s| s.translate(origin.to_vec2())); let shown = ctx.show_viewport_immediate(vid, builder, |ui, _| { let (close, focused) = ui.input(|inp| (inp.viewport().close_requested(), inp.viewport().focused == Some(true))); if close { @@ -399,7 +497,10 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b // or a modal can keep the document open. ui.ctx().send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let (activate, rect, fit) = body(app, ui, (i, blocked), w.fit); + let tab = crate::chrome::window_tab(app, ui, i); + let (over_strip, dock) = if blocked { (false, false) } else { drag_tab(app, ui, (w.uid, origin), &tab, strip) }; + let (pressed, rect, fit) = body(app, ui, (i, blocked), w.fit); + let activate = pressed || (!blocked && (tab.clicked() || tab.drag_started())); // Keys the window's widgets left over run the main window's shortcuts and tool keys, as a // floating panel's do. if !blocked && !ui.memory(|m| m.focused().is_some()) { @@ -415,7 +516,7 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b (events, Some(inp.modifiers)) }); } - Shown { close, activate, focused, body: Some(rect), fit } + Shown { close, activate, over_strip, dock, focused, body: Some(rect), fit } }); if !forwarded.0.is_empty() { crate::panel_docking::forward_keys(app, ctx, forwarded.0, forwarded.1); @@ -423,6 +524,45 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b shown } +/// A drag of document window `uid`'s OS window by the `tab` at its top: the window follows the +/// pointer, and its document goes back under the tabs when the tab is released over the main +/// window's tab `strip` (on the screen). `origin` is the main window's top-left corner on the +/// screen. → whether the pointer is over the strip, and whether the tab was released there. +fn drag_tab( + app: &mut VectorcraftApp, + ui: &egui::Ui, + (uid, origin): (u64, egui::Pos2), + tab: &egui::Response, + strip: Option, +) -> (bool, bool) { + let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == uid) else { + return (false, false); + }; + if !tab.dragged() && !tab.drag_stopped() { + win.grab = None; + return (false, false); + } + let (inner, outer, pointer) = ui.input(|inp| (inp.viewport().inner_rect, inp.viewport().outer_rect, inp.pointer.latest_pos())); + let (Some(inner), Some(outer), Some(pointer)) = (inner, outer, pointer) else { + return (false, false); + }; + let at = inner.min + pointer.to_vec2(); + // The pointer stays where it pressed the tab, relative to the window. + let pressed = ui.input(|inp| inp.pointer.press_origin()).map_or(at, |p| inner.min + p.to_vec2()); + let grab = *win.grab.get_or_insert(pressed - outer.min); + let over = strip.is_some_and(|s| s.contains(at)); + if tab.drag_stopped() { + win.grab = None; + return (over, over); + } + let pos = at - grab; + if pos != outer.min { + win.screen = Some((pos, win.generation)); + win.place = egui::Rect::from_min_size(pos - origin.to_vec2(), win.place.size()); + } + (over, false) +} + /// Document `i`'s window `w` as a window inside the main window, `blocked` by a modal or not; /// `raise` brings it to the front. fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown { @@ -449,8 +589,15 @@ fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, }); // A press on its title bar or edges, which brings the window to the front, also makes its // document the current one. - activate |= shown.is_some_and(|s| s.response.contains_pointer()) && ctx.input(|inp| inp.pointer.any_pressed()); - Shown { close: !open, activate, focused: false, body: rect, fit } + activate |= shown.as_ref().is_some_and(|s| s.response.contains_pointer()) && ctx.input(|inp| inp.pointer.any_pressed()); + // Moved by its title bar onto the main window's tab strip, it goes back under the tabs. + // egui moves a window by the drag of its title bar's own widget, or, where it moves windows by + // a drag anywhere (after touch input), by the drag of the window itself. + let mover = ctx.read_response(id.with("__title_click")).or_else(|| shown.as_ref().map(|s| s.response.clone())); + let moved = mover.map_or((false, false), |r| (r.dragged(), r.drag_stopped())); + let pointer = ctx.input(|inp| inp.pointer.latest_pos()); + let over_strip = !blocked && (moved.0 || moved.1) && app.canvas.windows.strip.zip(pointer).is_some_and(|(strip, p)| strip.contains(p)); + Shown { close: !open, activate, over_strip, dock: over_strip && moved.1, focused: false, body: rect, fit } } /// The host's rewrites of raw input ([`crate::services::Services::native_input_hook`]) for the diff --git a/crates/ui-egui/src/doc_windows/tests.rs b/crates/ui-egui/src/doc_windows/tests.rs index f49728e95..08306804b 100644 --- a/crates/ui-egui/src/doc_windows/tests.rs +++ b/crates/ui-egui/src/doc_windows/tests.rs @@ -60,7 +60,7 @@ fn body_of(app: &VectorcraftApp, i: usize) -> egui::Rect { fn window_of(app: &VectorcraftApp, ctx: &egui::Context, i: usize) -> egui::Rect { let uid = app.session.documents()[i].uid; let w = app.ui.floating.iter().find(|w| w.uid == uid).unwrap(); - ctx.memory(|m| m.area_rect(egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation)))).unwrap() + ctx.memory(|m| m.area_rect(window_id(w))).unwrap() } /// A drag with the primary button from `from` to `to`, then a frame to settle. @@ -279,7 +279,7 @@ fn closing_a_floating_window_closes_its_document_and_asks_to_save_an_unsaved_one let close_button = |app: &VectorcraftApp, i: usize| { let uid = app.session.documents()[i].uid; let w = app.ui.floating.iter().find(|w| w.uid == uid).unwrap(); - let area = ctx.memory(|m| m.area_rect(egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation)))).unwrap(); + let area = ctx.memory(|m| m.area_rect(window_id(w))).unwrap(); let body = w.body.unwrap(); (area, body) }; @@ -363,7 +363,7 @@ fn a_document_made_current_in_presentation_mode_comes_to_the_front_after_it() { let layer = |app: &VectorcraftApp, i: usize| { let uid = app.session.documents()[i].uid; let w = app.ui.floating.iter().find(|w| w.uid == uid).unwrap(); - egui::LayerId::new(egui::Order::Middle, egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation))) + egui::LayerId::new(egui::Order::Middle, window_id(w)) }; let top = |ctx: &egui::Context| ctx.memory(|m| m.areas().top_layer_id(egui::Order::Middle)); assert_eq!(top(&ctx), Some(layer(&app, 1)), "the current document's window is in front"); @@ -378,11 +378,15 @@ fn a_document_made_current_in_presentation_mode_comes_to_the_front_after_it() { } /// A stand-in for the window system of the documents' OS windows: it runs each one's frame, asks -/// each to close while `close` is set, and records the commands the windows were sent. +/// each to close while `close` is set, and records the commands the windows were sent. Each +/// window is 500 × 400 points with its top-left corner at `origin` on the screen, and its next +/// frame takes `events`. #[derive(Default)] struct Host { close: bool, commands: Vec<(egui::ViewportId, egui::ViewportCommand)>, + origin: Pos2, + events: Vec, } fn native_host() -> std::rc::Rc> { @@ -390,11 +394,25 @@ fn native_host() -> std::rc::Rc> { let shared = host.clone(); egui::Context::set_immediate_viewport_renderer(move |ctx, mut viewport| { let id = viewport.ids.this; - let events = if shared.borrow().close { vec![egui::ViewportEvent::Close] } else { vec![] }; - let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(500.0, 400.0)); - let info = egui::ViewportInfo { events, inner_rect: Some(rect), outer_rect: Some(rect), focused: Some(false), ..Default::default() }; - let input = - egui::RawInput { viewport_id: id, viewports: std::iter::once((id, info)).collect(), screen_rect: Some(rect), ..Default::default() }; + let close = if shared.borrow().close { vec![egui::ViewportEvent::Close] } else { vec![] }; + let size = vec2(500.0, 400.0); + let rect = egui::Rect::from_min_size(Pos2::ZERO, size); + let on_screen = egui::Rect::from_min_size(shared.borrow().origin, size); + let info = egui::ViewportInfo { + events: close, + inner_rect: Some(on_screen), + outer_rect: Some(on_screen), + focused: Some(false), + ..Default::default() + }; + let events = std::mem::take(&mut shared.borrow_mut().events); + let input = egui::RawInput { + viewport_id: id, + viewports: std::iter::once((id, info)).collect(), + screen_rect: Some(rect), + events, + ..Default::default() + }; let mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui)); full.textures_delta.clear(); let mut host = shared.borrow_mut(); @@ -433,6 +451,188 @@ fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() { ); } +/// Where the last frame drew the title of `title`'s tab in the tab strip. +fn tab_at(shapes: &[Shape], title: &str) -> Pos2 { + shapes + .iter() + .find_map(|s| match s { + Shape::Text(t) if t.pos.y < 35.0 && t.galley.text().contains(title) => Some(t.pos + vec2(4.0, 4.0)), + _ => None, + }) + .unwrap() +} + +#[test] +fn a_tab_is_torn_off_only_well_above_or_below_the_strip() { + let strip = egui::Rect::from_min_max(pos2(0.0, 40.0), pos2(1200.0, 75.0)); + assert!(!torn_off(strip, pos2(300.0, 60.0)), "along the strip"); + assert!(!torn_off(strip, pos2(300.0, 75.0 + TEAR_OFF - 1.0)), "a wobble below the strip"); + assert!(torn_off(strip, pos2(300.0, 75.0 + TEAR_OFF + 1.0))); + assert!(torn_off(strip, pos2(300.0, 40.0 - TEAR_OFF - 1.0)), "dragged up"); +} + +#[test] +fn dragging_a_tab_off_the_strip_floats_its_document_at_the_pointer() { + let mut app = app_with(3); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let shapes = frame(&mut app, &ctx, vec![]); + let title = app.session.documents()[0].title(); + let tab = tab_at(&shapes, &title); + // A short drag, and a drag along the strip, leave the tabs as they are. + drag(&mut app, &ctx, tab, tab + vec2(0.0, 35.0 + TEAR_OFF - 10.0 - tab.y)); + drag(&mut app, &ctx, tab, tab + vec2(300.0, 0.0)); + assert!(app.ui.floating.is_empty(), "the document stays under its tab"); + assert_eq!(app.session.active_index(), Some(2), "a drag is not a click"); + // Dragged well below the strip, it floats in a window under the pointer and becomes current. + let to = pos2(300.0, 200.0); + drag(&mut app, &ctx, tab, to); + assert_eq!(docked(&app), [1, 2]); + assert_eq!(app.session.active_index(), Some(0), "the torn-off document is the current one"); + assert_eq!(app.ui.floating[0].place.min, to - GRAB, "its window opens under the pointer"); + frame(&mut app, &ctx, vec![]); + let shapes = frame(&mut app, &ctx, vec![]); + assert!(!tab_drawn(&shapes, &title), "it has left the strip"); + let window = window_of(&app, &ctx, 0); + assert!(window.contains(to) && (window.min - (to - GRAB)).length() < 1.0, "the pointer is over its window: {window:?}"); + assert!(body_of(&app, 0).contains_rect(app.canvas_rect.unwrap()), "its window holds the canvas"); +} + +#[test] +fn a_window_dragged_by_its_title_bar_onto_the_strip_goes_back_under_the_tabs() { + let mut app = app_with(3); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.floatAllInWindows", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + let title_bar = |app: &VectorcraftApp, i: usize| { + let (window, body) = (window_of(app, &ctx, i), body_of(app, i)); + pos2(window.left() + 60.0, (window.top() + body.top()) / 2.0) + }; + // Moved elsewhere, document 2's window (in front) stays a window. + let from = title_bar(&app, 2); + drag(&mut app, &ctx, from, from + vec2(40.0, 30.0)); + assert_eq!(app.ui.floating.len(), 3, "a window moved off the strip still floats"); + // Dropped on the strip, it goes back under its tab, current; the other windows stay. + let from = title_bar(&app, 2); + drag(&mut app, &ctx, from, pos2(600.0, 15.0)); + assert_eq!(docked(&app), [2]); + assert_eq!(app.session.active_index(), Some(2)); + assert_eq!(app.ui.floating.len(), 2); + frame(&mut app, &ctx, vec![]); + let shapes = frame(&mut app, &ctx, vec![]); + assert!(tab_drawn(&shapes, &app.session.documents()[2].title()), "it has its tab again"); + assert!(app.views[2].fitted, "it fits the canvas under the tabs"); + let canvas = app.canvas_rect.unwrap(); + assert!(canvas.top() >= 35.0 && canvas.width() > 1000.0, "the canvas under the tabs shows it: {canvas:?}"); + // Torn off again, it opens where its tab is dropped, not where its last window was. + let to = pos2(200.0, 300.0); + let tab = tab_at(&shapes, &app.session.documents()[2].title()); + drag(&mut app, &ctx, tab, to); + frame(&mut app, &ctx, vec![]); + let window = window_of(&app, &ctx, 2); + assert!((window.min - (to - GRAB)).length() < 1.0, "its new window is under the pointer: {window:?}"); +} + +#[test] +fn after_touch_input_a_window_dragged_onto_the_strip_still_docks() { + let mut app = app_with(2); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.floatAllInWindows", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + // A touch makes egui move windows by a drag anywhere on them. + let touch = egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase: egui::TouchPhase::Start, + pos: pos2(1100.0, 700.0), + force: None, + }; + frame(&mut app, &ctx, vec![touch]); + frame(&mut app, &ctx, vec![]); + let (window, body) = (window_of(&app, &ctx, 1), body_of(&app, 1)); + let from = pos2(window.left() + 60.0, (window.top() + body.top()) / 2.0); + drag(&mut app, &ctx, from, pos2(600.0, 15.0)); + assert_eq!(docked(&app), [1], "document 1 is back under the tabs"); +} + +#[test] +fn a_document_floated_again_after_cascade_opens_where_it_is_asked_to() { + let mut app = app_with(2); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.floatAllInWindows", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.cascade", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.consolidateAllWindows", json!({"index": 1})).unwrap(); + frame(&mut app, &ctx, vec![]); + let at = pos2(200.0, 300.0); + app.run("window.arrange.floatInWindow", json!({"index": 1, "at": [at.x, at.y]})).unwrap(); + frame(&mut app, &ctx, vec![]); + let window = window_of(&app, &ctx, 1); + assert!((window.min - at).length() < 1.0, "its new window opens at {at:?}: {window:?}"); +} + +#[test] +fn float_in_window_and_consolidate_take_a_document() { + let mut app = app_with(3); + assert_eq!(app.run("window.arrange.floatInWindow", json!({"index": 0, "at": [100, 120]})).unwrap(), json!({"floating": [0]})); + assert_eq!(app.session.active_index(), Some(0), "document 0 becomes the current one"); + assert_eq!(app.ui.floating[0].place.min, pos2(100.0, 120.0)); + app.run("window.arrange.floatInWindow", json!({"index": 1})).unwrap(); + assert_eq!(app.run("window.arrange.floatInWindow", json!({"index": 3})), Err("no such document".to_string())); + app.run("window.arrange.tile", json!({})).unwrap(); + app.run("window.arrange.floatAllInWindows", json!({})).unwrap(); + assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({"index": 1})).unwrap(), json!({"tiled": true, "floating": [0, 2]})); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(docked(&app), [1], "only document 1 goes back under the tabs"); + assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({"index": 7})), Err("no such document".to_string())); + assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(), json!({"tiled": false, "floating": []})); +} + +#[test] +fn an_os_window_moves_by_its_tab_and_docks_on_the_strip() { + let host = native_host(); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let mut app = app_with(2); + if !native(&app, &ctx) { + // Wayland: the windows are inside the main window. + return; + } + frame(&mut app, &ctx, vec![]); + app.run("window.arrange.floatInWindow", json!({})).unwrap(); + // The OS window's top-left corner is at (300, 300) on the screen; the main window's at (0, 0). + host.borrow_mut().origin = pos2(300.0, 300.0); + frame(&mut app, &ctx, vec![]); + let os_drag = |app: &mut VectorcraftApp, from: Pos2, to: Pos2| { + let steps = [vec![egui::Event::PointerMoved(from)], vec![button(from, true)]] + .into_iter() + .chain((1..=4).map(|k| vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))])) + .chain([vec![button(to, false)], vec![]]); + for events in steps { + host.borrow_mut().events = events; + frame(app, &ctx, vec![]); + } + }; + // The tab at the top of the window: dragged down and right, the window follows the pointer. + let tab = pos2(20.0, 15.0); + os_drag(&mut app, tab, tab + vec2(100.0, 50.0)); + assert!(is_floating(&app, 1), "a window moved off the strip still floats"); + let w = &app.ui.floating[0]; + assert_eq!(w.screen.map(|s| s.0), Some(pos2(400.0, 350.0)), "the window moved with the pointer"); + assert_eq!(w.place.min, pos2(400.0, 350.0), "in the main window's points"); + // Released over the main window's tab strip (y 0 to 35 on the screen), it docks. + os_drag(&mut app, tab, pos2(20.0, -280.0)); + assert!(app.ui.floating.is_empty(), "the document is back under the tabs"); + assert_eq!(docked(&app), [0, 1]); + assert_eq!(app.session.active_index(), Some(1)); +} + #[test] fn the_arrange_items_are_enabled_as_the_documents_allow() { let ids = [ diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index d54173c3d..b788cd58b 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -547,8 +547,9 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("New Window isn't available yet: a document opens in one window".into()), "window.arrange.cascade" => crate::doc_windows::cascade(app), "window.arrange.tile" => crate::canvas::tile(app), - "window.arrange.floatInWindow" => crate::doc_windows::float_in_window(app), + "window.arrange.floatInWindow" => crate::doc_windows::float_in_window(app, p), "window.arrange.floatAllInWindows" => crate::doc_windows::float_all(app), + "window.arrange.consolidateAllWindows" if p.get("index").is_some_and(|i| !i.is_null()) => crate::doc_windows::dock(app, p), "window.arrange.consolidateAllWindows" => crate::canvas::consolidate(app), "window.nextDocument" => crate::chrome::step_document(app, true), "window.previousDocument" => crate::chrome::step_document(app, false), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index c97450093..8d1b6b36b 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -597,7 +597,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.arrange.floatInWindow", "Float in Window", "", - "{} move the current document out of the tab strip into a floating window of its own, fitted to it; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)", + "{index?, at?: [x, y]} move the current document, or document `index` (which becomes the current one), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)", ), ( "window.arrange.floatAllInWindows", @@ -609,7 +609,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.arrange.consolidateAllWindows", "Consolidate All Windows", "", - "{} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}", + "{index?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)", ), ( "window.nextDocument", diff --git a/docs/gaps.md b/docs/gaps.md index 292905bb7..3ccde6d98 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -593,7 +593,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): a document keeps up to 25 named views (zoom, centre and rotation), saved with the file; every one is listed by name at the bottom of the View menu (`view.goto1`–`view.goto25`), and choosing one changes the current window to it. Edit Views… lists them to rename or delete, applied together on OK as one undo step (`view.saved.edit {edits}`); a file's views are checked when it opens. Estimate unchanged. - **Done:** Window › Arrange › Tile (`window.arrange.tile`) shows every open document in its own pane, edge to edge in the document area, and Consolidate All Windows (`window.arrange.consolidateAllWindows`) goes back to one document under its tab, on the desktop and the web. Estimate unchanged. - **Done:** Window › Arrange › Float in Window (`window.arrange.floatInWindow`), Float All in Windows (`window.arrange.floatAllInWindows`) and Cascade (`window.arrange.cascade`) move documents out of the tab strip into floating windows of their own, OS windows on the desktop and windows inside the app's window on the web and on Wayland; Cascade stacks them from the upper left to the lower right. Consolidate All Windows brings them back under the tabs. Estimate unchanged. -- **Missing:** several windows of one document (Window › New Window), dragging a tab out to float its document and back in to dock it. +- **Done:** a document tab dragged off the tab strip floats its document in a window under the pointer, and a document window dragged by its title bar (an OS window by the tab at its top) onto the tab strip goes back under the tabs. Estimate unchanged. +- **Missing:** several windows of one document (Window › New Window). - **Estimate:** 15–25 h. ## G6 Automation and scripting diff --git a/docs/mcp.md b/docs/mcp.md index 4db07abae..4081cc27a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3648,7 +3648,12 @@ it is a window inside the app's window, so `ui.click`, `ui.drag` and `ui.screens document's window holds the canvas, and a press in another document's window makes its document the current one. `window.arrange.consolidateAllWindows` (Window › Arrange › Consolidate All Windows) shows one document under its tab again, floating ones included, and returns `{tiled: false, floating: []}`; Tile brings floating documents back into its -grid. The arrangement is not saved. `ui.inspect` reports `tiled`, `panes` (each pane's `document` index, `rect` and +grid. `window.arrange.floatInWindow {index, at: [x, y]}` floats document `index`, makes it the current one and puts its +window's top-left corner at `at` in the main window's points, as dragging its tab more than 24 pt above or below the tab +strip does. `window.arrange.consolidateAllWindows {index}` brings only document `index` back under its tab and makes it +the current one, as dragging its window by the title bar (an OS window by the tab at its top) onto the tab strip does; +it returns `{tiled, floating}`. An `index` that names no open document fails with "no such document". The arrangement +is not saved. `ui.inspect` reports `tiled`, `panes` (each pane's `document` index, `rect` and whether it is `active`) and `floating`; canvas coordinates map to the current document's pane or window (`canvasRect`), in that window's coordinates while it floats. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3d5685c43..208a8bf0e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -528,6 +528,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Saved views (G7):** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): up to 25 views per document, saved with it, renamed or deleted in Edit Views, chosen from the bottom of the View menu). Each view keeps the zoom, centre and rotation; the View menu lists all 25 (`view.goto1`–`view.goto25`), and Edit Views… lists them to rename or delete, applied together as one undo step. A file's views are checked when it opens (at most 25, unique names up to 255 characters, a finite centre and a positive zoom). Tests: `saved_views_round_trip_and_from_a_file_are_tidied` (`crates/format/tests/prop_format.rs`), `crates/engine/src/cmd/views.rs`, `crates/ui-egui/src/dialogs/edit_views.rs` and `every_saved_view_is_listed_at_the_bottom_of_the_view_menu` (`crates/ui-egui/src/menus.rs`). - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): "Tile displays windows edge to edge"), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. - **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html): "drag the window's tab out of the group"). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): "Cascade displays windows stacked and descending from the upper-left to the lower-right of the screen"). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. +- **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html): "To undock (float or untab) a Document window from a group of windows, drag the window’s tab out of the group. To dock a Document window to a separate group of Document windows, drag the window into the group."). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. - **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy).