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:
parent
0eea21c2c8
commit
14080c614c
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue