diff --git a/ROADMAP.md b/ROADMAP.md index 11b72e9..86c3b64 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color | | 2026-10-11 | Home › Font › Enclose Characters (#297): circle, square, triangle or diamond around a character, saved as Word's `EQ \o\ac` field | | 2026-10-11 | Phonetic Guide (#288): ruby text over base text, `w:ruby` round trip | | 2026-10-11 | Customize Ribbon and the Quick Access Toolbar: custom tabs and groups, hide and reorder tabs, add or remove toolbar commands (right-click any button, or the toolbar's … menu); kept between runs (#379, #376) | @@ -156,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Insert › Icons landed (#309): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 403/434 (92.9%) | | 2026-10-11 | trivial | Progress log: Customize Ribbon and the Quick Access Toolbar (#379) | | 2026-10-11 | trivial | Progress log: Customize Keyboard (#368) | diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 35fea55..41742ce 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -25,6 +25,8 @@ pub fn specs() -> Vec { CommandSpec::new("insert.coverPage", "Cover Page", "Insert › Pages", cover_page).params(r#"{"title"?: string, "subtitle"?: string, "author"?: string}"#), CommandSpec::new("insert.table", "Table", "Insert › Tables", table).params(r#"{"rows": n, "cols": n, "style"?: string}"#), CommandSpec::new("insert.picture", "Pictures", "Insert › Illustrations", picture).params(r#"{"path"?: string, "data"?: base64, "width"?: pt, "alt"?: string}"#), + CommandSpec::new("insert.icon", "Icons", "Insert › Illustrations", icon) + .params(r#"{"id"?: string (omit to list the library), "color"?: "RRGGBB", "size"?: pt, "query"?: string, "category"?: string}"#), CommandSpec::new("insert.shape", "Shapes", "Insert › Illustrations", shape) .params(r#"{"kind": "rectangle|roundedRectangle|ellipse|triangle|diamond|line|arrow|star|heart", "width"?: pt, "height"?: pt, "fill"?: "RRGGBB", "stroke"?: "RRGGBB"}"#), CommandSpec::new("insert.textBox", "Text Box", "Insert › Text", text_box) @@ -206,6 +208,12 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult { s.ui_requests.push(json!({"open": "insertPicture"})); return sel_result(s); }; + place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or("")) +} + +/// Insert an encoded image at the selection as an inline picture, `width` points wide (default: +/// its natural size at 96 ppi), never wider than the text column. +fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) -> CmdResult { if bytes.len() > 200 << 20 { return Err(CmdError::Failed("image is larger than 200 MB".into())); } @@ -223,7 +231,7 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult { // Natural size at 96 ppi, fitted to the text width. let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); let (mut w, mut h) = (pw as f32 * 0.75, ph as f32 * 0.75); - if let Some(want) = p::f32(v, "width") { + if let Some(want) = width { let k = want.max(4.0) / w.max(1.0); w *= k; h *= k; @@ -234,20 +242,44 @@ fn picture(s: &mut Session, v: &Value) -> CmdResult { } let props = s.typing_props(); let at = delete_selection(s)?; - let obj = InlineObject::Image { - media: key.clone(), - w, - h, - alt: p::str(v, "alt").unwrap_or("").to_string(), - float: Float::default(), - crop: [0.0; 4], - ole: None, - }; + let obj = InlineObject::Image { media: key.clone(), w, h, alt: alt.to_string(), float: Float::default(), crop: [0.0; 4], ole: None }; let end = s.doc.insert_object(&at, obj, &props)?; s.sel = Selection { anchor: at, focus: end }; Ok(json!({"media": key, "width": w, "height": h})) } +/// Insert › Icons: a library icon (original artwork drawn in code, `wordcraft_render::icon_lib`) +/// rendered to a PNG picture `size` points square (default 1 inch) in `color` (default black). +/// Without an `id` it lists the library (narrowed by `query` and `category`) and asks a front end +/// for its icon picker. +fn icon(s: &mut Session, v: &Value) -> CmdResult { + use wordcraft_render::icon_lib; + let Some(id) = p::str(v, "id") else { + let category = p::str(v, "category").and_then(icon_lib::Category::parse); + let icons: Vec = icon_lib::search(p::str(v, "query").unwrap_or(""), category) + .iter() + .map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords})) + .collect(); + s.ui_requests.push(json!({"open": "icons"})); + return Ok(json!({"icons": icons})); + }; + let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?; + let ink = match p::str(v, "color") { + Some(c) => Rgb::parse(c).ok_or_else(|| CmdError::Params(format!("bad color {c:?}: use RRGGBB")))?, + None => Rgb::BLACK, + }; + let size = p::f32(v, "size").unwrap_or(72.0).clamp(4.0, 1584.0); + // About 300 pixels per inch, so it prints sharply. + let px = (size * 300.0 / 72.0).round().clamp(64.0, 1024.0) as u32; + let png = icon_lib::render(icon, px, ink).to_png(); + if png.is_empty() { + return Err(CmdError::Failed("couldn't draw the icon".into())); + } + let mut r = place_picture(s, png, Some(size), icon.name)?; + r["icon"] = json!(icon.id); + Ok(r) +} + fn shape(s: &mut Session, v: &Value) -> CmdResult { let kind: ShapeKind = serde_json::from_value(v.get("kind").cloned().unwrap_or(json!("rectangle"))).map_err(|e| CmdError::Params(e.to_string()))?; diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index d9e9e0e..b80adb0 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -106,6 +106,20 @@ pub fn specs() -> Vec { }) .params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#) .when(has_picture), + CommandSpec::new("picture.iconColor", "Icon Color", "Picture Format › Adjust", |s, v| { + let c = p::str(v, "color").and_then(Rgb::parse).ok_or_else(|| CmdError::Params("`color` (RRGGBB) is required".into()))?; + // One colour everywhere, transparency kept: recolours an inserted icon or other + // one-colour line art without redrawing it. + adjust(s, move |img| { + let mut rgba = img.to_rgba8(); + for px in rgba.pixels_mut() { + px.0 = [c.0, c.1, c.2, px.0[3]]; + } + image::DynamicImage::ImageRgba8(rgba) + }) + }) + .params(r#"{"color": "RRGGBB"}"#) + .when(has_picture), CommandSpec::new("picture.effects", "Artistic Effects", "Picture Format › Adjust", |s, v| { let e = p::str(v, "effect").unwrap_or("blur").to_string(); adjust(s, move |img| match e.as_str() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1b9465f..dae0bc3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2808,6 +2808,40 @@ fn deleting_the_last_cell_column_never_leaves_a_table_without_rows() { assert!(s.doc.body.iter().any(|b| b.as_table().is_some_and(|t| !t.rows.is_empty()))); } +#[test] +fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours() { + use wordcraft_doc::para::InlineObject; + let mut s = s(); + // Without an id: the library is listed and a front end is asked for its picker. + let r = run(&mut s, "insert.icon", json!({"query": "envelope"})); + assert_eq!(r["icons"][0]["id"], "mail"); + assert_eq!(s.ui_requests.last(), Some(&json!({"open": "icons"}))); + let all = run(&mut s, "insert.icon", json!({})); + assert!(all["icons"].as_array().unwrap().len() >= 40); + assert!(s.run("insert.icon", &json!({"id": "no-such-icon"})).is_err()); + assert!(s.run("insert.icon", &json!({"id": "heart", "color": "red"})).is_err()); + + let r = run(&mut s, "insert.icon", json!({"id": "heart", "color": "C00000", "size": 48})); + assert_eq!((r["width"].as_f64(), r["height"].as_f64(), r["icon"].as_str()), (Some(48.0), Some(48.0), Some("heart"))); + let Some((_, InlineObject::Image { media, w, h, alt, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") }; + assert_eq!((w, h, alt.as_str()), (48.0, 48.0, "Heart")); + let png = image::load_from_memory(&s.doc.media[&media]).unwrap().to_rgba8(); + assert_eq!(png.dimensions(), (200, 200), "about 300 pixels per inch"); + let inked: Vec<_> = png.pixels().filter(|p| p.0[3] == 255).collect(); + assert!(!inked.is_empty() && inked.iter().all(|p| p.0[..3] == [0xC0, 0, 0])); + assert!(png.get_pixel(0, 0).0[3] == 0, "transparent background"); + // It is an ordinary picture: it saves to .docx. + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + assert_eq!(back.media.len(), 1); + + // Icon Color repaints it, keeping the transparency. + run(&mut s, "picture.iconColor", json!({"color": "1F4E79"})); + let Some((_, InlineObject::Image { media: m2, .. })) = crate::cmd::objects::selected(&s) else { panic!("no picture") }; + let png2 = image::load_from_memory(&s.doc.media[&m2]).unwrap().to_rgba8(); + assert!(png2.pixels().zip(png.pixels()).all(|(a, b)| a.0[3] == b.0[3] && (a.0[3] == 0 || a.0[..3] == [0x1F, 0x4E, 0x79]))); + assert!(s.run("picture.iconColor", &json!({})).is_err()); +} + /// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the /// way Word stores it (the style in the code's run sizes) and read back; null takes the text out /// again, and each step undoes. diff --git a/crates/render/src/icon_lib.rs b/crates/render/src/icon_lib.rs new file mode 100644 index 0000000..07a0e40 --- /dev/null +++ b/crates/render/src/icon_lib.rs @@ -0,0 +1,731 @@ +//! The Insert › Icons library: monochrome pictograms drawn in code. +//! +//! Every icon here is original WordCraft artwork: simple geometric line drawings designed on a +//! 24×24 grid for this file (no external icon set was copied, traced or adapted). The same path +//! data is drawn live by the interface's icon picker and rasterised here, by vello_cpu, into the +//! PNG picture `insert.icon` places in the document. +//! +//! Path data is a small SVG-like language in grid units (y grows downwards), absolute only: +//! - `M x y` move, `L x y` line (more pairs repeat the line), `Q cx cy x y` quadratic curve, +//! `C c1x c1y c2x c2y x y` cubic curve, `Z` close the subpath; +//! - `A cx cy r a0 a1` circular arc from angle `a0` to `a1` in degrees (0° points right, 90° +//! down), joined to the open subpath by a line, or starting one; +//! - `O cx cy r` a whole circle and `R x y w h r` a rounded rectangle, each its own closed subpath. +//! +//! Stroked parts are drawn with a [`STROKE`]-unit pen with round caps and joins. Filled parts are +//! convex outlines (so any front end can fill them as simple polygons). + +use std::sync::OnceLock; + +use vello_cpu::kurbo::{self, Affine, BezPath}; +use vello_cpu::{RenderContext, Resources}; +use wordcraft_doc::props::Rgb; + +use crate::Rendered; + +/// Side of the design grid, in grid units. +pub const GRID: f32 = 24.0; +/// Pen width of stroked parts, in grid units. +pub const STROKE: f32 = 2.0; + +/// The library's categories, in the order the picker shows them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Category { + Arrows, + Communication, + Office, + People, + Nature, + Weather, + Shapes, + Symbols, + Technology, + Transport, +} + +impl Category { + pub const ALL: [Category; 10] = [ + Category::Arrows, + Category::Communication, + Category::Office, + Category::People, + Category::Nature, + Category::Weather, + Category::Shapes, + Category::Symbols, + Category::Technology, + Category::Transport, + ]; + + /// English name (also the interface catalogs' source text). + pub fn name(self) -> &'static str { + match self { + Category::Arrows => "Arrows", + Category::Communication => "Communication", + Category::Office => "Office", + Category::People => "People", + Category::Nature => "Nature", + Category::Weather => "Weather", + Category::Shapes => "Shapes", + Category::Symbols => "Symbols", + Category::Technology => "Technology", + Category::Transport => "Transport", + } + } + + /// The category named `name` (case-insensitive). + pub fn parse(name: &str) -> Option { + Category::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(name.trim())) + } +} + +/// One drawing step of an icon. +#[derive(Clone, Copy, Debug)] +pub enum Part { + /// Path data stroked with the pen. + Stroke(&'static str), + /// Path data filled; every subpath is convex. + Fill(&'static str), +} + +/// A library icon. +#[derive(Debug)] +pub struct Icon { + /// Stable id (`insert.icon`'s `id`). + pub id: &'static str, + /// English name; also the inserted picture's alt text. + pub name: &'static str, + pub category: Category, + /// Extra search words. + pub keywords: &'static [&'static str], + pub parts: &'static [Part], +} + +/// A flattened subpath in grid units. +#[derive(Clone, Debug, PartialEq)] +pub struct Piece { + pub points: Vec<[f32; 2]>, + pub closed: bool, + pub fill: bool, +} + +use Category::*; +use Part::{Fill as F, Stroke as S}; + +macro_rules! icon { + ($id:literal, $name:literal, $cat:expr, [$($k:literal),*], [$($p:expr),* $(,)?]) => { + Icon { id: $id, name: $name, category: $cat, keywords: &[$($k),*], parts: &[$($p),*] } + }; +} + +/// The library, grouped by category. +pub static ICONS: &[Icon] = &[ + // Arrows + icon!("arrow-right", "Arrow Right", Arrows, ["next", "forward", "east"], [S("M3.5 12 L20 12 M14 6 L20 12 L14 18")]), + icon!("arrow-left", "Arrow Left", Arrows, ["back", "previous", "west"], [S("M20.5 12 L4 12 M10 6 L4 12 L10 18")]), + icon!("arrow-up", "Arrow Up", Arrows, ["north", "increase", "top"], [S("M12 20.5 L12 4 M6 10 L12 4 L18 10")]), + icon!("arrow-down", "Arrow Down", Arrows, ["south", "decrease", "bottom", "download"], [S("M12 3.5 L12 20 M6 14 L12 20 L18 14")]), + icon!("refresh", "Refresh", Arrows, ["reload", "repeat", "cycle", "rotate"], [S("A12 12 7.5 -45 225"), S("M2.7 6.7 L6.7 6.7 L6.7 10.7"),]), + icon!("swap", "Swap", Arrows, ["exchange", "transfer", "trade"], [S("M4 8 L19 8 M15 4 L19 8 L15 12 M20 16 L5 16 M9 12 L5 16 L9 20")]), + // Communication + icon!("mail", "Mail", Communication, ["email", "envelope", "letter", "message"], [S("R2.5 5 19 14 2 M3.5 6.5 L12 13 L20.5 6.5")]), + icon!( + "chat", + "Chat", + Communication, + ["speech", "bubble", "talk", "comment", "message"], + [S( + "M5 3.5 L19 3.5 Q21.5 3.5 21.5 6 L21.5 13.5 Q21.5 16 19 16 L11 16 L6 20.5 L6 16 L5 16 Q2.5 16 2.5 13.5 L2.5 6 Q2.5 3.5 5 3.5 Z M7 8 L17 8 M7 11.5 L14 11.5" + )] + ), + icon!( + "megaphone", + "Megaphone", + Communication, + ["announce", "loud", "marketing", "news"], + [S("M3 9.5 L3 14.5 L7 14.5 L17 19.5 L17 4.5 L7 9.5 Z M7 9.5 L7 14.5 M7 14.5 L8.5 20.5 L11.5 20.5 L10.5 16 M20 9.5 Q21.5 12 20 14.5")] + ), + icon!( + "bell", + "Bell", + Communication, + ["notification", "alert", "ring", "reminder"], + [S("M5 17 L19 17 M7 17 L7 11 Q7 5.5 12 5.5 Q17 5.5 17 11 L17 17 M12 3 L12 5.5 M10 20.5 L14 20.5")] + ), + icon!( + "inbox", + "Inbox", + Communication, + ["tray", "receive", "messages"], + [S("M2.5 13 L2.5 19.5 L21.5 19.5 L21.5 13 L16 13 L15 15.5 L9 15.5 L8 13 Z M5.5 13 L7.5 4.5 L16.5 4.5 L18.5 13")] + ), + // Office + icon!( + "document", + "Document", + Office, + ["page", "file", "paper", "text"], + [S("M6 2.5 L14 2.5 L19 7.5 L19 21.5 L6 21.5 Z M14 2.5 L14 7.5 L19 7.5 M9 12 L16 12 M9 15 L16 15 M9 18 L13 18")] + ), + icon!( + "folder", + "Folder", + Office, + ["directory", "files", "archive"], + [S("M2.5 5 L9 5 L11 7.5 L21.5 7.5 L21.5 19.5 L2.5 19.5 Z M2.5 10.5 L21.5 10.5")] + ), + icon!( + "calendar", + "Calendar", + Office, + ["date", "schedule", "month", "event"], + [S("R3 5 18 16 2 M3 10 L21 10 M8 3 L8 7 M16 3 L16 7"), F("O8 13.8 1.1 O12 13.8 1.1 O16 13.8 1.1 O8 17.4 1.1 O12 17.4 1.1"),] + ), + icon!( + "clipboard", + "Clipboard", + Office, + ["list", "tasks", "notes", "checklist"], + [S("R5 4.5 14 17 2 R9 2.5 6 4 1 M9 11 L15 11 M9 14.5 L15 14.5 M9 18 L13 18")] + ), + icon!( + "pencil", + "Pencil", + Office, + ["edit", "write", "draw", "pen"], + [S("M4 20 L5 15 L15.5 4.5 Q16.5 3.5 17.5 4.5 L19.5 6.5 Q20.5 7.5 19.5 8.5 L9 19 Z M13.5 6.5 L17.5 10.5 M5 15 L9 19")] + ), + icon!( + "briefcase", + "Briefcase", + Office, + ["work", "business", "job", "case"], + [S("R2.5 7 19 13 2 M9 7 L9 4.5 L15 4.5 L15 7 M2.5 13 L21.5 13"), F("R10.5 11.5 3 3 0.5"),] + ), + icon!( + "bar-chart", + "Bar Chart", + Office, + ["graph", "statistics", "report", "data"], + [S("M3 21 L21 21"), F("R5.5 12 3 7 0.5 R10.5 6 3 13 0.5 R15.5 9 3 10 0.5"),] + ), + // People + icon!("person", "Person", People, ["user", "account", "profile", "human"], [S("O12 7.5 4 M4 21 Q4 13.5 12 13.5 Q20 13.5 20 21")]), + icon!( + "group", + "Group", + People, + ["team", "users", "people", "friends"], + [S("O9 8 3.5 M2.5 20 Q2.5 14 9 14 Q15.5 14 15.5 20 O16.5 7 3 M17 12.8 Q21.5 12.8 21.5 18")] + ), + icon!( + "smiley", + "Smiley", + People, + ["face", "happy", "smile", "emotion"], + [S("O12 12 9.5 M8.2 14.2 Q12 18.5 15.8 14.2"), F("O9 9.8 1.3 O15 9.8 1.3"),] + ), + icon!( + "person-add", + "Add Person", + People, + ["invite", "new user", "join"], + [S("O10 7.5 4 M3 20.5 Q3 13.5 10 13.5 Q12.8 13.5 14.6 14.6 M19 13 L19 19 M16 16 L22 16")] + ), + icon!( + "badge", + "Name Badge", + People, + ["id", "identity", "card", "employee"], + [S("R4 2.5 16 19 2 O12 10 3 M8 17.5 Q12 13.5 16 17.5 M10 5.5 L14 5.5")] + ), + // Nature + icon!( + "leaf", + "Leaf", + Nature, + ["plant", "eco", "green", "growth"], + [S("M4.5 19.5 Q4.5 5 19.5 4.5 Q19.5 19.5 4.5 19.5 Z M4.5 19.5 L13 11 M3 21 L4.5 19.5")] + ), + icon!( + "tree", + "Pine Tree", + Nature, + ["forest", "fir", "evergreen", "winter"], + [S("M12 2.5 L18 10 L15 10 L19.5 16.5 L4.5 16.5 L9 10 L6 10 Z M12 16.5 L12 21.5")] + ), + icon!( + "flower", + "Tulip", + Nature, + ["flower", "spring", "garden", "bloom"], + [S( + "M7 5 L9.5 8 L12 4 L14.5 8 L17 5 L17 10 Q17 14.5 12 14.5 Q7 14.5 7 10 Z M12 14.5 L12 21.5 M12 18.5 Q8 18.5 6.5 15.5 M12 19.5 Q15.5 19.5 17 17" + )] + ), + icon!("mountain", "Mountain", Nature, ["landscape", "peak", "hiking", "outdoors"], [S("M2 20 L9 8 L13 14 L16 10 L22 20 Z"), F("O18.5 5 2"),]), + icon!("paw", "Paw Print", Nature, ["pet", "animal", "dog", "cat"], [F("O12 16.5 3.6 O6.5 11 1.9 O10 7 1.9 O14 7 1.9 O17.5 11 1.9")]), + // Weather + icon!( + "sun", + "Sun", + Weather, + ["sunny", "bright", "day", "summer"], + [S( + "O12 12 4 M12 3 L12 5.5 M12 18.5 L12 21 M3 12 L5.5 12 M18.5 12 L21 12 M5.64 5.64 L7.4 7.4 M16.6 16.6 L18.36 18.36 M5.64 18.36 L7.4 16.6 M16.6 7.4 L18.36 5.64" + )] + ), + icon!("cloud", "Cloud", Weather, ["cloudy", "overcast", "storage"], [S("M7 18 A7 14.5 3.5 90 270 A12 11 5 180 360 A17 14.5 3.5 270 450 Z")]), + icon!( + "rain", + "Rain", + Weather, + ["rainy", "shower", "storm", "wet"], + [S("M7 15 A7 11.5 3.5 90 270 A12 8 5 180 360 A17 11.5 3.5 270 450 Z M8 18 L7 21 M12 18 L11 21 M16 18 L15 21")] + ), + icon!( + "lightning", + "Lightning", + Weather, + ["bolt", "thunder", "energy", "flash"], + [S("M13.5 2.5 L5 13.5 L11.5 13.5 L10.5 21.5 L19 10 L12.5 10 Z")] + ), + icon!("moon", "Moon", Weather, ["night", "crescent", "sleep", "dark"], [S("M18 5.07 A14 12 8 -60 -300 A18 12 6.93 90 270 Z")]), + icon!( + "snowflake", + "Snowflake", + Weather, + ["snow", "cold", "winter", "frozen"], + [S( + "M12 2.5 L12 21.5 M20.23 7.25 L3.77 16.75 M20.23 16.75 L3.77 7.25 M14.4 3.9 L12 6.5 L9.6 3.9 M20.21 10.03 L16.76 9.25 L17.81 5.87 M17.81 18.13 L16.76 14.75 L20.21 13.97 M9.6 20.1 L12 17.5 L14.4 20.1 M3.79 13.97 L7.24 14.75 L6.19 18.13 M6.19 5.87 L7.24 9.25 L3.79 10.03" + )] + ), + icon!( + "droplet", + "Droplet", + Weather, + ["water", "drop", "liquid", "humidity"], + [S("M12 2.5 C12 2.5 18.5 10 18.5 14.5 C18.5 18.5 15.5 21.5 12 21.5 C8.5 21.5 5.5 18.5 5.5 14.5 C5.5 10 12 2.5 12 2.5 Z")] + ), + // Shapes + icon!("circle", "Circle", Shapes, ["round", "ring", "dot"], [S("O12 12 9")]), + icon!("square", "Square", Shapes, ["box", "rectangle"], [S("R3.5 3.5 17 17 2.5")]), + icon!("triangle", "Triangle", Shapes, ["delta", "pyramid"], [S("M12 3.5 L21 19.5 L3 19.5 Z")]), + icon!( + "star", + "Star", + Shapes, + ["favorite", "rating", "award"], + [S("M12 3 L14.29 9.35 L21.04 9.56 L15.71 13.71 L17.58 20.19 L12 16.4 L6.42 20.19 L8.29 13.71 L2.96 9.56 L9.71 9.35 Z")] + ), + icon!("hexagon", "Hexagon", Shapes, ["polygon", "honeycomb", "cell"], [S("M12 3 L19.79 7.5 L19.79 16.5 L12 21 L4.21 16.5 L4.21 7.5 Z")]), + // Symbols + icon!( + "heart", + "Heart", + Symbols, + ["love", "like", "favorite", "health"], + [S( + "M12 20 C5 15 2.5 11.5 2.5 8.5 C2.5 5.8 4.7 3.8 7.3 3.8 C9.4 3.8 11 5 12 6.8 C13 5 14.6 3.8 16.7 3.8 C19.3 3.8 21.5 5.8 21.5 8.5 C21.5 11.5 19 15 12 20 Z" + )] + ), + icon!("check", "Check Mark", Symbols, ["done", "ok", "yes", "complete", "tick"], [S("O12 12 9.5 M7.8 12.3 L10.8 15.3 L16.3 9.3")]), + icon!("plus", "Plus", Symbols, ["add", "new", "more", "positive"], [S("M12 4.5 L12 19.5 M4.5 12 L19.5 12")]), + icon!("info", "Information", Symbols, ["info", "about", "help", "details"], [S("O12 12 9.5 M12 10.5 L12 16.5"), F("O12 7.5 1.25")]), + icon!( + "warning", + "Warning", + Symbols, + ["caution", "alert", "danger", "attention"], + [S("M12 3 L21.5 20 L2.5 20 Z M12 9.5 L12 14"), F("O12 17 1.2"),] + ), + icon!( + "question", + "Question", + Symbols, + ["help", "ask", "faq", "unknown"], + [S("O12 12 9.5 M9.3 9.3 Q9.3 6.8 12 6.8 Q14.7 6.8 14.7 9.3 Q14.7 11 12 12.3 L12 13.6"), F("O12 16.8 1.2"),] + ), + icon!("flag", "Flag", Symbols, ["goal", "milestone", "report", "country"], [S("M5 21.5 L5 3 M5 4 L19 4 L15.5 8.5 L19 13 L5 13")]), + icon!( + "lightbulb", + "Light Bulb", + Symbols, + ["idea", "tip", "insight", "innovation"], + [S("M9 17 L9 15.2 Q5.8 13.2 5.8 9.3 Q5.8 3 12 3 Q18.2 3 18.2 9.3 Q18.2 13.2 15 15.2 L15 17 Z M9.5 20.5 L14.5 20.5")] + ), + // Technology + icon!( + "laptop", + "Laptop", + Technology, + ["computer", "notebook", "device"], + [S("R4.5 5 15 10.5 1.5 M2.5 19 L21.5 19 M4.5 15.5 L2.5 19 M19.5 15.5 L21.5 19")] + ), + icon!("mobile", "Mobile Phone", Technology, ["phone", "smartphone", "cell", "device"], [S("R6.5 2.5 11 19 2 M11 18.5 L13 18.5")]), + icon!( + "wifi", + "Wireless", + Technology, + ["wifi", "signal", "network", "internet"], + [S("A12 19.5 4.5 225 315"), S("A12 19.5 9 225 315"), S("A12 19.5 13.5 225 315"), F("O12 19.5 1.4"),] + ), + icon!("monitor", "Monitor", Technology, ["screen", "display", "desktop", "computer"], [S("R2.5 3.5 19 13 1.5 M12 16.5 L12 20 M8 20.5 L16 20.5")]), + icon!("battery", "Battery", Technology, ["power", "charge", "energy"], [S("R2.5 7 17 10 2 M21.5 10.5 L21.5 13.5"), F("R5 9.5 8 5 0.6")]), + icon!("code", "Code", Technology, ["programming", "brackets", "developer", "html"], [S("M8 7 L3 12 L8 17 M16 7 L21 12 L16 17 M14 4.5 L10 19.5")]), + icon!("power", "Power", Technology, ["on", "off", "switch", "shutdown"], [S("A12 13 7.5 -60 240"), S("M12 3 L12 11")]), + // Transport + icon!( + "car", + "Car", + Transport, + ["auto", "vehicle", "drive", "road"], + [S("M2.5 16 L2.5 11.5 L5.5 6 L18.5 6 L21.5 11.5 L21.5 16 Z M2.5 11.5 L21.5 11.5 M12 6 L12 11.5"), F("O7 17.5 2.3 O17 17.5 2.3"),] + ), + icon!( + "bicycle", + "Bicycle", + Transport, + ["bike", "cycling", "ride"], + [S("O5.5 16 3.5 O18.5 16 3.5 M5.5 16 L9.5 9 L12 16 Z M9.5 9 L16 9 L12 16 M16 9 L18.5 16 M16 9 L15 6 L17.5 6 M9.5 9 L9 7 M7.5 7 L10.5 7")] + ), + icon!( + "airplane", + "Airplane", + Transport, + ["plane", "flight", "travel", "airport"], + [S( + "M12 2.5 Q13.5 2.5 13.5 5 L13.5 9.5 L21 14 L21 16 L13.5 13.5 L13.5 18 L16 20 L16 21.5 L12 20.5 L8 21.5 L8 20 L10.5 18 L10.5 13.5 L3 16 L3 14 L10.5 9.5 L10.5 5 Q10.5 2.5 12 2.5 Z" + )] + ), + icon!( + "bus", + "Bus", + Transport, + ["coach", "public transport", "transit"], + [S("R4 2.5 16 16.5 2.5 M4 11 L20 11 M7 19 L7 21.5 M17 19 L17 21.5 M8 5.5 L16 5.5"), F("O8 15 1.3 O16 15 1.3"),] + ), + icon!( + "sailboat", + "Sailboat", + Transport, + ["boat", "ship", "sea", "sailing"], + [S("M3 16 L21 16 L18 20.5 L6 20.5 Z M12 2.5 L12 16 M12 4 L18.5 13.5 L12 13.5 M10 6 L5.5 13.5 L10 13.5 Z")] + ), + icon!( + "map-pin", + "Map Pin", + Transport, + ["location", "place", "marker", "map", "travel"], + [S("M12 21.5 Q5 14 5 9.5 A12 9.5 7 180 360 Q19 14 12 21.5 Z O12 9.5 2.5")] + ), +]; + +/// The icon with this id. +pub fn find(id: &str) -> Option<&'static Icon> { + ICONS.iter().find(|i| i.id == id.trim()) +} + +/// Icons matching every word of `query` (in the id, name, keywords or category; case-insensitive) +/// and, when given, in `category`. An empty query matches everything. +pub fn search(query: &str, category: Option) -> Vec<&'static Icon> { + let words: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + ICONS + .iter() + .filter(|i| category.is_none_or(|c| i.category == c)) + .filter(|i| { + words.iter().all(|w| { + i.id.contains(w.as_str()) + || i.name.to_lowercase().contains(w.as_str()) + || i.category.name().to_lowercase().contains(w.as_str()) + || i.keywords.iter().any(|k| k.contains(w.as_str())) + }) + }) + .collect() +} + +impl Icon { + /// Parse and flatten the icon's parts. + pub fn parse(&self) -> Result, String> { + let mut out = Vec::new(); + for part in self.parts { + let (d, fill) = match part { + Part::Stroke(d) => (d, false), + Part::Fill(d) => (d, true), + }; + for (points, closed) in flatten(d).map_err(|e| format!("{}: {e}", self.id))? { + out.push(Piece { points, closed: closed || fill, fill }); + } + } + Ok(out) + } + + /// The flattened geometry, parsed once (empty for an icon whose data doesn't parse, logged). + pub fn pieces(&self) -> &'static [Piece] { + static ALL: OnceLock>> = OnceLock::new(); + let all = ALL.get_or_init(|| { + ICONS + .iter() + .map(|i| { + i.parse().unwrap_or_else(|e| { + log::error!("icon library: {e}"); + Vec::new() + }) + }) + .collect() + }); + ICONS.iter().position(|i| i.id == self.id).and_then(|n| all.get(n)).map(Vec::as_slice).unwrap_or(&[]) + } +} + +#[derive(Clone, Copy, Debug)] +enum Tok { + Cmd(char), + Num(f32), +} + +fn tokens(d: &str) -> Result, String> { + let mut out = Vec::new(); + let mut chars = d.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if c.is_whitespace() || c == ',' { + continue; + } + if c.is_ascii_alphabetic() { + out.push(Tok::Cmd(c)); + continue; + } + let mut end = i + c.len_utf8(); + while let Some(&(j, n)) = chars.peek() { + if n.is_ascii_digit() || n == '.' { + end = j + n.len_utf8(); + chars.next(); + } else { + break; + } + } + let text = d.get(i..end).unwrap_or(""); + let v: f32 = text.parse().map_err(|_| format!("bad number {text:?}"))?; + if !v.is_finite() { + return Err(format!("bad number {text:?}")); + } + out.push(Tok::Num(v)); + } + Ok(out) +} + +/// Flatten path data into polylines (`(points, closed)`). +fn flatten(d: &str) -> Result, bool)>, String> { + let toks = tokens(d)?; + let mut subs: Vec<(Vec<[f32; 2]>, bool)> = Vec::new(); + let mut cur: Vec<[f32; 2]> = Vec::new(); + let mut i = 0; + let mut last = None; + let flush = |cur: &mut Vec<[f32; 2]>, subs: &mut Vec<(Vec<[f32; 2]>, bool)>, closed: bool| { + if cur.len() >= 2 { + subs.push((std::mem::take(cur), closed)); + } + cur.clear(); + }; + while i < toks.len() { + let cmd = match toks.get(i) { + Some(Tok::Cmd(c)) => { + i += 1; + *c + } + // A number repeats the previous command (a repeated move is a line). + Some(Tok::Num(_)) => match last { + Some('M') => 'L', + Some(c @ ('L' | 'Q' | 'C')) => c, + _ => return Err("number without a command".into()), + }, + None => break, + }; + let argc = match cmd { + 'M' | 'L' => 2, + 'Q' => 4, + 'C' => 6, + 'A' | 'R' => 5, + 'O' => 3, + 'Z' => 0, + c => return Err(format!("unknown command {c:?}")), + }; + let mut a = [0f32; 6]; + for slot in a.iter_mut().take(argc) { + match toks.get(i) { + Some(Tok::Num(v)) => *slot = *v, + _ => return Err(format!("{cmd} needs {argc} numbers")), + } + i += 1; + } + let from = cur.last().copied(); + match cmd { + 'M' => { + flush(&mut cur, &mut subs, false); + cur.push([a[0], a[1]]); + } + 'L' => { + from.ok_or("L without a current point")?; + cur.push([a[0], a[1]]); + } + 'Q' => { + let p0 = from.ok_or("Q without a current point")?; + for k in 1..=12 { + let t = k as f32 / 12.0; + let u = 1.0 - t; + cur.push([u * u * p0[0] + 2.0 * u * t * a[0] + t * t * a[2], u * u * p0[1] + 2.0 * u * t * a[1] + t * t * a[3]]); + } + } + 'C' => { + let p0 = from.ok_or("C without a current point")?; + for k in 1..=16 { + let t = k as f32 / 16.0; + let u = 1.0 - t; + let (b0, b1, b2, b3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + cur.push([b0 * p0[0] + b1 * a[0] + b2 * a[2] + b3 * a[4], b0 * p0[1] + b1 * a[1] + b2 * a[3] + b3 * a[5]]); + } + } + 'A' => { + let (cx, cy, r, a0, a1) = (a[0], a[1], a[2], a[3], a[4]); + if r <= 0.0 || (a1 - a0).abs() > 720.0 { + return Err("bad arc".into()); + } + let n = ((a1 - a0).abs() / 7.5).ceil().max(2.0) as usize; + for k in 0..=n { + let t = (a0 + (a1 - a0) * k as f32 / n as f32).to_radians(); + cur.push([cx + r * t.cos(), cy + r * t.sin()]); + } + } + 'O' => { + flush(&mut cur, &mut subs, false); + let (cx, cy, r) = (a[0], a[1], a[2]); + if r <= 0.0 { + return Err("bad circle".into()); + } + let pts = (0..48).map(|k| (k as f32 * 7.5).to_radians()).map(|t| [cx + r * t.cos(), cy + r * t.sin()]).collect(); + subs.push((pts, true)); + } + 'R' => { + flush(&mut cur, &mut subs, false); + let (x, y, w, h) = (a[0], a[1], a[2], a[3]); + if w <= 0.0 || h <= 0.0 { + return Err("bad rectangle".into()); + } + let r = a[4].clamp(0.0, w.min(h) / 2.0); + let mut pts = Vec::new(); + for (cx, cy, start) in [(x + w - r, y + r, -90.0f32), (x + w - r, y + h - r, 0.0), (x + r, y + h - r, 90.0), (x + r, y + r, 180.0)] { + for k in 0..=6 { + let t = (start + 15.0 * k as f32).to_radians(); + pts.push([cx + r * t.cos(), cy + r * t.sin()]); + } + } + pts.dedup(); + subs.push((pts, true)); + } + _ => flush(&mut cur, &mut subs, true), + } + last = Some(cmd); + } + flush(&mut cur, &mut subs, false); + Ok(subs) +} + +/// Rasterise `icon` into a transparent `px`×`px` image in `ink` (premultiplied RGBA). +pub fn render(icon: &Icon, px: u32, ink: Rgb) -> Rendered { + let side = px.clamp(8, 4096) as u16; + let mut ctx = RenderContext::new_with(side, side, vello_cpu::RenderSettings { num_threads: crate::default_threads(), ..Default::default() }); + ctx.set_transform(Affine::scale(side as f64 / GRID as f64)); + ctx.set_paint(crate::color(ink, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(STROKE as f64).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + for piece in icon.pieces() { + let mut path = BezPath::new(); + for (k, p) in piece.points.iter().enumerate() { + let p = (p[0] as f64, p[1] as f64); + if k == 0 { path.move_to(p) } else { path.line_to(p) } + } + if piece.closed { + path.close_path(); + } + if piece.fill { + ctx.fill_path(&path); + } else { + ctx.stroke_path(&path); + } + } + ctx.flush(); + let mut pixels = vec![0u8; side as usize * side as usize * 4]; + let mut res = Resources::new(); + if let Some(pm) = vello_cpu::PixmapMut::new(side, side, &mut pixels) { + ctx.render(pm, &mut res); + } + Rendered { width: side as u32, height: side as u32, pixels } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Twice the signed area's sign of each corner; a convex outline never changes turning sign. + fn convex(points: &[[f32; 2]]) -> bool { + let n = points.len(); + let mut sign = 0.0f32; + for k in 0..n { + let (a, b, c) = (points[k], points[(k + 1) % n], points[(k + 2) % n]); + let cross = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]); + if cross.abs() < 1e-4 { + continue; + } + if sign == 0.0 { + sign = cross.signum(); + } else if cross.signum() != sign { + return false; + } + } + true + } + + #[test] + fn every_icon_is_unique_parses_fits_the_grid_and_renders() { + assert!((40..=80).contains(&ICONS.len()), "{} icons", ICONS.len()); + let mut ids = std::collections::HashSet::new(); + for icon in ICONS { + assert!(ids.insert(icon.id), "duplicate id {}", icon.id); + assert!(!icon.name.is_empty() && !icon.keywords.is_empty(), "{}", icon.id); + let pieces = icon.parse().unwrap_or_else(|e| panic!("{e}")); + assert!(!pieces.is_empty(), "{} draws nothing", icon.id); + for p in &pieces { + assert!(p.points.len() >= 2, "{}: degenerate subpath", icon.id); + // Strokes keep half the pen inside the grid. + let m = if p.fill { 0.0 } else { STROKE / 2.0 - 0.01 }; + for &[x, y] in &p.points { + assert!((m..=GRID - m).contains(&x) && (m..=GRID - m).contains(&y), "{}: ({x}, {y}) leaves the grid", icon.id); + } + if p.fill { + assert!(convex(&p.points), "{}: filled outlines must be convex", icon.id); + } + } + let img = render(icon, 48, Rgb::BLACK); + let inked = img.pixels.as_chunks::<4>().0.iter().filter(|px| px[3] > 0).count(); + assert!(inked > 48, "{} renders blank", icon.id); + // Nothing touches the image's edge (margins are part of the design). + let edge = |x: usize, y: usize| img.pixels.get((y * 48 + x) * 4 + 3).copied().unwrap_or(0); + assert!((0..48).all(|k| edge(k, 0) == 0 && edge(k, 47) == 0 && edge(0, k) == 0 && edge(47, k) == 0), "{} is clipped", icon.id); + } + assert!(Category::ALL.iter().all(|c| ICONS.iter().any(|i| i.category == *c)), "every category has icons"); + } + + #[test] + fn search_matches_words_names_keywords_and_categories() { + let ids = |q: &str, c: Option| search(q, c).iter().map(|i| i.id).collect::>(); + assert_eq!(search("", None).len(), ICONS.len()); + assert_eq!(ids("Envelope", None), ["mail"]); + assert!(ids("weather", None).contains(&"rain")); + assert_eq!(ids("arrow up", None), ["arrow-up"]); + assert!(ids("help", Some(Category::Symbols)).contains(&"question")); + assert!(ids("help", Some(Category::Arrows)).is_empty()); + assert!(ids("zzz-nothing", None).is_empty()); + assert_eq!(Category::parse(" weather "), Some(Category::Weather)); + assert!(find("heart").is_some() && find("nope").is_none()); + assert!(flatten("M1 1 L").is_err() && flatten("X 1").is_err() && flatten("L 2 2").is_err() && flatten("5").is_err()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0d24da1..e61770b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,6 +15,8 @@ use wordcraft_layout::Page; use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_display}; use wordcraft_metafile::{Item, Picture, PlacedItem, Seg}; +pub mod icon_lib; + /// Worker threads for rasterising (0 on the web, where there are no threads). pub fn default_threads() -> u16 { #[cfg(target_arch = "wasm32")] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 83aa63b..17eae22 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,6 +1,6 @@ //! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark, //! Word Count, Zoom, Watermark, New/Modify Style, Manage Styles, New/Modify Table Style, Table -//! Properties, Enclose Characters, Command search, Paste Special, About, Save Changes, the password to open a +//! Properties, Enclose Characters, Command search, Icons, Paste Special, About, Save Changes, the password to open a //! document and Encrypt with Password, and the mail-merge Recipient List, Insert Merge Field, Find //! Recipient, merge rules, Match Fields and Check for Errors (Customize Keyboard is in //! `dialogs_keyboard`, Customize Ribbon in `dialogs_ribbon`). Every dialog ends by running a @@ -164,6 +164,12 @@ pub enum Dialog { }, /// Columns, Symbol and Field ([`crate::dialogs_insert`], #321). Insert(Box), + /// Insert › Icons: the icon library, searchable and filtered by category (empty for all). + Icons { + query: String, + category: String, + selected: String, + }, /// Paste Special: the clipboard's formats and the chosen one. The clipboard payload stays out /// of the serialized dialog state (it can be megabytes). PasteSpecial { @@ -672,6 +678,7 @@ impl Dialog { Dialog::TableStyle { .. } => "modifyTableStyle", Dialog::Commands { .. } => "commands", Dialog::Insert(d) => d.name(), + Dialog::Icons { .. } => "icons", Dialog::PasteSpecial { .. } => "pasteSpecial", Dialog::About { .. } => "about", Dialog::SaveChanges { .. } => "saveChanges", @@ -825,6 +832,7 @@ impl Dialog { Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } } "commands" => Dialog::Commands { query: String::new() }, + "icons" => Dialog::Icons { query: String::new(), category: String::new(), selected: String::new() }, "tableProperties" => { let form = Box::new(TableForm::read(app)?); Dialog::TableProperties { basis: form.clone(), form } @@ -1030,6 +1038,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::TableStyle { .. } => "Modify Table Style", Dialog::Commands { .. } => "Search Commands", Dialog::Insert(d) => d.title(), + Dialog::Icons { .. } => "Icons", Dialog::PasteSpecial { .. } => "Paste Special", Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", @@ -1763,6 +1772,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } close || ui.input(|i| i.key_pressed(egui::Key::Escape)) } + Dialog::Icons { query, category, selected } => icons_body(app, ui, query, category, selected), Dialog::PasteSpecial { formats, choice, payload } => { ui.label(egui::RichText::new(tl!("Paste as:")).font(semibold(12.5))); if formats.is_empty() { @@ -2439,6 +2449,69 @@ fn describe(st: &Value) -> String { parts.join(", ") } +/// path data; Insert (or a double click) runs `insert.icon`. Returns true to close. +fn icons_body(app: &mut WordApp, ui: &mut Ui, query: &mut String, category: &mut String, selected: &mut String) -> bool { + use wordcraft_render::icon_lib::{self, Category}; + let t = Tokens::get(ui.ctx()); + ui.set_width(560.0); + // Typing goes to the search box (never to the document behind the dialog). + ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Search icons")).desired_width(560.0)).request_focus(); + ui.add_space(4.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + if ui.selectable_label(category.is_empty(), tl!("All")).clicked() { + category.clear(); + } + for c in Category::ALL { + if ui.selectable_label(*category == c.name(), tl!(c.name())).clicked() { + *category = c.name().to_string(); + } + } + }); + ui.add_space(4.0); + let hits = icon_lib::search(query, Category::parse(category)); + let mut insert = false; + egui::ScrollArea::vertical().max_height(300.0).min_scrolled_height(300.0).auto_shrink([true, false]).show(ui, |ui| { + if hits.is_empty() { + ui.label(egui::RichText::new(tl!("No icons match your search.")).color(t.text_dim)); + } + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 6.0); + for icon in &hits { + let (rect, resp) = ui.allocate_exact_size(vec2(56.0, 56.0), Sense::click()); + let on = *selected == icon.id; + let fill = if on { + t.checked + } else if resp.hovered() { + t.hover + } else { + egui::Color32::TRANSPARENT + }; + let edge = if on { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.border) }; + ui.painter().rect(rect, 4.0, fill, edge, egui::StrokeKind::Inside); + crate::icons::library(ui.painter(), rect.shrink(12.0), icon, t.text); + let resp = resp.on_hover_text(icon.name); + if resp.clicked() { + *selected = icon.id.to_string(); + } + if resp.double_clicked() { + *selected = icon.id.to_string(); + insert = true; + } + } + }); + }); + let name = icon_lib::find(selected).map(|i| i.name).unwrap_or(""); + ui.add_space(4.0); + ui.label(egui::RichText::new(name).color(t.text_dim)); + let (ok, cancel) = buttons(ui, tl!("Insert")); + if (ok || insert) && !selected.is_empty() { + let _ = app.run("insert.icon", json!({"id": selected.as_str()})); + return true; + } + cancel +} + /// Enclose Characters' preview: the text in the chosen shape, shrunk or with the shape enlarged /// (drawn, so it doesn't depend on the interface font having ○ □ △ ◇). fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str) { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ea7d33c..0a8c728 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1410,6 +1410,18 @@ New Group Nuevo grupo Rename… Cambiar nombre… Ribbon and Quick Access Toolbar: Cinta de opciones y barra de acceso rápido: + Arrows Flechas + Communication Comunicación + Office Oficina + People Personas + Nature Naturaleza + Weather Tiempo + Technology Tecnología + Transport Transporte + Icon Color Color del icono + Search icons Buscar iconos + No icons match your search. Ningún icono coincide con la búsqueda. + Recolor a one-color picture, such as an icon Cambiar el color de una imagen de un solo color, como un icono Enclose Characters Encerrar caracteres Shrink text Reducir texto Enlarge symbol Agrandar símbolo diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 01ed34e..6b0cea8 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1396,6 +1396,18 @@ Ribbon Lint Ribbon and Quick Access Toolbar: Lint ja kiirpääsuriba: Ribbon tabs Lindi vahekaardid + Arrows Nooled + Communication Suhtlus + Office Kontor + People Inimesed + Nature Loodus + Weather Ilm + Technology Tehnoloogia + Transport Transport + Icon Color Ikooni värv + Search icons Otsi ikoone + No icons match your search. Otsingule vastavaid ikoone pole. + Recolor a one-color picture, such as an icon Muuda ühevärvilise pildi, näiteks ikooni, värvi Enclose Characters Ümbritsetud märgid Shrink text Vähenda teksti Enlarge symbol Suurenda sümbolit diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 595030b..75917ac 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1394,6 +1394,18 @@ New Group 新しいグループ Rename… 名前の変更… Ribbon and Quick Access Toolbar: リボンとクイック アクセス ツール バー: + Arrows 矢印 + Communication コミュニケーション + Office オフィス + People 人物 + Nature 自然 + Weather 天気 + Technology テクノロジー + Transport 乗り物 + Icon Color アイコンの色 + Search icons アイコンを検索 + No icons match your search. 検索に一致するアイコンはありません。 + Recolor a one-color picture, such as an icon アイコンなどの単色の画像の色を変更 Enclose Characters 囲い文字 Shrink text 文字を縮小 Enlarge symbol 記号を拡大 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1768c0b..525178f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1406,6 +1406,18 @@ New Group Novo Grupo Rename… Renomear… Ribbon and Quick Access Toolbar: Faixa de Opções e Barra de Acesso Rápido: + Arrows Setas + Communication Comunicação + Office Escritório + People Pessoas + Nature Natureza + Weather Clima + Technology Tecnologia + Transport Transporte + Icon Color Cor do Ícone + Search icons Pesquisar ícones + No icons match your search. Nenhum ícone corresponde à pesquisa. + Recolor a one-color picture, such as an icon Mudar a cor de uma imagem de uma só cor, como um ícone Enclose Characters Envolver caracteres Shrink text Reduzir texto Enlarge symbol Ampliar símbolo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 965c654..b4b4412 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1790,3 +1790,14 @@ preview Title Пример Left Only Только слева Right Only Только справа Largest Only Только по большей стороне + Communication Связь + Office Офис + People Люди + Nature Природа + Weather Погода + Technology Технологии + Transport Транспорт + Icon Color Цвет значка + Search icons Поиск значков + No icons match your search. Нет значков, соответствующих поиску. + Recolor a one-color picture, such as an icon Перекрасить одноцветный рисунок, например значок diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index c04fe18..2c687e1 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1404,6 +1404,18 @@ New Group Nova grupa Rename… Preimenuj… Ribbon and Quick Access Toolbar: Traka i traka sa alatkama za brzi pristup: + Arrows Strelice + Communication Komunikacija + Office Kancelarija + People Ljudi + Nature Priroda + Weather Vreme + Technology Tehnologija + Transport Prevoz + Icon Color Boja ikonice + Search icons Pretraži ikonice + No icons match your search. Nijedna ikonica ne odgovara pretrazi. + Recolor a one-color picture, such as an icon Promeni boju jednobojne slike, kao što je ikonica Enclose Characters Uokviri znakove Shrink text Smanji tekst Enlarge symbol Uvećaj simbol diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index f4ec8c9..a994d51 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1408,6 +1408,18 @@ New Group Нова група Rename… Преименуј… Ribbon and Quick Access Toolbar: Трака и трака са алаткама за брзи приступ: + Arrows Стрелице + Communication Комуникација + Office Канцеларија + People Људи + Nature Природа + Weather Време + Technology Технологија + Transport Превоз + Icon Color Боја иконице + Search icons Претражи иконице + No icons match your search. Ниједна иконица не одговара претрази. + Recolor a one-color picture, such as an icon Промени боју једнобојне слике, као што је иконица Enclose Characters Уоквири знакове Shrink text Смањи текст Enlarge symbol Увећај симбол diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1aaefb2..c29774d 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1408,3 +1408,15 @@ Left Only Yalnızca Sol Right Only Yalnızca Sağ Largest Only Yalnızca En Geniş + Arrows Oklar + Communication İletişim + Office Ofis + People İnsanlar + Nature Doğa + Weather Hava Durumu + Technology Teknoloji + Transport Ulaşım + Icon Color Simge Rengi + Search icons Simgelerde ara + No icons match your search. Aramanızla eşleşen simge yok. + Recolor a one-color picture, such as an icon Simge gibi tek renkli bir resmi yeniden renklendir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index dd00af8..836e765 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1406,6 +1406,18 @@ New Group Нова група Rename… Перейменувати… Ribbon and Quick Access Toolbar: Стрічка й панель швидкого доступу: + Arrows Стрілки + Communication Спілкування + Office Офіс + People Люди + Nature Природа + Weather Погода + Technology Технології + Transport Транспорт + Icon Color Колір піктограми + Search icons Пошук піктограм + No icons match your search. Немає піктограм, що відповідають пошуку. + Recolor a one-color picture, such as an icon Змінити колір одноколірного зображення, наприклад піктограми Enclose Characters Обведення символів Shrink text Зменшити текст Enlarge symbol Збільшити символ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a585901..8736777 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1406,6 +1406,18 @@ New Group 新建组 Rename… 重命名… Ribbon and Quick Access Toolbar: 功能区和快速访问工具栏: + Arrows 箭头 + Communication 通信 + Office 办公 + People 人物 + Nature 自然 + Weather 天气 + Technology 科技 + Transport 交通 + Icon Color 图标颜色 + Search icons 搜索图标 + No icons match your search. 没有与搜索匹配的图标。 + Recolor a one-color picture, such as an icon 为图标等单色图片重新着色 Enclose Characters 带圈字符 Shrink text 缩小文字 Enlarge symbol 增大圈号 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bc630b6..e65d67a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1395,6 +1395,18 @@ New Group 新增群組 Rename… 重新命名… Ribbon and Quick Access Toolbar: 功能區和快速存取工具列: + Arrows 箭頭 + Communication 通訊 + Office 辦公 + People 人物 + Nature 自然 + Weather 天氣 + Technology 科技 + Transport 交通 + Icon Color 圖示色彩 + Search icons 搜尋圖示 + No icons match your search. 沒有符合搜尋的圖示。 + Recolor a one-color picture, such as an icon 為圖示等單色圖片重新著色 Enclose Characters 圍繞字元 Shrink text 縮小文字 Enlarge symbol 放大符號 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 32a7673..22b2e80 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1172,3 +1172,26 @@ impl ThenLines for () { pen.lines(x0, x1, ys); } } + +/// Draw an Insert › Icons library icon into `r` in `c`, from the same path data the inserted +/// picture is rendered from (`wordcraft_render::icon_lib`). +pub fn library(p: &Painter, r: Rect, icon: &wordcraft_render::icon_lib::Icon, c: Color32) { + use wordcraft_render::icon_lib::{GRID, STROKE}; + let s = r.width().min(r.height()) / GRID; + let o = r.center() - vec2(GRID / 2.0 * s, GRID / 2.0 * s); + let stroke = Stroke::new(STROKE * s, c); + for piece in icon.pieces() { + let pts: Vec = piece.points.iter().map(|q| pos2(o.x + q[0] * s, o.y + q[1] * s)).collect(); + if piece.fill { + p.add(Shape::convex_polygon(pts, c, Stroke::NONE)); + } else if piece.closed { + p.add(Shape::closed_line(pts, stroke)); + } else { + // Round caps, as on the inserted picture. + for end in [pts.first(), pts.last()].into_iter().flatten() { + p.circle_filled(*end, stroke.width / 2.0, c); + } + p.add(Shape::line(pts, stroke)); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f426943..fa77836 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1243,6 +1243,19 @@ mod tests { WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) } + /// Insert › Icons (#309) opens the icon picker from the ribbon; an id inserts without it. + #[test] + fn icons_button_opens_the_picker_and_ids_insert_directly() { + let mut a = app(); + a.run("insert.icon", json!({})).unwrap(); + assert_eq!(a.dialog.as_ref().map(dialogs::Dialog::name), Some("icons")); + a.dialog = None; + let r = a.run("insert.icon", json!({"id": "sun", "size": 36})).unwrap(); + assert_eq!(r["icon"], "sun"); + assert!(a.dialog.is_none(), "an id inserts without the dialog"); + assert!(a.selected_picture_media().is_some()); + } + /// Table Layout › Properties (#44) opens the dialog from the ribbon; OK sends only what /// changed, as one `table.properties` step. Scripts with settings never see the dialog. #[test] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 62385a3..d54d71d 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1425,6 +1425,13 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { mi(ui, app, "Sepia", "picture.color", json!({"mode": "sepia"})); mi(ui, app, "Washout", "picture.color", json!({"mode": "washout"})); }); + menu_button(ui, app, "icons", Some("Icon Color"), "Recolor a one-color picture, such as an icon", true, |ui, app| { + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(hex) = color_grid(ui, &theme) { + let _ = app.run("picture.iconColor", json!({"color": hex})); + ui.close(); + } + }); menu_button(ui, app, "picture", Some("Transparency"), "Picture transparency", true, |ui, app| { for v in [0.0, 15.0, 30.0, 50.0, 65.0, 80.0, 95.0] { mi(ui, app, &format!("{v:.0}%"), "picture.transparency", json!({"percent": v})); diff --git a/docs/gaps.md b/docs/gaps.md index 6b5a835..d14d7ea 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) | | 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), rotated text-box text (free rotation and flips landed, #332; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | +| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, Drawing Canvas | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/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) | | 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) | @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Insert › Icons landed (#309); removed from the objects gap | | 2026-10-11 | trivial | #24 narrowed: Customize Ribbon and Quick Access Toolbar customization landed (#379); the default shortcuts remain | | 2026-10-11 | trivial | #24 narrowed: Customize Keyboard landed (#368); Customize Ribbon and the Quick Access Toolbar remain | | 2026-10-11 | trivial | #24: Word's field keys (Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11) (#339) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 14faed0..811ef45 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,14 +2,14 @@ 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. -**420 of 448 catalog features have commands (93.8%).** +**421 of 448 catalog features have commands (94.0%).** | Tab | Live | Total | |---|---|---| | File | 20 | 20 | | Quick Access | 4 | 4 | | Home | 101 | 102 | -| Insert | 43 | 53 | +| Insert | 44 | 53 | | Draw | 5 | 11 | | Design | 10 | 10 | | Layout | 19 | 19 | @@ -35,7 +35,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- ### Insert - Illustrations › Online Pictures (`insert.onlinePicture`) -- Illustrations › Icons (`insert.icon`) - Illustrations › 3D Models (`insert.model3d`) - Illustrations › SmartArt (`insert.smartArt`) - Illustrations › Chart (`insert.chart`) diff --git a/docs/roadmap.md b/docs/roadmap.md index 3a6dc83..b5089be 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -47,7 +47,7 @@ partial rows are fidelity gaps (beta work), not broken workflows. WordCraft stay | M0 | Skeleton and vertical slice (model, layout, render, engine, Word-style UI, CLI, MCP, web) | done | — | | M1 | DOCX I/O | done (first version); real-world corpus not started; charts/SmartArt/OLE dropped | 45–75 h | | M2 | Home tab | done except Paste Special, Clipboard pane, Style Inspector, Manage Styles, Asian typography (PRs open for four) | 5–10 h | -| M3 | Insert tab | mostly done; equations done (#191); charts, SmartArt, icons, 3D, screenshot, online media missing | 50–80 h | +| M3 | Insert tab | mostly done; equations done (#191), icons done (#309); charts, SmartArt, 3D, screenshot, online media missing | 50–80 h | | M4 | Layout and Design tabs | done except column balancing, group | 6–10 h | | M5 | Tables | done (row splitting, floating tables, style formatting); custom table styles (#256); Draw Table and Eraser (#303) | 6–10 h | | M6 | References | first version done (TOC fields, cross-references, Zotero); footnote continuation missing; all 12 bibliography styles (#383) | 15–25 h | @@ -71,6 +71,7 @@ languages, screen-reader access, ecosystem decisions (VBA, add-ins, cloud), an i | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | M3: icons landed (#309) | | 2026-10-11 | trivial | M11: contour (Tight/Through) wrap done (#354) | | 2026-10-11 | trivial | M6: the eight missing bibliography styles landed (#383) | | 2026-10-11 | trivial | M7: formatting revisions landed (#41) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4a8bc74..cba08a1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -68,9 +68,9 @@ full parity for the area. | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | | 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 or CSV (#247); no Excel/Outlook/Contacts 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). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, Icons, online pictures, screenshot. | +| 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), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309). Missing: tight/through contour wrap, rotated text-box text, group (#267 open), shape effects, WordArt, connectors, Drawing Canvas, 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 | 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, ink to shape/math and replay 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. | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Insert › Icons (#309): catalog 402/434 → 403/434 (92.9%); moved from missing to done under pictures and shapes | | 2026-10-11 | trivial | Enclose Characters (#297): catalog 402/434 → 403/434 (92.9%) | | 2026-10-11 | trivial | Phonetic Guide (ruby) landed (#288): catalog +1 feature | | 2026-10-11 | trivial | Shortcut count 101 → 125 (#330) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 869921e..e656748 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -16,7 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 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 | | Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 | | Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | -| Modal dialogs | **37** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 | +| Modal dialogs | **38** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors) plus panes and menus for the rest | ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~40% | 20–30 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility, Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 | | Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 | @@ -52,6 +52,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-10 | trivial | Shortcuts: Alt+Shift+Up/Down move paragraphs, Alt+Shift+Left/Right promote/demote (⌃⇧ arrows on macOS), Shift+F5 Go Back, Alt+X Toggle Unicode (#343) | | 2026-10-10 | trivial | Rotation handle on pictures, shapes and text boxes; Rotate menu (90° and flips) and a Rotation box in Picture/Shape Format › Size (#332) | | 2026-10-10 | trivial | System interface theme keeps following OS appearance changes (no longer pins the macOS window); Dark page no longer darkens the interface (#311, #312) | +| 2026-10-11 | trivial | Insert › Icons picker: search, category chips, icons drawn live from the library's path data; Picture Format › Icon Color (#309) | | 2026-10-10 | trivial | AutoSave switch greyed out with a tooltip saying why where AutoSave can't save; Save As from it (#196, #176) | | 2026-10-10 | trivial | Table Properties dialog and Table Layout Height/Width boxes (#44) | | 2026-10-10 | minor | Merged main: Style Inspector, Column Selection, zoom buttons, inertial scrolling, File Info landed |