Browse / github.com/storytold/wordcraft / patch
fix(layout): let the caret reach the end of a line that wraps after text
Caret can't reach the end of a soft-wrapped line in Chinese text (or a word wider than the line): End stops one character short, Right jumps to the next line ## Summary When a paragraph wraps **after text** rather than at a space — Chinese/Japanese text, or a single word wider than the line — there is no way to put the caret after the last character of a visual line (other than the paragraph's last line): - **End** stops *before* the last character of the line. - **Right** from there moves the caret to the *start of the next line*; it is never shown after the last character. - **Shift+End** selects the line without its last character. - **A click on the right half of the last character** (or just past it) puts the caret before that character. - A
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- inter0925 (imported from pull request #4519, open)
- Base
2cf1d6c7ae94723a45bd6e7e8c884fe375148750- Size
- +765 −94 in 29 files
- Review
- pending
- Shared
- 2026-10-28
- Id
73f7009d20b434bfcfe793ed
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/caret.rs crates/engine/src/cmd/column.rs crates/engine/src/cmd/edit.rs crates/engine/src/cmd/file.rs crates/engine/src/cmd/table/select.rs crates/engine/src/cmd/text/enter_tab.rs crates/engine/src/doc_state.rs crates/engine/src/print.rs crates/engine/src/session.rs crates/engine/src/session/documents.rs crates/engine/src/session/lifecycle.rs crates/engine/src/session/query.rs crates/engine/src/session/run.rs crates/engine/src/session/snapshot.rs crates/engine/src/session/views.rs crates/engine/src/tests.rs crates/engine/src/tests/soft_wrap.rs crates/layout/src/hit.rs crates/layout/src/para.rs crates/layout/src/tests.rs crates/layout/src/tests/autospace.rs crates/layout/src/tests/vertical.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/canvas/cursor.rs crates/ui-egui/src/canvas/input.rs crates/ui-egui/src/canvas/tests.rs crates/ui-egui/src/caret_glide.rs crates/ui-egui/src/chrome.rs docs/typing-parity.md
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> { CommandSpec::new("select.collapse", "Collapse Selection", "Navigation", |s, v| { let to_end = p::bool(v, "end").unwrap_or(false); let (a, b) = s.sel.ordered();- s.sel = Selection::caret(if to_end { b } else { a });+ collapse(s, if to_end { b } else { a }); sel_result(s) }) .key("Escape")@@ -75,21 +75,33 @@ fn extend(v: &Value) -> bool { p::bool(v, "extend").unwrap_or(false) } -fn apply(s: &mut Session, to: Pos, ext: bool) {+/// Move the caret (with `ext`, the selection's focus) to `to`. With `line_end`, where a line wraps+/// after text at `to` the caret is at the end of that line, not the start of the next+/// ([`Session::at_line_end`]).+fn apply(s: &mut Session, to: Pos, ext: bool, line_end: bool) { if ext { s.sel.focus = to; } else { s.sel = Selection::caret(to); }+ s.set_at_line_end(line_end); s.pending = None; } +/// Collapse the selection to the caret at `to`, which stays at the end of its line if the+/// selection's focus was there ([`Session::at_line_end`]).+fn collapse(s: &mut Session, to: Pos) {+ let line_end = to == s.sel.focus && s.at_line_end(&to);+ s.sel = Selection::caret(to);+ s.set_at_line_end(line_end);+}+ fn mv(s: &mut Session, v: &Value, f: fn(&mut Session, &Pos) -> Pos) -> CmdResult { let ext = extend(v); s.goal_x = None; let from = s.sel.focus.clone(); let to = f(s, &from);- apply(s, to, ext);+ apply(s, to, ext, false); sel_result(s) } @@ -110,14 +122,24 @@ fn lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult { let (a, b) = s.sel.ordered(); // The left edge of a selection in right-to-left text is its logical end. let to_start = is_left != rtl_para(s);- s.sel = Selection::caret(if to_start { a } else { b });+ collapse(s, if to_start { a } else { b }); s.goal_x = None; return sel_result(s); } if bidi_para(s) { return visual_lr(s, v, is_left); }- mv(s, v, if is_left { left } else { right })+ if is_left {+ return mv(s, v, left);+ }+ // Right past the last character of a line that wraps after text stays at that line's end,+ // rather than showing the caret at the start of the next line (the same text position).+ let r = mv(s, v, right)?;+ let to = s.sel.focus.clone();+ if s.layout().is_wrap_end(&to, s.page_hint) {+ s.set_at_line_end(true);+ }+ Ok(r) } /// Left/Right in bidirectional text: one caret position to the left or right on the screen.@@ -147,7 +169,7 @@ fn visual_lr(s: &mut Session, v: &Value, is_left: bool) -> CmdResult { None if is_left => left(s, &from), None => right(s, &from), };- apply(s, to, ext);+ apply(s, to, ext, false); sel_result(s) } @@ -216,21 +238,21 @@ fn vert(s: &mut Session, v: &Value, dir: i32) -> CmdResult { let ext = extend(v); let l = s.layout(); let from = s.sel.focus.clone();- let to = match l.vertical(&from, s.goal_x, dir, s.page_hint) {- Some((p, gx)) => {+ let (to, line_end) = match l.vertical_at(&from, s.goal_x, dir, s.page_hint, s.at_line_end(&from)) {+ Some((p, gx, line_end)) => { s.goal_x = Some(gx);- p+ (p, line_end) } None => { // Top/bottom of the story: go to its start/end.- if dir < 0 { s.doc.start_of(from.story) } else { s.doc.end_of(from.story) }+ (if dir < 0 { s.doc.start_of(from.story) } else { s.doc.end_of(from.story) }, false) } };- if let Some(c) = l.caret_on(&to, s.page_hint) {+ if let Some(c) = l.caret_at(&to, s.page_hint, line_end) { s.page_hint = c.page; } let gx = s.goal_x;- apply(s, to, ext);+ apply(s, to, ext, line_end); s.goal_x = gx; sel_result(s) }@@ -239,18 +261,18 @@ fn line(s: &mut Session, v: &Value, home: bool) -> CmdResult { let ext = extend(v); let l = s.layout(); let from = s.sel.focus.clone();- let to = match l.line_bounds(&from, s.page_hint) {- Some((a, b)) => {+ let (to, line_end) = match l.line_bounds(&from, s.page_hint, s.at_line_end(&from)) {+ Some((a, b, wraps)) => { if home {- a+ (a, false) } else {- b+ (b, wraps) } }- None => from,+ None => (from, false), }; s.goal_x = None;- apply(s, to, ext);+ apply(s, to, ext, line_end); sel_result(s) } @@ -258,29 +280,29 @@ fn page(s: &mut Session, v: &Value, dir: i32) -> CmdResult { let ext = extend(v); let l = s.layout(); let from = s.sel.focus.clone();- let Some(c) = l.caret_on(&from, s.page_hint) else { return sel_result(s) };+ let Some(c) = l.caret_at(&from, s.page_hint, s.at_line_end(&from)) else { return sel_result(s) }; let np = if dir < 0 { c.page.saturating_sub(1) } else { (c.page + 1).min(l.pages.len().saturating_sub(1)) };- let to = l.hit(np, c.x, c.top + 1.0, from.story).unwrap_or(from);+ let (to, line_end) = l.hit_at(np, c.x, c.top + 1.0, from.story).unwrap_or((from, false)); s.page_hint = np;- apply(s, to, ext);+ apply(s, to, ext, line_end); sel_result(s) } fn set(s: &mut Session, v: &Value) -> CmdResult { let ext = extend(v);- let to = if let Some(pv) = v.get("pos") {- parse_pos(pv).ok_or_else(|| CmdError::Params("bad `pos`".into()))?+ let (to, line_end) = if let Some(pv) = v.get("pos") {+ (parse_pos(pv).ok_or_else(|| CmdError::Params("bad `pos`".into()))?, false) } else if let (Some(pg), Some(x), Some(y)) = (p::u64(v, "page"), p::f32(v, "x"), p::f32(v, "y")) { let story = super::story_param(s, v); let l = s.layout(); s.page_hint = pg as usize;- l.hit(pg as usize, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))?+ l.hit_at(pg as usize, x, y, story).ok_or_else(|| CmdError::Params("no text on that page".into()))? } else { return Err(CmdError::Params("`pos` or `page`/`x`/`y` required".into())); }; let to = s.doc.clamp(&to); s.goal_x = None;- apply(s, to, ext);+ apply(s, to, ext, line_end); sel_result(s) } @@ -338,6 +360,10 @@ impl Unit { /// 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) {+ // The caret at the end of a line that wraps after text is after that line's last character:+ // the word, sentence or line is the one that character is in, not the next line's.+ let back = s.at_line_end(at).then(|| s.doc.para_at(at).map(|p| p.prev_boundary(at.off))).flatten();+ let at = &back.map_or_else(|| at.clone(), |off| Pos { off, ..at.clone() }); 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) {@@ -63,7 +63,7 @@ fn select_column(s: &mut Session, v: &Value) -> CmdResult { let l = s.layout(); let xa = l.caret_on(&a, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`anchor` isn't laid out".into()))?; let xb = l.caret_on(&b, s.page_hint).map(|c| c.x).ok_or_else(|| CmdError::Failed("`focus` isn't laid out".into()))?;- make(s, a, b, xa, xb);+ make(s, (a, false), (b, false), xa, xb); return result(s); } if let (Some(fv), Some(tv)) = (v.get("from"), v.get("to")) {@@ -71,10 +71,11 @@ fn select_column(s: &mut Session, v: &Value) -> CmdResult { let (tp, tx, ty) = point(tv, "to")?; let story = super::story_param(s, v); let l = s.layout();- let a = l.hit(fp, fx, fy, story).ok_or_else(|| CmdError::Params("no text at `from`".into()))?;- let b = l.hit(tp, tx, ty, story).ok_or_else(|| CmdError::Params("no text at `to`".into()))?;+ let (a, a_end) = l.hit_at(fp, fx, fy, story).ok_or_else(|| CmdError::Params("no text at `from`".into()))?;+ let (b, b_end) = l.hit_at(tp, tx, ty, story).ok_or_else(|| CmdError::Params("no text at `to`".into()))?; s.page_hint = tp;- make(s, a, b, fx, tx);+ make(s, (a, a_end), (b, b_end), fx, tx);+ s.set_at_line_end(b_end); return result(s); } // Ctrl+Shift+F8: toggle the mode; the block starts at the caret.@@ -82,8 +83,10 @@ fn select_column(s: &mut Session, v: &Value) -> CmdResult { s.column_mode = on; if on { let f = s.sel.focus.clone();- let x = s.layout().caret_on(&f, s.page_hint).map(|c| c.x).unwrap_or(0.0);- make(s, f.clone(), f, x, x);+ let line_end = s.at_line_end(&f);+ let x = s.layout().caret_at(&f, s.page_hint, line_end).map(|c| c.x).unwrap_or(0.0);+ make(s, (f.clone(), line_end), (f, line_end), x, x);+ s.set_at_line_end(line_end); s.status = "Column selection: the arrow keys extend the block, Esc ends it".into(); } else { s.status.clear();@@ -91,14 +94,15 @@ fn select_column(s: &mut Session, v: &Value) -> CmdResult { result(s) } -/// Make the block between corners `a` and `b` whose page x are `xa` and `xb`.-fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) {+/// Make the block between corners `a` and `b` whose page x are `xa` and `xb`; each corner with+/// whether it is at the end of its line where that line wraps after text ([`Session::at_line_end`]).+fn make(s: &mut Session, (a, a_end): (Pos, bool), (b, b_end): (Pos, bool), xa: f32, xb: f32) { let (xa, xb) = (if xa.is_finite() { xa } else { 0.0 }, if xb.is_finite() { xb } else { 0.0 }); let (left, right) = (xa.min(xb), xa.max(xb)); let l = s.layout();- let segments = l.column_segments(&s.doc, &a, &b, left, right, s.page_hint, MAX_LINES);+ let segments = l.column_segments(&s.doc, (&a, &b), (a_end, b_end), (left, right), s.page_hint, MAX_LINES); s.sel = Selection { anchor: a, focus: b };- s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, segments, cells: None });+ s.column = Some(ColumnBlock { sel: s.sel.clone(), anchor_x: xa, focus_x: xb, anchor_line_end: a_end, segments, cells: None }); s.pending = None; } @@ -106,11 +110,13 @@ fn make(s: &mut Session, a: Pos, b: Pos, xa: f32, xb: f32) { /// Up/Down). fn extend_to(s: &mut Session, to: Pos) { let Some(block) = s.column.clone() else { return };+ let line_end = s.at_line_end(&to); let x = match s.goal_x { Some(gx) => gx,- None => s.layout().caret_on(&to, s.page_hint).map(|c| c.x).unwrap_or(block.focus_x),+ None => s.layout().caret_at(&to, s.page_hint, line_end).map(|c| c.x).unwrap_or(block.focus_x), };- make(s, block.sel.anchor, to, block.anchor_x, x);+ make(s, (block.sel.anchor, block.anchor_line_end), (to, line_end), block.anchor_x, x);+ s.set_at_line_end(line_end); } /// Run a command with the column block in mind (called by `Session::run` for every command).@@ -733,7 +733,8 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult { // Relative pages, Go To's "+2" / "-1": from the page the caret is on. let by = v.get("pageBy").and_then(Value::as_i64); let rel = by.map(|by| {- let here = s.layout().caret_on(&s.sel.focus, s.page_hint).map_or(0, |c| c.page) as i64;+ let focus = s.sel.focus.clone();+ let here = s.layout().caret_at(&focus, s.page_hint, s.at_line_end(&focus)).map_or(0, |c| c.page) as i64; (here + 1).saturating_add(by).max(1) as u64 }); if let Some(pg) = rel.or_else(|| p::u64(v, "page")) {@@ -602,6 +602,7 @@ fn layout_summary(s: &mut Session, _: &Value) -> CmdResult { json!({"number": p.number, "w": p.w, "h": p.h, "section": p.section, "lines": lines, "firstBlock": p.first_block, "header": p.header_story, "footer": p.footer_story}) }) .collect();- let caret = l.caret_on(&s.sel.focus, s.page_hint).map(|c| json!({"page": c.page, "x": c.x, "y": c.top, "h": c.height}));+ let caret =+ l.caret_at(&s.sel.focus, s.page_hint, s.at_line_end(&s.sel.focus)).map(|c| json!({"page": c.page, "x": c.x, "y": c.top, "h": c.height})); Ok(json!({"pages": pages, "caret": caret, "ms": l.ms})) }@@ -98,7 +98,7 @@ pub(crate) fn select_range(s: &mut Session, story: StoryRef, tp: &Path, rows: (u let focus = segments.iter().rev().find(|(_, b)| b.path.0.len() == depth + 3).map_or(&last.1, |(_, b)| b).clone(); let sel = Selection { anchor: first.0.clone(), focus }; s.sel = sel.clone();- s.column = Some(crate::ColumnBlock { sel, anchor_x: 0.0, focus_x: 0.0, segments, cells: Some(range) });+ s.column = Some(crate::ColumnBlock { sel, anchor_x: 0.0, focus_x: 0.0, anchor_line_end: false, segments, cells: Some(range) }); s.column_mode = false; s.widened = None; s.goal_x = None;@@ -239,7 +239,7 @@ fn tab_indents(s: &mut Session, f: &Pos) -> bool { return s.doc.styles.resolve_para(¶.props).numbering.is_none_or(|n| n.num == 0); } let l = s.layout();- l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)+ l.line_bounds(f, s.page_hint, s.at_line_end(f)).is_some_and(|(a, ..)| a == *f) } /// Tab before the first line indents the first line by one default tab stop; before any other@@ -41,6 +41,9 @@ pub struct ColumnBlock { /// Page x of the anchor and focus corners (points); the block spans the range between. pub anchor_x: f32, pub focus_x: f32,+ /// The anchor corner is at the end of its line where that line wraps after text+ /// ([`crate::Session::at_line_end`]), so growing the block keeps that line.+ pub anchor_line_end: bool, /// One `(start, end)` per line, in document order, each inside one paragraph. pub segments: Vec<(Pos, Pos)>, /// Set when the block is whole table cells (Select Cell, Row, Column or Table, or the mouse in@@ -150,8 +150,8 @@ impl Session { /// The pages the caret (or selection) is on, in the output layout: (first, last). pub fn current_pages(&self, lay: &DocLayout) -> (usize, usize) { let (a, b) = self.sel.ordered();- let first = lay.caret_on(&a, self.page_hint).map_or(0, |c| c.page);- let last = lay.caret_on(&b, self.page_hint).map_or(first, |c| c.page);+ let first = lay.caret_at(&a, self.page_hint, self.at_line_end(&a)).map_or(0, |c| c.page);+ let last = lay.caret_at(&b, self.page_hint, self.at_line_end(&b)).map_or(first, |c| c.page); (first, last.max(first)) } @@ -76,6 +76,11 @@ pub struct Session { pub find: FindState, /// Page x the caret tries to keep on Up/Down. pub goal_x: Option<f32>,+ /// The caret is at the end of a line that wraps after text, not at the start of the next+ /// line, which is the same position in the text: the revision and selection it was put there+ /// with (see [`Session::at_line_end`]). It holds only while both stay as they were: the next+ /// command after an edit or another selection drops it.+ pub caret_line_end: Option<(u64, Selection)>, /// Page the caret was last on (headers/footers repeat on many pages). pub page_hint: usize, pub registry: Arc<Registry>,@@ -49,6 +49,7 @@ pub struct DocState { pub(super) pending_move: Option<crate::cmd::moves::PendingMove>, pub(super) find: FindState, pub(super) goal_x: Option<f32>,+ pub(super) caret_line_end: Option<(u64, Selection)>, pub(super) page_hint: usize, pub(super) new_header_parts: Vec<u32>, pub(super) hf_return: Option<Selection>,@@ -103,6 +104,7 @@ impl DocState { pending_move: None, find: FindState::default(), goal_x: None,+ caret_line_end: None, page_hint: 0, new_header_parts: Vec::new(), hf_return: None,@@ -203,6 +205,7 @@ impl Session { pending_move: _, find: _, goal_x: _,+ caret_line_end: _, page_hint: _, new_header_parts: _, hf_return: _,@@ -292,6 +295,7 @@ impl Session { pending_move, find, goal_x,+ caret_line_end, page_hint, new_header_parts, hf_return,@@ -339,6 +343,7 @@ impl Session { swap(&mut self.pending_move, pending_move); swap(&mut self.find, find); swap(&mut self.goal_x, goal_x);+ swap(&mut self.caret_line_end, caret_line_end); swap(&mut self.page_hint, page_hint); swap(&mut self.new_header_parts, new_header_parts); swap(&mut self.hf_return, hf_return);@@ -47,6 +47,7 @@ impl Session { pending_move, find, goal_x,+ caret_line_end, page_hint, new_header_parts, hf_return,@@ -101,6 +102,7 @@ impl Session { clip_history: Default::default(), find, goal_x,+ caret_line_end, page_hint, registry, author: "WordCraft User".into(),@@ -53,6 +53,20 @@ impl Session { self.widened.as_ref().filter(|w| w.sel == self.sel).map_or(&self.sel, |w| &w.ends) } + /// Whether the caret at `pos` is at the end of a line that wraps after text, rather than at+ /// the start of the next line (the same position in the text): End, Right onto the line's+ /// end, Up/Down, Page Up/Down or a click past its last character put it there+ /// ([`Self::caret_line_end`]). Only the selection's focus can be.+ pub fn at_line_end(&self, pos: &Pos) -> bool {+ self.caret_line_end.as_ref().is_some_and(|(rev, sel)| *rev == self.rev && *sel == self.sel && sel.focus == *pos)+ }++ /// Where a line wraps after text at the caret (the selection's focus), put it at the end of+ /// that line, or (`false`) at the start of the next ([`Self::at_line_end`]).+ pub fn set_at_line_end(&mut self, line_end: bool) {+ self.caret_line_end = line_end.then(|| (self.rev, self.sel.clone()));+ }+ pub fn selects_whole_story(&self) -> bool { let st = self.sel.focus.story; let (a, b) = self.sel.ordered();@@ -32,6 +32,11 @@ impl Session { // The body replacement promise is likewise scoped: only the `text.insert` at the // selection the deletion left may consume it. let ime_text_join = self.ime_text_join.take();+ // A line end the caret has left (an edit, another selection) is gone, so it can't come+ // back when the same selection does.+ if self.caret_line_end.as_ref().is_some_and(|(rev, sel)| *rev != self.rev || *sel != self.sel) {+ self.caret_line_end = None;+ } let reg = self.registry.clone(); let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; let join = join@@ -82,6 +82,7 @@ impl Session { clip_history: _, find: _, goal_x: _,+ caret_line_end: _, page_hint: _, registry: _, author: _,@@ -17,6 +17,7 @@ pub struct WindowView { view: ViewState, pending: Option<CharProps>, goal_x: Option<f32>,+ caret_line_end: Option<(u64, Selection)>, page_hint: usize, hf_return: Option<Selection>, typing_open: bool,@@ -63,6 +64,7 @@ impl Session { view: take(&mut self.view), pending: self.pending.take(), goal_x: self.goal_x.take(),+ caret_line_end: self.caret_line_end.take(), page_hint: take(&mut self.page_hint), hf_return: self.hf_return.take(), typing_open: take(&mut self.typing_open),@@ -87,6 +89,7 @@ impl Session { view: self.view.clone(), pending: self.pending.clone(), goal_x: self.goal_x,+ caret_line_end: self.caret_line_end.clone(), page_hint: self.page_hint, hf_return: self.hf_return.clone(), // The new window hasn't typed: its first key starts an undo step of its own.@@ -117,6 +120,7 @@ impl Session { view, pending, goal_x,+ caret_line_end, page_hint, hf_return, typing_open,@@ -150,6 +154,7 @@ impl Session { self.also_selected = also_selected; self.column = column; self.widened = widened;+ self.caret_line_end = caret_line_end; return; } self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) });@@ -162,6 +167,7 @@ impl Session { self.column = None; self.widened = None; self.goal_x = None;+ self.caret_line_end = None; self.clamp_selection(); } }@@ -14,6 +14,7 @@ mod documents; mod fonts; mod ime_noop; mod revision_checks;+mod soft_wrap; mod typing; mod wordart; new file mode 100644@@ -0,0 +1,371 @@+//! The caret at a soft line wrap. Where a line wraps after text (Chinese, a word broken because+//! it is wider than the line), the end of that line and the start of the next are the same+//! position in the text; the caret can still be put at either, with End, Shift+End, the arrow+//! keys, Up/Down and a click, in a paragraph and in a table cell, whatever the alignment. A line+//! that wraps at a space keeps its end before the space.++use serde_json::json;+use wordcraft_doc::Pos;+use wordcraft_layout::Placed;+use wordcraft_layout::para::{ClKind, LineEnd};++use crate::{Selection, Session};++/// Run command `id` with params `v`; a failing command fails the test.+fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {+ s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))+}++/// Chinese, a word too long for the line, Chinese with Latin letters and digits, and words+/// wrapping at spaces.+const TEXTS: [(&str, &str); 4] = [+ ("chinese", "春风拂过山岗,溪水绕着村庄静静流淌。"),+ ("long word", "Lanternsdriftingoverquietharbours"),+ ("mixed", "春风ABC拂过123山岗,溪水xyz绕着村庄。"),+ ("spaces", "Quiet lanterns drift along the harbour at dusk. "),+];++const ALIGNS: [&str; 4] = ["para.alignLeft", "para.alignCenter", "para.alignRight", "para.justify"];++/// A narrow page with one paragraph of `text` (repeated so it wraps over several lines) aligned+/// by `align`, in a one-cell table when `cell`; the caret is at its start.+fn session(text: &str, align: &str, cell: bool) -> Session {+ let mut d = wordcraft_doc::Document::new();+ d.last_section.page_w = 200.0;+ d.last_section.margin_left = 20.0;+ d.last_section.margin_right = 20.0;+ let mut s = Session::new(d);+ if cell {+ run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));+ }+ run(&mut s, "text.insert", json!({"text": text.repeat(4)}));+ run(&mut s, align, json!({}));+ let start = Pos { off: 0, ..s.sel.focus.clone() };+ s.sel = Selection::caret(start);+ s+}++/// One line of the caret's paragraph: its byte range, the start of its last character and where+/// to click on it (page, the x of that character's right half, and the line's middle).+#[derive(Clone, Debug)]+struct WrapLine {+ start: usize,+ stop: usize,+ last: usize,+ wraps_at_space: bool,+ click: (usize, f32, f32),+}++/// The lines of the paragraph at `at`, and its length.+fn lines(s: &mut Session, at: &Pos) -> (Vec<WrapLine>, usize) {+ let l = s.layout();+ let mut out = Vec::new();+ let mut len = 0;+ for (pi, page) in l.pages.iter().enumerate() {+ for it in &page.items {+ let Placed::Lines { story, path, para, l0, l1, x, y, .. } = it else { continue };+ if *story != at.story || *path != at.path {+ continue;+ }+ len = para.text_len;+ let first = ¶.lines[*l0];+ for li in *l0..*l1 {+ let line = ¶.lines[li];+ let last = ¶.clusters[line.c1 - 1];+ let (x0, x1) = (para.x_of(li, last.start).unwrap(), para.x_of(li, line.stop).unwrap());+ let mid = y + (line.top - first.top) + line.height / 2.0;+ out.push(WrapLine {+ start: line.start,+ stop: line.stop,+ last: last.start,+ wraps_at_space: line.end == LineEnd::Wrap && last.kind == ClKind::Space,+ click: (pi, x + x0 + (x1 - x0) * 0.75, mid),+ });+ }+ }+ }+ (out, len)+}++/// The position at byte `off` of the caret's paragraph.+fn at(s: &Session, off: usize) -> Pos {+ Pos { off, ..s.sel.focus.clone() }+}++/// Put the caret at byte `off` of its paragraph, with no goal column left from an earlier move.+fn put(s: &mut Session, off: usize) {+ s.sel = Selection::caret(at(s, off));+ s.goal_x = None;+}++/// Run `check` on every text, alignment and placement (in a table cell or not), with the+/// paragraph's lines and length; each paragraph wraps over at least three lines.+fn each_case(check: impl Fn(&str, &mut Session, &[WrapLine], usize)) {+ for (name, text) in TEXTS {+ for align in ALIGNS {+ for cell in [false, true] {+ let mut s = session(text, align, cell);+ let p = s.sel.focus.clone();+ let (lines, len) = lines(&mut s, &p);+ let case = format!("{name}, {align}, cell {cell}");+ assert!(lines.len() > 2, "{case}: the paragraph wraps ({lines:?})");+ check(&case, &mut s, &lines, len);+ }+ }+ }+}++/// End goes after the line's last character, and Home from there back to the line's start: the+/// caret is on that line, not at the start of the next one (the same position in the text).+/// Shift+End selects the whole line. Before a space the line wraps at, the end stays where it was.+#[test]+fn end_goes_after_the_last_character_of_a_wrapped_line() {+ each_case(|case, s, lines, len| {+ for (i, line) in lines.iter().enumerate() {+ let last_line = i + 1 == lines.len();+ let end = if last_line {+ len+ } else if line.wraps_at_space {+ line.last+ } else {+ line.stop+ };+ put(s, line.start);+ run(s, "caret.end", json!({}));+ assert_eq!(s.sel.focus.off, end, "{case}: End on line {i} {line:?}");+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, line.start, "{case}: Home after End on line {i}");+ run(s, "caret.end", json!({"extend": true}));+ assert_eq!(s.sel.ordered(), (at(s, line.start), at(s, end)), "{case}: Shift+End on line {i}");+ }+ });+}++/// Right from before a wrapped line's last character goes after it, at that line's end; the next+/// Right goes past the next line's first character. Left from there comes back to the start of+/// that line, and the next Left goes before the last character of the line above. Right over the+/// space a line wraps at still goes to the start of the next line.+#[test]+fn arrows_step_over_a_soft_wrap_one_character_at_a_time() {+ each_case(|case, s, lines, _| {+ for (i, pair) in lines.windows(2).enumerate() {+ let (line, next) = (&pair[0], &pair[1]);+ if line.wraps_at_space {+ put(s, line.last);+ run(s, "caret.right", json!({}));+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, next.start, "{case}: Right over the space at the end of line {i} goes to the next line");+ continue;+ }+ put(s, line.last);+ run(s, "caret.right", json!({}));+ assert_eq!(s.sel.focus.off, line.stop, "{case}: Right onto the end of line {i}");+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, line.start, "{case}: the caret was on line {i}, not the next");+ put(s, line.last);+ run(s, "caret.right", json!({}));+ run(s, "caret.right", json!({}));+ let after_first = s.sel.focus.off;+ assert!(after_first > next.start && after_first <= next.stop, "{case}: second Right goes into line {}", i + 1);+ run(s, "caret.left", json!({}));+ assert_eq!(s.sel.focus.off, next.start, "{case}: Left to the start of line {}", i + 1);+ run(s, "caret.end", json!({}));+ assert!(s.sel.focus.off > next.start, "{case}: the caret was at the start of line {}, not the end of line {i}", i + 1);+ put(s, after_first);+ run(s, "caret.left", json!({}));+ run(s, "caret.left", json!({}));+ assert_eq!(s.sel.focus.off, line.last, "{case}: second Left goes before the last character of line {i}");+ put(s, line.last);+ run(s, "caret.right", json!({"extend": true}));+ assert_eq!(s.sel.ordered(), (at(s, line.last), at(s, line.stop)), "{case}: Shift+Right selects the last character of line {i}");+ }+ });+}++/// Down from the end of a wrapped line goes to the next line (not the one after it), and Up comes+/// back to the end of the line it started from (before the space, for a line that wraps at one).+#[test]+fn up_and_down_from_the_end_of_a_wrapped_line_keep_to_the_next_line() {+ each_case(|case, s, lines, _| {+ for (i, pair) in lines.windows(2).enumerate() {+ let (line, next) = (&pair[0], &pair[1]);+ if line.wraps_at_space {+ put(s, line.start);+ run(s, "caret.end", json!({}));+ run(s, "caret.down", json!({}));+ let off = s.sel.focus.off;+ assert!(off >= next.start && off <= next.stop, "{case}: Down from the end of line {i} lands on line {} ({off})", i + 1);+ run(s, "caret.up", json!({}));+ assert_eq!(s.sel.focus.off, line.last, "{case}: Up comes back to before the space ending line {i}");+ continue;+ }+ put(s, line.start);+ run(s, "caret.end", json!({}));+ run(s, "caret.down", json!({}));+ let off = s.sel.focus.off;+ assert!(+ off > next.start && off <= next.stop.max(next.start + 1),+ "{case}: Down from the end of line {i} lands on line {} ({off})",+ i + 1+ );+ run(s, "caret.up", json!({}));+ assert_eq!(s.sel.focus.off, line.stop, "{case}: Up comes back to the end of line {i}");+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, line.start, "{case}: after Up the caret is on line {i}");+ }+ });+}++/// A click on the right half of a wrapped line's last character puts the caret after it, at that+/// line's end; a click on the space a line wraps at puts it before the space.+#[test]+fn a_click_after_the_last_character_of_a_wrapped_line_stays_on_that_line() {+ each_case(|case, s, lines, _| {+ for (i, line) in lines.iter().enumerate().take(lines.len() - 1) {+ if line.wraps_at_space {+ let (page, x, y) = line.click;+ run(s, "caret.set", json!({"page": page, "x": x, "y": y}));+ assert_eq!(s.sel.focus.off, line.last, "{case}: click on the space ending line {i}");+ continue;+ }+ let (page, x, y) = line.click;+ run(s, "caret.set", json!({"page": page, "x": x, "y": y}));+ assert_eq!(s.sel.focus.off, line.stop, "{case}: click after the last character of line {i}");+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, line.start, "{case}: the click put the caret on line {i}");+ }+ });+}++/// A double-click on the right half of a wrapped line's last character selects that character's+/// word or sentence, as one on its left half does, not the next line's.+#[test]+fn a_double_click_after_the_last_character_selects_its_word() {+ each_case(|case, s, lines, _| {+ for (i, line) in lines.iter().enumerate().take(lines.len() - 1) {+ for unit in ["select.word", "select.sentence"] {+ put(s, line.last);+ run(s, unit, json!({}));+ let want = s.sel.ordered();+ let (page, x, y) = line.click;+ run(s, "caret.set", json!({"page": page, "x": x, "y": y}));+ run(s, unit, json!({}));+ assert_eq!(s.sel.ordered(), want, "{case}: {unit} after the last character of line {i}");+ }+ }+ });+}++/// After Shift+End, Right (or Escape to the selection's end) leaves the caret at the line's end.+#[test]+fn collapsing_a_selection_to_a_line_end_keeps_the_caret_there() {+ each_case(|case, s, lines, _| {+ for (i, line) in lines.iter().enumerate().take(lines.len() - 1) {+ if line.wraps_at_space {+ continue;+ }+ for collapse in [("caret.right", json!({})), ("select.collapse", json!({"end": true}))] {+ put(s, line.start);+ run(s, "caret.end", json!({"extend": true}));+ run(s, collapse.0, collapse.1);+ assert_eq!(s.sel.focus.off, line.stop, "{case}: {} after Shift+End on line {i}", collapse.0);+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, line.start, "{case}: {} left the caret on line {i}", collapse.0);+ }+ }+ });+}++/// The caret at a line's end is that only while the text and the selection stay as they were:+/// after an edit (even one undone) or another selection, the same position is the next line's start.+#[test]+fn an_edit_or_another_selection_drops_the_line_end() {+ each_case(|case, s, lines, _| {+ let (line, next) = (&lines[0], &lines[1]);+ if line.wraps_at_space {+ return;+ }+ put(s, line.start);+ run(s, "caret.end", json!({}));+ let end = s.sel.focus.clone();+ s.sel = Selection::caret(at(s, 0));+ run(s, "select.collapse", json!({}));+ s.sel = Selection::caret(end.clone());+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, next.start, "{case}: back at the same position after another selection, the caret starts the next line");+ put(s, line.start);+ run(s, "caret.end", json!({}));+ run(s, "text.insert", json!({"text": "x"}));+ run(s, "edit.undo", json!({}));+ assert_eq!(s.sel.focus, end, "{case}");+ run(s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, next.start, "{case}: after an edit the caret starts the next line");+ });+}++/// A column (block) selection from the end of one wrapped line to the end of another takes those+/// lines and the ones between, not the line after each.+#[test]+fn a_column_selection_between_line_ends_takes_those_lines() {+ each_case(|case, s, lines, _| {+ if lines[..3].iter().any(|l| l.wraps_at_space) {+ return;+ }+ let ((fp, fx, fy), (tp, tx, ty)) = (lines[0].click, lines[2].click);+ run(s, "select.column", json!({"from": {"page": fp, "x": fx, "y": fy}, "to": {"page": tp, "x": tx, "y": ty}}));+ let segs = s.column_segments().unwrap_or_default().to_vec();+ assert_eq!(segs.len(), 3, "{case}: {segs:?}");+ for (seg, line) in segs.iter().zip(lines) {+ assert!(seg.0.off >= line.start && seg.1.off <= line.stop, "{case}: {seg:?} on {line:?}");+ }+ });+}++/// Column mode started at the end of a wrapped line (Ctrl+Shift+F8 there) keeps that line when+/// the arrow keys grow the block: the corner it started at stays at the end of its line.+#[test]+fn column_mode_started_at_a_line_end_keeps_that_line() {+ each_case(|case, s, lines, _| {+ if lines[..2].iter().any(|l| l.wraps_at_space) {+ return;+ }+ put(s, lines[0].start);+ run(s, "caret.end", json!({}));+ run(s, "select.column", json!({"on": true}));+ run(s, "caret.down", json!({}));+ let segs = s.column_segments().unwrap_or_default().to_vec();+ assert_eq!(segs.len(), 2, "{case}: {segs:?}");+ for (seg, line) in segs.iter().zip(lines) {+ assert!(seg.0.off >= line.start && seg.1.off <= line.stop, "{case}: {seg:?} on {line:?}");+ }+ });+}++/// Right in a column one character wide (each line wraps after its only character) goes from the+/// end of each line to the end of the next, one character at a time.+#[test]+fn right_in_a_column_one_character_wide_goes_from_line_end_to_line_end() {+ for cell in [false, true] {+ let mut d = wordcraft_doc::Document::new();+ d.last_section.page_w = 60.0;+ d.last_section.margin_left = 20.0;+ d.last_section.margin_right = 20.0;+ let mut s = Session::new(d);+ if cell {+ run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));+ }+ run(&mut s, "text.insert", json!({"text": "春风拂过山岗溪水绕着村庄"}));+ let p = Pos { off: 0, ..s.sel.focus.clone() };+ let (lines, _) = lines(&mut s, &p);+ assert!(lines.len() > 4 && lines.iter().all(|l| !l.wraps_at_space), "cell {cell}: {lines:?}");+ put(&mut s, 0);+ run(&mut s, "caret.end", json!({}));+ for (j, line) in lines.iter().enumerate().take(lines.len() - 1).skip(1) {+ run(&mut s, "caret.right", json!({}));+ assert_eq!(s.sel.focus.off, line.stop, "cell {cell}: Right to the end of line {j}");+ run(&mut s, "caret.home", json!({}));+ assert_eq!(s.sel.focus.off, line.start, "cell {cell}: the caret was on line {j}");+ run(&mut s, "caret.end", json!({}));+ }+ }+}@@ -287,8 +287,9 @@ fn between(a: &Path, b: &Path, l: &Path) -> bool { } /// The position in turned text (a table cell's text direction) of `story` under page point-/// (x, y), if there is turned text there.-fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> {+/// (x, y), if there is turned text there, and whether it is at the end of its line where that+/// line wraps after text ([`ParaLayout::is_wrap_end`]).+fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<(Pos, bool)> { items_of(page, story).find_map(|it| { let Placed::Lines { story: s, path, para, l0, l1, x: ox, y: oy, turn } = it else { return None }; if *s != story || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {@@ -301,13 +302,14 @@ fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option<Pos> { .filter_map(|li| para.lines.get(li).map(|l| (li, l.top - first.top, l.height))) .min_by(|a, b| band_dist(v, a.1, a.2).total_cmp(&band_dist(v, b.1, b.2)))? .0;- Some(Pos { story: *s, path: path.clone(), off: para.off_at_x(li, u) })+ let off = para.off_at_x(li, u);+ Some((Pos { story: *s, path: path.clone(), off }, para.is_wrap_end(li, off))) }) } /// The position in the turned text of `story` nearest to page point (x, y), wherever it is, in a-/// paragraph `skip` doesn't leave out.-fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&Path) -> bool) -> Option<Pos> {+/// paragraph `skip` doesn't leave out (and whether it is at a line's end, as for [`turned_hit`]).+fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&Path) -> bool) -> Option<(Pos, bool)> { let dist = |r: Rect| band_dist(x, r.x, r.w).hypot(band_dist(y, r.y, r.h)); let it = items_of(page, story) .filter(|it| matches!(it, Placed::Lines { story: s, path, .. } if *s == story && !skip(path)))@@ -321,7 +323,8 @@ fn nearest_turned(page: &Page, story: StoryRef, x: f32, y: f32, skip: &dyn Fn(&P .filter_map(|li| para.lines.get(li).map(|l| (li, l.top - first.top, l.height))) .min_by(|a, b| band_dist(v, a.1, a.2).total_cmp(&band_dist(v, b.1, b.2)))? .0;- Some(Pos { story, path: path.clone(), off: para.off_at_x(li, u) })+ let off = para.off_at_x(li, u);+ Some((Pos { story, path: path.clone(), off }, para.is_wrap_end(li, off))) } /// The body line on `page` whose band is nearest height `y` (the leftmost on a tie, so in@@ -364,9 +367,15 @@ fn band_dist(v: f32, top: f32, h: f32) -> f32 { impl DocLayout { /// The position nearest to (x, y) on `page`, in `story`. pub fn hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<Pos> {+ self.hit_at(page, x, y, story).map(|(pos, _)| pos)+ }++ /// [`Self::hit`], and whether the position is at the end of the line hit where it wraps after+ /// text ([`ParaLayout::is_wrap_end`]), rather than at the start of the next line.+ pub fn hit_at(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option<(Pos, bool)> { let p = self.pages.get(page)?;- if let Some(pos) = turned_hit(p, story, x, y) {- return Some(pos);+ if let Some(hit) = turned_hit(p, story, x, y) {+ return Some(hit); } // Like Word, a click outside a floating table doesn't land in its cells while there's // other text on the page: below one, it goes to the text beside or after it (#2038).@@ -380,7 +389,7 @@ impl DocLayout { let nearest = |a: &&LineHit, b: &&LineHit| score(a, x, y).total_cmp(&score(b, x, y)); let best = lines.iter().filter(|l| !away(l.path)).min_by(nearest).or_else(|| lines.iter().min_by(nearest))?; let off = best.para.off_at_x(best.li, x - best.x);- Some(Pos { story: best.story, path: best.path.clone(), off })+ Some((Pos { story: best.story, path: best.path.clone(), off }, best.para.is_wrap_end(best.li, off))) } /// The story whose text is under a point (body, footnotes, endnotes, text boxes; not@@ -555,21 +564,36 @@ impl DocLayout { items_of(p, story).filter(|it| matches!(it, Placed::Lines { story: s, path: q, .. } if *s == story && q == path)).map(|it| (pi, it)).collect() } + /// Whether `pos` is where a line wraps after text ([`ParaLayout::is_wrap_end`]): the end of+ /// one line and the start of the next.+ pub fn is_wrap_end(&self, pos: &Pos, page_hint: usize) -> bool {+ self.pieces(pos.story, &pos.path, page_hint)+ .into_iter()+ .any(|(_, it)| matches!(it, Placed::Lines { para, .. } if para.caret_line(pos.off, true) != para.line_of(pos.off)))+ }+ /// Caret geometry for a position. pub fn caret(&self, pos: &Pos) -> Option<Caret> { self.caret_on(pos, 0) } pub fn caret_on(&self, pos: &Pos, page_hint: usize) -> Option<Caret> {+ self.caret_at(pos, page_hint, false)+ }++ /// [`Self::caret_on`]; with `line_end`, a caret where a line wraps after text is at the end of+ /// that line rather than at the start of the next ([`ParaLayout::caret_line`]).+ pub fn caret_at(&self, pos: &Pos, page_hint: usize, line_end: bool) -> Option<Caret> { for (pi, it) in self.pieces(pos.story, &pos.path, page_hint) { let Placed::Lines { para, l0, l1, x, y, turn, .. } = it else { continue };- let li = para.line_of(pos.off);+ let li = para.caret_line(pos.off, line_end); if li < *l0 || li >= *l1 { continue; } let first = para.lines.get(*l0)?; let l = para.lines.get(li)?; let cx = para.x_of(li, pos.off)?;+ let cx = if para.is_wrap_end(li, pos.off) { cx - para.trailing_auto_space(li) } else { cx }; let cx = para.caret_limit(li).map_or(cx, |m| cx.min(m)); // In the lines' own frame (turned text turns it onto the page). let top = l.top - first.top;@@ -585,7 +609,14 @@ impl DocLayout { /// The position one line up (`dir < 0`) or down from `pos`, keeping `goal_x` (page x). pub fn vertical(&self, pos: &Pos, goal_x: Option<f32>, dir: i32, page_hint: usize) -> Option<(Pos, f32)> {- let c = self.caret_on(pos, page_hint)?;+ self.vertical_at(pos, goal_x, dir, page_hint, false).map(|(pos, gx, _)| (pos, gx))+ }++ /// [`Self::vertical`] from the caret at `pos`, at the end of its line with `line_end` (as for+ /// [`Self::caret_at`]), and whether the position it goes to is at the end of its line, where+ /// that line wraps after text.+ pub fn vertical_at(&self, pos: &Pos, goal_x: Option<f32>, dir: i32, page_hint: usize, line_end: bool) -> Option<(Pos, f32, bool)> {+ let c = self.caret_at(pos, page_hint, line_end)?; let gx = goal_x.unwrap_or(c.x); let page = self.pages.get(c.page)?; let lines = page_lines(page, pos.story);@@ -619,7 +650,7 @@ impl DocLayout { }; if let Some(l) = target { let off = l.para.off_at_x(l.li, gx - l.x);- return Some((Pos { story: l.story, path: l.path.clone(), off }, gx));+ return Some((Pos { story: l.story, path: l.path.clone(), off }, gx, l.para.is_wrap_end(l.li, off))); } // Next/previous page. let np = if dir < 0 { c.page.checked_sub(1)? } else { c.page + 1 };@@ -627,14 +658,15 @@ impl DocLayout { let lines = page_lines(p, pos.story); let l = if dir < 0 { lines.iter().max_by(|a, b| a.top.total_cmp(&b.top))? } else { lines.iter().min_by(|a, b| a.top.total_cmp(&b.top))? }; let off = l.para.off_at_x(l.li, gx - l.x);- Some((Pos { story: l.story, path: l.path.clone(), off }, gx))+ Some((Pos { story: l.story, path: l.path.clone(), off }, gx, l.para.is_wrap_end(l.li, off))) } - /// Start and end of the visual line holding `pos` (Home / End).- pub fn line_bounds(&self, pos: &Pos, page_hint: usize) -> Option<(Pos, Pos)> {+ /// Start and end of the visual line holding the caret at `pos` (Home / End; `line_end` as for+ /// [`Self::caret_at`]), and whether that end is where the line wraps after text.+ pub fn line_bounds(&self, pos: &Pos, page_hint: usize, line_end: bool) -> 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);+ let li = para.caret_line(pos.off, line_end); if li < *l0 || li >= *l1 { continue; }@@ -644,12 +676,13 @@ impl DocLayout { if matches!(l.end, crate::LineEnd::LineBreak | crate::LineEnd::PageBreak | crate::LineEnd::ColumnBreak) && end > l.start { end = para.clusters.get(l.c1.saturating_sub(1)).map(|c| c.start).unwrap_or(end); } else if l.end == crate::LineEnd::Wrap {- // Before the wrapped line's trailing space: its last reachable offset, in logical- // order (the far edge is the left one in right-to-left text).+ // Before the wrapped line's trailing space, or after its last character where it+ // wraps after text: its last reachable offset, in logical order (the far edge is+ // the left one in right-to-left text). end = para.line_offsets(li).into_iter().max().unwrap_or(end); } let mk = |off| Pos { story: pos.story, path: pos.path.clone(), off };- return Some((mk(l.start), mk(end)));+ return Some((mk(l.start), mk(end), para.is_wrap_end(li, end))); } None }@@ -709,9 +742,18 @@ impl DocLayout { /// 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- /// pair. At most `max` lines.- pub fn column_segments(&self, doc: &Document, a: &Pos, b: &Pos, left: f32, right: f32, page_hint: usize, max: usize) -> Vec<(Pos, Pos)> {- let (a, b) = if a <= b { (a, b) } else { (b, a) };+ /// pair. At most `max` lines. `line_end` says, for `a` and `b`, whether that corner is at the end+ /// of its line where the line wraps after text (as for [`Self::caret_at`]).+ pub fn column_segments(+ &self,+ doc: &Document,+ (a, b): (&Pos, &Pos),+ line_end: (bool, bool),+ (left, right): (f32, f32),+ page_hint: usize,+ max: usize,+ ) -> Vec<(Pos, Pos)> {+ let ((a, a_end), (b, b_end)) = if a <= b { ((a, line_end.0), (b, line_end.1)) } else { ((b, line_end.1), (a, line_end.0)) }; let (left, right) = if left <= right { (left, right) } else { (right, left) }; let mut out = Vec::new(); if a.story != b.story || !left.is_finite() || !right.is_finite() {@@ -721,8 +763,8 @@ impl DocLayout { for path in doc.paths_between(a, b) { for (_, it) in self.pieces_in(&mut pages, &path, page_hint) { let Placed::Lines { para, l0, l1, x, .. } = it else { continue };- let first = if path == a.path { para.line_of(a.off) } else { 0 };- let last = if path == b.path { para.line_of(b.off) } else { usize::MAX };+ let first = if path == a.path { para.caret_line(a.off, a_end) } else { 0 };+ let last = if path == b.path { para.caret_line(b.off, b_end) } else { usize::MAX }; for li in *l0..*l1 { if li < first || li > last { continue;@@ -196,6 +196,19 @@ impl ParaLayout { }; Some(end.unwrap_or(l.left).max(l.right)) }+ /// Whether `off` is the end of left-to-right line `li` where the line wraps right after text+ /// (Chinese, a word broken because it is wider than the line), not at a space or a break: the+ /// same position in the text as the next line's start, so the caret can be shown on either+ /// line. Zero-width markers (bookmarks, comment anchors) after the text don't count.+ pub fn is_wrap_end(&self, li: usize, off: usize) -> bool {+ let Some(l) = self.lines.get(li) else { return false };+ let last = self.clusters.get(l.c0..l.c1).and_then(|cs| cs.iter().rev().find(|c| c.kind != ClKind::Marker));+ l.end == LineEnd::Wrap+ && l.stop == off+ && l.vis.is_empty()+ && self.lines.get(li + 1).is_some_and(|n| n.start == off)+ && last.is_some_and(|c| !matches!(c.kind, ClKind::Space | ClKind::Tab | ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak))+ } /// The line holding offset `off` (a position at a wrap point belongs to the next line). pub fn line_of(&self, off: usize) -> usize { for (i, l) in self.lines.iter().enumerate() {@@ -211,6 +224,18 @@ impl ParaLayout { } self.lines.len().saturating_sub(1) }+ /// The line the caret at `off` is on: [`Self::line_of`], except that with `line_end` a+ /// position where a line wraps after text ([`Self::is_wrap_end`]) is at the end of that line.+ pub fn caret_line(&self, off: usize, line_end: bool) -> usize {+ let li = self.line_of(off);+ if line_end && li > 0 && self.is_wrap_end(li - 1, off) { li - 1 } else { li }+ }+ /// The automatic space ([`auto_space`]) after the last character of line `li`: it goes with+ /// the wrap, so the caret at the end of the line stands before it. 0 without one.+ pub fn trailing_auto_space(&self, li: usize) -> f32 {+ let Some(l) = self.lines.get(li) else { return 0.0 };+ (l.c0..l.c1).rev().find(|&k| self.clusters.get(k).is_some_and(|c| c.kind != ClKind::Marker)).map_or(0.0, |k| self.auto_space_after(k))+ } /// Caret x on a bidirectional line: the leading edge of the cluster after `off` (the right /// edge of right-to-left text), except where `off` leaves an embedded run (the cluster before /// it has the higher level), which keeps the caret at that run's trailing edge, so each@@ -256,7 +281,7 @@ impl ParaLayout { (l.c0..l.c1).filter_map(|k| self.clusters.get(k).filter(|c| self.is_edit_boundary(c.start)).map(|c| c.start)).collect(); let last_break = l.c1 > l.c0 && self.clusters.get(l.c1 - 1).is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak));- let wrapped = l.c1 > l.c0 && l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some();+ let wrapped = l.c1 > l.c0 && l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some() && !self.is_wrap_end(li, l.stop); if !last_break && !wrapped || l.c1 == l.c0 { v.push(l.stop); }@@ -290,7 +315,7 @@ impl ParaLayout { if lastc.is_some_and(|c| matches!(c.kind, ClKind::LineBreak | ClKind::PageBreak | ClKind::ColumnBreak)) { continue; }- if l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some() {+ if l.end == LineEnd::Wrap && self.lines.get(li + 1).is_some() && !self.is_wrap_end(li, l.stop) { continue; } }@@ -2435,6 +2435,47 @@ fn caret_after_hanging_spaces_stays_at_the_margin() { } } +/// Where a line wraps after text (Chinese, a word wider than the line) rather than at a space, the+/// end of the line and the start of the next are the same position in the text. The caret there+/// is at the end of the line with `line_end` and at the start of the next without it; a click on+/// the right half of the line's last character lands at the end of the line, and End goes there.+#[test]+fn the_caret_at_a_soft_wrap_can_be_at_either_line() {+ for text in ["春风拂过山岗,溪水绕着村庄静静流淌。", "Lanternsdriftingoverquietharbours"] {+ let mut d = Document::new();+ d.last_section.page_w = 200.0;+ d.last_section.margin_left = 20.0;+ d.last_section.margin_right = 20.0;+ d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&text.repeat(4), Default::default()))];+ let l = lay(&d);+ let (para, y) =+ l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, y, .. } = it { Some((para.clone(), *y)) } else { None }).unwrap();+ assert!(para.lines.len() > 2, "{text}");+ for li in 0..para.lines.len() - 1 {+ let (line, next) = (¶.lines[li], ¶.lines[li + 1]);+ let last = para.clusters[line.c1 - 1].start;+ assert!(para.is_wrap_end(li, line.stop), "{text}: line {li} wraps after text");+ assert!(!para.is_wrap_end(li, last) && !para.is_wrap_end(li + 1, line.stop));+ let at = |off| Pos::body(0, off);+ let (start, before) = (l.caret(&at(line.start)).unwrap(), l.caret(&at(last)).unwrap());+ let end = l.caret_at(&at(line.stop), 0, true).unwrap();+ assert_eq!(end.top, start.top, "{text}: the caret at the end of line {li} is on it");+ assert!(end.x > before.x && end.x <= d.last_section.page_w - d.last_section.margin_right + 0.5, "{text}: {end:?} after {before:?}");+ let below = l.caret_at(&at(line.stop), 0, false).unwrap();+ assert_eq!(below, l.caret(&at(next.start)).unwrap());+ assert!(below.top > end.top, "{text}: without line_end the caret starts line {}", li + 1);+ let mid = y + line.top - para.lines[0].top + line.height / 2.0;+ let click = before.x + (end.x - before.x) * 0.75;+ assert_eq!(l.hit_at(0, click, mid, StoryRef::Body), Some((at(line.stop), true)), "{text}: click on line {li}");+ let next_mid = mid + next.top - line.top;+ assert_eq!(l.hit_at(0, below.x + 0.5, next_mid, StoryRef::Body), Some((at(line.stop), false)), "{text}: click on line {}", li + 1);+ assert_eq!(l.line_bounds(&at(line.start), 0, false), Some((at(line.start), at(line.stop), true)));+ assert_eq!(l.line_bounds(&at(line.stop), 0, true), Some((at(line.start), at(line.stop), true)));+ assert_eq!(l.line_bounds(&at(line.stop), 0, false).map(|b| b.0), Some(at(next.start)));+ }+ }+}+ #[test] fn tall_first_page_header_pushes_body_down() { let mut d = Document::from_text(&"para\n".repeat(120));@@ -2501,12 +2542,12 @@ fn column_segments_take_the_same_x_range_on_every_line() { let l = lay(&d); let left = l.caret(&Pos::body(0, 2)).unwrap().x; let right = l.caret(&Pos::body(0, 4)).unwrap().x;- let segs = l.column_segments(&d, &Pos::body(2, 2), &Pos::body(0, 2), right, left, 0, 100);+ let segs = l.column_segments(&d, (&Pos::body(2, 2), &Pos::body(0, 2)), (false, false), (right, left), 0, 100); let offs: Vec<(u32, usize, usize)> = segs.iter().map(|(a, b)| (a.path.0[0], a.off, b.off)).collect(); assert_eq!(offs, vec![(0, 2, 4), (1, 2, 4), (2, 2, 2)]); // Capped, and junk x gives nothing.- assert_eq!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), left, right, 0, 2).len(), 2);- assert!(l.column_segments(&d, &Pos::body(0, 2), &Pos::body(2, 2), f32::NAN, right, 0, 100).is_empty());+ assert_eq!(l.column_segments(&d, (&Pos::body(0, 2), &Pos::body(2, 2)), (false, false), (left, right), 0, 2).len(), 2);+ assert!(l.column_segments(&d, (&Pos::body(0, 2), &Pos::body(2, 2)), (false, false), (f32::NAN, right), 0, 100).is_empty()); } #[test]@@ -128,3 +128,29 @@ fn the_space_takes_no_room_at_the_end_of_a_justified_line() { let end = line.xs[k - line.c0] + plain; assert!((end - line.right).abs() < 0.01, "the 1 ends at {end}, the line at {}", line.right); }++/// The caret at the end of a line that wraps after a character with an automatic space (before+/// Latin text or digits on the next line) is drawn right after that character: the space goes+/// with the wrap, so it neither moves the caret off the character nor carries it past the margin.+#[test]+fn the_caret_at_the_end_of_a_line_leaves_out_the_space() {+ let text = "abcdef 1漢漢漢";+ let mut d = Document::from_text(text);+ let pl = first_para(&lay(&d));+ let line = &pl.lines[0];+ let k = pl.clusters.iter().position(|c| c.start == text.find('1').unwrap()).unwrap();+ let gap = gaps(&d)[k];+ assert!(gap > 0.0);+ // Room for the 1 and a quarter of the space after it: the space overflows the line.+ let right = line.xs[k - line.c0] + pl.clusters[k].adv - gap + gap / 4.0;+ para0(&mut d).props.indent_right = Some(line.right - right);+ let l = lay(&d);+ let (pl, x) =+ l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, x, .. } = it { Some((para.clone(), *x)) } else { None }).unwrap();+ let line = &pl.lines[0];+ assert_eq!(line.c1 - 1, k, "the 1 ends the first line");+ let end = line.xs[k + 1 - line.c0] - gap;+ assert!(end < line.right && line.xs[k + 1 - line.c0] > line.right, "the space after the 1 overflows the line");+ let c = l.caret_at(&wordcraft_doc::Pos::body(0, line.stop), 0, true).unwrap();+ assert!((c.x - (x + end)).abs() < 0.01, "the caret at {} is after the 1 at {}", c.x, x + end);+}@@ -167,3 +167,24 @@ fn a_click_outside_a_floating_table_in_a_vertical_section_skips_its_cells() { // Inside the table a click still lands in a cell. assert!(l.hit(0, cx, cy, StoryRef::Body).unwrap().path.0.len() > 1); }++/// In Chinese running down the page, a click below the last character of a line that wraps there+/// lands at the end of that line, and the caret stays on it rather than going to the head of the+/// next line (the same position in the text).+#[test]+fn a_click_below_the_end_of_a_wrapped_vertical_line_stays_on_it() {+ let mut d = Document::from_text(&"春风拂过山岗溪水绕着村庄静静流淌".repeat(12));+ d.last_section.text_direction = TextDirection::Down;+ let l = lay(&d);+ let wrap = l.index.get(&(StoryRef::Body, Path::top(0))).map(|v| v[0]).unwrap();+ let Placed::Lines { para, .. } = &l.pages[wrap.0].items[wrap.1] else { panic!() };+ assert!(para.lines.len() >= 2);+ let stop = para.lines[0].stop;+ assert!(para.is_wrap_end(0, stop));+ let first = l.caret(&Pos::body(0, 0)).unwrap();+ let end = l.caret_at(&Pos::body(0, stop), 0, true).unwrap();+ let next = l.caret_at(&Pos::body(0, stop), 0, false).unwrap();+ assert!((end.x - first.x).abs() < 0.5 && next.x < first.x - 1.0, "end {end:?}, next {next:?}, first {first:?}");+ let bottom = l.pages[0].body.bottom() - 1.0;+ assert_eq!(l.hit_at(0, first.x + first.width / 2.0, bottom, StoryRef::Body), Some((Pos::body(0, stop), true)));+}@@ -32,7 +32,7 @@ use balloons::balloon_text; use balloons::{balloons, dashed, over_balloon}; pub use balloons::{deselect_balloon, select_balloon}; use context_menu::{context_menu, context_suggestions};-use cursor::{caret_color_at, preedit_cursor_chars};+use cursor::{caret_color_at, caret_to_hit, preedit_cursor_chars}; pub(crate) use cursor::{caret_color_on, caret_contrast}; pub use cursor::{caret_screen, pos_from_screen}; pub(crate) use geometry::SEAM;@@ -582,7 +582,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } // Where the selection really ends, also when it took whole table cells.- let caret = layout.caret_on(&preview_sel(app).unwrap_or(app.session.sel_ends()).focus, app.session.page_hint);+ let focus = &preview_sel(app).unwrap_or(app.session.sel_ends()).focus;+ let caret = layout.caret_at(focus, app.session.page_hint, app.session.at_line_end(focus)); // Editing a header/footer (or a note) dims the body; once per frame, for every page. let dim_body = dims_body(app, &layout); let grid_story = text_box_host(app, &layout).unwrap_or(app.session.sel.focus.story);@@ -1098,8 +1099,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { } } }+ let focus = &preview_sel(app).unwrap_or(&app.session.sel).focus; if !math_caret- && let Some(c) = layout.caret_on(&preview_sel(app).unwrap_or(&app.session.sel).focus, app.session.page_hint)+ && let Some(c) = layout.caret_at(focus, app.session.page_hint, app.session.at_line_end(focus)) && let Some(pr) = rects.get(c.page) { let scale = layout.pages.get(c.page).map_or(geo.scale, |page| page_screen_scale(*pr, page, geo.scale));@@ -1271,8 +1273,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let at = wordcraft_geom::Point::new(x, y); let on_cells = wordcraft_engine::cmd::table_mouse::cell_rects(&layout, &app.session.doc, range).iter().any(|(pi, r)| *pi == page && r.contains(at));- if !on_cells && let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) {- app.session.sel = wordcraft_engine::Selection::caret(pos);+ if !on_cells && let Some(hit) = layout.hit_at(page, x, y, app.session.sel.focus.story) {+ caret_to_hit(app, hit); } } // Right-click: move the caret there (unless inside the selection), then the context menu.@@ -1301,10 +1303,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if crate::objects::select_at(app, &layout, &rects, geo.scale, p) { app.canvas.context_issue = None; app.canvas.context_synonyms = None;- } else if let Some(pos) = layout.hit(page, x, y, app.session.sel.focus.story) {+ } else if let Some((pos, line_end)) = layout.hit_at(page, x, y, app.session.sel.focus.story) { let (a, b) = app.session.sel.ordered(); if !(a <= pos && pos <= b) || app.session.sel.is_collapsed() {- app.session.sel = wordcraft_engine::Selection::caret(pos);+ caret_to_hit(app, (pos, line_end)); } context_suggestions(app); }@@ -3,10 +3,18 @@ use super::pages::{page_render_options, page_screen_scale}; use super::story::dims_body; use super::*; +/// Put the caret at a position [`wordcraft_layout::DocLayout::hit_at`] found, at the end of its+/// line where that line wraps after text if the hit was.+pub(crate) fn caret_to_hit(app: &mut WordApp, (pos, line_end): (Pos, bool)) {+ app.session.sel = wordcraft_engine::Selection::caret(pos);+ app.session.set_at_line_end(line_end);+}+ /// Caret position on screen. pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> { let l = app.session.layout();- let c = l.caret_on(&app.session.sel.focus, app.session.page_hint)?;+ let focus = &app.session.sel.focus;+ let c = l.caret_at(focus, app.session.page_hint, app.session.at_line_end(focus))?; let p = page_to_screen(app, c.page, c.x, c.top)?; let scale = app .canvas@@ -172,16 +172,16 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ { let id = if header { "insert.editHeader" } else { "insert.editFooter" }; if app.run(id, json!({ "page": page })).is_ok()- && let Some(pos) = app.session.layout().hit(page, x, y, app.session.sel.focus.story)+ && let Some(hit) = app.session.layout().hit_at(page, x, y, app.session.sel.focus.story) {- app.session.sel = wordcraft_engine::Selection::caret(pos);+ caret_to_hit(app, hit); } return; } if matches!(story, StoryRef::Part(_)) && !in_text_box(app) && hf_zone.is_none() { let _ = app.run("insert.closeHeader", json!({}));- if let Some(pos) = layout.hit(page, x, y, StoryRef::Body) {- app.session.sel = wordcraft_engine::Selection::caret(pos);+ if let Some(hit) = layout.hit_at(page, x, y, StoryRef::Body) {+ caret_to_hit(app, hit); } return; }@@ -248,8 +248,8 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ let id = if header { "insert.editHeader" } else { "insert.editFooter" }; if app.run(id, json!({ "page": page })).is_ok() { let to = app.session.sel.focus.story;- if let Some(pos) = app.session.layout().hit(page, x, y, to) {- app.session.sel = wordcraft_engine::Selection::caret(pos);+ if let Some(hit) = app.session.layout().hit_at(page, x, y, to) {+ caret_to_hit(app, hit); } app.session.page_hint = page; }@@ -310,7 +310,7 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ app.canvas.dragging = true; return; }- let Some(pos) = layout.hit(page, x, y, story) else { return };+ let Some((pos, line_end)) = layout.hit_at(page, x, y, story) else { return }; // Ctrl/⌘+click follows a hyperlink. if mods.command && let Some(link) = link_under(app, layout, page, x, y, &pos)@@ -338,6 +338,8 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ } app.canvas.column_drag = None; let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));+ // Past the last character of a line that wraps after text, the caret stays on that line.+ app.session.set_at_line_end(line_end); // 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 {@@ -375,7 +377,11 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ let target = match d.unit { "line" => layout.body_line_at(page, y), "document" => None,- _ => layout.hit(page, x, y, story),+ // Past the last character of a line that wraps after text, that character's word.+ _ => layout.hit_at(page, x, y, story).map(|(pos, line_end)| match app.session.doc.para_at(&pos) {+ Some(p) if line_end => Pos { off: p.prev_boundary(pos.off), ..pos },+ _ => pos,+ }), }; if let Some(pos) = target && pos != d.last@@ -389,11 +395,12 @@ pub(super) fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[ } else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) { // Through the command, so from one table cell into another the drag takes whole cells // (Word); straight to the session, as the app's `run` would scroll on every frame.- if let Some(pos) = layout.hit(page, x, y, story)- && pos != app.session.sel_ends().focus+ if let Some((pos, line_end)) = layout.hit_at(page, x, y, story)+ && (pos != app.session.sel_ends().focus || line_end != app.session.at_line_end(&pos)) { let pj = serde_json::to_value(&pos).unwrap_or_default(); let _ = app.session.run("caret.set", &json!({"pos": pj, "extend": true}));+ app.session.set_at_line_end(line_end); app.session.page_hint = page; app.canvas.caret_visible_since = crate::now_ms(); }@@ -4798,3 +4798,49 @@ fn the_view_key_changes_when_a_drag_takes_an_object_off_the_page() { assert_ne!(hidden, view_key(&app, false, None), "the page without the dragged object is rendered again"); assert_eq!(hidden, view_key(&app, false, Some(&object)), "the same drag keeps the key: no re-render every frame"); }++/// A click on the right half of the last character of a line that wraps after text (Chinese)+/// puts the caret after it at that line's end, and it is drawn there, not at the start of the+/// next line (the same position in the text). A click on the left half of the next line's first+/// character puts it at the next line's start.+#[test]+fn a_click_after_the_last_character_of_a_wrapped_line_keeps_the_caret_on_it() {+ 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": "春风拂过山岗,溪水绕着村庄静静流淌。".repeat(6)})).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 l = h.state_mut().session.layout();+ let para = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }).unwrap();+ assert!(para.lines.len() > 1);+ let (line, next) = (¶.lines[0], ¶.lines[1]);+ let last = l.caret(&Pos::body(0, para.clusters[line.c1 - 1].start)).unwrap();+ let end = l.caret_at(&Pos::body(0, line.stop), 0, true).unwrap();+ let start = l.caret(&Pos::body(0, next.start)).unwrap();+ let mid = |c: wordcraft_layout::hit::Caret| c.top + c.height / 2.0;+ for (x, y, line_end, row) in [(last.x + (end.x - last.x) * 0.75, mid(last), true, end), (start.x + 0.5, mid(start), false, start)] {+ let at = page_to_screen(h.state_mut(), 0, x, y).expect("on screen");+ h.event(Event::PointerMoved(at));+ h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Default::default() });+ h.step();+ h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Default::default() });+ for _ in 0..40 {+ h.step();+ }+ let s = &h.state().session;+ assert_eq!((s.sel.focus.clone(), s.at_line_end(&s.sel.focus)), (Pos::body(0, line.stop), line_end));+ let (drawn, _) = caret_screen(h.state_mut()).expect("caret on screen");+ let want = page_to_screen(h.state_mut(), 0, row.x, row.top).expect("on screen");+ assert!((drawn - want).length() < 0.5, "line end {line_end}: the caret is drawn at {drawn:?}, not {want:?}");+ }+}@@ -326,11 +326,12 @@ fn state_id() -> egui::Id { egui::Id::unique("caret_glide") } -/// What puts the caret where it is: the document's revision and the caret's position in it.+/// What puts the caret where it is: the document's revision and the caret's position in it (at a+/// soft wrap, at the end of one line or the start of the next). fn cause_key(app: &WordApp) -> u64 { let s = &app.session; let mut h = std::collections::hash_map::DefaultHasher::new();- (s.rev(), &s.sel.focus).hash(&mut h);+ (s.rev(), &s.sel.focus, s.at_line_end(&s.sel.focus)).hash(&mut h); if let Some(m) = &s.math { format!("{:?}", m.pos).hash(&mut h); }@@ -564,7 +564,8 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) { } else if app.session.selects_whole_story() { app.session.page_hint.min(l.pages.len().saturating_sub(1)) + 1 } else {- l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1)+ let focus = &app.session.sel.focus;+ l.caret_at(focus, app.session.page_hint, app.session.at_line_end(focus)).map(|c| c.page + 1).unwrap_or(1) }; let st = |ui: &mut Ui, s: &str| { let font = TypeRung::Caption.regular();@@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (`*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (End, Right, Up/Down and a click reach the end of a line that wraps after text, such as Chinese; previously trivial: `*Bold* and _italic_ as real formatting` in AutoFormat As You Type; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: queued IME replacements are rejected before mutation after their marked composition loses its body/equation target; previously trivial: the transactional equation delete proves its commit with the engine revision; previously trivial: a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, so a later direct commit still applies; previously trivial: a tracked own-insertion shifts the rest of a two-sided IME deletion; previously trivial: AutoCorrect/AutoFormat classification compares the actual paragraph; previously trivial: a replaced body starts a new input-method context and interrupts the composition; a quote the engine leaves as typed is the input method's own change; equation `DeleteSurrounding` erases counted characters; previously trivial: AutoCorrect finds the word after a no-break space or an ideographic space; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously trivial: while an input method composes, the caret and its candidate window sit at the input method's cursor inside the composition; previously trivial: Ctrl+Tab types a tab in a table cell, #3137; previously trivial: List AutoFormat triggers in front of an already-written paragraph, not only a fresh empty one, checked in Word for Windows, #3352; previously trivial: AltGr types its characters without opening KeyTips on Linux, #2388; previously trivial: Delete on whole selected cells leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too, #2802; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: a slash opening a word travels whole to the next line, #2963; previously trivial: typing over a selection takes the first selected character's formatting, also when the selection was made right to left, #2879; previously trivial: Turkish and Azerbaijani case mapping in AutoCorrect, #1923; previously trivial: smart quotes in Hebrew pinned: logical order, ” in acronyms as in Word, #1670; previously trivial: Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343; the body IME deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its undo join to the matching replacement) · **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@@ -130,6 +130,7 @@ Windows/Linux keys are given first, macOS second. | 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` | | Ctrl+Left / Right (⌥← / ⌥→); Home / End (⌘← / ⌘→) | one word left / right; the start / end of the line. On macOS ⌘← / ⌘→ go to the line's ends, as in every Mac text field, and ⌥ moves by word (#954); Shift extends the selection | `word_default_shortcuts_resolve` |+| End, Right (→), Down / Up (↓ / ↑) or a click at a line that wraps after text (Chinese, a word wider than the line), not at a space | the caret goes after the line's last character and stays at that line's end, though the next line's start is the same position in the text: Home then goes to the start of that line, Down to the next line, Shift+End selects the whole line. The next Right goes past the next line's first character; Left from there stops at that line's start. A click on the right half of the last character lands at the line's end, one on the left half of the next line's first character at that line's start. A double-click there selects the last character's word, a column selection to there ends on that line, and Right in a column one character wide goes from line end to line end; an edit or another selection makes the position the next line's start again. The same holds in text running down the page. A line that wraps at a space still ends before the space. The caret at the line's end stands before the automatic space between Asian text and Latin text or digits, which goes with the wrap. Follows the usual caret behaviour of text editors; not yet compared with Word | `tests::soft_wrap::*`; layout `the_caret_at_a_soft_wrap_can_be_at_either_line`, `the_caret_at_the_end_of_a_line_leaves_out_the_space`, `a_click_below_the_end_of_a_wrapped_vertical_line_stays_on_it`; ui-egui `a_click_after_the_last_character_of_a_wrapped_line_keeps_the_caret_on_it` | | 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` | | Shift+Backspace / Shift+Delete | Shift+Backspace erases like Backspace (#707). Shift+Delete cuts the selection to the clipboard and does nothing without one (Word for Windows, observed 2026-10-14). Windows delivers Shift+Delete as a Cut; macOS and Linux deliver the key, which did nothing (#1669) | `shift_delete_cuts_the_selection_and_shift_backspace_erases` |@@ -471,6 +472,8 @@ again. longer repeated, but the cause of the caret jump hasn't been found and isn't worked around. - Word's body font Aptos isn't installed outside Office; our substitute (Carlito, else Helvetica Neue) is wider, so lines wrap earlier than in Word. Needs a metric-matched open substitute.+- On a right-to-left or bidirectional line that wraps inside a word, End still stops before the+ line's last character: the caret at the end of a soft-wrapped line covers left-to-right lines. - Moving paragraphs with Track Changes on isn't recorded as a tracked move; whole table rows don't move yet (a selection across cells is refused). Go Back's places aren't mapped through later edits (they are clamped to the document).@@ -492,6 +495,7 @@ again. | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | Editing keys: End, Right, Down/Up and a click reach the end of a line that wraps after text (Chinese, a word wider than the line), and the caret stays on that line; before, End stopped before the last character and Right showed the caret at the next line's start. | | 2026-10-25 | trivial | Highlight decisions include fully selected paragraph marks in whole cells and ordinary ranges, including empty paragraphs; partial text columns exclude unselected marks (#4085). | | 2026-10-25 | trivial | Highlight toggle checks the full target beyond 10,000 runs without collecting resolved formatting; mixed and inherited tails and Undo/Redo covered (#4085). | | 2026-10-24 | trivial | Input methods: reject queued old-composition replacements before mutation after body/equation target replacement (new regression validation pending); equation `DeleteSurrounding` is one transactional command whose checked revision proves a receipt; a caret-only step in a mixed request refuses the whole deletion, and an over-cap request clears the painted pre-edit and the previous replacement join; a refused byte-counted equation deletion drops only the immediately adjacent stale replacement, never a later direct commit; a tracked own-insertion shifts the following range; a body deletion maps signed placeholder growth/shrink, keeps control markers outside the range and scopes its replacement undo join to the matching `text.insert`; refusals clear the painted pre-edit and joins; AutoCorrect/AutoFormat classification compares the paragraph as typed; equation replacement interrupts the composition |