mirror of
https://github.com/penpot/penpot.git
synced 2026-08-01 19:06:18 +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.main.ui.workspace.viewport.widgets :as widgets]
|
||||||
[app.render-wasm.api :as wasm.api]
|
[app.render-wasm.api :as wasm.api]
|
||||||
[app.util.debug :as dbg]
|
[app.util.debug :as dbg]
|
||||||
|
[app.util.dom :as dom]
|
||||||
[app.util.text-editor :as ted]
|
[app.util.text-editor :as ted]
|
||||||
[app.util.timers :as ts]
|
[app.util.timers :as ts]
|
||||||
|
[cuerdas.core :as str]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[promesa.core :as p]
|
[promesa.core :as p]
|
||||||
[rumext.v2 :as mf]))
|
[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
|
;; --- Viewport
|
||||||
|
|
||||||
(defn- apply-modifiers-to-objects
|
(defn- apply-modifiers-to-objects
|
||||||
@ -491,6 +535,41 @@
|
|||||||
(when (and @canvas-init? hover-grid?)
|
(when (and @canvas-init? hover-grid?)
|
||||||
(wasm.api/show-grid @hover-top-frame-id)))
|
(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-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
|
||||||
(hooks/setup-viewport-size vport viewport-ref)
|
(hooks/setup-viewport-size vport viewport-ref)
|
||||||
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
||||||
@ -773,15 +852,6 @@
|
|||||||
[:& presence/active-cursors
|
[:& presence/active-cursors
|
||||||
{:page-id page-id}])
|
{: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?)
|
(when (and show-rulers? show-grids?)
|
||||||
[:> guides/viewport-guides*
|
[:> guides/viewport-guides*
|
||||||
|
|||||||
@ -1999,6 +1999,33 @@
|
|||||||
(aget buffer 3)))
|
(aget buffer 3)))
|
||||||
(request-render "show-grid"))
|
(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
|
(defn clear-grid
|
||||||
[]
|
[]
|
||||||
(h/call wasm/internal-module "_hide_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;
|
pub mod grid_layout;
|
||||||
mod images;
|
mod images;
|
||||||
mod options;
|
mod options;
|
||||||
|
pub mod rulers;
|
||||||
mod shadows;
|
mod shadows;
|
||||||
mod strokes;
|
mod strokes;
|
||||||
mod surfaces;
|
mod surfaces;
|
||||||
@ -26,7 +27,7 @@ use crate::shapes::{
|
|||||||
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
|
all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor,
|
||||||
Stroke, StrokeKind, TextContent, Type,
|
Stroke, StrokeKind, TextContent, Type,
|
||||||
};
|
};
|
||||||
use crate::state::{ShapesPoolMutRef, ShapesPoolRef};
|
use crate::state::{RulerState, ShapesPoolMutRef, ShapesPoolRef};
|
||||||
use crate::tiles::{self, PendingTiles, TileRect};
|
use crate::tiles::{self, PendingTiles, TileRect};
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
use crate::view::Viewbox;
|
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_blurs: Vec<Option<Blur>>, // FIXME: why is this an option?
|
||||||
pub nested_shadows: Vec<Vec<Shadow>>,
|
pub nested_shadows: Vec<Vec<Shadow>>,
|
||||||
pub show_grid: Option<Uuid>,
|
pub show_grid: Option<Uuid>,
|
||||||
|
pub rulers: RulerState,
|
||||||
pub focus_mode: FocusMode,
|
pub focus_mode: FocusMode,
|
||||||
pub touched_ids: HashSet<Uuid>,
|
pub touched_ids: HashSet<Uuid>,
|
||||||
/// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.)
|
/// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.)
|
||||||
@ -572,6 +574,7 @@ impl RenderState {
|
|||||||
nested_blurs: vec![],
|
nested_blurs: vec![],
|
||||||
nested_shadows: vec![],
|
nested_shadows: vec![],
|
||||||
show_grid: None,
|
show_grid: None,
|
||||||
|
rulers: RulerState::default(),
|
||||||
focus_mode: FocusMode::new(),
|
focus_mode: FocusMode::new(),
|
||||||
touched_ids: HashSet::default(),
|
touched_ids: HashSet::default(),
|
||||||
ignore_nested_blurs: false,
|
ignore_nested_blurs: false,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use crate::uuid::Uuid;
|
|||||||
pub static DEFAULT_EMOJI_FONT: &str = "noto-color-emoji";
|
pub static DEFAULT_EMOJI_FONT: &str = "noto-color-emoji";
|
||||||
|
|
||||||
const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../fonts/sourcesanspro-regular.ttf");
|
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 {
|
pub fn default_font() -> String {
|
||||||
let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal);
|
let family = FontFamily::new(default_font_uuid(), 400, FontStyle::Normal);
|
||||||
@ -23,6 +24,7 @@ pub struct FontStore {
|
|||||||
font_provider: textlayout::TypefaceFontProvider,
|
font_provider: textlayout::TypefaceFontProvider,
|
||||||
font_collection: textlayout::FontCollection,
|
font_collection: textlayout::FontCollection,
|
||||||
debug_font: Font,
|
debug_font: Font,
|
||||||
|
ui_font: Font,
|
||||||
fallback_fonts: HashSet<String>,
|
fallback_fonts: HashSet<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,11 +43,17 @@ impl FontStore {
|
|||||||
|
|
||||||
let debug_font = skia::Font::new(debug_typeface, 10.0);
|
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 {
|
Ok(Self {
|
||||||
font_mgr,
|
font_mgr,
|
||||||
font_provider,
|
font_provider,
|
||||||
font_collection,
|
font_collection,
|
||||||
debug_font,
|
debug_font,
|
||||||
|
ui_font,
|
||||||
fallback_fonts: HashSet::new(),
|
fallback_fonts: HashSet::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -67,6 +75,10 @@ impl FontStore {
|
|||||||
&self.debug_font
|
&self.debug_font
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn ui_font(&self) -> &Font {
|
||||||
|
&self.ui_font
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add(
|
pub fn add(
|
||||||
&mut self,
|
&mut self,
|
||||||
family: FontFamily,
|
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 skia_safe::{self as skia, Color4f};
|
||||||
|
|
||||||
use super::{RenderState, ShapesPoolRef, SurfaceId};
|
use super::{RenderState, ShapesPoolRef, SurfaceId};
|
||||||
use crate::render::grid_layout;
|
use crate::render::{grid_layout, rulers};
|
||||||
use crate::shapes::{Layout, Type};
|
use crate::shapes::{Layout, Type};
|
||||||
|
|
||||||
pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) {
|
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();
|
canvas.restore();
|
||||||
|
|
||||||
render_state.surfaces.draw_into(
|
render_state.surfaces.draw_into(
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
use skia_safe::{self as skia, textlayout::FontCollection, Path, Point};
|
use skia_safe::{self as skia, textlayout::FontCollection, Path, Point};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
mod rulers;
|
||||||
mod shapes_pool;
|
mod shapes_pool;
|
||||||
mod text_editor;
|
mod text_editor;
|
||||||
|
pub use rulers::RulerState;
|
||||||
pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef};
|
pub use shapes_pool::{ShapesPool, ShapesPoolMutRef, ShapesPoolRef};
|
||||||
pub use text_editor::*;
|
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 layouts;
|
||||||
pub mod mem;
|
pub mod mem;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
|
pub mod rulers;
|
||||||
pub mod shadows;
|
pub mod shadows;
|
||||||
pub mod shapes;
|
pub mod shapes;
|
||||||
pub mod strokes;
|
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