Drag after a double/triple-click extends the selection by words/paragraphs (#549) (#649)

Rebased onto main as one commit (the branch had merge commits from main).

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov 2026-10-12 04:58:11 +02:00 committed by GitHub
parent 0eea21c2c8
commit 14080c614c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 297 additions and 23 deletions

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (drag-select by whole units, #549; previously trivial: Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; 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; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
agent control. This page is the summary; the assessment is
@ -144,6 +144,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
| 2026-10-11 | Select by dragging whole units (#549): after a double-click the drag grows by words, after a triple-click by paragraphs, after Ctrl/⌘+click by sentences, as in Word |
| 2026-10-11 | Compare / Combine dialog with comparison settings, Restrict Editing pane (exceptions, SHA-512 password, locked styles; DOCX round trip), AutoCorrect Options dialog saved per user (#412) |
| 2026-10-11 | WordArt (#416): text fill/outline/shadow/glow/reflection, Transform warps, our own styles; straight/elbow/curved connectors glued to shapes that re-route; DOCX round trip |
| 2026-10-11 | Content controls (`w:sdt`) kept, shown and edited; Developer tab, Design Mode, Properties dialog (#381) |
@ -191,6 +192,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 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: drag-select by words/paragraphs/sentences (#549) |
| 2026-10-11 | trivial | Progress log: content controls (#381) |
| 2026-10-11 | trivial | Progress log: Insert › Chart (#409); catalog 408/439 (92.9%) |
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |

View File

@ -48,6 +48,9 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("select.word", "Select Word", "Navigation", select_word).pure(),
CommandSpec::new("select.sentence", "Select Sentence", "Navigation", select_sentence).pure(),
CommandSpec::new("select.paragraph", "Select Paragraph", "Navigation", select_paragraph).pure(),
CommandSpec::new("select.dragUnit", "Extend Selection by Units", "Navigation", drag_unit)
.params(r#"{"unit": "word|sentence|paragraph", "start": Pos, "end": Pos, "pos": Pos}"#)
.pure(),
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(),
CommandSpec::new("select.text", "Select Text", "Navigation", select_text).params(r#"{"text": string, "occurrence"?: n}"#).pure(),
CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| {
@ -287,33 +290,83 @@ fn select_range(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
fn select_word(s: &mut Session, _: &Value) -> CmdResult {
/// A unit a selection grows by: what a double-click (word), Ctrl/⌘+click (sentence) or
/// triple-click (paragraph) selects, and what dragging after it extends by.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unit {
Word,
Sentence,
Paragraph,
}
impl Unit {
pub fn parse(s: &str) -> Option<Unit> {
match s {
"word" => Some(Unit::Word),
"sentence" => Some(Unit::Sentence),
"paragraph" => Some(Unit::Paragraph),
_ => None,
}
}
}
/// The word, sentence or paragraph around `at`, as (start, end). A paragraph includes its mark
/// (it ends at the start of the next paragraph in the same container, when there is one).
pub fn unit_range(s: &Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
let (a, b) = match unit {
Unit::Word => p.word_at(at.off),
Unit::Sentence => p.sentence_at(at.off),
Unit::Paragraph => {
let start = Pos { off: 0, ..at.clone() };
let end = match s.doc.next_para(at.story, &at.path) {
Some(q) if q.parent() == at.path.parent() => Pos { story: at.story, path: q, off: 0 },
_ => Pos { off: p.len(), ..at.clone() },
};
return (start, end);
}
};
(Pos { off: a, ..at.clone() }, Pos { off: b, ..at.clone() })
}
fn select_unit(s: &mut Session, unit: Unit) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.word_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
if s.doc.para_at(&f).is_some() {
let (a, b) = unit_range(s, &f, unit);
s.sel = Selection { anchor: a, focus: b };
}
sel_result(s)
}
fn select_word(s: &mut Session, _: &Value) -> CmdResult {
select_unit(s, Unit::Word)
}
fn select_sentence(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
if let Some(p) = s.doc.para_at(&f) {
let (a, b) = p.sentence_at(f.off);
s.sel = Selection { anchor: Pos { off: a, ..f.clone() }, focus: Pos { off: b, ..f } };
}
sel_result(s)
select_unit(s, Unit::Sentence)
}
fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let f = s.sel.focus.clone();
let len = s.doc.para_at(&f).map(|p| p.len()).unwrap_or(0);
// Word includes the paragraph mark: extend to the start of the next paragraph when there is one.
let end = match s.doc.next_para(f.story, &f.path) {
Some(q) if q.parent() == f.path.parent() => Pos { story: f.story, path: q, off: 0 },
_ => Pos { off: len, ..f.clone() },
};
s.sel = Selection { anchor: Pos { off: 0, ..f }, focus: end };
select_unit(s, Unit::Paragraph)
}
/// Drag after a double-click (words), triple-click (paragraphs) or Ctrl/⌘+click (sentences):
/// the selection keeps the unit first selected (`start`..`end`) and grows by whole units to
/// the one under `pos`. Dragging forward anchors at the unit's start; dragging back before it
/// anchors at its end, as in Word.
fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
let unit = p::str(v, "unit").and_then(Unit::parse).ok_or_else(|| CmdError::Params("`unit` must be word, sentence or paragraph".into()))?;
let get = |k: &str| v.get(k).and_then(parse_pos).ok_or_else(|| CmdError::Params(format!("bad `{k}`")));
let (at, start, end) = (s.doc.clamp(&get("pos")?), s.doc.clamp(&get("start")?), s.doc.clamp(&get("end")?));
let (start, end) = if start <= end { (start, end) } else { (end, start) };
// Only within one story (the pointer over another story keeps the selection).
if at.story != start.story || end.story != start.story {
return sel_result(s);
}
let (us, ue) = unit_range(s, &at, unit);
s.sel = if at >= start { Selection { focus: ue.max(end.clone()), anchor: start } } else { Selection { focus: us.min(start), anchor: end } };
s.goal_x = None;
s.pending = None;
sel_result(s)
}

View File

@ -3218,6 +3218,59 @@ fn insert_icon_places_a_picture_of_the_requested_size_that_saves_and_recolours()
assert!(s.run("picture.iconColor", &json!({})).is_err());
}
/// #549: after a double-click, dragging extends the selection by whole words; after a
/// triple-click by whole paragraphs; after Ctrl/⌘+click by whole sentences. Forward drags keep
/// the first unit's start as the anchor, backward drags its end.
#[test]
fn drag_after_multi_click_extends_by_units() {
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Alpha beta gamma. Delta epsilon.\rSecond para here.\rThird one."}));
let drag = |s: &mut Session, unit: &str, start: Pos, end: Pos, pos: Pos| {
run(s, "select.dragUnit", json!({"unit": unit, "start": start, "end": end, "pos": pos}));
(s.sel.anchor.clone(), s.sel.focus.clone())
};
// Double-click "beta" (6..11 with its trailing space).
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 7)}));
run(&mut s, "select.word", json!({}));
let (a, b) = s.sel.ordered();
assert_eq!((a.off, b.off), (6, 11));
// Forward into the middle of "gamma": the whole word joins.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 13)), (Pos::body(0, 6), Pos::body(0, 16)));
// Back into "Alpha": anchored at the end of "beta ", focus at the start of "Alpha".
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 2)), (Pos::body(0, 11), Pos::body(0, 0)));
// Back inside the first word: just that word.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(0, 8)), (Pos::body(0, 6), Pos::body(0, 11)));
// Into the next paragraph: whole word there.
assert_eq!(drag(&mut s, "word", a.clone(), b.clone(), Pos::body(1, 8)), (Pos::body(0, 6), Pos::body(1, 12)));
// Ctrl+click a sentence, drag into the next sentence.
run(&mut s, "caret.set", json!({"pos": Pos::body(0, 3)}));
run(&mut s, "select.sentence", json!({}));
let (a, b) = s.sel.ordered();
let (_, f) = drag(&mut s, "sentence", a, b, Pos::body(0, 20));
assert_eq!(f.off, 32, "the second sentence is selected to its end");
// Triple-click the second paragraph, drag up into the first and down into the third.
run(&mut s, "caret.set", json!({"pos": Pos::body(1, 3)}));
run(&mut s, "select.paragraph", json!({}));
let (a, b) = s.sel.ordered();
assert_eq!((a.clone(), b.clone()), (Pos::body(1, 0), Pos::body(2, 0)));
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(0, 5)), (Pos::body(2, 0), Pos::body(0, 0)));
assert_eq!(drag(&mut s, "paragraph", a.clone(), b.clone(), Pos::body(2, 2)), (Pos::body(1, 0), Pos::body(2, 10)));
// Junk never panics and leaves the document alone.
let before = text(&s);
for v in [
json!({}),
json!({"unit": "line", "start": Pos::body(0, 0), "end": Pos::body(0, 1), "pos": Pos::body(0, 1)}),
json!({"unit": "word", "start": Pos::body(99, 999), "end": Pos::body(0, 0), "pos": Pos::body(77, usize::MAX)}),
json!({"unit": "word", "start": {"block": 0, "off": 9_999}, "end": {"block": 0, "off": 1}, "pos": {"block": 5}}),
] {
let _ = s.run("select.dragUnit", &v);
}
assert_eq!(text(&s), before);
}
/// Home › Font › Enclose Characters (#297): the selection becomes an `EQ \o\ac` field, saved the
/// way Word stores it (the style in the code's run sizes) and read back; null takes the text out
/// again, and each step undoes.

