diff --git a/ROADMAP.md b/ROADMAP.md index ea18713..f44e6f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -147,6 +147,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Citations and bibliography in all 12 of Word's styles: Harvard, ISO 690 (author-date and numerical), Turabian, GB/T 7714, GOST (name and title sort) and SIST02 join APA, MLA, Chicago and IEEE; numbered styles count in order of first citation (#383) | | 2026-10-11 | Interface size setting (#475): File › Options › General scales the interface 80–200 % on top of the system display scale, kept between runs; `ui.interfaceSize` for agents | | 2026-10-11 | Formatting revisions (#41): `w:rPrChange`/`w:pPrChange`, table/row/cell, section and numbering changes kept in DOCX; formatting recorded as revisions while tracking; accept/reject, Reviewing Pane descriptions, "Formatted: …" balloons and change bars | +| 2026-10-11 | Draw: Lasso Select (move, recolour, delete the strokes inside a loop), Add Pen gallery kept between runs, Ink to Shape (own recogniser: line, rectangle/square, ellipse/circle, triangle, polygon), Ink Replay in drawing order with play/pause/rewind/forward; drawing order kept in DOCX (#395); catalog 406/434 (93.5%) | | 2026-10-10 | Estonian (`et`) interface (#294): 1,161 translated entries, locale and saved-preference tests, bundled-font checks; no Estonian proofing resources | | 2026-10-10 | Draw tab: pen, pencil and highlighter ink, stroke eraser, Select, Review › Hide Ink; ink saved to DOCX as DrawingML freeforms (#307); catalog 399/431 (92.6%) | | 2026-10-10 | Draw Table and Eraser (#303): a pen draws one-cell tables and splits cells along drawn lines; the eraser merges the cells beside a border. Catalog 393/431 (91.2%) | @@ -162,6 +163,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 | | 2026-10-11 | trivial | Progress log: document grid (#391) | | 2026-10-11 | trivial | Progress log: text direction, Align Text and linked text boxes (#369) | | 2026-10-10 | trivial | Progress: Drawing Canvas (#344): insert, shapes/pictures/text boxes inside, Fit/Expand/Scale, DOCX `wpc:wpc`, turns and flips as a whole; catalog 404/434 | diff --git a/crates/doc/src/freeform.rs b/crates/doc/src/freeform.rs index 06d80c0..6b46d3b 100644 --- a/crates/doc/src/freeform.rs +++ b/crates/doc/src/freeform.rs @@ -88,18 +88,22 @@ pub struct Freeform { /// by the eraser. #[serde(skip_serializing_if = "Option::is_none")] pub ink: Option, + /// Ink: its place in the order the strokes were drawn (1, 2, 3…), for Draw › Ink Replay. + /// `None` for ink from files that don't record it (replayed first, in document order). + #[serde(skip_serializing_if = "Option::is_none")] + pub order: Option, } impl Default for Freeform { fn default() -> Self { - Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None } + Freeform { w: 0.0, h: 0.0, paths: Vec::new(), alpha: 1.0, ink: None, order: None } } } impl Freeform { /// An ink stroke through `pts` (shape-local points, already in the `w` × `h` frame). pub fn ink(tool: InkTool, w: f32, h: f32, pts: Vec<[f32; 2]>) -> Freeform { - Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool) }.sanitized() + Freeform { w, h, paths: vec![FreePath { pts, closed: false }], alpha: tool.alpha(), ink: Some(tool), order: None }.sanitized() } /// The same geometry with non-finite points dropped, coordinates clamped, the counts capped @@ -183,7 +187,7 @@ mod tests { fn hostile_geometry_is_sanitized() { let mut pts = vec![[f32::NAN, 1.0], [2.0, f32::INFINITY], [1.0, 2.0], [1e30, -1e30]]; pts.extend(std::iter::repeat_n([3.0, 3.0], MAX_POINTS * 2)); - let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None }.sanitized(); + let f = Freeform { w: f32::NAN, h: -4.0, paths: vec![FreePath { pts, closed: false }], alpha: 7.0, ink: None, order: None }.sanitized(); assert_eq!((f.w, f.h, f.alpha), (0.0, 0.0, 1.0)); let p = &f.paths[0].pts; assert_eq!(p.len(), MAX_POINTS); diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs index 50a6c77..6d2525f 100644 --- a/crates/docx/src/read/freeform.rs +++ b/crates/docx/src/read/freeform.rs @@ -71,7 +71,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option { paths.push(FreePath { pts: pts.into_iter().take(MAX_POINTS).map(scale).collect(), closed: closed && !fill_none }); } } - let f = Freeform { w, h, paths, alpha: 1.0, ink: None }.sanitized(); + let f = Freeform { w, h, paths, alpha: 1.0, ink: None, order: None }.sanitized(); (!f.paths.is_empty()).then_some(f) } @@ -85,6 +85,12 @@ pub fn ink_tool(name: &str) -> Option { words.next().and_then(InkTool::parse) } +/// The drawing order of an ink stroke written by WordCraft, from its drawing's name +/// (`Ink Pen 3 #12`: the twelfth stroke drawn), for Ink Replay. +pub fn ink_order(name: &str) -> Option { + name.split_whitespace().skip(3).find_map(|w| w.strip_prefix('#')?.parse().ok()) +} + /// The outline opacity of `ln` (`a:ln`): its colour's `a:alpha`, 0–1. pub fn line_alpha(ln: Option<&El>) -> f32 { ln.and_then(|l| l.child("a:solidFill")) diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index b17f219..e72b914 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -816,7 +816,9 @@ impl Reader<'_> { && let Some(mut f) = super::freeform::cust_geom(sppr, w, h) { f.alpha = super::freeform::line_alpha(sppr.child("a:ln")); - f.ink = c.child("wp:docPr").and_then(|p| p.attr("name")).and_then(super::freeform::ink_tool); + let name = c.child("wp:docPr").and_then(|p| p.attr("name")); + f.ink = name.and_then(super::freeform::ink_tool); + f.order = name.filter(|_| f.ink.is_some()).and_then(super::freeform::ink_order); *kind = ShapeKind::Freeform; *freeform = Some(Arc::new(f)); } diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 9037658..e95602d 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -1191,11 +1191,15 @@ fn sectpr_body(w: &mut W, s: &SectionProps) { } } -/// A shape's `cNvPr`/`docPr` name: ink is "Ink ", which reading looks for. +/// A shape's `cNvPr`/`docPr` name: ink is "Ink " (and "#" when its drawing +/// order is known), which reading looks for. fn shape_name(kind: ShapeKind, freeform: Option<&wordcraft_doc::freeform::Freeform>, id: &str) -> String { - match freeform.filter(|_| kind == ShapeKind::Freeform).and_then(|f| f.ink) { - Some(tool) => format!("Ink {} {id}", ink_name(tool)), - None => format!("Shape {id}"), + let f = freeform.filter(|_| kind == ShapeKind::Freeform); + match (f.and_then(|f| f.ink), f.and_then(|f| f.order)) { + // The drawing order rides along for Ink Replay: `Ink Pen 3 #12`. + (Some(tool), Some(n)) => format!("Ink {} {id} #{n}", ink_name(tool)), + (Some(tool), None) => format!("Ink {} {id}", ink_name(tool)), + (None, _) => format!("Shape {id}"), } } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 612fbdb..da13e30 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1373,7 +1373,8 @@ fn list_level_overrides_round_trip() { } /// Ink strokes and freeform shapes are written as DrawingML custom geometry (`a:custGeom`, -/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen and opacity. +/// `a:moveTo`/`a:lnTo`) in floating drawings and read back with their points, pen, opacity and +/// drawing order. #[test] fn ink_and_freeforms_round_trip_as_custom_geometry() { use wordcraft_doc::freeform::{FreePath, Freeform, InkTool}; @@ -1389,7 +1390,13 @@ fn ink_and_freeforms_round_trip_as_custom_geometry() { freeform: Some(Arc::new(f)), effects: Default::default(), }; - let pen = shape(None, 2.0, Wrap::InFrontOfText, Freeform::ink(InkTool::Pen, 60.0, 20.0, vec![[1.0, 1.0], [30.0, 19.0], [59.0, 4.0]])); + // The pen stroke was the seventh drawn: Ink Replay's order comes back too. + let pen = shape( + None, + 2.0, + Wrap::InFrontOfText, + Freeform { order: Some(7), ..Freeform::ink(InkTool::Pen, 60.0, 20.0, vec![[1.0, 1.0], [30.0, 19.0], [59.0, 4.0]]) }, + ); let marker = shape(None, 12.0, Wrap::BehindText, Freeform::ink(InkTool::Highlighter, 80.0, 12.0, vec![[6.0, 6.0], [74.0, 6.0]])); let triangle = Freeform { w: 40.0, diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 38f4121..c00724d 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1,20 +1,28 @@ -//! Draw tab and Review › Ink: pens, the eraser and ink strokes. +//! Draw tab and Review › Ink: pens, the eraser, the lasso and ink strokes. //! //! An ink stroke is a floating freeform shape ([`Freeform`] with its pen set) anchored to the //! paragraph under where it starts, in front of the text (a highlighter's behind it). The pen //! commands only choose what dragging on the page does (session view state); `draw.stroke` and //! `draw.erase` change the document. +//! +//! Draw › Lasso Select picks the strokes (and drawings) inside a loop drawn on a page; the +//! selection can then be moved, recoloured, deleted or turned into shapes. Ink to Shape replaces +//! strokes that look like a line, rectangle, ellipse, triangle or polygon with that shape +//! ([`ink_shape`](super::ink_shape)). Ink Replay plays the strokes back in the order they were +//! drawn (each stroke keeps its place in that order). Add Pen keeps custom pens in a gallery. use std::sync::Arc; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Value, json}; -use wordcraft_doc::freeform::{Freeform, InkTool, MAX_INK_PER_PAGE, MAX_POINTS}; +use wordcraft_doc::freeform::{FreePath, Freeform, InkTool, MAX_INK_PER_PAGE, MAX_POINTS}; use wordcraft_doc::para::{Anchor, Float, InlineObject, OBJ, ShapeKind, Wrap}; use wordcraft_doc::props::Rgb; use wordcraft_doc::{CharProps, Pos, StoryRef}; +use wordcraft_geom::Rect; use wordcraft_layout::{DocLayout, Placed}; +use super::ink_shape::{InkShape, recognise}; use super::pos_json; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -25,6 +33,8 @@ pub enum DrawMode { /// Ordinary selection of text and drawings. #[default] Select, + /// Dragging draws a loop that selects the ink inside it; dragging the selection moves it. + Lasso, /// Dragging over ink strokes deletes them. Eraser, /// Dragging draws ink with this pen. @@ -56,7 +66,69 @@ impl PenSettings { } } -/// The Draw tab's state: the tool in use and each pen's settings. +/// A pen added to the pen gallery (Draw › Add Pen): its kind, colour and thickness. Saved as +/// `{"kind": "pen", "color": "RRGGBB", "width": 1.5}`. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "Value", into = "Value")] +pub struct CustomPen { + pub kind: InkTool, + pub color: Rgb, + /// Points. + pub width: f32, +} + +impl TryFrom for CustomPen { + type Error = CmdError; + fn try_from(v: Value) -> Result { + custom_pen(&v) + } +} + +impl From for Value { + fn from(p: CustomPen) -> Value { + json!({"kind": p.kind.name(), "color": p.color.hex(), "width": p.width}) + } +} + +/// Most pens the gallery keeps. +pub const MAX_PENS: usize = 24; + +/// A pen from `{"kind"?, "color"?, "width"?}` (the kind's defaults for what's left out; the +/// width clamped). Anything but an object, an unknown kind or a malformed colour is refused. +pub fn custom_pen(v: &Value) -> Result { + if !v.is_object() { + return Err(CmdError::Params("a pen is {\"kind\", \"color\", \"width\"}".into())); + } + let kind = match p::str(v, "kind") { + Some(k) => InkTool::parse(k).ok_or_else(|| CmdError::Params("`kind` is pen, pencil or highlighter".into()))?, + None => InkTool::Pen, + }; + let color = match p::str(v, "color") { + Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?, + None => kind.default_color(), + }; + let width = p::f32(v, "width").filter(|w| w.is_finite()).unwrap_or(kind.default_width()).clamp(MIN_WIDTH, MAX_WIDTH); + Ok(CustomPen { kind, color, width }) +} + +/// Read a saved pen gallery, dropping entries that aren't pens (a hand-edited or damaged +/// settings file loses those pens, never the rest of the settings). +pub fn lenient_pens<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Value::deserialize(d)?; + Ok(v.as_array().map(|a| a.iter().filter_map(|x| custom_pen(x).ok()).take(MAX_PENS).collect()).unwrap_or_default()) +} + +/// Strokes and drawings picked with the lasso on `page`. Good while the document is as it was +/// when they were picked (or last moved, recoloured or converted through the lasso). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LassoSelection { + pub page: usize, + pub items: Vec, + rev: u64, +} + +/// The Draw tab's state: the tool in use, each pen's settings, the pen gallery, Ink to Shape, +/// the lasso selection and whether ink is being replayed. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct DrawState { @@ -64,6 +136,16 @@ pub struct DrawState { pub pen: PenSettings, pub pencil: PenSettings, pub highlighter: PenSettings, + /// Pens added with Add Pen (kept between runs by the app). + #[serde(deserialize_with = "lenient_pens")] + pub pens: Vec, + /// Ink to Shape is on: strokes drawn from now on that look like a shape become that shape. + pub ink_to_shape: bool, + #[serde(skip)] + pub lasso: Option, + /// Ink Replay is showing (the app plays the strokes back and turns this off when done). + #[serde(skip)] + pub replay: bool, } impl Default for DrawState { @@ -73,6 +155,10 @@ impl Default for DrawState { pen: PenSettings::of(InkTool::Pen), pencil: PenSettings::of(InkTool::Pencil), highlighter: PenSettings::of(InkTool::Highlighter), + pens: Vec::new(), + ink_to_shape: false, + lasso: None, + replay: false, } } } @@ -99,6 +185,12 @@ const MIN_WIDTH: f32 = 0.25; const MAX_WIDTH: f32 = 72.0; /// How far from a stroke (beyond its half width) the eraser still takes it, points. const ERASE_SLOP: f32 = 3.0; +/// Most points of a lasso loop that are kept (a longer loop is thinned out). +const MAX_LASSO_POINTS: usize = 512; +/// Points of a stroke tested against the lasso (spread along it). +const LASSO_SAMPLES: usize = 64; +/// Farthest the lasso moves its selection in one step, points. +const MAX_MOVE: f32 = 10_000.0; pub fn specs() -> Vec { vec![ @@ -107,6 +199,12 @@ pub fn specs() -> Vec { state(s) }) .pure(), + CommandSpec::new("draw.lasso", "Lasso Select", "Draw › Tools", lasso) + .params(r#"{"page"?: n, "points"?: [[x, y], …], "clear"?: bool} (no points: use the lasso; points: select the ink inside that loop; clear: drop the selection)"#) + .pure(), + CommandSpec::new("draw.lassoMove", "Move Selected Ink", "Draw › Tools", lasso_move).params(r#"{"dx": pt, "dy": pt}"#), + CommandSpec::new("draw.lassoDelete", "Delete Selected Ink", "Draw › Tools", lasso_delete), + CommandSpec::new("draw.lassoColor", "Recolor Selected Ink", "Draw › Tools", lasso_color).params(r#"{"color": "RRGGBB"}"#), CommandSpec::new("draw.eraser", "Eraser", "Draw › Tools", |s, v| { // With a point, erase there (as a script would); without, pick up the eraser. if v.get("page").is_some() || v.get("x").is_some() || v.get("y").is_some() { @@ -120,10 +218,42 @@ pub fn specs() -> Vec { CommandSpec::new("draw.pen", "Pen", "Draw › Pens", |s, v| pen(s, v, InkTool::Pen)).params(PEN_PARAMS).pure(), CommandSpec::new("draw.pencil", "Pencil", "Draw › Pens", |s, v| pen(s, v, InkTool::Pencil)).params(PEN_PARAMS).pure(), CommandSpec::new("draw.highlighter", "Highlighter", "Draw › Pens", |s, v| pen(s, v, InkTool::Highlighter)).params(PEN_PARAMS).pure(), + CommandSpec::new("draw.addPen", "Add Pen", "Draw › Pens", add_pen) + .params(r#"{"kind"?: "pen|pencil|highlighter", "color"?: "RRGGBB", "width"?: pt} (adds the pen to the gallery and picks it up)"#) + .pure(), + CommandSpec::new("draw.removePen", "Remove Pen", "Draw › Pens", |s, v| { + let i = p::u64(v, "index").and_then(|i| usize::try_from(i).ok()).ok_or_else(|| CmdError::Params("`index` (0-based) is required".into()))?; + if i >= s.view.draw.pens.len() { + return Err(CmdError::Params(format!("no pen {i}"))); + } + let pen = s.view.draw.pens.remove(i); + Ok(json!({"removed": Value::from(pen), "pens": s.view.draw.pens.len()})) + }) + .params(r#"{"index": n}"#) + .pure(), CommandSpec::new("draw.stroke", "Draw Ink", "Draw › Pens", stroke).params( r#"{"page": n, "points": [[x, y], …] (page points, at most 5000), "tool"?: "pen|pencil|highlighter", "color"?: "RRGGBB", "width"?: pt}"#, ), CommandSpec::new("draw.erase", "Erase Ink", "Draw › Tools", erase).params(r#"{"page": n, "x": pt, "y": pt}"#), + CommandSpec::new("draw.inkToShape", "Ink to Shape", "Draw › Convert", |s, v| { + if let Some(on) = p::bool(v, "value") { + s.view.draw.ink_to_shape = on; + return Ok(json!({"value": on})); + } + // With strokes lasso-selected it converts them; otherwise it turns the mode on or off. + let ink_selected = lasso_selection(s).is_some_and(|l| l.items.iter().any(|pos| s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).and_then(InlineObject::ink).is_some())); + if ink_selected { + return s.run("draw.convertInk", &json!({})); + } + s.view.draw.ink_to_shape = !s.view.draw.ink_to_shape; + Ok(json!({"value": s.view.draw.ink_to_shape})) + }) + .params(r#"{"value"?: bool} (no value: convert the lasso-selected strokes, or turn Ink to Shape on or off)"#) + .pure(), + CommandSpec::new("draw.convertInk", "Convert to Shapes", "Draw › Convert", convert_selection), + CommandSpec::new("draw.replay", "Ink Replay", "Draw › Replay", replay) + .params(r#"{"value"?: bool} (starts or stops Ink Replay; returns the strokes in the order they were drawn)"#) + .pure(), CommandSpec::new("review.hideInk", "Hide Ink", "Review › Ink", |s, v| { s.view.hide_ink = p::bool(v, "value").unwrap_or(!s.view.hide_ink); Ok(json!({"value": s.view.hide_ink})) @@ -146,15 +276,54 @@ fn pen(s: &mut Session, v: &Value, tool: InkTool) -> CmdResult { if let Some(c) = color { set.color = c; } - if let Some(w) = p::f32(v, "width") { + if let Some(w) = p::f32(v, "width").filter(|w| w.is_finite()) { set.width = w.clamp(MIN_WIDTH, MAX_WIDTH); } s.view.draw.mode = DrawMode::Pen(tool); state(s) } -/// Ink strokes laid out on `page`: their position, unturned frame, turn and geometry on the page. -fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_geom::Rect, wordcraft_geom::Spin, Arc, f32)> { +/// Add a pen to the gallery and pick it up. +fn add_pen(s: &mut Session, v: &Value) -> CmdResult { + let pen = custom_pen(v)?; + if s.view.draw.pens.len() >= MAX_PENS { + return Err(CmdError::Failed(format!("the pen gallery holds at most {MAX_PENS} pens"))); + } + s.view.draw.pens.push(pen); + *s.view.draw.settings_mut(pen.kind) = PenSettings { color: pen.color, width: pen.width }; + s.view.draw.mode = DrawMode::Pen(pen.kind); + Ok(json!({"pen": Value::from(pen), "index": s.view.draw.pens.len() - 1, "pens": s.view.draw.pens.len()})) +} + +/// An ink stroke laid out on a page. +#[derive(Clone, Debug)] +pub struct PlacedInk { + pub pos: Pos, + /// Its unturned frame on the page. + pub rect: Rect, + /// Its turn about the frame's centre. + pub spin: wordcraft_geom::Spin, + pub freeform: Arc, + pub color: Option, + pub width: f32, +} + +impl PlacedInk { + /// Its paths in page points, turned as it is drawn. + pub fn paths(&self) -> Vec> { + let r = self.rect; + let paths = self.freeform.placed(r.x, r.y, r.w, r.h).into_iter().map(|(pts, _)| pts); + if self.spin.is_identity() { + return paths.collect(); + } + let (cx, cy) = (r.x + r.w / 2.0, r.y + r.h / 2.0); + paths.map(|pts| pts.into_iter().map(|[x, y]| <[f32; 2]>::from(self.spin.apply(cx, cy, x, y))).collect()).collect() + } +} + +/// Ink strokes laid out on `page`, in the page's drawing order: their position, unturned frame, +/// turn and geometry on the page. +pub fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec { let Some(pg) = layout.pages.get(page) else { return Vec::new() }; pg.items .iter() @@ -162,8 +331,8 @@ fn ink_on(s: &Session, layout: &DocLayout, page: usize) -> Vec<(Pos, wordcraft_g Placed::Object { rect, spin, story, path, off, .. } => { let pos = Pos { story: *story, path: path.clone(), off: *off }; match s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) { - Some(InlineObject::Shape { freeform: Some(f), stroke_width, .. }) if f.is_ink() => { - Some((pos, *rect, *spin, f.clone(), *stroke_width)) + Some(InlineObject::Shape { freeform: Some(f), stroke_width, stroke, .. }) if f.is_ink() => { + Some(PlacedInk { pos, rect: *rect, spin: *spin, freeform: f.clone(), color: *stroke, width: *stroke_width }) } _ => None, } @@ -179,12 +348,13 @@ pub fn ink_at(s: &mut Session, page: usize, x: f32, y: f32) -> Option { return None; } let layout = s.layout(); - ink_on(s, &layout, page).into_iter().rev().find_map(|(pos, r, spin, f, sw)| { - let reach = (if sw.is_finite() { sw.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP; + ink_on(s, &layout, page).into_iter().rev().find_map(|k| { + let reach = (if k.width.is_finite() { k.width.clamp(0.0, MAX_WIDTH) } else { 1.0 }) / 2.0 + ERASE_SLOP; // A turned stroke is drawn turned about its frame's centre: test the point turned back // into the stroke's own frame (a turn keeps distances, so the reach holds). - let (u, v) = spin.unapply(r.x + r.w / 2.0, r.y + r.h / 2.0, x, y); - f.distance(r.x, r.y, r.w, r.h, u, v).filter(|d| *d <= reach).map(|_| pos) + let r = k.rect; + let (u, v) = k.spin.unapply(r.x + r.w / 2.0, r.y + r.h / 2.0, x, y); + k.freeform.distance(r.x, r.y, r.w, r.h, u, v).filter(|d| *d <= reach).map(|_| k.pos) }) } @@ -195,19 +365,55 @@ fn page_param(s: &mut Session, v: &Value) -> Result<(usize, Arc), Cmd Ok((page, layout)) } +/// `points` as page points: at most `max`, NaN, infinities and malformed entries dropped, each +/// clamped to the `pw` × `ph` page. +fn points_param(v: &Value, max: usize, pw: f32, ph: f32) -> Result, CmdError> { + Ok(v.get("points") + .and_then(Value::as_array) + .ok_or_else(|| CmdError::Params("`points` ([[x, y], …]) is required".into()))? + .iter() + .take(max) + .filter_map(|pt| { + let a = pt.as_array()?; + let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + (x.is_finite() && y.is_finite()).then(|| [x.clamp(0.0, pw), y.clamp(0.0, ph)]) + }) + .collect()) +} + /// Delete the ink stroke under a point. fn erase(s: &mut Session, v: &Value) -> CmdResult { let (page, _) = page_param(s, v)?; let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?); let pos = ink_at(s, page, x, y).ok_or_else(|| CmdError::Failed("no ink stroke there".into()))?; + delete_object(s, &pos)?; + Ok(json!({"erased": pos_json(&pos)})) +} + +/// Delete the object at `pos`, keeping the text selection on the same text. +fn delete_object(s: &mut Session, pos: &Pos) -> Result<(), CmdError> { let len = OBJ.len_utf8(); - s.doc.delete_range(&pos, &Pos { off: pos.off + len, ..pos.clone() })?; + s.doc.delete_range(pos, &Pos { off: pos.off + len, ..pos.clone() })?; for at in [&mut s.sel.anchor, &mut s.sel.focus] { if at.story == pos.story && at.path == pos.path && at.off > pos.off { at.off = at.off.saturating_sub(len).max(pos.off); } } - Ok(json!({"erased": pos_json(&pos)})) + Ok(()) +} + +/// The next stroke's place in the drawing order: one after the latest drawn. +fn next_order(s: &Session) -> u32 { + let latest = s + .doc + .para_paths(StoryRef::Body) + .iter() + .filter_map(|path| s.doc.para(StoryRef::Body, path)) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| o.ink().and_then(|f| f.order)) + .max() + .unwrap_or(0); + latest.saturating_add(1) } /// Draw an ink stroke through page points. @@ -222,22 +428,10 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?, None => set.color, }; - let width = p::f32(v, "width").unwrap_or(set.width).clamp(MIN_WIDTH, MAX_WIDTH); + let width = p::f32(v, "width").filter(|w| w.is_finite()).unwrap_or(set.width).clamp(MIN_WIDTH, MAX_WIDTH); let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?; let (pw, ph) = (pg.w.max(1.0), pg.h.clamp(1.0, 1e5)); - // Points on the page; NaN, infinities and malformed entries are dropped. - let pts: Vec<[f32; 2]> = v - .get("points") - .and_then(Value::as_array) - .ok_or_else(|| CmdError::Params("`points` ([[x, y], …]) is required".into()))? - .iter() - .take(MAX_POINTS) - .filter_map(|pt| { - let a = pt.as_array()?; - let (x, y) = (a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); - (x.is_finite() && y.is_finite()).then(|| [x.clamp(0.0, pw), y.clamp(0.0, ph)]) - }) - .collect(); + let pts = points_param(v, MAX_POINTS, pw, ph)?; let first = *pts.first().ok_or_else(|| CmdError::Params("`points` has no usable point".into()))?; if ink_on(s, &layout, page).len() >= MAX_INK_PER_PAGE { return Err(CmdError::Failed(format!("page {page} already has {MAX_INK_PER_PAGE} ink strokes"))); @@ -252,6 +446,7 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { let (fw, fh) = ((x1 - x0 + width).max(1.0), (y1 - y0 + width).max(1.0)); let local: Vec<[f32; 2]> = pts.iter().map(|[x, y]| [x - fx, y - fy]).collect(); let wrap = if tool == InkTool::Highlighter { Wrap::BehindText } else { Wrap::InFrontOfText }; + let order = next_order(s); let obj = InlineObject::Shape { kind: ShapeKind::Freeform, w: fw, @@ -261,11 +456,13 @@ fn stroke(s: &mut Session, v: &Value) -> CmdResult { stroke_width: width, float: Float { wrap, ..Default::default() }, story: None, - freeform: Some(Arc::new(Freeform::ink(tool, fw, fh, local))), + freeform: Some(Arc::new(Freeform { order: Some(order), ..Freeform::ink(tool, fw, fh, local) })), effects: Default::default(), }; let at = place(s, &layout, obj, page, (fx, fy), first[1])?; - Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh]})) + // Ink to Shape: a stroke that looks like a shape becomes it (in the same undo step). + let shape = if s.view.draw.ink_to_shape && tool != InkTool::Highlighter { convert_at(s, &at)? } else { None }; + Ok(json!({"pos": pos_json(&at), "page": page, "rect": [fx, fy, fw, fh], "shape": shape})) } /// Insert floating `obj` with its top-left at `(x, y)` on `page`, anchored to the body paragraph @@ -303,12 +500,271 @@ fn place(s: &mut Session, layout: &DocLayout, obj: InlineObject, page: usize, (x Ok(at) } +// --- Lasso ------------------------------------------------------------------------------- + +/// The current lasso selection, if the document hasn't changed under it. +pub fn lasso_selection(s: &Session) -> Option<&LassoSelection> { + s.view.draw.lasso.as_ref().filter(|l| l.rev == s.rev() && !l.items.is_empty()) +} + +/// Keep `sel` selected after the mutating command running now (which bumps the revision once). +fn keep_lasso(s: &mut Session, sel: LassoSelection) { + s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel }); +} + +/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule). +fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool { + let n = poly.len(); + let mut odd = false; + for i in 0..n { + let (Some(a), Some(b)) = (poly.get(i), poly.get((i + n - 1) % n)) else { continue }; + if (a[1] > y) != (b[1] > y) && x < (b[0] - a[0]) * (y - a[1]) / (b[1] - a[1]) + a[0] { + odd = !odd; + } + } + odd +} + +/// Select the ink inside a loop (most of a stroke's points inside it) and the drawings whose +/// centre is inside it; without a loop, pick up the lasso. +fn lasso(s: &mut Session, v: &Value) -> CmdResult { + if p::bool(v, "clear") == Some(true) { + s.view.draw.lasso = None; + return Ok(json!({"selected": [], "count": 0})); + } + if v.get("points").is_none() { + s.view.draw.mode = DrawMode::Lasso; + return state(s); + } + let (page, layout) = page_param(s, v)?; + let pg = layout.pages.get(page).ok_or_else(|| CmdError::Params(format!("no page {page}")))?; + let mut poly = points_param(v, MAX_POINTS, pg.w.max(1.0), pg.h.clamp(1.0, 1e5))?; + if poly.len() > MAX_LASSO_POINTS { + let step = poly.len().div_ceil(MAX_LASSO_POINTS); + poly = poly.into_iter().step_by(step).collect(); + } + if poly.len() < 3 { + return Err(CmdError::Params("a lasso loop needs at least three points".into())); + } + let mut items = Vec::new(); + for it in &pg.items { + let Placed::Object { rect, spin, story, path, off, .. } = it else { continue }; + let pos = Pos { story: *story, path: path.clone(), off: *off }; + let Some(obj) = s.doc.para_at(&pos).and_then(|p| p.object_at(*off)) else { continue }; + let picked = match obj.ink() { + Some(_) if s.view.hide_ink => false, + Some(f) => { + // Where the stroke is drawn: turned about its frame's centre. + let (cx, cy) = (rect.x + rect.w / 2.0, rect.y + rect.h / 2.0); + let pts: Vec<[f32; 2]> = f + .placed(rect.x, rect.y, rect.w, rect.h) + .into_iter() + .flat_map(|(p, _)| p) + .map(|[x, y]| <[f32; 2]>::from(spin.apply(cx, cy, x, y))) + .collect(); + let step = pts.len().div_ceil(LASSO_SAMPLES).max(1); + let (n, hits) = pts.iter().step_by(step).fold((0usize, 0usize), |(n, h), [x, y]| (n + 1, h + usize::from(inside(&poly, *x, *y)))); + n > 0 && hits * 10 >= n * 6 + } + None => obj.frame().is_some() && inside(&poly, rect.x + rect.w / 2.0, rect.y + rect.h / 2.0), + }; + if picked && !items.contains(&pos) { + items.push(pos); + } + } + let selected: Vec = items.iter().map(pos_json).collect(); + s.view.draw.lasso = (!items.is_empty()).then(|| LassoSelection { page, items, rev: s.rev() }); + Ok(json!({"page": page, "count": selected.len(), "selected": selected})) +} + +fn lasso_required(s: &Session) -> Result { + lasso_selection(s).cloned().ok_or_else(|| CmdError::Failed("nothing is selected with the lasso".into())) +} + +/// The lasso selection's bounds on its page in `layout` (the document's current layout), for +/// the app to draw and drag. +pub fn lasso_bounds(s: &Session, layout: &DocLayout) -> Option<(usize, Rect)> { + let sel = lasso_selection(s)?; + let mut r: Option = None; + for pos in &sel.items { + if let Some(hit) = layout.object(pos, sel.page) { + r = Some(r.map_or(hit.rect, |u| u.union(&hit.rect))); + } + } + r.map(|r| (sel.page, r)) +} + +/// Move the lasso-selected drawings by (`dx`, `dy`) points (one undo step). +fn lasso_move(s: &mut Session, v: &Value) -> CmdResult { + let sel = lasso_required(s)?; + let d = |k: &str| p::req_f32(v, k).map(|d| if d.is_finite() { d.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 }); + let (dx, dy) = (d("dx")?, d("dy")?); + let mut moved = 0; + for pos in &sel.items { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let mut hit = false; + if let Some(o) = para.object_at_mut(pos.off) + && o.is_floating() + && let Some(f) = o.float_mut() + { + if f.h_align.is_none() { + f.x += dx; + } + if f.v_align.is_none() { + f.y += dy; + } + hit = true; + } + if hit { + para.touch(); + moved += 1; + } + } + if moved == 0 { + return Err(CmdError::Failed("nothing selected can be moved".into())); + } + keep_lasso(s, sel); + Ok(json!({"moved": moved})) +} + +/// Delete the lasso-selected strokes and drawings (one undo step). +fn lasso_delete(s: &mut Session, _: &Value) -> CmdResult { + let mut items = lasso_required(s)?.items; + // Last first, so the earlier ones keep their place. + items.sort(); + items.dedup(); + let mut deleted = 0; + for pos in items.iter().rev() { + if s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).is_some() { + delete_object(s, pos)?; + deleted += 1; + } + } + s.view.draw.lasso = None; + if deleted == 0 { + return Err(CmdError::Failed("nothing selected to delete".into())); + } + Ok(json!({"deleted": deleted})) +} + +/// Give the lasso-selected strokes and shapes this outline colour (one undo step). +fn lasso_color(s: &mut Session, v: &Value) -> CmdResult { + let sel = lasso_required(s)?; + let color = Rgb::parse(p::req_str(v, "color")?).ok_or_else(|| CmdError::Params("`color` is RRGGBB".into()))?; + let mut changed = 0; + for pos in &sel.items { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let mut hit = false; + if let Some(InlineObject::Shape { stroke, .. }) = para.object_at_mut(pos.off) { + *stroke = Some(color); + hit = true; + } + if hit { + para.touch(); + changed += 1; + } + } + if changed == 0 { + return Err(CmdError::Failed("nothing selected has an outline to recolour".into())); + } + keep_lasso(s, sel); + Ok(json!({"recolored": changed, "color": color.hex()})) +} + +// --- Ink to Shape -------------------------------------------------------------------------- + +/// Turn the lasso-selected strokes that look like shapes into those shapes (one undo step). +fn convert_selection(s: &mut Session, _: &Value) -> CmdResult { + let sel = lasso_required(s)?; + let mut converted = Vec::new(); + for pos in &sel.items { + if let Some(name) = convert_at(s, pos)? { + converted.push(json!({"pos": pos_json(pos), "shape": name})); + } + } + if converted.is_empty() { + return Err(CmdError::Failed("none of the selected strokes looks like a shape".into())); + } + keep_lasso(s, sel); + Ok(json!({"count": converted.len(), "converted": converted})) +} + +/// Replace the ink stroke at `pos` by the shape it looks like, in its colour and thickness and +/// where it was drawn. `None` (nothing changed) when it isn't a pen or pencil stroke, it is +/// turned or flipped, or it doesn't look like a shape. +fn convert_at(s: &mut Session, pos: &Pos) -> Result, CmdError> { + let para = s.doc.para_mut(pos.story, &pos.path)?; + let Some(InlineObject::Shape { kind, w, h, fill, float, freeform, .. }) = para.object_at_mut(pos.off) else { return Ok(None) }; + let Some(f) = freeform.as_deref().filter(|f| matches!(f.ink, Some(InkTool::Pen | InkTool::Pencil)) && f.paths.len() == 1) else { + return Ok(None); + }; + if float.rot != 0.0 || float.flip_h || float.flip_v { + return Ok(None); + } + // The stroke in its frame's own points (the frame may have been resized since). + let Some((pts, _)) = f.placed(0.0, 0.0, *w, *h).into_iter().next() else { return Ok(None) }; + let Some(found) = recognise(&pts) else { return Ok(None) }; + let [bx, by, bw, bh] = found.bounds(); + let (bw, bh) = (bw.max(1.0), bh.max(1.0)); + let outline = |pts: Vec<[f32; 2]>, closed: bool| { + Some(Arc::new(Freeform { w: bw, h: bh, paths: vec![FreePath { pts, closed }], ..Default::default() }.sanitized())) + }; + let local = |c: &[[f32; 2]]| c.iter().map(|[x, y]| [x - bx, y - by]).collect::>(); + let (new_kind, geometry) = match &found { + InkShape::Rectangle { .. } => (ShapeKind::Rectangle, None), + InkShape::Ellipse { .. } => (ShapeKind::Ellipse, None), + InkShape::Line { a, b } => (ShapeKind::Freeform, outline(local(&[*a, *b]), false)), + InkShape::Polygon(c) => (ShapeKind::Freeform, outline(local(c), true)), + }; + *kind = new_kind; + *freeform = geometry; + *fill = None; + *w = bw; + *h = bh; + float.x += bx; + float.y += by; + para.touch(); + Ok(Some(found.name())) +} + +// --- Ink Replay ---------------------------------------------------------------------------- + +/// Every ink stroke in the document with its page, in the order it was drawn: strokes that +/// don't record their order (from other files) first, in document order, then the rest by order. +pub fn strokes_in_drawing_order(s: &mut Session) -> Vec<(usize, PlacedInk)> { + if s.view.hide_ink { + return Vec::new(); + } + let layout = s.layout(); + let mut all: Vec<(usize, PlacedInk)> = + (0..layout.pages.len()).flat_map(|page| ink_on(s, &layout, page).into_iter().map(move |k| (page, k))).collect(); + all.sort_by_key(|(_, k)| k.freeform.order.unwrap_or(0)); + all +} + +/// Start or stop Ink Replay; the strokes in the order they'll be replayed. +fn replay(s: &mut Session, v: &Value) -> CmdResult { + let on = p::bool(v, "value").unwrap_or(!s.view.draw.replay); + let strokes = if on { strokes_in_drawing_order(s) } else { Vec::new() }; + if on && strokes.is_empty() { + return Err(CmdError::Failed("there is no ink to replay".into())); + } + s.view.draw.replay = on; + let list: Vec = strokes + .iter() + .map(|(page, k)| { + json!({"page": page, "pos": pos_json(&k.pos), "order": k.freeform.order, "tool": k.freeform.ink.map(InkTool::name), "points": k.freeform.paths.iter().map(|p| p.pts.len()).sum::()}) + }) + .collect(); + Ok(json!({"replaying": on, "strokes": list})) +} + #[cfg(test)] mod tests { use serde_json::json; use wordcraft_doc::StoryRef; use wordcraft_doc::freeform::InkTool; - use wordcraft_doc::para::{InlineObject, Wrap}; + use wordcraft_doc::para::{InlineObject, ShapeKind, Wrap}; use crate::Session; @@ -404,6 +860,20 @@ mod tests { assert!(s.run("draw.erase", &json!({"page": 0, "x": 150, "y": 60})).is_err()); } + /// The lasso, like the eraser, finds a turned stroke where it is drawn. + #[test] + fn the_lasso_selects_a_rotated_stroke_where_it_is_drawn() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [200, 100]], "width": 2})).unwrap(); + s.run("select.objects", &json!({"index": 0})).unwrap(); + s.run("arrange.rotation", &json!({"degrees": 90})).unwrap(); + // A loop around its old horizontal path misses it; one around the vertical stroke takes it. + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[90, 95], [210, 95], [210, 105], [90, 105]]})).unwrap(); + assert_eq!(r["count"], 0); + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[140, 40], [160, 40], [160, 160], [140, 160]]})).unwrap(); + assert_eq!(r["count"], 1); + } + /// `draw.eraser` is a view command, but with a point it erases through `draw.erase`, which /// changes the document: that must be one undo step and mark the document changed. #[test] @@ -421,4 +891,127 @@ mod tests { s.run("edit.redo", &json!({})).unwrap(); assert!(ink(&s).is_empty()); } + + /// A closed rectangle drawn as page points, `per` points to a side. + fn rect_stroke(x: f32, y: f32, w: f32, h: f32) -> Vec<[f32; 2]> { + let c = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]; + let mut pts = Vec::new(); + for i in 0..4 { + let (a, b) = (c[i], c[(i + 1) % 4]); + for k in 0..10 { + let t = k as f32 / 10.0; + pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); + } + } + pts.push([x, y]); + pts + } + + #[test] + fn the_lasso_selects_the_ink_inside_and_moves_recolours_and_deletes_it() { + let mut s = session(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 100], [140, 110]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [340, 310]]})).unwrap(); + // Without points it picks up the lasso; a loop around the first stroke selects only it. + s.run("draw.lasso", &json!({})).unwrap(); + assert_eq!(s.view.draw.mode, super::DrawMode::Lasso); + let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [200, 80], [200, 150], [80, 150]]})).unwrap(); + assert_eq!(r["count"], 1); + let before = s.layout().object(&super::lasso_selection(&s).unwrap().items[0], 0).unwrap().rect; + // Moved, recoloured: each one undo step, and the selection stays. + let steps = s.undo_depth(); + s.run("draw.lassoMove", &json!({"dx": 20, "dy": -10})).unwrap(); + s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap(); + assert_eq!(s.undo_depth(), steps + 2); + let sel = super::lasso_selection(&s).unwrap().items[0].clone(); + let after = s.layout().object(&sel, 0).unwrap().rect; + assert!((after.x - before.x - 20.0).abs() < 0.01 && (after.y - before.y + 10.0).abs() < 0.01, "{before:?} -> {after:?}"); + let objs = ink(&s); + let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count(); + assert_eq!(blue, 1); + // Deleted: only the selected stroke goes, and the selection with it. + s.run("draw.lassoDelete", &json!({})).unwrap(); + assert_eq!(ink(&s).len(), 1); + assert!(super::lasso_selection(&s).is_none()); + assert!(s.run("draw.lassoMove", &json!({"dx": 1, "dy": 1})).is_err(), "nothing selected"); + // Hostile loops are refused or select nothing; they never panic. + assert!(s.run("draw.lasso", &json!({"page": 0, "points": [[1, 1], [2, 2]]})).is_err()); + let huge: Vec<[f64; 2]> = (0..20_000).map(|i| [(i % 600) as f64, (i / 40) as f64]).collect(); + let _ = s.run("draw.lasso", &json!({"page": 0, "points": huge})); + } + + #[test] + fn ink_to_shape_turns_shape_like_strokes_into_shapes_and_leaves_scribbles() { + let mut s = session(); + assert_eq!(s.run("draw.inkToShape", &json!({})).unwrap()["value"], true); + let steps = s.undo_depth(); + let r = s.run("draw.stroke", &json!({"page": 0, "points": rect_stroke(100.0, 100.0, 120.0, 60.0), "color": "C00000", "width": 2})).unwrap(); + assert_eq!(r["shape"], "rectangle"); + assert_eq!(s.undo_depth(), steps + 1, "drawn and converted in one undo step"); + let at: wordcraft_doc::Pos = serde_json::from_value(r["pos"].clone()).unwrap(); + let obj = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)).cloned().unwrap(); + let InlineObject::Shape { kind, w, h, stroke, stroke_width, freeform, .. } = obj else { panic!("{obj:?}") }; + assert_eq!((kind, stroke, stroke_width, freeform.is_none()), (ShapeKind::Rectangle, Some(wordcraft_doc::Rgb(0xC0, 0, 0)), 2.0, true)); + assert!((w - 120.0).abs() < 2.0 && (h - 60.0).abs() < 2.0, "{w} x {h}"); + let rect = s.layout().object(&at, 0).unwrap().rect; + assert!((rect.x - 100.0).abs() < 2.0 && (rect.y - 100.0).abs() < 2.0, "where it was drawn: {rect:?}"); + // A scribble stays ink. + let r = s.run("draw.stroke", &json!({"page": 0, "points": [[300, 300], [320, 340], [340, 300], [360, 340], [380, 300]]})).unwrap(); + assert!(r["shape"].is_null()); + assert_eq!(ink(&s).len(), 1); + // Off again; a drawn ellipse stays ink until the lasso selects it and Ink to Shape converts it. + s.run("draw.inkToShape", &json!({"value": false})).unwrap(); + let ring: Vec<[f32; 2]> = + (0..=48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|a| [200.0 + 50.0 * a.cos(), 500.0 + 30.0 * a.sin()]).collect(); + s.run("draw.stroke", &json!({"page": 0, "points": ring})).unwrap(); + assert_eq!(ink(&s).len(), 2); + s.run("draw.lasso", &json!({"page": 0, "points": [[130, 450], [270, 450], [270, 550], [130, 550]]})).unwrap(); + let r = s.run("draw.inkToShape", &json!({})).unwrap(); + assert_eq!(r["converted"][0]["shape"], "ellipse"); + assert_eq!(ink(&s).len(), 1); + assert!(!s.view.draw.ink_to_shape, "converting the selection doesn't turn the mode on"); + } + + #[test] + fn ink_replay_plays_strokes_in_the_order_they_were_drawn() { + let mut s = session(); + // Drawn low on the page first, then higher: document order would be the other way round. + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 400], [150, 400]]})).unwrap(); + s.run("draw.stroke", &json!({"page": 0, "points": [[100, 90], [150, 90]]})).unwrap(); + let r = s.run("draw.replay", &json!({})).unwrap(); + assert!(s.view.draw.replay); + let orders: Vec = r["strokes"].as_array().unwrap().iter().map(|k| k["order"].as_u64().unwrap()).collect(); + assert_eq!(orders, vec![1, 2]); + let ys: Vec = super::strokes_in_drawing_order(&mut s).iter().map(|(_, k)| k.rect.y).collect(); + assert!(ys[0] > ys[1], "the lower stroke was drawn first: {ys:?}"); + assert_eq!(s.run("draw.replay", &json!({})).unwrap()["replaying"], false); + // Nothing to replay without ink. + assert!(session().run("draw.replay", &json!({})).is_err()); + } + + #[test] + fn add_pen_validates_and_saved_galleries_drop_junk() { + let mut s = session(); + let r = s.run("draw.addPen", &json!({"kind": "highlighter", "color": "00B050", "width": 1e9})).unwrap(); + assert_eq!(r["pen"], json!({"kind": "highlighter", "color": "00B050", "width": super::MAX_WIDTH})); + assert_eq!(s.view.draw.mode, super::DrawMode::Pen(InkTool::Highlighter), "the new pen is picked up"); + for bad in [json!({"kind": "crayon"}), json!({"color": "blue"}), json!(5), json!(null)] { + assert!(s.run("draw.addPen", &bad).is_err(), "{bad}"); + } + for _ in 0..super::MAX_PENS + 3 { + let _ = s.run("draw.addPen", &json!({})); + } + assert_eq!(s.view.draw.pens.len(), super::MAX_PENS); + s.run("draw.removePen", &json!({"index": 0})).unwrap(); + assert!(s.run("draw.removePen", &json!({"index": 999})).is_err()); + // A saved gallery keeps its pens and drops the junk around them. + let saved = + json!({"pens": [{"kind": "pencil", "color": "7030A0", "width": 3}, "junk", {"kind": "crayon"}, {"width": "x"}], "inkToShape": true}); + let d: super::DrawState = serde_json::from_value(saved).unwrap(); + assert_eq!(d.pens.len(), 2); + assert_eq!((d.pens[0].kind, d.pens[0].color.hex(), d.pens[0].width), (InkTool::Pencil, "7030A0".to_string(), 3.0)); + assert!(d.ink_to_shape); + let back: super::DrawState = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap(); + assert_eq!(back.pens, d.pens); + } } diff --git a/crates/engine/src/cmd/ink_shape.rs b/crates/engine/src/cmd/ink_shape.rs new file mode 100644 index 0000000..b7b3aa3 --- /dev/null +++ b/crates/engine/src/cmd/ink_shape.rs @@ -0,0 +1,325 @@ +//! Draw › Ink to Shape: a small geometric recogniser that tells whether a hand-drawn stroke is a +//! straight line, a rectangle (or square), an ellipse (or circle), a triangle or another polygon +//! of up to eight corners. Our own fit: an open stroke is a line when it stays close to the chord +//! between its ends; a closed one is an ellipse when its points sit on the ellipse inscribed in +//! its bounds (low RMS of the normalised radius), else a polygon from its corners (the closed +//! path simplified with Ramer–Douglas–Peucker, near-straight and crowded corners merged) when +//! that polygon stays close to every point. Anything else (scribbles, curves, letters) stays ink. + +use wordcraft_doc::freeform::MAX_POINTS; + +/// A shape recognised in a stroke, in the stroke's own coordinates. +#[derive(Clone, Debug, PartialEq)] +pub enum InkShape { + /// A straight line from `a` to `b`. + Line { a: [f32; 2], b: [f32; 2] }, + /// An upright rectangle: top-left and size (equal sides when drawn nearly square). + Rectangle { x: f32, y: f32, w: f32, h: f32 }, + /// An upright ellipse in this box (a circle when drawn nearly round). + Ellipse { x: f32, y: f32, w: f32, h: f32 }, + /// A closed polygon through its 3–8 corners (a triangle has three). + Polygon(Vec<[f32; 2]>), +} + +impl InkShape { + /// What it is, for messages and command results. + pub fn name(&self) -> &'static str { + match self { + InkShape::Line { .. } => "line", + InkShape::Rectangle { w, h, .. } if w == h => "square", + InkShape::Rectangle { .. } => "rectangle", + InkShape::Ellipse { w, h, .. } if w == h => "circle", + InkShape::Ellipse { .. } => "ellipse", + InkShape::Polygon(c) if c.len() == 3 => "triangle", + InkShape::Polygon(_) => "polygon", + } + } + + /// Its bounds: left, top, width, height. + pub fn bounds(&self) -> [f32; 4] { + match self { + InkShape::Rectangle { x, y, w, h } | InkShape::Ellipse { x, y, w, h } => [*x, *y, *w, *h], + InkShape::Line { a, b } => bbox(&[*a, *b]), + InkShape::Polygon(c) => bbox(c), + } + } +} + +/// Smallest stroke (its longer side, points) that is turned into a shape: a tap or a tick stays ink. +const MIN_SIZE: f32 = 6.0; +/// Most corners a polygon may have. +pub const MAX_CORNERS: usize = 8; + +/// The shape `pts` draws, if it is one. +pub fn recognise(pts: &[[f32; 2]]) -> Option { + // Finite points only, without repeats. + let mut p: Vec<[f32; 2]> = Vec::with_capacity(pts.len().min(MAX_POINTS)); + for q in pts.iter().take(MAX_POINTS).filter(|[x, y]| x.is_finite() && y.is_finite() && x.abs() < 1e6 && y.abs() < 1e6) { + if p.last() != Some(q) { + p.push(*q); + } + } + let (first, last) = (*p.first()?, *p.last()?); + if p.len() < 2 { + return None; + } + let [x0, y0, w, h] = bbox(&p); + let size = w.max(h); + let diag = w.hypot(h); + if size < MIN_SIZE { + return None; + } + let length: f32 = p.windows(2).map(|s| s.first().zip(s.get(1)).map_or(0.0, |(a, b)| dist(*a, *b))).sum(); + let gap = dist(first, last); + // A line: the stroke barely leaves the chord between its ends and doesn't double back. + if gap > 0.85 * length && gap >= MIN_SIZE { + let off = p.iter().map(|q| seg_distance(first, last, *q)).fold(0.0, f32::max); + if off <= (0.06 * gap).max(1.5) { + return Some(InkShape::Line { a: first, b: last }); + } + } + // Other shapes are closed: the stroke ends near where it began and goes around. + if gap > 0.2 * size || length < 2.0 * size { + return None; + } + // Ellipse: the points sit on the ellipse inscribed in their bounds. + let ellipse_rms = ellipse_rms(&p, x0, y0, w, h); + if w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.06 { + let (x, y, w, h) = snap_square(x0, y0, w, h); + return Some(InkShape::Ellipse { x, y, w, h }); + } + // Polygon: the corners of the closed path, if they describe it. + let corners = corners(&p, diag); + let fits = corners.len() >= 3 && p.iter().all(|q| polygon_distance(&corners, *q) <= 0.08 * diag); + if fits && corners.len() == 4 && upright(&corners) { + let [x, y, w, h] = bbox(&corners); + let (x, y, w, h) = snap_square(x, y, w, h); + return Some(InkShape::Rectangle { x, y, w, h }); + } + if fits && corners.len() <= MAX_CORNERS { + return Some(InkShape::Polygon(corners)); + } + // A wobbly round stroke is still an ellipse. + (w > 0.2 * size && h > 0.2 * size && ellipse_rms < 0.1).then(|| { + let (x, y, w, h) = snap_square(x0, y0, w, h); + InkShape::Ellipse { x, y, w, h } + }) +} + +/// Left, top, width and height of `pts` (zero for none). +fn bbox(pts: &[[f32; 2]]) -> [f32; 4] { + let Some(f) = pts.first() else { return [0.0; 4] }; + let (mut x0, mut y0, mut x1, mut y1) = (f[0], f[1], f[0], f[1]); + for [x, y] in pts { + (x0, y0, x1, y1) = (x0.min(*x), y0.min(*y), x1.max(*x), y1.max(*y)); + } + [x0, y0, x1 - x0, y1 - y0] +} + +fn dist(a: [f32; 2], b: [f32; 2]) -> f32 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} + +/// Distance from `p` to the segment `a`–`b`. +fn seg_distance(a: [f32; 2], b: [f32; 2], p: [f32; 2]) -> f32 { + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + let len2 = dx * dx + dy * dy; + let t = if len2 > 0.0 { (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2).clamp(0.0, 1.0) } else { 0.0 }; + dist([a[0] + t * dx, a[1] + t * dy], p) +} + +/// Distance from `p` to the closed polygon through `c`. +fn polygon_distance(c: &[[f32; 2]], p: [f32; 2]) -> f32 { + let n = c.len(); + (0..n).filter_map(|i| Some(seg_distance(*c.get(i)?, *c.get((i + 1) % n)?, p))).fold(f32::INFINITY, f32::min) +} + +/// How far the points stray from the ellipse inscribed in the `w` × `h` box at (`x0`, `y0`): the +/// RMS of each point's normalised radius minus one (0 on the ellipse). +fn ellipse_rms(p: &[[f32; 2]], x0: f32, y0: f32, w: f32, h: f32) -> f32 { + let (rx, ry) = (w / 2.0, h / 2.0); + if rx <= 0.0 || ry <= 0.0 || p.is_empty() { + return f32::INFINITY; + } + let (cx, cy) = (x0 + rx, y0 + ry); + let sum: f32 = p.iter().map(|[x, y]| (((x - cx) / rx).hypot((y - cy) / ry) - 1.0).powi(2)).sum(); + (sum / p.len() as f32).sqrt() +} + +/// A box drawn nearly square made square (same centre). +fn snap_square(x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) { + if (w - h).abs() <= 0.12 * w.max(h) { + let s = (w + h) / 2.0; + (x + (w - s) / 2.0, y + (h - s) / 2.0, s, s) + } else { + (x, y, w, h) + } +} + +/// The corners of the closed path through `p`: simplified with Ramer–Douglas–Peucker (split +/// at the point farthest from the start), then corners that barely turn or crowd a neighbour +/// merged away. +fn corners(p: &[[f32; 2]], diag: f32) -> Vec<[f32; 2]> { + let eps = 0.05 * diag; + let Some(&start) = p.first() else { return Vec::new() }; + let far = p.iter().enumerate().fold((0, 0.0), |best, (i, q)| if dist(start, *q) > best.1 { (i, dist(start, *q)) } else { best }).0; + let (a, b) = p.split_at(far.min(p.len())); + let mut ring: Vec<[f32; 2]> = rdp(&[a, b.get(..1).unwrap_or(&[])].concat(), eps); + let mut back = rdp(&[b, &[start]].concat(), eps); + // The halves share the far point and the start. + ring.pop(); + back.pop(); + ring.extend(back); + // Merge until every corner turns sharply and none sits on top of the next. + while ring.len() > 3 { + let n = ring.len(); + let turn = |i: usize| -> f32 { + let (Some(a), Some(b), Some(c)) = (ring.get((i + n - 1) % n), ring.get(i), ring.get((i + 1) % n)) else { return 0.0 }; + turning(*a, *b, *c) + }; + let (i, t) = (0..n).map(|i| (i, turn(i))).fold((0, f32::INFINITY), |m, x| if x.1 < m.1 { x } else { m }); + let short = (0..n).find(|&j| ring.get(j).zip(ring.get((j + 1) % n)).is_some_and(|(a, b)| dist(*a, *b) < 0.1 * diag)); + if t < 30.0 { + ring.remove(i); + } else if let Some(j) = short { + // Of the two crowded corners keep the sharper. + let k = if turn(j) < turn((j + 1) % n) { j } else { (j + 1) % n }; + ring.remove(k); + } else { + break; + } + } + ring +} + +/// How far the path turns at `b` coming from `a` and going on to `c`, degrees (0 = straight on). +fn turning(a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> f32 { + let (u, v) = ([b[0] - a[0], b[1] - a[1]], [c[0] - b[0], c[1] - b[1]]); + let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1])); + if lu <= 0.0 || lv <= 0.0 { + return 0.0; + } + ((u[0] * v[0] + u[1] * v[1]) / (lu * lv)).clamp(-1.0, 1.0).acos().to_degrees() +} + +/// Ramer–Douglas–Peucker simplification of an open path (iterative: no deep recursion on long +/// strokes); keeps both ends. +fn rdp(p: &[[f32; 2]], eps: f32) -> Vec<[f32; 2]> { + let n = p.len(); + if n < 3 { + return p.to_vec(); + } + let mut keep = vec![false; n]; + if let Some(f) = keep.first_mut() { + *f = true; + } + if let Some(l) = keep.last_mut() { + *l = true; + } + let mut stack = vec![(0usize, n - 1)]; + while let Some((a, b)) = stack.pop() { + let (Some(&pa), Some(&pb)) = (p.get(a), p.get(b)) else { continue }; + let far = (a + 1..b).filter_map(|i| Some((i, seg_distance(pa, pb, *p.get(i)?)))).fold((a, 0.0), |m, x| if x.1 > m.1 { x } else { m }); + if far.1 > eps + && let Some(k) = keep.get_mut(far.0) + { + *k = true; + stack.push((a, far.0)); + stack.push((far.0, b)); + } + } + p.iter().zip(keep).filter(|(_, k)| *k).map(|(q, _)| *q).collect() +} + +/// A quadrilateral whose sides run (nearly) across and down the page, alternately. +fn upright(c: &[[f32; 2]]) -> bool { + let n = c.len(); + let dirs: Vec> = (0..n) + .map(|i| { + let (a, b) = (c.get(i)?, c.get((i + 1) % n)?); + let deg = (b[1] - a[1]).atan2(b[0] - a[0]).to_degrees().rem_euclid(180.0); + // true: across, false: down; None when it's slanted. + if deg <= 15.0 || deg >= 165.0 { + Some(true) + } else if (75.0..=105.0).contains(°) { + Some(false) + } else { + None + } + }) + .collect(); + dirs.iter().all(Option::is_some) && (0..n).all(|i| dirs.get(i) != dirs.get((i + 1) % n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Points along a closed polygon, `per` to a side, with a little deterministic hand wobble. + fn around(corners: &[[f32; 2]], per: usize) -> Vec<[f32; 2]> { + let mut out = Vec::new(); + for (i, a) in corners.iter().enumerate() { + let b = corners[(i + 1) % corners.len()]; + for k in 0..per { + let t = k as f32 / per as f32; + let wob = (out.len() as f32 * 1.7).sin() * 0.6; + out.push([a[0] + (b[0] - a[0]) * t + wob, a[1] + (b[1] - a[1]) * t - wob]); + } + } + out.push(corners[0]); + out + } + + #[test] + fn rectangles_ellipses_lines_and_triangles_are_recognised() { + // A rectangle, started mid-side as people often do. + let rect = around(&[[50.0, 10.0], [110.0, 10.0], [110.0, 70.0 + 0.0], [10.0, 70.0], [10.0, 10.0]], 12); + let Some(InkShape::Rectangle { x, y, w, h }) = recognise(&rect) else { panic!("{:?}", recognise(&rect)) }; + assert!((x - 10.0).abs() < 2.0 && (y - 10.0).abs() < 2.0 && (w - 100.0).abs() < 3.0 && (h - 60.0).abs() < 3.0, "{x} {y} {w} {h}"); + // Nearly square: a square. + assert_eq!(recognise(&around(&[[0.0, 0.0], [60.0, 0.0], [60.0, 57.0], [0.0, 57.0]], 10)).map(|s| s.name()), Some("square")); + // An ellipse and a circle. + let ring = |rx: f32, ry: f32| { + (0..=64).map(|i| (i as f32 / 64.0) * std::f32::consts::TAU).map(|a| [100.0 + rx * a.cos(), 80.0 + ry * a.sin()]).collect::>() + }; + let Some(InkShape::Ellipse { x, w, h, .. }) = recognise(&ring(40.0, 25.0)) else { panic!() }; + assert!((x - 60.0).abs() < 0.5 && (w - 80.0).abs() < 0.5 && (h - 50.0).abs() < 0.5); + assert_eq!(recognise(&ring(30.0, 29.0)).map(|s| s.name()), Some("circle")); + // A slightly shaky line keeps its ends. + let line: Vec<[f32; 2]> = (0..=40).map(|i| i as f32).map(|t| [10.0 + t * 3.0, 20.0 + t + (t * 2.1).sin() * 0.8]).collect(); + assert_eq!(recognise(&line), Some(InkShape::Line { a: line[0], b: line[40] })); + // A triangle and a pentagon. + let tri = around(&[[0.0, 80.0], [45.0, 0.0], [90.0, 80.0]], 15); + let Some(InkShape::Polygon(c)) = recognise(&tri) else { panic!("{:?}", recognise(&tri)) }; + assert_eq!(c.len(), 3); + let penta: Vec<[f32; 2]> = (0..5) + .map(|i| (i as f32 / 5.0) * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2) + .map(|a| [60.0 + 50.0 * a.cos(), 60.0 + 50.0 * a.sin()]) + .collect(); + assert_eq!(recognise(&around(&penta, 12)).map(|s| s.name()), Some("polygon")); + } + + #[test] + fn scribbles_ticks_and_hostile_points_stay_ink() { + // An open zigzag, a hook, a tap and a tiny tick. + let zigzag: Vec<[f32; 2]> = (0..20).map(|i| [i as f32 * 8.0, if i % 2 == 0 { 0.0 } else { 30.0 }]).collect(); + assert_eq!(recognise(&zigzag), None); + assert_eq!(recognise(&[[0.0, 0.0], [50.0, 0.0], [50.0, 50.0]]), None); + assert_eq!(recognise(&[[5.0, 5.0]]), None); + assert_eq!(recognise(&[[0.0, 0.0], [2.0, 3.0]]), None); + // A closed scribble with many spikes: not a polygon of eight or fewer corners, not round. + let star: Vec<[f32; 2]> = (0..=24) + .map(|i| { + let a = i as f32 / 24.0 * std::f32::consts::TAU; + let r = if i % 2 == 0 { 60.0 } else { 20.0 }; + [80.0 + r * a.cos(), 80.0 + r * a.sin()] + }) + .collect(); + assert_eq!(recognise(&star), None); + // NaN, infinities and a flood of points never panic. + let mut junk = vec![[f32::NAN, 1.0], [f32::INFINITY, 2.0], [3.0, f32::NEG_INFINITY]]; + junk.extend(std::iter::repeat_n([1.0, 1.0], MAX_POINTS * 3)); + assert_eq!(recognise(&junk), None); + assert_eq!(recognise(&[]), None); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 758787d..fc77351 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -12,6 +12,7 @@ pub mod fields; pub mod file; pub mod fmt_revisions; pub mod format; +pub mod ink_shape; pub mod insert; pub mod inspector; pub mod lists; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2a1c288..71ec3da 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -50,6 +50,8 @@ pub struct CanvasState { pub(crate) ink: Option, /// The eraser is down: whether it has erased anything in this drag yet. pub(crate) erasing: Option, + /// The lasso being drawn or dragged, and Ink Replay (Draw tab). + pub(crate) ink_ui: crate::ink::InkUi, /// Wheel/touchpad scrolling (smooth notches, touchpad momentum). pub(crate) wheel: crate::scroll::CanvasScroll, /// The scroll offset and its maximum at the end of last frame. @@ -111,6 +113,7 @@ impl Default for CanvasState { obj_drag: None, ink: None, erasing: None, + ink_ui: Default::default(), context_menu_open: false, mini_anchor: None, pasted: false, @@ -266,7 +269,7 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); scale_px.to_bits().hash(&mut h); let v = &app.session.view; - (v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink).hash(&mut h); + (v.marks, v.show_markup, v.dark_mode, wordcraft_render::DARK_PAPER, v.hide_ink, v.draw.replay).hash(&mut h); app.session.prefs.markup.hash(&mut h); dim_body.hash(&mut h); format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h); @@ -350,6 +353,7 @@ fn drawing_grid_step(spacing: f32, scale: f32) -> f32 { pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + crate::ink::replay_tick(app, ui.ctx()); let layout = app.session.layout(); // View › Switch Modes: dark paper, inverted document colours, a white caret. let dark_page = app.session.view.dark_mode; @@ -460,7 +464,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { opts.display.marks = app.session.view.marks; opts.display.placeholders = true; opts.display.markup = app.session.view.show_markup; - opts.display.hide_ink = app.session.view.hide_ink; + // Ink Replay draws the ink itself, stroke by stroke, over pages without it. + opts.display.hide_ink = app.session.view.hide_ink || app.session.view.draw.replay; opts.display.revisions = app.session.prefs.markup.clone(); opts.dark = dark_page; opts.dark_paper = dark_paper; @@ -675,6 +680,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { (resp, rects) }); app.canvas.scroll_offset = out.state.offset; + crate::ink::replay_bar(app, ui, out.inner_rect); app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); let (resp, rects) = out.inner; // A click on the page leaves the selected comment balloon. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 53daa14..66ecb82 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1469,3 +1469,16 @@ Continuous numbering Numeración continua Apply Aplicar Convert Notes Convertir notas + Lasso Select Selección con lazo + Move Selected Ink Mover la tinta seleccionada + Delete Selected Ink Eliminar la tinta seleccionada + Recolor Selected Ink Cambiar el color de la tinta seleccionada + Add Pen Agregar pluma + Remove Pen Quitar pluma + Ink to Shape Tinta a forma + Convert to Shapes Convertir en formas + Ink Replay Reproducir tinta + Add\nPen Agregar\npluma + Rewind Rebobinar + Forward Avanzar + Stroke {n} of {total} Trazo {n} de {total} diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 75fa642..da627a4 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1463,3 +1463,16 @@ Continuous numbering Pidev nummerdus Apply Rakenda Convert Notes Teisenda märkused + Lasso Select Lassoga valimine + Move Selected Ink Teisalda valitud tint + Delete Selected Ink Kustuta valitud tint + Recolor Selected Ink Muuda valitud tindi värvi + Add Pen Lisa pliiats + Remove Pen Eemalda pliiats + Ink to Shape Käsikiri kujundiks + Convert to Shapes Teisenda kujunditeks + Ink Replay Esita käsikiri + Add\nPen Lisa\npliiats + Rewind Keri algusesse + Forward Edasi + Stroke {n} of {total} Joon {n}/{total} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f598e09..5b7be25 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1453,3 +1453,16 @@ Continuous numbering 通し番号 Apply 適用 Convert Notes 注の変換 + Lasso Select なげなわ選択 + Move Selected Ink 選択したインクを移動 + Delete Selected Ink 選択したインクを削除 + Recolor Selected Ink 選択したインクの色を変更 + Add Pen ペンの追加 + Remove Pen ペンの削除 + Ink to Shape インクを図形に変換 + Convert to Shapes 図形に変換 + Ink Replay インクの再生 + Add\nPen ペンの\n追加 + Rewind 巻き戻し + Forward 早送り + Stroke {n} of {total} ストローク {n}/{total} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index df8b9b8..e4fbc88 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1465,3 +1465,16 @@ Continuous numbering Numeração contínua Apply Aplicar Convert Notes Converter notas + Lasso Select Seleção com laço + Move Selected Ink Mover a tinta selecionada + Delete Selected Ink Excluir a tinta selecionada + Recolor Selected Ink Mudar a cor da tinta selecionada + Add Pen Adicionar caneta + Remove Pen Remover caneta + Ink to Shape Tinta para forma + Convert to Shapes Converter em formas + Ink Replay Reproduzir tinta + Add\nPen Adicionar\ncaneta + Rewind Retroceder + Forward Avançar + Stroke {n} of {total} Traço {n} de {total} diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 786a925..3d430c6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1803,6 +1803,19 @@ preview Title Пример Right-to-Left Section Раздел справа налево Table direction: Направление таблицы: Section direction: Направление раздела: + Lasso Select Лассо + Move Selected Ink Переместить выделенные штрихи + Delete Selected Ink Удалить выделенные штрихи + Recolor Selected Ink Перекрасить выделенные штрихи + Add Pen Добавить перо + Remove Pen Удалить перо + Ink to Shape Рукописный ввод в фигуры + Convert to Shapes Преобразовать в фигуры + Ink Replay Воспроизведение рукописного ввода + Add\nPen Добавить\nперо + Rewind Назад + Forward Вперёд + Stroke {n} of {total} Штрих {n} из {total} Drawing Canvas Полотно для рисования New Drawing Canvas Новое полотно для рисования Fit По размеру diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index d00d9cc..ef47e71 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1463,3 +1463,16 @@ Continuous numbering Neprekidna numeracija Apply Primeni Convert Notes Pretvori napomene + Lasso Select Izbor lasom + Move Selected Ink Premesti izabrani rukopis + Delete Selected Ink Obriši izabrani rukopis + Recolor Selected Ink Promeni boju izabranog rukopisa + Add Pen Dodaj pero + Remove Pen Ukloni pero + Ink to Shape Rukopis u oblik + Convert to Shapes Pretvori u oblike + Ink Replay Rukopis repriza + Add\nPen Dodaj\npero + Rewind Na početak + Forward Napred + Stroke {n} of {total} Potez {n} od {total} diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 4541e8f..84270af 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1467,3 +1467,16 @@ Continuous numbering Непрекидна нумерација Apply Примени Convert Notes Претвори напомене + Lasso Select Избор ласом + Move Selected Ink Премести изабрани рукопис + Delete Selected Ink Обриши изабрани рукопис + Recolor Selected Ink Промени боју изабраног рукописа + Add Pen Додај перо + Remove Pen Уклони перо + Ink to Shape Рукопис у облик + Convert to Shapes Претвори у облике + Ink Replay Рукопис реприза + Add\nPen Додај\nперо + Rewind На почетак + Forward Напред + Stroke {n} of {total} Потез {n} од {total} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e62c0c6..4f80145 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1421,6 +1421,19 @@ Right-to-Left Section Sağdan Sola Bölüm Table direction: Tablo yönü: Section direction: Bölüm yönü: + Lasso Select Kement Seçimi + Move Selected Ink Seçili Mürekkebi Taşı + Delete Selected Ink Seçili Mürekkebi Sil + Recolor Selected Ink Seçili Mürekkebi Yeniden Renklendir + Add Pen Kalem Ekle + Remove Pen Kalemi Kaldır + Ink to Shape Mürekkepten Şekle + Convert to Shapes Şekillere Dönüştür + Ink Replay Mürekkep Yeniden Oynatma + Add\nPen Kalem\nEkle + Rewind Geri Sar + Forward İleri + Stroke {n} of {total} Vuruş {n} / {total} Drawing Canvas Çizim Tuvali New Drawing Canvas Yeni Çizim Tuvali Fit Sığdır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6c9244d..909d87a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1465,3 +1465,16 @@ Continuous numbering Наскрізна нумерація Apply Застосувати Convert Notes Перетворити примітки + Lasso Select Виділення ласо + Move Selected Ink Перемістити виділений рукопис + Delete Selected Ink Видалити виділений рукопис + Recolor Selected Ink Змінити колір виділеного рукопису + Add Pen Додати перо + Remove Pen Вилучити перо + Ink to Shape Рукопис у фігуру + Convert to Shapes Перетворити на фігури + Ink Replay Відтворити рукопис + Add\nPen Додати\nперо + Rewind На початок + Forward Вперед + Stroke {n} of {total} Штрих {n} з {total} diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 6141f47..2465337 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1465,3 +1465,16 @@ Continuous numbering 连续编号 Apply 应用 Convert Notes 转换注释 + Lasso Select 套索选择 + Move Selected Ink 移动所选墨迹 + Delete Selected Ink 删除所选墨迹 + Recolor Selected Ink 更改所选墨迹颜色 + Add Pen 添加笔 + Remove Pen 删除笔 + Ink to Shape 墨迹转形状 + Convert to Shapes 转换为形状 + Ink Replay 墨迹重播 + Add\nPen 添加笔 + Rewind 倒回 + Forward 前进 + Stroke {n} of {total} 第 {n} 笔,共 {total} 笔 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 176b8c2..0f564e3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1454,3 +1454,16 @@ Continuous numbering 連續編號 Apply 套用 Convert Notes 轉換附註 + Lasso Select 套索選取 + Move Selected Ink 移動選取的筆跡 + Delete Selected Ink 刪除選取的筆跡 + Recolor Selected Ink 變更選取筆跡的色彩 + Add Pen 新增畫筆 + Remove Pen 移除畫筆 + Ink to Shape 筆跡轉圖形 + Convert to Shapes 轉換為圖案 + Ink Replay 筆跡重播 + Add\nPen 新增畫筆 + Rewind 倒轉 + Forward 快轉 + Stroke {n} of {total} 第 {n} 筆,共 {total} 筆 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index c8fa350..8bbeca1 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -498,6 +498,27 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.circle(10.0, 10.0, 6.5, c); pen.fill(&[(8.5, 7.0), (8.5, 13.0), (13.0, 10.0)], a); } + // Add Pen: a pen with a plus. + "addPen" => { + pen.line(&[(3.0, 17.0), (11.0, 9.0), (13.0, 11.0), (5.0, 19.0), (2.5, 19.5), (3.0, 17.0)]); + pen.fill(&[(11.0, 9.0), (12.5, 7.5), (14.5, 9.5), (13.0, 11.0)], a); + pen.line_c(&[(15.0, 2.0), (15.0, 8.0)], a); + pen.line_c(&[(12.0, 5.0), (18.0, 5.0)], a); + } + // Ink Replay's controls. + "replayRewind" => { + pen.frect(4.0, 5.0, 5.5, 15.0, c); + pen.fill(&[(16.0, 5.0), (16.0, 15.0), (6.5, 10.0)], c); + } + "replayPlay" => pen.fill(&[(6.0, 4.0), (6.0, 16.0), (16.0, 10.0)], a), + "replayPause" => { + pen.frect(5.5, 4.5, 8.5, 15.5, a); + pen.frect(11.5, 4.5, 14.5, 15.5, a); + } + "replayForward" => { + pen.fill(&[(4.0, 5.0), (4.0, 15.0), (13.5, 10.0)], c); + pen.frect(14.5, 5.0, 16.0, 15.0, c); + } // Design "themes" => { pen.frect(3.0, 3.0, 10.0, 10.0, a); diff --git a/crates/ui-egui/src/ink.rs b/crates/ui-egui/src/ink.rs index d3ca7c6..a1b2273 100644 --- a/crates/ui-egui/src/ink.rs +++ b/crates/ui-egui/src/ink.rs @@ -1,9 +1,14 @@ -//! Drawing on the page with the Draw tab's pens and eraser. While a pen is in use, a drag draws a -//! live stroke and its release commits it with `draw.stroke` (one command, one undo step); with -//! the eraser, a click or drag over a stroke deletes it with `draw.erase` (one undo step per -//! drag). Select (or Esc) goes back to ordinary selection. +//! Drawing on the page with the Draw tab's pens, eraser and lasso, and Ink Replay. While a pen +//! is in use, a drag draws a live stroke and its release commits it with `draw.stroke` (one +//! command, one undo step); with the eraser, a click or drag over a stroke deletes it with +//! `draw.erase` (one undo step per drag). With the lasso, a drag draws a loop whose release +//! selects the ink inside (`draw.lasso`); dragging the selection moves it (`draw.lassoMove`), +//! Delete deletes it and Esc lets it go. Select (or Esc) goes back to ordinary selection. +//! +//! Ink Replay (`draw.replay`) hides the ink on the pages and draws the strokes again here, one +//! after another in the order they were drawn, with a bar of controls at the bottom. -use egui::{Color32, Painter, Pos2, Rect, Stroke, Ui}; +use egui::{Align2, Color32, Key, Painter, Pos2, Rect, Stroke, Ui, pos2, vec2}; use serde_json::json; use wordcraft_doc::freeform::{InkTool, MAX_POINTS}; use wordcraft_engine::cmd::draw::DrawMode; @@ -19,13 +24,55 @@ pub struct InkDrag { pts: Vec<[f32; 2]>, } +/// The lasso in use: a loop being drawn, or the selection being dragged (page points). +pub enum LassoDrag { + Loop { page: usize, pts: Vec<[f32; 2]> }, + Move { page: usize, from: [f32; 2], to: [f32; 2] }, +} + +/// Ink Replay in progress: the strokes in drawing order and how far it has got. +pub struct Replay { + strokes: Vec, + /// Seconds into the replay. + t: f32, + playing: bool, +} + +struct ReplayStroke { + page: usize, + paths: Vec>, + color: Color32, + width: f32, + /// When it starts being drawn and how long it takes, seconds. + start: f32, + dur: f32, +} + +/// The Draw tab's pointer state beyond the pens (kept on the canvas). +#[derive(Default)] +pub struct InkUi { + pub(crate) lasso: Option, + pub(crate) replay: Option, +} + /// Smallest move that adds a point to a live stroke, screen points. const MIN_STEP_PX: f32 = 1.5; +/// How fast replayed ink is drawn, page points per second, and the pause between strokes. +const REPLAY_SPEED: f32 = 400.0; +const REPLAY_GAP: f32 = 0.12; +/// How close to the lasso selection's frame a press still grabs it, page points. +const GRAB_SLOP: f32 = 4.0; -/// Handle the pointer while a pen or the eraser is in use; `true` when it did (no text -/// selection or object dragging then). +/// Handle the pointer while a pen, the eraser or the lasso is in use; `true` when it did (no +/// text selection or object dragging then). pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) -> bool { let mode = app.session.view.draw.mode; + if mode != DrawMode::Lasso { + app.canvas.ink_ui.lasso = None; + if app.session.view.draw.lasso.is_some() { + let _ = app.run("draw.lasso", json!({"clear": true})); + } + } if mode == DrawMode::Select { app.canvas.ink = None; app.canvas.erasing = None; @@ -73,27 +120,112 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect] app.canvas.erasing = None; } } + DrawMode::Lasso => lasso_pointer(app, (starts, down, released, at), rects, layout, scale), DrawMode::Select => {} } true } +fn lasso_pointer(app: &mut WordApp, (starts, down, released, at): (bool, bool, bool, Option), rects: &[Rect], layout: &DocLayout, scale: f32) { + if starts && let Some((page, x, y)) = at.and_then(|p| page_at(rects, layout, scale, p)) { + // A press on the selection drags it; anywhere else starts a new loop. + let grab = wordcraft_engine::cmd::draw::lasso_bounds(&app.session, layout).is_some_and(|(bp, r)| { + bp == page && x >= r.x - GRAB_SLOP && x <= r.right() + GRAB_SLOP && y >= r.y - GRAB_SLOP && y <= r.bottom() + GRAB_SLOP + }); + app.canvas.ink_ui.lasso = + Some(if grab { LassoDrag::Move { page, from: [x, y], to: [x, y] } } else { LassoDrag::Loop { page, pts: vec![[x, y]] } }); + } else if down + && let Some(p) = at + && let Some(d) = app.canvas.ink_ui.lasso.as_mut() + { + match d { + LassoDrag::Loop { page, pts } => { + if let Some((x, y)) = on_page(rects, layout, scale, *page, p) { + let far = pts.last().is_none_or(|[lx, ly]| ((x - lx).powi(2) + (y - ly).powi(2)).sqrt() * scale >= MIN_STEP_PX * 2.0); + if far && pts.len() < MAX_POINTS { + pts.push([x, y]); + } + } + } + LassoDrag::Move { page, to, .. } => { + if let Some((x, y)) = on_page(rects, layout, scale, *page, p) { + *to = [x, y]; + } + } + } + } + if released && let Some(d) = app.canvas.ink_ui.lasso.take() { + match d { + LassoDrag::Loop { page, pts } if pts.len() >= 3 => { + let _ = app.run("draw.lasso", json!({"page": page, "points": pts})); + } + // A click without a loop lets the selection go. + LassoDrag::Loop { .. } => { + let _ = app.run("draw.lasso", json!({"clear": true})); + } + LassoDrag::Move { from, to, .. } => { + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + if dx.abs() + dy.abs() > 0.25 { + let _ = app.run("draw.lassoMove", json!({"dx": dx, "dy": dy})); + } + } + } + } +} + +/// Keys the lasso and Ink Replay take: Delete deletes the lasso selection, Esc lets it go or +/// stops the replay. `true` when the key was used. +pub fn key(app: &mut WordApp, key: Key) -> bool { + if key == Key::Escape && app.session.view.draw.replay { + stop_replay(app); + return true; + } + let selected = wordcraft_engine::cmd::draw::lasso_selection(&app.session).is_some(); + match key { + Key::Delete | Key::Backspace if selected => { + let _ = app.run("draw.lassoDelete", json!({})); + true + } + Key::Escape if selected => { + let _ = app.run("draw.lasso", json!({"clear": true})); + true + } + _ => false, + } +} + /// Page point under screen point `p` on `page` (outside the page too, clamped by the command). fn on_page(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize, p: Pos2) -> Option<(f32, f32)> { let r = rects.get(page)?; - let s = layout.pages.get(page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); - (s > 0.0).then(|| ((p.x - r.min.x) / s, (p.y - r.min.y) / s)) + let s = page_scale(rects, layout, scale, page)?; + Some(((p.x - r.min.x) / s, (p.y - r.min.y) / s)) } -/// The live stroke, drawn over the page until it is committed. +/// Screen points per page point on `page`. +fn page_scale(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option { + let r = rects.get(page)?; + let s = layout.pages.get(page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); + (s > 0.0).then_some(s) +} + +/// Page points to screen points on `page`. +fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option Pos2> { + let r = *rects.get(page)?; + let s = page_scale(rects, layout, scale, page)?; + Some(move |[x, y]: [f32; 2]| pos2(r.min.x + x * s, r.min.y + y * s)) +} + +/// The live stroke, the lasso and its selection, and the replayed ink, drawn over the pages. pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + paint_replay(app, painter, rects, layout, scale); + paint_lasso(app, painter, rects, layout, scale); let Some(d) = &app.canvas.ink else { return }; - let Some(r) = rects.get(d.page) else { return }; - let s = layout.pages.get(d.page).map_or(scale, |pg| crate::canvas::page_screen_scale(*r, pg, scale)); + let Some(map) = to_screen(rects, layout, scale, d.page) else { return }; + let s = page_scale(rects, layout, scale, d.page).unwrap_or(scale); let set = app.session.view.draw.settings(d.tool); let alpha = (d.tool.alpha() * 255.0).round() as u8; let color = Color32::from_rgba_unmultiplied(set.color.0, set.color.1, set.color.2, alpha); - let pts: Vec = d.pts.iter().map(|[x, y]| egui::pos2(r.min.x + x * s, r.min.y + y * s)).collect(); + let pts: Vec = d.pts.iter().map(|p| map(*p)).collect(); let width = (set.width * s).max(1.0); if let [only] = pts.as_slice() { painter.circle_filled(*only, width / 2.0, color); @@ -102,10 +234,197 @@ pub fn paint(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayou } } -/// The pointer over the page while a pen or the eraser is in use. +fn paint_lasso(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + let t = crate::theme::Tokens::get(painter.ctx()); + let dash = |pts: &[Pos2]| egui::Shape::dashed_line(pts, Stroke::new(1.0, t.accent), 4.0, 3.0); + if let Some(LassoDrag::Loop { page, pts }) = &app.canvas.ink_ui.lasso + && let Some(map) = to_screen(rects, layout, scale, *page) + { + let mut loop_pts: Vec = pts.iter().map(|p| map(*p)).collect(); + if let Some(first) = loop_pts.first().copied() { + loop_pts.push(first); + } + painter.extend(dash(&loop_pts)); + } + let Some((page, r)) = wordcraft_engine::cmd::draw::lasso_bounds(&app.session, layout) else { return }; + let (dx, dy) = match &app.canvas.ink_ui.lasso { + Some(LassoDrag::Move { from, to, .. }) => (to[0] - from[0], to[1] - from[1]), + _ => (0.0, 0.0), + }; + let Some(map) = to_screen(rects, layout, scale, page) else { return }; + let (a, b) = (map([r.x + dx, r.y + dy]), map([r.right() + dx, r.bottom() + dy])); + let sr = Rect::from_min_max(a, b).expand(3.0); + painter.extend(dash(&[sr.left_top(), sr.right_top(), sr.right_bottom(), sr.left_bottom(), sr.left_top()])); +} + +/// The pointer over the page while a pen, the eraser or the lasso is in use. pub fn cursor(app: &WordApp) -> Option { match app.session.view.draw.mode { DrawMode::Select => None, - DrawMode::Eraser | DrawMode::Pen(_) => Some(egui::CursorIcon::Crosshair), + DrawMode::Lasso if matches!(app.canvas.ink_ui.lasso, Some(LassoDrag::Move { .. })) => Some(egui::CursorIcon::Grabbing), + DrawMode::Eraser | DrawMode::Pen(_) | DrawMode::Lasso => Some(egui::CursorIcon::Crosshair), } } + +// --- Ink Replay ---------------------------------------------------------------------------- + +/// Start, advance or end Ink Replay with the session's `draw.replay` switch; call once a frame +/// before the pages are drawn. +pub fn replay_tick(app: &mut WordApp, ctx: &egui::Context) { + if !app.session.view.draw.replay { + app.canvas.ink_ui.replay = None; + return; + } + if app.canvas.ink_ui.replay.is_none() { + let mut start = 0.0; + let strokes: Vec = wordcraft_engine::cmd::draw::strokes_in_drawing_order(&mut app.session) + .into_iter() + .map(|(page, k)| { + let paths = k.paths(); + let len: f32 = paths.iter().map(|p| path_len(p)).sum(); + let dur = (len / REPLAY_SPEED).clamp(0.15, 2.5); + let alpha = (k.freeform.alpha.clamp(0.0, 1.0) * 255.0).round() as u8; + let c = k.color.unwrap_or_default(); + let width = if k.width.is_finite() { k.width.clamp(0.25, 72.0) } else { 1.0 }; + let st = ReplayStroke { page, paths, color: Color32::from_rgba_unmultiplied(c.0, c.1, c.2, alpha), width, start, dur }; + start += dur + REPLAY_GAP; + st + }) + .collect(); + if strokes.is_empty() { + stop_replay(app); + return; + } + app.canvas.ink_ui.replay = Some(Replay { strokes, t: 0.0, playing: true }); + } + let dt = ctx.input(|i| i.stable_dt).clamp(0.0, 0.1); + if let Some(r) = app.canvas.ink_ui.replay.as_mut() + && r.playing + { + r.t = (r.t + dt).min(r.end()); + if r.t >= r.end() { + r.playing = false; + } + ctx.request_repaint(); + } +} + +impl Replay { + fn end(&self) -> f32 { + self.strokes.last().map_or(0.0, |s| s.start + s.dur) + } + /// Strokes started so far. + fn started(&self) -> usize { + self.strokes.iter().filter(|s| s.start <= self.t).count() + } +} + +fn path_len(p: &[[f32; 2]]) -> f32 { + p.windows(2).filter_map(|w| Some((w.first()?, w.get(1)?))).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum() +} + +/// The replayed ink so far: finished strokes whole, the current one up to where it has got. +fn paint_replay(app: &WordApp, painter: &Painter, rects: &[Rect], layout: &DocLayout, scale: f32) { + let Some(r) = &app.canvas.ink_ui.replay else { return }; + for k in r.strokes.iter().filter(|k| k.start <= r.t) { + let Some(map) = to_screen(rects, layout, scale, k.page) else { continue }; + let s = page_scale(rects, layout, scale, k.page).unwrap_or(scale); + let frac = if k.dur > 0.0 { ((r.t - k.start) / k.dur).clamp(0.0, 1.0) } else { 1.0 }; + let total: f32 = k.paths.iter().map(|p| path_len(p)).sum(); + let mut left = total * frac; + let width = (k.width * s).max(1.0); + for path in &k.paths { + let mut pts: Vec = Vec::new(); + for w in path.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + if pts.is_empty() { + pts.push(map(*a)); + } + let seg = (b[0] - a[0]).hypot(b[1] - a[1]); + if seg <= left { + pts.push(map(*b)); + left -= seg; + } else { + let f = if seg > 0.0 { left / seg } else { 0.0 }; + pts.push(map([a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f])); + left = 0.0; + break; + } + } + match (pts.as_slice(), path.as_slice()) { + (_, [only]) => { + painter.circle_filled(map(*only), width / 2.0, k.color); + } + ([], _) => {} + _ => { + painter.add(egui::Shape::line(pts, Stroke::new(width, k.color))); + } + } + if left <= 0.0 { + break; + } + } + } +} + +fn stop_replay(app: &mut WordApp) { + app.canvas.ink_ui.replay = None; + let _ = app.run("draw.replay", json!({"value": false})); +} + +/// Ink Replay's controls at the bottom of the document area: Rewind, Play/Pause, Forward (to +/// the end of the stroke being drawn), how far it has got, and Close. +pub fn replay_bar(app: &mut WordApp, ui: &Ui, area: Rect) { + let Some(r) = app.canvas.ink_ui.replay.as_ref() else { return }; + let (playing, n, total) = (r.playing, r.started(), r.strokes.len()); + let mut action = None; + egui::Area::new(egui::Id::new("ink_replay_bar")) + .order(egui::Order::Foreground) + .pivot(Align2::CENTER_BOTTOM) + .fixed_pos(pos2(area.center().x, area.max.y - 18.0)) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 0.0); + if crate::widgets::icon_button(ui, "replayRewind", tl!("Rewind")).clicked() { + action = Some("rewind"); + } + let (icon, tip) = if playing { ("replayPause", tl!("Pause")) } else { ("replayPlay", tl!("Play")) }; + if crate::widgets::icon_button(ui, icon, tip).clicked() { + action = Some("play"); + } + if crate::widgets::icon_button(ui, "replayForward", tl!("Forward")).clicked() { + action = Some("forward"); + } + let label = crate::i18n::fmt(tl!("Stroke {n} of {total}"), &[("n", &n.to_string()), ("total", &total.to_string())]); + ui.label(label); + if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() { + action = Some("close"); + } + }); + }); + }); + let Some(a) = action else { return }; + if a == "close" { + stop_replay(app); + return; + } + let Some(r) = app.canvas.ink_ui.replay.as_mut() else { return }; + match a { + "rewind" => r.t = 0.0, + "play" if r.playing => r.playing = false, + "play" => { + // Play again from the start once it has finished. + if r.t >= r.end() { + r.t = 0.0; + } + r.playing = true; + } + _ => { + // Forward: finish the stroke being drawn, or the next one between strokes. + let t = r.t; + r.t = r.strokes.iter().map(|s| s.start + s.dur).find(|e| *e > t + 1e-3).unwrap_or(r.end()); + } + } + ui.ctx().request_repaint(); +} diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 9afef5b..dbb444e 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -258,6 +258,10 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.session.painter = None; continue; } + // The lasso selection and Ink Replay take Delete and Escape first. + if crate::ink::key(app, key) { + continue; + } // Escape puts the pen or eraser down (back to selecting). if key == Key::Escape && app.session.view.draw.mode != wordcraft_engine::cmd::draw::DrawMode::Select { app.canvas.ink = None; diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index ce037d6..9a0f455 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -207,6 +207,7 @@ fn controls(tab: &str) -> Vec { c("Pen", "draw.pen"), c("Pencil", "draw.pencil"), c("Highlighter", "draw.highlighter"), + m("Add Pen", "draw.addPen"), c("Ink to Shape", "draw.inkToShape"), c("Ink to Math", "draw.inkToMath"), c("Drawing Canvas", "insert.canvas"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f6751bc..092de66 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -135,6 +135,10 @@ pub struct UiState { /// Custom ribbon tabs and groups, hidden tabs and the Quick Access Toolbar (Customize Ribbon, /// #379); the session owns them while running. pub ribbon: wordcraft_engine::RibbonLayout, + /// Draw › Add Pen: the pens added to the pen gallery; the session owns them while running. + /// Entries that aren't pens are dropped on reading. + #[serde(deserialize_with = "wordcraft_engine::cmd::draw::lenient_pens")] + pub ink_pens: Vec, } impl Default for UiState { @@ -162,6 +166,7 @@ impl Default for UiState { dark_page: false, keyboard: wordcraft_engine::KeyMap::default(), ribbon: wordcraft_engine::RibbonLayout::default(), + ink_pens: Vec::new(), } } } @@ -305,6 +310,7 @@ impl WordApp { ui.dark_page = self.session.view.dark_mode; ui.keyboard = self.session.keymap.clone(); ui.ribbon = self.session.ribbon.clone(); + ui.ink_pens = self.session.view.draw.pens.clone(); ui } @@ -332,6 +338,7 @@ impl WordApp { // So are ribbon and toolbar commands. self.session.ribbon = std::mem::take(&mut self.ui.ribbon); self.session.ribbon.retain_known(®); + self.session.view.draw.pens = std::mem::take(&mut self.ui.ink_pens); } /// Run a command the user asked for (ribbon, shortcut, Backstage, file drop). New, Open, diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0699914..749a1cc 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -717,29 +717,46 @@ fn insert(app: &mut WordApp, ui: &mut Ui) { fn draw(app: &mut WordApp, ui: &mut Ui) { use wordcraft_doc::freeform::InkTool; - use wordcraft_engine::cmd::draw::DrawMode; + use wordcraft_engine::cmd::draw::{DrawMode, lasso_selection}; let mode = app.session.view.draw.mode; // The pen the colour and thickness menus change: the one in use, else the pen. let tool = mode.pen().unwrap_or(InkTool::Pen); - let pen_id = |t: InkTool| match t { - InkTool::Pen => "draw.pen", - InkTool::Pencil => "draw.pencil", - InkTool::Highlighter => "draw.highlighter", - }; + let set = app.session.view.draw.settings(tool); + // The gallery pen in use, if any: changing the colour or thickness changes it too. + let custom = + app.session.view.draw.pens.iter().position(|p| mode == DrawMode::Pen(p.kind) && p.color == set.color && (p.width - set.width).abs() < 0.01); + let lasso_on = lasso_selection(&app.session).is_some(); group(ui, "Drawing Tools", None, app, |ui, app| { big_toggle(ui, app, "select", "Select", "draw.select", mode == DrawMode::Select); - big(ui, app, "lasso", "Lasso", "draw.lasso", json!({}), false); + big_toggle(ui, app, "lasso", "Lasso", "draw.lasso", mode == DrawMode::Lasso); big_toggle(ui, app, "eraser", "Eraser", "draw.eraser", mode == DrawMode::Eraser); - big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen)); - big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil)); - big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter)); - let set = app.session.view.draw.settings(tool); + big_toggle(ui, app, "pen", "Pen", "draw.pen", mode == DrawMode::Pen(InkTool::Pen) && custom.is_none()); + big_toggle(ui, app, "pencil", "Pencil", "draw.pencil", mode == DrawMode::Pen(InkTool::Pencil) && custom.is_none()); + big_toggle(ui, app, "highlight", "Highlighter", "draw.highlighter", mode == DrawMode::Pen(InkTool::Highlighter) && custom.is_none()); + // Pens added with Add Pen; right-click one to remove it. + let pens = app.session.view.draw.pens.clone(); + for (i, pen) in pens.iter().enumerate() { + gallery_pen(ui, app, i, *pen, custom == Some(i)); + } + menu_button(ui, app, "addPen", Some("Add\nPen"), "Add Pen", true, |ui, app| { + for (label, kind) in [("Pen", InkTool::Pen), ("Pencil", InkTool::Pencil), ("Highlighter", InkTool::Highlighter)] { + // A new pen starts as that kind's current colour and thickness. + let cur = app.session.view.draw.settings(kind); + mi(ui, app, label, "draw.addPen", json!({"kind": kind.name(), "color": cur.color.hex(), "width": cur.width})); + } + }); stack(ui, |ui| { let sw = Some(crate::theme::c32(set.color)); - split(ui, app, "fontcolor", "Color", pen_id(tool), json!({}), false, sw, |ui, app| { + split(ui, app, "fontcolor", "Color", pen_command(tool), json!({}), false, sw, |ui, app| { let theme = app.session.doc.settings.theme_colors.clone(); if let Some(hex) = color_grid(ui, &theme) { - let _ = app.run(pen_id(tool), json!({"color": hex})); + // With ink lasso-selected the colour goes to the selection, else to the pen. + if lasso_on { + let _ = app.run("draw.lassoColor", json!({"color": hex})); + } else { + let _ = app.run(pen_command(tool), json!({"color": hex})); + update_gallery_pen(app, custom, tool); + } ui.close(); } }); @@ -747,20 +764,80 @@ fn draw(app: &mut WordApp, ui: &mut Ui) { let widths: &[f32] = if tool == InkTool::Highlighter { &[4.0, 8.0, 12.0, 18.0, 24.0] } else { &[0.5, 1.0, 1.5, 2.5, 3.5, 5.0] }; for w in widths { let label = crate::i18n::fmt(tl!("{n} pt"), &[("n", &w.to_string())]); - mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_id(tool), json!({"width": w})); + mi_check(ui, app, &label, (set.width - w).abs() < 0.01, pen_command(tool), json!({"width": w})); } + update_gallery_pen(app, custom, tool); }); }); }); group(ui, "Convert", None, app, |ui, app| { - big(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", json!({}), false); + let on = app.session.view.draw.ink_to_shape; + big_toggle(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", on); big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false); }); group(ui, "Insert", None, app, |ui, app| { big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false); }); group(ui, "Replay", None, app, |ui, app| { - big(ui, app, "replay", "Ink\nReplay", "draw.replay", json!({}), false); + let on = app.session.view.draw.replay; + big_toggle(ui, app, "replay", "Ink\nReplay", "draw.replay", on); + }); +} + +/// The command that picks up `tool`. +fn pen_command(tool: wordcraft_doc::freeform::InkTool) -> &'static str { + use wordcraft_doc::freeform::InkTool; + match tool { + InkTool::Pen => "draw.pen", + InkTool::Pencil => "draw.pencil", + InkTool::Highlighter => "draw.highlighter", + } +} + +/// After the colour or thickness menu changed `tool`'s settings, the gallery pen that was in use +/// (`custom`) takes them too. +fn update_gallery_pen(app: &mut WordApp, custom: Option, tool: wordcraft_doc::freeform::InkTool) { + let set = app.session.view.draw.settings(tool); + if let Some(p) = custom.and_then(|i| app.session.view.draw.pens.get_mut(i)) + && p.kind == tool + { + p.color = set.color; + p.width = set.width; + } +} + +/// A pen of the gallery: its kind's icon over a bar of its colour and thickness; a click picks it +/// up, right-click offers Remove Pen. +fn gallery_pen(ui: &mut Ui, app: &mut WordApp, i: usize, pen: wordcraft_engine::cmd::draw::CustomPen, active: bool) { + use wordcraft_doc::freeform::InkTool; + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(34.0, CONTENT_H), Sense::click()); + crate::widgets::bg(ui, r, &resp, active, &t); + let icon = match pen.kind { + InkTool::Pen => "pen", + InkTool::Pencil => "pencil", + InkTool::Highlighter => "highlight", + }; + let color = crate::theme::c32(pen.color); + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(28.0, 28.0)), icon, t.icon, color); + let bar = (pen.width * 0.6).clamp(1.5, 8.0); + let y = r.min.y + 46.0; + ui.painter().line_segment([pos2(r.min.x + 6.0, y), pos2(r.max.x - 6.0, y)], Stroke::new(bar, color)); + let kind = tl!(match pen.kind { + InkTool::Pen => "Pen", + InkTool::Pencil => "Pencil", + InkTool::Highlighter => "Highlighter", + }); + let size = crate::i18n::fmt(tl!("{n} pt"), &[("n", &pen.width.to_string())]); + let resp = resp.on_hover_text(format!("{kind}: {size}, #{}", pen.color.hex())); + if resp.clicked() { + let _ = app.run(pen_command(pen.kind), json!({"color": pen.color.hex(), "width": pen.width})); + } + resp.context_menu(|ui| { + if ui.button(tl!("Remove Pen")).clicked() { + let _ = app.run("draw.removePen", json!({"index": i})); + ui.close(); + } }); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 3602e5a..d40d5e8 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -89,7 +89,7 @@ pub fn enabled(app: &WordApp, id: &str) -> bool { } } -fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { +pub(crate) fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) { let fill = if resp.is_pointer_button_down_on() { t.pressed } else if checked { diff --git a/docs/gaps.md b/docs/gaps.md index b96d95c..0b7d477 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -22,7 +22,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | | 10 | **Objects**: no group (#267 open), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | -| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) | +| 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395); missing: Ink to Math, arrows in Ink to Shape, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 9/11) | Pen and tablet users | 4–8 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) | | 14 | **`.doc` can't be written; metafile pictures and Word 6/95 files refused** | | `crates/docbin` is read-only | Users exchanging files with old Word or legacy systems | 25–40 | [file-format-parity.md](file-format-parity.md) | | 15 | **References depth**: sources not saved in DOCX (#169 open); no EndNote/Mendeley desktop integration | | Sources live only in the session (#169 open) | Students and researchers | 6–10 | [target-app-parity.md](target-app-parity.md) | @@ -79,6 +79,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-11 | trivial | Collapsed duplicate rows left by parallel merges | | 2026-10-11 | trivial | All 12 bibliography styles landed (#383); removed from References depth | | 2026-10-11 | trivial | Formatting revisions landed (#41); gap #5 narrowed to move tracking and content controls, 8–12 h | +| 2026-10-11 | trivial | #12 narrowed: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed (#395) | | 2026-10-10 | trivial | Draw Table and Eraser landed (#303); removed from the feature-gap list | | 2026-10-10 | trivial | Tabs and Borders and Shading dialogs landed (#320); removed from the dialog-depth list | | 2026-10-10 | trivial | Columns, Symbol and Field dialogs landed (#321); removed from the dialog-depth list | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index b0932f6..f8a06af 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**429 of 451 catalog features have commands (95.1%).** +**433 of 451 catalog features have commands (96.0%).** | Tab | Live | Total | |---|---|---| @@ -10,7 +10,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Quick Access | 4 | 4 | | Home | 101 | 102 | | Insert | 45 | 53 | -| Draw | 6 | 11 | +| Draw | 10 | 11 | | Design | 10 | 10 | | Layout | 21 | 21 | | References | 24 | 24 | @@ -45,11 +45,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Draw -- Tools › Lasso Select (`draw.lasso`) -- Pens › Add Pen (`draw.addPen`) -- Convert › Ink to Shape (`draw.inkToShape`) - Convert › Ink to Math (`draw.inkToMath`) -- Replay › Ink Replay (`draw.replay`) ### Review diff --git a/docs/roadmap.md b/docs/roadmap.md index b5089be..b9fd642 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # WordCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay landed, #395; previously trivial: M7: formatting revisions landed, #41; previously trivial: M5: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: alpha gate checked: six core workflows pass; previously major: milestones moved here from ROADMAP.md; Current focus set from the re-measured gaps) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Forward-looking plan. The summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); the numbers are in [`target-app-parity.md`](target-app-parity.md). Hours are @@ -55,7 +55,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M8 | View | mostly done; window commands missing | 3–5 h | | M9 | Mailings | done (first version); Excel/Outlook data sources and email merge missing | 6–10 h | | M10 | File/Backstage | mostly done; native printing, Options depth missing | 12–20 h | -| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307), contour wrap (#354) done; lasso and ink conversion, grouping missing | 35–55 h | +| M11 | Draw and objects | text wrap, text boxes, canvas handles, rotation (#332), ink pens and eraser (#307), contour wrap (#354), lasso, Add Pen, Ink to Shape and Ink Replay (#395) done; Ink to Math, grouping missing | 35–55 h | | M12 | Formats breadth (PDF, ODT, RTF, HTML, MD, TXT, LaTeX, `.doc` import) | done (first versions); `.doc` write, encryption, minor formats missing | 60–100 h | | M13 | Performance budgets | on track (1.4 ms relayout); large real-world documents unmeasured | 10–20 h | | M14 | 1.0 polish, packaging, signing | releases v0.1.0–v0.4.0 published (signed macOS, Windows MSI x64/x86/arm64, Linux, FreeBSD, web) | — | @@ -81,3 +81,4 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | 2026-10-10 | minor | Alpha gate table added (core-workflow gate from the progress-docs standard): all six workflows pass, stage stays alpha | | 2026-10-10 | major | Created: milestones moved from ROADMAP.md with remaining hours; Current focus from gaps.md; M15–M16 added | | 2026-10-10 | trivial | M11: ink pens and eraser landed (#307) | +| 2026-10-11 | trivial | M11: lasso, Add Pen, Ink to Shape and Ink Replay landed (#395) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 28353a7..fc58b63 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **429 / 451 (95.1%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **433 / 451 (96.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -70,7 +70,7 @@ full parity for the area. | Mailings | 2 | 70% | 70% | 6–10 | Mail merge from CSV, fields, rules (IF, SKIPIF, NEXT, NEXTIF, evaluated alike in preview and merge), fields in headers/footers (one section per letter), preview, envelopes, labels. Select Recipients takes a typed list, CSV (#247) or a sheet of an Excel (.xlsx/.xlsm) or OpenDocument (.ods) workbook, with Select Table to pick the sheet (#334); no Outlook/Contacts/Access data sources, no email merge. | | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | -| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; lasso, ink to shape/math and replay missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | +| Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts and SmartArt are dropped on DOCX read (no `c:chart`/`dgm` handling in `crates/docx`). | | View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane. Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Outline view depth, Immersive Reader depth. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | @@ -263,6 +263,7 @@ can be used without committing Word output. | 2026-10-11 | trivial | References: all 12 of Word's bibliography styles (eight more: Harvard, ISO 690 ×2, Turabian, GB/T 7714, GOST ×2, SIST02) (#383); no percentage change | | 2026-10-11 | trivial | Mail merge fixes (#427–#430): SKIPIF drops matching records, NEXT advances later fields, IF previews, header/footer fields merge; Mailings row updated, no percentage change | | 2026-10-11 | trivial | Formatting revisions (#41): Review row updated; no percentage change | +| 2026-10-11 | trivial | #395: Lasso Select, Add Pen, Ink to Shape and Ink Replay; catalog 423/448 → 427/448 (95.3%) | | 2026-10-10 | trivial | #307: Draw tab pens, eraser and Hide Ink; catalog 384/430 → 390/430 (90.7%) | | 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 384/430 → 387/430 (90.0%) | | 2026-10-10 | trivial | Pictures, shapes, text boxes: free rotation and flips, rotation handle, rotated wrap bounds, DOCX `a:xfrm` rot/flipH/flipV (#332); no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e656748..99e4731 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -10,7 +10,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Area | WordCraft | Word | Parity | Hours | |---|---|---|---|---| -| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); missing: Lasso, Add Pen, Ink to Shape/Math, Ink Replay | Same plus Draw | 85% | (Draw in features) | +| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation; ribbon overflow (#43, #129). Draw tab: Select, Eraser, Pen, Pencil, Highlighter with colour and thickness (#307); Lasso (drag to move, Delete, recolour), Add Pen gallery (right-click to remove), Ink to Shape, Ink Replay with play/pause/rewind/forward bar (#395); missing: Ink to Math | Same plus Draw | 85% | (Draw in features) | | Keytips (Alt / ⌃⌥ letters) | ✅ (#43) | ✅ | 85% | 1–2 | | Mini-toolbar on selection | ✅ (#43) | ✅ | 80% | 1–2 | | Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 | @@ -58,3 +58,4 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed | | 2026-10-10 | major | First version: ribbon, dialog, shortcut and interaction inventory | | 2026-10-10 | trivial | Draw tab tools and Review › Hide Ink (#307) | +| 2026-10-11 | trivial | Draw: Lasso, Add Pen gallery, Ink to Shape and Ink Replay (#395) |