Files
wordcraft/crates/engine/src/cmd/table_mouse.rs
T
f24a5b22b2 Tables: drags and Shift+arrows over cells select whole cells (#722)
* Tables: select, insert and move with the mouse; double-click a border to AutoFit

Like Word, on top of the border resizing:

- Selection areas: just above a column the pointer becomes a down arrow,
  left of a row the row arrow, along a cell's left edge (inside it) the
  cell arrow. A click selects the column, row or cell; a drag selects every
  one up to the pointer. Selected cells are shaded as cells, empty ones too.
- Insert buttons: left of a row border and above a column border a "+"
  shows with the border marked; a click adds a row or column there and puts
  the caret in it.
- Move handle past the table's top-left corner: a click selects the table;
  a drag shows a dashed outline and, on release, the table floats where it
  is dropped with the text wrapping around it (Word's behaviour). One undo
  step. Nested tables and tables outside the main text are only selected.
- A double-click on a column border fits the column left of it to its
  contents (the right neighbour gives or takes the room; on the right edge
  the table's width changes, up to the right margin).
- Table Layout > Select > Select Column (catalog 418/449).

Whole-cell selection: a column (or a rectangle of cells over several rows)
is not one run of the text, so selecting it makes a block of its cells'
paragraphs (`CellRange` on the existing `ColumnBlock`). Bold, alignment,
Delete, typing, copy and numbering act on those cells only (a numbered
column is one list); table commands (shading, merge, delete columns) take
the rectangle; Tab moves on without clearing it; Undo brings the selection
back. Cells of one row, or whole rows, stay an ordinary stream selection.
The format painter no longer clears a block's text (edit.pasteFormat was
handled like a paste). The mini toolbar stays hidden over whole-cell
selections and during these drags: pinned above the selection it covered
the column areas, the "+" buttons and the move handle.

Engine: crates/engine/src/cmd/table_mouse.rs - table.selectAt,
table.insertAt, table.moveDrag, table.fitColumn (page coordinates, so
agents and the control channel can do the same) and table.selectColumn;
hit testing reuses table_resize's drawn geometry, and borders keep priority
for resizing. 10 engine tests (areas, a column edited cell by cell,
numbering, rows/cells/drags, insert buttons, move, fit, format painter,
hostile params).
UI: crates/ui-egui/src/table_mouse.rs, wired into the existing canvas mouse
handler (no extra ui.interact areas, so clicks in a cell's text still place
the caret); kittest tests with real pointer events: column click, row drag,
"+" button, move-handle drag in one undo step, double-click fit.
New labels translated in all eleven catalogs. Progress docs updated: gaps,
ui-parity, target-app-parity, ROADMAP, parity checklist.
`cargo xtask ci` passes (fmt, clippy, test, assets, layers, wasm).

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

* Tables: a selected column's word count is its cells'; merged cells shade once

With whole cells selected (a column, or a rectangle that isn't one run of
the text) the status bar said "12 of 23 words": `Session::selected_text`
took everything between the selection's ends. It now takes each piece's
own text, a line each, for a column block too, so the status bar, Word
Count and the other callers see what is selected.

Since #669 a vertically merged cell is one box as tall as its rows, with a
`covered` box for each continuation row; shading selected cells skips
those, so a merged cell isn't shaded twice. `selected_paths` follows
main's `para_paths_in` (#418): a selection ending at the start of the next
paragraph doesn't take it.

- Test: a selected column's text is its three cells' and Word Count says
  three words.

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

* Tables: double-click AutoFit and the "+" buttons work as in Word

Checked against Word (2019, Windows):

- A double-click on a column border fits the column left of it to its
  contents and the columns right of it keep their widths, moving with the
  border, so the table narrows or widens (up to the right margin). The
  right neighbour used to give or take the difference.
- A "+" insert button selects the new row or column (the caret used to
  go into it); typing then goes into its first cell. If the table can't
  take another row or column, nothing changes.
- A double-click along a cell's left edge (the cell selection area)
  selects its row.

- Tests: engine (fit keeps the columns on the right; insert selects the
  new row or column, typing lands in its first cell) and kittest (real
  double-click on a border; "+" selects the row; double-click by a cell's
  edge selects the row).

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

* Tables: drags and Shift+arrows over cells select whole cells

An ordinary drag, Shift+click or Shift+arrow from one table cell into
another now selects the rectangle of whole cells between them, as Word
does, instead of the run of text between the two points: bold, alignment
and Delete leave the cells outside the rectangle alone (they already did
for Select Column and the table's selection areas). Between a cell and
text outside its table the selection takes whole rows, from the cell's
row out to the table's edge, and the text beyond. Extending again goes on
from where the selection really started and ends: dragging back into the
first cell selects its text again, Shift+Up after Shift+Down shrinks the
rectangle, and from a nested table's cell into another cell of the table
around it the outer table's cells are taken.

- Engine: `table::extend_selection` (the rule, down through nested
  tables) and `Session::sel_ends` (where a widened selection really
  runs); every caret command run with `extend` goes through it in
  `column::dispatch`, so agents get the same with `caret.*`.
- UI: a text drag extends through `caret.set` (still straight to the
  session, so nothing scrolls on every frame); the view follows the real
  caret.
- Tests: engine (drag across cells and back, nested tables, Shift+Down
  and Shift+Up through and past a table, Shift+click, from text into a
  table) and a kittest with a real mouse drag.

Progress docs updated: gaps (the known gap from #637 is gone),
ui-parity, target-app-parity, ROADMAP. `cargo xtask ci` passes.

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

---------

Co-authored-by: rabis <340973947+rabis7575@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 10:34:10 -04:00

865 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The rest of Word's table mouse (resizing is `table_resize`): selection areas just above a
//! column, left of a row and along a cell's left edge inside it select the column, row or cell,
//! and a drag from one selects every one up to the pointer; insert buttons left of a row border
//! and above a column border add a row or column there; the move handle just past the table's
//! top-left corner selects the table or, dragged, moves it (the table then floats where it's
//! dropped, the text wrapping around it, as in Word); a double-click on a column border fits the
//! column left of it to its contents. The app sends each click or finished drag here in page
//! points, so each is one command (one undo step when it changes the document).
use serde_json::Value;
use wordcraft_doc::para::Anchor;
use wordcraft_doc::props::TableFloat;
use wordcraft_doc::{Document, Path, Pos, StoryRef, Table};
use wordcraft_geom::Rect;
use wordcraft_layout::{DocLayout, Placed};
use super::table_resize::{self, CellBox, Edge, REACH, ResizeTarget};
use super::{sel_result, table};
use crate::{CellRange, CmdError, CmdResult, CommandSpec, Selection, Session, p};
/// The column selection area: from this far above a table's top border (points) to this far
/// below it.
const COLUMN_ABOVE: f32 = 6.0;
const COLUMN_BELOW: f32 = 1.5;
/// How far left of a table (points) the row selection area and the row insert buttons reach.
const ROW_LEFT: f32 = 24.0;
/// The cell selection area's width inside a cell's left edge (points), at the least; it starts
/// past the column border's resize reach.
const CELL_EDGE: f32 = 5.5;
/// The insert buttons answer this near a border (points), and above the table this far out.
const INSERT_REACH: f32 = 3.5;
const INSERT_OUT: f32 = 14.0;
/// An insert button's centre sits this far out from the table's edge (points).
const BUTTON_OUT: f32 = 8.0;
/// The move handle's side and its gap from the table's top-left corner (points).
const HANDLE: f32 = 8.0;
const HANDLE_GAP: f32 = 2.0;
/// The farthest a drag moves a table (points; Word's 22 inches).
const MAX_MOVE: f32 = 1584.0;
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.)"#,
)
.pure(),
CommandSpec::new("table.insertAt", "Insert Row or Column by Clicking", "Table Layout › Rows & Columns", insert_at).params(
r#"{"page": n, "x": pt, "y": pt, "reach"?: pt} (a click on a table's insert button: left of a row border adds a row there, above a column border a column; the new row or column is selected, as in Word)"#,
),
CommandSpec::new("table.moveDrag", "Move Table by Dragging", "Table Layout › Table", move_drag).params(
r#"{"page": n, "x": pt, "y": pt, "toX": pt, "toY": pt, "reach"?: pt} (a drag from the move handle past a table's top-left corner: the table floats where it's dropped, the text wrapping around it; tables in the main text only)"#,
),
CommandSpec::new("table.fitColumn", "AutoFit Column by Double-Clicking", "Table Layout › Cell Size", fit_column).params(
r#"{"page": n, "x": pt, "y": pt, "reach"?: pt} (a double-click on a column border: the column left of it fits its contents and the columns right of it keep their widths, moving with the border, so the table narrows or widens (up to the right margin), as in Word)"#,
),
]
}
/// What the pointer is over.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Spot {
/// The move handle.
Handle,
/// The insert button of horizontal border `n` (0 = the table's top, `n` = the bottom of row
/// `n - 1`).
InsertRow(usize),
/// The insert button of grid line `n` (0 = the table's left edge).
InsertColumn(usize),
/// The selection area above grid column `n`.
Column(usize),
/// The selection area left of row `n`.
Row(usize),
/// The selection area along cell (`row`, `cell`)'s left edge.
Cell(usize, usize),
}
/// A spot and the table's drawn geometry on the page (`geom.edge` means nothing here).
#[derive(Clone, Debug, PartialEq)]
pub struct Target {
pub spot: Spot,
pub geom: ResizeTarget,
}
/// An insert button: its centre and the border it inserts at, end to end (page points).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InsertMark {
pub center: (f32, f32),
pub from: (f32, f32),
pub to: (f32, f32),
}
/// The move handle's square (page points), just past the table's top-left corner.
pub fn handle(geom: &ResizeTarget) -> Rect {
Rect::new(geom.left() - HANDLE_GAP - HANDLE, geom.top() - HANDLE_GAP - HANDLE, HANDLE, HANDLE)
}
/// Whether the table's first row is on this page (its move handle is there).
fn starts_here(geom: &ResizeTarget) -> bool {
geom.rows.first().is_some_and(|r| r.row == 0)
}
fn on_handle(geom: &ResizeTarget, x: f32, y: f32, reach: f32) -> bool {
let h = handle(geom);
starts_here(geom) && x >= h.x - reach / 2.0 && x <= h.right() + reach / 2.0 && y >= h.y - reach / 2.0 && y <= h.bottom() + reach / 2.0
}
fn clean_reach(reach: f32) -> f32 {
if reach.is_finite() { reach.clamp(0.5, 24.0) } else { REACH }
}
/// Of `lines` (index, position), the one nearest `at` within `reach`.
fn nearest(lines: impl Iterator<Item = (usize, f32)>, at: f32, reach: f32) -> Option<usize> {
lines.map(|(i, v)| (i, (at - v).abs())).filter(|(_, d)| *d <= reach).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
}
/// The grid column at page x `x` (the first or the last beyond the table).
fn column_at(geom: &ResizeTarget, x: f32) -> usize {
let n = geom.cols.len().saturating_sub(1);
geom.cols.iter().skip(1).take_while(|cx| **cx <= x).count().min(n.saturating_sub(1))
}
/// The drawn row at page y `y` (the first or the last beyond the table).
fn row_at(geom: &ResizeTarget, y: f32) -> usize {
geom.rows.iter().find(|r| y < r.bottom).or(geom.rows.last()).map_or(0, |r| r.row)
}
/// 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> {
if on_handle(geom, x, y, reach) {
return Some(Spot::Handle);
}
let (left, right, top, bottom) = (geom.left(), geom.right(), geom.top(), geom.bottom());
// Above the table: the insert buttons over its column borders, and the column areas.
if y >= top - INSERT_OUT && y <= top + COLUMN_BELOW && x >= left - INSERT_REACH && x <= right + INSERT_REACH {
let lines = geom.cols.iter().copied().enumerate().filter(|(i, _)| geom.rows.iter().any(|r| r.edges.contains(i)));
if y < top
&& let Some(line) = nearest(lines, x, INSERT_REACH)
{
return Some(Spot::InsertColumn(line));
}
if y >= top - COLUMN_ABOVE && x >= left && x <= right {
return Some(Spot::Column(column_at(geom, x)));
}
}
// Left of the table: the insert buttons beside its row borders, and the row areas.
if x >= left - ROW_LEFT && x < left && y >= top - INSERT_REACH && y <= bottom + INSERT_REACH {
let borders = geom.rows.iter().flat_map(|r| [(r.row, r.top), (r.row + 1, r.bottom)]);
if let Some(line) = nearest(borders, y, INSERT_REACH) {
return Some(Spot::InsertRow(line));
}
if 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 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).
pub fn target_at(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x: f32, y: f32, reach: f32) -> Option<Target> {
if !x.is_finite() || !y.is_finite() {
return None;
}
let reach = clean_reach(reach);
if table_resize::resize_target(l, doc, page, x, y, reach).is_some() {
return None;
}
let boxes = table_resize::cell_boxes(l, page);
table_resize::tables_on(l, doc, page)
.into_iter()
.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 })
})
.max_by_key(|t| t.geom.table.0.len())
}
/// The table whose move handle shows with the pointer at (`x`, `y`): the innermost table of
/// `story` starting on this page that the pointer is over, or on its way to the handle.
pub fn handle_table(l: &DocLayout, doc: &Document, story: StoryRef, page: usize, x: f32, y: f32, reach: f32) -> Option<ResizeTarget> {
if !x.is_finite() || !y.is_finite() {
return None;
}
let pad = clean_reach(reach) / 2.0;
table_resize::tables_on(l, doc, page)
.into_iter()
.filter(|g| {
let h = handle(g);
g.story == story && starts_here(g) && x >= h.x - pad && x <= g.right() && y >= h.y - pad && y <= g.bottom()
})
.max_by_key(|g| g.table.0.len())
}
/// The insert button a target is, if it's one.
pub fn insert_mark(t: &Target) -> Option<InsertMark> {
let g = &t.geom;
match t.spot {
Spot::InsertRow(line) => {
// The bottom of the row above the border, or the top of the first row.
let y = g.rows.iter().find(|r| r.row + 1 == line).map(|r| r.bottom).or_else(|| g.rows.iter().find(|r| r.row == line).map(|r| r.top))?;
Some(InsertMark { center: (g.left() - BUTTON_OUT, y), from: (g.left(), y), to: (g.right(), y) })
}
Spot::InsertColumn(line) => {
let x = g.cols.get(line).copied()?;
Some(InsertMark { center: (x, g.top() - BUTTON_OUT), from: (x, g.top()), to: (x, g.bottom()) })
}
_ => None,
}
}
/// Whether dragging the move handle moves the table: tables in the main text, not nested ones.
pub fn movable(geom: &ResizeTarget) -> bool {
geom.story == StoryRef::Body && geom.table.0.len() == 1
}
/// Where a move-handle drag by (`dx`, `dy`) puts the table's top-left corner on `page`: on the
/// page, its part there above the bottom margin.
pub fn moved_to(l: &DocLayout, page: usize, geom: &ResizeTarget, dx: f32, dy: f32) -> (f32, f32) {
let clean = |d: f32| if d.is_finite() { d.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 };
let (w, h) = (geom.right() - geom.left(), geom.bottom() - geom.top());
let (page_w, body_bottom) = l.pages.get(page).map_or((0.0, 0.0), |pg| (pg.w, pg.body.bottom()));
let x = (geom.left() + clean(dx)).clamp(0.0, (page_w - w).max(0.0));
let y = (geom.top() + clean(dy)).clamp(0.0, (body_bottom - h).max(0.0));
(x, y)
}
/// Where the selected cells are drawn (page, rect), for shading them.
pub fn cell_rects(l: &DocLayout, doc: &Document, range: &CellRange) -> Vec<(usize, Rect)> {
let Some(t) = doc.table(range.story, &range.table) else { return Vec::new() };
let picked: std::collections::BTreeSet<(usize, usize)> = range.cells(t).into_iter().collect();
let mut out = Vec::new();
for (pi, pg) in l.pages.iter().enumerate() {
for it in &pg.items {
// A vertically merged cell's box spans its rows (#640); its continuation rows'
// `covered` boxes would shade it twice.
if let Placed::Cell { rect, table, row, cell, story, covered: false } = it
&& *story == range.story
&& *table == range.table
&& picked.contains(&(*row, *cell))
{
out.push((pi, *rect));
}
}
}
out
}
/// The click: `page`, `x`, `y` and the resize `reach`.
fn point(v: &Value) -> Result<(usize, f32, f32, f32), CmdError> {
let page =
p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into()))?;
Ok((page, p::req_f32(v, "x")?, p::req_f32(v, "y")?, p::f32(v, "reach").unwrap_or(REACH)))
}
/// The spot under the click, in the caret's story.
fn target(s: &mut Session, page: usize, x: f32, y: f32, reach: f32) -> Result<Target, CmdError> {
let l = s.layout();
if l.pages.get(page).is_none() {
return Err(CmdError::Params(format!("no page {page}")));
}
target_at(&l, &s.doc, s.sel.focus.story, page, x, y, reach)
.ok_or_else(|| CmdError::Failed("there's no table selection area or button there".into()))
}
fn select_at(s: &mut Session, v: &Value) -> CmdResult {
let (page, x, y, reach) = point(v)?;
let Target { spot, geom } = target(s, page, x, y, reach)?;
let to = p::f32(v, "toX").zip(p::f32(v, "toY"));
let story = geom.story;
let t = s.doc.table(story, &geom.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
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::Cell(r, c) => {
let (g0, g1) = table::grid_cols(t, r, c);
match to {
Some((tx, ty)) => {
let g = column_at(&geom, tx);
((r, row_at(&geom, ty)), (g0.min(g), g1.max(g)))
}
None => ((r, r), (g0, g1)),
}
}
Spot::Handle => ((0, last_row), (0, last_col)),
Spot::InsertRow(_) | Spot::InsertColumn(_) => return Err(CmdError::Failed("there's an insert button there, not a selection area".into())),
};
table::select_range(s, story, &geom.table, rows, cols)
}
/// 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)> {
t.rows.iter().enumerate().find_map(|(r, row)| {
let mut g = 0usize;
for (c, cell) in row.cells.iter().enumerate() {
if g == line {
return Some((r, c, false));
}
g = g.saturating_add(cell.span());
if g == line && c + 1 == row.cells.len() {
return Some((r, c, true));
}
}
None
})
}
fn insert_at(s: &mut Session, v: &Value) -> CmdResult {
let (page, x, y, reach) = point(v)?;
let Target { spot, geom } = target(s, page, x, y, reach)?;
let story = geom.story;
// The row or column goes beside a cell at the border, as Insert Above / Below / Left / Right
// would put it there, so it takes the border's index (`line`); then, as in Word, it's selected.
let (r, c, after, rows, line) = match spot {
Spot::InsertRow(line) => match line.checked_sub(1) {
Some(above) => (above, 0, true, true, line),
None => (0, 0, false, true, line),
},
Spot::InsertColumn(line) => {
let t = s.doc.table(story, &geom.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
let (r, c, after) = beside_line(t, line).ok_or_else(|| CmdError::Failed("there's no column border there".into()))?;
(r, c, after, false, line)
}
_ => return Err(CmdError::Failed("there's a selection area there, not an insert button".into())),
};
let size = |s: &Session| s.doc.table(story, &geom.table).map(|t| (t.rows.len(), t.cols()));
let before = size(s);
let mut path = geom.table.0.clone();
path.extend([r as u32, c as u32, 0]);
s.column = None;
s.sel = Selection::caret(Pos { story, path: Path(path), off: 0 });
if rows {
table::row(s, after)?;
} else {
table::col(s, after)?;
}
if size(s) == before {
return Err(CmdError::Failed("the table can't take another row or column".into()));
}
let (sel_rows, sel_cols) = if rows { ((line, line), (0, usize::MAX)) } else { ((0, usize::MAX), (line, line)) };
table::select_range(s, story, &geom.table, sel_rows, sel_cols)
}
/// Where table `table` of `story` is first drawn: its page and top-left corner.
fn corner(l: &DocLayout, story: StoryRef, table: &Path) -> Option<(usize, f32, f32)> {
l.pages.iter().enumerate().find_map(|(pi, pg)| {
let cells = pg.items.iter().filter_map(|it| match it {
Placed::Cell { rect, table: t, story: st, .. } if *st == story && t == table => Some(*rect),
_ => None,
});
cells.fold(None, |acc: Option<(f32, f32)>, r| Some(acc.map_or((r.x, r.y), |(x, y)| (x.min(r.x), y.min(r.y))))).map(|(x, y)| (pi, x, y))
})
}
fn move_drag(s: &mut Session, v: &Value) -> CmdResult {
let (page, x, y, reach) = point(v)?;
let (to_x, to_y) = (p::req_f32(v, "toX")?, p::req_f32(v, "toY")?);
let Target { spot, geom } = target(s, page, x, y, reach)?;
if spot != Spot::Handle {
return Err(CmdError::Failed("there's no table move handle there".into()));
}
if !movable(&geom) {
return Err(CmdError::Failed("only a table in the main text can be moved".into()));
}
let (gx, gy) = moved_to(&s.layout(), page, &geom, to_x - x, to_y - y);
if (gx - geom.left()).abs() < 0.01 && (gy - geom.top()).abs() < 0.01 {
return sel_result(s);
}
// Dragged, a table floats where it's dropped, the text wrapping around it (Word): placed from
// its column and the text where it stands, 0.125" from the text at its sides.
let t = s.doc.table_mut(geom.story, &geom.table)?;
let mut f = t.props.float.unwrap_or(TableFloat {
h_rel: Anchor::Column,
v_rel: Anchor::Paragraph,
dist: [9.0, 0.0, 9.0, 0.0],
overlap: true,
..Default::default()
});
f.h_align = None;
f.v_align = None;
t.props.float = Some(f);
table_resize::touch(s, &geom)?;
// Placed by offsets, the table moves as much as they do: one correction from where it landed
// puts it where it was dropped.
if let Some((p0, x0, y0)) = corner(&s.layout(), geom.story, &geom.table)
&& p0 == page
{
let fix = |v: f32| if v.is_finite() { v.clamp(-MAX_MOVE, MAX_MOVE) } else { 0.0 };
let t = s.doc.table_mut(geom.story, &geom.table)?;
if let Some(f) = t.props.float.as_mut() {
f.x = fix(f.x + gx - x0);
f.y = fix(f.y + gy - y0);
}
table_resize::touch(s, &geom)?;
}
sel_result(s)
}
fn fit_column(s: &mut Session, v: &Value) -> CmdResult {
let (page, x, y, reach) = point(v)?;
let l = s.layout();
let Some((target, line)) = table_resize::resize_target(&l, &s.doc, page, x, y, reach).and_then(|t| match t.edge {
Edge::Column(line) => Some((t, line)),
_ => None,
}) else {
return Err(CmdError::Failed("there's no column border there".into()));
};
let t = s.doc.table(target.story, &target.table).ok_or_else(|| CmdError::Failed("there's no table there".into()))?;
let g = line.checked_sub(1).ok_or_else(|| CmdError::Failed("there's no column left of that border".into()))?;
let (narrowest, widest) =
wordcraft_layout::measure_table_columns(&s.doc, t).get(g).copied().ok_or_else(|| CmdError::Failed("there's no such column".into()))?;
let current = target.widths().get(g).copied().unwrap_or(0.0);
// As in Word, the columns right of it keep their widths and move with the border, so the
// table narrows or widens: up to the right margin, never below the column's narrowest content.
let room = current + l.pages.get(page).map_or(0.0, |pg| (pg.body.right() - target.right()).max(0.0));
let want = widest.min(room.max(narrowest));
if let Some(w) = target.plan(want - current, 0.0, true).widths {
let t = s.doc.table_mut(target.story, &target.table)?;
table_resize::set_widths(t, w);
table_resize::touch(s, &target)?;
}
sel_result(s)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use wordcraft_doc::props::Align;
fn table_session(rows: usize, cols: usize) -> Session {
let mut s = Session::new(Document::new());
s.run("insert.table", &json!({"rows": rows, "cols": cols})).unwrap();
s
}
fn table_path(s: &Session) -> Path {
Path(vec![s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap() as u32])
}
fn first_table(s: &Session) -> Table {
s.doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap()
}
fn cell_pos(s: &Session, r: usize, c: usize) -> Pos {
let mut path = table_path(s).0;
path.extend([r as u32, c as u32, 0]);
Pos { story: StoryRef::Body, path: Path(path), off: 0 }
}
/// Type "r{row}c{cell}" into every cell.
fn fill(s: &mut Session) {
let t = first_table(s);
for (r, row) in t.rows.iter().enumerate() {
for c in 0..row.cells.len() {
s.sel = Selection::caret(cell_pos(s, r, c));
s.run("text.insert", &json!({"text": format!("r{r}c{c}")})).unwrap();
}
}
}
fn para(s: &Session, r: usize, c: usize) -> wordcraft_doc::Paragraph {
s.doc.para(StoryRef::Body, &cell_pos(s, r, c).path).cloned().unwrap()
}
fn bold(s: &Session, r: usize, c: usize) -> bool {
let p = para(s, r, c);
if p.is_empty() { p.mark.bold == Some(true) } else { p.run_ranges().all(|(_, ch)| ch.bold == Some(true)) }
}
fn cell_rect(s: &mut Session, row: usize, cell: usize) -> Rect {
s.layout().pages[0]
.items
.iter()
.find_map(|it| match it {
Placed::Cell { rect, row: r, cell: c, .. } if *r == row && *c == cell => Some(*rect),
_ => None,
})
.unwrap()
}
fn spot_at(s: &mut Session, x: f32, y: f32) -> Option<Spot> {
let l = s.layout();
target_at(&l, &s.doc, StoryRef::Body, 0, x, y, REACH).map(|t| t.spot)
}
fn mid_y(r: Rect) -> f32 {
r.y + r.h / 2.0
}
/// The areas around and in a table: column, row and cell selection areas, the insert buttons
/// and the move handle; borders stay the resize module's, the text the caret's.
#[test]
fn the_areas_of_a_table_are_found() {
let mut s = table_session(3, 3);
let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1));
let (left, top) = (a.x, a.y);
assert_eq!(spot_at(&mut s, b.x + b.w / 2.0, top - 3.0), Some(Spot::Column(1)));
assert_eq!(spot_at(&mut s, b.x + b.w / 2.0, top + 1.0), Some(Spot::Column(1)));
let r1 = cell_rect(&mut s, 1, 0);
assert_eq!(spot_at(&mut s, left - 10.0, mid_y(r1)), Some(Spot::Row(1)));
let c = cell_rect(&mut s, 1, 2);
assert_eq!(spot_at(&mut s, c.x + 4.5, mid_y(c)), Some(Spot::Cell(1, 2)));
assert_eq!(spot_at(&mut s, r1.x + 2.0, mid_y(r1)), Some(Spot::Cell(1, 0)), "the first column's edge has no resize border");
assert_eq!(spot_at(&mut s, c.x + 20.0, mid_y(c)), None, "the text is the caret's");
assert_eq!(spot_at(&mut s, b.x, mid_y(a)), None, "a column border resizes");
assert_eq!(spot_at(&mut s, b.x, top - 8.0), Some(Spot::InsertColumn(1)));
assert_eq!(spot_at(&mut s, left - 10.0, a.bottom()), Some(Spot::InsertRow(1)));
assert_eq!(spot_at(&mut s, left - 10.0, top), Some(Spot::InsertRow(0)));
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
assert_eq!(spot_at(&mut s, h.x + h.w / 2.0, h.y + h.h / 2.0), Some(Spot::Handle));
// Another story's tables aren't in reach.
let l = s.layout();
assert!(target_at(&l, &s.doc, StoryRef::Part(7), 0, b.x + b.w / 2.0, top - 3.0, REACH).is_none());
}
/// A selected column's text is its own cells', so the status bar and Word Count count only
/// them, not the cells between its ends in the text.
#[test]
fn a_selected_columns_text_is_its_cells() {
let mut s = table_session(3, 3);
fill(&mut s);
let b = cell_rect(&mut s, 0, 1);
s.run("table.selectAt", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y - 3.0})).unwrap();
assert_eq!(s.selected_text(), "r0c1\nr1c1\nr2c1");
assert_eq!(s.run("review.wordCount", &json!({})).unwrap()["words"], 3);
}
/// A click above a column selects just its cells: formatting, alignment and Delete leave the
/// cells between its ends alone, each one undo step; table commands take the column.
#[test]
fn a_selected_column_is_edited_cell_by_cell() {
let mut s = table_session(3, 3);
fill(&mut s);
let b = cell_rect(&mut s, 0, 1);
s.run("table.selectAt", &json!({"page": 0, "x": b.x + b.w / 2.0, "y": b.y - 3.0})).unwrap();
let range = s.cell_range().cloned().unwrap();
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (1, 1), false));
assert_eq!(s.column_segments().unwrap().len(), 3);
s.run("format.bold", &json!({})).unwrap();
for r in 0..3 {
assert!(bold(&s, r, 1), "row {r} of the column is bold");
assert!(!bold(&s, r, 0) && !bold(&s, r, 2), "row {r}'s other cells aren't");
}
s.run("para.alignCenter", &json!({})).unwrap();
assert_eq!(para(&s, 1, 1).props.align, Some(Align::Center));
assert_eq!(para(&s, 0, 2).props.align, None, "the cells between the corners keep theirs");
assert!(s.cell_range().is_some(), "formatting keeps the selection");
s.run("table.shading", &json!({"color": "FF0000"})).unwrap();
let t = first_table(&s);
assert!(t.rows.iter().all(|row| row.cells[1].props.shading.is_some() && row.cells[0].props.shading.is_none()));
let depth = s.undo_depth();
s.run("text.delete", &json!({})).unwrap();
assert_eq!(s.undo_depth(), depth + 1);
for r in 0..3 {
assert!(para(&s, r, 1).is_empty());
assert_eq!(para(&s, r, 0).plain_text(), format!("r{r}c0"));
assert_eq!(para(&s, r, 2).plain_text(), format!("r{r}c2"));
}
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(para(&s, 2, 1).plain_text(), "r2c1");
assert_eq!(s.cell_range().map(|r| r.cols), Some((1, 1)), "undo brings the column selection back");
}
/// Numbering a column numbers its cells as one list, like Word.
#[test]
fn numbering_a_column_makes_one_list() {
let mut s = table_session(3, 2);
fill(&mut s);
s.sel = Selection::caret(cell_pos(&s, 1, 0));
s.run("table.selectColumn", &json!({})).unwrap();
s.run("para.numbering", &json!({})).unwrap();
let nums: Vec<_> = (0..3).map(|r| para(&s, r, 0).props.numbering.map(|n| n.num)).collect();
assert!(nums[0].is_some_and(|n| n != 0) && nums.iter().all(|n| *n == nums[0]), "{nums:?}");
assert!((0..3).all(|r| para(&s, r, 1).props.numbering.is_none()));
}
/// An empty column's cells take character formatting in their marks; Tab moves on without
/// clearing a selected column; a click in place ends a selected empty cell.
#[test]
fn empty_cells_tab_and_clicks() {
let mut s = table_session(2, 2);
s.sel = Selection::caret(cell_pos(&s, 0, 0));
s.run("table.selectColumn", &json!({})).unwrap();
s.run("format.bold", &json!({})).unwrap();
assert!(bold(&s, 0, 0) && bold(&s, 1, 0) && !bold(&s, 0, 1) && !bold(&s, 1, 1));
fill(&mut s);
s.sel = Selection::caret(cell_pos(&s, 0, 1));
s.run("table.selectColumn", &json!({})).unwrap();
s.run("text.tab", &json!({})).unwrap();
assert_eq!(para(&s, 0, 1).plain_text(), "r0c1");
assert_eq!(para(&s, 1, 1).plain_text(), "r1c1");
// A selected empty cell's corners are one place; clicking there ends the selection.
let mut s = table_session(2, 2);
let c = cell_rect(&mut s, 1, 1);
s.run("table.selectAt", &json!({"page": 0, "x": c.x + 4.5, "y": mid_y(c)})).unwrap();
assert!(s.cell_range().is_some() && s.sel.is_collapsed());
let at = serde_json::to_value(s.sel.focus.clone()).unwrap();
s.run("caret.set", &json!({"pos": at})).unwrap();
assert!(s.cell_range().is_none());
}
/// Rows, cells and drags: whole rows and cells of one row are an ordinary stream selection;
/// a drag over cells of several rows selects their rectangle only.
#[test]
fn rows_cells_and_drags_select_rectangles() {
let mut s = table_session(3, 3);
fill(&mut s);
let r1 = cell_rect(&mut s, 1, 0);
s.run("table.selectAt", &json!({"page": 0, "x": r1.x - 10.0, "y": mid_y(r1)})).unwrap();
let range = s.cell_range().cloned().unwrap();
assert_eq!((range.rows, range.cols, range.stream), ((1, 1), (0, 2), true));
assert!(s.column_segments().is_none(), "a row is one run of the text");
let r2 = cell_rect(&mut s, 2, 0);
s.run("table.selectAt", &json!({"page": 0, "x": r1.x - 10.0, "y": mid_y(r1), "toX": r1.x - 10.0, "toY": mid_y(r2) + 50.0})).unwrap();
assert_eq!(s.cell_range().map(|r| r.rows), Some((1, 2)));
// From cell (0, 0) to cell (1, 1): four cells, not the row between.
let (a, d) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 1));
s.run("table.selectAt", &json!({"page": 0, "x": a.x + 2.0, "y": mid_y(a), "toX": d.x + d.w / 2.0, "toY": mid_y(d)})).unwrap();
let range = s.cell_range().cloned().unwrap();
assert_eq!((range.rows, range.cols, range.stream), ((0, 1), (0, 1), false));
s.run("format.italic", &json!({})).unwrap();
let italic = |s: &Session, r, c| para(s, r, c).run_ranges().all(|(_, ch)| ch.italic == Some(true));
assert!(italic(&s, 0, 0) && italic(&s, 1, 1) && !italic(&s, 0, 2) && !italic(&s, 1, 2));
// Dragging across columns: from the first to the last is every column, all rows.
let (c0, c2) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 2));
s.run("table.selectAt", &json!({"page": 0, "x": c0.x + 20.0, "y": c0.y - 3.0, "toX": c2.right() + 30.0, "toY": c0.y})).unwrap();
let range = s.cell_range().cloned().unwrap();
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (0, 2), true));
// The move handle selects the table.
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
s.run("table.selectAt", &json!({"page": 0, "x": h.x + 4.0, "y": h.y + 4.0})).unwrap();
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 2), (0, 2))));
// The ribbon's Select Row is the same whole-cell selection.
s.sel = Selection::caret(cell_pos(&s, 2, 1));
s.run("table.selectRow", &json!({})).unwrap();
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols, r.stream)), Some(((2, 2), (0, 2), true)));
}
/// 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]
fn the_insert_buttons_add_rows_and_columns() {
let mut s = table_session(2, 3);
fill(&mut s);
let a = cell_rect(&mut s, 0, 0);
s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.bottom()})).unwrap();
let t = first_table(&s);
assert_eq!(t.rows.len(), 3);
assert!(para(&s, 1, 0).is_empty() && para(&s, 2, 0).plain_text() == "r1c0", "the new row is the second");
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 1), (0, 2))), "it's selected");
s.run("text.insert", &json!({"text": "x"})).unwrap();
assert_eq!(para(&s, 1, 0).plain_text(), "x");
assert_eq!((first_table(&s).rows.len(), para(&s, 2, 0).plain_text()), (3, "r1c0".to_string()));
s.run("edit.undo", &json!({})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(first_table(&s).rows.len(), 2);
// Above the first row.
s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.y})).unwrap();
assert!(para(&s, 0, 0).is_empty() && para(&s, 1, 0).plain_text() == "r0c0");
assert_eq!(s.cell_range().map(|r| r.rows), Some((0, 0)));
s.run("edit.undo", &json!({})).unwrap();
// A column at the border between the first two, the table keeping its width.
let width: f32 = first_table(&s).grid.iter().sum();
let b = cell_rect(&mut s, 0, 1);
s.run("table.insertAt", &json!({"page": 0, "x": b.x, "y": a.y - 8.0})).unwrap();
let t = first_table(&s);
assert_eq!(t.cols(), 4);
assert!(para(&s, 0, 1).is_empty() && para(&s, 0, 2).plain_text() == "r0c1");
assert!((t.grid.iter().sum::<f32>() - width).abs() < 0.5);
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (1, 1))), "it's selected");
// And at the right edge.
let e = cell_rect(&mut s, 0, 3);
s.run("table.insertAt", &json!({"page": 0, "x": e.right(), "y": a.y - 8.0})).unwrap();
assert_eq!(first_table(&s).cols(), 5);
assert!(para(&s, 0, 4).is_empty() && para(&s, 0, 3).plain_text() == "r0c2");
assert_eq!(s.cell_range().map(|r| r.cols), Some((4, 4)));
}
/// Dragging the move handle floats the table where it's dropped; one undo puts it back; a
/// click on the handle moves nothing.
#[test]
fn dragging_the_handle_moves_the_table() {
let mut s = table_session(2, 2);
let a = cell_rect(&mut s, 0, 0);
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx, "toY": hy})).unwrap();
assert!(first_table(&s).props.float.is_none(), "a click doesn't move it");
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx + 50.0, "toY": hy + 30.0})).unwrap();
let f = first_table(&s).props.float.unwrap();
assert_eq!((f.h_rel, f.v_rel, f.overlap), (Anchor::Column, Anchor::Paragraph, true));
let moved = cell_rect(&mut s, 0, 0);
assert!((moved.x - (a.x + 50.0)).abs() < 0.5 && (moved.y - (a.y + 30.0)).abs() < 0.5, "{a:?} → {moved:?}");
// Moving it again starts from where it floats.
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
let (hx, hy) = (h.x + h.w / 2.0, h.y + h.h / 2.0);
s.run("table.moveDrag", &json!({"page": 0, "x": hx, "y": hy, "toX": hx - 20.0, "toY": hy + 10.0})).unwrap();
let again = cell_rect(&mut s, 0, 0);
assert!((again.x - (moved.x - 20.0)).abs() < 0.5 && (again.y - (moved.y + 10.0)).abs() < 0.5, "{moved:?} → {again:?}");
s.run("edit.undo", &json!({})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert!(first_table(&s).props.float.is_none());
assert_eq!(cell_rect(&mut s, 0, 0), a);
// Nested tables and tables outside the main text stay where they are.
let mut nested = table_resize::tables_on(&s.layout(), &s.doc, 0)[0].clone();
assert!(movable(&nested));
nested.table = Path(vec![0, 0, 0, 1]);
assert!(!movable(&nested));
}
/// A double-click on a column border fits the column left of it to its text; as in Word, the
/// columns right of it keep their widths and move along, so the table narrows. The right
/// edge's column fits the same way.
#[test]
fn a_double_click_on_a_border_fits_the_column() {
let mut s = table_session(1, 3);
s.sel = Selection::caret(cell_pos(&s, 0, 0));
s.run("text.insert", &json!({"text": "ab"})).unwrap();
let (a, b, c) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 2));
let fit = wordcraft_layout::measure_table_columns(&s.doc, &first_table(&s));
s.run("table.fitColumn", &json!({"page": 0, "x": a.right(), "y": mid_y(a)})).unwrap();
let (a1, b1, c1) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1), cell_rect(&mut s, 0, 2));
assert!((a1.w - fit[0].1).abs() < 0.5 && a1.w < a.w - 50.0, "{a:?} → {a1:?}, {fit:?}");
assert!((b1.w - b.w).abs() < 0.5 && (c1.w - c.w).abs() < 0.5, "the columns right of it keep their widths");
assert!((b1.x - b.x - (a1.w - a.w)).abs() < 0.5 && (c1.right() - c.right() - (a1.w - a.w)).abs() < 0.5, "and move along");
s.run("table.fitColumn", &json!({"page": 0, "x": c1.right(), "y": mid_y(c1)})).unwrap();
let c2 = cell_rect(&mut s, 0, 2);
assert!((c2.w - fit[2].1).abs() < 0.5 && c2.w < c.w, "{c:?} → {c2:?}");
}
/// Pasting formatting onto a selected column leaves its text (it used to clear it).
#[test]
fn the_format_painter_keeps_a_selected_columns_text() {
let mut s = table_session(2, 2);
fill(&mut s);
s.sel = Selection::caret(cell_pos(&s, 0, 0));
s.run("edit.formatPainter", &json!({})).unwrap();
s.sel = Selection::caret(cell_pos(&s, 0, 1));
s.run("table.selectColumn", &json!({})).unwrap();
s.run("edit.pasteFormat", &json!({})).unwrap();
assert_eq!(para(&s, 0, 1).plain_text(), "r0c1");
assert_eq!(para(&s, 1, 1).plain_text(), "r1c1");
}
/// Where the app extends the selection while dragging (`caret.set` with `extend`).
fn drag_to(s: &mut Session, to: Pos) {
s.run("caret.set", &json!({"pos": to, "extend": true})).unwrap();
}
/// An ordinary drag from one cell into another selects the rectangle of whole cells between
/// them, so bold takes just those, as in Word; back in the first cell it's text again, from
/// where the drag started. Out of a nested table, the cells are the table's around it.
#[test]
fn a_drag_across_cells_selects_their_rectangle() {
let mut s = table_session(3, 3);
fill(&mut s);
let start = Pos { off: 1, ..cell_pos(&s, 0, 1) };
s.run("caret.set", &json!({"pos": start})).unwrap();
let down = Pos { off: 2, ..cell_pos(&s, 2, 1) };
drag_to(&mut s, down);
let range = s.cell_range().cloned().unwrap();
assert_eq!((range.rows, range.cols, range.stream), ((0, 2), (1, 1), false));
s.run("format.bold", &json!({})).unwrap();
for r in 0..3 {
assert!(bold(&s, r, 1) && !bold(&s, r, 0) && !bold(&s, r, 2), "row {r}: just the dragged column");
}
// On to the left: the rectangle still runs from the cell the drag started in.
let left = cell_pos(&s, 1, 0);
drag_to(&mut s, left);
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1))));
// Back in that cell: its text, from where the drag started.
let back = Pos { off: 3, ..cell_pos(&s, 0, 1) };
drag_to(&mut s, back.clone());
assert!(s.cell_range().is_none());
assert_eq!(s.sel, Selection { anchor: start, focus: back });
// From a nested table's cell into another cell of the table around it.
let mut s = table_session(2, 2);
s.sel = Selection::caret(cell_pos(&s, 0, 0));
s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap();
assert_eq!(s.sel.focus.path.0.len(), 7, "the caret is in the nested table");
let to = cell_pos(&s, 1, 1);
drag_to(&mut s, to);
let range = s.cell_range().cloned().unwrap();
assert_eq!((range.table, range.rows, range.cols), (table_path(&s), (0, 1), (0, 1)));
}
/// Shift+Down from a cell takes it and the cell below, whole; on past the table, the anchor's
/// row and every row after it, then the text; Shift+Up comes back to the cells, then into the
/// first cell's text. Shift+click goes on from where the selection started, and from text
/// before the table a drag into a cell takes the rows down to it, whole.
#[test]
fn shift_arrows_and_clicks_select_cells_then_rows() {
let mut s = Session::new(Document::new());
s.run("text.insert", &json!({"text": "Intro"})).unwrap();
s.run("text.newParagraph", &json!({})).unwrap();
s.run("insert.table", &json!({"rows": 3, "cols": 3})).unwrap();
fill(&mut s);
let after = table_path(&s).last() + 1;
let shift = |s: &mut Session, id: &str| {
s.run(id, &json!({"extend": true})).unwrap();
};
s.sel = Selection::caret(cell_pos(&s, 1, 1));
shift(&mut s, "caret.down");
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1))));
shift(&mut s, "caret.down");
assert!(s.cell_range().is_none());
assert_eq!(s.sel.anchor, cell_pos(&s, 1, 0), "from the start of the anchor's row");
assert_eq!(s.sel.focus.path, Path::top(after));
s.run("format.italic", &json!({})).unwrap();
let italic = |s: &Session, r, c| para(s, r, c).run_ranges().all(|(_, ch)| ch.italic == Some(true));
assert!(italic(&s, 1, 0) && italic(&s, 2, 2) && !italic(&s, 0, 1), "whole rows from the anchor's");
shift(&mut s, "caret.up");
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((1, 2), (1, 1))));
shift(&mut s, "caret.up");
assert!(s.cell_range().is_none());
assert_eq!(s.sel.anchor, cell_pos(&s, 1, 1));
assert_eq!(s.sel.focus.path, cell_pos(&s, 1, 1).path);
// Shift+click: the rectangle from the anchor's cell.
let corner = cell_pos(&s, 0, 0);
s.run("caret.set", &json!({"pos": corner, "extend": true})).unwrap();
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some(((0, 1), (0, 1))));
// From the text before the table into a cell: the rows down to that cell's, whole.
s.sel = Selection::caret(Pos::body(0, 2));
let into = cell_pos(&s, 1, 1);
drag_to(&mut s, into);
assert!(s.cell_range().is_none());
assert_eq!(s.sel, Selection { anchor: Pos::body(0, 2), focus: Pos { off: 4, ..cell_pos(&s, 1, 2) } });
}
/// Clicks away from the areas, and hostile numbers: an error and no change, never a panic.
#[test]
fn misses_and_hostile_params_change_nothing() {
let mut s = table_session(2, 2);
let start = first_table(&s);
let c = cell_rect(&mut s, 1, 1);
let mid = json!({"page": 0, "x": c.x + c.w / 2.0, "y": mid_y(c), "toX": 1, "toY": 1});
for id in ["table.selectAt", "table.insertAt", "table.moveDrag", "table.fitColumn"] {
assert!(s.run(id, &mid).is_err(), "{id}");
assert!(s.run(id, &json!({"page": 99, "x": 1, "y": 1, "toX": 1, "toY": 1})).is_err(), "{id}");
assert!(s.run(id, &json!({"page": 0, "x": 1e30, "y": -1e30, "toX": 1e30, "toY": 1e30, "reach": 1e30})).is_err(), "{id}");
assert!(s.run(id, &json!({})).is_err(), "{id}");
}
// An insert button isn't a selection area, nor a selection area a button.
let a = cell_rect(&mut s, 0, 0);
assert!(s.run("table.selectAt", &json!({"page": 0, "x": a.x - 10.0, "y": a.bottom()})).is_err());
assert!(s.run("table.insertAt", &json!({"page": 0, "x": a.x - 10.0, "y": mid_y(a)})).is_err());
assert_eq!(first_table(&s), start);
// A drag to the ends of the earth keeps the table on its page.
let h = handle(&table_resize::tables_on(&s.layout(), &s.doc, 0)[0]);
s.run("table.moveDrag", &json!({"page": 0, "x": h.x + 4.0, "y": h.y + 4.0, "toX": 3e38, "toY": -3e38})).unwrap();
let f = first_table(&s).props.float.unwrap();
assert!(f.x.is_finite() && f.y.is_finite());
let moved = cell_rect(&mut s, 0, 0);
assert!(moved.x >= -0.5 && moved.right() <= 612.5 && moved.y >= -0.5, "{moved:?}");
}
}