View File

@ -25,6 +25,12 @@ pub struct CanvasState {
dragging: bool,
/// Alt+drag: where the column (block) selection started (page, x, y) and its last end.
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
/// The last primary press on the page (time, screen point, clicks in a row), to count
/// double- and triple-presses as they go down (egui reports them on release).
last_press: Option<(f64, Pos2, u8)>,
/// A drag after a double-click, triple-click or Ctrl/⌘+click: the unit it extends by, the
/// unit first selected, and the last position the pointer hit.
unit_drag: Option<UnitDrag>,
pub last_highlight: String,
pub last_font_color: String,
pub last_shading: String,
@ -101,6 +107,8 @@ impl Default for CanvasState {
caret_visible_since: 0.0,
dragging: false,
column_drag: None,
last_press: None,
unit_drag: None,
last_highlight: "yellow".into(),
last_font_color: "C00000".into(),
last_shading: "FFF2CC".into(),
@ -140,6 +148,25 @@ impl Default for CanvasState {
}
}
/// See [`CanvasState::unit_drag`].
struct UnitDrag {
unit: &'static str,
/// Clicks in a row that made it (2 or 3; 1 for Ctrl/⌘+click).
clicks: u8,
start: Pos,
end: Pos,
last: Pos,
}
/// Clicks in a row for a press at `at` (time `now`) after `last`: within the double-click
/// delay and distance it counts up to 3 (a triple-click), otherwise it starts again at 1.
fn press_count(last: Option<(f64, Pos2, u8)>, now: f64, at: Pos2, delay: f64, dist: f32) -> u8 {
match last {
Some((t, p, n)) if now - t >= 0.0 && now - t < delay && p.distance(at) <= dist => n.saturating_add(1).min(3),
_ => 1,
}
}
impl CanvasState {
/// True while a mouse drag-select is in progress (the mini toolbar waits for mouse-up).
pub fn drag_selecting(&self) -> bool {
@ -1143,6 +1170,19 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
}
return;
}
// The press already selected the word or paragraph (and a drag may have grown it).
let released_clicks = if resp.triple_clicked() {
3
} else if resp.double_clicked() {
2
} else {
0
};
if released_clicks > 0 && app.canvas.unit_drag.as_ref().is_some_and(|d| d.clicks == released_clicks) {
app.canvas.unit_drag = None;
app.canvas.dragging = false;
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)
@ -1180,6 +1220,14 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
}
let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer();
if pressed {
let now = ui.input(|i| i.time);
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
let clicks = press_count(app.canvas.last_press, now, p, delay, dist);
app.canvas.last_press = Some((now, p, clicks));
app.canvas.unit_drag = None;
// A double-click that enters or leaves a header/footer is handled on release.
let hf_switch = (layout.header_footer_at(page, y).is_some() && story == StoryRef::Body)
|| (matches!(story, StoryRef::Part(_)) && !in_text_box(app) && layout.header_footer_at(page, y).is_none());
// Clicking into a footnote/endnote or a text box edits it; clicking outside a text box goes
// back to where it is. While editing a header/footer, only its own text boxes are in reach.
let story = {
@ -1233,8 +1281,19 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
}
app.canvas.column_drag = None;
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
if mods.command && !mods.shift {
let _ = app.run("select.sentence", json!({}));
// Double-click selects a word, triple-click a paragraph, Ctrl/⌘+click a sentence; holding
// the button and dragging then extends by whole units (Word, #549).
let unit = match clicks {
_ if mods.shift => None,
3 if !hf_switch => Some("paragraph"),
2 if !hf_switch => Some("word"),
1 if mods.command => Some("sentence"),
_ => None,
};
if let Some(unit) = unit {
let _ = app.run(&format!("select.{unit}"), json!({}));
let (start, end) = app.session.sel.ordered();
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: pos });
}
app.session.page_hint = page;
app.canvas.dragging = true;
@ -1251,6 +1310,19 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
json!({"from": {"page": from.0, "x": from.1, "y": from.2}, "to": {"page": page, "x": x, "y": y}, "story": story}),
);
}
} else if app.canvas.dragging
&& ui.input(|i| i.pointer.primary_down())
&& let Some(d) = app.canvas.unit_drag.as_mut()
{
if let Some(pos) = layout.hit(page, x, y, story)
&& pos != d.last
{
d.last = pos.clone();
let params = json!({"unit": d.unit, "start": d.start, "end": d.end, "pos": pos});
let _ = app.run("select.dragUnit", params);
app.session.page_hint = page;
app.canvas.caret_visible_since = crate::now_ms();
}
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
if let Some(pos) = layout.hit(page, x, y, story)
&& pos != app.session.sel.focus
@ -1263,6 +1335,7 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
app.canvas.dragging = false;
app.canvas.column_drag = None;
app.canvas.unit_drag = None;
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
let _ = app.run("edit.pasteFormat", json!({}));
}
@ -1838,6 +1911,87 @@ mod tests {
assert_eq!(wordcraft_engine::cmd::page::sect(&app.session).margin_left, sect.margin_left);
}
/// #549: presses count as double/triple as they go down; slow or far presses start again.
#[test]
fn presses_count_up_to_three() {
let at = pos2(100.0, 100.0);
assert_eq!(press_count(None, 5.0, at, 0.3, 6.0), 1);
assert_eq!(press_count(Some((5.0, at, 1)), 5.1, at, 0.3, 6.0), 2);
assert_eq!(press_count(Some((5.1, at, 2)), 5.2, at + vec2(3.0, 0.0), 0.3, 6.0), 3);
assert_eq!(press_count(Some((5.2, at, 3)), 5.3, at, 0.3, 6.0), 3, "stays a triple");
assert_eq!(press_count(Some((5.0, at, 1)), 5.5, at, 0.3, 6.0), 1, "too slow");
assert_eq!(press_count(Some((5.0, at, 1)), 5.1, at + vec2(20.0, 0.0), 0.3, 6.0), 1, "too far");
assert_eq!(press_count(Some((5.0, at, 1)), 4.0, at, 0.3, 6.0), 1, "the clock went back");
}
/// #549: double-click a word and drag: the selection grows by whole words; triple-click and
/// drag: by whole paragraphs (Word's behaviour).
#[test]
fn multi_click_drag_extends_by_units() {
use egui::{Event, PointerButton};
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": "Alpha beta gamma delta\rSecond para\rThird para"})).unwrap();
a.run("view.commentsPane", json!({"value": false})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let screen = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: Pos| {
let c = h.state_mut().session.layout().caret(&pos).expect("caret");
page_to_screen(h.state_mut(), c.page, c.x + 1.0, c.top + c.height / 2.0).expect("on screen")
};
let button =
|at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let multi_drag = |h: &mut egui_kittest::Harness<'_, WordApp>, from: Pos2, to: Pos2, clicks: usize| {
h.event(Event::PointerMoved(from));
for i in 0..clicks {
h.event(button(from, true));
h.step();
if i + 1 < clicks {
h.event(button(from, false));
h.step();
}
}
for k in 1..=4 {
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 4.0)));
h.step();
}
h.event(button(to, false));
for _ in 0..3 {
h.step();
}
};
// Double-click inside "beta", drag into "gamma": "beta gamma " is selected.
let (from, to) = (screen(&mut h, Pos::body(0, 8)), screen(&mut h, Pos::body(0, 13)));
multi_drag(&mut h, from, to, 2);
let (s, e) = h.state().session.sel.ordered();
assert_eq!((s, e), (Pos::body(0, 6), Pos::body(0, 17)), "whole words");
// Let the double-click time pass, then triple-click the second paragraph and drag into the third.
for _ in 0..40 {
h.step();
}
let (from, to) = (screen(&mut h, Pos::body(1, 3)), screen(&mut h, Pos::body(2, 2)));
multi_drag(&mut h, from, to, 3);
let sel = h.state().session.sel.clone();
assert_eq!((sel.anchor, sel.focus), (Pos::body(1, 0), Pos::body(2, 10)), "whole paragraphs");
// A plain double-click (no drag) still selects just the word.
h.state_mut().run("select.collapse", json!({})).unwrap();
for _ in 0..40 {
h.step();
}
let at = screen(&mut h, Pos::body(0, 1));
multi_drag(&mut h, at, at, 2);
let (s, e) = h.state().session.sel.ordered();
assert_eq!((s, e), (Pos::body(0, 0), Pos::body(0, 6)));
}
/// #167: a comment balloon edits in place like Word's: click it, type, leave it, and the
/// comment has the new text as one undo step.
#[test]

