mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:29:44 -06:00
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:
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}}}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user