mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-15 06:44:14 +00:00
fit_selection checks every coordinate is finite (a NaN y or x1 left a NaN centre) and leaves the view as it is for such a rectangle. A point selection is centred in the area above the toolbar at its zoom, not in the whole viewport. Tests pin the exact short-canvas and no-inset positions, the narrow-canvas margins, lines, tiny and huge rectangles, the bottomInset clamp and that it is never saved, the unchanged Zoom to fit centre, the pill strip the app hands the engine (shown and hidden), and the Shift+2 centre without reading the inset back. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Model-Effort: Opus 5.5 triaged and briefed; Sonnet 5.5 verified and wrote at medium
172 lines
7.1 KiB
Rust
172 lines
7.1 KiB
Rust
use super::{Deserialize, Serialize};
|
|
|
|
/// The canvas view of a document: what's at the centre of the viewport and the zoom.
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct ViewState {
|
|
/// Page coordinate at the viewport centre.
|
|
pub cx: f64,
|
|
pub cy: f64,
|
|
/// Zoom (1 = 100%).
|
|
pub zoom: f64,
|
|
/// Viewport size in logical pixels (set by the UI; used by zoom-to-fit).
|
|
pub vw: f64,
|
|
pub vh: f64,
|
|
/// Height in screen points of the strip the floating bottom toolbar covers (from its top edge to
|
|
/// the canvas's bottom edge); set by the UI every frame, 0 when it's hidden. Never saved.
|
|
#[serde(skip)]
|
|
pub bottom_inset: f64,
|
|
pub pixel_grid: bool,
|
|
pub layout_grids: bool,
|
|
pub rulers: bool,
|
|
pub outlines: bool,
|
|
/// Outline mode for the selected layers only (the rest draws normally).
|
|
pub outlines_selection: bool,
|
|
/// Outline mode also outlines hidden layers (View ▸ Outlines ▸ Include hidden layers).
|
|
pub outline_hidden: bool,
|
|
/// Outline mode also draws every layer's box (View ▸ Outlines ▸ Include object bounds).
|
|
pub outline_bounds: bool,
|
|
/// Outline the masks (they don't draw themselves).
|
|
pub mask_outlines: bool,
|
|
pub pixel_preview: bool,
|
|
/// Pixel preview resolution: rendered pixels per page pixel (1 or 2).
|
|
pub pixel_scale: u8,
|
|
pub show_slices: bool,
|
|
pub comments: bool,
|
|
/// Others' cursors in a live session (View ▸ Multiplayer cursors).
|
|
pub multiplayer_cursors: bool,
|
|
pub frame_outlines: bool,
|
|
/// Soft-proof sRGB: the canvas marks the colours of a Display P3 file that sRGB can't show.
|
|
pub proof_srgb: bool,
|
|
pub snap_geometry: bool,
|
|
pub snap_objects: bool,
|
|
pub snap_pixel: bool,
|
|
pub ui_hidden: bool,
|
|
/// Minimized interface (Shift+Cmd/Ctrl+\): the navigation bar and both sidebars are collapsed
|
|
/// and the toolbar stays; the right sidebar shows while something is selected.
|
|
pub ui_minimized: bool,
|
|
}
|
|
|
|
impl Default for ViewState {
|
|
fn default() -> Self {
|
|
Self {
|
|
cx: 0.0,
|
|
cy: 0.0,
|
|
zoom: 1.0,
|
|
vw: 1200.0,
|
|
vh: 800.0,
|
|
bottom_inset: 0.0,
|
|
pixel_grid: true,
|
|
layout_grids: true,
|
|
rulers: false,
|
|
outlines: false,
|
|
outlines_selection: false,
|
|
outline_hidden: false,
|
|
outline_bounds: false,
|
|
mask_outlines: false,
|
|
pixel_preview: false,
|
|
pixel_scale: 1,
|
|
show_slices: false,
|
|
comments: true,
|
|
multiplayer_cursors: true,
|
|
frame_outlines: false,
|
|
proof_srgb: false,
|
|
snap_geometry: true,
|
|
snap_objects: true,
|
|
snap_pixel: true,
|
|
ui_hidden: false,
|
|
ui_minimized: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ViewState {
|
|
pub const MIN_ZOOM: f64 = 0.02;
|
|
pub const MAX_ZOOM: f64 = 256.0;
|
|
/// Page coordinate at the viewport's top-left.
|
|
pub fn origin(&self) -> (f64, f64) {
|
|
let z = self.zoom.max(1e-9);
|
|
(self.cx - self.vw / 2.0 / z, self.cy - self.vh / 2.0 / z)
|
|
}
|
|
pub fn set_zoom_at(&mut self, zoom: f64, anchor: (f64, f64)) {
|
|
let z = if zoom.is_finite() { zoom.clamp(Self::MIN_ZOOM, Self::MAX_ZOOM) } else { self.zoom };
|
|
let old = self.zoom.max(1e-9);
|
|
// Keep the page point under `anchor` fixed.
|
|
self.cx = anchor.0 + (self.cx - anchor.0) * old / z;
|
|
self.cy = anchor.1 + (self.cy - anchor.1) * old / z;
|
|
self.zoom = z;
|
|
}
|
|
/// Fit a page rectangle into the viewport with margin.
|
|
pub fn fit(&mut self, r: figcraft_geom::Rect, max_zoom: f64) {
|
|
if !(r.width() > 0.0 || r.height() > 0.0) || !r.x0.is_finite() {
|
|
self.cx = r.center().x;
|
|
self.cy = r.center().y;
|
|
return;
|
|
}
|
|
let margin = 64.0;
|
|
let zx = (self.vw - margin * 2.0).max(50.0) / r.width().max(1e-6);
|
|
let zy = (self.vh - margin * 2.0).max(50.0) / r.height().max(1e-6);
|
|
self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom);
|
|
self.cx = r.center().x;
|
|
self.cy = r.center().y;
|
|
}
|
|
/// Fit a page rectangle for Zoom to selection: a 40 point margin on all four sides of the area
|
|
/// above the bottom toolbar (`vw` by `vh - bottom_inset`), centred in that area.
|
|
pub fn fit_selection(&mut self, r: figcraft_geom::Rect, max_zoom: f64) {
|
|
// A rectangle that isn't all finite numbers leaves the view as it is.
|
|
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
|
return;
|
|
}
|
|
let inset = if self.bottom_inset.is_finite() { self.bottom_inset.clamp(0.0, (self.vh - 1.0).max(0.0)) } else { 0.0 };
|
|
let area_h = (self.vh - inset).max(1.0);
|
|
// A point (nothing to fit) is centred in the area above the toolbar at the zoom it has.
|
|
if !(r.width() > 0.0 || r.height() > 0.0) {
|
|
self.cx = r.center().x;
|
|
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom.max(1e-6);
|
|
return;
|
|
}
|
|
// 40 points as measured; a canvas too small for that keeps a quarter of its size on each
|
|
// side instead, so the selection still ends above the toolbar.
|
|
let mx = (self.vw / 4.0).clamp(0.0, 40.0);
|
|
let my = (area_h / 4.0).clamp(0.0, 40.0);
|
|
let zx = (self.vw - mx * 2.0).max(1.0) / r.width().max(1e-6);
|
|
let zy = (area_h - my * 2.0).max(1.0) / r.height().max(1e-6);
|
|
self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom);
|
|
// The rect's centre lands at screen y = area_h / 2, the page centre `(vh - area_h) / 2` below it.
|
|
self.cx = r.center().x;
|
|
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom;
|
|
}
|
|
/// The zoom from which the pixel grid shows (400%).
|
|
pub const PIXEL_GRID_ZOOM: f64 = 4.0;
|
|
/// Whether the pixel grid draws: it's on and the canvas is zoomed in to 400% or more.
|
|
pub fn pixel_grid_shown(&self) -> bool {
|
|
self.pixel_grid && self.zoom >= Self::PIXEL_GRID_ZOOM - 1e-9
|
|
}
|
|
/// Pixel preview's resolution (rendered pixels per page pixel) when it's on and zoomed in past
|
|
/// it; below that the canvas is already coarser than the preview.
|
|
pub fn pixel_preview_scale(&self) -> Option<f64> {
|
|
let k = f64::from(self.pixel_scale.clamp(1, 2));
|
|
(self.pixel_preview && self.zoom > k).then_some(k)
|
|
}
|
|
/// A wheel or trackpad scroll of (`dx`, `dy`) screen points: the canvas pans with it (content
|
|
/// follows the fingers); with Shift a plain vertical wheel pans sideways.
|
|
pub fn scroll(&mut self, dx: f64, dy: f64, shift: bool) {
|
|
let (dx, dy) = if shift && dx == 0.0 { (dy, 0.0) } else { (dx, dy) };
|
|
if !(dx.is_finite() && dy.is_finite()) {
|
|
return;
|
|
}
|
|
let z = self.zoom.max(1e-6);
|
|
self.cx -= dx / z;
|
|
self.cy -= dy / z;
|
|
}
|
|
pub fn page_to_screen(&self, x: f64, y: f64) -> (f64, f64) {
|
|
let (ox, oy) = self.origin();
|
|
((x - ox) * self.zoom, (y - oy) * self.zoom)
|
|
}
|
|
pub fn screen_to_page(&self, x: f64, y: f64) -> (f64, f64) {
|
|
let (ox, oy) = self.origin();
|
|
let z = self.zoom.max(1e-9);
|
|
(ox + x / z, oy + y / z)
|
|
}
|
|
}
|