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