darkly 0.5.0

A GPU-native paint engine on wgpu: brushes, layers, blend modes, masks, selections, and undo.
Documentation
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
//! Floating content — paste-in-place and interactive transforms.

use super::rendering::commit_undo_region;
use super::{DarklyEngine, PendingTransform};
use crate::gpu::paint_target::GpuPaintTarget;
use crate::gpu::transform::{Affine2D, ClearShape, FloatingContent, FloatingMode, IDENTITY};
use crate::layer::{Layer, LayerId};
use crate::undo::{GpuRegionAction, LayerAddAction};

impl DarklyEngine {
    /// Auto-commit any active floating content before performing other edits.
    /// Call this before operations that would conflict with floating content
    /// (layer switch, paint, undo, etc.).
    pub fn auto_commit_floating(&mut self) {
        if self.floating.is_some() {
            self.commit_floating();
        }
    }

    /// Check if there is active floating content.
    pub fn has_floating(&self) -> bool {
        self.floating.is_some()
    }

    /// Return floating content info for the frontend overlay:
    /// (source_origin_x, source_origin_y, source_width, source_height, matrix[6]).
    /// Returns None if no floating content is active.
    pub fn floating_info(&self) -> Option<(f32, f32, f32, f32, Affine2D)> {
        self.floating.as_ref().map(|fc| {
            (
                fc.source_origin.0 as f32,
                fc.source_origin.1 as f32,
                fc.source_width as f32,
                fc.source_height as f32,
                fc.matrix,
            )
        })
    }

    /// Return the layer the active floating content will commit to.
    /// Used by the frontend to distinguish "user switched away from the
    /// floating's layer" (dismiss) from "user activated the floating's
    /// own target layer" (keep — paste-as-floating sets active to its
    /// auto-created target).
    pub fn floating_target_layer(&self) -> Option<LayerId> {
        self.floating.as_ref().map(|fc| fc.target_layer)
    }

    /// Paste from the internal clipboard as floating content on the current
    /// layer/mask. Returns true if floating content was created.
    pub fn paste_in_place_floating(&mut self, layer_id: LayerId) -> bool {
        if !self.doc.is_node_editable(layer_id) {
            return false;
        }
        // Auto-commit any existing floating content first.
        self.auto_commit_floating();

        let clip = match self.clipboard.as_ref().and_then(|c| c.as_image()) {
            Some(c) => c,
            None => return false,
        };

        let source_origin = (clip.offset_x, clip.offset_y);
        let source_width = clip.width;
        let source_height = clip.height;

        // Upload flat RGBA data to GPU for preview. The target node's format
        // is read off `compositor.node_texture(layer_id).format` inside the
        // compositor — the engine never speaks the word "mask" here.
        self.compositor.set_floating_content(
            &self.gpu.device,
            &self.gpu.queue,
            &clip.data,
            source_origin,
            source_width,
            source_height,
            layer_id,
        );

        self.floating = Some(FloatingContent {
            source_origin,
            source_width,
            source_height,
            matrix: IDENTITY,
            target_layer: layer_id,
            mode: FloatingMode::Paste {
                created_layer_id: None,
            },
        });

        // Build the preview now so the paste is visible on the first frame.
        // Without this, `set_floating_content` allocates an empty preview
        // texture and the host's blend pass samples uninitialized pixels
        // until the user drags (which triggers `update_floating_matrix` →
        // `update_floating_preview`). The paste appeared invisible until
        // the first move.
        self.update_floating_preview();

        true
    }

