Add wasm rulers (#9858)

*  Add wasm rulers

* 🔧 Fix dpr on page zoom

Co-authored-by: Alejandro Alonso <alejandroalonsofernandez@gmail.com>
Co-authored-by: Elena Torro <elenatorro@gmail.com>

* 🔧 Change page-switch behavior to refresh rulers and keep blurred snapshot

* 🐛 Restore WASM rulers after WebGL context recovery

Co-Authored-By: Elena Torro <elenatorro@gmail.com>
Co-Authored-By: Alejandro Alonso <alejandroalonsofernandez@gmail.com>

---------

Co-authored-by: Alejandro Alonso <alejandroalonsofernandez@gmail.com>
This commit is contained in:
Elena Torró 2026-06-05 07:51:35 +02:00 committed by GitHub
parent 8d3516d06d
commit 60d3c81450
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 926 additions and 46 deletions

View File

@ -86,12 +86,13 @@
(not= id current-page-id))
(-> (if @wasm.api/page-transition?
(p/resolved nil)
(wasm.api/capture-canvas-snapshot-url))
;; Blur with Skia, then capture the already-blurred frame.
(do (wasm.api/render-blurred-snapshot!)
(wasm.api/capture-canvas-snapshot-url)))
(p/finally
(fn []
(wasm.api/apply-canvas-blur)
;; NOTE: it seems we need two RAF so the blur is actually applied and visible
;; in the canvas :(
;; Two RAF so the overlay paints before navigation.
(timers/raf
(fn []
(timers/raf navigate-fn))))))

View File

