Compare commits

...
6 changed files with 219 additions and 81 deletions

No files matched your search

@@ -257,6 +257,10 @@
;; True when we are opening a new file or switching to a new page
page-transition? (mf/deref wasm.api/page-transition?)
;; True during pan/zoom (set by `set-view-box`). Used to suppress heavy
;; overlays that would otherwise re-render + recompute geometry on every
;; viewbox change of the gesture.
view-interaction? (mf/deref wasm.api/view-interaction-active?)
context-loss-overlay? (mf/deref wasm.api/context-loss-overlay?)
transition-reveal-rulers? (mf/deref wasm.api/transition-reveal-rulers?)
@@ -289,7 +293,7 @@
show-cursor-tooltip? tooltip
show-draw-area? drawing-obj
show-gradient-handlers? (= (count selected) 1)
show-grids? (and (contains? layout :display-guides) (not page-transition?))
show-grids? (and (contains? layout :display-guides) (not page-transition?) (not view-interaction?))
show-frame-outline? (and (= transform :move) (not panning) (not page-transition?))
show-outlines? (and (nil? transform)
@@ -325,7 +329,8 @@
show-measures? (and (not transform)
(not path-editing?)
(or show-distances? mode-inspect? read-only?)
(not page-transition?))
(not page-transition?)
(not view-interaction?))
show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?))
hide-ui? (contains? layout :hide-ui)
@@ -360,6 +365,7 @@
show-padding?
(and (nil? transform)
(not view-interaction?)
single-select?
(= (:type first-shape) :frame)
(= (:layout first-shape) :flex)
@@ -367,6 +373,7 @@
show-margin?
(and (nil? transform)
(not view-interaction?)
single-select?
(= (:layout selected-frame) :flex)
(zero? (:rotation first-shape)))
+30 -39
View File
@@ -86,10 +86,6 @@
(defonce transition-image-url* (atom nil))
(defonce transition-epoch* (atom 0))
(defonce transition-tiles-handler* (atom nil))
(defonce snapshot-tiles-handler* (atom nil))
(def ^:private snapshot-capture-debounce-ms 250)
(defn initialized?
"True when the WASM render context is ready to receive design-state
@@ -173,31 +169,6 @@
(f))
#js {:once true}))
(defonce ^:private schedule-canvas-snapshot-capture!
(fns/debounce
(fn []
(when (and (initialized?)
(some? wasm/canvas))
(-> (webgl/capture-canvas-snapshot-url)
(p/catch (fn [_] nil)))))
snapshot-capture-debounce-ms))
(defn- start-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(let [handler (fn [_] (schedule-canvas-snapshot-capture!))]
(reset! snapshot-tiles-handler* handler)
(.addEventListener ^js ug/document "penpot:wasm:tiles-complete" handler)))
(defn- stop-canvas-snapshot-listener!
[]
(when-let [prev @snapshot-tiles-handler*]
(.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev))
(reset! snapshot-tiles-handler* nil)
(when-let [cancel (unchecked-get schedule-canvas-snapshot-capture! "cancel")]
(cancel)))
(defn text-editor-wasm?
[]
(or (contains? cf/flags :feature-text-editor-wasm)
@@ -224,7 +195,11 @@
(def ^:const DEBOUNCE_DELAY_MS 100)
(defonce ^:private view-interaction-active? (atom false))
;; Public so the viewport can gate heavy overlays (flex controls, frame grid,
;; measurements) during pan/zoom — they otherwise re-render + recompute geometry
;; on every viewbox change. Set by `set-view-box` (pan AND zoom), cleared on
;; interaction end.
(defonce view-interaction-active? (atom false))
;; Time budget (ms) per chunk of shape processing before yielding to browser
(def ^:private ^:const CHUNK_TIME_BUDGET_MS 8)
@@ -270,7 +245,6 @@
(def noop-fn
(constantly nil))
;;
(def shape-wrapper-factory nil)
(let [^js ch (js/MessageChannel.)]
@@ -1270,17 +1244,34 @@
(internal-render)))]
(fns/debounce do-render DEBOUNCE_DELAY_MS)))
(defonce ^:private pending-cache-render? (atom false))
(defn- flush-cache-render!
[]
(reset! pending-cache-render? false)
(when (initialized?)
(perf/begin-measure "render-from-cache")
(h/call wasm/internal-module "_render_from_cache" 0)
(render-finish)
(perf/end-measure "render-from-cache")))
(defn set-view-box
[zoom vbox]
(perf/begin-measure "set-view-box")
(view-interaction-start!)
;; `_set_view` is cheap and must run per event so the renderer always has the
;; latest viewbox.
(h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox)))
(perf/end-measure "set-view-box")
(perf/begin-measure "render-from-cache")
(h/call wasm/internal-module "_render_from_cache" 0)
(render-finish)
(perf/end-measure "render-from-cache"))
;; Coalesce the cache-preview render to one per animation frame. Wheel/trackpad
;; momentum (and any batched viewport updates) fire many `set-view-box` calls
;; per frame; calling `_render_from_cache` synchronously on each piled up to
;; ~17 renders × tens-to-hundreds of ms in a single RAF (1.5s handlers that
;; froze input). One coalesced render per frame uses the latest viewbox.
(when-not @pending-cache-render?
(reset! pending-cache-render? true)
(timers/raf (fn [_] (flush-cache-render!)))))
(defn sync-workspace-local-viewport!
"Pushes `[:workspace-local :zoom]` and `:vbox` into WASM."
@@ -1981,7 +1972,6 @@
(set! wasm/canvas canvas)
(.addEventListener canvas "webglcontextlost" on-webgl-context-lost)
(.addEventListener canvas "webglcontextrestored" on-webgl-context-restored)
(start-canvas-snapshot-listener!)
(reset! wasm/context-lost? false)
(set! wasm/context-initialized? true)))
@@ -2008,7 +1998,6 @@
(when wasm/canvas
(.removeEventListener wasm/canvas "webglcontextlost" on-webgl-context-lost)
(.removeEventListener wasm/canvas "webglcontextrestored" on-webgl-context-restored))
(stop-canvas-snapshot-listener!)
(when (wasm/module-ready?)
(free-gpu-resources)
@@ -2083,8 +2072,10 @@
(wasm-gesture/reset-after-wasm-reload!))))
;; Ensure render surfaces are blank before replay to avoid overpainting.
(p/then (fn [_] (h/call wasm/internal-module "_reset_canvas")))
(p/then (fn [_] (replay-font-resources! fonts)))
(p/then (fn [_] (replay-image-resources! image-resources)))
(p/then (fn [_]
(replay-font-resources! fonts)))
(p/then (fn [_]
(replay-image-resources! image-resources)))
(p/then
(fn []
(initialize-viewport base-objects zoom vbox
+37 -12
View File
@@ -134,11 +134,24 @@ void main() {
(.bindTexture ^js gl (.-TEXTURE_2D ^js gl) nil)
(.deleteTexture ^js gl texture))))
(defn capture-canvas-snapshot-url
"Captures the current viewport canvas as a PNG `blob:` URL and stores it in
`wasm/canvas-snapshot-url`.
;; Codec for the transition snapshot. The snapshot is only ever shown as a
;; heavily-blurred overlay (see TRANSITION_BLUR_RADIUS), so a lossy, alpha-capable
;; codec is fine and encodes much faster than lossless PNG -- on a full-viewport
;; canvas, PNG `toBlob` of millions of pixels costs ~1s+ on the main thread.
;; WebP keeps the alpha channel (unlike JPEG) and encodes in a fraction of that.
(def ^:private SNAPSHOT_MIME "image/webp")
(def ^:private SNAPSHOT_QUALITY 0.6)
Returns a promise resolving to the URL string (or nil)."
;; The snapshot is only shown heavily blurred, so it is downscaled to this max
;; side before encoding to keep the WebP encode cheap on big/high-DPI canvases.
(def ^:private SNAPSHOT_MAX_DIM 1024)
(defonce ^:private snapshot-scratch-canvas
(delay (js/document.createElement "canvas")))
(defn capture-canvas-snapshot-url
"Captures the current viewport canvas as a downscaled WebP `blob:` URL and
stores it in `wasm/canvas-snapshot-url`. Returns a promise of the URL or nil."
[]
(if-let [^js canvas wasm/canvas]
(p/create
@@ -148,14 +161,26 @@ void main() {
(when (and (string? prev) (.startsWith ^js prev "blob:"))
(js/URL.revokeObjectURL prev)))
(set! wasm/canvas-snapshot-url nil)
(.toBlob canvas
(fn [^js blob]
(if blob
(let [url (js/URL.createObjectURL blob)]
(set! wasm/canvas-snapshot-url url)
(resolve url))
(resolve nil)))
"image/png")))
(let [cw (.-width canvas)
ch (.-height canvas)
;; Cap the longest side to SNAPSHOT_MAX_DIM (never upscale).
scale (min 1.0 (/ SNAPSHOT_MAX_DIM (max 1 cw ch)))
tw (max 1 (js/Math.round (* cw scale)))
th (max 1 (js/Math.round (* ch scale)))
^js sc @snapshot-scratch-canvas
^js ctx (.getContext sc "2d")]
(set! (.-width sc) tw)
(set! (.-height sc) th)
(.drawImage ctx canvas 0 0 tw th)
(.toBlob sc
(fn [^js blob]
(if blob
(let [url (js/URL.createObjectURL blob)]
(set! wasm/canvas-snapshot-url url)
(resolve url))
(resolve nil)))
SNAPSHOT_MIME
SNAPSHOT_QUALITY))))
(p/resolved nil)))
(defn draw-thumbnail-to-canvas
+56 -21
View File
@@ -3,8 +3,8 @@ use crate::{
error::Result,
math::Rect,
shapes::{
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, Stroke, StrokeKind,
TextContent,
calculate_text_layout_data, set_paint_fill, ParagraphBuilderGroup, ParagraphLayout,
Stroke, StrokeKind, TextContent,
},
utils::{get_fallback_fonts, get_font_collection},
};
@@ -250,6 +250,8 @@ fn render_text_on_canvas(
fill_inset: Option<f32>,
layer_opacity: Option<f32>,
) {
let use_cache = shadow.is_none() && blur.is_none();
if let Some(blur_filter) = blur {
let mut blur_paint = Paint::default();
blur_paint.set_image_filter(blur_filter.clone());
@@ -260,7 +262,7 @@ fn render_text_on_canvas(
if let Some(shadow_paint) = shadow {
let layer_rec = SaveLayerRec::default().paint(shadow_paint);
canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders, layer_opacity);
draw_text(canvas, shape, paragraph_builders, layer_opacity, use_cache);
canvas.restore();
} else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) {
if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) {
@@ -268,13 +270,13 @@ fn render_text_on_canvas(
layer_paint.set_image_filter(erode);
let layer_rec = SaveLayerRec::default().paint(&layer_paint);
canvas.save_layer(&layer_rec);
draw_text(canvas, shape, paragraph_builders, layer_opacity);
draw_text(canvas, shape, paragraph_builders, layer_opacity, use_cache);
canvas.restore();
} else {
draw_text(canvas, shape, paragraph_builders, layer_opacity);
draw_text(canvas, shape, paragraph_builders, layer_opacity, use_cache);
}
} else {
draw_text(canvas, shape, paragraph_builders, layer_opacity);
draw_text(canvas, shape, paragraph_builders, layer_opacity, use_cache);
}
if blur.is_some() {
@@ -284,17 +286,8 @@ fn render_text_on_canvas(
canvas.restore();
}
/// Lays out and paints paragraph builders without any layer management.
fn paint_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
) {
let text_content = shape.get_text_content();
let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
for para in &layout_info.paragraphs {
fn paint_paragraph_layouts(canvas: &Canvas, paragraphs: &[ParagraphLayout]) {
for para in paragraphs {
para.paragraph.paint(canvas, (para.x, para.y));
for deco in &para.decorations {
draw_text_decorations(
@@ -309,11 +302,53 @@ fn paint_text(
}
}
/// `use_cache` is set only by the plain fill path; stroke/mask/shadow pass
/// `false` since they build differently painted builders.
fn paint_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
use_cache: bool,
) {
let text_content = shape.get_text_content();
if use_cache {
let selrect = shape.selrect();
let key = (
text_content.content_version(),
selrect.x().to_bits(),
selrect.y().to_bits(),
selrect.width().to_bits(),
selrect.height().to_bits(),
shape.vertical_align() as u8,
text_content.grow_type() as u8,
);
if let Some(paragraphs) = text_content.layout.cached_render_paragraphs(key) {
paint_paragraph_layouts(canvas, &paragraphs);
return;
}
let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
paint_paragraph_layouts(canvas, &layout_info.paragraphs);
text_content
.layout
.store_render_paragraphs(key, layout_info.paragraphs);
return;
}
let layout_info =
calculate_text_layout_data(shape, text_content, paragraph_builder_groups, true);
paint_paragraph_layouts(canvas, &layout_info.paragraphs);
}
fn draw_text(
canvas: &Canvas,
shape: &Shape,
paragraph_builder_groups: &mut [Vec<ParagraphBuilder>],
layer_opacity: Option<f32>,
use_cache: bool,
) {
if let Some(opacity) = layer_opacity {
let mut opacity_paint = Paint::default();
@@ -324,7 +359,7 @@ fn draw_text(
canvas.save_layer(&SaveLayerRec::default());
}
paint_text(canvas, shape, paragraph_builder_groups);
paint_text(canvas, shape, paragraph_builder_groups, use_cache);
}
/// Renders an inner stroke using mask + SrcIn + DstOver layer structure.
@@ -371,7 +406,7 @@ fn render_inner_stroke_on_canvas(
canvas.save_layer(&SaveLayerRec::default());
// Draw opaque mask (full alpha text shape)
paint_text(canvas, shape, mask_builders);
paint_text(canvas, shape, mask_builders, false);
// SrcIn layer — only keeps stroke pixels where mask has alpha
let mut src_in_paint = Paint::default();
@@ -379,7 +414,7 @@ fn render_inner_stroke_on_canvas(
canvas.save_layer(&SaveLayerRec::default().paint(&src_in_paint));
// Draw stroke
paint_text(canvas, shape, stroke_builders);
paint_text(canvas, shape, stroke_builders, false);
canvas.restore(); // SrcIn layer
canvas.restore(); // mask group layer
@@ -389,7 +424,7 @@ fn render_inner_stroke_on_canvas(
dst_over_paint.set_blend_mode(skia::BlendMode::DstOver);
canvas.save_layer(&SaveLayerRec::default().paint(&dst_over_paint));
paint_text(canvas, shape, fill_builders);
paint_text(canvas, shape, fill_builders, false);
canvas.restore(); // DstOver layer
canvas.restore(); // outer layer
+44 -6
View File
@@ -18,6 +18,16 @@ use crate::shapes::{
use crate::state::{ShapesPoolRef, State};
use crate::uuid::Uuid;
/// Memoizes auto-grow text measurements within a single `propagate_modifiers`
/// pass. The same text shape is re-visited many times because ancestor reflows
/// clear the reflow guard and re-propagate the (often unchanged) layout
/// transform; each visit would otherwise run a full Skia paragraph layout on a
/// throwaway clone. The measured `(width, height)` is deterministic for a fixed
/// content version and input selrect, and content does not change within one
/// call, so it is safe to cache keyed by shape id + the selrect dimensions
/// (as raw bits, for an exact match). The cache is per-call and discarded after.
type TextMeasureCache = HashMap<(Uuid, u32, u32), (f32, f32)>;
#[allow(clippy::too_many_arguments)]
fn propagate_children(
shape: &Shape,
@@ -183,6 +193,7 @@ fn propagate_transform(
reflown: &mut HashSet<Uuid>,
reflowed_shapes: &mut HashSet<Uuid>,
pending_reflows: &mut HashSet<Uuid>,
text_measure_cache: &mut TextMeasureCache,
) -> Result<()> {
let Some(shape) = state.shapes.get(&entry.id) else {
return Ok(());
@@ -213,9 +224,20 @@ fn propagate_transform(
GrowType::AutoHeight => {
let height_before = text_content.size.height;
let new_height = if width_changed {
let mut clone = text_content.clone();
clone.update_layout(resized_selrect);
clone.size.height
let key = (
shape.id,
resized_selrect.width().to_bits(),
resized_selrect.height().to_bits(),
);
if let Some(&(_, h)) = text_measure_cache.get(&key) {
h
} else {
let mut clone = text_content.clone();
clone.update_layout(resized_selrect);
let measured = (clone.width(), clone.size.height);
text_measure_cache.insert(key, measured);
measured.1
}
} else {
height_before
};
@@ -243,9 +265,20 @@ fn propagate_transform(
let width_before = text_content.width();
let height_before = text_content.size.height;
let (new_width, new_height) = if height_changed {
let mut clone = text_content.clone();
clone.update_layout(resized_selrect);
(clone.width(), clone.size.height)
let key = (
shape.id,
resized_selrect.width().to_bits(),
resized_selrect.height().to_bits(),
);
if let Some(&measured) = text_measure_cache.get(&key) {
measured
} else {
let mut clone = text_content.clone();
clone.update_layout(resized_selrect);
let measured = (clone.width(), clone.size.height);
text_measure_cache.insert(key, measured);
measured
}
} else {
(width_before, height_before)
};
@@ -438,6 +471,10 @@ pub fn propagate_modifiers(
// duplicate Reflow entries when many children of the same parent
// are transformed in the same pass.
let mut pending_reflows = HashSet::<Uuid>::new();
// Memoizes auto-grow text measurements so the repeated ancestor re-reflows
// (driven by `reflown` being cleared per text resize) don't re-run Skia
// paragraph layout for the same text + selrect over and over in one call.
let mut text_measure_cache = TextMeasureCache::new();
// We first propagate the transforms to the children and then after
// recalculate the layouts. The layout can create further transforms that
@@ -457,6 +494,7 @@ pub fn propagate_modifiers(
&mut reflown,
&mut reflowed_shapes,
&mut pending_reflows,
&mut text_measure_cache,
)?,
Modifier::Reflow(id, force_reflow) => {
pending_reflows.remove(&id);
+43 -1
View File
@@ -18,7 +18,7 @@ use skia_safe::{
Contains,
};
use std::cell::Cell;
use std::cell::{Cell, Ref, RefCell};
use std::collections::HashSet;
use super::FontFamily;
@@ -190,11 +190,23 @@ struct CachedExtrect {
bottom: f32,
}
/// (content_version, x, y, width, height, valign, grow_type) as raw bits.
pub type RenderTextCacheKey = (u64, u32, u32, u32, u32, u8, u8);
/// Cached laid-out paragraphs for the plain fill paint path, reused while the
/// key is unchanged so re-renders skip Skia paragraph layout.
#[derive(Debug)]
struct RenderTextCache {
key: RenderTextCacheKey,
paragraphs: Vec<ParagraphLayout>,
}
#[derive(Debug)]
pub struct TextContentLayout {
pub paragraph_builders: Vec<ParagraphBuilderGroup>,
pub paragraphs: Vec<Vec<skia::textlayout::Paragraph>>,
cached_extrect: Cell<Option<CachedExtrect>>,
render_cache: RefCell<Option<RenderTextCache>>,
}
impl Default for TextContentLayout {
@@ -209,6 +221,7 @@ impl Clone for TextContentLayout {
paragraph_builders: vec![],
paragraphs: vec![],
cached_extrect: Cell::new(None),
render_cache: RefCell::new(None),
}
}
}
@@ -225,6 +238,7 @@ impl TextContentLayout {
paragraph_builders: vec![],
paragraphs: vec![],
cached_extrect: Cell::new(None),
render_cache: RefCell::new(None),
}
}
@@ -236,11 +250,35 @@ impl TextContentLayout {
self.paragraph_builders = paragraph_builders;
self.paragraphs = paragraphs;
self.cached_extrect.set(None);
self.render_cache.replace(None);
}
pub fn needs_update(&self) -> bool {
self.paragraph_builders.is_empty() || self.paragraphs.is_empty()
}
pub fn cached_render_paragraphs(
&self,
key: RenderTextCacheKey,
) -> Option<Ref<'_, Vec<ParagraphLayout>>> {
let cache = self.render_cache.borrow();
if cache.as_ref().map(|c| c.key) == Some(key) {
Some(Ref::map(cache, |c| {
&c.as_ref().expect("checked above").paragraphs
}))
} else {
None
}
}
pub fn store_render_paragraphs(
&self,
key: RenderTextCacheKey,
paragraphs: Vec<ParagraphLayout>,
) {
self.render_cache
.replace(Some(RenderTextCache { key, paragraphs }));
}
}
#[derive(Debug, Clone)]
@@ -873,6 +911,10 @@ impl TextContent {
paragraph_height
}
pub fn content_version(&self) -> u64 {
self.content_version
}
pub fn needs_update_layout(&self) -> bool {
self.layout.needs_update()
}