Add wasm rulers

This commit is contained in:
Elena Torro 2026-05-12 12:58:36 +02:00
parent f3f697b4a2
commit a5e6143a32
11 changed files with 659 additions and 11 deletions

View File

@ -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
;; `<body>`, 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*

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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