mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
Some checks failed
Rust check (native) / macos-latest / 1.94 (push) Waiting to run
Rust check (native) / windows-latest / 1.94 (push) Waiting to run
Rust multi-platform build / linux-aarch64 (push) Waiting to run
Rust multi-platform build / macos-aarch64 (push) Waiting to run
Rust multi-platform build / windows-x86_64 (push) Waiting to run
Rust multi-platform build / macos-x86_64 (push) Waiting to run
Rust multi-platform build / windows-aarch64 (push) Waiting to run
Rust multi-platform build / ios-aarch64 (cargo check only) (push) Waiting to run
Rust multi-platform build / ios-aarch64-sim (cargo check only) (push) Waiting to run
Rust check (native) / ubuntu-latest / 1.94 (push) Failing after 2s
Rust check (native) / cargo-deny (native) (push) Failing after 2s
Rust check (native) / diagnostics golden drift (push) Failing after 2s
Rust multi-platform build / linux-x86_64 (push) Failing after 1s
Rust multi-platform build / wasm32-unknown-unknown / op-host-web (compile guard) (push) Failing after 2s
Rust multi-platform build / android-aarch64 (cargo check only) (push) Failing after 1s
Rust multi-platform build / android-x86_64 (cargo check only) (push) Failing after 1s
WASM bundle check (kickoff §1.2) / cargo check --target wasm32-unknown-unknown (push) Failing after 1s
WASM bundle check (kickoff §1.2) / cargo-deny --target wasm32-unknown-unknown check bans (push) Failing after 2s
799 lines
30 KiB
Rust
799 lines
30 KiB
Rust
//! Per-kind node painter for [`super::canvas_viewport::CanvasViewport`].
|
||
//!
|
||
//! Walks a [`LayoutScene`](crate::layout_scene::LayoutScene)
|
||
//! [`SceneNode`] tree and reproduces the canvas pixel-for-pixel:
|
||
//! per-kind paint (Frame / Group / Rect / Ellipse / Polygon / Line /
|
||
//! Path / Text / `icon_font`), per-node rotation, corner radius,
|
||
//! pre-resolved fills / strokes, drop-shadow effects, viewport culling
|
||
//! and CJK-aware text wrap.
|
||
//!
|
||
//! Split out of `canvas_viewport.rs` to keep that file under the
|
||
//! 800-line ceiling. The scene's geometry is already layout-resolved
|
||
//! and its fills are already `$ref`-resolved, so this painter applies
|
||
//! only the viewport transform — no second layout pass, no variable
|
||
//! lookup.
|
||
|
||
use crate::layout_scene::{regular_polygon_points, SceneNode};
|
||
use crate::layout_scene::{Effect, NodeKind};
|
||
use crate::widgets::canvas_viewport::EditCaret;
|
||
use crate::widgets::canvas_viewport_overlay::{paint_fill_then_stroke, wrap_text};
|
||
use crate::widgets::PaintCx;
|
||
use crate::{Point2D, Rect, TextLayout};
|
||
|
||
/// Paint every `Effect::DropShadow` on `node` as a blurred shape
|
||
/// behind its fill. The shadow corner radius matches the node
|
||
/// kind — `corner_radius` for Frame / Rect, min-half for an
|
||
/// ellipse silhouette. Offset + blur scale by `zoom` so the
|
||
/// shadow tracks the node across viewport zoom.
|
||
fn paint_drop_shadows(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, zoom: f32) {
|
||
let radius = if node.kind == NodeKind::Ellipse {
|
||
world_rect.size.x.min(world_rect.size.y) / 2.0
|
||
} else {
|
||
node.corner_radius * zoom
|
||
};
|
||
for effect in &node.effects {
|
||
let Effect::DropShadow(s) = effect;
|
||
let shadow_rect = Rect {
|
||
origin: Point2D::new(
|
||
world_rect.origin.x + s.offset_x * zoom,
|
||
world_rect.origin.y + s.offset_y * zoom,
|
||
),
|
||
size: world_rect.size,
|
||
};
|
||
cx.backend
|
||
.fill_drop_shadow(shadow_rect, radius, s.blur * zoom, s.color);
|
||
}
|
||
}
|
||
|
||
/// Tessellate an ellipse arc / pie / donut-sector into a closed
|
||
/// polygon outline. `start_deg` / `sweep_deg` use the screen
|
||
/// convention (0° = +X, positive = clockwise); `inner` is the
|
||
/// donut-hole radius as a 0.0..=1.0 fraction.
|
||
pub(crate) fn arc_polygon(rect: Rect, start_deg: f32, sweep_deg: f32, inner: f32) -> Vec<Point2D> {
|
||
let cx_pt = rect.origin.x + rect.size.x / 2.0;
|
||
let cy_pt = rect.origin.y + rect.size.y / 2.0;
|
||
let rx = rect.size.x / 2.0;
|
||
let ry = rect.size.y / 2.0;
|
||
// ~1 segment per 4° of sweep, clamped to a sane range.
|
||
let segs = ((sweep_deg.abs() / 4.0).ceil() as usize).clamp(2, 512);
|
||
let point = |frac: f32, scale: f32| -> Point2D {
|
||
let ang = (start_deg + sweep_deg * frac).to_radians();
|
||
Point2D::new(
|
||
cx_pt + rx * scale * ang.cos(),
|
||
cy_pt + ry * scale * ang.sin(),
|
||
)
|
||
};
|
||
let mut poly = Vec::with_capacity(segs * 2 + 2);
|
||
if inner > 0.001 {
|
||
// Annular sector: outer arc start→end, inner arc end→start.
|
||
for i in 0..=segs {
|
||
poly.push(point(i as f32 / segs as f32, 1.0));
|
||
}
|
||
for i in (0..=segs).rev() {
|
||
poly.push(point(i as f32 / segs as f32, inner));
|
||
}
|
||
} else {
|
||
// Pie wedge: centre + outer arc.
|
||
poly.push(Point2D::new(cx_pt, cy_pt));
|
||
for i in 0..=segs {
|
||
poly.push(point(i as f32 / segs as f32, 1.0));
|
||
}
|
||
}
|
||
poly
|
||
}
|
||
|
||
/// Paint an Ellipse node — a full oval when no arc geometry is
|
||
/// authored, otherwise a tessellated pie / arc / donut sector.
|
||
fn paint_ellipse(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, zoom: f32) {
|
||
let inner = node.arc_inner_radius.unwrap_or(0.0).clamp(0.0, 1.0);
|
||
let has_arc = node.arc_start_angle.is_some() || node.arc_sweep_angle.is_some() || inner > 0.001;
|
||
let sweep = node.arc_sweep_angle.unwrap_or(360.0);
|
||
// A full-circle sweep with no donut hole is just a plain oval.
|
||
if !has_arc || (sweep.abs() >= 359.9 && inner <= 0.001) {
|
||
if let Some(fill) = node.fill {
|
||
cx.backend.fill_oval(world_rect, fill);
|
||
}
|
||
if let Some(stroke) = node.stroke {
|
||
cx.backend
|
||
.stroke_oval(world_rect, stroke.color, stroke.width * zoom);
|
||
}
|
||
return;
|
||
}
|
||
let start = node.arc_start_angle.unwrap_or(0.0);
|
||
let poly = arc_polygon(world_rect, start, sweep, inner);
|
||
if let Some(fill) = node.fill {
|
||
cx.backend.fill_polygon(&poly, fill);
|
||
}
|
||
if let Some(stroke) = node.stroke {
|
||
let w = stroke.width * zoom;
|
||
if sweep.abs() >= 359.9 && inner > 0.001 {
|
||
// Full ring — stroke the two concentric ovals so the
|
||
// polygon's radial seam isn't drawn.
|
||
cx.backend.stroke_oval(world_rect, stroke.color, w);
|
||
let iw = world_rect.size.x * inner;
|
||
let ih = world_rect.size.y * inner;
|
||
let inner_rect = Rect {
|
||
origin: Point2D::new(
|
||
world_rect.origin.x + (world_rect.size.x - iw) / 2.0,
|
||
world_rect.origin.y + (world_rect.size.y - ih) / 2.0,
|
||
),
|
||
size: Point2D::new(iw, ih),
|
||
};
|
||
cx.backend.stroke_oval(inner_rect, stroke.color, w);
|
||
} else {
|
||
cx.backend.stroke_polygon(&poly, stroke.color, w);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One point on the cubic Bezier `p0→p3` (control points `p1`,`p2`).
|
||
pub(crate) fn cubic_point(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, t: f32) -> Point2D {
|
||
let u = 1.0 - t;
|
||
let (w0, w1, w2, w3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||
Point2D::new(
|
||
w0 * p0.x + w1 * p1.x + w2 * p2.x + w3 * p3.x,
|
||
w0 * p0.y + w1 * p1.y + w2 * p2.y + w3 * p3.y,
|
||
)
|
||
}
|
||
|
||
/// One flattened segment `a → b` appended onto `out` — a cubic when
|
||
/// either endpoint carries a handle, else a straight line.
|
||
fn flatten_segment(
|
||
a: &crate::layout_scene::SceneAnchor,
|
||
b: &crate::layout_scene::SceneAnchor,
|
||
out: &mut Vec<Point2D>,
|
||
) {
|
||
let (p0, p3) = (a.pos, b.pos);
|
||
let p1 = a.handle_out.unwrap_or(p0);
|
||
let p2 = b.handle_in.unwrap_or(p3);
|
||
if p1 == p0 && p2 == p3 {
|
||
out.push(p3); // straight segment
|
||
} else {
|
||
for i in 1..=16 {
|
||
out.push(cubic_point(p0, p1, p2, p3, i as f32 / 16.0));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Flatten a Path scene node into a doc-space polyline — cubic
|
||
/// segments whose endpoints carry handles are tessellated; a
|
||
/// handle-free path falls back to the straight `points` polyline.
|
||
/// A closed path appends the last-anchor → first-anchor segment.
|
||
pub(crate) fn flatten_path(node: &SceneNode) -> Vec<Point2D> {
|
||
let anchors = &node.path_anchors;
|
||
let has_handle = anchors
|
||
.iter()
|
||
.any(|a| a.handle_in.is_some() || a.handle_out.is_some());
|
||
if anchors.len() < 2 || !has_handle {
|
||
let mut out = node.points.clone();
|
||
// Closed handle-free path — link the polyline back to its
|
||
// start so the closing edge is drawn.
|
||
if node.path_closed && out.len() > 2 {
|
||
out.push(out[0]);
|
||
}
|
||
return out;
|
||
}
|
||
let mut out = Vec::with_capacity(anchors.len() * 16 + 16);
|
||
out.push(anchors[0].pos);
|
||
for pair in anchors.windows(2) {
|
||
flatten_segment(&pair[0], &pair[1], &mut out);
|
||
}
|
||
if node.path_closed {
|
||
flatten_segment(&anchors[anchors.len() - 1], &anchors[0], &mut out);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Recursively paint one resolved [`SceneNode`] and its subtree.
|
||
///
|
||
/// `viewport_origin` is the canvas-rect origin shifted by the
|
||
/// viewport pan; `zoom` is the viewport zoom. The scene already
|
||
/// carries layout-resolved absolute doc-space bounds, so paint is a
|
||
/// straight `doc → world` transform.
|
||
pub fn paint_node(
|
||
cx: &mut PaintCx<'_>,
|
||
node: &SceneNode,
|
||
viewport_origin: Point2D,
|
||
zoom: f32,
|
||
edit_caret: Option<EditCaret>,
|
||
cull: Rect,
|
||
) {
|
||
// Hidden nodes (and their subtree) skip canvas paint entirely.
|
||
// Layer panel still shows them, dimmed, so the user can unhide.
|
||
if node.hidden {
|
||
return;
|
||
}
|
||
let world_rect = Rect {
|
||
origin: Point2D::new(
|
||
viewport_origin.x + node.bounds.origin.x * zoom,
|
||
viewport_origin.y + node.bounds.origin.y * zoom,
|
||
),
|
||
size: Point2D::new(node.bounds.size.x * zoom, node.bounds.size.y * zoom),
|
||
};
|
||
// Viewport culling — bounded leaves skip paint entirely when
|
||
// off-screen. Containers (bounds = ZERO) always recurse.
|
||
if world_rect.size.x > 0.0 && world_rect.size.y > 0.0 && node.children.is_empty() {
|
||
let off = world_rect.origin.x + world_rect.size.x < cull.origin.x
|
||
|| world_rect.origin.x > cull.origin.x + cull.size.x
|
||
|| world_rect.origin.y + world_rect.size.y < cull.origin.y
|
||
|| world_rect.origin.y > cull.origin.y + cull.size.y;
|
||
if off {
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Wrap the paint in save/rotate/restore if the node carries a
|
||
// non-zero rotation. Rotation pivots around the node's own
|
||
// bounds centre — for containers, this is the aggregate centre.
|
||
let rotated = node.rotation.abs() > f32::EPSILON;
|
||
if rotated {
|
||
let pivot_doc = node.aggregate_bounds();
|
||
let pivot = Point2D::new(
|
||
viewport_origin.x + (pivot_doc.origin.x + pivot_doc.size.x / 2.0) * zoom,
|
||
viewport_origin.y + (pivot_doc.origin.y + pivot_doc.size.y / 2.0) * zoom,
|
||
);
|
||
cx.backend.save();
|
||
cx.backend.rotate(node.rotation, pivot);
|
||
}
|
||
|
||
// Drop shadows paint behind the node's own fill. Only kinds
|
||
// whose silhouette a rounded rect / ellipse can represent
|
||
// faithfully (Frame / Rect / Ellipse) cast one; Polygon / Line
|
||
// / Path shadows are deferred until a shape-mask path exists.
|
||
if !node.effects.is_empty()
|
||
&& world_rect.size.x > 0.0
|
||
&& world_rect.size.y > 0.0
|
||
&& matches!(
|
||
node.kind,
|
||
NodeKind::Frame | NodeKind::Rect | NodeKind::Ellipse
|
||
)
|
||
{
|
||
paint_drop_shadows(cx, node, world_rect, zoom);
|
||
}
|
||
|
||
match &node.kind {
|
||
NodeKind::Frame => {
|
||
// Image-fill Frames paint the bitmap behind their
|
||
// children; gradient + solid fall back to the shared
|
||
// fill/stroke painter. Without this branch a Frame whose
|
||
// primary fill is `PenFill::Image { url }` only shows the
|
||
// grey placeholder + its children, never the image.
|
||
if let Some(src) = node.image_src.as_deref() {
|
||
paint_image_node(cx, node, world_rect, zoom, src);
|
||
} else {
|
||
paint_fill_then_stroke(cx, node, world_rect, zoom, node.fill);
|
||
}
|
||
for child in node.children.iter().rev() {
|
||
paint_node(cx, child, viewport_origin, zoom, edit_caret.clone(), cull);
|
||
}
|
||
}
|
||
NodeKind::Other(tag) if tag == "icon_font" => crate::widgets::icons::paint_icon_font_node(
|
||
cx.backend,
|
||
node.font_family.as_str(),
|
||
node.text.as_deref().unwrap_or(""),
|
||
world_rect,
|
||
node.fill,
|
||
),
|
||
NodeKind::Group | NodeKind::Other(_) => {
|
||
for child in node.children.iter().rev() {
|
||
paint_node(cx, child, viewport_origin, zoom, edit_caret.clone(), cull);
|
||
}
|
||
}
|
||
NodeKind::Rect => {
|
||
// Image nodes land as `kind="rect"` (the loader rewrites
|
||
// their variant so non-image paths keep working). When a
|
||
// `src` is carried, paint the bitmap; the grey `fill`
|
||
// remains as the placeholder visible while the decoder
|
||
// is missing the bytes (corrupt URL / unsupported codec).
|
||
if let Some(src) = node.image_src.as_deref() {
|
||
paint_image_node(cx, node, world_rect, zoom, src);
|
||
} else {
|
||
paint_fill_then_stroke(cx, node, world_rect, zoom, node.fill);
|
||
}
|
||
}
|
||
NodeKind::Ellipse => {
|
||
if let Some(src) = node.image_src.as_deref() {
|
||
// Image-fill ellipse: paint the bitmap clipped to the
|
||
// ellipse silhouette via skia's `clip_oval`-style
|
||
// approximation (no native clip_oval on the trait, so
|
||
// fall back to the rect-clip path the painter has).
|
||
paint_image_node(cx, node, world_rect, zoom, src);
|
||
if let Some(stroke) = node.stroke {
|
||
cx.backend
|
||
.stroke_oval(world_rect, stroke.color, stroke.width * zoom);
|
||
}
|
||
} else {
|
||
paint_ellipse(cx, node, world_rect, zoom);
|
||
}
|
||
}
|
||
NodeKind::Polygon => {
|
||
let pts = regular_polygon_points(world_rect, node.polygon_sides);
|
||
// Image fills paint the bitmap in the AABB underneath the
|
||
// polygon outline; the polygon silhouette is then drawn
|
||
// by the stroke. A perfect clip-to-polygon path lands when
|
||
// `RenderBackend` grows a polygon-clip primitive.
|
||
if let Some(src) = node.image_src.as_deref() {
|
||
paint_image_node(cx, node, world_rect, zoom, src);
|
||
} else if let Some(fill) = node.fill {
|
||
cx.backend.fill_polygon(&pts, fill);
|
||
}
|
||
if let Some(stroke) = node.stroke {
|
||
cx.backend
|
||
.stroke_polygon(&pts, stroke.color, stroke.width * zoom);
|
||
}
|
||
}
|
||
NodeKind::Line => {
|
||
// Top-left → bottom-right diagonal across the bounds,
|
||
// stroked at the stroke width (or 1.5 if no stroke).
|
||
let from = Point2D::new(world_rect.origin.x, world_rect.origin.y);
|
||
let to = Point2D::new(
|
||
world_rect.origin.x + world_rect.size.x,
|
||
world_rect.origin.y + world_rect.size.y,
|
||
);
|
||
let (color, width) = match node.stroke {
|
||
Some(s) => (s.color, s.width * zoom),
|
||
None => (
|
||
node.fill.unwrap_or(crate::Color::BLACK),
|
||
(1.5_f32).max(zoom),
|
||
),
|
||
};
|
||
cx.backend.stroke_line(from, to, color, width);
|
||
}
|
||
NodeKind::Path => {
|
||
if let Some(d) = node.svg_path.as_deref() {
|
||
paint_svg_path_node(cx, node, world_rect, zoom, d);
|
||
if rotated {
|
||
cx.backend.restore();
|
||
}
|
||
return;
|
||
}
|
||
let to_world = |p: Point2D| -> Point2D {
|
||
Point2D::new(
|
||
viewport_origin.x + p.x * zoom,
|
||
viewport_origin.y + p.y * zoom,
|
||
)
|
||
};
|
||
// Bezier-aware: when the path carries anchors with control
|
||
// handles, flatten each cubic segment; otherwise fall back
|
||
// to the straight `points` polyline.
|
||
let polyline = flatten_path(node);
|
||
// A closed path with a fill paints its enclosed area.
|
||
let filled = node.path_closed && node.fill.is_some();
|
||
if filled {
|
||
let world: Vec<Point2D> = polyline.iter().map(|p| to_world(*p)).collect();
|
||
cx.backend.fill_polygon(&world, node.fill.unwrap());
|
||
}
|
||
// Stroke: an explicit stroke always paints; with no
|
||
// stroke, only an UNfilled path strokes (so it stays
|
||
// visible) — a filled path must not draw an implicit
|
||
// outline.
|
||
let stroke = match node.stroke {
|
||
Some(s) => Some((s.color, s.width * zoom)),
|
||
None if !filled => Some((
|
||
node.fill.unwrap_or(crate::Color::BLACK),
|
||
(1.5_f32).max(zoom),
|
||
)),
|
||
None => None,
|
||
};
|
||
if let Some((color, width)) = stroke {
|
||
for pair in polyline.windows(2) {
|
||
cx.backend
|
||
.stroke_line(to_world(pair[0]), to_world(pair[1]), color, width);
|
||
}
|
||
}
|
||
}
|
||
NodeKind::Text => {
|
||
paint_text_node(cx, node, world_rect, zoom, &edit_caret);
|
||
}
|
||
}
|
||
|
||
if rotated {
|
||
cx.backend.restore();
|
||
}
|
||
}
|
||
|
||
fn paint_svg_path_node(
|
||
cx: &mut PaintCx<'_>,
|
||
node: &SceneNode,
|
||
world_rect: Rect,
|
||
zoom: f32,
|
||
d: &str,
|
||
) {
|
||
if let Some(fill) = node.fill {
|
||
cx.backend
|
||
.fill_svg_path(d, world_rect.origin, zoom, 1.0, fill);
|
||
}
|
||
if let Some(stroke) = node.stroke {
|
||
cx.backend.stroke_svg_path(
|
||
d,
|
||
world_rect.origin,
|
||
24.0 * zoom,
|
||
stroke.color,
|
||
stroke.width * zoom,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Paint a Text `SceneNode` — wrapped or single-line text plus the
|
||
/// edit caret when the node is the one being edited.
|
||
/// Decode an inline-base64 `data:image/...;base64,...` URL into the
|
||
/// raw image bytes the backend's `draw_image` decoder expects. Returns
|
||
/// `None` for any URL that isn't an inline base64 payload (file paths,
|
||
/// remote URLs, malformed strings) — those paths are deferred to a
|
||
/// future loader.
|
||
fn data_url_bytes(src: &str) -> Option<Vec<u8>> {
|
||
let after_scheme = src.strip_prefix("data:")?;
|
||
let comma = after_scheme.find(',')?;
|
||
let meta = &after_scheme[..comma];
|
||
let payload = &after_scheme[comma + 1..];
|
||
if !meta.contains(";base64") {
|
||
return None;
|
||
}
|
||
// The base64 alphabet is ASCII; strip any embedded whitespace
|
||
// (line breaks in a wrapped data URL) before decode.
|
||
let clean: String = payload
|
||
.chars()
|
||
.filter(|c| !c.is_ascii_whitespace())
|
||
.collect();
|
||
use base64::engine::general_purpose::STANDARD as B64;
|
||
use base64::Engine as _;
|
||
B64.decode(clean.as_bytes()).ok()
|
||
}
|
||
|
||
/// Hash a string into a stable u64 — drives the backend's image
|
||
/// decode cache so the same `src` doesn't re-decode every frame.
|
||
fn src_hash(src: &str) -> u64 {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
src.hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
/// Paint a raster image inside `world_rect`. Decodes the data URL
|
||
/// once, hands raw bytes + a stable id to the backend cache, then
|
||
/// strokes the corner-radius outline on top so a per-corner radius
|
||
/// authored on the schema still reads.
|
||
fn paint_image_node(
|
||
cx: &mut PaintCx<'_>,
|
||
node: &SceneNode,
|
||
world_rect: Rect,
|
||
zoom: f32,
|
||
src: &str,
|
||
) {
|
||
let bytes = data_url_bytes(src);
|
||
let r = node.corner_radius * zoom;
|
||
let use_round = r > 0.5;
|
||
// Only paint the grey placeholder when the URL can't be decoded
|
||
// — painting it under a transparent raster would leave a grey
|
||
// matte bleeding through the alpha channel.
|
||
if bytes.is_none() {
|
||
if let Some(fill) = node.fill {
|
||
if use_round {
|
||
cx.backend.fill_round_rect(world_rect, r, fill);
|
||
} else {
|
||
cx.backend.fill_rect(world_rect, fill);
|
||
}
|
||
}
|
||
}
|
||
if let Some(bytes) = bytes {
|
||
let id = src_hash(src);
|
||
cx.backend.draw_image_with_options(
|
||
world_rect,
|
||
id,
|
||
&bytes,
|
||
node.image_fit.to_draw_mode(),
|
||
node.image_adjustments,
|
||
);
|
||
}
|
||
if let Some(stroke) = node.stroke {
|
||
let width = stroke.width * zoom;
|
||
if use_round {
|
||
cx.backend
|
||
.stroke_round_rect(world_rect, r, stroke.color, width);
|
||
} else {
|
||
cx.backend.stroke_rect(world_rect, stroke.color, width);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn paint_text_node(
|
||
cx: &mut PaintCx<'_>,
|
||
node: &SceneNode,
|
||
world_rect: Rect,
|
||
zoom: f32,
|
||
edit_caret: &Option<EditCaret>,
|
||
) {
|
||
let text = node.text.as_deref().unwrap_or("");
|
||
// Ink colour follows the resolved fill (defaults to near black).
|
||
let ink = node.fill.unwrap_or(crate::Color {
|
||
r: 0.08,
|
||
g: 0.08,
|
||
b: 0.08,
|
||
a: 1.0,
|
||
});
|
||
fn ch(v: f32) -> u8 {
|
||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||
}
|
||
// Honour authored font size from the canonical schema; default to
|
||
// 13 px so editor-created text stays uniform. Baseline ≈ 1.08 × size.
|
||
let base_size = if node.font_size > 0.0 {
|
||
node.font_size
|
||
} else {
|
||
13.0
|
||
};
|
||
let font_size = base_size * zoom;
|
||
let weight = if node.font_weight > 0 {
|
||
node.font_weight
|
||
} else {
|
||
400
|
||
};
|
||
let family = if node.font_family.trim().is_empty() {
|
||
"system-ui"
|
||
} else {
|
||
node.font_family.as_str()
|
||
};
|
||
let line_height = if node.line_height > 0.0 {
|
||
node.line_height
|
||
} else {
|
||
1.2
|
||
};
|
||
let letter_spacing = node.letter_spacing * zoom;
|
||
let lines: Vec<String> = if node.text_wrap {
|
||
wrap_text(cx.backend, text, font_size, world_rect.size.x, weight)
|
||
} else {
|
||
text.split('\n').map(str::to_string).collect()
|
||
};
|
||
if !text.is_empty() {
|
||
let jc = jian_core::scene::Color::rgba(ch(ink.r), ch(ink.g), ch(ink.b), ch(ink.a));
|
||
let line_h = base_size * line_height * zoom;
|
||
let text_h = if lines.is_empty() {
|
||
0.0
|
||
} else {
|
||
font_size + line_h * (lines.len().saturating_sub(1) as f32)
|
||
};
|
||
let first_baseline_y = match node.text_vertical_align {
|
||
crate::layout_scene::SceneTextVerticalAlign::Middle => {
|
||
world_rect.origin.y + ((world_rect.size.y - text_h).max(0.0) / 2.0) + font_size
|
||
}
|
||
crate::layout_scene::SceneTextVerticalAlign::Bottom => {
|
||
world_rect.origin.y + (world_rect.size.y - text_h).max(0.0) + font_size
|
||
}
|
||
crate::layout_scene::SceneTextVerticalAlign::Top => world_rect.origin.y + font_size,
|
||
};
|
||
for (idx, line) in lines.iter().enumerate() {
|
||
let line_w = measure_line_width(cx.backend, line, font_size, weight, letter_spacing);
|
||
let x = match node.text_align {
|
||
crate::layout_scene::SceneTextAlign::Center => {
|
||
world_rect.origin.x + (world_rect.size.x - line_w).max(0.0) / 2.0
|
||
}
|
||
crate::layout_scene::SceneTextAlign::Right => {
|
||
world_rect.origin.x + (world_rect.size.x - line_w).max(0.0)
|
||
}
|
||
crate::layout_scene::SceneTextAlign::Left
|
||
| crate::layout_scene::SceneTextAlign::Justify => world_rect.origin.x,
|
||
};
|
||
let y = first_baseline_y + idx as f32 * line_h;
|
||
draw_text_line(
|
||
cx.backend,
|
||
line,
|
||
family,
|
||
font_size,
|
||
weight,
|
||
jc,
|
||
Point2D::new(x, y),
|
||
letter_spacing,
|
||
);
|
||
}
|
||
}
|
||
// Caret while editing — sits at the end of the text.
|
||
if let Some(c) = edit_caret {
|
||
if c.editing == node.id && jian_core::anim::blink_visible(c.now_ms, c.anchor_ms, 500) {
|
||
let caret_line = lines.last().map(String::as_str).unwrap_or("");
|
||
let text_w =
|
||
measure_line_width(cx.backend, caret_line, font_size, weight, letter_spacing);
|
||
let caret = Rect {
|
||
origin: Point2D::new(
|
||
world_rect.origin.x + text_w,
|
||
world_rect.origin.y + 2.0 * zoom,
|
||
),
|
||
size: Point2D::new(1.0_f32.max(zoom), font_size * 1.15),
|
||
};
|
||
cx.backend.fill_rect(caret, ink);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn measure_line_width(
|
||
backend: &mut dyn crate::RenderBackend,
|
||
line: &str,
|
||
font_size: f32,
|
||
weight: u16,
|
||
letter_spacing: f32,
|
||
) -> f32 {
|
||
let base = backend.measure_text_weighted(line, font_size, weight);
|
||
let extra = line.chars().count().saturating_sub(1) as f32 * letter_spacing;
|
||
base + extra
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn draw_text_line(
|
||
backend: &mut dyn crate::RenderBackend,
|
||
line: &str,
|
||
family: &str,
|
||
font_size: f32,
|
||
weight: u16,
|
||
color: jian_core::scene::Color,
|
||
origin: Point2D,
|
||
letter_spacing: f32,
|
||
) {
|
||
if letter_spacing.abs() < f32::EPSILON {
|
||
backend.draw_text(
|
||
&TextLayout::single_run(line, family, font_size, color, Point2D::new(0.0, 0.0))
|
||
.with_font_weight(weight),
|
||
origin,
|
||
);
|
||
return;
|
||
}
|
||
let mut x = origin.x;
|
||
for ch in line.chars() {
|
||
let s = ch.to_string();
|
||
backend.draw_text(
|
||
&TextLayout::single_run(&s, family, font_size, color, Point2D::new(0.0, 0.0))
|
||
.with_font_weight(weight),
|
||
Point2D::new(x, origin.y),
|
||
);
|
||
x += backend.measure_text_weighted(&s, font_size, weight) + letter_spacing;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod arc_tests {
|
||
use super::arc_polygon;
|
||
use crate::Rect;
|
||
|
||
#[test]
|
||
fn pie_polygon_starts_at_centre() {
|
||
let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0);
|
||
assert_eq!(poly[0].x, 50.0);
|
||
assert_eq!(poly[0].y, 50.0);
|
||
assert!((poly[1].x - 100.0).abs() < 0.01);
|
||
assert!((poly[1].y - 50.0).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn donut_polygon_has_outer_and_inner_rings() {
|
||
let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 360.0, 0.5);
|
||
assert_eq!(poly.len(), 2 * (90 + 1));
|
||
let last = poly[poly.len() - 1];
|
||
let dist = ((last.x - 50.0).powi(2) + (last.y - 50.0).powi(2)).sqrt();
|
||
assert!((dist - 25.0).abs() < 0.5, "inner radius ~25, got {dist}");
|
||
}
|
||
|
||
#[test]
|
||
fn quarter_sweep_end_point_at_90_degrees() {
|
||
let poly = arc_polygon(Rect::xywh(0.0, 0.0, 100.0, 100.0), 0.0, 90.0, 0.0);
|
||
let last = poly[poly.len() - 1];
|
||
assert!((last.x - 50.0).abs() < 0.01);
|
||
assert!((last.y - 100.0).abs() < 0.01);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod text_tests {
|
||
use super::paint_text_node;
|
||
use crate::layout_scene::{NodeKind, SceneNode, SceneTextAlign, SceneTextVerticalAlign};
|
||
use crate::widgets::PaintCx;
|
||
use crate::{Color, ImageDrawMode, Point2D, Rect, RenderBackend, TextLayout};
|
||
|
||
#[derive(Default)]
|
||
struct TextCaptureBackend {
|
||
origins: Vec<Point2D>,
|
||
families: Vec<String>,
|
||
}
|
||
|
||
impl RenderBackend for TextCaptureBackend {
|
||
fn begin_frame(&mut self) {}
|
||
fn end_frame(&mut self) {}
|
||
fn fill_rect(&mut self, _: Rect, _: Color) {}
|
||
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
|
||
fn draw_text(&mut self, layout: &TextLayout, origin: Point2D) {
|
||
self.origins.push(origin);
|
||
if let Some(run) = layout.runs().first() {
|
||
self.families.push(run.font_family.clone());
|
||
}
|
||
}
|
||
fn clip_rect(&mut self, _: Rect) {}
|
||
fn save(&mut self) {}
|
||
fn restore(&mut self) {}
|
||
fn translate(&mut self, _: Point2D) {}
|
||
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
|
||
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
|
||
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
|
||
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
|
||
fn draw_image(&mut self, _: Rect, _: u64, _: &[u8]) {}
|
||
fn draw_image_with_mode(&mut self, _: Rect, _: u64, _: &[u8], _: ImageDrawMode) {}
|
||
fn resize(&mut self, _: u32, _: u32) {}
|
||
fn dpi_scale(&self) -> f32 {
|
||
1.0
|
||
}
|
||
fn measure_text_weighted(&mut self, text: &str, font_size: f32, _: u16) -> f32 {
|
||
text.chars().count() as f32 * font_size * 0.5
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn text_node_paint_honors_typography_alignment() {
|
||
let mut node = SceneNode::leaf("t", NodeKind::Text);
|
||
node.bounds = Rect::xywh(0.0, 0.0, 200.0, 80.0);
|
||
node.text = Some("Hi".to_string());
|
||
node.font_family = "Georgia".to_string();
|
||
node.font_size = 20.0;
|
||
node.line_height = 1.0;
|
||
node.text_align = SceneTextAlign::Center;
|
||
node.text_vertical_align = SceneTextVerticalAlign::Middle;
|
||
let mut backend = TextCaptureBackend::default();
|
||
let mut cx = PaintCx {
|
||
backend: &mut backend,
|
||
};
|
||
|
||
paint_text_node(&mut cx, &node, node.bounds, 1.0, &None);
|
||
|
||
assert_eq!(backend.families, vec!["Georgia".to_string()]);
|
||
let origin = backend.origins[0];
|
||
assert!(
|
||
origin.x > 80.0,
|
||
"center-aligned text should move away from the left edge"
|
||
);
|
||
assert!(
|
||
origin.y > 40.0,
|
||
"middle-aligned text should move down from the top baseline"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod path_tests {
|
||
use super::flatten_path;
|
||
use crate::layout_scene::{NodeKind, SceneAnchor, SceneNode, ScenePointType};
|
||
use crate::{Point2D, Rect};
|
||
|
||
fn anchor(x: f32, y: f32, hout: Option<Point2D>) -> SceneAnchor {
|
||
SceneAnchor {
|
||
pos: Point2D::new(x, y),
|
||
handle_in: None,
|
||
handle_out: hout,
|
||
point_type: ScenePointType::Corner,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn handle_free_path_falls_back_to_points() {
|
||
let mut n = SceneNode::leaf("p", NodeKind::Path);
|
||
n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(10.0, 0.0)];
|
||
n.path_anchors = vec![anchor(0.0, 0.0, None), anchor(10.0, 0.0, None)];
|
||
assert_eq!(flatten_path(&n), n.points);
|
||
}
|
||
|
||
#[test]
|
||
fn curved_segment_tessellates_into_many_points() {
|
||
let mut n = SceneNode::leaf("p", NodeKind::Path);
|
||
n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(100.0, 0.0)];
|
||
n.path_anchors = vec![
|
||
anchor(0.0, 0.0, Some(Point2D::new(0.0, 50.0))),
|
||
anchor(100.0, 0.0, None),
|
||
];
|
||
let poly = flatten_path(&n);
|
||
assert_eq!(poly.len(), 17);
|
||
assert_eq!(poly[0], Point2D::new(0.0, 0.0));
|
||
assert_eq!(poly[poly.len() - 1], Point2D::new(100.0, 0.0));
|
||
assert!(poly[8].y > 1.0, "curve bows toward the handle");
|
||
}
|
||
|
||
#[test]
|
||
fn bounds_kept_so_helper_is_pure() {
|
||
let mut n = SceneNode::leaf("p", NodeKind::Path);
|
||
n.bounds = Rect::xywh(1.0, 2.0, 3.0, 4.0);
|
||
let _ = flatten_path(&n);
|
||
assert_eq!(n.bounds, Rect::xywh(1.0, 2.0, 3.0, 4.0));
|
||
}
|
||
}
|