    /// Paste raw RGBA bytes as floating content on a NEW raster layer.
    /// The caller is expected to switch to the transform tool. On commit, the
    /// pixel data is rendered into the new layer and a single LayerAddAction
    /// is pushed to undo. On cancel, the new layer is removed silently.
    ///
    /// Returns the new layer id.
    pub fn paste_image_floating(
        &mut self,
        width: u32,
        height: u32,
        rgba: &[u8],
        offset_x: i32,
        offset_y: i32,
        active_layer_id: Option<LayerId>,
    ) -> LayerId {
        // Auto-commit any existing floating content first.
        self.auto_commit_floating();

        // Size the new layer to fit the paste, so off-canvas pixels are
        // preserved when the floating commits.
        let layer_bounds = crate::coord::CanvasRect::from_xywh(offset_x, offset_y, width, height);

        // Create the target layer (no undo entry yet — pushed at commit).
        let new_id = self.doc.add_raster_layer(None);
        if let Some(Layer::Raster(r)) = self.doc.layer_mut(new_id) {
            r.common.name = "Pasted Layer".to_string();
            r.pixels.bounds = layer_bounds;
        }
        self.compositor.ensure_raster_layer(
            &self.gpu.device,
            &self.gpu.queue,
            new_id,
            layer_bounds,
        );

        // Position relative to the active node. `resolve_anchor_target` maps a
        // filter anchor (the active id while editing a mask) to its host, so
        // the pasted layer lands as the host's sibling rather than nested under
        // it — the same anchor resolution the document's `add_*` helpers use.
        let target = self.doc.resolve_anchor_target(active_layer_id);
        self.doc.move_layer(new_id, target);

        // Upload RGBA to floating source texture; the compositor renders it
        // as a preview overlay until commit.
        self.compositor.set_floating_content(
            &self.gpu.device,
            &self.gpu.queue,
            rgba,
            (offset_x, offset_y),
            width,
            height,
            new_id,
        );

        self.floating = Some(FloatingContent {
            source_origin: (offset_x, offset_y),
            source_width: width,
            source_height: height,
            matrix: IDENTITY,
            target_layer: new_id,
            mode: FloatingMode::Paste {
                created_layer_id: Some(new_id),
            },
        });

        // Build the preview so the paste is visible on the first frame.
        // See `paste_in_place_floating` for the full reasoning.
        self.update_floating_preview();

        new_id
    }

    /// Begin an interactive transform on the current layer/mask content.
    ///
    /// When a selection is active, source bounds come from the selection and
    /// the transform is set up synchronously (returns true).
    ///
    /// When there is no selection, content bounds are needed from the
    /// compositor's GPU compute system. If cached, setup is synchronous.
    /// Otherwise, an async compute is dispatched and the transform completes
    /// on the next frame via `poll_pending`.
    pub fn begin_transform(&mut self, layer_id: LayerId) -> bool {
        if !self.doc.is_node_editable(layer_id) {
            return false;
        }
        self.auto_commit_floating();

        // Active node may be either a raster layer or a mask filter — both
        // own a `PixelBuffer` and a node texture. The doc lookup just verifies
        // the id resolves to one of those two; the rest of the flow uses the
        // node id uniformly.
        if self.doc.layer(layer_id).is_none() && self.doc.find_filter(layer_id).is_none() {
            return false;
        }

        let canvas_w = self.doc.width;
        let canvas_h = self.doc.height;

        // Determine source bounds.
        if self.has_selection() {
            // Selection bounds come from cpu_cache (populated eagerly on
            // upload or lazily from async readback). If unavailable, defer.
            if self.selection_pixel_bounds().is_none() {
                let recomputed = {
                    let data = self.selection_cpu_cache();
                    data.map(|d| {
                        crate::mask::pixel_bounds_r8(d, self.doc.width, self.doc.height).map(
                            |[x, y, w, h]| {
                                crate::coord::WindowRect::from_xywh(x as i32, y as i32, w, h)
                            },
                        )
                    })
                };
                match recomputed {
                    Some(bounds) => self.set_selection_pixel_bounds(bounds),
                    None => {
                        // Cache not ready — defer until SelectionReadback completes.
                        self.pending_transform = Some(PendingTransform { node_id: layer_id });
                        return false;
                    }
                }
            }
            let bounds = match self.selection_pixel_bounds() {
                Some(b) => b,
                None => return false,
            };

            // Bounds are window-local; clamp against the window, then lift the
            // origin into plane space — `setup_transform` wants a plane-space
            // source origin, matching the no-selection branch's `layer_to_canvas`.
            let x = bounds.x0().max(0);
            let y = bounds.y0().max(0);
            let w = bounds.width.min(canvas_w.saturating_sub(x as u32));
            let h = bounds.height.min(canvas_h.saturating_sub(y as u32));

            if w == 0 || h == 0 {
                return false;
            }

            let origin = crate::coord::WindowPoint::new(x, y).to_canvas(self.doc.canvas_origin);
            self.setup_transform(layer_id, (origin.x, origin.y), w, h);
            true
        } else {
            // No selection — use compositor content bounds.
            if let Some(bounds) = self.compositor.content_bounds(layer_id) {
                // content_bounds are in layer-local coords; translate to
                // canvas-space via the layer texture so callers (and floating
                // preview/uniforms) see canvas coords.
                let [bx, by, bw, bh] = bounds;
                if bw == 0 || bh == 0 {
                    return false;
                }
                let canvas_origin = self
                    .compositor
                    .node_texture(layer_id)
                    .map(|t| t.layer_to_canvas(crate::coord::LayerPoint::new(bx, by)))
                    .unwrap_or(crate::coord::CanvasPoint::new(bx as i32, by as i32));
                self.setup_transform(layer_id, (canvas_origin.x, canvas_origin.y), bw, bh);
                true
            } else {
                // Bounds not yet computed — request async GPU compute.
                self.compositor
                    .request_content_bounds(&self.gpu.device, &self.gpu.queue, layer_id);
                self.pending_transform = Some(super::PendingTransform { node_id: layer_id });
                false
            }
        }
    }

