mirror of
https://github.com/penpot/penpot.git
synced 2026-06-16 04:12:03 +00:00
✨ Add wasm rulers
This commit is contained in:
parent
f3f697b4a2
commit
a5e6143a32
@ -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*
|
||||
|
||||
@ -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")
|
||||
|
||||
BIN
render-wasm/src/fonts/WorkSans-Numeric.ttf
Normal file
BIN
render-wasm/src/fonts/WorkSans-Numeric.ttf
Normal file
Binary file not shown.
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
447
render-wasm/src/render/rulers.rs
Normal file
447
render-wasm/src/render/rulers.rs
Normal 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();
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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::*;
|
||||
|
||||
|
||||
38
render-wasm/src/state/rulers.rs
Normal file
38
render-wasm/src/state/rulers.rs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
42
render-wasm/src/wasm/rulers.rs
Normal file
42
render-wasm/src/wasm/rulers.rs
Normal 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(())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user