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

1108 lines
49 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 {
pub min: Pos2,
pub origin: Point,
pub zoom: f64,
}
impl Xf {
pub fn new(rect: Rect, v: &View) -> Self {
Xf { min: rect.min, origin: v.origin, zoom: v.zoom }
}
pub fn to_screen(&self, p: Point) -> Pos2 {
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)
}
pub fn to_canvas(&self, p: Pos2) -> Point {
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 dx = (self.min.x - area_min.x) as f64;
let dy = (self.min.y - area_min.y) as f64;
Affine::scale(ppp) * Affine::translate((dx, dy)) * Affine::scale(self.zoom) * Affine::translate((-self.origin.x, -self.origin.y))
}
}
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 pi = sp.page_at_x(center - slot.offset.x).unwrap_or(0);
b = sp.pages[pi].bounds() + slot.offset;
}
if what == "all" {
b = layout.slots.iter().map(|s| s.bounds).reduce(|a, b| a.union(b)).unwrap_or(b);
}
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 };
}
}
/// 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 cx = v.origin.x + r.width() as f64 / 2.0 / v.zoom - slot.offset.x;
let pi = st.doc.spreads[si].page_at_x(cx).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 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) {
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 rulers = app.ui.rulers && app.ui.screen_mode != ScreenMode::Presentation;
let rect = if rulers { Rect::from_min_max(full.min + vec2(RULER, RULER), full.max) } else { full };
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 v = *app.view().expect("view");
let xf = Xf::new(rect, &v);
let doc = st.doc.clone();
let layout = CanvasLayout::new(&doc, st.editing_parents);
let painter = ui.painter_at(rect);
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(rect, 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).
let k = (xf.zoom / sh.zoom) as f32;
let tl = xf.to_screen(sh.origin);
let tex_rect = Rect::from_min_size(tl, vec2(sh.size.0 * k, sh.size.1 * k));
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
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(rect)).image(tex.id(), tex_rect, uv, Color32::WHITE);
}
} else {
painter.image(tex.id(), tex_rect, uv, Color32::WHITE);
}
}
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);
draw_frames(app, &painter, &xf, &doc, &layout);
if app.ui.hidden_characters {
draw_hidden_characters(app, &painter, &xf, &doc, &layout);
}
}
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);
}
// Cursor.
if let Some(p) = resp.hover_pos().filter(|p| rect.contains(*p)) {
let space = ui.input(|i| i.key_down(egui::Key::Space)) && !app.session.wants_text();
let c = 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.
let (ow, oh) = (rect.width() * 0.25, rect.height() * 0.25);
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.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().expect("doc");
let placed: Vec<designcraft_render::Placed> =
layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, offset: s.offset }).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, ..Default::default() };
(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;
if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) {
let t0 = crate::now_ms();
let (doc, placed, w, h, view, opts) = job(app);
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.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 (doc, placed, w, h, view, opts) = job(app);
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));
}
}
#[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, img: designcraft_render::Rendered) {
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 };
let off = slot.offset;
// 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() + off;
let br = DRect::new(sb.x0 - l, sb.y0 - b[0], sb.x1 + r, sb.y1 + b[1]);
painter.rect_stroke(xf.rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle);
}
for p in &sp.pages {
let pr = p.bounds() + off;
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 {
let a = xf.to_screen(Point::new(pr.x0, pr.y0 + y));
let bb = xf.to_screen(Point::new(pr.x1, pr.y0 + y));
painter.line_segment([a, bb], Stroke::new(hair(painter), c32(g.color).gamma_multiply(0.8)));
y += g.increment.max(1.0);
}
}
// Margins (magenta) and columns (violet).
let m = p.margin_rect() + off;
painter.rect_stroke(xf.rect(m), 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 c = *c + off;
let col = Stroke::new(hair(painter), c32(s.column_color));
if i > 0 {
painter.line_segment([xf.to_screen(Point::new(c.x0, c.y0)), xf.to_screen(Point::new(c.x0, c.y1))], col);
}
if i + 1 < cols.len() {
painter.line_segment([xf.to_screen(Point::new(c.x1, c.y0)), xf.to_screen(Point::new(c.x1, c.y1))], col);
}
}
}
for g in &p.guides {
let (a, bb) = match g.orientation {
designcraft_doc::Orientation::Horizontal => (Point::new(pr.x0, g.position + off.y), Point::new(pr.x1, g.position + off.y)),
designcraft_doc::Orientation::Vertical => (Point::new(g.position + off.x, pr.y0), Point::new(g.position + off.x, pr.y1)),
};
painter.line_segment([xf.to_screen(a), xf.to_screen(bb)], Stroke::new(hair(painter), Color32::from_rgb(74, 227, 255)));
}
}
}
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)?;
let off = layout.offset(loc.spread);
Some((Affine::translate(off) * 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 = Affine::translate(slot.offset);
// 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) {
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);
}
}
}
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))
}
fn draw_selection(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) {
let Some(st) = app.session.active() else { return };
let sel: &Selection = &st.selection;
// Text selection / caret.
if let Some(ts) = sel.text {
draw_text_selection(app, painter, xf, doc, layout, ts, sel.cells);
}
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");
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));
}
}
}
}
/// 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 * it.xf;
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;
}
for ft in &cs.frames {
let Some((a, _)) = item_canvas_xf(doc, layout, ft.frame) else { continue };
let Some(it) = doc.item(ft.frame) else { continue };
let m = a * it.xf;
// 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() {
for l in &ft.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 = caret_x(l, s);
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { 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((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 * it.xf;
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,
) {
let Some((fi, _, pc)) = compose::find_cell(cs, cell.table, cell.row, cell.col) 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 * it.xf * designcraft_geom::Affine::translate(pc.origin.to_vec2());
let range = ts.range();
if !range.is_empty()
&& let Some(cft) = pc.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 = caret_x(l, s);
let x1 = if e == l.range.end && range.end > l.range.end { l.end_x.max(x0 + 3.0) } else { 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)) = compose::cell_caret(cs, cell.table, cell.row, cell.col, ts.focus)
{
let m = a * it.xf;
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));
}
}
fn caret_x(l: &compose::Line, pos: usize) -> f64 {
for g in &l.glyphs {
if g.len > 0 && pos >= g.byte && pos < g.byte + g.len {
return g.x + g.adv * (pos - g.byte) as f64 / g.len as f64;
}
if g.len > 0 && g.byte >= pos {
return g.x;
}
}
l.end_x
}
fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) {
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), Color32::WHITE);
let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0));
painter.rect_filled(r, 3.0, Color32::from_rgba_unmultiplied(70, 70, 70, 230));
painter.galley(r.min + vec2(5.0, 3.0), g, Color32::WHITE);
}
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, Color32::from_rgb(0, 200, 83)));
}
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().egui_wants_keyboard_input();
// 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()
{
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.
if space && resp.dragged() {
let d = 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;
}
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");
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| 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 });
}
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().egui_wants_keyboard_input() && !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 => {
let _ = app.run("text.insert", json!({"text": t.replace("\r\n", "\n").replace('\r', "\n")}));
}
egui::Event::Copy | egui::Event::Cut if wants_text => {
if let Some(t) = selected_text(app) {
ui.ctx().copy_text(t);
if matches!(e, egui::Event::Cut) {
let _ = app.run("text.delete", json!({}));
}
}
}
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 * it.xf;
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);
}
}
}
}
}
/// 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()
}