mirror of
https://github.com/penpot/penpot.git
synced 2026-05-04 15:49:34 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
164f0cba7a
@ -67,6 +67,12 @@
|
||||
(some? undo-group)
|
||||
(assoc :undo-group undo-group)))
|
||||
|
||||
(defn set-translation?
|
||||
[changes translation?]
|
||||
(cond-> changes
|
||||
translation?
|
||||
(assoc :translation? true)))
|
||||
|
||||
(defn with-page
|
||||
[changes page]
|
||||
(vary-meta changes assoc
|
||||
|
||||
1
frontend/playwright/data/workspace/versions-init-2.json
Normal file
1
frontend/playwright/data/workspace/versions-init-2.json
Normal file
@ -0,0 +1 @@
|
||||
{"~:features":{"~#set":["layout/grid","styles/v2","fdata/pointer-map","fdata/objects-map","components/v2","fdata/shape-data-type"]},"~:permissions":{"~:type":"~:membership","~:is-owner":true,"~:is-admin":true,"~:can-edit":true,"~:can-read":true,"~:is-logged":true},"~:has-media-trimmed":false,"~:comment-thread-seqn":0,"~:name":"New File 5","~:revn":2,"~:modified-at":"~m1730197748522","~:vern":2,"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:is-shared":false,"~:version":55,"~:project-id":"~u3ffbd505-2f26-800f-8004-f34da98bdad8","~:created-at":"~m1730197736824","~:data":{"~:pages":["~u406b7b01-d3e2-80e4-8005-3138ac5d449d"],"~:pages-index":{"~u406b7b01-d3e2-80e4-8005-3138ac5d449d":{"~#penpot/pointer":["~u406b7b01-d3e2-80e4-8005-3138b7cc5f0b",{"~:created-at":"~m1730197748531"}]}},"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:options":{"~:components-v2":true}}}
|
||||
@ -14,14 +14,16 @@ const setupFile = async (workspacePage) => {
|
||||
/get\-file\?/,
|
||||
"workspace/get-file-inspect-tab.json",
|
||||
);
|
||||
|
||||
await workspacePage.mockRPC(
|
||||
/update\-file\?/,
|
||||
"workspace/update-file-empty.json",
|
||||
);
|
||||
|
||||
await workspacePage.goToWorkspace({
|
||||
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
|
||||
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
|
||||
});
|
||||
await workspacePage.mockRPC(
|
||||
"update-file?id=*",
|
||||
"workspace/update-file-empty.json",
|
||||
);
|
||||
};
|
||||
|
||||
const shapeToLayerName = {
|
||||
|
||||
@ -87,6 +87,9 @@ test("Save and restore version", async ({ page }) => {
|
||||
"workspace/versions-restore-snapshot-1.json",
|
||||
);
|
||||
|
||||
await workspacePage.mockRPC(/get\-file\?/, "workspace/versions-init-2.json");
|
||||
|
||||
|
||||
await page.getByRole("button", { name: "Open version menu" }).click();
|
||||
await page.getByRole("button", { name: "Restore" }).click();
|
||||
await page.getByRole("button", { name: "Restore" }).click();
|
||||
|
||||
@ -124,7 +124,7 @@
|
||||
"Create a commit event instance"
|
||||
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
|
||||
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?
|
||||
selected-before]}]
|
||||
selected-before translation?]}]
|
||||
|
||||
(assert (cpc/check-changes redo-changes)
|
||||
"expect valid vector of changes for redo-changes")
|
||||
@ -151,7 +151,8 @@
|
||||
:tags tags
|
||||
:stack-undo? stack-undo?
|
||||
:ignore-wasm? ignore-wasm?
|
||||
:selected-before selected-before}]
|
||||
:selected-before selected-before
|
||||
:translation? translation?}]
|
||||
|
||||
(ptk/reify ::commit
|
||||
cljs.core/IDeref
|
||||
@ -189,7 +190,8 @@
|
||||
- undo-group: if some consecutive changes (or even transactions) share the same
|
||||
undo-group, they will be undone or redone in a single step
|
||||
"
|
||||
[{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id]
|
||||
[{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id
|
||||
translation?]
|
||||
:or {save-undo? true
|
||||
stack-undo? false
|
||||
undo-group (uuid/next)
|
||||
@ -222,4 +224,5 @@
|
||||
(assoc :undo-changes uchg)
|
||||
(assoc :redo-changes rchg)
|
||||
(assoc :selected-before selected)
|
||||
(assoc :translation? translation?)
|
||||
(commit)))))))))
|
||||
|
||||
@ -1382,8 +1382,14 @@
|
||||
|
||||
check-changes
|
||||
(fn [[event old-data]]
|
||||
(if (nil? old-data)
|
||||
(cond
|
||||
(nil? old-data)
|
||||
(rx/empty)
|
||||
|
||||
(:translation? event)
|
||||
(rx/empty)
|
||||
|
||||
:else
|
||||
(let [{:keys [file-id changes save-undo? undo-group]} event
|
||||
|
||||
changed-components
|
||||
|
||||
@ -659,15 +659,14 @@
|
||||
snap-pixel?
|
||||
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))]
|
||||
(set-wasm-props! objects prev-wasm-props wasm-props)
|
||||
(let [structure-entries (parse-structure-modifiers modif-tree)]
|
||||
(wasm.api/set-structure-modifiers structure-entries)
|
||||
(let [geometry-entries (parse-geometry-modifiers modif-tree)
|
||||
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
|
||||
(wasm.api/set-modifiers modifiers)
|
||||
(let [ids (into [] xf:map-key geometry-entries)
|
||||
selrect (wasm.api/get-selection-rect ids)]
|
||||
(rx/of (set-temporary-selrect selrect)
|
||||
(set-temporary-modifiers modifiers)))))))))
|
||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
|
||||
(let [geometry-entries (parse-geometry-modifiers modif-tree)
|
||||
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
|
||||
(wasm.api/set-modifiers modifiers)
|
||||
(let [ids (into [] xf:map-key geometry-entries)
|
||||
selrect (wasm.api/get-selection-rect ids)]
|
||||
(rx/of (set-temporary-selrect selrect)
|
||||
(set-temporary-modifiers modifiers))))))))
|
||||
|
||||
(defn propagate-structure-modifiers
|
||||
[modif-tree objects]
|
||||
@ -701,8 +700,7 @@
|
||||
ptk/WatchEvent
|
||||
(watch [_ state _]
|
||||
(wasm.api/clean-modifiers)
|
||||
(let [structure-entries (parse-structure-modifiers modif-tree)]
|
||||
(wasm.api/set-structure-modifiers structure-entries))
|
||||
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
|
||||
|
||||
;; Apply property changes (e.g. grow-type) to WASM shapes before
|
||||
;; propagating geometry, so propagate_modifiers sees the updated state.
|
||||
@ -722,6 +720,12 @@
|
||||
transforms
|
||||
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))
|
||||
|
||||
;; Pure-translation gesture: every shape's modifier only
|
||||
;; contains `:move` operations (no resize/rotate/scale and
|
||||
;; no structural mutation)
|
||||
translation?
|
||||
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))
|
||||
|
||||
ignore-tree
|
||||
(calculate-ignore-tree-wasm transforms objects)
|
||||
|
||||
@ -729,6 +733,7 @@
|
||||
(-> params
|
||||
(assoc :reg-objects? true)
|
||||
(assoc :ignore-tree ignore-tree)
|
||||
(assoc :translation? translation?)
|
||||
;; Attributes that can change in the transform. This
|
||||
;; way we don't have to check all the attributes
|
||||
(assoc :attrs transform-attrs))
|
||||
|
||||
@ -50,7 +50,7 @@
|
||||
([ids update-fn] (update-shapes ids update-fn nil))
|
||||
([ids update-fn
|
||||
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
|
||||
ignore-touched undo-group with-objects? changed-sub-attr]
|
||||
ignore-touched undo-group with-objects? changed-sub-attr translation?]
|
||||
:or {reg-objects? false
|
||||
save-undo? true
|
||||
stack-undo? false
|
||||
@ -90,7 +90,8 @@
|
||||
:ignore-touched ignore-touched
|
||||
:with-objects? with-objects?})
|
||||
(cond-> undo-group
|
||||
(pcb/set-undo-group undo-group)))
|
||||
(pcb/set-undo-group undo-group))
|
||||
(pcb/set-translation? translation?))
|
||||
|
||||
changes
|
||||
(add-undo-group changes state)]
|
||||
|
||||
@ -289,7 +289,9 @@
|
||||
:ref container-ref
|
||||
:data-testid "text-editor-container"
|
||||
:style {:width "var(--editor-container-width)"
|
||||
:height "var(--editor-container-height)"}}
|
||||
:height "var(--editor-container-height)"
|
||||
:min-width "1px"
|
||||
:min-height "1px"}}
|
||||
;; We hide the editor when is blurred because otherwise the
|
||||
;; selection won't let us see the underlying text. Use opacity
|
||||
;; because display or visibility won't allow to recover focus
|
||||
|
||||
@ -40,3 +40,4 @@ uuid = { version = "1.11.0", features = ["v4", "js"] }
|
||||
opt-level = 3
|
||||
lto = "fat"
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
@ -464,9 +464,6 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
|
||||
performance::begin_measure!("set_modifiers_start");
|
||||
state.render_state.options.set_fast_mode(true);
|
||||
state.render_state.options.set_interactive_transform(true);
|
||||
// Capture the last fully-rendered frame as a stable backdrop for the drag.
|
||||
// This avoids relying on atlas/cache correctness during fast_mode.
|
||||
state.render_state.surfaces.copy_target_to_backbuffer();
|
||||
performance::end_measure!("set_modifiers_start");
|
||||
});
|
||||
Ok(())
|
||||
|
||||
@ -15,8 +15,7 @@ mod ui;
|
||||
|
||||
use skia_safe::{self as skia, Matrix, RRect, Rect};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gpu_state::GpuState;
|
||||
|
||||
@ -384,6 +383,15 @@ pub(crate) struct RenderState {
|
||||
/// interactive backdrop exactly once per gesture (first rAF) so we don't
|
||||
/// repeatedly overwrite tiles that have already been updated.
|
||||
pub interactive_target_seeded: bool,
|
||||
/// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during
|
||||
/// drag, entries for the moved top-level selection are ensured here
|
||||
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
|
||||
}
|
||||
|
||||
pub struct InteractiveDragCrop {
|
||||
pub src_doc_bounds: Rect,
|
||||
pub src_selrect: Rect,
|
||||
pub image: skia::Image,
|
||||
}
|
||||
|
||||
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
|
||||
@ -403,6 +411,72 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISiz
|
||||
}
|
||||
|
||||
impl RenderState {
|
||||
/// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an
|
||||
/// interactive transform (drag/resize/rotate).
|
||||
///
|
||||
/// We only reuse cached pixels when it is safe and visually correct:
|
||||
/// - **Top-level only**: cache entries are built for direct children of the root.
|
||||
/// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew),
|
||||
/// because other transforms would require resampling and can diverge from the live render.
|
||||
/// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so
|
||||
/// we don't show stale content while something moves over/inside it.
|
||||
fn should_use_cached_top_level_during_interactive(
|
||||
&mut self,
|
||||
node_id: Uuid,
|
||||
tree: ShapesPoolRef,
|
||||
moved_ids: &[Uuid],
|
||||
moved_bounds: Option<Rect>,
|
||||
) -> bool {
|
||||
if !self.backbuffer_crop_cache.contains_key(&node_id) {
|
||||
return false;
|
||||
}
|
||||
let Some(raw) = tree.get_raw(&node_id) else {
|
||||
return false;
|
||||
};
|
||||
if raw.parent_id != Some(Uuid::nil()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this top-level shape itself is being moved, always allow using its cached pixels.
|
||||
// BUT only for pure translations. For non-translation transforms (scale/rotate/skew),
|
||||
// cached pixels won't match the live result (and may require resampling), so render live.
|
||||
if moved_ids.contains(&node_id) {
|
||||
let Some(m) = tree.get_modifier(&node_id) else {
|
||||
return false;
|
||||
};
|
||||
return crate::math::is_move_only_matrix(m);
|
||||
}
|
||||
|
||||
// Invalidate cached top-level pixels whenever the moving content overlaps
|
||||
// the cached pixel area. Use `src_doc_bounds` because it's the exact bounds
|
||||
// captured from the Backbuffer crop (more reliable than extents derived
|
||||
// from layout/layout-less container heuristics).
|
||||
if let Some(moved) = moved_bounds {
|
||||
let intersects = self
|
||||
.backbuffer_crop_cache
|
||||
.get(&node_id)
|
||||
.is_some_and(|crop| moved.intersects(crop.src_doc_bounds));
|
||||
|
||||
if intersects {
|
||||
// Simplest "automatic invalidation": once something moves over this cached
|
||||
// area, drop the cached crop so it won't be reused again until the next
|
||||
// full-frame rebuild.
|
||||
self.backbuffer_crop_cache.remove(&node_id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_recortable_for_drag_crop(&self, shape: &Shape) -> bool {
|
||||
// "Recortable" (happy path): the shape is fully represented by the pixels
|
||||
// already in Backbuffer and can be moved as a texture during drag.
|
||||
shape.blur.is_none()
|
||||
&& shape.shadows.is_empty()
|
||||
&& (shape.opacity - 1.0).abs() <= 1e-4
|
||||
&& shape.blend_mode().0 == skia::BlendMode::SrcOver
|
||||
}
|
||||
|
||||
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
|
||||
// This needs to be done once per WebGL context.
|
||||
let mut gpu_state = GpuState::try_new()?;
|
||||
@ -460,6 +534,7 @@ impl RenderState {
|
||||
cache_cleared_this_render: false,
|
||||
current_tile_had_shapes: false,
|
||||
interactive_target_seeded: false,
|
||||
backbuffer_crop_cache: HashMap::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -1541,6 +1616,97 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
|
||||
self.backbuffer_crop_cache.clear();
|
||||
|
||||
// Collect candidate shapes that are "recortable" and visible in the current viewport.
|
||||
// This is intentionally conservative; we only cache shapes that do not overlap with
|
||||
// ANY other candidate to guarantee the pixels under their bounds belong exclusively
|
||||
// to that shape in Backbuffer.
|
||||
let viewport = self.viewbox.area;
|
||||
let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect)
|
||||
|
||||
let root_ids: Vec<Uuid> = match tree.get(&Uuid::nil()) {
|
||||
Some(root) => root.children_ids(false),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
for shape_id in root_ids {
|
||||
let Some(shape) = tree.get(&shape_id) else {
|
||||
continue;
|
||||
};
|
||||
if shape.hidden {
|
||||
continue;
|
||||
}
|
||||
if !self.is_recortable_for_drag_crop(shape) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let doc_bounds = self.get_cached_extrect(shape, tree, 1.0);
|
||||
if !doc_bounds.intersects(viewport) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Also require selrect to be visible; used for drag delta placement.
|
||||
let selrect = shape.selrect();
|
||||
if !selrect.intersects(viewport) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push((shape.id, doc_bounds, selrect));
|
||||
}
|
||||
|
||||
// Filter out any candidate that overlaps with any other candidate.
|
||||
let mut non_overlapping: Vec<(Uuid, Rect, Rect)> = Vec::new();
|
||||
'outer: for (i, (id, bounds, selrect)) in candidates.iter().enumerate() {
|
||||
for (j, (_id2, bounds2, _sel2)) in candidates.iter().enumerate() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
if bounds.intersects(*bounds2) {
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
non_overlapping.push((*id, *bounds, *selrect));
|
||||
}
|
||||
|
||||
// Snapshot from Backbuffer for each accepted shape.
|
||||
let scale = self.get_scale();
|
||||
let vb_left = self.viewbox.area.left;
|
||||
let vb_top = self.viewbox.area.top;
|
||||
for (id, doc_bounds, selrect) in non_overlapping {
|
||||
let left = ((doc_bounds.left - vb_left) * scale).floor() as i32;
|
||||
let top = ((doc_bounds.top - vb_top) * scale).floor() as i32;
|
||||
let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32;
|
||||
let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32;
|
||||
if right <= left || bottom <= top {
|
||||
continue;
|
||||
}
|
||||
let src_irect = skia::IRect::new(left, top, right, bottom);
|
||||
let Some(image) = self
|
||||
.surfaces
|
||||
.snapshot_rect(SurfaceId::Backbuffer, src_irect)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let src_doc_bounds = Rect::new(
|
||||
src_irect.left as f32 / scale + vb_left,
|
||||
src_irect.top as f32 / scale + vb_top,
|
||||
src_irect.right as f32 / scale + vb_left,
|
||||
src_irect.bottom as f32 / scale + vb_top,
|
||||
);
|
||||
self.backbuffer_crop_cache.insert(
|
||||
id,
|
||||
InteractiveDragCrop {
|
||||
src_doc_bounds,
|
||||
src_selrect: selrect,
|
||||
image,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
|
||||
let _start = performance::begin_timed_log!("render_from_cache");
|
||||
performance::begin_measure!("render_from_cache");
|
||||
@ -1906,6 +2072,12 @@ impl RenderState {
|
||||
self.cancel_animation_frame();
|
||||
self.render_request_id = Some(wapi::request_animation_frame!());
|
||||
} else {
|
||||
// A full-quality frame is now complete. Refresh Backbuffer and regenerate
|
||||
// the per-shape crop cache so interactive drags can reuse pixels.
|
||||
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
|
||||
self.surfaces.copy_target_to_backbuffer();
|
||||
self.rebuild_backbuffer_crop_cache(tree);
|
||||
}
|
||||
wapi::notify_tiles_render_complete!();
|
||||
performance::end_measure!("render");
|
||||
}
|
||||
@ -2706,6 +2878,29 @@ impl RenderState {
|
||||
target_surface = SurfaceId::Export;
|
||||
}
|
||||
|
||||
// During interactive transforms we compute the union of the current bounds of all
|
||||
// modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap
|
||||
// guard to decide when cached top-level crops are unsafe to reuse (something is moving
|
||||
// over/inside them), without doing expensive ancestor walks per node.
|
||||
let moved_bounds =
|
||||
if self.options.is_interactive_transform() && !tree.modifier_ids().is_empty() {
|
||||
let mut acc: Option<Rect> = None;
|
||||
for id in tree.modifier_ids().iter() {
|
||||
let Some(s) = tree.get(id) else { continue };
|
||||
let r = self.get_cached_extrect(s, tree, 1.0);
|
||||
acc = Some(match acc {
|
||||
None => r,
|
||||
Some(mut prev) => {
|
||||
prev.join(r);
|
||||
prev
|
||||
}
|
||||
});
|
||||
}
|
||||
acc
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
while let Some(node_render_state) = self.pending_nodes.pop() {
|
||||
let node_id = node_render_state.id;
|
||||
let visited_children = node_render_state.visited_children;
|
||||
@ -2776,6 +2971,64 @@ impl RenderState {
|
||||
}
|
||||
}
|
||||
|
||||
// Interactive drag cache: if this node is cacheable during interactive transform,
|
||||
// draw it directly from Backbuffer crop on the current tile surface and skip
|
||||
// traversing/rendering the subtree.
|
||||
if self.options.is_interactive_transform() {
|
||||
let use_cached = self.should_use_cached_top_level_during_interactive(
|
||||
node_id,
|
||||
tree,
|
||||
&tree.modifier_ids(),
|
||||
moved_bounds,
|
||||
);
|
||||
if use_cached {
|
||||
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
|
||||
let crop_image = &crop.image;
|
||||
let crop_src_selrect = crop.src_selrect;
|
||||
let crop_src_doc_bounds = crop.src_doc_bounds;
|
||||
|
||||
let cur_selrect = tree.get(&node_id).map(|s| s.selrect());
|
||||
let (dx, dy) = match cur_selrect {
|
||||
Some(cur) => (
|
||||
cur.left - crop_src_selrect.left,
|
||||
cur.top - crop_src_selrect.top,
|
||||
),
|
||||
None => (0.0, 0.0),
|
||||
};
|
||||
|
||||
let dst_doc_rect = Rect::new(
|
||||
crop_src_doc_bounds.left + dx,
|
||||
crop_src_doc_bounds.top + dy,
|
||||
crop_src_doc_bounds.right + dx,
|
||||
crop_src_doc_bounds.bottom + dy,
|
||||
);
|
||||
let scale = self.get_scale();
|
||||
let translation = self
|
||||
.surfaces
|
||||
.get_render_context_translation(self.render_area, scale);
|
||||
let dst_tile_rect = skia::Rect::from_xywh(
|
||||
(dst_doc_rect.left + translation.0) * scale,
|
||||
(dst_doc_rect.top + translation.1) * scale,
|
||||
dst_doc_rect.width() * scale,
|
||||
dst_doc_rect.height() * scale,
|
||||
);
|
||||
|
||||
// let canvas = self.surfaces.canvas_and_mark_dirty(target_surface);
|
||||
let canvas = self.surfaces.canvas(target_surface);
|
||||
canvas.save();
|
||||
canvas.reset_matrix();
|
||||
canvas.draw_image_rect(
|
||||
crop_image,
|
||||
None,
|
||||
dst_tile_rect,
|
||||
&skia::Paint::default(),
|
||||
);
|
||||
canvas.restore();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id);
|
||||
|
||||
// Skip render_shape_enter/exit for flattened containers
|
||||
|
||||
@ -493,6 +493,11 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot_rect(&mut self, id: SurfaceId, irect: skia::IRect) -> Option<skia::Image> {
|
||||
let surface = self.get_mut(id);
|
||||
surface.image_snapshot_with_bounds(irect)
|
||||
}
|
||||
|
||||
/// Returns a mutable reference to the canvas and automatically marks
|
||||
/// render surfaces as dirty when accessed. This tracks which surfaces
|
||||
/// have content for optimization purposes.
|
||||
@ -698,6 +703,11 @@ impl Surfaces {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn surface_size(&self, id: SurfaceId) -> (i32, i32) {
|
||||
let s = self.get(id);
|
||||
(s.width(), s.height())
|
||||
}
|
||||
|
||||
/// Copy the current `Target` contents into the persistent `Backbuffer`.
|
||||
/// This is a GPU→GPU copy via Skia (no ReadPixels).
|
||||
pub fn copy_target_to_backbuffer(&mut self) {
|
||||
|
||||
@ -236,7 +236,11 @@ fn initialize_tracks(
|
||||
|
||||
let gap_main = if first { 0.0 } else { layout_axis.gap_main };
|
||||
|
||||
let next_main_size = current_track.main_size + child_main_size + gap_main;
|
||||
let next_main_size = if current_track.shapes.is_empty() {
|
||||
child_main_size
|
||||
} else {
|
||||
current_track.main_size + child_main_size + gap_main
|
||||
};
|
||||
let main_space = layout_axis.main_space();
|
||||
let exceeds_main_space = next_main_size > main_space + TRACK_TOLERANCE;
|
||||
|
||||
@ -329,9 +333,19 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
|
||||
let current = left_space / to_resize_tracks.len() as f32;
|
||||
for i in (0..to_resize_tracks.len()).rev() {
|
||||
let track = &mut to_resize_tracks[i];
|
||||
let delta =
|
||||
f32::min(track.max_across_size, track.across_size + current) - track.across_size;
|
||||
track.across_size += delta;
|
||||
|
||||
let delta = if math::is_close_to(track.across_size, MIN_SIZE) {
|
||||
f32::min(track.max_across_size, track.across_size + current)
|
||||
} else {
|
||||
f32::min(track.max_across_size, track.across_size + current) - track.across_size
|
||||
};
|
||||
|
||||
if math::is_close_to(track.across_size, MIN_SIZE) {
|
||||
track.across_size = delta;
|
||||
} else {
|
||||
track.across_size += delta;
|
||||
}
|
||||
|
||||
left_space -= delta;
|
||||
|
||||
if (track.across_size - track.max_across_size).abs() < MIN_SIZE {
|
||||
@ -686,7 +700,7 @@ pub fn reflow_flex_layout(
|
||||
+ (nshapes as f32 - 1.0) * layout_axis.gap_main
|
||||
})
|
||||
.reduce(f32::max)
|
||||
.unwrap_or(0.01)
|
||||
.unwrap_or(MIN_SIZE)
|
||||
+ layout_axis.padding_main_start
|
||||
+ layout_axis.padding_main_end
|
||||
} else {
|
||||
|
||||
@ -140,6 +140,18 @@ impl ShapesPoolImpl {
|
||||
Some(&mut self.shapes[idx])
|
||||
}
|
||||
|
||||
/// Returns the current transform modifier matrix for the shape, if any.
|
||||
pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> {
|
||||
let idx = *self.uuid_to_idx.get(id)?;
|
||||
self.modifiers.get(&idx)
|
||||
}
|
||||
|
||||
/// Get a shape by UUID without applying modifiers/structure/scale-content.
|
||||
pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> {
|
||||
let idx = *self.uuid_to_idx.get(id)?;
|
||||
Some(&self.shapes[idx])
|
||||
}
|
||||
|
||||
/// Get a shape by UUID. Returns the modified shape if modifiers/structure
|
||||
/// are applied, otherwise returns the base shape.
|
||||
pub fn get(&self, id: &Uuid) -> Option<&Shape> {
|
||||
|
||||
@ -209,60 +209,29 @@ impl PendingTiles {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate tiles in spiral order from center
|
||||
// Generate tiles ordered by distance to the center (closest processed first).
|
||||
fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
|
||||
let columns = rect.width();
|
||||
let rows = rect.height();
|
||||
let total = columns * rows;
|
||||
let cx = rect.center_x();
|
||||
let cy = rect.center_y();
|
||||
|
||||
if total <= 0 {
|
||||
return Vec::new();
|
||||
// TileRect is inclusive (x1..=x2, y1..=y2).
|
||||
let mut tiles = Vec::new();
|
||||
for x in rect.x1()..=rect.x2() {
|
||||
for y in rect.y1()..=rect.y2() {
|
||||
tiles.push(Tile(x, y));
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(total as usize);
|
||||
let mut cx = rect.center_x();
|
||||
let mut cy = rect.center_y();
|
||||
|
||||
let ratio = (columns as f32 / rows as f32).ceil() as i32;
|
||||
|
||||
let mut direction_current = 0;
|
||||
let mut direction_total_x = ratio;
|
||||
let mut direction_total_y = 1;
|
||||
let mut direction = 0;
|
||||
let mut current = 0;
|
||||
|
||||
result.push(Tile(cx, cy));
|
||||
while current < total {
|
||||
match direction {
|
||||
0 => cx += 1,
|
||||
1 => cy += 1,
|
||||
2 => cx -= 1,
|
||||
3 => cy -= 1,
|
||||
_ => unreachable!("Invalid direction"),
|
||||
}
|
||||
|
||||
result.push(Tile(cx, cy));
|
||||
|
||||
direction_current += 1;
|
||||
let direction_total = if direction % 2 == 0 {
|
||||
direction_total_x
|
||||
} else {
|
||||
direction_total_y
|
||||
};
|
||||
|
||||
if direction_current == direction_total {
|
||||
if direction % 2 == 0 {
|
||||
direction_total_x += 1;
|
||||
} else {
|
||||
direction_total_y += 1;
|
||||
}
|
||||
direction = (direction + 1) % 4;
|
||||
direction_current = 0;
|
||||
}
|
||||
current += 1;
|
||||
}
|
||||
result.reverse();
|
||||
result
|
||||
// We pop() from the end, so keep nearest-to-center tiles at the end.
|
||||
tiles.sort_unstable_by(|a, b| {
|
||||
let da = (a.x() - cx).abs() + (a.y() - cy).abs();
|
||||
let db = (b.x() - cx).abs() + (b.y() - cy).abs();
|
||||
da.cmp(&db)
|
||||
.then_with(|| a.x().cmp(&b.x()))
|
||||
.then_with(|| a.y().cmp(&b.y()))
|
||||
});
|
||||
tiles.reverse();
|
||||
tiles
|
||||
}
|
||||
|
||||
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user