View File

@ -378,6 +378,7 @@
Export a copy to share (PDF, Word document) Exporta una copia para compartir (PDF, documento de Word)
Exported {path} Exportado: {path}
Extend Selection Ampliar selección
Extend Selection by Units Ampliar selección por unidades
Field Campo
File Archivo
Find Buscar

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -251,6 +251,7 @@
Export a copy to share (PDF, Word document) Ekspordi koopia jagamiseks (PDF, Wordi dokument)
Exported {path} Eksporditud: {path}
Extend Selection Laienda valikut
Extend Selection by Units Laienda valikut ühikute kaupa
Field Väli
File Fail
Find Otsi

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -380,6 +380,7 @@
Export PDF PDF にエクスポート
Exported {path} {path} にエクスポートしました
Extend Selection 選択範囲の拡張
Extend Selection by Units 単位ごとに選択範囲を拡張
Field フィールド
File ファイル
Find 検索

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -381,6 +381,7 @@
Export PDF Exportar PDF
Exported {path} Exportado {path}
Extend Selection Expandir seleção
Extend Selection by Units Expandir seleção por unidades
Field Campo
File Arquivo
Find Localizar

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -253,6 +253,7 @@
Export PDF Экспорт в PDF
Exported {path} Экспортировано: {path}
Extend Selection Расширить выделение
Extend Selection by Units Расширить выделение по единицам
Field Поле
File Файл
Find Найти

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -428,6 +428,7 @@
Export PDF Izvezi PDF
Exported {path} Izvezeno: {path}
Extend Selection Proširi izbor
Extend Selection by Units Proširi izbor po jedinicama
Field Polje
File Fajl
Find Pronađi

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -432,6 +432,7 @@
Export PDF Извези PDF
Exported {path} Извезено: {path}
Extend Selection Прошири избор
Extend Selection by Units Прошири избор по јединицама
Field Поље
File Фајл
Find Пронађи

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -252,6 +252,7 @@
Export a copy to share (PDF, Word document) Paylaşmak için bir kopya dışa aktarın (PDF, Word belgesi)
Exported {path} Dışa aktarıldı: {path}
Extend Selection Seçimi Genişlet
Extend Selection by Units Seçimi Birimlere Göre Genişlet
Field Alan
File Dosya
Find Bul

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -390,6 +390,7 @@
Export PDF Експортувати PDF
Exported {path} Експортовано {path}
Extend Selection Розширити виділення
Extend Selection by Units Розширити виділення за одиницями
Field Поле
File Файл
Find Знайти

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -392,6 +392,7 @@
Export PDF 导出 PDF
Exported {path} 已导出 {path}
Extend Selection 扩展选定范围
Extend Selection by Units 按单位扩展选定范围
Field 域
File 文件
Find 查找

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -381,6 +381,7 @@
Export PDF 匯出 PDF
Exported {path} 已匯出 {path}
Extend Selection 擴大選取範圍
Extend Selection by Units 按單位擴大選取範圍
Field 功能變數
File 檔案
Find 尋找

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Find and Replace special characters and wildcards, #543; 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 (drag after a double/triple-click extends by words/paragraphs, #549; previously trivial: Find and Replace special characters and wildcards, #543; 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
@ -23,7 +23,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tabs; table column drag pending (#49) | ✅ | 70% | 2–3 |
| On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228) | ✅ | 75% | — |
| Rotation handle, crop handles on canvas, alignment guides, smart guides | rotation handle (Shift: 15° steps), turned frames resize along their axes (#332); crop handles and guides missing | ✅ | 25% | 3–5 |
| Selection: word/sentence/paragraph clicks, Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 85% | 1 |
| Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237) | ✅ | ✅ | 90% | 1 |
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — |
@ -45,6 +45,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 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) |
| 2026-10-11 | trivial | Screen readers: the document canvas exposes paragraphs, headings, lists, tables, links, pictures and the caret through AccessKit (#488); 25% → 50% |
| 2026-10-11 | trivial | Selection by units: double-click and drag extends by whole words, triple-click and drag by paragraphs, Ctrl/⌘+click and drag by sentences; `select.dragUnit` (#549) |
| 2026-10-11 | trivial | Compare / Combine Documents (original and revised document, label changes with, comparison settings, word or character level, show changes in) and AutoCorrect Options (replacement list, capitalization, exceptions, AutoFormat As You Type, Math AutoCorrect) dialogs; Restrict Editing pane (formatting and editing restrictions, exceptions for everyone, password); modal dialog rows merged into one (#412) |
| 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one |
| 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) |