From 60d3c81450d06a97dfe1c7160ff95e78bce3f69b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Fri, 5 Jun 2026 07:51:35 +0200 Subject: [PATCH] :sparkles: Add wasm rulers (#9858) * :sparkles: Add wasm rulers * :wrench: Fix dpr on page zoom Co-authored-by: Alejandro Alonso Co-authored-by: Elena Torro * :wrench: Change page-switch behavior to refresh rulers and keep blurred snapshot * :bug: Restore WASM rulers after WebGL context recovery Co-Authored-By: Elena Torro Co-Authored-By: Alejandro Alonso --------- Co-authored-by: Alejandro Alonso --- .../main/ui/workspace/sidebar/sitemap.cljs | 7 +- .../app/main/ui/workspace/viewport_wasm.cljs | 102 ++-- frontend/src/app/render_wasm/api.cljs | 119 ++++- .../src/app/render_wasm/rulers_state.cljs | 74 +++ frontend/src/app/util/color.cljs | 11 + frontend/src/app/util/theme.cljs | 18 +- frontend/src/app/util/webapi.cljs | 18 + render-wasm/src/fonts/WorkSans-Numeric.ttf | Bin 0 -> 9828 bytes render-wasm/src/main.rs | 18 + render-wasm/src/render.rs | 35 +- render-wasm/src/render/fonts.rs | 12 + render-wasm/src/render/rulers.rs | 450 ++++++++++++++++++ render-wasm/src/render/ui.rs | 6 +- render-wasm/src/state.rs | 10 + render-wasm/src/state/rulers.rs | 42 ++ render-wasm/src/wasm.rs | 1 + render-wasm/src/wasm/rulers.rs | 49 ++ 17 files changed, 926 insertions(+), 46 deletions(-) create mode 100644 frontend/src/app/render_wasm/rulers_state.cljs create mode 100644 render-wasm/src/fonts/WorkSans-Numeric.ttf create mode 100644 render-wasm/src/render/rulers.rs create mode 100644 render-wasm/src/state/rulers.rs create mode 100644 render-wasm/src/wasm/rulers.rs diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index 1e20efc086..ce00c06d60 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -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)))))) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 5fa174fe2c..40546618d8 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -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 diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 07889e05b3..2797dd596e 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -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!) diff --git a/frontend/src/app/render_wasm/rulers_state.cljs b/frontend/src/app/render_wasm/rulers_state.cljs new file mode 100644 index 0000000000..231907cec7 --- /dev/null +++ b/frontend/src/app/render_wasm/rulers_state.cljs @@ -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}))) diff --git a/frontend/src/app/util/color.cljs b/frontend/src/app/util/color.cljs index 86314ce190..58cd64beb7 100644 --- a/frontend/src/app/util/color.cljs +++ b/frontend/src/app/util/color.cljs @@ -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))))) diff --git a/frontend/src/app/util/theme.cljs b/frontend/src/app/util/theme.cljs index 83c3506155..7291fe043e 100644 --- a/frontend/src/app/util/theme.cljs +++ b/frontend/src/app/util/theme.cljs @@ -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!)))) diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index 011bf656e6..250b01bced 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -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 diff --git a/render-wasm/src/fonts/WorkSans-Numeric.ttf b/render-wasm/src/fonts/WorkSans-Numeric.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9566da91bebf127c6b72280a4c210c60ee8c388e GIT binary patch literal 9828 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 g4tnLdyEOVnjG2qK^>HVD~-=>Px# literal 0 HcmV?d00001 diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index 4ab5677895..a0939cdd95 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -133,6 +133,24 @@ pub extern "C" fn render(timestamp: i32, flags: u8) -> Result { }); } +#[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<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 5de1bac3f5..b2907bc6c0 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -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>, // FIXME: why is this an option? pub nested_shadows: Vec>, pub show_grid: Option, + pub rulers: RulerState, pub focus_mode: FocusMode, pub touched_ids: HashSet, /// 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); } diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index c24276f267..17bdb84290 100644 --- a/render-wasm/src/render/fonts.rs +++ b/render-wasm/src/render/fonts.rs @@ -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, } @@ -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, diff --git a/render-wasm/src/render/rulers.rs b/render-wasm/src/render/rulers.rs new file mode 100644 index 0000000000..a33215b942 --- /dev/null +++ b/render-wasm/src/render/rulers.rs @@ -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(); +} diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 71ff44eec6..2d48cdda6d 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -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( diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 740ef9deef..970c9cef1a 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -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 { get_render_state().start_render_loop(None, &self.shapes, timestamp, true) } diff --git a/render-wasm/src/state/rulers.rs b/render-wasm/src/state/rulers.rs new file mode 100644 index 0000000000..c17cd46a71 --- /dev/null +++ b/render-wasm/src/state/rulers.rs @@ -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, + 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 + }; + } +} diff --git a/render-wasm/src/wasm.rs b/render-wasm/src/wasm.rs index c33b614cd3..2a3e641c1f 100644 --- a/render-wasm/src/wasm.rs +++ b/render-wasm/src/wasm.rs @@ -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; diff --git a/render-wasm/src/wasm/rulers.rs b/render-wasm/src/wasm/rulers.rs new file mode 100644 index 0000000000..7f7191e5eb --- /dev/null +++ b/render-wasm/src/wasm/rulers.rs @@ -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(()) +}