🎉 Serialize guides to wasm

This commit is contained in:
Belén Albeza 2026-06-03 16:23:27 +02:00
parent c0188b6c58
commit 4f4f195113
13 changed files with 227 additions and 59 deletions

View File

@ -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

View File

@ -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

View File

@ -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))))))

View File

@ -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

View File

@ -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(())
}

View File

@ -9,6 +9,7 @@ mod render;
mod shapes;
mod state;
mod tiles;
mod ui;
mod utils;
mod uuid;
mod view;

View File

@ -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();

View File

@ -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::<skia::Color>::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);

View File

@ -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;

View File

@ -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<Guide>,
pub _show_grid: Option<Uuid>,
}
#[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,
}
}
}

19
render-wasm/src/ui.rs Normal file
View File

@ -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 }
}
}

View File

@ -13,3 +13,4 @@ pub mod svg_attrs;
pub mod text;
pub mod text_editor;
pub mod transforms;
pub mod ui;

View File

@ -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::<RawGuide>();
#[repr(u8)]
#[derive(Debug, Clone, PartialEq, Copy, ToJs)]
pub enum RawGuideKind {
Vertical = 0,
Horizontal = 1,
}
impl From<u32> 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<RawGuide> 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<Self> {
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<Vec<Guide>> {
buffer
.chunks_exact(RAW_GUIDE_SIZE)
.take(count)
.map(|bytes| RawGuide::try_from(bytes).map(|guide| guide.into()))
.collect::<Result<Vec<Guide>>>()
}
#[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(())
}