    /// Common setup logic for interactive transforms — copies source region
    /// to the floating texture and stores a `ClearShape` so commit (and the
    /// per-frame preview) can erase the source rect on the right surface
    /// before re-rendering. The live target texture is **not** mutated:
    /// preview is a derived view, and commit applies the clear to the live
    /// target right before writing the transform.
    pub(crate) fn setup_transform(
        &mut self,
        node_id: LayerId,
        source_origin: (i32, i32),
        source_width: u32,
        source_height: u32,
    ) {
        let layer_id = node_id;
        let format = self
            .compositor
            .node_texture(node_id)
            .map(|t| t.format())
            .unwrap_or(wgpu::TextureFormat::Rgba8Unorm);
        let canvas_w = self.doc.width;
        let canvas_h = self.doc.height;

        // Layer's current canvas-space extent (paste-extent layers may run
        // off-canvas), and the canvas-space rect of the source region
        // clipped against it.
        let layer_extent = self
            .compositor
            .node_texture(node_id)
            .map(|t| t.canvas_extent())
            .unwrap_or(crate::coord::CanvasRect::from_xywh(
                0, 0, canvas_w, canvas_h,
            ));
        let canvas_source = crate::coord::CanvasRect::from_xywh(
            source_origin.0,
            source_origin.1,
            source_width,
            source_height,
        );
        let canvas_save_rect = layer_extent
            .intersect(canvas_source)
            .unwrap_or_else(|| crate::coord::CanvasRect::from_xywh(0, 0, 0, 0));

        // Bail if the target node has no GPU texture (caller already
        // validated the id, but a freshly-added paste-extent layer can be
        // pre-bounds-allocation).
        if self.compositor.node_texture(node_id).is_none() {
            return;
        }

        // Copy source region from the live target into the floating source
        // texture (premultiplied for RGBA, raw for R8).
        self.gpu.encode("transform-copy-source", |encoder| {
            self.compositor.set_floating_content_from_gpu(
                &self.gpu.device,
                &self.gpu.queue,
                encoder,
                source_origin,
                source_width,
                source_height,
                layer_id,
            );
        });

        // If selection is active, mask the source texture so only selected
        // pixels are included in the transform. Snapshot the selection into
        // a dedicated R8 so commit can replay it after the live selection
        // clear at the end of setup zeroes the marching ants.
        let has_selection = self.has_selection();
        let clear_shape = if has_selection {
            // `source_origin` is plane (it indexes the live layer + the clear
            // rect); the selection cpu-cache is window-local, so shift back by
            // `canvas_origin` for the crop read.
            let o = self.doc.canvas_origin;
            let sel_origin = (source_origin.0 - o.x, source_origin.1 - o.y);
            let cropped_sel_bg =
                self.upload_cropped_selection_r8(sel_origin, source_width, source_height);

            if let Some(sel_bg) = &cropped_sel_bg {
                if let Some(source_tex) = self.compositor.transform_source_texture() {
                    let target = GpuPaintTarget::from_canvas_texture(
                        source_tex.0,
                        source_tex.1,
                        format,
                        crate::coord::CanvasRect::from_xywh(0, 0, source_width, source_height),
                    );
                    self.gpu.encode("transform-sel-mask", |encoder| {
                        target.multiply_by_mask(
                            encoder,
                            &self.paint_pipelines,
                            &self.gpu.queue,
                            sel_bg,
                        );
                    });
                }
            }

            ClearShape::Selection {
                mask_bind_group: self.snapshot_selection_for_clear(),
            }
        } else {
            ClearShape::Rect(canvas_save_rect)
        };

        self.floating = Some(FloatingContent {
            source_origin,
            source_width,
            source_height,
            matrix: IDENTITY,
            target_layer: layer_id,
            mode: FloatingMode::Transform { clear_shape },
        });

        // Selection was used to define what gets picked up — clear it now
        // so marching ants disappear and the transform output isn't clipped
        // by the live selection during commit.
        if has_selection {
            let bounds = self.selection_pixel_bounds();
            if let Some(state) = self.compositor.selection_state_mut() {
                state.clear_region(&self.gpu.queue, bounds);
            }
            self.set_selection_pixel_bounds(None);
            self.set_selection_active(false);
            self.invalidate_selection_cpu_cache();

            self.selection_overlay.clear();
            self.push_merged_overlay();
        }

        // Render the initial preview so the host's blend reads the right
        // texture from the very first frame after setup.
        self.update_floating_preview();
    }

