diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index a85cb19b2a..744ae0be23 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -538,6 +538,12 @@ (when @canvas-init? (wasm.api/render-ui-only))) + ;; Ruler guides: push the page guides to the render engine whenever they + ;; change or their visibility toggles. When hidden we send an empty set. + (mf/with-effect [@canvas-init? guides show-rulers? show-grids?] + (when @canvas-init? + (wasm.api/set-guides (if (and show-rulers? show-grids?) guides {})))) + (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?) @@ -824,36 +830,19 @@ [:& 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?}]) + ;; NOTE: ruler guides are being migrated to the WASM render engine. + ;; The SVG-overlay rendering is temporarily disabled while we implement + ;; the new path. (when (and show-rulers? show-grids?) [:> guides/viewport-guides* {:zoom zoom :vbox vbox - :guides guides + ;; :guides guides + :guides #{} :hover-frame guide-frame :disabled-guides disabled-guides? :modifiers wasm-modifiers}]) - ;; NOTE: ruler guides are being migrated to the WASM render engine. - ;; The SVG-overlay rendering is temporarily disabled while we implement - ;; the new path. - #_(when (and show-rulers? show-grids?) - [:> guides/viewport-guides* - {:zoom zoom - :vbox vbox - :guides guides - :hover-frame guide-frame - :disabled-guides disabled-guides? - :modifiers wasm-modifiers}]) - ;; DEBUG LAYOUT DROP-ZONES (when (dbg/enabled? :layout-drop-zones) [:> wvd/debug-drop-zones* {:selected-shapes selected-shapes diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a1a0a2f6bf..98fcabb768 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2240,6 +2240,20 @@ (h/call wasm/internal-module "_hide_grid") (request-render "clear-grid")) +;; Ruler guides ---------------------------------------------------------------- + +(defn set-guides + "Serializes the page guides and sends them to the render engine. + `guides` is the page `:guides` map (id -> guide)." + [guides] + (let [size (sr/get-guides-byte-size guides) + offset (mem/alloc->offset-32 size) + heapu32 (mem/get-heap-u32) + heapf32 (mem/get-heap-f32)] + (sr/write-guides guides heapu32 heapf32 offset) + (h/call wasm/internal-module "_set_guides") + (request-render "set-guides"))) + (defn get-grid-coords [position] (let [offset (h/call wasm/internal-module diff --git a/frontend/src/app/render_wasm/serializers.cljs b/frontend/src/app/render_wasm/serializers.cljs index 4ce4c77400..e8518ebc28 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -8,7 +8,9 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.types.color :as clr] [app.common.uuid :as uuid] + [app.render-wasm.serializers.color :as sr-clr] [app.render-wasm.wasm :as wasm] [cuerdas.core :as str])) @@ -281,3 +283,47 @@ (let [values (unchecked-get wasm/serializers "transform-entry-kind") default (unchecked-get values "parent")] (d/nilv (unchecked-get values (d/name kind)) default))) + +;; --- Guides + +;; Each guide is serialized as 3 x 32-bit words: +;; kind (u32) | color (u32 argb) | position (f32) +(def ^:private guide-entry-size 12) + +;; Default guide color used when a guide has no explicit color (matches the +;; previous SVG overlay `default-guide-color`). +(def ^:private default-guide-color clr/new-danger) + +(defn- translate-guide-axis + "Maps a guide axis to the RawGuideKind discriminant expected by WASM. + `:x` (constant x) is a vertical guide, `:y` is a horizontal one." + [axis] + (let [values (unchecked-get wasm/serializers "guide-kind") + default (unchecked-get values "vertical")] + (case axis + :x (unchecked-get values "vertical") + :y (unchecked-get values "horizontal") + default))) + +(defn get-guides-byte-size + "Total heap size (in bytes) needed to serialize `guides` (a map id -> guide), + including the 4-byte header that holds the guide count." + [guides] + (+ 4 (* (count guides) guide-entry-size))) + +(defn write-guides + "Writes `guides` (a map id -> guide) into the heap views starting at the + 32-bit `offset`. Layout: count header (u32) followed by + `kind | color | position` per guide." + [guides heapu32 heapf32 offset] + (let [guides (vec (vals guides)) + total (count guides)] + (aset heapu32 offset total) + (loop [i 0] + (when (< i total) + (let [guide (nth guides i) + base (+ offset 1 (* i 3))] + (aset heapu32 base (translate-guide-axis (get guide :axis))) + (aset heapu32 (+ base 1) (sr-clr/hex->u32argb (or (get guide :color) default-guide-color) 1)) + (aset heapf32 (+ base 2) (get guide :position))) + (recur (inc i)))))) diff --git a/frontend/src/app/render_wasm/wasm.cljs b/frontend/src/app/render_wasm/wasm.cljs index caf3d3a15a..2864c5f7fb 100644 --- a/frontend/src/app/render_wasm/wasm.cljs +++ b/frontend/src/app/render_wasm/wasm.cljs @@ -62,6 +62,7 @@ :wrap-type shared/RawWrapType :grid-track-type shared/RawGridTrackType :shadow-style shared/RawShadowStyle + :guide-kind shared/RawGuideKind :stroke-style shared/RawStrokeStyle :stroke-cap shared/RawStrokeCap :shape-type shared/RawShapeType diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs index 0c9008c005..64e7d8c94f 100644 --- a/render-wasm/src/globals.rs +++ b/render-wasm/src/globals.rs @@ -5,7 +5,7 @@ use crate::emscripten::init_gl; use crate::mem; use crate::render::{gpu_state::GpuState, RenderState}; -use crate::state::{State, TextEditorState}; +use crate::state::{State, TextEditorState, UIState}; static mut DESIGN_STATE: *mut State = std::ptr::null_mut(); @@ -50,6 +50,17 @@ pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState { } } +/// UI State +static mut UI_STATE: *mut UIState = std::ptr::null_mut(); + +#[inline(always)] +pub(crate) fn get_ui_state() -> &'static mut UIState { + unsafe { + debug_assert!(!UI_STATE.is_null(), "UI State is null"); + &mut *UI_STATE + } +} + // FIXME: These with_state* macros should be using our CriticalError instead of expect. // But to do that, we need to not use them at domain-level (i.e. in business logic), just // in the context of the wasm call. @@ -118,6 +129,14 @@ fn text_editor_init() { } } +/// Initializes UIState. +fn ui_init() { + unsafe { + let ui_state = UIState::new(); + UI_STATE = Box::into_raw(Box::new(ui_state)); + } +} + #[no_mangle] #[wasm_error] pub extern "C" fn init(width: i32, height: i32) -> Result<()> { @@ -127,6 +146,7 @@ pub extern "C" fn init(width: i32, height: i32) -> Result<()> { render_init(width, height); text_editor_init(); design_init(); + ui_init(); Ok(()) } diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index ddd30fe356..2f7503d181 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -9,6 +9,7 @@ mod render; mod shapes; mod state; mod tiles; +mod ui; mod utils; mod uuid; mod view; diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index a5a7ad411a..b59eb6f235 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -1,9 +1,9 @@ use skia_safe::{self as skia, Color4f}; use super::{RenderState, ShapesPoolRef, SurfaceId}; +use crate::globals::get_ui_state; use crate::render::{grid_layout, rulers}; use crate::shapes::{Layout, Type}; - pub mod guides; pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { @@ -66,7 +66,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let ruler_state = render_state.rulers; rulers::render(canvas, viewbox, &render_state.fonts, &ruler_state); // TODO: pass guides data here - guides::render(canvas, zoom, viewbox.area); + guides::render(canvas, zoom, viewbox.area, &get_ui_state().guides); canvas.restore(); diff --git a/render-wasm/src/render/ui/guides.rs b/render-wasm/src/render/ui/guides.rs index cc5adfdbff..8c71433b71 100644 --- a/render-wasm/src/render/ui/guides.rs +++ b/render-wasm/src/render/ui/guides.rs @@ -1,36 +1,26 @@ use skia_safe::{self as skia}; use crate::math::Rect; -use crate::state::Guide; +use crate::ui::{Guide, GuideKind}; -const LABEL_TEXT_COLOR: Color = Color::new(255, 255, 255); -const LABEL_FONT_SIZE: f32 = 12.0; - -/// Renders the ruler guides overlay. -/// -/// NOTE: temporary hardcoded implementation while we figure out exactly which -/// data we need to receive from ClojureScript. For now it draws a single -/// vertical 1px magenta (#ff00ff) guide at x=100, spanning the visible area. -pub fn render(canvas: &skia::Canvas, zoom: f32, area: Rect) { - let guides = vec![ - Guide::new(GuideKind::Vertical(100.0), Color::new(255, 0, 255)), - Guide::new(GuideKind::Horizontal(200.0), Color::new(0, 255, 0)), - ]; +/// Renders the ruler guides overlay using the guides provided by the host +/// (ClojureScript) and stored in the render state. +pub fn render(canvas: &skia::Canvas, zoom: f32, area: Rect, guides: &[Guide]) { for guide in guides { - render_guide(canvas, zoom, area, guide); + render_guide(canvas, zoom, area, *guide); } } pub fn render_guide(canvas: &skia::Canvas, zoom: f32, area: Rect, guide: Guide) { let mut paint = skia::Paint::default(); paint.set_style(skia::PaintStyle::Stroke); - paint.set_color(guide.color.into()); + paint.set_color(Into::::into(guide.color)); paint.set_anti_alias(true); paint.set_stroke_width(1.0 / zoom); - let (x1, y1, x2, y2) = match guide { - Guide::Vertical(x) => (x, area.top, x, area.bottom), - Guide::Horizontal(y) => (area.left, y, area.right, y), + let (x1, y1, x2, y2) = match guide.kind { + GuideKind::Vertical(x) => (x, area.top, x, area.bottom), + GuideKind::Horizontal(y) => (area.left, y, area.right, y), }; canvas.draw_line((x1, y1), (x2, y2), &paint); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 87ef279dc7..9bf90fc2cf 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -8,7 +8,7 @@ mod ui; pub use rulers::RulerState; pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef}; pub use text_editor::*; -pub use ui::*; +pub use ui::UIState; use crate::error::{Error, Result}; use crate::render::FrameType; diff --git a/render-wasm/src/state/ui.rs b/render-wasm/src/state/ui.rs index 51bcd018e4..b03b6d45a8 100644 --- a/render-wasm/src/state/ui.rs +++ b/render-wasm/src/state/ui.rs @@ -1,19 +1,16 @@ -use crate::shapes::Color; +use crate::ui::Guide; +use crate::uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Copy)] -pub enum GuideKind { - Vertical(f32), - Horizontal(f32), +pub struct UIState { + pub guides: Vec, + pub _show_grid: Option, } -#[derive(Debug, Clone, PartialEq, Copy)] -pub struct Guide { - pub kind: GuideKind, - pub color: Color, -} - -impl Guide { - pub fn new(kind: GuideKind, color: Color) -> Self { - Self { kind, color } +impl UIState { + pub fn new() -> Self { + Self { + guides: Vec::new(), + _show_grid: None, + } } } diff --git a/render-wasm/src/ui.rs b/render-wasm/src/ui.rs new file mode 100644 index 0000000000..51bcd018e4 --- /dev/null +++ b/render-wasm/src/ui.rs @@ -0,0 +1,19 @@ +use crate::shapes::Color; + +#[derive(Debug, Clone, PartialEq, Copy)] +pub enum GuideKind { + Vertical(f32), + Horizontal(f32), +} + +#[derive(Debug, Clone, PartialEq, Copy)] +pub struct Guide { + pub kind: GuideKind, + pub color: Color, +} + +impl Guide { + pub fn new(kind: GuideKind, color: Color) -> Self { + Self { kind, color } + } +} diff --git a/render-wasm/src/wasm.rs b/render-wasm/src/wasm.rs index 2a3e641c1f..86f3101a83 100644 --- a/render-wasm/src/wasm.rs +++ b/render-wasm/src/wasm.rs @@ -13,3 +13,4 @@ pub mod svg_attrs; pub mod text; pub mod text_editor; pub mod transforms; +pub mod ui; diff --git a/render-wasm/src/wasm/ui.rs b/render-wasm/src/wasm/ui.rs new file mode 100644 index 0000000000..2e2fc93ef9 --- /dev/null +++ b/render-wasm/src/wasm/ui.rs @@ -0,0 +1,90 @@ +use crate::mem; +use crate::{ + error::{Error, Result}, + globals::get_ui_state, + ui::{Guide, GuideKind}, +}; +use macros::{wasm_error, ToJs}; + +const RAW_GUIDE_SIZE: usize = std::mem::size_of::(); + +#[repr(u8)] +#[derive(Debug, Clone, PartialEq, Copy, ToJs)] +pub enum RawGuideKind { + Vertical = 0, + Horizontal = 1, +} + +impl From for RawGuideKind { + fn from(value: u32) -> Self { + match value { + 1 => RawGuideKind::Horizontal, + _ => RawGuideKind::Vertical, + } + } +} + +/// Flat, FFI-friendly representation of a guide. +/// +/// The layout uses only 32-bit fields so it can be written from ClojureScript +/// straight into the `HEAPU32`/`HEAPF32` views without padding surprises. +#[repr(C)] +#[repr(align(4))] +#[derive(Debug, Clone, PartialEq, Copy)] +pub struct RawGuide { + kind: u32, + color: u32, + position: f32, +} + +impl From for Guide { + fn from(value: RawGuide) -> Self { + let kind = match RawGuideKind::from(value.kind) { + RawGuideKind::Vertical => GuideKind::Vertical(value.position), + RawGuideKind::Horizontal => GuideKind::Horizontal(value.position), + }; + Guide::new(kind, value.color.into()) + } +} + +impl From<[u8; RAW_GUIDE_SIZE]> for RawGuide { + fn from(bytes: [u8; RAW_GUIDE_SIZE]) -> Self { + unsafe { std::mem::transmute(bytes) } + } +} + +impl TryFrom<&[u8]> for RawGuide { + type Error = Error; + fn try_from(bytes: &[u8]) -> Result { + let bytes: [u8; RAW_GUIDE_SIZE] = bytes + .try_into() + .map_err(|_| Error::CriticalError("Invalid guide data".to_string()))?; + Ok(RawGuide::from(bytes)) + } +} + +fn read_guides_from_bytes(buffer: &[u8], count: usize) -> Result> { + buffer + .chunks_exact(RAW_GUIDE_SIZE) + .take(count) + .map(|bytes| RawGuide::try_from(bytes).map(|guide| guide.into())) + .collect::>>() +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_guides() -> Result<()> { + let bytes = mem::bytes(); + // The first 4 bytes are a header holding the number of guides. + let count = u32::from_le_bytes( + bytes + .get(0..4) + .and_then(|slice| slice.try_into().ok()) + .unwrap_or([0; 4]), + ) as usize; + let guides = read_guides_from_bytes(&bytes[4..], count)?; + get_ui_state().guides = guides; + + mem::free_bytes()?; + Ok(()) +}