fix(ui): Show the Pencil stroke while drawing

The Pencil tool's stroke only appeared once the mouse button was
released. The tool reported the stroke so far as a path overlay, but the
canvas skipped path overlays (they were never drawn), so nothing showed
while drawing. The canvas now draws them: curves flattened to about a
quarter of a screen pixel, dashed when asked. That also brings back the
Smooth and Erase tools' dashed drag previews.

The Pencil's preview was black, lost on a dark pasteboard; it is drawn in
the colour of the layer the path goes on, as InDesign does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 09:33:18 +02:00
committed by paaspaas00
co-authored by Claude Opus 5.5
parent e862e0a613
commit 9d666f3dc8
4 changed files with 104 additions and 4 deletions
+4 -2
View File
@@ -51,7 +51,7 @@ impl Tool for PencilTool {
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
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 {
+11
View File
@@ -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");
+88 -1
View File
@@ -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<Pos2>> = 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<Vec<Pos2>> {
fn walk(s: &egui::Shape, color: egui::Color32, out: &mut Vec<Vec<Pos2>>) {
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<Pos2> = (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");
}
}
+1 -1
View File
@@ -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.