#856: click in the left margin (selection bar) selects lines (#879)

A click in the left margin beside body text selects that line (trailing
space, and the paragraph mark on a paragraph's last line), a double-click
the paragraph, a triple-click or Ctrl/Cmd+click the document; dragging
there selects whole lines and Shift+click extends by lines. The pointer
shows the arrow there. Layout gains selection_bar_at, body_line_at and
line_span; select.line takes an optional pos; select.dragUnit takes the
units line and document.

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:
authored and GitHub committed 2026-10-12 11:08:43 -04:00
1 parent 35b3600ea5
commit 7f1085fbbd
7 files changed
+322 -28

No files matched your search

+3 -1
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 (progress log: section break over a selection, #852; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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 (selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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
@@ -214,6 +214,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Complex scripts (#604): Myanmar, Khmer and Indic text shaped one script at a time in OpenType fallback fonts (regular weight, not AAT-only fonts), the caret and line breaks never split a syllable, Myanmar wraps between syllables, Backspace removes one sign at a time |
| 2026-10-11 | Any text file opens (#569): `.m3u`/`.m3u8` playlists and other unknown extensions open as plain text with encoding detection (UTF-8, BOMs, UTF-16, Windows-1252), misnamed Word/RTF files by their signature; binary files give a clear error |
| 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-12 | Selection bar (#856): in the left margin beside body text a click selects the line, a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; dragging there selects whole lines and Shift+click extends by lines; the pointer turns to an arrow. Right margin for right-to-left paragraphs not yet |
| 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 | DOCX diagrams (#611): lines, connectors and boxes styled by the theme now show (they were invisible); callouts and flowchart boxes drawn as the nearest shape; VML lines, rectangles and ovals |
| 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 |
@@ -302,6 +303,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | trivial | Progress log: complex-script shaping (#604) |
| 2026-10-11 | trivial | Progress log: unknown text extensions open as text (#569) |
| 2026-10-11 | trivial | Progress log: drag-select by words/paragraphs/sentences (#549) |
| 2026-10-12 | trivial | Progress log: selection bar in the left margin (#856) |
| 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) |
+44 -22
View File
@@ -49,9 +49,9 @@ pub fn specs() -> Vec<CommandSpec> {
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}"#)
.params(r#"{"unit": "word|sentence|line|paragraph|document", "start": Pos, "end": Pos, "pos": Pos}"#)
.pure(),
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).pure(),
CommandSpec::new("select.line", "Select Line", "Navigation", select_line).params(r#"{"pos"?: Pos}"#).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| {
let to_end = p::bool(v, "end").unwrap_or(false);
@@ -291,12 +291,15 @@ fn select_range(s: &mut Session, v: &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.
/// triple-click (paragraph) selects, and what dragging after it extends by. A click in the
/// selection bar (the left margin) selects a line; triple-click there the whole document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unit {
Word,
Sentence,
Line,
Paragraph,
Document,
}
impl Unit {
@@ -304,26 +307,36 @@ impl Unit {
match s {
"word" => Some(Unit::Word),
"sentence" => Some(Unit::Sentence),
"line" => Some(Unit::Line),
"paragraph" => Some(Unit::Paragraph),
"document" => Some(Unit::Document),
_ => 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) {
/// The word, sentence, line, paragraph or story 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), and so does its last line.
pub fn unit_range(s: &mut Session, at: &Pos, unit: Unit) -> (Pos, Pos) {
let Some(p) = s.doc.para_at(at) else { return (at.clone(), at.clone()) };
let len = p.len();
let mark_end = |s: &Session| 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: len, ..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() },
Unit::Paragraph => return (Pos { off: 0, ..at.clone() }, mark_end(s)),
Unit::Document => return (s.doc.start_of(at.story), s.doc.end_of(at.story)),
Unit::Line => {
let hint = s.page_hint;
return match s.layout().line_span(at, hint) {
Some((a, _, true)) => (a, mark_end(s)),
Some((a, b, false)) => (a, b),
None => (at.clone(), at.clone()),
};
return (start, end);
}
};
(Pos { off: a, ..at.clone() }, Pos { off: b, ..at.clone() })
@@ -350,12 +363,23 @@ fn select_paragraph(s: &mut Session, _: &Value) -> CmdResult {
select_unit(s, Unit::Paragraph)
}
/// A click in the selection bar (#856): the whole line at `pos` (default: the caret's), its
/// trailing spaces and, on a paragraph's last line, the paragraph mark.
fn select_line(s: &mut Session, v: &Value) -> CmdResult {
if let Some(pos) = v.get("pos").and_then(parse_pos) {
s.sel = Selection::caret(s.doc.clamp(&pos));
}
select_unit(s, Unit::Line)
}
/// 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 unit = p::str(v, "unit")
.and_then(Unit::parse)
.ok_or_else(|| CmdError::Params("`unit` must be word, sentence, line, paragraph or document".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) };
@@ -364,20 +388,18 @@ fn drag_unit(s: &mut Session, v: &Value) -> CmdResult {
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.sel = if unit == Unit::Document {
Selection { anchor: us, focus: ue }
} else 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)
}
fn select_line(s: &mut Session, _: &Value) -> CmdResult {
let l = s.layout();
if let Some((a, b)) = l.line_bounds(&s.sel.focus, s.page_hint) {
s.sel = Selection { anchor: a, focus: b };
}
sel_result(s)
}
/// Select the n-th occurrence of `text` in the body (agents: "select the word X").
fn select_text(s: &mut Session, v: &Value) -> CmdResult {
let needle = p::req_str(v, "text")?;
+34 -1
View File
@@ -3815,7 +3815,7 @@ fn drag_after_multi_click_extends_by_units() {
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": "page", "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}}),
] {
@@ -3824,6 +3824,39 @@ fn drag_after_multi_click_extends_by_units() {
assert_eq!(text(&s), before);
}
/// #856: a click in the selection bar selects the whole line (its trailing space; the
/// paragraph mark on the last line); dragging extends by lines; triple-click takes the document.
#[test]
fn selection_bar_selects_and_drags_by_lines() {
let mut s = s();
let long = "The quick brown fox jumps over the lazy dog. ".repeat(8);
run(&mut s, "text.insert", json!({"text": format!("{}\rShort one.\rLast.", long.trim_end())}));
let l = s.layout();
let c = l.caret(&Pos::body(0, 0)).unwrap();
let line2 = l.selection_bar_at(c.page, 20.0, c.top + c.height * 1.5).unwrap();
assert!(line2.off > 0);
run(&mut s, "select.line", json!({"pos": line2.clone()}));
let (a, b) = s.sel.ordered();
assert_eq!(a, line2);
assert_eq!(b.path, wordcraft_doc::Path::top(0));
assert!(b.off > a.off && b.off < long.trim_end().len());
assert_eq!(s.doc.para_at(&b).unwrap().text.as_bytes()[b.off - 1], b' ', "the trailing space is in");
// The last line of a paragraph takes its mark.
run(&mut s, "select.line", json!({"pos": Pos::body(1, 3)}));
assert_eq!(s.sel.ordered(), (Pos::body(1, 0), Pos::body(2, 0)));
// Drag from the "Short one." line down to "Last." and back up into the first paragraph.
let (a, b) = s.sel.ordered();
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": Pos::body(2, 0)}));
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(1, 0), Pos::body(2, 5)));
run(&mut s, "select.dragUnit", json!({"unit": "line", "start": a.clone(), "end": b.clone(), "pos": line2.clone()}));
assert_eq!((s.sel.anchor.clone(), s.sel.focus.clone()), (Pos::body(2, 0), line2));
run(&mut s, "select.dragUnit", json!({"unit": "document", "start": a.clone(), "end": b.clone(), "pos": Pos::body(0, 0)}));
assert_eq!(s.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 5)));
// Junk positions never panic.
let _ = s.run("select.line", &json!({"pos": {"block": 99, "off": usize::MAX}}));
let _ = s.run("select.line", &json!({"pos": 5}));
}
/// 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.
+54
View File
@@ -213,6 +213,15 @@ fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {
Some(Pos { story, path: path.clone(), off: para.off_at_x(li, u) })
}
/// The body line on `page` whose band is nearest height `y` (the leftmost on a tie, so in
/// columns the first column wins); only top-level paragraphs' lines when `top_only`.
fn nearest_line_at<'a>(page: &'a Page, y: f32, top_only: bool) -> Option<LineHit<'a>> {
page_lines(page, StoryRef::Body)
.into_iter()
.filter(|l| !top_only || l.path.0.len() <= 1)
.min_by(|a, b| band_dist(y, a.top, a.bottom - a.top).total_cmp(&band_dist(y, b.top, b.bottom - b.top)).then(a.left.total_cmp(&b.left)))
}
/// How far `v` is outside the band `top..top + h`.
fn band_dist(v: f32, top: f32, h: f32) -> f32 {
if v < top {
@@ -476,6 +485,51 @@ impl DocLayout {
None
}
/// Word's selection bar (#856): when (x, y) is in the page's left margin, left of the body
/// text column, the start of the body line at that height. Not in the header/footer area,
/// not beside a table's lines (those are left to the table), nothing on an empty page.
pub fn selection_bar_at(&self, page: usize, x: f32, y: f32) -> Option<Pos> {
let p = self.pages.get(page)?;
if !(x.is_finite() && y.is_finite()) || x < 0.0 || x >= p.body.x || y < p.body.y || y > p.body.bottom() {
return None;
}
let l = nearest_line_at(p, y, false)?;
if l.path.0.len() > 1 || l.left < p.body.x - 0.5 {
// A table's line, or text hanging into the margin: not the selection bar.
return None;
}
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start })
}
/// The start of the top-level body line nearest height `y` on `page` (dragging in the
/// selection bar extends line by line, wherever the pointer is across).
pub fn body_line_at(&self, page: usize, y: f32) -> Option<Pos> {
let p = self.pages.get(page)?;
if !y.is_finite() {
return None;
}
let l = nearest_line_at(p, y, true)?;
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start })
}
/// The whole visual line holding `pos`, trailing spaces and break included: (start, end,
/// whether it is the paragraph's last line). What a click in the selection bar selects.
pub fn line_span(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos, bool)> {
for (_, it) in self.pieces(pos.story, &pos.path, page_hint) {
let Placed::Lines { para, l0, l1, .. } = it else { continue };
let li = para.line_of(pos.off);
if li < *l0 || li >= *l1 {
continue;
}
let l = para.lines.get(li)?;
let next = para.lines.get(li.saturating_add(1));
let end = next.map_or(l.stop, |n| n.start).max(l.start);
let mk = |off| Pos { story: pos.story, path: pos.path.clone(), off };
return Some((mk(l.start), mk(end), next.is_none()));
}
None
}
/// Column (block) selection: the piece of every line from the line holding `a` to the line
/// holding `b` (document order) that lies between page x `left` and `right`. One `(start,
/// end)` pair per line, both in the same paragraph; lines shorter than `left` give an empty
+33
View File
@@ -86,6 +86,39 @@ fn hit_and_caret_agree() {
assert_eq!(up.path, Path::top(0));
}
#[test]
fn selection_bar_finds_the_line_beside_the_pointer() {
// #856: the left margin beside a line is Word's selection bar.
let text = format!("{}\nShort.", "The quick brown fox jumps over the lazy dog. ".repeat(8));
let mut d = Document::from_text(&text);
d.insert_block(StoryRef::Body, &Path::top(2), wordcraft_doc::Block::Table(Table::new(1, 1, 468.0))).unwrap();
let l = lay(&d);
let p = &l.pages[0];
let c = l.caret(&Pos::body(0, 0)).unwrap();
let first = l.selection_bar_at(0, 30.0, c.top + c.height / 2.0).unwrap();
assert_eq!(first, Pos::body(0, 0));
// The second line of the wrapped paragraph: its start, and its span is the whole line.
let line2 = l.selection_bar_at(0, 30.0, c.top + c.height * 1.5).unwrap();
assert_eq!(line2.path, Path::top(0));
assert!(line2.off > 0);
let (a, b, last) = l.line_span(&line2, 0).unwrap();
assert_eq!(a, line2);
assert!(b.off > a.off && !last);
let (_, b, last) = l.line_span(&Pos::body(1, 0), 0).unwrap();
assert_eq!(b.off, "Short.".len());
assert!(last);
// In the text column, the header area, off the page or beside a table: not the bar.
assert_eq!(l.selection_bar_at(0, p.body.x + 5.0, c.top + 2.0), None);
assert_eq!(l.selection_bar_at(0, 30.0, p.body.y - 10.0), None);
assert_eq!(l.selection_bar_at(0, -5.0, c.top + 2.0), None);
assert_eq!(l.selection_bar_at(0, f32::NAN, c.top), None);
assert_eq!(l.selection_bar_at(9, 30.0, c.top), None);
let cell = l.caret(&Pos { story: StoryRef::Body, path: Path(vec![2, 0, 0, 0]), off: 0 }).unwrap();
assert_eq!(l.selection_bar_at(0, 30.0, cell.top + cell.height / 2.0), None);
// Dragging takes the nearest top-level line at any height.
assert_eq!(l.body_line_at(0, -100.0), Some(Pos::body(0, 0)));
}
#[test]
fn deletions_leave_the_final_text_layout() {
// "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED".
+150 -3
View File
@@ -1325,8 +1325,15 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
if let Some(c) = crate::ink::cursor(app).filter(|_| resp.hovered() || app.canvas.ink.is_some()) {
ui.ctx().set_cursor_icon(c);
} else if resp.hovered() || app.canvas.obj_drag.is_some() {
let over_object = ui.input(|i| i.pointer.latest_pos()).and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
ui.ctx().set_cursor_icon(over_object.unwrap_or(egui::CursorIcon::Text));
let latest = ui.input(|i| i.pointer.latest_pos());
let over_object = latest.and_then(|p| crate::objects::cursor(app, &layout, &rects, geo.scale, p));
// The selection bar: Word shows a right-pointing arrow there; egui has none, so the
// plain arrow stands in (#856).
let in_bar = app.session.sel.focus.story == StoryRef::Body
&& (app.canvas.unit_drag.as_ref().is_some_and(|d| d.unit == "line")
|| latest.and_then(|p| page_at(&rects, &layout, geo.scale, p)).is_some_and(|(pg, x, y)| layout.selection_bar_at(pg, x, y).is_some()));
let fallback = if in_bar { egui::CursorIcon::Default } else { egui::CursorIcon::Text };
ui.ctx().set_cursor_icon(over_object.unwrap_or(fallback));
}
crate::table_resize::cursor(app, ui, &resp, &layout, &rects, geo.scale);
crate::table_mouse::cursor(app, ui, &resp, &layout, &rects, geo.scale);
@@ -1868,6 +1875,42 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
if app.session.math.is_some() {
let _ = app.run("equation.exit", json!({}));
}
// The selection bar (the left margin beside body text, #856): a click selects the line,
// a double-click the paragraph, a triple-click or Ctrl/⌘+click the document; Shift+click
// extends by lines; dragging grows the selection by the clicked unit.
if story == StoryRef::Body
&& !mods.alt
&& let Some(line) = layout.selection_bar_at(page, x, y)
{
app.canvas.column_drag = None;
app.session.page_hint = page;
let lj = serde_json::to_value(&line).unwrap_or_default();
let (unit, start, end) = if mods.shift {
let anchor = app.session.sel.anchor.clone();
let aj = serde_json::to_value(&anchor).unwrap_or_default();
let _ = app.run("select.dragUnit", json!({"unit": "line", "start": aj, "end": aj, "pos": lj}));
("line", anchor.clone(), anchor)
} else {
let unit = match clicks {
_ if mods.command => "document",
3.. => "document",
2 => "paragraph",
_ => "line",
};
let _ = app.run("caret.set", json!({"pos": lj}));
let id = match unit {
"document" => "select.all",
"paragraph" => "select.paragraph",
_ => "select.line",
};
let _ = app.run(id, json!({}));
let (start, end) = app.session.sel.ordered();
(unit, start, end)
};
app.canvas.unit_drag = Some(UnitDrag { unit, clicks, start, end, last: line });
app.canvas.dragging = true;
return;
}
let Some(pos) = layout.hit(page, x, y, story) else { return };
// Ctrl/⌘+click follows a hyperlink.
if mods.command
@@ -1926,7 +1969,13 @@ fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layo
&& 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)
// Lines grow line by line wherever the pointer is across; the whole document can't grow.
let target = match d.unit {
"line" => layout.body_line_at(page, y),
"document" => None,
_ => layout.hit(page, x, y, story),
};
if let Some(pos) = target
&& pos != d.last
{
d.last = pos.clone();
@@ -2777,6 +2826,104 @@ mod tests {
assert_eq!((s, e), (Pos::body(0, 0), Pos::body(0, 6)));
}
/// #856: the left margin beside body text is Word's selection bar. A click selects the line,
/// a double-click the paragraph, a triple-click (or Ctrl/⌘+click) the document; dragging
/// selects whole lines and Shift+click extends by lines.
#[test]
fn selection_bar_clicks_select_lines() {
use egui::{Event, Modifiers, PointerButton};
let long = "The quick brown fox jumps over the lazy dog. ".repeat(8);
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": format!("{}\rSecond para\rThird para", long.trim_end())})).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();
}
// A point in the left margin beside the line `li` lines below the one holding `pos`.
let margin = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: Pos, li: f32| {
let c = h.state_mut().session.layout().caret(&pos).expect("caret");
page_to_screen(h.state_mut(), c.page, 30.0, c.top + c.height * (li + 0.5)).expect("on screen")
};
let line_start = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
let (rects, scale) = (h.state().canvas.page_rects.clone(), h.state().canvas.scale);
let layout = h.state_mut().session.layout();
let (pg, x, y) = page_at(&rects, &layout, scale, at).expect("page");
layout.selection_bar_at(pg, x, y).expect("in the selection bar")
};
let span = |h: &mut egui_kittest::Harness<'_, WordApp>, pos: &Pos| h.state_mut().session.layout().line_span(pos, 0).expect("line");
let button =
|at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let clicks_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();
}
};
let pause = |h: &mut egui_kittest::Harness<'_, WordApp>| {
for _ in 0..40 {
h.step();
}
};
// Click beside the second line: that whole line is selected, not a caret at its start.
let at = margin(&mut h, Pos::body(0, 0), 1.0);
let l2 = line_start(&mut h, at);
assert!(l2.off > 0 && l2.path == wordcraft_doc::Path::top(0));
clicks_drag(&mut h, at, at, 1);
let (_, l2_end, _) = span(&mut h, &l2);
assert_eq!(h.state().session.sel.ordered(), (l2.clone(), l2_end.clone()), "the line");
// Double-click: the paragraph, mark included.
pause(&mut h);
clicks_drag(&mut h, at, at, 2);
assert_eq!(h.state().session.sel.ordered(), (Pos::body(0, 0), Pos::body(1, 0)), "the paragraph");
// Triple-click: the whole document.
pause(&mut h);
clicks_drag(&mut h, at, at, 3);
assert_eq!(h.state().session.sel.ordered(), (Pos::body(0, 0), Pos::body(2, 10)), "everything");
// Drag from beside the first line down past the third: three whole lines.
pause(&mut h);
let (from, to) = (margin(&mut h, Pos::body(0, 0), 0.0), margin(&mut h, Pos::body(0, 0), 2.0));
let l3 = line_start(&mut h, to);
clicks_drag(&mut h, from, to, 1);
let (_, l3_end, _) = span(&mut h, &l3);
let sel = h.state().session.sel.clone();
assert_eq!((sel.anchor, sel.focus), (Pos::body(0, 0), l3_end), "three lines");
// Shift+click beside "Second para" extends by lines, to that paragraph's mark.
pause(&mut h);
let second = margin(&mut h, Pos::body(1, 0), 0.0);
h.event(Event::ModifiersChanged(Modifiers::SHIFT));
h.step();
clicks_drag(&mut h, second, second, 1);
h.event(Event::ModifiersChanged(Modifiers::default()));
h.step();
let sel = h.state().session.sel.clone();
assert_eq!((sel.anchor, sel.focus), (Pos::body(0, 0), Pos::body(2, 0)), "extended by lines");
// Hovering the bar shows the arrow, not the I-beam.
h.event(Event::PointerMoved(at));
h.step();
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default);
}
/// #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]
+4 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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 (selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; 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
@@ -28,6 +28,8 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 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, 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), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — |
| 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), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 |
| Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — |
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
| Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged | reading-optimized view and navigation | 75% | 1–2 |
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's; a click sets the proofing language) | ✅ | 85% | — |
@@ -81,6 +83,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| 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 | More Colors… in every colour drop-down and a Colors dialog (Standard and Custom tabs, RGB/HSL numbers, hex box) with Recent Colors (#606) |
| 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-12 | trivial | Selection bar: clicks in the left margin select the line, paragraph or document, drags select whole lines, Shift+click extends by lines; `select.line {"pos"}`, `select.dragUnit` units `line` and `document` (#856) |
| 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) |