    /// Build (or rebuild) the floating preview texture for the current
    /// matrix and clear shape. Called after `setup_transform` and on every
    /// `update_floating_matrix`.
    fn update_floating_preview(&mut self) {
        let Some(fc) = self.floating.as_ref() else {
            return;
        };
        let clear_shape = match &fc.mode {
            FloatingMode::Transform { clear_shape } => Some(clear_shape),
            FloatingMode::Paste { .. } => None,
        };
        self.compositor.update_floating_preview(
            &self.gpu.device,
            &self.gpu.queue,
            &self.paint_pipelines,
            &fc.matrix,
            fc.source_origin,
            fc.source_width,
            fc.source_height,
            clear_shape,
        );
    }

    /// Snapshot the live GPU selection into a fresh canvas-sized R8 texture
    /// and return a paint-pipeline bind group sampling it. The returned
    /// bind group keeps the underlying texture alive for its lifetime, so
    /// it remains valid after the selection clear zeroes the live selection
    /// at the end of `setup_transform`.
    fn snapshot_selection_for_clear(&self) -> wgpu::BindGroup {
        let full = crate::coord::WindowRect::from_xywh(0, 0, self.doc.width, self.doc.height);
        self.selection_region_bind_group(full, wgpu::FilterMode::Linear)
            .expect("snapshot_selection_for_clear: selection_state allocated")
    }

    /// Update the floating content's transform matrix and rebuild the
    /// per-frame preview texture so the host's blend reads the new shape.
    pub fn update_floating_matrix(&mut self, matrix: Affine2D) {
        if let Some(fc) = self.floating.as_mut() {
            fc.matrix = matrix;
        } else {
            return;
        }
        self.update_floating_preview();
        self.compositor.mark_dirty();
    }

