From a5e6143a32d7eea8ee4b31a0ca499561abcfabfc Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 12 May 2026 12:58:36 +0200 Subject: [PATCH] :sparkles: Add wasm rulers --- .../app/main/ui/workspace/viewport_wasm.cljs | 88 +++- frontend/src/app/render_wasm/api.cljs | 27 ++ render-wasm/src/fonts/WorkSans-Numeric.ttf | Bin 0 -> 9828 bytes render-wasm/src/render.rs | 5 +- render-wasm/src/render/fonts.rs | 12 + render-wasm/src/render/rulers.rs | 447 ++++++++++++++++++ render-wasm/src/render/ui.rs | 8 +- render-wasm/src/state.rs | 2 + render-wasm/src/state/rulers.rs | 38 ++ render-wasm/src/wasm.rs | 1 + render-wasm/src/wasm/rulers.rs | 42 ++ 11 files changed, 659 insertions(+), 11 deletions(-) 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/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 69ef789c27..9ddff41e8f 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -57,12 +57,56 @@ [app.main.ui.workspace.viewport.widgets :as widgets] [app.render-wasm.api :as wasm.api] [app.util.debug :as dbg] + [app.util.dom :as dom] [app.util.text-editor :as ted] [app.util.timers :as ts] + [cuerdas.core :as str] [beicon.v2.core :as rx] [promesa.core :as p] [rumext.v2 :as mf])) +;; --- Ruler theme color resolution +;; +;; Theme classes (`.default` for dark, `.light` for light) live on +;; ``, so the CSS custom properties we care about only resolve +;; against an element inside body. We read them with +;; `getComputedStyle(document.body)` and normalize the returned hex. + +(def ^:private ruler-color-vars + {:bg "--panel-background-color" + :border "--panel-border-color" + :label "--layer-row-foreground-color" + :accent "--color-accent-tertiary"}) + +(def ^:private ruler-color-fallbacks + {:bg "#181818" + :border "#2e3434" + :label "#8f9da3" + :accent "#00d1b8"}) + +(defn- normalize-hex + "CSS may return `#rgb` (4 chars) or `#rrggbb` (7 chars). Expand short + form, trim whitespace, and reject anything else (rgba, hsl, empty)." + [raw] + (let [s (some-> raw str/trim)] + (cond + (and (string? s) (= 7 (count s)) (str/starts-with? s "#")) s + (and (string? s) (= 4 (count s)) (str/starts-with? s "#")) + (let [r (subs s 1 2) g (subs s 2 3) b (subs s 3 4)] + (str "#" r r g g b b)) + :else nil))) + +(defn- resolve-ruler-color [k] + (or (normalize-hex (dom/get-css-variable (get ruler-color-vars k) js/document.body)) + (get ruler-color-fallbacks k))) + +(defn- push-ruler-colors! [] + (wasm.api/set-rulers-colors! + (resolve-ruler-color :bg) + (resolve-ruler-color :border) + (resolve-ruler-color :label) + (resolve-ruler-color :accent))) + ;; --- Viewport (defn- apply-modifiers-to-objects @@ -491,6 +535,41 @@ (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). + (let [ruler-selection (when (and show-rulers? + (d/not-empty? selected-shapes)) + (gsh/shapes->rect selected-shapes))] + + (mf/with-effect [@canvas-init?] + (when @canvas-init? + (push-ruler-colors!) + (wasm.api/request-render "rulers-colors-init") + (let [obs (js/MutationObserver. + (fn [_] + (push-ruler-colors!) + (wasm.api/request-render "rulers-colors-theme")))] + (.observe obs js/document.body + #js {:attributes true :attributeFilter #js ["class"]}) + (fn [] (.disconnect obs))))) + + (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) + (wasm.api/request-render "rulers-offsets"))) + + (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")))) + (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?) @@ -773,15 +852,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* diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 6b20f1c9d3..14c9b2b336 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -1999,6 +1999,33 @@ (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-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") 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/render.rs b/render-wasm/src/render.rs index 72cc2078f2..abe7ae2f1e 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; @@ -358,6 +359,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.) @@ -572,6 +574,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, diff --git a/render-wasm/src/render/fonts.rs b/render-wasm/src/render/fonts.rs index d528d7b691..af4b811079 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, 10.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..7b25601840 --- /dev/null +++ b/render-wasm/src/render/rulers.rs @@ -0,0 +1,447 @@ +//! 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) { + if !state.visible { + return; + } + + 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); + + let ctx = RenderCtx { + canvas, + state, + font: &font, + vx: area.left, + vy: area.top, + bar: RULER_AREA_SIZE * zi, + zi, + }; + + draw_background(&ctx, vw, vh); + + let step = calculate_step_size(zoom); + 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_horizontal_mask(ctx, Rect::from_xywh(left_x, ctx.vy, mask_w, ctx.bar), false); + let right_x = sel.right - OVER_NUMBER_SIZE * (1.0 - OVER_NUMBER_PERCENT) * zi; + draw_horizontal_mask(ctx, Rect::from_xywh(right_x, ctx.vy, mask_w, ctx.bar), 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(); +} + +/// Fills `rect` with a horizontal gradient of `state.bg_color`. When +/// `fade_to_right` is false the gradient is transparent → opaque (used to +/// the LEFT of the selection band). When true the gradient is opaque → +/// transparent (used to the RIGHT of the band). +fn draw_horizontal_mask(ctx: &RenderCtx, rect: Rect, fade_to_right: 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_right { + ( + &[opaque, opaque, transparent], + &[0.0, 1.0 - GRADIENT_FADE_FRACTION, 1.0], + ) + } else { + ( + &[transparent, opaque, opaque], + &[0.0, GRADIENT_FADE_FRACTION, 1.0], + ) + }; + let shader = skia::gradient_shader::linear( + ((rect.left, rect.top), (rect.right, rect.top)), + &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); +} + +/// Same as `draw_horizontal_mask` but the gradient runs top→bottom. +/// `fade_to_bottom = false` is the "above the band" mask (transparent +/// at top, opaque toward the band); `true` is "below the band". +fn draw_vertical_mask(ctx: &RenderCtx, rect: Rect, fade_to_bottom: 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_bottom { + ( + &[opaque, opaque, transparent], + &[0.0, 1.0 - GRADIENT_FADE_FRACTION, 1.0], + ) + } else { + ( + &[transparent, opaque, opaque], + &[0.0, GRADIENT_FADE_FRACTION, 1.0], + ) + }; + let shader = skia::gradient_shader::linear( + ((rect.left, rect.top), (rect.left, rect.bottom)), + &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_vertical_mask(ctx, Rect::from_xywh(ctx.vx, top_y, ctx.bar, mask_h), false); + let bottom_y = sel.bottom - OVER_NUMBER_SIZE * (1.0 - OVER_NUMBER_PERCENT) * zi; + draw_vertical_mask( + ctx, + Rect::from_xywh(ctx.vx, bottom_y, ctx.bar, mask_h), + 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..3463d6d43f 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,12 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { } } + if render_state.rulers.visible { + 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 116e7a67c4..10c8211693 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::*; diff --git a/render-wasm/src/state/rulers.rs b/render-wasm/src/state/rulers.rs new file mode 100644 index 0000000000..993f6d5353 --- /dev/null +++ b/render-wasm/src/state/rulers.rs @@ -0,0 +1,38 @@ +use skia_safe::{self as skia, Rect}; + +#[derive(Debug, Clone, Copy)] +pub struct RulerState { + pub visible: 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, + 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..3104441433 --- /dev/null +++ b/render-wasm/src/wasm/rulers.rs @@ -0,0 +1,42 @@ +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_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(()) +}