mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 22:22:25 +00:00
Document tabs: drag a tab along the strip to reorder the open documents (#5663)
A document tab dragged sideways along the tab strip moves its document to the
place an insertion line marks; the current document stays current. Dragged
above or below the strip, the tab still tears off into a floating window. The
views Window > New Window opened move with their document, and each document
keeps its own zoom, centre and artboard.
The move is the engine command `document.move {from?, to}`, so the control
channel, MCP and recordings reach it, and a replay moves the document before
the steps recorded after it. `sync_views` keeps the views with their documents
by uid when the documents change order.
Behaviour: https://helpx.adobe.com/illustrator/using/customizing-workspace.html
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
f73701a0e7
commit
e0645341a4
@@ -29,6 +29,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
|
||||
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
|
||||
cmd!(
|
||||
"document.move",
|
||||
"Move Document",
|
||||
[],
|
||||
None,
|
||||
"{from?: index (default: the current document), to: index (0 = the first tab; past the last document = the last tab)} → {index}: moves the document to tab `to`, as dragging its tab along the tab strip does; the documents between shift by one, the order Window › Next/Previous Document steps through follows, and the current document stays the current one",
|
||||
has_doc,
|
||||
doc_move
|
||||
),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
|
||||
@@ -222,6 +231,23 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
|
||||
}
|
||||
|
||||
fn doc_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = s.documents().len();
|
||||
let from = match p.get("from") {
|
||||
None | Some(Value::Null) => s.active_index().ok_or(EngineError::NoDocument)?,
|
||||
Some(v) => v.as_u64().map(index).ok_or_else(|| bad("document.move", "`from` must be a non-negative integer"))?,
|
||||
};
|
||||
if from >= n {
|
||||
return Err(bad("document.move", "no such document"));
|
||||
}
|
||||
let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.move", "missing `to`: a non-negative integer"))?;
|
||||
let to = to.min(n.saturating_sub(1));
|
||||
if !s.move_document(from, to) {
|
||||
return Err(bad("document.move", "no such document"));
|
||||
}
|
||||
Ok(json!({ "index": to }))
|
||||
}
|
||||
|
||||
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
|
||||
let mut opts = slice_opts(p, "document.node")?;
|
||||
|
||||
@@ -292,6 +292,27 @@ impl Session {
|
||||
true
|
||||
}
|
||||
|
||||
/// Move the document in tab `from` to tab `to` (a document tab dragged along the strip,
|
||||
/// `document.move`); the documents between them shift by one. The current document stays the
|
||||
/// current one, wherever it moves to, and its tool keeps its state: no document is switched to.
|
||||
/// False, changing nothing, when either index is past the last document.
|
||||
pub fn move_document(&mut self, from: usize, to: usize) -> bool {
|
||||
let n = self.docs.len();
|
||||
if from >= n || to >= n {
|
||||
return false;
|
||||
}
|
||||
if from == to {
|
||||
return true;
|
||||
}
|
||||
let active = self.active().map(|d| d.uid);
|
||||
let d = self.docs.remove(from);
|
||||
self.docs.insert(to, d);
|
||||
if let Some(uid) = active {
|
||||
self.active = self.docs.iter().position(|d| d.uid == uid);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Commands with enablement (for menus, palette, MCP `list_commands`).
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
|
||||
@@ -41,6 +41,7 @@ mod cut;
|
||||
mod dashalign;
|
||||
mod distort;
|
||||
mod docsetup;
|
||||
mod document_move;
|
||||
mod draw2;
|
||||
mod dynamic_symbols;
|
||||
mod editcolors;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! `document.move` (#5663): a document tab dragged along the tab strip moves its document to
|
||||
//! another place among the open documents, and the current document stays the current one.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// A session with documents A, B, C and D, in that order, D current.
|
||||
fn four() -> Session {
|
||||
let mut s = Session::new();
|
||||
for name in ["A", "B", "C", "D"] {
|
||||
s.execute("file.new", &json!({"name": name, "created": null})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> String {
|
||||
s.documents().iter().map(DocState::title).collect::<Vec<_>>().join("")
|
||||
}
|
||||
|
||||
fn current(s: &Session) -> String {
|
||||
s.active().map(DocState::title).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn moved(s: &mut Session, p: Value) -> Value {
|
||||
s.execute("document.move", &p).unwrap_or_else(|e| panic!("document.move {p}: {e}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_moves_to_its_new_tab_and_the_others_shift() {
|
||||
let mut s = four();
|
||||
assert_eq!(order(&s), "ABCD");
|
||||
assert_eq!(moved(&mut s, json!({"from": 0, "to": 2})), json!({"index": 2}));
|
||||
assert_eq!(order(&s), "BCAD", "forward: the documents between shift left");
|
||||
assert_eq!(moved(&mut s, json!({"from": 3, "to": 0})), json!({"index": 0}));
|
||||
assert_eq!(order(&s), "DBCA", "backward: the documents between shift right");
|
||||
assert_eq!(moved(&mut s, json!({"from": 1, "to": 1})), json!({"index": 1}));
|
||||
assert_eq!(order(&s), "DBCA", "onto itself: nothing changes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_current_document_stays_current_wherever_the_move_puts_it() {
|
||||
let mut s = four();
|
||||
assert!(s.set_active(1));
|
||||
// The current document itself moves.
|
||||
moved(&mut s, json!({"from": 1, "to": 3}));
|
||||
assert_eq!((order(&s), current(&s)), ("ACDB".into(), "B".into()));
|
||||
assert_eq!(s.active_index(), Some(3));
|
||||
// A document moves past the current one, which shifts by one.
|
||||
moved(&mut s, json!({"from": 3, "to": 0}));
|
||||
moved(&mut s, json!({"from": 3, "to": 0}));
|
||||
assert_eq!((order(&s), current(&s)), ("DBAC".into(), "B".into()));
|
||||
assert_eq!(s.active_index(), Some(1));
|
||||
// A move on one side of the current document leaves it where it is.
|
||||
moved(&mut s, json!({"from": 2, "to": 3}));
|
||||
assert_eq!((order(&s), current(&s)), ("DBCA".into(), "B".into()));
|
||||
assert_eq!(s.active_index(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_from_the_current_document_moves() {
|
||||
let mut s = four();
|
||||
assert!(s.set_active(2));
|
||||
moved(&mut s, json!({"to": 0}));
|
||||
assert_eq!((order(&s), current(&s)), ("CABD".into(), "C".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_past_the_last_tab_ends_there_and_bad_indices_change_nothing() {
|
||||
let mut s = four();
|
||||
assert_eq!(moved(&mut s, json!({"from": 0, "to": 99})), json!({"index": 3}));
|
||||
assert_eq!(order(&s), "BCDA");
|
||||
assert_eq!(moved(&mut s, json!({"from": 0, "to": u64::MAX})), json!({"index": 3}));
|
||||
assert_eq!(order(&s), "CDAB");
|
||||
for p in [
|
||||
json!({"from": 4, "to": 0}),
|
||||
json!({"from": u64::MAX, "to": 0}),
|
||||
json!({"from": 0}),
|
||||
json!({"from": -1, "to": 0}),
|
||||
json!({"from": 0, "to": "1"}),
|
||||
] {
|
||||
assert!(s.execute("document.move", &p).is_err(), "{p} is refused");
|
||||
assert_eq!(order(&s), "CDAB", "{p} changes nothing");
|
||||
}
|
||||
assert!(!s.move_document(0, 4));
|
||||
assert!(!s.move_document(4, 0));
|
||||
assert_eq!(order(&s), "CDAB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_needs_an_open_document_and_one_document_stays_put() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("document.move", &json!({"to": 0})).is_err(), "no document open");
|
||||
s.execute("file.new", &json!({"name": "A", "created": null})).unwrap();
|
||||
assert_eq!(moved(&mut s, json!({"to": 5})), json!({"index": 0}));
|
||||
assert_eq!((order(&s), current(&s)), ("A".into(), "A".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_keeps_the_tool_and_the_documents_edits() {
|
||||
let mut s = four();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap();
|
||||
let resets = s.tool_resets;
|
||||
let nodes = s.doc().unwrap().doc.node_count();
|
||||
let history = super::history(&s);
|
||||
moved(&mut s, json!({"from": 3, "to": 0}));
|
||||
assert_eq!(super::history(&s), history, "a move is no undo step, and it keeps Redo");
|
||||
assert_eq!(s.tool_resets, resets, "no document was switched to, so the tool is not reset");
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), nodes);
|
||||
assert!(s.doc().unwrap().is_dirty(), "the document keeps its unsaved edit");
|
||||
}
|
||||
|
||||
/// A recording replays the move before the steps after it, so an `index` they name is the same
|
||||
/// document as when they were recorded.
|
||||
#[test]
|
||||
fn a_journaled_move_replays_in_order() {
|
||||
let mut s = four();
|
||||
for (id, p) in [
|
||||
("document.move", json!({"from": 0, "to": 3})),
|
||||
("document.activate", json!({"index": 3})),
|
||||
("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap();
|
||||
}
|
||||
assert_eq!((order(&s), current(&s)), ("BCDA".into(), "A".into()));
|
||||
let mut r = Session::new();
|
||||
for (id, p) in &s.journal {
|
||||
r.execute(id, p).unwrap();
|
||||
}
|
||||
assert_eq!((order(&r), current(&r)), ("BCDA".into(), "A".into()));
|
||||
let counts = |s: &Session| s.documents().iter().map(|d| d.doc.node_count()).collect::<Vec<_>>();
|
||||
assert_eq!(counts(&r), counts(&s), "the rectangle is in the same document after the replay");
|
||||
}
|
||||
@@ -526,6 +526,10 @@ fn structured_junk() {
|
||||
("document.open", json!({"name": "x.pdf", "dataBase64": "JVBERi0xLjQK"})),
|
||||
("document.open", json!({"name": "x.vectorcraft", "dataBase64": "e30="})),
|
||||
("document.activate", json!({"index": 99})),
|
||||
("document.move", json!({"from": 99, "to": 0})),
|
||||
("document.move", json!({"from": 0, "to": u64::MAX})),
|
||||
("document.move", json!({"from": -1, "to": 1e308})),
|
||||
("document.move", json!({"from": "x", "to": null})),
|
||||
("file.close", json!({"index": 99})),
|
||||
("file.new", json!({"width": -1, "height": 0, "artboards": 1000000})),
|
||||
("command.batch", json!({"commands": [{"command": 5}, {"params": 3}]})),
|
||||
|
||||
@@ -54,11 +54,13 @@ impl VectorcraftApp {
|
||||
// ([`place::place_from_cursor`]).
|
||||
session.place_cursor_requests = true;
|
||||
let views = session.documents().iter().map(View::of).collect();
|
||||
let view_uids = session.documents().iter().map(|d| d.uid).collect();
|
||||
let known = session.documents().iter().map(|d| d.uid).collect();
|
||||
Self {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
views,
|
||||
view_uids,
|
||||
services,
|
||||
canvas: CanvasCache {
|
||||
renderer: vectorcraft_render::Renderer::new(),
|
||||
|
||||
@@ -4,13 +4,32 @@ impl VectorcraftApp {
|
||||
/// Keep `views` aligned with the session's documents (a new one starts at its saved view, and
|
||||
/// opens in a floating window while Open Documents As Tabs is cleared). The views Window › New
|
||||
/// Window opened go with their documents.
|
||||
///
|
||||
/// A view stays with its document when the documents change order (`document.move`, also in a
|
||||
/// recording or a batch, #5663): while every view still has the document it was synced with,
|
||||
/// they are put in the documents' order by uid. Code that removed a view by hand (closing a
|
||||
/// document) leaves them aligned by index, as before.
|
||||
pub fn sync_views(&mut self) {
|
||||
let docs = self.session.documents();
|
||||
if self.view_uids.len() == self.views.len()
|
||||
&& docs.len() == self.views.len()
|
||||
&& docs.iter().map(|d| d.uid).ne(self.view_uids.iter().copied())
|
||||
&& docs.iter().all(|d| self.view_uids.contains(&d.uid))
|
||||
{
|
||||
let mut old: Vec<Option<View>> = std::mem::take(&mut self.views).into_iter().map(Some).collect();
|
||||
self.views = docs
|
||||
.iter()
|
||||
.map(|d| {
|
||||
self.view_uids.iter().position(|u| *u == d.uid).and_then(|k| old.get_mut(k)).and_then(Option::take).unwrap_or_else(|| View::of(d))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.views.truncate(docs.len());
|
||||
self.views.extend(docs.iter().skip(self.views.len()).map(View::of));
|
||||
for (view, doc) in self.views.iter_mut().zip(docs) {
|
||||
view.artboard = view.artboard.min(doc.doc.artboards.len().saturating_sub(1));
|
||||
}
|
||||
self.view_uids = docs.iter().map(|d| d.uid).collect();
|
||||
crate::doc_windows::retain_views(self);
|
||||
crate::doc_windows::opened(self);
|
||||
// Undo can remove the Artboard tool's active or additionally selected boards. Repair only
|
||||
|
||||
@@ -28,7 +28,7 @@ pub use status_bar::status_bar;
|
||||
#[cfg(test)]
|
||||
use status_bar::{StatusReadoutWidths, status_readouts};
|
||||
#[cfg(test)]
|
||||
use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_title};
|
||||
use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_drop, tab_groups, tab_title};
|
||||
pub use tabs::{doc_tabs, isolation_bar, step_document};
|
||||
pub(crate) use tabs::{document_window_title, show_document, window_tab};
|
||||
|
||||
|
||||
@@ -79,6 +79,40 @@ fn tear_off(ui: &Ui, resp: &egui::Response, strip: egui::Rect) -> Option<egui::P
|
||||
torn_off(strip, p).then_some(p - GRAB)
|
||||
}
|
||||
|
||||
/// The documents of the tabs the strip shows, from `slots` (each tab's document index and rect,
|
||||
/// left to right): one entry per document, spanning the tabs of all its views (Window › New
|
||||
/// Window), which sit side by side and move together.
|
||||
pub(super) fn tab_groups(slots: &[(usize, egui::Rect)]) -> Vec<(usize, egui::Rangef)> {
|
||||
let mut groups: Vec<(usize, egui::Rangef)> = vec![];
|
||||
for &(i, r) in slots {
|
||||
match groups.last_mut() {
|
||||
Some((d, span)) if *d == i => span.max = span.max.max(r.right()),
|
||||
_ => groups.push((i, egui::Rangef::new(r.left(), r.right()))),
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Where document `from`'s tabs, dragged along the strip to `x`, go among the other documents'
|
||||
/// tabs in `groups` ([`tab_groups`]): before the first whose middle is right of `x`, else after
|
||||
/// the last. → the document's new index among all the open documents (floating ones and those
|
||||
/// whose tabs don't fit included) and the x of the gap the drop goes in; `None` when the drop
|
||||
/// leaves the document where it is, or there is no other tab to drop beside.
|
||||
pub(super) fn tab_drop(groups: &[(usize, egui::Rangef)], from: usize, x: f32) -> Option<(usize, f32)> {
|
||||
let others: Vec<&(usize, egui::Rangef)> = groups.iter().filter(|(d, _)| *d != from).collect();
|
||||
let slot = others.iter().filter(|(_, span)| span.center() < x).count();
|
||||
let (to, gap) = match others.get(slot) {
|
||||
// Before document `d`: it shifts left by one when `from` was before it.
|
||||
Some(&&(d, span)) => (if from < d { d - 1 } else { d }, span.min),
|
||||
// After the last tab shown.
|
||||
None => {
|
||||
let &&(d, span) = others.last()?;
|
||||
(if from < d { d } else { d + 1 }, span.max)
|
||||
}
|
||||
};
|
||||
(to != from).then_some((to, gap))
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -100,7 +134,9 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option<usize>, room: f32)
|
||||
/// Each view Window › New Window opened has a tab of its own. Windows floating have no tab
|
||||
/// ([`crate::doc_windows`]); while the current window is a floating one, the tab of the window the
|
||||
/// strip shows is the highlighted one. A tab dragged more than [`crate::doc_windows::TEAR_OFF`]
|
||||
/// above or below the strip floats its view in a window under the pointer.
|
||||
/// above or below the strip floats its view in a window under the pointer; one dragged along the
|
||||
/// strip moves its document among the others (`document.move`, #5663), a line marking where it
|
||||
/// will go.
|
||||
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
/// The width of the » button.
|
||||
const MORE: f32 = 30.0;
|
||||
@@ -133,6 +169,10 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let mut close = None;
|
||||
let mut float = None;
|
||||
let mut hovering_art = false;
|
||||
// The tabs shown, by document and rect, and the document whose tab is dragged (and whether
|
||||
// the drag just ended), for moving it along the strip.
|
||||
let mut slots: Vec<(usize, egui::Rect)> = vec![];
|
||||
let mut dragged = None;
|
||||
for (k, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) {
|
||||
if x + w > strip.left() + room && Some(k) != active {
|
||||
break;
|
||||
@@ -142,6 +182,10 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
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, win.1)), Sense::click_and_drag());
|
||||
slots.push((i, r));
|
||||
if resp.dragged() || resp.drag_stopped() {
|
||||
dragged = Some((i, resp.drag_stopped()));
|
||||
}
|
||||
if is_active {
|
||||
ui.painter().rect_filled(r, 0.0, t.panel);
|
||||
} else if resp.hovered() {
|
||||
@@ -166,6 +210,25 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
x += w;
|
||||
}
|
||||
crate::document_drag::finish_tabs(ui.ctx(), hovering_art);
|
||||
// A tab dragged along the strip (not off it, which floats it) moves its document: a line marks
|
||||
// the gap it goes in, and the release moves it there. Artwork dragged over the tabs never does.
|
||||
let mut reorder = None;
|
||||
if let Some((from, released)) = dragged
|
||||
&& !crate::document_drag::carrying_artwork(ui.ctx())
|
||||
&& let Some(p) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos()))
|
||||
&& !crate::doc_windows::torn_off(strip, p)
|
||||
{
|
||||
let drop = tab_drop(&tab_groups(&slots), from, p.x);
|
||||
if released {
|
||||
reorder = drop.map(|(to, _)| (from, to));
|
||||
} else {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if let Some((_, gap)) = drop {
|
||||
let gap = gap.clamp(strip.left() + 1.0, strip.right() - 1.0);
|
||||
ui.painter().vline(gap, strip.y_range().shrink(4.0), Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
// The » button: every window, floating ones included, the current one checked. The list
|
||||
// scrolls once it reaches the bottom of the window, so every document of a large session stays
|
||||
// reachable (#4017).
|
||||
@@ -197,6 +260,10 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
if let Err(e) = app.run("window.arrange.floatInWindow", json!({"index": i, "view": n, "at": [at.x, at.y]})) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if let Some((from, to)) = reorder {
|
||||
if let Err(e) = app.run("document.move", json!({"from": from, "to": to})) {
|
||||
app.status(e);
|
||||
}
|
||||
} else if let Some(w) = activate {
|
||||
crate::doc_windows::show_window(app, w);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,56 @@ fn the_active_tab_stays_in_view() {
|
||||
assert_eq!(super::first_tab_shown(&widths, None, 350.0), 0);
|
||||
assert_eq!(super::first_tab_shown(&widths, Some(4), 50.0), 4, "a tab wider than the room still shows");
|
||||
}
|
||||
|
||||
/// Tabs 100 wide at 0, 100, 200…, for documents `docs` (a document listed twice has two views).
|
||||
fn slots(docs: &[usize]) -> Vec<(usize, egui::Rect)> {
|
||||
docs.iter().enumerate().map(|(k, &d)| (d, egui::Rect::from_min_size(egui::pos2(k as f32 * 100.0, 0.0), egui::vec2(100.0, 30.0)))).collect()
|
||||
}
|
||||
|
||||
/// A document's view tabs make one group, which moves as one (#5663).
|
||||
#[test]
|
||||
fn a_documents_view_tabs_make_one_group() {
|
||||
let groups = super::tab_groups(&slots(&[0, 1, 1, 2]));
|
||||
let spans: Vec<(usize, f32, f32)> = groups.iter().map(|(d, s)| (*d, s.min, s.max)).collect();
|
||||
assert_eq!(spans, vec![(0, 0.0, 100.0), (1, 100.0, 300.0), (2, 300.0, 400.0)]);
|
||||
assert!(super::tab_groups(&[]).is_empty());
|
||||
}
|
||||
|
||||
/// A dragged tab drops past the middles of the tabs it crossed, as an index among all the open
|
||||
/// documents (#5663).
|
||||
#[test]
|
||||
fn a_dragged_tab_drops_past_the_middles_it_crossed() {
|
||||
let groups = super::tab_groups(&slots(&[0, 1, 2, 3]));
|
||||
let drop = |from, x| super::tab_drop(&groups, from, x);
|
||||
assert_eq!(drop(0, 40.0), None, "still over itself");
|
||||
assert_eq!(drop(0, 140.0), None, "not yet past the next tab's middle");
|
||||
assert_eq!(drop(0, 160.0), Some((1, 200.0)), "past the second tab's middle: between it and the third");
|
||||
assert_eq!(drop(0, 900.0), Some((3, 400.0)), "past the end: last");
|
||||
assert_eq!(drop(3, -50.0), Some((0, 0.0)), "before the start: first");
|
||||
assert_eq!(drop(3, 120.0), Some((1, 100.0)), "between the first two");
|
||||
assert_eq!(drop(2, 260.0), None, "between its neighbours: where it is");
|
||||
assert_eq!(super::tab_drop(&super::tab_groups(&slots(&[0])), 0, 500.0), None, "no other tab");
|
||||
assert_eq!(super::tab_drop(&[], 0, 500.0), None);
|
||||
}
|
||||
|
||||
/// Documents without a tab on the strip (floating, or past its end) keep their places in the
|
||||
/// documents' order; the drop names an index among all of them (#5663).
|
||||
#[test]
|
||||
fn a_drop_counts_the_documents_the_strip_does_not_show() {
|
||||
// Documents 0, 2 and 5 have tabs; 1, 3 and 4 float.
|
||||
let groups = super::tab_groups(&slots(&[0, 2, 5]));
|
||||
let drop = |from, x| super::tab_drop(&groups, from, x).map(|(to, _)| to);
|
||||
assert_eq!(drop(0, 260.0), Some(5), "after document 5: last, as 0 leaves its place");
|
||||
assert_eq!(drop(0, 160.0), Some(4), "between 2 and 5: just before 5, which is index 4 once 0 has left its place");
|
||||
assert_eq!(drop(5, -10.0), Some(0), "before document 0");
|
||||
assert_eq!(drop(5, 60.0), Some(2), "before document 2: index 2, the floating document 1 stays before it");
|
||||
assert_eq!(drop(2, 260.0), Some(5), "after document 5");
|
||||
// With view tabs: document 1 has two, side by side; it moves as one.
|
||||
let groups = super::tab_groups(&slots(&[0, 1, 1, 2]));
|
||||
assert_eq!(super::tab_drop(&groups, 1, 20.0), Some((0, 0.0)), "both views' document goes first");
|
||||
assert_eq!(super::tab_drop(&groups, 0, 220.0), Some((1, 300.0)), "past document 1's middle, which both its tabs span");
|
||||
assert_eq!(super::tab_drop(&groups, 0, 190.0), None, "not yet past it");
|
||||
}
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{StatusReadoutWidths, status_readouts};
|
||||
|
||||
@@ -44,6 +44,12 @@ fn activated_at_id() -> Id {
|
||||
Id::unique("document-drag-activated-at")
|
||||
}
|
||||
|
||||
/// Whether a Selection drag is carrying artwork across the document tabs, which then switch
|
||||
/// documents and never move along the strip.
|
||||
pub(crate) fn carrying_artwork(ctx: &Context) -> bool {
|
||||
egui::DragAndDrop::payload::<Artwork>(ctx).is_some()
|
||||
}
|
||||
|
||||
/// Record the Selection drag source and grab point, clearing any previous tab-hover timer.
|
||||
pub(crate) fn begin(app: &VectorcraftApp, ctx: &Context, grab: Point) {
|
||||
ctx.data_mut(|d| {
|
||||
|
||||
@@ -126,6 +126,10 @@ pub struct VectorcraftApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub views: Vec<View>,
|
||||
/// The document ([`vectorcraft_engine::DocState::uid`]) each of `views` belonged to at the last
|
||||
/// [`VectorcraftApp::sync_views`], which puts the views back with their documents after
|
||||
/// `document.move` reorders them (#5663).
|
||||
pub(crate) view_uids: Vec<u64>,
|
||||
pub services: Services,
|
||||
pub canvas: CanvasCache,
|
||||
pub perf: Perf,
|
||||
|
||||
@@ -51,6 +51,7 @@ mod sysclip_emf;
|
||||
mod sysclip_guides;
|
||||
mod sysclip_probe;
|
||||
mod tab_overflow;
|
||||
mod tab_reorder;
|
||||
mod taskbar;
|
||||
mod toolescape;
|
||||
mod trace_runs;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Document tabs dragged along the tab strip move their documents (#5663); dragged off it, they
|
||||
//! still float them.
|
||||
|
||||
use egui::{Event, PointerButton, Pos2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use crate::{VectorcraftApp, chrome};
|
||||
|
||||
const SIZE: egui::Vec2 = egui::vec2(1000.0, 700.0);
|
||||
const NAMES: [&str; 4] = ["Alpha", "Bravo", "Charlie", "Delta"];
|
||||
|
||||
/// Run a tab-strip frame; → the tabs' titles with their rects, and the frame's output.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> (Vec<(String, egui::Rect)>, egui::FullOutput) {
|
||||
let mut out = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, SIZE)), time: Some(time), events, ..Default::default() },
|
||||
|ui| {
|
||||
egui::CentralPanel::default().show(ui, |ui| chrome::doc_tabs(app, ui));
|
||||
},
|
||||
);
|
||||
out.textures_delta.clear();
|
||||
fn walk(s: &egui::Shape, v: &mut Vec<(String, egui::Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => v.push((t.galley.text().to_owned(), egui::Rect::from_min_size(t.pos, t.galley.size()))),
|
||||
egui::Shape::Vec(list) => list.iter().for_each(|s| walk(s, v)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut texts = vec![];
|
||||
for c in &out.shapes {
|
||||
walk(&c.shape, &mut texts);
|
||||
}
|
||||
(texts, out)
|
||||
}
|
||||
|
||||
fn button(at: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// An app with documents Alpha, Bravo, Charlie and Delta, Delta current, and a context that has
|
||||
/// drawn the strip once.
|
||||
fn app() -> (VectorcraftApp, egui::Context) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
for name in NAMES {
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": name})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
frame(&mut app, &ctx, 0.0, vec![]);
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
fn order(app: &VectorcraftApp) -> Vec<String> {
|
||||
app.session.documents().iter().map(|d| d.title()).collect()
|
||||
}
|
||||
|
||||
fn current(app: &VectorcraftApp) -> String {
|
||||
app.session.active().map(|d| d.title()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The middle of the title of `name`'s tab.
|
||||
fn tab(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, name: &str) -> Pos2 {
|
||||
let (texts, _) = frame(app, ctx, time, vec![]);
|
||||
texts.iter().find(|(t, _)| t.starts_with(name)).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("{name}'s tab: {texts:?}"))
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` in steps, and release there; → the output of the frame before
|
||||
/// the release (while the tab is still dragged).
|
||||
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, from: Pos2, to: Pos2) -> egui::FullOutput {
|
||||
frame(app, ctx, time, vec![Event::PointerMoved(from)]);
|
||||
frame(app, ctx, time + 0.05, vec![button(from, true)]);
|
||||
let mut last = None;
|
||||
for k in 1..=8 {
|
||||
let p = from + (to - from) * (k as f32 / 8.0);
|
||||
last = Some(frame(app, ctx, time + 0.05 + 0.02 * f64::from(k), vec![Event::PointerMoved(p)]).1);
|
||||
}
|
||||
frame(app, ctx, time + 0.3, vec![button(to, false)]);
|
||||
frame(app, ctx, time + 0.35, vec![]);
|
||||
last.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_dragged_along_the_strip_moves_its_document_and_the_current_one_stays() {
|
||||
let (mut app, ctx) = app();
|
||||
assert_eq!(current(&app), "Delta");
|
||||
let alpha = tab(&mut app, &ctx, 0.1, "Alpha");
|
||||
let delta = tab(&mut app, &ctx, 0.15, "Delta");
|
||||
let during = drag(&mut app, &ctx, 0.2, alpha, delta + egui::vec2(80.0, 0.0));
|
||||
assert_eq!(during.platform_output.cursor_icon, egui::CursorIcon::Grabbing, "the tab is held while it moves");
|
||||
assert_eq!(order(&app), ["Bravo", "Charlie", "Delta", "Alpha"]);
|
||||
assert_eq!(current(&app), "Delta", "a move is not a switch");
|
||||
// The tabs are drawn in the new order.
|
||||
let xs: Vec<f32> = NAMES.iter().map(|n| tab(&mut app, &ctx, 1.0, n).x).collect();
|
||||
assert!(xs[1] < xs[2] && xs[2] < xs[3] && xs[3] < xs[0], "Bravo, Charlie, Delta, Alpha: {xs:?}");
|
||||
|
||||
// And back to the start, before Bravo.
|
||||
let alpha = tab(&mut app, &ctx, 1.1, "Alpha");
|
||||
let bravo = tab(&mut app, &ctx, 1.15, "Bravo");
|
||||
drag(&mut app, &ctx, 1.2, alpha, bravo - egui::vec2(80.0, 0.0));
|
||||
assert_eq!(order(&app), NAMES);
|
||||
assert_eq!(current(&app), "Delta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_keeps_its_own_zoom_after_its_tab_moves() {
|
||||
let (mut app, ctx) = app();
|
||||
for (k, v) in app.views.iter_mut().enumerate() {
|
||||
v.zoom = 1.0 + k as f64;
|
||||
}
|
||||
let bravo = tab(&mut app, &ctx, 0.1, "Bravo");
|
||||
let delta = tab(&mut app, &ctx, 0.15, "Delta");
|
||||
drag(&mut app, &ctx, 0.2, bravo, delta + egui::vec2(80.0, 0.0));
|
||||
assert_eq!(order(&app), ["Alpha", "Charlie", "Delta", "Bravo"]);
|
||||
let zooms: Vec<f64> = app.views.iter().map(|v| v.zoom).collect();
|
||||
assert_eq!(zooms, [1.0, 3.0, 4.0, 2.0], "each view went with its document");
|
||||
assert_eq!(app.view().map(|v| v.zoom), Some(4.0), "the current document's view is still Delta's");
|
||||
}
|
||||
|
||||
/// A move from outside the strip (MCP, the control channel, a recording) keeps the views with
|
||||
/// their documents too.
|
||||
#[test]
|
||||
fn views_follow_a_move_made_by_the_engine_command() {
|
||||
let (mut app, _ctx) = app();
|
||||
for (k, v) in app.views.iter_mut().enumerate() {
|
||||
v.zoom = 1.0 + k as f64;
|
||||
}
|
||||
app.session.execute("document.move", &json!({"from": 3, "to": 0})).unwrap();
|
||||
app.sync_views();
|
||||
assert_eq!(order(&app), ["Delta", "Alpha", "Bravo", "Charlie"]);
|
||||
assert_eq!(app.views.iter().map(|v| v.zoom).collect::<Vec<_>>(), [4.0, 1.0, 2.0, 3.0]);
|
||||
// Closing a document still leaves the others' views with them.
|
||||
app.run("file.close", json!({"index": 1})).unwrap();
|
||||
app.sync_views();
|
||||
assert_eq!(order(&app), ["Delta", "Bravo", "Charlie"]);
|
||||
assert_eq!(app.views.iter().map(|v| v.zoom).collect::<Vec<_>>(), [4.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_dragged_off_the_strip_still_floats_and_moves_nothing() {
|
||||
let (mut app, ctx) = app();
|
||||
let alpha = tab(&mut app, &ctx, 0.1, "Alpha");
|
||||
drag(&mut app, &ctx, 0.2, alpha, alpha + egui::vec2(150.0, 300.0));
|
||||
assert_eq!(order(&app), NAMES, "the documents keep their order");
|
||||
let uid = app.session.documents()[0].uid;
|
||||
assert!(app.ui.floating.iter().any(|w| w.uid == uid), "Alpha floats in a window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_document_steps_through_the_tabs_in_their_new_order() {
|
||||
let (mut app, ctx) = app();
|
||||
let delta = tab(&mut app, &ctx, 0.1, "Delta");
|
||||
let alpha = tab(&mut app, &ctx, 0.15, "Alpha");
|
||||
drag(&mut app, &ctx, 0.2, delta, alpha - egui::vec2(80.0, 0.0));
|
||||
assert_eq!(order(&app), ["Delta", "Alpha", "Bravo", "Charlie"]);
|
||||
assert_eq!(current(&app), "Delta");
|
||||
app.run("window.nextDocument", json!({})).unwrap();
|
||||
assert_eq!(current(&app), "Alpha", "after Delta, now first, comes Alpha");
|
||||
app.run("window.previousDocument", json!({})).unwrap();
|
||||
app.run("window.previousDocument", json!({})).unwrap();
|
||||
assert_eq!(current(&app), "Charlie", "before the first tab comes the last");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_a_tab_still_switches_to_it() {
|
||||
let (mut app, ctx) = app();
|
||||
let bravo = tab(&mut app, &ctx, 0.1, "Bravo");
|
||||
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(bravo)]);
|
||||
frame(&mut app, &ctx, 0.25, vec![button(bravo, true)]);
|
||||
frame(&mut app, &ctx, 0.3, vec![button(bravo, false)]);
|
||||
assert_eq!(current(&app), "Bravo");
|
||||
assert_eq!(order(&app), NAMES);
|
||||
}
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -611,6 +611,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
- **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.
|
||||
- **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.
|
||||
- **Done (#5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)), a line marking where it goes; the current document stays current, each document keeps its own view, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` does the same from MCP and the control channel. Estimate unchanged.
|
||||
- **Done (#4116):** Window › New Window (`window.newWindow`) opens another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab titled with the view's number ("Name:2"); Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document. Closing a view leaves the document open until its last view closes. Estimate unchanged.
|
||||
- **Estimate:** 15–25 h.
|
||||
|
||||
@@ -735,6 +736,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | G7: document tabs are dragged along the tab strip to reorder them (`document.move`, #5663). Estimate unchanged. |
|
||||
| 2026-11-02 | minor | G15: the renderer keeps a compound path's assembled path between frames and drops unused geometry entries after a few frames however few there are. Estimate unchanged. |
|
||||
| 2026-11-02 | minor | G15: hit testing culls paths and compound paths by their control points' box and tests a compound path's outline and fill against only its members near the click; the same objects are hit. Estimate unchanged. |
|
||||
| 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides within the artwork (#5280). Guides outside the artwork remain unsupported. The G5 estimate remains 14–24 h. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Type Tools add-on: Release Type on a Path, a VectorCraft addition, #5532; optical kerning spaces pairs by the font's own stem spacing instead of a fixed tight gap; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -560,6 +560,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **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)), 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)). 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)). 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)). 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`.
|
||||
- **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`.
|
||||
- **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
|
||||
- **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
|
||||
|
||||
@@ -1646,6 +1647,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | View & navigation: document tabs are reordered by dragging them along the tab strip (`document.move`, #5663); evidence added; scores unchanged. |
|
||||
| 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. |
|
||||
| 2026-11-01 | minor | Batch Trace Folder (`imageTrace.batchFolder`, #4921); evidence added; scores unchanged. |
|
||||
| 2026-11-01 | minor | Copy and paste ruler guides (#3226); evidence added; scores unchanged. |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (Path Precision Tools: Set Corners… rounds only the corners in an angle range; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (document tabs are reordered by dragging them along the tab strip; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -300,6 +300,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
| Interaction | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Selection artwork drag hovers a visible document tab to activate it, then drops editable artwork on its canvas | done | `tests_document_drag`, `edit.dropSelection`, lossless resource and one-step Undo regressions; overflow and cross-window drag remain separate |
|
||||
| A document tab dragged along the tab strip moves its document; a line marks where it goes, the current document and every document's view stay as they were, and a drag off the strip still floats it | done | #5663, [public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html); `document.move`; `tests::tab_reorder`, `chrome::tests` (drop index with floating and view tabs), `engine` `tests::document_move` |
|
||||
|
||||
| Behaviour | Status | Notes |
|
||||
|---|---|---|
|
||||
@@ -435,6 +436,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-05 | minor | Document tabs are reordered by dragging them along the tab strip (`document.move`, #5663). |
|
||||
| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds only the corners in an angle range (#5545); scores and estimates unchanged |
|
||||
| 2026-11-04 | minor | Path Precision Tools add-on (a VectorCraft addition): Set Corners… rounds all, the odd-numbered or the even-numbered anchors (#5546); scores and estimates unchanged |
|
||||
| 2026-11-04 | minor | New Document: the units chosen in Preset Details belong to the chosen document, as the pane's options are listed "for the selected preset" ([public source](https://helpx.adobe.com/illustrator/desktop/add-and-import-files/start-a-new-file/create-and-save-custom-document-presets.html)); the chosen card shows them, every other Recent and Saved card keeps the units its document or preset was made in, and the built-in tabs still follow the chosen units (#2078); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user