Selection bar: right margin for right-to-left text, and beside table rows (UI gap) (#2042)

* gap: selection bar RTL/table rows WIP

* gap: selection bar in the right margin for right-to-left paragraphs and beside table rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap: selection bar review fixes (row bar only beside the table, Shift+click from outside, text-box tables)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap: selection bar review round 2 (0.5pt row-overlap tolerance, body-story check for Shift+click)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap: selection bar: triple-click or Ctrl+click beside a table row selects the document, as in Word

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap: selection bar: ui-parity wording (only triple-click was checked in Word)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* gap: selection bar: fix docs after rebase onto main

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* gap: selection bar: a Shift+click drag keeps the anchor row of its press

table.selectAt takes an optional anchorRow; the app reads the anchor at the
press and sends it on every drag frame, so pointer jitter after an upward
Shift+click no longer re-reads it from the rows the press selected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Selection bar RTL test: the left margin shows no bar arrow (Windows reports no cursor there)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* #2042: repair three doc status lines a #3882 merge left with a stray Target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-23 13:24:33 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Dmitrii Khlapov Brandon Thomas
parent fa5992223f
commit 38931c34be
10 changed files with 474 additions and 20 deletions
+3 -1
View File
File diff suppressed because one or more lines are too long
+160 -4
View File
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("table.selectAt", "Select Table Cells by Clicking", "Table Layout › Table › Select", select_at)
.params(
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
r#"{"page": n, "x": pt, "y": pt, "toX"?: pt, "toY"?: pt, "reach"?: pt, "extend"?: bool, "anchorRow"?: n} (a click in page points, 0-based page, on a selection area of a table in the caret's story: just above a column selects the column, left of a row the row, along a cell's left edge inside it the cell, the move handle past the top-left corner the table; with toX/toY (a drag) every column, row or cell from there to that point; the page margin beside a table (its leading side: right for a right-to-left table) is a row area too, and `extend` (Shift+click) on one takes the rows from the selected ones' anchor, or from row `anchorRow` (0-based) when given, so a drag keeps its press's anchor. `reach` (default 3) is the column borders' resize reach, which the cell areas keep clear of.)"#,
)
.pure(),
CommandSpec::new("table.insertAt", "Insert Row or Column by Clicking", "Table Layout › Rows & Columns", insert_at).params(
@@ -129,7 +129,7 @@ fn row_at(geom: &ResizeTarget, y: f32) -> usize {
/// The spot of one table at (`x`, `y`): the handle, then the insert buttons, then the selection
/// areas. `cells` are its cell boxes on the page.
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32) -> Option<Spot> {
fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32, bar: Option<(f32, f32)>) -> Option<Spot> {
if on_handle(geom, x, y, reach) {
return Some(Spot::Handle);
}
@@ -156,11 +156,47 @@ fn spot_in(geom: &ResizeTarget, cells: &[&CellBox], x: f32, y: f32, reach: f32)
return Some(Spot::Row(r.row));
}
}
// The page margin beside the table is the selection bar: a row's area too.
if let Some((x0, x1)) = bar
&& x >= x0
&& x <= x1
&& let Some(r) = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom)
{
return Some(Spot::Row(r.row));
}
// Inside a cell, along its left edge.
let edge = CELL_EDGE.max(reach + 2.5);
cells.iter().find(|b| x >= b.rect.x && x <= b.rect.x + edge && y >= b.rect.y && y <= b.rect.bottom()).map(|b| Spot::Cell(b.row, b.cell))
}
/// The x range of the selection bar beside the table's row at page height `y`: the page margin
/// on the table's leading side (left, or right for a right-to-left table). Only a non-floating
/// table of the main text has one, and only where it is what stands beside that margin: no line
/// of the text (another column, text wrapped round the table) lies between the two at that
/// height. A nested table's rows are the outermost table's.
fn row_bar(l: &DocLayout, doc: &Document, page: usize, geom: &ResizeTarget, y: f32) -> Option<(f32, f32)> {
let p = l.pages.get(page)?;
if geom.story != StoryRef::Body || geom.table.0.len() != 1 {
return None;
}
let t = doc.table(geom.story, &geom.table)?;
let row = geom.rows.iter().find(|r| y >= r.top && y <= r.bottom)?;
if t.props.float.is_some() {
return None;
}
let rtl = t.props.bidi_visual;
let blocked = wordcraft_layout::hit::page_lines(p, StoryRef::Body).iter().any(|ln| {
ln.path.0.len() <= 1
&& ln.top < row.bottom - 0.5
&& ln.bottom > row.top + 0.5
&& if rtl { ln.right > geom.right() } else { ln.left < geom.left() }
});
if blocked {
return None;
}
Some(if rtl { (geom.right().max(p.body.right()), p.w) } else { (0.0, geom.left().min(p.body.x)) })
}
/// The spot under page point (`x`, `y`) of a table in `story`, of nested tables the innermost's.
/// None on a column or row border or the resize handle: those resize (`table_resize`). `reach`
/// is their resize reach (points).
@@ -178,7 +214,7 @@ pub fn target_at(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x:
.filter(|g| g.story == story)
.filter_map(|geom| {
let cells: Vec<&CellBox> = boxes.iter().filter(|b| b.story == geom.story && b.table == geom.table).collect();
spot_in(&geom, &cells, x, y, reach).map(|spot| Target { spot, geom })
spot_in(&geom, &cells, x, y, reach, row_bar(l, doc, page, &geom, y)).map(|spot| Target { spot, geom })
})
.max_by_key(|t| t.geom.table.0.len())
}
@@ -377,7 +413,17 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
let (rows, cols) = match spot {
Spot::Column(g) => ((0, last_row), (g, to.map_or(g, |(tx, _)| column_at(&geom, tx)))),
Spot::Row(r) => ((r, to.map_or(r, |(_, ty)| row_at(&geom, ty))), (0, last_col)),
Spot::Row(r) => {
let r2 = to.map_or(r, |(_, ty)| row_at(&geom, ty));
// Shift+click: the rows from the selection's anchor row to the one clicked.
// A drag after it keeps the anchor the press had (`anchorRow`).
let anchor = if v.get("extend").and_then(Value::as_bool).unwrap_or(false) {
p::u64(v, "anchorRow").map_or_else(|| anchor_row(s, &geom.table, r), |a| usize::try_from(a).unwrap_or(usize::MAX).min(last_row))
} else {
r
};
((anchor, r2), (0, last_col))
}
Spot::Cell(r, c) => {
let (g0, g1) = table::grid_cols(t, r, c);
match to {
@@ -394,6 +440,19 @@ fn select_at(s: &mut Session, v: &Value) -> CmdResult {
table::select_range(s, story, &geom.table, rows, cols)
}
/// The row a Shift+click on row `clicked` of table `tp` extends from: the end of the selected
/// rows farther from it, or the caret's row; the clicked row itself when nothing of the table is
/// selected. The app reads it at the press and sends it as `anchorRow` while the button is held.
pub fn anchor_row(s: &Session, tp: &Path, clicked: usize) -> usize {
if let Some(range) = s.cell_range().filter(|c| &c.table == tp) {
return if clicked >= range.rows.0 { range.rows.0 } else { range.rows.1 };
}
match s.sel.anchor.path.cell() {
Some((t, row, _)) if &t == tp => row,
_ => clicked,
}
}
/// A cell to insert a column beside at grid line `line`: one starting there (the column goes
/// before it) or, at the table's right edge, a row's last cell (after it). (row, cell, after)
fn beside_line(t: &Table, line: usize) -> Option<(usize, usize, bool)> {
@@ -761,6 +820,103 @@ mod tests {
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols, r.stream)), Some(((2, 2), (0, 2), true)));
}
/// The page margin beside a table is a row selection area too, as far as the selection bar
/// reaches (Word): left of a left-to-right table, right of a right-to-left one.
#[test]
fn the_margin_beside_a_row_selects_it() {
let mut s = table_session(3, 3);
let r1 = cell_rect(&mut s, 1, 0);
let body_x = s.layout().pages[0].body.x;
assert!(r1.x - 40.0 > 0.0 && r1.x - body_x < 24.0);
// Far past the 24pt of the plain row area, still in the margin: the row.
assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), Some(Spot::Row(1)));
assert_eq!(spot_at(&mut s, body_x - 35.0, mid_y(r1)), Some(Spot::Row(1)));
// The right margin is not a row bar of a left-to-right table.
let right = s.layout().pages[0].body.right();
assert_eq!(spot_at(&mut s, right + 20.0, mid_y(r1)), None);
// A right-to-left table has its bars on the right.
let tp = table_path(&s);
s.doc.table_mut(StoryRef::Body, &tp).unwrap().props.bidi_visual = true;
s.touch();
let r1 = cell_rect(&mut s, 1, 0);
let (page_w, right) = (s.layout().pages[0].w, s.layout().pages[0].body.right());
assert_eq!(spot_at(&mut s, right + 30.0, mid_y(r1)), Some(Spot::Row(1)));
assert_eq!(spot_at(&mut s, page_w - 5.0, mid_y(r1)), Some(Spot::Row(1)));
assert_eq!(spot_at(&mut s, 10.0, mid_y(r1)), None);
// Shift+click in the bar extends the selected rows from the anchor row.
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r1)})).unwrap();
let r2 = cell_rect(&mut s, 2, 0);
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r2), "extend": true})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((1, 2)));
let r0 = cell_rect(&mut s, 0, 0);
s.run("table.selectAt", &json!({"page": 0, "x": right + 30.0, "y": mid_y(r0), "extend": true})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the far edge of the old rows stays");
}
/// `anchorRow` fixes the row a Shift+click extends from, whatever is selected (a drag after
/// an upward Shift+click keeps its rows); out of range it's the last row.
#[test]
fn an_anchor_row_param_fixes_the_extend_anchor() {
let mut s = table_session(3, 3);
let (r0, r2) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 2, 0));
let x = r0.x - 10.0;
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r2)})).unwrap();
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)));
// Without it, the next frame of the drag would re-read the anchor as row 0.
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "toX": x, "toY": mid_y(r0) + 1.0, "extend": true, "anchorRow": 2}))
.unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "the press's anchor stays");
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 99999999999u64})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 2)), "clamped to the last row");
s.run("table.selectAt", &json!({"page": 0, "x": x, "y": mid_y(r0), "extend": true, "anchorRow": 1})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 1)));
}
/// The margin is the row bar only where the table is what's beside it: not at a column-2
/// table's height beside column 1's lines (the selection bar's lines), nor beside text
/// wrapped round a floating table.
#[test]
fn the_row_bar_needs_the_table_beside_the_margin() {
// A two-column section: 36 short lines fill column 1 and start column 2, then a table.
let mut s = Session::new(Document::new());
s.run("layout.columns", &json!({"count": 2})).unwrap();
let lines: Vec<String> = (0..36).map(|i| format!("Line {i}")).collect();
s.run("text.insert", &json!({"text": format!("{}\r", lines.join("\r"))})).unwrap();
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
let r0 = cell_rect(&mut s, 0, 0);
let body = s.layout().pages[0].body;
assert!(r0.x > body.x + body.w / 2.0, "the table is in column 2: {r0:?}");
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "column 1's line is the selection bar's");
// A floating table with text wrapped to its left.
let mut d = Document::from_text(&"Words that wrap round the floating table beside them. ".repeat(40));
let mut t = Table::new(3, 1, 150.0);
t.props.float = Some(wordcraft_doc::props::TableFloat {
h_rel: wordcraft_doc::para::Anchor::Margin,
v_rel: wordcraft_doc::para::Anchor::Paragraph,
x: 300.0,
overlap: false,
..Default::default()
});
d.insert_block(StoryRef::Body, &Path(vec![0]), wordcraft_doc::Block::Table(t)).unwrap();
let mut s = Session::new(d);
let r0 = cell_rect(&mut s, 0, 0);
assert!(r0.x > 300.0, "the table floats at the right: {r0:?}");
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), None, "the text wrapped beside it has the line bar");
}
/// A paragraph right above the table (no space after) doesn't hide the first row's bar.
#[test]
fn a_paragraph_flush_above_the_table_keeps_the_row_bar() {
let mut d = Document::from_text("Above");
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props.space_after = Some(0.0);
d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(Table::new(2, 2, 400.0))).unwrap();
let mut s = Session::new(d);
let r0 = cell_rect(&mut s, 0, 0);
assert_eq!(spot_at(&mut s, 10.0, mid_y(r0)), Some(Spot::Row(0)));
assert_eq!(spot_at(&mut s, 10.0, r0.y + 0.3), Some(Spot::Row(0)), "even at the very top of the row");
}
/// The insert buttons add a row or column at their border, one undo step, and select it (as
/// Word does); typing then goes into its first cell.
#[test]
+2
View File
@@ -3349,6 +3349,8 @@ fn hostile_params_never_panic() {
json!({}),
json!({"text": 5, "size": "x", "path": [], "pos": {"block": 9999, "off": 99999}}),
json!({"value": -1e308, "rows": 1e9}),
json!({"page": 0, "x": 10, "y": 100, "extend": "x", "toX": null, "toY": 1e30, "reach": "r"}),
json!({"page": 0, "x": 1e30, "y": -1e30, "extend": 1}),
json!({"width": 1e-9, "height": 1e30, "top": -1e9, "left": 1e9}),
json!({"section": {"pageW": -1e308, "pageH": 0, "marginTop": 1e30, "header": -5, "columns": {"count": 1000000, "space": -1e300, "widths": [[0, 0]]}}}),
];
+14 -7
View File
@@ -566,20 +566,27 @@ 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.
/// Word's selection bar (#856): when (x, y) is in the page margin beside the text column,
/// the start of the body line at that height. The bar is the left margin for a left-to-right
/// paragraph and the right margin for a right-to-left one. Not in the header/footer area,
/// not beside a table's lines (those are the table's row areas), 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() {
if !(x.is_finite() && y.is_finite()) || x < 0.0 || x > p.w || y < p.body.y || y > p.body.bottom() {
return None;
}
let left = x < p.body.x;
if !left && x <= p.body.right() {
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.
let line = l.para.lines.get(l.li)?;
// The line's own side, and no text hanging into that margin.
let hangs = if line.rtl { l.right > p.body.right() + 0.5 } else { l.left < p.body.x - 0.5 };
if l.path.0.len() > 1 || line.rtl == left || hangs {
return None;
}
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: l.para.lines.get(l.li)?.start })
Some(Pos { story: StoryRef::Body, path: l.path.clone(), off: line.start })
}
/// The start of the top-level body line nearest height `y` on `page` (dragging in the
+30
View File
@@ -404,6 +404,36 @@ fn selection_bar_finds_the_line_beside_the_pointer() {
assert_eq!(l.body_line_at(0, -100.0), Some(Pos::body(0, 0)));
}
#[test]
fn selection_bar_of_a_right_to_left_paragraph_is_in_the_right_margin() {
// Paragraph 0 reads left to right, paragraph 1 right to left, paragraph 2 left to right.
let mut d = Document::from_text("Left one\n\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e9}\u{5dc}\u{5d5}\u{5dd}\nLeft two");
let at = Pos::body(1, 0);
d.format_paragraphs(&at, &at, &|p: &mut ParaProps| p.bidi = Some(true)).unwrap();
let l = lay(&d);
let p = &l.pages[0];
let mid = |i: usize| {
let c = l.caret(&Pos::body(i, 0)).unwrap();
c.top + c.height / 2.0
};
let (left_x, right_x) = (p.body.x / 2.0, (p.body.right() + p.w) / 2.0);
// The right-to-left paragraph: the bar is on the right, not the left.
assert_eq!(l.selection_bar_at(0, right_x, mid(1)).map(|q| q.path), Some(Path::top(1)));
assert_eq!(l.selection_bar_at(0, left_x, mid(1)), None);
// Left-to-right neighbours keep the left margin only (each line uses its own side).
assert_eq!(l.selection_bar_at(0, left_x, mid(0)), Some(Pos::body(0, 0)));
assert_eq!(l.selection_bar_at(0, right_x, mid(0)), None);
assert_eq!(l.selection_bar_at(0, left_x, mid(2)), Some(Pos::body(2, 0)));
assert_eq!(l.selection_bar_at(0, right_x, mid(2)), None);
// Not in the text column, off the page, or hostile numbers.
assert_eq!(l.selection_bar_at(0, p.body.right() - 5.0, mid(1)), None);
assert_eq!(l.selection_bar_at(0, p.w + 5.0, mid(1)), None);
assert_eq!(l.selection_bar_at(0, f32::INFINITY, mid(1)), None);
// The line span is the whole line, as for the left bar.
let q = l.selection_bar_at(0, right_x, mid(1)).unwrap();
assert!(l.line_span(&q, 0).is_some());
}
#[test]
fn deletions_leave_the_final_text_layout() {
// "Keep " + deleted "DELETEDTEXT " (5..17) + inserted "INSERTED".
+65 -2
View File
@@ -71,7 +71,7 @@ pub struct CanvasState {
column_drag: Option<((usize, f32, f32), (usize, f32, f32))>,
/// The last primary press on the page (time, screen point, clicks in a row), to count
/// double- and triple-presses as they go down (egui reports them on release).
last_press: Option<(f64, Pos2, u8)>,
pub(crate) last_press: Option<(f64, Pos2, u8)>,
/// The first press of the current run of presses moved header/footer editing into another
/// header/footer; the double-click it starts doesn't select a word there.
hf_press_switched: bool,
@@ -324,9 +324,17 @@ struct UnitDrag {
last: Pos,
}
/// After a whole-document selection from a press (not a line, so it can't grow): holds the press
/// so its release isn't a caret click.
pub(crate) fn hold_document_selection(app: &mut WordApp, clicks: u8) {
let (start, end) = app.session.sel.ordered();
app.canvas.unit_drag = Some(UnitDrag { unit: "document", clicks, start, last: end.clone(), end });
app.canvas.dragging = true;
}
/// Clicks in a row for a press at `at` (time `now`) after `last`: within the double-click
/// delay and distance it counts up to 3 (a triple-click), otherwise it starts again at 1.
fn press_count(last: Option<(f64, Pos2, u8)>, now: f64, at: Pos2, delay: f64, dist: f32) -> u8 {
pub(crate) fn press_count(last: Option<(f64, Pos2, u8)>, now: f64, at: Pos2, delay: f64, dist: f32) -> u8 {
match last {
Some((t, p, n)) if now - t >= 0.0 && now - t < delay && p.distance(at) <= dist => n.saturating_add(1).min(3),
_ => 1,
@@ -4465,6 +4473,61 @@ mod tests {
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default);
}
/// A right-to-left paragraph's selection bar is the right margin; the left margin beside it
/// is nothing special (a click there is the caret's).
#[test]
fn selection_bar_of_a_right_to_left_paragraph_is_on_the_right() {
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": "First para\r\u{5e9}\u{5dc}\u{5d5}\u{5dd} \u{5e9}\u{5dc}\u{5d5}\u{5dd}\rThird para"})).unwrap();
a.run("caret.set", json!({"pos": serde_json::to_value(Pos::body(1, 0)).unwrap()})).unwrap();
a.run("para.rtl", json!({})).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 c = h.state_mut().session.layout().caret(&Pos::body(1, 0)).expect("caret");
let (body_x, page_w) = {
let l = h.state_mut().session.layout();
(l.pages[c.page].body.right(), l.pages[c.page].w)
};
let y = c.top + c.height / 2.0;
let right = page_to_screen(h.state_mut(), c.page, (body_x + page_w) / 2.0, y).expect("on screen");
let left = page_to_screen(h.state_mut(), c.page, 30.0, y).expect("on screen");
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
h.event(Event::PointerMoved(at));
h.step();
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..3 {
h.step();
}
};
h.event(Event::PointerMoved(right));
h.step();
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "the bar's arrow on the right");
click(&mut h, right);
let (s, e) = h.state().session.sel.ordered();
assert_eq!(s, Pos::body(1, 0), "the line is selected");
assert!(e > s && (e.path == wordcraft_doc::Path::top(1) || e == Pos::body(2, 0)), "{s:?}..{e:?}");
// Beside it on the left: no bar (not the bar's arrow, and a caret, not a line selection).
// The cursor there is the text cursor, or none at all on some platforms' test windows.
h.event(Event::PointerMoved(left));
h.step();
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default);
click(&mut h, left);
assert!(h.state().session.sel.is_collapsed(), "a click in the left margin beside it is a caret, not a line");
}
/// #3453: while the Format Painter is armed the pointer over the text says so, and it goes
/// back once the painter is off. The selection bar keeps its arrow while it is armed.
#[test]
+196 -3
View File
@@ -26,6 +26,19 @@ pub(crate) struct TableDrag {
/// The resize reach at the press (page points).
reach: f32,
target: Target,
/// Shift was down at the press on a row area: the rows extend from the anchor row.
extend: bool,
/// The row that Shift+click extends from, read at the press: the rows it selects would
/// otherwise move it on the next frame.
anchor: Option<usize>,
}
/// `table.selectAt`'s params, with the press's anchor row when it has one.
fn select_params(mut at: serde_json::Value, anchor: Option<usize>) -> serde_json::Value {
if let (Some(a), Some(m)) = (anchor, at.as_object_mut()) {
m.insert("anchorRow".into(), json!(a));
}
at
}
/// Whether the table mouse is in reach: when resizing is, and not while a border is dragged.
@@ -54,7 +67,10 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
d.to = ((p.x - r.min.x) / sc, (p.y - r.min.y) / sc);
}
let down = ui.input(|i| i.pointer.primary_down());
let at = json!({"page": d.page, "x": d.from.0, "y": d.from.1, "reach": d.reach, "toX": d.to.0, "toY": d.to.1});
let at = select_params(
json!({"page": d.page, "x": d.from.0, "y": d.from.1, "reach": d.reach, "toX": d.to.0, "toY": d.to.1, "extend": d.extend}),
d.anchor,
);
if d.target.spot == Spot::Handle {
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
return true;
@@ -92,7 +108,33 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
}
let Some(p) = latest else { return false };
let Some((page, x, y, reach, target)) = target_at(app, layout, rects, scale, p) else { return false };
let at = json!({"page": page, "x": x, "y": y, "reach": reach});
let mods = ui.input(|i| i.modifiers);
// Word: a triple-click or Ctrl/Cmd+click beside a row selects the whole document, as in the
// body's selection bar.
if matches!(target.spot, Spot::Row(_)) && !mods.shift {
let now = ui.input(|i| i.time);
let (delay, dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist));
let clicks = crate::canvas::press_count(app.canvas.last_press, now, p, delay, dist);
app.canvas.last_press = Some((now, p, clicks));
if clicks >= 3 || mods.command {
if let Err(e) = app.run("select.all", json!({"whole": true})) {
app.status(e);
}
crate::canvas::hold_document_selection(app, clicks);
return true;
}
}
let extend = matches!(target.spot, Spot::Row(_)) && mods.shift;
// Shift+click with the selection started outside the table is the ordinary Shift+click into
// a table (the caret logic extends it by whole rows); only the table's own selection extends here.
if extend && !in_table(app, &target.geom.table) {
return false;
}
let anchor = match target.spot {
Spot::Row(r) if extend => Some(table_mouse::anchor_row(&app.session, &target.geom.table, r)),
_ => None,
};
let at = select_params(json!({"page": page, "x": x, "y": y, "reach": reach, "extend": extend}), anchor);
match target.spot {
Spot::InsertRow(_) | Spot::InsertColumn(_) => {
if let Err(e) = app.run_keep_view("table.insertAt", at) {
@@ -106,12 +148,19 @@ pub fn pointer(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect]
{
app.status(e);
}
app.canvas.table_drag = Some(TableDrag { page, from: (x, y), to: (x, y), sent: (x, y), reach, target });
app.canvas.table_drag = Some(TableDrag { page, from: (x, y), to: (x, y), sent: (x, y), reach, target, extend, anchor });
}
}
true
}
/// Whether the selection's anchor (or its selected cells) is in the table at `tp`.
fn in_table(app: &WordApp, tp: &wordcraft_doc::Path) -> bool {
let body = |story| story == wordcraft_doc::StoryRef::Body;
app.session.cell_range().is_some_and(|c| body(c.story) && &c.table == tp)
|| (body(app.session.sel.anchor.story) && app.session.sel.anchor.path.cell().is_some_and(|(t, ..)| &t == tp))
}
/// The pointer: the move arrows on the handle, the plain arrow on an insert button; over a
/// selection area the system pointer hides and `paint` draws the selection arrow.
pub fn cursor(app: &WordApp, ui: &Ui, resp: &egui::Response, layout: &DocLayout, rects: &[Rect], scale: f32) {
@@ -317,6 +366,150 @@ mod tests {
assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 2))));
}
/// The selection bar beside a table (the page margin, well past the row areas) selects the
/// row; a drag down it selects the rows it passes; Shift+click extends by rows.
#[test]
fn the_margin_beside_a_table_selects_rows() {
let mut h = table_window();
let (a, b) = (cell(&mut h, 0, 0), cell(&mut h, 1, 0));
let (ya, yb) = (a.y + a.h / 2.0, b.y + b.h / 2.0);
let far = 20.0;
assert!(a.x - far > 30.0, "well past the row areas");
let at_a = screen(&mut h, far, ya);
h.event(egui::Event::PointerMoved(at_a));
steps(&mut h, 2);
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "the drawn selection arrow");
click(&mut h, at_a);
assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 0), (0, 2))), "one click, the row");
// Drag from row 0 to row 1.
let end = screen(&mut h, far, yb);
h.event(egui::Event::PointerMoved(at_a));
h.step();
button(&h, at_a, true);
h.step();
h.event(egui::Event::PointerMoved(end));
h.step();
assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((0, 1)), "the selection follows the pointer");
button(&h, end, false);
steps(&mut h, 2);
// Click row 0 again, then Shift+click row 1.
click(&mut h, at_a);
assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((0, 0)));
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
h.step();
h.event(egui::Event::PointerMoved(end));
h.step();
button(&h, end, true);
h.step();
button(&h, end, false);
steps(&mut h, 2);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::default()));
h.step();
assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 2))), "extended by rows");
}
/// Shift+click beside an upper row with the last row selected takes the rows between, and
/// the pointer moving a little before the release keeps them: the anchor row is the one at
/// the press, not re-read from the rows the press selected.
#[test]
fn a_shift_click_up_the_margin_keeps_its_anchor_while_held() {
let mut h = table_window();
let (a, b) = (cell(&mut h, 0, 0), cell(&mut h, 1, 0));
let far = 20.0;
let at_b = screen(&mut h, far, b.y + b.h / 2.0);
click(&mut h, at_b);
assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((1, 1)));
let at = screen(&mut h, far, a.y + a.h / 2.0);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
h.step();
h.event(egui::Event::PointerMoved(at));
h.step();
button(&h, at, true);
h.step();
assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((0, 1)), "extended up");
for k in 1..=3 {
h.event(egui::Event::PointerMoved(at + vec2(k as f32, 1.0)));
h.step();
}
button(&h, at + vec2(3.0, 1.0), false);
steps(&mut h, 2);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::default()));
h.step();
assert_eq!(h.state().session.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 2))), "the jitter kept the rows");
}
/// Word: a triple-click, or Ctrl/Cmd+click, in the margin beside a table row selects the whole
/// document; a single click (and a drag) keeps selecting rows.
#[test]
fn triple_click_or_ctrl_click_beside_a_row_selects_the_document() {
let mut h = table_window_stepping(1.0 / 60.0);
let b = cell(&mut h, 1, 0);
let at = screen(&mut h, 20.0, b.y + b.h / 2.0);
let whole = |h: &mut H| {
let s = &h.state().session;
// From the first cell's start to the end of the paragraph after the table.
let (a, e) = s.sel.ordered();
a.path == wordcraft_doc::Path(vec![0, 0, 0, 0]) && a.off == 0 && e == wordcraft_doc::Pos::body(1, 0)
};
// One click: the row.
click(&mut h, at);
assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((1, 1)), "a click selects the row");
// Three quick clicks: everything.
steps(&mut h, 40);
for _ in 0..3 {
h.event(egui::Event::PointerMoved(at));
h.step();
button(&h, at, true);
h.step();
button(&h, at, false);
h.step();
}
steps(&mut h, 2);
assert!(whole(&mut h), "triple-click: {:?}", h.state().session.sel);
// Ctrl+click: everything too.
steps(&mut h, 40);
click(&mut h, at);
assert_eq!(h.state().session.cell_range().map(|r| r.rows), Some((1, 1)));
steps(&mut h, 40);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND));
h.step();
click(&mut h, at);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::default()));
h.step();
assert!(whole(&mut h), "ctrl+click: {:?}", h.state().session.sel);
}
/// Shift+click in the bar beside a row, with the selection started above the table, extends
/// that selection (the ordinary Shift+click into a table), instead of selecting only the row.
#[test]
fn shift_click_beside_a_row_extends_a_selection_from_above() {
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
a.run("text.insert", json!({"text": "Above"})).unwrap();
a.run("insert.table", json!({"rows": 3, "cols": 2})).unwrap();
a.run("caret.set", json!({"pos": serde_json::to_value(wordcraft_doc::Pos::body(0, 0)).unwrap()})).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,
);
steps(&mut h, 6);
let b = cell(&mut h, 1, 0);
let at = screen(&mut h, 20.0, b.y + b.h / 2.0);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
h.step();
click(&mut h, at);
h.event(egui::Event::ModifiersChanged(egui::Modifiers::default()));
h.step();
let sel = h.state().session.sel.clone();
assert_eq!(sel.anchor, wordcraft_doc::Pos::body(0, 0), "the selection still starts above the table");
assert!(!sel.is_collapsed());
assert!(sel.focus.path.cell().is_some_and(|(_, r, _)| r == 1), "and runs into the clicked row: {:?}", sel.focus);
}
/// An ordinary drag from one cell's text into another's selects the rectangle of whole cells
/// between them as the pointer goes: no undo step, no mini toolbar over the cells.
#[test]
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -1
View File
File diff suppressed because one or more lines are too long