Files
vectorcraft/crates/ui-egui/src/canvas.rs
T

749 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The document canvas: rendering, rulers, navigation, pointer routing to tools and on-canvas
//! selection visuals (bounding box, anchors, handles, smart-guide style labels).
use drawcraft_doc::{Node, NodeKind};
use drawcraft_geom::{Affine, BezPath, PathEl, Point, Rect};
use drawcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind};
use egui::{Color32, CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use crate::state::View;
use crate::theme::{self, Tokens};
use crate::{CacheKey, DrawcraftApp, now_ms, widgets};
const RULER: f32 = 16.0;
/// Screen ↔ document mapping for one frame.
#[derive(Clone, Copy, Debug)]
pub struct Xf {
pub rect: egui::Rect,
pub zoom: f64,
pub center: Point,
}
impl Xf {
pub fn to_screen(&self, p: Point) -> Pos2 {
let c = self.rect.center();
pos2(c.x + ((p.x - self.center.x) * self.zoom) as f32, c.y + ((p.y - self.center.y) * self.zoom) as f32)
}
pub fn to_doc(&self, p: Pos2) -> Point {
let c = self.rect.center();
Point::new(self.center.x + (p.x - c.x) as f64 / self.zoom, self.center.y + (p.y - c.y) as f64 / self.zoom)
}
pub fn rect_to_screen(&self, r: Rect) -> egui::Rect {
egui::Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1)))
}
/// Affine mapping document points to screen points.
pub fn affine(&self) -> Affine {
let c = self.rect.center();
Affine::translate((c.x as f64, c.y as f64)) * Affine::scale(self.zoom) * Affine::translate(-self.center.to_vec2())
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drag {
Tool,
Pan { start: Pos2, center: Point },
ZoomBox { start: Pos2 },
/// Cmd held: temporary selection tool; restore this tool on release.
TempSelect,
}
fn drag_id() -> egui::Id {
egui::Id::new("canvas-drag")
}
fn temp_tool_id() -> egui::Id {
egui::Id::new("canvas-temp-tool")
}
pub fn mods(m: egui::Modifiers, space: bool) -> Mods {
Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl, space }
}
/// Fit the view (View → Fit Artboard / Fit All / Actual Size).
pub fn fit(app: &mut DrawcraftApp, how: &str) {
let Some(rect) = app.canvas_rect else {
if let Some(v) = app.view_mut() {
v.fitted = false;
}
return;
};
let Some(st) = app.session.active() else { return };
let target = match how {
"view.fitAll" => st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)),
_ => st.doc.artboards.first().map(|a| a.rect),
};
let Some(target) = target else { return };
let v = app.view_mut().unwrap();
v.center = target.center();
v.fitted = true;
if how == "view.actualSize" {
v.zoom = 1.0;
} else {
let zx = (rect.width() as f64 - 60.0) / target.width().max(1.0);
let zy = (rect.height() as f64 - 60.0) / target.height().max(1.0);
v.zoom = zx.min(zy).clamp(0.0313, 640.0);
}
}
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
if app.session.active().is_none() {
home(app, ui, full);
return;
}
let rect = if app.ui.view.rulers && app.ui.screen_mode < 3 { egui::Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
app.canvas_rect = Some(rect);
let fitted = app.view().is_some_and(|v| v.fitted);
if !fitted {
fit(app, "view.fitArtboard");
}
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
handle_input(app, ui, &resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf { rect, zoom: v.zoom, center: v.center };
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
// Pasteboard, artboard shadows and paper.
painter.rect_filled(rect, 0.0, t.pasteboard);
if app.ui.view.artboards && !app.ui.view.outline {
for ab in &doc.artboards {
let r = xf.rect_to_screen(ab.rect);
painter.rect_filled(r.translate(vec2(2.0, 2.0)).expand(1.0), 1.0, Color32::from_black_alpha(90));
painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(110));
if app.ui.view.transparency_grid {
checker(&painter, r);
} else {
painter.rect_filled(r, 0.0, Color32::WHITE);
}
}
} else if app.ui.view.outline {
for ab in &doc.artboards {
painter.rect_filled(xf.rect_to_screen(ab.rect), 0.0, Color32::WHITE);
}
}
if app.ui.view.grid {
grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions);
}
// Artwork raster.
let ppp = ui.ctx().pixels_per_point();
let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32);
let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] };
if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() {
let t0 = now_ms();
let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2());
let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() };
let img = app.canvas.renderer.render(&doc, w, h, view, &opts);
let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
match &mut app.canvas.texture {
Some(tex) => tex.set(color, egui::TextureOptions::LINEAR),
None => app.canvas.texture = Some(ui.ctx().load_texture("canvas", color, egui::TextureOptions::LINEAR)),
}
app.canvas.key = Some(key);
app.perf.render_ms = now_ms() - t0;
}
if let Some(tex) = &app.canvas.texture {
painter.image(tex.id(), rect, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
// Artboard edges and names.
let active_ab = 0;
for (i, ab) in doc.artboards.iter().enumerate() {
let r = xf.rect_to_screen(ab.rect);
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
painter.rect_stroke(r, 0.0, Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c), StrokeKind::Outside);
if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 {
painter.text(r.left_top() - vec2(0.0, 4.0), egui::Align2::LEFT_BOTTOM, format!("{:02} - {}", i + 1, ab.name), egui::FontId::proportional(11.0), t.text_dim);
}
}
if app.ui.view.guides {
for g in &doc.guides {
let (a, b) = if g.vertical {
let x = xf.to_screen(Point::new(g.pos, 0.0)).x;
(pos2(x, rect.top()), pos2(x, rect.bottom()))
} else {
let y = xf.to_screen(Point::new(0.0, g.pos)).y;
(pos2(rect.left(), y), pos2(rect.right(), y))
};
painter.line_segment([a, b], Stroke::new(1.0, t.guide));
}
}
// Selection visuals and tool overlays.
if app.ui.view.edges {
hover_highlight(app, &painter, &xf);
selection_overlay(app, &painter, &xf);
}
let view_info = app.view_info();
let overlays = app.session.overlays(view_info);
draw_overlays(&painter, &xf, &overlays, &t);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
rulers(ui, full, &xf, app.hover_doc, &t);
}
// Cursor.
if resp.hovered() {
let m = ui.input(|i| i.modifiers);
let space = ui.input(|i| i.key_down(egui::Key::Space));
let cur = if space || app.session.tool_id() == "hand" {
if ui.input(|i| i.pointer.primary_down()) { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }
} else if app.session.tool_id() == "zoom" {
if m.alt { egui::CursorIcon::ZoomOut } else { egui::CursorIcon::ZoomIn }
} else if let Some(p) = app.hover_doc {
let c = app.session.cursor(p, mods(m, space), view_info);
if matches!(c, Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue)
&& let Some(hp) = ui.input(|i| i.pointer.hover_pos())
{
pen_cursor_badge(ui, hp, c, &t);
}
cursor_icon(c)
} else {
egui::CursorIcon::Default
};
ui.ctx().set_cursor_icon(cur);
}
}
fn pen_cursor_badge(ui: &Ui, p: Pos2, c: Cursor, t: &Tokens) {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("pen-cursor")));
let r = egui::Rect::from_min_size(p + vec2(6.0, 4.0), vec2(16.0, 16.0));
// Pen nib glyph (black with white outline, like a system cursor).
let tip = r.left_top();
let nib = vec![tip, tip + vec2(11.0, 4.0), tip + vec2(13.0, 13.0), tip + vec2(4.0, 11.0)];
painter.add(Shape::convex_polygon(nib, Color32::BLACK, Stroke::new(1.0, Color32::WHITE)));
let _ = t;
let badge = match c {
Cursor::PenClose => "o",
Cursor::PenContinue => "/",
Cursor::PenAdd => "+",
Cursor::PenDelete => "–",
_ => "",
};
if !badge.is_empty() {
painter.text(r.right_bottom() + vec2(1.0, -2.0), egui::Align2::LEFT_BOTTOM, badge, egui::FontId::proportional(11.0), Color32::BLACK);
}
}
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
use egui::CursorIcon as C;
match c {
Cursor::Arrow => C::Default,
Cursor::ArrowHollow => C::Default,
Cursor::Move => C::Move,
Cursor::Crosshair => C::Crosshair,
Cursor::ResizeH => C::ResizeHorizontal,
Cursor::ResizeV => C::ResizeVertical,
Cursor::ResizeNwSe => C::ResizeNwSe,
Cursor::ResizeNeSw => C::ResizeNeSw,
Cursor::Rotate => C::Alias,
Cursor::Pen | Cursor::PenAdd | Cursor::PenDelete | Cursor::PenClose | Cursor::PenContinue => C::Crosshair,
Cursor::Text => C::Text,
Cursor::Hand => C::Grab,
Cursor::HandGrab => C::Grabbing,
Cursor::ZoomIn => C::ZoomIn,
Cursor::ZoomOut => C::ZoomOut,
Cursor::Eyedropper => C::Crosshair,
Cursor::NotAllowed => C::NotAllowed,
}
}
fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let (pointer, m, space, scroll, zoom_delta) = ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta()));
let v = *app.view().unwrap_or(&View::default());
let xf = Xf { rect, zoom: v.zoom, center: v.center };
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
app.hover_doc = hover.map(|p| xf.to_doc(p));
let view = app.view_info();
let drag: Option<Drag> = ui.data(|d| d.get_temp(drag_id()));
// Zoom: pinch / Cmd-scroll / Alt-scroll around the pointer. Plain scroll pans.
if resp.hovered() {
let mut factor = zoom_delta as f64;
if m.alt && scroll.y != 0.0 {
factor *= (scroll.y as f64 * 0.01).exp();
}
if (factor - 1.0).abs() > 1e-6 {
if let (Some(p), Some(vm)) = (hover, app.view_mut()) {
let before = xf.to_doc(p);
vm.zoom = (vm.zoom * factor).clamp(0.0313, 640.0);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center };
let after = nx.to_doc(p);
vm.center += before - after;
}
} else if (scroll.x != 0.0 || scroll.y != 0.0)
&& !m.alt
&& let Some(vm) = app.view_mut()
{
vm.center.x -= scroll.x as f64 / vm.zoom;
vm.center.y -= scroll.y as f64 / vm.zoom;
}
}
let tool = app.session.tool_id();
let pan_mode = space || tool == "hand";
if pointer.primary_pressed() && resp.hovered() {
ui.ctx().memory_mut(|mem| mem.stop_text_input());
app.ui.flyout = None;
let p = hover.unwrap_or(rect.center());
let d = if pan_mode {
Drag::Pan { start: p, center: v.center }
} else if tool == "zoom" {
Drag::ZoomBox { start: p }
} else {
let selection_family = matches!(tool, "selection" | "directSelection" | "groupSelection");
let mut kind = Drag::Tool;
if m.command && !selection_family {
ui.data_mut(|d| d.insert_temp(temp_tool_id(), tool.to_string()));
app.select_tool("selection");
kind = Drag::TempSelect;
}
let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
kind
};
ui.data_mut(|dd| dd.insert_temp(drag_id(), d));
} else if let Some(d) = drag {
let p = pointer.interact_pos().unwrap_or(rect.center());
if pointer.primary_down() {
match d {
Drag::Pan { start, center } => {
if let Some(vm) = app.view_mut() {
vm.center = center - (p - start).to_vec2_f64() / vm.zoom;
}
}
Drag::ZoomBox { .. } => {}
Drag::Tool | Drag::TempSelect => {
if pointer.delta() != egui::Vec2::ZERO {
let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
}
}
} else {
ui.data_mut(|dd| dd.remove::<Drag>(drag_id()));
match d {
Drag::ZoomBox { start } => {
let r = egui::Rect::from_two_pos(start, p);
if let Some(vm) = app.view_mut() {
if r.width() > 8.0 && r.height() > 8.0 {
let a = xf.to_doc(r.min);
let b = xf.to_doc(r.max);
vm.center = Point::new((a.x + b.x) / 2.0, (a.y + b.y) / 2.0);
vm.zoom = (rect.width() as f64 / (b.x - a.x)).min(rect.height() as f64 / (b.y - a.y)).clamp(0.0313, 640.0);
} else {
let before = xf.to_doc(p);
vm.zoom = crate::state::next_zoom(vm.zoom, !m.alt);
let nx = Xf { rect, zoom: vm.zoom, center: vm.center };
vm.center += before - nx.to_doc(p);
}
}
}
Drag::Tool | Drag::TempSelect => {
let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
if d == Drag::TempSelect
&& let Some(prev) = ui.data(|dd| dd.get_temp::<String>(temp_tool_id()))
{
app.select_tool(&prev);
}
}
Drag::Pan { .. } => {}
}
}
} else if let Some(p) = hover {
if pointer.is_moving() {
let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
}
if resp.double_clicked()
&& let Some(p) = hover
{
let ev = PointerEvent { kind: PointerKind::DoubleClick, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 };
dispatch(app, &ev, view);
}
if drag.is_some() || pointer.is_moving() {
ui.ctx().request_repaint();
}
}
trait ToVec2F64 {
fn to_vec2_f64(self) -> drawcraft_geom::Vec2;
}
impl ToVec2F64 for egui::Vec2 {
fn to_vec2_f64(self) -> drawcraft_geom::Vec2 {
drawcraft_geom::Vec2::new(self.x as f64, self.y as f64)
}
}
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
pub fn dispatch(app: &mut DrawcraftApp, ev: &PointerEvent, view: drawcraft_engine::ViewInfo) {
match app.session.pointer(ev, view) {
Ok(reqs) => {
for r in reqs {
match r {
drawcraft_engine::UiRequest::Dialog(kind, p) => crate::dialogs::open_tool_dialog(app, &kind, p),
drawcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t),
}
}
}
Err(e) => app.status(e.to_string()),
}
}
fn checker(p: &egui::Painter, r: egui::Rect) {
p.rect_filled(r, 0.0, Color32::WHITE);
let s = 8.0;
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
let (nx, ny) = (((r.width() / s).ceil() as i32).min(400), ((r.height() / s).ceil() as i32).min(400));
for y in 0..ny {
for x in 0..nx {
if (x + y) % 2 == 1 {
clip.rect_filled(egui::Rect::from_min_size(r.min + vec2(x as f32 * s, y as f32 * s), vec2(s, s)), 0.0, Color32::from_gray(204));
}
}
}
}
fn grid(p: &egui::Painter, xf: &Xf, spacing: f64, subdiv: u32) {
let r = xf.rect;
let a = xf.to_doc(r.min);
let b = xf.to_doc(r.max);
let sub = spacing / subdiv.max(1) as f64;
let step = if sub * xf.zoom >= 6.0 { sub } else { spacing };
if step * xf.zoom < 4.0 {
return;
}
let mut x = (a.x / step).floor() * step;
while x <= b.x {
let major = (x / spacing).round() * spacing == x || (x / spacing - (x / spacing).round()).abs() < 1e-6;
let sx = xf.to_screen(Point::new(x, 0.0)).x;
p.line_segment([pos2(sx, r.top()), pos2(sx, r.bottom())], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 })));
x += step;
}
let mut y = (a.y / step).floor() * step;
while y <= b.y {
let major = (y / spacing - (y / spacing).round()).abs() < 1e-6;
let sy = xf.to_screen(Point::new(0.0, y)).y;
p.line_segment([pos2(r.left(), sy), pos2(r.right(), sy)], Stroke::new(1.0, Color32::from_black_alpha(if major { 60 } else { 22 })));
y += step;
}
}
fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, t: &Tokens) {
let p = ui.painter();
let top = egui::Rect::from_min_max(pos2(full.left() + RULER, full.top()), pos2(full.right(), full.top() + RULER));
let left = egui::Rect::from_min_max(pos2(full.left(), full.top() + RULER), pos2(full.left() + RULER, full.bottom()));
let corner = egui::Rect::from_min_size(full.min, vec2(RULER, RULER));
for r in [top, left, corner] {
p.rect_filled(r, 0.0, t.ruler);
}
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.border));
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
// Crosshair in the origin box.
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick));
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick));
// Pick a label step that gives ≥ 50 px between labels.
let steps = [1.0, 2.0, 5.0, 10.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0];
let step = steps.iter().copied().find(|s| s * xf.zoom >= 50.0).unwrap_or(10000.0);
let minor = step / 10.0;
let font = egui::FontId::proportional(9.5);
let a = xf.to_doc(top.left_top());
let b = xf.to_doc(top.right_top());
let clip_top = p.with_clip_rect(top);
let mut x = (a.x / minor).floor() * minor;
while x <= b.x {
let sx = xf.to_screen(Point::new(x, 0.0)).x;
let is_major = ((x / step).round() * step - x).abs() < minor * 0.01;
let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01;
let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 };
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if is_major {
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, format!("{}", x.round() as i64), font.clone(), t.ruler_tick);
}
x += minor;
}
let a = xf.to_doc(left.left_top());
let b = xf.to_doc(left.left_bottom());
let clip_left = p.with_clip_rect(left);
let mut y = (a.y / minor).floor() * minor;
while y <= b.y {
let sy = xf.to_screen(Point::new(0.0, y)).y;
let is_major = ((y / step).round() * step - y).abs() < minor * 0.01;
let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01;
let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 };
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if is_major {
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
let s = format!("{}", y.round() as i64);
for (k, ch) in s.chars().enumerate() {
clip_left.text(pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), egui::Align2::LEFT_TOP, ch.to_string(), font.clone(), t.ruler_tick);
}
}
y += minor;
}
if let Some(h) = hover {
let s = xf.to_screen(h);
clip_top.line_segment([pos2(s.x, top.top()), pos2(s.x, top.bottom())], Stroke::new(1.0, t.text));
clip_left.line_segment([pos2(left.left(), s.y), pos2(left.right(), s.y)], Stroke::new(1.0, t.text));
}
}
fn to_screen_path(bp: &BezPath, xf: &Xf) -> Vec<Vec<Pos2>> {
// Flatten for drawing: a polyline per subpath (tolerance ~0.25 screen px).
let mut out: Vec<Vec<Pos2>> = vec![];
let a = xf.affine();
let mut t = bp.clone();
t.apply_affine(a);
let mut cur: Vec<Pos2> = vec![];
kurbo_flatten(&t, 0.25, &mut |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(pos2(p.x as f32, p.y as f32));
}
PathEl::LineTo(p) => cur.push(pos2(p.x as f32, p.y as f32)),
PathEl::ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
});
if cur.len() > 1 {
out.push(cur);
}
out
}
fn stroke_path(p: &egui::Painter, bp: &BezPath, xf: &Xf, s: Stroke) {
for line in to_screen_path(bp, xf) {
p.add(Shape::line(line, s));
}
}
fn c32(rgb: [u8; 3]) -> Color32 {
Color32::from_rgb(rgb[0], rgb[1], rgb[2])
}
/// Outline of a node for highlighting (paths, compound children, text/image bounds).
fn node_outline(n: &Node) -> BezPath {
let mut bp = BezPath::new();
n.walk(&mut |c| match &c.kind {
NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()),
NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
if let Some(b) = c.geometric_bounds() {
bp.extend(drawcraft_geom::shapes::rectangle(b).to_bezpath());
}
}
_ => {}
});
bp
}
fn hover_highlight(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return };
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return;
}
let Some(st) = app.session.active() else { return };
let opt = drawcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false };
let Some(hit) = drawcraft_doc::hit::hit_test(&st.doc, h, opt) else { return };
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
if st.selection.contains(id) {
return;
}
if let Some(n) = st.doc.node(id) {
let color = c32(st.doc.layer_color(id));
stroke_path(p, &node_outline(n), xf, Stroke::new(1.5, color));
}
}
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
let r = egui::Rect::from_center_size(c, vec2(size, size));
if filled {
p.rect_filled(r, 0.0, color);
} else {
p.rect_filled(r, 0.0, Color32::WHITE);
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
}
fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(st) = app.session.active() else { return };
let tool = app.session.tool_id();
let direct = matches!(tool, "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "anchorPoint" | "curvature");
for id in &st.selection.objects {
let Some(n) = st.doc.node(*id) else { continue };
let color = c32(st.doc.layer_color(*id));
let partial = st.selection.partial(*id);
// Path outlines.
stroke_path(p, &node_outline(n), xf, Stroke::new(1.0, color));
// Anchors (and handles for selected anchors in direct mode).
n.walk(&mut |c| {
let NodeKind::Path { path, .. } = &c.kind else { return };
for (si, ai, a) in path.anchors() {
let sel = match partial {
Some(set) => set.contains(&(si, ai)),
None => !direct || c.id == *id,
};
let sp = xf.to_screen(a.p);
if sel && (direct || partial.is_some()) {
for h in [a.h_in, a.h_out] {
if h.distance(a.p) > 1e-6 {
let hp = xf.to_screen(h);
p.line_segment([sp, hp], Stroke::new(1.0, color));
p.circle_filled(hp, 2.8, color);
}
}
}
anchor_square(p, sp, color, sel, if partial.is_some() || direct { 6.0 } else { 4.5 });
}
});
// Text: baseline marker.
if let NodeKind::Text(tx) = &n.kind {
let o = xf.to_screen(tx.xf * Point::ZERO);
let b = n.geometric_bounds().unwrap_or_default();
let e = xf.to_screen(Point::new(b.x1, (tx.xf * Point::ZERO).y));
p.line_segment([o, e], Stroke::new(1.0, color));
p.circle_filled(o, 2.5, color);
}
}
// Bounding box with handles (Selection tool).
if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() {
let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return };
let color = c32(st.doc.layer_color(st.selection.objects[0]));
let r = xf.rect_to_screen(b);
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
for h in drawcraft_tools::bbox::Handle::ALL {
let c = xf.to_screen(h.pos(b));
let hr = egui::Rect::from_center_size(c, vec2(7.0, 7.0));
p.rect_filled(hr, 0.0, Color32::WHITE);
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
// Centre point.
let c = r.center();
p.rect_filled(egui::Rect::from_center_size(c, vec2(3.0, 3.0)), 0.0, color);
// Live corner widgets on single live rectangles.
if st.selection.len() == 1
&& let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = st.doc.node(st.selection.objects[0]).map(|n| &n.kind)
&& r.width() > 40.0
&& r.height() > 40.0
{
let inset = (radii[0].max(20.0 / xf.zoom)).min(w.min(*h) / 2.0);
for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] {
let sp = xf.to_screen(*lxf * Point::new(cx, cy));
p.circle_stroke(sp, 3.5, Stroke::new(1.0, color));
p.circle_filled(sp, 1.3, color);
}
}
}
}
fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens) {
for o in overlays {
match o {
Overlay::Marquee(r) => {
let sr = xf.rect_to_screen(*r);
let pts = [sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()];
for w in pts.windows(2) {
p.extend(Shape::dashed_line(&[w[0], w[1]], Stroke::new(1.0, Color32::from_gray(90)), 3.0, 3.0));
}
}
Overlay::Path { path, color, width, dashed } => {
let s = Stroke::new(*width, c32(*color));
if *dashed {
for line in to_screen_path(path, xf) {
p.extend(Shape::dashed_line(&line, s, 4.0, 3.0));
}
} else {
stroke_path(p, path, xf, s);
}
}
Overlay::Line { a, b, color, dashed } => {
let s = Stroke::new(1.0, c32(*color));
let (a, b) = (xf.to_screen(*a), xf.to_screen(*b));
if *dashed {
p.extend(Shape::dashed_line(&[a, b], s, 4.0, 3.0));
} else {
p.line_segment([a, b], s);
}
}
Overlay::Anchor { p: pt, color, filled, size } => anchor_square(p, xf.to_screen(*pt), c32(*color), *filled, *size),
Overlay::Handle { p: pt, color } => {
p.circle_filled(xf.to_screen(*pt), 2.8, c32(*color));
}
Overlay::Label { p: pt, text, color } => {
let sp = xf.to_screen(*pt) + vec2(8.0, -14.0);
p.text(sp, egui::Align2::LEFT_TOP, text, egui::FontId::proportional(11.0), c32(*color));
}
Overlay::Measure { p: pt, text } => {
let sp = xf.to_screen(*pt) + vec2(14.0, 14.0);
let galley = p.layout(text.clone(), egui::FontId::proportional(11.0), Color32::WHITE, 200.0);
let r = egui::Rect::from_min_size(sp, galley.size() + vec2(12.0, 8.0));
p.rect_filled(r, CornerRadius::same(3), t.measure_bg);
p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE);
}
}
}
}
/// The Home screen shown when no document is open.
fn home(app: &mut DrawcraftApp, ui: &mut Ui, rect: egui::Rect) {
let t = Tokens::get(ui.ctx());
ui.painter().rect_filled(rect, 0.0, t.panel_darker);
let inner = rect.shrink2(vec2((rect.width() - 820.0).max(40.0) / 2.0, 60.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
let ui = &mut child;
ui.label(egui::RichText::new("Welcome to DrawCraft").font(theme::semibold(26.0)).color(t.text));
ui.add_space(4.0);
ui.label(egui::RichText::new("Vector illustration — fast, open, scriptable.").size(14.0).color(t.text_dim));
ui.add_space(22.0);
ui.horizontal(|ui| {
if widgets::primary_button(ui, "New file").clicked() {
app.run("file.newDialog", json!({})).ok();
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Open").clicked() {
app.run("file.open", json!({})).ok();
}
});
ui.add_space(28.0);
ui.label(egui::RichText::new("Quickly start a new file").font(theme::semibold(14.0)).color(t.text));
ui.add_space(10.0);
let presets: [(&str, &str, f64, f64); 6] = [
("Letter", "612 × 792 pt", 612.0, 792.0),
("A4", "595.28 × 841.89 pt", 595.28, 841.89),
("Web 1920", "1920 × 1080 px", 1920.0, 1080.0),
("Mobile", "390 × 844 px", 390.0, 844.0),
("Postcard", "288 × 432 pt", 432.0, 288.0),
("Square", "1080 × 1080 px", 1080.0, 1080.0),
];
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
for (name, size, w, h) in presets {
let (r, resp) = ui.allocate_exact_size(vec2(120.0, 132.0), Sense::click());
ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel });
let s = (70.0 / w.max(h)) as f32;
let pr = egui::Rect::from_center_size(r.center_top() + vec2(0.0, 50.0), vec2(w as f32 * s, h as f32 * s));
ui.painter().rect_filled(pr.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(80));
ui.painter().rect_filled(pr, 0.0, Color32::WHITE);
ui.painter().text(r.center_bottom() - vec2(0.0, 30.0), egui::Align2::CENTER_CENTER, name, theme::semibold(12.5), t.text);
ui.painter().text(r.center_bottom() - vec2(0.0, 14.0), egui::Align2::CENTER_CENTER, size, egui::FontId::proportional(11.0), t.text_dim);
if resp.clicked() {
app.run("file.new", json!({"width": w, "height": h, "units": if size.ends_with("px") { "Pixels" } else { "Points" }})).ok();
}
}
});
}
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
}