Compare commits

...

2 Commits

Author SHA1 Message Date
Elena Torro
af6cf17435 wip fix pan on shadows and blurs 2026-02-16 18:03:32 +01:00
Alejandro Alonso
f2d09a6140 🐛 Preserving selection when applying styles to selected text range 2026-02-16 17:39:30 +01:00
14 changed files with 969 additions and 124 deletions

View File

@@ -8,6 +8,7 @@
"A WASM based render API"
(:require
["react-dom/server" :as rds]
[app.common.buffer :as buf]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
@@ -495,7 +496,23 @@
(mapcat get-fill-images)
(map #(process-fill-image shape-id % thumbnail?))))))
(defn- pending-fill-images
"Return pending image fetches for fill image-ids without re-sending data to WASM."
[shape-id image-ids thumbnail?]
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
image-ids))
(defn set-shape-fills
"Write fills to WASM and return pending image fetches."
[shape-id fills thumbnail?]
(if (empty? fills)
(h/call wasm/internal-module "_clear_shape_fills")
@@ -510,24 +527,73 @@
(h/call wasm/internal-module "_set_shape_fills")
;; load images for image fills if not cached
(keep (fn [id]
(let [buffer (uuid/get-u32 id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id id thumbnail?))))
(pending-fill-images shape-id (types.fills/get-image-ids fills) thumbnail?))))
(types.fills/get-image-ids fills)))))
(defn set-shape-fills-data
"Write fills data to WASM (no image fetching). Returns the coerced fills for later image checks."
[fills]
(if (empty? fills)
(do (h/call wasm/internal-module "_clear_shape_fills") nil)
(let [fills (types.fills/coerce fills)
offset (mem/alloc->offset-32 (types.fills/get-byte-size fills))
heap (mem/get-heap-u32)]
(types.fills/write-to fills heap offset)
(h/call wasm/internal-module "_set_shape_fills")
fills)))
(def ^:const STROKE-ENTRY-U8-SIZE
"Per-stroke binary entry: 4 bytes header (kind, style, cap-start, cap-end)
+ 4 bytes width (f32) + FILL-U8-SIZE bytes fill data."
(+ 8 types.fills.impl/FILL-U8-SIZE))
(defn- translate-stroke-kind
"Translate stroke alignment keyword to binary kind value.
Must match RawStrokeKind repr(u8) in strokes.rs."
[align]
(case align
:inner 1
:outer 2
0)) ;; center (default)
(defn- pending-stroke-images
"Return pending image fetches for stroke images without re-sending data to WASM."
[shape-id strokes thumbnail?]
(into []
(keep (fn [stroke]
(when-let [image (:stroke-image stroke)]
(let [image-id (get image :id)
buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0) (aget buffer 1)
(aget buffer 2) (aget buffer 3)
thumbnail?)]
(when (zero? cached-image?)
(fetch-image shape-id image-id thumbnail?))))))
strokes))
(defn set-shape-strokes
"Write strokes to WASM and return pending image fetches."
[shape-id strokes thumbnail?]
(h/call wasm/internal-module "_clear_shape_strokes")
(keep (fn [stroke]
(let [opacity (or (:stroke-opacity stroke) 1.0)
(if (empty? strokes)
(h/call wasm/internal-module "_clear_shape_strokes")
(let [num-strokes (count strokes)
buf-size (+ 4 (* num-strokes STROKE-ENTRY-U8-SIZE))
base-ptr (mem/alloc buf-size)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
;; Header: stroke count at byte 0
(buf/write-byte dview base-ptr num-strokes)
;; Write each stroke entry into the shared buffer
(loop [i 0
remaining (seq strokes)]
(when remaining
(let [stroke (first remaining)
entry-offset (+ base-ptr 4 (* i STROKE-ENTRY-U8-SIZE))
fill-offset (+ entry-offset 8)
opacity (or (:stroke-opacity stroke) 1.0)
color (:stroke-color stroke)
gradient (:stroke-color-gradient stroke)
image (:stroke-image stroke)
@@ -536,37 +602,89 @@
style (-> stroke :stroke-style sr/translate-stroke-style)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
offset (mem/alloc types.fills.impl/FILL-U8-SIZE)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
(case align
:inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end)
:outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end)
(h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end))
kind (translate-stroke-kind align)]
;; Stroke header: kind(u8), style(u8), cap-start(u8), cap-end(u8), width(f32)
(buf/write-byte dview (+ entry-offset 0) kind)
(buf/write-byte dview (+ entry-offset 1) style)
(buf/write-byte dview (+ entry-offset 2) cap-start)
(buf/write-byte dview (+ entry-offset 3) cap-end)
(buf/write-float dview (+ entry-offset 4) width)
;; Fill data (written at fill-offset inside the same buffer)
(cond
(some? gradient)
(do
(types.fills.impl/write-gradient-fill offset dview opacity gradient)
(h/call wasm/internal-module "_add_shape_stroke_fill"))
(types.fills.impl/write-gradient-fill fill-offset dview opacity gradient)
(some? image)
(let [image-id (get image :id)
buffer (uuid/get-u32 image-id)
cached-image? (h/call wasm/internal-module "_is_image_cached"
(aget buffer 0) (aget buffer 1)
(aget buffer 2) (aget buffer 3)
thumbnail?)]
(types.fills.impl/write-image-fill offset dview opacity image)
(h/call wasm/internal-module "_add_shape_stroke_fill")
(when (== cached-image? 0)
(fetch-image shape-id image-id thumbnail?)))
(types.fills.impl/write-image-fill fill-offset dview opacity image)
(some? color)
(do
(types.fills.impl/write-solid-fill offset dview opacity color)
(h/call wasm/internal-module "_add_shape_stroke_fill")))))
strokes))
(types.fills.impl/write-solid-fill fill-offset dview opacity color))
(recur (inc i) (next remaining)))))
;; Single WASM call to set all strokes at once
(h/call wasm/internal-module "_set_shape_strokes")
;; Check image cache and fetch uncached images
(pending-stroke-images shape-id strokes thumbnail?))))
(defn set-shape-strokes-data
"Write strokes data to WASM (no image fetching)."
[strokes]
(if (empty? strokes)
(h/call wasm/internal-module "_clear_shape_strokes")
(let [num-strokes (count strokes)
buf-size (+ 4 (* num-strokes STROKE-ENTRY-U8-SIZE))
base-ptr (mem/alloc buf-size)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
;; Header: stroke count at byte 0
(buf/write-byte dview base-ptr num-strokes)
;; Write each stroke entry into the shared buffer
(loop [i 0
remaining (seq strokes)]
(when remaining
(let [stroke (first remaining)
entry-offset (+ base-ptr 4 (* i STROKE-ENTRY-U8-SIZE))
fill-offset (+ entry-offset 8)
opacity (or (:stroke-opacity stroke) 1.0)
color (:stroke-color stroke)
gradient (:stroke-color-gradient stroke)
image (:stroke-image stroke)
width (:stroke-width stroke)
align (:stroke-alignment stroke)
style (-> stroke :stroke-style sr/translate-stroke-style)
cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap)
cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap)
kind (translate-stroke-kind align)]
;; Stroke header
(buf/write-byte dview (+ entry-offset 0) kind)
(buf/write-byte dview (+ entry-offset 1) style)
(buf/write-byte dview (+ entry-offset 2) cap-start)
(buf/write-byte dview (+ entry-offset 3) cap-end)
(buf/write-float dview (+ entry-offset 4) width)
;; Fill data
(cond
(some? gradient)
(types.fills.impl/write-gradient-fill fill-offset dview opacity gradient)
(some? image)
(types.fills.impl/write-image-fill fill-offset dview opacity image)
(some? color)
(types.fills.impl/write-solid-fill fill-offset dview opacity color))
(recur (inc i) (next remaining)))))
;; Single WASM call to set all strokes at once
(h/call wasm/internal-module "_set_shape_strokes"))))
(defn set-shape-svg-attrs
[attrs]
@@ -863,29 +981,39 @@
(when (ctl/grid-layout? shape)
(set-grid-layout shape)))
;; Shadow binary layout constants (must match Rust RawShadowData):
;; 24 bytes per shadow: color(u32) + blur(f32) + spread(f32) + x(f32) + y(f32) + style(u8) + hidden(u8) + padding(2)
(def ^:const SHADOW-ENTRY-SIZE 24)
(def ^:const SHADOW-HEADER-SIZE 4)
(defn set-shape-shadows
[shadows]
(h/call wasm/internal-module "_clear_shape_shadows")
(run! (fn [shadow]
(let [color (get shadow :color)
blur (get shadow :blur)
(if (or (nil? shadows) (empty? shadows))
(h/call wasm/internal-module "_clear_shape_shadows")
(let [n (count shadows)
size (+ SHADOW-HEADER-SIZE (* n SHADOW-ENTRY-SIZE))
offset (mem/alloc size)
heap (mem/get-heap-u8)
dview (js/DataView. (.-buffer heap))]
;; Header: shadow count in first byte
(.setUint8 dview offset n)
;; Write each shadow entry
(loop [i 0, shadows (seq shadows)]
(when shadows
(let [shadow (first shadows)
base (+ offset SHADOW-HEADER-SIZE (* i SHADOW-ENTRY-SIZE))
color (get shadow :color)
rgba (sr-clr/hex->u32argb (get color :color)
(get color :opacity))
hidden (get shadow :hidden)
x (get shadow :offset-x)
y (get shadow :offset-y)
spread (get shadow :spread)
style (get shadow :style)]
(h/call wasm/internal-module "_add_shape_shadow"
rgba
blur
spread
x
y
(sr/translate-shadow-style style)
hidden)))
shadows))
(get color :opacity))]
(.setUint32 dview base rgba true)
(.setFloat32 dview (+ base 4) (get shadow :blur) true)
(.setFloat32 dview (+ base 8) (get shadow :spread) true)
(.setFloat32 dview (+ base 12) (get shadow :offset-x) true)
(.setFloat32 dview (+ base 16) (get shadow :offset-y) true)
(.setUint8 dview (+ base 20) (sr/translate-shadow-style (get shadow :style)))
(.setUint8 dview (+ base 21) (if (get shadow :hidden) 1 0))
(recur (inc i) (next shadows)))))
(h/call wasm/internal-module "_set_shape_shadows"))))
(defn fonts-from-text-content [content fallback-fonts-only?]
(let [paragraph-set (first (get content :children))
@@ -962,6 +1090,12 @@
;; to prevent errors when navigating quickly
(when wasm/context-initialized?
(perf/begin-measure "render-finish")
;; set_view_end clears fast mode, enters settling mode,
;; rebuilds tiles and syncs the viewbox. The settling render
;; uses reduced blur quality so visible tiles appear quickly.
;; When all settling tiles finish, the Rust side automatically
;; transitions to full quality (clears settling, invalidates
;; caches, starts a new render loop) — no JS round-trip needed.
(h/call wasm/internal-module "_set_view_end")
(render ts)
(perf/end-measure "render-finish")))]
@@ -1043,15 +1177,21 @@
(set-shape-layout shape)
(set-layout-data shape)
(let [pending_thumbnails (into [] (concat
;; Write fills & strokes data to WASM once (identical for both resolutions)
(let [coerced-fills (set-shape-fills-data fills)
_ (set-shape-strokes-data strokes)
fill-image-ids (when coerced-fills (types.fills/get-image-ids coerced-fills))
;; Collect pending image fetches per resolution (only the image cache check differs)
pending_thumbnails (into [] (concat
(set-shape-text-content id content)
(set-shape-text-images id content true)
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
(pending-fill-images id fill-image-ids true)
(pending-stroke-images id strokes true)))
pending_full (into [] (concat
(set-shape-text-images id content false)
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(pending-fill-images id fill-image-ids false)
(pending-stroke-images id strokes false)))]
(perf/end-measure "set-object")
{:thumbnails pending_thumbnails
:full pending_full})))

View File

@@ -36,10 +36,14 @@
;; | 48 | 24 | transform | 6 × f32 LE (a,b,c,d,e,f) |
;; | 72 | 16 | selrect | 4 × f32 LE (x1,y1,x2,y2) |
;; | 88 | 16 | corners | 4 × f32 LE (r1,r2,r3,r4) |
;; | 104 | 1 | blur_hidden | u8 (0 = visible, !0 = hidden) |
;; | 105 | 1 | blur_type | u8 (0 = layer-blur) |
;; | 106 | 2 | blur_pad | - |
;; | 108 | 4 | blur_value | f32 LE (0.0 = no blur) |
;; |--------|------|--------------|-----------------------------------|
;; | Total | 104 | | |
;; | Total | 112 | | |
(def ^:const BASE-PROPS-SIZE 104)
(def ^:const BASE-PROPS-SIZE 112)
(def ^:const FLAG-CLIP-CONTENT 0x01)
(def ^:const FLAG-HIDDEN 0x02)
(def ^:const CONSTRAINT-NONE 0xFF)
@@ -188,6 +192,18 @@
(.setFloat32 dview (+ offset 96) r3 true)
(.setFloat32 dview (+ offset 100) r4 true)
;; Write blur fields (offset 104..111)
;; Layout in Rust: u8 hidden, u8 blur_type, 2 bytes padding, f32 value
(let [blur (get shape :blur)]
(when (some? blur)
(let [bt (-> blur :type sr/translate-blur-type)
hidden (if (:hidden blur) 1 0)
value (d/nilv (:value blur) 0.0)]
(.setUint8 dview (+ offset 104) hidden)
(.setUint8 dview (+ offset 105) bt)
;; padding bytes at 106,107 left as zero
(.setFloat32 dview (+ offset 108) value true))))
(h/call wasm/internal-module "_set_shape_base_props")
nil)))

