From d0007a604daad91c1ec43a6a10616b168dc18ef9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 06:33:47 -0400 Subject: [PATCH] M5.1: background render worker with reprojection; Layers rename + drag reorder Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 49 +++++++++++++---- crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/panels/layers.rs | 61 +++++++++++++++++++-- crates/ui-egui/src/render_worker.rs | 84 +++++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/render_worker.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5e04d82f..d0634324 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -133,21 +133,42 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { let ppp = ui.ctx().pixels_per_point(); let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32); let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] }; + if !app.canvas.worker_started { + app.canvas.worker_started = true; + if std::env::var_os("DRAWCRAFT_SYNC_RENDER").is_none() { + app.canvas.worker = crate::render_worker::Worker::spawn(ui.ctx().clone()); + } + } + // Upload finished background renders. + if let Some(done) = app.canvas.worker.as_mut().and_then(|w| w.poll()) { + upload(app, ui.ctx(), &done.img); + app.canvas.key = Some(done.key); + app.perf.render_ms = done.ms; + app.canvas.last_ms = done.ms; + } if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { - let t0 = now_ms(); let view = Affine::translate((w as f64 / 2.0, h as f64 / 2.0)) * Affine::scale(v.zoom * ppp as f64) * Affine::translate(-v.center.to_vec2()); let opts = drawcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() }; - let img = app.canvas.renderer.render(&doc, w, h, view, &opts); - let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - match &mut app.canvas.texture { - Some(tex) => tex.set(color, egui::TextureOptions::LINEAR), - None => app.canvas.texture = Some(ui.ctx().load_texture("canvas", color, egui::TextureOptions::LINEAR)), + // Light documents render synchronously (no lag vs overlays); heavy ones go to the worker. + let heavy = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); + match (&mut app.canvas.worker, heavy) { + (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), + _ => { + let t0 = now_ms(); + let img = app.canvas.renderer.render(&doc, w, h, view, &opts); + upload(app, ui.ctx(), &img); + app.canvas.key = Some(key.clone()); + app.perf.render_ms = now_ms() - t0; + app.canvas.last_ms = app.perf.render_ms; + } } - app.canvas.key = Some(key); - app.perf.render_ms = now_ms() - t0; } - if let Some(tex) = &app.canvas.texture { - painter.image(tex.id(), rect, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if let (Some(tex), Some(k)) = (&app.canvas.texture, &app.canvas.key) { + // Reproject the last frame if it was rendered for a different view. + let old = Xf { rect, zoom: k.zoom, center: Point::new(k.cx, k.cy) }; + let a = xf.to_screen(old.to_doc(rect.min)); + let b = xf.to_screen(old.to_doc(rect.max)); + painter.image(tex.id(), egui::Rect::from_min_max(a, b), egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } // Artboard edges and names. let active_ab = 0; @@ -746,3 +767,11 @@ fn home(app: &mut DrawcraftApp, ui: &mut Ui, rect: egui::Rect) { fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); } + +fn upload(app: &mut DrawcraftApp, ctx: &egui::Context, img: &drawcraft_render::Rendered) { + let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + match &mut app.canvas.texture { + Some(tex) => tex.set(color, egui::TextureOptions::LINEAR), + None => app.canvas.texture = Some(ctx.load_texture("canvas", color, egui::TextureOptions::LINEAR)), + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f07d2e29..a39a68e1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -15,6 +15,7 @@ pub mod icons; pub mod io; pub mod menus; pub mod palette; +pub mod render_worker; pub mod panels; pub mod shortcuts; pub mod state; @@ -60,6 +61,8 @@ pub struct CanvasCache { pub texture: Option, pub key: Option, pub last_ms: f64, + pub worker: Option, + pub worker_started: bool, } #[derive(Clone, Debug, PartialEq)] @@ -116,7 +119,7 @@ impl DrawcraftApp { ui: UiState::default(), views, services, - canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0 }, + canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0, worker: None, worker_started: false }, perf: Perf::default(), integrated_titlebar: false, control_rx: None, diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index ab8d8c35..9ea8bc6b 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -32,6 +32,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { } }); ui.data_mut(|d| d.insert_temp(expanded_id(), expanded)); + if ui.input(|i| i.pointer.any_released()) { + ui.data_mut(|d| d.remove::(egui::Id::new("layers-drag"))); + } // Bottom bar. let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider)); @@ -73,7 +76,7 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { #[allow(clippy::too_many_arguments)] fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: &HashSet, current: Option, expanded: &mut HashSet, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag()); let is_sel = sel.contains(&n.id); let child_sel = !is_sel && n.children().is_some_and(|_| { let mut any = false; @@ -148,7 +151,30 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: // Name. let name = n.display_name(); let font = if n.is_layer() { egui::FontId::proportional(12.5) } else { egui::FontId::proportional(12.0) }; - ui.painter().with_clip_rect(egui::Rect::from_min_max(egui::pos2(x, r.top()), egui::pos2(r.right() - 44.0, r.bottom()))).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name, font, t.text); + let rename_id = egui::Id::new("layers-rename"); + let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id)); + let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.top() + 3.0), egui::pos2(r.right() - 44.0, r.bottom() - 3.0)); + match renaming { + Some((rid, mut buf)) if rid == n.id.0 => { + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect)); + let te = child.add(egui::TextEdit::singleline(&mut buf).desired_width(name_rect.width()).font(font.clone())); + te.request_focus(); + if te.lost_focus() { + ui.data_mut(|d| d.remove::<(u64, String)>(rename_id)); + if child.input(|i| !i.key_pressed(egui::Key::Escape)) && buf != name { + let cmd = if n.is_layer() { "layer.setProps" } else { "object.setProps" }; + let key = if n.is_layer() { "id" } else { "ids" }; + let idv = if n.is_layer() { json!(n.id.0) } else { json!([n.id.0]) }; + actions.push((cmd.into(), json!({key: idv, "name": buf}))); + } + } else { + ui.data_mut(|d| d.insert_temp(rename_id, (n.id.0, buf))); + } + } + _ => { + ui.painter().with_clip_rect(name_rect).text(egui::pos2(x, r.center().y), egui::Align2::LEFT_CENTER, name.clone(), font, t.text); + } + } // Target circle and selection square. let tc = egui::pos2(r.right() - 30.0, r.center().y); let styled = !n.appearance.is_basic() || n.opacity < 1.0; @@ -181,9 +207,36 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: actions.push(("select.set".into(), json!({"ids": [n.id.0]}))); } } - if resp.double_clicked() && n.is_layer() { - // Rename via Layer Options (later: inline edit). + if resp.double_clicked() { + ui.data_mut(|d| d.insert_temp(egui::Id::new("layers-rename"), (n.id.0, n.display_name()))); } + // Drag to reorder: drop onto a row moves the dragged node above it (into its parent). + let drag_id = egui::Id::new("layers-drag"); + if resp.drag_started() { + ui.data_mut(|d| d.insert_temp(drag_id, n.id.0)); + } + let dragging: Option = ui.data(|d| d.get_temp(drag_id)); + if let Some(src) = dragging + && src != n.id.0 + && ui.rect_contains_pointer(r) + { + let above = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.y < r.center().y); + let y = if above { r.top() } else { r.bottom() }; + ui.painter().line_segment([egui::pos2(r.left() + 46.0, y), egui::pos2(r.right(), y)], Stroke::new(2.0, t.accent)); + if ui.input(|i| i.pointer.any_released()) { + let (parent, index) = match doc.position(n.id) { + Some((par, idx, _)) => (par, if above { idx + 1 } else { idx }), + None => (None, 0), + }; + // A layer dropped on a non-layer goes into that row's container; top level only for layers. + let src_is_layer = doc.node(drawcraft_doc::NodeId(src)).is_some_and(|x| x.is_layer()); + if parent.is_some() || src_is_layer { + actions.push(("node.move".into(), json!({"id": src, "parent": parent.map(|p| p.0), "index": index}))); + } + ui.data_mut(|d| d.remove::(drag_id)); + } + } + if has_children && expanded.contains(&n.id.0) { for c in n.children().unwrap().iter().rev() { row(ui, doc, c, depth + 1, sel, current, expanded, actions, t); diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/render_worker.rs new file mode 100644 index 00000000..97d40b99 --- /dev/null +++ b/crates/ui-egui/src/render_worker.rs @@ -0,0 +1,84 @@ +//! Background rendering (native): heavy documents render off the UI thread while the canvas shows +//! the previous frame reprojected to the current view. Light documents render synchronously so +//! edits never lag behind overlays. + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use drawcraft_doc::Document; +use drawcraft_geom::Affine; +use drawcraft_render::{RenderOptions, Rendered, Renderer}; + +use crate::CacheKey; + +pub struct Job { + pub key: CacheKey, + pub doc: Arc, + pub w: u32, + pub h: u32, + pub view: Affine, + pub opts: RenderOptions, +} + +pub struct Done { + pub key: CacheKey, + pub img: Rendered, + pub ms: f64, +} + +pub struct Worker { + tx: Sender, + rx: Receiver, + pub pending: Option, +} + +impl Worker { + /// Spawn the worker thread (None on wasm, where rendering stays synchronous). + pub fn spawn(ctx: egui::Context) -> Option { + if cfg!(target_arch = "wasm32") { + return None; + } + let (tx, jobs) = channel::(); + let (done_tx, rx) = channel::(); + std::thread::Builder::new() + .name("drawcraft-render".into()) + .spawn(move || { + let mut r = Renderer::new(); + while let Ok(mut job) = jobs.recv() { + // Skip stale jobs: only the newest matters. + while let Ok(newer) = jobs.try_recv() { + job = newer; + } + let t0 = crate::now_ms(); + let img = r.render(&job.doc, job.w, job.h, job.view, &job.opts); + if done_tx.send(Done { key: job.key, img, ms: crate::now_ms() - t0 }).is_err() { + break; + } + ctx.request_repaint(); + } + }) + .ok()?; + Some(Self { tx, rx, pending: None }) + } + + pub fn submit(&mut self, job: Job) { + if self.pending.as_ref() == Some(&job.key) { + return; + } + self.pending = Some(job.key.clone()); + let _ = self.tx.send(job); + } + + pub fn poll(&mut self) -> Option { + let mut last = None; + while let Ok(d) = self.rx.try_recv() { + last = Some(d); + } + if let Some(d) = &last + && self.pending.as_ref() == Some(&d.key) + { + self.pending = None; + } + last + } +}