mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 17:44:08 +00:00
Merge pull request #5216 from dotCooCoo/feat/arrange-a
Window: Arrange > Tile shows every open document side by side
This commit is contained in:
@@ -84,6 +84,7 @@ impl VectorcraftApp {
|
||||
view_moved: None,
|
||||
hover_cursor: None,
|
||||
presented: None,
|
||||
panes: Default::default(),
|
||||
},
|
||||
perf: Perf::default(),
|
||||
dock_rt: Default::default(),
|
||||
@@ -424,14 +425,17 @@ impl VectorcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas panel's body: the document tabs, the canvas and the isolation bar over it.
|
||||
/// The canvas panel's body: the document tabs, the canvas (a pane for each document while
|
||||
/// Window › Arrange › Tile is on) and the isolation bar over it.
|
||||
pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) {
|
||||
let t = theme::Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard);
|
||||
if self.ui.screen_mode < 3 {
|
||||
chrome::doc_tabs(self, ui);
|
||||
}
|
||||
canvas::show(self, ui);
|
||||
if !canvas::tiled_area(self, ui) {
|
||||
canvas::show(self, ui);
|
||||
}
|
||||
// Over the canvas inside the rulers, so the rulers and scroll bars stay uncovered.
|
||||
if self.ui.screen_mode < 3
|
||||
&& !menus::home_showing(self)
|
||||
|
||||
@@ -58,6 +58,8 @@ pub struct CanvasCache {
|
||||
/// In Presentation Mode, the canvas rect the active artboard was last fitted to: it is fitted
|
||||
/// again when the rect changes (the window going full screen).
|
||||
pub presented: Option<egui::Rect>,
|
||||
/// Window › Arrange › Tile: the renders the panes of the other documents draw.
|
||||
pub panes: canvas::Panes,
|
||||
}
|
||||
|
||||
/// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision).
|
||||
|
||||
@@ -25,6 +25,7 @@ mod home;
|
||||
mod input;
|
||||
mod outline;
|
||||
mod overlays;
|
||||
mod panes;
|
||||
mod raster;
|
||||
mod task_bar;
|
||||
mod tool_overlays;
|
||||
@@ -59,6 +60,8 @@ use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn};
|
||||
#[cfg(test)]
|
||||
use overlays::selected_anchor;
|
||||
use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay};
|
||||
pub use panes::Panes;
|
||||
pub(crate) use panes::{consolidate, tile, tiled_area};
|
||||
pub(crate) use raster::color_image;
|
||||
use raster::{changed_pixels, pixel_region, upload};
|
||||
use task_bar::task_bar;
|
||||
@@ -169,6 +172,17 @@ pub(crate) fn dragging(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|d| d.get_temp::<Drag>(drag_id()).is_some())
|
||||
}
|
||||
|
||||
/// The part of `outer` inside the scroll bars, and the part of that inside the rulers, which shows
|
||||
/// the document. Normal Screen Mode and Full Screen Mode with Menu Bar keep the scroll bars along
|
||||
/// the right and bottom edges; Full Screen and Presentation Mode have none.
|
||||
fn view_rects(app: &VectorcraftApp, outer: egui::Rect, ppp: f32) -> (egui::Rect, egui::Rect) {
|
||||
let full = if app.ui.screen_mode < 2 { egui::Rect::from_min_max(outer.min, outer.max - vec2(SCROLL_BAR, SCROLL_BAR)) } else { outer };
|
||||
let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
|
||||
// Dock layout can differ by fractions of a device pixel between fresh windows.
|
||||
// Use one pixel-aligned rectangle for drawing, inspection and pointer transforms.
|
||||
(full, rect.round_to_pixels(ppp))
|
||||
}
|
||||
|
||||
/// Paint the document canvas, complete or cached artwork, navigation and tool overlays.
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -201,15 +215,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
ui.painter().rect_filled(full, 0.0, t.panel_darker);
|
||||
return;
|
||||
}
|
||||
// Normal Screen Mode and Full Screen Mode with Menu Bar keep the scroll bars along the right
|
||||
// and bottom edges; Full Screen and Presentation Mode have none.
|
||||
let outer = full;
|
||||
let bars = app.ui.screen_mode < 2;
|
||||
let full = if bars { egui::Rect::from_min_max(full.min, full.max - vec2(SCROLL_BAR, SCROLL_BAR)) } else { full };
|
||||
let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
|
||||
// Dock layout can differ by fractions of a device pixel between fresh windows.
|
||||
// Use one pixel-aligned rectangle for drawing, inspection and pointer transforms.
|
||||
let rect = rect.round_to_pixels(ui.ctx().pixels_per_point());
|
||||
let (full, rect) = view_rects(app, outer, ui.ctx().pixels_per_point());
|
||||
app.canvas_rect = Some(rect);
|
||||
let fitted = app.view().is_some_and(|v| v.fitted);
|
||||
// Presentation Mode shows the active artboard on the whole screen and nothing but the art:
|
||||
@@ -245,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, ui, &painter, &xf, rect, &t);
|
||||
paint_paper(app, st, ui, &painter, &xf, rect, &t);
|
||||
// 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;
|
||||
@@ -655,9 +663,9 @@ fn draw_frame(app: &mut VectorcraftApp, ui: &Ui, frame: Frame) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pasteboard, the artboards' shadows and paper, and the outline mode's white artboards.
|
||||
fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
/// 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) {
|
||||
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).
|
||||
@@ -748,9 +756,22 @@ fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf:
|
||||
// Artboard edges and names. The Artboard tool labels its selected artboards itself (following
|
||||
// a move or resize): those aren't named twice.
|
||||
let active_ab = crate::panels::artboards::selected(app, doc.artboards.len());
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
let tool_selected = artboards::tool_labelled(app);
|
||||
let editing_name = ui.data(|d| d.get_temp::<ArtboardNameEdit>(name_edit_id()).map(|e| e.index));
|
||||
let unlabelled = |i: usize| editing_name == Some(i) || u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx));
|
||||
artboard_edges(app, doc, (painter, xf, t), active_ab, unlabelled);
|
||||
}
|
||||
|
||||
/// The edges, bleeds, marks and names of `doc`'s artboards, `active_ab` the active one; the
|
||||
/// artboards `unlabelled` gives are not named.
|
||||
fn artboard_edges(
|
||||
app: &VectorcraftApp,
|
||||
doc: &vectorcraft_doc::Document,
|
||||
(painter, xf, t): (&egui::Painter, &Xf, &Tokens),
|
||||
active_ab: usize,
|
||||
unlabelled: impl Fn(usize) -> bool,
|
||||
) {
|
||||
let artboard_tool = app.session.tool_id() == "artboard";
|
||||
for (i, ab) in doc.artboards.iter().enumerate() {
|
||||
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
|
||||
painter.add(Shape::closed_line(xf.quad(ab.rect), Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c)));
|
||||
@@ -761,8 +782,7 @@ fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf:
|
||||
// Show Center Mark, Show Cross Hairs and Show Video Safe Areas (Artboard Options). Only the
|
||||
// canvas draws them, as it does the edges: exports and print leave them out.
|
||||
artboard_marks(painter, xf, ab, t.artboard_mark);
|
||||
let labelled_by_tool = u64::try_from(i).ok().is_some_and(|idx| tool_selected.contains(&idx));
|
||||
if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && !labelled_by_tool {
|
||||
if (artboard_tool || doc.artboards.len() > 1) && !unlabelled(i) {
|
||||
// At the artboard's own top-left corner, which a rotated view turns away from its
|
||||
// bounding box's (#1095).
|
||||
let corner = xf.to_screen(Point::new(ab.rect.x0, ab.rect.y0));
|
||||
@@ -773,13 +793,18 @@ fn artboard_outlines(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf:
|
||||
|
||||
/// The ruler guides.
|
||||
fn guide_lines(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: egui::Rect, t: &Tokens) {
|
||||
let Some(st) = app.session.active() else { return };
|
||||
if let Some(st) = app.session.active() {
|
||||
guides_of(app, st, (painter, xf, t), rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ruler guides of document `st`, its selected guides in the selection colour.
|
||||
fn guides_of(app: &VectorcraftApp, st: &vectorcraft_engine::DocState, (painter, xf, t): (&egui::Painter, &Xf, &Tokens), rect: egui::Rect) {
|
||||
let doc = &st.doc;
|
||||
let look = LineLook::guides(&app.session.prefs, t);
|
||||
// Selected guides in the selection colour.
|
||||
let picked = LineLook { color: t.selection, ..look };
|
||||
let selected: &[usize] = app.session.active().map_or(&[], |st| &st.selection.guides);
|
||||
let key = app.session.active().and_then(|st| st.selection.key_guide);
|
||||
let selected: &[usize] = &st.selection.guides;
|
||||
let key = st.selection.key_guide;
|
||||
let view = view_bounds(xf);
|
||||
// Guides on a hidden layer hide with it.
|
||||
for (i, g) in doc.guides.iter().enumerate().filter(|(_, g)| doc.guide_shown(g)) {
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::grid::LineLook;
|
||||
use super::outline::{c32, node_outline, stroke_path, too_many_anchors};
|
||||
use super::view::fit_zoom;
|
||||
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::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.
|
||||
#[derive(Default)]
|
||||
pub struct Panes {
|
||||
/// Renders off the UI thread (None: on it, as on wasm).
|
||||
worker: Option<ThumbWorker<usize, 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)>,
|
||||
}
|
||||
|
||||
impl Panes {
|
||||
/// Forget every render and pane.
|
||||
fn clear(&mut self) {
|
||||
self.shown.clear();
|
||||
self.pending.clear();
|
||||
self.last.clear();
|
||||
self.fits.clear();
|
||||
}
|
||||
|
||||
/// Upload `img`, the render `key` describes.
|
||||
fn upload(&mut self, ctx: &egui::Context, 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) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let (key, doc, ((w, h), view), opts) = frame;
|
||||
if !self.worker_started {
|
||||
self.worker_started = true;
|
||||
self.worker = ThumbWorker::spawn(ctx, "arrange-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);
|
||||
}
|
||||
if let Some(img) = d.img {
|
||||
self.upload(ctx, d.key, &img);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The thread is gone: render here from now on.
|
||||
Some(None) => {
|
||||
self.worker = None;
|
||||
self.pending.clear();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let uid = key.doc;
|
||||
let current = self.shown.get(&uid).is_some_and(|(k, _)| *k == key) || self.pending.get(&uid) == 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))));
|
||||
if queued {
|
||||
self.pending.insert(uid, 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);
|
||||
}
|
||||
}
|
||||
if let Some((k, tex)) = self.shown.get(&uid) {
|
||||
// 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));
|
||||
return;
|
||||
}
|
||||
let ppp = if k.ppp > 0.0 { k.ppp } else { 1.0 };
|
||||
let old_rect = egui::Rect::from_center_size(xf.rect.center(), vec2(k.w as f32 / ppp, k.h as f32 / ppp));
|
||||
let old = Xf { rect: old_rect, zoom: k.zoom, center: Point::new(k.cx, k.cy), rot: k.rot.to_radians() };
|
||||
let q = [old_rect.left_top(), old_rect.right_top(), old_rect.right_bottom(), old_rect.left_bottom()].map(|c| xf.to_screen(old.to_doc(c)));
|
||||
textured_quad(painter, tex.id(), &q, (1.0, 1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid of `n` panes in `area`, filled row by row: as many columns as the square root of `n`
|
||||
/// rounded up, and as many rows as those columns need. The panes meet edge to edge and cover
|
||||
/// `area`; the spare cells of the last row stay empty.
|
||||
pub(crate) fn cells(n: usize, area: egui::Rect) -> Vec<egui::Rect> {
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let mut cols = 1;
|
||||
while cols * cols < n {
|
||||
cols += 1;
|
||||
}
|
||||
let rows = n.div_ceil(cols);
|
||||
let x = |c: usize| if c == cols { area.right() } else { area.left() + area.width() * c as f32 / cols as f32 };
|
||||
let y = |r: usize| if r == rows { area.bottom() } else { area.top() + area.height() * r as f32 / rows as f32 };
|
||||
(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.
|
||||
pub(crate) fn tile(app: &mut VectorcraftApp) -> Result<serde_json::Value, String> {
|
||||
app.sync_views();
|
||||
let n = app.session.documents().len();
|
||||
app.ui.tiled = n > 0;
|
||||
if n > 0 {
|
||||
app.ui.home = None;
|
||||
}
|
||||
for v in &mut app.views {
|
||||
v.fitted = false;
|
||||
}
|
||||
Ok(json!({"tiled": app.ui.tiled, "panes": n}))
|
||||
}
|
||||
|
||||
/// `window.arrange.consolidateAllWindows`: one document at a time again, under its tab.
|
||||
pub(crate) fn consolidate(app: &mut VectorcraftApp) -> Result<serde_json::Value, String> {
|
||||
app.ui.tiled = false;
|
||||
app.canvas.panes.clear();
|
||||
Ok(json!({"tiled": false}))
|
||||
}
|
||||
|
||||
/// Draw the document area as panes while Tile is on and more than one document is open, outside
|
||||
/// the Home screen and Presentation Mode. False when it did not: the caller draws the canvas.
|
||||
pub(crate) fn tiled_area(app: &mut VectorcraftApp, ui: &mut Ui) -> bool {
|
||||
let n = app.session.documents().len();
|
||||
if n == 0 {
|
||||
app.ui.tiled = false;
|
||||
}
|
||||
let tiling = app.ui.tiled && n > 1 && app.ui.screen_mode < 3 && !crate::menus::home_showing(app);
|
||||
let Some(active) = app.session.active_index().filter(|_| tiling) else {
|
||||
if !app.canvas.panes.shown.is_empty() || !app.canvas.panes.last.is_empty() {
|
||||
app.canvas.panes.clear();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
app.sync_views();
|
||||
let live: Vec<usize> = app.session.documents().iter().map(|d| d.uid as usize).collect();
|
||||
app.canvas.panes.shown.retain(|uid, _| live.contains(uid));
|
||||
app.canvas.panes.pending.retain(|uid, _| live.contains(uid));
|
||||
app.canvas.panes.fits.retain(|uid, _| live.contains(uid));
|
||||
// A document opened or closed changes the grid, and so does coming back to the panes from one
|
||||
// document: each document fits its new pane.
|
||||
if app.canvas.panes.last.len() != n {
|
||||
for v in &mut app.views {
|
||||
v.fitted = false;
|
||||
}
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let cells = cells(n, ui.available_rect_before_wrap());
|
||||
let areas: Vec<egui::Rect> = cells.iter().map(|c| view_rects(app, *c, ppp).1).collect();
|
||||
let unfitted = refit_resized(app, &areas);
|
||||
let mut activate = None;
|
||||
// The other panes first, so that what the canvas draws past its pane (the Contextual Task Bar)
|
||||
// stays over them.
|
||||
for (i, cell) in cells.iter().copied().enumerate().filter(|(i, _)| *i != active) {
|
||||
if pane(app, ui, i, cell) {
|
||||
activate = Some(i);
|
||||
}
|
||||
ui.painter().rect_stroke(cell, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
}
|
||||
if let Some(cell) = cells.get(active).copied() {
|
||||
let mut canvas = ui.new_child(egui::UiBuilder::new().max_rect(cell));
|
||||
super::show(app, &mut canvas);
|
||||
ui.painter().rect_stroke(cell, 0.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
// Note the fits this frame made, for `refit_resized`.
|
||||
for ((i, area), was_unfitted) in areas.iter().enumerate().zip(unfitted) {
|
||||
if let (true, Some(v), Some(d)) = (was_unfitted, app.views.get(i).filter(|v| v.fitted), app.session.documents().get(i)) {
|
||||
app.canvas.panes.fits.insert(d.uid as usize, (area.size(), v.zoom, v.center));
|
||||
}
|
||||
}
|
||||
app.canvas.panes.last = cells.into_iter().enumerate().collect();
|
||||
if let Some(i) = activate {
|
||||
crate::chrome::show_document(app, i);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Unfit each document whose view is still where a pane fit left it when its view's area (in
|
||||
/// `areas`, by document index) has changed size, as when the window or a panel is resized: it
|
||||
/// fits its pane again. A view zoomed or scrolled since keeps its place. → which views are
|
||||
/// unfitted now.
|
||||
fn refit_resized(app: &mut VectorcraftApp, areas: &[egui::Rect]) -> Vec<bool> {
|
||||
let fits = &app.canvas.panes.fits;
|
||||
let docs = app.session.documents();
|
||||
for ((v, d), area) in app.views.iter_mut().zip(docs).zip(areas) {
|
||||
if let Some((size, zoom, center)) = fits.get(&(d.uid as usize))
|
||||
&& *size != area.size()
|
||||
&& *zoom == v.zoom
|
||||
&& *center == v.center
|
||||
{
|
||||
v.fitted = false;
|
||||
}
|
||||
}
|
||||
app.views.iter().map(|v| !v.fitted).collect()
|
||||
}
|
||||
|
||||
/// Fit `v` to show its artboard (the first if it has gone) in `rect`, as Fit Artboard in Window
|
||||
/// does.
|
||||
fn fit_pane(v: &mut View, doc: &vectorcraft_doc::Document, rect: egui::Rect) {
|
||||
if let Some(target) = doc.artboards.get(v.artboard).or(doc.artboards.first()).map(|a| a.rect) {
|
||||
v.zoom = fit_zoom(rect, target, v.rotation);
|
||||
v.center = target.center();
|
||||
v.fitted = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The pane of document `i`, not the current one, in `cell`: its paper, art, grid, artboards,
|
||||
/// guides and selected objects, and its rulers and the room of the canvas's scroll bars, so the
|
||||
/// document stays where it is when the pane becomes the canvas. True when a press in the pane asks
|
||||
/// for its document to become the current one.
|
||||
fn pane(app: &mut VectorcraftApp, ui: &Ui, i: usize, cell: egui::Rect) -> bool {
|
||||
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 v = app.views.get(i).copied().unwrap_or_default();
|
||||
let xf = Xf::new(rect, &v);
|
||||
let painter = ui.painter_at(rect);
|
||||
paint_paper(app, st, ui, &painter, &xf, rect, &t);
|
||||
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()));
|
||||
// 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 opts = frame_options(app, &key, st.shown_mask(), smooth_images);
|
||||
let (fw, fh, view) = frame_view(pixel, (w, h), &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 };
|
||||
if let Some(look) = grid_look.filter(|_| !grids_in_back) {
|
||||
grid(&painter, &xf, st.doc.grid.spacing, st.doc.grid.subdivisions, look);
|
||||
}
|
||||
if shows_artboards(app) {
|
||||
artboard_edges(app, &st.doc, (&painter, &xf, &t), v.artboard, |_| false);
|
||||
}
|
||||
if app.ui.view.guides {
|
||||
guides_of(app, st, (&painter, &xf, &t), rect);
|
||||
}
|
||||
if app.ui.view.edges {
|
||||
selected_objects(st, &painter, &xf);
|
||||
}
|
||||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
rulers(ui, full, &xf, None, RulerScale::document(app.session.general_unit(), st.doc.ruler_origin_for(v.artboard)), &t);
|
||||
}
|
||||
if app.ui.screen_mode < 2 {
|
||||
let bars = [
|
||||
egui::Rect::from_min_max(pos2(full.right(), cell.top()), cell.max),
|
||||
egui::Rect::from_min_max(pos2(cell.left(), full.bottom()), cell.max),
|
||||
];
|
||||
for bar in bars {
|
||||
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());
|
||||
resp.contains_pointer() && ui.input(|inp| inp.pointer.any_pressed())
|
||||
}
|
||||
|
||||
/// The outlines of the objects selected in `st`, in their layers' colors; their bounds when they
|
||||
/// have too many points to outline.
|
||||
fn selected_objects(st: &vectorcraft_engine::DocState, painter: &egui::Painter, xf: &Xf) {
|
||||
let symbols = st.doc.symbol_boxes();
|
||||
let nodes: Vec<(vectorcraft_doc::NodeId, &vectorcraft_doc::Node)> =
|
||||
st.selection.objects.iter().filter_map(|id| Some((*id, st.doc.node(*id)?))).collect();
|
||||
let color = |id: vectorcraft_doc::NodeId| Stroke::new(1.0, c32(st.doc.layer_color(id)));
|
||||
if too_many_anchors(nodes.iter().map(|(_, n)| *n)) {
|
||||
let bounds = nodes.iter().filter_map(|(_, n)| n.geometric_bounds_with(&symbols)).reduce(|a, b| a.union(b));
|
||||
if let (Some(b), Some((id, _))) = (bounds, nodes.first()) {
|
||||
stroke_path(painter, &vectorcraft_geom::shapes::rectangle(b).to_bezpath(), xf, color(*id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (id, n) in nodes {
|
||||
stroke_path(painter, &node_outline(n, &symbols), xf, color(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,309 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use egui::{Color32, Pos2, Shape};
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
use crate::menus::enabled;
|
||||
|
||||
/// An app with `n` new 400 × 300 pt documents, the last one current.
|
||||
fn app_with(n: usize) -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
for _ in 0..n {
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
/// One headless frame of the canvas panel's body (the tabs and the canvas or panes) on a
|
||||
/// 1200 × 800 window → the shapes it drew, `Shape::Vec`s flattened.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<Shape> {
|
||||
frame_in(app, ctx, events, vec2(1200.0, 800.0))
|
||||
}
|
||||
|
||||
/// [`frame`] on a window `size` points large.
|
||||
fn frame_in(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, size: egui::Vec2) -> Vec<Shape> {
|
||||
fn flat(s: Shape, out: &mut Vec<Shape>) {
|
||||
match s {
|
||||
Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)),
|
||||
s => out.push(s),
|
||||
}
|
||||
}
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, size)), max_texture_side: Some(8192), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| app.canvas_area(ui));
|
||||
out.textures_delta.clear();
|
||||
let mut shapes = vec![];
|
||||
out.shapes.into_iter().for_each(|c| flat(c.shape, &mut shapes));
|
||||
shapes
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> egui::Event {
|
||||
egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_tile_the_area_edge_to_edge() {
|
||||
let area = egui::Rect::from_min_size(pos2(10.0, 35.0), vec2(1001.0, 703.0));
|
||||
assert!(cells(0, area).is_empty());
|
||||
for (n, cols, rows) in [(1, 1, 1), (2, 2, 1), (3, 2, 2), (4, 2, 2), (5, 3, 2), (6, 3, 2), (7, 3, 3), (10, 4, 3)] {
|
||||
let c = cells(n, area);
|
||||
assert_eq!(c.len(), n);
|
||||
let lefts: BTreeSet<u32> = c.iter().map(|r| r.left().to_bits()).collect();
|
||||
let tops: BTreeSet<u32> = c.iter().map(|r| r.top().to_bits()).collect();
|
||||
assert_eq!((lefts.len(), tops.len()), (cols, rows), "{n} panes: columns and rows");
|
||||
assert_eq!((c[0].left(), c[0].top()), (area.left(), area.top()), "{n} panes start at the corner");
|
||||
for (i, r) in c.iter().enumerate() {
|
||||
assert!(r.width() > 0.0 && r.height() > 0.0 && area.contains_rect(*r), "{n} panes: {r:?}");
|
||||
let last_column = i % cols == cols - 1;
|
||||
match c.get(i + 1).filter(|_| !last_column) {
|
||||
Some(next) => {
|
||||
assert_eq!((r.right(), r.top(), r.bottom()), (next.left(), next.top(), next.bottom()), "{n} panes: {i} meets its neighbor")
|
||||
}
|
||||
None if last_column => assert_eq!(r.right(), area.right(), "{n} panes: {i} reaches the right edge"),
|
||||
None => {}
|
||||
}
|
||||
match c.get(i + cols) {
|
||||
Some(below) => assert_eq!(r.bottom(), below.top(), "{n} panes: {i} meets the pane below"),
|
||||
None if i / cols == rows - 1 => assert_eq!(r.bottom(), area.bottom(), "{n} panes: {i} reaches the bottom"),
|
||||
None => {}
|
||||
}
|
||||
for o in &c[i + 1..] {
|
||||
assert!(r.intersect(*o).area() <= 0.0, "{n} panes overlap: {r:?} {o:?}");
|
||||
}
|
||||
}
|
||||
let covered: f32 = c.iter().map(egui::Rect::area).sum();
|
||||
let whole = area.area() * n as f32 / (cols * rows) as f32;
|
||||
assert!((covered - whole).abs() < 1.0, "{n} panes cover {covered}, not {whole}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tile_shows_a_pane_for_each_document_fitted_to_it() {
|
||||
let mut app = app_with(3);
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert!(panes(&app).0.is_empty(), "documents open under their tabs");
|
||||
let tabbed = app.canvas_rect.unwrap();
|
||||
assert_eq!(app.run("window.arrange.tile", json!({})).unwrap(), json!({"tiled": true, "panes": 3}));
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let (docs, rects) = panes(&app);
|
||||
assert_eq!(docs, [0, 1, 2]);
|
||||
// Three documents: two columns and two rows, the lower-right cell empty.
|
||||
assert_eq!(rects[0].top(), rects[1].top());
|
||||
assert_eq!(rects[0].left(), rects[2].left());
|
||||
assert!(rects[2].top() > rects[0].top());
|
||||
let canvas = app.canvas_rect.unwrap();
|
||||
assert!(rects[2].contains_rect(canvas) && canvas.width() < tabbed.width() / 2.0, "the current document's pane is the canvas: {canvas:?}");
|
||||
let artboard = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
for (i, r) in docs.iter().zip(&rects) {
|
||||
let v = app.views[*i];
|
||||
let shown = if *i == 2 { canvas } else { view_rects(&app, *r, ctx.pixels_per_point()).1 };
|
||||
assert!(v.fitted && (v.zoom - fit_zoom(shown, artboard, 0.0)).abs() < 1e-9, "document {i} fits its pane: {v:?}");
|
||||
assert_eq!(v.center, artboard.center());
|
||||
}
|
||||
assert_eq!(app.canvas.panes.shown.len(), 2, "the other two documents are rendered for their panes");
|
||||
let inspected = crate::control::inspect(&app, &ctx);
|
||||
assert_eq!(inspected["tiled"], json!(true));
|
||||
let active: Vec<bool> = inspected["panes"].as_array().unwrap().iter().map(|p| p["active"].as_bool().unwrap()).collect();
|
||||
assert_eq!(active, [false, false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_in_another_pane_makes_its_document_current_without_drawing() {
|
||||
let mut app = app_with(3);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.select_tool("rectangle");
|
||||
let pane = panes(&app).1[0];
|
||||
let (from, to) = (pane.center() - vec2(30.0, 20.0), pane.center() + vec2(30.0, 20.0));
|
||||
let drag = |app: &mut VectorcraftApp| {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from)]);
|
||||
frame(app, &ctx, vec![button(from, true)]);
|
||||
for k in 1..=4 {
|
||||
frame(app, &ctx, vec![egui::Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))]);
|
||||
}
|
||||
frame(app, &ctx, vec![button(to, false)]);
|
||||
frame(app, &ctx, vec![]);
|
||||
};
|
||||
drag(&mut app);
|
||||
assert_eq!(app.session.active_index(), Some(0), "the press made the first document current");
|
||||
assert!(app.session.documents()[0].doc.art_bounds().is_none(), "the press that switched documents drew nothing");
|
||||
assert!(app.canvas_rect.is_some_and(|c| pane.contains_rect(c)), "its pane is the canvas now");
|
||||
drag(&mut app);
|
||||
assert!(app.session.documents()[0].doc.art_bounds().is_some(), "a drag in the current pane draws");
|
||||
assert!(app.session.documents()[1..].iter().all(|d| d.doc.art_bounds().is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidate_all_windows_shows_one_document_again() {
|
||||
let mut app = app_with(2);
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let tabbed = app.canvas_rect.unwrap();
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(panes(&app).0.len(), 2);
|
||||
assert_eq!(app.run("window.arrange.consolidateAllWindows", json!({})).unwrap(), json!({"tiled": false}));
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert!(panes(&app).0.is_empty() && app.canvas.panes.shown.is_empty());
|
||||
assert_eq!(app.canvas_rect, Some(tabbed), "the current document fills the document area again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_items_follow_the_open_documents() {
|
||||
let mut app = app_with(0);
|
||||
let items = |app: &VectorcraftApp| (enabled(app, "window.arrange.tile"), enabled(app, "window.arrange.consolidateAllWindows"));
|
||||
assert_eq!(items(&app), (false, false), "no document");
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
assert_eq!(items(&app), (false, false), "one document");
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
assert_eq!(items(&app), (true, false), "two documents under their tabs");
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
assert_eq!(items(&app), (true, true), "tiled");
|
||||
app.run("window.arrange.consolidateAllWindows", json!({})).unwrap();
|
||||
assert_eq!(items(&app), (true, false), "consolidated");
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(items(&app), (false, false), "every document closed");
|
||||
assert!(!app.ui.tiled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_document_while_tiled_tiles_the_others() {
|
||||
let mut app = app_with(3);
|
||||
let ctx = egui::Context::default();
|
||||
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;
|
||||
assert!(app.canvas.panes.shown.contains_key(&first));
|
||||
// The first document, not the current one, closes.
|
||||
app.run("file.close", json!({"index": 0})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let (docs, rects) = panes(&app);
|
||||
assert_eq!(docs, [0, 1]);
|
||||
assert_eq!(rects[0].height(), rects[1].height(), "two panes side by side");
|
||||
assert!(!app.canvas.panes.shown.contains_key(&first), "the closed document's render is dropped");
|
||||
app.run("file.close", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert!(panes(&app).0.is_empty(), "one document fills the document area");
|
||||
assert!(enabled(&app, "window.arrange.consolidateAllWindows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_shows_in_its_pane_once_another_document_is_current() {
|
||||
let mut app = app_with(2);
|
||||
let ctx = egui::Context::default();
|
||||
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 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();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 80})).unwrap();
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let edited = &app.session.documents()[1];
|
||||
assert_ne!(Some(edited.revision), before);
|
||||
let shown = app.canvas.panes.shown.get(&uid).map(|(k, _)| k.revision);
|
||||
assert_eq!(shown, Some(edited.revision), "the pane shows the document as edited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_panes_draw_their_guides_and_selected_objects() {
|
||||
let mut app = app_with(2);
|
||||
app.run("document.activate", json!({"index": 0})).unwrap();
|
||||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
app.session.execute("guide.add", &json!({"vertical": true, "pos": 300})).unwrap();
|
||||
let st = &app.session.documents()[0];
|
||||
let layer = super::c32(st.doc.layer_color(vectorcraft_doc::NodeId(id)));
|
||||
app.run("document.activate", json!({"index": 1})).unwrap();
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let shapes = frame(&mut app, &ctx, vec![]);
|
||||
let pane = panes(&app).1[0];
|
||||
let xf = Xf::new(view_rects(&app, pane, ctx.pixels_per_point()).1, &app.views[0]);
|
||||
let outline: Vec<Pos2> = shapes
|
||||
.iter()
|
||||
.filter_map(|s| match s {
|
||||
Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(layer) => Some(p.points.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
let corner = xf.to_screen(Point::new(50.0, 50.0));
|
||||
assert!(outline.iter().any(|p| p.distance(corner) < 1.0), "the selected rectangle is outlined in its pane: {outline:?}");
|
||||
let guide_x = xf.to_screen(Point::new(300.0, 0.0)).x;
|
||||
let guide = LineLook::guides(&app.session.prefs, &Tokens::get(&ctx)).color;
|
||||
let guides = shapes.iter().any(|s| {
|
||||
matches!(s, Shape::LineSegment { points: [a, b], stroke } if stroke.color == guide && (a.x - guide_x).abs() < 0.5 && (b.x - guide_x).abs() < 0.5 && pane.contains(*a))
|
||||
});
|
||||
assert!(guides, "the guide is drawn in its pane");
|
||||
assert_ne!(layer, Color32::TRANSPARENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_document_while_tiled_fits_each_document_to_its_new_pane() {
|
||||
let mut app = app_with(2);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let side_by_side = app.views[0].zoom;
|
||||
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let pane = panes(&app).1[0];
|
||||
let rect = view_rects(&app, pane, ctx.pixels_per_point()).1;
|
||||
let v = app.views[0];
|
||||
assert!((v.zoom - fit_zoom(rect, Rect::new(0.0, 0.0, 400.0, 300.0), 0.0)).abs() < 1e-9, "fitted to its new pane: {v:?}");
|
||||
assert_ne!(v.zoom, side_by_side, "the pane changed size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_panes_show_pixel_preview() {
|
||||
let mut app = app_with(2);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
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 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_window_fits_the_tiled_documents_again_unless_one_was_zoomed() {
|
||||
let mut app = app_with(3);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("window.arrange.tile", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.views[1].zoom *= 2.0;
|
||||
let zoomed = app.views[1];
|
||||
let smaller = vec2(900.0, 600.0);
|
||||
frame_in(&mut app, &ctx, vec![], smaller);
|
||||
let artboard = Rect::new(0.0, 0.0, 400.0, 300.0);
|
||||
let (_, rects) = panes(&app);
|
||||
for i in [0, 2] {
|
||||
let area = if i == 2 { app.canvas_rect.unwrap() } else { view_rects(&app, rects[i], ctx.pixels_per_point()).1 };
|
||||
assert!((app.views[i].zoom - fit_zoom(area, artboard, 0.0)).abs() < 1e-9, "document {i} fits its smaller pane: {:?}", app.views[i]);
|
||||
}
|
||||
assert_eq!(app.views[1].zoom, zoomed.zoom, "the zoomed document keeps its zoom");
|
||||
}
|
||||
@@ -23,12 +23,7 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
|
||||
let zoom = if how == "view.actualSize" {
|
||||
actual_size_zoom(app.session.prefs.display_print_size)
|
||||
} else {
|
||||
// The width and height of the box round the target turned with the view, at zoom 1.
|
||||
let (sn, cs) = app.view().map_or(0.0, |v| v.rotation.to_radians()).sin_cos();
|
||||
let (w, h) = (target.width(), target.height());
|
||||
let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0);
|
||||
let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0);
|
||||
zx.min(zy).clamp(0.0313, 640.0)
|
||||
fit_zoom(rect, target, app.view().map_or(0.0, |v| v.rotation))
|
||||
};
|
||||
let Some(v) = app.view_mut() else { return };
|
||||
v.center = target.center();
|
||||
@@ -36,6 +31,17 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) {
|
||||
v.zoom = zoom;
|
||||
}
|
||||
|
||||
/// The zoom at which `target`, turned `rotation` degrees with the view, fits in `rect` with a
|
||||
/// 30-point margin on each side.
|
||||
pub(super) fn fit_zoom(rect: egui::Rect, target: Rect, rotation: f64) -> f64 {
|
||||
// The width and height of the box round the target turned with the view, at zoom 1.
|
||||
let (sn, cs) = rotation.to_radians().sin_cos();
|
||||
let (w, h) = (target.width(), target.height());
|
||||
let zx = (rect.width() as f64 - 60.0) / (w * cs.abs() + h * sn.abs()).max(1.0);
|
||||
let zy = (rect.height() as f64 - 60.0) / (w * sn.abs() + h * cs.abs()).max(1.0);
|
||||
zx.min(zy).clamp(0.0313, 640.0)
|
||||
}
|
||||
|
||||
/// How much one point of wheel motion zooms (as `exp(points × WHEEL_ZOOM)`).
|
||||
const WHEEL_ZOOM: f64 = 0.01;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ pub use hints::{hint_bar, hint_strings};
|
||||
pub use status_bar::status_bar;
|
||||
#[cfg(test)]
|
||||
use status_bar::{StatusReadoutWidths, status_readouts};
|
||||
pub(crate) use tabs::show_document;
|
||||
#[cfg(test)]
|
||||
use tabs::{CLOSE_BEFORE_TITLE, first_tab_shown, tab_title};
|
||||
pub use tabs::{doc_tabs, isolation_bar, step_document};
|
||||
|
||||
@@ -126,7 +126,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
|
||||
/// Makes document `i` the current one, as a click on its tab does: the Home screen closes, and
|
||||
/// with the Artboard tool the artboard the document shows becomes the tool's target.
|
||||
fn show_document(app: &mut VectorcraftApp, i: usize) {
|
||||
pub(crate) fn show_document(app: &mut VectorcraftApp, i: usize) {
|
||||
app.ui.home = None;
|
||||
let changed = app.session.active_index() != Some(i);
|
||||
if app.session.set_active(i)
|
||||
|
||||
@@ -191,6 +191,15 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
|
||||
"freeTransformWidget": crate::free_transform::rect(ctx).map(|r| json!([r.left(), r.top(), r.width(), r.height()])),
|
||||
"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`).
|
||||
"tiled": app.ui.tiled,
|
||||
"panes": app.canvas.panes.last.iter().map(|(i, c)| json!({
|
||||
"document": i,
|
||||
"rect": [c.left(), c.top(), c.width(), c.height()],
|
||||
"active": app.session.active_index() == Some(*i),
|
||||
})).collect::<Vec<_>>(),
|
||||
"window": [r.width(), r.height()],
|
||||
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
|
||||
"activeDocument": app.session.active_index(),
|
||||
|
||||
@@ -521,6 +521,8 @@ fn run_ui_part6(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
None => Err("brightness must be dark|mediumDark|mediumLight|light".into()),
|
||||
},
|
||||
"window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()),
|
||||
"window.arrange.tile" => crate::canvas::tile(app),
|
||||
"window.arrange.consolidateAllWindows" => crate::canvas::consolidate(app),
|
||||
"window.nextDocument" => crate::chrome::step_document(app, true),
|
||||
"window.previousDocument" => crate::chrome::step_document(app, false),
|
||||
"tool.select" => match s("tool") {
|
||||
|
||||
@@ -272,8 +272,11 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some()
|
||||
}
|
||||
"ui.perspectivePlane" => app.session.active().is_some(),
|
||||
// Only one window per document so far: greyed out, as Window › Arrange's items are (#3724).
|
||||
// Only one window per document so far: greyed out, as Cascade and Float in Window are (#3724).
|
||||
"window.newWindow" => false,
|
||||
// Tile with two documents or more; Consolidate All Windows while they are tiled.
|
||||
"window.arrange.tile" => app.session.documents().len() > 1,
|
||||
"window.arrange.consolidateAllWindows" => app.ui.tiled && !app.session.documents().is_empty(),
|
||||
"window.nextDocument" | "window.previousDocument" => !app.session.documents().is_empty(),
|
||||
_ => true,
|
||||
}
|
||||
|
||||
@@ -607,7 +607,15 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
("Window", {
|
||||
let mut window_items = vec![
|
||||
c("New Window", "window.newWindow"),
|
||||
sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]),
|
||||
sub(
|
||||
"Arrange",
|
||||
vec![
|
||||
todo("Cascade"),
|
||||
c("Tile", "window.arrange.tile"),
|
||||
todo("Float in Window"),
|
||||
c("Consolidate All Windows", "window.arrange.consolidateAllWindows"),
|
||||
],
|
||||
),
|
||||
sub("Workspace", crate::workspaces::menu_items()),
|
||||
Sep,
|
||||
c("Control", "window.control"),
|
||||
|
||||
@@ -554,6 +554,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
("window.workspace.rename", "Rename Workspace", "", "{name, to}"),
|
||||
("window.workspace.list", "List Workspaces", "", "{}"),
|
||||
("window.newWindow", "New Window", "", "{}"),
|
||||
(
|
||||
"window.arrange.tile",
|
||||
"Tile",
|
||||
"",
|
||||
"{} show every open document at once, edge to edge in the document area, one pane each in a grid (columns: the square root of the number of documents, rounded up), each fitted to its pane; the current document's pane takes the tools, a click in another pane makes its document the current one; the tabs stay; returns {tiled: true, panes}",
|
||||
),
|
||||
(
|
||||
"window.arrange.consolidateAllWindows",
|
||||
"Consolidate All Windows",
|
||||
"",
|
||||
"{} show one document at a time again, under its tab, after Tile; returns {tiled: false}",
|
||||
),
|
||||
(
|
||||
"window.nextDocument",
|
||||
"Next Document",
|
||||
|
||||
@@ -441,6 +441,10 @@ pub struct UiState {
|
||||
/// Presentation Mode came back on restart with no way out).
|
||||
#[serde(skip)]
|
||||
pub screen_mode: u8,
|
||||
/// Window › Arrange › Tile: the open documents share the document area, one pane each, until
|
||||
/// Consolidate All Windows. Not saved: the reference app does not save windows with a document.
|
||||
#[serde(skip)]
|
||||
pub tiled: bool,
|
||||
/// Draw Normal / Behind / Inside.
|
||||
pub draw_mode: u8,
|
||||
pub about: bool,
|
||||
@@ -881,6 +885,7 @@ impl Default for UiState {
|
||||
palette_open: false,
|
||||
palette_query: String::new(),
|
||||
screen_mode: 0,
|
||||
tiled: false,
|
||||
draw_mode: 0,
|
||||
about: false,
|
||||
action_sets: crate::panels::actions::default_sets(),
|
||||
|
||||
+2
-1
@@ -574,7 +574,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
panel widths kept with a workspace (#1753). Inside the estimate below.
|
||||
- **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window.
|
||||
- **Done:** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): a document keeps up to 25 named views (zoom, centre and rotation), saved with the file; every one is listed by name at the bottom of the View menu (`view.goto1`–`view.goto25`), and choosing one changes the current window to it. Edit Views… lists them to rename or delete, applied together on OK as one undo step (`view.saved.edit {edits}`); a file's views are checked when it opens. Estimate unchanged.
|
||||
- **Missing:** multiple windows and Window › Arrange (Cascade, Tile, Float in Window, Consolidate All Windows).
|
||||
- **Done:** Window › Arrange › Tile (`window.arrange.tile`) shows every open document in its own pane, edge to edge in the document area, and Consolidate All Windows (`window.arrange.consolidateAllWindows`) goes back to one document under its tab, on the desktop and the web. Estimate unchanged.
|
||||
- **Missing:** multiple windows and the rest of Window › Arrange (Cascade, Float in Window, Float All in Windows).
|
||||
- **Estimate:** 15–25 h.
|
||||
|
||||
## G6 Automation and scripting
|
||||
|
||||
@@ -3571,6 +3571,13 @@ open document in tab order the current one, as a click on its tab does, and retu
|
||||
comes the first, and before the first document comes the last. From the Home screen they go to the first and the last
|
||||
document. With no document open they fail with "no document open". `document.activate {index}` makes a given document
|
||||
the current one.
|
||||
`window.arrange.tile` (Window › Arrange › Tile) shows every open document in its own pane, edge to edge in the document
|
||||
area, in a grid of as many columns as the square root of the number of documents, rounded up, and fits each document to
|
||||
its pane; it returns `{tiled, panes}`. The current document's pane is the canvas, and a press in another pane makes its
|
||||
document the current one. `window.arrange.consolidateAllWindows` (Window › Arrange › Consolidate All Windows) shows one
|
||||
document under its tab again and returns `{tiled: false}`. The arrangement is not saved. `ui.inspect` reports `tiled`
|
||||
and `panes` (each pane's `document` index, `rect` and whether it is `active`); canvas coordinates map to the current
|
||||
document's pane (`canvasRect`).
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}}
|
||||
|
||||
@@ -523,6 +523,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html).
|
||||
- **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`).
|
||||
- **Saved views (G7):** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): up to 25 views per document, saved with it, renamed or deleted in Edit Views, chosen from the bottom of the View menu). Each view keeps the zoom, centre and rotation; the View menu lists all 25 (`view.goto1`–`view.goto25`), and Edit Views… lists them to rename or delete, applied together as one undo step. A file's views are checked when it opens (at most 25, unique names up to 255 characters, a finite centre and a positive zoom). Tests: `saved_views_round_trip_and_from_a_file_are_tidied` (`crates/format/tests/prop_format.rs`), `crates/engine/src/cmd/views.rs`, `crates/ui-egui/src/dialogs/edit_views.rs` and `every_saved_view_is_listed_at_the_bottom_of_the_view_menu` (`crates/ui-egui/src/menus.rs`).
|
||||
- **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): "Tile displays windows edge to edge"), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`.
|
||||
|
||||
- **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`.
|
||||
- **Control bar with the Artboard tool:** while the Artboard tool is in use, the Control bar shows the active artboard's controls in place of the object controls: Preset, Portrait and Landscape, New Artboard, Delete Artboard, the Name field, Artboard Options, Rearrange All Artboards, the Transform link, Move and Scale Artwork with Artboard and, while the bar has room, the reference point with X and Y, and W and H with the Constrain Width and Height Proportions link. Each runs the command its Properties or Transform panel counterpart runs (`artboard.setProps`, `artboard.new`, `artboard.delete`, `tool.options {tool: "artboard"}`), and the reference point is the one the Transform panel uses. In an 800-point window the controls up to the Transform link fit, Move and Scale Artwork with Artboard show as their boxes alone with their names in tooltips, and the link opens the Transform panel with the artboard's position and size. Pointer and key tests in `panels/artboards.rs` cover the controls, W about the reference point, the orientation and the name. Public reference: [Using multiple artboards](https://web.archive.org/web/20210925094818/https://helpx.adobe.com/illustrator/using/using-multiple-artboards.html) (archived copy).
|
||||
|
||||
Reference in New Issue
Block a user