designcraft/crates/ui-egui/src/canvas.rs

1729 lines
80 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: pasteboard, spreads (rendered by `designcraft-render`), guides, frame edges,
//! selection, text ports and threads, the text caret, rulers, and pointer/keyboard input.
use designcraft_compose as compose;
use designcraft_doc::{Content, Document, Item, PageSide, SpreadRef};
use designcraft_engine::doc::Selection;
use designcraft_geom::{Affine, Point, Rect as DRect, Vec2 as DVec2};
use designcraft_tools::{CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolKey};
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::{Value, json};
use crate::{DesignApp, ScreenMode, View, theme::Tokens};
pub const RULER: f32 = 15.0;
/// Canvas ↔ screen transform.
#[derive(Clone, Copy, Debug)]
pub struct Xf {
/// Top left of the (unrotated) view rectangle.
pub min: Pos2,
pub origin: Point,
pub zoom: f64,
/// Quarter turns clockwise about `pivot` (the view centre).
pub rot: u8,
pub pivot: Pos2,
}
/// Rotate `p` by `q` quarter turns clockwise about `c` (screen space, y down).
fn quarter(p: Pos2, c: Pos2, q: u8) -> Pos2 {
let (dx, dy) = (p.x - c.x, p.y - c.y);
let (x, y) = match q % 4 {
0 => (dx, dy),
1 => (-dy, dx),
2 => (-dx, -dy),
_ => (dy, -dx),
};
pos2(c.x + x, c.y + y)
}
/// The unrotated view rectangle for a screen rectangle and a rotation (width and height swap
/// for quarter turns).
pub fn view_rect(screen: Rect, rot: u8) -> Rect {
if rot % 2 == 1 { Rect::from_center_size(screen.center(), vec2(screen.height(), screen.width())) } else { screen }
}
impl Xf {
pub fn new(rect: Rect, v: &View) -> Self {
Xf { min: rect.min, origin: v.origin, zoom: v.zoom, rot: v.rotation % 4, pivot: rect.center() }
}
pub fn to_screen(&self, p: Point) -> Pos2 {
let s = pos2(self.min.x + ((p.x - self.origin.x) * self.zoom) as f32, self.min.y + ((p.y - self.origin.y) * self.zoom) as f32);
if self.rot == 0 { s } else { quarter(s, self.pivot, self.rot) }
}
/// A screen position in the unrotated view.
pub fn unrotate(&self, p: Pos2) -> Pos2 {
if self.rot == 0 { p } else { quarter(p, self.pivot, 4 - self.rot) }
}
/// A screen-space movement in the unrotated view.
pub fn unrotate_delta(&self, d: egui::Vec2) -> egui::Vec2 {
self.unrotate(self.pivot + d) - self.pivot
}
pub fn to_canvas(&self, p: Pos2) -> Point {
let p = self.unrotate(p);
Point::new(self.origin.x + (p.x - self.min.x) as f64 / self.zoom, self.origin.y + (p.y - self.min.y) as f64 / self.zoom)
}
pub fn rect(&self, r: DRect) -> Rect {
Rect::from_two_pos(self.to_screen(Point::new(r.x0, r.y0)), self.to_screen(Point::new(r.x1, r.y1)))
}
/// Canvas → screen affine (for vello rendering: pixels = points × ppp).
pub fn affine(&self, ppp: f64, area_min: Pos2) -> Affine {
let unrotated = Affine::translate((self.min.x as f64, self.min.y as f64))
* Affine::scale(self.zoom)
* Affine::translate((-self.origin.x, -self.origin.y));
let (cx, cy) = (self.pivot.x as f64, self.pivot.y as f64);
let rot = Affine::translate((cx, cy)) * Affine::rotate(self.rot as f64 * std::f64::consts::FRAC_PI_2) * Affine::translate((-cx, -cy));
Affine::scale(ppp) * Affine::translate((-(area_min.x as f64), -(area_min.y as f64))) * rot * unrotated
}
}
fn c32(c: [u8; 3]) -> Color32 {
Color32::from_rgb(c[0], c[1], c[2])
}
/// Fit the current spread (or page) in the canvas.
pub fn fit(app: &mut DesignApp, rect: Rect, what: &str) {
let Some(st) = app.session.active() else { return };
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let cur = current_slot(app, &layout).unwrap_or(0);
let Some(slot) = layout.slots.get(cur) else { return };
let mut b = slot.bounds;
if what == "page"
&& let Some(sp) = st.doc.spread(slot.spread)
{
let center = app.view().map(|v| v.origin.x + rect.width() as f64 / 2.0 / v.zoom).unwrap_or(0.0);
let c = slot.to_spread(Point::new(center, slot.bounds.center().y));
let pi = sp.page_at_x(c.x).unwrap_or(0);
b = slot.xf.transform_rect_bbox(sp.pages[pi].bounds());
}
if what == "all" {
b = layout.slots.iter().map(|s| s.bounds).reduce(|a, b| a.union(b)).unwrap_or(b);
}
if what == "selection" {
// Fit Selection in Window: the selected objects (on their spread's slot).
let sel: Vec<designcraft_geom::Rect> = st
.selection
.items
.iter()
.filter_map(|id| {
let loc = st.doc.find(*id)?;
let slot = layout.slots.iter().find(|s| s.spread == loc.spread)?;
Some(slot.xf.transform_rect_bbox(st.doc.item(*id)?.bounds()))
})
.collect();
let Some(u) = sel.into_iter().reduce(|a, b| a.union(b)) else { return };
b = u.inflate(u.width().max(u.height()) * 0.05 + 1.0, u.width().max(u.height()) * 0.05 + 1.0);
}
let pad = 40.0;
let zoom = ((rect.width() as f64 - 2.0 * pad) / b.width()).min((rect.height() as f64 - 2.0 * pad) / b.height()).clamp(0.05, 40.0);
let origin = Point::new(b.center().x - rect.width() as f64 / 2.0 / zoom, b.center().y - rect.height() as f64 / 2.0 / zoom);
if let Some(v) = app.view_mut() {
*v = View { zoom, origin, fitted: true, rotation: v.rotation };
}
}
/// Index of the slot nearest the view centre.
pub fn current_slot(app: &DesignApp, layout: &CanvasLayout) -> Option<usize> {
let v = app.view()?;
let r = app.canvas_rect?;
let c = Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, v.origin.y + r.height() as f64 / 2.0 / v.zoom);
layout
.slots
.iter()
.enumerate()
.min_by(|a, b| {
let da = (a.1.bounds.center().y - c.y).abs();
let db = (b.1.bounds.center().y - c.y).abs();
da.total_cmp(&db)
})
.map(|(i, _)| i)
}
/// Absolute page index under the view centre.
pub fn current_page(app: &DesignApp) -> Option<usize> {
let st = app.session.active()?;
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let i = current_slot(app, &layout)?;
let slot = layout.slots.get(i)?;
let SpreadRef::Doc(si) = slot.spread else { return Some(0) };
let v = app.view()?;
let r = app.canvas_rect?;
let c = slot.to_spread(Point::new(v.origin.x + r.width() as f64 / 2.0 / v.zoom, slot.bounds.center().y));
let pi = st.doc.spreads[si].page_at_x(c.x).unwrap_or(0);
Some(st.doc.first_page_of_spread(si) + pi)
}
/// Scroll so absolute page `abs` is centred (keeps zoom).
pub fn go_to_page(app: &mut DesignApp, abs: usize) {
let Some(st) = app.session.active() else { return };
let layout = CanvasLayout::new(&st.doc, false);
let Some(pr) = layout.page_rect(&st.doc, abs) else { return };
let Some(rect) = app.canvas_rect else { return };
if let Some(v) = app.view_mut() {
v.origin = Point::new(pr.center().x - rect.width() as f64 / 2.0 / v.zoom, pr.center().y - rect.height() as f64 / 2.0 / v.zoom);
}
}
pub fn zoom_at(app: &mut DesignApp, screen: Pos2, factor: f64) {
let Some(rect) = app.canvas_rect else { return };
let Some(v) = app.view_mut() else { return };
let xf = Xf::new(rect, v);
let c = xf.to_canvas(screen);
let screen = xf.unrotate(screen);
let nz = (v.zoom * factor).clamp(0.05, 40.0);
v.zoom = nz;
v.origin = Point::new(c.x - (screen.x - rect.min.x) as f64 / nz, c.y - (screen.y - rect.min.y) as f64 / nz);
}
pub fn set_zoom(app: &mut DesignApp, z: f64) {
let Some(rect) = app.canvas_rect else { return };
let cur = app.view().map(|v| v.zoom).unwrap_or(1.0);
zoom_at(app, rect.center(), z / cur);
}
pub fn apply_view_request(app: &mut DesignApp, p: &Value) {
// Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim a view-sized rectangle, zoom
// back in where it was released.
if let Some(phase) = p.get("powerZoom").and_then(Value::as_str) {
let at = p.get("at").and_then(Value::as_array).map(|a| Point::new(a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0)));
match phase {
"start" => {
if let (Some(v), Some(rect)) = (app.view().copied(), app.canvas_rect) {
let centre = Xf::new(rect, &v).to_canvas(rect.center());
app.power_zoom = Some((v.zoom, at.unwrap_or(centre)));
fit(app, rect, "spread");
}
}
"move" => {
if let (Some(pz), Some(a)) = (&mut app.power_zoom, at) {
pz.1 = a;
}
}
_ => {
if let (Some((z, c)), Some(rect)) = (app.power_zoom.take(), app.canvas_rect)
&& let Some(v) = app.view_mut()
{
let c = at.unwrap_or(c);
v.zoom = z;
v.origin = Point::new(c.x - rect.width() as f64 / 2.0 / z, c.y - rect.height() as f64 / 2.0 / z);
}
}
}
}
if let Some(d) = p.get("pan").and_then(Value::as_array) {
let (dx, dy) = (d.first().and_then(Value::as_f64).unwrap_or(0.0), d.get(1).and_then(Value::as_f64).unwrap_or(0.0));
if let Some(v) = app.view_mut() {
v.origin = Point::new(v.origin.x - dx / v.zoom, v.origin.y - dy / v.zoom);
}
}
if let (Some(a), Some(rect)) = (p.get("zoomAt").and_then(Value::as_array), app.canvas_rect)
&& let Some(v) = app.view()
{
let c = Point::new(a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0));
let xf = Xf::new(rect, v);
let s = xf.to_screen(c);
zoom_at(app, s, p.get("factor").and_then(Value::as_f64).unwrap_or(2.0));
}
}
fn mods(i: &egui::InputState, space: bool) -> Mods {
Mods { shift: i.modifiers.shift, alt: i.modifiers.alt, cmd: i.modifiers.command, ctrl: i.modifiers.ctrl, space }
}
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
let rot = app.view().map_or(0, |v| v.rotation % 4);
// Rulers measure the unrotated layout, so they hide while the view is rotated.
let rulers = app.ui.rulers && app.ui.screen_mode != ScreenMode::Presentation && rot == 0;
let screen = if rulers { Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
let rect = view_rect(screen, rot);
app.canvas_rect = Some(rect);
let resp = ui.allocate_rect(full, Sense::click_and_drag());
if !app.view().is_some_and(|v| v.fitted) {
fit(app, rect, "spread");
}
handle_input(app, ui, &resp, rect);
let Some(st) = app.session.active() else { return };
let Some(&v) = app.view() else { return };
let xf = Xf::new(rect, &v);
let doc = st.doc.clone();
let layout = CanvasLayout::new(&doc, st.editing_parents);
let painter = ui.painter_at(screen);
let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation);
let bg = if app.ui.screen_mode == ScreenMode::Presentation { Color32::BLACK } else { t.pasteboard };
painter.rect_filled(screen, 0.0, bg);
// Page shadow: a hard 1.5 pt black offset on the right and bottom (InDesign 2026).
for slot in &layout.slots {
let r = xf.rect(slot.bounds);
painter.rect_filled(r.translate(vec2(1.5, 1.5)), 0.0, Color32::BLACK);
}
// Rendered content.
render_texture(app, ui.ctx(), rect, &xf, &layout, preview);
if let (Some(tex), Some(sh)) = (&app.canvas.texture, app.canvas.shown) {
// Place the texture where its region is in the current view (shifted while panning,
// scaled while a zoom re-render is pending).
// The texture's corners in canvas space, placed through the view (rotation included).
let (w, h) = (sh.size.0 as f64 / sh.zoom, sh.size.1 as f64 / sh.zoom);
let o = sh.origin;
let corners = [
(o, pos2(0.0, 0.0)),
(Point::new(o.x + w, o.y), pos2(1.0, 0.0)),
(Point::new(o.x + w, o.y + h), pos2(1.0, 1.0)),
(Point::new(o.x, o.y + h), pos2(0.0, 1.0)),
];
let mut mesh = egui::Mesh::with_texture(tex.id());
for (c, uv) in corners {
mesh.vertices.push(egui::epaint::Vertex { pos: xf.to_screen(c), uv, color: Color32::WHITE });
}
mesh.indices.extend([0, 1, 2, 0, 2, 3]);
if preview {
// Preview: only page areas (trim) show content.
for slot in &layout.slots {
let r = xf.rect(slot.bounds);
painter.with_clip_rect(r.intersect(screen)).add(egui::Shape::mesh(mesh.clone()));
}
} else {
painter.add(egui::Shape::mesh(mesh));
}
}
let hair = 1.0 / ui.ctx().pixels_per_point();
for slot in &layout.slots {
painter.rect_stroke(xf.rect(slot.bounds), 0.0, Stroke::new(hair, Color32::from_rgb(0x4a, 0x4a, 0x4a)), StrokeKind::Outside);
}
if !preview {
draw_guides(app, &painter, &xf, &doc, &layout, &t);
if let Some(g) = ui.data(|d| d.get_temp::<GuideDrag>(egui::Id::new(GUIDE_DRAG)))
&& let Some(p) = g.at
{
let col = Color32::from_rgb(74, 227, 255);
let (a, b) = match g.orientation {
designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)),
designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)),
};
painter.line_segment([a, b], Stroke::new(1.0, col));
}
draw_frames(app, &painter, &xf, &doc, &layout);
if app.ui.hidden_characters {
draw_hidden_characters(app, &painter, &xf, &doc, &layout);
}
if app.ui.flattener_preview {
draw_flattener_preview(&painter, &xf, &doc, &layout);
}
if app.ui.dynamic_spelling && !preview {
draw_dynamic_spelling(app, ui.ctx(), &painter, &xf, &doc, &layout);
}
}
let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout);
draw_tool_overlays(app, &painter, &xf);
if rulers {
draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t);
}
if let Some(r) = sel_rect
&& !preview
&& r.intersects(screen)
{
crate::taskbar::show(app, &ui.ctx().clone(), r, screen);
}
// Cursor.
if let Some(p) = resp.hover_pos().filter(|p| screen.contains(*p)) {
let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text();
let middle = ui.input(|i| i.pointer.middle_down());
let c = if middle {
Cursor::HandGrab
} else if space {
Cursor::Hand
} else {
app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info())
};
ui.ctx().set_cursor_icon(cursor_icon(c));
}
}
fn cursor_icon(c: Cursor) -> egui::CursorIcon {
use egui::CursorIcon as C;
match c {
Cursor::Arrow | 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 => 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::LoadedText | Cursor::LoadedGraphic => C::Copy,
Cursor::NotAllowed => C::NotAllowed,
}
}
/// Keep the canvas texture current. Panning inside the overscanned region just shifts the
/// texture; zooming shows the old texture scaled until the background render arrives; document
/// edits render synchronously so editing never lags.
fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, layout: &CanvasLayout, preview: bool) {
let Some(st) = app.session.active() else { return };
let ppp = ctx.pixels_per_point() as f64;
let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview);
// Overscan: 25% of the viewport on every side, within the GPU's maximum texture size.
let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32;
let (ow, oh) =
((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0));
let want = |origin: Point| crate::Shown {
doc: doc_key,
origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom),
zoom: xf.zoom,
size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh),
};
let target = want(xf.origin);
// Does the current texture cover the viewport at this zoom and document state?
let covers = |s: &crate::Shown| {
s.doc == doc_key && (s.zoom - xf.zoom).abs() < 1e-9 && {
let x0 = (xf.origin.x - s.origin.x) * s.zoom;
let y0 = (xf.origin.y - s.origin.y) * s.zoom;
x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5
}
};
// Collect finished background renders.
#[cfg(not(target_arch = "wasm32"))]
if let (Some(w), Some((tok, shown))) = (app.canvas.worker.as_ref(), app.canvas.pending)
&& let Some(done) = w.poll()
&& done.token == tok
{
upload(app, ctx, done.image);
app.canvas.shown = Some(shown);
app.canvas.shown_doc = app.canvas.pending_doc.take();
app.canvas.pending = None;
app.perf.render_ms = done.ms;
}
if app.canvas.shown.is_some_and(|s| covers(&s)) {
return;
}
let doc_changed = app.canvas.shown.is_none_or(|s| s.doc != doc_key);
let zoom_same = app.canvas.shown.is_some_and(|s| (s.zoom - xf.zoom).abs() < 1e-9);
let job = |app: &DesignApp| {
let st = app.session.active()?;
let placed: Vec<designcraft_render::Placed> =
layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect();
let w = (target.size.0 as f64 * ppp).round().max(1.0) as u32;
let h = (target.size.1 as f64 * ppp).round().max(1.0) as u32;
let view = Affine::scale(ppp) * Affine::scale(xf.zoom) * Affine::translate((-target.origin.x, -target.origin.y));
let opts = designcraft_render::RenderOptions {
printing_only: preview,
greek_below_px: 3.0 * ppp,
highlight_missing_fonts: !preview && app.session.prefs.highlight_substituted_fonts,
highlight_hj: !preview && app.session.prefs.highlight_hj,
condition_indicators: !preview,
note_indicators: !preview && app.session.prefs.show_note_anchors,
change_markup: !preview && app.session.prefs.show_added_text,
tag_markers: !preview && app.ui.tag_markers,
plate: app.ui.separation,
highlight_keeps: !preview && app.session.prefs.highlight_keeps,
highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking,
quality: app.ui.display_quality,
rich_black: app.ui.rich_black,
overprint_preview: app.ui.overprint_preview,
blend_space_view: !app.ui.proof_colors,
..Default::default()
};
Some((st.doc.clone(), placed, w, h, view, opts))
};
// Synchronous path: document edits (keep editing crisp), first frame, or no worker.
#[cfg(not(target_arch = "wasm32"))]
if !app.canvas.worker_started {
app.canvas.worker_started = true;
app.canvas.worker = render_worker_spawn(ctx);
}
#[cfg(not(target_arch = "wasm32"))]
let has_worker = app.canvas.worker.is_some();
#[cfg(target_arch = "wasm32")]
let has_worker = false;
// An edit with the view unchanged: repaint just the regions it changed.
if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) {
return;
}
if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) {
let t0 = crate::now_ms();
let Some((doc, placed, w, h, view, opts)) = job(app) else { return };
let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts);
upload(app, ctx, img);
app.canvas.shown = Some(target);
app.canvas.shown_doc = Some(doc);
app.canvas.pending = None;
app.perf.render_ms = crate::now_ms() - t0;
return;
}
#[cfg(not(target_arch = "wasm32"))]
{
// Already rendering this view?
if app.canvas.pending.is_some_and(|(_, p)| {
p.doc == doc_key && (p.zoom - xf.zoom).abs() < 1e-9 && {
let x0 = (xf.origin.x - p.origin.x) * p.zoom;
let y0 = (xf.origin.y - p.origin.y) * p.zoom;
x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64
}
}) {
return;
}
let Some((doc, placed, w, h, view, opts)) = job(app) else { return };
app.canvas.pending_doc = Some(doc.clone());
app.canvas.token += 1;
let token = app.canvas.token;
if let Some(wk) = app.canvas.worker.as_ref() {
wk.submit(crate::render_worker::Job { token, doc, cache: app.session.cache.clone(), placed, w, h, view, opts });
}
app.canvas.pending = Some((token, target));
}
}
/// Repaint only the damaged regions of the current texture after an edit (typing, nudging).
/// Returns false when a full render is needed (view moved, too much changed, or damage unknown).
fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: (u64, u64, u64, bool), preview: bool) -> bool {
let (Some(sh), Some(old), Some(st)) = (app.canvas.shown, app.canvas.shown_doc.clone(), app.session.active()) else { return false };
if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || app.canvas.texture.is_none() {
return false;
}
let new = st.doc.clone();
let Some(regions) = designcraft_render::damage::damage_with(&old, &new, Some(&app.session.cache)) else { return false };
let t0 = crate::now_ms();
let (tw, th) = ((sh.size.0 as f64 * ppp).round() as i64, (sh.size.1 as f64 * ppp).round() as i64);
let k = sh.zoom * ppp;
let mut px: Option<(i64, i64, i64, i64)> = None;
for (r, rect) in &regions {
let c = layout.xf(*r).transform_rect_bbox(*rect);
let x0 = ((c.x0 - sh.origin.x) * k).floor() as i64 - 1;
let y0 = ((c.y0 - sh.origin.y) * k).floor() as i64 - 1;
let x1 = ((c.x1 - sh.origin.x) * k).ceil() as i64 + 1;
let y1 = ((c.y1 - sh.origin.y) * k).ceil() as i64 + 1;
px = Some(px.map_or((x0, y0, x1, y1), |p| (p.0.min(x0), p.1.min(y0), p.2.max(x1), p.3.max(y1))));
}
let done = |app: &mut DesignApp| {
app.canvas.shown = Some(crate::Shown { doc: doc_key, ..sh });
app.canvas.shown_doc = Some(new.clone());
// A background render of the old document must not overwrite this.
app.canvas.pending = None;
app.canvas.pending_doc = None;
};
let Some((x0, y0, x1, y1)) = px.map(|p| (p.0.max(0), p.1.max(0), p.2.min(tw), p.3.min(th))) else {
done(app);
return true;
};
if x1 <= x0 || y1 <= y0 {
done(app);
return true;
}
// Big changes: a full render costs about the same.
if ((x1 - x0) * (y1 - y0)) as f64 > 0.5 * (tw * th) as f64 {
return false;
}
let placed: Vec<designcraft_render::Placed> = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect();
let view = Affine::translate((-(x0 as f64), -(y0 as f64)))
* Affine::scale(ppp)
* Affine::scale(sh.zoom)
* Affine::translate((-sh.origin.x, -sh.origin.y));
let opts = designcraft_render::RenderOptions {
printing_only: preview,
greek_below_px: 3.0 * ppp,
highlight_missing_fonts: !preview && app.session.prefs.highlight_substituted_fonts,
highlight_hj: !preview && app.session.prefs.highlight_hj,
condition_indicators: !preview,
note_indicators: !preview && app.session.prefs.show_note_anchors,
change_markup: !preview && app.session.prefs.show_added_text,
tag_markers: !preview && app.ui.tag_markers,
plate: app.ui.separation,
highlight_keeps: !preview && app.session.prefs.highlight_keeps,
highlight_custom_tracking: !preview && app.session.prefs.highlight_custom_tracking,
quality: app.ui.display_quality,
rich_black: app.ui.rich_black,
overprint_preview: app.ui.overprint_preview,
blend_space_view: !app.ui.proof_colors,
..Default::default()
};
let img = app.canvas.patcher.render(&new, &app.session.cache, &placed, (x1 - x0) as u32, (y1 - y0) as u32, view, &opts);
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
if let Some(tex) = app.canvas.texture.as_mut() {
tex.set_partial([x0 as usize, y0 as usize], ci, egui::TextureOptions::LINEAR);
}
done(app);
app.canvas.patches += 1;
app.perf.render_ms = crate::now_ms() - t0;
true
}
#[cfg(not(target_arch = "wasm32"))]
fn render_worker_spawn(ctx: &egui::Context) -> Option<crate::render_worker::Worker> {
if std::env::var_os("DESIGNCRAFT_SYNC_RENDER").is_some() {
return None;
}
crate::render_worker::Worker::spawn(ctx.clone())
}
fn upload(app: &mut DesignApp, ctx: &egui::Context, mut img: designcraft_render::Rendered) {
if app.ui.proof_colors {
designcraft_render::proof_view(&mut img, &app.ui.proof_setup);
}
// Plates render from source colours; the ink limit is estimated from the composite.
if app.ui.separation.is_none() && app.ui.ink_limit.is_some() {
designcraft_render::separation_view(&mut img, None, app.ui.ink_limit);
}
let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels);
match &mut app.canvas.texture {
Some(tex) => tex.set(ci, egui::TextureOptions::LINEAR),
None => app.canvas.texture = Some(ctx.load_texture("canvas", ci, egui::TextureOptions::LINEAR)),
}
}
fn dashed(painter: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke, dash: f32, gap: f32) {
painter.extend(egui::Shape::dashed_line(&[a, b], stroke, dash, gap));
}
fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
let s = &doc.settings;
for slot in &layout.slots {
let Some(sp) = doc.spread(slot.spread) else { continue };
// Everything below is in spread coordinates, placed through the slot (which may be turned).
let m = slot.xf;
let pt = |p: Point| xf.to_screen(m * p);
let rect = |r: DRect| xf.rect(m.transform_rect_bbox(r));
// Bleed: one rectangle around the spread (inside bleed only applies at the spread's outer edges).
let b = s.bleed;
if app.ui.guides && b.iter().any(|v| *v > 0.0) && !sp.pages.is_empty() {
let first = &sp.pages[0];
let last = &sp.pages[sp.pages.len() - 1];
let l = if first.side == PageSide::Left { b[3] } else { b[2] };
let r = if last.side == PageSide::Right || last.side == PageSide::Single { b[3] } else { b[2] };
let sb = sp.bounds();
let br = DRect::new(sb.x0 - l, sb.y0 - b[0], sb.x1 + r, sb.y1 + b[1]);
painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle);
}
let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard);
for p in &sp.pages {
let pr = p.bounds();
if !app.ui.guides {
continue;
}
// Baseline grid.
if app.ui.baseline_grid && xf.zoom >= s.baseline_grid.view_threshold {
let g = &s.baseline_grid;
let mut y = g.start;
while y < p.height {
painter.line_segment(
[pt(Point::new(pr.x0, pr.y0 + y)), pt(Point::new(pr.x1, pr.y0 + y))],
Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)),
);
y += g.increment.max(1.0);
}
}
// Margins (magenta) and columns (violet).
painter.rect_stroke(rect(p.margin_rect()), 0.0, Stroke::new(hair(painter), c32(s.margin_color)), StrokeKind::Middle);
let cols = p.column_rects();
if cols.len() > 1 {
for (i, c) in cols.iter().enumerate() {
let col = Stroke::new(hair(painter), c32(s.column_color));
if i > 0 {
painter.line_segment([pt(Point::new(c.x0, c.y0)), pt(Point::new(c.x0, c.y1))], col);
}
if i + 1 < cols.len() {
painter.line_segment([pt(Point::new(c.x1, c.y0)), pt(Point::new(c.x1, c.y1))], col);
}
}
}
for g in p.guides.iter().filter(|g| g.visible_in(doc)) {
// Spread guides cross the pasteboard; page guides their page.
let span = if g.spread { pasteboard } else { pr };
let (a, bb) = match g.orientation {
designcraft_doc::Orientation::Horizontal => (Point::new(span.x0, g.position), Point::new(span.x1, g.position)),
designcraft_doc::Orientation::Vertical => {
let span = if g.spread { sp.bounds().inflate(0.0, 36.0) } else { pr };
(Point::new(g.position, span.y0), Point::new(g.position, span.y1))
}
};
let stroke = Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255));
if g.liquid {
// Liquid guides are dashed.
painter.extend(egui::Shape::dashed_line(&[pt(a), pt(bb)], stroke, 6.0, 3.0));
} else {
painter.line_segment([pt(a), pt(bb)], stroke);
}
}
}
}
let _ = t;
}
/// Spread-space → canvas transform for an item at `loc`.
fn item_canvas_xf(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId) -> Option<(Affine, &'static str)> {
let loc = doc.find(id)?;
Some((layout.xf(loc.spread) * doc.parent_xf(&loc), ""))
}
fn path_screen(it: &Item, xf: &Xf, a: Affine) -> Vec<Vec<Pos2>> {
let bp = it.path.to_bezpath();
let full = a * it.xf;
let mut out = Vec::new();
let mut cur: Vec<Pos2> = Vec::new();
designcraft_geom::kurbo::flatten(&bp, 0.25 / xf.zoom.max(0.01), |el| {
use designcraft_geom::PathEl::*;
match el {
MoveTo(p) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(xf.to_screen(full * p));
}
LineTo(p) => cur.push(xf.to_screen(full * p)),
ClosePath => {
if let Some(f) = cur.first().copied() {
cur.push(f);
}
}
_ => {}
}
});
if cur.len() > 1 {
out.push(cur);
}
out
}
fn draw_frames(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
if !app.ui.frame_edges {
return;
}
for slot in &layout.slots {
let Some(sp) = doc.spread(slot.spread) else { continue };
let a = slot.xf;
// Parent items on document pages: dotted edges.
if let SpreadRef::Doc(si) = slot.spread {
let first = doc.first_page_of_spread(si);
for (pi, page) in sp.pages.iter().enumerate() {
let Some((ppi, ppg)) = doc.parent_page_for(first + pi) else { continue };
let parent = &doc.parents[ppi];
let dx = page.x - parent.pages[ppg].x;
for it in &parent.items {
if (parent.pages.len() > 1 && parent.page_at_x(it.bounds().center().x) != Some(ppg)) || page.overridden.contains(&it.id) {
continue;
}
let col = doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::LIGHT_BLUE);
for poly in path_screen(it, xf, a * Affine::translate((dx, 0.0))) {
for w in poly.windows(2) {
dashed(painter, w[0], w[1], Stroke::new(hair(painter), col.gamma_multiply(0.8)), 2.0, 2.0);
}
}
}
}
}
for it in &sp.items {
draw_item_edges(painter, xf, doc, it, a);
}
// Tagged frames in their tag's colour.
if app.ui.tagged_frames {
for top in &sp.items {
top.walk(&mut |it: &Item| {
if it.xml_tag.is_empty() {
return;
}
let Some(t) = doc.xml.tags.iter().find(|t| t.name == it.xml_tag) else { return };
let col = Color32::from_rgb(t.color[0], t.color[1], t.color[2]);
for poly in path_screen(it, xf, a) {
for w in poly.windows(2) {
painter.line_segment([w[0], w[1]], Stroke::new(2.5, col));
}
}
});
}
}
}
}
fn draw_item_edges(painter: &egui::Painter, xf: &Xf, doc: &Document, it: &Item, a: Affine) {
if it.hidden || doc.layer(it.layer).is_some_and(|l| !l.visible) {
return;
}
let col = doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::LIGHT_BLUE);
if let Content::Group { items } = &it.content {
for c in items {
draw_item_edges(painter, xf, doc, c, a * it.xf);
}
return;
}
// Frames without a stroke show their edge; shapes with strokes are visible already.
let show = it.stroke.is_none() || matches!(it.content, Content::Text(_) | Content::Graphic(_));
if show {
for poly in path_screen(it, xf, a) {
painter.add(egui::Shape::line(poly, Stroke::new(hair(painter), col.gamma_multiply(0.75))));
}
}
// Empty graphic frame: the X.
if it.object_style == designcraft_doc::BASIC_GRAPHICS_FRAME && matches!(it.content, Content::Unassigned) {
let r = it.inner_bounds();
let m = a * it.xf;
let p = |x: f64, y: f64| xf.to_screen(m * Point::new(x, y));
painter.line_segment([p(r.x0, r.y0), p(r.x1, r.y1)], Stroke::new(hair(painter), col.gamma_multiply(0.75)));
painter.line_segment([p(r.x1, r.y0), p(r.x0, r.y1)], Stroke::new(hair(painter), col.gamma_multiply(0.75)));
}
}
fn layer_color(doc: &Document, it: &Item) -> Color32 {
doc.layer(it.layer).map(|l| c32(l.color)).unwrap_or(Color32::from_rgb(79, 153, 255))
}
/// Draw selection chrome; returns the selection's screen bounds (for the Contextual Task Bar).
fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) -> Option<Rect> {
let st = app.session.active()?;
let sel: &Selection = &st.selection;
let mut text_rect = None;
// Text selection / caret.
if let Some(ts) = sel.text {
draw_text_selection(app, painter, xf, doc, layout, ts, sel.cells);
let fid = ts.frame.or_else(|| {
let cs = app.session.cache.get(doc, ts.story, None);
compose::caret(&cs, ts.focus).and_then(|c| cs.frames.get(c.0)).map(|f| f.frame)
});
if let Some(fid) = fid
&& let (Some(it), Some((a, _))) = (doc.item(fid), item_canvas_xf(doc, layout, fid))
{
text_rect = Some(xf.rect(a.transform_rect_bbox(it.bounds())));
}
}
let mut union: Option<Rect> = None;
let mut color = Color32::from_rgb(79, 153, 255);
for id in &sel.items {
let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };
color = layer_color(doc, it);
for poly in path_screen(it, xf, a) {
painter.add(egui::Shape::line(poly, Stroke::new(1.0, color)));
}
if sel.items.len() == 1 && sel.text.is_none() {
// InDesign draws a 1 pt bounding box around the selection.
painter.rect_stroke(xf.rect(a.transform_rect_bbox(it.bounds())), 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
}
let b = xf.rect(a.transform_rect_bbox(it.bounds()));
union = Some(union.map_or(b, |u| u.union(b)));
// Text frame ports.
if let Content::Text(tf) = &it.content {
draw_ports(app, painter, xf, doc, layout, it, tf.story, a, color);
}
if let Content::Graphic(g) = &it.content
&& sel.content
{
let gb = xf.rect((a * it.xf).transform_rect_bbox(g.xf.transform_rect_bbox(DRect::new(0.0, 0.0, g.size.0, g.size.1))));
painter.rect_stroke(gb, 0.0, Stroke::new(1.0, Color32::from_rgb(196, 111, 43)), StrokeKind::Middle);
}
}
let direct = matches!(app.session.tool_id(), "directSelection" | "pen" | "addAnchor" | "deleteAnchor" | "convertDirection");
if direct {
for id in &sel.items {
let (Some(it), Some((a, _))) = (doc.item(*id), item_canvas_xf(doc, layout, *id)) else { continue };
let m = a * it.xf;
let col = layer_color(doc, it);
for sp in &it.path.subpaths {
for an in &sp.anchors {
let p = xf.to_screen(m * an.p);
for (h, has) in [(an.h_in, an.has_in()), (an.h_out, an.has_out())] {
if has {
let hp = xf.to_screen(m * h);
painter.line_segment([p, hp], Stroke::new(1.0, col));
painter.circle_filled(hp, 2.5, col);
}
}
let r = Rect::from_center_size(p, vec2(5.0, 5.0));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Inside);
}
}
}
}
if let Some(u) = union
&& sel.text.is_none()
&& !direct
{
if sel.items.len() > 1 {
painter.rect_stroke(u, 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
}
let hs = 6.5;
// Centre point and the live-corner widget.
painter.rect_filled(Rect::from_center_size(u.center(), vec2(3.5, 3.5)), 0.0, color);
if sel.items.len() == 1
&& doc.item(sel.items[0]).is_some_and(|i| matches!(i.shape, designcraft_doc::Shape::Rectangle) && i.children().is_empty())
{
let lc = Rect::from_center_size(pos2(u.max.x, u.min.y + 11.5), vec2(6.0, 6.0));
painter.rect_filled(lc, 0.0, Color32::from_rgb(0xff, 0xe5, 0x00));
painter.rect_stroke(lc, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
for h in designcraft_tools::select::handles(DRect::new(u.min.x as f64, u.min.y as f64, u.max.x as f64, u.max.y as f64)) {
let r = Rect::from_center_size(pos2(h.x as f32, h.y as f32), vec2(hs, hs));
painter.rect_filled(r, 0.0, Color32::WHITE);
painter.rect_stroke(r, 0.0, Stroke::new(hair(painter), color), StrokeKind::Inside);
}
// Content grabber on graphic frames.
if sel.items.len() == 1 && doc.item(sel.items[0]).is_some_and(|i| matches!(i.content, Content::Graphic(_))) {
painter.circle_stroke(u.center(), 8.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
painter.circle_stroke(u.center(), 4.0, Stroke::new(1.5, Color32::from_white_alpha(170)));
}
}
// Threads.
if app.ui.text_threads || sel.items.iter().any(|i| doc.item(*i).is_some_and(|x| x.is_text_frame())) {
for id in &sel.items {
let Some(sid) = doc.item(*id).and_then(|i| i.text_frame()).map(|t| t.story) else { continue };
let Some(story) = doc.story(sid) else { continue };
for w in story.frames.windows(2) {
let (Some(a), Some(b)) = (port_pos(doc, layout, w[0], true), port_pos(doc, layout, w[1], false)) else { continue };
let col = doc.item(w[0]).map(|i| layer_color(doc, i)).unwrap_or(color);
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, col));
}
}
}
text_rect.or(union)
}
/// Port location (canvas): out port at bottom-right, in port at top-left.
fn port_pos(doc: &Document, layout: &CanvasLayout, id: designcraft_doc::ItemId, out: bool) -> Option<Point> {
let it = doc.item(id)?;
let (a, _) = item_canvas_xf(doc, layout, id)?;
let r = it.inner_bounds();
let p = if out { Point::new(r.x1 - 10.0, r.y1) } else { Point::new(r.x0 + 10.0, r.y0) };
Some(a * it.xf * p)
}
#[allow(clippy::too_many_arguments)]
fn draw_ports(
app: &DesignApp,
painter: &egui::Painter,
xf: &Xf,
doc: &Document,
layout: &CanvasLayout,
it: &Item,
sid: designcraft_doc::StoryId,
a: Affine,
color: Color32,
) {
let Some(story) = doc.story(sid) else { return };
let pos = story.frames.iter().position(|f| *f == it.id).unwrap_or(0);
let is_last = pos + 1 == story.frames.len();
let cs = app.session.cache.get(doc, sid, None);
let overset = is_last && cs.is_overset();
let s = 8.5;
let _ = layout;
let r = it.inner_bounds();
let m = a * it.xf;
// In port on the left edge below the top-left corner; out port on the right edge above the
// bottom-right corner (screen-space offsets).
let inp = xf.to_screen(m * Point::new(r.x0, r.y0)) + vec2(0.0, 14.5);
let outp = xf.to_screen(m * Point::new(r.x1, r.y1)) - vec2(0.0, 12.0);
for (c, has_link, is_out) in [(inp, pos > 0, false), (outp, !is_last, true)] {
let pr = Rect::from_center_size(c, vec2(s, s));
painter.rect_filled(pr, 0.0, Color32::WHITE);
painter.rect_stroke(pr, 0.0, Stroke::new(hair(painter), color), StrokeKind::Inside);
if is_out && overset {
let red = Color32::from_rgb(230, 20, 20);
painter.line_segment([c - vec2(2.5, 0.0), c + vec2(2.5, 0.0)], Stroke::new(1.5, red));
painter.line_segment([c - vec2(0.0, 2.5), c + vec2(0.0, 2.5)], Stroke::new(1.5, red));
} else if has_link {
painter.add(egui::Shape::convex_polygon(vec![c + vec2(-2.0, -2.5), c + vec2(2.5, 0.0), c + vec2(-2.0, 2.5)], color, Stroke::NONE));
}
}
}
fn draw_text_selection(
app: &DesignApp,
painter: &egui::Painter,
xf: &Xf,
doc: &Document,
layout: &CanvasLayout,
ts: designcraft_doc::TextSel,
cells: Option<designcraft_doc::TableSel>,
) {
let cs = app.session.cache.get(doc, ts.story, None);
let range = ts.range();
// Selected table cells: tint every selected cell.
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
for ft in &cs.frames {
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
let m = a * doc.text_xf(it);
for t in ft.tables.iter().filter(|t| t.table == sel.table) {
for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) {
let q = [
xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)),
xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)),
xf.to_screen(m * Point::new(c.rect.x1, c.rect.y1)),
xf.to_screen(m * Point::new(c.rect.x0, c.rect.y1)),
];
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 90), Stroke::NONE));
}
}
}
return;
}
if let Some(cell) = ts.cell {
draw_cell_selection(app, painter, xf, doc, layout, &cs, ts, cell);
return;
}
let mut runs: Vec<(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)> = Vec::new();
for (fi, ft) in cs.frames.iter().enumerate() {
let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue };
let Some(it) = doc.item(ft.frame) else { continue };
let m = a * doc.text_xf(it);
// Show the frame edge while typing.
for poly in path_screen(it, xf, a) {
painter.add(egui::Shape::line(poly, Stroke::new(1.0, layer_color(doc, it).gamma_multiply(0.6))));
}
if range.is_empty() {
continue;
}
let mut quads = Vec::new();
let mut glyphs = Vec::new();
for (li, l) in ft.lines.iter().enumerate() {
let s = range.start.max(l.range.start);
let e = range.end.min(l.range.end);
if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {
continue;
}
let quad = |x0: f64, x1: f64| {
[
Point::new(x0, l.baseline - l.ascent),
Point::new(x1, l.baseline - l.ascent),
Point::new(x1, l.baseline + l.descent),
Point::new(x0, l.baseline + l.descent),
]
};
if l.glyphs.iter().any(|g| g.rtl) {
// Mixed directions: the selected glyphs' boxes, joined where they touch.
let mut spans: Vec<(f64, f64)> =
l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= s && g.byte < e).map(|g| (g.x, g.x + g.adv)).collect();
spans.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f64, f64)> = Vec::new();
for (a, b) in spans {
match merged.last_mut() {
Some(m) if a <= m.1 + 0.5 => m.1 = m.1.max(b),
_ => merged.push((a, b)),
}
}
quads.extend(merged.into_iter().map(|(a, b)| quad(a, b)));
} else {
let x0 = compose::caret_x(l, s);
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { compose::caret_x(l, e) };
quads.push(quad(x0, x1));
}
for (gi, g) in l.glyphs.iter().enumerate() {
if g.visible && g.len > 0 && g.byte >= s && g.byte < e {
glyphs.push((li, gi));
}
}
}
if !quads.is_empty() {
runs.push((fi, m, quads, glyphs));
}
}
if !runs.is_empty() {
draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs);
}
if ts.is_caret()
&& let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus)
&& let Some(ft) = cs.frames.get(fi)
&& let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame))
{
let m = a * doc.text_xf(it);
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
if blink {
let p0 = xf.to_screen(m * Point::new(x, bl - asc));
let p1 = xf.to_screen(m * Point::new(x, bl + desc));
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
}
}
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
#[allow(clippy::too_many_arguments)]
fn draw_cell_selection(
_app: &DesignApp,
painter: &egui::Painter,
xf: &Xf,
doc: &Document,
layout: &CanvasLayout,
cs: &compose::ComposedStory,
ts: designcraft_doc::TextSel,
cell: designcraft_doc::CellAddr,
) {
// Footnote text or a cell: its composed story and where it sits in the frame.
let found = match cell.footnote_id() {
Some(id) => compose::find_note(cs, id).map(|(fi, n)| (fi, n.origin, n.text.clone())),
None => compose::find_cell(cs, cell.table, cell.row, cell.col).map(|(fi, _, pc)| (fi, pc.origin, pc.text.clone())),
};
let Some((fi, origin, text)) = found else { return };
let Some(ft) = cs.frames.get(fi) else { return };
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2());
let range = ts.range();
if !range.is_empty()
&& let Some(cft) = text.frames.first()
{
for l in &cft.lines {
let s = range.start.max(l.range.start);
let e = range.end.min(l.range.end);
if s > e || (s == e && !(l.range.end < range.end && e == l.range.end)) {
continue;
}
let x0 = compose::caret_x(l, s);
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { compose::caret_x(l, e) };
let q = [
xf.to_screen(m * Point::new(x0, l.baseline - l.ascent)),
xf.to_screen(m * Point::new(x1, l.baseline - l.ascent)),
xf.to_screen(m * Point::new(x1, l.baseline + l.descent)),
xf.to_screen(m * Point::new(x0, l.baseline + l.descent)),
];
painter.add(egui::Shape::convex_polygon(q.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
}
}
if ts.is_caret()
&& let Some((_, x, bl, asc, desc)) = match cell.footnote_id() {
Some(id) => compose::note_caret(cs, id, ts.focus),
None => compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus),
}
{
let m = a * doc.text_xf(it);
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
if blink {
painter.line_segment(
[xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))],
Stroke::new(1.0, Color32::BLACK),
);
}
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
}
}
/// InDesign draws selected text inverted: a black highlight with the selected glyphs in the
/// inverse of their colour (white for black text). The highlight and glyph outlines are
/// rasterised with `vello_cpu` into a small screen-space texture over the selection.
fn draw_inverse_highlight(
app: &DesignApp,
painter: &egui::Painter,
xf: &Xf,
doc: &Document,
cs: &compose::ComposedStory,
story: designcraft_doc::StoryId,
runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)],
) {
use designcraft_render::vello_cpu::{self, kurbo, peniko};
let ctx = painter.ctx();
let ppp = ctx.pixels_per_point();
let to_screen = |m: Affine, p: Point| xf.to_screen(m * p);
let mut bbox = Rect::NOTHING;
for (_, m, quads, _) in runs {
for q in quads {
for p in q {
bbox.extend_with(to_screen(*m, *p));
}
}
}
let bbox = bbox.intersect(painter.clip_rect());
if !bbox.is_positive() {
return;
}
// Snap to device pixels so the texture maps 1:1.
let bbox = Rect::from_min_max(
pos2((bbox.min.x * ppp).floor() / ppp, (bbox.min.y * ppp).floor() / ppp),
pos2((bbox.max.x * ppp).ceil() / ppp, (bbox.max.y * ppp).ceil() / ppp),
);
let (w, h) = (((bbox.width() * ppp).round() as u32).clamp(1, 8192), ((bbox.height() * ppp).round() as u32).clamp(1, 8192));
let Some(st) = app.session.active() else { return };
let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0))
.with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::<usize>(), w, h))
.with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus))));
let cache_id = egui::Id::new("inverse_sel_tex");
let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id));
let tex = match cached {
Some((k, t)) if k == key => t,
_ => {
let view = xf.affine(ppp as f64, bbox.min);
let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
for (_, m, quads, _) in runs {
rc.set_transform(view * *m);
for q in quads {
let mut bp = kurbo::BezPath::new();
bp.move_to(q[0]);
for p in &q[1..] {
bp.line_to(*p);
}
bp.close_path();
rc.fill_path(&bp);
}
}
let db = designcraft_fonts::FontDb::global();
for (fi, m, _, glyphs) in runs {
let Some(ft) = cs.frames.get(*fi) else { continue };
for &(li, gi) in glyphs {
let Some(l) = ft.lines.get(li) else { continue };
let Some(g) = l.glyphs.get(gi) else { continue };
let Some(style) = cs.styles.get(g.style as usize) else { continue };
let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue };
let [r, gg, b, _] = c.to_rgba8(1.0);
rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));
let outline = db.outline(&g.face, g.gid);
let skew =
if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
let ga = Affine::translate((g.x, l.baseline + g.y)) * skew * Affine::scale_non_uniform(g.sx, g.sy);
rc.set_transform(view * *m * ga);
rc.fill_path(&outline);
}
}
rc.flush();
let mut pm = vello_cpu::Pixmap::new(w as u16, h as u16);
let mut res = vello_cpu::Resources::new();
rc.render(&mut pm, &mut res);
let ci = egui::ColorImage::from_rgba_premultiplied([w as usize, h as usize], pm.data_as_u8_slice());
let t = ctx.load_texture("inverse_selection", ci, egui::TextureOptions::NEAREST);
ctx.data_mut(|d| d.insert_temp(cache_id, (key, t.clone())));
t
}
};
painter.image(tex.id(), bbox, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
if let (Some((z, c)), Some(rect)) = (app.power_zoom, app.canvas_rect) {
// The view that releasing returns to.
let (w, h) = (rect.width() as f64 / z, rect.height() as f64 / z);
let r = xf.rect(designcraft_geom::Rect::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0));
painter.rect_stroke(r, 0.0, egui::Stroke::new(1.5, Color32::from_rgb(230, 40, 40)), egui::StrokeKind::Middle);
}
let tok = Tokens::get(painter.ctx());
let ov = app.session.overlays(app.view_info());
for o in ov {
match o {
Overlay::Marquee(r) => {
let r = xf.rect(r);
painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(80, 140, 255, 30));
for (a, b) in [
(r.left_top(), r.right_top()),
(r.right_top(), r.right_bottom()),
(r.right_bottom(), r.left_bottom()),
(r.left_bottom(), r.left_top()),
] {
dashed(painter, a, b, Stroke::new(1.0, Color32::from_gray(200)), 3.0, 3.0);
}
}
Overlay::Measure { p, text } => {
let s = xf.to_screen(p) + vec2(14.0, 14.0);
let g = painter.layout_no_wrap(text, egui::FontId::proportional(11.0), tok.measure_text);
let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0));
painter.rect_filled(r, 3.0, tok.measure_bg);
painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text);
}
Overlay::Line { a, b, color, dashed: d } => {
let st = Stroke::new(1.0, c32(color));
if d {
dashed(painter, xf.to_screen(a), xf.to_screen(b), st, 3.0, 3.0);
} else {
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], st);
}
}
Overlay::Guide { a, b } => {
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(1.0, tok.smart_guide));
}
Overlay::Gap { a, b, label } => {
let (a, b) = (xf.to_screen(a), xf.to_screen(b));
painter.line_segment([a, b], Stroke::new(1.0, tok.smart_guide));
let s = a + (b - a) * 0.5;
let g = painter.layout_no_wrap(label, egui::FontId::proportional(11.0), tok.measure_text);
let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0));
painter.rect_filled(r, 3.0, tok.measure_bg);
painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text);
}
Overlay::Path { .. } => {}
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) {
let painter = ui.painter_at(full);
let top = Rect::from_min_max(pos2(rect.min.x, full.min.y), pos2(full.max.x, rect.min.y));
let left = Rect::from_min_max(pos2(full.min.x, rect.min.y), pos2(rect.min.x, full.max.y));
painter.rect_filled(top, 0.0, t.ruler);
painter.rect_filled(left, 0.0, t.ruler);
painter.rect_filled(Rect::from_min_max(full.min, rect.min), 0.0, t.ruler);
painter.line_segment([pos2(rect.min.x, rect.min.y - 0.25), pos2(full.max.x, rect.min.y - 0.25)], Stroke::new(0.5, t.ruler_tick));
painter.line_segment([pos2(rect.min.x - 0.25, rect.min.y), pos2(rect.min.x - 0.25, full.max.y)], Stroke::new(0.5, t.ruler_tick));
// Zero-point crosshair box.
let zc = Rect::from_min_max(full.min, rect.min).center();
painter.line_segment([zc - vec2(4.0, 0.0), zc + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick));
painter.line_segment([zc - vec2(0.0, 4.0), zc + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick));
// Origin: top-left of the current spread's first page.
let Some(i) = current_slot(app, layout) else { return };
let slot = &layout.slots[i];
let origin = Point::new(slot.bounds.x0, slot.bounds.y0);
let unit = doc.settings.horizontal_units;
let (major, sub) = unit.ruler_ticks(xf.zoom);
let font = egui::FontId::proportional(9.0);
let tick = Stroke::new(1.0, t.ruler_tick);
// Horizontal.
let c0 = xf.to_canvas(top.min).x - origin.x;
let c1 = xf.to_canvas(pos2(top.max.x, 0.0)).x - origin.x;
let mut k = (c0 / major).floor() as i64;
while (k as f64) * major <= c1 {
let x0 = k as f64 * major;
for s in 0..sub {
let x = x0 + major * s as f64 / sub as f64;
let sx = xf.to_screen(Point::new(origin.x + x, 0.0)).x;
let len = if s == 0 {
RULER
} else if sub % 2 == 0 && s == sub / 2 {
7.0
} else {
4.0
};
painter.line_segment([pos2(sx, top.max.y - len), pos2(sx, top.max.y)], tick);
if s == 0 {
let v = unit.from_pt(x);
painter.text(pos2(sx + 2.0, top.min.y + 1.0), egui::Align2::LEFT_TOP, fmt_tick(v), font.clone(), t.ruler_text);
}
}
k += 1;
}
// Vertical.
let c0 = xf.to_canvas(pos2(0.0, left.min.y)).y - origin.y;
let c1 = xf.to_canvas(pos2(0.0, left.max.y)).y - origin.y;
let mut k = (c0 / major).floor() as i64;
while (k as f64) * major <= c1 {
let y0 = k as f64 * major;
for s in 0..sub {
let y = y0 + major * s as f64 / sub as f64;
let sy = xf.to_screen(Point::new(0.0, origin.y + y)).y;
let len = if s == 0 {
RULER
} else if sub % 2 == 0 && s == sub / 2 {
7.0
} else {
4.0
};
painter.line_segment([pos2(left.max.x - len, sy), pos2(left.max.x, sy)], tick);
if s == 0 {
// Stacked digits like InDesign's vertical ruler.
let label = fmt_tick(unit.from_pt(y));
for (j, ch) in label.chars().enumerate() {
painter.text(pos2(left.min.x + 3.0, sy + 2.0 + j as f32 * 8.5), egui::Align2::LEFT_TOP, ch, font.clone(), t.ruler_text);
}
}
}
k += 1;
}
// Pointer position markers.
if let Some(p) = ui.ctx().pointer_hover_pos().filter(|p| rect.contains(*p)) {
let m = Stroke::new(1.0, t.text_dim);
dashed(&painter, pos2(p.x, top.min.y), pos2(p.x, top.max.y), m, 1.5, 1.5);
dashed(&painter, pos2(left.min.x, p.y), pos2(left.max.x, p.y), m, 1.5, 1.5);
}
}
fn fmt_tick(v: f64) -> String {
let r = v.round();
if (v - r).abs() < 1e-6 { format!("{}", r as i64) } else { format!("{v:.1}") }
}
fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect) {
let Some(v) = app.view().copied() else { return };
let xf = Xf::new(rect, &v);
let wants_text = app.session.wants_text();
let space = ui.input(|i| i.key_down(egui::Key::Space)) && !wants_text && !ui.ctx().text_edit_focused();
// Scroll / zoom.
if resp.hovered() {
let (scroll, zoom_delta, m, hover) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers, i.pointer.hover_pos()));
if let Some(hp) = hover {
if zoom_delta != 1.0 {
zoom_at(app, hp, zoom_delta as f64);
} else if m.command && scroll.y != 0.0 {
zoom_at(app, hp, (1.0 + scroll.y as f64 * 0.003).clamp(0.5, 2.0));
} else if scroll != egui::Vec2::ZERO
&& let Some(v) = app.view_mut()
{
let scroll = xf.unrotate_delta(scroll);
v.origin = Point::new(v.origin.x - scroll.x as f64 / v.zoom, v.origin.y - scroll.y as f64 / v.zoom);
}
}
}
// Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live
// Distribute while resizing). Middle-drag pans too, with any tool; tools only
// see the primary button.
let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false);
if !tool_drag && ((space && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) {
let d = xf.unrotate_delta(resp.drag_delta());
if let Some(v) = app.view_mut() {
v.origin = Point::new(v.origin.x - d.x as f64 / v.zoom, v.origin.y - d.y as f64 / v.zoom);
}
return;
}
// Ruler guides need the rulers (hidden while the view is rotated).
if !space && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) {
return;
}
let m = ui.input(|i| mods(i, space));
let vi = app.view_info();
let pos = |p: Pos2| xf.to_canvas(p);
let mut events: Vec<PointerEvent> = Vec::new();
let down_id = egui::Id::new(("canvas_pointer_down", app.pane));
let mut down: bool = ui.data(|d| d.get_temp(down_id)).unwrap_or(false);
let (pressed, released, origin, latest, dbl) = ui.input(|i| {
(
i.pointer.primary_pressed(),
i.pointer.primary_released(),
i.pointer.press_origin(),
i.pointer.latest_pos(),
i.pointer.button_double_clicked(egui::PointerButton::Primary),
)
});
if pressed
&& !space
&& let Some(o) = origin.filter(|o| (if xf.rot == 0 { rect.contains(*o) } else { resp.rect.contains(*o) }) && resp.hovered())
{
events.push(PointerEvent { kind: if dbl { PointerKind::DoubleClick } else { PointerKind::Down }, pos: pos(o), mods: m });
down = true;
}
if down
&& resp.dragged()
&& resp.drag_delta() != egui::Vec2::ZERO
&& let Some(p) = latest
{
events.push(PointerEvent { kind: PointerKind::Drag, pos: pos(p), mods: m });
}
// Power Zoom: holding the Hand tool still for half a second turns the press into one.
let hold_id = egui::Id::new(("canvas_hold", app.pane));
if down && !released && app.session.tool_id() == "hand" && !m.alt {
let (now, origin_pos) = ui.input(|i| (i.time, i.pointer.press_origin()));
let (start, fired): (f64, bool) = ui.data(|d| d.get_temp(hold_id)).unwrap_or((now, false));
let still = origin_pos.zip(latest).is_some_and(|(o, l)| (o - l).length() < 3.0);
if !fired && still && now - start >= 0.5 {
let p = pos(latest.unwrap_or(rect.center()));
events.push(PointerEvent { kind: PointerKind::Up, pos: p, mods: m });
events.push(PointerEvent { kind: PointerKind::Down, pos: p, mods: Mods { alt: true, ..m } });
ui.data_mut(|d| d.insert_temp(hold_id, (start, true)));
} else {
ui.data_mut(|d| d.insert_temp(hold_id, (start, fired)));
if !fired {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
}
}
} else {
ui.data_mut(|d| d.remove::<(f64, bool)>(hold_id));
}
if down && released {
let p = latest.unwrap_or(rect.center());
events.push(PointerEvent { kind: PointerKind::Up, pos: pos(p), mods: m });
down = false;
} else if !down && let Some(p) = resp.hover_pos() {
events.push(PointerEvent { kind: PointerKind::Move, pos: pos(p), mods: m });
}
ui.data_mut(|d| d.insert_temp(down_id, down));
for e in events {
if let Err(err) = app.session.pointer(&e, vi) {
app.status(err.to_string());
}
}
app.after_engine();
if resp.clicked() || resp.drag_started() {
resp.request_focus();
}
// Keyboard for the active tool.
if ui.ctx().text_edit_focused() && !resp.has_focus() {
return;
}
let evs = ui.input(|i| i.events.clone());
for e in evs {
match e {
egui::Event::Text(t) if wants_text => {
let _ = app.run("text.insert", json!({"text": t}));
}
egui::Event::Paste(t) if wants_text => {
// Formatted when the system clipboard still holds what was copied here;
// Shift (⇧⌘V) pastes without formatting.
let plain = ui.input(|i| i.modifiers.shift);
let id = if plain { "edit.pasteWithoutFormatting" } else { "edit.paste" };
let _ = app.run(id, json!({"text": t}));
}
egui::Event::Copy | egui::Event::Cut if wants_text => {
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
if let Ok(r) = app.run(id, json!({}))
&& let Some(t) = r.get("text").and_then(serde_json::Value::as_str)
{
ui.ctx().copy_text(t.to_string());
}
}
egui::Event::Key { key, pressed: true, modifiers, .. } => {
let k = match key {
egui::Key::ArrowLeft => Some(ToolKey::Left),
egui::Key::ArrowRight => Some(ToolKey::Right),
egui::Key::ArrowUp => Some(ToolKey::Up),
egui::Key::ArrowDown => Some(ToolKey::Down),
egui::Key::Enter => Some(ToolKey::Enter),
egui::Key::Escape => Some(ToolKey::Escape),
egui::Key::Backspace => Some(ToolKey::Backspace),
egui::Key::Delete => Some(ToolKey::Delete),
egui::Key::Tab => Some(ToolKey::Tab),
egui::Key::Home => Some(ToolKey::Home),
egui::Key::End => Some(ToolKey::End),
_ => None,
};
if let Some(k) = k {
let m = Mods { shift: modifiers.shift, alt: modifiers.alt, cmd: modifiers.command, ctrl: modifiers.ctrl, space: false };
let handled = app.session.tool_key(k, m, vi).unwrap_or(false);
if !handled && k == ToolKey::Escape {
let _ = app.run("edit.deselectAll", json!({}));
}
app.after_engine();
}
}
_ => {}
}
}
let _ = DVec2::ZERO;
}
/// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story.
fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
for story in doc.stories.values() {
let cs = app.session.cache.get(doc, story.id, None);
for ft in &cs.frames {
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
let m = a * doc.text_xf(it);
let col = layer_color(doc, it);
for l in &ft.lines {
let size = (l.ascent * 0.75 * xf.zoom).clamp(6.0, 40.0) as f32;
let font = egui::FontId::proportional(size);
for g in &l.glyphs {
if g.len == 0 {
continue;
}
let ch = story.text[g.byte.min(story.text.len())..].chars().next().unwrap_or(' ');
let mark = match ch {
' ' => "·",
'\t' => "»",
'\u{2028}' => "¬",
'\u{A0}' => "°",
_ => continue,
};
let p = xf.to_screen(m * Point::new(g.x + if ch == ' ' { g.adv / 2.0 } else { 0.0 }, l.baseline));
let align = if ch == ' ' { egui::Align2::CENTER_BOTTOM } else { egui::Align2::LEFT_BOTTOM };
painter.text(p, align, mark, font.clone(), col);
}
if l.last_in_para {
let end = l.range.end;
let mark = if end >= story.text.len() { "#" } else { "" };
let p = xf.to_screen(m * Point::new(l.end_x + 1.0, l.baseline));
painter.text(p, egui::Align2::LEFT_BOTTOM, mark, font.clone(), col);
}
}
}
}
}
/// View › Flattener Preview: red over every object that involves transparency (opacity, blend
/// modes, effects, knockout) — what flattening or export rasterising affects.
fn draw_flattener_preview(painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
fn transparent(it: &Item) -> bool {
it.opacity < 1.0 || it.blend != Default::default() || it.effects.any() || it.knockout || it.children().iter().any(|c| transparent(c))
}
let red = Color32::from_rgba_unmultiplied(230, 30, 30, 110);
for slot in &layout.slots {
let Some(sp) = doc.spread(slot.spread) else { continue };
for it in sp.items.iter().filter(|it| !it.hidden && transparent(it)) {
let r = it.bounds().inflate(designcraft_render::effect_outset(it), designcraft_render::effect_outset(it));
painter.rect_filled(xf.rect(slot.xf.transform_rect_bbox(r)), 0.0, red);
}
}
}
/// Edit › Spelling › Dynamic Spelling: red squiggles under misspelled words (checked once per
/// story version).
fn draw_dynamic_spelling(app: &DesignApp, ctx: &egui::Context, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
for story in doc.stories.values() {
let key = egui::Id::new(("dyn_spell", std::sync::Arc::as_ptr(story) as usize, doc.user_words.len()));
let bad: std::sync::Arc<Vec<std::ops::Range<usize>>> = match ctx.data(|d| d.get_temp(key)) {
Some(v) => v,
None => {
let v = std::sync::Arc::new(designcraft_engine::cmd::spelling::misspellings(doc, story, &doc.user_words));
ctx.data_mut(|d| d.insert_temp(key, v.clone()));
v
}
};
if bad.is_empty() {
continue;
}
let cs = app.session.cache.get(doc, story.id, None);
let red = Stroke::new(1.0, Color32::from_rgb(230, 30, 30));
for ft in &cs.frames {
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
let m = a * doc.text_xf(it);
for l in &ft.lines {
for r in bad.iter().filter(|r| r.start < l.range.end && r.end > l.range.start) {
let gs: Vec<_> = l.glyphs.iter().filter(|g| g.len > 0 && g.byte >= r.start && g.byte < r.end).collect();
let (Some(x0), Some(x1)) = (gs.iter().map(|g| g.x).reduce(f64::min), gs.iter().map(|g| g.x + g.adv).reduce(f64::max)) else {
continue;
};
let y = l.baseline + l.descent * 0.5;
let (p0, p1) = (xf.to_screen(m * Point::new(x0, y)), xf.to_screen(m * Point::new(x1, y)));
// A zigzag 2 px high, 4 px per wave.
let n = ((p1.x - p0.x) / 2.0).max(1.0) as usize;
let pts: Vec<Pos2> =
(0..=n).map(|k| pos2(p0.x + (p1.x - p0.x) * k as f32 / n as f32, p0.y + if k % 2 == 0 { 0.0 } else { 2.0 })).collect();
painter.add(egui::Shape::line(pts, red));
}
}
}
}
}
/// Plain text of the current text selection (story markers resolved to readable characters).
pub fn selected_text(app: &DesignApp) -> Option<String> {
let st = app.session.active()?;
let t = st.selection.text?;
let story = st.doc.text_story(t.story, t.cell)?;
let s = story.slice(t.range());
if s.is_empty() {
return None;
}
Some(s.replace(designcraft_doc::story::FORCED_LINE_BREAK, "\n"))
}
/// One device pixel, the width InDesign uses for guides and frame edges.
fn hair(painter: &egui::Painter) -> f32 {
1.0 / painter.ctx().pixels_per_point()
}
const GUIDE_DRAG: &str = "canvas_guide_drag";
/// A ruler guide being dragged: out of a ruler (`from` None) or an existing one.
#[derive(Clone, Debug)]
struct GuideDrag {
orientation: designcraft_doc::Orientation,
from: Option<(designcraft_doc::SpreadRef, usize, usize)>,
/// Pointer position (screen).
at: Option<Pos2>,
}
/// The guide under a screen point (within 3 px), as (spread, page, index, orientation).
fn guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(designcraft_doc::SpreadRef, usize, usize, designcraft_doc::Orientation)> {
let st = app.session.active()?;
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let cp = xf.to_canvas(p);
let tol = 3.0 / xf.zoom;
for slot in &layout.slots {
let sp = st.doc.spread(slot.spread)?;
let local = slot.to_spread(cp);
for (pi, pg) in sp.pages.iter().enumerate() {
let pr = if pg.guides.iter().any(|g| g.spread) { slot.xf.inverse().transform_rect_bbox(layout.pasteboard) } else { pg.bounds() };
for (gi, g) in pg.guides.iter().enumerate() {
if !g.editable_in(&st.doc) {
continue;
}
let r = if g.spread { pr } else { pg.bounds() };
let hit = match g.orientation {
designcraft_doc::Orientation::Horizontal => (local.y - g.position).abs() <= tol && local.x >= r.x0 && local.x <= r.x1,
designcraft_doc::Orientation::Vertical => (local.x - g.position).abs() <= tol && local.y >= r.y0 - tol && local.y <= r.y1 + tol,
};
if hit && !g.locked {
return Some((slot.spread, pi, gi, g.orientation));
}
}
}
}
None
}
/// Ruler guides: drag one out of a ruler, drag an existing one (Selection tools, not over an
/// object) to move it, drop it back on a ruler to delete it. Returns true while it owns the pointer.
fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool {
let id = egui::Id::new(GUIDE_DRAG);
let (pressed, released, origin, latest) =
ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_released(), i.pointer.press_origin(), i.pointer.latest_pos()));
let mut drag: Option<GuideDrag> = ui.data(|d| d.get_temp(id));
if drag.is_none() && pressed && resp.hovered() && app.ui.guides && !app.ui.guides_locked {
let Some(o) = origin else { return false };
let full = resp.rect;
if full.contains(o) && !rect.contains(o) {
// In a ruler (not the corner).
let orientation = if o.y < rect.min.y && o.x >= rect.min.x {
Some(designcraft_doc::Orientation::Horizontal)
} else if o.x < rect.min.x && o.y >= rect.min.y {
Some(designcraft_doc::Orientation::Vertical)
} else {
None
};
if let Some(orientation) = orientation {
drag = Some(GuideDrag { orientation, from: None, at: Some(o) });
}
} else if rect.contains(o)
&& matches!(app.session.tool_id(), "selection" | "directSelection")
&& let Some((r, pi, gi, orientation)) = guide_at(app, xf, o)
{
// Objects take precedence over the guides behind them.
let over_item = app.session.active().is_some_and(|st| {
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
layout.spread_at(xf.to_canvas(o)).is_some_and(|(sr, p)| match sr {
designcraft_doc::SpreadRef::Doc(si) => st.doc.hit_item(si, p, 3.0 / xf.zoom).is_some(),
_ => false,
})
});
if !over_item {
drag = Some(GuideDrag { orientation, from: Some((r, pi, gi)), at: Some(o) });
}
}
}
let Some(mut g) = drag else { return false };
if let Some(p) = latest {
g.at = Some(p);
}
if !released {
ui.ctx().set_cursor_icon(match g.orientation {
designcraft_doc::Orientation::Horizontal => egui::CursorIcon::ResizeVertical,
designcraft_doc::Orientation::Vertical => egui::CursorIcon::ResizeHorizontal,
});
ui.data_mut(|d| d.insert_temp(id, g));
return true;
}
ui.data_mut(|d| d.remove::<GuideDrag>(id));
let Some(p) = g.at else { return true };
let on_canvas = rect.contains(p);
let target = app.session.active().and_then(|st| {
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
let (r, sp) = layout.spread_at(xf.to_canvas(p))?;
let spread = st.doc.spread(r)?;
let on_page = spread.pages.iter().position(|pg| pg.bounds().contains(sp));
Some((r, sp, on_page))
});
let pos_of = |sp: Point| match g.orientation {
designcraft_doc::Orientation::Horizontal => sp.y,
designcraft_doc::Orientation::Vertical => sp.x,
};
let orient = match g.orientation {
designcraft_doc::Orientation::Horizontal => "horizontal",
designcraft_doc::Orientation::Vertical => "vertical",
};
match (g.from, on_canvas, target) {
// Dropped back on a ruler: delete.
(Some((r, pi, gi)), false, _) => {
let _ = app.run("guide.delete", json!({"spread": r, "page": pi, "index": gi}));
}
(Some((r, pi, gi)), true, Some((tr, sp, _))) if tr == r => {
let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)}));
}
(None, true, Some((r, sp, on_page))) => {
let mut params = json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none()});
if let Some(pi) = on_page {
params["page"] = json!(pi);
}
let _ = app.run("guide.add", params);
}
_ => {}
}
true
}