@ -53,12 +53,15 @@
[app.main.ui.workspace.viewport.snap-points :as snap-points]
[app.main.ui.workspace.viewport.top-bar :refer [path-edition-bar* grid-edition-bar* view-only-bar*]]
[app.main.ui.workspace.viewport.utils :as utils]
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.viewport-ref :as vp-ref :refer [create-viewport-ref]]
[app.main.ui.workspace.viewport.widgets :as widgets]
[app.render-wasm.api :as wasm.api]
[app.render-wasm.rulers-state :as rs]
[app.util.debug :as dbg]
[app.util.text-editor :as ted]
[app.util.theme :as theme]
[app.util.timers :as ts]
[app.util.webapi :as webapi]
[beicon.v2.core :as rx]
[promesa.core :as p]
[rumext.v2 :as mf]))
@ -127,7 +130,6 @@
vbox
vport
zoom
zoom-inverse
edition]}
(mf/deref refs/workspace-local)
@ -256,6 +258,7 @@
;; True when we are opening a new file or switching to a new page
page-transition? (mf/deref wasm.api/page-transition?)
context-loss-overlay? (mf/deref wasm.api/context-loss-overlay?)
transition-reveal-rulers? (mf/deref wasm.api/transition-reveal-rulers?)
on-click (actions/on-click hover selected edition path-drawing? drawing-tool space? selrect z?)
on-context-menu (actions/on-context-menu hover hover-ids read-only?)
@ -325,8 +328,16 @@
(not page-transition?))
show-artboard-names? (and (contains? layout :display-artboard-names) (not page-transition?))
hide-ui? (contains? layout :hide-ui)
show-rulers? (and (contains? layout :rulers) (not hide-ui?))
rulers-ui (rs/display-state
{:layout layout
:selected-shapes selected-shapes
:base-objects base-objects})
show-rulers? (:show-rulers? rulers-ui)
frame-visible? (:frame-visible? rulers-ui)
offset-x (:offset-x rulers-ui)
offset-y (:offset-y rulers-ui)
ruler-selection (:ruler-selection rulers-ui)
disabled-guides? (or drawing-tool transform path-drawing? path-editing?
(contains? layout :lock-guides))
@ -360,17 +371,6 @@
(= (:layout selected-frame) :flex)
(zero? (:rotation first-shape)))
selecting-first-level-frame?
(and single-select? (cfh/root-frame? first-shape))
offset-x (if selecting-first-level-frame?
(:x first-shape)
(:x selected-frame))
offset-y (if selecting-first-level-frame?
(:y first-shape)
(:y selected-frame))
rule-area-size (/ rulers/ruler-area-size zoom)
preview-blend (-> refs/workspace-preview-blend
(mf/deref))
@ -404,8 +404,7 @@
false))]
(cond
init?
(do
(reset! canvas-init? true))
(reset! canvas-init? true)
(pos? retries)
(vreset! timeout-id-ref
@ -436,9 +435,19 @@
(st/emit! (dwt/resize-text-editor edition dimension))
(wasm.api/request-render "content"))))))
(mf/with-effect [@canvas-init?]
(when @canvas-init?
(let [canvas (mf/ref-val canvas-ref)
cancel (webapi/on-dpr-change
(fn [new-dpr]
(wasm.api/resize-canvas! canvas new-dpr)
(wasm.api/render-ui-only)
(ts/raf (fn [_] (wasm.api/render-sync)))))]
cancel)))
(mf/with-effect [vport]
(when (and @canvas-init? @initialized?)
(wasm.api/resize-viewbox (:width vport) (:height vport))
(wasm.api/resize-canvas! (mf/ref-val canvas-ref))
(wasm.api/set-view-box zoom vbox)))
(mf/with-effect [@canvas-init? preview-blend]
@ -492,6 +501,43 @@
(when (and @canvas-init? hover-grid?)
(wasm.api/show-grid @hover-top-frame-id)))
;; Rulers-wasm: push visibility / offsets / selection band into the
;; render-wasm overlay (always active, no feature flag).
(mf/with-effect [@canvas-init?]
(when @canvas-init?
(wasm.api/push-ruler-theme-colors!)
(theme/add-color-scheme-listener!
(fn []
(wasm.api/push-ruler-theme-colors!)
(wasm.api/request-render "rulers-colors-theme")))))
(mf/with-effect [@canvas-init? frame-visible?]
(when @canvas-init?
(wasm.api/set-rulers-frame-visible! frame-visible?)
(wasm.api/request-render "rulers-frame")))
(mf/with-effect [@canvas-init? show-rulers?]
(when @canvas-init?
(wasm.api/set-rulers-visible! show-rulers?)
(wasm.api/request-render "rulers-visible")))
(mf/with-effect [@canvas-init? show-rulers? offset-x offset-y]
(when (and @canvas-init? show-rulers?)
(wasm.api/set-rulers-offsets! offset-x offset-y)))
(mf/with-effect [@canvas-init? show-rulers?
(some-> ruler-selection :x) (some-> ruler-selection :y)
(some-> ruler-selection :width) (some-> ruler-selection :height)]
(when (and @canvas-init? show-rulers?)
(wasm.api/set-rulers-selection! ruler-selection)
(wasm.api/request-render "rulers-selection")))
;; Paint background + rulers instantly, before shapes finish loading. Runs
;; after the ruler push effects so the WASM ruler state is already set.
(mf/with-effect [@canvas-init? page-id]
(when @canvas-init?
(wasm.api/render-ui-only)))
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
(hooks/setup-viewport-size vport viewport-ref)
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
@ -542,8 +588,6 @@
:ref canvas-ref
:class (stl/css :render-shapes)
:key (dm/str "render" page-id)
:width (* wasm.api/dpr (:width vport 0))
:height (* wasm.api/dpr (:height vport 0))
:style {:background-color background
:pointer-events "none"}}]
@ -554,14 +598,20 @@
[:img {:data-testid "canvas-wasm-transition"
:src src
:draggable false
;; Full-bleed so the snapshot overlays the canvas 1:1.
:style {:position "absolute"
:inset 0
:width "100%"
:height "100%"
:object-fit "cover"
:pointer-events "none"
;; use (when page-transition? "blur(4px)") if we don't want the blur on context loss
:filter "blur(4px)"}}]))
;; Initial load: clip to the live canvas frame (rounded
;; corner + ruler strips when present) so it shows
;; through. No frame in hide-UI mode -> no clip.
:clip-path (when (and transition-reveal-rulers? frame-visible?)
(let [strip (if show-rulers? rulers/ruler-area-size 0)]
(dm/str "inset(" strip "px 0 0 " strip "px round "
rulers/canvas-border-radius "px)")))}}]))
[:svg.viewport-controls
@ -774,16 +824,6 @@
[:& presence/active-cursors
{:page-id page-id}])
(when-not hide-ui?
[:> rulers/rulers*
{:zoom zoom
:zoom-inverse zoom-inverse
:vbox vbox
:selected-shapes selected-shapes
:offset-x offset-x
:offset-y offset-y
:show-rulers show-rulers?}])
(when (and show-rulers? show-grids?)
[:> guides/viewport-guides*
{:zoom zoom

View File

@ -42,6 +42,7 @@
[app.render-wasm.mem :as mem]
[app.render-wasm.mem.heap32 :as mem.h32]
[app.render-wasm.performance :as perf]
[app.render-wasm.rulers-state :as rulers-state]
[app.render-wasm.serializers :as sr]
[app.render-wasm.serializers.color :as sr-clr]
[app.render-wasm.svg-filters :as svg-filters]
@ -78,6 +79,10 @@
;; `penpot:wasm:tiles-complete`, so we can remove/replace it safely.
(defonce page-transition? (atom false))
(defonce context-loss-overlay? (atom false))
;; When true (initial load) the overlay clips out the ruler strips so the live
;; rulers show through. False (page switch / context loss) keeps the snapshot's
;; baked-in rulers full-bleed to avoid a blank-strip flicker on canvas remount.
(defonce transition-reveal-rulers? (atom false))
(defonce transition-image-url* (atom nil))
(defonce transition-epoch* (atom 0))
(defonce transition-tiles-handler* (atom nil))
@ -138,6 +143,7 @@
- Installs a tiles-complete handler to end the transition
- Uses a solid background-color placeholder as the transition image"
[background]
(reset! transition-reveal-rulers? true) ; reveal the live rulers
;; If something already toggled `page-transition?` (e.g. legacy init code paths),
;; ensure we still have a deterministic placeholder on initial load.
(when (or (not @page-transition?) (nil? @transition-image-url*))
@ -255,6 +261,12 @@
(def dpr
(if use-dpr? (if (exists? js/window) js/window.devicePixelRatio 1.0) 1.0))
(defn get-dpr
"Returns the current device pixel ratio. Use instead of `dpr` wherever
the value must reflect browser-zoom changes that happen after load."
[]
(if use-dpr? (.-devicePixelRatio ^js ug/window) 1.0))
(def noop-fn
(constantly nil))
@ -341,6 +353,28 @@
:vbox vbox
:background background}))
(declare set-rulers-colors!
set-rulers-visible!
set-rulers-frame-visible!
set-rulers-offsets!
set-rulers-selection!)
(defn push-ruler-theme-colors!
[]
(if-let [{:keys [bg border label accent]} (rulers-state/theme-colors)]
(set-rulers-colors! bg border label accent)
(js/console.error "Failed to resolve ruler CSS colors")))
(defn- sync-rulers-to-wasm!
[{:keys [show-rulers? frame-visible? offset-x offset-y ruler-selection push-colors?]
:or {push-colors? true frame-visible? true}}]
(when push-colors? (push-ruler-theme-colors!))
(set-rulers-frame-visible! frame-visible?)
(set-rulers-visible! show-rulers?)
(when show-rulers?
(set-rulers-offsets! offset-x offset-y)
(set-rulers-selection! ruler-selection)))
(defn free-gpu-resources
[]
;; check if the context has not been lost already or we will get warnings about
@ -380,6 +414,24 @@
(set! wasm/internal-frame-id nil)
(ug/dispatch! (ug/event "penpot:wasm:render"))))
(defn render-ui-only
"Renders only the canvas background and UI surface (rulers/frame) without
rebuilding shape tiles. Fast synchronous call used to show the viewport
frame immediately before a potentially slow tile rebuild."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_ui_only")))
;; CSS-pixel blur radius for the page-transition snapshot (DPR-scaled in WASM).
(def ^:private TRANSITION_BLUR_RADIUS 4.0)
(defn render-blurred-snapshot!
"Blurs the current page into the canvas so a following
`capture-canvas-snapshot-url` grabs an already-blurred transition frame."
[]
(when (and wasm/context-initialized? (not @wasm/context-lost?))
(h/call wasm/internal-module "_render_blurred_snapshot" TRANSITION_BLUR_RADIUS)))
(defn render-sync
[]
(when (initialized?)
@ -1832,17 +1884,33 @@
(let [setter-name (str/concat "_set_" (name param-name))]
(h/call wasm/internal-module setter-name value))))
(defn set-canvas-size
[canvas]
(let [width (or (.-clientWidth ^js canvas) (.-width ^js canvas))
height (or (.-clientHeight ^js canvas) (.-height ^js canvas))]
(set! (.-width canvas) (* dpr width))
(set! (.-height canvas) (* dpr height))))
(defn set-render-options!
"Updates WASM render options with a new DPR value."
[new-dpr]
(h/call wasm/internal-module "_set_render_options" (debug-flags) new-dpr))
(defn resize-canvas!
"Sizes the canvas drawing buffer, the WASM render surface and the DPR from a
single source of truth (the canvas CSS client size) so the GL framebuffer
and the Skia target surface stay the same size. A size mismatch leaves an
unpainted strip on the top/right edges because the GL framebuffer origin is
bottom-left, so a smaller Skia surface is anchored to the bottom-left of the
larger drawing buffer."
([canvas]
(resize-canvas! canvas (get-dpr)))
([canvas new-dpr]
(let [css-w (.-clientWidth ^js canvas)
css-h (.-clientHeight ^js canvas)]
(set! (.-width ^js canvas) (* new-dpr css-w))
(set! (.-height ^js canvas) (* new-dpr css-h))
(set-render-options! new-dpr)
(resize-viewbox css-w css-h))))
(defn- on-webgl-context-lost
[event]
(dom/prevent-default event)
;; Keep the last rendered pixels visible while context is lost/recovering.
(reset! transition-reveal-rulers? false) ; snapshot has rulers baked in
(start-context-loss-overlay!)
(when-let [url wasm/canvas-snapshot-url]
(when (string? url)
@ -1895,8 +1963,8 @@
(.getExtension context "WEBGL_debug_renderer_info")
;; Initialize Wasm Render Engine
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr)
(h/call wasm/internal-module "_init" (.-clientWidth ^js canvas) (.-clientHeight ^js canvas))
(h/call wasm/internal-module "_set_render_options" flags (get-dpr))
;; Configurable parameters.
(wasm-set-param-from-route-params-if-present :antialias_threshold)
@ -1907,7 +1975,7 @@
;; Set browser and canvas size only after initialization
(h/call wasm/internal-module "_set_browser" browser)
(set-canvas-size canvas)
(resize-canvas! canvas)
;; Add event listeners for WebGL context lost
(set! wasm/canvas canvas)
@ -2025,6 +2093,7 @@
:on-render on-render
:on-shapes-ready on-shapes-ready
:force-sync force-sync)
(sync-rulers-to-wasm! (rulers-state/from-store @st/state))
(request-render "reload-renderer")
(ug/dispatch! (ug/event "penpot:wasm:reload-complete"))
payload))
@ -2044,6 +2113,37 @@
(aget buffer 3)))
(request-render "show-grid"))
(defn set-rulers-visible!
[visible?]
(h/call wasm/internal-module "_set_rulers_visible" (if visible? 1 0)))
(defn set-rulers-frame-visible!
[visible?]
(h/call wasm/internal-module "_set_rulers_frame_visible" (if visible? 1 0)))
(defn set-rulers-offsets!
[offset-x offset-y]
(h/call wasm/internal-module "_set_rulers_offsets"
(or offset-x 0) (or offset-y 0)))
(defn set-rulers-selection!
[rect]
(if (some? rect)
(h/call wasm/internal-module "_set_rulers_selection" 1
(or (:x rect) 0) (or (:y rect) 0)
(or (:width rect) 0) (or (:height rect) 0))
(h/call wasm/internal-module "_set_rulers_selection" 0 0 0 0 0)))
(defn set-rulers-colors!
"Push ruler chrome / accent colors as ARGB u32. Inputs are hex strings
(e.g. \"#181818\"); call once on theme change."
[bg-hex border-hex label-hex accent-hex]
(h/call wasm/internal-module "_set_rulers_colors"
(sr-clr/hex->u32argb bg-hex 1)
(sr-clr/hex->u32argb border-hex 1)
(sr-clr/hex->u32argb label-hex 1)
(sr-clr/hex->u32argb accent-hex 1)))
(defn clear-grid
[]
(h/call wasm/internal-module "_hide_grid")
@ -2217,6 +2317,7 @@
(defn apply-canvas-blur
[]
(reset! transition-reveal-rulers? false) ; snapshot has rulers baked in
(let [already? @page-transition?
epoch (begin-page-transition!)]
(set-transition-tiles-complete-handler! epoch end-page-transition!)

View File

@ -0,0 +1,74 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL
(ns app.render-wasm.rulers-state
"Ruler overlay state derived from the workspace (no WASM/api deps)."
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.focus :as cpf]
[app.common.files.helpers :as cfh]
[app.common.geom.shapes :as gsh]
[app.main.data.helpers :as dsh]
[app.util.color :as uc]
[app.util.dom :as dom]))
(def ^:private color-css-vars
{:bg "--panel-background-color"
:border "--panel-border-color"
:label "--layer-row-foreground-color"
:accent "--color-accent-tertiary"})
(defn- display-state*
[layout selected-shapes base-objects]
(let [hide-ui? (contains? layout :hide-ui)
show-rulers? (and (contains? layout :rulers) (not hide-ui?))
selected-frames (into #{} (map :frame-id) selected-shapes)
selected-frame (when (= (count selected-frames) 1)
(get base-objects (first selected-frames)))
first-shape (first selected-shapes)
selecting-first-level-frame? (and (= (count selected-shapes) 1)
(cfh/root-frame? first-shape))]
{:show-rulers? show-rulers?
;; The rounded canvas frame is shown unless the whole UI is hidden.
:frame-visible? (not hide-ui?)
:offset-x (if selecting-first-level-frame?
(:x first-shape)
(:x selected-frame))
:offset-y (if selecting-first-level-frame?
(:y first-shape)
(:y selected-frame))
:ruler-selection (when (and show-rulers? (d/not-empty? selected-shapes))
(gsh/shapes->rect selected-shapes))}))
(defn display-state
[{:keys [layout selected-shapes base-objects]}]
(display-state* layout selected-shapes base-objects))
(defn from-store
[state]
(let [layout (:workspace-layout state)
file-id (:current-file-id state)
page-id (:current-page-id state)
objects (dsh/lookup-page-objects state file-id page-id)
base-objects (cpf/focus-objects objects (:workspace-focus-selected state))
selected-shapes (->> (dm/get-in state [:workspace-local :selected])
(dsh/process-selected base-objects)
(keep (d/getf base-objects))
(not-empty))]
(display-state* layout selected-shapes base-objects)))
(defn theme-colors
[]
(let [resolve (fn [k]
(some-> (dom/get-css-variable (get color-css-vars k) js/document.body)
uc/parse-css-color))
bg (resolve :bg)
border (resolve :border)
label (resolve :label)
accent (resolve :accent)]
(when (and bg border label accent)
{:bg bg :border border :label label :accent accent})))

View File

@ -93,3 +93,14 @@
(mth/floor (* (js/Math.random) 256))
(mth/floor (* (js/Math.random) 256))
(mth/floor (* (js/Math.random) 256))))
(defn parse-css-color
"Normalizes a CSS color string to #rrggbb.
Handles #rrggbb, #rgb, rgb()."
[raw]
(let [s (some-> raw str/trim)]
(when (string? s)
(cond
(cc/valid-hex-color? s)
(-> (subs s 1) cc/expand-hex cc/prepend-hash)
:else (some-> (cc/parse-rgb s) cc/rgb->hex)))))

View File

@ -14,6 +14,9 @@
(defonce ^:private color-scheme-media-query
(.matchMedia globals/window "(prefers-color-scheme: dark)"))
(defonce ^:private color-scheme-listeners*
(atom #{}))
(def ^:const default "dark")
(defn get-system-theme
@ -22,6 +25,18 @@
"dark"
"light"))
(defn- notify-color-scheme-listeners!
[]
(doseq [f @color-scheme-listeners*]
(f)))
(defn add-color-scheme-listener!
"Registers `f` to run after each `body` color-scheme update in
`use-initialize` (profile theme or OS preference). Returns a dispose fn."
[f]
(swap! color-scheme-listeners* conj f)
(fn [] (swap! color-scheme-listeners* disj f)))
(defn- set-color-scheme
[^string color]
@ -47,4 +62,5 @@
(cond
(= profile-theme "system") system-theme
(= profile-theme "default") "dark"
:else (d/nilv profile-theme "dark"))))))
:else (d/nilv profile-theme "dark")))
(notify-color-scheme-listeners!))))

