From 7c57bc56f1ad72185c8db7a440d00814f7083e79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Fri, 5 Jun 2026 14:46:32 +0200 Subject: [PATCH] :tada: Right click on guides to change color or remove --- .../main/ui/workspace/viewport/guides.cljs | 63 ++++++++++++++----- .../app/main/ui/workspace/viewport_wasm.cljs | 6 +- frontend/src/app/render_wasm/api.cljs | 14 +++++ frontend/src/app/render_wasm/serializers.cljs | 4 +- render-wasm/src/render.rs | 4 +- render-wasm/src/render/surfaces.rs | 12 +++- render-wasm/src/render/ui/guides.rs | 6 +- render-wasm/src/state/ui.rs | 32 +++++++++- render-wasm/src/wasm/ui.rs | 19 +++++- 9 files changed, 132 insertions(+), 28 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index 134f610c1c..fa721051f2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -23,6 +23,8 @@ [app.main.ui.css-cursors :as cur] [app.main.ui.formats :as fmt] [app.main.ui.workspace.viewport.rulers :as rulers] + [app.main.ui.workspace.viewport.viewport-ref :as uwvv] + [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] [app.util.keyboard :as kbd] [cuerdas.core :as str] @@ -581,10 +583,17 @@ :is-hover true :hover-frame frame}])])) +(defn- guide-by-serialized-index + "Maps a WASM guide index back to the guide map entry. The index matches the + serialization order used by `set-guides` / `write-guides`." + [guides index] + (when (>= index 0) + (nth (vec (vals guides)) index nil))) + (mf/defc viewport-guides* {::mf/wrap [mf/memo]} - [{:keys [zoom vbox hover-frame disabled-guides modifiers guides]}] - (let [guides + [{:keys [zoom vbox hover-frame disabled-guides modifiers guides wasm-guides?]}] + (let [visible-guides (mf/with-memo [guides vbox] (->> (vals guides) (filter (partial guide-inside-vbox? zoom vbox)))) @@ -616,6 +625,23 @@ (st/emit! (dw/show-guide-context-menu {:position position :guide guide}))))) + ;; When guides are WASM-rendered, right-click hit testing is delegated to + ;; the render engine instead of per-guide SVG areas. + on-wasm-context-menu + (mf/use-fn + (mf/deps guides zoom disabled-guides) + (fn [event] + (when-not disabled-guides + (let [position (dom/get-client-position event) + pt (uwvv/point->viewport position) + index (when pt (wasm.api/find-guide-at pt zoom)) + guide (guide-by-serialized-index guides index)] + (when guide + (dom/prevent-default event) + (dom/stop-propagation event) + (st/emit! (dw/show-guide-context-menu {:position position + :guide guide}))))))) + frame-modifiers (-> (group-by :id modifiers) (update-vals (comp :transform first)))] @@ -623,6 +649,12 @@ (mf/with-effect [hover-frame] (mf/set-ref-val! hover-frame-ref hover-frame)) + (mf/with-effect [wasm-guides? disabled-guides on-wasm-context-menu] + (when (and wasm-guides? (not disabled-guides)) + (when-let [viewport @uwvv/viewport-ref] + (.addEventListener viewport "contextmenu" on-wasm-context-menu true) + #(.removeEventListener viewport "contextmenu" on-wasm-context-menu true)))) + [:g.guides {:pointer-events "none"} [:> new-guide-area* {:vbox vbox :zoom zoom @@ -636,16 +668,17 @@ :get-hover-frame get-hover-frame :disabled-guides disabled-guides}] - (for [{:keys [id frame-id] :as guide} guides] - (when (or (nil? frame-id) - (empty? focus) - (contains? focus frame-id)) - [:> guide* {:key (dm/str "guide-" id) - :guide guide - :vbox vbox - :zoom zoom - :frame-transform (get frame-modifiers frame-id) - :get-hover-frame get-hover-frame - :on-guide-change on-guide-change - :on-guide-context-menu on-guide-context-menu - :disabled-guides disabled-guides}]))])) + (when-not wasm-guides? + (for [{:keys [id frame-id] :as guide} visible-guides] + (when (or (nil? frame-id) + (empty? focus) + (contains? focus frame-id)) + [:> guide* {:key (dm/str "guide-" id) + :guide guide + :vbox vbox + :zoom zoom + :frame-transform (get frame-modifiers frame-id) + :get-hover-frame get-hover-frame + :on-guide-change on-guide-change + :on-guide-context-menu on-guide-context-menu + :disabled-guides disabled-guides}])))])) diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 744ae0be23..df13ee6b82 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -542,7 +542,7 @@ ;; 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 {})))) + (wasm.api/set-guides (if (and show-rulers? show-grids?) (or 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) @@ -837,8 +837,8 @@ [:> guides/viewport-guides* {:zoom zoom :vbox vbox - ;; :guides guides - :guides #{} + :guides guides + :wasm-guides? true :hover-frame guide-frame :disabled-guides disabled-guides? :modifiers wasm-modifiers}]) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 98fcabb768..2cdfef8c92 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2254,6 +2254,20 @@ (h/call wasm/internal-module "_set_guides") (request-render "set-guides"))) +;; Screen-space hit tolerance for ruler guides. Must match +;; `guide-active-area` in `app.main.ui.workspace.viewport.guides`. +(def ^:private guide-active-area 16) + +(defn find-guide-at + "Returns the serialized guide index at `position` (viewport coordinates), + or -1 when no guide is within the hit tolerance." + [position zoom] + (h/call wasm/internal-module "_find_guide_at" + (:x position) + (:y position) + zoom + guide-active-area)) + (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 e8518ebc28..db2c62845d 100644 --- a/frontend/src/app/render_wasm/serializers.cljs +++ b/frontend/src/app/render_wasm/serializers.cljs @@ -309,14 +309,14 @@ "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))) + (+ 4 (* (count (or 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)) + (let [guides (vec (vals (or guides {}))) total (count guides)] (aset heapu32 offset total) (loop [i 0] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b613ec5586..ddf6fd7184 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -865,8 +865,10 @@ impl RenderState { self.surfaces.clear_target(skia::Color::TRANSPARENT); self.surfaces.copy_backbuffer_to_target_replace(); } else { - self.surfaces.copy_backbuffer_to_target(); + self.surfaces + .copy_backbuffer_to_target(self.background_color); } + if self.options.is_debug_visible() { debug::render(self); } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 867b657c7b..dcbe89901d 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -524,6 +524,7 @@ impl Surfaces { tile_viewbox: &TileViewbox, background: skia::Color, ) { + // self.backbuffer.canvas().clear(background); self.tiles.update(viewbox, tile_viewbox); let atlas_image = self.tile_atlas.image_snapshot(); let canvas = self.backbuffer.canvas(); @@ -854,10 +855,17 @@ impl Surfaces { /// Copy the current `Backbuffer` contents into `Target`. /// This is a GPU→GPU copy via Skia (no ReadPixels). - pub fn copy_backbuffer_to_target(&mut self) { + /// + /// `Target` is cleared to `background` first so UI overlay pixels (guides, + /// grid) from the previous frame are fully erased. Without this, `SrcOver` + /// compositing would keep stale overlay pixels wherever the backbuffer is + /// transparent. + pub fn copy_backbuffer_to_target(&mut self, background: skia::Color) { let sampling_options = self.sampling_options; + let canvas = self.target.canvas(); + canvas.clear(background); self.backbuffer.draw( - self.target.canvas(), + canvas, (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), diff --git a/render-wasm/src/render/ui/guides.rs b/render-wasm/src/render/ui/guides.rs index 4af6333e55..e72ee43da1 100644 --- a/render-wasm/src/render/ui/guides.rs +++ b/render-wasm/src/render/ui/guides.rs @@ -24,8 +24,10 @@ 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(Into::::into(guide.color)); - paint.set_anti_alias(true); - paint.set_stroke_width(1.0 / zoom); + // we disable antialias and increase the stroke thickness so the guides + // do not appear faint or blurry. + paint.set_anti_alias(false); + paint.set_stroke_width(2.0 / zoom); let (x1, y1, x2, y2) = match guide.kind { GuideKind::Vertical(x) => (x, area.top, x, area.bottom), diff --git a/render-wasm/src/state/ui.rs b/render-wasm/src/state/ui.rs index ee1a129ca1..b9d053de50 100644 --- a/render-wasm/src/state/ui.rs +++ b/render-wasm/src/state/ui.rs @@ -15,6 +15,9 @@ impl GuidePool { } pub fn set(&mut self, guides: Vec) { + self.horizontal.clear(); + self.vertical.clear(); + for guide in guides { match guide.kind { GuideKind::Vertical(_) => self.vertical.push(guide), @@ -56,6 +59,7 @@ impl GuidePool { return None; } + // FIXME: do binary search instead let idx = guides.partition_point(|guide| guide.position() < coord); let mut closest: Option<&Guide> = None; let mut closest_dist = world_tolerance; @@ -96,8 +100,7 @@ impl UIState { self.guides.set(guides); } - #[allow(dead_code)] - fn find_guide_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> { + pub fn find_guide_at(&self, x: f32, y: f32, zoom: f32, tolerance: f32) -> Option<&Guide> { self.guides.find_at(x, y, zoom, tolerance) } } @@ -121,6 +124,31 @@ mod tests { pool } + #[test] + fn set_replaces_existing_guides() { + let mut pool = pool_with(vec![vertical_guide(100.0, 0)]); + pool.set(vec![vertical_guide(200.0, 0)]); + + assert_eq!(pool.vertical.len(), 1); + assert_eq!(pool.vertical[0].kind, GuideKind::Vertical(200.0)); + assert!(pool.horizontal.is_empty()); + } + + #[test] + fn set_drops_removed_guides() { + let mut pool = pool_with(vec![ + vertical_guide(100.0, 0), + vertical_guide(200.0, 1), + horizontal_guide(300.0, 2), + ]); + pool.set(vec![vertical_guide(100.0, 0), horizontal_guide(300.0, 1)]); + + assert_eq!(pool.vertical.len(), 1); + assert_eq!(pool.horizontal.len(), 1); + assert_eq!(pool.vertical[0].kind, GuideKind::Vertical(100.0)); + assert_eq!(pool.horizontal[0].kind, GuideKind::Horizontal(300.0)); + } + #[test] fn find_at_returns_none_when_no_guides() { let pool = GuidePool::new(); diff --git a/render-wasm/src/wasm/ui.rs b/render-wasm/src/wasm/ui.rs index b30f79425f..2c03fd9e10 100644 --- a/render-wasm/src/wasm/ui.rs +++ b/render-wasm/src/wasm/ui.rs @@ -1,9 +1,10 @@ use crate::mem; use crate::{ error::{Error, Result}, - globals::get_ui_state, + globals::{get_render_state, get_ui_state}, ui::{Guide, GuideKind}, }; +use crate::with_state; use macros::{wasm_error, ToJs}; const RAW_GUIDE_SIZE: usize = std::mem::size_of::(); @@ -93,5 +94,21 @@ pub extern "C" fn set_guides() -> Result<()> { get_ui_state().set_guides(guides); mem::free_bytes()?; + + // Guides are drawn on the UI overlay composited onto `Target`. Refresh the + // presented frame immediately so removed guides do not linger as stale pixels. + with_state!(state, { + get_render_state().present_frame(&state.shapes); + }); + Ok(()) } + +#[wasm_error] +#[no_mangle] +pub extern "C" fn find_guide_at(x: f32, y: f32, zoom: f32, tolerance: f32) -> Result { + Ok(get_ui_state() + .find_guide_at(x, y, zoom, tolerance) + .map(|guide| guide.index as i32) + .unwrap_or(-1)) +}