Window: Float in Window, Float All in Windows and Cascade float documents

Window > Arrange > Float in Window (window.arrange.floatInWindow) moves
the current document out of the tab strip into a floating window of its
own, and Float All in Windows (window.arrange.floatAllInWindows), a new
item, does so for every tabbed document. Cascade (window.arrange.cascade)
floats every document and stacks the windows from the upper left of the
document area to its lower right, 30 pt apart, in tab order. On the
desktop each window is an OS window; on the web and on Wayland it is a
window inside the app's window, as floating panels are. While the
control channel is attached, the windows also open inside the app's
window, so ui.click, ui.drag and ui.screenshot reach them.

The current document's window holds the canvas. The other windows, and
the main window while the current document floats, draw their documents
as Tile's panes do, and a press in one makes its document the current
one. A document's OS window that takes the keyboard focus makes its
document the current one, and keys typed there run the main window's
shortcuts. Closing a window closes its document, asking to save unsaved
changes. Consolidate All Windows brings floating documents back under
the tabs, and Tile brings them back into its grid.

Cascade is enabled with two or more documents, Float in Window while the
current document is tabbed, Float All in Windows while any document is
tabbed, and Consolidate All Windows while documents are tiled or
floating. ui.inspect reports the floating documents.
This commit is contained in:
dotCooCoo
2026-11-03 16:03:39 +07:00
committed by bflatastic
parent c79fd5c132
commit 2c0ef0f24e
46 changed files with 1147 additions and 62 deletions
+17 -2
View File
@@ -85,6 +85,7 @@ impl VectorcraftApp {
hover_cursor: None,
presented: None,
panes: Default::default(),
windows: Default::default(),
},
perf: Perf::default(),
dock_rt: Default::default(),
@@ -427,18 +428,31 @@ impl VectorcraftApp {
}
/// The canvas panel's body: the document tabs, the canvas (a pane for each document while
/// Window › Arrange › Tile is on) and the isolation bar over it.
/// Window › Arrange › Tile is on) and the isolation bar over it. While the current document is
/// in a floating window, the tab strip's document draws as a pane.
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);
if self.ui.screen_mode < 3 {
chrome::doc_tabs(self, ui);
}
self.canvas.windows.area = Some(ui.available_rect_before_wrap());
let floats = crate::doc_windows::current_floats(self);
if !canvas::tiled_area(self, ui) {
canvas::show(self, ui);
if floats {
let rect = ui.available_rect_before_wrap();
if let Some(i) = crate::doc_windows::tab_document(self)
&& canvas::pane(self, ui, i, rect)
{
chrome::show_document(self, i);
}
} else {
canvas::show(self, ui);
}
}
// Over the canvas inside the rulers, so the rulers and scroll bars stay uncovered.
if self.ui.screen_mode < 3
&& !floats
&& !menus::home_showing(self)
&& let Some(area) = self.canvas_rect
{
@@ -510,6 +524,7 @@ impl VectorcraftApp {
panels::library_panel::tidy(self);
// The canvas and the panels: one docking layout. Tab hides the panels but not the canvas.
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui));
crate::doc_windows::show(self, &ctx);
dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
export_progress::show(self, &ctx);
+2
View File
@@ -60,6 +60,8 @@ pub struct CanvasCache {
pub presented: Option<egui::Rect>,
/// Window › Arrange › Tile: the renders the panes of the other documents draw.
pub panes: canvas::Panes,
/// Window › Arrange › Float in Window: what the floating document windows keep between frames.
pub windows: crate::doc_windows::Windows,
}
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
+1 -1
View File
@@ -61,7 +61,7 @@ use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn};
use overlays::selected_anchor;
use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay};
pub use panes::Panes;
pub(crate) use panes::{consolidate, tile, tiled_area};
pub(crate) use panes::{consolidate, pane, tile, tiled_area};
use raster::{changed_pixels, pixel_region, upload};
pub(crate) use raster::{color_image, color_image_owned};
use task_bar::task_bar;
+47 -28
View File
@@ -140,9 +140,11 @@ pub(crate) fn cells(n: usize, area: egui::Rect) -> Vec<egui::Rect> {
(0..n).map(|i| egui::Rect::from_min_max(pos2(x(i % cols), y(i / cols)), pos2(x(i % cols + 1), y(i / cols + 1)))).collect()
}
/// `window.arrange.tile`: every open document in a pane of its own, each fitted to its pane.
/// `window.arrange.tile`: every open document in a pane of its own, each fitted to its pane. The
/// documents in floating windows come back under the tabs for it.
pub(crate) fn tile(app: &mut VectorcraftApp) -> Result<serde_json::Value, String> {
app.sync_views();
crate::doc_windows::dock_all(app);
let n = app.session.documents().len();
app.ui.tiled = n > 0;
if n > 0 {
@@ -154,65 +156,80 @@ pub(crate) fn tile(app: &mut VectorcraftApp) -> Result<serde_json::Value, String
Ok(json!({"tiled": app.ui.tiled, "panes": n}))
}
/// `window.arrange.consolidateAllWindows`: one document at a time again, under its tab.
/// `window.arrange.consolidateAllWindows`: one document at a time again, under its tab, the
/// documents in floating windows included.
pub(crate) fn consolidate(app: &mut VectorcraftApp) -> Result<serde_json::Value, String> {
app.ui.tiled = false;
crate::doc_windows::dock_all(app);
app.canvas.panes.clear();
Ok(json!({"tiled": false}))
Ok(json!({"tiled": false, "floating": []}))
}
/// Draw the document area as panes while Tile is on and more than one document is open, outside
/// the Home screen and Presentation Mode. False when it did not: the caller draws the canvas.
/// Draw the document area as panes while Tile is on and more than one document is under the tabs,
/// outside the Home screen and Presentation Mode. False when it did not: the caller draws the
/// canvas. The documents in floating windows have no pane.
pub(crate) fn tiled_area(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
let n = app.session.documents().len();
if n == 0 {
if app.session.documents().is_empty() {
app.ui.tiled = false;
}
let docked = crate::doc_windows::docked(app);
let n = docked.len();
let tiling = app.ui.tiled && n > 1 && app.ui.screen_mode < 3 && !crate::menus::home_showing(app);
let Some(active) = app.session.active_index().filter(|_| tiling) else {
if !app.canvas.panes.shown.is_empty() || !app.canvas.panes.last.is_empty() {
if !tiling {
// The floating windows draw their panes from the same renders.
if app.ui.floating.is_empty() && (!app.canvas.panes.shown.is_empty() || !app.canvas.panes.last.is_empty()) {
app.canvas.panes.clear();
}
app.canvas.panes.last.clear();
return false;
};
}
// The current document's pane is the canvas, unless it is in a floating window.
let active = app.session.active_index().filter(|a| docked.contains(a));
app.sync_views();
let live: Vec<usize> = app.session.documents().iter().map(|d| d.uid as usize).collect();
app.canvas.panes.shown.retain(|uid, _| live.contains(uid));
app.canvas.panes.pending.retain(|uid, _| live.contains(uid));
app.canvas.panes.fits.retain(|uid, _| live.contains(uid));
// A document opened or closed changes the grid, and so does coming back to the panes from one
// document: each document fits its new pane.
if app.canvas.panes.last.len() != n {
for v in &mut app.views {
v.fitted = false;
// A document opened, closed, floated or docked changes the grid, and so does coming back to the
// panes from one document: each document fits its new pane.
if app.canvas.panes.last.iter().map(|(i, _)| *i).ne(docked.iter().copied()) {
for i in &docked {
if let Some(v) = app.views.get_mut(*i) {
v.fitted = false;
}
}
}
let t = Tokens::get(ui.ctx());
let ppp = ui.ctx().pixels_per_point();
let cells = cells(n, ui.available_rect_before_wrap());
let areas: Vec<egui::Rect> = cells.iter().map(|c| view_rects(app, *c, ppp).1).collect();
let cells: Vec<(usize, egui::Rect)> = docked.iter().copied().zip(cells(n, ui.available_rect_before_wrap())).collect();
let mut areas: Vec<Option<egui::Rect>> = vec![None; app.session.documents().len()];
for (i, cell) in &cells {
if let Some(a) = areas.get_mut(*i) {
*a = Some(view_rects(app, *cell, ppp).1);
}
}
let unfitted = refit_resized(app, &areas);
let mut activate = None;
// The other panes first, so that what the canvas draws past its pane (the Contextual Task Bar)
// stays over them.
for (i, cell) in cells.iter().copied().enumerate().filter(|(i, _)| *i != active) {
for (i, cell) in cells.iter().copied().filter(|(i, _)| Some(*i) != active) {
if pane(app, ui, i, cell) {
activate = Some(i);
}
ui.painter().rect_stroke(cell, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
}
if let Some(cell) = cells.get(active).copied() {
if let Some((_, cell)) = cells.iter().copied().find(|(i, _)| Some(*i) == active) {
let mut canvas = ui.new_child(egui::UiBuilder::new().max_rect(cell));
super::show(app, &mut canvas);
ui.painter().rect_stroke(cell, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
}
// Note the fits this frame made, for `refit_resized`.
for ((i, area), was_unfitted) in areas.iter().enumerate().zip(unfitted) {
if let (true, Some(v), Some(d)) = (was_unfitted, app.views.get(i).filter(|v| v.fitted), app.session.documents().get(i)) {
if let (Some(area), true, Some(v), Some(d)) = (area, was_unfitted, app.views.get(i).filter(|v| v.fitted), app.session.documents().get(i)) {
app.canvas.panes.fits.insert(d.uid as usize, (area.size(), v.zoom, v.center));
}
}
app.canvas.panes.last = cells.into_iter().enumerate().collect();
app.canvas.panes.last = cells;
if let Some(i) = activate {
crate::chrome::show_document(app, i);
}
@@ -220,14 +237,15 @@ pub(crate) fn tiled_area(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
}
/// Unfit each document whose view is still where a pane fit left it when its view's area (in
/// `areas`, by document index) has changed size, as when the window or a panel is resized: it
/// fits its pane again. A view zoomed or scrolled since keeps its place. → which views are
/// unfitted now.
fn refit_resized(app: &mut VectorcraftApp, areas: &[egui::Rect]) -> Vec<bool> {
/// `areas`, by document index; None for a document without a pane) has changed size, as when the
/// window or a panel is resized: it fits its pane again. A view zoomed or scrolled since keeps its
/// place. → which views are unfitted now.
fn refit_resized(app: &mut VectorcraftApp, areas: &[Option<egui::Rect>]) -> Vec<bool> {
let fits = &app.canvas.panes.fits;
let docs = app.session.documents();
for ((v, d), area) in app.views.iter_mut().zip(docs).zip(areas) {
if let Some((size, zoom, center)) = fits.get(&(d.uid as usize))
if let Some(area) = area
&& let Some((size, zoom, center)) = fits.get(&(d.uid as usize))
&& *size != area.size()
&& *zoom == v.zoom
&& *center == v.center
@@ -251,8 +269,9 @@ fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect) {
/// The pane of document `i`, not the current one, in `cell`: its paper, art, grid, artboards,
/// guides and selected objects, and its rulers and the room of the canvas's scroll bars, so the
/// document stays where it is when the pane becomes the canvas. True when a press in the pane asks
/// for its document to become the current one.
fn pane(app: &mut VectorcraftApp, ui: &Ui, i: usize, cell: egui::Rect) -> bool {
/// for its document to become the current one. A floating document's window draws its document
/// with this too ([`crate::doc_windows`]).
pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, i: usize, cell: egui::Rect) -> bool {
let t = Tokens::get(ui.ctx());
let ppp = ui.ctx().pixels_per_point();
let (full, rect) = view_rects(app, cell, ppp);
+1 -1
View File
@@ -151,7 +151,7 @@ fn consolidate_all_windows_shows_one_document_again() {
app.run("window.arrange.tile", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
assert_eq!(panes(&app).0.len(), 2);
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(), json!({"tiled": false}));
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(), json!({"tiled": false, "floating": []}));
frame(&mut app, &ctx, vec![]);
assert!(panes(&app).0.is_empty() && app.canvas.panes.shown.is_empty());
assert_eq!(app.canvas_rect, Some(tabbed), "the current document fills the document area again");
+1 -1
View File
@@ -27,10 +27,10 @@ pub use hints::{hint_bar, hint_strings};
pub use status_bar::status_bar;
#[cfg(test)]
use status_bar::{StatusReadoutWidths, status_readouts};
pub(crate) use tabs::show_document;
#[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};
/// The application bar's height, in points (compact, as in PhotoCraft).
pub const APP_BAR_HEIGHT: f32 = 32.0;
+26 -14
View File
@@ -19,6 +19,12 @@ pub(super) fn doc_tab_title(app: &VectorcraftApp, i: usize, d: &vectorcraft_engi
tab_title(d, zoom, view.is_some_and(|v| v.outline), app.session.prefs.display_print_size)
}
/// The title of document `i`'s floating window ([`crate::doc_windows`]): its tab's title.
pub(crate) fn document_window_title(app: &VectorcraftApp, i: usize) -> Option<String> {
let d = app.session.documents().get(i)?;
Some(doc_tab_title(app, i, d))
}
/// 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");
@@ -37,6 +43,8 @@ pub(super) fn first_tab_shown(widths: &[f32], active: Option<usize>, room: f32)
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)". User Interface › Large Tabs makes the tabs
/// 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.
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The width of the » button.
const MORE: f32 = 30.0;
@@ -46,15 +54,16 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
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.
let active = app.session.active_index().filter(|_| app.ui.home.is_none());
let tabs: Vec<(String, std::sync::Arc<egui::Galley>)> = app
.session
.documents()
let shown = crate::doc_windows::tab_document(app).filter(|_| app.ui.home.is_none());
// The documents under the tabs, by index; `active` is the position of the highlighted one.
let docked = crate::doc_windows::docked(app);
let active = docked.iter().position(|i| Some(*i) == shown);
let tabs: Vec<(String, std::sync::Arc<egui::Galley>)> = docked
.iter()
.enumerate()
.filter_map(|i| Some((*i, app.session.documents().get(*i)?)))
.map(|(i, d)| {
let title = doc_tab_title(app, i, d);
let color = if Some(i) == active { t.text_strong } else { t.text_dim };
let color = if Some(i) == shown { t.text_strong } else { t.text_dim };
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
})
.collect();
@@ -66,11 +75,12 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let mut activate = None;
let mut close = None;
let mut hovering_art = false;
for (i, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) {
if x + w > strip.left() + room && Some(i) != active {
for (k, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) {
if x + w > strip.left() + room && Some(k) != active {
break;
}
let is_active = Some(i) == active;
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());
if is_active {
@@ -95,10 +105,12 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
x += w;
}
crate::document_drag::finish_tabs(ui.ctx(), hovering_art);
// The » button: every open document, the active one checked. The list scrolls once it
// reaches the bottom of the window, so every document of a large session stays reachable
// (#4017).
// The » button: every open document, floating ones included, the active one checked. The list
// scrolls once it reaches the bottom of the window, so every document of a large session stays
// reachable (#4017).
if overflow {
let current = app.session.active_index().filter(|_| app.ui.home.is_none());
let titles: Vec<String> = (0..app.session.documents().len()).map(|i| document_window_title(app, i).unwrap_or_default()).collect();
let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0));
let resp = ui.interact(more, ui.scope_id().with("tabs-more"), Sense::click());
ui.painter().rect_filled(more, 0.0, if resp.hovered() { t.hover.gamma_multiply(0.4) } else { t.tab_strip });
@@ -106,8 +118,8 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
let resp = resp.on_hover_text(tl!("Show all open documents"));
egui::Popup::menu(&resp).show(|ui| {
widgets::menu_scroll(ui, |ui| {
for (i, (title, _)) in tabs.iter().enumerate() {
if widgets::menu_item_name(ui, title, true, Some(i) == active) {
for (i, title) in titles.iter().enumerate() {
if widgets::menu_item_name(ui, title, true, Some(i) == current) {
activate = Some(i);
ui.close();
}
+3
View File
@@ -200,6 +200,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"rect": [c.left(), c.top(), c.width(), c.height()],
"active": app.session.active_index() == Some(*i),
})).collect::<Vec<_>>(),
// Window › Arrange › Float in Window: the documents in floating windows, by index. While
// the current one floats, `canvasRect` is in its window's coordinates.
"floating": (0..app.session.documents().len()).filter(|i| crate::doc_windows::is_floating(app, *i)).collect::<Vec<_>>(),
"window": [r.width(), r.height()],
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
"activeDocument": app.session.active_index(),
+497
View File
@@ -0,0 +1,497 @@
//! Window › Arrange › Float in Window, Float All in Windows and Cascade: documents in floating
//! windows of their own, out of the tab strip, until Consolidate All Windows. On the desktop each
//! window is an OS window; on the web, on Wayland and wherever egui draws every viewport inside
//! the main window, it is a window inside the app's window, as floating panels are
//! ([`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.
use serde_json::{Value, json};
use crate::VectorcraftApp;
/// 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.
const CASCADE_MARGIN: f32 = 24.0;
/// The smallest size of a document window, in points.
const MIN_SIZE: egui::Vec2 = egui::vec2(240.0, 180.0);
/// A document in a floating window.
#[derive(Clone, Debug)]
pub struct DocWindow {
/// The document ([`vectorcraft_engine::DocState::uid`]).
pub uid: u64,
/// Where the window goes, in the main window's points.
pub place: egui::Rect,
/// 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
/// that move. Repeated each frame, so egui sees an unchanged window.
screen: Option<(egui::Pos2, u32)>,
/// Its OS window had the keyboard focus in the last frame.
focused: bool,
/// Where the last frame drew its document, in its window's coordinates.
pub(crate) body: Option<egui::Rect>,
/// 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<Fit>,
}
/// [`DocWindow::fit`]: a body size, and a view's zoom and center.
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 }
}
}
/// What the document windows keep between frames.
#[derive(Default)]
pub struct Windows {
/// The document area of the main window (the tabs and the canvas) in its last frame.
pub(crate) area: Option<egui::Rect>,
/// The current document (its uid) when the windows were last drawn: a floating document that
/// becomes the current one comes to the front.
current: Option<u64>,
/// While Presentation Mode is on: the floating documents (their uids) it has shown.
presented: Option<Vec<u64>>,
}
/// Whether document `i` is in a floating window.
pub(crate) fn is_floating(app: &VectorcraftApp, i: usize) -> bool {
app.session.documents().get(i).is_some_and(|d| app.ui.floating.iter().any(|w| w.uid == d.uid))
}
/// The documents under the tab strip, by index, in tab order.
pub(crate) fn docked(app: &VectorcraftApp) -> Vec<usize> {
(0..app.session.documents().len()).filter(|i| !is_floating(app, *i)).collect()
}
/// The document the tab strip shows: the current one when it is under the tabs, else the tabbed
/// document that was current last, else the first tabbed one.
pub(crate) fn tab_document(app: &VectorcraftApp) -> Option<usize> {
let docked = docked(app);
let active = app.session.active_index();
if let Some(a) = active.filter(|a| docked.contains(a)) {
return Some(a);
}
let last = app.ui.tab_document.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid));
last.filter(|i| docked.contains(i)).or(docked.first().copied())
}
/// Whether the current document is in a floating window, outside the Home screen and Presentation
/// Mode: the main window's canvas then draws the tab strip's document as a pane.
pub(crate) fn current_floats(app: &VectorcraftApp) -> bool {
app.session.active_index().is_some_and(|i| is_floating(app, i)) && !crate::menus::home_showing(app) && !presenting(app)
}
/// Whether Presentation Mode is on: the main window shows the current document, and the floating
/// windows are hidden until it ends.
fn presenting(app: &VectorcraftApp) -> bool {
app.ui.screen_mode >= 3
}
/// The places Cascade gives `n` windows in `area`: each the same size, the first near the area's
/// top-left corner and each next one [`CASCADE_STEP`] further down and to the right, starting
/// again at the corner when the next one would leave the area. Every place lies inside `area`.
pub(crate) fn cascade_places(area: egui::Rect, n: usize) -> Vec<egui::Rect> {
let size = (area.size() * 0.6).max(MIN_SIZE).min(area.size());
let room = (area.size() - size).min_elem().max(0.0);
let steps = if room > CASCADE_MARGIN { ((room - CASCADE_MARGIN) / CASCADE_STEP).floor() as usize + 1 } else { 1 };
(0..n)
.map(|k| {
let offset = (CASCADE_MARGIN + (k % steps) as f32 * CASCADE_STEP).min(room);
egui::Rect::from_min_size(area.min + egui::vec2(offset, offset), size)
})
.collect()
}
/// The document area windows open in: the main window's last one, else a 1200 × 800 area.
fn area(app: &VectorcraftApp) -> egui::Rect {
app.canvas.windows.area.filter(|r| r.is_positive()).unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0)))
}
/// Float documents `docs` (indices), each at the next cascade place after the windows already
/// open. Each fits its window.
fn float(app: &mut VectorcraftApp, docs: &[usize]) {
app.sync_views();
let first = app.ui.floating.len();
let places = cascade_places(area(app), first + docs.len());
for (k, i) in docs.iter().enumerate() {
let Some(uid) = app.session.documents().get(*i).map(|d| d.uid) else { continue };
if app.ui.floating.iter().any(|w| w.uid == uid) {
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));
if let Some(v) = app.views.get_mut(*i) {
v.fitted = false;
}
}
if !docs.is_empty() {
app.ui.home = None;
}
}
/// The floating documents' indices, in the order their windows opened.
fn floating_indices(app: &VectorcraftApp) -> Vec<usize> {
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<Value, String> {
if let Some(i) = app.session.active_index().filter(|i| !is_floating(app, *i)) {
float(app, &[i]);
}
Ok(json!({ "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<Value, String> {
let docked = docked(app);
float(app, &docked);
Ok(json!({ "floating": floating_indices(app) }))
}
/// `window.arrange.cascade`: every document in a floating window, the windows stacked from the
/// document area's upper left to its lower right in tab order, each fitted to its window, the
/// current document's in front. Returns `{floating}`.
pub(crate) fn cascade(app: &mut VectorcraftApp) -> Result<Value, String> {
app.ui.tiled = false;
let docked = docked(app);
float(app, &docked);
let order: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
app.ui.floating.sort_by_key(|w| order.iter().position(|u| *u == w.uid));
let places = cascade_places(area(app), app.ui.floating.len());
for (w, place) in app.ui.floating.iter_mut().zip(places) {
w.place = place;
w.generation += 1;
}
for v in &mut app.views {
v.fitted = false;
}
// The current document's window comes to the front.
app.canvas.windows.current = None;
Ok(json!({ "floating": floating_indices(app) }))
}
/// Every floating document back under the tab strip ([`crate::canvas::consolidate`], Tile), each
/// fitted to where it shows.
pub(crate) fn dock_all(app: &mut VectorcraftApp) {
let floating = floating_indices(app);
for i in floating {
if let Some(v) = app.views.get_mut(i) {
v.fitted = false;
}
}
app.ui.floating.clear();
}
/// Whether the floating windows are OS windows: egui runs viewports of their own, the session is
/// not Wayland, where the main window cannot learn where its other windows are (craft-ui's docking
/// floats panels by the same rule), and no control channel is attached. With a control channel the
/// windows stay inside the main window, where `ui.click`, `ui.drag` and `ui.screenshot` reach them
/// and `canvasRect` is in the main window's coordinates.
fn native(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
if ctx.embed_viewports() || app.control_rx.is_some() {
return false;
}
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
{
let set = |name: &str| std::env::var_os(name).is_some_and(|v| !v.is_empty());
if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
// Under XWayland the main window reports where it is.
return ctx.input_for(egui::ViewportId::ROOT, |i| i.viewport().outer_rect.is_some());
}
}
true
}
/// The OS window of document window `uid`.
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.
fn window_id(w: &DocWindow) -> egui::Id {
egui::Id::unique(("vectorcraft-document-window", w.uid, w.generation))
}
/// What happened in a document window this frame.
#[derive(Default)]
struct Shown {
/// Its close button was pressed.
close: bool,
/// Its document asks to become the current one.
activate: bool,
/// Its OS window has the keyboard focus.
focused: bool,
/// Where it drew its document.
body: Option<egui::Rect>,
/// [`DocWindow::fit`] after this frame.
fit: Option<Fit>,
}
/// Draw the floating document windows, after the main window's canvas. Call once per frame from
/// the main window.
pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let live: Vec<u64> = app.session.documents().iter().map(|d| d.uid).collect();
app.ui.floating.retain(|w| live.contains(&w.uid));
let current = app.session.active().map(|d| d.uid);
if let Some(uid) = current.filter(|u| !app.ui.floating.iter().any(|w| w.uid == *u)) {
app.ui.tab_document = Some(uid);
}
// Presentation Mode fits each floating document it shows to the main window: back in their
// floating windows, those documents fit their windows again.
let presenting = presenting(app);
if presenting {
let shown = app.canvas.windows.presented.get_or_insert_default();
if let Some(uid) = current.filter(|u| app.ui.floating.iter().any(|w| w.uid == *u) && !shown.contains(u)) {
shown.push(uid);
if let Some(v) = app.session.active_index().and_then(|i| app.views.get_mut(i)) {
v.fitted = false;
ctx.request_repaint();
}
}
} else if let Some(shown) = app.canvas.windows.presented.take() {
for (d, v) in app.session.documents().iter().zip(app.views.iter_mut()) {
if shown.contains(&d.uid) {
v.fitted = false;
}
}
}
// While Presentation Mode hides the windows, `current` keeps the document whose window was in
// front, so a document made current meanwhile comes to the front when it ends.
if presenting {
return;
}
if app.ui.floating.is_empty() {
app.canvas.windows.current = current;
return;
}
let raise = current.filter(|u| app.canvas.windows.current != Some(*u));
let native = native(app, ctx);
// Windows inside the main window that come back (after Presentation Mode) all go to the front
// in their old order, so the raise waits for the next frame.
let returning = !native
&& app.ui.floating.iter().any(|w| {
let layer = egui::LayerId::new(egui::Order::Middle, window_id(w));
ctx.memory(|m| m.area_rect(layer.id).is_some() && !m.areas().visible_last_frame(&layer))
});
if raise.is_some() && returning {
ctx.request_repaint();
} else {
app.canvas.windows.current = current;
}
#[cfg(not(target_arch = "wasm32"))]
if native {
input::register(ctx, app.services.native_input_hook.clone(), app.ui.floating.iter().map(|w| viewport_id(w.uid)).collect());
}
// While a dialog or other modal is open the windows take no input, as the main window behind
// it takes none: each draws its document as a pane, the current one included.
let blocked = crate::panel_docking::modal_open(app, ctx);
let mut close = None;
let mut activate = None;
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 {
os_window(app, ctx, &w, (i, blocked), raise == Some(w.uid))
} else {
app_window(app, ctx, &w, (i, blocked), raise == Some(w.uid))
};
if blocked {
continue;
}
if let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid) {
// The OS window took the keyboard: its document becomes the current one.
if shown.focused && !win.focused && app.session.active_index() != Some(i) {
activate = Some(i);
}
win.focused = shown.focused;
win.body = shown.body;
win.fit = shown.fit;
}
if shown.close {
close = Some(i);
} else if shown.activate && app.session.active_index() != Some(i) {
activate = Some(i);
}
}
if let Some(i) = close {
// The save prompt opens in the main window.
if let Err(e) = crate::unsaved::close(app, i) {
app.status(e);
}
if native && app.ui.dialog.is_some() {
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);
}
}
/// Document `i`'s window body in `ui`: the canvas for the current document, else (and while
/// `blocked` by a modal) a pane. The document fits the body again when the body changed size since
/// `fit` and its view has not moved. → whether a press in the pane asks for its document to become
/// the current one, the body's rect and the fit after this frame.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, (i, blocked): (usize, bool), fit: Option<Fit>) -> (bool, egui::Rect, Option<Fit>) {
let t = crate::theme::Tokens::get(ui.ctx());
let rect = ui.available_rect_before_wrap();
ui.painter().rect_filled(rect, 0.0, t.pasteboard);
if let (Some(v), Some((size, zoom, center))) = (app.views.get_mut(i), fit)
&& size != rect.size()
&& zoom == v.zoom
&& center == v.center
{
v.fitted = false;
}
let was_fitted = app.views.get(i).is_some_and(|v| v.fitted);
let pressed = if !blocked && app.session.active_index() == Some(i) && !crate::menus::home_showing(app) {
crate::canvas::show(app, ui);
false
} else {
crate::canvas::pane(app, ui, i, rect)
};
ui.advance_cursor_after_rect(rect);
let fit = match app.views.get(i) {
Some(v) if v.fitted && !was_fitted => Some((rect.size(), v.zoom, v.center)),
_ => fit,
};
(pressed, rect, fit)
}
/// Document `i`'s window `w` as an OS window of its own, `blocked` by a modal or not; `raise`
/// brings it to the front.
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);
let screen = match w.screen {
Some((pos, generation)) if generation == w.generation => pos,
moved => {
let pos = origin + w.place.min.to_vec2();
if moved.is_some() {
ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::OuterPosition(pos));
ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::InnerSize(w.place.size()));
}
if let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid) {
win.screen = Some((pos, w.generation));
}
pos
}
};
if raise {
ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::Focus);
}
let title = crate::chrome::document_window_title(app, i).unwrap_or_default();
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);
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 {
// The OS window stays until its document closes, which stops drawing it: a save prompt
// 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);
// 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()) {
forwarded = ui.input(|inp| {
let events: Vec<egui::Event> = inp
.events
.iter()
.filter(|e| {
matches!(e, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_))
})
.cloned()
.collect();
(events, Some(inp.modifiers))
});
}
Shown { close, activate, focused, body: Some(rect), fit }
});
if !forwarded.0.is_empty() {
crate::panel_docking::forward_keys(app, ctx, forwarded.0, forwarded.1);
}
shown
}
/// 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 {
let id = window_id(w);
if raise {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
}
let title = crate::chrome::document_window_title(app, i).unwrap_or_default();
let mut open = true;
let (mut activate, mut rect, mut fit) = (false, None, w.fit);
let frame = egui::Frame::window(&ctx.global_style()).inner_margin(0.0);
let shown = egui::Window::new(title)
.id(id)
.default_rect(w.place)
.min_size(MIN_SIZE)
.collapsible(false)
.resizable(true)
.constrain_to(ctx.content_rect())
.frame(frame)
.open(&mut open)
.show(ctx, |ui| {
let (pressed, r, f) = body(app, ui, (i, blocked), fit);
(activate, rect, fit) = (pressed, Some(r), f);
});
// 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 }
}
/// The host's rewrites of raw input ([`crate::services::Services::native_input_hook`]) for the
/// document windows' OS windows: eframe runs the host's hook for the main window only.
#[cfg(not(target_arch = "wasm32"))]
mod input {
use std::collections::HashSet;
use crate::services::NativeInputHook;
/// Runs the hook on the input of the document windows' OS windows.
#[derive(Default)]
struct DocumentWindowInput {
hook: Option<NativeInputHook>,
windows: HashSet<egui::ViewportId>,
}
impl egui::Plugin for DocumentWindowInput {
fn debug_name(&self) -> &'static str {
"vectorcraft document windows input"
}
fn input_hook(&mut self, _: &egui::Context, input: &mut egui::RawInput) {
if let Some(hook) = self.hook.as_ref().filter(|_| self.windows.contains(&input.viewport_id)) {
hook(input);
}
}
}
/// Run `hook` on the input of `windows` from their next frame on.
pub(super) fn register(ctx: &egui::Context, hook: Option<NativeInputHook>, windows: HashSet<egui::ViewportId>) {
if hook.is_none() && ctx.with_plugin(|_: &mut DocumentWindowInput| ()).is_none() {
return;
}
ctx.add_plugin(DocumentWindowInput::default());
ctx.with_plugin(|p: &mut DocumentWindowInput| {
p.hook = hook;
p.windows = windows;
});
}
}
#[cfg(test)]
mod tests;
+459
View File
@@ -0,0 +1,459 @@
use egui::{Pos2, Shape, pos2, vec2};
use vectorcraft_engine::Session;
use super::*;
use crate::menus::enabled;
/// An app with `n` new 400 × 300 pt documents, the last one current.
fn app_with(n: usize) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
for _ in 0..n {
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
}
app
}
/// One headless frame of the canvas panel's body (the tabs and the canvas or panes) on a
/// 1200 × 800 window, then the floating document windows, which egui draws inside the window →
/// the shapes it drew, `Shape::Vec`s flattened.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<Shape> {
fn flat(s: Shape, out: &mut Vec<Shape>) {
match s {
Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)),
s => out.push(s),
}
}
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 800.0))),
max_texture_side: Some(8192),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
app.canvas_area(ui);
show(app, ui.ctx());
});
out.textures_delta.clear();
let mut shapes = vec![];
out.shapes.into_iter().for_each(|c| flat(c.shape, &mut shapes));
shapes
}
fn button(pos: Pos2, pressed: bool) -> egui::Event {
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
}
/// A press and release at `pos`, then a frame to settle.
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, pos: Pos2) {
frame(app, ctx, vec![egui::Event::PointerMoved(pos), button(pos, true)]);
frame(app, ctx, vec![button(pos, false)]);
frame(app, ctx, vec![]);
}
/// Where the last frame drew document `i`'s window body.
fn body_of(app: &VectorcraftApp, i: usize) -> egui::Rect {
let uid = app.session.documents()[i].uid;
app.ui.floating.iter().find(|w| w.uid == uid).and_then(|w| w.body).unwrap()
}
/// The whole of document `i`'s window inside the main window, title bar included.
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()
}
/// A drag with the primary button from `from` to `to`, then a frame to settle.
fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2) {
frame(app, ctx, vec![egui::Event::PointerMoved(from)]);
frame(app, ctx, vec![button(from, true)]);
for k in 1..=4 {
frame(app, ctx, vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))]);
}
frame(app, ctx, vec![button(to, false)]);
frame(app, ctx, vec![]);
}
/// Whether the last frame drew text holding `title` in the tab strip (the top 35 points).
fn tab_drawn(shapes: &[Shape], title: &str) -> bool {
shapes.iter().any(|s| matches!(s, Shape::Text(t) if t.pos.y < 35.0 && t.galley.text().contains(title)))
}
#[test]
fn cascade_places_step_down_and_right_inside_the_area() {
let area = egui::Rect::from_min_size(pos2(10.0, 35.0), vec2(1190.0, 765.0));
let places = cascade_places(area, 5);
assert_eq!(places.len(), 5);
for (k, p) in places.iter().enumerate() {
assert!(area.contains_rect(*p), "place {k} is inside the area: {p:?}");
assert_eq!(p.size(), places[0].size(), "every window is the same size");
let offset = CASCADE_MARGIN + k as f32 * CASCADE_STEP;
assert_eq!(p.min, area.min + vec2(offset, offset), "place {k} is {offset} pt down and right");
}
// A small area holds fewer steps: the stack starts again at the corner and stays inside.
let small = egui::Rect::from_min_size(pos2(0.0, 0.0), vec2(500.0, 400.0));
let places = cascade_places(small, 12);
assert!(places.iter().all(|p| small.contains_rect(*p)), "{places:?}");
assert!(places.iter().skip(1).any(|p| p.min == places[0].min), "the stack starts again: {places:?}");
// An area smaller than a window: the window fills it.
let tiny = egui::Rect::from_min_size(pos2(5.0, 5.0), vec2(100.0, 80.0));
assert_eq!(cascade_places(tiny, 2), [tiny, tiny]);
}
#[test]
fn a_control_channel_keeps_the_windows_inside_the_main_window() {
let ctx = egui::Context::default();
ctx.set_embed_viewports(false);
let plain = app_with(0);
let wayland = ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"].iter().any(|n| std::env::var_os(n).is_some_and(|v| !v.is_empty()));
if !wayland {
assert!(native(&plain, &ctx), "without a control channel the windows are OS windows");
}
let (_tx, rx) = std::sync::mpsc::channel();
let controlled = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
assert!(!native(&controlled, &ctx), "with a control channel the windows stay inside the main window");
ctx.set_embed_viewports(true);
assert!(!native(&plain, &ctx), "where egui draws every viewport inside the main window");
}
#[test]
fn float_in_window_moves_the_current_document_out_of_the_tabs() {
let mut app = app_with(3);
let ctx = egui::Context::default();
let shapes = frame(&mut app, &ctx, vec![]);
let title = app.session.documents()[2].title();
assert!(tab_drawn(&shapes, &title), "the current document has a tab");
let tabbed = app.canvas_rect.unwrap();
assert_eq!(app.run("window.arrange.floatInWindow", json!({})).unwrap(), json!({"floating": [2]}));
frame(&mut app, &ctx, vec![]);
let shapes = frame(&mut app, &ctx, vec![]);
assert!(!tab_drawn(&shapes, &title), "a floating document has no tab");
assert!(tab_drawn(&shapes, &app.session.documents()[0].title()));
assert_eq!(docked(&app), [0, 1]);
assert_eq!(app.session.active_index(), Some(2), "the floating document stays the current one");
// Its window holds the canvas, fitted to the window; the main window shows a tabbed document.
let body = body_of(&app, 2);
let canvas = app.canvas_rect.unwrap();
assert!(body.contains_rect(canvas) && canvas.width() < tabbed.width(), "the canvas is in the window: {canvas:?} in {body:?}");
assert!(app.views[2].fitted);
assert_eq!(tab_document(&app), Some(0));
assert_eq!(crate::control::inspect(&app, &ctx)["floating"], json!([2]));
// Floating it again leaves it where it is.
assert_eq!(app.run("window.arrange.floatInWindow", json!({})).unwrap(), json!({"floating": [2]}));
assert_eq!(app.ui.floating.len(), 1);
}
#[test]
fn a_press_in_another_documents_window_or_the_main_window_makes_its_document_current() {
let mut app = app_with(3);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.run("document.activate", json!({"index": 0})).unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
app.run("document.activate", json!({"index": 1})).unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
assert_eq!(app.session.active_index(), Some(1));
// The lower left of document 0's window, clear of document 1's window, which cascades below
// and to the right of it, and of the window's resize edges.
let body = body_of(&app, 0);
click(&mut app, &ctx, pos2(body.left() + 12.0, body.bottom() - 12.0));
assert_eq!(app.session.active_index(), Some(0), "a press in its window makes document 0 current");
assert!(body_of(&app, 0).contains_rect(app.canvas_rect.unwrap()), "its window holds the canvas now");
// The main window shows the only tabbed document as a pane: a press there makes it current.
assert_eq!(tab_document(&app), Some(2));
click(&mut app, &ctx, pos2(1100.0, 700.0));
assert_eq!(app.session.active_index(), Some(2));
// A press on the title bar of document 0's window, in front, makes document 0 current again.
let (window, body) = (window_of(&app, &ctx, 0), body_of(&app, 0));
click(&mut app, &ctx, pos2(window.left() + 60.0, (window.top() + body.top()) / 2.0));
assert_eq!(app.session.active_index(), Some(0), "a press on its title bar makes document 0 current");
}
#[test]
fn a_resized_window_fits_its_document_again_unless_it_was_zoomed() {
let mut app = app_with(2);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.run("document.activate", json!({"index": 0})).unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
app.run("document.activate", json!({"index": 1})).unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
let (before, other) = (app.views[1].zoom, app.views[0].zoom);
// Document 1's window, in front and holding the canvas, grows from its lower-right corner.
let window = window_of(&app, &ctx, 1);
drag(&mut app, &ctx, window.right_bottom() - vec2(2.0, 2.0), window.right_bottom() + vec2(150.0, 100.0));
assert!(window_of(&app, &ctx, 1).width() > window.width() + 100.0, "the window grew");
frame(&mut app, &ctx, vec![]);
assert_eq!(app.session.active_index(), Some(1));
assert!(app.views[1].zoom > before, "the document fits its larger window: {before} → {}", app.views[1].zoom);
assert_eq!(app.views[0].zoom, other, "the other window keeps its size and its document's zoom");
// A zoomed view keeps its zoom when its window changes size again.
app.run("view.zoomIn", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let zoomed = app.views[1].zoom;
let window = window_of(&app, &ctx, 1);
drag(&mut app, &ctx, window.right_bottom() - vec2(2.0, 2.0), window.right_bottom() - vec2(100.0, 60.0));
frame(&mut app, &ctx, vec![]);
assert_eq!(app.views[1].zoom, zoomed, "a zoomed document keeps its zoom");
}
#[test]
fn cascade_floats_every_document_and_stacks_the_windows() {
let mut app = app_with(3);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.tile", json!({})).unwrap();
app.run("document.activate", json!({"index": 1})).unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
assert_eq!(app.run("window.arrange.cascade", json!({})).unwrap(), json!({"floating": [0, 1, 2]}));
assert!(!app.ui.tiled, "Cascade ends Tile");
assert!(docked(&app).is_empty());
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
let area = app.canvas.windows.area.unwrap();
let places: Vec<egui::Rect> = app.ui.floating.iter().map(|w| w.place).collect();
assert_eq!(places, cascade_places(area, 3), "the windows stack in tab order, document 1's moved from where it floated");
for (i, place) in places.iter().copied().enumerate() {
let body = body_of(&app, i);
assert!((body.left() - place.left()).abs() < 4.0 && body.top() > place.top(), "document {i}'s window is at its place: {body:?} {place:?}");
assert!(app.views[i].fitted, "document {i} fits its window");
}
assert!(body_of(&app, 1).contains_rect(app.canvas_rect.unwrap()), "the current document's window holds the canvas");
}
#[test]
fn float_all_in_windows_floats_the_tabbed_documents_and_consolidate_brings_them_back() {
let mut app = app_with(3);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let tabbed = app.canvas_rect.unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let first = app.ui.floating[0].place;
assert_eq!(app.run("window.arrange.floatAllInWindows", json!({})).unwrap(), json!({"floating": [2, 0, 1]}));
assert_eq!(app.ui.floating[0].place, first, "a window already floating stays where it is");
assert!(docked(&app).is_empty());
frame(&mut app, &ctx, vec![]);
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(), json!({"tiled": false, "floating": []}));
frame(&mut app, &ctx, vec![]);
let shapes = frame(&mut app, &ctx, vec![]);
assert_eq!(docked(&app), [0, 1, 2]);
for d in app.session.documents() {
assert!(tab_drawn(&shapes, &d.title()), "{} has its tab again", d.title());
}
assert_eq!(app.canvas_rect, Some(tabbed), "the canvas is under the tabs again");
assert!(app.views[2].fitted);
}
#[test]
fn tile_brings_floating_documents_back_into_the_grid() {
let mut app = app_with(3);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
app.run("window.arrange.tile", json!({})).unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let docs: Vec<usize> = app.canvas.panes.last.iter().map(|(i, _)| *i).collect();
assert_eq!(docs, [0, 1], "a floating document leaves the grid; the tabbed ones share it");
assert_eq!(app.run("window.arrange.tile", json!({})).unwrap(), json!({"tiled": true, "panes": 3}));
frame(&mut app, &ctx, vec![]);
let docs: Vec<usize> = app.canvas.panes.last.iter().map(|(i, _)| *i).collect();
assert_eq!(docs, [0, 1, 2]);
assert!(app.ui.floating.is_empty());
}
#[test]
fn closing_a_floating_window_closes_its_document_and_asks_to_save_an_unsaved_one() {
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![]);
frame(&mut app, &ctx, vec![]);
// Document 0's window is behind document 1's: its close button is at the top right of its
// title bar, clear of document 1's window to the right and below.
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 body = w.body.unwrap();
(area, body)
};
let (area0, body0) = close_button(&app, 0);
assert!(body0.top() > area0.top(), "the window has a title bar");
// Close document 1 (in front): it is unsaved after an edit, so the app asks first.
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
assert!(app.session.documents()[1].is_dirty());
frame(&mut app, &ctx, vec![]);
// The close button sits near the right end of the title bar.
let press_close = |app: &mut VectorcraftApp, i: usize| {
let (area, body) = close_button(app, i);
let bar = (area.top() + body.top()) / 2.0;
let n = app.session.documents().len();
for dx in (4..40).step_by(3) {
click(app, &ctx, pos2(area.right() - dx as f32, bar));
if app.ui.dialog.is_some() || app.session.documents().len() < n {
return true;
}
}
false
};
assert!(press_close(&mut app, 1), "the close button of document 1's window acts");
assert!(app.ui.dialog.is_some(), "an unsaved document asks to be saved");
assert_eq!(app.session.documents().len(), 2);
// While the prompt is open the windows take no press.
let body = body_of(&app, 0);
click(&mut app, &ctx, pos2(body.left() + 12.0, body.bottom() - 12.0));
assert_eq!(app.session.active_index(), Some(1), "a press in another window waits for the prompt");
app.ui.dialog = None;
// Document 0 has no changes: its window closes with it.
app.run("document.activate", json!({"index": 0})).unwrap();
frame(&mut app, &ctx, vec![]);
assert!(press_close(&mut app, 0), "the close button of document 0's window acts");
assert_eq!(app.session.documents().len(), 1);
frame(&mut app, &ctx, vec![]);
assert_eq!(app.ui.floating.len(), 1, "its window has gone");
}
#[test]
fn presentation_mode_shows_the_floating_current_document_in_the_main_window() {
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![]);
frame(&mut app, &ctx, vec![]);
let body = body_of(&app, 1);
assert!(body.contains_rect(app.canvas_rect.unwrap()), "the current document's window holds the canvas");
let windowed = (app.views[0].zoom, app.views[1].zoom);
app.run("view.presentation", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let shown = app.canvas_rect.unwrap();
assert!(shown.width() > body.width() + 100.0, "the main window shows the current document: {shown:?}, its window was {body:?}");
// The presentation goes on to the other floating document, fitted to the main window, then
// ends on the first one.
app.run("document.activate", json!({"index": 0})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
app.run("document.activate", json!({"index": 1})).unwrap();
frame(&mut app, &ctx, vec![]);
assert!(app.views[0].zoom > windowed.0, "the presentation fitted document 0 to the main window");
app.run("view.presentation", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
assert!(body_of(&app, 1).contains_rect(app.canvas_rect.unwrap()), "the window holds the canvas again");
assert_eq!(app.ui.floating.len(), 2, "the windows come back");
for (i, before) in [windowed.0, windowed.1].into_iter().enumerate() {
assert!((app.views[i].zoom - before).abs() < 1e-6, "document {i} fits its window again: {before} → {}", app.views[i].zoom);
}
}
#[test]
fn a_document_made_current_in_presentation_mode_comes_to_the_front_after_it() {
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![]);
frame(&mut app, &ctx, vec![]);
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)))
};
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");
app.run("view.presentation", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
app.run("document.activate", json!({"index": 0})).unwrap();
frame(&mut app, &ctx, vec![]);
app.run("view.presentation", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
assert_eq!(top(&ctx), Some(layer(&app, 0)), "document 0's window comes to the front");
}
/// 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.
#[derive(Default)]
struct Host {
close: bool,
commands: Vec<(egui::ViewportId, egui::ViewportCommand)>,
}
fn native_host() -> std::rc::Rc<std::cell::RefCell<Host>> {
let host = std::rc::Rc::new(std::cell::RefCell::new(Host::default()));
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 mut full = ctx.run_ui(input, |ui| (viewport.viewport_ui_cb)(ui));
full.textures_delta.clear();
let mut host = shared.borrow_mut();
for (vid, output) in full.viewport_output {
host.commands.extend(output.commands.into_iter().map(|c| (vid, c)));
}
});
host
}
#[test]
fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() {
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("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("window.arrange.floatInWindow", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let vid = viewport_id(app.session.documents()[1].uid);
host.borrow_mut().close = true;
frame(&mut app, &ctx, vec![]);
host.borrow_mut().close = false;
assert!(app.ui.dialog.is_some(), "the unsaved document asks to be saved");
assert_eq!(app.session.documents().len(), 2);
assert!(is_floating(&app, 1), "the document still floats");
assert!(
host.borrow().commands.iter().any(|(id, c)| *id == vid && *c == egui::ViewportCommand::CancelClose),
"its OS window is kept open: {:?}",
host.borrow().commands
);
}
#[test]
fn the_arrange_items_are_enabled_as_the_documents_allow() {
let ids = [
"window.arrange.cascade",
"window.arrange.tile",
"window.arrange.floatInWindow",
"window.arrange.floatAllInWindows",
"window.arrange.consolidateAllWindows",
];
let states = |app: &VectorcraftApp| ids.map(|id| enabled(app, id));
let mut app = app_with(0);
assert_eq!(states(&app), [false; 5], "no document");
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
assert_eq!(states(&app), [false, false, true, true, false], "one tabbed document");
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
assert_eq!(states(&app), [true, true, true, true, false], "two tabbed documents");
app.run("window.arrange.floatInWindow", json!({})).unwrap();
assert_eq!(states(&app), [true, true, false, true, true], "the current document floats, one is tabbed");
app.run("window.arrange.floatAllInWindows", json!({})).unwrap();
assert_eq!(states(&app), [true, true, false, false, true], "every document floats");
app.run("document.activate", json!({"index": 0})).unwrap();
app.run("window.arrange.consolidateAllWindows", json!({})).unwrap();
assert_eq!(states(&app), [true, true, true, true, false], "consolidated");
}
+1
View File
@@ -1177,6 +1177,7 @@ font-technology Variable Променливи
Flip Row Order Обръщане реда на редовете
Flip Vertical Вертикално обръщане
Float in Window Плаващ прозорец
Float All in Windows Всички в плаващи прозорци
Focal Fill Фокусно запълване
Focal Stroke Фокусен щрих
Folder Папка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -628,6 +628,7 @@
Cascade Kaskadno
Tile Popločaj
Float in Window Odvoji u prozor
Float All in Windows Odvoji sve u prozore
Consolidate All Windows Objedini sve prozore
Workspace Radni prostor
Reset Essentials Vrati Osnove na početno
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1610,6 +1610,7 @@ digits Malayalam Malaiàlam
Flip Vertical Inverteix verticalment
Flip: Inverteix:
Float in Window Flota a la finestra
Float All in Windows Flota-ho tot en finestres
Float Panel Flota el panell
Float Tool Group Flota el grup d’eines
Focal Fill Farciment focal
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -636,6 +636,7 @@
Cascade Do kaskády
Tile Vedle sebe
Float in Window Uvolnit do samostatného okna
Float All in Windows Uvolnit vše do samostatných oken
Consolidate All Windows Sloučit všechna okna
Workspace Pracovní prostor
Reset Essentials Obnovit Essentials
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -586,6 +586,7 @@
Cascade Kaskade
Tile Fliselæg
Float in Window Flyd i vindue
Float All in Windows Flyd alle i vinduer
Consolidate All Windows Saml alle vinduer
Workspace Arbejdsområde
Reset Essentials Nulstil Essentials
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1208,6 +1208,7 @@ file-filter Templates Vorlagen
Flip Row Order Zeilenreihenfolge umkehren
Flip Vertical Vertikal spiegeln
Float in Window In eigenem Fenster anzeigen
Float All in Windows Alle in eigenen Fenstern anzeigen
Focal Fill Fläche im Fokus
Focal Stroke Kontur im Fokus
Folder Ordner
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1150,6 +1150,7 @@ font-technology Variable Μεταβλητές
Flip Row Order Αντιστροφή σειράς γραμμών
Flip Vertical Κατακόρυφη αναστροφή
Float in Window Μετατροπή σε ανεξάρτητο παράθυρο
Float All in Windows Μετατροπή όλων σε ανεξάρτητα παράθυρα
Focal Fill Εστιακό γέμισμα
Focal Stroke Εστιακό περίγραμμα
Folder Φάκελος
Can't render this file because it contains an unexpected character in line 163 and column 5.
+1
View File
@@ -1226,6 +1226,7 @@ font-technology Variable Variables
Flip Row Order Invertir orden de filas
Flip Vertical Voltear verticalmente
Float in Window Flotar en ventana
Float All in Windows Flotar todo en ventanas
Focal Fill Relleno focal
Focal Stroke Trazo focal
Folder Carpeta
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -627,6 +627,7 @@
Cascade Porrasta
Tile Vierekkäin
Float in Window Kelluta ikkunassa
Float All in Windows Kelluta kaikki ikkunoissa
Consolidate All Windows Yhdistä kaikki ikkunat
Workspace Työtila
Reset Essentials Palauta Essentials
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1220,6 +1220,7 @@ font-technology Variable Variables
Flip Row Order Inverser l’ordre des lignes
Flip Vertical Retourner verticalement
Float in Window Flottant dans une fenêtre
Float All in Windows Tout flottant dans des fenêtres
Focal Fill Fond actif
Focal Stroke Contour actif
Folder Dossier
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1528,6 +1528,7 @@ file-filter Templates Sablonok
Flip Vertical Függőleges tükrözés
Flip: Tükrözés:
Float in Window Lebegtetés ablakban
Float All in Windows Összes lebegtetése ablakokban
Float Panel Panel lebegtetése
Float Tool Group Eszközcsoport lebegtetése
Focal Fill Aktív kitöltés
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -586,6 +586,7 @@
Cascade Bertingkat
Tile Ubin
Float in Window Mengambang di jendela
Float All in Windows Mengambangkan semua di jendela
Consolidate All Windows Konsolidasikan semua jendela
Workspace Ruang kerja
Reset Essentials Atur ulang Essentials
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
586
587
588
589
590
591
592
+1
View File
@@ -1238,6 +1238,7 @@ file-filter Templates Modelli
Flip Row Order Inverti ordine delle righe
Flip Vertical Capovolgi verticalmente
Float in Window Rendi mobile nella finestra
Float All in Windows Rendi tutto mobile nelle finestre
Focal Fill Riempimento attivo
Focal Stroke Traccia attiva
Folder Cartella
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -648,6 +648,7 @@ digits Malayalam マラヤーラム数字
Cascade 重ねて表示
Tile 並べて表示
Float in Window ウィンドウを分離
Float All in Windows すべてをウィンドウに分離
Consolidate All Windows すべてのウィンドウを統合
Workspace ワークスペース
Reset Essentials 初期設定をリセット
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -538,6 +538,7 @@
Cascade კასკადად
Tile ფილებად
Float in Window ცალკე ფანჯარაში გამოტანა
Float All in Windows ყველას ცალკე ფანჯრებში გამოტანა
Consolidate All Windows ყველა ფანჯრის გაერთიანება
Workspace სამუშაო სივრცე
Reset Essentials Essentials-ის ჩამოყრა
Can't render this file because it is too large.
+1
View File
@@ -641,6 +641,7 @@ digits Malayalam 말라얄람어
Cascade 계단식 배열
Tile 바둑판식 배열
Float in Window 창에 플로팅
Float All in Windows 모두 창에 플로팅
Consolidate All Windows 모든 창 통합
Workspace 작업 영역
Reset Essentials Essentials 재설정
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -600,6 +600,7 @@
Cascade Trapsgewijs schikken
Tile Naast elkaar schikken
Float in Window In venster zweven
Float All in Windows Alles in vensters zweven
Consolidate All Windows Alle vensters samenvoegen
Workspace Werkruimte
Reset Essentials Essentials herstellen
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -594,6 +594,7 @@
Cascade Kaskadowo
Tile Sąsiadująco
Float in Window Pływające w oknie
Float All in Windows Wszystkie pływające w oknach
Consolidate All Windows Scal wszystkie okna
Workspace Obszar roboczy
Reset Essentials Resetuj Essentials
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1091,6 +1091,7 @@
Flip Row Order Inverter Ordem das Linhas
Flip Vertical Espelhar Verticalmente
Float in Window Flutuar na Janela
Float All in Windows Flutuar Tudo em Janelas
Folder Pasta
Folder Name: Nome da Pasta:
Font Fonte
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1235,6 +1235,7 @@ file-filter Templates Шаблоны
Flip Row Order Изменить порядок строк
Flip Vertical Отразить по вертикали
Float in Window Плавать в окне
Float All in Windows Все плавающие в окнах
Focal Fill Фокусная заливка
Focal Stroke Фокусная обводка
Folder Папка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1185,6 +1185,7 @@ font-technology Variable Variabla
Flip Row Order Vänd radordning
Flip Vertical Vänd lodrätt
Float in Window Gör flytande i fönster
Float All in Windows Gör alla flytande i fönster
Focal Fill Fokal fyllning
Focal Stroke Fokal kontur
Folder Mapp
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -1179,6 +1179,7 @@ font-technology Variable Değişken
Flip Row Order Satır Sırasını Çevir
Flip Vertical Dikey Çevir
Float in Window Pencerede Yüzer Yap
Float All in Windows Tümünü Pencerelerde Yüzer Yap
Focal Fill Odak Dolgusu
Focal Stroke Odak Konturu
Folder Klasör
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -1232,6 +1232,7 @@ file-filter Templates Шаблони
Flip Row Order Змінити порядок рядків
Flip Vertical Відобразити по вертикалі
Float in Window Відкріпити у вікні
Float All in Windows Відкріпити все у вікна
Focal Fill Фокусна заливка
Focal Stroke Фокусне обведення
Folder Папка
Can't render this file because it contains an unexpected character in line 177 and column 5.
+1
View File
@@ -1431,6 +1431,7 @@ file-filter Templates Mẫu
Flip Vertical Lật dọc
Flip: Lật:
Float in Window Nổi trong cửa sổ
Float All in Windows Nổi tất cả trong cửa sổ
Float Panel Thả nổi bảng
Float Tool Group Thả nổi nhóm công cụ
Focal Fill Tô tiêu điểm
Can't render this file because it contains an unexpected character in line 199 and column 5.
+1
View File
@@ -1176,6 +1176,7 @@ file-filter Templates 模板
Flip Row Order 反转行顺序
Flip Vertical 垂直翻转
Float in Window 在窗口中浮动
Float All in Windows 全部在窗口中浮动
Focal Fill 焦点填色
Focal Stroke 焦点描边
Folder 文件夹
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -1202,6 +1202,7 @@ file-filter Templates 範本
Flip Row Order 反轉列順序
Flip Vertical 垂直翻轉
Float in Window 在視窗中浮動
Float All in Windows 全部在視窗中浮動
Focal Fill 焦點填色
Focal Stroke 焦點筆畫
Folder 資料夾
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -34,6 +34,7 @@ pub mod credits;
pub mod cursors;
mod custom_toolbars;
pub mod dialogs;
pub mod doc_windows;
pub mod dock;
mod edit_toolbar;
pub mod export_progress;
+3
View File
@@ -545,7 +545,10 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
None => Err("brightness must be dark|mediumDark|mediumLight|light".into()),
},
"window.newWindow" => 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.floatAllInWindows" => crate::doc_windows::float_all(app),
"window.arrange.consolidateAllWindows" => crate::canvas::consolidate(app),
"window.nextDocument" => crate::chrome::step_document(app, true),
"window.previousDocument" => crate::chrome::step_document(app, false),
+11 -4
View File
@@ -283,11 +283,18 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some()
}
"ui.perspectivePlane" => app.session.active().is_some(),
// Only one window per document so far: greyed out, as Cascade and Float in Window are (#3724).
// Only one window per document so far: grayed out (#3724).
"window.newWindow" => false,
// Tile with two documents or more; Consolidate All Windows while they are tiled.
"window.arrange.tile" => app.session.documents().len() > 1,
"window.arrange.consolidateAllWindows" => app.ui.tiled && !app.session.documents().is_empty(),
// Tile and Cascade with two documents or more; Float in Window for a current document under
// the tabs; Float All in Windows while a document is under the tabs; Consolidate All
// Windows while documents are tiled or floating.
"window.arrange.tile" | "window.arrange.cascade" => app.session.documents().len() > 1,
"window.arrange.floatInWindow" => app.session.active_index().is_some_and(|i| !crate::doc_windows::is_floating(app, i)),
"window.arrange.floatAllInWindows" => !crate::doc_windows::docked(app).is_empty(),
"window.arrange.consolidateAllWindows" => {
let n = app.session.documents().len();
n > 0 && (app.ui.tiled || crate::doc_windows::docked(app).len() < n)
}
"window.nextDocument" | "window.previousDocument" => !app.session.documents().is_empty(),
_ => true,
}
+3 -2
View File
@@ -612,9 +612,10 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
sub(
"Arrange",
vec![
todo("Cascade"),
c("Cascade", "window.arrange.cascade"),
c("Tile", "window.arrange.tile"),
todo("Float in Window"),
c("Float in Window", "window.arrange.floatInWindow"),
c("Float All in Windows", "window.arrange.floatAllInWindows"),
c("Consolidate All Windows", "window.arrange.consolidateAllWindows"),
],
),
+20 -2
View File
@@ -581,17 +581,35 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.workspace.rename", "Rename Workspace", "", "{name, to}"),
("window.workspace.list", "List Workspaces", "", "{}"),
("window.newWindow", "New Window", "", "{}"),
(
"window.arrange.cascade",
"Cascade",
"",
"{} put every open document in a floating window of its own and stack the windows from the upper left of the document area to its lower right, 30 pt apart, in tab order, each fitted to its window, the current document's in front; on the desktop each is an OS window, on the web and Wayland a window inside the app's window; returns {floating} (document indices)",
),
(
"window.arrange.tile",
"Tile",
"",
"{} show every open document at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of documents, rounded up), each fitted to its pane; the current document's pane takes the tools, a click in another pane makes its document the current one; the tabs stay; returns {tiled: true, panes}",
"{} show every open document at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of documents, rounded up), each fitted to its pane; the current document's pane takes the tools, a click in another pane makes its document the current one; the tabs stay; documents in floating windows come back under the tabs first; returns {tiled: true, panes}",
),
(
"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)",
),
(
"window.arrange.floatAllInWindows",
"Float All in Windows",
"",
"{} move every document under the tab strip into a floating window of its own, at the next places of a cascade; returns {floating} (document indices)",
),
(
"window.arrange.consolidateAllWindows",
"Consolidate All Windows",
"",
"{} show one document at a time again, under its tab, after Tile; returns {tiled: false}",
"{} 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: []}",
),
(
"window.nextDocument",
+3 -2
View File
@@ -1177,8 +1177,9 @@ pub(crate) fn modal_open(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
/// Keys, text and clipboard events that a floating panel's own OS window left over run the main
/// window's shortcuts, tool keys and clipboard on its next frame ([`VectorcraftApp::raw_input_hook`]).
/// craft-ui forwards nothing while a field in that window has the keyboard. A field of the main
/// window loses it, as a click elsewhere in one window would take it.
fn forward_keys(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, modifiers: Option<egui::Modifiers>) {
/// window loses it, as a click elsewhere in one window would take it. A floating document's OS
/// window forwards its keys here too ([`crate::doc_windows`]).
pub(crate) fn forward_keys(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, modifiers: Option<egui::Modifiers>) {
if events.is_empty() {
return;
}
+11
View File
@@ -463,6 +463,15 @@ pub struct UiState {
/// Consolidate All Windows. Not saved: the reference app does not save windows with a document.
#[serde(skip)]
pub tiled: bool,
/// Window › Arrange › Float in Window, Float All in Windows and Cascade: the documents in
/// floating windows of their own, out of the tab strip, until Consolidate All Windows. Not
/// saved, as `tiled` is not.
#[serde(skip)]
pub floating: Vec<crate::doc_windows::DocWindow>,
/// The document (its uid) the tab strip shows while the current document is in a floating
/// window: the tabbed document that was current last.
#[serde(skip)]
pub tab_document: Option<u64>,
/// Draw Normal / Behind / Inside.
pub draw_mode: u8,
pub about: bool,
@@ -944,6 +953,8 @@ impl Default for UiState {
palette_query: String::new(),
screen_mode: 0,
tiled: false,
floating: Vec::new(),
tab_document: None,
draw_mode: 0,
about: false,
action_sets: crate::panels::actions::default_sets(),
+2 -1
View File
@@ -592,7 +592,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window.
- **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.
- **Missing:** multiple windows and the rest of Window › Arrange (Cascade, Float in Window, Float All in Windows).
- **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.
- **Estimate:** 15–25 h.
## G6 Automation and scripting
+12 -4
View File
@@ -3639,10 +3639,18 @@ the current one.
`window.arrange.tile` (Window › Arrange › Tile) shows every open document in its own pane, edge to edge in the document
area, in a grid of as many columns as the square root of the number of documents, rounded up, and fits each document to
its pane; it returns `{tiled, panes}`. The current document's pane is the canvas, and a press in another pane makes its
document the current one. `window.arrange.consolidateAllWindows` (Window › Arrange › Consolidate All Windows) shows one
document under its tab again and returns `{tiled: false}`. The arrangement is not saved. `ui.inspect` reports `tiled`
and `panes` (each pane's `document` index, `rect` and whether it is `active`); canvas coordinates map to the current
document's pane (`canvasRect`).
document the current one. `window.arrange.floatInWindow` (Window › Arrange › Float in Window) moves the current document
out of the tab strip into a floating window of its own, `window.arrange.floatAllInWindows` (Float All in Windows) does
so for every tabbed document, and `window.arrange.cascade` (Cascade) floats every document and stacks the windows 30 pt
apart from the upper left of the document area; each returns `{floating}`, the floating documents' indices. On the
desktop each window is an OS window; on the web, on Wayland and while the control channel is attached (`--control`),
it is a window inside the app's window, so `ui.click`, `ui.drag` and `ui.screenshot` reach it. The current
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
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.
```json
{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}
+1
View File
@@ -527,6 +527,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`).
- **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`.
- **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).