View File

@ -204,6 +204,24 @@
(fn []
(.disconnect ^js obs))))))
(defn on-dpr-change
"Registers a recurring listener for device-pixel-ratio changes (browser zoom).
Calls `f` with the new DPR each time it changes. Returns a 0-arity cancel fn."
[f]
(let [cancelled? (volatile! false)]
(letfn [(listen! []
(when-not @cancelled?
(let [dpr (.-devicePixelRatio ^js globals/window)
mq (.matchMedia globals/window (str "(resolution: " dpr "dppx)"))]
(.addEventListener mq "change"
(fn [_]
(when-not @cancelled?
(f (.-devicePixelRatio ^js globals/window))
(listen!)))
#js {:once true}))))]
(listen!)
(fn [] (vreset! cancelled? true)))))
(defn empty-png-size*
[width height]
(p/create

Binary file not shown.

View File

@ -133,6 +133,24 @@ pub extern "C" fn render(timestamp: i32, flags: u8) -> Result<FrameType> {
});
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_ui_only() -> Result<()> {
with_state!(state, {
state.render_ui_only();
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_blurred_snapshot(blur_radius: f32) -> Result<()> {
with_state!(state, {
state.render_blurred_snapshot(blur_radius);
});
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn render_sync() -> Result<()> {

View File

@ -6,6 +6,7 @@ pub mod gpu_state;
pub mod grid_layout;
mod images;
mod options;
pub mod rulers;
mod shadows;
mod strokes;
mod surfaces;
@ -26,7 +27,7 @@ use crate::shapes::{
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
Stroke, StrokeKind, TextContent, Type,
};
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
use crate::state::{RulerState, ShapesPoolMutRef, ShapesPoolRef};
use crate::tiles::{self, PendingTiles, TileRect};
use crate::uuid::Uuid;
use crate::view::Viewbox;
@ -369,6 +370,7 @@ pub(crate) struct RenderState {
pub nested_blurs: Vec<Option<Blur>>, // FIXME: why is this an option?
pub nested_shadows: Vec<Vec<Shadow>>,
pub show_grid: Option<Uuid>,
pub rulers: RulerState,
pub focus_mode: FocusMode,
pub touched_ids: HashSet<Uuid>,
/// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.)
@ -564,6 +566,7 @@ impl RenderState {
nested_blurs: vec![],
nested_shadows: vec![],
show_grid: None,
rulers: RulerState::default(),
focus_mode: FocusMode::new(),
touched_ids: HashSet::default(),
ignore_nested_blurs: false,
@ -856,6 +859,36 @@ impl RenderState {
self.surfaces.flush_and_submit(SurfaceId::Target);
}
/// Renders only the canvas background and UI surface (rulers/frame), without
/// rebuilding or drawing any shape tiles. Used to show the viewport frame
/// immediately before shape tiles are built (e.g., right after a DPR change).
pub fn render_ui_only(&mut self, tree: ShapesPoolRef) {
self.surfaces
.canvas(SurfaceId::Target)
.clear(self.background_color);
ui::render(self, tree);
self.flush_and_submit();
}
/// Blurs the Backbuffer into Target and draws the rulers sharp on top, for
/// capturing an already-blurred page-transition snapshot. `blur_radius` is in
/// CSS pixels, scaled by DPR to match the device-resolution capture.
pub fn render_blurred_snapshot(&mut self, tree: ShapesPoolRef, blur_radius: f32) {
let sigma = (blur_radius * self.options.dpr).max(0.0);
self.surfaces
.canvas(SurfaceId::Target)
.clear(self.background_color);
let mut paint = skia::Paint::default();
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
self.surfaces
.draw_into(SurfaceId::Backbuffer, SurfaceId::Target, Some(&paint));
ui::render(self, tree);
self.surfaces.flush_and_submit(SurfaceId::Target);
}
pub fn reset_canvas(&mut self) {
self.surfaces.reset(self.background_color);
}

View File

@ -8,6 +8,7 @@ use crate::uuid::Uuid;
pub static DEFAULT_EMOJI_FONT: &str = "noto-color-emoji";
const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
const UI_FONT_BYTES: &[u8] = include_bytes!("../fonts/WorkSans-Numeric.ttf");
pub fn default_font() -> String {
let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal);
@ -23,6 +24,7 @@ pub struct FontStore {
font_provider: textlayout::TypefaceFontProvider,
font_collection: textlayout::FontCollection,
debug_font: Font,
ui_font: Font,
fallback_fonts: HashSet<String>,
}
@ -41,11 +43,17 @@ impl FontStore {
let debug_font = skia::Font::new(debug_typeface, 12.0);
let ui_typeface = font_mgr
.new_from_data(UI_FONT_BYTES, None)
.ok_or(Error::CriticalError("Failed to load UI font".to_string()))?;
let ui_font = skia::Font::new(ui_typeface, 12.0);
Ok(Self {
font_mgr,
font_provider,
font_collection,
debug_font,
ui_font,
fallback_fonts: HashSet::new(),
})
}
@ -67,6 +75,10 @@ impl FontStore {
&self.debug_font
}
pub fn ui_font(&self) -> &Font {
&self.ui_font
}
pub fn add(
&mut self,
family: FontFamily,

View File

@ -0,0 +1,450 @@
//! Ruler overlay rendered on `SurfaceId::UI`.
//!
//! Mirrors the SVG implementation in
//! `frontend/src/app/main/ui/workspace/viewport/rulers.cljs`. Coordinates are
//! in document space; the caller has already applied the world-space
//! transform (`scale(zoom*dpr) + translate(-vbox.left,-vbox.top)`), so all
//! sizes that should look constant on screen are multiplied by
//! `zoom_inverse = 1.0 / zoom`.
use skia_safe::{self as skia, Color, Font, Paint, PaintStyle, PathFillType, Point, RRect, Rect};
use super::fonts::FontStore;
use crate::state::RulerState;
use crate::view::Viewbox;
const RULER_AREA_SIZE: f32 = 22.0;
const RULER_TICK_OFFSET: f32 = 15.0;
const RULER_TICK_LEN: f32 = 4.0;
const RULER_TICK_GAP: f32 = 2.0;
const FONT_SIZE: f32 = 12.0;
const SELECTION_FILL_OPACITY: f32 = 0.3;
const CANVAS_BORDER_RADIUS: f32 = 12.0;
// Baseline of selection labels inside the 22-px bar. Empirical value from
// the SVG (`rulers.cljs`): the only place this number is justified is "it
// looks right for a 12-px font in a 22-px bar". Different from the regular
// tick-label baseline (`RULER_TICK_OFFSET - 1.0 = 14.0`); the SVG uses
// distinct offsets for the two and we mirror that.
const SELECTION_LABEL_BASELINE: f32 = 13.6;
// Selection-label gradient mask: matches the SVG `selection-gradient-start`
// and `selection-gradient-end` defs. The mask is `OVER_NUMBER_SIZE` screen
// pixels long, with the opaque part starting `OVER_NUMBER_PERCENT` of the
// way through the rect (40% from the outside edge, 60% from the inside).
const OVER_NUMBER_SIZE: f32 = 100.0;
const OVER_NUMBER_PERCENT: f32 = 0.75;
const GRADIENT_FADE_FRACTION: f32 = 0.4;
fn calculate_step_size(zoom: f32) -> f32 {
if zoom <= 0.0 {
return 1.0;
}
if zoom < 0.008 {
10000.0
} else if zoom < 0.015 {
5000.0
} else if zoom < 0.04 {
2500.0
} else if zoom < 0.07 {
1000.0
} else if zoom < 0.2 {
500.0
} else if zoom < 0.5 {
250.0
} else if zoom < 1.0 {
100.0
} else if zoom <= 2.0 {
50.0
} else if zoom < 4.0 {
25.0
} else if zoom < 6.0 {
10.0
} else if zoom < 15.0 {
5.0
} else if zoom < 25.0 {
2.0
} else {
1.0
}
}
fn format_label(value: f32) -> String {
// Match `format-number` in app.main.ui.formats: round to integer if whole,
// else 2 decimals. Tick steps are integers in our table, so this is the
// common path.
let rounded = value.round();
if (value - rounded).abs() < 1e-3 {
format!("{}", rounded as i64)
} else {
format!("{:.2}", value)
}
}
fn with_alpha(color: Color, alpha_fraction: f32) -> Color {
let a = (alpha_fraction.clamp(0.0, 1.0) * 255.0) as u8;
Color::from_argb(a, color.r(), color.g(), color.b())
}
/// Per-frame draw context: the canvas, the ruler state, the (constant-size)
/// label font, the viewport top-left in document coords, and the cached
/// derived sizes `bar` and `zi`. Bundled so the helpers don't blow past
/// clippy's `too_many_arguments` threshold.
struct RenderCtx<'a> {
canvas: &'a skia::Canvas,
state: &'a RulerState,
font: &'a Font,
vx: f32,
vy: f32,
bar: f32,
zi: f32,
}
pub fn render(canvas: &skia::Canvas, viewbox: Viewbox, fonts: &FontStore, state: &RulerState) {
let zoom = viewbox.zoom;
if zoom <= 0.0 {
return;
}
let zi = 1.0 / zoom;
let area = viewbox.area;
let vw = area.width();
let vh = area.height();
// Keep the font at a constant rasterization size and apply the
// inverse-scale (`zi`) at draw time. Pre-scaling the font size by `zi`
// makes Skia rasterize at smaller and smaller sizes as we zoom in,
// which rounds glyph advances to whole device pixels — the canvas then
// scales the rounded gaps back up and the spacing looks too wide.
// Subpixel positioning + a stable font size keeps spacing consistent.
let mut font: Font = fonts.ui_font().clone();
font.set_size(FONT_SIZE);
font.set_subpixel(true);
// When rulers are hidden we still draw the viewport frame (the rounded
// canvas border) with bar=0, matching the SVG viewport-frame* behavior
// which always renders the frame regardless of ruler visibility.
let bar = if state.visible {
RULER_AREA_SIZE * zi
} else {
0.0
};
let ctx = RenderCtx {
canvas,
state,
font: &font,
vx: area.left,
vy: area.top,
bar,
zi,
};
if state.frame {
draw_background(&ctx, vw, vh);
}
if state.visible {
let step = calculate_step_size(zoom);
if step > 0.0 && step.is_finite() {
draw_ticks_x(&ctx, vw, step, state.offset_x);
draw_ticks_y(&ctx, vh, step, state.offset_y);
}
if let Some(sel) = state.selection {
draw_selection_x(&ctx, sel, state.offset_x);
draw_selection_y(&ctx, sel, state.offset_y);
}
}
}
/// Draws the L-shaped ruler chrome with a rounded inner edge.
///
/// Mirrors the SVG `viewport-frame*` pattern:
/// 1. Stroke the inner rounded rect (this is the visible border between
/// the rulers and the canvas; bg fill covers the outer half later).
/// 2. Fill an `outer inner` path with even-odd, so only the L-shape
/// (outer minus inner) gets the bg color. The rounded corners of the
/// inner rect carve small bg-color fillets at the four corners of the
/// viewport, including the top-left intersection where the two bars
/// meet.
fn draw_background(ctx: &RenderCtx, vw: f32, vh: f32) {
let radius = CANVAS_BORDER_RADIUS * ctx.zi;
let inner_rect = Rect::from_ltrb(ctx.vx + ctx.bar, ctx.vy + ctx.bar, ctx.vx + vw, ctx.vy + vh);
let inner_rrect = RRect::new_rect_xy(inner_rect, radius, radius);
let mut border = Paint::default();
border.set_anti_alias(true);
border.set_style(PaintStyle::Stroke);
border.set_stroke_width(4.0 * ctx.zi);
border.set_color(ctx.state.border_color);
ctx.canvas.draw_rrect(inner_rrect, &border);
let outer_rect = Rect::from_xywh(ctx.vx, ctx.vy, vw, vh);
let mut pb = skia::PathBuilder::new();
pb.add_rect(outer_rect, None, None);
pb.add_rrect(inner_rrect, None, None);
let mut path = pb.detach();
path.set_fill_type(PathFillType::EvenOdd);
let mut bg = Paint::default();
bg.set_anti_alias(true);
bg.set_style(PaintStyle::Fill);
bg.set_color(ctx.state.bg_color);
ctx.canvas.draw_path(&path, &bg);
}
fn draw_ticks_x(ctx: &RenderCtx, vw: f32, step: f32, offset: f32) {
let canvas = ctx.canvas;
let zi = ctx.zi;
canvas.save();
// Clip out the corner so labels do not bleed under the Y bar.
let clip = Rect::from_xywh(ctx.vx + ctx.bar, ctx.vy, (vw - ctx.bar).max(0.0), ctx.bar);
canvas.clip_rect(clip, None, false);
let mut paint = Paint::default();
paint.set_color(ctx.state.label_color);
paint.set_anti_alias(true);
paint.set_stroke_width(zi);
let start = ctx.vx;
let end = ctx.vx + vw;
let minv = (start.max(-100_000.0) / step).ceil() * step + (offset % step);
let maxv = (end.min(100_000.0) / step).floor() * step + (offset % step);
let tick_top = ctx.vy + (RULER_TICK_OFFSET + RULER_TICK_GAP) * zi;
let tick_bottom = tick_top + RULER_TICK_LEN * zi;
let text_y = ctx.vy + (RULER_TICK_OFFSET - 1.0) * zi;
let mut v = minv;
while v <= maxv {
canvas.draw_line(Point::new(v, tick_top), Point::new(v, tick_bottom), &paint);
let label = format_label(v - offset);
let (w, _) = ctx.font.measure_str(&label, None);
canvas.save();
canvas.translate((v, text_y));
canvas.scale((zi, zi));
canvas.draw_str(&label, Point::new(-w / 2.0, 0.0), ctx.font, &paint);
canvas.restore();
v += step;
}
canvas.restore();
}
fn draw_ticks_y(ctx: &RenderCtx, vh: f32, step: f32, offset: f32) {
let canvas = ctx.canvas;
let zi = ctx.zi;
canvas.save();
let clip = Rect::from_xywh(ctx.vx, ctx.vy + ctx.bar, ctx.bar, (vh - ctx.bar).max(0.0));
canvas.clip_rect(clip, None, false);
let mut paint = Paint::default();
paint.set_color(ctx.state.label_color);
paint.set_anti_alias(true);
paint.set_stroke_width(zi);
let start = ctx.vy;
let end = ctx.vy + vh;
let minv = (start.max(-100_000.0) / step).ceil() * step + (offset % step);
let maxv = (end.min(100_000.0) / step).floor() * step + (offset % step);
let tick_left = ctx.vx + (RULER_TICK_OFFSET + RULER_TICK_GAP) * zi;
let tick_right = tick_left + RULER_TICK_LEN * zi;
let text_x = ctx.vx + (RULER_TICK_OFFSET - 1.0) * zi;
let mut v = minv;
while v <= maxv {
canvas.draw_line(Point::new(tick_left, v), Point::new(tick_right, v), &paint);
let label = format_label(v - offset);
let (w, _) = ctx.font.measure_str(&label, None);
// Rotate -90° around (text_x, v) so the label reads bottom-to-top
// along the Y axis, matching the SVG `transform="rotate(-90 …)"`.
// The scale(zi) brings the constant-size font down to 12 CSS px on
// screen after the outer world-space transform.
canvas.save();
canvas.translate((text_x, v));
canvas.rotate(-90.0, None);
canvas.scale((zi, zi));
canvas.draw_str(&label, Point::new(-w / 2.0, 0.0), ctx.font, &paint);
canvas.restore();
v += step;
}
canvas.restore();
}
fn draw_selection_x(ctx: &RenderCtx, sel: Rect, offset: f32) {
let canvas = ctx.canvas;
let zi = ctx.zi;
// Render order matches the SVG: outer gradient masks first (so their
// bg color paints over the regular tick labels behind), then the
// semi-transparent band on top of the masked area, then the selection
// labels on top of everything.
let mask_w = OVER_NUMBER_SIZE * zi;
let left_x = sel.left - OVER_NUMBER_SIZE * OVER_NUMBER_PERCENT * zi;
draw_mask(
ctx,
Rect::from_xywh(left_x, ctx.vy, mask_w, ctx.bar),
MaskAxis::Horizontal,
false,
);
let right_x = sel.right - OVER_NUMBER_SIZE * (1.0 - OVER_NUMBER_PERCENT) * zi;
draw_mask(
ctx,
Rect::from_xywh(right_x, ctx.vy, mask_w, ctx.bar),
MaskAxis::Horizontal,
true,
);
let mut fill = Paint::default();
fill.set_anti_alias(false);
fill.set_style(PaintStyle::Fill);
fill.set_color(with_alpha(ctx.state.accent_color, SELECTION_FILL_OPACITY));
canvas.draw_rect(
Rect::from_xywh(sel.left, ctx.vy, sel.width(), ctx.bar),
&fill,
);
let text_y = ctx.vy + SELECTION_LABEL_BASELINE * zi;
let pad_x = 4.0 * zi;
let left_label = format_label(sel.left - offset);
let right_label = format_label(sel.right - offset);
let (lw_font, _) = ctx.font.measure_str(&left_label, None);
// The right label is anchored at its left edge, so we don't need its
// measured width.
let lx = sel.left - pad_x - lw_font * zi;
let rx = sel.right + pad_x;
let mut text_paint = Paint::default();
text_paint.set_color(ctx.state.accent_color);
text_paint.set_anti_alias(true);
canvas.save();
canvas.translate((lx, text_y));
canvas.scale((zi, zi));
canvas.draw_str(&left_label, Point::new(0.0, 0.0), ctx.font, &text_paint);
canvas.restore();
canvas.save();
canvas.translate((rx, text_y));
canvas.scale((zi, zi));
canvas.draw_str(&right_label, Point::new(0.0, 0.0), ctx.font, &text_paint);
canvas.restore();
}
enum MaskAxis {
Horizontal,
Vertical,
}
/// Fills `rect` with a `bg_color` gradient along `axis` that fades the tick
/// labels behind the selection band. `fade_to_end` flips it from
/// transparent→opaque (before the band) to opaque→transparent (after).
fn draw_mask(ctx: &RenderCtx, rect: Rect, axis: MaskAxis, fade_to_end: bool) {
let opaque = ctx.state.bg_color;
let transparent = with_alpha(ctx.state.bg_color, 0.0);
let (colors, offsets): (&[skia::Color; 3], &[f32; 3]) = if fade_to_end {
(
&[opaque, opaque, transparent],
&[0.0, 1.0 - GRADIENT_FADE_FRACTION, 1.0],
)
} else {
(
&[transparent, opaque, opaque],
&[0.0, GRADIENT_FADE_FRACTION, 1.0],
)
};
let end = match axis {
MaskAxis::Horizontal => (rect.right, rect.top),
MaskAxis::Vertical => (rect.left, rect.bottom),
};
let shader = skia::gradient_shader::linear(
((rect.left, rect.top), end),
&colors[..],
Some(&offsets[..]),
skia::TileMode::Clamp,
None,
None,
);
let mut paint = Paint::default();
paint.set_anti_alias(false);
paint.set_style(PaintStyle::Fill);
paint.set_shader(shader);
ctx.canvas.draw_rect(rect, &paint);
}
fn draw_selection_y(ctx: &RenderCtx, sel: Rect, offset: f32) {
let canvas = ctx.canvas;
let zi = ctx.zi;
let pad_y = 4.0 * zi;
let top_label = format_label(sel.top - offset);
let bottom_label = format_label(sel.bottom - offset);
// Top label's draw position doesn't depend on its own width (LX is just
// pad_y/zi), so we only need bw_font for the bottom label's right-anchor.
let (bw_font, _) = ctx.font.measure_str(&bottom_label, None);
// Mask first (gradient bg over tick labels behind), then band, then
// labels — same order as SVG.
let mask_h = OVER_NUMBER_SIZE * zi;
let top_y = sel.top - OVER_NUMBER_SIZE * OVER_NUMBER_PERCENT * zi;
draw_mask(
ctx,
Rect::from_xywh(ctx.vx, top_y, ctx.bar, mask_h),
MaskAxis::Vertical,
false,
);
let bottom_y = sel.bottom - OVER_NUMBER_SIZE * (1.0 - OVER_NUMBER_PERCENT) * zi;
draw_mask(
ctx,
Rect::from_xywh(ctx.vx, bottom_y, ctx.bar, mask_h),
MaskAxis::Vertical,
true,
);
let mut fill = Paint::default();
fill.set_anti_alias(false);
fill.set_style(PaintStyle::Fill);
fill.set_color(with_alpha(ctx.state.accent_color, SELECTION_FILL_OPACITY));
canvas.draw_rect(
Rect::from_xywh(ctx.vx, sel.top, ctx.bar, sel.height()),
&fill,
);
let text_x = ctx.vx + SELECTION_LABEL_BASELINE * zi;
let mut text_paint = Paint::default();
text_paint.set_color(ctx.state.accent_color);
text_paint.set_anti_alias(true);
// Both labels read bottom-to-top on screen (after the -90° rotation
// local +x points upward). With the transform stack
// (translate→rotate→scale) and a draw at code-(LX, 0), the actual
// origin in document coords is (text_x, pivot_y LX·zi).
//
// Top label: want origin just above sel.top, reading upward from
// there, so pivot_y LX·zi = sel.top pad_y ⇒ LX = pad_y/zi.
canvas.save();
canvas.translate((text_x, sel.top));
canvas.rotate(-90.0, None);
canvas.scale((zi, zi));
canvas.draw_str(
&top_label,
Point::new(pad_y / zi, 0.0),
ctx.font,
&text_paint,
);
canvas.restore();
// Bottom label: want the text END at sel.bottom + pad_y and origin
// at sel.bottom + pad_y + bw so it reads upward toward the band.
canvas.save();
canvas.translate((text_x, sel.bottom));
canvas.rotate(-90.0, None);
canvas.scale((zi, zi));
canvas.draw_str(
&bottom_label,
Point::new(-bw_font - pad_y / zi, 0.0),
ctx.font,
&text_paint,
);
canvas.restore();
}

View File

@ -1,7 +1,7 @@
use skia_safe::{self as skia, Color4f};
use super::{RenderState, ShapesPoolRef, SurfaceId};
use crate::render::grid_layout;
use crate::render::{grid_layout, rulers};
use crate::shapes::{Layout, Type};
pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
@ -60,6 +60,10 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
}
}
let viewbox = render_state.viewbox;
let ruler_state = render_state.rulers;
rulers::render(canvas, viewbox, &render_state.fonts, &ruler_state);
canvas.restore();
render_state.surfaces.draw_into(

View File

@ -1,8 +1,10 @@
use skia_safe::{self as skia, textlayout::FontCollection, Path, Point};
use std::collections::HashMap;
mod rulers;
mod shapes_pool;
mod text_editor;
pub use rulers::RulerState;
pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef};
pub use text_editor::*;
@ -65,6 +67,14 @@ impl State {
get_render_state().render_from_cache(&self.shapes);
}
pub fn render_ui_only(&mut self) {
get_render_state().render_ui_only(&self.shapes);
}
pub fn render_blurred_snapshot(&mut self, blur_radius: f32) {
get_render_state().render_blurred_snapshot(&self.shapes, blur_radius);
}
pub fn render_sync(&mut self, timestamp: i32) -> Result<FrameType> {
get_render_state().start_render_loop(None, &self.shapes, timestamp, true)
}

View File

@ -0,0 +1,42 @@
use skia_safe::{self as skia, Rect};
#[derive(Debug, Clone, Copy)]
pub struct RulerState {
pub visible: bool,
// The rounded canvas frame/border. Drawn even when `visible` is false
// (rulers toggled off), but hidden in hide-UI mode.
pub frame: bool,
pub offset_x: f32,
pub offset_y: f32,
pub selection: Option<Rect>,
pub bg_color: skia::Color,
pub border_color: skia::Color,
pub label_color: skia::Color,
pub accent_color: skia::Color,
}
impl Default for RulerState {
fn default() -> Self {
Self {
visible: false,
frame: true,
offset_x: 0.0,
offset_y: 0.0,
selection: None,
bg_color: skia::Color::from_argb(0xff, 0x18, 0x18, 0x1a),
border_color: skia::Color::from_argb(0xff, 0x2e, 0x2e, 0x36),
label_color: skia::Color::from_argb(0xff, 0xb1, 0xb2, 0xb5),
accent_color: skia::Color::from_argb(0xff, 0x91, 0xff, 0x11),
}
}
}
impl RulerState {
pub fn set_selection(&mut self, has: bool, x: f32, y: f32, w: f32, h: f32) {
self.selection = if has {
Some(Rect::from_xywh(x, y, w, h))
} else {
None
};
}
}

View File

@ -5,6 +5,7 @@ pub mod fonts;
pub mod layouts;
pub mod mem;
pub mod paths;
pub mod rulers;
pub mod shadows;
pub mod shapes;
pub mod strokes;

View File

@ -0,0 +1,49 @@
use macros::wasm_error;
use skia_safe::{self as skia};
#[allow(unused_imports)]
use crate::error::{Error, Result};
use crate::get_render_state;
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_rulers_visible(visible: u32) -> Result<()> {
get_render_state().rulers.visible = visible != 0;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_rulers_frame_visible(visible: u32) -> Result<()> {
get_render_state().rulers.frame = visible != 0;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_rulers_offsets(offset_x: f32, offset_y: f32) -> Result<()> {
let r = &mut get_render_state().rulers;
r.offset_x = offset_x;
r.offset_y = offset_y;
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_rulers_selection(has: u32, x: f32, y: f32, w: f32, h: f32) -> Result<()> {
get_render_state()
.rulers
.set_selection(has != 0, x, y, w, h);
Ok(())
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_rulers_colors(bg: u32, border: u32, label: u32, accent: u32) -> Result<()> {
let r = &mut get_render_state().rulers;
r.bg_color = skia::Color::new(bg);
r.border_color = skia::Color::new(border);
r.label_color = skia::Color::new(label);
r.accent_color = skia::Color::new(accent);
Ok(())
}