1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
//! Canvas resize & crop-to-selection.
//!
//! Both features move the **canvas window** within the fixed canvas/plane.
//! Content, layer extents, and the undo stack stay put in the plane; only the
//! window moves, so undo is exact and document-only. See the
//! `Document::canvas_origin` doc for the coordinate model. (Content-*scaling*
//! resize is a planned follow-up — it needs lossy per-node pixel-snapshot
//! undo, distinct from this document-only path.)
use super::DarklyEngine;
use crate::coord::{CanvasRect, WindowRect};
use crate::undo::CanvasResizeAction;
/// Largest canvas dimension we allow, matching wgpu's common
/// `max_texture_dimension_2d`. A content-scaling resize that would push any
/// layer extent past this is refused rather than silently clamped.
pub const MAX_CANVAS_DIM: u32 = 8192;
impl DarklyEngine {
/// Resize / move the canvas window.
///
/// The canvas window as a plane-space rect — `(canvas_origin, width,
/// height)`. Single public read of the document's canvas window (the WASM
/// `canvas_rect()` query and tests read through this).
pub fn canvas_rect(&self) -> CanvasRect {
self.doc.canvas_rect()
}
/// Moves/crops the canvas window: content, layer extents, and the undo
/// stack stay put in the plane — only the window moves, so undo is exact
/// and document-only. No-ops on a zero/over-limit target or a no-op move.
///
/// (Content-*scaling* resize — resampling every layer about the window
/// origin — is a planned follow-up; it needs lossy per-node pixel-snapshot
/// undo, distinct from this document-only path.)
pub fn resize_canvas(&mut self, new_rect: CanvasRect) {
self.auto_commit_floating();
if new_rect.width == 0
|| new_rect.height == 0
|| new_rect.width > MAX_CANVAS_DIM
|| new_rect.height > MAX_CANVAS_DIM
{
return;
}
let old = (self.doc.canvas_origin, self.doc.width, self.doc.height);
let new = (new_rect.origin, new_rect.width, new_rect.height);
if old == new {
return;
}
self.doc.canvas_origin = new_rect.origin;
self.doc.width = new_rect.width;
self.doc.height = new_rect.height;
self.push_undo(Box::new(CanvasResizeAction::new(old, new)));
self.apply_canvas_rect_to_compositor();
self.compositor.mark_dirty();
}
/// Crop the canvas to the active selection's plane bounds. No-op if there
/// is no active selection (or its bounds are still empty/unknown).
pub fn crop_to_selection(&mut self) {
if !self.has_selection() {
return;
}
// Selection pixel bounds are *window-local* (see CLAUDE.md selection
// notes); fall back to recomputing them from the CPU cache when the
// async readback hasn't landed yet.
let local = match self.selection_pixel_bounds().filter(|b| !b.is_empty()) {
Some(b) => b,
None => match self.selection_cpu_cache().and_then(|data| {
crate::mask::pixel_bounds_r8(data, self.doc.width, self.doc.height)
.map(|[x, y, w, h]| WindowRect::from_xywh(x as i32, y as i32, w, h))
}) {
Some(b) => b,
None => return,
},
};
// Lift window-local bounds into the plane: the crop window is a plane
// rect anchored at the current window origin.
self.resize_canvas(local.to_canvas(self.doc.canvas_origin));
}
/// Push the document's canvas rect onto the compositor — recreates the
/// window-sized GPU resources and re-realizes the selection mask. Shared by
/// the resize op and the undo-reconcile hook.
pub(crate) fn apply_canvas_rect_to_compositor(&mut self) {
let rect = self.doc.canvas_rect();
self.compositor.set_canvas_rect(
&self.gpu.device,
&self.gpu.queue,
rect.origin,
rect.width,
rect.height,
);
// The present view matrix bakes in the canvas dimensions (both the
// sampling-normalization and the canvas center). `set_canvas_rect`
// reallocated the composite cache at the new size but left the cached
// matrix encoding the old dims — re-derive it from the unchanged view
// params + the new dims so the next present isn't stretched/offset.
self.rebuild_view_transform();
// `set_canvas_rect` re-realized the selection mask at the moved window
// (preserving its plane pixels), but the doc-side window-local caches
// are now stale: `pixel_bounds` / `cpu_cache` were measured against the
// OLD window, and the marching ants were emitted for it. Drop them and
// kick a readback to repopulate against the new window — which also
// regenerates the ants. Async, so a transform/copy issued before it
// lands defers through the existing pending-op mechanism.
if self.has_selection() {
self.set_selection_pixel_bounds(None);
self.invalidate_selection_cpu_cache();
self.kick_selection_readback();
}
}
}