🚧 Reserve atlas-sample scaffolding for layout-drag fast path

This commit is contained in:
Elena Torro 2026-05-04 17:49:01 +02:00
parent a5c4b83611
commit c168f39d5c
3 changed files with 184 additions and 3 deletions

View File

@ -1049,7 +1049,7 @@ pub extern "C" fn set_modifiers() -> Result<()> {
let ts = performance::get_time();
state
.render_state
.drag_sprite_try_capture(&ids, &state.shapes, ts)?;
.drag_sprite_try_capture(&ids, &modifiers, &state.shapes, ts)?;
}
state.set_modifiers(modifiers);
// Throttle: skip per-pointer-move tile invalidation. The render

View File

@ -17,7 +17,7 @@
//! render rebuild the affected tiles with the shape at its committed
//! position.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use skia_safe::{self as skia, Rect};
@ -71,6 +71,80 @@ pub struct DragSprite {
pub above_image: Option<skia::Image>,
}
/// Find the unique "primary" shape in the modifier set: the one whose
/// ancestors are NOT also in the set. For a layout-container drag
/// `propagate-modifiers` expands the user's single-shape modifier into
/// transforms-per-descendant; this picks the topmost one back out so
/// the sprite path can capture the parent (whose subtree contains all
/// the propagated children).
///
/// Returns `None` when the set has no unique root (multi-shape drag of
/// unrelated shapes), in which case the fast path is disabled.
///
/// Currently dormant — pairs with the layout-drag atlas-sample
/// shortcut that hasn't landed cleanly yet.
#[allow(dead_code)]
fn find_primary_shape(ids: &[Uuid], tree: ShapesPoolRef) -> Option<Uuid> {
let id_set: HashSet<Uuid> = ids.iter().copied().collect();
let mut roots: Vec<Uuid> = Vec::new();
for &id in ids {
// Walk ancestors; if any is in the set, `id` isn't a root.
let mut cur = tree.get(&id).and_then(|s| s.parent_id);
let mut has_ancestor_in_set = false;
while let Some(pid) = cur {
if id_set.contains(&pid) {
has_ancestor_in_set = true;
break;
}
cur = tree.get(&pid).and_then(|s| s.parent_id);
}
if !has_ancestor_in_set {
roots.push(id);
if roots.len() > 1 {
return None;
}
}
}
if roots.len() == 1 {
Some(roots[0])
} else {
None
}
}
/// True iff every matrix in `modifiers` is a pure translation AND they
/// all share the same `(tx, ty)` (within `eps`). This is the safety
/// invariant for the layout-container drag path: a layout that's just
/// being dragged emits the same translation for the parent and every
/// auto-laid-out descendant. If any descendant differs, the layout has
/// reflowed (e.g. a flex item resized) and the captured sprite would
/// no longer represent the live state.
///
/// Currently dormant — pairs with `find_primary_shape`.
#[allow(dead_code)]
fn all_modifiers_uniform_translation<'a, I>(modifiers: I) -> bool
where
I: IntoIterator<Item = &'a skia::Matrix>,
{
let eps = 1e-3_f32;
let mut first: Option<(f32, f32)> = None;
for m in modifiers {
if !is_translation_only(m) {
return false;
}
let (tx, ty) = (m.translate_x(), m.translate_y());
match first {
None => first = Some((tx, ty)),
Some((fx, fy)) => {
if (tx - fx).abs() > eps || (ty - fy).abs() > eps {
return false;
}
}
}
}
true
}
/// Returns true iff the matrix is a pure translation (within a small
/// epsilon to absorb floating-point noise from modifier composition).
/// Drag-sprite's per-rAF blit assumes the captured pixels are still
@ -122,6 +196,7 @@ impl RenderState {
pub fn drag_sprite_try_capture(
&mut self,
ids: &[Uuid],
_modifiers: &HashMap<Uuid, skia::Matrix>,
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<()> {
@ -129,7 +204,11 @@ impl RenderState {
return Ok(());
}
if ids.len() != 1 {
// Multi-shape drag: out of Phase 3 scope.
// Multi-shape drag (incl. layout containers whose
// `propagate-modifiers` expanded into per-descendant
// transforms): no fast path — slow tile walker runs for the
// gesture. Layout-aware drag-sprite optimization is a known
// open problem.
self.drag_sprite_state = DragSpriteState::Disabled;
return Ok(());
}

View File

@ -403,6 +403,108 @@ impl Surfaces {
self.atlas_size.width > 0 && self.atlas_size.height > 0
}
/// True iff the atlas (a) exists, (b) has the same scale as the
/// workspace render scale (no resampling on the sprite), and (c)
/// fully contains `doc_rect`. Strict because the layout-drag
/// atlas-sample path is gated on this and any miss should bail to
/// the slow path with no fallback — partial coverage / scale
/// mismatch caused too many regressions.
///
/// Currently dormant — pairs with the layout-drag atlas-sample
/// shortcut that hasn't landed cleanly yet.
#[allow(dead_code)]
pub fn atlas_covers_doc_rect_strict(
&self,
doc_rect: skia::Rect,
workspace_scale: f32,
) -> bool {
if !self.has_atlas() {
return false;
}
if (self.atlas_scale - workspace_scale).abs() > 1e-3 {
return false;
}
let atlas_scale = self.atlas_scale.max(0.01);
let left = self.atlas_origin.x;
let top = self.atlas_origin.y;
let right = left + (self.atlas_size.width as f32) / atlas_scale;
let bottom = top + (self.atlas_size.height as f32) / atlas_scale;
let eps = 1e-3_f32;
doc_rect.left >= left - eps
&& doc_rect.top >= top - eps
&& doc_rect.right <= right + eps
&& doc_rect.bottom <= bottom + eps
}
/// Doc-space rect covered by the atlas, or `None` when the atlas
/// is empty.
///
/// Currently dormant — pairs with the layout-drag atlas-sample
/// shortcut that hasn't landed cleanly yet.
#[allow(dead_code)]
pub fn atlas_doc_extents(&self) -> Option<skia::Rect> {
if !self.has_atlas() {
return None;
}
let atlas_scale = self.atlas_scale.max(0.01);
let left = self.atlas_origin.x;
let top = self.atlas_origin.y;
let right = left + (self.atlas_size.width as f32) / atlas_scale;
let bottom = top + (self.atlas_size.height as f32) / atlas_scale;
Some(skia::Rect::from_ltrb(left, top, right, bottom))
}
/// True iff the atlas exists at a usable scale for sampling at the
/// given `workspace_scale`. We accept some downscale (atlas may be
/// reduced when document bounds exceed the texture cap) since a
/// slightly resampled drag preview is still much better than the
/// alternative — re-rendering the dragged subtree, which freezes
/// the UI on layouts with many components.
///
/// Currently dormant — pairs with the layout-drag atlas-sample
/// shortcut that hasn't landed cleanly yet.
#[allow(dead_code)]
pub fn atlas_supports_sample(&self, workspace_scale: f32) -> bool {
if !self.has_atlas() {
return false;
}
// Allow up to 2× upscale (atlas_scale ≥ 0.5 × workspace_scale).
// Below that the sprite gets visibly soft; better to fall back.
self.atlas_scale + 1e-4 >= workspace_scale * 0.5
}
/// Snapshot the atlas region covering `doc_rect` as a standalone
/// `skia::Image`. Used by the drag-sprite atlas-sample shortcut
/// (multi-id / layout drag path) to obtain a sprite without
/// re-rendering the dragged subtree. Returns `None` when the atlas
/// is empty or the region snapshot fails. Caller should pre-validate
/// coverage with `atlas_covers_doc_rect_strict`.
///
/// Currently dormant — pairs with the layout-drag atlas-sample
/// shortcut that hasn't landed cleanly yet.
#[allow(dead_code)]
pub fn snapshot_atlas_doc_rect(&mut self, doc_rect: skia::Rect) -> Option<skia::Image> {
if !self.has_atlas() {
return None;
}
let atlas_scale = self.atlas_scale.max(0.01);
let x = ((doc_rect.left - self.atlas_origin.x) * atlas_scale).floor() as i32;
let y = ((doc_rect.top - self.atlas_origin.y) * atlas_scale).floor() as i32;
let w = ((doc_rect.width() * atlas_scale).ceil() as i32).max(1);
let h = ((doc_rect.height() * atlas_scale).ceil() as i32).max(1);
// Clamp to atlas extents so the snapshot stays in-bounds even
// if the caller's `atlas_covers_doc_rect_at_scale` rounded down
// by a sub-pixel.
let aw = self.atlas_size.width;
let ah = self.atlas_size.height;
let x = x.clamp(0, aw.saturating_sub(1));
let y = y.clamp(0, ah.saturating_sub(1));
let w = w.min(aw - x).max(1);
let h = h.min(ah - y).max(1);
let irect = skia::IRect::from_xywh(x, y, w, h);
self.atlas.image_snapshot_with_bounds(irect)
}
/// Draw the persistent atlas onto the target using the current viewbox transform.
/// Intended for fast pan/zoom-out previews (avoids per-tile composition).
/// Clears Target to `background` first so atlas-uncovered regions don't