* Draw: Lasso Select, Add Pen, Ink to Shape and Ink Replay (#395) - draw.lasso: with no points picks up the lasso; with a loop on a page it selects the strokes mostly inside it and drawings whose centre is inside. draw.lassoMove / draw.lassoColor / draw.lassoDelete move, recolour and delete the selection (one undo step each); it lasts until the document changes otherwise. In the app: drag a loop, drag the selection to move it, Delete deletes, Esc lets go, the Color menu recolours it. - draw.addPen / draw.removePen: a pen gallery (kind, colour, width; capped, validated) shown after the built-in pens, kept in ui.json; damaged entries are dropped on reading. Right-click a gallery pen to remove it. - draw.inkToShape: toggles Ink to Shape (pen and pencil strokes that look like a line, rectangle/square, ellipse/circle, triangle or polygon of up to eight corners become that shape, in the same undo step); with ink lasso-selected it converts the selection (draw.convertInk). Our own recogniser (cmd/ink_shape.rs): chord test for lines, closure test, ellipse RMS fit, closed RDP simplification with corner merging. - draw.replay: Ink Replay plays the strokes back in drawing order with a Rewind / Play-Pause / Forward / Close bar; pages are drawn without ink meanwhile. Each new stroke records its drawing order (Freeform::order), written to DOCX in the drawing name (`Ink Pen 3 #12`) and read back. Catalog 402/434 -> 406/434 (93.5%). New strings in all nine catalogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Regenerate parity checklist after rebase (#395) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
parent
6526b134be
commit
4ca58d67e1
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -88,18 +88,22 @@ pub struct Freeform {
|
|||
/// by the eraser.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ink: Option<InkTool>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option<Freeform> {
|
|||
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<InkTool> {
|
|||
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<u32> {
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1191,11 +1191,15 @@ fn sectpr_body(w: &mut W, s: &SectionProps) {
|
|||
}
|
||||
}
|
||||
|
||||
/// A shape's `cNvPr`/`docPr` name: ink is "Ink <pen> <id>", which reading looks for.
|
||||
/// A shape's `cNvPr`/`docPr` name: ink is "Ink <pen> <id>" (and "#<order>" 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Value> for CustomPen {
|
||||
type Error = CmdError;
|
||||
fn try_from(v: Value) -> Result<Self, CmdError> {
|
||||
custom_pen(&v)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CustomPen> 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<CustomPen, CmdError> {
|
||||
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<Vec<CustomPen>, 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<Pos>,
|
||||
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<CustomPen>,
|
||||
/// 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<LassoSelection>,
|
||||
/// 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<CommandSpec> {
|
||||
vec![
|
||||
|
|
@ -107,6 +199,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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> {
|
|||
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<Freeform>, 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<Freeform>,
|
||||
pub color: Option<Rgb>,
|
||||
pub width: f32,
|
||||
}
|
||||
|
||||
impl PlacedInk {
|
||||
/// Its paths in page points, turned as it is drawn.
|
||||
pub fn paths(&self) -> Vec<Vec<[f32; 2]>> {
|
||||
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<PlacedInk> {
|
||||
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<Pos> {
|
|||
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<DocLayout>), 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<Vec<[f32; 2]>, 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<Value> = 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<LassoSelection, CmdError> {
|
||||
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<Rect> = 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<Option<&'static str>, 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::<Vec<_>>();
|
||||
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<Value> = 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::<usize>()})
|
||||
})
|
||||
.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<u64> = r["strokes"].as_array().unwrap().iter().map(|k| k["order"].as_u64().unwrap()).collect();
|
||||
assert_eq!(orders, vec![1, 2]);
|
||||
let ys: Vec<f32> = 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<InkShape> {
|
||||
// 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<Option<bool>> = (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::<Vec<_>>()
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ pub struct CanvasState {
|
|||
pub(crate) ink: Option<crate::ink::InkDrag>,
|
||||
/// The eraser is down: whether it has erased anything in this drag yet.
|
||||
pub(crate) erasing: Option<bool>,
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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}
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 12.
|
|
|
@ -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}
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 3.
|
|
|
@ -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}
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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 По размеру
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -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}
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
|
|
@ -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}
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 11 and column 25.
|
|
|
@ -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
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 18.
|
|
|
@ -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}
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
|
|
@ -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} 笔
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 7 and column 25.
|
|
|
@ -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} 筆
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 4.
|
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<ReplayStroke>,
|
||||
/// Seconds into the replay.
|
||||
t: f32,
|
||||
playing: bool,
|
||||
}
|
||||
|
||||
struct ReplayStroke {
|
||||
page: usize,
|
||||
paths: Vec<Vec<[f32; 2]>>,
|
||||
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<LassoDrag>,
|
||||
pub(crate) replay: Option<Replay>,
|
||||
}
|
||||
|
||||
/// 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<Pos2>), 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<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_some(s)
|
||||
}
|
||||
|
||||
/// Page points to screen points on `page`.
|
||||
fn to_screen(rects: &[Rect], layout: &DocLayout, scale: f32, page: usize) -> Option<impl Fn([f32; 2]) -> 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<Pos2> = d.pts.iter().map(|[x, y]| egui::pos2(r.min.x + x * s, r.min.y + y * s)).collect();
|
||||
let pts: Vec<Pos2> = 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<Pos2> = 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<egui::CursorIcon> {
|
||||
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<ReplayStroke> = 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<Pos2> = 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ fn controls(tab: &str) -> Vec<Control> {
|
|||
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"),
|
||||
|
|
|
|||
|
|
@ -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<wordcraft_engine::cmd::draw::CustomPen>,
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<usize>, 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue