refactor(ui-egui): split canvas.rs into canvas/

This commit is contained in:
aiongg
2026-10-26 20:17:48 +00:00
parent 99edc6c815
commit f61032b3c9
7 changed files with 1398 additions and 1368 deletions
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
use super::outline::stroke;
use super::overlays::accent;
use super::*;
/// Width of the lines hatching the hovered and selected regions in vector edit mode (screen px).
const REGION_HATCH_WIDTH: f32 = 1.4;
/// Vector edit mode: the selected regions and the hovered one hatched in the selection colour,
/// under the segments and points (the engine lays out and clips the lines).
pub(super) fn region_hatch(app: &FigApp, painter: &egui::Painter) {
let Ok(ds) = app.session.ds() else { return };
let (ox, oy) = ds.view.page_to_screen(0.0, 0.0);
let (z, min) = (ds.view.zoom, app.canvas.rect.min);
let view = figcraft_geom::Affine::new([z, 0.0, 0.0, z, f64::from(min.x) + ox, f64::from(min.y) + oy]);
let r = painter.clip_rect();
let clip = figcraft_geom::Rect::new(f64::from(r.min.x), f64::from(r.min.y), f64::from(r.max.x), f64::from(r.max.y));
for [a, b] in app.session.vector_region_hatch(view, Some(clip)) {
painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(accent(painter), REGION_HATCH_WIDTH));
}
}
/// Shape builder: every region's edges, and the hovered or collected regions hatched (in the
/// danger colour when releasing removes them).
pub(super) fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figcraft_engine::shapebuilder::Arrangement) {
let ov = &app.session.tool.overlay;
let screen = |r: &figcraft_engine::shapebuilder::Region| -> Vec<Vec<Pos2>> {
r.polys.iter().filter_map(|poly| clean_poly(poly.iter().map(|p| app.to_screen(p.x, p.y)))).collect()
};
// Edges read on any fill: a light halo under a thin accent line.
for r in &arr.regions {
for poly in screen(r) {
for seg in segments(&poly) {
painter.add(Shape::line(seg.to_vec(), stroke(Color32::from_white_alpha(150), 2.5)));
painter.add(Shape::line(seg.to_vec(), stroke(accent(painter), 1.0)));
}
}
}
let col = if ov.builder_remove { t.danger } else { accent(painter) };
for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) {
hatched(painter, &screen(r), col);
}
}
/// A region contour in screen space, ready to draw: non-finite and repeated points dropped (the
/// last point back at the first, the points a sliver doubles back on) and sub-pixel slivers left
/// out, since they're invisible anyway.
pub(crate) fn clean_poly(points: impl IntoIterator<Item = Pos2>) -> Option<Vec<Pos2>> {
let mut out: Vec<Pos2> = Vec::new();
for q in points {
if !(q.x.is_finite() && q.y.is_finite()) || out.last().is_some_and(|l| l.distance(q) < 0.01) {
continue;
}
out.push(q);
}
if out.len() > 1 && out.first().is_some_and(|f| f.distance(*out.last().unwrap_or(f)) < 0.01) {
out.pop();
}
let n = out.len();
let twice_area: f32 = (0..n).map(|k| out[k].x * out[(k + 1) % n].y - out[(k + 1) % n].x * out[k].y).sum();
(n > 2 && twice_area.abs() > 0.4).then_some(out)
}
/// A closed polygon as its segments. Drawn segment by segment rather than as one closed path: a
/// joined path asks the tessellator for a mitre at every hairpin a region's sliver doubles back on,
/// and a zero-length segment there divides by a zero normal, which draws a spike across the canvas.
fn segments(poly: &[Pos2]) -> impl Iterator<Item = [Pos2; 2]> + '_ {
let n = poly.len();
(0..n).map(move |k| [poly[k], poly[(k + 1) % n]])
}
/// Polygons hatched and outlined in `col` (an area about to be filled, merged or removed), each
/// line on a light halo so it reads on any fill, even one in `col`.
pub(super) fn hatched(painter: &egui::Painter, polys: &[Vec<Pos2>], col: Color32) {
let halo = Color32::from_white_alpha(200);
for l in hatch(polys, 6.0) {
painter.line_segment(l, stroke(halo, 3.0));
painter.line_segment(l, stroke(col, 1.25));
}
for poly in polys {
for seg in segments(poly) {
painter.add(Shape::line(seg.to_vec(), stroke(halo, 4.0)));
painter.add(Shape::line(seg.to_vec(), stroke(col, 2.0)));
}
}
}
/// 45° hatch lines `gap` apart inside polygons (even-odd), in screen space.
fn hatch(polys: &[Vec<Pos2>], gap: f32) -> Vec<[Pos2; 2]> {
// Rotate so the hatch is horizontal, scan, rotate back.
let rot = |p: Pos2| pos2((p.x + p.y) * std::f32::consts::FRAC_1_SQRT_2, (p.y - p.x) * std::f32::consts::FRAC_1_SQRT_2);
let back = |x: f32, y: f32| pos2((x - y) * std::f32::consts::FRAC_1_SQRT_2, (x + y) * std::f32::consts::FRAC_1_SQRT_2);
let edges: Vec<(Pos2, Pos2)> = polys
.iter()
.flat_map(|poly| {
let pts: Vec<Pos2> = poly.iter().map(|p| rot(*p)).collect();
let n = pts.len();
(0..n).filter_map(move |k| Some((*pts.get(k)?, *pts.get((k + 1) % n)?)))
})
.collect();
let (y0, y1) = edges.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), (a, b)| (lo.min(a.y).min(b.y), hi.max(a.y).max(b.y)));
let mut out = Vec::new();
if !(y0.is_finite() && y1.is_finite()) || (y1 - y0) / gap > 4000.0 {
return out;
}
let mut y = (y0 / gap).ceil() * gap;
while y < y1 {
let mut xs: Vec<f32> = edges
.iter()
.filter(|(a, b)| (a.y <= y && y < b.y) || (b.y <= y && y < a.y))
.map(|(a, b)| a.x + (y - a.y) * (b.x - a.x) / (b.y - a.y))
.collect();
xs.sort_by(f32::total_cmp);
for pair in xs.chunks_exact(2) {
if let [a, b] = pair {
out.push([back(*a, y), back(*b, y)]);
}
}
y += gap;
}
out
}
+260
View File
@@ -0,0 +1,260 @@
use super::*;
/// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag
/// zooms to the dragged area. Takes the pointer from the other tools while it's active.
fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: egui::Modifiers, typing: bool) -> bool {
let held = !typing && !mods.command && ui.input(|i| i.key_down(egui::Key::Z));
app.canvas.zoom_tool = held.then_some(mods.alt);
let Some(start) = app.canvas.zoom_drag.or_else(|| (held && resp.hovered() && ui.input(|i| i.pointer.primary_pressed())).then_some(pos)) else {
return held;
};
app.canvas.zoom_drag = Some(start);
let area = Rect::from_two_pos(start, pos);
if ui.input(|i| i.pointer.primary_down()) {
return true;
}
app.canvas.zoom_drag = None;
let (a, b) = (app.to_page(area.min), app.to_page(area.max));
let params = if area.width() > 4.0 && area.height() > 4.0 {
json!({"x": a.x, "y": a.y, "width": b.x - a.x, "height": b.y - a.y})
} else {
let p = app.to_page(pos);
json!({"x": p.x, "y": p.y, "out": mods.alt})
};
app.run("view.zoomAt", params);
true
}
/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
/// then doesn't show.
pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
if crate::proto_canvas::input(app, ui, resp) {
// A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
app.ui.pages_focus = None;
return true;
}
let ctx = ui.ctx().clone();
// Two fingers pan as well as pinch (a touch screen has no scroll wheel).
let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
(i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
});
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
let typing_elsewhere = crate::a11y::field_focused(&ctx);
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
return false;
}
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
// a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
// nothing into the design, and says so once for the whole drop.
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
let mut refused = false;
for f in dropped {
let path = f.path().to_path_buf();
if crate::is_document(&path) {
crate::pending_open::open_dropped(app, &f);
continue;
}
if refused || crate::menus::dev_mode_refuses(app, "file.import") {
refused = true;
continue;
}
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None)
} else {
(json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name)))
};
let Some(target) =
app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() })
else {
continue;
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
}
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.dragging = false;
if let Some(at) = hover_pos.or_else(|| app.canvas.last_pointer.map(|p| app.to_screen(p.x, p.y))) {
send(app, PointerKind::Up, at, &mods);
}
}
// Leaving the canvas: nothing is hovered there any more.
if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) {
app.session.pointer_left();
}
// A pan whose button was released off the canvas is over.
if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
return true;
};
// A press on the canvas gives the keyboard back to it (arrows nudge again).
if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) {
app.ui.layers_focus = false;
app.ui.pages_focus = None;
app.ui.key_box = None;
}
// Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans.
if resp.hovered() {
if (zoom_delta - 1.0).abs() > 1e-4 {
let anchor = app.to_page(pos);
if let Ok(ds) = app.session.ds_mut() {
let z = ds.view.zoom * f64::from(zoom_delta);
ds.view.set_zoom_at(z, (anchor.x, anchor.y));
ds.revision += 1;
}
} else if scroll != egui::Vec2::ZERO
&& let Ok(ds) = app.session.ds_mut()
{
ds.view.scroll(f64::from(scroll.x), f64::from(scroll.y), mods.shift);
ds.revision += 1;
}
}
if zoom_tool(app, ui, resp, pos, mods, editing_text || typing_elsewhere) {
return true;
}
// Middle-button / space panning.
let middle_down = ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle));
if (middle_down || (app.canvas.space_pan && ctx.input(|i| i.pointer.primary_down()))) && resp.hovered() || app.canvas.middle_pan.is_some() {
if let Ok(ds) = app.session.ds_mut() {
match app.canvas.middle_pan {
None => app.canvas.middle_pan = Some((pos, ds.view.cx, ds.view.cy)),
Some((p0, cx, cy)) => {
let z = ds.view.zoom.max(1e-6);
ds.view.cx = cx - f64::from(pos.x - p0.x) / z;
ds.view.cy = cy - f64::from(pos.y - p0.y) / z;
ds.revision += 1;
}
}
}
if !middle_down && !ctx.input(|i| i.pointer.primary_down()) {
app.canvas.middle_pan = None;
}
return true;
}
if !app.canvas.dragging && (resp.hovered() || app.ui.comments.dragging()) && crate::comments::input(app, &ctx, pos) {
return false;
}
if !app.canvas.dragging && resp.hovered() && crate::guides::input(app, &ctx, pos) {
return false;
}
if !app.canvas.dragging && resp.hovered() && crate::handles::input(app, &ctx, pos) {
return false;
}
if resp.secondary_clicked() {
open_context_menu(app, pos, mods.command);
return true;
}
// A third quick click is counted as a triple, not a double (a click elsewhere in between still
// leaves its time in the sequence): it means the same here — edit, enter, open — unless a
// vector or text is being edited, where a triple already has its own meaning. A triple on
// empty canvas still means a double in vector edit mode: the double-click that entered edit
// mode counts on, so leaving it right away would otherwise take a fourth click or a pause.
let ds = app.session.ds().ok();
let editing = ds.is_some_and(|d| d.vector_edit.is_some() || d.text_edit.is_some());
// Only a triple click asks (it walks the parts' render bounds).
let empty_canvas = || {
ds.is_some_and(|d| {
let Some(ve) = d.vector_edit.as_ref().filter(|v| v.anchor.is_none() && d.text_edit.is_none()) else {
return false;
};
let p = app.to_page(pos);
let tol = 6.0 / d.view.zoom.max(1e-6);
ve.parts().iter().all(|part| d.doc.world_render_bounds(part.id).is_none_or(|b| !b.inflate(tol * 4.0, tol * 4.0).contains(p)))
})
};
if resp.double_clicked() || (resp.triple_clicked() && (!editing || empty_canvas())) {
let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods);
if let Err(e) = app.session.pointer(&ev) {
app.toast(e.to_string());
}
app.canvas.dragging = false;
return true;
}
let primary_pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
let primary_down = ctx.input(|i| i.pointer.primary_down());
let primary_released = ctx.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
let kind = if primary_pressed && resp.hovered() {
app.canvas.dragging = true;
app.ui.context_menu = None;
app.ui.tool_flyout = None;
// Clicking the canvas takes focus from panel fields.
ctx.memory_mut(|m| {
if let Some(f) = m.focused() {
m.surrender_focus(f);
}
});
Some(PointerKind::Down)
} else if app.canvas.dragging && primary_released {
app.canvas.dragging = false;
Some(PointerKind::Up)
} else if app.canvas.dragging && primary_down {
Some(PointerKind::Move)
} else if resp.hovered() {
app.canvas.hovering = true;
Some(PointerKind::Hover)
} else {
// Something over the canvas took the pointer.
if std::mem::take(&mut app.canvas.hovering) {
app.session.pointer_left();
}
None
};
// Pen pressure comes with touch events (a pen on Windows, a stylus in a browser); a mouse,
// macOS and Linux tablets report none and draw at the stroke weight.
let force = ctx.input(|i| {
i.events.iter().rev().find_map(|e| match e {
egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => Some(f.clamp(0.0, 1.0)),
_ => None,
})
});
if force.is_some() {
app.canvas.pressure = force;
}
if let Some(k) = kind {
send(app, k, pos, &mods);
// The pointer also let go within this frame (a quick click, or a slow frame): the tools get
// the release too, so the press's gesture ends — a quick click with a shape tool still
// draws and returns to Move, and a double-click right after drawing finds the Move tool.
if matches!(k, PointerKind::Down) && primary_released {
app.canvas.dragging = false;
send(app, PointerKind::Up, pos, &mods);
}
}
true
}
/// Pointer event `kind` at screen point `pos`, to the tools.
fn send(app: &mut FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers) {
let ev = pointer_event(app, kind, pos, m);
if kind == PointerKind::Up {
app.canvas.pressure = None;
}
app.canvas.last_pointer = Some(Point::new(ev.x, ev.y));
if let Err(e) = app.session.pointer(&ev) {
app.toast(e.to_string());
}
app.handle_ui_requests();
}
/// Right-click at screen `pos`: select what's under the pointer (`deep`: the nested layer), then
/// open the context menu there.
pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) {
let p = app.to_page(pos);
if let Ok(ds) = app.session.ds() {
let (doc, page, sel, zoom) = (ds.doc.clone(), ds.page, ds.selection.clone(), ds.view.zoom.max(1e-6));
if let Some(leaf) = app.session.hit(&doc, page, p, 2.0 / zoom) {
let target = figcraft_doc::geometry::select_target(&doc, leaf, &sel, deep);
if !sel.contains(&target) {
app.session.set_selection(vec![target]);
}
}
}
app.ui.context_menu = Some((pos, app.session.selection()));
app.ui.context_menu_shown = false;
}
+196
View File
@@ -0,0 +1,196 @@
use super::*;
pub(super) fn stroke(c: Color32, w: f32) -> Stroke {
Stroke::new(w, c)
}
/// A text layer's layout placed on the canvas: where carets and selected characters show (yours
/// and other people's in a live session).
pub(crate) struct TextOnScreen {
pub layout: Arc<figcraft_text::TextLayout>,
/// How many characters the text has.
pub chars: usize,
/// The layout's vertical offset in the box (vertical alignment).
dy: f64,
world: figcraft_geom::Affine,
}
impl TextOnScreen {
/// Text layer `id` of `doc` (`None`: not a text layer).
pub(crate) fn of(doc: &Document, id: NodeId) -> Option<Self> {
let n = doc.node(id)?;
let NodeKind::Text(t) = &n.kind else { return None };
let (layout, dy) = figcraft_text::layout_node(n)?;
Some(Self { layout, chars: t.char_count(), dy, world: doc.world_transform(id) })
}
/// Layout point `(x, y)` on screen (on the path, for text on a path).
pub(crate) fn at(&self, app: &FigApp, x: f64, y: f64) -> Pos2 {
let q = self.layout.warp_point(x, y);
let p = self.world * Point::new(q.x, q.y + self.dy);
app.to_screen(p.x, p.y)
}
/// The highlight of characters `a..b`, one screen quad per line.
pub(crate) fn selection(&self, app: &FigApp, a: usize, b: usize) -> Vec<[Pos2; 4]> {
let rects = self.layout.selection_rects(a, b);
rects.iter().map(|r| [self.at(app, r.x0, r.y0), self.at(app, r.x1, r.y0), self.at(app, r.x1, r.y1), self.at(app, r.x0, r.y1)]).collect()
}
/// The caret before character `index`: its top and bottom on screen (an empty layer: a line
/// of default height at its origin).
pub(crate) fn caret(&self, app: &FigApp, index: usize, zoom: f64) -> [Pos2; 2] {
let (cx, li) = self.layout.caret(index);
match self.layout.lines.get(li).or(self.layout.lines.last()) {
Some(line) => [self.at(app, cx, line.top), self.at(app, cx, line.top + line.height)],
None => {
let p = self.at(app, 0.0, 0.0);
[p, p + vec2(0.0, 14.0 * zoom as f32)]
}
}
}
}
/// A node's oriented outline in screen space.
pub(crate) fn node_quad(app: &FigApp, doc: &Document, id: NodeId) -> Option<[Pos2; 4]> {
let n = doc.node(id)?;
let w = doc.world_transform(id);
let corners = [Point::new(0.0, 0.0), Point::new(n.width, 0.0), Point::new(n.width, n.height), Point::new(0.0, n.height)];
Some(corners.map(|c| {
let p = w * c;
app.to_screen(p.x, p.y)
}))
}
pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) {
painter.add(Shape::closed_line(q.to_vec(), s));
}
/// The slots of the selected components and instances (and a slot being dropped onto): a dashed
/// outline and the slot's name above its top-left corner, like a frame label (never over the
/// content).
pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option<NodeId>) {
use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of};
let mut roots: Vec<NodeId> = Vec::new();
for id in sel {
let root = match doc.node(*id).map(|n| &n.kind) {
Some(NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }) => Some(*id),
_ => owning_instance(doc, *id).or_else(|| owning_component(doc, *id)),
};
if let Some(r) = root.filter(|r| !roots.contains(r)) {
roots.push(r);
}
}
let mut slots: Vec<(NodeId, String)> = roots.iter().flat_map(|r| slots_of(doc, *r)).collect();
if let Some(d) = drop
&& !slots.iter().any(|(s, _)| *s == d)
&& let Some(name) = slot_name(doc, d)
{
slots.push((d, name));
}
for (id, name) in slots {
let Some(q) = node_quad(app, doc, id) else { continue };
if !Rect::from_points(&q).intersects(app.canvas.rect) {
continue;
}
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
// The label, when the slot is wide enough on screen to hold it.
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
if (q[1] - q[0]).length() < w {
continue;
}
let at = q[0] - vec2(0.0, h + 2.0);
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
}
}
/// The instance an Alt-drop of a component from the Assets panel swaps: tinted and outlined in
/// the component colour, with a "Swap instance" label above its top-left corner.
pub(super) fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: NodeId) {
let Some(q) = node_quad(app, doc, id) else { return };
// White on the component colour, in both themes (like the canvas's selection labels).
let ink = Color32::WHITE;
painter.add(Shape::convex_polygon(q.to_vec(), COMPONENT.gamma_multiply(0.12), Stroke::NONE));
quad(painter, q, stroke(COMPONENT, 2.0));
let galley = painter.layout_no_wrap(tr("Swap instance").to_string(), semibold(10.0), ink);
let size = vec2(galley.size().x + 26.0, 18.0);
let top = Rect::from_points(&q).min;
let pill = Rect::from_min_size(pos2(top.x, top.y - size.y - 4.0), size);
painter.rect_filled(pill, CornerRadius::same(4), COMPONENT);
crate::icons::paint(
painter,
Rect::from_center_size(pos2(pill.min.x + 11.0, pill.center().y), vec2(10.0, 10.0)),
crate::icons::Icon::Instance,
ink,
);
painter.galley(pos2(pill.min.x + 20.0, pill.center().y - galley.size().y / 2.0), galley, ink);
}
/// The outline colour for a layer: purple for components, sets and instances and every layer in
/// them, the page's overlay `green` otherwise.
pub(super) fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Color32 {
use figcraft_engine::components::{owning_component, owning_instance};
let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. }));
if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green }
}
/// The outline path of shapes (ellipses, vectors) for hover/selection outlines.
fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option<Vec<Vec<Pos2>>> {
let n = doc.node(id)?;
if !matches!(
n.kind,
NodeKind::Ellipse { .. } | NodeKind::Vector { .. } | NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::BooleanOp { .. }
) {
return None;
}
let (path, _) = figcraft_render::node_outline(doc, n)?;
let w = doc.world_transform(id);
let mut out = Vec::new();
let mut cur: Vec<Pos2> = Vec::new();
figcraft_geom::kurbo::flatten(path.iter(), 0.5 / app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0), |el| {
use figcraft_geom::kurbo::PathEl::*;
match el {
MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
let q = w * p;
cur.push(app.to_screen(q.x, q.y));
}
LineTo(p) => {
let q = w * p;
cur.push(app.to_screen(q.x, q.y));
}
ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
}
});
if cur.len() > 1 {
out.push(cur);
}
Some(out)
}
/// A layer's outline on the canvas: its shape for ellipses, vectors, polygons, stars and boolean
/// groups, its box otherwise.
pub(super) fn draw_outline(app: &FigApp, painter: &egui::Painter, doc: &Document, id: NodeId, s: Stroke) {
match outline_shape(app, doc, id) {
Some(lines) => {
for l in lines {
painter.add(Shape::line(l, s));
}
}
None => {
if let Some(q) = node_quad(app, doc, id) {
quad(painter, q, s);
}
}
}
}
+545
View File
@@ -0,0 +1,545 @@
use super::builder::{builder_overlay, hatched, region_hatch};
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight};
use super::rulers::{boards, fmt, rulers};
use super::*;
/// Everything drawn over the rendered page; returns the frame labels drawn (frame, corners on the
/// page) for the engine to hit-test.
pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [Point; 4])> {
let mut labels = Vec::new();
let Ok(ds) = app.session.ds() else { return labels };
let doc = ds.doc.clone();
let page = ds.page;
let sel = ds.selection.clone();
let zoom = ds.view.zoom;
let view = ds.view.clone();
// Pixel grid at high zoom.
if view.pixel_grid_shown() {
let r = app.canvas.rect;
let p0 = app.to_page(r.min);
let p1 = app.to_page(r.max);
let col = Color32::from_black_alpha(18);
let mut x = p0.x.floor();
while x <= p1.x {
let s = app.to_screen(x, 0.0).x;
painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0));
x += 1.0;
}
let mut y = p0.y.floor();
while y <= p1.y {
let s = app.to_screen(0.0, y).y;
painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0));
y += 1.0;
}
}
// Layout grids and frame labels for top-level frames (also those in sections); the focus view
// labels only its frame.
let focus = ds.focused();
for c in boards(&doc, page).iter().filter(|c| focus.is_none_or(|f| f == **c)) {
let Some(n) = doc.node(*c) else { continue };
let Some(b) = doc.world_bounds(*c) else { continue };
// Off-screen frames draw neither grids nor a label (labels sit just outside the frame).
let on_screen = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(20.0);
if !on_screen.intersects(app.canvas.rect) {
continue;
}
if view.layout_grids && !n.grids.is_empty() && figcraft_layout::grids_shown(&doc, *c) {
let w = doc.world_transform(*c);
for g in n.grids.iter().filter(|g| g.visible) {
let col = doc_c32(painter.ctx(), g.color);
for r in figcraft_layout::grid_lines(g, n.width, n.height) {
let a = w * Point::new(r.x0, r.y0);
let b = w * Point::new(r.x1, r.y1);
let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y));
if g.kind == figcraft_doc::GridKind::Grid {
painter.line_segment([sa, sb], stroke(col, 1.0));
} else {
painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col);
}
}
}
}
// The focus view's bar names its frame.
// Board objects (stickies, shapes with text, stamps) are frames without a name label.
let labelled = focus.is_none() && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) && !n.is_board_atom();
if labelled && let Some(q) = crate::frame_labels::draw(app, painter, t, &doc, *c, sel.contains(c), ds.hover == Some(*c)) {
labels.push((*c, q.map(|p| app.to_page(p))));
}
}
// Prototype connections.
crate::proto_canvas::draw(app, painter);
// Hover outline (measuring outlines the measured box in red instead).
if let Some(h) = ds.hover
&& !sel.contains(&h)
&& matches!(app.session.tool.tool, ToolId::Move | ToolId::Scale)
&& app.session.prefs.highlight_on_hover
&& app.session.tool.overlay.measure_box.is_none()
{
draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5));
}
// Inside a boolean group (an operand selected or in vector edit mode): every operand's outline,
// so the shapes making the live result stay visible while one of them changes.
let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id));
for id in operands.iter().filter(|o| !sel.contains(o)) {
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0));
}
// Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows.
if view.mask_outlines || view.frame_outlines || view.show_slices {
for id in doc.subtree(page) {
let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
if view.mask_outlines && n.is_mask {
draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0));
} else if view.frame_outlines && n.kind.is_frame_like() {
draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0));
} else if view.show_slices
&& matches!(n.kind, NodeKind::Slice)
&& let Some(q) = node_quad(app, &doc, id)
{
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
}
}
}
// Drop target / insertion indicator.
let ov = &app.session.tool.overlay;
if let Some(d) = ov.drop_target
&& let Some(q) = node_quad(app, &doc, d)
{
quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0));
}
slot_badges(app, painter, &doc, &sel, ov.drop_target);
if let Some(t) = ov.swap_target {
swap_highlight(app, painter, &doc, t);
}
if let Some([x0, y0, x1, y1]) = ov.insert_line {
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 2.0));
}
if let Some([x0, y0, x1, y1]) = ov.drop_cell {
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
painter.rect_filled(r, CornerRadius::same(2), accent(painter).gamma_multiply(0.12));
let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()];
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.5), 4.0, 3.0));
}
// Vector edit mode, with the transform box around several selected points.
// The selection's size label, which measurement labels keep clear of.
let mut size_label = None;
if let Some(ve) = ds.vector_edit.clone()
&& let Some(o) = figcraft_engine::vectoredit::overlay(&doc, &ve)
{
// The Variable width tool shows width points in place of the path's points.
let width_tool = ve.tool == figcraft_engine::vectoredit::VTool::Width;
// Other layers edited at once go under the active one; the hover shows on its own layer.
let ov = &app.session.tool.overlay;
let hover_on = |id| ov.vector_hover.filter(|_| ov.vector_hover_on.unwrap_or(ve.id) == id);
for part in &ve.others {
if let Some(po) = figcraft_engine::vectoredit::overlay(&doc, &ve.view(part)) {
vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false, None);
}
}
vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true, ov.vector_add);
if width_tool {
width_overlay(app, painter, &doc, &ve, zoom);
} else if let Some(c) = app.session.vector_edit_box() {
// The box around several selected points draws no corner handles: they would read as
// points. Its corners and edges still scale the points when dragged.
let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot()));
size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 0, Some(label));
}
}
// Selection.
let editing = ds.text_edit.clone();
if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() {
let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
for id in &sel {
draw_outline(app, painter, &doc, *id, stroke(col, 1.0));
}
// The shape builder reshapes by regions: no transform box.
if app.session.tool.tool != ToolId::ShapeBuilder
&& let Some(b) = selection_box(&doc, &sel)
&& let Some((_, w, h)) = selection_frame(&doc, &sel)
{
let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
// A selected connector's handles are its two ends (drag one onto another object).
let connector = figcraft_engine::board::selected_connector_ends(&doc, &sel).map(|(_, ends)| ends);
let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
let scale = app.session.tool.overlay.scale;
if let Some(hint) = scale {
label = format!("{label} · {}x", fmt(hint.factor.abs()));
}
// A layer smaller than its handles on screen shows just its outline (they still answer
// just outside it).
let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
// Measuring to another layer (Alt held) shows just the outlines: no handles or size.
let measuring = app.session.tool.overlay.measure_box.is_some();
// Locked layers (selected in the layers panel) can't be resized, nor anything in Dev
// Mode: no handles either.
let handles =
if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
0
} else if is_line {
2
} else if tiny {
0
} else {
4
};
size_label = transform_box(
app,
painter,
b.map(|p| app.to_screen(p.x, p.y)),
col,
sel.len() > 1,
handles,
(!measuring && connector.is_none()).then_some(label),
);
for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) {
let c = app.to_screen(end.x, end.y);
painter.circle_filled(c, 5.0, Color32::WHITE);
painter.circle_stroke(c, 5.0, stroke(col, 1.5));
}
if !measuring {
arc_overlay(app, painter, col);
shape_overlay(app, painter, col);
}
if app.session.tool.tool == ToolId::Scale {
// The point that stays put: the dragged pivot, else the anchor.
let pivot = scale
.map(|h| Point::new(h.pivot[0], h.pivot[1]))
.or_else(|| figcraft_engine::tools::scale_anchor_point(&doc, &sel, app.session.tool.scale_anchor));
if let Some(p) = pivot {
let c = app.to_screen(p.x, p.y);
painter.circle_filled(c, 4.0, Color32::WHITE);
painter.circle_stroke(c, 4.0, stroke(col, 1.5));
painter.circle_filled(c, 1.5, col);
}
}
// The rotation origin while it shows (Alt+R): a ring with a cross through it.
if let Some(o) = ds.origin_handle() {
let c = app.to_screen(o.x, o.y);
painter.circle_filled(c, 5.0, Color32::WHITE);
painter.circle_stroke(c, 5.0, stroke(col, 1.5));
for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
painter.line_segment([c - d * 10.0, c - d * 5.0], stroke(col, 1.5));
painter.line_segment([c + d * 5.0, c + d * 10.0], stroke(col, 1.5));
}
painter.circle_filled(c, 1.5, col);
}
}
}
if app.session.tool.tool == ToolId::ShapeBuilder
&& let Some(arr) = figcraft_engine::shapebuilder::current(&app.session)
{
builder_overlay(app, painter, t, &arr);
}
// Text editing: caret and selection, or the IME composition underlined at the caret.
let preedit = app.preedit_view();
let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(&doc);
if let Some(te) = editing
&& let Some(text) = TextOnScreen::of(edoc, te.id)
{
let layout = &text.layout;
if let Some(q) = node_quad(app, edoc, te.id) {
quad(painter, q, stroke(accent(painter), 1.0));
}
let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
if a != b && preedit.is_none() {
for q in text.selection(app, a, b) {
painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
}
}
if let Some(p) = preedit {
for r in layout.selection_rects(p.range.0, p.range.1) {
// Underline just below the baseline, across the composed chars only.
let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
let x1 = layout
.glyphs
.get(line.glyph_start..line.glyph_end)
.unwrap_or(&[])
.iter()
.filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
.map(|g| g.x + g.advance)
.fold(r.x0, f64::max);
let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
}
}
let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
if blink || a != b {
painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
}
// Smart guides and measurements (their labels kept clear of the size label).
crate::measure_overlay::draw(app, painter, t, size_label);
// Marquee.
if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) {
let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1));
painter.rect_filled(r, CornerRadius::ZERO, accent(painter).gamma_multiply(0.1));
painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside);
}
// Pen path.
if !ov.pen.is_empty() {
let pts: Vec<Pos2> = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if app.session.tool.tool == ToolId::Pencil {
// The stroke it will make, in its colour and weight (at least a point when zoomed out).
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0))));
} else {
for [x0, y0, x1, y1] in &ov.pen_handles {
bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE);
}
for p in &pts {
painter.circle_filled(*p, 3.5, Color32::WHITE);
painter.circle_stroke(*p, 3.5, stroke(accent(painter), 1.0));
}
if let Some([x0, y0, x1, y1]) = ov.rubber {
let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1));
// The segment a click would make: along the last anchor's out handle, if it has one.
match ov.rubber_handle {
Some([hx, hy]) => {
let h = app.to_screen(hx, hy);
painter.add(egui::epaint::CubicBezierShape::from_points_stroke(
[a, h, b, b],
false,
Color32::TRANSPARENT,
stroke(accent(painter), 1.0),
));
}
None => {
painter.line_segment([a, b], stroke(accent(painter), 1.0));
}
}
}
}
} else if let Some([x0, y0, x1, y1]) = ov.rubber {
// A connector being drawn, or one of its ends dragged: from where it starts to the pointer.
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5));
}
// Eraser brush (vector edit mode).
if let Some([x, y, r]) = ov.brush {
let c = app.to_screen(x, y);
let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
}
// Lasso (vector edit mode).
if ov.lasso.len() > 1 {
let mut pts: Vec<Pos2> = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if let Some(first) = pts.first().copied() {
pts.push(first);
}
painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
}
// Guides (under the rulers).
crate::guides::draw(app, painter);
// Rulers.
if view.rulers {
rulers(app, painter, t, &doc, &sel);
}
labels
}
/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles`
/// corner handles and, when dimensions are shown, the size `label` (if any) under it (its box returned).
fn transform_box(
app: &FigApp,
painter: &egui::Painter,
q: [Pos2; 4],
col: Color32,
outline: bool,
handles: usize,
label: Option<String>,
) -> Option<Rect> {
if outline {
quad(painter, q, stroke(col, 1.0));
}
for c in q.iter().take(handles) {
let r = Rect::from_center_size(*c, vec2(8.0, 8.0));
painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE);
painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside);
}
if let Some(label) = label.filter(|_| app.session.prefs.show_dimensions) {
let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max);
let cx = q.iter().map(|p| p.x).sum::<f32>() / 4.0;
return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col));
}
None
}
/// A canvas readout: white `text` on a `col` pill placed at `at` by `align` (its box returned).
fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect {
let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE);
let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0));
painter.rect_filled(r, CornerRadius::same(3), crate::theme::under_white(col));
painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE);
r
}
/// The selected ellipse's arc handles (the hovered or dragged one larger, Start with a dot inside)
/// and, while one is dragged, its readout beside it.
fn arc_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) {
use figcraft_engine::arc_handles::ArcHandle;
let Some(hs) = app.session.arc_handles() else { return };
let ov = &app.session.tool.overlay;
for h in [ArcHandle::Ratio, ArcHandle::Start, ArcHandle::Sweep] {
let Some([x, y]) = hs.at(h) else { continue };
let c = app.to_screen(x, y);
let r = if ov.arc_hover == Some(h) { 5.0 } else { 4.0 };
painter.circle_filled(c, r, Color32::WHITE);
painter.circle_stroke(c, r, stroke(col, 1.0));
if h == ArcHandle::Start {
painter.circle_filled(c, 1.5, col);
}
}
if let Some(hint) = &ov.arc {
let c = app.to_screen(hint.at[0], hint.at[1]);
pill(painter, c + vec2(12.0, 12.0), Align2::LEFT_TOP, hint.label.clone(), col);
}
}
/// The selected shape's corner radius handles and a star's ratio handle (the hovered or dragged
/// one larger) and, while one is dragged, its value beside it.
fn shape_overlay(app: &FigApp, painter: &egui::Painter, col: Color32) {
use figcraft_engine::shape_handles::ShapeHandle;
let ov = &app.session.tool.overlay;
for (h, [x, y]) in app.session.shape_handles() {
let r = if ov.shape_hover == Some(h) { 4.5 } else { 3.5 };
let c = app.to_screen(x, y);
painter.circle_filled(c, r, Color32::WHITE);
painter.circle_stroke(c, r, stroke(col, 1.0));
}
if let Some(hint) = &ov.shape {
let c = app.to_screen(hint.at[0], hint.at[1]);
let text = match hint.handle {
ShapeHandle::Radius { .. } => format!("{} {}", tr("Radius"), hint.value),
ShapeHandle::Ratio => format!("{} {}%", tr("Ratio"), hint.value),
};
pill(painter, c + vec2(12.0, 12.0), Align2::LEFT_TOP, text, col);
}
}
/// Variable width tool: each width point on the path with a bar across the stroke to its two
/// width handles (the selected point filled), and a faint preview where a drag would add one.
fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &figcraft_engine::vectoredit::VectorEdit, zoom: f64) {
use figcraft_doc::WidthSide;
use figcraft_engine::vectoredit::{WHit, width_handles, width_preview};
let hover = app.session.tool.overlay.width_hover;
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| {
let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1]));
let col = if ghost { accent(painter).gamma_multiply(0.5) } else { accent(painter) };
painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0));
painter.line_segment([a, b], stroke(col, 1.0));
for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] {
let grab = !ghost && hover == Some(WHit::Handle { index: h.index, side });
let r = if grab { 4.5 } else { 3.5 };
painter.circle_filled(e, r, Color32::WHITE);
painter.circle_stroke(e, r, stroke(col, 1.0));
}
let on = !ghost && ve.width_point == Some(h.index);
let hovered = !ghost && hover == Some(WHit::Point { index: h.index });
let r = if hovered { 5.0 } else { 4.0 };
painter.circle_filled(c, r, if on { accent(painter) } else { Color32::WHITE });
painter.circle_stroke(c, r, stroke(col, 1.0));
};
for h in width_handles(doc, ve, zoom) {
draw(&h, false);
}
if let Some(WHit::Path { t, .. }) = hover {
for h in width_preview(doc, ve, t, zoom) {
draw(&h, true);
}
}
}
/// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of
/// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike).
fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) {
painter.line_segment([v, e], stroke(accent(painter), 1.0));
painter.circle_filled(e, r, fill);
painter.circle_stroke(e, r, stroke(accent(painter), 1.0));
}
/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, the
/// `add` point a press would put on the outline, and for the `active` layer the closed area under
/// the paint bucket and the pen's rubber band.
fn vector_overlay(
app: &FigApp,
painter: &egui::Painter,
o: &figcraft_engine::vectoredit::EditOverlay,
points: bool,
hover: Option<figcraft_engine::vectoredit::VHit>,
active: bool,
add: Option<figcraft_engine::tools::VectorAdd>,
) {
use figcraft_engine::vectoredit::VHit;
let ov = &app.session.tool.overlay;
let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]);
// Paint bucket target: hatched like a fill to come.
if active && !ov.face.is_empty() {
let polys: Vec<Vec<Pos2>> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect();
hatched(painter, &polys, accent(painter));
}
if active {
region_hatch(app, painter);
}
let hover_seg = match hover {
Some(VHit::Segment { seg, .. }) => Some(seg),
_ => None,
};
for (i, seg) in o.segments.iter().enumerate() {
let [a, b, c, d] = [sp(&seg[0]), sp(&seg[1]), sp(&seg[2]), sp(&seg[3])];
let w = if o.selected_segments.contains(&i) {
3.0
} else if hover_seg == Some(i) {
2.5
} else {
1.5
};
painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(accent(painter), w)));
}
if !points {
return;
}
for h in &o.handles {
let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start });
let on = o.selected_handles.contains(&(h.seg, h.start));
bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { accent(painter) } else { Color32::WHITE });
}
for (i, v) in o.vertices.iter().enumerate() {
let p = sp(v);
let hovered = matches!(hover, Some(VHit::Vertex { index }) if index == i);
let size = if hovered { 9.0 } else { 7.0 };
let r = Rect::from_center_size(p, vec2(size, size));
let on = o.selected_vertices.contains(&i);
painter.rect_filled(r, CornerRadius::same(5), if on { accent(painter) } else { Color32::WHITE });
painter.rect_stroke(r, CornerRadius::same(5), stroke(accent(painter), 1.0), StrokeKind::Inside);
if o.anchor == Some(i) {
painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0));
}
}
// The point a press would add to the outline: the pen's ghost anchor where it is over a
// segment, or a segment's midpoint under the Move tool (which grows and fills once the pointer
// is on it, when a press adds it).
if let Some(a) = add {
let p = app.to_screen(a.at[0], a.at[1]);
let col = accent(painter);
if a.midpoint {
let (r, fill) = if a.on { (5.5, col) } else { (3.5, Color32::from_white_alpha(190)) };
painter.circle_filled(p, r, fill);
painter.circle_stroke(p, r, stroke(if a.on { Color32::WHITE } else { col.gamma_multiply(0.7) }, 1.2));
} else {
let r = Rect::from_center_size(p, vec2(7.0, 7.0));
painter.rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(160));
painter.rect_stroke(r, CornerRadius::same(5), stroke(col.gamma_multiply(0.8), 1.0), StrokeKind::Inside);
}
}
if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) {
painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0));
}
}
/// The overlay green for the page this painter draws on ([`crate::theme::canvas_accent`]).
pub(super) fn accent(p: &egui::Painter) -> Color32 {
crate::theme::canvas_accent(p.ctx())
}
+166
View File
@@ -0,0 +1,166 @@
use super::*;
#[derive(Clone, PartialEq)]
pub(super) struct JobKey {
doc: usize,
page: NodeId,
origin: (i64, i64),
scale: i64,
size: (u32, u32),
outline: bool,
outline_extras: figcraft_render::Outline,
outlined: Vec<NodeId>,
skip: Vec<NodeId>,
solo: Option<NodeId>,
/// Text lays out anew when fonts arrive (system fonts load in the background).
fonts: u64,
proof: bool,
dots: Option<figcraft_render::Dots>,
}
/// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and
/// not a coarse preview over a refined image of the same or a newer request, or one that already
/// shows the whole view asked for at its zoom (the same zoom elsewhere on the page is no such
/// image).
pub(crate) fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool {
if shown != Some((done.uid, done.page)) {
return false;
}
let refined = !c.tex_coarse && c.tex_of == shown;
if done.coarse && refined && done.seq <= c.tex_seq {
return false;
}
let refined_here = refined
&& c.tex_view
.as_ref()
.zip(c.want_view.as_ref())
.is_some_and(|(have, want)| have.scale.to_bits() == want.scale.to_bits() && covers(have, want));
!(done.coarse && refined_here)
}
/// Whether `outer` shows all of the page that `inner` does.
fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool {
let (o, i) = (outer.page_rect(), inner.page_rect());
// Rounding in the snapped origin, well under a device pixel.
let eps = 1e-6 / inner.scale.max(1e-9);
o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps
}
pub(super) fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_side: u32, dots: Option<figcraft_render::Dots>) {
let Ok(ds) = app.session.ds() else { return };
let rect = app.canvas.rect;
let (mut ox, mut oy) = ds.view.origin();
let (scale, visible_w, visible_h) = if let Some(k) = ds.view.pixel_preview_scale() {
// Pixel preview: render `k` pixels per page unit from a whole-pixel origin, shown magnified
// without smoothing.
(ox, oy) = ((ox * k).floor() / k, (oy * k).floor() / k);
let z = ds.view.zoom.max(1e-9) / k;
(k, (f64::from(rect.width()) / z).ceil() as u32 + 2, (f64::from(rect.height()) / z).ceil() as u32 + 2)
} else {
(ds.view.zoom * f64::from(ppp), (rect.width() * ppp).ceil().max(1.0) as u32, (rect.height() * ppp).ceil().max(1.0) as u32)
};
// The edited text is drawn live (an IME composition included); the caret is an overlay.
let doc = app.preedit_view().map(|p| p.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] };
let proof = ds.view.proof_srgb && !doc.color_profile.is_srgb();
let outline_extras = figcraft_render::Outline { hidden: ds.view.outline_hidden, bounds: ds.view.outline_bounds, light: false };
// Render beyond the visible viewport. During a pan the previous texture then still covers
// the screen while the worker renders newly exposed content. Request a new buffer before the
// visible area reaches its edge, rather than exposing an empty strip each pointer move.
let margin = overscan_margin(visible_w, visible_h, max_texture_side);
let origin = (ox, oy);
let visible = (visible_w, visible_h);
let requested = (ox - f64::from(margin) / scale, oy - f64::from(margin) / scale);
let size = (visible_w.saturating_add(margin * 2).min(max_texture_side), visible_h.saturating_add(margin * 2).min(max_texture_side));
let key = JobKey {
doc: Arc::as_ptr(&doc) as usize,
page: ds.page,
origin: ((requested.0 * 1000.0) as i64, (requested.1 * 1000.0) as i64),
scale: (scale * 10000.0) as i64,
size,
outline: ds.view.outlines,
outline_extras,
outlined: outlined.clone(),
skip: vec![],
solo: ds.focused(),
fonts: figcraft_text::layout_generation(),
proof,
dots,
};
if let Some(previous) = &app.canvas.submitted {
let previous_origin = (previous.origin.0 as f64 / 1000.0, previous.origin.1 as f64 / 1000.0);
let same_content = JobKey { origin: previous.origin, size: previous.size, ..key.clone() } == *previous;
let covers = cached_render_covers(previous_origin, previous.size, origin, visible, scale);
if same_content && covers {
return;
}
}
if app.canvas.submitted.as_ref() == Some(&key) {
return;
}
let job = Job {
doc,
page: ds.page,
view: figcraft_render::View { origin: requested, scale, width: size.0, height: size.1 },
outline: ds.view.outlines,
outline_extras,
outlined,
skip: vec![],
solo: key.solo,
proof,
dots,
uid: ds.uid,
zoom_preview: ds.view.pixel_preview_scale().is_none(),
at: web_time::Instant::now(),
};
app.canvas.want_view = Some(job.view.clone());
worker.submit(job);
app.canvas.submitted = Some(key);
}
/// Leave a 96-pixel reserve so the next render starts before a pan reaches a cached edge.
fn cached_render_covers(buffer_origin: (f64, f64), buffer_size: (u32, u32), view_origin: (f64, f64), view_size: (u32, u32), scale: f64) -> bool {
if !scale.is_finite() || scale <= 0.0 {
return false;
}
let guard_x = 96.0_f64.min(f64::from(buffer_size.0.saturating_sub(view_size.0)) / 4.0) / scale;
let guard_y = 96.0_f64.min(f64::from(buffer_size.1.saturating_sub(view_size.1)) / 4.0) / scale;
view_origin.0 >= buffer_origin.0 + guard_x
&& view_origin.1 >= buffer_origin.1 + guard_y
&& view_origin.0 + f64::from(view_size.0) / scale <= buffer_origin.0 + f64::from(buffer_size.0) / scale - guard_x
&& view_origin.1 + f64::from(view_size.1) / scale <= buffer_origin.1 + f64::from(buffer_size.1) / scale - guard_y
}
/// Bound the extra rendered area to about 1.6 times the visible area. A fixed large margin can
/// multiply the pixel count several-fold on a narrow or small viewport.
fn overscan_margin(width: u32, height: u32, max_texture_side: u32) -> u32 {
width.min(height).saturating_div(8).min(128).min(max_texture_side.saturating_sub(width.max(height)) / 2)
}
#[cfg(test)]
#[test]
fn pan_uses_cached_pixels_until_the_refill_boundary() {
let margin = overscan_margin(1000, 800, 16_383);
assert_eq!(margin, 100);
let buffer = (-f64::from(margin), -f64::from(margin));
let size = (1000 + 2 * margin, 800 + 2 * margin);
assert!(cached_render_covers(buffer, size, (0.0, 0.0), (1000, 800), 1.0));
assert!(cached_render_covers(buffer, size, (50.0, 0.0), (1000, 800), 1.0));
assert!(!cached_render_covers(buffer, size, (80.0, 0.0), (1000, 800), 1.0));
assert!(!cached_render_covers(buffer, size, (-80.0, 0.0), (1000, 800), 1.0));
assert!(cached_render_covers((-24.0, -24.0), (1048, 848), (10.0, 0.0), (1000, 800), 1.0));
assert!(!cached_render_covers((-24.0, -24.0), (1048, 848), (19.0, 0.0), (1000, 800), 1.0));
}
#[cfg(test)]
#[test]
fn overscan_stays_within_texture_and_pixel_budget() {
for (width, height, limit) in [(1000, 800, 16_383), (390, 844, 16_383), (2560, 1440, 16_383), (1000, 800, 1060)] {
let margin = overscan_margin(width, height, limit);
assert!(width + 2 * margin <= limit || width > limit);
assert!(height + 2 * margin <= limit || height > limit);
let rendered = u64::from(width + 2 * margin) * u64::from(height + 2 * margin);
let visible = u64::from(width) * u64::from(height);
assert!(rendered * 5 <= visible * 8); // At most 1.6× the visible pixels.
}
}
+93
View File
@@ -0,0 +1,93 @@
use super::outline::stroke;
use super::overlays::accent;
use super::*;
/// The layers that get canvas labels and layout grids, in z-order: the page's visible top-level
/// layers and, inside sections whose contents show, their visible children (sections nest). A
/// section comes after its children, so its title pill draws over (and takes clicks from) the
/// labels of frames near its top-left (#1150).
pub(super) fn boards(doc: &Document, page: NodeId) -> Vec<NodeId> {
fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec<NodeId>) {
let Some(n) = doc.node(id) else { return };
for c in &n.children {
let Some(cn) = doc.node(*c) else { continue };
if !cn.visible {
continue;
}
if matches!(cn.kind, NodeKind::Section { contents_hidden: false }) && depth < 32 {
walk(doc, *c, depth + 1, out);
}
out.push(*c);
}
}
let mut out = Vec::new();
walk(doc, page, 0, &mut out);
out
}
/// A frame's or section's handoff status as its badge says it, with the badge colour ("Changed"
/// once a ready-for-dev layer was edited; `None` without a status).
pub(crate) fn status_label(n: &figcraft_doc::Node) -> Option<(&'static str, Color32)> {
match n.dev_status {
figcraft_doc::DevStatus::None => None,
figcraft_doc::DevStatus::ReadyForDev if n.dev_changed => Some((tr("Changed"), crate::theme::DEV_CHANGED)),
figcraft_doc::DevStatus::ReadyForDev => Some((tr("Ready for dev"), crate::theme::DEV_READY)),
figcraft_doc::DevStatus::Completed => Some((tr("Completed"), crate::theme::DEV_COMPLETED)),
}
}
/// A frame's or section's handoff status as a small pill left-centred on `at`; the pill's area.
pub(crate) fn status_badge(painter: &egui::Painter, n: &figcraft_doc::Node, at: Pos2) -> Option<Rect> {
let (label, fill) = status_label(n)?;
let galley = painter.layout_no_wrap(label.to_string(), semibold(10.0), Color32::WHITE);
let r = Rect::from_min_size(pos2(at.x, at.y - galley.size().y / 2.0 - 2.0), galley.size() + vec2(10.0, 4.0));
painter.rect_filled(r, CornerRadius::same(4), fill);
painter.galley(r.min + vec2(5.0, 2.0), galley, Color32::WHITE);
Some(r)
}
pub(super) fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel: &[NodeId]) {
let r = app.canvas.rect;
let size = 20.0;
painter.rect_filled(Rect::from_min_size(r.min, vec2(r.width(), size)), CornerRadius::ZERO, t.panel);
painter.rect_filled(Rect::from_min_size(r.min, vec2(size, r.height())), CornerRadius::ZERO, t.panel);
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
let step = figcraft_engine::guides::ruler_step(zoom);
let p0 = app.to_page(r.min);
let p1 = app.to_page(r.max);
// Highlight the selection's span.
if let Some(b) = doc.bounds_of(sel) {
let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
painter.rect_filled(
Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)),
CornerRadius::ZERO,
accent(painter).gamma_multiply(0.15),
);
painter.rect_filled(
Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)),
CornerRadius::ZERO,
accent(painter).gamma_multiply(0.15),
);
}
let mut x = (p0.x / step).floor() * step;
while x <= p1.x {
let s = app.to_screen(x, 0.0).x;
painter.line_segment([pos2(s, r.min.y + size - 6.0), pos2(s, r.min.y + size)], stroke(t.text_tertiary, 1.0));
painter.text(pos2(s + 2.0, r.min.y + 3.0), Align2::LEFT_TOP, fmt(x), regular(9.0), t.text_secondary);
x += step;
}
let mut y = (p0.y / step).floor() * step;
while y <= p1.y {
let s = app.to_screen(0.0, y).y;
painter.line_segment([pos2(r.min.x + size - 6.0, s), pos2(r.min.x + size, s)], stroke(t.text_tertiary, 1.0));
painter.text(pos2(r.min.x + 3.0, s + 2.0), Align2::LEFT_TOP, fmt(y), regular(9.0), t.text_secondary);
y += step;
}
painter.line_segment([pos2(r.min.x, r.min.y + size), pos2(r.max.x, r.min.y + size)], stroke(t.border, 1.0));
painter.line_segment([pos2(r.min.x + size, r.min.y), pos2(r.min.x + size, r.max.y)], stroke(t.border, 1.0));
}
pub fn fmt(v: f64) -> String {
let r = (v * 100.0).round() / 100.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
}