View File

@@ -642,13 +642,15 @@ export class SelectionController extends EventTarget {
} else {
this.#anchorNode = anchorNode;
this.#anchorOffset = anchorOffset;
if (anchorNode === focusNode) {
this.#focusNode = this.#anchorNode;
this.#focusOffset = this.#anchorOffset;
this.#focusNode = focusNode;
this.#focusOffset = focusOffset;
// setPosition() collapses the selection to a single caret. We must only use it
// when anchorOffset === focusOffset. When both points are in the same node but
// offsets differ (e.g. selecting "hola" in "hola adios"), we need setBaseAndExtent()
// to preserve the range so we don't incorrectly collapse ranges and lose the selection.
if (anchorNode === focusNode && anchorOffset === focusOffset) {
this.#selection.setPosition(anchorNode, anchorOffset);
} else {
this.#focusNode = focusNode;
this.#focusOffset = focusOffset;
this.#selection.setBaseAndExtent(
anchorNode,
anchorOffset,

View File

@@ -263,6 +263,9 @@ pub extern "C" fn set_view_start() {
}
performance::begin_measure!("set_view_start");
state.render_state.options.set_fast_mode(true);
// Clear settling mode if a new pan/zoom starts before the
// settling→full quality transition completes.
state.render_state.options.set_settling_mode(false);
performance::end_measure!("set_view_start");
});
}
@@ -273,6 +276,10 @@ pub extern "C" fn set_view_end() {
let _end_start = performance::begin_timed_log!("set_view_end");
performance::begin_measure!("set_view_end");
state.render_state.options.set_fast_mode(false);
// Enter settling mode: the first render pass will use reduced blur
// quality so visible tiles appear quickly. The frontend should call
// `settle_view_end` after this render to schedule a full-quality pass.
state.render_state.options.set_settling_mode(true);
state.render_state.cancel_animation_frame();
// Update tile_viewbox first so that get_tiles_for_shape uses the correct interest area
@@ -306,6 +313,24 @@ pub extern "C" fn set_view_end() {
});
}
/// Called by the frontend after the settling render pass completes.
/// Turns off settling mode, invalidates all tile caches so that the
/// next render produces full-quality output.
#[no_mangle]
pub extern "C" fn settle_view_end() {
with_state_mut!(state, {
if state.render_state.options.is_settling_mode() {
performance::begin_measure!("settle_view_end");
state.render_state.options.set_settling_mode(false);
// Soft-invalidate cached tiles so the next render pass
// re-draws them at full quality while keeping settling
// textures as visual fallback to avoid popping.
state.render_state.surfaces.soft_clear_tiles();
performance::end_measure!("settle_view_end");
}
});
}
#[no_mangle]
pub extern "C" fn clear_focus_mode() {
with_state_mut!(state, {

View File

@@ -1,3 +1,7 @@
pub const DEBUG_VISIBLE: u32 = 0x01;
pub const PROFILE_REBUILD_TILES: u32 = 0x02;
pub const FAST_MODE: u32 = 0x04;
/// Settling mode: transitional state between fast mode and full quality.
/// Renders blur at reduced quality for a fast first pass after pan/zoom ends,
/// then a full-quality re-render is scheduled automatically.
pub const SETTLING_MODE: u32 = 0x08;

View File

@@ -272,6 +272,10 @@ pub(crate) struct RenderState {
pub render_request_id: Option<i32>,
// Indicates whether the rendering process has pending frames.
pub render_in_progress: bool,
/// When true, visible tiles are allowed to yield across frames (progressive
/// rendering). This is set when the Target surface has been primed with
/// cached content so that un-rendered tiles still show something.
progressive_render: bool,
// Stack of nodes pending to be rendered.
pending_nodes: Vec<NodeRenderState>,
pub current_tile: Option<tiles::Tile>,
@@ -299,6 +303,12 @@ pub(crate) struct RenderState {
pub preview_mode: bool,
}
/// Maximum cache surface dimension in pixels.
/// Prevents GPU memory exhaustion (and WebGL context loss) at high zoom
/// levels where the tile grid can grow very large.
/// 8192 × 8192 × 4 bytes ≈ 256 MB, which is safe for most GPUs.
const MAX_CACHE_DIMENSION: i32 = 8192;
pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
// First we retrieve the extended area of the viewport that we could render.
let TileRect(isx, isy, iex, iey) = tiles::get_tiles_for_viewbox_with_interest(
@@ -311,11 +321,9 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
let dy = if isy.signum() != iey.signum() { 1 } else { 0 };
let tile_size = tiles::TILE_SIZE;
(
((iex - isx).abs() + dx) * tile_size as i32,
((iey - isy).abs() + dy) * tile_size as i32,
)
.into()
let w = (((iex - isx).abs() + dx) * tile_size as i32).min(MAX_CACHE_DIMENSION);
let h = (((iey - isy).abs() + dy) * tile_size as i32).min(MAX_CACHE_DIMENSION);
(w, h).into()
}
impl RenderState {
@@ -350,6 +358,7 @@ impl RenderState {
background_color: skia::Color::TRANSPARENT,
render_request_id: None,
render_in_progress: false,
progressive_render: false,
pending_nodes: vec![],
current_tile: None,
sampling_options,
@@ -664,16 +673,20 @@ impl RenderState {
.nested_fills
.last()
.is_some_and(|fills| !fills.is_empty());
let has_inherited_blur = !self.ignore_nested_blurs
let has_inherited_blur = !fast_mode
&& !self.ignore_nested_blurs
&& self.nested_blurs.iter().flatten().any(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
});
// In fast mode blur is stripped from shapes, so treat blur as absent
// for the direct-render eligibility check.
let effective_blur_none = shape.blur.is_none() || fast_mode;
let can_render_directly = apply_to_current_surface
&& clip_bounds.is_none()
&& offset.is_none()
&& parent_shadows.is_none()
&& !shape.needs_layer()
&& shape.blur.is_none()
&& effective_blur_none
&& !has_inherited_blur
&& shape.shadows.is_empty()
&& shape.transform.is_identity()
@@ -784,7 +797,29 @@ impl RenderState {
shape.to_mut().set_blur(None);
}
if fast_mode {
shape.to_mut().set_blur(None);
// In fast mode (pan/zoom), strip blur entirely for individual shapes.
// Each blur requires an expensive offscreen filter-surface pass
// (clear → render → composite), so with many blurred shapes (e.g. 100)
// even clamped blur values cause significant thread blocking.
// The tile cache already shows the previously-blurred content, so
// removing blur during interaction is visually acceptable. The
// settling pass will restore blur at reduced quality, followed by
// a full-quality re-render.
if shape.blur.is_some() {
shape.to_mut().set_blur(None);
}
} else if self.options.is_settling_mode() {
// In settling mode (first render after pan/zoom ends) we allow
// blur but clamp it to a small value so the visible tiles
// render quickly. A full-quality re-render is scheduled after.
const SETTLING_MAX_BLUR: f32 = 4.0;
if let Some(ref blur) = shape.blur {
if !blur.hidden && blur.value > SETTLING_MAX_BLUR {
let mut clamped = *blur;
clamped.value = SETTLING_MAX_BLUR;
shape.to_mut().set_blur(Some(clamped));
}
}
}
let center = shape.center();
@@ -869,13 +904,18 @@ impl RenderState {
);
}
} else {
let mut drop_shadows = shape.drop_shadow_paints();
let max_shadow_blur = if self.options.is_settling_mode() {
Some(4.0_f32)
} else {
None
};
let mut drop_shadows = shape.drop_shadow_paints(max_shadow_blur);
if let Some(inherited_shadows) = self.get_inherited_drop_shadows() {
drop_shadows.extend(inherited_shadows);
}
let inner_shadows = shape.inner_shadow_paints();
let inner_shadows = shape.inner_shadow_paints(max_shadow_blur);
let blur_filter = shape.image_filter(1.);
let mut paragraphs_with_shadows =
text_content.paragraph_builder_group_from_text(Some(true));
@@ -1032,7 +1072,12 @@ impl RenderState {
Some(strokes_surface_id),
antialias,
);
if !fast_mode {
if !self.options.is_fast_mode() {
let max_shadow_blur = if self.options.is_settling_mode() {
Some(4.0)
} else {
None
};
for stroke in &visible_strokes {
shadows::render_stroke_inner_shadows(
self,
@@ -1040,17 +1085,24 @@ impl RenderState {
stroke,
antialias,
innershadows_surface_id,
max_shadow_blur,
);
}
}
}
if !fast_mode {
if !self.options.is_fast_mode() {
let max_shadow_blur = if self.options.is_settling_mode() {
Some(4.0)
} else {
None
};
shadows::render_fill_inner_shadows(
self,
shape,
antialias,
innershadows_surface_id,
max_shadow_blur,
);
}
// bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure);
@@ -1175,6 +1227,37 @@ impl RenderState {
performance::begin_measure!("start_render_loop");
self.reset_canvas();
// When we have valid cached content (from a previous render or
// settling pass), prime the Target surface with it. This lets
// visible tiles yield across frames without showing empty
// background — un-rendered tiles display the cached content until
// they are individually re-rendered at the current quality level.
let has_valid_cache = self.cached_viewbox.area.width() > 0.0;
self.progressive_render = has_valid_cache;
if has_valid_cache {
let cached_scale = self.get_cached_scale();
let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom;
let TileRect(start_tile_x, start_tile_y, _, _) =
tiles::get_tiles_for_viewbox_with_interest(
self.cached_viewbox,
VIEWPORT_INTEREST_AREA_THRESHOLD,
cached_scale,
);
let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr();
let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr();
let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x;
let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y;
{
let canvas = self.surfaces.canvas(SurfaceId::Target);
canvas.save();
canvas.scale((navigate_zoom, navigate_zoom));
canvas.translate((translate_x, translate_y));
}
self.surfaces.draw_cache_to_target();
self.surfaces.canvas(SurfaceId::Target).restore();
}
let surface_ids = SurfaceId::Strokes as u32
| SurfaceId::Fills as u32
| SurfaceId::InnerShadows as u32
@@ -1185,11 +1268,15 @@ impl RenderState {
let viewbox_cache_size = get_cache_size(self.viewbox, scale);
let cached_viewbox_cache_size = get_cache_size(self.cached_viewbox, scale);
// Only resize cache if the new size is larger than the cached size
// This avoids unnecessary surface recreations when the cache size decreases
if viewbox_cache_size.width > cached_viewbox_cache_size.width
|| viewbox_cache_size.height > cached_viewbox_cache_size.height
{
// Resize cache when the new size is larger, or when the current cache
// is significantly oversized (more than 2× the needed area). This
// prevents the cache from staying at its peak size after zooming out,
// which would waste GPU memory.
let needs_grow = viewbox_cache_size.width > cached_viewbox_cache_size.width
|| viewbox_cache_size.height > cached_viewbox_cache_size.height;
let needs_shrink = cached_viewbox_cache_size.width > viewbox_cache_size.width * 2
|| cached_viewbox_cache_size.height > viewbox_cache_size.height * 2;
if needs_grow || needs_shrink {
self.surfaces
.resize_cache(viewbox_cache_size, VIEWPORT_INTEREST_AREA_THRESHOLD);
}
@@ -1247,6 +1334,26 @@ impl RenderState {
if self.render_in_progress {
self.cancel_animation_frame();
self.render_request_id = Some(wapi::request_animation_frame!());
} else if self.options.is_settling_mode() {
// The settling render (reduced-quality) has finished all tiles.
// Automatically transition to full quality: clear settling mode,
// invalidate tile caches, and start a fresh render loop so that
// tiles are re-drawn at full blur quality. This keeps the rAF
// chain going without needing a round-trip through JS.
//
// Use soft_clear so settling-quality tile textures remain
// available as visual fallback. This prevents the "bumpy" pop
// where plain (unblurred) shapes flash before full-quality
// tiles replace them.
performance::begin_measure!("settling_to_full_quality");
self.options.set_settling_mode(false);
self.surfaces.soft_clear_tiles();
performance::end_measure!("settling_to_full_quality");
// Use a fresh timestamp so the full-quality pass gets its own
// time budget instead of inheriting the exhausted budget from
// the settling pass.
let fresh_ts = performance::get_time();
self.start_render_loop(base_object, tree, fresh_ts, false)?;
} else {
performance::end_measure!("render");
}
@@ -1324,7 +1431,15 @@ impl RenderState {
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
let mut sigma = frame_blur.value * scale;
// In fast mode skip frame-level blur entirely for better
// responsiveness; in settling mode clamp to a small value.
if self.options.is_fast_mode() {
sigma = 0.0;
} else if self.options.is_settling_mode() {
const SETTLING_MAX_SIGMA: f32 = 4.0;
sigma = sigma.min(SETTLING_MAX_SIGMA * scale);
}
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
@@ -1508,6 +1623,15 @@ impl RenderState {
transformed_shadow.to_mut().offset = (0.0, 0.0);
transformed_shadow.to_mut().color = skia::Color::BLACK;
// Clamp shadow blur during settling mode for faster rendering.
// The filter surface is also at 0.5x resolution (see FAST_MODE_FILTER_DOWNSCALE).
if self.options.is_settling_mode() {
const SETTLING_MAX_SHADOW_BLUR: f32 = 4.0;
if transformed_shadow.blur > SETTLING_MAX_SHADOW_BLUR {
transformed_shadow.to_mut().blur = SETTLING_MAX_SHADOW_BLUR;
}
}
let mut plain_shape = Cow::Borrowed(shape);
let combined_blur =
Self::combine_blur_values(self.combined_layer_blur(shape.blur), extra_layer_blur);
@@ -1738,7 +1862,11 @@ impl RenderState {
let mut transformed_shadow: Cow<Shadow> = Cow::Borrowed(shadow);
transformed_shadow.to_mut().color = skia::Color::BLACK;
transformed_shadow.to_mut().blur = transformed_shadow.blur * scale;
let mut blur = transformed_shadow.blur;
if self.options.is_settling_mode() {
blur = blur.min(4.0);
}
transformed_shadow.to_mut().blur = blur * scale;
transformed_shadow.to_mut().spread = transformed_shadow.spread * scale;
let mut new_shadow_paint = skia::Paint::default();
@@ -1879,11 +2007,14 @@ impl RenderState {
let element_extrect =
extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale));
element_extrect.intersects(self.render_area)
&& !transformed_element.visually_insignificant(scale, tree)
&& !transformed_element
.visually_insignificant_with_extrect(scale, element_extrect)
} else {
// For simple shapes without effects, use selrect for both intersection
// and size check — avoids expensive extrect computation entirely.
let selrect = transformed_element.selrect();
selrect.intersects(self.render_area)
&& !transformed_element.visually_insignificant(scale, tree)
&& !transformed_element.visually_insignificant_with_extrect(scale, &selrect)
};
if self.options.is_debug_visible() {
@@ -1936,6 +2067,9 @@ impl RenderState {
.get_render_context_translation(self.render_area, scale);
// Skip expensive drop shadow rendering in fast mode (during pan/zoom)
// and settling mode (first post-interaction pass). Shadow rendering
// is the most expensive operation per shape; deferring it to the
// progressive full-quality pass prevents the UI from freezing.
let skip_shadows = self.options.is_fast_mode();
// Skip shadow block when already rendered before the layer (frame_clip_layer_blur)
@@ -2097,11 +2231,18 @@ impl RenderState {
}
} else {
performance::begin_measure!("render_shape_tree::uncached");
// Only allow stopping (yielding) if the current tile is NOT visible.
// This ensures all visible tiles render synchronously before showing,
// eliminating empty squares during zoom. Interest-area tiles can still yield.
// Decide whether this tile can yield (stop mid-render to
// avoid blocking the main thread for too long).
//
// Normally, visible tiles render synchronously to prevent
// empty squares on screen. However, when progressive_render
// is active (the Target was primed with cached content),
// visible tiles can also yield because the cached content
// fills in for tiles that haven't been re-rendered yet.
// This keeps each frame short and responsive even when
// tiles contain heavy blurs.
let tile_is_visible = self.tile_viewbox.is_visible(&current_tile);
let can_stop = allow_stop && !tile_is_visible;
let can_stop = allow_stop && (!tile_is_visible || self.progressive_render);
let (is_empty, early_return) =
self.render_shape_tree_partial_uncached(tree, timestamp, can_stop)?;
@@ -2122,11 +2263,20 @@ impl RenderState {
);
}
} else {
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
let mut paint = skia::Paint::default();
paint.set_color(self.background_color);
s.canvas().draw_rect(tile_rect, &paint);
});
// Tile is empty — try to show a soft-cleared fallback tile
// (e.g. from a settling pass) instead of plain background.
let tile = self.current_tile.unwrap();
if !self.surfaces.draw_cached_tile_fallback(
tile,
tile_rect,
self.background_color,
) {
self.surfaces.apply_mut(SurfaceId::Target as u32, |s| {
let mut paint = skia::Paint::default();
paint.set_color(self.background_color);
s.canvas().draw_rect(tile_rect, &paint);
});
}
}
}
}
@@ -2167,6 +2317,7 @@ impl RenderState {
}
self.render_in_progress = false;
self.progressive_render = false;
self.surfaces.gc();
@@ -2360,8 +2511,10 @@ impl RenderState {
}
}
// Invalidate changed tiles - old content stays visible until new tiles render
self.surfaces.remove_cached_tiles(self.background_color);
if zoom_changed {
// Invalidate changed tiles - old content stays visible until new tiles render
self.surfaces.remove_cached_tiles(self.background_color);
}
performance::end_measure!("rebuild_tiles_shallow");
}
@@ -2393,11 +2546,10 @@ impl RenderState {
}
}
// Invalidate changed tiles - old content stays visible until new tiles render
// Invalidate all cached tiles - content will be re-rendered at the new state.
// remove_cached_tiles already clears the entire tile cache, so no need
// to also call remove_cached_tile per tile individually.
self.surfaces.remove_cached_tiles(self.background_color);
for tile in all_tiles {
self.remove_cached_tile(tile);
}
performance::end_measure!("rebuild_tiles");
}

View File

@@ -2,6 +2,12 @@ use skia_safe::{self as skia, ImageFilter, Rect};
use super::{RenderState, SurfaceId};
/// Extra downscale factor applied to the filter surface during fast mode
/// (pan/zoom interactions). Rendering blurred content at half resolution
/// is virtually indistinguishable while the viewport is moving and cuts
/// GPU fill-rate / shader work significantly.
const FAST_MODE_FILTER_DOWNSCALE: f32 = 0.5;
/// Composes two image filters, returning a combined filter if both are present,
/// or the individual filter if only one is present, or None if neither is present.
///
@@ -87,7 +93,7 @@ where
let bounds_height = bounds.height().ceil().max(1.0) as i32;
// Calculate scale factor if bounds exceed filter surface size
let scale = if bounds_width > filter_width || bounds_height > filter_height {
let mut scale = if bounds_width > filter_width || bounds_height > filter_height {
let scale_x = filter_width as f32 / bounds_width as f32;
let scale_y = filter_height as f32 / bounds_height as f32;
// Use the smaller scale to ensure everything fits
@@ -96,6 +102,14 @@ where
1.0
};
// In fast mode or settling mode (pan/zoom or post-interaction first pass)
// apply an additional downscale so that blur filter surfaces are rendered at
// lower resolution. The compositing step scales the result back up, trading
// a small amount of fidelity for a large reduction in GPU work.
if render_state.options.is_reduced_quality() {
scale = (scale * FAST_MODE_FILTER_DOWNSCALE).max(0.1);
}
{
let canvas = render_state.surfaces.canvas(filter_id);
canvas.clear(skia::Color::TRANSPARENT);

View File

@@ -28,6 +28,27 @@ impl RenderOptions {
}
}
/// Settling mode: transitional reduced-quality render after pan/zoom ends.
/// Blur is kept but rendered at reduced resolution/sigma so the first
/// post-interaction frame appears quickly. A follow-up full-quality render
/// is then scheduled.
pub fn is_settling_mode(&self) -> bool {
self.flags & options::SETTLING_MODE == options::SETTLING_MODE
}
pub fn set_settling_mode(&mut self, enabled: bool) {
if enabled {
self.flags |= options::SETTLING_MODE;
} else {
self.flags &= !options::SETTLING_MODE;
}
}
/// Returns true if blur quality should be reduced (either fast mode or settling mode).
pub fn is_reduced_quality(&self) -> bool {
self.is_fast_mode() || self.is_settling_mode()
}
pub fn dpr(&self) -> f32 {
self.dpr.unwrap_or(1.0)
}

View File

@@ -11,10 +11,12 @@ pub fn render_fill_inner_shadows(
shape: &Shape,
antialias: bool,
surface_id: SurfaceId,
max_blur: Option<f32>,
) {
if shape.has_fills() {
for shadow in shape.inner_shadows_visible() {
render_fill_inner_shadow(render_state, shape, shadow, antialias, surface_id);
let shadow = clamp_shadow_blur(shadow, max_blur);
render_fill_inner_shadow(render_state, shape, &shadow, antialias, surface_id);
}
}
}
@@ -36,9 +38,11 @@ pub fn render_stroke_inner_shadows(
stroke: &Stroke,
antialias: bool,
surface_id: SurfaceId,
max_blur: Option<f32>,
) {
if !shape.has_fills() {
for shadow in shape.inner_shadows_visible() {
let shadow = clamp_shadow_blur(shadow, max_blur);
let filter = shadow.get_inner_shadow_filter();
strokes::render_single(
render_state,
@@ -166,3 +170,14 @@ pub fn render_text_shadows(
canvas.restore();
}
}
fn clamp_shadow_blur(shadow: &Shadow, max_blur: Option<f32>) -> Shadow {
match max_blur {
Some(max) if shadow.blur > max => {
let mut clamped = *shadow;
clamped.blur = max;
clamped
}
_ => *shadow,
}
}

View File

@@ -115,6 +115,13 @@ impl Surfaces {
self.tiles.clear();
}
/// Soft-clear tiles: marks all tiles as needing re-render but keeps their
/// textures available for visual fallback via `draw_cached_tile_surface`.
/// This avoids the visual "pop" when transitioning between quality levels.
pub fn soft_clear_tiles(&mut self) {
self.tiles.soft_clear();
}
pub fn resize(&mut self, gpu_state: &mut GpuState, new_width: i32, new_height: i32) {
self.reset_from_target(gpu_state.create_target_surface(new_width, new_height));
}
@@ -461,6 +468,29 @@ impl Surfaces {
}
}
/// Draws a cached tile even if it was soft-removed, for visual fallback
/// during quality transitions. Returns true if a fallback tile was drawn.
pub fn draw_cached_tile_fallback(
&mut self,
tile: Tile,
rect: skia::Rect,
color: skia::Color,
) -> bool {
if let Some(image) = self.tiles.get_even_if_removed(tile) {
let mut paint = skia::Paint::default();
paint.set_color(color);
self.target.canvas().draw_rect(rect, &paint);
self.target
.canvas()
.draw_image_rect(&image, None, rect, &skia::Paint::default());
true
} else {
false
}
}
/// Draws the current tile directly to the target and cache surfaces without
/// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't
/// populate the tile texture cache (suitable for one-shot renders like tests).
@@ -534,6 +564,7 @@ impl TileTextureCache {
for tile in self.removed.iter() {
self.grid.remove(tile);
}
self.removed.clear();
}
fn free_tiles(&mut self, tile_viewbox: &TileViewbox) {
@@ -579,13 +610,29 @@ impl TileTextureCache {
self.grid.get_mut(&tile)
}
/// Returns the tile texture even if it was soft-removed.
/// Used for visual fallback during quality transitions.
pub fn get_even_if_removed(&mut self, tile: Tile) -> Option<&mut skia::Image> {
self.grid.get_mut(&tile)
}
pub fn remove(&mut self, tile: Tile) {
self.removed.insert(tile);
}
pub fn clear(&mut self) {
/// Soft-clear marks every tile as removed (so `has()` returns false and
/// tiles will be re-rendered) but keeps the textures in `grid` so that
/// `get()` can still return them when used explicitly for visual fallback.
/// Use this when transitioning between quality levels so that old tiles
/// remain visible until replaced by higher-quality versions.
pub fn soft_clear(&mut self) {
for k in self.grid.keys() {
self.removed.insert(*k);
}
}
pub fn clear(&mut self) {
self.grid.clear();
self.removed.clear();
}
}

View File

@@ -196,6 +196,10 @@ pub struct Shape {
pub extrect_cache: RefCell<Option<(math::Rect, u32)>>,
pub svg_transform: Option<Matrix>,
pub ignore_constraints: bool,
/// Cached `ImageFilter` for the unit-scale blur (`scale = 1.0`).
/// Invalidated whenever `set_blur` is called. Keyed by the blur
/// parameters so a stale entry is never returned.
cached_image_filter: RefCell<Option<(Blur, skia::ImageFilter)>>,
}
// Returns all ancestor shapes of this shape, traversing up the parent hierarchy
@@ -284,6 +288,7 @@ impl Shape {
extrect_cache: RefCell::new(None),
svg_transform: None,
ignore_constraints: false,
cached_image_filter: RefCell::new(None),
}
}
@@ -296,6 +301,8 @@ impl Shape {
if let Some(blur) = self.blur.as_mut() {
blur.scale_content(value);
// Invalidate cached filter since blur value changed
*self.cached_image_filter.borrow_mut() = None;
}
self.layout_item
@@ -604,6 +611,8 @@ impl Shape {
pub fn set_blur(&mut self, blur: Option<Blur>) {
self.invalidate_extrect();
// Invalidate the cached ImageFilter when blur parameters change
*self.cached_image_filter.borrow_mut() = None;
self.blur = blur;
}
@@ -654,6 +663,11 @@ impl Shape {
self.strokes.push(s)
}
pub fn set_strokes(&mut self, strokes: Vec<Stroke>) {
self.invalidate_extrect();
self.strokes = strokes;
}
pub fn set_stroke_fill(&mut self, f: Fill) -> Result<(), String> {
let stroke = self.strokes.last_mut().ok_or("Shape has no strokes")?;
stroke.fill = f;
@@ -740,8 +754,17 @@ impl Shape {
self.calculate_extrect(shapes_pool, scale)
}
/// Check if shape is too small to be visually relevant at the given scale.
/// Prefer `visually_insignificant_with_extrect` if you already have a computed extrect.
#[allow(dead_code)]
pub fn visually_insignificant(&self, scale: f32, shapes_pool: ShapesPoolRef) -> bool {
let extrect = self.extrect(shapes_pool, scale);
self.visually_insignificant_with_extrect(scale, &extrect)
}
/// Check if shape is too small to be visually relevant, using a precomputed extrect.
/// This avoids redundant extrect computation when the caller already has one.
pub fn visually_insignificant_with_extrect(&self, scale: f32, extrect: &math::Rect) -> bool {
extrect.width() * scale < MIN_VISIBLE_SIZE && extrect.height() * scale < MIN_VISIBLE_SIZE
}
@@ -872,6 +895,11 @@ impl Shape {
}
fn apply_shadow_bounds(&self, bounds: Bounds) -> Bounds {
// Fast path: skip when no shadows exist
if self.shadows.is_empty() {
return bounds;
}
let mut rect = bounds.to_rect();
for shadow in self.shadows_visible() {
if !shadow.hidden() {
@@ -885,6 +913,12 @@ impl Shape {
}
fn apply_blur_bounds(&self, bounds: Bounds) -> Bounds {
// Fast path: skip when no active blur (most common case)
match self.blur {
Some(b) if !b.hidden => {}
_ => return bounds,
}
let mut rect = bounds.to_rect();
let image_filter = self.image_filter(1.);
if let Some(image_filter) = image_filter {
@@ -956,7 +990,6 @@ impl Shape {
}
pub fn apply_children_blur(&self, bounds: Bounds, tree: ShapesPoolRef) -> Bounds {
let mut rect = bounds.to_rect();
let mut children_blur = 0.0;
let mut current_parent_id = self.parent_id;
@@ -983,6 +1016,12 @@ impl Shape {
}
}
// Short-circuit: no parent has a layer blur, skip Skia filter creation entirely
if children_blur == 0.0 {
return bounds;
}
let mut rect = bounds.to_rect();
let blur = skia::image_filters::blur((children_blur, children_blur), None, None, None);
if let Some(image_filter) = blur {
let blur_bounds = image_filter.compute_fast_bounds(rect);
@@ -1212,16 +1251,39 @@ impl Shape {
}
pub fn image_filter(&self, scale: f32) -> Option<skia::ImageFilter> {
self.blur
.filter(|blur| !blur.hidden)
.and_then(|blur| match blur.blur_type {
BlurType::LayerBlur => skia::image_filters::blur(
(blur.value * scale, blur.value * scale),
None,
None,
None,
),
})
let blur = self.blur.filter(|b| !b.hidden)?;
// Fast path: for the most common case (scale == 1.0) use the cached filter.
if scale == 1.0 {
let cache = self.cached_image_filter.borrow();
if let Some((cached_blur, ref filter)) = *cache {
if cached_blur == blur {
return Some(filter.clone());
}
}
drop(cache);
// Compute and cache
let filter = match blur.blur_type {
BlurType::LayerBlur => {
skia::image_filters::blur((blur.value, blur.value), None, None, None)
}
};
if let Some(ref f) = filter {
*self.cached_image_filter.borrow_mut() = Some((blur, f.clone()));
}
return filter;
}
// Non-unit scale: compute directly (rare path)
match blur.blur_type {
BlurType::LayerBlur => skia::image_filters::blur(
(blur.value * scale, blur.value * scale),
None,
None,
None,
),
}
}
#[allow(dead_code)]
@@ -1251,6 +1313,11 @@ impl Shape {
self.shadows.push(shadow);
}
pub fn set_shadows(&mut self, shadows: Vec<Shadow>) {
self.invalidate_extrect();
self.shadows = shadows;
}
pub fn clear_shadows(&mut self) {
self.invalidate_extrect();
self.shadows.clear();
@@ -1578,12 +1645,20 @@ impl Shape {
.count()
}
pub fn drop_shadow_paints(&self) -> Vec<skia_safe::Paint> {
pub fn drop_shadow_paints(&self, max_blur: Option<f32>) -> Vec<skia_safe::Paint> {
let drop_shadows: Vec<&Shadow> = self.drop_shadows_visible().collect();
drop_shadows
.into_iter()
.map(|shadow| {
let shadow = match max_blur {
Some(max) if shadow.blur > max => {
let mut c = *shadow;
c.blur = max;
c
}
_ => *shadow,
};
let mut paint = skia_safe::Paint::default();
let filter = shadow.get_drop_shadow_filter();
paint.set_image_filter(filter);
@@ -1592,12 +1667,20 @@ impl Shape {
.collect()
}
pub fn inner_shadow_paints(&self) -> Vec<skia_safe::Paint> {
pub fn inner_shadow_paints(&self, max_blur: Option<f32>) -> Vec<skia_safe::Paint> {
let inner_shadows: Vec<&Shadow> = self.inner_shadows_visible().collect();
inner_shadows
.into_iter()
.map(|shadow| {
let shadow = match max_blur {
Some(max) if shadow.blur > max => {
let mut c = *shadow;
c.blur = max;
c
}
_ => *shadow,
};
let mut paint = skia_safe::Paint::default();
let filter = shadow.get_inner_shadow_filter();
paint.set_image_filter(filter);

View File

@@ -1,9 +1,12 @@
use macros::ToJs;
use skia_safe as skia;
use crate::mem;
use crate::shapes::{Shadow, ShadowStyle};
use crate::{with_current_shape_mut, STATE};
const RAW_SHADOW_DATA_SIZE: usize = std::mem::size_of::<RawShadowData>();
#[derive(Debug, Clone, Copy, PartialEq, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
@@ -28,6 +31,93 @@ impl From<RawShadowStyle> for ShadowStyle {
}
}
/// Binary layout for a single shadow entry in the batched buffer.
///
/// | Offset | Size | Field | Type |
/// |--------|------|---------|------|
/// | 0 | 4 | color | u32 |
/// | 4 | 4 | blur | f32 |
/// | 8 | 4 | spread | f32 |
/// | 12 | 4 | x | f32 |
/// | 16 | 4 | y | f32 |
/// | 20 | 1 | style | u8 |
/// | 21 | 1 | hidden | u8 |
/// | 22 | 2 | padding | - |
/// | Total | 24 | | |
#[repr(C, align(4))]
#[derive(Debug, Clone, Copy)]
struct RawShadowData {
color: u32,
blur: f32,
spread: f32,
x: f32,
y: f32,
style: u8,
hidden: u8,
_padding: [u8; 2],
}
impl From<RawShadowData> for Shadow {
fn from(raw: RawShadowData) -> Self {
let color = skia::Color::new(raw.color);
let style = RawShadowStyle::from(raw.style).into();
Shadow::new(
color,
raw.blur,
raw.spread,
(raw.x, raw.y),
style,
raw.hidden != 0,
)
}
}
impl From<[u8; RAW_SHADOW_DATA_SIZE]> for RawShadowData {
fn from(bytes: [u8; RAW_SHADOW_DATA_SIZE]) -> Self {
unsafe { std::mem::transmute(bytes) }
}
}
impl TryFrom<&[u8]> for RawShadowData {
type Error = String;
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let data: [u8; RAW_SHADOW_DATA_SIZE] = bytes
.get(0..RAW_SHADOW_DATA_SIZE)
.and_then(|slice| slice.try_into().ok())
.ok_or("Invalid shadow data".to_string())?;
Ok(RawShadowData::from(data))
}
}
fn parse_shadows_from_bytes(buffer: &[u8], num_shadows: usize) -> Vec<Shadow> {
buffer
.chunks_exact(RAW_SHADOW_DATA_SIZE)
.take(num_shadows)
.map(|bytes| {
RawShadowData::try_from(bytes)
.expect("Invalid shadow data")
.into()
})
.collect()
}
/// Batched shadow setter: reads all shadows from the shared memory buffer.
/// Buffer layout: [u8 count][3 bytes padding][N × 24-byte RawShadowData]
#[no_mangle]
pub extern "C" fn set_shape_shadows() {
with_current_shape_mut!(state, |shape: &mut Shape| {
let bytes = mem::bytes();
let num_shadows = bytes.first().copied().unwrap_or(0) as usize;
let shadows = if num_shadows == 0 {
vec![]
} else {
parse_shadows_from_bytes(&bytes[4..], num_shadows)
};
shape.set_shadows(shadows);
mem::free_bytes();
});
}
#[no_mangle]
pub extern "C" fn add_shape_shadow(
raw_color: u32,
@@ -52,3 +142,41 @@ pub extern "C" fn clear_shape_shadows() {
shape.clear_shadows();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_raw_shadow_data_layout() {
assert_eq!(RAW_SHADOW_DATA_SIZE, 24);
assert_eq!(std::mem::align_of::<RawShadowData>(), 4);
}
#[test]
fn test_raw_shadow_data_from_bytes() {
let mut bytes = [0u8; RAW_SHADOW_DATA_SIZE];
// color = 0xFF112233
bytes[0..4].copy_from_slice(&0xFF112233_u32.to_le_bytes());
// blur = 5.0
bytes[4..8].copy_from_slice(&5.0_f32.to_le_bytes());
// spread = 2.0
bytes[8..12].copy_from_slice(&2.0_f32.to_le_bytes());
// x = 10.0
bytes[12..16].copy_from_slice(&10.0_f32.to_le_bytes());
// y = 15.0
bytes[16..20].copy_from_slice(&15.0_f32.to_le_bytes());
// style = 0 (DropShadow)
bytes[20] = 0;
// hidden = 0 (false)
bytes[21] = 0;
let raw = RawShadowData::from(bytes);
let shadow: Shadow = raw.into();
assert_eq!(shadow.blur, 5.0);
assert_eq!(shadow.spread, 2.0);
assert_eq!(shadow.offset, (10.0, 15.0));
assert!(!shadow.hidden());
}
}

View File

@@ -7,6 +7,8 @@ use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV};
use crate::{with_state_mut, STATE};
use super::RawShapeType;
use crate::shapes::Blur;
use crate::wasm::blurs::RawBlurType;
const FLAG_CLIP_CONTENT: u8 = 0b0000_0001;
const FLAG_HIDDEN: u8 = 0b0000_0010;
@@ -59,6 +61,12 @@ pub struct RawBasePropsData {
corner_r2: f32,
corner_r3: f32,
corner_r4: f32,
// Blur fields (8 bytes)
// hidden (u8), blur_type (u8), padding (2 bytes), value (f32)
blur_hidden: u8,
blur_type: u8,
blur_padding: [u8; 2],
blur_value: f32,
}
impl RawBasePropsData {
@@ -97,6 +105,17 @@ impl RawBasePropsData {
Some(RawConstraintV::from(self.constraint_v).into())
}
}
fn blur(&self) -> Option<Blur> {
// Only LayerBlur is currently supported; hidden indicated by blur_hidden != 0
if self.blur_value > 0.0 {
let hidden = self.blur_hidden != 0;
let blur_type = RawBlurType::from(self.blur_type).into();
Some(Blur::new(blur_type, hidden, self.blur_value))
} else {
None
}
}
}
impl From<[u8; RAW_BASE_PROPS_SIZE]> for RawBasePropsData {
@@ -149,6 +168,8 @@ pub extern "C" fn set_shape_base_props() {
raw.selrect_y2,
);
shape.set_corners((raw.corner_r1, raw.corner_r2, raw.corner_r3, raw.corner_r4));
// Apply blur if present in the batched data
shape.set_blur(raw.blur());
}
});
}
@@ -169,7 +190,7 @@ mod tests {
#[test]
fn test_raw_base_props_layout() {
assert_eq!(RAW_BASE_PROPS_SIZE, 104);
assert_eq!(RAW_BASE_PROPS_SIZE, 112);
assert_eq!(std::mem::align_of::<RawBasePropsData>(), 4);
}
@@ -189,6 +210,8 @@ mod tests {
assert_eq!(std::mem::offset_of!(RawBasePropsData, transform_a), 48);
assert_eq!(std::mem::offset_of!(RawBasePropsData, selrect_x1), 72);
assert_eq!(std::mem::offset_of!(RawBasePropsData, corner_r1), 88);
assert_eq!(std::mem::offset_of!(RawBasePropsData, blur_hidden), 104);
assert_eq!(std::mem::offset_of!(RawBasePropsData, blur_value), 108);
}
#[test]

View File

@@ -1,10 +1,23 @@
use macros::ToJs;
use crate::mem;
use crate::shapes::{self, StrokeCap, StrokeStyle};
use crate::shapes::{self, Fill, StrokeCap, StrokeKind, StrokeStyle};
use crate::with_current_shape_mut;
use crate::STATE;
use super::fills::RawFillData;
const RAW_FILL_DATA_SIZE: usize = std::mem::size_of::<RawFillData>();
/// Binary layout per stroke entry (matches STROKE-ENTRY-U8-SIZE on CLJS side):
/// byte 0: kind (0=center, 1=inner, 2=outer)
/// byte 1: style (0=solid, 1=dotted, 2=dashed, 3=mixed)
/// byte 2: cap_start
/// byte 3: cap_end
/// bytes 4-7: width (f32, little-endian)
/// bytes 8..: fill data (RAW_FILL_DATA_SIZE bytes)
const STROKE_ENTRY_SIZE: usize = 8 + RAW_FILL_DATA_SIZE;
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
#[repr(u8)]
#[allow(dead_code)]
@@ -69,6 +82,76 @@ impl TryFrom<RawStrokeCap> for StrokeCap {
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(u8)]
#[allow(dead_code)]
pub enum RawStrokeKind {
Center = 0,
Inner = 1,
Outer = 2,
}
impl From<u8> for RawStrokeKind {
fn from(value: u8) -> Self {
unsafe { std::mem::transmute(value) }
}
}
impl From<RawStrokeKind> for StrokeKind {
fn from(value: RawStrokeKind) -> Self {
match value {
RawStrokeKind::Center => StrokeKind::Center,
RawStrokeKind::Inner => StrokeKind::Inner,
RawStrokeKind::Outer => StrokeKind::Outer,
}
}
}
fn parse_stroke_from_bytes(bytes: &[u8]) -> shapes::Stroke {
let kind = RawStrokeKind::from(bytes[0]);
let style = RawStrokeStyle::from(bytes[1]);
let cap_start = RawStrokeCap::from(bytes[2]);
let cap_end = RawStrokeCap::from(bytes[3]);
let width = f32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let fill_bytes = &bytes[8..8 + RAW_FILL_DATA_SIZE];
let raw_fill = RawFillData::try_from(fill_bytes).expect("Invalid stroke fill data");
let fill: Fill = raw_fill.into();
shapes::Stroke {
fill,
width,
style: style.into(),
cap_start: cap_start.try_into().ok(),
cap_end: cap_end.try_into().ok(),
kind: kind.into(),
}
}
fn parse_strokes_from_bytes(buffer: &[u8], num_strokes: usize) -> Vec<shapes::Stroke> {
buffer
.chunks_exact(STROKE_ENTRY_SIZE)
.take(num_strokes)
.map(parse_stroke_from_bytes)
.collect()
}
/// Batched stroke setter: reads all strokes from a single shared-memory buffer.
///
/// Buffer layout:
/// byte 0: number of strokes (u8)
/// bytes 1-3: padding (reserved)
/// bytes 4..: N × STROKE_ENTRY_SIZE stroke entries
#[no_mangle]
pub extern "C" fn set_shape_strokes() {
with_current_shape_mut!(state, |shape: &mut Shape| {
let bytes = mem::bytes();
let num_strokes = bytes.first().copied().unwrap_or(0) as usize;
let strokes = parse_strokes_from_bytes(&bytes[4..], num_strokes);
shape.set_strokes(strokes);
mem::free_bytes();
});
}
#[no_mangle]
pub extern "C" fn add_shape_center_stroke(width: f32, style: u8, cap_start: u8, cap_end: u8) {
let stroke_style = RawStrokeStyle::from(style);
@@ -134,3 +217,95 @@ pub extern "C" fn clear_shape_strokes() {
shape.clear_strokes();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stroke_entry_size() {
// STROKE_ENTRY_SIZE = 8 bytes header + RAW_FILL_DATA_SIZE bytes fill
assert_eq!(STROKE_ENTRY_SIZE, 8 + RAW_FILL_DATA_SIZE);
}
#[test]
fn test_parse_stroke_center_solid() {
let mut entry = vec![0u8; STROKE_ENTRY_SIZE];
// kind = Center (0)
entry[0] = 0;
// style = Solid (0)
entry[1] = 0;
// cap_start = None (0)
entry[2] = 0;
// cap_end = None (0)
entry[3] = 0;
// width = 5.0 (f32 LE)
entry[4..8].copy_from_slice(&5.0f32.to_le_bytes());
// fill: solid (tag byte 0x00 at offset 8, color at offset 12)
entry[8] = 0x00;
entry[12..16].copy_from_slice(&0xff00ff00_u32.to_le_bytes());
let stroke = parse_stroke_from_bytes(&entry);
assert_eq!(stroke.kind, StrokeKind::Center);
assert_eq!(stroke.style, StrokeStyle::Solid);
assert_eq!(stroke.cap_start, None);
assert_eq!(stroke.cap_end, None);
assert_eq!(stroke.width, 5.0);
}
#[test]
fn test_parse_stroke_inner_dashed_with_caps() {
let mut entry = vec![0u8; STROKE_ENTRY_SIZE];
// kind = Inner (1)
entry[0] = 1;
// style = Dashed (2)
entry[1] = 2;
// cap_start = LineArrow (1)
entry[2] = 1;
// cap_end = Round (6)
entry[3] = 6;
// width = 3.5 (f32 LE)
entry[4..8].copy_from_slice(&3.5f32.to_le_bytes());
// fill: solid
entry[8] = 0x00;
entry[12..16].copy_from_slice(&0xffaabbcc_u32.to_le_bytes());
let stroke = parse_stroke_from_bytes(&entry);
assert_eq!(stroke.kind, StrokeKind::Inner);
assert_eq!(stroke.style, StrokeStyle::Dashed);
assert_eq!(stroke.cap_start, Some(StrokeCap::LineArrow));
assert_eq!(stroke.cap_end, Some(StrokeCap::Round));
assert_eq!(stroke.width, 3.5);
}
#[test]
fn test_parse_multiple_strokes() {
let mut buffer = vec![0u8; 2 * STROKE_ENTRY_SIZE];
// First stroke: center solid
buffer[0] = 0; // center
buffer[1] = 0; // solid
buffer[4..8].copy_from_slice(&2.0f32.to_le_bytes());
buffer[8] = 0x00; // solid fill
buffer[12..16].copy_from_slice(&0xff000000_u32.to_le_bytes());
// Second stroke: outer dotted
let off = STROKE_ENTRY_SIZE;
buffer[off] = 2; // outer
buffer[off + 1] = 1; // dotted
buffer[off + 4..off + 8].copy_from_slice(&4.0f32.to_le_bytes());
buffer[off + 8] = 0x00; // solid fill
buffer[off + 12..off + 16].copy_from_slice(&0xff0000ff_u32.to_le_bytes());
let strokes = parse_strokes_from_bytes(&buffer, 2);
assert_eq!(strokes.len(), 2);
assert_eq!(strokes[0].kind, StrokeKind::Center);
assert_eq!(strokes[0].width, 2.0);
assert_eq!(strokes[1].kind, StrokeKind::Outer);
assert_eq!(strokes[1].style, StrokeStyle::Dotted);
assert_eq!(strokes[1].width, 4.0);
}
}