diff --git a/ROADMAP.md b/ROADMAP.md index 12a3f93..a7da4f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,7 +14,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **460 / 476 (96.6%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **473 / 489 (96.7%)** | 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) | @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) | | 2026-10-11 | Vertical sections (#491): Layout › Text Direction turns the section (or the cell or text box the caret is in); lines run top to bottom, right to left, ideographs upright; `w:textDirection` in `w:sectPr` read and written | | 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly | | 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) | @@ -175,6 +176,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Progress log: Outline view (#493) | | 2026-10-11 | trivial | Progress log: vertical sections (#491) | | 2026-10-11 | trivial | Progress log: Paragraph dialog options, mirror indents (#512) | | 2026-10-11 | trivial | Progress log: content controls (#381) | diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 5b289fa..775d961 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -405,6 +405,19 @@ Equation|Equation Options|Change to Inline / Display|equation.display Equation|Equation Options|Justification|equation.justify Equation|Equation Options|Equation Number (#)|equation.number Equation|Equation Options|Structure Commands|equation.structure +Outlining|Outline Tools|Promote to Heading 1|outline.promoteToHeading1 +Outlining|Outline Tools|Promote|outline.promote +Outlining|Outline Tools|Outline Level|outline.level +Outlining|Outline Tools|Demote|outline.demote +Outlining|Outline Tools|Demote to Body Text|outline.demoteToBody +Outlining|Outline Tools|Move Up|outline.moveUp +Outlining|Outline Tools|Move Down|outline.moveDown +Outlining|Outline Tools|Expand|outline.expand +Outlining|Outline Tools|Collapse|outline.collapse +Outlining|Outline Tools|Show Level|outline.showLevel +Outlining|Outline Tools|Show Text Formatting|outline.showFormatting +Outlining|Outline Tools|Show First Line Only|outline.firstLineOnly +Outlining|Close|Close Outline View|outline.close Picture Format|Adjust|Remove Background|picture.removeBackground Picture Format|Adjust|Corrections|picture.corrections Picture Format|Adjust|Color|picture.color diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ee3a900..9c976ed 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -25,6 +25,7 @@ pub mod mailings; pub mod moves; pub mod objects; pub mod options; +pub mod outline; pub mod page; pub mod para; pub mod paste; @@ -62,6 +63,7 @@ pub fn registry() -> Registry { v.extend(lists::specs()); v.extend(inspector::specs()); v.extend(view::specs()); + v.extend(outline::specs()); v.extend(insert::specs()); v.extend(equation::specs()); v.extend(page::specs()); diff --git a/crates/engine/src/cmd/outline.rs b/crates/engine/src/cmd/outline.rs new file mode 100644 index 0000000..21dcecb --- /dev/null +++ b/crates/engine/src/cmd/outline.rs @@ -0,0 +1,509 @@ +//! Outline view and the Outlining tab: outline levels (Heading 1–9 or body text), promote and +//! demote, moving paragraphs with what is under them, expand and collapse, Show Level, Show First +//! Line Only, Show Text Formatting. What's collapsed and shown is view state ([`OutlineState`]), +//! never saved in the document; level changes and moves are ordinary undoable edits. + +use std::collections::BTreeSet; +use std::hash::{Hash, Hasher}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use wordcraft_doc::{Block, Document, Pos, StoryRef}; +use wordcraft_layout::ViewMode; +use wordcraft_layout::outline::{self as ol, BODY, OutlineView}; + +use super::sel_result; +use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; + +/// What Outline view shows (part of [`crate::ViewState`]). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OutlineState { + /// Collapsed headings: body block index and a hash of the heading's text, so they are found + /// again after edits move them (see [`after_command`]). + pub collapsed: Vec<(usize, u64)>, + /// Show Level: 1–9, or 0 for all levels. + pub show_level: u8, + /// Show First Line Only (body text). + pub first_line_only: bool, + /// Show Text Formatting turned off. + pub hide_formatting: bool, +} + +impl OutlineState { + /// What the layout needs. + pub fn view(&self) -> OutlineView { + OutlineView { + collapsed: self.collapsed.iter().map(|(i, _)| *i).collect(), + show_level: self.show_level, + first_line_only: self.first_line_only, + plain: self.hide_formatting, + } + } +} + +fn in_outline(s: &Session) -> Option<&'static str> { + (s.view.mode != ViewMode::Outline).then_some("only in Outline view") +} + +const LEVEL: &str = r#"{"level": 1-9 | "body"}"#; +const BLOCK: &str = r#"{"block"?: body paragraph index (default: the selection)}"#; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("outline.promoteToHeading1", "Promote to Heading 1", "Outlining › Outline Tools", |s, _| set_levels(s, Some(1))), + CommandSpec::new("outline.promote", "Promote", "Outlining › Outline Tools", |s, _| s.run("para.promote", &json!({}))), + CommandSpec::new("outline.demote", "Demote", "Outlining › Outline Tools", |s, _| s.run("para.demote", &json!({}))), + CommandSpec::new("outline.demoteToBody", "Demote to Body Text", "Outlining › Outline Tools", |s, _| set_levels(s, None)), + CommandSpec::new("outline.level", "Outline Level", "Outlining › Outline Tools", |s, v| { + let level = match v.get("level") { + Some(Value::String(b)) if b.eq_ignore_ascii_case("body") => None, + Some(x) => match x.as_u64() { + Some(n @ 1..=9) => Some(n as u8), + _ => return Err(CmdError::Params(format!("`level` must be 1–9 or \"body\": {LEVEL}"))), + }, + None => return Err(CmdError::Params(format!("`level` is required: {LEVEL}"))), + }; + set_levels(s, level) + }) + .params(LEVEL), + CommandSpec::new("outline.moveUp", "Move Up", "Outlining › Outline Tools", |s, _| s.run("para.moveUp", &json!({}))), + CommandSpec::new("outline.moveDown", "Move Down", "Outlining › Outline Tools", |s, _| s.run("para.moveDown", &json!({}))), + CommandSpec::new("outline.expand", "Expand", "Outlining › Outline Tools", |s, v| expand(s, v, false)) + .key("Alt+Shift+=") + .params(BLOCK) + .when(in_outline) + .pure(), + CommandSpec::new("outline.collapse", "Collapse", "Outlining › Outline Tools", |s, v| collapse(s, v, false)) + .key("Alt+Shift+-") + .params(BLOCK) + .when(in_outline) + .pure(), + // Double-clicking a heading's symbol: all of it collapsed, or all of it expanded. + CommandSpec::new("outline.toggle", "Expand or Collapse Heading", "Outlining › Outline Tools", |s, v| { + let collapsed = targets(s, v).first().is_some_and(|h| s.view.outline.collapsed.iter().any(|(i, _)| i == h)); + if collapsed { expand(s, v, true) } else { collapse(s, v, true) } + }) + .params(BLOCK) + .when(in_outline) + .pure(), + // Clicking a symbol: the paragraph and everything under it. + CommandSpec::new("outline.selectSubtree", "Select Heading and Content", "Outlining › Outline Tools", select_subtree) + .params(BLOCK) + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel", "Show Level", "Outlining › Outline Tools", |s, v| { + let level = match v.get("level") { + Some(Value::String(a)) if a.eq_ignore_ascii_case("all") => 0, + Some(x) => match x.as_u64() { + Some(n @ 1..=9) => n as u8, + _ => return Err(CmdError::Params(r#"`level` must be 1–9 or "all""#.into())), + }, + None => return Err(CmdError::Params(r#"`level` is required: 1–9 or "all""#.into())), + }; + show_level(s, level) + }) + .params(r#"{"level": 1-9 | "all"}"#) + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel1", "Show Level 1", "Outlining › Outline Tools", |s, _| show_level(s, 1)) + .key("Alt+Shift+1") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel2", "Show Level 2", "Outlining › Outline Tools", |s, _| show_level(s, 2)) + .key("Alt+Shift+2") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel3", "Show Level 3", "Outlining › Outline Tools", |s, _| show_level(s, 3)) + .key("Alt+Shift+3") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel4", "Show Level 4", "Outlining › Outline Tools", |s, _| show_level(s, 4)) + .key("Alt+Shift+4") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel5", "Show Level 5", "Outlining › Outline Tools", |s, _| show_level(s, 5)) + .key("Alt+Shift+5") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel6", "Show Level 6", "Outlining › Outline Tools", |s, _| show_level(s, 6)) + .key("Alt+Shift+6") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel7", "Show Level 7", "Outlining › Outline Tools", |s, _| show_level(s, 7)) + .key("Alt+Shift+7") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel8", "Show Level 8", "Outlining › Outline Tools", |s, _| show_level(s, 8)) + .key("Alt+Shift+8") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showLevel9", "Show Level 9", "Outlining › Outline Tools", |s, _| show_level(s, 9)) + .key("Alt+Shift+9") + .when(in_outline) + .pure(), + CommandSpec::new("outline.showAll", "Show All Levels", "Outlining › Outline Tools", |s, _| show_level(s, 0)) + .key("Alt+Shift+A") + .when(in_outline) + .pure(), + CommandSpec::new("outline.firstLineOnly", "Show First Line Only", "Outlining › Outline Tools", |s, v| { + s.view.outline.first_line_only = p::bool(v, "value").unwrap_or(!s.view.outline.first_line_only); + Ok(json!({"value": s.view.outline.first_line_only})) + }) + .params(r#"{"value"?: bool}"#) + .when(in_outline) + .pure(), + CommandSpec::new("outline.showFormatting", "Show Text Formatting", "Outlining › Outline Tools", |s, v| { + let on = p::bool(v, "value").unwrap_or(s.view.outline.hide_formatting); + s.view.outline.hide_formatting = !on; + Ok(json!({"value": on})) + }) + .params(r#"{"value"?: bool}"#) + .when(in_outline) + .pure(), + CommandSpec::new("outline.close", "Close Outline View", "Outlining › Close", |s, _| s.run("view.printLayout", &json!({}))) + .when(in_outline) + .pure(), + CommandSpec::new("outline.state", "Outline State", "Outlining", |s, _| { + let levels = ol::levels(&s.doc); + let hidden = ol::hidden(&levels, &s.view.outline.view()); + let shown: Vec = hidden.iter().enumerate().filter(|(_, h)| !**h).map(|(i, _)| i).collect(); + Ok(json!({ + "levels": levels, + "shown": shown, + "collapsed": s.view.outline.view().collapsed, + "showLevel": s.view.outline.show_level, + "firstLineOnly": s.view.outline.first_line_only, + "showFormatting": !s.view.outline.hide_formatting, + })) + }) + .pure(), + ] +} + +/// The top-level body blocks the selection touches (none outside the body text or in a table). +fn selected_blocks(s: &Session) -> Option<(usize, usize)> { + let (a, b) = s.sel.ordered(); + if a.story != StoryRef::Body || b.story != StoryRef::Body { + return None; + } + let i0 = *a.path.0.first()? as usize; + let mut i1 = *b.path.0.first()? as usize; + // A selection ending at the very start of a paragraph doesn't touch it. + if i1 > i0 && b.off == 0 && b.path.0.len() == 1 { + i1 -= 1; + } + Some((i0, i1)) +} + +/// The headings a command acts on: `block` when given, else the selected headings, else the +/// heading the caret is under. +fn targets(s: &Session, v: &Value) -> Vec { + let levels = ol::levels(&s.doc); + let heading = |i: usize| levels.get(i).is_some_and(|l| *l < BODY); + if let Some(b) = v.get("block").and_then(Value::as_u64) { + let b = usize::try_from(b).unwrap_or(usize::MAX); + return if heading(b) { vec![b] } else { Vec::new() }; + } + let Some((i0, i1)) = selected_blocks(s) else { return Vec::new() }; + let sel: Vec = (i0..=i1.min(levels.len().saturating_sub(1))).filter(|i| heading(*i)).collect(); + if !sel.is_empty() { + return sel; + } + (0..=i0.min(levels.len().saturating_sub(1))).rev().find(|i| heading(*i)).into_iter().collect() +} + +fn text_hash(doc: &Document, i: usize) -> Option { + let Some(Block::Para(p)) = doc.body.get(i).map(|b| &**b) else { return None }; + let mut h = std::collections::hash_map::DefaultHasher::new(); + p.text.hash(&mut h); + Some(h.finish()) +} + +fn set_collapsed(s: &mut Session, set: &BTreeSet) { + s.view.outline.collapsed = set.iter().filter_map(|i| text_hash(&s.doc, *i).map(|h| (*i, h))).collect(); +} + +/// Show Level turned into collapsed headings, so single headings can then be expanded or +/// collapsed: the headings at the shown level with something under them are collapsed and every +/// level is shown. +fn settle_show_level(s: &mut Session) -> BTreeSet { + let mut set = s.view.outline.view().collapsed; + let n = s.view.outline.show_level; + if (1..=9).contains(&n) { + let levels = ol::levels(&s.doc); + let hidden = ol::hidden(&levels, &s.view.outline.view()); + for (i, l) in levels.iter().enumerate() { + if *l < BODY && *l <= n && !hidden.get(i).copied().unwrap_or(true) && ol::has_children(&levels, i) { + // A heading whose next shown paragraph is deeper keeps that open. + let deeper_shown = (i + 1..ol::subtree_end(&levels, i)).any(|j| !hidden.get(j).copied().unwrap_or(true)); + if !deeper_shown { + set.insert(i); + } + } + } + s.view.outline.show_level = 0; + } + set +} + +/// Expand: a collapsed heading opens (what was collapsed under it stays so); an open one shows +/// the shallowest collapsed level under it. `all`: the heading and everything under it opens. +fn expand(s: &mut Session, v: &Value, all: bool) -> CmdResult { + let targets = targets(s, v); + let mut set = settle_show_level(s); + let levels = ol::levels(&s.doc); + for h in targets { + let end = ol::subtree_end(&levels, h); + if all { + set.retain(|i| *i < h || *i >= end); + continue; + } + if set.remove(&h) { + continue; + } + let hidden = ol::hidden(&levels, &OutlineView { collapsed: set.clone(), ..Default::default() }); + let open: Vec = (h + 1..end).filter(|j| set.contains(j) && !hidden.get(*j).copied().unwrap_or(true)).collect(); + if let Some(top) = open.iter().filter_map(|j| levels.get(*j)).min().copied() { + for j in open { + if levels.get(j) == Some(&top) { + set.remove(&j); + } + } + } + } + set_collapsed(s, &set); + sel_result(s) +} + +/// Collapse: the deepest open level under a heading closes first; a heading with nothing open +/// under it closes itself. `all`: the heading closes at once. +fn collapse(s: &mut Session, v: &Value, all: bool) -> CmdResult { + let targets = targets(s, v); + let mut set = settle_show_level(s); + let levels = ol::levels(&s.doc); + for h in targets { + if !ol::has_children(&levels, h) || set.contains(&h) { + continue; + } + if all { + set.insert(h); + continue; + } + let hidden = ol::hidden(&levels, &OutlineView { collapsed: set.clone(), ..Default::default() }); + let open: Vec = (h + 1..ol::subtree_end(&levels, h)) + .filter(|j| { + levels.get(*j).is_some_and(|l| *l < BODY) + && ol::has_children(&levels, *j) + && !set.contains(j) + && !hidden.get(*j).copied().unwrap_or(true) + }) + .collect(); + match open.iter().filter_map(|j| levels.get(*j)).max().copied() { + Some(deepest) => { + for j in open { + if levels.get(j) == Some(&deepest) { + set.insert(j); + } + } + } + None => { + set.insert(h); + } + } + } + set_collapsed(s, &set); + sel_result(s) +} + +fn show_level(s: &mut Session, level: u8) -> CmdResult { + s.view.outline.show_level = level; + s.view.outline.collapsed.clear(); + Ok(json!({"showLevel": level})) +} + +/// The end of block `i` as a selection end: the end of its text, or for a table the start of what +/// follows it. +fn block_end(doc: &Document, i: usize) -> Pos { + match doc.body.get(i).map(|b| &**b) { + Some(Block::Para(p)) => Pos::body(i, p.text.len()), + _ => doc.clamp(&Pos::body(i.saturating_add(1), 0)), + } +} + +fn select_subtree(s: &mut Session, v: &Value) -> CmdResult { + let levels = ol::levels(&s.doc); + let start = match v.get("block").and_then(Value::as_u64) { + Some(b) => usize::try_from(b).unwrap_or(usize::MAX), + None => selected_blocks(s).map(|(i, _)| i).ok_or_else(|| CmdError::Failed("put the caret in the body text".into()))?, + }; + if start >= levels.len() { + return Err(CmdError::Params("`block` is past the end of the document".into())); + } + let end = ol::subtree_end(&levels, start).max(start + 1); + let anchor = s.doc.clamp(&Pos::body(start, 0)); + let focus = block_end(&s.doc, end - 1); + s.sel = Selection { anchor, focus }; + s.goal_x = None; + sel_result(s) +} + +/// Apply Heading `level` (or body text, `None`) to the selected paragraphs. +fn set_levels(s: &mut Session, level: Option) -> CmdResult { + let (a, b) = s.sel.ordered(); + let paths = s.doc.paths_between(&a, &b); + if paths.is_empty() { + return Err(CmdError::Failed("select paragraphs to change their level".into())); + } + for path in paths { + super::para::set_heading(s, a.story, &path, level)?; + } + sel_result(s) +} + +/// The headings hidden under collapsed headings in the selection, with their levels: promoting +/// or demoting a collapsed heading carries them along (Outline view only). +pub(crate) fn hidden_subheadings(s: &Session) -> Vec<(usize, u8)> { + if s.view.mode != ViewMode::Outline || s.view.outline.collapsed.is_empty() { + return Vec::new(); + } + let Some((i0, i1)) = selected_blocks(s) else { return Vec::new() }; + let levels = ol::levels(&s.doc); + let hidden = ol::hidden(&levels, &s.view.outline.view()); + let mut out = Vec::new(); + for &(h, _) in &s.view.outline.collapsed { + if h < i0 || h > i1 { + continue; + } + for j in h + 1..ol::subtree_end(&levels, h) { + if let Some(&l) = levels.get(j).filter(|l| **l < BODY) + && hidden.get(j).copied().unwrap_or(false) + && !out.iter().any(|(k, _)| *k == j) + { + out.push((j, l)); + } + } + } + out +} + +/// Move Up / Move Down in Outline view: the selected paragraphs and what is hidden under them +/// pass the next shown paragraph and what is hidden under it. `None` when the selection isn't in +/// top-level body paragraphs (the ordinary move then applies). +pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option { + if s.view.mode != ViewMode::Outline { + return None; + } + let (a, b) = s.sel.ordered(); + if a.story != StoryRef::Body || b.story != StoryRef::Body || a.path.0.len() != 1 || b.path.0.len() != 1 { + return None; + } + let (i0, i1) = selected_blocks(s)?; + let levels = ol::levels(&s.doc); + let hidden = ol::hidden(&levels, &s.view.outline.view()); + let is_hidden = |i: usize| hidden.get(i).copied().unwrap_or(false); + let mut r1 = i1; + while is_hidden(r1 + 1) { + r1 += 1; + } + let len = s.doc.body.len(); + let (lo, hi, shift): (usize, usize, isize) = if up { + let Some(v) = (0..i0).rev().find(|j| !is_hidden(*j)) else { return Some(Err(CmdError::Failed("already at the top".into()))) }; + (v, r1, -((i0 - v) as isize)) + } else { + let w = r1 + 1; + if w >= len { + return Some(Err(CmdError::Failed("already at the bottom".into()))); + } + let mut e = w; + while is_hidden(e + 1) { + e += 1; + } + (i0, e, (e - r1) as isize) + }; + let blocks = match s.doc.container_mut(StoryRef::Body, &a.path) { + Ok(b) => b, + Err(e) => return Some(Err(e.into())), + }; + let Some(slice) = blocks.get_mut(lo..=hi) else { return Some(Err(CmdError::Failed("bad range".into()))) }; + if slice.iter().any(|b| b.as_para().is_some_and(|p| p.section.is_some())) { + return Some(Err(CmdError::Failed("paragraphs don't move across a section break".into()))); + } + if up { + slice.rotate_left(i0 - lo); + } else { + slice.rotate_right(hi - r1); + } + // The selection moves with the paragraphs; an end just past them stays just past them. + let new_r1 = r1.saturating_add_signed(shift); + let map = |p: &Pos| -> Pos { + let i = p.path.last(); + if (i0..=r1).contains(&i) { + Pos { path: p.path.with_last(i.saturating_add_signed(shift)), ..p.clone() } + } else if new_r1 + 1 < len { + Pos::body(new_r1 + 1, 0) + } else { + block_end(&s.doc, new_r1) + } + }; + let (anchor, focus) = (map(&s.sel.anchor), map(&s.sel.focus)); + s.sel = Selection { anchor: s.doc.clamp(&anchor), focus: s.doc.clamp(&focus) }; + s.goal_x = None; + Some(sel_result(s)) +} + +/// After every command: collapsed headings are found again where edits moved them (by their +/// text; one whose text changed in place stays collapsed), and in Outline view a caret the +/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to +/// the nearest place it does, in the direction it was going. +pub(crate) fn after_command(s: &mut Session, before: &Selection) { + if !s.view.outline.collapsed.is_empty() { + let hashes: Vec> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect(); + let levels = ol::levels(&s.doc); + let mut used = BTreeSet::new(); + let mut out = Vec::new(); + for (i, h) in std::mem::take(&mut s.view.outline.collapsed) { + let free = |j: usize, used: &BTreeSet| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j); + let same = |j: usize| hashes.get(j).copied().flatten() == Some(h); + let found = if free(i, &used) && same(i) { + Some(i) + } else { + (0..levels.len()).filter(|j| free(*j, &used) && same(*j)).min_by_key(|j| j.abs_diff(i)).or_else(|| free(i, &used).then_some(i)) + }; + if let Some(j) = found { + used.insert(j); + out.push((j, hashes.get(j).copied().flatten().unwrap_or(h))); + } + } + out.sort_unstable(); + s.view.outline.collapsed = out; + } + if s.view.mode != ViewMode::Outline || !s.sel.is_collapsed() || s.sel.focus.story != StoryRef::Body { + return; + } + if s.layout().caret(&s.sel.focus).is_some() { + return; + } + let Some(&block) = s.sel.focus.path.0.first() else { return }; + let block = block as usize; + let levels = ol::levels(&s.doc); + let hidden = ol::hidden(&levels, &s.view.outline.view()); + let layout = s.layout(); + let shown_para = |j: usize| !hidden.get(j).copied().unwrap_or(true) && matches!(s.doc.body.get(j).map(|b| &**b), Some(Block::Para(_))); + let forward = before.focus <= s.sel.focus; + let next = (block + 1..levels.len()).find(|j| shown_para(*j)).map(|j| Pos::body(j, 0)); + let prev = || { + if shown_para(block) { + return Some(Pos::body(block, 0)); + } + // The end of the paragraph before, if it shows (not past a first line). + let j = (0..block).rev().find(|j| shown_para(*j))?; + let end = block_end(&s.doc, j); + Some(if layout.caret(&end).is_some() { end } else { Pos::body(j, 0) }) + }; + let target = if forward { next.or_else(prev) } else { prev().or(next) }; + if let Some(t) = target { + let t = s.doc.clamp(&t); + s.sel = Selection::caret(t); + } +} diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 8ad7405..fedfb48 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -590,6 +590,10 @@ pub fn border_sides(v: &Value, space_tb: f32, space_lr: f32) -> Result CmdResult { + // Outline view: what's collapsed moves with its heading, past whole shown paragraphs. + if let Some(r) = super::outline::move_shown(s, up) { + return r; + } let (a, b) = s.sel.ordered(); if a.story != b.story || a.path.parent() != b.path.parent() { return Err(CmdError::Failed("select paragraphs in one place (not across table cells) to move them".into())); @@ -653,6 +657,17 @@ fn heading_level(s: &Session, props: &ParaProps) -> Option { s.doc.styles.resolve_para(props).outline_level.filter(|l| *l < 9).map(|l| l + 1) } +/// Give paragraph `path` Heading `level` (1–9), or make it body text (`None`: the default +/// paragraph style); its own outline level goes, so the style's counts. +pub(crate) fn set_heading(s: &mut Session, story: wordcraft_doc::StoryRef, path: &wordcraft_doc::Path, level: Option) -> Result<(), CmdError> { + let style = level.map(|n| ensure_heading_style(s, n.clamp(1, 9))); + let para = s.doc.para_mut(story, path)?; + para.props.style = style; + para.props.outline_level = None; + para.touch(); + Ok(()) +} + /// Make sure the built-in `Heading{n}` style (and its linked character style) is in the /// document, for files that didn't bring it. fn ensure_heading_style(s: &mut Session, n: u8) -> String { @@ -676,6 +691,8 @@ fn ensure_heading_style(s: &mut Session, n: u8) -> String { /// promoted becomes a heading at the level of the heading before it (Heading 1 when there is /// none) and can't be demoted further; a list item changes list level like Shift+Tab / Tab. fn outline_step(s: &mut Session, promote: bool) -> CmdResult { + // Outline view: the headings hidden under a collapsed heading move a level with it. + let carried = super::outline::hidden_subheadings(s); let (a, b) = s.sel.ordered(); let paths = s.doc.paths_between(&a, &b); // The heading before the selection, for promoted body text. @@ -718,6 +735,12 @@ fn outline_step(s: &mut Session, promote: bool) -> CmdResult { if !changed { return Err(CmdError::Failed(if promote { "nothing to promote".into() } else { "nothing to demote".into() })); } + for (j, h) in carried { + let n = if promote { h.saturating_sub(1).max(1) } else { (h + 1).min(9) }; + if n != h { + set_heading(s, wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(j), Some(n))?; + } + } sel_result(s) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 965e98c..6647a5c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -24,13 +24,8 @@ pub fn specs() -> Vec { CommandSpec::new("view.printLayout", "Print Layout", "View › Views", |s, _| mode(s, ViewMode::Print, false)).key("Mod+Alt+P").pure(), CommandSpec::new("view.webLayout", "Web Layout", "View › Views", |s, _| mode(s, ViewMode::Web, false)).pure(), CommandSpec::new("view.draft", "Draft", "View › Views", |s, _| mode(s, ViewMode::Draft, false)).key("Mod+Alt+N").pure(), - CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| { - mode(s, ViewMode::Draft, false)?; - s.view.nav_pane = true; - sel_result(s) - }) - .key("Mod+Alt+O") - .pure(), + // The body as an outline of its headings, with the Outlining tab (`outline.*`). + CommandSpec::new("view.outline", "Outline", "View › Views", |s, _| mode(s, ViewMode::Outline, false)).key("Mod+Alt+O").pure(), CommandSpec::new("view.readMode", "Read Mode", "View › Views", |s, _| mode(s, ViewMode::Print, true)).pure(), CommandSpec::new("view.focus", "Focus", "View › Immersive", |s, v| toggle(s, v, |x| &mut x.focus_mode)).pure(), CommandSpec::new("view.ruler", "Ruler", "View › Show", |s, v| toggle(s, v, |x| &mut x.ruler)).pure(), diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index b9dc54b..fc91eda 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -92,6 +92,9 @@ pub struct ViewState { /// Show field codes instead of their results (`view.fieldCodes`, Alt+F9). #[serde(default)] pub field_codes: bool, + /// Outline view: collapsed headings, Show Level, first lines, text formatting. + #[serde(default)] + pub outline: crate::cmd::outline::OutlineState, /// Developer › Design Mode: every content control shows its frame and title. #[serde(default)] pub design_mode: bool, @@ -129,6 +132,7 @@ impl Default for ViewState { hide_ink: false, draw: Default::default(), field_codes: false, + outline: Default::default(), design_mode: false, } } @@ -246,7 +250,7 @@ pub struct Session { /// mail merge, recover), never on an edit. document_id: u64, cache: LayoutCache, - layout: Option<(u64, f32, ViewMode, Arc, bool)>, + layout: Option<(u64, f32, ViewMode, Arc, bool, Option)>, /// Picture edits: edited media key → original media key (Reset Picture). pub originals: std::collections::HashMap, /// Last mutating command (Repeat). @@ -505,11 +509,13 @@ impl Session { /// The current layout (recomputed when the document or view changed). pub fn layout(&mut self) -> Arc { let ww = self.view.web_width; - if let Some((r, w, m, l, pf)) = &self.layout + let outline = (self.view.mode == ViewMode::Outline).then(|| self.view.outline.view()); + if let Some((r, w, m, l, pf, o)) = &self.layout && *r == self.rev && (*w == ww || self.view.mode == ViewMode::Print) && *m == self.view.mode && *pf == self.view.proofing + && *o == outline { return l.clone(); } @@ -520,16 +526,16 @@ impl Session { hide_deleted: !self.view.show_markup || self.prefs.markup.hides_deletions(), proofing: self.view.proofing, field_codes: self.view.field_codes, + outline: outline.clone(), proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing)); + self.layout = Some((self.rev, ww, self.view.mode, l.clone(), self.view.proofing, outline)); l } /// A layout for output (PDF, images, print): no proofing marks, print view. pub fn export_layout(&self) -> Arc { - let opts = - LayoutOptions { view: ViewMode::Print, web_width: 0.0, show_hidden: false, hide_deleted: false, proofing: false, ..Default::default() }; + let opts = LayoutOptions { view: ViewMode::Print, ..Default::default() }; Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts)) } @@ -654,6 +660,7 @@ impl Session { self.column_mode = false; self.edit_marks.clear(); self.go_back_step = 0; + self.view.outline.collapsed.clear(); self.reset_history(); self.touch(); self.dirty = false; @@ -907,6 +914,8 @@ impl Session { if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") { self.note_edit(); } + // Outline view: collapsed headings follow edits; the caret stays where it shows. + crate::cmd::outline::after_command(self, &sel_before); // Extra selected objects last until something else is edited or selected. if !matches!(id, "select.addObject" | "arrange.ungroup") && (spec.mutates || self.sel != sel_before) { self.also_selected.clear(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9202dde..795d3ba 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3253,3 +3253,120 @@ fn note_options_per_section_and_convert_notes() { assert_eq!(s.doc.parts[&b].kind, PartKind::Footnote); assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err()); } + +/// Outline view of: Heading 1 "One", body "a", Heading 2 "Two", body "b", Heading 1 "Three", body "c". +fn outline_doc() -> Session { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "One\na\nTwo\nb\nThree\nc"})); + for (i, style) in [(0, "Heading1"), (2, "Heading2"), (4, "Heading1")] { + s.sel = crate::Selection::caret(Pos::body(i, 0)); + run(&mut s, "para.style", json!({"style": style})); + } + run(&mut s, "view.outline", json!({})); + s +} + +fn outline_shown(s: &mut Session) -> Vec { + let st = run(s, "outline.state", json!({})); + st["shown"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +/// The body blocks the outline layout draws. +fn laid_out_blocks(s: &mut Session) -> Vec { + let l = s.layout(); + let mut v: Vec = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + wordcraft_layout::Placed::Lines { story: StoryRef::Body, path, .. } => path.0.first().copied(), + _ => None, + }) + .collect(); + v.dedup(); + v +} + +#[test] +fn outline_level_changes_are_undoable() { + let mut s = outline_doc(); + let level = |s: &mut Session, i: usize| run(s, "outline.state", json!({}))["levels"][i].as_u64().unwrap(); + s.sel = crate::Selection::caret(Pos::body(1, 0)); + run(&mut s, "outline.level", json!({"level": 3})); + assert_eq!(level(&mut s, 1), 3); + run(&mut s, "edit.undo", json!({})); + assert_eq!(level(&mut s, 1), 10, "body text again"); + s.sel = crate::Selection::caret(Pos::body(2, 0)); + run(&mut s, "outline.promoteToHeading1", json!({})); + assert_eq!(level(&mut s, 2), 1); + run(&mut s, "outline.demoteToBody", json!({})); + assert_eq!(level(&mut s, 2), 10); + assert!(s.run("outline.level", &json!({"level": 12})).is_err()); + assert!(s.run("outline.level", &json!({})).is_err()); + // Every outline command survives junk parameters in Outline view. + for spec in cmd::registry().all().iter().filter(|c| c.id.starts_with("outline.")) { + for j in [json!(null), json!({"block": 99999, "level": "x", "value": 5}), json!({"block": -1, "level": 0})] { + let mut s = outline_doc(); + let _ = s.run(spec.id, &j); + let _ = s.layout(); + } + } +} + +#[test] +fn outline_show_level_filters_paragraphs() { + let mut s = outline_doc(); + assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); + assert_eq!(laid_out_blocks(&mut s), vec![0, 1, 2, 3, 4, 5]); + run(&mut s, "outline.showLevel", json!({"level": 1})); + assert_eq!(outline_shown(&mut s), vec![0, 4]); + assert_eq!(laid_out_blocks(&mut s), vec![0, 4]); + run(&mut s, "outline.showLevel2", json!({})); + assert_eq!(outline_shown(&mut s), vec![0, 2, 4]); + run(&mut s, "outline.showAll", json!({})); + assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); + // Word's keys, and only in Outline view. + let reg = cmd::registry(); + assert_eq!(reg.by_shortcut("Alt+Shift+1").map(|c| c.id), Some("outline.showLevel1")); + assert_eq!(reg.by_shortcut("Alt+Shift+A").map(|c| c.id), Some("outline.showAll")); + assert_eq!(reg.by_shortcut("Alt+Shift+=").map(|c| c.id), Some("outline.expand")); + assert_eq!(reg.by_shortcut("Alt+Shift+-").map(|c| c.id), Some("outline.collapse")); + run(&mut s, "outline.close", json!({})); + assert!(s.run("outline.showLevel1", &json!({})).is_err()); +} + +#[test] +fn outline_collapse_hides_descendants_and_expand_restores() { + let mut s = outline_doc(); + s.sel = crate::Selection::caret(Pos::body(0, 0)); + // The deepest open level closes first, then the heading itself. + run(&mut s, "outline.collapse", json!({})); + assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]); + run(&mut s, "outline.collapse", json!({})); + assert_eq!(outline_shown(&mut s), vec![0, 4, 5]); + assert_eq!(laid_out_blocks(&mut s), vec![0, 4, 5]); + // A caret moving into the collapsed text goes on to the next shown paragraph. + run(&mut s, "caret.end", json!({})); + run(&mut s, "caret.right", json!({})); + assert_eq!(s.sel.focus, Pos::body(4, 0)); + // Moving "Three" and "c" up passes "One" with everything collapsed under it, in one undo step. + s.sel = crate::Selection { anchor: Pos::body(4, 0), focus: Pos::body(5, 1) }; + run(&mut s, "outline.moveUp", json!({})); + assert_eq!(text(&s), "Three\nc\nOne\na\nTwo\nb"); + assert_eq!(outline_shown(&mut s), vec![0, 1, 2], "the collapsed heading moved and stays collapsed"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), "One\na\nTwo\nb\nThree\nc"); + assert_eq!(outline_shown(&mut s), vec![0, 4, 5]); + // Expand opens one level at a time; double-clicking the symbol (toggle) opens it all. + s.sel = crate::Selection::caret(Pos::body(0, 0)); + run(&mut s, "outline.expand", json!({})); + assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 4, 5]); + run(&mut s, "outline.expand", json!({})); + assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); + run(&mut s, "outline.toggle", json!({"block": 0})); + assert_eq!(outline_shown(&mut s), vec![0, 4, 5]); + run(&mut s, "outline.toggle", json!({"block": 0})); + assert_eq!(outline_shown(&mut s), vec![0, 1, 2, 3, 4, 5]); + // Clicking a symbol selects the heading with what is under it. + run(&mut s, "outline.selectSubtree", json!({"block": 0})); + assert_eq!(s.selected_text(), "One\na\nTwo\nb"); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 0aaba40..029fac8 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -14,6 +14,7 @@ pub mod fields; pub mod hit; pub mod kinsoku; pub mod math; +pub mod outline; pub mod para; mod table; mod textbox; @@ -44,8 +45,10 @@ pub enum ViewMode { Print, /// One page as wide as the window, no page breaks. Web, - /// Draft/Outline: like web, page breaks shown as rules. + /// Draft: like web, page breaks shown as rules. Draft, + /// Outline: like draft, the body as an outline of its headings ([`outline`]). + Outline, } #[derive(Clone, Debug, Default)] @@ -61,6 +64,8 @@ pub struct LayoutOptions { /// Show field codes (`{ PAGE }`, shaded) instead of field results (View › Field Codes, Alt+F9). /// A field whose own `code` flag is set shows the other one. pub field_codes: bool, + /// Outline view: what's collapsed and shown (used when `view` is [`ViewMode::Outline`]). + pub outline: Option, /// What the spelling check skips, and whether grammar is marked too (File › Options › Proofing). pub proof: ProofOptions, } @@ -373,6 +378,8 @@ fn hash_of(t: &T) -> u64 { fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default(); + // Outline view lays paragraphs out changed (no indents; plain without text formatting). + let outline = (opts.view == ViewMode::Outline).then(|| opts.outline.as_ref().is_some_and(|o| o.plain)); hash_of(&( s, opts.show_hidden, @@ -382,6 +389,7 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { opts.proof, wordcraft_proof::dictionary_generation(), hyphenation_key(doc), + outline, )) } @@ -1542,6 +1550,7 @@ fn layout_pass( }; let sections = doc.sections(); let web = opts.view != ViewMode::Print; + let outline = (opts.view == ViewMode::Outline).then(|| OutlinePass::new(doc, opts.outline.clone().unwrap_or_default())); let default_sect = SectionProps::default(); let mut web_sect; let first_sect = match sections.first() { @@ -1684,9 +1693,11 @@ fn layout_pass( while block <= last { let Some(b) = doc.body.get(block) else { break }; let before = balance.then(|| (pb.pages.len(), Snap::take(&mut pb, &ctx))); - match &**b { - Block::Para(p) => place_para(&mut ctx, &mut pb, p, block, body_top), - Block::Table(t) => place_table(&mut ctx, &mut pb, t, block, body_top), + match (&**b, &outline) { + (Block::Para(p), Some(o)) => place_outline_para(&mut ctx, &mut pb, p, block, o), + (Block::Table(_), Some(o)) if o.hidden.get(block).copied().unwrap_or(false) => {} + (Block::Para(p), None) => place_para(&mut ctx, &mut pb, p, block, body_top), + (Block::Table(t), _) => place_table(&mut ctx, &mut pb, t, block, body_top), } if let Some((pages, snap)) = before && pb.pages.len() != pages @@ -2572,6 +2583,62 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, pb.prev = Some((pl.rp.style.clone(), pl.rp.contextual_spacing, pl.rp.space_after)); } +/// The outline of the body for an Outline view pass. +struct OutlinePass { + view: outline::OutlineView, + levels: Vec, + hidden: Vec, +} + +impl OutlinePass { + fn new(doc: &Document, view: outline::OutlineView) -> Self { + let levels = outline::levels(doc); + let hidden = outline::hidden(&levels, &view); + OutlinePass { view, levels, hidden } + } +} + +/// A body paragraph in Outline view (one long sheet: no page or column breaks, no floats +/// wrapping): indented by its level behind its symbol; hidden when collapsed or deeper than Show +/// Level; body text cut to its first line with Show First Line Only. +fn place_outline_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, o: &OutlinePass) { + // List numbers count the hidden paragraphs too. + let label = ctx.next_label(p); + if o.hidden.get(block).copied().unwrap_or(false) { + return; + } + let level = o.levels.get(block).copied().unwrap_or(outline::BODY); + let indent = outline::indent(&o.levels, block); + let x = pb.col_x() + indent + outline::SYMBOL_W; + let width = (pb.col_w() - indent - outline::SYMBOL_W).max(72.0); + let q = outline::outline_para(ctx.doc, p, o.view.plain); + ctx.fields.page = pb.number; + let pl = ctx.para_labelled(&q, width, None, &[], label); + pb.y += pl.rp.space_before.max(0.0); + let n = pl.lines.len(); + let l1 = if o.view.first_line_only && level >= outline::BODY { n.min(1) } else { n }; + let y = pb.y + pl.lines.first().map(|f| f.top.max(0.0)).unwrap_or(0.0); + let mut items = Vec::new(); + push_para(&mut items, StoryRef::Body, &[block as u32], &pl, 0, l1, x, y, width); + let at = ObjFrame { page: Some(PageFrame { sect: pb.sect, origin: (0.0, 0.0) }), col: (x, width), para_y: y, table_chr: None }; + let (behind, front) = place_objects(ctx, StoryRef::Body, &[block as u32], &q, &pl, (0, l1), (x, y), &at, &HashMap::new(), 0); + items.extend(front); + let children = level < outline::BODY && outline::has_children(&o.levels, block); + // Collapsed, or with something under it hidden by Show Level: the wavy line says so. + let collapsed = children && o.hidden.get(block + 1).copied().unwrap_or(false); + items.extend(outline::symbol_items(&pl, x, y, level, children, collapsed)); + if let Some(pg) = pb.page() { + for (i, it) in behind.into_iter().enumerate() { + pg.items.insert(i.min(pg.items.len()), it); + } + pg.items.extend(items); + } + if let (Some(f), Some(l)) = (pl.lines.first(), l1.checked_sub(1).and_then(|k| pl.lines.get(k))) { + pb.y = y + (l.top + l.height - f.top); + } + pb.y += pl.rp.space_after.max(0.0); +} + /// How far a paragraph with mirrored indents (`w:mirrorIndents`) moves right on page `number`: /// its left and right indents are the inside and outside ones, which swap sides on even pages. fn mirror_shift(rp: &wordcraft_doc::resolve::ResolvedPara, number: u32, web: bool) -> f32 { diff --git a/crates/layout/src/outline.rs b/crates/layout/src/outline.rs new file mode 100644 index 0000000..af72e83 --- /dev/null +++ b/crates/layout/src/outline.rs @@ -0,0 +1,245 @@ +//! Outline view (View › Outline): the body as an outline of its headings. No pages: the text runs +//! on one long sheet like Draft. Each paragraph is indented by its outline level and has a symbol +//! in front of it: a circled plus for a heading with something under it, a circled minus for a +//! heading with nothing under it, a small dot for body text. Collapsed headings hide what is under +//! them and are marked with a wavy line; Show Level hides everything deeper than a level; Show +//! First Line Only shows body paragraphs' first lines; without text formatting every paragraph is +//! drawn in the Normal style. Paragraph indents and alignment aren't shown. +//! +//! What's collapsed is view state (not saved in the document): the caller passes it in +//! [`OutlineView`] by body block index. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use wordcraft_doc::para::ShapeKind; +use wordcraft_doc::props::{Align, Border, BorderStyle, CharProps, Rgb}; +use wordcraft_doc::{Block, Document, Paragraph, StoryRef}; +use wordcraft_geom::Rect; + +use crate::{DocLayout, Page, ParaLayout, Placed}; + +/// The outline level of body text (headings are 1–9). +pub const BODY: u8 = 10; +/// How far each outline level is indented, points. +pub const LEVEL_INDENT: f32 = 21.6; +/// The gutter in front of each paragraph that holds its symbol, points. +pub const SYMBOL_W: f32 = 16.0; + +const SYMBOL_GREY: Rgb = Rgb(0x6E, 0x6E, 0x6E); + +/// What the outline shows. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct OutlineView { + /// Collapsed headings (body block indices). + pub collapsed: BTreeSet, + /// Show Level: 1–9 shows headings down to that level (no body text); anything else shows all. + pub show_level: u8, + /// Show First Line Only: body paragraphs show their first line. + pub first_line_only: bool, + /// Show Text Formatting off: every paragraph is drawn in the Normal style. + pub plain: bool, +} + +impl OutlineView { + /// The deepest level shown (body text is [`BODY`]). + pub fn deepest(&self) -> u8 { + if (1..=9).contains(&self.show_level) { self.show_level } else { BODY } + } +} + +/// The outline level of a paragraph: 1–9 for headings (its style's or its own outline level), +/// [`BODY`] for body text. +pub fn para_level(doc: &Document, p: &Paragraph) -> u8 { + doc.styles.resolve_para(&p.props).outline_level.filter(|l| *l < 9).map(|l| l + 1).unwrap_or(BODY) +} + +/// The outline level of every body block (tables are body text). +pub fn levels(doc: &Document) -> Vec { + doc.body + .iter() + .map(|b| match &**b { + Block::Para(p) => para_level(doc, p), + Block::Table(_) => BODY, + }) + .collect() +} + +/// The end (exclusive) of what is under block `i`: the blocks after a heading up to the next +/// heading of its level or higher. Body text has nothing under it (`i + 1`). +pub fn subtree_end(levels: &[u8], i: usize) -> usize { + let Some(&l) = levels.get(i) else { return i }; + if l >= BODY { + return i.saturating_add(1); + } + levels.iter().enumerate().skip(i.saturating_add(1)).find(|(_, x)| **x <= l).map(|(j, _)| j).unwrap_or(levels.len()) +} + +/// Whether heading `i` has anything under it. +pub fn has_children(levels: &[u8], i: usize) -> bool { + subtree_end(levels, i) > i.saturating_add(1) +} + +/// Which blocks the outline hides: deeper than Show Level, or under a collapsed heading. +pub fn hidden(levels: &[u8], view: &OutlineView) -> Vec { + let deepest = view.deepest(); + // While inside a collapsed heading: its level (blocks deeper than it are hidden). + let mut under: Option = None; + levels + .iter() + .enumerate() + .map(|(i, &l)| { + if let Some(h) = under { + if l > h { + return true; + } + under = None; + } + if l > deepest { + return true; + } + if l < BODY && view.collapsed.contains(&i) { + under = Some(l); + } + false + }) + .collect() +} + +/// How far block `i` is indented: a heading by its level, body text one level deeper than the +/// heading it follows. +pub fn indent(levels: &[u8], i: usize) -> f32 { + let Some(&l) = levels.get(i) else { return 0.0 }; + let depth = if l < BODY { l.saturating_sub(1) } else { levels.get(..i).unwrap_or(&[]).iter().rev().find(|x| **x < BODY).copied().unwrap_or(0) }; + f32::from(depth) * LEVEL_INDENT +} + +/// Character formatting kept without text formatting: what the text is (hidden, tracked, a +/// link, its language and direction), not how it looks. +fn plain_chars(c: &CharProps) -> CharProps { + CharProps { + hidden: c.hidden, + lang: c.lang.clone(), + no_proof: c.no_proof, + rtl: c.rtl, + cs: c.cs, + lang_bidi: c.lang_bidi.clone(), + link: c.link.clone(), + ins: c.ins, + del: c.del, + ..Default::default() + } +} + +/// The paragraph as the outline draws it: no indents, start-aligned, and in the Normal style +/// without text formatting (`plain`). +pub(crate) fn outline_para(doc: &Document, p: &Paragraph, plain: bool) -> Paragraph { + let mut q = p.clone(); + q.props.indent_left = Some(0.0); + q.props.indent_right = Some(0.0); + q.props.indent_first = Some(0.0); + q.props.align = Some(Align::Left); + q.props.page_break_before = Some(false); + q.props.drop_cap = None; + if plain { + let rp = doc.styles.resolve_para(&p.props); + q.props.style = None; + q.props.numbering = p.props.numbering.or(rp.numbering); + q.props.outline_level = None; + q.props.shading = None; + q.props.borders = None; + for r in &mut q.runs { + r.props = plain_chars(&r.props); + } + q.mark = plain_chars(&q.mark); + } + q +} + +/// The symbol in front of a paragraph whose first line `pl` starts at (`x`, `y`) (the text's left +/// edge): a circled plus or minus for a heading, a dot for body text; and a wavy line under a +/// collapsed heading's first line. +pub(crate) fn symbol_items(pl: &Arc, x: f32, y: f32, level: u8, children: bool, collapsed: bool) -> Vec { + let Some(first) = pl.lines.first() else { return Vec::new() }; + let asc = (first.baseline - first.top).max(4.0); + let cy = y + asc * 0.62; + let cx = x - SYMBOL_W + 6.0; + let mut out = Vec::new(); + let disc = |r: f32| Placed::Shape { + rect: Rect::new(cx - r, cy - r, r * 2.0, r * 2.0), + kind: ShapeKind::Ellipse, + fill: Some(SYMBOL_GREY), + stroke: None, + stroke_width: 0.0, + freeform: None, + effects: Default::default(), + spin: Default::default(), + extra: Default::default(), + }; + let white = Border { style: BorderStyle::Single, width: 1.1, color: Some(Rgb(0xFF, 0xFF, 0xFF)), space: 0.0 }; + if level >= BODY { + out.push(disc(2.0)); + return out; + } + let r = 4.6; + out.push(disc(r)); + out.push(Placed::Rule { x0: cx - 2.6, y0: cy, x1: cx + 2.6, y1: cy, border: white }); + if children { + out.push(Placed::Rule { x0: cx, y0: cy - 2.6, x1: cx, y1: cy + 2.6, border: white }); + } + if collapsed { + let w = first.xs.last().copied().unwrap_or(0.0).max(12.0); + let by = y + (first.baseline - first.top) + 3.0; + out.push(Placed::Rule { + x0: x, + y0: by, + x1: x + w, + y1: by, + border: Border { style: BorderStyle::Wave, width: 0.75, color: Some(SYMBOL_GREY), space: 0.0 }, + }); + } + out +} + +impl DocLayout { + /// The outline symbol at page point (`x`, `y`), as the body block it belongs to (Outline view: + /// clicking a symbol selects the heading and what is under it). + pub fn outline_symbol_at(&self, page: usize, x: f32, y: f32) -> Option { + let page: &Page = self.pages.get(page)?; + page.items.iter().find_map(|it| { + let Placed::Lines { story: StoryRef::Body, path, para, l0: 0, x: lx, y: ly, .. } = it else { return None }; + let [block] = path.0.as_slice() else { return None }; + let first = para.lines.first()?; + let r = Rect::new(lx - SYMBOL_W, *ly, SYMBOL_W, first.height.max(8.0)); + (x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h).then_some(*block as usize) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn show_level_and_collapse_hide_what_is_under_a_heading() { + // H1, body, H2, body, H1, body + let levels = [1, BODY, 2, BODY, 1, BODY]; + let all = OutlineView::default(); + assert_eq!(hidden(&levels, &all), vec![false; 6]); + let one = OutlineView { show_level: 1, ..Default::default() }; + assert_eq!(hidden(&levels, &one), vec![false, true, true, true, false, true]); + let two = OutlineView { show_level: 2, ..Default::default() }; + assert_eq!(hidden(&levels, &two), vec![false, true, false, true, false, true]); + // Collapsing the first heading hides its whole subtree, not the next Heading 1. + let c = OutlineView { collapsed: [0].into_iter().collect(), ..Default::default() }; + assert_eq!(hidden(&levels, &c), vec![false, true, true, true, false, false]); + // Collapsing body text does nothing. + let b = OutlineView { collapsed: [1].into_iter().collect(), ..Default::default() }; + assert_eq!(hidden(&levels, &b), vec![false; 6]); + assert!(has_children(&levels, 0) && has_children(&levels, 2) && !has_children(&levels, 1)); + assert_eq!(subtree_end(&levels, 0), 4); + assert_eq!(subtree_end(&levels, 99), 99); + assert_eq!(indent(&levels, 3), 2.0 * LEVEL_INDENT); + assert_eq!(indent(&levels, 0), 0.0); + } +} diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ef1a1b5..723fb04 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -864,11 +864,7 @@ fn runs_differing_only_in_link_or_decoration_keep_their_own_style() { fn web_view_is_one_page() { let d = Document::from_text(&"text ".repeat(3000)); let mut c = LayoutCache::new(); - let l = layout( - &d, - &mut c, - &LayoutOptions { view: ViewMode::Web, web_width: 800.0, show_hidden: false, hide_deleted: false, proofing: false, ..Default::default() }, - ); + let l = layout(&d, &mut c, &LayoutOptions { view: ViewMode::Web, web_width: 800.0, ..Default::default() }); assert_eq!(l.pages.len(), 1); assert!(l.pages[0].h > 800.0); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ea3f686..f930ba7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1114,6 +1114,19 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo if (resp.double_clicked() || resp.triple_clicked()) && layout.equation_hit(page, x, y, story).is_some() { return; } + // Outline view: a paragraph's symbol selects it with what is under it; a double-click + // expands or collapses the heading. + if app.session.view.mode == wordcraft_layout::ViewMode::Outline + && let Some(block) = layout.outline_symbol_at(page, x, y) + { + if resp.double_clicked() { + let _ = app.run("outline.toggle", json!({ "block": block })); + } else if ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer() { + let _ = app.run("outline.selectSubtree", json!({ "block": block })); + app.session.page_hint = page; + } + return; + } // Double-click in the header/footer area edits it; double-click in the body leaves it. if resp.double_clicked() { if let Some((s, header)) = layout.header_footer_at(page, y) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 1742ae3..5dede14 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1680,6 +1680,32 @@ Continuous numbering Numeración continua Apply Aplicar Convert Notes Convertir notas + Promote to Heading 1 Promover a Título 1 + Demote to Body Text Degradar a texto independiente + Move Up Subir + Move Down Bajar + Collapse Contraer + Expand or Collapse Heading Expandir o contraer título + Select Heading and Content Seleccionar título y contenido + Show Level Mostrar nivel + Show Level 1 Mostrar nivel 1 + Show Level 2 Mostrar nivel 2 + Show Level 3 Mostrar nivel 3 + Show Level 4 Mostrar nivel 4 + Show Level 5 Mostrar nivel 5 + Show Level 6 Mostrar nivel 6 + Show Level 7 Mostrar nivel 7 + Show Level 8 Mostrar nivel 8 + Show Level 9 Mostrar nivel 9 + Show All Levels Mostrar todos los niveles + All Levels Todos los niveles + Show First Line Only Mostrar solo la primera línea + Show Text Formatting Mostrar formato de texto + Close Outline View Cerrar vista Esquema + Outline State Estado del esquema + Outlining Esquema + Outline Tools Herramientas de esquema + Close Outline\nView Cerrar vista\nEsquema A, B, C, … A, B, C, … Above the item Encima del elemento Accessed on Consultado el diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 5fbf242..c1006fc 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1641,6 +1641,32 @@ Continuous numbering Pidev nummerdus Apply Rakenda Convert Notes Teisenda märkused + Promote to Heading 1 Tõsta pealkirjaks 1 + Demote to Body Text Langeta põhitekstiks + Move Up Teisalda üles + Move Down Teisalda alla + Collapse Ahenda + Expand or Collapse Heading Laienda või ahenda pealkiri + Select Heading and Content Vali pealkiri koos sisuga + Show Level Kuva tase + Show Level 1 Kuva tase 1 + Show Level 2 Kuva tase 2 + Show Level 3 Kuva tase 3 + Show Level 4 Kuva tase 4 + Show Level 5 Kuva tase 5 + Show Level 6 Kuva tase 6 + Show Level 7 Kuva tase 7 + Show Level 8 Kuva tase 8 + Show Level 9 Kuva tase 9 + Show All Levels Kuva kõik tasemed + All Levels Kõik tasemed + Show First Line Only Kuva ainult esimene rida + Show Text Formatting Kuva teksti vorming + Close Outline View Sule liigendusvaade + Outline State Liigenduse olek + Outlining Liigendamine + Outline Tools Liigendustööriistad + Close Outline\nView Sule\nliigendusvaade A, B, C, … A, B, C, … Above the item Objekti kohal Accessed on Vaadatud diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5174f05..6780584 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1664,6 +1664,32 @@ Continuous numbering 通し番号 Apply 適用 Convert Notes 注の変換 + Promote to Heading 1 見出し 1 に設定 + Demote to Body Text 本文に設定 + Move Up 上へ移動 + Move Down 下へ移動 + Collapse 折りたたみ + Expand or Collapse Heading 見出しの展開/折りたたみ + Select Heading and Content 見出しと内容を選択 + Show Level レベルの表示 + Show Level 1 レベル 1 を表示 + Show Level 2 レベル 2 を表示 + Show Level 3 レベル 3 を表示 + Show Level 4 レベル 4 を表示 + Show Level 5 レベル 5 を表示 + Show Level 6 レベル 6 を表示 + Show Level 7 レベル 7 を表示 + Show Level 8 レベル 8 を表示 + Show Level 9 レベル 9 を表示 + Show All Levels すべてのレベルを表示 + All Levels すべてのレベル + Show First Line Only 1 行目のみ表示 + Show Text Formatting 文字列の書式を表示 + Close Outline View アウトライン表示を閉じる + Outline State アウトラインの状態 + Outlining アウトライン + Outline Tools アウトライン ツール + Close Outline\nView アウトライン表示\nを閉じる A, B, C, … A, B, C, … Above the item 項目の上 Accessed on アクセス日 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index acb4d66..9d38fff 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1676,6 +1676,32 @@ Continuous numbering Numeração contínua Apply Aplicar Convert Notes Converter notas + Promote to Heading 1 Promover a Título 1 + Demote to Body Text Rebaixar a corpo do texto + Move Up Mover para cima + Move Down Mover para baixo + Collapse Recolher + Expand or Collapse Heading Expandir ou recolher título + Select Heading and Content Selecionar título e conteúdo + Show Level Mostrar nível + Show Level 1 Mostrar nível 1 + Show Level 2 Mostrar nível 2 + Show Level 3 Mostrar nível 3 + Show Level 4 Mostrar nível 4 + Show Level 5 Mostrar nível 5 + Show Level 6 Mostrar nível 6 + Show Level 7 Mostrar nível 7 + Show Level 8 Mostrar nível 8 + Show Level 9 Mostrar nível 9 + Show All Levels Mostrar todos os níveis + All Levels Todos os níveis + Show First Line Only Mostrar só a primeira linha + Show Text Formatting Mostrar formatação do texto + Close Outline View Fechar estrutura de tópicos + Outline State Estado da estrutura de tópicos + Outlining Estrutura de tópicos + Outline Tools Ferramentas de tópicos + Close Outline\nView Fechar estrutura\nde tópicos A, B, C, … A, B, C, … Above the item Acima do item Accessed on Acessado em diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a3af8de..a583d06 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2113,6 +2113,33 @@ preview Title Пример Apply Применить Convert Notes Преобразовать сноски +# Outline view (#493). + Promote to Heading 1 Повысить до заголовка 1 + Demote to Body Text Понизить до обычного текста + Move Up Переместить вверх + Move Down Переместить вниз + Collapse Свернуть + Expand or Collapse Heading Развернуть или свернуть заголовок + Select Heading and Content Выделить заголовок и содержимое + Show Level Показать уровень + Show Level 1 Показать уровень 1 + Show Level 2 Показать уровень 2 + Show Level 3 Показать уровень 3 + Show Level 4 Показать уровень 4 + Show Level 5 Показать уровень 5 + Show Level 6 Показать уровень 6 + Show Level 7 Показать уровень 7 + Show Level 8 Показать уровень 8 + Show Level 9 Показать уровень 9 + Show All Levels Показать все уровни + All Levels Все уровни + Show First Line Only Показывать только первую строку + Show Text Formatting Показывать форматирование текста + Close Outline View Закрыть режим структуры + Outline State Состояние структуры + Outlining Структура + Outline Tools Работа со структурой + Close Outline\nView Закрыть режим\nструктуры # Wrap Text context menu (#513). Move with Text Перемещать вместе с текстом Fix Position on Page Зафиксировать положение на странице @@ -2219,7 +2246,6 @@ preview Title Пример Turkish Турецкий Ukrainian Украинский {language} is now the document's default language. {language} теперь язык документа по умолчанию. - # File > Options panes (#485). As you type По мере ввода AutoSave writes your changes to the file a moment after you stop typing. Автосохранение записывает изменения в файл вскоре после того, как вы перестанете печатать. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 882e21c..d5e944e 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1674,6 +1674,32 @@ Continuous numbering Neprekidna numeracija Apply Primeni Convert Notes Pretvori napomene + Promote to Heading 1 Podigni na Naslov 1 + Demote to Body Text Spusti na osnovni tekst + Move Up Pomeri nagore + Move Down Pomeri nadole + Collapse Skupi + Expand or Collapse Heading Proširi ili skupi naslov + Select Heading and Content Izaberi naslov i sadržaj + Show Level Prikaži nivo + Show Level 1 Prikaži nivo 1 + Show Level 2 Prikaži nivo 2 + Show Level 3 Prikaži nivo 3 + Show Level 4 Prikaži nivo 4 + Show Level 5 Prikaži nivo 5 + Show Level 6 Prikaži nivo 6 + Show Level 7 Prikaži nivo 7 + Show Level 8 Prikaži nivo 8 + Show Level 9 Prikaži nivo 9 + Show All Levels Prikaži sve nivoe + All Levels Svi nivoi + Show First Line Only Prikaži samo prvi red + Show Text Formatting Prikaži oblikovanje teksta + Close Outline View Zatvori prikaz konture + Outline State Stanje konture + Outlining Kontura + Outline Tools Alatke konture + Close Outline\nView Zatvori prikaz\nkonture A, B, C, … A, B, C, … Above the item Iznad stavke Accessed on Pristupljeno diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c10cd21..6270d93 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1678,6 +1678,32 @@ Continuous numbering Непрекидна нумерација Apply Примени Convert Notes Претвори напомене + Promote to Heading 1 Подигни на Наслов 1 + Demote to Body Text Спусти на основни текст + Move Up Помери нагоре + Move Down Помери надоле + Collapse Скупи + Expand or Collapse Heading Прошири или скупи наслов + Select Heading and Content Изабери наслов и садржај + Show Level Прикажи ниво + Show Level 1 Прикажи ниво 1 + Show Level 2 Прикажи ниво 2 + Show Level 3 Прикажи ниво 3 + Show Level 4 Прикажи ниво 4 + Show Level 5 Прикажи ниво 5 + Show Level 6 Прикажи ниво 6 + Show Level 7 Прикажи ниво 7 + Show Level 8 Прикажи ниво 8 + Show Level 9 Прикажи ниво 9 + Show All Levels Прикажи све нивое + All Levels Сви нивои + Show First Line Only Прикажи само први ред + Show Text Formatting Прикажи обликовање текста + Close Outline View Затвори приказ контуре + Outline State Стање контуре + Outlining Контура + Outline Tools Алатке контуре + Close Outline\nView Затвори приказ\nконтуре A, B, C, … A, B, C, … Above the item Изнад ставке Accessed on Приступљено diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b1f0e6d..f8144d1 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1735,6 +1735,33 @@ Apply Uygula Convert Notes Notları Dönüştür +# Outline view (#493). + Promote to Heading 1 Başlık 1'e yükselt + Demote to Body Text Gövde metnine düşür + Move Up Yukarı taşı + Move Down Aşağı taşı + Collapse Daralt + Expand or Collapse Heading Başlığı genişlet veya daralt + Select Heading and Content Başlığı ve içeriğini seç + Show Level Düzeyi göster + Show Level 1 Düzey 1'i göster + Show Level 2 Düzey 2'yi göster + Show Level 3 Düzey 3'ü göster + Show Level 4 Düzey 4'ü göster + Show Level 5 Düzey 5'i göster + Show Level 6 Düzey 6'yı göster + Show Level 7 Düzey 7'yi göster + Show Level 8 Düzey 8'i göster + Show Level 9 Düzey 9'u göster + Show All Levels Tüm düzeyleri göster + All Levels Tüm düzeyler + Show First Line Only Yalnızca ilk satırı göster + Show Text Formatting Metin biçimlendirmesini göster + Close Outline View Anahat görünümünü kapat + Outline State Anahat durumu + Outlining Anahat + Outline Tools Anahat araçları + Close Outline\nView Anahat görünümünü\nkapat # Wrap Text context menu (#513). Move with Text Metinle birlikte taşı Fix Position on Page Sayfadaki konumu sabitle @@ -1841,7 +1868,6 @@ Turkish Türkçe Ukrainian Ukraynaca {language} is now the document's default language. {language} artık belgenin varsayılan dili. - # File > Options panes (#485). As you type Yazarken AutoSave writes your changes to the file a moment after you stop typing. Otomatik kaydetme, yazmayı bıraktıktan kısa süre sonra değişikliklerinizi dosyaya yazar. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 9d1b1ef..e2d5ccf 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1676,6 +1676,32 @@ Continuous numbering Наскрізна нумерація Apply Застосувати Convert Notes Перетворити примітки + Promote to Heading 1 Підвищити до заголовка 1 + Demote to Body Text Понизити до основного тексту + Move Up Перемістити вгору + Move Down Перемістити вниз + Collapse Згорнути + Expand or Collapse Heading Розгорнути або згорнути заголовок + Select Heading and Content Виділити заголовок із вмістом + Show Level Показати рівень + Show Level 1 Показати рівень 1 + Show Level 2 Показати рівень 2 + Show Level 3 Показати рівень 3 + Show Level 4 Показати рівень 4 + Show Level 5 Показати рівень 5 + Show Level 6 Показати рівень 6 + Show Level 7 Показати рівень 7 + Show Level 8 Показати рівень 8 + Show Level 9 Показати рівень 9 + Show All Levels Показати всі рівні + All Levels Усі рівні + Show First Line Only Лише перший рядок + Show Text Formatting Показати форматування тексту + Close Outline View Закрити режим плану + Outline State Стан плану + Outlining Структура + Outline Tools Знаряддя плану + Close Outline\nView Закрити\nрежим плану A, B, C, … A, B, C, … Above the item Над елементом Accessed on Дата звернення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 353dd40..fb09542 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1676,6 +1676,32 @@ Continuous numbering 连续编号 Apply 应用 Convert Notes 转换注释 + Promote to Heading 1 提升至标题 1 + Demote to Body Text 降级为正文文本 + Move Up 上移 + Move Down 下移 + Collapse 折叠 + Expand or Collapse Heading 展开或折叠标题 + Select Heading and Content 选择标题及其内容 + Show Level 显示级别 + Show Level 1 显示 1 级 + Show Level 2 显示 2 级 + Show Level 3 显示 3 级 + Show Level 4 显示 4 级 + Show Level 5 显示 5 级 + Show Level 6 显示 6 级 + Show Level 7 显示 7 级 + Show Level 8 显示 8 级 + Show Level 9 显示 9 级 + Show All Levels 显示所有级别 + All Levels 所有级别 + Show First Line Only 仅显示首行 + Show Text Formatting 显示文本格式 + Close Outline View 关闭大纲视图 + Outline State 大纲状态 + Outlining 大纲显示 + Outline Tools 大纲工具 + Close Outline\nView 关闭\n大纲视图 A, B, C, … A, B, C, … Above the item 项目上方 Accessed on 访问日期 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7e5d2a7..34dc7a8 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1665,6 +1665,32 @@ Continuous numbering 連續編號 Apply 套用 Convert Notes 轉換附註 + Promote to Heading 1 升級至標題 1 + Demote to Body Text 降級為本文 + Move Up 上移 + Move Down 下移 + Collapse 摺疊 + Expand or Collapse Heading 展開或摺疊標題 + Select Heading and Content 選取標題及其內容 + Show Level 顯示階層 + Show Level 1 顯示第 1 階 + Show Level 2 顯示第 2 階 + Show Level 3 顯示第 3 階 + Show Level 4 顯示第 4 階 + Show Level 5 顯示第 5 階 + Show Level 6 顯示第 6 階 + Show Level 7 顯示第 7 階 + Show Level 8 顯示第 8 階 + Show Level 9 顯示第 9 階 + Show All Levels 顯示所有階層 + All Levels 所有階層 + Show First Line Only 僅顯示首行 + Show Text Formatting 顯示文字格式設定 + Close Outline View 關閉大綱模式 + Outline State 大綱狀態 + Outlining 大綱 + Outline Tools 大綱工具 + Close Outline\nView 關閉\n大綱模式 A, B, C, … A, B, C, … Above the item 項目上方 Accessed on 存取日期 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 3679b54..a090e6c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1261,6 +1261,45 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.line_c(&[(6.5, 16.5), (9.0, 11.5)], c); } } + + // Outlining tab: arrows moving a paragraph between outline levels and along the outline. + "outlinePromote" | "outlineDemote" => { + pen.lines(9.0, 17.0, &[6.0, 10.0, 14.0]); + if name == "outlinePromote" { + pen.line_c(&[(7.0, 10.0), (2.5, 10.0)], a); + pen.line_c(&[(5.0, 7.5), (2.5, 10.0), (5.0, 12.5)], a); + } else { + pen.line_c(&[(2.5, 10.0), (7.0, 10.0)], a); + pen.line_c(&[(4.5, 7.5), (7.0, 10.0), (4.5, 12.5)], a); + } + } + "outlineTop" | "outlineBody" => { + pen.lines(11.0, 17.0, &[6.0, 10.0, 14.0]); + let (x, d) = if name == "outlineTop" { (3.0, 1.0) } else { (9.0, -1.0) }; + for k in [0.0, 3.5] { + pen.line_c(&[(x + d * k + d * 2.5, 7.0), (x + d * k, 10.0), (x + d * k + d * 2.5, 13.0)], a); + } + } + "outlineUp" | "outlineDown" => { + pen.lines(3.0, 11.0, &[6.0, 10.0, 14.0]); + let up = name == "outlineUp"; + let (tip, tail) = if up { (3.0, 17.0) } else { (17.0, 3.0) }; + pen.line_c(&[(15.0, tail), (15.0, tip)], a); + let wing = if up { tip + 3.0 } else { tip - 3.0 }; + pen.line_c(&[(12.0, wing), (15.0, tip), (18.0, wing)], a); + } + "outlineExpand" | "outlineCollapse" => { + pen.fcircle(10.0, 10.0, 6.5, c); + pen.line_c(&[(6.5, 10.0), (13.5, 10.0)], Color32::WHITE); + if name == "outlineExpand" { + pen.line_c(&[(10.0, 6.5), (10.0, 13.5)], Color32::WHITE); + } + } + "closeOutline" => { + pen.page(4.0, 2.0, 16.0, 18.0); + pen.line_c(&[(7.0, 8.0), (13.0, 14.0)], red); + pen.line_c(&[(13.0, 8.0), (7.0, 14.0)], red); + } "help" => { pen.circle(10.0, 10.0, 7.0, c); pen.text(10.0, 10.0, 10.0, "?", a, true); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 818579e..7432145 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -278,6 +278,8 @@ pub struct WordApp { last_autosave: f64, /// The tab shown before the Equation tab came up (restored when editing ends). pub(crate) equation_prev_tab: Option, + /// The tab shown before Outline view brought up the Outlining tab (restored when it closes). + pub(crate) outline_prev_tab: Option, /// Zotero commands in flight (`ui.zotero.*`). pub zotero: zotero::ZoteroLink, /// The egui context, once the first frame has run (background work wakes the UI with it). @@ -348,6 +350,7 @@ impl WordApp { word_count: (0, 0), last_autosave: 0.0, equation_prev_tab: None, + outline_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, read_aloud_at: None, diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6c8a168..c2a0670 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -47,6 +47,10 @@ pub fn contextual_tabs(s: &wordcraft_engine::Session) -> Vec<&'static str> { if s.math.is_some() { tabs.push("Equation"); } + // Outline view (drawn first, next to File, as Word does). + if s.view.mode == wordcraft_layout::ViewMode::Outline { + tabs.push("Outlining"); + } tabs } @@ -73,14 +77,27 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { { app.ui.tab = prev; } + // Outline view brings up the Outlining tab; closing it goes back. + if app.session.view.mode == wordcraft_layout::ViewMode::Outline { + if app.outline_prev_tab.is_none() { + app.outline_prev_tab = Some(app.ui.tab.clone()); + app.ui.tab = "Outlining".into(); + } + } else if let Some(prev) = app.outline_prev_tab.take() + && app.ui.tab == "Outlining" + { + app.ui.tab = prev; + } // The tabs shown: File, the user's tabs in their order (Customize Ribbon, #379), then the - // contextual ones. A contextual tab (Table, Picture Format) may be stored while the selection - // moved away, and a tab may have been hidden. + // contextual ones (the Outlining tab right after File). A contextual tab (Table, Picture + // Format) may be stored while the selection moved away, and a tab may have been hidden. let user_tabs = app.session.ribbon.tabs().into_owned(); let mut tabs: Vec<(String, bool)> = vec![("File".to_string(), false)]; tabs.extend(user_tabs.iter().filter(|e| !e.hidden).map(|e| (e.name.clone(), e.custom))); for ct in contextual_tabs(&app.session) { - if !tabs.iter().any(|(n, _)| n == ct) { + if ct == "Outlining" { + tabs.insert(1.min(tabs.len()), (ct.to_string(), false)); + } else if !tabs.iter().any(|(n, _)| n == ct) { tabs.push((ct.to_string(), false)); } } @@ -210,6 +227,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { "Equation" if app.session.math.is_some() => crate::equation_tab::show(app, ui), "Picture Format" => picture_format(app, ui), "Shape Format" => shape_format(app, ui), + "Outlining" => outlining(app, ui), "Chart Design" => chart_design(app, ui), _ => home(app, ui), } @@ -1514,6 +1532,64 @@ fn view(app: &mut WordApp, ui: &mut Ui) { }); } +/// The Outlining tab (Outline view): outline levels, moving, expanding and collapsing, what is +/// shown, and closing the view. +fn outlining(app: &mut WordApp, ui: &mut Ui) { + use wordcraft_engine::layout::outline::{BODY, para_level}; + let o = app.session.view.outline.clone(); + let level = app.session.doc.para_at(&app.session.sel.focus).map(|p| para_level(&app.session.doc, p)).unwrap_or(BODY); + let name = |l: u8| if l >= BODY { tl!("Body Text").to_string() } else { crate::i18n::fmt(tl!("Level {n}"), &[("n", &l.to_string())]) }; + group(ui, "Outline Tools", None, app, |ui, app| { + stack(ui, |ui| { + crate::widgets::row(ui, |ui| { + small(ui, app, "outlineTop", None, "Promote to Heading 1", "outline.promoteToHeading1", json!({}), false); + small(ui, app, "outlinePromote", None, "Promote", "outline.promote", json!({}), false); + let levels: Vec = (1..=BODY).map(name).collect(); + if let Some(v) = combo(ui, "outlineLevel", 100.0, &name(level), &levels, None) + && let Some(i) = levels.iter().position(|x| *x == v) + { + let l = if i + 1 >= usize::from(BODY) { json!("body") } else { json!(i + 1) }; + let _ = app.run("outline.level", json!({ "level": l })); + } + small(ui, app, "outlineDemote", None, "Demote", "outline.demote", json!({}), false); + small(ui, app, "outlineBody", None, "Demote to Body Text", "outline.demoteToBody", json!({}), false); + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + small(ui, app, "outlineUp", None, "Move Up", "outline.moveUp", json!({}), false); + small(ui, app, "outlineDown", None, "Move Down", "outline.moveDown", json!({}), false); + small(ui, app, "outlineExpand", None, "Expand", "outline.expand", json!({}), false); + small(ui, app, "outlineCollapse", None, "Collapse", "outline.collapse", json!({}), false); + ui.add_space(6.0); + ui.label(egui::RichText::new(tl!("Show Level")).size(11.5)); + let mut shown: Vec = vec![tl!("All Levels").to_string()]; + shown.extend((1..BODY).map(name)); + let current = shown.get(usize::from(o.show_level)).cloned().unwrap_or_default(); + if let Some(v) = combo(ui, "outlineShowLevel", 90.0, ¤t, &shown, None) + && let Some(i) = shown.iter().position(|x| *x == v) + { + let l = if i == 0 { json!("all") } else { json!(i) }; + let _ = app.run("outline.showLevel", json!({ "level": l })); + } + }); + ui.add_space(3.0); + crate::widgets::row(ui, |ui| { + let mut f = !o.hide_formatting; + if ui.checkbox(&mut f, tl!("Show Text Formatting")).changed() { + let _ = app.run("outline.showFormatting", json!({ "value": f })); + } + let mut first = o.first_line_only; + if ui.checkbox(&mut first, tl!("Show First Line Only")).changed() { + let _ = app.run("outline.firstLineOnly", json!({ "value": first })); + } + }); + }); + }); + group(ui, "Close", None, app, |ui, app| { + big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false); + }); +} + fn help(app: &mut WordApp, ui: &mut Ui) { group(ui, "Help", None, app, |ui, app| { big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index dc75384..2acac2e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**460 of 476 catalog features have commands (96.6%).** +**473 of 489 catalog features have commands (96.7%).** | Tab | Live | Total | |---|---|---| @@ -21,6 +21,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Table Design | 8 | 8 | | Table Layout | 32 | 32 | | Equation | 21 | 22 | +| Outlining | 13 | 13 | | Picture Format | 14 | 14 | | Shape Format | 11 | 11 | | Chart Design | 5 | 5 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fcc9db4..ab1f98c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (document canvas exposed to screen readers, #488; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Outline view depth, #493; previously trivial: English spelling knows common modern words, #520; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **460 / 476 (96.6%)** | **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 | **473 / 489 (96.7%)** | **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)) | @@ -73,7 +73,7 @@ full parity for the area. | 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 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: Insert › Chart makes column, bar (clustered and stacked), line, area, pie, doughnut and scatter charts with Chart Design (change type, Edit Data grid, title, legend, data labels) and saves them as real chart parts (#409); charts from Word are drawn (#292) and kept on save (#319) but stay read-only; SmartArt is drawn and kept, not editable. | -| 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. | +| 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; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Master Document, 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. | | East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Vertical text in sections, text boxes and cells with upright ideographs (#491; no `vert` glyph alternates). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). No `w:eastAsianLayout`, enclose characters, Asian line-break options. | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Outline view depth (#493): View row updated; catalog 428/450 → 441/463 (95.2%) with the Outlining tab rows | | 2026-10-11 | trivial | Screen readers can read the document canvas (#488): Accessibility row updated; no percentage change | | 2026-10-11 | trivial | Compare settings, Restrict Editing pane and AutoCorrect Options (#412): Review row updated; no percentage change | | 2026-10-11 | trivial | Pictures, shapes, text boxes, WordArt: WordArt text effects and Transform warps, connectors glued to shapes (#416); catalog 423/448 → 428/453 (94.5%); no percentage change | diff --git a/docs/typing-parity.md b/docs/typing-parity.md index d07215f..7ca9359 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (editing options: typing replaces selection, overtype, #485; previously minor: Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (outline keys in Outline view, #493) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 What Word does when you type, observed black-box (same keystrokes typed into Word and WordCraft, results read back paragraph by paragraph) and pinned by tests in @@ -49,6 +49,8 @@ Windows/Linux keys are given first, macOS second. |---|---|---| | Alt+Shift+Up / Down (⌃⇧↑ / ⌃⇧↓) | the paragraphs the selection touches swap with the paragraph above / below, keeping style and list membership; the selection moves with them; one undo step. A selection ending at the start of a paragraph doesn't take it along. Refused at the top or bottom of the story or cell, next to a table and across a section break | `alt_shift_down_then_up_moves_a_paragraph_and_back_and_undoes` | | Alt+Shift+Left / Right (⌃⇧← / ⌃⇧→) | Promote / Demote: a heading goes up or down one level (Heading 1 and Heading 9 are the ends); promoted body text becomes a heading at the level of the heading before it; a list item changes level like Shift+Tab / Tab | `alt_shift_left_and_right_promote_and_demote_headings_and_list_items` | +| Outline view: Alt+Shift+Up / Down, Left / Right | as above, but a collapsed heading takes what is hidden under it along: moving passes whole shown paragraphs with their hidden content; promote/demote shifts the hidden subheadings too | `outline_collapse_hides_descendants_and_expand_restores` | +| Outline view: Alt+Shift+Plus / Minus, Alt+Shift+1–9, Alt+Shift+A | Expand / Collapse the heading (one level at a time); Show Level 1–9; show all levels. The caret never stays in hidden text: it moves on to the next shown paragraph | `outline_show_level_filters_paragraphs` | | Shift+F5 | Go Back: the caret cycles through the last four places edited, most recent first | `shift_f5_goes_back_through_the_last_edits` | | Alt+X (no macOS default) | the hex code before the caret (up to six digits, `U+` optional) or the selected code becomes its character; otherwise the character before the caret becomes its code (`é` ↔ `00E9`); one undo step | `alt_x_toggles_between_a_character_and_its_code` | @@ -113,6 +115,7 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Outline view keys: expand/collapse, Show Level, moving and promoting with collapsed content (#493) | | 2026-10-11 | minor | Editing options: typing replaces selection, overtype and the Insert key (#485) | | 2026-10-11 | minor | Pasted paragraph breaks: tracked under Track Changes (#417), kept in a list by Keep Text Only (#422) | | 2026-10-11 | minor | Undo rows: a selection ends the typing group (#419); Undo of Bold at the caret restores unbolded typing (#424) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62d71e2..c7fc518 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (File › Options panes, #485; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Outlining tab and outline keys, #493; previously trivial: Customize Ribbon and the Quick Access Toolbar, #379; previously: major, first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mouse and touch. Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our @@ -10,12 +10,12 @@ 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); 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) | +| Ribbon tabs and groups | All Word tabs including contextual Table Design/Layout, Picture Format, Shape Format, Header & Footer, Equation, Outlining (Outline view, #493); 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 | | 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 | +| Keyboard shortcuts | **137** commands bound in `CommandSpec::key`, 156 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); Outline view: Alt+Shift+Plus/Minus expand/collapse, Alt+Shift+1–9 and Alt+Shift+A Show Level (#493). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 | | Modal dialogs | **54** (`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, Templates and Add-ins, Organizer, Compare and Combine, AutoCorrect Options, 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; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Options panes…) | ~45% | 11–19 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), 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); right-click selects a picture, shape or text box and offers Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables partial | rich per context | 60% | 3–5 | @@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Outlining tab in Outline view (level box, promote/demote, move, expand/collapse, Show Level, Show Text Formatting, Show First Line Only, Close); outline symbols select a heading with its content on click and expand/collapse on double-click; Alt+Shift+Plus/Minus, Alt+Shift+1–9, Alt+Shift+A (#493) | | 2026-10-11 | trivial | File › Options as panes (General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents), every setting wired; `file.options` headless (#485) | | 2026-10-11 | trivial | Right-click on a picture, shape or text box selects it and offers Wrap Text: the seven wrapping styles (current one checked), Move with Text and Fix Position on Page (`arrange.moveWithText`) (#513) | | 2026-10-11 | trivial | Paragraph dialog: Mirror indents (Inside/Outside labels), Don't add space between paragraphs of the same style, Formatting exceptions (Suppress line numbers, Don't hyphenate) (#512) |