mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 00:40:33 -06:00
M-EGUI037: reconcile WordCraft main and preserve IME editing
This commit is contained in:
@@ -864,6 +864,25 @@ mod editing_tests {
|
||||
assert_eq!(typed, None, "a placeholder style localized in id and name");
|
||||
}
|
||||
|
||||
/// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the
|
||||
/// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays
|
||||
/// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command
|
||||
/// and split a one-line control).
|
||||
#[test]
|
||||
fn typed_line_breaks_do_not_split_a_one_line_control() {
|
||||
for (multi_line, paragraphs) in [(false, 1), (true, 2)] {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut s, "text.insert", json!({"text": "Name: "}));
|
||||
run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"}));
|
||||
run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line}));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true}));
|
||||
assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}");
|
||||
let r = s.doc.control_at(&s.sel.focus).unwrap();
|
||||
assert_eq!(r.control.tag, "name", "still inside the control");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_replaces_the_placeholder_and_emptying_brings_it_back() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -532,6 +532,16 @@ fn keep_lasso(s: &mut Session, sel: LassoSelection) {
|
||||
s.view.draw.lasso = Some(LassoSelection { rev: s.rev().wrapping_add(1), ..sel });
|
||||
}
|
||||
|
||||
/// After a command that changed nothing, which puts the revision back from `touched` to `rev`: a
|
||||
/// lasso selection the command kept for the revision it would have reached stays selected.
|
||||
pub(crate) fn rekey_lasso(s: &mut Session, touched: u64, rev: u64) {
|
||||
if let Some(l) = s.view.draw.lasso.as_mut()
|
||||
&& (l.rev == touched || l.rev == touched.wrapping_add(1))
|
||||
{
|
||||
l.rev = rev;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether (`x`, `y`) is inside the closed loop `poly` (even–odd rule).
|
||||
fn inside(poly: &[[f32; 2]], x: f32, y: f32) -> bool {
|
||||
let n = poly.len();
|
||||
@@ -1081,6 +1091,15 @@ mod tests {
|
||||
let objs = ink(&s);
|
||||
let blue = objs.iter().filter(|o| matches!(o, InlineObject::Shape { stroke: Some(c), .. } if c.hex() == "0070C0")).count();
|
||||
assert_eq!(blue, 1);
|
||||
// Recolouring to the colour it has changes nothing, and the selection stays; an edit
|
||||
// elsewhere ends it.
|
||||
s.run("draw.lassoColor", &json!({"color": "0070C0"})).unwrap();
|
||||
assert_eq!(s.undo_depth(), steps + 2);
|
||||
assert_eq!(super::lasso_selection(&s).map(|l| l.items.clone()), Some(vec![sel.clone()]));
|
||||
s.run("text.insert", &json!({"text": "x"})).unwrap();
|
||||
assert!(super::lasso_selection(&s).is_none());
|
||||
let r = s.run("draw.lasso", &json!({"page": 0, "points": [[80, 80], [260, 80], [260, 180], [80, 180]]})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
// Deleted: only the selected stroke goes, and the selection with it.
|
||||
s.run("draw.lassoDelete", &json!({})).unwrap();
|
||||
assert_eq!(ink(&s).len(), 1);
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
/// Word's font size list.
|
||||
pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 22.0, 24.0, 26.0, 28.0, 36.0, 48.0, 72.0];
|
||||
|
||||
/// Register character-formatting commands, their parameters and keyboard bindings.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
|
||||
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"color": "RRGGBB" | "auto"} (the underline's own colour; "auto" follows the text colour)"#),
|
||||
CommandSpec::new("format.highlight", "Text Highlight Color", "Home › Font", highlight)
|
||||
.params(
|
||||
r#"{"color": "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"}"#,
|
||||
r#"{"color"?: "yellow|brightGreen|turquoise|pink|blue|red|darkBlue|teal|green|violet|darkRed|darkYellow|gray50|gray25|black|none"} (explicit colour sets it; omitted toggles the last chosen colour, initially yellow)"#,
|
||||
)
|
||||
// ⌘⌥H hides other apps on macOS.
|
||||
.key(super::non_mac("Mod+Alt+H")),
|
||||
@@ -228,7 +229,8 @@ pub fn apply(s: &mut Session, f: &dyn Fn(&mut CharProps)) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Resolved formatting of every character in the target (or the caret).
|
||||
/// Resolved target formatting, sampled up to a paragraph/column-row boundary after 10,000 runs.
|
||||
/// Empty targets use pending caret formatting or the selected text box paragraph mark.
|
||||
fn resolved(s: &Session) -> Vec<ResolvedChar> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(rows) = column_rows(s) {
|
||||
@@ -391,18 +393,73 @@ fn underline_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
apply(s, &|x| x.underline_color = rgb)
|
||||
}
|
||||
|
||||
/// Test the entire highlight target without the formatting-state query's run cap.
|
||||
/// Resolve one run at a time and stop at the first mismatch, without collecting them.
|
||||
fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
let matches = |style: Option<&str>, props: &CharProps| s.doc.styles.resolve_char(style, props).highlight == h.rgb();
|
||||
let mut seen = false;
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
if a == b {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some((a, b)) = selected_text_box(s).or_else(|| target(s)) {
|
||||
for path in s.doc.paths_between(&a, &b) {
|
||||
let Some(p) = s.doc.para(a.story, &path) else { continue };
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > from && r.start < to {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// No runs: retain the caret/pending and empty text-box paragraph-mark rules.
|
||||
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
|
||||
}
|
||||
|
||||
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
|
||||
/// the last choice off only when the whole formatting target already has that colour.
|
||||
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::str(v, "color").unwrap_or("yellow");
|
||||
let h = Highlight::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|h| {
|
||||
h.ooxml().eq_ignore_ascii_case(c)
|
||||
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|
||||
|| format!("{h:?}").eq_ignore_ascii_case(c)
|
||||
})
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?;
|
||||
apply(s, &|x| x.highlight = Some(h))
|
||||
let color = v.get("color").map(|_| p::req_str(v, "color")).transpose()?;
|
||||
let h = match color {
|
||||
None => s.last_highlight,
|
||||
Some(c) => Highlight::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|h| {
|
||||
h.ooxml().eq_ignore_ascii_case(c)
|
||||
|| h.name().replace(['-', ' ', '%'], "").eq_ignore_ascii_case(&c.replace(['-', ' ', '%'], ""))
|
||||
|| format!("{h:?}").eq_ignore_ascii_case(c)
|
||||
})
|
||||
.ok_or_else(|| CmdError::Params(format!("unknown highlight colour `{c}`")))?,
|
||||
};
|
||||
let remove = color.is_none() && target_has_highlight(s, h);
|
||||
let value = if remove { Highlight::None } else { h };
|
||||
let result = apply(s, &|x| x.highlight = Some(value))?;
|
||||
s.last_highlight = h;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// `format.changeCase`: `mode` (lower, upper, title, sentence, toggle), or Shift+F3's cycle
|
||||
|
||||
@@ -227,8 +227,8 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
|
||||
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
|
||||
}
|
||||
|
||||
/// The media extension of an encoded image by its magic bytes, as `image_size` accepted it
|
||||
/// (TIFF included: the `image` crate decodes it).
|
||||
/// The media extension of an encoded image by its magic bytes, as the picture probe accepted it
|
||||
/// (TIFF included when its compression is one the `image` crate decodes within limits).
|
||||
pub(crate) fn image_ext(bytes: &[u8]) -> &'static str {
|
||||
match bytes.get(..4) {
|
||||
Some([0x89, b'P', b'N', b'G']) => "png",
|
||||
@@ -249,14 +249,15 @@ fn place_picture(s: &mut Session, bytes: Vec<u8>, width: Option<f32>, alt: &str)
|
||||
if bytes.len() > 200 << 20 {
|
||||
return Err(CmdError::Failed("image is larger than 200 MB".into()));
|
||||
}
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802).
|
||||
let (natural_w, natural_h, ext) = if let Some((pw, ph)) = wordcraft_render::image_size(&bytes) {
|
||||
let ext = image_ext(&bytes);
|
||||
(pw as f32 * 0.75, ph as f32 * 0.75, ext)
|
||||
} else if let Some(size) = wordcraft_render::metafile_size(&bytes) {
|
||||
size
|
||||
} else {
|
||||
return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into()));
|
||||
// Natural size: pixels at 96 ppi, or a metafile's own frame (WMF, EMF: #802). A picture that
|
||||
// is never decoded (a TIFF with JPEG strips, #3645) isn't inserted.
|
||||
let (natural_w, natural_h, ext) = match wordcraft_geom::picture::probe(&bytes) {
|
||||
Ok(pic) => (pic.width as f32 * 0.75, pic.height as f32 * 0.75, image_ext(&bytes)),
|
||||
Err(wordcraft_geom::picture::Refusal::Unsupported(why)) => return Err(CmdError::Failed(why.into())),
|
||||
Err(_) => match wordcraft_render::metafile_size(&bytes) {
|
||||
Some(size) => size,
|
||||
None => return Err(CmdError::Failed("not a supported image (PNG, JPEG, GIF, WebP, BMP, WMF, EMF)".into())),
|
||||
},
|
||||
};
|
||||
let key = s.doc.add_media(bytes, ext);
|
||||
// Fitted to the text width.
|
||||
@@ -1050,6 +1051,36 @@ pub fn drop_cap_state(s: &Session) -> DropCapState {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads.
|
||||
fn jpeg_strip_tiff() -> Vec<u8> {
|
||||
let mut t = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut t), image::ImageFormat::Tiff).unwrap();
|
||||
assert_eq!(&t[..4], b"II*\0");
|
||||
let ifd = u32::from_le_bytes(t[4..8].try_into().unwrap()) as usize;
|
||||
let n = usize::from(u16::from_le_bytes([t[ifd], t[ifd + 1]]));
|
||||
let e = (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| t[e..e + 2] == 259u16.to_le_bytes()).unwrap();
|
||||
t[e + 8..e + 10].copy_from_slice(&7u16.to_le_bytes());
|
||||
t
|
||||
}
|
||||
|
||||
/// #3645: a TIFF with JPEG strips is never decoded, so it is neither inserted nor swapped in;
|
||||
/// before, its readable header was all insertion looked at.
|
||||
#[test]
|
||||
fn tiffs_with_jpeg_strips_are_refused() {
|
||||
let tiff = jpeg_strip_tiff();
|
||||
assert_eq!(wordcraft_render::image_size(&tiff), Some((4, 4)), "its header reads");
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let e = s.run("insert.picture", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
assert!(s.doc.media.is_empty());
|
||||
let mut plain = Vec::new();
|
||||
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut plain), image::ImageFormat::Tiff).unwrap();
|
||||
s.run("insert.picture", &json!({"data": base64_encode(&plain)})).unwrap();
|
||||
let e = s.run("picture.change", &json!({"data": base64_encode(&tiff)})).unwrap_err().to_string();
|
||||
assert!(e.contains("TIFF"), "{e}");
|
||||
assert_eq!(s.doc.media.len(), 1);
|
||||
}
|
||||
|
||||
/// Closing the header or footer (Esc, the ribbon) puts the caret back where it was in the
|
||||
/// body, not on page 1 (#3331).
|
||||
#[test]
|
||||
|
||||
@@ -1501,6 +1501,45 @@ mod tests {
|
||||
assert_eq!(parse_csv("a;b\n1;2")[1], vec!["1", "2"]);
|
||||
}
|
||||
|
||||
/// #4001: finishing a merge opens a new document, with a new undo history. It is not an
|
||||
/// edit of the open document, even when the merged document holds the same content.
|
||||
#[test]
|
||||
fn finishing_a_merge_opens_a_new_document_with_a_new_history() {
|
||||
let mut s = Session::new(Document::from_text("Hello"));
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
assert_eq!(s.undo_depth(), 1);
|
||||
s.run("caret.docStart", &json!({})).unwrap();
|
||||
// One recipient and no merge fields: the merged document holds the same content.
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
|
||||
let id = s.document_id();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_ne!(s.document_id(), id, "the document was replaced");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello");
|
||||
assert_eq!(s.undo_depth(), 0, "the new document starts with an empty undo history");
|
||||
// Undo cannot bring the old document's text back.
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "!Hello", "the old text does not come back");
|
||||
}
|
||||
|
||||
/// #4004: finishing a merge to a file saves the merged copy there and leaves the open
|
||||
/// document alone: it changes nothing, so it is not an edit.
|
||||
#[test]
|
||||
fn finishing_a_merge_to_a_file_is_not_an_edit() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-merge-to-file-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let out = dir.join("merged.docx");
|
||||
let mut s = Session::new(Document::from_text("Hello"));
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Ada"}]})).unwrap();
|
||||
let (depth, dirty, text) = (s.undo_depth(), s.dirty, s.doc.plain_text(StoryRef::Body));
|
||||
s.run("mailings.finish", &json!({"path": out.to_string_lossy()})).unwrap();
|
||||
assert!(out.exists(), "the merged copy was written");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), text, "the open document is unchanged");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
assert_eq!(s.dirty, dirty, "the unsaved-changes flag is unchanged");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_delimiter_ignores_quoted_text() {
|
||||
let r = parse_csv("\"Name, first, last\";City\nAnn;Paris\n");
|
||||
|
||||
@@ -387,8 +387,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect,
|
||||
let mut at = s.sel.focus.clone();
|
||||
let mut first = true;
|
||||
let mut inserted = false;
|
||||
// Typed line breaks are the Enter key between the lines: in a one-line control it does
|
||||
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
|
||||
// its breaks, as before).
|
||||
let no_breaks = enter && controls::refuses_paragraph(s);
|
||||
for part in text.split(['\r', '\u{2029}']) {
|
||||
if !first {
|
||||
if !first && !no_breaks {
|
||||
at = split_para_as(s, &at, enter)?;
|
||||
inserted = true;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// `blur` asserts on a subnormal width.
|
||||
img = img.blur(-sharpen / 25.0);
|
||||
}
|
||||
img
|
||||
Ok(img)
|
||||
})
|
||||
})
|
||||
.params(r#"{"brightness"?: -100..100, "contrast"?: -100..100, "sharpen"?: -100..100}"#)
|
||||
@@ -146,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
let base = s.originals.get(&media).cloned().unwrap_or(media.clone());
|
||||
adjust_media(s, &base, move |img| recolor(img, &mode, sat))
|
||||
adjust_media(s, &base, move |img| Ok(recolor(img, &mode, sat)))
|
||||
})
|
||||
.params(r#"{"mode": "grayscale|sepia|washout|blackAndWhite|saturation|tint", "saturation"?: 0..400}"#)
|
||||
.when(has_picture),
|
||||
@@ -159,7 +159,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0 = [c.0, c.1, c.2, px.0[3]];
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
Ok(image::DynamicImage::ImageRgba8(rgba))
|
||||
})
|
||||
})
|
||||
.params(r#"{"color": "RRGGBB"}"#)
|
||||
@@ -186,20 +186,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for px in rgba.pixels_mut() {
|
||||
px.0[3] = (px.0[3] as f32 * (1.0 - t / 100.0)) as u8;
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
Ok(image::DynamicImage::ImageRgba8(rgba))
|
||||
})
|
||||
})
|
||||
.params(r#"{"percent": 0..100}"#)
|
||||
.when(has_picture),
|
||||
CommandSpec::new("picture.removeBackground", "Remove Background", "Picture Format › Adjust", |s, v| {
|
||||
let tol = p::f32(v, "tolerance").unwrap_or(30.0).clamp(0.0, 255.0);
|
||||
adjust(s, move |img| remove_background(img, tol))
|
||||
adjust(s, move |img| Ok(remove_background(img, tol)))
|
||||
})
|
||||
.params(r#"{"tolerance"?: 0..255}"#)
|
||||
.when(has_picture),
|
||||
CommandSpec::new("picture.compress", "Compress Pictures", "Picture Format › Adjust", |s, v| {
|
||||
let max = p::u64(v, "maxPixels").unwrap_or(1600).clamp(32, 20000) as u32;
|
||||
adjust(s, move |img| if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img })
|
||||
adjust(s, move |img| Ok(if img.width().max(img.height()) > max { img.resize(max, max, image::imageops::FilterType::Lanczos3) } else { img }))
|
||||
})
|
||||
.params(r#"{"maxPixels"?: n}"#)
|
||||
.when(has_picture),
|
||||
@@ -212,7 +212,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
} else {
|
||||
return Err(CmdError::Params("`path` or `data` required".into()));
|
||||
};
|
||||
wordcraft_render::image_size(&bytes).ok_or_else(|| CmdError::Failed("not a supported image".into()))?;
|
||||
wordcraft_geom::picture::probe(&bytes).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::Unsupported(why) => CmdError::Failed(why.into()),
|
||||
_ => CmdError::Failed("not a supported image".into()),
|
||||
})?;
|
||||
let key = s.doc.add_media(bytes.clone(), super::insert::image_ext(&bytes));
|
||||
with_obj(s, |o| {
|
||||
if let InlineObject::Image { media, .. } = o {
|
||||
@@ -1182,7 +1185,7 @@ pub fn fit_size(w: f32, h: f32, keep: bool) -> (f32, f32) {
|
||||
}
|
||||
|
||||
/// Apply an image operation to the selected picture's bitmap (stored as a new PNG).
|
||||
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||||
fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let (_, o) = selected(s).ok_or_else(|| CmdError::Disabled("no picture".into()))?;
|
||||
let InlineObject::Image { media, .. } = o else { return Err(CmdError::Disabled("not a picture".into())) };
|
||||
adjust_media(s, &media, f)
|
||||
@@ -1191,13 +1194,14 @@ fn adjust(s: &mut Session, f: impl Fn(image::DynamicImage) -> image::DynamicImag
|
||||
/// Like [`adjust`], but decodes `base` (a media key, usually the pre-edit original) instead
|
||||
/// of the picture's current bitmap. The result still chains onto the originals map, so Reset
|
||||
/// Picture keeps working.
|
||||
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> image::DynamicImage) -> CmdResult {
|
||||
fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) -> Result<image::DynamicImage, CmdError>) -> CmdResult {
|
||||
let bytes = s.doc.media.get(base).cloned().ok_or_else(|| CmdError::Failed("picture data missing".into()))?;
|
||||
let img = image::load_from_memory(&bytes).map_err(|e| CmdError::Failed(format!("can't decode the picture: {e}")))?;
|
||||
if (img.width() as u64) * (img.height() as u64) > 80_000_000 {
|
||||
return Err(CmdError::Failed("picture is too large to edit".into()));
|
||||
}
|
||||
let out = f(img);
|
||||
// Within the budget pictures are drawn within, checked from the header first (#3644, #3647).
|
||||
let img = wordcraft_geom::picture::decode(&bytes, wordcraft_geom::MAX_DECODED_BYTES).map_err(|e| match e {
|
||||
wordcraft_geom::picture::Refusal::TooLarge { .. } => too_large(),
|
||||
e => CmdError::Failed(format!("can't decode the picture: {e}")),
|
||||
})?;
|
||||
let out = f(img)?;
|
||||
let mut png = Vec::new();
|
||||
out.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
let key = s.doc.add_media(png, "png");
|
||||
@@ -1221,6 +1225,25 @@ fn adjust_media(s: &mut Session, base: &str, f: impl Fn(image::DynamicImage) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn too_large() -> CmdError {
|
||||
CmdError::Failed("picture is too large to edit".into())
|
||||
}
|
||||
|
||||
/// A `w` × `h` bitmap of `fill` for an edit to draw on, refused when the picture it makes would be
|
||||
/// too large to draw (two RGBA buffers within the decode budget), before it is allocated (#3611).
|
||||
fn canvas(w: u32, h: u32, fill: [u8; 4]) -> Result<image::RgbaImage, CmdError> {
|
||||
if !wordcraft_geom::decode_fits(w, h, wordcraft_geom::MAX_DECODED_BYTES) {
|
||||
return Err(too_large());
|
||||
}
|
||||
Ok(image::RgbaImage::from_pixel(w, h, image::Rgba(fill)))
|
||||
}
|
||||
|
||||
/// `w` and `h` grown by `pad` on every side, None past `u32`.
|
||||
fn grown(w: u32, h: u32, pad: u32) -> Option<(u32, u32)> {
|
||||
let twice = pad.checked_mul(2)?;
|
||||
Some((w.checked_add(twice)?, h.checked_add(twice)?))
|
||||
}
|
||||
|
||||
/// What Artistic Effects, Picture Styles and Picture Border drew on a picture's bitmap: the
|
||||
/// bitmap they started from and the effect, style and border on it, so the next one draws again
|
||||
/// from that bitmap instead of over the last result. Picking a second style replaces the first,
|
||||
@@ -1267,12 +1290,12 @@ fn relook(s: &mut Session, change: impl FnOnce(&mut PictureLook)) -> CmdResult {
|
||||
None => img,
|
||||
};
|
||||
if let Some(st) = &l.style {
|
||||
img = picture_style(img, st);
|
||||
img = picture_style(img, st)?;
|
||||
}
|
||||
if let Some((c, w)) = l.border {
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255]);
|
||||
img = frame(img, w, [c.0, c.1, c.2, 255])?;
|
||||
}
|
||||
img
|
||||
Ok(img)
|
||||
})?;
|
||||
if let Some((_, InlineObject::Image { media, .. })) = selected(s)
|
||||
&& let Some(drawn) = s.doc.media.get(&media).cloned()
|
||||
@@ -1356,12 +1379,22 @@ fn recolor(img: image::DynamicImage, mode: &str, sat: f32) -> image::DynamicImag
|
||||
/// Flood the border-connected region close to the corner colour with transparency.
|
||||
fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage {
|
||||
let mut rgba = img.to_rgba8();
|
||||
clear_background(&mut rgba, tol);
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
}
|
||||
|
||||
/// [`remove_background`] on `rgba`, a row span at a time: besides the border's seeds, the stack
|
||||
/// holds one for each run of matching pixels beside a filled span, instead of four for every
|
||||
/// pixel filled (#3647). Returns the most seeds it held.
|
||||
fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
|
||||
let (w, h) = rgba.dimensions();
|
||||
if w == 0 || h == 0 {
|
||||
return image::DynamicImage::ImageRgba8(rgba);
|
||||
}
|
||||
let bg = rgba.get_pixel(0, 0).0;
|
||||
let Some(bg) = rgba.get_pixel_checked(0, 0).map(|p| p.0) else { return 0 };
|
||||
let close = |p: [u8; 4]| (0..3).map(|i| (p[i] as f32 - bg[i] as f32).abs()).fold(0.0f32, f32::max) <= tol;
|
||||
let at = |x: u32, y: u32| y as usize * w as usize + x as usize;
|
||||
// Not cleared yet, and close to the corner colour.
|
||||
let open = |rgba: &image::RgbaImage, seen: &[bool], x: u32, y: u32| {
|
||||
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
|
||||
};
|
||||
let mut seen = vec![false; (w as usize) * (h as usize)];
|
||||
let mut stack: Vec<(u32, u32)> = Vec::new();
|
||||
for x in 0..w {
|
||||
@@ -1372,46 +1405,55 @@ fn remove_background(img: image::DynamicImage, tol: f32) -> image::DynamicImage
|
||||
stack.push((0, y));
|
||||
stack.push((w - 1, y));
|
||||
}
|
||||
let mut most = stack.len();
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
let i = (y as usize) * (w as usize) + x as usize;
|
||||
if seen.get(i).copied().unwrap_or(true) {
|
||||
if !open(rgba, &seen, x, y) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sv) = seen.get_mut(i) {
|
||||
*sv = true;
|
||||
let (mut x0, mut x1) = (x, x);
|
||||
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
|
||||
x0 -= 1;
|
||||
}
|
||||
let p = rgba.get_pixel(x, y).0;
|
||||
if !close(p) {
|
||||
continue;
|
||||
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
|
||||
x1 += 1;
|
||||
}
|
||||
rgba.put_pixel(x, y, image::Rgba([p[0], p[1], p[2], 0]));
|
||||
if x > 0 {
|
||||
stack.push((x - 1, y));
|
||||
for xi in x0..=x1 {
|
||||
if let Some(s) = seen.get_mut(at(xi, y)) {
|
||||
*s = true;
|
||||
}
|
||||
if let Some(p) = rgba.get_pixel_mut_checked(xi, y) {
|
||||
p.0[3] = 0;
|
||||
}
|
||||
}
|
||||
if x + 1 < w {
|
||||
stack.push((x + 1, y));
|
||||
}
|
||||
if y > 0 {
|
||||
stack.push((x, y - 1));
|
||||
}
|
||||
if y + 1 < h {
|
||||
stack.push((x, y + 1));
|
||||
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
|
||||
let mut xi = x0;
|
||||
while xi <= x1 {
|
||||
if open(rgba, &seen, xi, ny) {
|
||||
stack.push((xi, ny));
|
||||
while xi <= x1 && open(rgba, &seen, xi, ny) {
|
||||
xi += 1;
|
||||
}
|
||||
} else {
|
||||
xi += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
most = most.max(stack.len());
|
||||
}
|
||||
image::DynamicImage::ImageRgba8(rgba)
|
||||
most
|
||||
}
|
||||
|
||||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> image::DynamicImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let mut out = image::RgbaImage::from_pixel(w + 2 * border, h + 2 * border, image::Rgba(color));
|
||||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
|
||||
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
|
||||
let mut out = canvas(w, h, color)?;
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), border as i64, border as i64);
|
||||
image::DynamicImage::ImageRgba8(out)
|
||||
Ok(image::DynamicImage::ImageRgba8(out))
|
||||
}
|
||||
|
||||
fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||||
fn picture_style(img: image::DynamicImage, style: &str) -> Result<image::DynamicImage, CmdError> {
|
||||
let m = (img.width().max(img.height()) / 40).max(4);
|
||||
match style {
|
||||
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255]), m / 2, [40, 40, 40, 255]),
|
||||
Ok(match style {
|
||||
"thickFrame" => frame(frame(img, m * 2, [255, 255, 255, 255])?, m / 2, [40, 40, 40, 255])?,
|
||||
"rounded" | "softEdge" => {
|
||||
let mut rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
@@ -1434,18 +1476,47 @@ fn picture_style(img: image::DynamicImage, style: &str) -> image::DynamicImage {
|
||||
"shadow" => {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let off = m;
|
||||
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
|
||||
let shadow = image::DynamicImage::ImageRgba8(shadow);
|
||||
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
image::imageops::overlay(&mut padded, &shadow.to_rgba8(), (off + off / 2) as i64, (off + off / 2) as i64);
|
||||
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
|
||||
image::imageops::overlay(&mut out, &blurred, 0, 0);
|
||||
let (pw, ph) = grown(w, h, off).ok_or_else(too_large)?;
|
||||
let mut out = canvas(pw, ph, [0, 0, 0, 0])?;
|
||||
// A rectangle of alpha 90 the picture's size, one and a half margins in, blurred. Its
|
||||
// blur is the product of its sides' blurs along each axis, so it is drawn from two
|
||||
// profiles, not by blurring every pixel with a kernel that grows with the picture (#3647).
|
||||
let (sigma, start) = (off as f32 / 2.0, off + off / 2);
|
||||
let (xs, ys) = (blurred_span(pw, start, w, sigma), blurred_span(ph, start, h, sigma));
|
||||
for (x, y, p) in out.enumerate_pixels_mut() {
|
||||
let k = xs.get(x as usize).copied().unwrap_or(0.0) * ys.get(y as usize).copied().unwrap_or(0.0);
|
||||
p.0[3] = (90.0 * k).round() as u8;
|
||||
}
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
|
||||
image::DynamicImage::ImageRgba8(out)
|
||||
}
|
||||
_ => frame(img, m, [255, 255, 255, 255]),
|
||||
_ => frame(img, m, [255, 255, 255, 255])?,
|
||||
})
|
||||
}
|
||||
|
||||
/// How much of a span `len` long from `start` covers each of `n` positions once blurred with the
|
||||
/// Gaussian `image::imageops::blur` uses for `sigma` (outside the `n` positions counts as clear):
|
||||
/// 0 to 1. Each position is one difference of the kernel's running sums.
|
||||
fn blurred_span(n: u32, start: u32, len: u32, sigma: f32) -> Vec<f32> {
|
||||
let kernel = gaussian_kernel(sigma);
|
||||
let mut sums = vec![0.0f32];
|
||||
for k in &kernel {
|
||||
sums.push(sums.last().copied().unwrap_or(0.0) + k);
|
||||
}
|
||||
let (size, r) = (kernel.len() as i64, (kernel.len() / 2) as i64);
|
||||
let sum_to = |k: i64| sums.get(k.clamp(0, size) as usize).copied().unwrap_or(0.0);
|
||||
let (start, end) = (i64::from(start), i64::from(start) + i64::from(len));
|
||||
// Position i takes weight k from i + k - r, inside the span for k in [start - i + r, end - i + r).
|
||||
(0..i64::from(n)).map(|i| sum_to(end - i + r) - sum_to(start - i + r)).collect()
|
||||
}
|
||||
|
||||
/// The normalized kernel `image::imageops::blur` (0.25) uses for `sigma`: odd, about 6.7 σ wide.
|
||||
fn gaussian_kernel(sigma: f32) -> Vec<f32> {
|
||||
let size = ((((sigma - 0.8) / 0.3 + 1.0) * 2.0 + 1.0).max(3.0) as usize) | 1;
|
||||
let mean = (size / 2) as f32;
|
||||
let weights: Vec<f32> = (0..size).map(|x| (-0.5 * ((x as f32 - mean) / sigma).powi(2)).exp()).collect();
|
||||
let total: f32 = weights.iter().sum();
|
||||
weights.into_iter().map(|v| v / total).collect()
|
||||
}
|
||||
|
||||
/// The selected pictures, shapes, text boxes and groups: the one the selection holds, then
|
||||
@@ -1982,6 +2053,112 @@ mod tests {
|
||||
assert_eq!(s.run("arrange.selectionPane", &json!({})).unwrap().as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
|
||||
/// A PNG whose header (valid CRC) declares `w` × `h` pixels, with one pixel of data.
|
||||
fn header_only_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut b = png(8, 8);
|
||||
b[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
b[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &byte in &b[12..29] {
|
||||
crc ^= u32::from(byte);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 1 { 0xEDB8_8320 ^ (crc >> 1) } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
b[29..33].copy_from_slice(&(!crc).to_be_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_edits_refuse_an_over_budget_header_before_decoding() {
|
||||
// Inserting reads the size only; every edit then refuses it from the header (#3644, #3647).
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&header_only_png(20_000, 20_000))})).unwrap();
|
||||
let media = s.doc.media.len();
|
||||
for (id, v) in [
|
||||
("picture.corrections", json!({"brightness": 10})),
|
||||
("picture.color", json!({"mode": "sepia"})),
|
||||
("picture.removeBackground", json!({})),
|
||||
("picture.style", json!({"style": "shadow"})),
|
||||
] {
|
||||
let e = s.run(id, &v).unwrap_err().to_string();
|
||||
assert!(e.contains("too large to edit"), "{id}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.media.len(), media, "nothing was added");
|
||||
}
|
||||
|
||||
/// The Shadow style before #3647: the rectangle blurred with `image`'s Gaussian.
|
||||
fn blurred_shadow(img: &image::DynamicImage) -> image::RgbaImage {
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let off = (w.max(h) / 40).max(4);
|
||||
let mut out = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
let shadow = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 90]));
|
||||
let mut padded = image::RgbaImage::from_pixel(w + off * 2, h + off * 2, image::Rgba([0, 0, 0, 0]));
|
||||
image::imageops::overlay(&mut padded, &shadow, (off + off / 2) as i64, (off + off / 2) as i64);
|
||||
let blurred = image::DynamicImage::ImageRgba8(padded).blur(off as f32 / 2.0).to_rgba8();
|
||||
image::imageops::overlay(&mut out, &blurred, 0, 0);
|
||||
image::imageops::overlay(&mut out, &img.to_rgba8(), 0, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// #3647: the shadow drawn from two profiles looks like the blur it replaces.
|
||||
#[test]
|
||||
fn the_shadow_style_matches_the_blur_it_replaces() {
|
||||
for (w, h) in [(80, 40), (400, 220), (37, 300)] {
|
||||
let img = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, y| {
|
||||
image::Rgba([(x * 7) as u8, (y * 5) as u8, 90, if (x + y) % 3 == 0 { 255 } else { 120 }])
|
||||
}));
|
||||
let new = picture_style(img.clone(), "shadow").unwrap().to_rgba8();
|
||||
let old = blurred_shadow(&img);
|
||||
assert_eq!(new.dimensions(), old.dimensions());
|
||||
let worst = new.pixels().zip(old.pixels()).flat_map(|(a, b)| a.0.into_iter().zip(b.0).map(|(a, b)| a.abs_diff(b))).max();
|
||||
assert!(worst.is_some_and(|d| d <= 2), "{w} x {h}: {worst:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3647: Remove Background fills a span at a time, so its stack stays near the border's seeds
|
||||
/// instead of growing with the picture, and it clears the same region.
|
||||
#[test]
|
||||
fn remove_background_keeps_a_small_stack() {
|
||||
let mut uniform = image::RgbaImage::from_pixel(2000, 2000, image::Rgba([250, 250, 250, 255]));
|
||||
let most = clear_background(&mut uniform, 30.0);
|
||||
assert!(most <= 4 * (2000 + 2000) + 8, "{most}");
|
||||
assert!(uniform.pixels().all(|p| p.0[3] == 0));
|
||||
// A square ring keeps what it encloses: only background reachable from the border goes.
|
||||
let mut ring = image::RgbaImage::from_fn(60, 60, |x, y| {
|
||||
let d = (x as i32 - 30).abs().max((y as i32 - 30).abs());
|
||||
if (10..14).contains(&d) { image::Rgba([200, 20, 20, 255]) } else { image::Rgba([255, 255, 255, 255]) }
|
||||
});
|
||||
clear_background(&mut ring, 30.0);
|
||||
assert_eq!(ring.get_pixel(0, 0).0, [255, 255, 255, 0], "outside: cleared, colour kept");
|
||||
assert_eq!(ring.get_pixel(5, 30).0[3], 0);
|
||||
assert_eq!(ring.get_pixel(30, 18).0[3], 255, "the ring");
|
||||
assert_eq!(ring.get_pixel(30, 30).0[3], 255, "inside the ring");
|
||||
assert_eq!(clear_background(&mut image::RgbaImage::new(0, 0), 30.0), 0);
|
||||
}
|
||||
|
||||
/// #3611: a style or border is drawn only when the picture it makes can still be drawn. On a
|
||||
/// 1 × 2,000,000 picture Thick Frame asked for about 1.8 TB and aborted the process.
|
||||
#[test]
|
||||
fn picture_styles_and_borders_check_the_size_they_draw() {
|
||||
let mut thin = Vec::new();
|
||||
image::GrayImage::new(1, 2_000_000).write_to(&mut std::io::Cursor::new(&mut thin), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
s.run("insert.picture", &json!({"data": super::super::insert::base64_encode(&thin)})).unwrap();
|
||||
let media = s.doc.media.len();
|
||||
for (id, v) in [
|
||||
("picture.style", json!({"style": "thickFrame"})),
|
||||
("picture.style", json!({"style": "shadow"})),
|
||||
("picture.style", json!({})),
|
||||
("picture.border", json!({"width": 200})),
|
||||
] {
|
||||
let e = s.run(id, &v).unwrap_err().to_string();
|
||||
assert!(e.contains("too large to edit"), "{id} {v}: {e}");
|
||||
}
|
||||
assert_eq!(s.doc.media.len(), media, "nothing was drawn");
|
||||
assert_eq!(grown(u32::MAX, 1, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_pipeline() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
|
||||
@@ -398,7 +398,7 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult {
|
||||
};
|
||||
let para = s.doc.para_mut(a.story, &path)?;
|
||||
match num {
|
||||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }),
|
||||
Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) }),
|
||||
None => {
|
||||
para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab);
|
||||
para.props.cancel_char_indents(true, false, false);
|
||||
@@ -810,7 +810,7 @@ fn outline_step(s: &mut Session, promote: bool) -> CmdResult {
|
||||
let list = p.props.numbering.filter(|n| n.num != 0);
|
||||
let target = match (list, heading_level(s, &p.props)) {
|
||||
(Some(n), _) => {
|
||||
let level = if promote { n.level.saturating_sub(1) } else { (n.level + 1).min(8) };
|
||||
let level = if promote { n.level.saturating_sub(1) } else { n.level.saturating_add(1).min(8) };
|
||||
if level != n.level {
|
||||
let para = s.doc.para_mut(a.story, &path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level });
|
||||
|
||||
@@ -21,9 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
|
||||
if let Some((start, end)) = toc_range(s) {
|
||||
for i in (start..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
remove_toc(s, start, end)?;
|
||||
s.clamp_selection();
|
||||
}
|
||||
sel_result(s)
|
||||
@@ -339,6 +337,36 @@ fn toc_range(s: &Session) -> Option<(usize, usize)> {
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends
|
||||
/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its
|
||||
/// own just above the field's paragraph (Word's tables start the field after their heading) goes
|
||||
/// with the table.
|
||||
fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result<usize, CmdError> {
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
|
||||
Ok(p) if p.section.is_some() => {
|
||||
*p = Paragraph { section: p.section.take(), ..Paragraph::new() };
|
||||
start + 1
|
||||
}
|
||||
_ => {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(start))?;
|
||||
start
|
||||
}
|
||||
};
|
||||
let titled = start
|
||||
.checked_sub(1)
|
||||
.and_then(|i| s.doc.body.get(i))
|
||||
.and_then(|b| b.as_para())
|
||||
.is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none());
|
||||
if titled {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?;
|
||||
return Ok(at - 1);
|
||||
}
|
||||
Ok(at)
|
||||
}
|
||||
|
||||
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
|
||||
/// offers Update Field for the whole table.
|
||||
pub fn caret_in_toc(s: &Session) -> bool {
|
||||
@@ -393,15 +421,24 @@ fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
|
||||
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
|
||||
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
|
||||
let i = at.path.last();
|
||||
// Or one holding only the end of Word's TOC control, still styled as an entry: Update Table
|
||||
// would delete it with the old entries anyway.
|
||||
let leftover = |p: &Paragraph| {
|
||||
p.is_empty()
|
||||
|| (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd))
|
||||
&& p.text.chars().all(|c| c == OBJ)
|
||||
&& p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
};
|
||||
if at.path.depth() == 0
|
||||
&& i > 0
|
||||
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
|
||||
&& s.doc.para_at(&at).is_some_and(leftover)
|
||||
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
|
||||
// field was (the field is saved opening in the first entry), so the next save and update
|
||||
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
|
||||
// range TOC is reused the same way, so deleting it later deletes the TOC field.
|
||||
// range TOC is reused the same way, so deleting it later deletes the TOC field. So is one
|
||||
// ending the section before, as above entries in columns (#3889).
|
||||
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
|
||||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
|
||||
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty())
|
||||
})
|
||||
{
|
||||
s.doc.remove_block(StoryRef::Body, &at.path)?;
|
||||
@@ -474,9 +511,7 @@ fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
Some((start, end)) => {
|
||||
for i in (start..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
let start = remove_toc(s, start, end)?;
|
||||
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
|
||||
placeholder = true;
|
||||
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
|
||||
@@ -585,10 +620,13 @@ fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, V
|
||||
for i in (start + 1..=end).rev() {
|
||||
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
|
||||
}
|
||||
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
|
||||
// at the end of the document, possibly in a wider (landscape) section.
|
||||
let section = s.doc.section_index_of(start);
|
||||
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
|
||||
// The column width of the TOC's own section, not the caret's: after Select All the caret sits
|
||||
// at the end of the document, possibly in a wider (landscape) section. In columns, the first
|
||||
// column's, as Word does (#3889); after a field paragraph ending a section, the next section's.
|
||||
let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some());
|
||||
let section = s.doc.section_index_of(start + usize::from(after_break));
|
||||
let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w);
|
||||
let width = s.doc.sections().get(section).map_or_else(|| column(&super::page::sect(s)), |(_, x)| column(x));
|
||||
let entries = toc_entries(s, &f);
|
||||
let links: Vec<Option<String>> = if f.has('h') {
|
||||
let mut v = Vec::new();
|
||||
|
||||
@@ -141,9 +141,20 @@ fn new_comment(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(&text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
s.doc
|
||||
.comments
|
||||
.insert(id, Comment { author: s.author.clone(), initials, date: now_iso(), parent: None, resolved: false, part, ..Default::default() });
|
||||
let now = now_iso();
|
||||
s.doc.comments.insert(
|
||||
id,
|
||||
Comment {
|
||||
author: s.author.clone(),
|
||||
initials,
|
||||
date: now.clone(),
|
||||
date_utc: Some(now),
|
||||
parent: None,
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let props = Default::default();
|
||||
s.doc.insert_object(&b, InlineObject::CommentEnd { id }, &props)?;
|
||||
s.doc.insert_object(&a, InlineObject::CommentStart { id }, &props)?;
|
||||
@@ -170,9 +181,19 @@ fn reply(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let part = s.doc.try_add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, Default::default()))])?;
|
||||
let id = comment_id(s)?;
|
||||
let initials = s.user_initials();
|
||||
let now = now_iso();
|
||||
s.doc.comments.insert(
|
||||
id,
|
||||
Comment { author: s.author.clone(), initials, date: now_iso(), parent: Some(parent), resolved: false, part, ..Default::default() },
|
||||
Comment {
|
||||
author: s.author.clone(),
|
||||
initials,
|
||||
date: now.clone(),
|
||||
date_utc: Some(now),
|
||||
parent: Some(parent),
|
||||
resolved: false,
|
||||
part,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -363,20 +384,8 @@ fn nav_comment(s: &mut Session, dir: i32) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
|
||||
/// the section break on it), `props` the paragraph's own formatting change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_para(
|
||||
s: &mut Session,
|
||||
story: StoryRef,
|
||||
path: &wordcraft_doc::Path,
|
||||
from: usize,
|
||||
to: usize,
|
||||
mark: bool,
|
||||
props: bool,
|
||||
accept: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
/// Accept or reject the tracked formatting and text of `from..to` in one paragraph (not its mark).
|
||||
fn resolve_runs(para: &mut wordcraft_doc::Paragraph, from: usize, to: usize, props: bool, accept: bool) -> Result<(), CmdError> {
|
||||
if props && (para.props.fmt_change.is_some() || para.props.num_change.is_some()) {
|
||||
fmt_revisions::resolve_para(&mut para.props, accept);
|
||||
para.touch();
|
||||
@@ -397,6 +406,24 @@ fn resolve_para(
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accept or reject a revision range in one paragraph; `mark` includes its paragraph mark (and
|
||||
/// the section break on it), `props` the paragraph's own formatting change.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn resolve_para(
|
||||
s: &mut Session,
|
||||
story: StoryRef,
|
||||
path: &wordcraft_doc::Path,
|
||||
from: usize,
|
||||
to: usize,
|
||||
mark: bool,
|
||||
props: bool,
|
||||
accept: bool,
|
||||
) -> Result<(), CmdError> {
|
||||
let para = s.doc.para_mut(story, path)?;
|
||||
resolve_runs(para, from, to, props, accept)?;
|
||||
if !mark {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -513,6 +540,28 @@ pub fn move_changes(s: &Session) -> Vec<(Pos, String, bool)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Accept or reject the tracked text and paragraph marks of the top-level paragraphs in a block
|
||||
/// list that is not a story (a note separator).
|
||||
fn resolve_loose_blocks(blocks: &mut wordcraft_doc::Blocks, accept: bool) -> Result<(), CmdError> {
|
||||
for b in blocks.iter_mut() {
|
||||
let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b) else { continue };
|
||||
let len = p.len();
|
||||
resolve_runs(p, 0, len, true, accept)?;
|
||||
if p.mark.fmt_change.is_some() || p.section.as_ref().is_some_and(|x| x.fmt_change.is_some()) {
|
||||
fmt_revisions::resolve_char(&mut p.mark, accept);
|
||||
if let Some(sec) = p.section.as_deref_mut() {
|
||||
fmt_revisions::resolve_section(sec, accept);
|
||||
}
|
||||
}
|
||||
// A mark can't be removed here (no neighbour to join), so it just stops being a revision.
|
||||
p.mark.ins = None;
|
||||
p.mark.del = None;
|
||||
p.touch();
|
||||
}
|
||||
fmt_revisions::resolve_containers(blocks, accept, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect();
|
||||
for st in stories {
|
||||
@@ -527,6 +576,13 @@ fn resolve_all(s: &mut Session, accept: bool) -> CmdResult {
|
||||
fmt_revisions::resolve_containers(&mut part.blocks, accept, 0);
|
||||
}
|
||||
fmt_revisions::resolve_section(&mut s.doc.last_section, accept);
|
||||
// The note separators are block lists of their own, not stories; they must not keep ids of
|
||||
// revisions that are about to be dropped.
|
||||
for sep in [&mut s.doc.footnote_separators, &mut s.doc.endnote_separators] {
|
||||
for blocks in [&mut sep.separator, &mut sep.continuation_separator, &mut sep.continuation_notice].into_iter().flatten() {
|
||||
resolve_loose_blocks(blocks, accept)?;
|
||||
}
|
||||
}
|
||||
s.doc.revisions.clear();
|
||||
s.clamp_selection();
|
||||
sel_result(s)
|
||||
|
||||
@@ -243,14 +243,15 @@ fn autofit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pub(crate) fn sync_cell_widths(t: &mut Table) {
|
||||
let grid = t.grid.clone();
|
||||
for row in &mut t.rows {
|
||||
let mut g = 0usize;
|
||||
// A row's cells start after the grid columns it skips (`w:gridBefore`).
|
||||
let mut g = row.first_grid();
|
||||
for c in &mut row.cells {
|
||||
let span = c.span();
|
||||
let w: f32 = grid.get(g..g.saturating_add(span).min(grid.len())).unwrap_or(&[]).iter().sum();
|
||||
if w > 0.0 {
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
g += span;
|
||||
g = g.saturating_add(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,6 +517,9 @@ pub(super) fn col(s: &mut Session, right: bool) -> CmdResult {
|
||||
let k = before / total;
|
||||
t.grid.iter_mut().for_each(|w| *w *= k);
|
||||
}
|
||||
// The inserted cells carry the pre-scale column width, and the layout prefers a cell width
|
||||
// over the grid: sync them, or the shrink never reaches the layout or the saved `w:tcW`.
|
||||
sync_cell_widths(t);
|
||||
fix_caret(s, &tp, r, if right { c + 1 } else { c })
|
||||
}
|
||||
|
||||
|
||||
+169
-17
@@ -254,9 +254,12 @@ enum EnterAutoFormat {
|
||||
Border(Border),
|
||||
/// "+---+-----+": a one-row table; the dash count of each column.
|
||||
Table(Vec<usize>),
|
||||
/// A short line before an empty paragraph: Heading 1, 2 or 3.
|
||||
Heading(u8),
|
||||
}
|
||||
|
||||
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
|
||||
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". A line
|
||||
/// holding a tracked deletion stays as it is.
|
||||
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
let para = s.doc.para_at(at)?;
|
||||
let text = para.text.as_str();
|
||||
@@ -267,6 +270,7 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
|| text.len() < 3
|
||||
|| !text.chars().all(|x| x == c)
|
||||
|| para.props.numbering.is_some_and(|n| n.num != 0)
|
||||
|| para.run_ranges().any(|(_, r)| r.del.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -283,8 +287,8 @@ fn border_line(s: &Session, at: &Pos) -> Option<Border> {
|
||||
}
|
||||
|
||||
/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs
|
||||
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold
|
||||
/// leave the text alone.
|
||||
/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold,
|
||||
/// or a line holding a tracked deletion, leave the text alone.
|
||||
fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
let para = s.doc.para_at(at)?;
|
||||
let text = para.text.as_str();
|
||||
@@ -293,6 +297,7 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
|| at.path.0.len() != 1
|
||||
|| at.off != text.len()
|
||||
|| para.props.numbering.is_some_and(|n| n.num != 0)
|
||||
|| para.run_ranges().any(|(_, c)| c.del.is_some())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -307,8 +312,76 @@ fn table_line(s: &Session, at: &Pos) -> Option<Vec<usize>> {
|
||||
Some(cols)
|
||||
}
|
||||
|
||||
/// AutoFormat Built-in Heading styles: Enter in an empty body paragraph whose previous paragraph
|
||||
/// is a line of Normal text with one to five words that ends in a letter or digit and holds no
|
||||
/// tracked deletion. The line becomes Heading 1, or Heading 2 or 3 when it starts with one or
|
||||
/// two tabs.
|
||||
fn heading_line(s: &Session, at: &Pos) -> Option<u8> {
|
||||
let ac = &s.prefs.autocorrect;
|
||||
let cur = s.doc.para_at(at)?;
|
||||
let i = at.path.last();
|
||||
if !ac.enabled || !ac.heading_styles || at.story != StoryRef::Body || at.path.0.len() != 1 || i == 0 || !cur.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(Block::Para(prev)) = s.doc.block(at.story, &at.path.with_last(i - 1)) else { return None };
|
||||
// Normal and outside a list, also one the style sheet gives (a numbered Normal style).
|
||||
let plain = |p: &wordcraft_doc::Paragraph| {
|
||||
let r = s.doc.styles.resolve_para(&p.props);
|
||||
p.props.style.as_deref().is_none_or(|st| st == "Normal") && r.numbering.is_none_or(|n| n.num == 0) && r.outline_level.is_none_or(|l| l >= 9)
|
||||
};
|
||||
let text = prev.text.trim_start_matches('\t');
|
||||
let tabs = prev.text.len() - text.len();
|
||||
if !plain(cur)
|
||||
|| !plain(prev)
|
||||
|| !prev.objects.is_empty()
|
||||
|| prev.section.is_some()
|
||||
|| prev.run_ranges().any(|(_, c)| c.del.is_some())
|
||||
|| tabs > 2
|
||||
|| !(1..=5).contains(&text.split_whitespace().count())
|
||||
|| text.starts_with(char::is_whitespace)
|
||||
|| text.contains(char::is_control)
|
||||
|| !text.ends_with(char::is_alphanumeric)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let level = tabs as u8 + 1;
|
||||
// Formatting limited to the document's styles: a locked heading style isn't applied.
|
||||
let id = format!("Heading{level}");
|
||||
if super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting && s.doc.styles.get(&id).is_some_and(|st| st.locked) {
|
||||
return None;
|
||||
}
|
||||
Some(level)
|
||||
}
|
||||
|
||||
/// Give the paragraph before the empty one at `at` Heading `level` without its leading tabs;
|
||||
/// the caret stays in the empty paragraph. With Track Changes on, the tabs are marked deleted
|
||||
/// (or removed, when this author typed them) and the style change is a formatting revision.
|
||||
fn apply_heading_line(s: &mut Session, at: &Pos, level: u8) -> Result<(), CmdError> {
|
||||
let prev = at.path.with_last(at.path.last().saturating_sub(1));
|
||||
let Some(before) = s.doc.para(at.story, &prev).cloned() else { return Ok(()) };
|
||||
let tabs = before.text.len() - before.text.trim_start_matches('\t').len();
|
||||
let start = Pos { story: at.story, path: prev.clone(), off: 0 };
|
||||
s.sel = Selection { anchor: start.clone(), focus: Pos { off: tabs, ..start } };
|
||||
delete_selection(s)?;
|
||||
super::para::set_heading(s, at.story, &prev, Some(level))?;
|
||||
// Removed tabs change the text, and the formatting comparison after the command only looks
|
||||
// at paragraphs that keep theirs, so the style change is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& s.doc.para(at.story, &prev).is_some_and(|p| p.text != before.text && p.props.fmt_change.is_none())
|
||||
{
|
||||
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &prev)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.props.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(at.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Turn the border-line characters into a bottom border on the paragraph above (or this one
|
||||
/// at the top of a story).
|
||||
/// at the top of a story). With Track Changes on, the characters are marked deleted (or
|
||||
/// removed, when this author typed them) and the border is a formatting revision.
|
||||
fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> {
|
||||
let set_bottom = |p: &mut wordcraft_doc::Paragraph| {
|
||||
let mut b = p.props.borders.unwrap_or_default();
|
||||
@@ -318,31 +391,52 @@ fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdE
|
||||
};
|
||||
let i = at.path.last();
|
||||
let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None };
|
||||
let cur = s.doc.para_mut(at.story, &at.path)?;
|
||||
let len = cur.len();
|
||||
cur.delete(0, len)?;
|
||||
let start = Pos { off: 0, ..at.clone() };
|
||||
let end = delete_typed_line(s, at)?;
|
||||
match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) {
|
||||
Some(q) => {
|
||||
// The characters' paragraph becomes the one typing continues in.
|
||||
set_bottom(s.doc.para_mut(at.story, &q)?);
|
||||
s.sel = Selection::caret(start);
|
||||
s.sel = Selection::caret(end);
|
||||
}
|
||||
None => {
|
||||
let before = s.doc.para(at.story, &at.path).map(|p| p.props.clone());
|
||||
set_bottom(s.doc.para_mut(at.story, &at.path)?);
|
||||
let new = split_para(s, &start)?;
|
||||
let new = split_para(s, &end)?;
|
||||
let p = s.doc.para_mut(new.story, &new.path)?;
|
||||
p.props.borders = None;
|
||||
p.touch();
|
||||
// The split changes the block count, and the formatting comparison after the command
|
||||
// skips a container whose block count changed, so the border is recorded here.
|
||||
if s.doc.settings.track_changes
|
||||
&& s.prefs.markup.track_formatting
|
||||
&& let Some(before) = before.filter(|b| b.fmt_change.is_none())
|
||||
{
|
||||
let rev = super::new_revision(s, wordcraft_doc::RevisionKind::Format);
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
p.props.fmt_change = wordcraft_doc::props::PropChange::boxed(rev, before.formatting());
|
||||
p.touch();
|
||||
}
|
||||
s.sel = Selection::caret(new);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the AutoFormat characters of the paragraph at `at`, tracked when Track Changes is on.
|
||||
/// Returns the end of what is left: the paragraph's end, after any characters marked deleted.
|
||||
fn delete_typed_line(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
|
||||
let len = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
|
||||
s.sel = Selection { anchor: Pos { off: 0, ..at.clone() }, focus: Pos { off: len, ..at.clone() } };
|
||||
delete_selection(s)?;
|
||||
let left = s.doc.para(at.story, &at.path).map_or(0, |p| p.len());
|
||||
Ok(Pos { off: left, ..at.clone() })
|
||||
}
|
||||
|
||||
/// Replace the "+---+" characters with a one-row table across the text width, each column as
|
||||
/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret
|
||||
/// goes into the first cell.
|
||||
/// goes into the first cell. With Track Changes on, the characters are marked deleted (or
|
||||
/// removed, when this author typed them); the table is inserted untracked, as Insert › Table
|
||||
/// inserts one.
|
||||
fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> {
|
||||
let total = cols.iter().sum::<usize>().max(1) as f32;
|
||||
let block = at.path.0.first().copied().unwrap_or(0) as usize;
|
||||
@@ -357,9 +451,7 @@ fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), Cmd
|
||||
c.props.width = Some(w);
|
||||
}
|
||||
}
|
||||
let p = s.doc.para_mut(at.story, &at.path)?;
|
||||
let len = p.len();
|
||||
p.delete(0, len)?;
|
||||
delete_typed_line(s, at)?;
|
||||
s.doc.insert_block(at.story, &at.path, Block::Table(t))?;
|
||||
let mut first = at.path.0.clone();
|
||||
first.extend([0, 0, 0]);
|
||||
@@ -607,7 +699,10 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
return sel_result(s);
|
||||
}
|
||||
let mut at = delete_selection(s)?;
|
||||
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
|
||||
let auto = border_line(s, &at)
|
||||
.map(EnterAutoFormat::Border)
|
||||
.or_else(|| table_line(s, &at).map(EnterAutoFormat::Table))
|
||||
.or_else(|| heading_line(s, &at).map(EnterAutoFormat::Heading));
|
||||
if let Some(auto) = auto {
|
||||
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
|
||||
// and the Enter stays. Record that plain Enter as its own undo step.
|
||||
@@ -619,6 +714,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
match auto {
|
||||
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
|
||||
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
|
||||
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
|
||||
}
|
||||
s.goal_x = None;
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
@@ -636,7 +732,9 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
|
||||
/// left- and first-indent with tabs and backspaces" on, at the start of a line of a paragraph with
|
||||
/// text: indent. Else a tab.
|
||||
fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
@@ -666,10 +764,13 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
&& n.num != 0
|
||||
{
|
||||
let para = s.doc.para_mut(f.story, &f.path)?;
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) });
|
||||
para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
para.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
|
||||
return indent_by_tab(s, &f);
|
||||
}
|
||||
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
|
||||
// Multiple paragraphs selected: increase indent.
|
||||
return super::para::indent(s, &Value::Null);
|
||||
@@ -677,6 +778,52 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
ins_char(s, '\t')
|
||||
}
|
||||
|
||||
/// Is AutoFormat As You Type's "Set left- and first-indent with tabs and backspaces" on? Not
|
||||
/// while the document limits formatting to its styles: the indents are direct formatting.
|
||||
fn tab_indent_on(s: &Session) -> bool {
|
||||
let ac = &s.prefs.autocorrect;
|
||||
ac.enabled && ac.tab_indent && !(super::protect::enforced(&s.doc) && s.doc.settings.protect_formatting)
|
||||
}
|
||||
|
||||
/// Does Tab at `f` indent instead of typing a tab? Only at the start of a line of a paragraph
|
||||
/// with text. Not before the first line of a list item numbered by its style, where Tab types a
|
||||
/// tab character as with the option off.
|
||||
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
|
||||
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
|
||||
if f.off == 0 {
|
||||
return !s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0);
|
||||
}
|
||||
let l = s.layout();
|
||||
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
|
||||
}
|
||||
|
||||
/// Tab before the first line indents the first line by one default tab stop; before any other
|
||||
/// line, or when the first line is indented or hanging already, it indents the whole paragraph.
|
||||
/// Before a later line of a list item, numbered directly or by its style, it takes the item down
|
||||
/// a level.
|
||||
fn indent_by_tab(s: &mut Session, f: &Pos) -> CmdResult {
|
||||
let Some(para) = s.doc.para_at(f) else { return sel_result(s) };
|
||||
let r = s.doc.styles.resolve_para(¶.props);
|
||||
if f.off > 0
|
||||
&& para.props.numbering.is_none()
|
||||
&& let Some(n) = r.numbering.filter(|n| n.num != 0)
|
||||
{
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_add(1).min(8) });
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if f.off == 0 && r.indent_first == 0.0 && r.indent_chars[2] == 0.0 {
|
||||
let tab = s.doc.settings.default_tab.max(1.0);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
p.props.indent_first = Some(tab);
|
||||
p.props.cancel_char_indents(false, false, true);
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
super::para::indent(s, &Value::Null)
|
||||
}
|
||||
|
||||
fn back_tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some((tpath, r, c)) = s.sel.focus.path.cell() {
|
||||
let story = s.sel.focus.story;
|
||||
@@ -770,6 +917,11 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
}
|
||||
if tab_indent_on(s) && (para.props.indent_left.is_some_and(|x| x > 0.0) || para.props.indent_left_chars.is_some_and(|x| x > 0.0)) {
|
||||
// Set left- and first-indent with tabs and backspaces: the paragraph's own left indent
|
||||
// (in points or characters) goes back one tab stop at a time before the paragraphs join.
|
||||
return super::para::outdent(s, &Value::Null);
|
||||
}
|
||||
let i = f.path.last();
|
||||
if i == 0 {
|
||||
return sel_result(s); // start of a story or cell
|
||||
|
||||
@@ -103,6 +103,14 @@ pub struct AutoCorrectPrefs {
|
||||
pub numbering: bool,
|
||||
/// Border lines.
|
||||
pub border_lines: bool,
|
||||
/// Set left- and first-indent with tabs and backspaces: Tab at the start of a line of a
|
||||
/// paragraph with text indents it instead of typing a tab, and Backspace at its start takes
|
||||
/// the indent back. Off by default.
|
||||
pub tab_indent: bool,
|
||||
/// Built-in Heading styles: a second Enter after a line of five or fewer words that ends in a
|
||||
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
|
||||
/// Off by default.
|
||||
pub heading_styles: bool,
|
||||
/// The user's entries: added, or replacing a built-in one.
|
||||
pub entries: Vec<(String, String)>,
|
||||
/// Built-in entries the user deleted.
|
||||
@@ -138,6 +146,8 @@ impl Default for AutoCorrectPrefs {
|
||||
bullets: true,
|
||||
numbering: true,
|
||||
border_lines: true,
|
||||
tab_indent: false,
|
||||
heading_styles: false,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
@@ -327,6 +337,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("bullets", &mut ac.bullets),
|
||||
("numbering", &mut ac.numbering),
|
||||
("borderLines", &mut ac.border_lines),
|
||||
("tabIndent", &mut ac.tab_indent),
|
||||
("headingStyles", &mut ac.heading_styles),
|
||||
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
|
||||
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
|
||||
] {
|
||||
@@ -400,6 +412,8 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"bullets": ac.bullets,
|
||||
"numbering": ac.numbering,
|
||||
"borderLines": ac.border_lines,
|
||||
"tabIndent": ac.tab_indent,
|
||||
"headingStyles": ac.heading_styles,
|
||||
"autoAddFirstLetter": ac.auto_add_first_letter,
|
||||
"autoAddInitialCaps": ac.auto_add_initial_caps,
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
@@ -437,7 +451,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
@@ -466,18 +480,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
.params(r#"{"mode": "none|readOnly|comments|trackedChanges"} (starting enforcement; "none" doesn't stop a protected document — use review.restrict {"stop": true})"#),
|
||||
CommandSpec::new("file.versions", "Version History", "File › Info", |s, v| {
|
||||
// Validate a restore before anything is saved: a rejected command must leave the
|
||||
// version list as it was. A `restore` that is there but not a non-negative integer
|
||||
// is refused, not read as absent.
|
||||
let restore = if v.get("restore").is_some_and(|x| !x.is_null()) {
|
||||
let i = p::u64(v, "restore").ok_or_else(|| CmdError::Params("restore (non-negative integer) is required".into()))?;
|
||||
// A `u64` that doesn't fit in `usize` names no version: `as usize` would wrap it
|
||||
// to a small index on a 32-bit target and restore the wrong document.
|
||||
let i = usize::try_from(i).map_err(|_| CmdError::Params("no such version".into()))?;
|
||||
let version = s.versions.get(i).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
|
||||
// The restore replaces the whole document, including the content the protection
|
||||
// kept read-only, so the protection is stopped first (with its password, when it
|
||||
// has one) — a restore must not go around it.
|
||||
super::protect::check_version_restore(s)?;
|
||||
Some(version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(label) = p::str(v, "save") {
|
||||
s.versions.push((label.to_string(), super::now_iso(), s.doc.clone()));
|
||||
if s.versions.len() > 50 {
|
||||
s.versions.remove(0);
|
||||
}
|
||||
}
|
||||
if let Some(i) = p::u64(v, "restore") {
|
||||
let (_, _, d) = s.versions.get(i as usize).cloned().ok_or_else(|| CmdError::Params("no such version".into()))?;
|
||||
// The restore replaces the whole document, including the content the protection
|
||||
// kept read-only, so the protection is stopped first (with its password, when it
|
||||
// has one) — a restore must not go around it.
|
||||
super::protect::check_version_restore(s)?;
|
||||
if let Some((_, _, d)) = restore {
|
||||
s.checkpoint("Restore Version");
|
||||
// A snapshot's own protection is stale: it was taken at some earlier moment, and
|
||||
// restoring it must not bring a protection back that the live document doesn't
|
||||
@@ -875,7 +901,9 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError>
|
||||
}
|
||||
let trigger = if on_enter { 0 } else { last.len_utf8() };
|
||||
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
|
||||
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
|
||||
// The word starts after the last white-space character, whatever its width (a no-break
|
||||
// space is two bytes and an ideographic space is three).
|
||||
let start = body.char_indices().rev().find(|(_, c)| c.is_whitespace()).map(|(i, c)| i + c.len_utf8()).unwrap_or(0);
|
||||
let Some(mut word) = body.get(start..).map(str::to_string) else { return Ok(()) };
|
||||
if word.is_empty() {
|
||||
return Ok(());
|
||||
|
||||
@@ -37,6 +37,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("view.readLineSpacing", "Reading Line Spacing", "Reading › Layout", read_line_spacing)
|
||||
.params(r#"{"value": "document" | 1.0 | 1.25 | 1.5 | 2.0}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.readPageColor", "Reading Page Color", "Reading › Layout", read_page_color)
|
||||
.params(r#"{"value": "none" | "sepia" | "inverse"}"#)
|
||||
.pure(),
|
||||
// Print Layout: hide the white space (margins, headers, footers) between pages. Screen only.
|
||||
CommandSpec::new("view.hideWhiteSpace", "Hide White Space", "View › Show", |s, v| toggle(s, v, |x| &mut x.hide_white_space))
|
||||
.params(r#"{"value"?: bool}"#)
|
||||
@@ -171,6 +174,7 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
s.view.read_column_width = None;
|
||||
s.view.read_zoom = 1.0;
|
||||
s.view.read_line_spacing = None;
|
||||
s.view.read_page_color = ReadPageColor::None;
|
||||
}
|
||||
s.view.mode = m;
|
||||
s.view.read_mode = read;
|
||||
@@ -213,6 +217,31 @@ fn read_line_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"spacing": spacing}))
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color, as in Word. Screen only: the document and its own page color
|
||||
/// are unchanged.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ReadPageColor {
|
||||
/// White paper; the document's page color and page picture are not shown.
|
||||
#[default]
|
||||
None,
|
||||
/// Sepia paper (Word's RGB 112, 66, 20 at 80% transparency over white); the text keeps its colors.
|
||||
Sepia,
|
||||
/// A dark page with light text, as View › Switch Modes draws it.
|
||||
Inverse,
|
||||
}
|
||||
|
||||
fn read_page_color(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let color = match v.get("value").and_then(Value::as_str) {
|
||||
Some("none") => ReadPageColor::None,
|
||||
Some("sepia") => ReadPageColor::Sepia,
|
||||
Some("inverse") => ReadPageColor::Inverse,
|
||||
_ => return Err(CmdError::Params("reading page color must be none, sepia, or inverse".into())),
|
||||
};
|
||||
s.view.read_page_color = color;
|
||||
Ok(json!({"pageColor": color}))
|
||||
}
|
||||
|
||||
/// The zoom a document is saved with (#995): the fit mode, else the percentage; `None` at 100%.
|
||||
pub(crate) fn saved_zoom(v: &crate::ViewState) -> Option<Zoom> {
|
||||
let fit = match v.fit.as_str() {
|
||||
|
||||
@@ -202,6 +202,8 @@ mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_fonts;
|
||||
#[cfg(test)]
|
||||
mod tests_ime_noop;
|
||||
#[cfg(test)]
|
||||
mod tests_revision_checks;
|
||||
#[cfg(test)]
|
||||
mod tests_typing;
|
||||
|
||||
@@ -39,7 +39,11 @@ pub fn parse_page_range(spec: &str, total: usize) -> Result<Vec<usize>, String>
|
||||
continue;
|
||||
}
|
||||
let page = |t: &str| -> Result<usize, String> {
|
||||
let n: usize = t.parse().map_err(|_| format!("`{item}` isn't a page or a range like 2-4"))?;
|
||||
let not_a_page = || format!("`{item}` isn't a page or a range like 2-4");
|
||||
if !t.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(not_a_page());
|
||||
}
|
||||
let n: usize = t.parse().map_err(|_| not_a_page())?;
|
||||
if n == 0 || n > total {
|
||||
return Err(format!("page {n} doesn't exist; the document has {total} page{}", if total == 1 { "" } else { "s" }));
|
||||
}
|
||||
@@ -194,7 +198,7 @@ mod tests {
|
||||
assert_eq!(parse_page_range("5,1-2,2", 6), Ok(vec![0, 1, 4]), "document order, no repeats");
|
||||
assert_eq!(parse_page_range("4-", 6), Ok(vec![3, 4, 5]));
|
||||
assert_eq!(parse_page_range("-2", 6), Ok(vec![0, 1]));
|
||||
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", &"1,".repeat(400)] {
|
||||
for bad in ["", " , ", "0", "7", "3-1", "a", "1-2-3", "99999999999999999999999", "-", "1..3", "+3", "1-+3", "+2-+4", &"1,".repeat(400)] {
|
||||
assert!(parse_page_range(bad, 6).is_err(), "`{bad}` is refused");
|
||||
}
|
||||
assert!(parse_page_range("1", 0).is_err());
|
||||
|
||||
+166
-16
@@ -178,6 +178,9 @@ pub struct ViewState {
|
||||
pub read_zoom: f32,
|
||||
#[serde(skip)]
|
||||
pub read_line_spacing: Option<f32>,
|
||||
/// Read Mode's Page Color: the paper the reading columns are drawn on.
|
||||
#[serde(skip)]
|
||||
pub read_page_color: crate::cmd::view::ReadPageColor,
|
||||
pub dark_mode: bool,
|
||||
pub show_markup: bool,
|
||||
pub track_changes_pane: bool,
|
||||
@@ -265,6 +268,7 @@ impl Default for ViewState {
|
||||
read_column_width: None,
|
||||
read_zoom: default_read_zoom(),
|
||||
read_line_spacing: None,
|
||||
read_page_color: Default::default(),
|
||||
dark_mode: false,
|
||||
show_markup: true,
|
||||
track_changes_pane: false,
|
||||
@@ -324,8 +328,9 @@ struct Undo {
|
||||
/// What [`Session::restore`] needs to take commands back exactly: the document, the selection,
|
||||
/// the undo and redo stacks (also the oldest undo steps the undo limit pushes out meanwhile), the
|
||||
/// open typing group, the pending
|
||||
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises
|
||||
/// and the dirty flag. See [`Session::edit_snapshot`].
|
||||
/// [`Session::join_next_undo`]/[`Session::join_next_equation_edit`]/body IME replacement promises,
|
||||
/// whether the group has a step of its own, and the dirty flag. See
|
||||
/// [`Session::edit_snapshot`].
|
||||
pub struct EditSnapshot {
|
||||
doc: Document,
|
||||
sel: Selection,
|
||||
@@ -341,6 +346,7 @@ pub struct EditSnapshot {
|
||||
equation_join_next: bool,
|
||||
/// The body `DeleteSurrounding` replacement promise then, with the exact caret it expects.
|
||||
ime_text_join: Option<Selection>,
|
||||
join_anchor: bool,
|
||||
dirty: bool,
|
||||
/// The body's replacement epoch then: a previewed replacement that is rolled back is not a
|
||||
/// replacement after all.
|
||||
@@ -373,6 +379,8 @@ pub struct Session {
|
||||
pub view: ViewState,
|
||||
/// Formatting picked with a collapsed caret, applied to the next typed text.
|
||||
pub pending: Option<CharProps>,
|
||||
/// Last highlighter choice for the shortcut; palette state, outside document undo.
|
||||
pub(crate) last_highlight: wordcraft_doc::props::Highlight,
|
||||
pub path: Option<std::path::PathBuf>,
|
||||
/// Name given to a document not saved yet (File › Info › Rename): what Save suggests. A file
|
||||
/// name, not document metadata, so it is not part of the document or its undo history.
|
||||
@@ -431,6 +439,10 @@ pub struct Session {
|
||||
/// `DeleteSurrounding`'s undo step. Any other command consumes and discards it, so an
|
||||
/// intervening edit owns its own checkpoint instead of being merged into the deletion.
|
||||
ime_text_join: Option<Selection>,
|
||||
/// Whether the undo group has a step of its own for the next joined command to merge
|
||||
/// into. A command that changed nothing leaves the group without one, so the next
|
||||
/// command in the group starts the step the group needs.
|
||||
join_anchor: bool,
|
||||
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
|
||||
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
|
||||
rev: u64,
|
||||
@@ -742,10 +754,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an editing session at the document beginning with empty history and
|
||||
/// default view, preferences and a yellow highlighter independent of document undo.
|
||||
pub fn new(doc: Document) -> Self {
|
||||
Self::with_registry(doc, Arc::new(crate::cmd::registry()))
|
||||
}
|
||||
|
||||
/// Initialise session defaults while sharing the immutable command registry with previews.
|
||||
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
@@ -754,6 +769,7 @@ impl Session {
|
||||
sel: Selection::caret(start),
|
||||
view: ViewState::default(),
|
||||
pending: None,
|
||||
last_highlight: wordcraft_doc::props::Highlight::Yellow,
|
||||
path: None,
|
||||
file_name: None,
|
||||
user_dict_path: None,
|
||||
@@ -780,6 +796,7 @@ impl Session {
|
||||
join_next: false,
|
||||
equation_join_next: false,
|
||||
ime_text_join: None,
|
||||
join_anchor: false,
|
||||
autocap: None,
|
||||
rev: 1,
|
||||
protect_facts: std::sync::Mutex::new(None),
|
||||
@@ -1098,6 +1115,7 @@ impl Session {
|
||||
copy.path = self.path.clone();
|
||||
copy.file_name = self.file_name.clone();
|
||||
copy.author = self.author.clone();
|
||||
copy.last_highlight = self.last_highlight;
|
||||
copy.system_region = self.system_region.clone();
|
||||
copy.editing_lang = self.editing_lang.clone();
|
||||
// The copy shares the paragraph layouts, so only what the command changed is laid out.
|
||||
@@ -1163,10 +1181,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot for undo before a change.
|
||||
pub fn checkpoint(&mut self, label: &str) {
|
||||
/// Snapshot for undo before a change. Returns whether it took a step: typing that is
|
||||
/// already in an open step is not a new one.
|
||||
pub fn checkpoint(&mut self, label: &str) -> bool {
|
||||
if label == "Typing" && self.typing_open {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
self.typing_open = label == "Typing";
|
||||
self.history.push(Arc::new(Undo {
|
||||
@@ -1181,6 +1200,9 @@ impl Session {
|
||||
self.undo_evicted += 1;
|
||||
}
|
||||
self.redo.clear();
|
||||
// The newest step is what a joined command merges into.
|
||||
self.join_anchor = true;
|
||||
true
|
||||
}
|
||||
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
|
||||
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
|
||||
@@ -1191,6 +1213,7 @@ impl Session {
|
||||
self.undo_evicted += 1;
|
||||
}
|
||||
self.typing_open = false;
|
||||
self.join_anchor = true;
|
||||
}
|
||||
/// Make the next mutating command part of the previous undo step instead of a new one, so a
|
||||
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
|
||||
@@ -1372,6 +1395,7 @@ impl Session {
|
||||
self.history.clear();
|
||||
self.redo.clear();
|
||||
self.typing_open = false;
|
||||
self.join_anchor = false;
|
||||
}
|
||||
|
||||
/// The version snapshots belong to the document they were taken from: keeping the old ones
|
||||
@@ -1400,6 +1424,18 @@ impl Session {
|
||||
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
|
||||
}
|
||||
|
||||
/// The Spanish of the region in effect (`es-MX` on a Mexican system, `es-UY` on a Uruguayan
|
||||
/// one; the ISO 639-2 `spa` counts as Spanish), or Spain's (`es-ES`) when the region's
|
||||
/// language isn't Spanish or it names no territory. A Spanish interface writes new documents in it, so they are proofed in the
|
||||
/// writer's Spanish from the first word (#3360).
|
||||
pub fn region_spanish(&self) -> String {
|
||||
let r = self.region();
|
||||
(matches!(r.language.as_str(), "es" | "spa") && !r.territory.is_empty())
|
||||
.then(|| format!("es-{}", r.territory))
|
||||
.and_then(|t| wordcraft_proof::lang::normalize_tag(&t))
|
||||
.unwrap_or_else(|| "es-ES".into())
|
||||
}
|
||||
|
||||
/// The unit rulers and dialogs show lengths in: the user's choice, else the region's.
|
||||
pub fn units(&self) -> wordcraft_geom::Unit {
|
||||
self.prefs.units.unwrap_or_else(|| self.region().units())
|
||||
@@ -1452,8 +1488,9 @@ impl Session {
|
||||
/// may want to take it back without a trace (cheap: the document's blocks are shared).
|
||||
///
|
||||
/// Covered: `doc`, `sel`, the undo stack (its length, and its oldest steps when it is near
|
||||
/// the limit), `redo`, the open typing group, the pending `join_next_undo` and
|
||||
/// `join_next_equation_edit` promises and `dirty`. `rev`,
|
||||
/// the limit), `redo`, the open typing group, the pending `join_next_undo`,
|
||||
/// `join_next_equation_edit` and body replacement promises, whether the typing group has a
|
||||
/// step of its own, and `dirty`. `rev`,
|
||||
/// `cache` and `layout` are derived (`restore` bumps `rev`). The other fields are not
|
||||
/// covered: a caller that lets the command change them (the view, the find state, the
|
||||
/// clipboard, macros, the file path…) saves and puts them back itself.
|
||||
@@ -1471,6 +1508,7 @@ impl Session {
|
||||
sel,
|
||||
view: _,
|
||||
pending: _,
|
||||
last_highlight: _,
|
||||
path: _,
|
||||
file_name: _,
|
||||
// The custom dictionary file, like the file path: host wiring, not an edit.
|
||||
@@ -1500,6 +1538,7 @@ impl Session {
|
||||
join_next,
|
||||
equation_join_next,
|
||||
ime_text_join,
|
||||
join_anchor,
|
||||
autocap: _,
|
||||
rev: _,
|
||||
protect_facts: _,
|
||||
@@ -1566,6 +1605,7 @@ impl Session {
|
||||
join_next: *join_next,
|
||||
equation_join_next: *equation_join_next,
|
||||
ime_text_join: ime_text_join.clone(),
|
||||
join_anchor: *join_anchor,
|
||||
dirty: *dirty,
|
||||
ime_body_epoch: *ime_body_epoch,
|
||||
}
|
||||
@@ -1586,6 +1626,7 @@ impl Session {
|
||||
join_next,
|
||||
equation_join_next,
|
||||
ime_text_join,
|
||||
join_anchor,
|
||||
dirty,
|
||||
ime_body_epoch,
|
||||
} = snap;
|
||||
@@ -1606,6 +1647,7 @@ impl Session {
|
||||
self.equation_join_next = equation_join_next;
|
||||
self.ime_text_join = ime_text_join;
|
||||
self.ime_body_epoch = ime_body_epoch;
|
||||
self.join_anchor = join_anchor;
|
||||
self.touch();
|
||||
self.dirty = dirty;
|
||||
}
|
||||
@@ -1638,6 +1680,26 @@ impl Session {
|
||||
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
|
||||
}
|
||||
|
||||
/// Put the revision back to `rev` after a command that changed nothing or failed. What the
|
||||
/// command cached at the revision it touched to is dropped: that number comes round again
|
||||
/// with the next edit, for a different document.
|
||||
fn restore_rev(&mut self, rev: u64) {
|
||||
if self.rev == rev {
|
||||
return;
|
||||
}
|
||||
self.rev = rev;
|
||||
if self.layout.as_ref().is_some_and(|l| l.0 != rev) {
|
||||
self.layout = None;
|
||||
}
|
||||
if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) {
|
||||
self.find.highlights = None;
|
||||
}
|
||||
let facts = self.protect_facts.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if facts.as_ref().is_some_and(|((_, r, _), _)| *r != rev) {
|
||||
*facts = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// [`crate::cmd::protect::Facts`] for the document as it is now, cached per revision.
|
||||
fn protect_facts(&self) -> crate::cmd::protect::Facts {
|
||||
let key = (self.document_id, self.rev, self.author.clone());
|
||||
@@ -1729,6 +1791,15 @@ impl Session {
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether the command changed nothing: the document, the selection, the formatting
|
||||
/// waiting at the caret and the column selection are as they were before it ran.
|
||||
///
|
||||
/// A command that changes nothing is not an edit: it adds no undo step, marks no
|
||||
/// unsaved changes, forces no relayout and leaves the redo stack alone (#23).
|
||||
fn unchanged(&self, doc: &Document, sel: &Selection, column: &Option<ColumnBlock>, pending: &Option<CharProps>) -> bool {
|
||||
doc.same_content(&self.doc) && *sel == self.sel && *column == self.column && *pending == self.pending
|
||||
}
|
||||
|
||||
/// Run a command by id with JSON params (the single entry point for the UI, CLI, MCP and
|
||||
/// control channel). Mutating commands are undoable; a failed command leaves the document as
|
||||
/// it was; a panic inside a command becomes an error.
|
||||
@@ -1805,10 +1876,25 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The formatting waiting at the caret (⌘B with nothing selected): an undo step restores
|
||||
// it, so a command that only changes it is an edit.
|
||||
let pending_before = spec.mutates.then(|| self.pending.clone());
|
||||
// Which document is open. A command that replaces the document (a mail merge) opens a
|
||||
// new one, with a new undo history: it is not an edit of this document, whatever the
|
||||
// new document holds.
|
||||
let document_id_before = self.document_id;
|
||||
// Whether the document has unsaved changes, and the revision its layout is keyed on: a
|
||||
// command that changes nothing leaves both as they were, and a failed one leaves no
|
||||
// trace.
|
||||
let dirty_before = spec.mutates.then_some(self.dirty);
|
||||
let rev_restore = spec.mutates.then_some(self.rev);
|
||||
// These mutating commands can replace the open document and discard its saved versions.
|
||||
// Keep the versions until the command and the protected-region post-check have both
|
||||
// succeeded; rejected commands must leave the old document and its snapshots together.
|
||||
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.finish" | "mailings.envelopes" | "mailings.labels");
|
||||
// `mailings.finish` with a path saves the merged copy there and leaves the open
|
||||
// document alone, so it replaces nothing.
|
||||
let replaces_document = matches!(id, "review.compare" | "review.combine" | "mailings.envelopes" | "mailings.labels")
|
||||
|| (id == "mailings.finish" && crate::p::str(params, "path").is_none());
|
||||
let versions_before = replaces_document.then(|| self.versions.clone());
|
||||
let identity_before = replaces_document.then(|| {
|
||||
(
|
||||
@@ -1825,10 +1911,15 @@ impl Session {
|
||||
if spec.mutates && !typing {
|
||||
self.typing_open = false;
|
||||
}
|
||||
// Whether this command pushed an undo step (a no-op command's step is dropped below,
|
||||
// leaving its undo group without one).
|
||||
let mut pushed = false;
|
||||
if spec.mutates {
|
||||
let label = if typing { "Typing" } else { spec.label };
|
||||
if !join || self.history.is_empty() {
|
||||
self.checkpoint(label);
|
||||
// A joined command merges into its group's step; a group whose first command
|
||||
// changed nothing has none, so the next command in it starts one (#4001).
|
||||
if !join || self.history.is_empty() || !self.join_anchor {
|
||||
pushed = self.checkpoint(label);
|
||||
}
|
||||
} else if !spec.id.starts_with("caret.") && !spec.id.starts_with("view.") {
|
||||
// Non-mutating commands other than caret movement keep the typing group.
|
||||
@@ -1866,18 +1957,64 @@ impl Session {
|
||||
}
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
let mut no_op = false;
|
||||
if spec.mutates {
|
||||
// Formatting changed while tracking becomes a formatting revision.
|
||||
if let Some((before, ..)) = &before_doc {
|
||||
crate::cmd::fmt_revisions::record(self, id, before);
|
||||
}
|
||||
self.touch();
|
||||
self.doc.ensure_nonempty();
|
||||
self.doc.prune_text_boxes();
|
||||
// Glued connectors follow the shapes they join.
|
||||
if crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self) {
|
||||
// Whether the command left the document, the selection and the formatting
|
||||
// waiting at the caret as they were. A command that replaces the document —
|
||||
// a mail merge, a comparison — is not an edit of this one, whatever the new
|
||||
// document holds: `replaces_document` names the commands that may replace
|
||||
// it, and `document_id` catches any other that does (through
|
||||
// `set_document`, which starts a new history the old one must not overwrite).
|
||||
let unchanged = |s: &Session| match (&before_doc, &pending_before) {
|
||||
(Some((doc, sel, _, _, _, _, column)), Some(pending)) => {
|
||||
!replaces_document && s.document_id == document_id_before && s.unchanged(doc, sel, column, pending)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let changed = !unchanged(self);
|
||||
if changed {
|
||||
// The layout the connectors are rerouted against is the new one.
|
||||
self.touch();
|
||||
}
|
||||
// Glued connectors follow the shapes they join.
|
||||
let rerouted = crate::cmd::connectors::reroutes_after(id) && crate::cmd::connectors::reroute(self);
|
||||
if rerouted {
|
||||
self.touch();
|
||||
}
|
||||
// A command that changed nothing is not an edit (#23): it adds no undo
|
||||
// step, marks no unsaved changes, forces no relayout and leaves the redo
|
||||
// stack alone. What it left behind is compared with what it started from,
|
||||
// after the reroute: a reroute that moved a connector is an edit of its own.
|
||||
no_op = !changed && !rerouted && unchanged(self);
|
||||
if no_op {
|
||||
// The step the dispatch pushed is not an edit: put the stacks back as
|
||||
// they were, so the redo stack survives and no step is evicted. The
|
||||
// group it belonged to is left without a step of its own, so the next
|
||||
// command in it starts one.
|
||||
if let Some((_, _, history, redo, typing_open, evicted, _)) = &before_doc {
|
||||
self.history = history.clone();
|
||||
self.redo = redo.clone();
|
||||
self.typing_open = *typing_open;
|
||||
self.undo_evicted = *evicted;
|
||||
}
|
||||
if let Some(dirty) = dirty_before {
|
||||
self.dirty = dirty;
|
||||
}
|
||||
if let Some(rev) = rev_restore {
|
||||
let touched = self.rev;
|
||||
self.restore_rev(rev);
|
||||
crate::cmd::draw::rekey_lasso(self, touched, rev);
|
||||
}
|
||||
if pushed {
|
||||
self.join_anchor = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.clamp_selection();
|
||||
let mut ime_outcome = self.ime_edit_outcome;
|
||||
@@ -1886,7 +2023,10 @@ impl Session {
|
||||
}
|
||||
// An unclassified mutating command is conservatively a state change. It can make
|
||||
// a frame Other, but it can never mint a receipt because `classified` is false.
|
||||
if !ime_outcome.classified && mutates {
|
||||
// A command that changed nothing is not an edit (#23), so it is not an IME
|
||||
// state change either: otherwise Backspace at the document start would still
|
||||
// signal an application change and move the settled IME revision.
|
||||
if !ime_outcome.classified && mutates && !no_op {
|
||||
ime_outcome.state_changed = true;
|
||||
}
|
||||
ime_outcome.selection_changed = self.current_ime_caret_state() != ime_caret_before;
|
||||
@@ -1896,7 +2036,7 @@ impl Session {
|
||||
self.ime_state_seen = Some(caret);
|
||||
self.reset_ime_state_revision();
|
||||
}
|
||||
if !self.ime_origin && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
|
||||
if !self.ime_origin && !no_op && (ime_outcome.content_changed || ime_outcome.state_changed || (!ime_outcome.classified && mutates)) {
|
||||
self.pending_ime_application_change = true;
|
||||
}
|
||||
if sel_before.focus.story == StoryRef::Body && self.in_header_footer() {
|
||||
@@ -1907,7 +2047,7 @@ impl Session {
|
||||
if !spec.mutates && self.sel != sel_before {
|
||||
self.typing_open = false;
|
||||
}
|
||||
if spec.mutates && !matches!(id, "edit.undo" | "edit.redo") {
|
||||
if spec.mutates && !no_op && !matches!(id, "edit.undo" | "edit.redo") {
|
||||
self.note_edit();
|
||||
}
|
||||
// Outline view: collapsed headings follow edits; the caret stays where it shows.
|
||||
@@ -1952,6 +2092,16 @@ impl Session {
|
||||
// The steps the command pushed out are back, so they no longer count as gone.
|
||||
self.undo_evicted = ev;
|
||||
self.column = c;
|
||||
// Its step is gone with them: its undo group has none to merge into.
|
||||
if pushed {
|
||||
self.join_anchor = false;
|
||||
}
|
||||
}
|
||||
if let Some(dirty) = dirty_before {
|
||||
self.dirty = dirty;
|
||||
}
|
||||
if let Some(rev) = rev_restore {
|
||||
self.restore_rev(rev);
|
||||
}
|
||||
if let Some(b) = blocks_before {
|
||||
self.building_blocks = b;
|
||||
|
||||
+573
-4
@@ -275,6 +275,136 @@ fn typing_enter_undo() {
|
||||
assert_eq!(text(&s), "Hello world");
|
||||
}
|
||||
|
||||
/// A command that changes nothing is not an edit (#23): no undo step, no unsaved changes
|
||||
/// and no relayout.
|
||||
#[test]
|
||||
fn a_command_that_changes_nothing_is_not_an_edit() {
|
||||
let mut s = s();
|
||||
let rev = s.rev();
|
||||
// Backspace at the start of the document: there is nothing to delete.
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(s.undo_depth(), 0, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
assert_eq!(s.rev(), rev, "no relayout");
|
||||
// Typing nothing is the same.
|
||||
run(&mut s, "text.insert", json!({"text": ""}));
|
||||
assert_eq!(s.undo_depth(), 0, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
// A command that changes something is an edit.
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
assert_eq!(s.undo_depth(), 1, "one undo step");
|
||||
assert!(s.dirty, "the document is edited");
|
||||
// Setting the alignment a paragraph already has changes nothing.
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "para.align", json!({"value": "center"}));
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
// A command that changes nothing leaves the unsaved-changes flag and the layout revision as
|
||||
// they were, even when it calls `touch` itself: a saved document is not marked edited for
|
||||
// nothing, and its layout is not laid out again.
|
||||
let mut clean = Session::new(wordcraft_doc::Document::new());
|
||||
run(&mut clean, "para.align", json!({"value": "left"}));
|
||||
clean.dirty = false;
|
||||
let rev = clean.rev();
|
||||
run(&mut clean, "para.align", json!({"value": "left"}));
|
||||
assert!(!clean.dirty, "no unsaved changes");
|
||||
assert_eq!(clean.rev(), rev, "no relayout");
|
||||
assert_eq!(clean.undo_depth(), 1, "only the first align is a step");
|
||||
}
|
||||
|
||||
/// Five hundred commands that change nothing used to fill the whole undo stack and push the
|
||||
/// user's own edits out of it, so they could never be undone again (#23).
|
||||
#[test]
|
||||
fn no_op_commands_do_not_evict_the_undo_history() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
for _ in 0..600 {
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
}
|
||||
assert_eq!(s.undo_depth(), 1, "the typing step is still there");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "", "the typed text is still undoable");
|
||||
}
|
||||
|
||||
/// A command that changes nothing does not throw the redo stack away (#23).
|
||||
#[test]
|
||||
fn a_no_op_command_keeps_the_redo_stack() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.can_redo());
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert!(s.can_redo(), "the redo stack survives a command that changed nothing");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
/// Undo and Redo move between steps even when a step's document is the one showing: they are
|
||||
/// never taken back as commands that changed nothing, so Undo can't get stuck on such a step.
|
||||
#[test]
|
||||
fn undo_moves_past_a_step_that_changed_nothing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
s.checkpoint("Nothing");
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth - 1);
|
||||
assert!(s.can_redo());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "");
|
||||
}
|
||||
|
||||
/// Commands that run as one undo step (`join_next_undo`: a dialog's OK, the frames of a drag):
|
||||
/// a group whose first command changes nothing has no step to merge into, so the next command
|
||||
/// in it starts the group's step instead of merging into an older edit (#23 follow-up).
|
||||
#[test]
|
||||
fn a_joined_command_starts_its_group_when_the_first_changed_nothing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
let (depth, margins) = (s.undo_depth(), (s.doc.last_section.margin_left, s.doc.last_section.margin_top));
|
||||
// Portrait on a portrait page changes nothing.
|
||||
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
|
||||
assert_eq!(s.undo_depth(), depth, "the first command of the group is no edit");
|
||||
// The next command of the group starts the step the group needs.
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.margins", json!({"left": 100.0}));
|
||||
assert_eq!(s.doc.last_section.margin_left, 100.0);
|
||||
assert_eq!(s.undo_depth(), depth + 1, "the group's one step");
|
||||
// A command that changes nothing in the middle of the group leaves that step alone, and
|
||||
// the next one merges into it.
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.orientation", json!({"value": "portrait"}));
|
||||
s.join_next_undo();
|
||||
run(&mut s, "layout.margins", json!({"top": 60.0}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "still the group's one step");
|
||||
// Undo takes the whole group back and no more: the typing is still there.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!((s.doc.last_section.margin_left, s.doc.last_section.margin_top), margins, "the margins the group started from");
|
||||
assert_eq!(text(&s), "Hello");
|
||||
}
|
||||
|
||||
/// Bold with nothing selected arms the formatting for the next word: the document is the
|
||||
/// same, so only the caret's formatting changed — and that is an edit (#23).
|
||||
#[test]
|
||||
fn a_command_that_only_arms_the_caret_formatting_is_an_edit() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "format.bold", json!({}));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one undo step");
|
||||
// Undo takes the armed formatting back, as Word does (docs/typing-parity.md).
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
assert_eq!(s.doc.para_at(&s.sel.focus).map(|p| p.props_of_char(0).bold), Some(None));
|
||||
}
|
||||
|
||||
/// The Undo button's list (#701): several steps undone at once, newest first.
|
||||
#[test]
|
||||
fn undo_to_takes_several_steps_back_at_once() {
|
||||
@@ -514,7 +644,10 @@ fn failed_command_restores_history_changed_by_nested_commands() {
|
||||
// Comments are stamped with the time to the second, and the two sessions compared
|
||||
// are built a moment apart, so leave the stamp out of the comparison.
|
||||
let mut doc = s.doc.clone();
|
||||
doc.comments.values_mut().for_each(|c| c.date.clear());
|
||||
doc.comments.values_mut().for_each(|c| {
|
||||
c.date.clear();
|
||||
c.date_utc = None;
|
||||
});
|
||||
out.push((s.undo_labels(), doc));
|
||||
if !s.can_undo() {
|
||||
return out;
|
||||
@@ -1674,6 +1807,70 @@ fn tables_commands() {
|
||||
assert!(s.run("table.merge", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let widths = |s: &Session| {
|
||||
let t = s.doc.body.iter().find_map(|b| b.as_table()).unwrap();
|
||||
(t.grid.clone(), t.rows[0].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect::<Vec<_>>())
|
||||
};
|
||||
let (grid_before, _) = widths(&s);
|
||||
assert!(grid_before.iter().all(|w| *w > 0.0));
|
||||
run(&mut s, "table.insertColumnRight", json!({}));
|
||||
let (grid_after, cells) = widths(&s);
|
||||
// The table keeps its total width, and each cell carries its grid columns' width: the
|
||||
// layout and the saved `w:tcW` see the same shrink the grid got.
|
||||
let sum = |v: &[f32]| v.iter().sum::<f32>();
|
||||
assert!((sum(&grid_after) - sum(&grid_before)).abs() < 0.5, "{grid_after:?} vs {grid_before:?}");
|
||||
assert_eq!(cells.len(), grid_after.len());
|
||||
for (g, w) in grid_after.iter().zip(cells.iter()) {
|
||||
assert!((w - g).abs() < 0.01, "cell width {w} vs grid {g}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A row that skips grid columns (`w:gridBefore`) gives its cells the widths of the columns
|
||||
/// they sit in, not of the first ones.
|
||||
#[test]
|
||||
fn cell_widths_follow_the_grid_after_skipped_columns() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 3}));
|
||||
let i = s.doc.body.iter().position(|b| b.as_table().is_some()).unwrap();
|
||||
let wordcraft_doc::Block::Table(t) = std::sync::Arc::make_mut(&mut s.doc.body[i]) else { panic!("no table") };
|
||||
t.grid = vec![100.0, 200.0, 300.0];
|
||||
t.rows[1].props.grid_before = 1;
|
||||
t.rows[1].cells.pop();
|
||||
crate::cmd::table::sync_cell_widths(t);
|
||||
let widths: Vec<f32> = t.rows[1].cells.iter().map(|c| c.props.width.unwrap_or(0.0)).collect();
|
||||
assert_eq!(widths, [200.0, 300.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_version_restore_saves_nothing() {
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Original text."));
|
||||
s.run("review.restrict", &json!({"mode": "readOnly", "password": "pw"})).unwrap();
|
||||
s.run("file.versions", &json!({"save": "before"})).unwrap();
|
||||
// One command asking for both a save and a restore the protection refuses: the save must
|
||||
// not happen either.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 0})).unwrap_err().to_string();
|
||||
assert!(e.contains("stop protection"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// A bad index is refused before anything is saved, too.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 99})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such version"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// A restore that is there but not a non-negative integer is refused, not read as absent:
|
||||
// {"save": "mid", "restore": -1} must not save quietly without restoring.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("restore (non-negative integer)"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
// An index too large for `usize` names no version: on a 32-bit target `as usize` would wrap
|
||||
// it to 0 and restore the first version instead.
|
||||
let e = s.run("file.versions", &json!({"save": "mid", "restore": 4294967296u64})).unwrap_err().to_string();
|
||||
assert!(e.contains("no such version"), "{e}");
|
||||
assert_eq!(s.versions.len(), 1, "the refused command saved a version anyway");
|
||||
}
|
||||
|
||||
/// #3931: the page as a command would leave it, laid out without running it on the document:
|
||||
/// Insert › Table's grid previews the table it would insert. The document, the caret, the undo
|
||||
/// history and Repeat stay as they were.
|
||||
@@ -2873,6 +3070,40 @@ fn toc_and_fields() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Summary\t").count(), 1, "one TOC entry after update");
|
||||
}
|
||||
|
||||
/// A comment or reply made here carries its UTC time as `date_utc` too, so it shows in local time
|
||||
/// and is saved as `dateUtc`.
|
||||
#[test]
|
||||
fn new_comments_and_replies_carry_their_utc_time() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Some text"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let id = run(&mut s, "review.newComment", json!({"text": "one"}))["id"].as_u64().unwrap();
|
||||
run(&mut s, "review.reply", json!({"id": id, "text": "two"}));
|
||||
assert_eq!(s.doc.comments.len(), 2);
|
||||
for c in s.doc.comments.values() {
|
||||
assert!(c.date.ends_with('Z'), "{}", c.date);
|
||||
assert_eq!(c.date_utc.as_deref(), Some(c.date.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Update Table with nothing to change lays the document out at the revisions it touches, then
|
||||
/// puts the revision back. The next edits reach those numbers again with a different document, so
|
||||
/// the layout must be the new one.
|
||||
#[test]
|
||||
fn a_command_that_changes_nothing_leaves_no_stale_layout() {
|
||||
let mut s = Session::new(crate::sample::report());
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "references.toc", json!({}));
|
||||
let (rev, steps) = (s.rev(), s.undo_depth());
|
||||
let pages = s.layout().pages.len();
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
assert_eq!((s.rev(), s.undo_depth()), (rev, steps), "Update Table changed nothing");
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "text.insert", json!({"text": "A"}));
|
||||
run(&mut s, "text.insert", json!({"text": "word ".repeat(4000)}));
|
||||
assert!(s.layout().pages.len() > pages, "the layout has the inserted text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_refresh_after_save_and_reopen() {
|
||||
for extension in ["docx", "json"] {
|
||||
@@ -3089,6 +3320,172 @@ fn word_toc_control_with_the_field_end_after_the_entries_survives_update_and_sav
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph style `id` named `name`, based on Normal.
|
||||
fn style_xml(id: &str, name: &str, ppr: &str, rpr: &str) -> String {
|
||||
format!(
|
||||
r#"<w:style w:type="paragraph" w:styleId="{id}"><w:name w:val="{name}"/><w:basedOn w:val="Normal"/><w:pPr>{ppr}</w:pPr><w:rPr>{rpr}</w:rPr></w:style>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A DOCX package with `body` and the paragraph `styles` after a default Normal style.
|
||||
fn docx_with_styles(body: &str, styles: &str) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
const RELS: &str = r#"xmlns="http://schemas.openxmlformats.org/package/2006/relationships""#;
|
||||
const TYPE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, value) in [
|
||||
("word/document.xml", format!(r#"<w:document {W}><w:body>{body}</w:body></w:document>"#)),
|
||||
(
|
||||
"word/styles.xml",
|
||||
format!(
|
||||
r#"<w:styles {W}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
|
||||
),
|
||||
),
|
||||
(
|
||||
"word/_rels/document.xml.rels",
|
||||
format!(r#"<Relationships {RELS}><Relationship Id="rId1" Type="{TYPE}/styles" Target="styles.xml"/></Relationships>"#),
|
||||
),
|
||||
(
|
||||
"_rels/.rels",
|
||||
format!(r#"<Relationships {RELS}><Relationship Id="main" Type="{TYPE}/officeDocument" Target="word/document.xml"/></Relationships>"#),
|
||||
),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(value.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// #3891: Update Table on a TOC Word wrote in Portuguese keeps the formatting of its TOC styles,
|
||||
/// stored under the ids `Sumrio1`/`Sumrio3` (`toc 1`/`toc 3`): bold Arial at level 1, italic and
|
||||
/// indented at level 3.
|
||||
#[test]
|
||||
fn update_table_keeps_the_formatting_of_localized_toc_styles() {
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let body = [
|
||||
p("CabealhodoSumrio", &t("Contents")),
|
||||
p("Sumrio1", &format!("{begin}{}", t("Chapter\t1"))),
|
||||
p("Sumrio3", &t("Detail\t1")),
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
|
||||
p("Ttulo1", &t("Chapter")),
|
||||
p("Ttulo3", &t("Detail")),
|
||||
]
|
||||
.concat();
|
||||
let styles = [
|
||||
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
|
||||
style_xml("Ttulo3", "heading 3", r#"<w:outlineLvl w:val="2"/>"#, ""),
|
||||
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
|
||||
style_xml("Sumrio1", "toc 1", "", r#"<w:rFonts w:ascii="Arial" w:hAnsi="Arial"/><w:b/>"#),
|
||||
style_xml("Sumrio3", "toc 3", r#"<w:ind w:left="220"/>"#, "<w:i/>"),
|
||||
]
|
||||
.concat();
|
||||
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let entries: Vec<(String, bool, bool, f32)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
.map(|p| {
|
||||
let c = s.doc.styles.resolve_char(p.props.style.as_deref(), p.props_of_char(0));
|
||||
(c.font, c.bold, c.italic, s.doc.styles.resolve_para(&p.props).indent_left)
|
||||
})
|
||||
.collect();
|
||||
let level3 = entries.get(1).map(|e| e.0.clone()).unwrap_or_default();
|
||||
assert_eq!(entries, [("Arial".to_string(), true, false, 0.0), (level3, false, true, 11.0)], "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #3889: Word's TOC in two columns, written by Word in Portuguese. The TOC heading ends a
|
||||
/// one-column section; the entries, styled `Sumrio1`/`Sumrio2` (`toc 1`/`toc 2`), fill a continuous
|
||||
/// two-column one, and the field's end and the control's end share a paragraph after them. Update
|
||||
/// Table keeps the entries' styles, puts the page numbers at the first column's right edge (3888
|
||||
/// twips, as Word's own entries have them) and adds no blank line above the first column; update,
|
||||
/// save and reopen keeps it so.
|
||||
#[test]
|
||||
fn toc_in_columns_with_localized_styles_keeps_its_layout_through_update_table() {
|
||||
let t = |s: &str| format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#);
|
||||
let p = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/></w:pPr>{inner}</w:p>"#);
|
||||
let tab = r#"<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="3888"/></w:tabs>"#;
|
||||
let entry = |st: &str, inner: &str| format!(r#"<w:p><w:pPr><w:pStyle w:val="{st}"/>{tab}</w:pPr>{inner}</w:p>"#);
|
||||
let sect = |cols: &str, kind: &str| {
|
||||
format!(
|
||||
r#"<w:sectPr>{kind}<w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1417" w:right="1701" w:bottom="1417" w:left="1701" w:header="708" w:footer="708" w:gutter="0"/><w:cols {cols} w:space="708"/></w:sectPr>"#
|
||||
)
|
||||
};
|
||||
let continuous = r#"<w:type w:val="continuous"/>"#;
|
||||
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-2" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
|
||||
let toc = [
|
||||
p("CabealhodoSumrio", &t("Contents")),
|
||||
format!("<w:p><w:pPr>{}</w:pPr></w:p>", sect("", "")),
|
||||
entry("Sumrio1", &format!("{begin}{}", t("Chapter One\t1"))),
|
||||
entry("Sumrio2", &t("Section\t1")),
|
||||
r#"<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#.to_string(),
|
||||
]
|
||||
.concat();
|
||||
let body = [
|
||||
format!(
|
||||
r#"<w:sdt><w:sdtPr><w:id w:val="-1360965913"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtContent>{toc}</w:sdtContent></w:sdt>"#
|
||||
),
|
||||
format!(r#"<w:p><w:pPr>{}</w:pPr></w:p>"#, sect(r#"w:num="2""#, continuous)),
|
||||
p("Ttulo1", &t("Chapter One")),
|
||||
p("Ttulo2", &t("Section")),
|
||||
sect("", ""),
|
||||
]
|
||||
.concat();
|
||||
let styles = [
|
||||
style_xml("Ttulo1", "heading 1", r#"<w:outlineLvl w:val="0"/>"#, ""),
|
||||
style_xml("Ttulo2", "heading 2", r#"<w:outlineLvl w:val="1"/>"#, ""),
|
||||
style_xml("CabealhodoSumrio", "TOC Heading", "", ""),
|
||||
style_xml("Sumrio1", "toc 1", r#"<w:spacing w:after="100"/>"#, ""),
|
||||
style_xml("Sumrio2", "toc 2", r#"<w:spacing w:after="100"/><w:ind w:left="220"/>"#, ""),
|
||||
]
|
||||
.concat();
|
||||
let mut s = Session::new(wordcraft_docx::read(&docx_with_styles(&body, &styles)).unwrap());
|
||||
let mut blocks = None;
|
||||
for round in 0..3 {
|
||||
run(&mut s, "references.updateToc", json!({}));
|
||||
let entries: Vec<(usize, &wordcraft_doc::Paragraph)> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, b)| b.as_para().map(|p| (i, p)))
|
||||
.filter(|(_, p)| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
|
||||
.collect();
|
||||
// The chapters' section starts a new page.
|
||||
let found: Vec<(Option<&str>, String)> = entries.iter().map(|(_, p)| (p.props.style.as_deref(), p.plain_text())).collect();
|
||||
assert_eq!(found, [(Some("TOC1"), "Chapter One\t2".to_string()), (Some("TOC2"), "Section\t2".to_string())], "round {round}: {}", text(&s));
|
||||
assert_eq!(s.doc.styles.get("TOC2").and_then(|st| st.para.indent_left), Some(11.0), "round {round}: the document's own TOC 2");
|
||||
for (_, e) in &entries {
|
||||
let right = e.props.tabs.as_ref().and_then(|t| t.iter().find(|t| t.align == wordcraft_doc::props::TabAlign::Right)).unwrap();
|
||||
assert!((right.pos - 194.4).abs() < 0.1, "round {round}: page numbers at the first column's edge, not the page's: {}", right.pos);
|
||||
}
|
||||
// The paragraph above the first entry ends the one-column section: no blank line tops the
|
||||
// first column.
|
||||
let above = entries[0].0 - 1;
|
||||
assert!(s.doc.body[above].as_para().unwrap().section.is_some(), "round {round}: a blank line above the entries: {}", text(&s));
|
||||
let bytes = wordcraft_docx::write(&s.doc).unwrap();
|
||||
s = Session::new(wordcraft_docx::read(&bytes).unwrap());
|
||||
assert!(blocks.is_none_or(|n| n == s.doc.body.len()), "round {round}: the document grew: {}", text(&s));
|
||||
blocks = Some(s.doc.body.len());
|
||||
}
|
||||
// The field's paragraph ends the one-column section: replacing or removing the TOC keeps it.
|
||||
let columns = |s: &Session| s.doc.sections().iter().map(|(_, x)| x.columns.count).collect::<Vec<_>>();
|
||||
assert_eq!(columns(&s), [1, 2, 1]);
|
||||
// The heading goes with the table: one "Contents" after Replace, none after Remove.
|
||||
let titles = |s: &Session| s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.plain_text() == "Contents").count();
|
||||
run(&mut s, "references.toc", json!({"levels": 2}));
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 1, "{}", text(&s));
|
||||
run(&mut s, "references.removeToc", json!({}));
|
||||
assert_eq!(columns(&s), [1, 2, 1], "{}", text(&s));
|
||||
assert_eq!(titles(&s), 0, "{}", text(&s));
|
||||
}
|
||||
|
||||
/// #2182: with two TOCs only the one Update Table would refresh is offered Update Field; the other
|
||||
/// range TOC doesn't claim the menu, so it never refreshes a different table.
|
||||
#[test]
|
||||
@@ -5181,6 +5578,32 @@ fn reading_mode_reflows_per_viewport_without_changing_document_or_export() {
|
||||
assert_eq!(s.view.read_line_spacing, None);
|
||||
}
|
||||
|
||||
/// Read Mode › View › Page Color: None, Sepia or Inverse, a view setting that leaves the
|
||||
/// document alone, is not saved, and starts at None on each entry to Read Mode.
|
||||
#[test]
|
||||
fn reading_page_color_is_a_transient_view_setting() {
|
||||
use crate::cmd::view::ReadPageColor;
|
||||
let mut s = s();
|
||||
let original = s.doc.clone();
|
||||
let dirty = s.dirty;
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
assert_eq!(s.run("view.readPageColor", &json!({"value": "sepia"})).unwrap(), json!({"pageColor": "sepia"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Sepia);
|
||||
run(&mut s, "view.readPageColor", json!({"value": "inverse"}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse);
|
||||
for bad in [json!({"value": "black"}), json!({"value": 1}), json!({})] {
|
||||
assert!(s.run("view.readPageColor", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::Inverse, "rejected values keep the color");
|
||||
assert!(serde_json::to_value(&s.view).unwrap().get("readPageColor").is_none());
|
||||
assert_eq!(s.doc, original);
|
||||
assert_eq!(s.dirty, dirty);
|
||||
run(&mut s, "view.printLayout", json!({}));
|
||||
run(&mut s, "view.readMode", json!({}));
|
||||
assert_eq!(s.view.read_page_color, ReadPageColor::None);
|
||||
}
|
||||
|
||||
/// File › Options › Advanced › Numeral: the display mode for digits (display only).
|
||||
#[test]
|
||||
fn numeral_mode_sets_gets_and_rejects_junk() {
|
||||
@@ -7032,6 +7455,10 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() {
|
||||
("pl_PL", POLISH_TEST_AFF, POLISH_TEST_DIC),
|
||||
("pt_BR", PORTUGUESE_TEST_AFF, PORTUGUESE_TEST_DIC),
|
||||
("id_ID", INDONESIAN_TEST_AFF, INDONESIAN_TEST_DIC),
|
||||
// The others the tests install, so reloading from the folder keeps their words.
|
||||
("it_IT", ITALIAN_TEST_AFF, ITALIAN_TEST_DIC),
|
||||
("es_ES", SPANISH_TEST_AFF, SPANISH_TEST_DIC),
|
||||
("es_UY", SPANISH_TEST_AFF, URUGUAYAN_TEST_DIC),
|
||||
// #3252: set_dictionaries_folder() now reloads Czech too (it used to leave its cache
|
||||
// alone), so a run of this test overlapping czech_proofing_through_the_review_commands
|
||||
// would otherwise force a reload that finds no cs_CZ here and empties it mid-test. Same
|
||||
@@ -7055,6 +7482,11 @@ fn dictionaries_folder_option_is_saved_searched_and_reported() {
|
||||
assert_eq!(file("pl"), Some(json!(dir.join("pl_PL.dic").to_string_lossy())));
|
||||
assert_eq!(file("pt"), Some(json!(dir.join("pt_BR.dic").to_string_lossy())));
|
||||
assert_eq!(file("id"), Some(json!(dir.join("id_ID.dic").to_string_lossy())));
|
||||
// Each Spanish variety reports its own file (Spain, Mexico, Latin America, Uruguay, in order).
|
||||
let spanish: Vec<serde_json::Value> = dicts.iter().filter(|d| d["language"] == "es").map(|d| d["file"].clone()).collect();
|
||||
assert_eq!(spanish.len(), 4);
|
||||
assert_eq!(spanish.first(), Some(&json!(dir.join("es_ES.dic").to_string_lossy())));
|
||||
assert_eq!(spanish.last(), Some(&json!(dir.join("es_UY.dic").to_string_lossy())));
|
||||
// Polish text is checked against the folder's dictionary.
|
||||
run(&mut s, "document.setText", json!({"text": "rzeka płynie żeka"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
@@ -7247,6 +7679,119 @@ fn new_documents_follow_the_regions_english() {
|
||||
assert_eq!(s.region_english(), None);
|
||||
}
|
||||
|
||||
/// A Spanish interface writes new documents in the region's Spanish (#3360): Uruguay's on a
|
||||
/// Uruguayan system, Mexico's on a Mexican one, Spain's elsewhere.
|
||||
#[test]
|
||||
fn new_documents_follow_the_regions_spanish() {
|
||||
let mut s = s();
|
||||
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
|
||||
assert_eq!(s.region_spanish(), "es-UY");
|
||||
// The ISO 639-2 code is Spanish too (CodeRabbit review on #3562).
|
||||
s.system_region = wordcraft_geom::Region::from_tag("spa_MX.UTF-8");
|
||||
assert_eq!(s.region_spanish(), "es-MX");
|
||||
s.system_region = wordcraft_geom::Region::from_tag("es_UY.UTF-8");
|
||||
s.set_editing_language(Some(&s.region_spanish()));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-UY"), "the untouched document follows");
|
||||
for (tag, spanish) in [("es-MX", "es-MX"), ("es-419", "es-419"), ("es-ES", "es-ES"), ("en-US", "es-ES"), ("fr-FR", "es-ES"), ("auto", "es-UY")] {
|
||||
run(&mut s, "file.options", json!({"region": tag}));
|
||||
assert_eq!(s.region_spanish(), spanish, "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
// Hand-written synthetic fixtures (no system dictionary data): Spain's has `vosotros` forms,
|
||||
// Uruguay's has `vos` ones' verbs only (the forms come from the voseo rules).
|
||||
const SPANISH_TEST_AFF: &str =
|
||||
"SET UTF-8\nFLAG UTF-8\nTRY aeosrnidlctumpbgvyqhfzjñáéíóú\nMAP 2\nMAP eé\nMAP ií\nREP 2\nREP v b\nREP b v\nSFX S Y 1\nSFX S 0 s [aeiou]\n";
|
||||
const SPANISH_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\nhabéis\nvenido\ndecir\n";
|
||||
const URUGUAYAN_TEST_DIC: &str = "10\nesto\nes\nuna\nprueba/S\ndel\ncorrector\ntambién\ntener\nvenir\ndecir\n";
|
||||
|
||||
fn spanish_test_dictionaries() {
|
||||
use wordcraft_proof::ProofLang;
|
||||
for (lang, dic) in [(ProofLang::Es, SPANISH_TEST_DIC), (ProofLang::EsUy, URUGUAYAN_TEST_DIC)] {
|
||||
wordcraft_proof::es::install_dictionary(lang, SPANISH_TEST_AFF, dic).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// #3553: Spanish proofing through the same commands as Review, F7, Change and Add to
|
||||
/// Dictionary: `prueva` and `corector` are flagged with `prueba` and `corrector` offered.
|
||||
#[test]
|
||||
fn spanish_proofing_through_the_review_commands() {
|
||||
spanish_test_dictionaries();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "esto es una prueva del corector. Habéis venido?"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es_es", "default": true}));
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("es-ES"));
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let arr = issues.as_array().unwrap();
|
||||
assert!(arr.iter().all(|i| i["lang"] == "es"), "{issues}");
|
||||
let has = |text: &str, kind: &str, sugg: &str| {
|
||||
arr.iter().any(|i| i["text"] == text && i["kind"] == kind && i["suggestions"].as_array().unwrap().iter().any(|x| x == sugg))
|
||||
};
|
||||
assert!(has("prueva", "spelling", "prueba"), "{issues}");
|
||||
assert!(has("corector", "spelling", "corrector"), "{issues}");
|
||||
assert!(has("Habéis venido?", "grammar", "¿Habéis venido?"), "{issues}");
|
||||
assert_eq!(arr.len(), 3, "{issues}");
|
||||
assert!(arr.iter().any(|i| i["message"] == "Posible error ortográfico"), "{issues}");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
assert_eq!(run(&mut s, "review.spelling", json!({}))["text"], "prueva");
|
||||
run(&mut s, "review.applySuggestion", json!({"text": "prueba"}));
|
||||
assert!(text(&s).starts_with("esto es una prueba del"), "{}", text(&s));
|
||||
run(&mut s, "review.addToDictionary", json!({"word": "corector"}));
|
||||
let left = run(&mut s, "review.issues", json!({}));
|
||||
assert_eq!(left.as_array().unwrap().len(), 1, "{left}");
|
||||
}
|
||||
|
||||
/// #3562 review: Add to Dictionary and Ignore All get the issue's own text, decomposed (NFD) as
|
||||
/// typed; the word is then accepted decomposed and composed, and its issue range was the typed
|
||||
/// bytes.
|
||||
#[test]
|
||||
fn add_and_ignore_all_accept_both_normalization_forms() {
|
||||
spanish_test_dictionaries();
|
||||
let mut s = s();
|
||||
let text = "esto zqanade\u{301}do y zqignora\u{301}do";
|
||||
run(&mut s, "document.setText", json!({"text": text}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-ES"}));
|
||||
let issues = run(&mut s, "review.issues", json!({}));
|
||||
let words: Vec<(String, u64)> = issues
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|i| i["kind"] == "spelling")
|
||||
.map(|i| (i["text"].as_str().unwrap().to_string(), i["start"]["off"].as_u64().unwrap()))
|
||||
.collect();
|
||||
assert_eq!(words, [("zqanade\u{301}do".to_string(), 5), ("zqignora\u{301}do".to_string(), text.find("zqign").unwrap() as u64)], "{issues}");
|
||||
run(&mut s, "review.addToDictionary", json!({"word": words[0].0}));
|
||||
run(&mut s, "review.ignoreAll", json!({"word": words[1].0}));
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
|
||||
// The composed spellings are the same words.
|
||||
run(&mut s, "document.setText", json!({"text": "esto zqanadédo y zqignorádo"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-ES"}));
|
||||
assert!(run(&mut s, "review.issues", json!({})).as_array().unwrap().is_empty());
|
||||
wordcraft_proof::remove_word("zqanadédo");
|
||||
}
|
||||
|
||||
/// Uruguayan Spanish accepts the `vos` forms, imperatives included, that Spain's flags.
|
||||
#[test]
|
||||
fn uruguayan_spanish_accepts_vos_forms() {
|
||||
spanish_test_dictionaries();
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Vení, decime qué tenés."}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-UY"}));
|
||||
assert_eq!(run(&mut s, "format.state", json!({}))["lang"], "es-UY");
|
||||
let spelling = |s: &mut Session| -> Vec<String> {
|
||||
let issues = run(s, "review.issues", json!({}));
|
||||
issues.as_array().unwrap().iter().filter(|i| i["kind"] == "spelling").filter_map(|i| i["text"].as_str().map(String::from)).collect()
|
||||
};
|
||||
assert_eq!(spelling(&mut s), ["qué"], "only the word the fixture lacks");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "review.language", json!({"lang": "es-ES"}));
|
||||
assert_eq!(spelling(&mut s), ["Vení", "decime", "qué", "tenés"]);
|
||||
}
|
||||
|
||||
/// Czech proofing through the same commands as Review, F7, Change and Add to Dictionary.
|
||||
#[test]
|
||||
fn czech_proofing_through_the_review_commands() {
|
||||
@@ -7286,10 +7831,11 @@ fn czech_proofing_through_the_review_commands() {
|
||||
/// Review › Language and the proofing commands work in Polish end to end: the language makes
|
||||
/// the document's words Polish, F7 and the issue list use the Polish dictionary and grammar,
|
||||
/// suggestions come from it, and a suggestion fixes the text.
|
||||
const ITALIAN_TEST_AFF: &str = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
|
||||
const ITALIAN_TEST_DIC: &str = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
|
||||
|
||||
fn italian_test_dictionary() {
|
||||
let aff = "SET UTF-8\nTRY aeiortnsclcaffèéè\nMAP 2\nMAP aà\nMAP eéè\nREP 1\nREP caffe caffè\n";
|
||||
let dic = "6\ncasa\ncaffè\nroma\ndisse\ne\nper\n";
|
||||
wordcraft_proof::it::install_dictionary(aff, dic).unwrap();
|
||||
wordcraft_proof::it::install_dictionary(ITALIAN_TEST_AFF, ITALIAN_TEST_DIC).unwrap();
|
||||
}
|
||||
|
||||
/// Review › Language and the proofing commands work in Italian end to end: the language makes
|
||||
@@ -8630,6 +9176,29 @@ fn a_version_snapshot_does_not_survive_an_untitled_comparison() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A comparison with an `original` replaces the open document in place — the handler loads the
|
||||
/// original into `s.doc` without `set_document` — so `document_id` does not move and two
|
||||
/// identical comparisons produce identical documents. A comparison is an edit of its own all
|
||||
/// the same: the `replaces_document` guard keeps its undo step (#4004 review).
|
||||
#[test]
|
||||
fn a_repeated_comparison_is_still_an_edit() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-compare-twice-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("original.docx");
|
||||
let mut other = Session::new(wordcraft_doc::Document::from_text("Original document."));
|
||||
other.run("file.save", &json!({"path": original.to_string_lossy()})).unwrap();
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Current document."));
|
||||
let compare = json!({"original": original.to_string_lossy(), "text": "Revised document."});
|
||||
s.run("review.compare", &compare).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.dirty);
|
||||
s.run("review.compare", &compare).unwrap();
|
||||
assert_eq!(s.undo_depth(), depth + 1, "the second comparison is an edit of its own");
|
||||
assert!(s.dirty, "the comparison result is unsaved");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A rejected comparison leaves the current document and its snapshots together.
|
||||
#[test]
|
||||
fn a_rejected_comparison_keeps_the_current_versions() {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the
|
||||
//! migration's input-method application-change and state-revision tracking.
|
||||
//!
|
||||
//! Kept in its own module so the merged `tests.rs` is not touched.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and
|
||||
/// must not look like one to the input method: no effect flags, no application-change signal, no
|
||||
/// settled-revision move, and no history/dirty/revision side effects.
|
||||
#[test]
|
||||
fn a_no_op_command_is_not_an_ime_state_change() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
assert!(s.pending.is_none(), "caret movement dropped any pending formatting");
|
||||
s.take_pending_ime_application_change();
|
||||
let revision = s.ime_state_revision().expect("an IME revision");
|
||||
let rev = s.rev();
|
||||
let depth = s.undo_depth();
|
||||
let dirty = s.dirty;
|
||||
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}");
|
||||
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
|
||||
assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change");
|
||||
assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move");
|
||||
assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev));
|
||||
}
|
||||
|
||||
/// `document.setText` with identical text replaces the body under the same document id, so the
|
||||
/// IME replacement epoch must advance — but because nothing observable changed it is not an edit
|
||||
/// and must not signal an application-origin change (main's #23 no-op rule).
|
||||
#[test]
|
||||
fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Same body"}));
|
||||
s.take_pending_ime_application_change();
|
||||
let epoch = s.ime_body_epoch();
|
||||
let document_id = s.document_id();
|
||||
let rev = s.rev();
|
||||
let depth = s.undo_depth();
|
||||
|
||||
run(&mut s, "document.setText", json!({"text": "Same body"}));
|
||||
|
||||
assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id");
|
||||
assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
|
||||
assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change");
|
||||
assert_eq!((s.undo_depth(), s.rev()), (depth, rev));
|
||||
}
|
||||
@@ -401,3 +401,74 @@ fn tracked_delete_of_struck_table_text_keeps_it_for_reject_all() {
|
||||
assert_eq!(cells(&s), [["A1", "B1"], ["A2", "B2"]], "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Slot {
|
||||
FootnoteSeparator,
|
||||
EndnoteSeparator,
|
||||
FootnoteContinuationSeparator,
|
||||
EndnoteContinuationNotice,
|
||||
}
|
||||
|
||||
const SLOTS: [Slot; 4] = [Slot::FootnoteSeparator, Slot::EndnoteSeparator, Slot::FootnoteContinuationSeparator, Slot::EndnoteContinuationNotice];
|
||||
|
||||
fn slot_mut(doc: &mut wordcraft_doc::Document, slot: Slot) -> &mut Option<wordcraft_doc::Blocks> {
|
||||
match slot {
|
||||
Slot::FootnoteSeparator => &mut doc.footnote_separators.separator,
|
||||
Slot::EndnoteSeparator => &mut doc.endnote_separators.separator,
|
||||
Slot::FootnoteContinuationSeparator => &mut doc.footnote_separators.continuation_separator,
|
||||
Slot::EndnoteContinuationNotice => &mut doc.endnote_separators.continuation_notice,
|
||||
}
|
||||
}
|
||||
|
||||
/// A session whose note separator `slot` holds the text "SEP" as a tracked insertion (revision 0).
|
||||
fn tracked_separator_session(slot: Slot) -> Session {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
doc.revisions.push(wordcraft_doc::Revision {
|
||||
kind: wordcraft_doc::RevisionKind::Insert,
|
||||
author: "Alice".into(),
|
||||
date: String::new(),
|
||||
move_name: None,
|
||||
});
|
||||
let props = wordcraft_doc::CharProps { ins: Some(0), ..Default::default() };
|
||||
let p = wordcraft_doc::Paragraph::with_text("SEP", props);
|
||||
*slot_mut(&mut doc, slot) = Some(vec![wordcraft_doc::para_block(p)]);
|
||||
Session::new(doc)
|
||||
}
|
||||
|
||||
fn separator_para(s: &mut Session, slot: Slot) -> wordcraft_doc::Paragraph {
|
||||
slot_mut(&mut s.doc, slot).as_ref().and_then(|b| b.iter().find_map(|b| b.as_para())).expect("separator").clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_all_resolves_note_separator_revisions() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
let p = separator_para(&mut s, slot);
|
||||
assert_eq!(p.plain_text(), "SEP", "{slot:?}");
|
||||
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_all_resolves_note_separator_revisions() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(separator_para(&mut s, slot).plain_text(), "", "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_tracked_change_does_not_alias_separator_revision() {
|
||||
for slot in SLOTS {
|
||||
let mut s = tracked_separator_session(slot);
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
s.author = "Bob".into();
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "text.insert", json!({"text": "NEW "}));
|
||||
let p = separator_para(&mut s, slot);
|
||||
assert!(p.run_ranges().all(|(_, c)| c.ins.is_none() && c.del.is_none()), "{slot:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Create an empty session for command-driven typing fixtures.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
/// Execute a registry command and report its id on a test failure.
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
@@ -39,10 +41,12 @@ fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Expected paragraph text and list membership for typing assertions.
|
||||
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
|
||||
(text.to_string(), level, level.is_some())
|
||||
}
|
||||
|
||||
/// Create a session and enter its fixture through the actual typing commands.
|
||||
fn typed(keys: &str) -> Session {
|
||||
let mut s = s();
|
||||
typ(&mut s, keys);
|
||||
@@ -489,6 +493,25 @@ fn enter_finishes_a_word_for_autocorrect() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
|
||||
}
|
||||
|
||||
/// A word after a no-break space or an ideographic space is corrected as after a plain space:
|
||||
/// replacements, sentence capitals, ordinals and links.
|
||||
#[test]
|
||||
fn autocorrect_finds_the_word_after_a_wide_space() {
|
||||
let s = typed("a\u{a0}teh x\u{3000}teh ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "a\u{a0}the x\u{3000}the ");
|
||||
let s = typed("end.\u{a0}hello end.\u{3000}world ");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "End.\u{a0}Hello end.\u{3000}World ");
|
||||
let s = typed("the\u{a0}1st and\u{3000}22nd ");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let sup = |t: &str| p.props_of_char(p.text.find(t).unwrap() + t.len() - 2).vert_align == Some(VertAlign::Superscript);
|
||||
assert!(sup("1st") && sup("22nd"));
|
||||
let s = typed("see\u{a0}https://example.org/log, and\u{3000}https://example.net/day, then ");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
let link = |t: &str| p.props_of_char(p.text.find(t).unwrap()).link.clone();
|
||||
assert_eq!(link("https://example.org").as_deref(), Some("https://example.org/log"));
|
||||
assert_eq!(link("https://example.net").as_deref(), Some("https://example.net/day"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinals_become_superscript() {
|
||||
let s = typed("the 1st and 22nd and 13th but not 1th ");
|
||||
@@ -568,6 +591,71 @@ fn a_capitalised_word_after_a_symbol_is_not_in_the_symbol_font() {
|
||||
assert_eq!(p.props_of_char(m).sym_font, None);
|
||||
}
|
||||
|
||||
/// Whether every character of the body paragraph `i` is marked deleted.
|
||||
fn all_deleted(s: &Session, i: usize, text: &str) -> bool {
|
||||
s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.text == text && p.run_ranges().all(|(_, c)| c.del.is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_border_line_is_a_deletion_and_a_formatting_change() {
|
||||
let bottom = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
|
||||
let tracked = |s: &Session, i| s.doc.para_at(&Pos::body(i, 0)).is_some_and(|p| p.props.fmt_change.is_some());
|
||||
// Characters that were there before Track Changes was turned on are marked deleted.
|
||||
let mut s = typed("Above\n---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\n");
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
assert!(all_deleted(&s, 1, "---"), "{:?}", paras(&s));
|
||||
// Enter again after the deleted characters is a plain Enter.
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(texts(&s), ["above", "---", ""]);
|
||||
assert_eq!(s.sel.focus, Pos::body(2, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(texts(&s), ["above", "---"]);
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.acceptAll", json!({}));
|
||||
assert_eq!(texts(&s), ["above", ""]);
|
||||
assert!(bottom(&s, 0).is_some());
|
||||
// At the top of the document the border goes on the characters' paragraph, and typing
|
||||
// continues in a new one.
|
||||
let mut s = typed("---");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\nText");
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
assert!(all_deleted(&s, 0, "---"), "{:?}", paras(&s));
|
||||
assert_eq!(texts(&s)[1], "text");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(texts(&s), ["---"]);
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
// Typed with Track Changes on, the characters go and the border is still a change.
|
||||
let mut s = typed("");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "---\n");
|
||||
assert_eq!(texts(&s), ["", ""]);
|
||||
assert!(bottom(&s, 0).is_some() && tracked(&s, 0));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert!(bottom(&s, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_table_line_keeps_its_characters_as_a_deletion() {
|
||||
let mut s = typed("+--+------+");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\nA");
|
||||
let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) };
|
||||
assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell");
|
||||
assert!(all_deleted(&s, 1, "+--+------+"), "{:?}", paras(&s));
|
||||
// Enter after the deleted characters makes no second table.
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 11}}));
|
||||
typ(&mut s, "\n");
|
||||
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_none()).count(), 1, "{:?}", paras(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.text == "+--+------+"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_line_autoformat() {
|
||||
let s = typed("Above\n---\nBelow");
|
||||
@@ -600,6 +688,155 @@ fn undo_right_after_a_border_line_brings_the_characters_back() {
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---");
|
||||
}
|
||||
|
||||
/// The first paragraph's (left, first-line) indents as set on the paragraph itself.
|
||||
fn indents(s: &Session) -> (Option<f32>, Option<f32>) {
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
(p.props.indent_left, p.props.indent_first)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_and_backspace_at_a_paragraph_start_set_its_indents() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "Some text");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
// The first Tab indents the first line, the next one the whole paragraph.
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (None, Some(36.0)));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), Some(36.0)));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
|
||||
// Backspace takes the first-line indent back, then the left indent, and stops there.
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(indents(&s), (Some(36.0), Some(0.0)));
|
||||
typ(&mut s, "\u{8}\u{8}");
|
||||
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Some text");
|
||||
// Each Tab is one undo step.
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(indents(&s), (Some(0.0), Some(0.0)));
|
||||
// A left indent in characters goes back one tab stop too, instead of the paragraphs joining:
|
||||
// six characters of 11 or 12 points are 66 or 72 points, two tab stops.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One\nTwo");
|
||||
let second = Pos::body(1, 0);
|
||||
s.doc.para_mut(StoryRef::Body, &second.path).unwrap().props.indent_left_chars = Some(6.0);
|
||||
s.sel = crate::Selection::caret(second.clone());
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nTwo");
|
||||
let p = s.doc.para_at(&second).unwrap();
|
||||
assert_eq!((p.props.indent_left, p.props.indent_left_chars), (Some(36.0), Some(0.0)));
|
||||
// With formatting limited to the document's styles the option is off: Tab types a tab
|
||||
// character at the start of the first line and of a later one, and Backspace at the start
|
||||
// keeps the paragraph's left indent.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "Two");
|
||||
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tOne\n\tTwo");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.indent_left = Some(36.0);
|
||||
typ(&mut s, "\u{8}\u{8}");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\n\tTwo");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
// An empty paragraph takes a tab character.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "\tText");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tText");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// At the start of a list item numbered by its style, Tab types a tab character, as with the
|
||||
// option off.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tAlpha\nBeta");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// With the option off (the default), Tab types a tab character.
|
||||
let mut s = self::s();
|
||||
typ(&mut s, "Some text");
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tSome text");
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_before_a_later_line_of_a_paragraph_indents_the_whole_paragraph() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, &"Words that run on past the end of one line. ".repeat(6));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
assert!(s.sel.focus.off > 0, "the caret is on the second line");
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
|
||||
// After a line break too; inside a line Tab still types a tab character.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
typ(&mut s, "One");
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "Two");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
run(&mut s, "caret.right", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "One\nT\two");
|
||||
// Before a later line of a list item numbered by its style, Tab takes it down a level, as
|
||||
// for a list item numbered directly.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "More");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert!(!s.doc.plain_text(StoryRef::Body).contains('\t'));
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
|
||||
assert_eq!(p.props.numbering.map(|n| n.level), Some(1));
|
||||
assert_eq!(indents(&s), (None, None));
|
||||
// A list level past the last one, from a style or set directly, goes to the last level.
|
||||
let mut s = style_inherited_list(wordcraft_doc::numbering::ListKind::Numbered, false);
|
||||
let style = s.doc.styles.styles.iter_mut().find(|x| x.id == "ListBase").unwrap();
|
||||
style.para.numbering = style.para.numbering.map(|n| wordcraft_doc::props::NumRef { level: u8::MAX, ..n });
|
||||
run(&mut s, "tools.autocorrect", json!({"tabIndent": true}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.end", json!({}));
|
||||
run(&mut s, "text.lineBreak", json!({}));
|
||||
typ(&mut s, "More");
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
let num = s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.unwrap().num;
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
s.sel = crate::Selection::caret(Pos::body(1, 0));
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "caret.down", json!({}));
|
||||
run(&mut s, "caret.home", json!({}));
|
||||
assert_eq!((s.sel.focus.path.clone(), s.sel.focus.off), (Pos::body(0, 0).path, 6), "the caret is before \"More\"");
|
||||
typ(&mut s, "\t");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
s.doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().props.numbering = Some(wordcraft_doc::props::NumRef { num, level: u8::MAX });
|
||||
run(&mut s, "outline.demote", json!({}));
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().props.numbering.map(|n| n.level), Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_and_hyphen_line_becomes_a_table() {
|
||||
let mut s = typed("+--+------+\nA");
|
||||
@@ -621,6 +858,118 @@ fn plus_and_hyphen_line_becomes_a_table() {
|
||||
assert!(s.doc.body.iter().all(|b| b.as_para().is_some()));
|
||||
}
|
||||
|
||||
/// Body paragraphs as (text, paragraph style).
|
||||
fn styled(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| (p.text.clone(), p.props.style.clone())).collect()
|
||||
}
|
||||
|
||||
fn with_heading_styles(keys: &str) -> Session {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
typ(&mut s, keys);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_enters_after_a_short_line_make_it_a_heading() {
|
||||
let h = |t: &str, st: Option<&str>| (t.to_string(), st.map(str::to_string));
|
||||
// No tab: Heading 1; one or two tabs: Heading 2 or 3, without the tabs. Typing goes on in
|
||||
// the paragraph after the heading.
|
||||
for (keys, style) in [("Introduction\n\nText", "Heading1"), ("\tOur methods\n\nText", "Heading2"), ("\t\tStep 2\n\nText", "Heading3")] {
|
||||
let s = with_heading_styles(keys);
|
||||
let text = keys.trim_start_matches('\t').split('\n').next().unwrap_or_default();
|
||||
assert_eq!(styled(&s), [h(text, Some(style)), h("Text", None)], "{keys:?}");
|
||||
}
|
||||
// Five words is the most; one Enter only ends the line.
|
||||
let s = with_heading_styles("One two three four five\n\n");
|
||||
assert_eq!(s.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Heading1"));
|
||||
assert_eq!(styled(&with_heading_styles("Introduction\nText")), [h("Introduction", None), h("Text", None)]);
|
||||
// Not a heading: six words, a closing punctuation mark, three tabs, a list item, a style.
|
||||
for keys in ["One two three four five six\n\n", "Dear Sir,\n\n", "Is it?\n\n", "\t\t\tDeep\n\n", "* Item\n\n"] {
|
||||
let s = with_heading_styles(keys);
|
||||
assert!(
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.style.as_deref().is_none_or(|st| !st.starts_with("Heading"))),
|
||||
"{keys:?}: {:?}",
|
||||
styled(&s)
|
||||
);
|
||||
}
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
run(&mut q, "para.style", json!({"style": "Quote"}));
|
||||
typ(&mut q, "Quoted\n\n");
|
||||
assert_eq!(q.doc.body.first().and_then(|b| b.as_para()).and_then(|p| p.props.style.as_deref()), Some("Quote"));
|
||||
// A list item numbered by the Normal style is not body text either.
|
||||
let mut q = self::s();
|
||||
run(&mut q, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
let num = q.doc.numbering.try_add_list(wordcraft_doc::ListKind::Bullet).unwrap();
|
||||
if let Some(normal) = q.doc.styles.get_mut("Normal") {
|
||||
normal.para.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
||||
}
|
||||
typ(&mut q, "Item\n\n");
|
||||
assert!(styled(&q).iter().all(|(_, st)| st.as_deref().is_none_or(|st| !st.starts_with("Heading"))), "{:?}", styled(&q));
|
||||
// Off by default.
|
||||
let s = typed("Introduction\n\n");
|
||||
assert_eq!(styled(&s), [h("Introduction", None), h("", None), h("", None)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_right_after_a_heading_autoformat_keeps_the_enter() {
|
||||
let mut s = with_heading_styles("\tMethods\n\n");
|
||||
assert_eq!(styled(&s), [("Methods".to_string(), Some("Heading2".to_string())), (String::new(), None)]);
|
||||
assert_eq!(s.sel.focus, Pos::body(1, 0));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
// The AutoFormat is undone on its own: the tab and Normal text are back, the Enter stays.
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "\tMethods\n\n");
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()));
|
||||
assert_eq!(s.sel.focus, Pos::body(2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tracked_heading_autoformat_can_be_rejected() {
|
||||
// With Track Changes on, the tab is marked deleted and the style change is a formatting
|
||||
// revision, so Reject All brings the line back as it was.
|
||||
let mut s = with_heading_styles("\tMethods\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("\tMethods", Some("Heading2")));
|
||||
assert!(p.run_ranges().any(|(r, c)| r.start == 0 && c.del.is_some()), "the tab is marked deleted");
|
||||
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("\tMethods".to_string(), None)));
|
||||
// A tab this author inserted is removed outright, and the style change is still tracked.
|
||||
let mut s = with_heading_styles("Title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
typ(&mut s, "\t");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
let p = s.doc.para_at(&Pos::body(0, 0)).cloned().unwrap_or_default();
|
||||
assert_eq!((p.text.as_str(), p.props.style.as_deref()), ("Title", Some("Heading2")));
|
||||
assert!(p.props.fmt_change.is_some(), "the style change is tracked");
|
||||
run(&mut s, "review.rejectAll", json!({}));
|
||||
assert_eq!(styled(&s).first(), Some(&("Title".to_string(), None)));
|
||||
// A line holding a tracked deletion is left alone.
|
||||
let mut s = with_heading_styles("Old title\n");
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 3}}));
|
||||
typ(&mut s, "\u{8}\u{8}\u{8}");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 1, "off": 0}}));
|
||||
typ(&mut s, "\n");
|
||||
assert!(styled(&s).iter().all(|(_, st)| st.is_none()), "{:?}", styled(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_heading_style_is_not_applied_as_you_type() {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"headingStyles": true}));
|
||||
run(&mut s, "review.restrict", json!({"mode": "none", "formatting": true, "lockedStyles": ["Heading1"]}));
|
||||
typ(&mut s, "Locked\n\n\tOpen\n\n");
|
||||
let st = styled(&s);
|
||||
assert_eq!(st.first(), Some(&("Locked".to_string(), None)), "{st:?}");
|
||||
assert!(st.contains(&("Open".to_string(), Some("Heading2".to_string()))), "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_bullets() {
|
||||
for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] {
|
||||
@@ -799,6 +1148,20 @@ fn typing_then_selecting_then_typing_are_two_undo_steps() {
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_at_the_start_of_the_document_is_not_an_edit() {
|
||||
// #23: a key that changes nothing adds no undo step, marks no unsaved changes and
|
||||
// leaves the redo stack alone.
|
||||
let mut s = typed("alpha");
|
||||
run(&mut s, "caret.set", json!({"pos": {"block": 0, "off": 0}}));
|
||||
s.dirty = false;
|
||||
let depth = s.undo_depth();
|
||||
typ(&mut s, "\u{8}");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
assert!(!s.dirty, "no unsaved changes");
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap().text, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_bold_at_the_caret_turns_bold_typing_off_again() {
|
||||
// #424: Bold at a caret in an empty paragraph, Undo, type: the text isn't bold.
|
||||
@@ -2511,3 +2874,117 @@ fn a_new_list_uses_the_requested_kind_after_a_mixed_format_list() {
|
||||
assert_eq!(s.doc.para_at(&Pos::body(0, 0)).unwrap(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4085: the registered shortcut toggles the last selected highlight, while explicit
|
||||
/// colour commands remain idempotent setters for the palette, CLI and automation.
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn highlight_shortcut_toggles_the_last_colour() {
|
||||
let mut s = typed("Alpha Beta");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
let colour = |s: &mut Session| s.run("format.state", &json!({})).unwrap()["highlight"].clone();
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), json!("FFFF00"));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(colour(&mut s), json!("FFFF00"));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
assert_eq!(colour(&mut s), json!("0000FF"));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({"color":"none"}));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert_eq!(colour(&mut s), serde_json::Value::Null);
|
||||
assert_eq!(s.doc.body[0].as_para().unwrap().text, "Alpha Beta");
|
||||
}
|
||||
|
||||
/// #4085: mixed selections take one colour; removing an inherited colour writes an
|
||||
/// explicit No Color, and a collapsed caret toggles pending formatting for future typing.
|
||||
#[test]
|
||||
fn highlight_toggle_handles_mixed_inherited_and_pending_formatting() {
|
||||
let mut s = typed("Alpha Beta ");
|
||||
run(&mut s, "select.text", json!({"text":"Alpha"}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], json!("0000FF"));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
s.doc.styles.default_chr.highlight = Some(wordcraft_doc::props::Highlight::Blue);
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert!(s.doc.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(wordcraft_doc::props::Highlight::None)));
|
||||
run(&mut s, "caret.docEnd", json!({}));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::Blue));
|
||||
run(&mut s, "format.highlight", json!({}));
|
||||
assert_eq!(s.pending.as_ref().unwrap().highlight, Some(wordcraft_doc::props::Highlight::None));
|
||||
run(&mut s, "text.insert", json!({"text":"X"}));
|
||||
run(&mut s, "select.text", json!({"text":"X"}));
|
||||
assert_eq!(s.run("format.state", &json!({})).unwrap()["highlight"], serde_json::Value::Null);
|
||||
let before = serde_json::to_value(&s.doc).unwrap();
|
||||
assert!(s.run("format.highlight", &json!({"color":123})).is_err());
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), before);
|
||||
}
|
||||
|
||||
/// A differently coloured final paragraph must remain visible to the shortcut beyond
|
||||
/// the formatting-state query limit, including a style-inherited tail and Undo/Redo.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn highlight_toggle_checks_past_the_resolved_run_limit() {
|
||||
use wordcraft_doc::para::Run;
|
||||
use wordcraft_doc::props::{CharProps, Highlight};
|
||||
for count in [10_000, 10_001, 10_002] {
|
||||
for inherited in [false, true] {
|
||||
let mut doc = wordcraft_doc::Document::from_text(&format!("{}\nZ", "x".repeat(count)));
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(0, 0).path).unwrap().runs = (0..count)
|
||||
.map(|i| Run { len: 1, props: CharProps { highlight: Some(Highlight::Blue), bold: Some(i % 2 == 0), ..Default::default() } })
|
||||
.collect();
|
||||
if inherited {
|
||||
doc.styles.default_chr.highlight = Some(Highlight::Yellow);
|
||||
} else {
|
||||
doc.para_mut(StoryRef::Body, &Pos::body(1, 0).path).unwrap().runs[0].props.highlight = Some(Highlight::Yellow);
|
||||
}
|
||||
let original = doc.clone();
|
||||
let mut s = Session::new(doc);
|
||||
s.last_highlight = Highlight::Blue;
|
||||
run(&mut s, "select.all", json!({}));
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert!(
|
||||
s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::Blue)),
|
||||
"count={count}, inherited={inherited}"
|
||||
);
|
||||
press(&mut s, "Mod+Alt+H").unwrap();
|
||||
assert!(s.doc.body.iter().filter_map(|b| b.as_para()).flat_map(|p| &p.runs).all(|r| r.props.highlight == Some(Highlight::None)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), serde_json::to_value(&original).unwrap());
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert!(s.doc.body[1].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::Blue)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A command preview uses the real session's remembered colour without changing its
|
||||
/// document, colour choice or undo history when the preview toggles highlighting off.
|
||||
#[test]
|
||||
fn highlight_preview_preserves_the_session_colour_and_document() {
|
||||
use wordcraft_doc::props::Highlight;
|
||||
let mut s = typed("Alpha");
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.highlight", json!({"color":"blue"}));
|
||||
let original = serde_json::to_value(&s.doc).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
let (preview, _, _) = s.preview_layout("format.highlight", &json!({})).unwrap();
|
||||
assert!(preview.body[0].as_para().unwrap().runs.iter().all(|r| r.props.highlight == Some(Highlight::None)));
|
||||
assert_eq!(serde_json::to_value(&s.doc).unwrap(), original);
|
||||
assert_eq!(s.last_highlight, Highlight::Blue);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,22 @@ fn moving_a_joined_shape_reroutes_its_connector() {
|
||||
assert_eq!(connector_ends(&mut s), ((172.0, 248.0), (300.0, 218.0)));
|
||||
}
|
||||
|
||||
/// A command that changes nothing but reroutes a stale connector is still an edit: the
|
||||
/// reroute must not be left without an undo step (#23).
|
||||
#[test]
|
||||
fn a_rerouted_connector_is_still_undoable() {
|
||||
let mut s = joined();
|
||||
select(&mut s, 5);
|
||||
let before = connector_ends(&mut s);
|
||||
// A nudge by nothing moves nothing, but the connector is stale and gets rerouted.
|
||||
run(&mut s, "arrange.nudge", json!({"dx": 0, "dy": 0}));
|
||||
assert_ne!(connector_ends(&mut s), before, "the connector was rerouted");
|
||||
assert_eq!(s.undo_depth(), 1, "the reroute is an undo step of its own");
|
||||
// One undo takes the reroute back.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(connector_ends(&mut s), before, "one undo takes the reroute back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_connector_end_glues_it_to_the_nearest_site() {
|
||||
let mut s = joined();
|
||||
|
||||
Reference in New Issue
Block a user