diff --git a/crates/tools/src/pencil.rs b/crates/tools/src/pencil.rs index 5c4bbd511..c16351611 100644 --- a/crates/tools/src/pencil.rs +++ b/crates/tools/src/pencil.rs @@ -51,7 +51,7 @@ impl Tool for PencilTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { + fn overlays(&self, cx: &ToolContext) -> Vec { let Some((_, _, pts)) = &self.stroke else { return vec![] }; let mut path = BezPath::new(); for (i, p) in pts.iter().enumerate() { @@ -61,7 +61,9 @@ impl Tool for PencilTool { path.line_to(*p); } } - vec![Overlay::Path { path, color: [0, 0, 0], dashed: false }] + // In the colour of the layer the path goes on, as InDesign draws it. + let color = cx.doc.layer(cx.layer).map_or([60, 120, 220], |l| l.color); + vec![Overlay::Path { path, color, dashed: false }] } fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..e4cf52619 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -172,7 +172,18 @@ fn pencil_draws_a_smooth_path() { t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 100.0 + 50.0 * a.sin() + off.x, 100.0 + 50.0 * (1.0 - a.cos()) + off.y)); } assert!(t.busy()); + // While drawing, the stroke so far is shown, in the colour of the layer it goes on. + let layer = d.layer(cx.layer).unwrap().color; + match &t.overlays(&cx)[..] { + [crate::Overlay::Path { path, color, dashed: false }] => { + assert_eq!(*color, layer); + assert!(path.elements().len() > 40, "the stroke so far: {} points", path.elements().len()); + assert_eq!(path.elements()[0], designcraft_geom::PathEl::MoveTo(designcraft_geom::Point::new(100.0 + off.x, 100.0 + off.y))); + } + other => panic!("{other:?}"), + } let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 150.0 + off.x, 150.0 + off.y)); + assert!(t.overlays(&cx).is_empty(), "the preview ends with the stroke"); match &a[..] { [Action::Exec(cmd, p)] => { assert_eq!(cmd, "path.create"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..df473465c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1237,7 +1237,39 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_filled(r, 3.0, tok.measure_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } - Overlay::Path { .. } => {} + Overlay::Path { path, color, dashed: d } => draw_path_overlay(painter, xf, &path, c32(color), d), + } + } +} + +/// A path overlay (canvas coordinates; the Pencil's stroke while drawing, the Smooth and Erase +/// drags) as screen polylines, its curves flattened to about a quarter of a screen pixel. +fn draw_path_overlay(painter: &egui::Painter, xf: &Xf, path: &designcraft_geom::BezPath, color: Color32, dashed_line: bool) { + use designcraft_geom::PathEl; + let stroke = Stroke::new(1.0, color); + let mut runs: Vec> = Vec::new(); + designcraft_geom::kurbo::flatten(path, 0.25 / xf.zoom.max(1e-9), |el| match el { + PathEl::MoveTo(p) => runs.push(vec![xf.to_screen(p)]), + PathEl::LineTo(p) => { + if let Some(run) = runs.last_mut() { + run.push(xf.to_screen(p)); + } + } + PathEl::ClosePath => { + if let Some(run) = runs.last_mut() + && let Some(&first) = run.first() + { + run.push(first); + } + } + // Flattening yields only moves, lines and closes. + PathEl::QuadTo(..) | PathEl::CurveTo(..) => {} + }); + for run in runs.into_iter().filter(|r| r.len() >= 2) { + if dashed_line { + painter.extend(egui::Shape::dashed_line(&run, stroke, 3.0, 3.0)); + } else { + painter.add(egui::Shape::line(run, stroke)); } } } @@ -1919,3 +1951,58 @@ mod tests { assert!(h.query_by_label(" Points").is_none()); } } + +#[cfg(test)] +mod pencil_preview_tests { + use egui::{Pos2, pos2, vec2}; + + /// Lines painted in `color` (egui paths) and the points they pass through. + fn lines_in(h: &egui_kittest::Harness<'static, crate::test_window::Window>, color: egui::Color32) -> Vec> { + fn walk(s: &egui::Shape, color: egui::Color32, out: &mut Vec>) { + match s { + egui::Shape::Path(p) if p.stroke.color == egui::epaint::ColorMode::Solid(color) && p.points.len() >= 2 => out.push(p.points.clone()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, color, out)), + _ => {} + } + } + let mut out = vec![]; + h.output().shapes.iter().for_each(|s| walk(&s.shape, color, &mut out)); + out + } + + #[test] + fn the_pencil_stroke_shows_while_drawing() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("tool.select", serde_json::json!({"tool": "pencil"})).unwrap(); + let mut h = crate::test_window::open(app, vec2(1400.0, 900.0)); + let doc = &h.state().app.session.doc().unwrap().doc; + let layer = doc.layers[0].color; + let color = egui::Color32::from_rgb(layer[0], layer[1], layer[2]); + let items = |h: &egui_kittest::Harness<'static, crate::test_window::Window>| h.state().app.session.doc().unwrap().doc.spreads[0].items.len(); + let before = items(&h); + // Press on the page and drag, without letting go. + let start = pos2(520.0, 380.0); + h.hover_at(start); + h.drag_at(start); + h.run_steps(2); + let path: Vec = (1..=12).map(|i| start + vec2(i as f32 * 12.0, (i as f32 * 0.6).sin() * 30.0)).collect(); + for p in &path { + h.hover_at(*p); + h.run_steps(1); + } + let drawn = lines_in(&h, color); + let stroke = drawn.iter().max_by_key(|l| l.len()).expect("the stroke is painted while the button is down"); + let near = |a: Pos2, b: Pos2| (a - b).length() < 2.0; + assert!(near(stroke[0], start), "it starts where the press was: {:?}", stroke[0]); + assert!(path.iter().all(|p| stroke.iter().any(|q| near(*p, *q))), "it follows the pointer: {stroke:?}"); + assert_eq!(items(&h), before, "nothing is made before the release"); + // Letting go makes the path and ends the preview. + h.drop_at(*path.last().unwrap()); + h.run_steps(3); + assert_eq!(items(&h), before + 1); + let app = &mut h.state_mut().app; + let view = app.view_info(); + assert!(app.session.overlays(view).is_empty(), "the preview is gone"); + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..3d2fec857 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -403,7 +403,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. -- Pencil tool (N): freehand strokes simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). +- Pencil tool (N): freehand strokes, shown as you draw in the layer's colour, simplified to smooth paths (Alt closes); Smooth and Erase tools re-fit or remove the stretch you drag along (`path.smooth`, `path.erase`). - Edit › Keyboard Shortcuts: every command, click and press the new keys, conflicts shown, per-command and global reset; `window.setShortcut`. - Primary Text Frame (New Document) and Smart Text Reflow: pages are added while the primary story oversets and empty ones at the end removed, in the same undo step (Preferences › Type). - Glyphs panel: every character of any font (rendered by our renderer), search by character or U+code, click to insert, recently used.