Window: New Window opens a second view of the current document

Window > New Window was grayed out. It now opens another view of the
current document, with its own zoom, center, rotation, artboard and
Outline or Preview, as a tab of its own. While a document has more than
one view, each tab shows the view's number after the name
("Untitled-1:2"). An edit in one view shows in every view of the
document.

Tile, Cascade, Float in Window, Float All in Windows and Consolidate All
Windows arrange each view as they arrange a document, and Tile and
Cascade are enabled with two views of one document. A new view of a view
in a floating window opens in a floating window too. File > Close and a
tab's close button close the view; the document's last view closes the
document, asking to save it first. Next Document and Previous Document
step through the views in tab order.

Each Tile pane and floating window draws its view in its own Outline or
Preview, and switching views with the Artboard tool targets the artboard
the view shows. A switch to another view of the current document resets
the tool, as a switch to another document does (Session::reset_tool).
Artwork held over another view's tab during a drag switches to that
view, and a drop there moves it.

Fixes #4116
This commit is contained in:
dotCooCoo
2026-11-04 08:13:58 +07:00
committed by bflatastic
parent b91f527c91
commit e03665a215
24 changed files with 937 additions and 378 deletions
+6
View File
@@ -158,6 +158,12 @@ impl Session {
self.tool_set_since_reset = false;
}
/// Finish the tool's pending work and give it fresh state, as a switch to another document
/// does, while the current document stays the same (a switch to another view of it).
pub fn reset_tool(&mut self) {
self.reset_tool_for_doc_switch();
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
if self.active != Some(index) {
+3 -3
View File
@@ -444,10 +444,10 @@ impl VectorcraftApp {
if !canvas::tiled_area(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)
if let Some(w) = crate::doc_windows::tab_window(self)
&& canvas::pane(self, ui, w, rect)
{
chrome::show_document(self, i);
crate::doc_windows::show_window(self, w);
}
} else {
canvas::show(self, ui);
+3 -1
View File
@@ -2,7 +2,8 @@ use super::{Value, VectorcraftApp, View, ViewInfo, panels};
impl VectorcraftApp {
/// Keep `views` aligned with the session's documents (a new one starts at its saved view, and
/// opens in a floating window while Open Documents As Tabs is cleared).
/// opens in a floating window while Open Documents As Tabs is cleared). The views Window › New
/// Window opened go with their documents.
pub fn sync_views(&mut self) {
let docs = self.session.documents();
self.views.truncate(docs.len());
@@ -10,6 +11,7 @@ impl VectorcraftApp {
for (view, doc) in self.views.iter_mut().zip(docs) {
view.artboard = view.artboard.min(doc.doc.artboards.len().saturating_sub(1));
}
crate::doc_windows::retain_views(self);
crate::doc_windows::opened(self);
// Undo can remove the Artboard tool's active or additionally selected boards. Repair only
// invalid indices: valid multiselection, drag state and tool options must survive history.
+14 -6
View File
@@ -253,7 +253,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let doc = st.doc.clone();
let mask_view = st.shown_mask();
paint_paper(app, st, ui, &painter, &xf, rect, &t);
paint_paper(app, st, ui, &painter, &xf, rect, (&t, app.outline_view()));
// The grid behind the art (Guides & Grid › Grids In Back), else over it below.
let grid_look = (app.ui.view.grid && !presenting).then(|| LineLook::grid(&app.session.prefs));
let grids_in_back = app.session.prefs.grids_in_back;
@@ -574,7 +574,7 @@ fn frame_options(
mask_view: Option<vectorcraft_doc::NodeId>,
smooth_images: bool,
) -> vectorcraft_render::RenderOptions {
let opts = vectorcraft_render::RenderOptions { outline: app.outline_view(), background: None, artboards: false, ..Default::default() };
let opts = vectorcraft_render::RenderOptions { outline: key.outline, background: None, artboards: false, ..Default::default() };
vectorcraft_render::RenderOptions {
proof: vectorcraft_render::proof::active_proof(),
overprint_preview: vectorcraft_render::proof::overprint_preview_on(),
@@ -678,8 +678,16 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
}
/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards, of
/// document `st`.
fn paint_paper(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, ui: &Ui, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) {
/// document `st`, in Outline when `outline`.
fn paint_paper(
app: &VectorcraftApp,
st: &vectorcraft_engine::DocState,
ui: &Ui,
painter: &egui::Painter,
xf: &Xf,
rect: egui::Rect,
(t, outline): (&Tokens, bool),
) {
let doc = &st.doc;
// Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the
// simulated paper colour; a white Background Contents hides the grid).
@@ -691,7 +699,7 @@ fn paint_paper(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, ui: &Ui,
// View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038).
let setup = &doc.setup;
painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE });
} else if !app.outline_view() {
} else if !outline {
let setup = &doc.setup;
let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup));
let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE };
@@ -709,7 +717,7 @@ fn paint_paper(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, ui: &Ui,
}
}
}
} else if app.outline_view() {
} else {
for ab in &doc.artboards {
painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE));
}
+113 -105
View File
@@ -1,7 +1,8 @@
//! Window › Arrange › Tile: every open document in a pane of its own, edge to edge in the document
//! area. The current document's pane is the canvas ([`super::show`]). Each other pane draws its
//! document from a render of its own, with its artboards, guides and selected objects, and a press
//! in it makes its document the current one.
//! in it makes its document the current one. Each view Window › New Window opened has a pane of
//! its own ([`crate::doc_windows`]).
use std::collections::HashMap;
use std::sync::Arc;
@@ -13,28 +14,32 @@ use super::{
Affine, CacheKey, Point, Rect, RulerScale, Sense, Stroke, StrokeKind, Tokens, Ui, VectorcraftApp, View, Xf, artboard_edges, cache_key,
frame_options, frame_view, grid, guides_of, json, paint_paper, pixel_region, pos2, rulers, shows_artboards, textured_quad, vec2, view_rects,
};
use crate::doc_windows::Win;
use crate::thumb_worker::ThumbWorker;
/// A pane's render: what it shows, the document, its size in pixels and view, and its options.
type PaneFrame = (CacheKey, Arc<vectorcraft_doc::Document>, ((u32, u32), Affine), vectorcraft_render::RenderOptions);
/// The renders that the panes of the documents other than the current one draw.
/// Whose render a pane draws: its document's uid and its view's number.
type Slot = (u64, u32);
/// The renders that the panes of the windows other than the current one draw.
#[derive(Default)]
pub struct Panes {
/// Renders off the UI thread (None: on it, as on wasm).
worker: Option<ThumbWorker<usize, CacheKey>>,
worker: Option<ThumbWorker<Slot, (Slot, CacheKey)>>,
worker_started: bool,
/// Renders on the UI thread when there is no worker.
renderer: Option<vectorcraft_render::Renderer>,
/// By document uid: the render shown and what it shows.
shown: HashMap<usize, (CacheKey, egui::TextureHandle)>,
/// By document uid: the render the worker is making.
pending: HashMap<usize, CacheKey>,
/// The panes the last frame drew, as (document index, pane rect).
pub(crate) last: Vec<(usize, egui::Rect)>,
/// By document uid: the size of the view's area its document was last fitted to in a pane,
/// and the zoom and center the fit gave.
fits: HashMap<usize, (egui::Vec2, f64, Point)>,
/// By slot: the render shown and what it shows.
pub(crate) shown: HashMap<Slot, (CacheKey, egui::TextureHandle)>,
/// By slot: the render the worker is making.
pending: HashMap<Slot, CacheKey>,
/// The panes the last frame drew, as (window, pane rect).
pub(crate) last: Vec<(Win, egui::Rect)>,
/// By slot: the size of the view's area its view was last fitted to in a pane, and the zoom and
/// center the fit gave.
fits: HashMap<Slot, (egui::Vec2, f64, Point)>,
}
impl Panes {
@@ -46,27 +51,27 @@ impl Panes {
self.fits.clear();
}
/// Upload `img`, the render `key` describes.
fn upload(&mut self, ctx: &egui::Context, key: CacheKey, img: &vectorcraft_render::Rendered) {
/// Upload `img`, the render `key` describes, for `slot`.
fn upload(&mut self, ctx: &egui::Context, slot: Slot, key: CacheKey, img: &vectorcraft_render::Rendered) {
let color = super::color_image(img);
// Pixel Preview's document pixels show hard-edged, as on the canvas.
let options = if key.pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR };
match self.shown.get_mut(&key.doc) {
match self.shown.get_mut(&slot) {
Some((k, tex)) => {
tex.set(color, options);
*k = key;
}
None => {
let tex = ctx.load_texture(format!("arrange-pane-{}", key.doc), color, options);
self.shown.insert(key.doc, (key, tex));
let tex = ctx.load_texture(format!("arrange-pane-{}-{}", slot.0, slot.1), color, options);
self.shown.insert(slot, (key, tex));
}
}
}
/// Bring the render of the document `key` describes up to `key` (`size` pixels through `view`),
/// on the worker when there is one, then draw the latest render of that document through `xf`,
/// turned and scaled into the view it was rendered for.
fn draw(&mut self, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, frame: PaneFrame) {
/// Bring `slot`'s render up to `key` (`size` pixels through `view`), on the worker when there
/// is one, then draw the slot's latest render through `xf`, turned and scaled into the view it
/// was rendered for.
fn draw(&mut self, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, slot: Slot, frame: PaneFrame) {
let (key, doc, ((w, h), view), opts) = frame;
if !self.worker_started {
self.worker_started = true;
@@ -75,11 +80,12 @@ impl Panes {
match self.worker.as_ref().map(ThumbWorker::poll) {
Some(Some(done)) => {
for d in done {
if self.pending.get(&d.key.doc) == Some(&d.key) {
self.pending.remove(&d.key.doc);
let (s, k) = d.key;
if self.pending.get(&s) == Some(&k) {
self.pending.remove(&s);
}
if let Some(img) = d.img {
self.upload(ctx, d.key, &img);
self.upload(ctx, s, k, &img);
}
}
}
@@ -90,25 +96,24 @@ impl Panes {
}
None => {}
}
let uid = key.doc;
let current = self.shown.get(&uid).is_some_and(|(k, _)| *k == key) || self.pending.get(&uid) == Some(&key);
let current = self.shown.get(&slot).is_some_and(|(k, _)| *k == key) || self.pending.get(&slot) == Some(&key);
if !current {
let mut queued = false;
if let Some(worker) = &self.worker {
let (job_doc, job_opts) = (doc.clone(), opts.clone());
queued = worker.submit(uid, key.clone(), Box::new(move |r| Some(r.render(&job_doc, w, h, view, &job_opts))));
queued = worker.submit(slot, (slot, key.clone()), Box::new(move |r| Some(r.render(&job_doc, w, h, view, &job_opts))));
if queued {
self.pending.insert(uid, key.clone());
self.pending.insert(slot, key.clone());
} else {
self.worker = None;
}
}
if !queued {
let img = self.renderer.get_or_insert_with(vectorcraft_render::Renderer::new).render(&doc, w, h, view, &opts);
self.upload(ctx, key, &img);
self.upload(ctx, slot, key, &img);
}
}
if let Some((k, tex)) = self.shown.get(&uid) {
if let Some((k, tex)) = self.shown.get(&slot) {
// Document pixels: placed over the document rect they cover.
if let Some([x0, y0, x1, y1]) = k.pixel {
textured_quad(painter, tex.id(), &xf.quad(Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64)), (1.0, 1.0));
@@ -140,21 +145,19 @@ 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. With
/// fewer than two documents open (when the menu item is grayed out) it leaves the view and `tiled` as
/// they are. The documents in floating windows come back under the tabs for it.
/// `window.arrange.tile`: every open document, and each further view of one, in a pane of its own,
/// each fitted to its pane. With fewer than two windows open (when the menu item is grayed out) it
/// leaves the view and `tiled` as they are. The windows floating come back under the tabs for it.
pub(crate) fn tile(app: &mut VectorcraftApp) -> Result<serde_json::Value, String> {
let n = app.session.documents().len();
app.sync_views();
let n = crate::doc_windows::windows(app).len();
if n < 2 {
return Ok(json!({"tiled": app.ui.tiled, "panes": n}));
}
app.sync_views();
crate::doc_windows::dock_all(app);
app.ui.tiled = true;
app.ui.home = None;
for v in &mut app.views {
v.fitted = false;
}
crate::doc_windows::unfit_all(app);
Ok(json!({"tiled": app.ui.tiled, "panes": n}))
}
@@ -167,14 +170,14 @@ pub(crate) fn consolidate(app: &mut VectorcraftApp) -> Result<serde_json::Value,
Ok(json!({"tiled": false, "floating": []}))
}
/// Draw the document area as panes while Tile is on and more than one document is under the tabs,
/// Draw the document area as panes while Tile is on and more than one window 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.
/// canvas. The floating windows have no pane.
pub(crate) fn tiled_area(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
if app.session.documents().is_empty() {
app.ui.tiled = false;
}
let docked = crate::doc_windows::docked(app);
let docked = crate::doc_windows::docked_windows(app);
let n = docked.len();
let tiling = app.ui.tiled && n > 1 && app.ui.screen_mode < 3 && !crate::menus::home_showing(app);
if !tiling {
@@ -185,77 +188,78 @@ pub(crate) fn tiled_area(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
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));
// The current window's pane is the canvas, unless it is a floating one.
let active = crate::doc_windows::current(app).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, 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) {
let live: Vec<Slot> = crate::doc_windows::windows(app).into_iter().filter_map(|w| slot(app, w)).collect();
app.canvas.panes.shown.retain(|s, _| live.contains(s));
app.canvas.panes.pending.retain(|s, _| live.contains(s));
app.canvas.panes.fits.retain(|s, _| live.contains(s));
// A window opened, closed, floated or docked changes the grid, and so does coming back to the
// panes from one window: each window fits its new pane.
if app.canvas.panes.last.iter().map(|(w, _)| *w).ne(docked.iter().copied()) {
for w in &docked {
if let Some(v) = crate::doc_windows::view_mut(app, *w) {
v.fitted = false;
}
}
}
let t = Tokens::get(ui.ctx());
let ppp = ui.ctx().pixels_per_point();
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 cells: Vec<(Win, egui::Rect)> = docked.iter().copied().zip(cells(n, ui.available_rect_before_wrap())).collect();
let areas: Vec<(Win, egui::Rect)> = cells.iter().map(|(w, cell)| (*w, view_rects(app, *cell, ppp).1)).collect();
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().filter(|(i, _)| Some(*i) != active) {
if pane(app, ui, i, cell) {
activate = Some(i);
for (w, cell) in cells.iter().copied().filter(|(w, _)| Some(*w) != active) {
if pane(app, ui, w, cell) {
activate = Some(w);
}
ui.painter().rect_stroke(cell, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
}
if let Some((_, cell)) = cells.iter().copied().find(|(i, _)| Some(*i) == active) {
if let Some((_, cell)) = cells.iter().copied().find(|(w, _)| Some(*w) == 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 (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));
for ((w, area), was_unfitted) in areas.iter().zip(unfitted) {
if let (true, Some(v), Some(s)) = (was_unfitted, crate::doc_windows::view(app, *w).filter(|v| v.fitted).copied(), slot(app, *w)) {
app.canvas.panes.fits.insert(s, (area.size(), v.zoom, v.center));
}
}
app.canvas.panes.last = cells;
if let Some(i) = activate {
crate::chrome::show_document(app, i);
if let Some(w) = activate {
crate::doc_windows::show_window(app, w);
}
true
}
/// Unfit each document whose view is still where a pane fit left it when its view's area (in
/// `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(area) = area
&& let Some((size, zoom, center)) = fits.get(&(d.uid as usize))
&& *size != area.size()
&& *zoom == v.zoom
&& *center == v.center
{
v.fitted = false;
}
}
app.views.iter().map(|v| !v.fitted).collect()
/// Window `w`'s [`Slot`].
fn slot(app: &VectorcraftApp, (i, n): Win) -> Option<Slot> {
app.session.documents().get(i).map(|d| (d.uid, n))
}
/// Unfit each window whose view is still where a pane fit left it when its view's area (`areas`,
/// by window) 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 of `areas`' views are unfitted now.
fn refit_resized(app: &mut VectorcraftApp, areas: &[(Win, egui::Rect)]) -> Vec<bool> {
areas
.iter()
.map(|(w, area)| {
let fit = slot(app, *w).and_then(|s| app.canvas.panes.fits.get(&s).copied());
let Some(v) = crate::doc_windows::view_mut(app, *w) else { return false };
if let Some((size, zoom, center)) = fit
&& size != area.size()
&& zoom == v.zoom
&& center == v.center
{
v.fitted = false;
}
!v.fitted
})
.collect()
}
/// Fit `v` to show its artboard (the first if it has gone) in `rect`, as Fit Artboard in Window
@@ -268,41 +272,45 @@ 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. 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 {
/// The pane of window `w` (document `i`'s view `n`), 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 window to become the current one. A floating window draws its
/// view with this too ([`crate::doc_windows`]).
pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, w: Win, 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);
let Some(st) = app.session.documents().get(i) else { return false };
if let Some(v) = app.views.get_mut(i).filter(|v| !v.fitted) {
fit_pane(v, &st.doc, rect);
let Some(st) = app.session.documents().get(w.0) else { return false };
let doc = st.doc.clone();
if let Some(v) = crate::doc_windows::view_mut(app, w).filter(|v| !v.fitted) {
fit_pane(v, &doc, rect);
}
let v = app.views.get(i).copied().unwrap_or_default();
let v = crate::doc_windows::view(app, w).copied().unwrap_or_default();
let Some(st) = app.session.documents().get(w.0) else { return false };
let slot = (st.uid, w.1);
let xf = Xf::new(rect, &v);
let painter = ui.painter_at(rect);
paint_paper(app, st, ui, &painter, &xf, rect, &t);
paint_paper(app, st, ui, &painter, &xf, rect, (&t, v.outline));
let grid_look = app.ui.view.grid.then(|| LineLook::grid(&app.session.prefs));
let grids_in_back = app.session.prefs.grids_in_back;
if let Some(look) = grid_look.filter(|_| grids_in_back) {
grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look);
}
let size = |l: f32| ((l * ppp).round().max(1.0) as u32).min(u32::from(u16::MAX));
let (w, h) = (size(rect.width()), size(rect.height()));
let (pw, ph) = (size(rect.width()), size(rect.height()));
// View › Pixel Preview, as the canvas draws it.
let pixel = (app.ui.view.pixel_preview && v.zoom * ppp as f64 > 1.0).then(|| pixel_region(&xf)).flatten();
let smooth_images = pixel.is_none() || app.session.prefs.anti_aliased_bitmaps;
let mut key = cache_key(app, ui, st, &t, &v, (w, h, ppp), (pixel, smooth_images));
// The current document's drag and Image Trace hold are not this document's.
(key.dragging, key.trace_source) = (false, None);
let mut key = cache_key(app, ui, st, &t, &v, (pw, ph, ppp), (pixel, smooth_images));
// The current document's drag and Image Trace hold are not this document's, and this view has
// its own Outline or Preview.
(key.dragging, key.trace_source, key.outline) = (false, None, v.outline);
let opts = frame_options(app, &key, st.shown_mask(), smooth_images);
let (fw, fh, view) = frame_view(pixel, (w, h), &v, ppp);
let (fw, fh, view) = frame_view(pixel, (pw, ph), &v, ppp);
let doc = st.doc.clone();
app.canvas.panes.draw(ui.ctx(), &painter, &xf, (key, doc, ((fw, fh), view), opts));
let Some(st) = app.session.documents().get(i) else { return false };
app.canvas.panes.draw(ui.ctx(), &painter, &xf, slot, (key, doc, ((fw, fh), view), opts));
let Some(st) = app.session.documents().get(w.0) else { return false };
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look);
}
@@ -327,7 +335,7 @@ pub(crate) fn pane(app: &mut VectorcraftApp, ui: &Ui, i: usize, cell: egui::Rect
ui.painter().rect_filled(bar, 0.0, t.ruler);
}
}
let resp = ui.interact(cell, egui::Id::unique(("arrange-pane", st.uid)), Sense::click_and_drag());
let resp = ui.interact(cell, egui::Id::unique(("arrange-pane", st.uid, w.1)), Sense::click_and_drag());
resp.contains_pointer() && ui.input(|inp| inp.pointer.any_pressed())
}
+4 -4
View File
@@ -45,7 +45,7 @@ fn button(pos: Pos2, pressed: bool) -> egui::Event {
/// The documents' indices in the panes the last frame drew, and the panes.
fn panes(app: &VectorcraftApp) -> (Vec<usize>, Vec<egui::Rect>) {
app.canvas.panes.last.iter().copied().unzip()
app.canvas.panes.last.iter().map(|(w, r)| (w.0, *r)).unzip()
}
#[test]
@@ -198,7 +198,7 @@ fn closing_a_document_while_tiled_tiles_the_others() {
app.run("window.arrange.tile", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
assert_eq!(panes(&app).0, [0, 1, 2]);
let first = app.session.documents()[0].uid as usize;
let first = (app.session.documents()[0].uid, 1);
assert!(app.canvas.panes.shown.contains_key(&first));
// The first document, not the current one, closes.
app.run("file.close", json!({"index": 0})).unwrap();
@@ -220,7 +220,7 @@ fn an_edit_shows_in_its_pane_once_another_document_is_current() {
app.run("document.activate", json!({"index": 0})).unwrap();
app.run("window.arrange.tile", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let uid = app.session.documents()[1].uid as usize;
let uid = (app.session.documents()[1].uid, 1);
let before = app.canvas.panes.shown.get(&uid).map(|(k, _)| k.revision);
assert_eq!(before, Some(app.session.documents()[1].revision), "the second document's pane shows it");
app.run("document.activate", json!({"index": 1})).unwrap();
@@ -294,7 +294,7 @@ fn other_panes_show_pixel_preview() {
app.ui.view.pixel_preview = true;
app.views[0].zoom = 4.0;
frame(&mut app, &ctx, vec![]);
let uid = app.session.documents()[0].uid as usize;
let uid = (app.session.documents()[0].uid, 1);
let key = app.canvas.panes.shown.get(&uid).map(|(k, _)| k.clone()).unwrap();
let [x0, y0, x1, y1] = key.pixel.expect("the pane renders document pixels");
assert!(x1 > x0 && y1 > y0);
+70 -52
View File
@@ -1,28 +1,37 @@
use super::{Sense, Stroke, Tokens, Ui, VectorcraftApp, icons, json, theme, vec2, widgets, zoom_label};
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)".
/// [`view_tab_title`] for a document with one view.
#[cfg(test)]
pub(super) fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bool, display_print_size: bool) -> String {
view_tab_title(d, None, zoom, outline, display_print_size)
}
/// A document tab's title: "Name* @ 66.67 % (RGB/Preview)", or while an opacity mask is edited
/// "Name* @ 66.67 % (<Opacity Mask>/Opacity Mask)". For a view of a document with more than one
/// view, the view's `number` follows the name: "Name:2* @ 66.67 % (RGB/Preview)".
fn view_tab_title(d: &vectorcraft_engine::DocState, number: Option<u32>, zoom: f64, outline: bool, display_print_size: bool) -> String {
let mode = if d.doc.mask_edit.is_some() {
format!("<{0}>/{0}", tl!("Opacity Mask"))
} else {
let color = if d.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
format!("{color}/{}", if outline { tl!("Outline") } else { tl!("Preview") })
};
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %"))
let number = number.map(|n| format!(":{n}")).unwrap_or_default();
format!("{}{number}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %"))
}
/// The title of open document `i`'s tab, with its own view's zoom and Outline or Preview.
pub(super) fn doc_tab_title(app: &VectorcraftApp, i: usize, d: &vectorcraft_engine::DocState) -> String {
let view = app.views.get(i);
/// The title of window `w`, a view of document `d`: with the view's zoom and Outline or Preview,
/// and the view's number while the document has more than one view.
fn window_tab_title(app: &VectorcraftApp, w: crate::doc_windows::Win, d: &vectorcraft_engine::DocState) -> String {
let view = crate::doc_windows::view(app, w);
let zoom = view.map_or(1.0, |v| v.zoom);
tab_title(d, zoom, view.is_some_and(|v| v.outline), app.session.prefs.display_print_size)
view_tab_title(d, crate::doc_windows::label(app, w), 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))
/// The title of window `w`'s tab and floating window ([`crate::doc_windows`]).
pub(crate) fn document_window_title(app: &VectorcraftApp, w: crate::doc_windows::Win) -> Option<String> {
let d = app.session.documents().get(w.0)?;
Some(window_tab_title(app, w, d))
}
/// The height of the tab strip and the size of a tab's title, in points: larger with User
@@ -31,16 +40,16 @@ fn tab_size(app: &VectorcraftApp) -> (f32, f32) {
if app.session.prefs.large_tabs { (44.0, 14.0) } else { (35.0, 12.5) }
}
/// The tab at the top of document `i`'s floating OS window ([`crate::doc_windows`]): a strip with
/// the document's tab, as the main window's strip draws it but without its ×. → the tab's
/// response, which senses clicks and drags.
pub(crate) fn window_tab(app: &VectorcraftApp, ui: &mut Ui, i: usize) -> egui::Response {
/// The tab at the top of window `w`'s floating OS window ([`crate::doc_windows`]): a strip with the
/// window's tab, as the main window's strip draws it but without its ×. → the tab's response,
/// which senses clicks and drags.
pub(crate) fn window_tab(app: &VectorcraftApp, ui: &mut Ui, w: crate::doc_windows::Win) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (height, title_size) = tab_size(app);
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let title = document_window_title(app, i).unwrap_or_default();
let title = document_window_title(app, w).unwrap_or_default();
let galley = ui.painter().layout_no_wrap(title, theme::semibold(title_size), t.text_strong);
let r = egui::Rect::from_min_size(strip.min, vec2((galley.size().x + 28.0).min(strip.width()), strip.height() - 1.0));
ui.painter().rect_filled(r, 0.0, t.panel);
@@ -88,10 +97,10 @@ 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. A tab dragged more
/// than [`crate::doc_windows::TEAR_OFF`] above or below the strip floats its document in a window
/// under the pointer.
/// Each view Window › New Window opened has a tab of its own. Windows floating have no tab
/// ([`crate::doc_windows`]); while the current window is a floating one, the tab of the window the
/// strip shows is the highlighted one. A tab dragged more than [`crate::doc_windows::TEAR_OFF`]
/// above or below the strip floats its view in a window under the pointer.
pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
/// The width of the » button.
const MORE: f32 = 30.0;
@@ -102,16 +111,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 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 shown = crate::doc_windows::tab_window(app).filter(|_| app.ui.home.is_none());
// The windows under the tabs; `active` is the position of the highlighted one.
let docked = crate::doc_windows::docked_windows(app);
let active = docked.iter().position(|w| Some(*w) == shown);
let tabs: Vec<(String, std::sync::Arc<egui::Galley>)> = docked
.iter()
.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) == shown { t.text_strong } else { t.text_dim };
.filter_map(|w| Some((*w, app.session.documents().get(w.0)?)))
.map(|(w, d)| {
let title = window_tab_title(app, w, d);
let color = if Some(w) == shown { t.text_strong } else { t.text_dim };
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
})
.collect();
@@ -128,10 +137,11 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
if x + w > strip.left() + room && Some(k) != active {
break;
}
let Some(&i) = docked.get(k) else { break };
let Some(&win) = docked.get(k) else { break };
let i = win.0;
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_and_drag());
let resp = ui.interact(r, ui.scope_id().with(("tab", i, win.1)), Sense::click_and_drag());
if is_active {
ui.painter().rect_filled(r, 0.0, t.panel);
} else if resp.hovered() {
@@ -142,26 +152,27 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
// (#673).
let (x_center, title_left) = if CLOSE_BEFORE_TITLE { (r.left() + 16.0, r.left() + 32.0) } else { (r.right() - 16.0, r.left() + 14.0) };
let xr = egui::Rect::from_center_size(egui::pos2(x_center, r.center().y), vec2(12.0, 12.0));
let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i)), Sense::click());
let xresp = ui.interact(xr.expand(3.0), ui.scope_id().with(("tabx", i, win.1)), Sense::click());
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text);
hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0));
hovering_art |= crate::document_drag::hover_tab(app, &resp, win, xr.expand(3.0));
if xresp.clicked() {
close = Some(i);
close = Some(win);
} else if resp.clicked() {
activate = Some(i);
activate = Some(win);
} else if let Some(at) = tear_off(ui, &resp, strip) {
float = Some((i, at));
float = Some((win, at));
}
x += w;
}
crate::document_drag::finish_tabs(ui.ctx(), hovering_art);
// The » button: every open document, floating ones included, the active one checked. The list
// The » button: every window, floating ones included, the current one checked. The list
// scrolls once it reaches the bottom of the window, so every document of a large session stays
// reachable (#4017).
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 current = crate::doc_windows::current(app).filter(|_| app.ui.home.is_none());
let all = crate::doc_windows::windows(app);
let titles: Vec<(crate::doc_windows::Win, String)> = all.iter().map(|w| (*w, document_window_title(app, *w).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 });
@@ -169,25 +180,25 @@ 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 titles.iter().enumerate() {
if widgets::menu_item_name(ui, title, true, Some(i) == current) {
activate = Some(i);
for (w, title) in &titles {
if widgets::menu_item_name(ui, title, true, Some(*w) == current) {
activate = Some(*w);
ui.close();
}
}
});
});
}
if let Some(i) = close {
if let Err(e) = crate::unsaved::close(app, i) {
if let Some(w) = close {
if let Err(e) = crate::doc_windows::close_window(app, w) {
app.status(e);
}
} else if let Some((i, at)) = float {
if let Err(e) = app.run("window.arrange.floatInWindow", json!({"index": i, "at": [at.x, at.y]})) {
} else if let Some(((i, n), at)) = float {
if let Err(e) = app.run("window.arrange.floatInWindow", json!({"index": i, "view": n, "at": [at.x, at.y]})) {
app.status(e);
}
} else if let Some(i) = activate {
show_document(app, i);
} else if let Some(w) = activate {
crate::doc_windows::show_window(app, w);
}
}
@@ -212,20 +223,27 @@ pub(crate) fn show_document(app: &mut VectorcraftApp, i: usize) {
/// the current one in tab order (before it) the current one, as a click on its tab does. After the
/// last document comes the first, and before the first document comes the last. On the Home
/// screen, where no tab is the current one, the next document is the first and the previous
/// document is the last. Returns `{index}`.
/// document is the last. Each view Window › New Window opened is a step of its own. Returns
/// `{index}`, with the view's number as `view` while the document has more than one view.
pub fn step_document(app: &mut VectorcraftApp, forward: bool) -> Result<serde_json::Value, String> {
let n = app.session.documents().len();
let all = crate::doc_windows::windows(app);
let n = all.len();
if n == 0 {
return Err("no document open".into());
}
let i = match (app.session.active_index().filter(|_| app.ui.home.is_none()), forward) {
let current = crate::doc_windows::current(app).filter(|_| app.ui.home.is_none()).and_then(|c| all.iter().position(|w| *w == c));
let k = match (current, forward) {
(Some(a), true) => (a + 1) % n,
(Some(a), false) => (a + n - 1) % n,
(None, true) => 0,
(None, false) => n - 1,
};
show_document(app, i);
Ok(json!({ "index": i }))
let Some(&w) = all.get(k) else { return Err("no document open".into()) };
crate::doc_windows::show_window(app, w);
Ok(match crate::doc_windows::label(app, w) {
Some(view) => json!({ "index": w.0, "view": view }),
None => json!({ "index": w.0 }),
})
}
/// The isolation mode breadcrumb bar. It is drawn over the top of `area` (the canvas), after the
+1 -1
View File
@@ -873,7 +873,7 @@ fn only_the_outlined_documents_tab_reads_outline() {
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.run("view.outline", json!({})).unwrap();
let titles: Vec<String> = app.session.documents().iter().enumerate().map(|(i, d)| super::tabs::doc_tab_title(&app, i, d)).collect();
let titles: Vec<String> = (0..app.session.documents().len()).filter_map(|i| super::document_window_title(&app, (i, 1))).collect();
assert!(titles[0].ends_with("(RGB/Preview)"), "{titles:?}");
assert!(titles[1].ends_with("(RGB/Outline)"), "{titles:?}");
}
+14 -8
View File
@@ -192,17 +192,23 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"view": app.view().map(|v| serde_json::to_value(v).unwrap_or_default()),
"canvasRect": app.canvas_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
// Window › Arrange › Tile (not saved, so not in `ui`) and the panes the last frame drew:
// each one's document index and rect. Canvas coordinates map to the current document's
// pane, inside its rulers and scroll bars (`canvasRect`).
// each one's document index, view number (Window › New Window) and rect. Canvas
// coordinates map to the current window's pane, inside its rulers and scroll bars
// (`canvasRect`).
"tiled": app.ui.tiled,
"panes": app.canvas.panes.last.iter().map(|(i, c)| json!({
"document": i,
"panes": app.canvas.panes.last.iter().map(|(w, c)| json!({
"document": w.0,
"view": w.1,
"rect": [c.left(), c.top(), c.width(), c.height()],
"active": app.session.active_index() == Some(*i),
"active": crate::doc_windows::current(app) == Some(*w),
})).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 › Arrange › Float in Window: the floating windows' documents, by index. While the
// current one floats, `canvasRect` is in its window's coordinates.
"floating": crate::doc_windows::floating_windows(app).iter().map(|w| w.0).collect::<Vec<_>>(),
// Window › New Window: every window, as [document index, view number], in tab order, and
// the current one.
"windows": crate::doc_windows::windows(app),
"currentWindow": crate::doc_windows::current(app),
"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(),
+180 -158
View File
@@ -12,11 +12,20 @@
//!
//! With Preferences › User Interface › Open Documents As Tabs cleared, each document opened or
//! created afterwards opens in a floating window ([`opened`]).
//!
//! Each view of a document that Window › New Window opened ([`views`]) is a window of its own
//! here: it has its own tab, pane or floating window.
use serde_json::{Value, json};
use crate::VectorcraftApp;
mod views;
pub(crate) use views::{
Views, Win, close_current, close_window, current, label, new_window, retain as retain_views, show_window, unfit_all, view, view_mut, windows,
};
use views::{find, key, window_param};
/// How far above or below the tab strip, in points, a dragged document tab must be released to
/// float its document.
pub(crate) const TEAR_OFF: f32 = 24.0;
@@ -30,11 +39,13 @@ 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.
/// A document's view in a floating window.
#[derive(Clone, Debug)]
pub struct DocWindow {
/// The document ([`vectorcraft_engine::DocState::uid`]).
pub uid: u64,
/// The view's number ([`views`]).
pub view: u32,
/// Where the window goes, in the main window's points.
pub place: egui::Rect,
/// The window's own number ([`Windows::opened`]): a document floated again gets a new window,
@@ -61,8 +72,8 @@ pub struct DocWindow {
type Fit = (egui::Vec2, f64, vectorcraft_geom::Point);
impl DocWindow {
fn new(uid: u64, place: egui::Rect) -> Self {
Self { uid, place, number: 0, generation: 0, screen: None, focused: false, body: None, fit: None, grab: None }
fn new((uid, view): (u64, u32), place: egui::Rect) -> Self {
Self { uid, view, place, number: 0, generation: 0, screen: None, focused: false, body: None, fit: None, grab: None }
}
}
@@ -71,17 +82,19 @@ impl DocWindow {
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>>,
/// The current window ([`views::key`]) when the windows were last drawn: a floating window
/// that becomes the current one comes to the front.
current: Option<(u64, u32)>,
/// While Presentation Mode is on: the floating windows ([`views::key`]) it has shown.
presented: Option<Vec<(u64, u32)>>,
/// The main window's tab strip in its last frame, while it was drawn.
pub(crate) strip: Option<egui::Rect>,
/// Counts the windows opened ([`DocWindow::number`]).
opened: u32,
/// The open documents (their uids) when [`opened`] last ran, or when the app started.
known: Vec<u64>,
/// The documents' views beyond the one each shows ([`views`]).
pub(crate) views: Views,
}
impl Windows {
@@ -97,32 +110,51 @@ pub(crate) fn torn_off(strip: egui::Rect, p: egui::Pos2) -> bool {
p.y > strip.bottom() + TEAR_OFF || p.y < strip.top() - TEAR_OFF
}
/// Whether document `i` is in a floating window.
/// Whether window `w` is in a floating window.
pub(crate) fn floats(app: &VectorcraftApp, w: Win) -> bool {
key(app, w).is_some_and(|(u, n)| app.ui.floating.iter().any(|x| x.uid == u && x.view == n))
}
/// Whether document `i`'s shown view is in a floating window.
#[cfg(test)]
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))
floats(app, (i, views::shown(app, i)))
}
/// The documents under the tab strip, by index, in tab order.
/// The windows under the tab strip, in tab order.
pub(crate) fn docked_windows(app: &VectorcraftApp) -> Vec<Win> {
windows(app).into_iter().filter(|w| !floats(app, *w)).collect()
}
/// The documents with a window under the tab strip, by index, in tab order.
#[cfg(test)]
pub(crate) fn docked(app: &VectorcraftApp) -> Vec<usize> {
(0..app.session.documents().len()).filter(|i| !is_floating(app, *i)).collect()
let mut docs: Vec<usize> = docked_windows(app).into_iter().map(|(i, _)| i).collect();
docs.dedup();
docs
}
/// 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);
/// The window the tab strip shows: the current one when it is under the tabs, else the tabbed
/// window that was current last, else the first tabbed one.
pub(crate) fn tab_window(app: &VectorcraftApp) -> Option<Win> {
let docked = docked_windows(app);
if let Some(c) = current(app).filter(|c| docked.contains(c)) {
return Some(c);
}
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())
let last = app.ui.tab_document.and_then(|k| find(app, k));
last.filter(|w| docked.contains(w)).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.
/// The document of the window the tab strip shows ([`tab_window`]).
#[cfg(test)]
pub(crate) fn tab_document(app: &VectorcraftApp) -> Option<usize> {
tab_window(app).map(|(i, _)| i)
}
/// Whether the current window is a floating one, outside the Home screen and Presentation Mode:
/// the main window's canvas then draws the tab strip's window 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)
current(app).is_some_and(|w| floats(app, w)) && !crate::menus::home_showing(app) && !presenting(app)
}
/// Whether Presentation Mode is on: the main window shows the current document, and the floating
@@ -151,11 +183,11 @@ 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]) {
/// Float windows `wins`, each at the next cascade place after the windows already open. Each fits
/// its window.
fn float(app: &mut VectorcraftApp, wins: &[Win]) {
app.sync_views();
place(app, docs);
place(app, wins);
}
/// The documents opened or created since the last call: each one opened while Preferences › User
@@ -168,131 +200,120 @@ pub(crate) fn opened(app: &mut VectorcraftApp) {
if known.len() == docs.len() && known.iter().zip(docs).all(|(u, d)| *u == d.uid) {
return;
}
let new: Vec<usize> = docs.iter().enumerate().filter(|(_, d)| !d.opened_as_tab && !known.contains(&d.uid)).map(|(i, _)| i).collect();
let new: Vec<Win> = docs.iter().enumerate().filter(|(_, d)| !d.opened_as_tab && !known.contains(&d.uid)).map(|(i, _)| (i, 1)).collect();
app.canvas.windows.known = docs.iter().map(|d| d.uid).collect();
if !new.is_empty() {
place(app, &new);
}
}
/// [`float`] for documents whose views exist.
fn place(app: &mut VectorcraftApp, docs: &[usize]) {
/// [`float`] for windows whose views exist.
fn place(app: &mut VectorcraftApp, wins: &[Win]) {
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) {
let places = cascade_places(area(app), first + wins.len());
for (k, w) in wins.iter().enumerate() {
let Some(key) = key(app, *w) else { continue };
if floats(app, *w) {
continue;
}
let place = places.get(first + k).copied().unwrap_or(egui::Rect::from_min_size(egui::Pos2::ZERO, MIN_SIZE));
app.canvas.windows.opened = app.canvas.windows.opened.wrapping_add(1);
let mut w = DocWindow::new(uid, place);
w.number = app.canvas.windows.opened;
app.ui.floating.push(w);
if let Some(v) = app.views.get_mut(*i) {
let mut win = DocWindow::new(key, place);
win.number = app.canvas.windows.opened;
app.ui.floating.push(win);
if let Some(v) = view_mut(app, *w) {
v.fitted = false;
}
}
if !docs.is_empty() {
if !wins.is_empty() {
app.ui.home = None;
}
}
/// The floating documents' indices, in the order their windows opened.
/// The floating windows' document indices, in the order the 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()
floating_windows(app).into_iter().map(|(i, _)| i).collect()
}
/// The open document `p`'s `index` names: `Ok(None)` without one.
fn document_param(app: &VectorcraftApp, p: &Value) -> Result<Option<usize>, String> {
match p.get("index") {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.and_then(|i| usize::try_from(i).ok())
.filter(|i| *i < app.session.documents().len())
.map(Some)
.ok_or_else(|| "no such document".to_string()),
}
/// The floating windows, in the order they opened.
pub(crate) fn floating_windows(app: &VectorcraftApp) -> Vec<Win> {
app.ui.floating.iter().filter_map(|w| find(app, (w.uid, w.view))).collect()
}
/// `window.arrange.floatInWindow`: the current document, or document `index`, leaves the tab strip
/// for a floating window of its own, with its top-left corner at `at` (`[x, y]` in the main
/// window's points) when given, else at the next cascade place. A document already floating stays
/// where it is. Document `index` becomes the current one. Returns `{floating}`.
/// `window.arrange.floatInWindow`: the current window, or document `index`'s view `view` (its
/// shown view without one), leaves the tab strip for a floating window of its own, with its
/// top-left corner at `at` (`[x, y]` in the main window's points) when given, else at the next
/// cascade place. A window already floating stays where it is. The window `index` names becomes
/// the current one. Returns `{floating}`, the floating windows' document indices.
pub(crate) fn float_in_window(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let index = document_param(app, p)?;
let named = window_param(app, p)?;
let at = p
.get("at")
.and_then(Value::as_array)
.and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)))
.filter(|at| at.is_finite());
if let Some(i) = index.or(app.session.active_index()).filter(|i| !is_floating(app, *i)) {
float(app, &[i]);
if let Some(w) = named.or(current(app)).filter(|w| !floats(app, *w)) {
float(app, &[w]);
if let (Some(at), Some(w)) = (at, app.ui.floating.last_mut()) {
w.place = egui::Rect::from_min_size(at, w.place.size());
}
}
if let Some(i) = index {
crate::chrome::show_document(app, i);
if let Some(w) = named {
show_window(app, w);
}
Ok(json!({ "floating": floating_indices(app) }))
}
/// `window.arrange.consolidateAllWindows` with an `index`: document `index`, floating or not,
/// goes back under its tab, fitted there, and becomes the current one; the other windows and Tile
/// stay as they are. Returns `{tiled, floating}`.
/// `window.arrange.consolidateAllWindows` with an `index`: document `index`'s view `view` (its
/// shown view without one), floating or not, goes back under its tab, fitted there, and becomes
/// the current window; the other windows and Tile stay as they are. Returns `{tiled, floating}`.
pub(crate) fn dock(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let Some(i) = document_param(app, p)? else {
let Some(w) = window_param(app, p)? else {
return Err("no such document".into());
};
if is_floating(app, i) {
let uid = app.session.documents().get(i).map(|d| d.uid);
app.ui.floating.retain(|w| Some(w.uid) != uid);
if let Some(v) = app.views.get_mut(i) {
if let Some((u, n)) = key(app, w).filter(|_| floats(app, w)) {
app.ui.floating.retain(|x| !(x.uid == u && x.view == n));
if let Some(v) = view_mut(app, w) {
v.fitted = false;
}
}
crate::chrome::show_document(app, i);
show_window(app, w);
Ok(json!({ "tiled": app.ui.tiled, "floating": floating_indices(app) }))
}
/// `window.arrange.floatAllInWindows`: every document under the tab strip goes to a floating window
/// `window.arrange.floatAllInWindows`: every window 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);
let docked = docked_windows(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}`.
/// `window.arrange.cascade`: every window floating, stacked from the document area's upper left to
/// its lower right in tab order, each fitted to its window, the current window in front. Returns
/// `{floating}`.
pub(crate) fn cascade(app: &mut VectorcraftApp) -> Result<Value, String> {
app.ui.tiled = false;
let docked = docked(app);
let docked = docked_windows(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 order: Vec<(u64, u32)> = windows(app).into_iter().filter_map(|w| key(app, w)).collect();
app.ui.floating.sort_by_key(|w| order.iter().position(|k| *k == (w.uid, w.view)));
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.
unfit_all(app);
// The current 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
/// Every floating window 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) {
for w in floating_windows(app) {
if let Some(v) = view_mut(app, w) {
v.fitted = false;
}
}
@@ -319,9 +340,9 @@ fn native(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
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 OS window of document window `w`.
fn viewport_id(w: &DocWindow) -> egui::ViewportId {
egui::ViewportId::from_hash_of(("vectorcraft-document-window", w.uid, w.number))
}
/// The id of document window `w` inside the main window: a new one for each window and each
@@ -352,33 +373,36 @@ struct Shown {
/// 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);
let live: Vec<(u64, u32)> = windows(app).into_iter().filter_map(|w| key(app, w)).collect();
app.ui.floating.retain(|w| live.contains(&(w.uid, w.view)));
let current = current(app).and_then(|w| key(app, w));
let floating = |app: &VectorcraftApp, k: &(u64, u32)| app.ui.floating.iter().any(|w| (w.uid, w.view) == *k);
if let Some(k) = current.filter(|k| !floating(app, k)) {
app.ui.tab_document = Some(k);
}
// Presentation Mode fits each floating document it shows to the main window: back in their
// floating windows, those documents fit their windows again.
// Presentation Mode fits each floating window it shows to the main window: back in their
// floating windows, those views fit their windows again.
let presenting = presenting(app);
if presenting {
let is_floating = current.is_some_and(|k| floating(app, &k));
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(k) = current.filter(|k| is_floating && !shown.contains(k)) {
shown.push(k);
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) {
let wins: Vec<Win> = shown.into_iter().filter_map(|k| find(app, k)).collect();
for w in wins {
if let Some(v) = view_mut(app, w) {
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.
// While Presentation Mode hides the windows, `current` keeps the window that was in front, so a
// window made current meanwhile comes to the front when it ends.
if presenting {
return;
}
@@ -402,7 +426,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
#[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());
input::register(ctx, app.services.native_input_hook.clone(), app.ui.floating.iter().map(viewport_id).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.
@@ -412,31 +436,29 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
let mut dock = None;
let mut over_strip = false;
for w in app.ui.floating.clone() {
let Some(i) = app.session.documents().iter().position(|d| d.uid == w.uid) else { continue };
let shown = if native {
os_window(app, ctx, &w, (i, blocked), raise == Some(w.uid))
} else {
app_window(app, ctx, &w, (i, blocked), raise == Some(w.uid))
};
let Some(win) = find(app, (w.uid, w.view)) else { continue };
let raised = raise == Some((w.uid, w.view));
let shown = if native { os_window(app, ctx, &w, (win, blocked), raised) } else { app_window(app, ctx, &w, (win, blocked), raised) };
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);
let is_current = views::current(app) == Some(win);
if let Some(x) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid && x.view == w.view) {
// The OS window took the keyboard: its view becomes the current window.
if shown.focused && !x.focused && !is_current {
activate = Some(win);
}
win.focused = shown.focused;
win.body = shown.body;
win.fit = shown.fit;
x.focused = shown.focused;
x.body = shown.body;
x.fit = shown.fit;
}
over_strip |= shown.over_strip;
if shown.close {
close = Some(i);
close = Some(win);
} else if shown.dock {
dock = Some(i);
} else if shown.activate && app.session.active_index() != Some(i) {
activate = Some(i);
dock = Some(win);
} else if shown.activate && !is_current {
activate = Some(win);
}
}
// The tab strip a dragged window would go back under shows where it lands.
@@ -446,61 +468,61 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
painter.rect_filled(strip, 0.0, t.accent.gamma_multiply(0.12));
painter.rect_stroke(strip, 0.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
}
if let Some(i) = close {
if let Some(win) = close {
// The save prompt opens in the main window.
if let Err(e) = crate::unsaved::close(app, i) {
if let Err(e) = close_window(app, win) {
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) = dock {
if let Err(e) = app.run("window.arrange.consolidateAllWindows", json!({ "index": i })) {
} else if let Some((i, n)) = dock {
if let Err(e) = app.run("window.arrange.consolidateAllWindows", json!({ "index": i, "view": n })) {
app.status(e);
}
if native {
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
}
} else if let Some(i) = activate {
crate::chrome::show_document(app, i);
app.canvas.windows.current = app.session.active().map(|d| d.uid);
} else if let Some(win) = activate {
show_window(app, win);
app.canvas.windows.current = views::current(app).and_then(|w| key(app, w));
}
}
/// 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>) {
/// Window `win`'s body in `ui`: the canvas for the current window, else (and while `blocked` by a
/// modal) a pane. The view fits the body again when the body changed size since `fit` and the view
/// has not moved. → whether a press in the pane asks for its window to become the current one, the
/// body's rect and the fit after this frame.
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, (win, blocked): (Win, 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)
if let (Some(v), Some((size, zoom, center))) = (view_mut(app, win), 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) {
let was_fitted = view(app, win).is_some_and(|v| v.fitted);
let pressed = if !blocked && views::current(app) == Some(win) && !crate::menus::home_showing(app) {
crate::canvas::show(app, ui);
false
} else {
crate::canvas::pane(app, ui, i, rect)
crate::canvas::pane(app, ui, win, rect)
};
ui.advance_cursor_after_rect(rect);
let fit = match app.views.get(i) {
let fit = match view(app, win) {
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. The document's tab is at the top of the window, above its body.
fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown {
let vid = viewport_id(w.uid);
/// Window `win`'s floating window `w` as an OS window of its own, `blocked` by a modal or not;
/// `raise` brings it to the front. Its tab is at the top of the window, above its body.
fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (win, blocked): (Win, bool), raise: bool) -> Shown {
let vid = viewport_id(w);
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,
@@ -510,8 +532,8 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
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));
if let Some(x) = app.ui.floating.iter_mut().find(|x| x.uid == w.uid && x.view == w.view) {
x.screen = Some((pos, w.generation));
}
pos
}
@@ -519,7 +541,7 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
if raise {
ctx.send_viewport_cmd_to(vid, egui::ViewportCommand::Focus);
}
let title = crate::chrome::document_window_title(app, i).unwrap_or_default();
let title = crate::chrome::document_window_title(app, win).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);
@@ -532,9 +554,9 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
// or a modal can keep the document open.
ui.ctx().send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
let tab = crate::chrome::window_tab(app, ui, i);
let (over_strip, dock) = if blocked { (false, false) } else { drag_tab(app, ui, (w.uid, origin), &tab, strip) };
let (pressed, rect, fit) = body(app, ui, (i, blocked), w.fit);
let tab = crate::chrome::window_tab(app, ui, win);
let (over_strip, dock) = if blocked { (false, false) } else { drag_tab(app, ui, ((w.uid, w.view), origin), &tab, strip) };
let (pressed, rect, fit) = body(app, ui, (win, blocked), w.fit);
let activate = pressed || (!blocked && (tab.clicked() || tab.drag_started()));
// Keys the window's widgets left over run the main window's shortcuts and tool keys, as a
// floating panel's do.
@@ -559,18 +581,18 @@ fn os_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, b
shown
}
/// A drag of document window `uid`'s OS window by the `tab` at its top: the window follows the
/// pointer, and its document goes back under the tabs when the tab is released over the main
/// window's tab `strip` (on the screen). `origin` is the main window's top-left corner on the
/// A drag of floating window `key`'s ([`views::key`]) OS window by the `tab` at its top: the window
/// follows the pointer, and its view goes back under the tabs when the tab is released over the
/// main window's tab `strip` (on the screen). `origin` is the main window's top-left corner on the
/// screen. → whether the pointer is over the strip, and whether the tab was released there.
fn drag_tab(
app: &mut VectorcraftApp,
ui: &egui::Ui,
(uid, origin): (u64, egui::Pos2),
((uid, view), origin): ((u64, u32), egui::Pos2),
tab: &egui::Response,
strip: Option<egui::Rect>,
) -> (bool, bool) {
let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == uid) else {
let Some(win) = app.ui.floating.iter_mut().find(|x| x.uid == uid && x.view == view) else {
return (false, false);
};
if !tab.dragged() && !tab.drag_stopped() {
@@ -598,14 +620,14 @@ fn drag_tab(
(over, false)
}
/// Document `i`'s window `w` as a window inside the main window, `blocked` by a modal or not;
/// `raise` brings it to the front.
fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i, blocked): (usize, bool), raise: bool) -> Shown {
/// Window `win`'s floating 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, (win, blocked): (Win, 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 title = crate::chrome::document_window_title(app, win).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);
@@ -619,7 +641,7 @@ fn app_window(app: &mut VectorcraftApp, ctx: &egui::Context, w: &DocWindow, (i,
.frame(frame)
.open(&mut open)
.show(ctx, |ui| {
let (pressed, r, f) = body(app, ui, (i, blocked), fit);
let (pressed, r, f) = body(app, ui, (win, 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
+168 -3
View File
@@ -257,11 +257,11 @@ fn tile_brings_floating_documents_back_into_the_grid() {
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();
let docs: Vec<usize> = app.canvas.panes.last.iter().map(|(w, _)| w.0).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();
let docs: Vec<usize> = app.canvas.panes.last.iter().map(|(w, _)| w.0).collect();
assert_eq!(docs, [0, 1, 2]);
assert!(app.ui.floating.is_empty());
}
@@ -437,7 +437,7 @@ fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() {
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);
let vid = viewport_id(&app.ui.floating[0]);
host.borrow_mut().close = true;
frame(&mut app, &ctx, vec![]);
host.borrow_mut().close = false;
@@ -725,3 +725,168 @@ fn the_arrange_items_are_enabled_as_the_documents_allow() {
app.run("window.arrange.consolidateAllWindows", json!({})).unwrap();
assert_eq!(states(&app), [true, true, true, true, false], "consolidated");
}
/// The title of window `w`'s tab.
fn title_of(app: &VectorcraftApp, w: Win) -> String {
crate::chrome::document_window_title(app, w).unwrap()
}
#[test]
fn new_window_opens_a_numbered_view_with_its_own_zoom() {
let mut app = app_with(1);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let name = app.session.documents()[0].title();
assert!(!title_of(&app, (0, 1)).contains(&format!("{name}:")), "one view: no number");
let zoom = app.view().unwrap().zoom;
assert_eq!(app.run("window.newWindow", json!({})).unwrap(), json!({"index": 0, "view": 2}));
assert_eq!(windows(&app), [(0, 1), (0, 2)]);
assert_eq!(current(&app), Some((0, 2)), "the new view is the current window");
assert_eq!(app.view().unwrap().zoom, zoom, "it opens where the first view is");
assert!(title_of(&app, (0, 1)).starts_with(&format!("{name}:1 @")), "{}", title_of(&app, (0, 1)));
assert!(title_of(&app, (0, 2)).starts_with(&format!("{name}:2 @")), "{}", title_of(&app, (0, 2)));
let shapes = frame(&mut app, &ctx, vec![]);
assert!(tab_drawn(&shapes, &format!("{name}:1")) && tab_drawn(&shapes, &format!("{name}:2")), "each view has a tab");
// Each view keeps its own zoom.
app.run("view.zoomIn", json!({})).unwrap();
let zoomed = app.view().unwrap().zoom;
assert_ne!(zoomed, zoom);
show_window(&mut app, (0, 1));
assert_eq!(app.view().unwrap().zoom, zoom, "the first view did not zoom");
assert_eq!(view(&app, (0, 2)).unwrap().zoom, zoomed);
show_window(&mut app, (0, 2));
assert_eq!(app.view().unwrap().zoom, zoomed);
assert_eq!(app.run("window.newWindow", json!({})).unwrap(), json!({"index": 0, "view": 3}));
}
#[test]
fn an_edit_in_one_view_shows_in_the_other() {
let mut app = app_with(1);
let ctx = egui::Context::default();
app.run("window.newWindow", json!({})).unwrap();
app.run("window.arrange.tile", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
assert_eq!(app.canvas.panes.last.iter().map(|(w, _)| *w).collect::<Vec<_>>(), [(0, 1), (0, 2)], "one pane per view");
view_mut(&mut app, (0, 1)).unwrap().zoom = 0.5;
app.view_mut().unwrap().zoom = 2.0;
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
frame(&mut app, &ctx, vec![]);
frame(&mut app, &ctx, vec![]);
let d = &app.session.documents()[0];
let shown = app.canvas.panes.shown.get(&(d.uid, 1)).map(|(k, _)| (k.revision, k.zoom));
assert_eq!(shown, Some((d.revision, 0.5)), "the other view's pane shows the edit at its own zoom");
assert_eq!(app.view().unwrap().zoom, 2.0, "the view edited in keeps its zoom");
}
#[test]
fn closing_a_view_leaves_the_document_open() {
let mut app = app_with(1);
let ctx = egui::Context::default();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap();
app.run("window.newWindow", json!({})).unwrap();
app.run("window.newWindow", json!({})).unwrap();
show_window(&mut app, (0, 2));
assert_eq!(app.run("file.close", json!({})).unwrap(), json!({"index": 0, "views": 2}));
assert!(app.ui.dialog.is_none(), "closing a view does not ask to save");
assert_eq!((app.session.documents().len(), windows(&app)), (1, vec![(0, 1), (0, 3)]));
assert_eq!(current(&app), Some((0, 3)), "the next view becomes the current window");
frame(&mut app, &ctx, vec![]);
assert_eq!(app.run("file.close", json!({})).unwrap(), json!({"index": 0, "views": 1}));
assert_eq!(windows(&app), [(0, 1)], "the view left is the document's only view again");
let name = app.session.documents()[0].title();
assert!(!title_of(&app, (0, 1)).contains(&format!("{name}:")), "{}", title_of(&app, (0, 1)));
// The last view: the document closes, asking to save it first.
app.run("file.close", json!({})).unwrap();
assert!(app.ui.dialog.is_some(), "the changed document asks to be saved");
assert_eq!(app.session.documents().len(), 1);
}
#[test]
fn arrange_treats_each_view_as_a_window() {
let mut app = app_with(1);
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
assert!(!enabled(&app, "window.arrange.tile") && !enabled(&app, "window.arrange.cascade"), "one window");
app.run("window.newWindow", json!({})).unwrap();
assert!(enabled(&app, "window.arrange.tile") && enabled(&app, "window.arrange.cascade"), "two views of one document");
assert_eq!(app.run("window.arrange.tile", json!({})).unwrap(), json!({"tiled": true, "panes": 2}));
app.run("window.arrange.cascade", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let uid = app.session.documents()[0].uid;
let floating: Vec<(u64, u32)> = app.ui.floating.iter().map(|w| (w.uid, w.view)).collect();
assert_eq!(floating, [(uid, 1), (uid, 2)], "each view in a window of its own, in tab order");
assert_ne!(app.ui.floating[0].place.min, app.ui.floating[1].place.min, "stacked down and to the right");
assert!(docked_windows(&app).is_empty());
app.run("window.arrange.consolidateAllWindows", json!({})).unwrap();
assert!(app.ui.floating.is_empty());
assert_eq!(docked_windows(&app), [(0, 1), (0, 2)]);
// Float in Window floats the current view only.
app.run("window.arrange.floatInWindow", json!({})).unwrap();
assert_eq!(app.ui.floating.iter().map(|w| (w.uid, w.view)).collect::<Vec<_>>(), [(uid, 2)]);
assert_eq!(docked_windows(&app), [(0, 1)], "the first view keeps its tab");
frame(&mut app, &ctx, vec![]);
let shapes = frame(&mut app, &ctx, vec![]);
let name = app.session.documents()[0].title();
assert!(tab_drawn(&shapes, &format!("{name}:1")) && !tab_drawn(&shapes, &format!("{name}:2")));
// A new view of a floating view floats too.
app.run("window.newWindow", json!({})).unwrap();
assert!(floats(&app, (0, 3)), "it opens beside the view it came from");
app.run("window.arrange.floatAllInWindows", json!({})).unwrap();
assert!(docked_windows(&app).is_empty());
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({"index": 0, "view": 3})).unwrap()["floating"], json!([0, 0]));
assert_eq!((docked_windows(&app), current(&app)), (vec![(0, 3)], Some((0, 3))));
}
#[test]
fn each_pane_draws_its_own_views_outline_or_preview() {
let mut app = app_with(1);
let ctx = egui::Context::default();
app.run("window.newWindow", json!({})).unwrap();
show_window(&mut app, (0, 1));
app.run("view.outline", json!({})).unwrap();
show_window(&mut app, (0, 2));
assert!(!app.outline_view(), "the second view is in Preview");
app.run("window.arrange.tile", json!({})).unwrap();
frame(&mut app, &ctx, vec![]);
let uid = app.session.documents()[0].uid;
assert_eq!(app.canvas.panes.shown.get(&(uid, 1)).map(|(k, _)| k.outline), Some(true), "the first view's pane is in Outline");
}
#[test]
fn next_and_previous_document_step_through_the_views() {
let mut app = app_with(2);
app.run("document.activate", json!({"index": 0})).unwrap();
app.run("window.newWindow", json!({})).unwrap();
assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 1}));
assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 0, "view": 1}));
assert_eq!(app.run("window.nextDocument", json!({})).unwrap(), json!({"index": 0, "view": 2}));
assert_eq!(current(&app), Some((0, 2)));
assert_eq!(app.run("window.previousDocument", json!({})).unwrap(), json!({"index": 0, "view": 1}));
assert_eq!(app.run("window.previousDocument", json!({})).unwrap(), json!({"index": 1}));
}
#[test]
fn switching_views_resets_the_tool() {
let mut app = app_with(2);
app.run("window.newWindow", json!({})).unwrap();
let before = app.session.tool_resets();
show_window(&mut app, (1, 1));
assert_eq!(app.session.tool_resets(), before + 1, "another view of the current document resets the tool");
show_window(&mut app, (1, 1));
assert_eq!(app.session.tool_resets(), before + 1, "the current view stays as it is");
}
#[test]
fn switching_views_targets_the_artboard_the_view_shows() {
let mut app = app_with(1);
app.run("artboard.new", json!({})).unwrap();
app.run("tool.select", json!({"tool": "artboard"})).unwrap();
crate::panels::artboards::select(&mut app, 0);
app.run("window.newWindow", json!({})).unwrap();
crate::panels::artboards::select(&mut app, 1);
assert_eq!(app.session.tool_options()["active"], json!(1));
show_window(&mut app, (0, 1));
assert_eq!(app.session.tool_options()["active"], json!(0), "the first view shows the first artboard");
show_window(&mut app, (0, 2));
assert_eq!(app.session.tool_options()["active"], json!(1));
}
+247
View File
@@ -0,0 +1,247 @@
//! Window › New Window: more views of an open document, each with its own zoom, center, rotation,
//! artboard and Outline or Preview, shown as tabs titled with the view's number ("Name:2"). Tile,
//! Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as
//! they arrange a document. Every view shows the same document, so an edit made in one shows in
//! all of them. Closing a view leaves the document open until its last view closes.
//!
//! A window is a document's index and its view's number ([`Win`]). The view a document shows
//! while it is the current one is in [`VectorcraftApp::views`]; its other views are kept in
//! [`Views`].
use std::collections::HashMap;
use serde_json::{Value, json};
use crate::VectorcraftApp;
use crate::state::View;
/// A window: an open document's index and its view's number (1 for a document's first view).
pub(crate) type Win = (usize, u32);
/// A view of an open document other than the one [`VectorcraftApp::views`] holds for it.
#[derive(Clone, Debug)]
struct OtherView {
/// The document ([`vectorcraft_engine::DocState::uid`]).
uid: u64,
/// The view's number.
number: u32,
view: View,
}
/// The views of the open documents beyond the one each document shows.
#[derive(Default)]
pub struct Views {
others: Vec<OtherView>,
/// By document uid: the number of the view in [`VectorcraftApp::views`] (1 when absent).
shown: HashMap<u64, u32>,
}
fn uid(app: &VectorcraftApp, i: usize) -> Option<u64> {
app.session.documents().get(i).map(|d| d.uid)
}
/// The number of the view of document `i` that [`VectorcraftApp::views`] holds.
pub(crate) fn shown(app: &VectorcraftApp, i: usize) -> u32 {
uid(app, i).and_then(|u| app.canvas.windows.views.shown.get(&u).copied()).unwrap_or(1)
}
/// The numbers of document `i`'s views, in order; none for a document that is not open.
pub(crate) fn numbers(app: &VectorcraftApp, i: usize) -> Vec<u32> {
let Some(u) = uid(app, i) else { return vec![] };
let mut n: Vec<u32> =
std::iter::once(shown(app, i)).chain(app.canvas.windows.views.others.iter().filter(|o| o.uid == u).map(|o| o.number)).collect();
n.sort_unstable();
n
}
/// Every window, by document and then by view number: the tab order.
pub(crate) fn windows(app: &VectorcraftApp) -> Vec<Win> {
(0..app.session.documents().len()).flat_map(|i| numbers(app, i).into_iter().map(move |n| (i, n))).collect()
}
/// The current window: the current document's shown view.
pub(crate) fn current(app: &VectorcraftApp) -> Option<Win> {
app.session.active_index().map(|i| (i, shown(app, i)))
}
/// Window `w`'s key, which stays the same while documents before it close: its document's uid and
/// its view's number.
pub(crate) fn key(app: &VectorcraftApp, (i, n): Win) -> Option<(u64, u32)> {
uid(app, i).map(|u| (u, n))
}
/// The window `key` ([`key`]) names, while its document is open.
pub(crate) fn find(app: &VectorcraftApp, (u, n): (u64, u32)) -> Option<Win> {
let i = app.session.documents().iter().position(|d| d.uid == u)?;
numbers(app, i).contains(&n).then_some((i, n))
}
/// Window `w`'s view.
pub(crate) fn view(app: &VectorcraftApp, (i, n): Win) -> Option<&View> {
if n == shown(app, i) {
return app.views.get(i);
}
let u = uid(app, i)?;
app.canvas.windows.views.others.iter().find(|o| o.uid == u && o.number == n).map(|o| &o.view)
}
/// Window `w`'s view, to change.
pub(crate) fn view_mut(app: &mut VectorcraftApp, (i, n): Win) -> Option<&mut View> {
if n == shown(app, i) {
return app.views.get_mut(i);
}
let u = uid(app, i)?;
app.canvas.windows.views.others.iter_mut().find(|o| o.uid == u && o.number == n).map(|o| &mut o.view)
}
/// Unfit every window's view: each fits where it shows next.
pub(crate) fn unfit_all(app: &mut VectorcraftApp) {
for v in &mut app.views {
v.fitted = false;
}
for o in &mut app.canvas.windows.views.others {
o.view.fitted = false;
}
}
/// The number a window's title shows: its view's while its document has more than one view.
pub(crate) fn label(app: &VectorcraftApp, (i, n): Win) -> Option<u32> {
(numbers(app, i).len() > 1).then_some(n)
}
/// Make view `n` the one [`VectorcraftApp::views`] holds for document `i`. False when the document
/// has no such view. While document `i` is the current one, the tool is reset first, as a switch to
/// another document resets it, and with the Artboard tool the artboard the view shows becomes the
/// tool's target.
fn bring(app: &mut VectorcraftApp, (i, n): Win) -> bool {
let old = shown(app, i);
if n == old {
return app.views.get(i).is_some();
}
let Some(u) = uid(app, i) else { return false };
if app.views.get(i).is_none() || !app.canvas.windows.views.others.iter().any(|o| o.uid == u && o.number == n) {
return false;
}
if app.session.active_index() == Some(i) {
app.session.reset_tool();
}
let views = &mut app.canvas.windows.views;
let (Some(other), Some(shown)) = (views.others.iter_mut().find(|o| o.uid == u && o.number == n), app.views.get_mut(i)) else {
return false;
};
std::mem::swap(shown, &mut other.view);
other.number = old;
views.shown.insert(u, n);
if app.session.active_index() == Some(i)
&& app.session.tool_id() == "artboard"
&& let Some(count) = app.session.active().map(|d| d.doc.artboards.len())
{
let board = crate::panels::artboards::selected(app, count);
crate::panels::artboards::select(app, board);
}
true
}
/// Make window `w` the current one, as a click on its tab does.
pub(crate) fn show_window(app: &mut VectorcraftApp, w: Win) {
bring(app, w);
crate::chrome::show_document(app, w.0);
}
/// Forget the views of documents that have closed, and keep each other view on an artboard its
/// document has. Called by [`VectorcraftApp::sync_views`].
pub(crate) fn retain(app: &mut VectorcraftApp) {
let views = &mut app.canvas.windows.views;
if views.others.is_empty() && views.shown.is_empty() {
return;
}
let docs = app.session.documents();
views.shown.retain(|u, _| docs.iter().any(|d| d.uid == *u));
views.others.retain_mut(|o| match docs.iter().find(|d| d.uid == o.uid) {
Some(d) => {
o.view.artboard = o.view.artboard.min(d.doc.artboards.len().saturating_sub(1));
true
}
None => false,
});
}
/// `window.newWindow`: another view of the current document, numbered after its others, which
/// starts where the current view is and becomes the current window. It opens as a tab, or in a
/// floating window of its own when the current view is in one. Returns `{index, view}`.
pub(crate) fn new_window(app: &mut VectorcraftApp) -> Result<Value, String> {
app.sync_views();
let Some((i, n)) = current(app) else { return Err("no document open".into()) };
let (Some(u), Some(view)) = (uid(app, i), app.views.get(i).copied()) else { return Err("no document open".into()) };
let number = numbers(app, i).last().copied().unwrap_or(n) + 1;
let floats = super::floats(app, (i, n));
app.canvas.windows.views.others.push(OtherView { uid: u, number, view });
show_window(app, (i, number));
if floats {
super::place(app, &[(i, number)]);
}
Ok(json!({ "index": i, "view": number }))
}
/// Close window `w`: its view goes, and its document stays open with its other views. A
/// document's last view closes the document, asking to save it first when it has changed
/// ([`crate::unsaved::close`]). When one view is left, it is numbered 1 again. Nothing closes while
/// a file dialog's sheet is on the window.
pub(crate) fn close_window(app: &mut VectorcraftApp, (i, n): Win) -> Result<Value, String> {
let nums = numbers(app, i);
if nums.len() <= 1 {
return crate::unsaved::close(app, i);
}
crate::unsaved::refuse_while_picking(app)?;
let Some(u) = uid(app, i) else { return Err("no such document".into()) };
if !nums.contains(&n) {
return Err("no such document".into());
}
let was_current = current(app) == Some((i, n));
// The document's next view in the tab strip takes the closing one's place.
let next = nums.iter().copied().find(|m| *m > n).or(nums.iter().copied().rfind(|m| *m != n));
if n == shown(app, i)
&& let Some(next) = next
{
bring(app, (i, next));
}
app.canvas.windows.views.others.retain(|o| !(o.uid == u && o.number == n));
app.ui.floating.retain(|w| !(w.uid == u && w.view == n));
if app.ui.tab_document == Some((u, n)) {
app.ui.tab_document = None;
}
let left = numbers(app, i);
if let [only] = left[..] {
app.canvas.windows.views.shown.remove(&u);
for w in app.ui.floating.iter_mut().filter(|w| w.uid == u && w.view == only) {
w.view = 1;
}
if app.ui.tab_document == Some((u, only)) {
app.ui.tab_document = Some((u, 1));
}
}
if was_current {
crate::chrome::show_document(app, i);
}
Ok(json!({ "index": i, "views": left.len() }))
}
/// `file.close` without an `index`: the current window closes ([`close_window`]).
pub(crate) fn close_current(app: &mut VectorcraftApp) -> Result<Value, String> {
let Some(w) = current(app) else { return Err("no document open".into()) };
close_window(app, w)
}
/// `params`' `index` and `view`: the window they name, `Ok(None)` without an `index`. Without a
/// `view`, the document's shown view.
pub(crate) fn window_param(app: &VectorcraftApp, p: &Value) -> Result<Option<Win>, String> {
let i = match p.get("index") {
None | Some(Value::Null) => return Ok(None),
Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < app.session.documents().len()).ok_or("no such document")?,
};
let n = match p.get("view") {
None | Some(Value::Null) => shown(app, i),
Some(v) => v.as_u64().and_then(|n| u32::try_from(n).ok()).filter(|n| numbers(app, i).contains(n)).ok_or("no such document")?,
};
Ok(Some((i, n)))
}
+9 -5
View File
@@ -26,6 +26,8 @@ struct Artwork {
#[derive(Clone)]
struct Hover {
uid: u64,
/// The hovered tab's view of its document (Window › New Window).
view: u32,
since: f64,
}
@@ -91,14 +93,16 @@ pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) {
}
}
/// Hover uses `contains_pointer`: the source canvas still owns egui's dragged widget.
pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, close: egui::Rect) -> bool {
/// Hover uses `contains_pointer`: the source canvas still owns egui's dragged widget. The tab is
/// window `win`'s: another view of the current document's is a tab to switch to as well.
pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, win: crate::doc_windows::Win, close: egui::Rect) -> bool {
let ctx = &resp.ctx;
let (index, view) = win;
let eligible = resp.contains_pointer()
&& ctx
.input(|i| i.pointer.primary_down() && !i.key_pressed(egui::Key::Escape) && i.pointer.interact_pos().is_some_and(|p| !close.contains(p)))
&& app.ui.dialog.is_none();
if !eligible || app.session.active_index() == Some(index) || ctx.data(|d| d.get_temp::<egui::Pos2>(activated_at_id()).is_some()) {
if !eligible || crate::doc_windows::current(app) == Some(win) || ctx.data(|d| d.get_temp::<egui::Pos2>(activated_at_id()).is_some()) {
return false;
}
let transfer = egui::DragAndDrop::has_payload_of_type::<Artwork>(ctx);
@@ -110,7 +114,7 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize,
return false;
};
let now = ctx.input(|i| i.time);
let hover = ctx.data(|d| d.get_temp::<Hover>(hover_id())).filter(|h| h.uid == uid).unwrap_or(Hover { uid, since: now });
let hover = ctx.data(|d| d.get_temp::<Hover>(hover_id())).filter(|h| h.uid == uid && h.view == view).unwrap_or(Hover { uid, view, since: now });
ctx.data_mut(|d| d.insert_temp(hover_id(), hover.clone()));
let remaining = HOVER_SECONDS - (now - hover.since);
if remaining > 0.0 {
@@ -147,7 +151,7 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize,
egui::DragAndDrop::set_payload(ctx, art);
}
app.ui.home = None;
app.session.set_active(index);
crate::doc_windows::show_window(app, win);
ctx.request_repaint();
let at = ctx.input(|i| i.pointer.interact_pos());
ctx.data_mut(|d| {
+10 -5
View File
@@ -544,7 +544,7 @@ 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.newWindow" => crate::doc_windows::new_window(app),
"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, p),
@@ -609,10 +609,15 @@ fn run_ui_part7(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
Ok(Value::Null)
}
// Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`).
"file.close" => {
let i = p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index())?;
crate::unsaved::close(app, i)
}
// Without an `index`, the current window closes: one of several views of a document closes
// without closing the document (Window › New Window).
"file.close" => match p.get("index").and_then(Value::as_u64).map(|i| i as usize) {
Some(i) => crate::unsaved::close(app, i),
None => {
app.session.active_index()?;
crate::doc_windows::close_current(app)
}
},
"file.closeAll" => crate::unsaved::close_all(app, "closeAll"),
"app.quit" => crate::unsaved::close_all(app, "quit"),
"ui.swatchOptions" => match s("name") {
+9 -10
View File
@@ -283,17 +283,16 @@ 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: grayed out (#3724).
"window.newWindow" => false,
// 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.newWindow" => app.session.active().is_some(),
// Tile and Cascade with two windows or more (documents, or views of one from New Window);
// Float in Window for a current window under the tabs; Float All in Windows while a window
// is under the tabs; Consolidate All Windows while windows are tiled or floating.
"window.arrange.tile" | "window.arrange.cascade" => crate::doc_windows::windows(app).len() > 1,
"window.arrange.floatInWindow" => crate::doc_windows::current(app).is_some_and(|w| !crate::doc_windows::floats(app, w)),
"window.arrange.floatAllInWindows" => !crate::doc_windows::docked_windows(app).is_empty(),
"window.arrange.consolidateAllWindows" => {
let n = app.session.documents().len();
n > 0 && (app.ui.tiled || crate::doc_windows::docked(app).len() < n)
let n = crate::doc_windows::windows(app).len();
n > 0 && (app.ui.tiled || crate::doc_windows::docked_windows(app).len() < n)
}
"window.nextDocument" | "window.previousDocument" => !app.session.documents().is_empty(),
_ => true,
+8 -6
View File
@@ -912,15 +912,17 @@ fn unlock_all_and_show_all_clicked_with_shift_alt_act_within_the_group() {
}
}
/// #3724: Window › New Window is greyed out until VectorCraft can open a second window on a
/// document, instead of being enabled and answering with an internal milestone note.
/// #4116: Window › New Window is enabled with a document open and opens a second view of it; with
/// no document open it is greyed out.
#[test]
fn new_window_is_disabled_until_it_opens_a_window() {
fn new_window_opens_a_view_of_the_current_document() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
assert!(!enabled(&app, "window.newWindow"), "no document to open a window on");
assert_eq!(app.run("window.newWindow", json!({})).unwrap_err(), "no document open");
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
assert!(!enabled(&app, "window.newWindow"), "New Window is enabled but opens no window");
let e = app.run("window.newWindow", json!({})).unwrap_err();
assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}");
assert!(enabled(&app, "window.newWindow"));
assert_eq!(app.run("window.newWindow", json!({})).unwrap(), json!({"index": 0, "view": 2}));
assert_eq!(app.session.documents().len(), 1, "the same document, in a second window");
}
/// #4143: Window menu panels and panel submenus are listed in alphabetical order according to the
+11 -6
View File
@@ -580,7 +580,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.workspace.delete", "Delete Workspace", "", "{name}"),
("window.workspace.rename", "Rename Workspace", "", "{name, to}"),
("window.workspace.list", "List Workspaces", "", "{}"),
("window.newWindow", "New Window", "", "{}"),
(
"window.newWindow",
"New Window",
"",
"{} open another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own (titled with the view's number, \"Name:2\") or, when the current view floats, in a floating window; it becomes the current window; the Arrange commands treat each view as a window; file.close closes the current view and the last view closes the document; returns {index, view}",
),
(
"window.arrange.cascade",
"Cascade",
@@ -591,13 +596,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"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; documents in floating windows come back under the tabs first; with fewer than two documents open the view stays as it is; returns {tiled, panes}",
"{} show every open document and each further view of one (window.newWindow) at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of panes, rounded up), each fitted to its pane; the current window's pane takes the tools, a click in another pane makes its window the current one; the tabs stay; windows floating come back under the tabs first; with fewer than two windows open the view stays as it is; returns {tiled, panes}",
),
(
"window.arrange.floatInWindow",
"Float in Window",
"",
"{index?, at?: [x, y]} move the current document, or document `index` (which becomes the current one), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)",
"{index?, view?, at?: [x, y]} move the current window, or document `index`'s view `view` (its current view without one; it becomes the current window), out of the tab strip into a floating window of its own, fitted to it, with the window's top-left corner at `at` (main window points) or else at the next place of a cascade; dragging a document tab more than 24 pt above or below the tab strip does this at the pointer; the window holding the current document takes the tools, a click in another document's window makes its document the current one; a document already floating stays where it is; returns {floating} (document indices)",
),
(
"window.arrange.floatAllInWindows",
@@ -609,19 +614,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"window.arrange.consolidateAllWindows",
"Consolidate All Windows",
"",
"{index?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)",
"{index?, view?} show one document at a time again, under its tab, after Tile, Float in Window, Float All in Windows or Cascade; returns {tiled: false, floating: []}; with `index`, only document `index` (its view `view`, else its current view) goes back under its tab and becomes the current one, the other windows and Tile staying as they are, as when a document window is dragged by its title bar (an OS window by the tab at its top) onto the tab strip; returns {tiled, floating} (document indices)",
),
(
"window.nextDocument",
"Next Document",
"Cmd+F6",
"{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); returns {index}",
"{} make the open document after the current one in tab order the current one, as a click on its tab does (after the last document it goes to the first, and from the Home screen to the first); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view",
),
(
"window.previousDocument",
"Previous Document",
"Cmd+Shift+F6",
"{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); returns {index}",
"{} make the open document before the current one in tab order the current one, as a click on its tab does (before the first document it goes to the last, and from the Home screen to the last); each view window.newWindow opened is a step of its own; returns {index}, with `view` for a document with more than one view",
),
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
(
+3 -3
View File
@@ -468,10 +468,10 @@ pub struct UiState {
/// 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.
/// The window (its document's uid and its view's number) the tab strip shows while the current
/// window is a floating one: the tabbed window that was current last.
#[serde(skip)]
pub tab_document: Option<u64>,
pub tab_document: Option<(u64, u32)>,
/// Draw Normal / Behind / Inside.
pub draw_mode: u8,
pub about: bool,
+35
View File
@@ -361,3 +361,38 @@ fn scrolling_tabs_under_a_stationary_drag_does_not_cascade_to_other_documents()
frame(&mut app, &ctx, 2.3, vec![button(drop, false)]);
assert_eq!(app.session.active().unwrap().selection.objects.len(), 1);
}
/// Artwork held over the tab of another view of its own document (Window › New Window) switches to
/// that view, and a drop there moves it.
#[test]
fn dragging_artwork_over_another_views_tab_switches_to_that_view() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300, "name": "Logo"})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("window.newWindow", json!({})).unwrap();
crate::doc_windows::show_window(&mut app, (0, 1));
app.select_tool("selection");
app.ui.task_bar = false;
app.ui.view.rulers = false;
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&mut app, &ctx, 0.0, vec![]);
let tabs = frame(&mut app, &ctx, 0.01, vec![]);
assert_eq!(tabs.len(), 2);
let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(100.0, 100.0));
frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]);
frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]);
assert!(app.session.in_interaction());
frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]);
frame(&mut app, &ctx, 0.25, vec![]);
frame(&mut app, &ctx, 0.8, vec![]);
assert_eq!(crate::doc_windows::current(&app), Some((0, 2)), "holding the artwork over the other view's tab shows that view");
assert!(!app.session.tool_busy(), "the switch resets the Selection tool before the drop");
let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(200.0, 150.0));
frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]);
frame(&mut app, &ctx, 1.0, vec![button(drop, false)]);
let st = app.session.active().unwrap();
let moved = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap();
assert!((moved.center().x - 200.0).abs() < 0.01 && (moved.center().y - 150.0).abs() < 0.01, "{moved:?}");
assert!(!app.session.tool_busy(), "the move leaves the Selection tool idle");
}
+15
View File
@@ -640,6 +640,21 @@ fn nothing_closes_or_quits_while_a_sheet_is_open() {
assert_eq!(app.ui.status, "quit");
}
/// A view Window › New Window opened does not close while a dialog's sheet is on the window either.
#[test]
fn no_view_closes_while_a_sheet_is_open() {
let (mut app, shown, _) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("window.newWindow", json!({})).unwrap();
assert!(app.run("file.open", json!({})).is_err());
assert_eq!(app.run("file.close", json!({})), Err(picks::busy().to_string()));
assert_eq!(crate::doc_windows::windows(&app), [(0, 1), (0, 2)]);
shown.answer(&[]);
poll(&mut app);
app.run("file.close", json!({})).unwrap();
assert_eq!(crate::doc_windows::windows(&app), [(0, 1)]);
}
/// On Linux the dialog is a window of its own, not a sheet: Close and Quit go on while it is open,
/// and its answer for the document closed meanwhile is dropped, nothing written.
#[test]
+1 -1
View File
@@ -19,7 +19,7 @@ pub const KIND: &str = "saveChanges";
/// Nothing closes while a file dialog's sheet is on the window (macOS): what asked for it runs
/// again when it answers. (Elsewhere the dialog is a window of its own, and closing goes on.)
fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> {
pub(crate) fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> {
if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) }
}
+1 -1
View File
@@ -608,7 +608,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Done:** Window › Arrange › Tile (`window.arrange.tile`) shows every open document in its own pane, edge to edge in the document area, and Consolidate All Windows (`window.arrange.consolidateAllWindows`) goes back to one document under its tab, on the desktop and the web. Estimate unchanged.
- **Done:** Window › Arrange › Float in Window (`window.arrange.floatInWindow`), Float All in Windows (`window.arrange.floatAllInWindows`) and Cascade (`window.arrange.cascade`) move documents out of the tab strip into floating windows of their own, OS windows on the desktop and windows inside the app's window on the web and on Wayland; Cascade stacks them from the upper left to the lower right. Consolidate All Windows brings them back under the tabs. Estimate unchanged.
- **Done:** a document tab dragged off the tab strip floats its document in a window under the pointer, and a document window dragged by its title bar (an OS window by the tab at its top) onto the tab strip goes back under the tabs. Estimate unchanged.
- **Missing:** several windows of one document (Window › New Window).
- **Done (#4116):** Window › New Window (`window.newWindow`) opens another view of the current document, with its own zoom, center, rotation, artboard and Outline or Preview, as a tab titled with the view's number ("Name:2"); Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document. Closing a view leaves the document open until its last view closes. Estimate unchanged.
- **Estimate:** 15–25 h.
## G6 Automation and scripting
+11
View File
@@ -3692,6 +3692,17 @@ it returns `{tiled, floating}`. An `index` that names no open document fails wit
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.
`window.newWindow` (Window › New Window) opens another view of the current document, with its own zoom, center,
rotation, artboard and Outline or Preview, makes it the current window and returns `{index, view}`, the view's number.
Each view has a tab of its own; while a document has more than one view, its tabs and windows show the view's number
after the name ("Name:2"). It opens in a floating window when the view it came from is in one. With no document open
it fails with "no document open". The Arrange commands treat each view as a window: `floatInWindow` and
`consolidateAllWindows` take `view` beside `index` (the document's current view without it), and Tile and Cascade
count views. `file.close` without an `index` closes the current view, and the document's last view closes the document;
with an `index` it closes the document and all its views. `window.nextDocument` and `window.previousDocument` step
through the views in tab order and add `view` to their reply for a document with more than one view. `ui.inspect`
reports `windows` (every window as `[document index, view number]`, in tab order), `currentWindow` and each pane's
`view`. Views are not saved.
```json
{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}
+1
View File
@@ -543,6 +543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html): "drag the window's tab out of the group"). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): "Cascade displays windows stacked and descending from the upper-left to the lower-right of the screen"). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
- **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html): "To undock (float or untab) a Document window from a group of windows, drag the window’s tab out of the group. To dock a Document window to a separate group of Document windows, drag the window into the group."). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
- **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html): "When you create or open documents, they are arranged in tabs in a single document window"). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
- **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): "You can open multiple windows of a single document at the same time."), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`.
- **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).