    /// Commit floating content: render transformed pixels into the target
    /// layer/mask texture via a GPU render pass.
    pub fn commit_floating(&mut self) {
        let fc = match self.floating.take() {
            Some(fc) => fc,
            None => return,
        };

        let layer_id = fc.target_layer;
        // The target can become locked after `begin_transform` / paste — fall
        // back to cancel-equivalent behavior (drop float state, no write to
        // the layer). The float is already taken out of `self.floating` above.
        if !self.doc.is_node_editable(layer_id) {
            self.compositor.clear_floating_content();
            self.compositor.mark_dirty();
            return;
        }
        // Format comes from the unified node-texture pool. Both raster layer
        // (RGBA8) and mask filter (R8) targets resolve through the same call.
        let format = self
            .compositor
            .node_texture(layer_id)
            .map(|t| t.format())
            .unwrap_or(wgpu::TextureFormat::Rgba8Unorm);

        // Compute tight affected rect = union(source bounds, transformed
        // bounds), in CANVAS coordinates. Intentionally NOT clamped to
        // canvas — layer textures may extend past the canvas, and content
        // dragged past the canvas edge must survive on the layer so it
        // reappears when moved back. We grow the target below to fit.
        let (min_x, min_y, max_x, max_y) = fc.transformed_bounds();
        let (sox, soy) = fc.source_origin;
        let src_max_x = sox + fc.source_width as i32;
        let src_max_y = soy + fc.source_height as i32;
        let affected_canvas = crate::coord::CanvasRect::from_xywh(
            min_x.min(sox),
            min_y.min(soy),
            (max_x.max(src_max_x) - min_x.min(sox)).max(0) as u32,
            (max_y.max(src_max_y) - min_y.min(soy)).max(0) as u32,
        );

        // Grow the target (or its host, for mask filters) so the layer
        // texture can hold any portion of the affected rect that lies
        // outside its current bounds — including pixels past the canvas
        // edge. Best-effort: if growth is refused (cap, or target is
        // neither raster nor filter with a raster host), commit falls
        // back to the pre-grow extent and the texture-side clip below
        // still keeps the commit consistent.
        let _ = self.grow_node_to_fit(layer_id, affected_canvas);

        // Path A — paste onto a layer auto-created for this paste.
        // The layer is empty by construction, so a single LayerAddAction
        // captures the whole paste as one undo step (no GpuRegionAction).
        if let FloatingMode::Paste {
            created_layer_id: Some(_),
        } = fc.mode
        {
            self.gpu.encode("paste-commit", |encoder| {
                self.compositor.commit_floating_to_texture(
                    &self.gpu.device,
                    encoder,
                    &self.gpu.queue,
                    &fc.matrix,
                    fc.source_origin,
                    fc.source_width,
                    fc.source_height,
                );
            });

            let parent = self.doc.parent_of(layer_id);
            let pos = self.doc.position_in_parent(layer_id).unwrap_or(0);
            self.push_undo(Box::new(LayerAddAction::new(layer_id, parent, pos)));

            self.compositor.mark_node_pixels_dirty(layer_id);
            self.compositor.clear_floating_content();
            return;
        }

        // Translate the canvas-space affected rect into the target's
        // layer-local frame. After grow, the post-grow extent contains
        // `affected_canvas`; the intersect is just a safety net for the
        // growth-refused path.
        let target_canvas_extent = self
            .compositor
            .node_texture(layer_id)
            .map(|t| t.canvas_extent());
        let affected_canvas_rect = match target_canvas_extent {
            Some(extent) => match affected_canvas.intersect(extent) {
                Some(c) => c,
                None => {
                    self.compositor.clear_floating_content();
                    return;
                }
            },
            None => {
                self.compositor.clear_floating_content();
                return;
            }
        };

        // The live target was never destructively touched during the
        // floating session — `setup_transform` only copied source pixels
        // out, and the per-frame preview ran into a dedicated preview
        // texture. So `save_region` here captures the genuine pre-
        // transform state for undo, no un-clear dance required. The
        // target's texture existence was already verified above when
        // `target_canvas_extent` was read.
        macro_rules! layer_frame {
            () => {
                self.compositor
                    .node_texture(layer_id)
                    .unwrap()
                    .canvas_frame()
            };
        }
        let commit_snap = self.gpu.encode_ret("transform-commit-save", |encoder| {
            self.region_scratch.save_region(
                &self.gpu.device,
                encoder,
                &layer_frame!(),
                format,
                affected_canvas_rect,
            )
        });

        // The undo entry has to be committed BEFORE the ClearShape + commit
        // pass writes to the target, because `commit_region` reads from the
        // scratch (which holds the pre-clear state). Separate encode-and-
        // submit so the clear/commit pass sees the scratch upload done.
        let frame = layer_frame!();
        let entry = commit_undo_region(
            &self.gpu,
            &self.region_scratch,
            &mut self.readbacks,
            "transform-commit-undo",
            layer_id,
            &frame,
            &commit_snap,
            affected_canvas_rect,
        );

        // Apply ClearShape to the live target, then run commit. Same
        // sequence the preview applies on every drag, but here it lands
        // on the live texture and survives the floating session.
        self.gpu.encode("transform-commit", |encoder| {
            if let FloatingMode::Transform {
                ref clear_shape, ..
            } = fc.mode
            {
                let target = self
                    .compositor
                    .node_texture(layer_id)
                    .map(|t| GpuPaintTarget::from_node(t, self.doc.canvas_rect()));
                if let Some(target) = target {
                    match clear_shape {
                        ClearShape::Rect(rect) => {
                            target.clear_rect(
                                encoder,
                                &self.paint_pipelines,
                                &self.gpu.queue,
                                *rect,
                            );
                        }
                        ClearShape::Selection { mask_bind_group } => {
                            target.erase_with_selection(
                                encoder,
                                &self.paint_pipelines,
                                &self.gpu.queue,
                                mask_bind_group,
                            );
                        }
                    }
                }
            }

            self.compositor.commit_floating_to_texture(
                &self.gpu.device,
                encoder,
                &self.gpu.queue,
                &fc.matrix,
                fc.source_origin,
                fc.source_width,
                fc.source_height,
            );
        });
        self.push_undo(Box::new(GpuRegionAction::new(entry)));

        // Clean up GPU state
        self.compositor.mark_node_pixels_dirty(layer_id);
        self.compositor.clear_floating_content();
    }

    /// Cancel floating content: drop the floating session. The live target
    /// texture was never mutated during a transform (preview lives on a
    /// separate texture), so cancel is a pure session-state reset.
    pub fn cancel_floating(&mut self) {
        let fc = match self.floating.take() {
            Some(fc) => fc,
            None => return,
        };

        if let FloatingMode::Paste {
            created_layer_id: Some(id),
        } = fc.mode
        {
            // Paste auto-created a target layer; drop it silently. No undo
            // entry to maintain — `LayerAddAction` is only pushed on commit.
            self.doc.detach_for_undo(id);
            self.compositor.dispose_layer(id);
            self.compositor.mark_dirty();
        }
        // FloatingMode::Transform and Paste-onto-existing both leave the
        // target texture exactly as it was at setup_transform — nothing to
        // restore.

        self.compositor.clear_floating_content();
        self.compositor.mark_dirty();
    }
}