mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 10:27:44 -06:00
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:
co-authored by
Claude Sonnet 5.5
Dmitrii Khlapov
Brandon Thomas
parent
fa5992223f
commit
38931c34be
+3
-1
File diff suppressed because one or more lines are too long
@@ -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]
|
||||
|
||||
@@ -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]]}}}),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user