mirror of
https://github.com/penpot/penpot.git
synced 2026-06-09 17:02:05 +00:00
✨ 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:
parent
8d3516d06d
commit
60d3c81450
@ -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))))))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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!)
|
||||
|
||||
74
frontend/src/app/render_wasm/rulers_state.cljs
Normal file
74
frontend/src/app/render_wasm/rulers_state.cljs
Normal 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})))
|
||||
@ -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)))))
|
||||
|
||||
@ -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!))))
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
render-wasm/src/fonts/WorkSans-Numeric.ttf
Normal file
BIN
render-wasm/src/fonts/WorkSans-Numeric.ttf
Normal file
Binary file not shown.
@ -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<()> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
450
render-wasm/src/render/rulers.rs
Normal file
450
render-wasm/src/render/rulers.rs
Normal 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();
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
42
render-wasm/src/state/rulers.rs
Normal file
42
render-wasm/src/state/rulers.rs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
49
render-wasm/src/wasm/rulers.rs
Normal file
49
render-wasm/src/wasm/rulers.rs
Normal 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(())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user