Merge origin/main into spanish-proofing

Resolve the ROADMAP.md and docs/gaps.md status-line conflicts, keeping both
changes.
This commit is contained in:
Federico Olivares
2026-10-23 14:37:36 -03:00
45 changed files with 1015 additions and 34 deletions
+5 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -279,9 +279,13 @@ Mailings|Write & Insert Fields|Match Fields|mailings.matchFields
Mailings|Preview Results|Preview Results|mailings.preview
Mailings|Preview Results|Next Record|mailings.next
Mailings|Preview Results|Previous Record|mailings.previous
Mailings|Preview Results|First Record|mailings.first
Mailings|Preview Results|Last Record|mailings.last
Mailings|Preview Results|Go to Record|mailings.goToRecord
Mailings|Preview Results|Find Recipient|mailings.findRecipient
Mailings|Preview Results|Check for Errors|mailings.checkErrors
Mailings|Finish|Finish & Merge|mailings.finish
Mailings|Finish|Print Documents|mailings.printDocuments
Review|Proofing|Spelling & Grammar|review.spelling
Review|Proofing|Thesaurus|review.thesaurus
Review|Proofing|Word Count|review.wordCount
+105 -5
View File
@@ -439,6 +439,15 @@ pub fn specs() -> Vec<CommandSpec> {
.when(has_recipients),
CommandSpec::new("mailings.next", "Next Record", "Mailings › Preview Results", |s, _| step(s, 1)).when(has_recipients),
CommandSpec::new("mailings.previous", "Previous Record", "Mailings › Preview Results", |s, _| step(s, -1)).when(has_recipients),
CommandSpec::new("mailings.first", "First Record", "Mailings › Preview Results", |s, _| go_to(s, 0)).when(has_recipients),
CommandSpec::new("mailings.last", "Last Record", "Mailings › Preview Results", |s, _| go_to(s, i64::MAX)).when(has_recipients),
CommandSpec::new("mailings.goToRecord", "Go to Record", "Mailings › Preview Results", |s, v| {
let n = v.get("record").and_then(|r| r.as_i64().or_else(|| r.as_str().and_then(|t| t.trim().parse().ok())));
let n = n.ok_or_else(|| CmdError::Params("`record` must be a record number (1 = the first)".into()))?;
go_to(s, n.saturating_sub(1))
})
.params(r#"{"record": n (1 = the first; past either end goes to the first or last record)}"#)
.when(has_recipients),
CommandSpec::new("mailings.findRecipient", "Find Recipient", "Mailings › Preview Results", |s, v| {
let q = p::req_str(v, "text")?.to_lowercase();
let idx = s
@@ -464,9 +473,21 @@ pub fn specs() -> Vec<CommandSpec> {
})
.pure(),
CommandSpec::new("mailings.finish", "Finish & Merge", "Mailings › Finish", finish)
.params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#)
.params(r#"{"path"?: string (save the merged document), "records"?: "all|current" (current: the previewed record; default all, or from/to), "from"?: n, "to"?: n (record numbers, 1 = the first)}"#)
.key(super::non_mac("Alt+Shift+N"))
.when(has_recipients),
CommandSpec::new("mailings.printDocuments", "Print Documents", "Mailings › Finish › Finish & Merge", |s, v| {
let mut v = v.clone();
if let Some(o) = v.as_object_mut() {
o.remove("path");
}
let r = finish(s, &v)?;
s.push_ui_request(json!({"open": "print"}));
Ok(r)
})
.params(r#"{"records"?: "all|current", "from"?: n, "to"?: n} (merges into a new document, as Finish & Merge does, and opens Print for it)"#)
.key(super::non_mac("Alt+Shift+M"))
.when(has_recipients),
CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params(
r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#,
),
@@ -724,8 +745,13 @@ fn header_split(mut all: Vec<Vec<String>>, headers: bool) -> Result<(Vec<String>
}
fn step(s: &mut Session, d: i64) -> CmdResult {
go_to(s, (s.merge.record as i64).saturating_add(d))
}
/// Preview record `idx` (0-based), clamped to the list.
fn go_to(s: &mut Session, idx: i64) -> CmdResult {
let n = s.merge.rows.len().max(1) as i64;
s.merge.record = (s.merge.record as i64 + d).clamp(0, n - 1) as usize;
s.merge.record = idx.clamp(0, n - 1) as usize;
s.merge.preview = true;
refresh(s);
Ok(json!({"record": s.merge.record + 1, "of": n}))
@@ -933,6 +959,23 @@ pub fn refresh(s: &mut Session) {
s.touch();
}
/// The records Finish & Merge and Print Documents take (0-based, end exclusive): `records`
/// all or current, or `from` and `to` by record number. A range with none is an error.
pub fn merge_range(s: &Session, v: &Value) -> Result<(usize, usize), CmdError> {
let n = s.merge.rows.len();
let (from, to) = match p::str(v, "records").map(str::trim) {
Some("current") => (s.merge.record.min(n.saturating_sub(1)), s.merge.record.saturating_add(1).min(n)),
None | Some("all") => {
((p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1), (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize)
}
Some(_) => return Err(CmdError::Params("`records` must be all or current".into())),
};
if from >= to {
return Err(CmdError::Failed(format!("no records from {} to {to} (the list has {n})", from.saturating_add(1))));
}
Ok((from, to))
}
/// Most merged documents' worth of body blocks Finish & Merge builds.
const MAX_MERGED_BLOCKS: usize = 200_000;
@@ -942,9 +985,7 @@ const MAX_MERGED_BLOCKS: usize = 200_000;
/// are merged too: with merge fields in them, each letter becomes its own section with its own
/// header/footer copies.
fn finish(s: &mut Session, v: &Value) -> CmdResult {
let n = s.merge.rows.len();
let from = (p::u64(v, "from").unwrap_or(1).max(1) as usize).saturating_sub(1);
let to = (p::u64(v, "to").unwrap_or(n as u64).min(n as u64)) as usize;
let (from, to) = merge_range(s, v)?;
let directory = s.merge.kind == "directory";
let hf: Vec<u32> =
header_footer_ids(&s.doc).into_iter().filter(|id| s.doc.story(StoryRef::Part(*id)).is_some_and(|b| has_merge_fields(b, 0))).collect();
@@ -1580,6 +1621,65 @@ mod tests {
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha / Beta\nGamma / ");
}
/// #3028: First, Last and Go to Record move the preview; Finish & Merge and Print Documents
/// take all records, the current one, or a range by record number.
#[test]
fn record_navigation_and_merge_ranges() {
let names = ["Alpha", "Beta", "Gamma", "Delta"];
let fresh = || {
let mut s = Session::new(Document::new());
s.run("mailings.recipients", &json!({"rows": names.map(|n| json!({"Name": n}))})).unwrap();
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
s.run("mailings.insertField", &json!({"field": "Name"})).unwrap();
s
};
let mut s = fresh();
let body = |s: &Session| s.doc.plain_text(StoryRef::Body);
assert_eq!(s.run("mailings.last", &json!({})).unwrap(), json!({"record": 4, "of": 4}));
assert_eq!(body(&s), "Delta");
assert_eq!(s.run("mailings.first", &json!({})).unwrap()["record"], 1);
assert_eq!(body(&s), "Alpha");
assert_eq!(s.run("mailings.goToRecord", &json!({"record": 3})).unwrap()["record"], 3);
assert_eq!(body(&s), "Gamma");
assert_eq!(s.run("mailings.goToRecord", &json!({"record": " 2 "})).unwrap()["record"], 2);
// Past either end: the last or first record, as the step buttons do.
assert_eq!(s.run("mailings.goToRecord", &json!({"record": 1141})).unwrap()["record"], 4);
assert_eq!(s.run("mailings.goToRecord", &json!({"record": -5})).unwrap()["record"], 1);
for bad in [json!({}), json!({"record": "two"}), json!({"record": 2.5})] {
assert!(matches!(s.run("mailings.goToRecord", &bad), Err(CmdError::Params(_))), "{bad}");
}
s.run("mailings.goToRecord", &json!({"record": 3})).unwrap();
assert_eq!(s.run("mailings.finish", &json!({"records": "current"})).unwrap()["records"], 1);
assert_eq!(body(&s), "Gamma");
let mut s = fresh();
assert_eq!(s.run("mailings.finish", &json!({"from": 2, "to": 3})).unwrap()["records"], 2);
assert_eq!(body(&s), "Beta\nGamma");
let mut s = fresh();
assert_eq!(s.run("mailings.finish", &json!({"records": "all", "from": 3, "to": 99})).unwrap()["records"], 2);
assert_eq!(body(&s), "Gamma\nDelta");
// An empty range is refused and the document is kept, not replaced by an empty one.
let mut s = fresh();
let before = body(&s);
for bad in [json!({"from": 3, "to": 2}), json!({"from": 5}), json!({"records": "some"})] {
assert!(s.run("mailings.finish", &bad).is_err(), "{bad}");
assert_eq!(body(&s), before, "{bad}");
}
let e = s.run("mailings.finish", &json!({"from": 5})).unwrap_err().to_string();
assert_eq!(e, "no records from 5 to 4 (the list has 4)", "a plain message for the dialog");
// Print Documents merges the same way, then asks the interface for Print.
let mut s = fresh();
s.ui_requests.clear();
assert_eq!(s.run("mailings.printDocuments", &json!({"from": 4, "path": "ignored.docx"})).unwrap()["records"], 1);
assert_eq!(body(&s), "Delta");
assert!(s.ui_requests.contains(&json!({"open": "print"})));
let mut s = fresh();
s.ui_requests.clear();
assert!(s.run("mailings.printDocuments", &json!({"from": 9})).is_err());
assert!(s.ui_requests.is_empty(), "nothing to print, no Print dialog");
}
/// #429: an IF rule shows its true / false text in Preview Results, as Finish & Merge does.
#[test]
fn if_rule_previews_its_result() {
+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]
+105
View File
@@ -447,6 +447,15 @@ pub enum Dialog {
edit: bool,
message: String,
},
/// Mailings › Finish & Merge › Edit Individual Documents… or Print Documents… (`print`):
/// which records to merge (0 all, 1 the previewed one, 2 the From and To range).
MergeRecords {
print: bool,
choice: u8,
from: String,
to: String,
message: String,
},
/// Mailings › Find Recipient.
FindRecipient {
text: String,
@@ -988,6 +997,8 @@ impl Dialog {
Dialog::SelectTable { .. } => "selectTable",
Dialog::InsertMergeField { .. } => "insertMergeField",
Dialog::FindRecipient { .. } => "findRecipient",
Dialog::MergeRecords { print: false, .. } => "mergeRecords",
Dialog::MergeRecords { .. } => "printRecords",
Dialog::FieldCode { edit: false, .. } => "fieldCode",
Dialog::FieldCode { .. } => "editFieldCode",
Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "ruleSkipIf",
@@ -1249,6 +1260,13 @@ impl Dialog {
Dialog::InsertMergeField { field: fields.first().cloned().unwrap_or_default(), fields }
}
"findRecipient" => Dialog::FindRecipient { text: String::new(), message: String::new() },
"mergeRecords" | "printRecords" => Dialog::MergeRecords {
print: name == "printRecords",
choice: 0,
from: "1".into(),
to: app.session.merge.rows.len().to_string(),
message: String::new(),
},
"fieldCode" => Dialog::FieldCode { code: String::new(), edit: false, message: String::new() },
"editFieldCode" => {
let field = wordcraft_engine::cmd::fields::selected(&app.session).into_iter().next()?;
@@ -1446,6 +1464,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::SelectTable { .. } => "Select Table",
Dialog::InsertMergeField { .. } => "Insert Merge Field",
Dialog::FindRecipient { .. } => "Find Recipient",
Dialog::MergeRecords { .. } => "Finish & Merge",
Dialog::FieldCode { edit: false, .. } => "Field",
Dialog::FieldCode { .. } => "Edit Field Code",
Dialog::MergeRule { rule, .. } if rule == "SKIPIF" => "Skip Record If",
@@ -2978,6 +2997,46 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
}
cancel
}
Dialog::MergeRecords { print, choice, from, to, message } => {
ui.radio_value(choice, 0, tl!("All"));
ui.radio_value(choice, 1, tl!("Current record"));
ui.horizontal(|ui| {
ui.radio_value(choice, 2, tl!("From:"));
// Typing a number picks the range, as in Word.
if ui.add(egui::TextEdit::singleline(from).desired_width(60.0)).changed() {
*choice = 2;
}
ui.label(tl!("To:"));
if ui.add(egui::TextEdit::singleline(to).desired_width(60.0)).changed() {
*choice = 2;
}
});
if !message.is_empty() {
ui.label(egui::RichText::new(message.as_str()).small().weak());
}
let (ok, cancel) = buttons(ui, tl!(if *print { "Print" } else { "OK" }));
if ok {
let params = match *choice {
0 => Some(json!({"records": "all"})),
1 => Some(json!({"records": "current"})),
_ => match (from.trim().parse::<u64>(), to.trim().parse::<u64>()) {
(Ok(f), Ok(t)) => Some(json!({"from": f, "to": t})),
_ => None,
},
};
let id = if *print { "mailings.printDocuments" } else { "mailings.finish" };
// An empty range is reported here, before Save Changes asks about the document.
let run = params.ok_or_else(|| tl!("Enter a number.").to_string()).and_then(|p| {
wordcraft_engine::cmd::mailings::merge_range(&app.session, &p).map_err(|e| e.to_string())?;
app.run(id, p)
});
match run {
Ok(_) => return true,
Err(e) => *message = e,
}
}
cancel
}
Dialog::MergeRule { rule, fields, field, value, then, els } => {
let skip = rule == "SKIPIF";
ui.label(tl!(if skip {
@@ -4384,6 +4443,52 @@ mod tests {
/// #1346: a check box the selected paragraphs differ in shows as neither on nor off; a click
/// and OK turn it on in all of them, and the other fields they differ in stay as they were.
/// #3028: the records dialog refuses a range with no records itself, before Save Changes asks
/// about the unsaved document; a good range goes on to that question.
#[test]
fn merge_records_dialog_checks_the_range_before_asking_to_save() {
use egui_kittest::kittest::Queryable;
let mut app = app_with("Dear ");
let _ = app.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan\nGrace"}));
let _ = app.run("text.insert", json!({"text": "unsaved"}));
assert!(app.session.dirty);
app.dialog = Dialog::open("mergeRecords", &mut app);
if let Some(Dialog::MergeRecords { choice, from, to, .. }) = &mut app.dialog {
*choice = 2;
*from = "3".into();
*to = "2".into();
}
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click();
for _ in 0..2 {
h.step();
}
let Some(Dialog::MergeRecords { message, .. }) = &h.state().dialog else { panic!("the dialog stays open") };
assert!(message.contains("no records from 3 to 2"), "{message}");
if let Some(Dialog::MergeRecords { to, .. }) = &mut h.state_mut().dialog {
*to = "3".into();
}
for _ in 0..2 {
h.step();
}
h.get_by_role_and_label(egui::accesskit::Role::Button, "OK").click();
for _ in 0..2 {
h.step();
}
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "nothing merged yet");
}
#[test]
fn paragraph_dialog_sets_a_mixed_check_box_for_all() {
use egui_kittest::kittest::{NodeT, Queryable};
+2
View File
@@ -240,6 +240,8 @@ fn untranslated_interface_text() {
"models",
"insertMergeField",
"findRecipient",
"mergeRecords",
"printRecords",
"caption",
"index",
"markEntry",
+10
View File
@@ -2766,3 +2766,13 @@
More tabs Още раздели
# #3013: Insert Table of Figures
Include label and number Включване на етикета и номера
First Record Първи запис
Last Record Последен запис
Go to Record Отиване до запис
Edit Individual Documents… Редактиране на отделни документи…
Print Documents… Печат на документи…
Print Documents Печат на документи
Current record Текущ запис
From: От:
To: До:
Enter a number. Въведете число.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2768,3 +2768,13 @@ preview Title Ukázka
More tabs Další karty
# #3013: Insert Table of Figures
Include label and number Zahrnout popisek a číslo
First Record První záznam
Last Record Poslední záznam
Go to Record Přejít na záznam
Edit Individual Documents… Upravit jednotlivé dokumenty…
Print Documents… Tisk dokumentů…
Print Documents Tisk dokumentů
Current record Aktuální záznam
From: Od:
To: Do:
Enter a number. Zadejte číslo.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2601,3 +2601,13 @@ preview Title Titel
Full Screen View Full Screen View
Personal Templates Personal Templates
Underline Color Underline Color
First Record Første post
Last Record Sidste post
Go to Record Gå til post
Edit Individual Documents… Rediger enkelte dokumenter…
Print Documents… Udskriv dokumenter…
Print Documents Udskriv dokumenter
Current record Aktuel post
From: Fra:
To: Til:
Enter a number. Angiv et tal.
Can't render this file because it contains an unexpected character in line 756 and column 23.
+10
View File
@@ -2368,3 +2368,13 @@
More tabs Weitere Registerkarten
# #3013: Insert Table of Figures
Include label and number Bezeichnung und Nummer einbeziehen
First Record Erster Datensatz
Last Record Letzter Datensatz
Go to Record Gehe zu Datensatz
Edit Individual Documents… Einzelne Dokumente bearbeiten…
Print Documents… Dokumente drucken…
Print Documents Dokumente drucken
Current record Aktueller Datensatz
From: Von:
To: Bis:
Enter a number. Bitte eine Zahl eingeben.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2731,3 +2731,13 @@ preview Title Título
More tabs Más pestañas
# #3013: Insert Table of Figures
Include label and number Incluir rótulo y número
First Record Primer registro
Last Record Último registro
Go to Record Ir al registro
Edit Individual Documents… Editar documentos individuales…
Print Documents… Imprimir documentos…
Print Documents Imprimir documentos
Current record Registro actual
From: Desde:
To: Hasta:
Enter a number. Introduce un número.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2403,3 +2403,13 @@
More tabs Veel vahekaarte
# #3013: Insert Table of Figures
Include label and number Lisa silt ja number
First Record Esimene kirje
Last Record Viimane kirje
Go to Record Mine kirjele
Edit Individual Documents… Redigeeri üksikdokumente…
Print Documents… Prindi dokumendid…
Print Documents Prindi dokumendid
Current record Praegune kirje
From: Alates:
To: Kuni:
Enter a number. Sisesta arv.
Can't render this file because it contains an unexpected character in line 855 and column 23.
+10
View File
@@ -2777,3 +2777,13 @@ template Letter Lettre
More tabs Plus d'onglets
# #3013: Insert Table of Figures
Include label and number Inclure l’étiquette et le numéro
First Record Premier enregistrement
Last Record Dernier enregistrement
Go to Record Atteindre l’enregistrement
Edit Individual Documents… Modifier les documents individuels…
Print Documents… Imprimer les documents…
Print Documents Imprimer les documents
Current record Enregistrement actif
From: De :
To: À :
Enter a number. Entrez un nombre.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2776,3 +2776,13 @@ preview Title Naslov
More tabs Više kartica
# #3013: Insert Table of Figures
Include label and number Uključi oznaku i broj
First Record Prvi zapis
Last Record Posljednji zapis
Go to Record Idi na zapis
Edit Individual Documents… Uredi pojedinačne dokumente…
Print Documents… Ispis dokumenata…
Print Documents Ispis dokumenata
Current record Trenutni zapis
From: Od:
To: Do:
Enter a number. Unesite broj.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2772,3 +2772,13 @@
More tabs További lapok
# #3013: Insert Table of Figures
Include label and number Címkével és számmal
First Record Első rekord
Last Record Utolsó rekord
Go to Record Ugrás rekordra
Edit Individual Documents… Egyedi dokumentumok szerkesztése…
Print Documents… Dokumentumok nyomtatása…
Print Documents Dokumentumok nyomtatása
Current record Aktuális rekord
From: Ettől:
To: Eddig:
Enter a number. Adj meg egy számot.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2801,3 +2801,13 @@ template Letter Lettera
More tabs Altre schede
# #3013: Insert Table of Figures
Include label and number Includi etichetta e numero
First Record Primo record
Last Record Ultimo record
Go to Record Vai al record
Edit Individual Documents… Modifica singoli documenti…
Print Documents… Stampa documenti…
Print Documents Stampa documenti
Current record Record corrente
From: Da:
To: A:
Enter a number. Immetti un numero.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2369,3 +2369,13 @@
More tabs その他のタブ
# #3013: Insert Table of Figures
Include label and number ラベルと番号を含める
First Record 最初のレコード
Last Record 最後のレコード
Go to Record レコードへ移動
Edit Individual Documents… 個々のドキュメントを編集…
Print Documents… ドキュメントを印刷…
Print Documents ドキュメントを印刷
Current record 現在のレコード
From: 開始:
To: 終了:
Enter a number. 数値を入力してください。
Can't render this file because it contains an unexpected character in line 933 and column 23.
+10
View File
@@ -2619,3 +2619,13 @@ preview Title 제목
More tabs 탭 더 보기
# #3013: Insert Table of Figures
Include label and number 레이블 및 번호 포함
First Record 첫 레코드
Last Record 마지막 레코드
Go to Record 레코드로 이동
Edit Individual Documents… 개별 문서 편집…
Print Documents… 문서 인쇄…
Print Documents 문서 인쇄
Current record 현재 레코드
From: 시작:
To: 끝:
Enter a number. 숫자를 입력하세요.
Can't render this file because it contains an unexpected character in line 939 and column 23.
+10
View File
@@ -2671,3 +2671,13 @@ preview Title Titel
Full Screen View Full Screen View
Personal Templates Personal Templates
Underline Color Underline Color
First Record Eerste record
Last Record Laatste record
Go to Record Ga naar record
Edit Individual Documents… Afzonderlijke documenten bewerken…
Print Documents… Documenten afdrukken…
Print Documents Documenten afdrukken
Current record Huidig record
From: Van:
To: Tot:
Enter a number. Voer een getal in.
Can't render this file because it contains an unexpected character in line 756 and column 23.
+10
View File
@@ -2797,3 +2797,13 @@ preview Title Tytuł
This document has no bookmarks. This document has no bookmarks.
Type a page number, or +2 / -1. Type a page number, or +2 / -1.
Wait for the print job to finish submitting before closing. Wait for the print job to finish submitting before closing.
First Record Pierwszy rekord
Last Record Ostatni rekord
Go to Record Przejdź do rekordu
Edit Individual Documents… Edytuj poszczególne dokumenty…
Print Documents… Drukuj dokumenty…
Print Documents Drukuj dokumenty
Current record Bieżący rekord
From: Od:
To: Do:
Enter a number. Wpisz liczbę.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2381,3 +2381,13 @@
More tabs Mais guias
# #3013: Insert Table of Figures
Include label and number Incluir rótulo e número
First Record Primeiro registro
Last Record Último registro
Go to Record Ir para o registro
Edit Individual Documents… Editar documentos individuais…
Print Documents… Imprimir documentos…
Print Documents Imprimir documentos
Current record Registro atual
From: De:
To: Até:
Enter a number. Digite um número.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2764,3 +2764,13 @@ template Letter Scrisoare
cm cm
mm mm
pi pi
First Record Prima înregistrare
Last Record Ultima înregistrare
Go to Record Salt la înregistrare
Edit Individual Documents… Editare documente individuale…
Print Documents… Imprimare documente…
Print Documents Imprimare documente
Current record Înregistrarea curentă
From: De la:
To: Până la:
Enter a number. Introduceți un număr.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2775,3 +2775,13 @@ preview Title Пример
More tabs Другие вкладки
# #3013: Insert Table of Figures
Include label and number Включить подпись и номер
First Record Первая запись
Last Record Последняя запись
Go to Record Перейти к записи
Edit Individual Documents… Изменить отдельные документы…
Print Documents… Печать документов…
Print Documents Печать документов
Current record Текущая запись
From: С:
To: По:
Enter a number. Введите число.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2765,3 +2765,13 @@ preview Title Naslov
{names} and {n} more {names} and {n} more
{name} options {name} options
{width} × {height} pixels {width} × {height} pixels
First Record Prvi zapis
Last Record Zadnji zapis
Go to Record Pojdi na zapis
Edit Individual Documents… Uredi posamezne dokumente…
Print Documents… Natisni dokumente…
Print Documents Natisni dokumente
Current record Trenutni zapis
From: Od:
To: Do:
Enter a number. Vnesite število.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2379,3 +2379,13 @@
More tabs Još kartica
# #3013: Insert Table of Figures
Include label and number Uključi oznaku i broj
First Record Prvi zapis
Last Record Poslednji zapis
Go to Record Idi na zapis
Edit Individual Documents… Uredi pojedinačne dokumente…
Print Documents… Štampaj dokumente…
Print Documents Štampaj dokumente
Current record Trenutni zapis
From: Od:
To: Do:
Enter a number. Unesite broj.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+10
View File
@@ -2383,3 +2383,13 @@
More tabs Још картица
# #3013: Insert Table of Figures
Include label and number Укључи ознаку и број
First Record Први запис
Last Record Последњи запис
Go to Record Иди на запис
Edit Individual Documents… Уреди појединачне документе…
Print Documents… Штампај документе…
Print Documents Штампај документе
Current record Тренутни запис
From: Од:
To: До:
Enter a number. Унесите број.
Can't render this file because it contains an unexpected character in line 11 and column 25.
+10
View File
@@ -2804,3 +2804,13 @@ template Letter Brev
More tabs Fler flikar
# #3013: Insert Table of Figures
Include label and number Ta med etikett och nummer
First Record Första post
Last Record Sista post
Go to Record Gå till post
Edit Individual Documents… Redigera enskilda dokument…
Print Documents… Skriv ut dokument…
Print Documents Skriv ut dokument
Current record Aktuell post
From: Från:
To: Till:
Enter a number. Ange ett tal.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2774,3 +2774,13 @@ preview Title Örnek
More tabs Diğer sekmeler
# #3013: Insert Table of Figures
Include label and number Etiketi ve numarayı ekle
First Record İlk Kayıt
Last Record Son Kayıt
Go to Record Kayda Git
Edit Individual Documents… Belgeleri Ayrı Ayrı Düzenle…
Print Documents… Belgeleri Yazdır…
Print Documents Belgeleri Yazdır
Current record Geçerli kayıt
From: Başlangıç:
To: Bitiş:
Enter a number. Bir sayı girin.
Can't render this file because it contains an unexpected character in line 808 and column 23.
+10
View File
@@ -2381,3 +2381,13 @@
More tabs Інші вкладки
# #3013: Insert Table of Figures
Include label and number Додавати мітку й номер
First Record Перший запис
Last Record Останній запис
Go to Record Перейти до запису
Edit Individual Documents… Редагувати окремі документи…
Print Documents… Друк документів…
Print Documents Друк документів
Current record Поточний запис
From: З:
To: По:
Enter a number. Введіть число.
Can't render this file because it contains an unexpected character in line 6 and column 31.
+10
View File
@@ -2816,3 +2816,13 @@ template Letter Thư
More tabs Các tab khác
# #3013: Insert Table of Figures
Include label and number Bao gồm nhãn và số
First Record Bản ghi đầu tiên
Last Record Bản ghi cuối cùng
Go to Record Đi tới bản ghi
Edit Individual Documents… Sửa từng tài liệu…
Print Documents… In tài liệu…
Print Documents In tài liệu
Current record Bản ghi hiện tại
From: Từ:
To: Đến:
Enter a number. Nhập một số.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2381,3 +2381,13 @@
More tabs 更多选项卡
# #3013: Insert Table of Figures
Include label and number 包含标签和编号
First Record 第一条记录
Last Record 最后一条记录
Go to Record 转到记录
Edit Individual Documents… 编辑单个文档…
Print Documents… 打印文档…
Print Documents 打印文档
Current record 当前记录
From: 从:
To: 到:
Enter a number. 请输入数字。
Can't render this file because it contains an unexpected character in line 7 and column 25.
+10
View File
@@ -2370,3 +2370,13 @@
More tabs 更多索引標籤
# #3013: Insert Table of Figures
Include label and number 包含標籤和編號
First Record 第一筆記錄
Last Record 最後一筆記錄
Go to Record 移至記錄
Edit Individual Documents… 編輯個別文件…
Print Documents… 列印文件…
Print Documents 列印文件
Current record 目前記錄
From: 從:
To: 到:
Enter a number. 請輸入數字。
Can't render this file because it contains an unexpected character in line 934 and column 23.
+6
View File
@@ -854,6 +854,12 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
let pts: &[(f32, f32)] =
if name == "next" || name == "last" { &[(7.0, 4.0), (13.0, 10.0), (7.0, 16.0)] } else { &[(13.0, 4.0), (7.0, 10.0), (13.0, 16.0)] };
pen.line_c(pts, a);
// First and Last: a bar at the end the chevron points to.
match name {
"first" => pen.line_c(&[(5.0, 4.0), (5.0, 16.0)], a),
"last" => pen.line_c(&[(15.0, 4.0), (15.0, 16.0)], a),
_ => {}
}
}
"finish" => {
pen.page(3.0, 2.0, 13.0, 15.0);
+2
View File
@@ -332,8 +332,10 @@ fn controls(tab: &str) -> Vec<Control> {
c("Rules", "mailings.rules"),
c("Match Fields", "mailings.matchFields"),
c("Preview Results", "mailings.preview"),
p("First Record", "mailings.first", serde_json::json!({})),
p("Previous Record", "mailings.previous", serde_json::json!({})),
p("Next Record", "mailings.next", serde_json::json!({})),
p("Last Record", "mailings.last", serde_json::json!({})),
c("Find Recipient", "mailings.findRecipient"),
c("Check for Errors", "mailings.checkErrors"),
c("Finish & Merge", "mailings.finish"),
+24 -5
View File
@@ -2732,8 +2732,9 @@ pub fn keeps_everything(name: &str) -> bool {
/// The dialog a user-run command opens when it lacks the input it needs (scripts and agents get
/// the command's own error or default instead): Select Recipients and Edit Recipient List without
/// data, Insert Merge Field without a field, Find Recipient without text, the If and Skip
/// Record If rules (or Rules with no rule at all) without a field, Table Properties without
/// settings, Symbol and Field without a character or code, and Enclose Characters without a shape.
/// Record If rules (or Rules with no rule at all) without a field, Finish & Merge and Print
/// Documents without records, Table Properties without settings, Symbol and Field without a
/// character or code, and Enclose Characters without a shape.
fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
let has = |k: &str| params.get(k).is_some_and(|v| !v.is_null());
let rule = params.get("rule").and_then(Value::as_str).map(str::to_ascii_uppercase);
@@ -2748,6 +2749,9 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
"mailings.editRecipients" if !has("rows") => Some("recipientList"),
"mailings.insertField" if !has("field") => Some("insertMergeField"),
"mailings.findRecipient" if !has("text") => Some("findRecipient"),
// Finish & Merge and Print Documents without records ask which (#3028).
"mailings.finish" if !["path", "records", "from", "to"].into_iter().any(has) => Some("mergeRecords"),
"mailings.printDocuments" if !["records", "from", "to"].into_iter().any(has) => Some("printRecords"),
// Symbol without a character and Field without a code show their dialogs (#321).
"insert.symbol" if !has("char") => Some("symbol"),
"insert.field" if !has("instr") => Some("field"),
@@ -2899,6 +2903,7 @@ pub(crate) fn discards_document(id: &str, params: &Value) -> bool {
"file.open" => params.get("path").is_some(),
"file.recover" => params.get("restore").is_some(),
"mailings.finish" => params.get("path").and_then(Value::as_str).is_none(),
"mailings.printDocuments" => true,
_ => false,
}
}
@@ -7231,14 +7236,28 @@ mod tests {
assert!(!a.has_document() && !a.quit_requested);
}
const MAILINGS: [&str; 3] = ["mailings.envelopes", "mailings.labels", "mailings.finish"];
const MAILINGS: [&str; 4] = ["mailings.envelopes", "mailings.labels", "mailings.finish", "mailings.printDocuments"];
/// What the user gave: Envelopes and Labels without anything open their dialogs (#407).
fn mailing(id: &str) -> Value {
match id {
"mailings.envelopes" => json!({"delivery": "Ada"}),
"mailings.labels" => json!({"text": "Ada"}),
_ => json!({}),
_ => json!({"records": "all"}),
}
}
/// #3028: Finish & Merge and Print Documents from the ribbon ask which records first; with
/// records given (scripts, the dialog's OK) they merge.
#[test]
fn finish_and_print_documents_ask_which_records() {
let mut a = app();
a.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan"})).unwrap();
for (id, dialog) in [("mailings.finish", "mergeRecords"), ("mailings.printDocuments", "printRecords")] {
assert_eq!(a.run(id, json!({})).unwrap(), json!({"pending": dialog}));
assert_eq!(prompt(&a), Some(dialog));
assert!(matches!(&a.dialog, Some(dialogs::Dialog::MergeRecords { from, to, .. }) if from == "1" && to == "2"));
a.dialog = None;
}
}
@@ -7269,7 +7288,7 @@ mod tests {
// A `path` that isn't text merges into a new document, like no path at all.
let mut a = typed();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("mailings.finish", json!({"path": null})).unwrap();
a.run("mailings.finish", json!({"path": null, "records": "all"})).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"));
}
+27 -1
View File
@@ -2054,18 +2054,44 @@ fn mailings(app: &mut WordApp, ui: &mut Ui) {
big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false);
stack(ui, |ui| {
crate::widgets::row(ui, |ui| {
small(ui, app, "first", None, "First Record", "mailings.first", json!({}), false);
small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false);
record_box(ui, app);
small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false);
small(ui, app, "last", None, "Last Record", "mailings.last", json!({}), false);
});
small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false);
small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false);
});
});
group(ui, "Finish", None, app, |ui, app| {
big(ui, app, "finish", "Finish &\nMerge", "mailings.finish", json!({}), false);
menu_button(ui, app, "finish", Some("Finish &\nMerge"), "Finish & Merge", true, "mailings.finish", |ui, app| {
mi(ui, app, "Edit Individual Documents…", "mailings.finish", json!({}));
mi(ui, app, "Print Documents…", "mailings.printDocuments", json!({}));
});
});
}
/// Preview Results' record number: shows the previewed record; a number typed and Enter go there.
fn record_box(ui: &mut Ui, app: &mut WordApp) {
let on = !app.session.merge.rows.is_empty();
let shown = if on { (app.session.merge.record + 1).to_string() } else { String::new() };
// The draft lives in temp memory while typing (frame-local text would lose keystrokes).
let key = egui::Id::new("merge_record_box");
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| shown.clone());
let r = ui.add_enabled(on, egui::TextEdit::singleline(&mut text).desired_width(36.0).horizontal_align(egui::Align::Center));
let r = r.on_hover_text(tl!("Go to Record"));
if r.changed() {
ui.data_mut(|d| d.insert_temp(key, text.clone()));
}
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(key));
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && text.trim() != shown {
let _ = app.run("mailings.goToRecord", json!({"record": text.trim()}));
}
}
}
/// Zotero (`docs/zotero.md`): the Zotero desktop app does the work in its own window.
fn zotero(app: &mut WordApp, ui: &mut Ui) {
group(ui, "Citations", None, app, |ui, app| {
+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 -2
View File
@@ -2,7 +2,7 @@
Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.
**498 of 513 catalog features have commands (97.1%).**
**502 of 517 catalog features have commands (97.1%).**
| Tab | Live | Total |
|---|---|---|
@@ -15,7 +15,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-
| Design | 10 | 10 |
| Layout | 22 | 22 |
| References | 25 | 25 |
| Mailings | 19 | 19 |
| Mailings | 23 | 23 |
| Review | 29 | 31 |
| View | 25 | 29 |
| Developer | 11 | 11 |
+4 -2
View File
File diff suppressed because one or more lines are too long