Merge main and preserve both UI parity histories (#6232)

This commit is contained in:
FredApps committed 2026-11-12 18:13:35 +01:00
commit 0196647dc6
46 files changed
+3090 -196

No files matched your search

+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+7 -2
View File
@@ -42,6 +42,10 @@ jobs:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: '0'
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -77,12 +81,13 @@ jobs:
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \
libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev libvulkan1 mesa-vulkan-drivers
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- uses: step-security/dtolnay-rust-toolchain@8dd607977d06adf4ed49b190381e74c17a9dd25f # v1.0.0
with:
toolchain: stable
components: rustfmt, clippy
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- uses: step-security/rust-cache@8871c8ba063416da7c32246932ea6ae08700453e # v2.9.2
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
+5
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "0 3 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -33,6 +37,7 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: wordcraft
app-title: WordCraft
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
Generated
+25 -22
View File
@@ -1081,7 +1081,7 @@ dependencies = [
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"krilla",
"thiserror 2.0.21",
@@ -1135,7 +1135,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1145,6 +1145,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -2981,7 +2982,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6806,7 +6807,7 @@ dependencies = [
[[package]]
name = "wordcraft"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"arboard",
"chrono",
@@ -6838,13 +6839,15 @@ dependencies = [
"wordcraft-engine",
"wordcraft-fonts",
"wordcraft-ui-egui",
"x11-dl",
"x11rb",
"xkbcommon-dl",
"zbus",
]
[[package]]
name = "wordcraft-cli"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"serde_json",
@@ -6860,11 +6863,11 @@ dependencies = [
[[package]]
name = "wordcraft-control-key"
version = "0.15.1"
version = "0.18.1"
[[package]]
name = "wordcraft-doc"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"log",
@@ -6878,7 +6881,7 @@ dependencies = [
[[package]]
name = "wordcraft-docbin"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6895,7 +6898,7 @@ dependencies = [
[[package]]
name = "wordcraft-docx"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6920,7 +6923,7 @@ dependencies = [
[[package]]
name = "wordcraft-engine"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"cfb",
"chrono",
@@ -6954,7 +6957,7 @@ dependencies = [
[[package]]
name = "wordcraft-fonts"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"brotli-decompressor",
"harfrust 0.13.3",
@@ -6968,7 +6971,7 @@ dependencies = [
[[package]]
name = "wordcraft-formats"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"aes",
"base64 0.23.1",
@@ -6995,7 +6998,7 @@ dependencies = [
[[package]]
name = "wordcraft-geom"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"serde",
@@ -7003,7 +7006,7 @@ dependencies = [
[[package]]
name = "wordcraft-layout"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-proof",
"image",
@@ -7023,7 +7026,7 @@ dependencies = [
[[package]]
name = "wordcraft-mcp"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"serde_json",
@@ -7035,7 +7038,7 @@ dependencies = [
[[package]]
name = "wordcraft-metafile"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"kurbo",
"thiserror 2.0.21",
@@ -7043,7 +7046,7 @@ dependencies = [
[[package]]
name = "wordcraft-pdf"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"chrono",
"craft-pdf",
@@ -7064,7 +7067,7 @@ dependencies = [
[[package]]
name = "wordcraft-render"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"image",
"log",
@@ -7078,7 +7081,7 @@ dependencies = [
[[package]]
name = "wordcraft-ui-egui"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"accesskit_consumer",
"cfb",
@@ -7111,7 +7114,7 @@ dependencies = [
[[package]]
name = "wordcraft-web"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"craft-web-api",
"craft-web-host",
@@ -7132,7 +7135,7 @@ dependencies = [
[[package]]
name = "wordcraft-zotero"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"log",
"proptest",
@@ -7295,7 +7298,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.15.1"
version = "0.18.1"
dependencies = [
"serde_json",
]
+4 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/wordcraft", "apps/wordcraft-cli", "xtask"]
[workspace.package]
version = "0.15.1"
version = "0.18.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -67,9 +67,9 @@ wuff = "0.2.9"
brotli-decompressor = "5"
vello_cpu = "0.2"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
@@ -134,7 +134,7 @@ debug = 0
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+5
View File
@@ -354,6 +354,11 @@ XWayland:
WORDCRAFT_WAYLAND=1 wordcraft
```
XWayland needs the X11 libraries, which the packages only recommend and the tarball doesn't
install: libX11, libX11-xcb, libXcursor, libXi and libxkbcommon-x11 (on Debian and Ubuntu,
`libx11-6`, `libx11-xcb1`, `libxcursor1`, `libxi6` and `libxkbcommon-x11-0`). Without them the app
opens on native Wayland, whatever the setting says, and logs which ones are missing.
**Arch Linux (AUR).** Community members maintain three AUR packages; the project doesn't publish
to the AUR itself. Install one of them:
| Package | What it installs | Choose it when |
+3 -1
View File
@@ -8,7 +8,8 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (progress log: Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (progress log: Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (progress log: #4501 refactor regressions restored: per-section header grid, ODT default font, link tooltip, #4683); previously trivial (progress log: the window moves by its title bar behind a modal dialog, #5124); previously trivial (progress log: `convert --strict` and `render --strict` refuse a silently incomplete PDF or PNG (#6005, #6008)); ; trivial (progress log: ODT columns stating only their shares); previously trivial (progress log: ODT font size for every script); previously trivial (progress log: ODT text turning off what its style turns on); previously trivial (progress log: edited ODT paragraphs keep custom styles with an outline level); previously trivial (progress log: ODT pictures keep the size their file gives); previously trivial (progress log: ODT frames keep the page they are anchored to); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux no longer looks semi-bold); previously trivial (Control/MCP #2596 review: partial-write outcomes, path-bearing image exports, accept-thread startup cleanup and malformed UTF-8 handling); previously trivial (progress log: a turned inline picture whose file keeps no room for the turn keeps its line to its frame); previously trivial (progress log: the Arabic interface's Styles gallery reads from the right (#5566)); previously trivial (progress log: ODT pictures offered in several formats); previously trivial (progress log: ODT heading text of another size); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress lLine truncated
>
> **Recipient CSV update:** 2026-10-27 · Complete quoted header records choose the delimiter (#4249); detection and regressions follow the current mailings module split. Recipient limits and readiness estimates are unchanged.
>
@@ -183,6 +184,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-11-06: Line numbers take the document's "Line Number" style over its defaults, as in Word (Aptos 12 pt black in a manuscript whose defaults are Aptos 12 pt, measured in Word for Mac), instead of a fixed 10 pt grey. Readiness estimates unchanged.
- 2026-11-07: Newly opened secondary windows constrain their initial viewport dimensions to the monitor's usable area with margins for system panels, preventing new windows from exceeding screen width on high-DPI scaled displays (#5871).
- 2026-11-07: A floating anchor after a page break stays with text on the far side of the same wrapped row (Leaf #107); marker-only cover anchors retain their rule. Picture spacing, drop position, Undo/Redo and DOCX regressions.
- 2026-11-07: Vertical scrollbar track: clicking above the elevator advances one screen up and clicking below it advances one screen down, matching Word's behavior, instead of jumping to the clicked document position (#5853). Readiness estimates unchanged.
- 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged.
- 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged.
- 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged.
+28 -3
View File
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use serde_json::{Value, json};
use wordcraft_engine::Session;
use wordcraft_engine::{CmdError, Session};
const USAGE: &str = "\
wordcraft-cli — WordCraft from the command line
@@ -19,7 +19,8 @@ wordcraft-cli — WordCraft from the command line
USAGE:
wordcraft-cli convert <in> <out> [--markup] [--update-fields] [--strict] [--format wordml2003|flatopc]
convert between formats (docx, doc [read], pdf, odt, rtf, html, md, tex, txt, json, png,
xml); --update-fields refreshes unlocked fields before saving;
xml); --update-fields refreshes unlocked fields before saving
(if protection blocks updates, they are skipped with a warning);
an .xml file is a Word XML Document (Flat OPC), or with
--format wordml2003 a Word 2003 XML Document;
--markup (Print Markup): comments beside each page and
@@ -451,6 +452,30 @@ fn report_lines(json: &Value) -> Vec<String> {
wordcraft_engine::strict::lines_from_json(json)
}
/// Explicitly update unlocked fields in a session before saving the document.
///
/// If field updates are disabled by document protection (`CmdError::Disabled`), logs a warning to
/// stderr and continues conversion rather than aborting. Other command failures are converted to
/// [`Failure`] via [`engine_error`].
///
/// # Arguments
///
/// * `s` - The active document [`Session`] whose unlocked fields should be refreshed.
///
/// # Errors
///
/// Returns `Err(Failure)` if the command fails with an error other than `CmdError::Disabled`.
fn update_fields(s: &mut Session) -> Result<(), Failure> {
if let Err(e) = s.run("references.updateFields", &json!({})) {
if matches!(e, CmdError::Disabled(_)) {
eprintln!("wordcraft-cli: warning: {e}");
} else {
return Err(engine_error(e));
}
}
Ok(())
}
/// `convert <in> <out>`: `rest` is the command line after `convert`, `open` opens the input.
fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Session, Failure>) -> Result<(), Failure> {
validate_options("convert", rest, CONVERT_OPTIONS)?;
@@ -473,7 +498,7 @@ fn run_convert(rest: &[String], open: impl Fn(&str, Option<&str>) -> Result<Sess
let password = password_from(rest)?;
let mut s = open(&i, password.as_deref())?;
if rest.iter().any(|a| a == "--update-fields") {
s.run("references.updateFields", &json!({})).map_err(engine_error)?;
update_fields(&mut s)?;
}
let r = if to_png {
s.run("file.exportPng", &json!({"path": o, "page": 1, "markup": markup, "strict": strict}))
@@ -47,3 +47,65 @@ fn convert_refresh_is_opt_in() {
}
std::fs::remove_dir_all(dir).unwrap();
}
/// Converting a protected document with `--update-fields` skips updating fields with a warning,
/// retains the cached field result, and preserves the protection mode on output.
#[test]
fn convert_protected_document_with_update_fields_skips_with_warning() {
let dir = std::env::temp_dir().join(format!("wc-convert-fields-prot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (mode, expected_warning) in [("readOnly", "protected"), ("forms", "protected"), ("comments", "comments")] {
let mut doc = Document::from_text("Contents\nTarget");
doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props.page_break_before = Some(true);
doc.insert_object(&Pos::body(1, 0), InlineObject::BookmarkStart { name: "target".into() }, &Default::default()).unwrap();
doc.insert_object(
&Pos::body(0, 0),
InlineObject::Field {
instr: "PAGEREF target".into(),
result: "104".into(),
locked: false,
dirty: true,
code: false,
form: String::new(),
},
&Default::default(),
)
.unwrap();
doc.settings.protection = Some(mode.into());
let input = dir.join(format!("in_{mode}.json"));
std::fs::write(&input, serde_json::to_vec(&doc).unwrap()).unwrap();
let docx = dir.join(format!("in_{mode}.docx"));
let make = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")).arg("convert").arg(&input).arg(&docx).output().unwrap();
assert!(make.status.success(), "{}", String::from_utf8_lossy(&make.stderr));
let output = dir.join(format!("out_{mode}.json"));
let _ = std::fs::remove_file(&output);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
cmd.arg("convert").arg(&docx).arg(&output).arg("--update-fields");
let run = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&run.stderr);
assert!(run.status.success(), "exit status was failure for mode {mode}: {stderr}");
assert!(stderr.contains("warning:"), "stderr missing warning for mode {mode}: {stderr}");
assert!(stderr.contains(expected_warning), "stderr missing '{expected_warning}' for mode {mode}: {stderr}");
assert!(output.exists(), "output file was not created for mode {mode}");
let out: Document = serde_json::from_slice(&std::fs::read(&output).unwrap()).unwrap();
let results: Vec<_> = out
.para(StoryRef::Body, &Path::top(0))
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.as_str()),
_ => None,
})
.collect();
// Result should remain "104" (cached) because the field could not be updated under protection
assert_eq!(results, vec!["104"], "field result was unexpectedly modified for mode {mode}");
assert_eq!(out.settings.protection.as_deref(), Some(mode), "protection mode was not preserved for mode {mode}");
}
std::fs::remove_dir_all(dir).unwrap();
}
+5
View File
@@ -64,6 +64,11 @@ wl-clipboard-rs = "0.9"
# Another program's RTF on X11 (`text/rtf`), which arboard doesn't read. Already in the build
# through arboard, at this version.
x11rb = "0.13"
# Check that the libraries winit's X11 backend opens are installed before choosing XWayland (see
# `x11_libraries` in main.rs). Both are already in the build through winit's `x11` feature, at
# these versions and with this feature.
x11-dl = "2.21"
xkbcommon-dl = { version = "0.4.2", features = ["x11"] }
[target.'cfg(target_os = "windows")'.dependencies]
# The upstream default-apps button opens Windows Settings; printing uses craft-print instead.
+4
View File
@@ -334,6 +334,8 @@ fn save_exts(name: &str) -> Vec<String> {
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_ascii_lowercase)
// A .markdown file is under Markdown's one entry in the list.
.map(|e| if e == "markdown" { "md".to_string() } else { e })
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
.unwrap_or_else(|| "docx".to_string());
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
@@ -458,6 +460,8 @@ mod tests {
assert_eq!(save_exts("novel.docx")[0], "docx");
assert_eq!(save_exts("report.odt")[0], "odt");
assert_eq!(save_exts("annual report.pdf")[0], "pdf");
assert_eq!(save_exts("notes.md")[0], "md");
assert_eq!(save_exts("notes.markdown")[0], "md");
// A suggested name always has an extension; anything unreadable falls back to Word's.
assert_eq!(save_exts("no extension")[0], "docx");
assert_eq!(save_exts("mystery.rtf2")[0], "docx");
+67 -8
View File
@@ -278,9 +278,16 @@ fn saved_ui() -> Option<UiState> {
/// whenever there's an X server: winit has no file drag-and-drop on Wayland, so dropping files from
/// the file manager showed a "no" cursor. X11 is only forced with an X server (`DISPLAY`), and
/// unforced winit picks Wayland when it's there and X11 otherwise, so a choice that isn't
/// available falls back to the other instead of failing to start.
/// available falls back to the other instead of failing to start. The X11 libraries are only
/// recommended by the packages, so when `x11_libraries` reports some missing (it's asked only when
/// X11 would be forced) the app stays on Wayland: forced X11 without them failed to start, and
/// winit panicked without libxkbcommon-x11.
#[cfg(all(unix, not(target_os = "macos")))]
fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Option<std::ffi::OsString>) -> (bool, String) {
fn uses_x11(
setting: wordcraft_ui_egui::DisplayServer,
env: impl Fn(&str) -> Option<std::ffi::OsString>,
x11_libraries: impl FnOnce() -> Result<(), String>,
) -> (bool, String) {
use wordcraft_ui_egui::DisplayServer;
let set = |name: &str| env(name).is_some_and(|v| !v.is_empty());
let (wanted, by) = match env("WORDCRAFT_WAYLAND") {
@@ -290,17 +297,39 @@ fn uses_x11(setting: wordcraft_ui_egui::DisplayServer, env: impl Fn(&str) -> Opt
Some(_) => (DisplayServer::Wayland, "WORDCRAFT_WAYLAND"),
None => (setting, "the setting"),
};
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY");
let missing = if wanted != DisplayServer::Wayland && set("DISPLAY") { x11_libraries().err() } else { None };
let x11 = wanted != DisplayServer::Wayland && set("DISPLAY") && missing.is_none();
let wayland = set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET");
let runs = if x11 {
"X11"
} else if set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET") {
} else if wayland {
"native Wayland"
} else if set("DISPLAY") {
"X11 (no Wayland)"
} else {
"neither (no Wayland or X server found)"
};
(x11, format!("display: {runs}; {} asks for {}", by, wanted.code()))
let mut why = format!("display: {runs}; {} asks for {}", by, wanted.code());
if let Some(missing) = missing {
why += &format!("; not X11 because its libraries are missing: {missing}");
}
(x11, why)
}
/// `Err` naming the libraries winit's X11 backend opens at startup that can't be loaded: libX11,
/// libXcursor, libX11-xcb (which brings in libxcb), libXi, and libxkbcommon-x11 for the keyboard.
#[cfg(all(unix, not(target_os = "macos")))]
fn x11_libraries() -> Result<(), String> {
use x11_dl::{xcursor::Xcursor, xinput2::XInput2, xlib::Xlib, xlib_xcb::Xlib_xcb};
let mut missing: Vec<String> = [Xlib::open().err(), Xcursor::open().err(), Xlib_xcb::open().err(), XInput2::open().err()]
.into_iter()
.flatten()
.map(|e| e.to_string())
.collect();
if xkbcommon_dl::x11::xkbcommon_x11_option().is_none() {
missing.push("libxkbcommon-x11.so.0 could not be loaded".into());
}
if missing.is_empty() { Ok(()) } else { Err(missing.join("; ")) }
}
fn save_prefs(app: &WordApp) {
@@ -561,7 +590,7 @@ fn main() -> eframe::Result {
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name));
let (x11, why) = uses_x11(saved.as_ref().map(|ui| ui.display_server).unwrap_or_default(), |name| std::env::var_os(name), x11_libraries);
log::info!("{why}");
if x11 {
options.event_loop_builder = Some(Box::new(|b| {
@@ -694,7 +723,7 @@ mod tests {
fn the_display_server_follows_the_variable_then_the_setting() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let x11 = |setting, vars: &[(&str, &str)]| {
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v))).0
uses_x11(setting, |name| vars.iter().find(|(k, _)| *k == name).map(|(_, v)| std::ffi::OsString::from(v)), || Ok(())).0
};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
assert!(x11(Auto, &both), "Automatic keeps XWayland, for file drag-and-drop");
@@ -710,10 +739,40 @@ mod tests {
let wayland_only = [("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")];
assert!(!x11(X11, &wayland_only) && !x11(Auto, &wayland_only));
// Without Wayland, Wayland isn't forced either: unforced winit picks X11.
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()));
let (forced, why) = uses_x11(Wayland, |name| (name == "DISPLAY").then(|| ":0".into()), || Ok(()));
assert!(!forced && why.contains("X11 (no Wayland)"), "{why}");
}
/// Without the X11 libraries (the packages only recommend them) X11 isn't forced, whatever asks
/// for it: forced X11 failed to start, and winit panicked without libxkbcommon-x11. The check
/// runs only when X11 would be forced.
#[test]
fn missing_x11_libraries_leave_the_app_on_wayland() {
use wordcraft_ui_egui::DisplayServer::{Auto, Wayland, X11};
let both = [("DISPLAY", ":0"), ("WAYLAND_DISPLAY", "wayland-0")];
for (setting, variable) in [(Auto, None), (X11, None), (Wayland, Some("0"))] {
let env = |name: &str| {
both.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).into())
.or_else(|| (name == "WORDCRAFT_WAYLAND").then_some(variable).flatten().map(Into::into))
};
let (x11, why) = uses_x11(setting, env, || Err("libxkbcommon-x11.so.0 could not be loaded".into()));
assert!(!x11, "{setting:?} with WORDCRAFT_WAYLAND={variable:?}: {why}");
assert!(why.contains("native Wayland") && why.contains("libxkbcommon-x11"), "{why}");
}
let checked = std::cell::Cell::new(false);
let (x11, _) = uses_x11(
Wayland,
|name| both.iter().find(|(k, _)| *k == name).map(|(_, v)| (*v).into()),
|| {
checked.set(true);
Ok(())
},
);
assert!(!x11 && !checked.get(), "asked for Wayland, the libraries aren't checked");
}
/// A named pipe or a device under such a name is a warning, not a read: the pipe blocked
/// start-up until something wrote to it, and `/dev/zero` read until memory ran out.
#[test]
+2
View File
@@ -24,6 +24,7 @@ pub mod insert;
pub mod inspector;
pub mod lists;
pub mod mailings;
pub mod md_source;
pub mod moves;
pub mod objects;
pub mod options;
@@ -73,6 +74,7 @@ pub fn registry() -> Registry {
v.extend(lists::specs());
v.extend(inspector::specs());
v.extend(view::specs());
v.extend(md_source::specs());
v.extend(outline::specs());
v.extend(insert::specs());
v.extend(equation::specs());
+43 -3
View File
@@ -269,6 +269,10 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
super::view::restore_zoom(s, z);
}
s.path = Some(path.into());
// A Markdown file's own text, for the view to show and Save to write back as it is.
if super::md_source::is_markdown_file(std::path::Path::new(path)) {
s.md_file = markdown_file_text(path, v);
}
// Like Word, a document opened with a password is saved with it again.
if encrypted {
s.password = password.map(crate::io::Password::new);
@@ -278,6 +282,28 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
)
}
/// A Markdown file's own text as `file.open` finds it: the bytes the caller hands over (base64 in
/// `data`, the same ones `read_document` decodes), else the head of the file at `path`.
/// `FileText::from_bytes` keeps it only when it can be written back as it is.
fn markdown_file_text(path: &str, v: &Value) -> super::md_source::FileText {
if let Some(data) = p::str(v, "data") {
// `read_document` has already refused a text it cannot decode.
return super::insert::base64_decode(data).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default();
}
// Without `data`, the file's own bytes; not reachable on the web, where a file only arrives
// as `data`.
#[cfg(not(target_arch = "wasm32"))]
{
let max = u64::try_from(super::md_source::MAX_SOURCE_BYTES).unwrap_or(u64::MAX).saturating_add(1);
crate::io::read_file_head(path, max).map(|bytes| super::md_source::FileText::from_bytes(&bytes)).unwrap_or_default()
}
#[cfg(target_arch = "wasm32")]
{
let _ = path;
super::md_source::FileText::default()
}
}
/// The document the file at `path` holds (or its bytes, base64 in `data`, with `path` only
/// naming it), read with `password`, and whether it was encrypted: what `file.open` and
/// `file.newFromTemplate` read. An empty file (File Explorer's New › Microsoft Word Document makes
@@ -364,6 +390,8 @@ fn encrypt(s: &mut Session, v: &Value) -> CmdResult {
Ok(json!({"encrypted": s.password.is_some()}))
}
/// Executes `file.save`: writes the document or text to disk, preserving exact file text for
/// unedited Markdown files, and updating session path and dirty state.
fn save(s: &mut Session, v: &Value) -> CmdResult {
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
Some(p) => p,
@@ -407,20 +435,32 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
let password = s.password.as_ref().map(crate::io::Password::as_str);
// A header/footer just made and left empty isn't saved (#960).
let copy = super::insert::saved_copy(s);
if let Err(e) = crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts) {
// A Markdown file's own text goes back as it is while it still says what the document holds;
// once it doesn't (an edit in the formatted view), the document is written out again.
let exact = super::md_source::text_to_save(s, &path, opts.markup);
let rewritten = exact.is_none() && !s.md_file.is_empty() && super::md_source::is_markdown_file(&path);
let written = match &exact {
Some(text) => crate::io::save_text_path(&path, text),
None => crate::io::save_path_with(&path, copy.as_ref().unwrap_or(&s.doc), password, &opts),
};
if let Err(e) = written {
(s.doc.settings.zoom, s.doc.core.last_modified_by, s.doc.core.created, s.doc.core.modified, s.doc.core.revision) = before_stamp;
return Err(CmdError::Failed(e));
}
let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy());
// A save in a format that doesn't keep everything (plain text, Markdown, a page image) is a
// copy, except over the document's own file: a note.txt opened and saved is saved (#2376).
// A Markdown file saved as another one loses nothing its own save wouldn't: it moves there.
let own_file = s.path.as_deref() == Some(path.as_path());
if own_file || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
let markdown_to_markdown = super::md_source::is_markdown_file(&path) && s.path.as_deref().is_some_and(super::md_source::is_markdown_file);
if own_file || markdown_to_markdown || ["docx", "docm", "dotx", "dotm", "odt", "ott", "fodt", "rtf", "json", "xml"].contains(&ext.as_str()) {
s.path = Some(path.clone());
s.dirty = false;
s.relayout();
// What the file holds now.
s.md_file = super::md_source::FileText::saved(exact.filter(|_| super::md_source::is_markdown_file(&path)));
}
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted}))
Ok(json!({"saved": true, "path": path.to_string_lossy(), "encrypted": encrypted, "rewritten": rewritten}))
}
/// Does `path` hold a Word 2003 XML Document already (so a plain save keeps that format)?
+897
View File
@@ -0,0 +1,897 @@
//! View › Markdown Source: a Markdown file's document as editable text.
//!
//! The document holds no Markdown while it is edited, so entering writes it out as text, and the
//! text waits beside the document ([`MdSource`]) while it is edited. Leaving reads a changed text
//! back into a new document; one Undo takes back the whole visit. A visit that changes nothing
//! leaves the document as it was, whatever Markdown can't represent.
//!
//! While the text is open it is the document: commands that edit the document are refused, those
//! that read it (Save, Export) first get the text applied, and those that change the view leave.
//!
//! The file's own text is kept beside the document too ([`FileText`]): while it still stands for
//! the document, the view shows it and Save writes it, unchanged. An edit in the formatted view
//! makes the document something only writing it out again can say.
use std::cell::RefCell;
use std::sync::Arc;
use serde_json::{Value, json};
use wordcraft_doc::{Document, Path, Pos, StoryRef};
use crate::doc_state::Selection;
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// The largest text the view takes, in bytes: its text box lays all of it out after each edit.
pub const MAX_SOURCE_BYTES: usize = 2 * 1024 * 1024;
const LABEL: &str = "Markdown Source";
/// The text of an open Markdown Source view.
#[derive(Clone)]
pub struct MdSource {
/// What the view shows and edits.
pub text: String,
/// The caret in `text`, as a byte offset. The front end keeps it current.
pub caret: usize,
/// The front end still has to put its caret at `caret` and show it.
pub place_caret: bool,
/// The front end still has to reset its typing history from earlier visits.
pub reset_undo: bool,
/// Markdown can't represent all of the document: applying a changed text loses something.
pub loses_content: bool,
/// When the front end last applied the text while it was being typed, on its own clock.
pub applied_at_ms: f64,
/// The text on entry.
entry_text: Arc<str>,
/// The document on entry, which that text stands for: back to it, nothing is lost.
entry_doc: Arc<Document>,
/// The line each top-level block of `entry_doc` starts on.
entry_lines: Vec<usize>,
/// The text the document now holds.
applied: Arc<str>,
/// The visit has its undo step.
has_step: bool,
/// The undo mark recorded when the visit took its undo step, if one was taken.
step: Option<u64>,
/// The line each top-level block starts on, from the last parse.
block_lines: Vec<usize>,
}
impl MdSource {
/// The text holds edits the document doesn't have yet.
pub fn is_pending(&self) -> bool {
self.text != *self.applied
}
}
/// A Markdown file's own text, which keeps what the document can't (front matter, comments, a
/// code block's language, the author's own spacing): the text the file was opened or last saved
/// with, and the last text the view applied. Either stands for the document while reading it
/// gives the document as it is, so an Undo back to either finds it again.
#[derive(Clone, Default)]
pub struct FileText {
saved: Option<String>,
latest: Option<String>,
cached_exact: RefCell<Option<(u64, Option<String>)>>,
}
impl FileText {
/// A file's bytes as its text, if they can be written back as they are: valid UTF-8,
/// not empty, and no longer than the view takes.
pub fn from_bytes(bytes: &[u8]) -> Self {
let keep = !bytes.is_empty() && bytes.len() <= MAX_SOURCE_BYTES;
let saved = keep.then(|| std::str::from_utf8(bytes).ok()).flatten().map(str::to_string);
Self { saved, latest: None, cached_exact: RefCell::new(None) }
}
/// The text just written to the file, which is the file's own text now.
pub fn saved(text: Option<String>) -> Self {
Self { saved: text, latest: None, cached_exact: RefCell::new(None) }
}
/// No text is kept.
pub fn is_empty(&self) -> bool {
self.saved.is_none() && self.latest.is_none()
}
}
/// The kept text that stands for the document as it is, if one does: the latest first.
fn exact_text(s: &Session) -> Option<String> {
if let Some((cached_rev, ref res)) = *s.md_file.cached_exact.borrow()
&& cached_rev == s.rev()
{
return res.clone();
}
let kept = [s.md_file.latest.as_deref(), s.md_file.saved.as_deref()];
let res = kept.into_iter().flatten().find(|text| parse(&s.doc, text).same_content(&s.doc)).map(str::to_string);
*s.md_file.cached_exact.borrow_mut() = Some((s.rev(), res.clone()));
res
}
/// What Save writes to the Markdown file `path`, in place of the document written out again: the
/// file's own text, while it stands for the document. Not for a Print Markup copy, which adds
/// the comments.
pub(crate) fn text_to_save(s: &Session, path: &std::path::Path, markup: bool) -> Option<String> {
if markup || !is_markdown_file(path) {
return None;
}
exact_text(s)
}
/// Command specifications for Markdown Source view toggling and text inspection.
pub fn specs() -> Vec<CommandSpec> {
vec![
// Not `pure`: leaving edits the document. Entering changes nothing, so it adds no undo step.
CommandSpec::new(TOGGLE, LABEL, "View › Views", toggle).params(r#"{"value"?: bool}"#).when(can_toggle),
CommandSpec::new(TEXT, "Markdown Source Text", "View › Views", text)
.params(r#"{"text"?: string (up to 2 MB), "caret"?: integer >= 0}"#)
.when(|s| s.md_source.is_none().then_some("only in Markdown Source")),
]
}
const TOGGLE: &str = "view.markdownSource";
const TEXT: &str = "view.markdownSourceText";
/// The views that replace this one: they show the document, so the text goes into it first.
const LEAVES: [&str; 5] = ["view.printLayout", "view.webLayout", "view.draft", "view.outline", "view.readMode"];
/// Whether a file's name says it is Markdown.
pub fn is_markdown_file(path: &std::path::Path) -> bool {
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
}
/// Whether the current session's path points to a Markdown file.
fn is_markdown_path(s: &Session) -> bool {
s.path.as_deref().is_some_and(is_markdown_file)
}
/// Checks whether the Markdown Source view can currently be toggled in `s`.
fn can_toggle(s: &Session) -> Option<&'static str> {
if s.md_source.is_some() {
None
} else if !is_markdown_path(s) {
Some("only for Markdown (.md) files")
} else if s.view.read_mode {
Some("not in Read Mode")
} else if s.doc.settings.track_changes || !s.doc.revisions.is_empty() {
Some("not with tracked changes")
} else if s.doc.settings.protection.is_some() {
Some("not in a protected document")
} else {
None
}
}
/// Toggles or sets the Markdown Source view state.
fn toggle(s: &mut Session, v: &Value) -> CmdResult {
let on = p::bool(v, "value").unwrap_or(s.md_source.is_none());
match (on, s.md_source.is_some()) {
(true, false) => enter(s)?,
(false, true) => leave(s),
_ => {}
}
Ok(json!({"value": s.md_source.is_some()}))
}
/// Gets or sets the Markdown Source text and caret position.
fn text(s: &mut Session, v: &Value) -> CmdResult {
let new_text = p::str(v, "text");
if new_text.is_some_and(|t| t.len() > MAX_SOURCE_BYTES) {
return Err(CmdError::Params(too_long()));
}
if let Some(cv) = v.get("caret").filter(|c| !c.is_null())
&& cv.as_u64().is_none()
{
return Err(CmdError::Params("`caret` must be a non-negative integer".into()));
}
let Some(src) = s.md_source.as_mut() else { return Err(CmdError::Disabled("only in Markdown Source".into())) };
let mut edited = false;
if let Some(t) = new_text {
edited = src.text != t;
src.text = t.to_string();
src.caret = src.text.floor_char_boundary(src.caret.min(src.text.len()));
}
if let Some(caret) = v.get("caret").and_then(Value::as_u64) {
let caret = usize::try_from(caret).unwrap_or(usize::MAX).min(src.text.len());
src.caret = src.text.floor_char_boundary(caret);
src.place_caret = true;
}
if edited {
src.place_caret = true;
s.dirty = true;
}
let mut result = json!({"caret": src.caret, "losesContent": src.loses_content});
if new_text.is_none() {
result["text"] = json!(&src.text);
}
Ok(result)
}
/// Error message returned when Markdown Source text exceeds [`MAX_SOURCE_BYTES`].
fn too_long() -> String {
format!("Markdown Source takes up to {} MB of text", MAX_SOURCE_BYTES / (1024 * 1024))
}
/// Enters Markdown Source view, preparing source text and mapping caret position.
fn enter(s: &mut Session) -> Result<(), CmdError> {
// The file's own text when it still says what the document holds, so nothing is lost.
let (text, loses_content, block_lines) = match exact_text(s) {
Some(text) => {
let (_, lines) = parse_with_lines(&s.doc, &text);
(text, false, lines)
}
None => {
let text = wordcraft_formats::markdown::export(&s.doc);
if text.len() > MAX_SOURCE_BYTES {
return Err(CmdError::Failed(too_long()));
}
let (doc, lines) = parse_with_lines(&s.doc, &text);
let loses_content = !doc.same_content(&s.doc);
(text, loses_content, lines)
}
};
let block = if s.sel.focus.story == StoryRef::Body { s.sel.focus.path.0.first().copied().unwrap_or(0) as usize } else { 0 };
let line = block_lines.get(block).copied().unwrap_or(0);
let caret = line_start_byte(&text, line);
let text_arc: Arc<str> = Arc::from(text.as_str());
let entry_lines = block_lines.clone();
s.md_source = Some(MdSource {
caret,
place_caret: true,
reset_undo: true,
loses_content,
applied_at_ms: 0.0,
entry_doc: Arc::new(s.doc.clone()),
entry_lines,
entry_text: text_arc.clone(),
applied: text_arc,
text,
has_step: false,
step: None,
block_lines,
});
Ok(())
}
/// Leaves Markdown Source view, applying changes to the document model.
fn leave(s: &mut Session) {
// `Session::run` took a step for this command: the visit's first, or one too many.
// If the toggle was joined to an earlier edit (`join_next_undo`), it created no checkpoint.
let had_step = s.md_source.as_ref().is_some_and(|src| src.has_step);
let step = s.md_source.as_ref().and_then(|src| src.step);
let mark = s.undo_mark();
let toggle_created_step = step.is_some_and(|step| mark > step);
if toggle_created_step {
s.drop_last_undo_step();
}
let mark_after = s.undo_mark();
if let Some(src) = s.md_source.as_mut() {
src.has_step = true;
if src.step.is_none() {
src.step = Some(mark_after);
}
}
apply(s);
if let Some(src) = s.md_source.take()
&& (had_step || src.text != *src.entry_text)
{
let block = block_of_caret(&src.text, src.caret, &s.doc, &src.block_lines);
s.sel = Selection::caret(block);
}
}
/// The text into the document, if it has edits the document lacks. Its first change in a visit
/// takes the visit's undo step. Save, AutoSave and crash recovery see the text this way.
pub fn apply(s: &mut Session) {
let (text, entry_text, entry_doc, has_step, entry_lines) = {
let Some(src) = s.md_source.as_ref().filter(|src| src.is_pending()) else { return };
if src.text.len() > MAX_SOURCE_BYTES {
return;
}
(src.text.clone(), src.entry_text.clone(), src.entry_doc.clone(), src.has_step, src.entry_lines.clone())
};
let (doc, lines) = if text == *entry_text { ((*entry_doc).clone(), entry_lines) } else { parse_with_lines(&s.doc, &text) };
let step = if !has_step {
s.checkpoint(LABEL);
Some(s.undo_mark())
} else {
s.md_source.as_ref().and_then(|src| src.step)
};
s.doc = doc;
s.note_body_replaced();
s.math = None;
s.touch();
s.clamp_selection();
s.md_file.latest = Some(text.clone());
*s.md_file.cached_exact.borrow_mut() = Some((s.rev(), Some(text.clone())));
if let Some(src) = s.md_source.as_mut() {
src.applied = Arc::from(text.as_str());
src.block_lines = lines;
src.has_step = true;
src.step = step;
}
}
/// `text` as a document and block lines, with what isn't in Markdown from `current`: its page, settings,
/// properties and language, as opening the file gave it.
fn parse_with_lines(current: &Document, text: &str) -> (Document, Vec<usize>) {
let (mut doc, lines) = wordcraft_formats::markdown::import_on_with_lines(text, &current.last_section);
doc.ensure_nonempty();
doc.settings = current.settings.clone();
doc.core = current.core.clone();
Arc::make_mut(&mut doc.styles).default_chr.lang = current.styles.default_chr.lang.clone();
(doc, lines)
}
/// `text` as a document, with what isn't in Markdown from `current`: its page, settings,
/// properties and language, as opening the file gave it.
fn parse(current: &Document, text: &str) -> Document {
parse_with_lines(current, text).0
}
/// The byte offset where `line` starts, or the end of a text with fewer lines.
fn line_start_byte(text: &str, line: usize) -> usize {
if line == 0 {
return 0;
}
let mut lines = 0;
for (i, &b) in text.as_bytes().iter().enumerate() {
if b == b'\n' {
lines += 1;
if lines == line {
return i.saturating_add(1);
}
}
}
text.len()
}
/// The 0-based line that byte offset `byte` belongs to.
fn line_of_byte(text: &str, byte: usize) -> usize {
let slice = text.as_bytes().get(..byte).unwrap_or(text.as_bytes());
slice.iter().filter(|&&b| b == b'\n').count()
}
/// Where the caret at byte offset `caret` of `text` lands in `doc`: at the
/// start of the top-level block its line belongs to.
fn block_of_caret(text: &str, caret: usize, doc: &Document, block_lines: &[usize]) -> Pos {
let caret_line = line_of_byte(text, caret);
let top = if block_lines.is_empty() { 0 } else { block_lines.partition_point(|&l| l <= caret_line).saturating_sub(1) };
let top = top.min(doc.body.len().saturating_sub(1));
let top = u32::try_from(top).unwrap_or(0);
let path = doc.para_paths(StoryRef::Body).into_iter().find(|path| path.0.first().is_some_and(|b| *b >= top));
match path {
Some(path) => Pos { story: StoryRef::Body, path, off: 0 },
None => Pos { story: StoryRef::Body, path: Path::top(0), off: 0 },
}
}
/// Why the open text refuses command `id`, if it does.
pub(crate) fn refusal(s: &Session, id: &str, mutates: bool) -> Option<&'static str> {
(s.md_source.is_some()
&& id != TOGGLE
&& id != TEXT
&& !LEAVES.contains(&id)
&& (mutates || matches!(id, "edit.undo" | "edit.undoTo" | "edit.redo" | "edit.repeat")))
.then_some("leave Markdown Source first")
}
/// What a command does while the text is open, before it runs (see the module's header).
pub(crate) fn before_command(s: &mut Session, id: &str, mutates: bool) -> Result<(), CmdError> {
if s.md_source.is_none() || id == TOGGLE || id == TEXT {
return Ok(());
}
if LEAVES.contains(&id) {
s.run(TOGGLE, &json!({"value": false}))?;
} else if let Some(why) = refusal(s, id, mutates) {
return Err(CmdError::Disabled(format!("{id}: {why}")));
} else if !id.starts_with("caret.") && !id.starts_with("select.") && !id.starts_with("view.") {
apply(s);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use wordcraft_layout::ViewMode;
const SAMPLE: &str = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\nLast **bold** paragraph.\n";
/// Creates a test session with a Markdown document and a dummy path.
fn session(md: &str) -> Session {
let mut s = Session::new(crate::io::open_bytes("a.md", md.as_bytes()).unwrap());
s.path = Some(PathBuf::from("/tmp/wordcraft-md-source-test.md"));
s.dirty = false;
s
}
/// Runs the Markdown Source text command with `text`.
fn set_text(s: &mut Session, text: &str) {
s.run(TEXT, &json!({"text": text})).unwrap();
}
/// Extracts the plain body text of `s`.
fn body_text(s: &Session) -> String {
s.doc.plain_text(StoryRef::Body)
}
/// Verifies entering and leaving without changes does not mutate document or add undo step.
#[test]
fn a_visit_that_changes_nothing_is_no_edit() {
let mut s = session(SAMPLE);
let before = s.doc.clone();
let depth = s.undo_depth();
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.is_some());
assert!(!s.dirty, "entering is not an edit");
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.is_none());
assert!(s.doc.same_content(&before));
assert!(!s.dirty, "a visit without edits leaves no unsaved changes");
assert_eq!(s.undo_depth(), depth, "and no undo step");
}
/// Verifies edited source text becomes document upon leaving and one undo reverts the visit.
#[test]
fn an_edited_text_becomes_the_document_and_one_undo_takes_it_back() {
let mut s = session(SAMPLE);
let before = s.doc.clone();
s.run(TOGGLE, &json!({})).unwrap();
let text = s.run(TEXT, &json!({})).unwrap()["text"].as_str().unwrap().to_string();
assert!(text.starts_with("# Title"), "{text}");
set_text(&mut s, &text.replace("First paragraph.", "Changed paragraph."));
assert!(s.dirty, "the edit is an unsaved change");
s.run(TOGGLE, &json!({})).unwrap();
assert!(body_text(&s).contains("Changed paragraph."));
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.same_content(&before), "one undo restores the document");
assert!(s.can_redo());
}
static TEST_DIR_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Generates a unique temporary directory for test file isolation.
fn unique_temp_dir(tag: &str) -> PathBuf {
let n = TEST_DIR_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
std::env::temp_dir().join(format!("wordcraft-md-{tag}-{}-{n}-{nanos}", std::process::id()))
}
/// Verifies saving within source view writes text to disk while keeping view open and preserving undo.
#[test]
fn save_writes_the_text_and_the_visit_stays_one_undo_step() {
let dir = unique_temp_dir("source-save");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("saved.md");
let mut s = session(SAMPLE);
s.path = Some(path.clone());
let before = s.doc.clone();
let depth = s.undo_depth();
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Saved one\n");
s.run("file.save", &json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("Saved one"));
assert!(!s.dirty);
assert!(s.md_source.is_some(), "saving keeps the view open");
set_text(&mut s, "# Saved two\n");
s.run("file.save", &json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().contains("Saved two"));
set_text(&mut s, "# Left three\n");
s.run(TOGGLE, &json!({})).unwrap();
assert!(body_text(&s).contains("Left three"));
assert_eq!(s.undo_depth(), depth + 1, "one step for the whole visit");
s.run("edit.undo", &json!({})).unwrap();
assert!(s.doc.same_content(&before));
let _ = std::fs::remove_dir_all(&dir);
}
/// Verifies returning to the original entry text restores formatting that Markdown cannot represent and maps the caret using entry block lines.
#[test]
fn going_back_to_the_entry_text_restores_what_markdown_cannot_hold() {
let mut s = session(SAMPLE);
std::sync::Arc::make_mut(&mut s.doc.styles).get_mut("Normal").unwrap().chr.bold = Some(true);
let before = s.doc.clone();
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.as_ref().unwrap().loses_content, "Markdown doesn't keep a changed style");
let text = s.md_source.as_ref().unwrap().text.clone();
set_text(&mut s, "# Other\n");
apply(&mut s);
assert!(!s.doc.same_content(&before));
set_text(&mut s, &text);
let last_off = text.find("Last ").unwrap_or(0);
s.run(TEXT, &json!({"caret": last_off})).unwrap();
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.doc.same_content(&before));
assert_eq!(s.sel.focus.path.0.first().copied(), Some(5));
}
/// Verifies that leaving Markdown Source with `join_next_undo` after an applied edit keeps the visit's undo checkpoint.
#[test]
fn leaving_joined_after_an_applied_edit_keeps_the_visits_undo_step() {
let mut s = session(SAMPLE);
let before = body_text(&s);
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Intermediate\n");
apply(&mut s);
assert_eq!(s.undo_depth(), 1);
set_text(&mut s, "# Final\n");
s.join_next_undo();
s.run(TOGGLE, &json!({"value": false})).unwrap();
assert!(s.md_source.is_none());
assert!(body_text(&s).contains("Final"));
assert_eq!(s.undo_depth(), 1);
assert!(s.can_undo());
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(body_text(&s), before);
}
/// Verifies that opening an unedited Markdown file reports no content loss on entry.
#[test]
fn markdown_the_document_came_from_is_not_reported_as_lost() {
let s = &mut session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
assert!(!s.md_source.as_ref().unwrap().loses_content);
}
/// Verifies standard document editing commands are disabled while Markdown Source view is open.
#[test]
fn edits_are_refused_while_the_text_is_open_and_nothing_is_lost() {
let mut s = session(SAMPLE);
let before = s.doc.clone();
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Kept\n");
for id in ["format.bold", "edit.undo", "edit.redo"] {
assert!(matches!(s.run(id, &json!({})), Err(CmdError::Disabled(_))), "{id}");
}
assert!(matches!(s.run("text.insert", &json!({"text": "x"})), Err(CmdError::Disabled(_))));
assert!(s.doc.same_content(&before));
assert_eq!(s.md_source.as_ref().unwrap().text, "# Kept\n");
}
/// Verifies switching view mode leaves Markdown Source view and applies edits.
#[test]
fn changing_the_view_leaves_with_the_edits() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Applied\n");
s.run("view.webLayout", &json!({})).unwrap();
assert!(s.md_source.is_none());
assert!(body_text(&s).contains("Applied"));
assert_eq!(s.view.mode, ViewMode::Web);
}
/// Verifies only unprotected Markdown files without tracked changes can enter Markdown Source.
#[test]
fn only_markdown_files_without_tracking_or_protection_enter() {
let mut s = Session::new(Document::from_text("Text"));
assert!(s.run(TOGGLE, &json!({})).is_err(), "no file");
s.path = Some(PathBuf::from("/tmp/a.docx"));
assert!(s.run(TOGGLE, &json!({})).is_err(), "not Markdown");
s.path = Some(PathBuf::from("/tmp/A.MARKDOWN"));
s.doc.settings.track_changes = true;
assert!(s.run(TOGGLE, &json!({})).is_err(), "tracking");
s.doc.settings.track_changes = false;
s.doc.settings.protection = Some("readOnly".into());
assert!(s.run(TOGGLE, &json!({})).is_err(), "protected");
s.doc.settings.protection = None;
s.view.read_mode = true;
assert!(s.run(TOGGLE, &json!({})).is_err(), "Read Mode");
s.view.read_mode = false;
s.run(TOGGLE, &json!({})).unwrap();
}
/// Verifies source text exceeding limit is refused with CmdError::Failed.
#[test]
fn a_text_too_long_is_refused() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
let long = "x".repeat(MAX_SOURCE_BYTES + 1);
assert!(matches!(s.run(TEXT, &json!({"text": long})), Err(CmdError::Params(_))));
s.run(TOGGLE, &json!({})).unwrap();
s.set_document(Document::from_text(&"x".repeat(MAX_SOURCE_BYTES + 1)));
s.path = Some(PathBuf::from("/tmp/a.md"));
assert!(matches!(s.run(TOGGLE, &json!({})), Err(CmdError::Failed(_))));
assert!(s.md_source.is_none());
}
/// Verifies caret stays synchronized with its paragraph when entering and leaving source view.
#[test]
fn the_caret_stays_with_its_paragraph_both_ways() {
let mut s = session(SAMPLE);
let target = s
.doc
.para_paths(StoryRef::Body)
.into_iter()
.find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last")))
.unwrap();
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target.clone(), off: 2 });
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect();
assert!(line.contains("Last"), "caret on {line:?} in {:?}", src.text);
let changed = src.text.replace("First paragraph.", "First changed paragraph.");
let caret = changed.find("Last").unwrap();
s.run(TEXT, &json!({"text": changed, "caret": caret})).unwrap();
s.run(TOGGLE, &json!({})).unwrap();
let para = s.doc.para(StoryRef::Body, &s.sel.focus.path).unwrap();
assert!(para.plain_text().contains("Last"), "{:?}", para.plain_text());
}
/// Verifies that the caret in the trailing paragraph appended after a final table maps to the end of the text.
#[test]
fn caret_in_appended_paragraph_after_final_table_stays_at_end() {
let md = "| Col1 | Col2 |\n|---|---|\n| Val1 | Val2 |\n";
let mut s = session(md);
let trailing_para = s
.doc
.para_paths(StoryRef::Body)
.into_iter()
.find(|path| path.0.first() == Some(&1))
.expect("document ending in a table must have an appended paragraph");
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: trailing_para, off: 0 });
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
assert_eq!(src.caret, src.text.len(), "caret must land after the table at the end of the text");
s.run(TOGGLE, &json!({})).unwrap();
assert_eq!(s.sel.focus.path.0.first(), Some(&1), "caret must stay in the appended paragraph after table");
}
/// Verifies hostile carets and texts do not panic.
#[test]
fn hostile_carets_and_texts_do_not_panic() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
for (text, caret) in [("", u64::MAX), ("é\n\n\n", 2), ("```\nopen fence", 9), ("|\n|-", 1), ("> > > >\n- - - -", 3)] {
s.run(TEXT, &json!({"text": text, "caret": caret})).unwrap();
s.run(TOGGLE, &json!({})).unwrap();
s.clamp_selection();
assert!(s.doc.para(s.sel.focus.story, &s.sel.focus.path).is_some(), "{text:?}");
s.run(TOGGLE, &json!({})).unwrap();
}
}
/// Verifies a parked document preserves its Markdown Source text.
#[test]
fn a_parked_document_keeps_its_text() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
set_text(&mut s, "# Mine\n");
let first = s.document_id();
let _second = s.add_opened_document(Document::from_text("Other"));
assert!(s.md_source.is_none(), "the other document has no text open");
#[cfg(not(target_arch = "wasm32"))]
{
let first_rec = s.recovery_docs().find(|r| r.id == first).unwrap();
assert!(first_rec.doc.plain_text(StoryRef::Body).contains("Mine"), "pending text was applied before parking");
}
s.activate(first).unwrap();
assert_eq!(s.md_source.as_ref().unwrap().text, "# Mine\n");
}
/// What the document can't hold, all of which a save without formatted edits keeps.
const OWN: &str = "---\ntitle: Notes\n---\n\nTitle\n=====\n\n<!-- a comment -->\n\n* star bullet\n* another\n\n- [ ] a task\n- [x] done\n\n```bash\necho hi\n```\n\nSome *emphasis* and a [link](https://example.com \"its title\").\nA wrapped\nline.\n\n![a picture](pic.png)\n\nLast paragraph.\n";
/// A scratch folder holding `name` with `text`, opened in a new session.
fn open_file(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) {
let dir = unique_temp_dir(&format!("own-{dir}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, text).unwrap();
let mut s = Session::new(Document::from_text(""));
s.run("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
(s, path)
}
/// Saves the session document via `file.save`.
fn save(s: &mut Session) -> Value {
s.run("file.save", &json!({})).unwrap()
}
/// A scratch folder holding `name`, opened from the bytes `file.open` is handed (`data`, as a
/// platform hands a file over). The path holds other bytes, so a save that left them alone
/// would show.
fn open_handed_over(dir: &str, name: &str, text: &[u8]) -> (Session, PathBuf) {
let dir = unique_temp_dir(&format!("handed-{dir}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, b"not the opened text").unwrap();
let mut s = Session::new(Document::from_text(""));
let data = crate::cmd::insert::base64_encode(text);
s.run("file.open", &json!({"path": path.to_string_lossy(), "data": data})).unwrap();
(s, path)
}
/// Verifies a Markdown file opened from handed-over bytes keeps its own text and saves it back
/// byte-for-byte, as one opened from its path does.
#[test]
fn a_file_opened_from_handed_over_bytes_keeps_its_own_text() {
for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n"))] {
let (mut s, path) = open_handed_over(dir, "own.md", text.as_bytes());
assert!(!s.md_file.is_empty(), "{dir}: the opened text must be kept");
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
assert_eq!(std::fs::read(&path).unwrap(), text.as_bytes(), "{dir}");
done(&path);
}
}
/// Removes the temporary test folder created for `path`.
fn done(path: &std::path::Path) {
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// Verifies unedited Markdown files are written back byte-for-byte upon save.
#[test]
fn a_file_saved_without_edits_is_written_back_byte_for_byte() {
for (dir, text) in [("plain", OWN.to_string()), ("crlf", OWN.replace('\n', "\r\n")), ("bom", format!("\u{FEFF}{OWN}"))] {
let (mut s, path) = open_file(dir, "own.md", text.as_bytes());
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), text, "{dir}");
done(&path);
}
}
/// Verifies BOM-prefixed Markdown files retain original bytes when saved without edits.
#[test]
fn a_bom_prefixed_file_saved_without_edits_retains_its_original_bytes() {
let bom = [b"\xEF\xBB\xBF".as_slice(), OWN.as_bytes()].concat();
let (mut s, path) = open_file("bom-exact", "own.md", &bom);
assert!(!s.md_file.is_empty(), "BOM file text must be kept");
assert_eq!(save(&mut s)["rewritten"], false);
let read = std::fs::read(&path).unwrap();
assert_eq!(read, bom, "Saved bytes must match original BOM bytes byte-for-byte");
done(&path);
}
/// Verifies Markdown Source view displays the file's own unedited text.
#[test]
fn the_view_shows_the_files_own_text() {
let (mut s, path) = open_file("view", "own.md", OWN.as_bytes());
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
assert_eq!(src.text, OWN);
assert!(!src.loses_content);
done(&path);
}
/// Verifies edits made directly in Markdown Source view are saved as typed.
#[test]
fn edits_in_the_view_are_saved_as_typed() {
let (mut s, path) = open_file("typed", "own.md", OWN.as_bytes());
s.run(TOGGLE, &json!({})).unwrap();
let typed = OWN.replace("Last paragraph.", "<!-- another -->\n\n* Last *changed* paragraph.");
set_text(&mut s, &typed);
s.run(TOGGLE, &json!({})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), typed);
// Saved while the view is open: the same.
s.run(TOGGLE, &json!({})).unwrap();
let again = typed.replace("another", "a third");
set_text(&mut s, &again);
assert_eq!(save(&mut s)["rewritten"], false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), again);
done(&path);
}
/// Verifies formatted edits force re-exporting the document on save with a warning.
#[test]
fn an_edit_in_the_formatted_view_writes_the_document_out_again_once_with_a_warning() {
let (mut s, path) = open_file("formatted", "own.md", OWN.as_bytes());
s.run("text.insert", &json!({"text": "New "})).unwrap();
assert_eq!(save(&mut s)["rewritten"], true);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("New "), "{written}");
assert!(!written.contains("<!-- a comment -->"), "written out again: {written}");
s.run("text.insert", &json!({"text": "Again "})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false, "warned once: the file holds the document's own text now");
done(&path);
}
/// Verifies undoing back to original kept text restores original formatting.
#[test]
fn undoing_back_to_a_kept_text_finds_it_again() {
// A formatted edit undone.
let (mut s, path) = open_file("undo", "own.md", OWN.as_bytes());
s.run("text.insert", &json!({"text": "New "})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false);
assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN);
// A visit to the view undone: back to the opened text, then redone: the typed one.
s.run(TOGGLE, &json!({})).unwrap();
let typed = OWN.replace("Last paragraph.", "Typed paragraph.");
set_text(&mut s, &typed);
s.run(TOGGLE, &json!({})).unwrap();
s.run("edit.undo", &json!({})).unwrap();
save(&mut s);
assert_eq!(std::fs::read_to_string(&path).unwrap(), OWN);
s.run("edit.redo", &json!({})).unwrap();
assert_eq!(save(&mut s)["rewritten"], true, "the typed text was given up for the saved one");
assert!(std::fs::read_to_string(&path).unwrap().contains("Typed paragraph."));
done(&path);
}
/// Verifies files that cannot go back as they were are written out without rewriting warnings.
#[test]
fn files_that_cannot_go_back_as_they_were_are_written_out_without_a_warning() {
let latin1 = b"Caf\xE9 au lait.\n".to_vec();
for (dir, bytes) in [("latin1", latin1), ("empty", Vec::new())] {
let (mut s, path) = open_file(dir, "own.md", &bytes);
assert!(s.md_file.is_empty(), "{dir}");
s.run("text.insert", &json!({"text": "New "})).unwrap();
assert_eq!(save(&mut s)["rewritten"], false, "{dir}");
done(&path);
}
// A Word document saved as Markdown is a copy written out as always.
let mut s = Session::new(Document::from_text("Hello"));
let dir = unique_temp_dir("own-docx");
std::fs::create_dir_all(&dir).unwrap();
s.path = Some(dir.join("a.docx"));
let r = s.run("file.saveAs", &json!({"path": dir.join("a.md").to_string_lossy()})).unwrap();
assert_eq!(r["rewritten"], false);
let _ = std::fs::remove_dir_all(&dir);
}
/// Verifies markdown source text command sets place_caret flag on explicit carets and edits.
#[test]
fn markdown_source_text_command_sets_place_caret() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
assert!(s.md_source.as_ref().unwrap().place_caret);
s.md_source.as_mut().unwrap().place_caret = false;
s.run(TEXT, &json!({"caret": 3})).unwrap();
assert!(s.md_source.as_ref().unwrap().place_caret);
s.md_source.as_mut().unwrap().place_caret = false;
s.run(TEXT, &json!({"text": "# Changed\n"})).unwrap();
assert!(s.md_source.as_ref().unwrap().place_caret);
}
/// Verifies caret finds its matching line in file's own text.
#[test]
fn the_caret_finds_its_line_in_the_files_own_text() {
let (mut s, path) = open_file("caret", "own.md", OWN.as_bytes());
let target = s
.doc
.para_paths(StoryRef::Body)
.into_iter()
.find(|path| s.doc.para(StoryRef::Body, path).is_some_and(|para| para.plain_text().contains("Last")))
.unwrap();
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: target, off: 0 });
s.run(TOGGLE, &json!({})).unwrap();
let src = s.md_source.as_ref().unwrap();
let line: String = src.text[src.caret..].chars().take_while(|c| *c != '\n').collect();
assert_eq!(line, "Last paragraph.");
done(&path);
}
/// Verifies opening another document over session closes Markdown Source view.
#[test]
fn opening_over_the_document_closes_the_view() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
s.set_document(Document::from_text("New"));
assert!(s.md_source.is_none());
}
/// Verifies invalid and negative carets are refused with CmdError::Params.
#[test]
fn invalid_and_negative_carets_are_refused() {
let mut s = session(SAMPLE);
s.run(TOGGLE, &json!({})).unwrap();
for bad in [json!({"caret": -1}), json!({"caret": -100}), json!({"caret": 2.5}), json!({"caret": "three"})] {
assert!(matches!(s.run(TEXT, &bad), Err(CmdError::Params(_))), "{bad:?}");
}
}
/// Verifies markdown source text command is marked as mutating.
#[test]
fn markdown_source_text_is_not_marked_pure() {
let s = session(SAMPLE);
let spec = s.registry.get(TEXT).unwrap();
assert!(spec.mutates, "view.markdownSourceText mutates session and dirty state");
}
}
+140 -53
View File
@@ -5,7 +5,7 @@ use super::{
use wordcraft_doc::para::OBJ;
/// Compare and Combine take the same settings.
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character", "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
pub(super) const COMPARE_PARAMS: &str = r#"{"path"?: string (revised document), "text"?: string (revised text), "original"?: string (original document; default: this one), "author"?: string (label changes with), "caseChanges"?: bool, "whiteSpace"?: bool, "formatting"?: bool (default true: differences count), "level"?: "word|character" (character: paragraphs over 2000 characters fall back to word; see characterFallback/notHonoured), "showIn"?: "original|revised|new" (revised/new: an untitled document), "moves"|"comments"|"tables"|"headersFooters"|"footnotes"|"textBoxes"|"fields"?: bool (accepted; not honoured yet except tables, always compared)}"#;
/// What Compare / Combine compares (the More >> settings of Word's dialog).
struct CompareOptions {
@@ -27,6 +27,15 @@ impl CompareOptions {
}
}
/// Context and tracking counters for diffing paragraph contents.
struct DiffCtx<'a> {
ins: u32,
del: u32,
opts: &'a CompareOptions,
budget: &'a mut u64,
char_fallbacks: &'a mut usize,
}
/// Changed paragraphs longer than this (in characters) are compared word by word even at
/// character level, so the comparison table stays small.
const MAX_CHAR_DIFF: usize = 2000;
@@ -35,6 +44,9 @@ const MAX_CHAR_DIFF: usize = 2000;
/// result shows the differences as tracked insertions, deletions and formatting changes, labelled
/// with `author`. Options Word offers that the comparison can't honour yet (moves, comments,
/// headers and footers, footnotes, text boxes, fields) are accepted and listed in the result.
/// When character-level comparison is requested (`"level": "character"`) but any paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, it falls back to word-level diff for that paragraph, surfaces the
/// fallback count in `"characterFallback"`, and includes `"level"` in `"notHonoured"`.
pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
let revised = if let Some(t) = p::str(v, "text") {
Document::from_text(t)
@@ -61,7 +73,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
formatting: p::bool(v, "formatting").unwrap_or(true),
characters: matches!(level, "character" | "char"),
};
let not_honoured: Vec<&str> =
let mut not_honoured: Vec<&str> =
["moves", "comments", "headersFooters", "footnotes", "textBoxes", "fields"].into_iter().filter(|k| v.get(*k).is_some()).collect();
let author = p::str(v, "author").map(str::trim).filter(|a| !a.is_empty()).map(str::to_string).unwrap_or_else(|| s.author.clone());
let date = crate::cmd::now_iso();
@@ -83,7 +95,9 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
};
let mut budget = MAX_COMPARE_CELLS;
let mut char_fallbacks = 0usize;
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
let mut ctx = DiffCtx { ins, del, opts: &opts, budget: &mut budget, char_fallbacks: &mut char_fallbacks };
let mut out: Vec<Arc<Block>> = Vec::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
@@ -97,7 +111,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
if !same_objects(&s.doc, pa, pb) {
// Same text, but objects (text boxes, pictures…) differ: diffed, so the
// original's objects aren't lost.
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)));
out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx)));
changes += 1;
i += 1;
j += 1;
@@ -129,9 +143,7 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
for x in 0..dels.max(inss) {
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
match (a.map(|x| &**x), b.map(|x| &**x)) {
(Some(Block::Para(pa)), Some(Block::Para(pb))) => {
out.push(para_block(word_diff(&s.doc, pa, pb, ins, del, &opts, &mut budget)))
}
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(&s.doc, pa, pb, &mut ctx))),
_ => {
if let Some(x) = a {
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
@@ -162,8 +174,21 @@ pub(super) fn compare(s: &mut Session, v: &Value) -> CmdResult {
// Compare creates an untitled result from the current document.
s.clear_versions();
}
if char_fallbacks > 0 {
log::warn!(
"compare: {char_fallbacks} paragraph(s) exceeded character diff limit of {MAX_CHAR_DIFF}; fell back from character to word level diff"
);
if !not_honoured.contains(&"level") {
not_honoured.push("level");
}
}
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
Ok(json!({"changes": changes, "author": author, "notHonoured": not_honoured}))
Ok(json!({
"changes": changes,
"author": author,
"notHonoured": not_honoured,
"characterFallback": char_fallbacks,
}))
}
/// A block's text (a table's cells in order), for matching blocks.
@@ -177,13 +202,14 @@ fn block_text(b: &Block, depth: usize) -> String {
}
}
/// A copy of the block with every run (in tables, every cell's) changed by `f`.
/// Returns a copy of `b` with all text runs and paragraph marks (or nested table cells) updated by `f`.
fn mark_block(b: &Block, f: &dyn Fn(&mut CharProps), depth: usize) -> Block {
match b {
Block::Para(p) => {
let mut p = p.clone();
let len = p.len();
let _ = p.format(0, len, f);
f(&mut p.mark);
Block::Para(p)
}
Block::Table(t) => {
@@ -205,59 +231,96 @@ pub(super) enum Op {
Ins,
}
/// Longest sequence the comparison table is built for; the rest of a longer sequence is
/// compared as deleted and inserted, never dropped.
/// Longest sequence window per LCS comparison table chunk; sequences longer than this are
/// aligned in successive chunked windows rather than truncated, preserving tail alignment.
const MAX_LCS: usize = 4000;
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
const MAX_COMPARE_CELLS: u64 = 50_000_000;
/// LCS edit script between two sequences: the common start and end are matched first, then the
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
/// middle is deleted and inserted whole.
/// Computes the longest common subsequence (LCS) edit script (`Op::Same`, `Op::Del`, `Op::Ins`)
/// between sequences `a` and `b`.
///
/// Strips common prefix and suffix first, then iterates over windows capped at [`MAX_LCS`],
/// charging comparison cells against `budget`. Between windows, intermediate common prefixes
/// are trimmed to re-anchor alignment without table allocations. When the remaining cells
/// exceed `budget`, the unaligned tail degrades honestly as whole deletions and insertions.
pub(super) fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
let mut rem_a = ra.get(..ra.len().saturating_sub(suf)).unwrap_or_default();
let mut rem_b = rb.get(..rb.len().saturating_sub(suf)).unwrap_or_default();
let mut ops = vec![Op::Same; pre];
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget {
(n, m) = (0, 0);
} else {
*budget -= cells;
}
let mut t = vec![vec![0u32; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
let v = if a.get(i) == b.get(j) {
t.get(i + 1).and_then(|r| r.get(j + 1)).copied().unwrap_or(0) + 1
} else {
t.get(i + 1).and_then(|r| r.get(j)).copied().unwrap_or(0).max(t.get(i).and_then(|r| r.get(j + 1)).copied().unwrap_or(0))
};
if let Some(c) = t.get_mut(i).and_then(|r| r.get_mut(j)) {
*c = v;
while !rem_a.is_empty() && !rem_b.is_empty() {
let p = rem_a.iter().zip(rem_b).take_while(|(x, y)| x == y).count();
if p > 0 {
ops.extend(std::iter::repeat_n(Op::Same, p));
rem_a = rem_a.get(p..).unwrap_or_default();
rem_b = rem_b.get(p..).unwrap_or_default();
if rem_a.is_empty() || rem_b.is_empty() {
break;
}
}
}
let (mut i, mut j) = (0, 0);
while i < n && j < m {
if a.get(i) == b.get(j) {
ops.push(Op::Same);
i += 1;
j += 1;
} else if t.get(i + 1).and_then(|r| r.get(j)) >= t.get(i).and_then(|r| r.get(j + 1)) {
ops.push(Op::Del);
i += 1;
} else {
ops.push(Op::Ins);
j += 1;
let n = rem_a.len().min(MAX_LCS);
let m = rem_b.len().min(MAX_LCS);
let cells = (n as u64).saturating_mul(m as u64);
if cells > *budget || n == 0 || m == 0 {
break;
}
*budget = budget.saturating_sub(cells);
let stride = m.saturating_add(1);
let table_len = n.saturating_add(1).saturating_mul(stride);
let mut t = vec![0u32; table_len];
for i in (0..n).rev() {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
for j in (0..m).rev() {
let v = if rem_a.get(i) == rem_b.get(j) {
t.get(next_row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0).saturating_add(1)
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
down.max(right)
};
if let Some(c) = t.get_mut(row.saturating_add(j)) {
*c = v;
}
}
}
let (mut i, mut j) = (0usize, 0usize);
while i < n && j < m {
let row = i.saturating_mul(stride);
let next_row = i.saturating_add(1).saturating_mul(stride);
if rem_a.get(i) == rem_b.get(j) {
ops.push(Op::Same);
i = i.saturating_add(1);
j = j.saturating_add(1);
} else {
let down = t.get(next_row.saturating_add(j)).copied().unwrap_or(0);
let right = t.get(row.saturating_add(j).saturating_add(1)).copied().unwrap_or(0);
if down >= right {
ops.push(Op::Del);
i = i.saturating_add(1);
} else {
ops.push(Op::Ins);
j = j.saturating_add(1);
}
}
}
rem_a = rem_a.get(i..).unwrap_or_default();
rem_b = rem_b.get(j..).unwrap_or_default();
}
ops.extend(std::iter::repeat_n(Op::Del, a.len() - i));
ops.extend(std::iter::repeat_n(Op::Ins, b.len() - j));
ops.extend(std::iter::repeat_n(Op::Del, rem_a.len()));
ops.extend(std::iter::repeat_n(Op::Ins, rem_b.len()));
ops.extend(std::iter::repeat_n(Op::Same, suf));
ops
}
@@ -410,15 +473,39 @@ fn put(p: &mut Paragraph, t: Option<&Tok<'_>>, props: &CharProps) {
}
}
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
let (at, bt) = (a.plain_text(), b.plain_text());
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
/// Returns `true` if paragraph `p` contains more characters than `limit`.
///
/// Bounded to scan at most `limit + 1` characters without allocating unless inline objects
/// are present, avoiding O(n) traversal of large paragraphs.
fn para_exceeds_char_limit(p: &Paragraph, limit: usize) -> bool {
if p.objects.is_empty() {
p.text.chars().take(limit.saturating_add(1)).count() > limit
} else {
p.plain_text().chars().take(limit.saturating_add(1)).count() > limit
}
}
/// Diffs two matched paragraphs either at word granularity or at character granularity according
/// to `ctx.opts.characters`. When character-level diff is requested but either paragraph exceeds
/// [`MAX_CHAR_DIFF`] characters, falls back to word-level diff and increments `ctx.char_fallbacks`
/// so callers can discover the fallback.
fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ctx: &mut DiffCtx<'_>) -> Paragraph {
let chars = if ctx.opts.characters {
if para_exceeds_char_limit(a, MAX_CHAR_DIFF) || para_exceeds_char_limit(b, MAX_CHAR_DIFF) {
*ctx.char_fallbacks = ctx.char_fallbacks.saturating_add(1);
false
} else {
true
}
} else {
false
};
let (ta, tb) = (tokens(a, chars), tokens(b, chars));
let key = |t: &Tok<'_>| match t {
Tok::Text(t) => Key::Text(opts.key(t)),
Tok::Text(t) => Key::Text(ctx.opts.key(t)),
Tok::Obj(o) => obj_key(doc, o),
};
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), budget);
let ops = lcs_ops(&ta.iter().map(key).collect::<Vec<_>>(), &tb.iter().map(key).collect::<Vec<_>>(), ctx.budget);
// Only at character granularity: a matched whole word almost never happens by coincidence,
// but a matched single character very often does (digits, punctuation), so only there does
// a short interior match need folding back into its surrounding edit.
@@ -436,11 +523,11 @@ fn word_diff(doc: &Document, a: &Paragraph, b: &Paragraph, ins: u32, del: u32, o
j += 1;
}
Op::Del => {
put(&mut p, ta.get(i), &CharProps { del: Some(del), ..base.clone() });
put(&mut p, ta.get(i), &CharProps { del: Some(ctx.del), ..base.clone() });
i += 1;
}
Op::Ins => {
put(&mut p, tb.get(j), &CharProps { ins: Some(ins), ..base.clone() });
put(&mut p, tb.get(j), &CharProps { ins: Some(ctx.ins), ..base.clone() });
j += 1;
}
}
+560
View File
@@ -66,6 +66,309 @@ fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
assert_eq!(collapse_noise_matches(&ops), ops);
}
/// Verifies that `lcs_ops` chunks sequence alignment across multiple `MAX_LCS` windows,
/// replaying operations to prove exact reconstruction and asserting specific edit indices.
#[test]
fn lcs_ops_chunks_across_multiple_windows() {
// Two sequences of 9,000 elements (spanning three 4,000-element MAX_LCS windows).
// Element at 50 moves to 8,500. Substitutions at 0 and 8,999 zero the outer prefix/suffix.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 99999;
b[0] = 88888;
a[total - 1] = 77777;
b[total - 1] = 66666;
let moved = a[50];
b.remove(50);
b.insert(8500, moved);
let mut budget = u64::MAX;
let ops = lcs_ops(&a, &b, &mut budget);
// Replay ops against `a` and `b` to verify exact sequence reconstruction and order.
let mut ia = 0;
let mut ib = 0;
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
let mut reconstructed = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib), "Same op must match elements at ia={ia}, ib={ib}");
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
// Truth: 2 substitutions (indices 0 and 8999), 1 deletion at 50, 1 insertion at 8500.
assert_eq!(dels_seen, vec![(0, 99999), (50, 50), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 88888), (8500, 50), (8999, 66666)]);
}
/// Verifies that `lcs_ops` forces successive full 4,000-element windows without
/// intermediate prefix trimming collapsing them, asserting exact budget exhaustion.
#[test]
fn lcs_ops_forces_successive_full_windows_without_intermediate_prefix_trim() {
// Force successive full 4,000-element windows without intermediate prefix trimming
// collapsing the middle into fewer tables.
// Length: 9,000 elements across 3 windows (4,000 + 4,000 + 1,000).
// Boundary substitutions at indices 0, 4000, 8000, and 8999 guarantee rem_a[0] != rem_b[0]
// at the beginning of each window, forcing intermediate prefix trim to find p == 0 so
// all three full DP tables must execute.
let total = 9000;
let mut a: Vec<usize> = (0..total).collect();
let mut b = a.clone();
a[0] = 11111;
b[0] = 22222;
a[4000] = 33333;
b[4000] = 44444;
a[8000] = 55555;
b[8000] = 66666;
a[total - 1] = 77777;
b[total - 1] = 88888;
// Budget for 3 full windows:
// Window 1: 4,000 * 4,000 = 16,000,000 cells
// Window 2: 4,000 * 4,000 = 16,000,000 cells
// Window 3: 1,000 * 1,000 = 1,000,000 cells
// Total cells = 33,000,000.
let mut budget = 33_000_000u64;
let ops = lcs_ops(&a, &b, &mut budget);
// Exact budget is fully exhausted (budget == 0), proving all 3 full DP tables were built.
assert_eq!(budget, 0, "all 3 successive windows must execute their DP tables without collapsing");
let mut ia = 0;
let mut ib = 0;
let mut reconstructed = Vec::new();
let mut dels_seen = Vec::new();
let mut inss_seen = Vec::new();
for op in &ops {
match op {
Op::Same => {
assert_eq!(a.get(ia), b.get(ib));
if let Some(&val) = a.get(ia) {
reconstructed.push(val);
}
ia += 1;
ib += 1;
}
Op::Del => {
if let Some(&val) = a.get(ia) {
dels_seen.push((ia, val));
}
ia += 1;
}
Op::Ins => {
if let Some(&val) = b.get(ib) {
inss_seen.push((ib, val));
reconstructed.push(val);
}
ib += 1;
}
}
}
assert_eq!(ia, a.len());
assert_eq!(ib, b.len());
assert_eq!(reconstructed, b);
assert_eq!(dels_seen, vec![(0, 11111), (4000, 33333), (8000, 55555), (8999, 77777)]);
assert_eq!(inss_seen, vec![(0, 22222), (4000, 44444), (8000, 66666), (8999, 88888)]);
// Also verify completely disjoint sequences across multiple windows with zero common items.
// Window 1: 4,000 * 4,000 = 16,000,000 cells (deletes 4,000 of a_diff)
// Window 2: 4,000 * 4,000 = 16,000,000 cells (deletes next 4,000 of a_diff)
// Window 3: 1,000 of a_diff * 4,000 of b_diff = 4,000,000 cells
// Total cells = 36,000,000.
let a_diff: Vec<usize> = (0..9000).collect();
let b_diff: Vec<usize> = (10_000..19_000).collect();
let mut budget_diff = 36_000_000u64;
let ops_diff = lcs_ops(&a_diff, &b_diff, &mut budget_diff);
assert_eq!(budget_diff, 0, "all 3 windows in disjoint comparison must exhaust exact 36,000,000 budget");
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Del).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Ins).count(), 9000);
assert_eq!(ops_diff.iter().filter(|o| **o == Op::Same).count(), 0);
}
/// Tests `lcs_ops` budget behavior across edge cases: zero initial budget, exact-fit allocation,
/// one-short exhaustion, and multi-window exhaustion after the first chunk.
#[test]
fn lcs_ops_exhausted_budget_falls_back_whole() {
// 1. Budget 0 before any table: middle degrades whole, budget remains 0
let a = vec![1, 2, 3, 4, 5];
let b = vec![1, 8, 9, 5];
let mut budget = 0;
let ops = lcs_ops(&a, &b, &mut budget);
assert_eq!(ops, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
assert_eq!(budget, 0, "budget must remain 0");
// 2. Exact-fit: cells == budget
// Middle has 3 elements in a (2, 3, 4) and 2 in b (8, 9) -> 3 * 2 = 6 cells.
let mut budget_exact = 6;
let ops_exact = lcs_ops(&a, &b, &mut budget_exact);
assert_eq!(budget_exact, 0, "budget exactly exhausted to 0");
assert_eq!(ops_exact, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// Exact-fit with shared elements in middle
let a_mid = vec![1, 2, 3, 5];
let b_mid = vec![1, 2, 9, 5];
// Middle is (3) vs (9): 1 * 1 = 1 cell
let mut budget_mid = 1;
let ops_mid = lcs_ops(&a_mid, &b_mid, &mut budget_mid);
assert_eq!(budget_mid, 0);
assert_eq!(ops_mid, vec![Op::Same, Op::Same, Op::Del, Op::Ins, Op::Same]);
// 3. One-short: budget == cells - 1
let mut budget_short = 5;
let ops_short = lcs_ops(&a, &b, &mut budget_short);
assert_eq!(budget_short, 5, "one-short budget must not be charged");
assert_eq!(ops_short, vec![Op::Same, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Same]);
// 4. Multi-window: Exhaustion after the first window
// Two windows: window 1 has 4,000 elements (16,000,000 cells), window 2 has 500 elements (250,000 cells).
let mut a_multi: Vec<usize> = (0..4500).collect();
let mut b_multi = a_multi.clone();
a_multi[0] = 99999;
b_multi[0] = 88888;
a_multi[4000] = 77777;
b_multi[4000] = 66666;
a_multi[4499] = 55555;
b_multi[4499] = 44444;
// Case 4A: budget fits window 1 exactly (16,000,000), exhausts for window 2
let mut budget_w1_only = 16_000_000u64;
let ops_w1_only = lcs_ops(&a_multi, &b_multi, &mut budget_w1_only);
assert_eq!(budget_w1_only, 0, "budget spent on window 1, 0 remaining for window 2");
// Window 1 aligned via DP table, window 2 fell back to 500 Dels and 500 Inss
let tail_dels = ops_w1_only.iter().rev().skip(500).take(500).filter(|o| **o == Op::Del).count();
let tail_inss = ops_w1_only.iter().rev().take(500).filter(|o| **o == Op::Ins).count();
assert_eq!(tail_dels, 500);
assert_eq!(tail_inss, 500);
// Case 4B: budget fits both windows exactly: 16,250,000
let mut budget_both = 16_250_000u64;
let ops_both = lcs_ops(&a_multi, &b_multi, &mut budget_both);
assert_eq!(budget_both, 0, "both windows charged exactly to 0");
assert_eq!(ops_both.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_both.iter().filter(|o| **o == Op::Ins).count(), 3);
// Case 4C: one short on window 2: 16_249_999
let mut budget_both_short = 16_249_999u64;
let _ = lcs_ops(&a_multi, &b_multi, &mut budget_both_short);
assert_eq!(budget_both_short, 249_999, "remaining budget must reflect window 1 spent and window 2 unspent");
}
/// Tests boundary conditions for `lcs_ops`: empty slices, one-sided empty, exact `MAX_LCS`,
/// `MAX_LCS + 1`, `2 * MAX_LCS`, and prefix-trim loop break paths.
#[test]
fn lcs_ops_boundary_cases_and_break_paths() {
// 1. Empty sequences
let mut b = u64::MAX;
assert_eq!(lcs_ops::<i32>(&[], &[], &mut b), Vec::<Op>::new());
// 2. One-sided empty
let items = [1, 2, 3];
assert_eq!(lcs_ops(&[], &items, &mut b), vec![Op::Ins, Op::Ins, Op::Ins]);
assert_eq!(lcs_ops(&items, &[], &mut b), vec![Op::Del, Op::Del, Op::Del]);
// 3. Exact MAX_LCS (4,000)
let len_max = 4000;
let mut a_max: Vec<usize> = (0..len_max).collect();
let mut b_max = a_max.clone();
a_max[0] = 99999;
b_max[0] = 88888;
a_max[len_max - 1] = 77777;
b_max[len_max - 1] = 66666;
let mut b_budget = u64::MAX;
let ops_max = lcs_ops(&a_max, &b_max, &mut b_budget);
assert_eq!(ops_max.len(), len_max + 2);
assert_eq!(ops_max.iter().filter(|o| **o == Op::Same).count(), len_max - 2);
// 4. Exact MAX_LCS + 1 (4,001)
let len_plus1 = 4001;
let mut a_p1: Vec<usize> = (0..len_plus1).collect();
let mut b_p1 = a_p1.clone();
a_p1[0] = 99999;
b_p1[0] = 88888;
a_p1[len_plus1 - 1] = 77777;
b_p1[len_plus1 - 1] = 66666;
let mut b_budget2 = u64::MAX;
let ops_p1 = lcs_ops(&a_p1, &b_p1, &mut b_budget2);
assert_eq!(ops_p1.len(), len_plus1 + 2);
assert_eq!(ops_p1.iter().filter(|o| **o == Op::Same).count(), len_plus1 - 2);
// 5. Exact 2 * MAX_LCS (8,000)
let len_2m = 8000;
let mut a_2m: Vec<usize> = (0..len_2m).collect();
let mut b_2m = a_2m.clone();
a_2m[0] = 11111;
b_2m[0] = 22222;
a_2m[4000] = 33333;
b_2m[4000] = 44444;
a_2m[len_2m - 1] = 55555;
b_2m[len_2m - 1] = 66666;
let mut b_budget3 = u64::MAX;
let ops_2m = lcs_ops(&a_2m, &b_2m, &mut b_budget3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Del).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Ins).count(), 3);
assert_eq!(ops_2m.iter().filter(|o| **o == Op::Same).count(), len_2m - 3);
// 6. Prefix-trim break paths inside the while loop:
// Case 6A: rem_a becomes empty after matching p elements
let mut a_break_a: Vec<usize> = (0..4005).collect();
let mut b_break_a: Vec<usize> = (0..4008).collect();
a_break_a[0] = 99999;
b_break_a[0] = 88888;
let mut b_break1 = u64::MAX;
let ops_break_a = lcs_ops(&a_break_a, &b_break_a, &mut b_break1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_a.iter().filter(|o| **o == Op::Ins).count(), 4);
// Case 6B: rem_b becomes empty after matching p elements
let mut a_break_b: Vec<usize> = (0..4008).collect();
let mut b_break_b: Vec<usize> = (0..4005).collect();
a_break_b[0] = 99999;
b_break_b[0] = 88888;
let mut b_break2 = u64::MAX;
let ops_break_b = lcs_ops(&a_break_b, &b_break_b, &mut b_break2);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Del).count(), 4);
assert_eq!(ops_break_b.iter().filter(|o| **o == Op::Ins).count(), 1);
// Case 6C: both become empty simultaneously after matching p elements
let mut a_break_both: Vec<usize> = (0..4005).collect();
let mut b_break_both: Vec<usize> = (0..4005).collect();
a_break_both[0] = 99999;
b_break_both[0] = 88888;
let mut b_break3 = u64::MAX;
let ops_break_both = lcs_ops(&a_break_both, &b_break_both, &mut b_break3);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Del).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Ins).count(), 1);
assert_eq!(ops_break_both.iter().filter(|o| **o == Op::Same).count(), 4004);
}
#[test]
fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
// `collapse_noise_matches` only runs for character-level compares (wired up in
@@ -80,6 +383,145 @@ fn compare_at_word_level_still_matches_a_whole_unchanged_word() {
);
}
#[test]
fn compare_character_level_surfaces_fallback_on_long_paragraphs() {
// Under MAX_CHAR_DIFF (2,000 characters), character-level diff engages normally:
// sub-word fragment is marked, characterFallback is 0, and notHonoured does not contain "level".
let original_short = "The quick brown fox jumps over the lazy dog. ".repeat(10);
let revised_short = original_short.replacen("jumps", "jumped", 1);
assert!(original_short.chars().count() < 2000);
let mut s = Session::new(Document::from_text(&original_short));
let r = s.run("review.compare", &json!({"text": revised_short, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "level should be honoured for short paragraphs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "s", "character level marks only the sub-word suffix for short paragraphs");
assert_eq!(inserted, "ed", "character level marks only the sub-word suffix for short paragraphs");
// Above MAX_CHAR_DIFF (2,000 characters), character-level diff falls back to word-level diff.
// The fallback is surfaced in result metadata: characterFallback is incremented and
// notHonoured reports "level" rather than silently degrading without signal.
// In the output document, the fallback marks the whole word rather than sub-word fragments.
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "level should be listed in notHonoured when fallback occurs");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ", "long paragraph fallback marks the whole word");
assert_eq!(inserted, "jumped ", "long paragraph fallback marks the whole word");
}
#[test]
fn compare_character_level_exact_boundaries_at_limit() {
let prefix = "The quick brown fox jumps over the lazy dog. ".repeat(44); // 1980 chars
// Exactly MAX_CHAR_DIFF (2000) characters: character-level diff engages without fallback.
let original_2000 = format!("{prefix}1234567890123456789x");
let revised_2000 = format!("{prefix}1234567890123456789y");
assert_eq!(original_2000.chars().count(), 2000);
assert_eq!(revised_2000.chars().count(), 2000);
let mut s = Session::new(Document::from_text(&original_2000));
let r = s.run("review.compare", &json!({"text": revised_2000, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"), "2000 chars should not fall back");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "x", "character level marks single changed character at exactly 2000 chars");
assert_eq!(inserted, "y", "character level marks single changed character at exactly 2000 chars");
// Exactly 2001 characters (MAX_CHAR_DIFF + 1): triggers fallback to word-level diff.
let original_2001 = format!("{prefix}12345678901234567890x");
let revised_2001 = format!("{prefix}12345678901234567890y");
assert_eq!(original_2001.chars().count(), 2001);
assert_eq!(revised_2001.chars().count(), 2001);
let mut s = Session::new(Document::from_text(&original_2001));
let r = s.run("review.compare", &json!({"text": revised_2001, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(not_honoured.iter().any(|v| v == "level"), "2001 chars must trigger fallback");
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "12345678901234567890x", "fallback marks whole word at 2001 chars");
assert_eq!(inserted, "12345678901234567890y", "fallback marks whole word at 2001 chars");
}
#[test]
fn compare_character_level_multiple_paragraphs_and_asymmetric_lengths() {
let short_a = "Short original text here.";
let short_b = "Short revised text here.";
let long_a = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let long_b = long_a.replacen("jumps", "jumped", 1);
let long_c = "Another long paragraph that exceeds two thousand chars. ".repeat(40);
let long_d = long_c.replacen("Another", "Different", 1);
// Document with 1 short modified paragraph and 1 long modified paragraph: fallback count is 1.
let doc_mixed_a = format!("{short_a}\n{long_a}");
let doc_mixed_b = format!("{short_b}\n{long_b}");
let mut s = Session::new(Document::from_text(&doc_mixed_a));
let r = s.run("review.compare", &json!({"text": doc_mixed_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Document with 2 long modified paragraphs: fallback count is 2.
let doc_2long_a = format!("{long_a}\n{long_c}");
let doc_2long_b = format!("{long_b}\n{long_d}");
let mut s = Session::new(Document::from_text(&doc_2long_a));
let r = s.run("review.compare", &json!({"text": doc_2long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(2));
// Asymmetric lengths: original short, revised long (> 2000 chars) -> fallback count is 1.
let mut s = Session::new(Document::from_text(short_a));
let r = s.run("review.compare", &json!({"text": long_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
// Asymmetric lengths: original long (> 2000 chars), revised short -> fallback count is 1.
let mut s = Session::new(Document::from_text(&long_a));
let r = s.run("review.compare", &json!({"text": short_b, "level": "character"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(1));
}
#[test]
fn compare_word_level_default_and_explicit_never_flag_fallback() {
let original_long = "The quick brown fox jumps over the lazy dog. ".repeat(50);
let revised_long = original_long.replacen("jumps", "jumped", 1);
assert!(original_long.chars().count() > 2000);
// Explicit "level": "word" on long paragraph: characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long, "level": "word"})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
// Default (no "level" param provided): behaves as word level, characterFallback is 0 and "level" not in notHonoured.
let mut s = Session::new(Document::from_text(&original_long));
let r = s.run("review.compare", &json!({"text": revised_long})).unwrap();
assert_eq!(r["characterFallback"].as_u64(), Some(0));
let not_honoured = r["notHonoured"].as_array().expect("notHonoured array");
assert!(!not_honoured.iter().any(|v| v == "level"));
let p = s.doc.body[0].as_para().expect("body paragraph");
let deleted: String = p.run_ranges().filter(|(_, c)| c.del.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
let inserted: String = p.run_ranges().filter(|(_, c)| c.ins.is_some()).map(|(r, _)| p.text[r].to_string()).collect();
assert_eq!(deleted, "jumps ");
assert_eq!(inserted, "jumped ");
}
#[test]
fn compare_keeps_text_boxes_of_the_original() {
// Text boxes are anchored in body paragraphs: one after changed text, one alone in an
@@ -193,6 +635,124 @@ fn compare_bounds_total_diff_work() {
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that moving a paragraph past `MAX_LCS` in a long document does not cascade into false
/// revisions in the tail, asserting exact changed block indices, text, and edit types.
#[test]
fn compare_lcs_window_move_past_cap_does_not_cascade_false_revisions() {
// Issue #6110: 4,600 blocks where prefix/suffix trim is zeroed (substitutions at blocks 0
// and 4599) and a record moves from index 100 to index 4400 (past MAX_LCS = 4000).
// Without chunking/continuation past the window, the tail misaligns and positional pairing
// marks ~202 paragraphs instead of the true 4 changes (substitutions at 0 and 4599,
// deletion at 100, insertion at 4400).
let total = 4600;
let mut orig_paras: Vec<String> = (0..total).map(|i| format!("Record {i} has alpha beta gamma payload.")).collect();
let mut rev_paras = orig_paras.clone();
// Zero out prefix trim: substitution at 0
orig_paras[0] = "Record 0 has alpha beta gamma original.".into();
rev_paras[0] = "Record 0 has alpha beta gamma revised.".into();
// Zero out suffix trim: substitution at 4599
orig_paras[total - 1] = format!("Record {} has alpha beta gamma original.", total - 1);
rev_paras[total - 1] = format!("Record {} has alpha beta gamma revised.", total - 1);
// Move record 100 to 4400
let moved = orig_paras[100].clone();
rev_paras.remove(100);
rev_paras.insert(4400, moved);
let original = orig_paras.join("\n");
let revised = rev_paras.join("\n");
let mut s = Session::new(Document::from_text(&original));
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
// The true number of changed paragraphs is 4 (the two substitutions, the move's deletion,
// and the move's insertion). On unpatched code, window truncation misaligned the tail
// and marked ~202 paragraphs.
assert_eq!(r["changes"], 4, "move past MAX_LCS cap must not cascade into false revisions in the tail");
// In s.doc.body (total 4601 blocks: 4598 unchanged, 1 del at 100, 1 ins at 4401, 2 substituted at 0 and 4600):
let changed_blocks: Vec<(usize, String)> = s
.doc
.body
.iter()
.enumerate()
.filter_map(|(idx, b)| {
let p = b.as_para()?;
let has_change = p.mark.del.is_some() || p.mark.ins.is_some() || p.runs.iter().any(|r| r.props.del.is_some() || r.props.ins.is_some());
if has_change { Some((idx, p.plain_text())) } else { None }
})
.collect();
assert_eq!(changed_blocks.len(), 4);
assert_eq!(changed_blocks[0], (0, "Record 0 has alpha beta gamma original.revised.".into()));
assert_eq!(changed_blocks[1], (100, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[2], (4401, "Record 100 has alpha beta gamma payload.".into()));
assert_eq!(changed_blocks[3], (4600, "Record 4599 has alpha beta gamma original.revised.".into()));
// Verify exact change types on those blocks:
let b0 = s.doc.body[0].as_para().unwrap();
assert!(b0.runs.iter().any(|r| r.props.del.is_some()) && b0.runs.iter().any(|r| r.props.ins.is_some()), "block 0 is text replacement");
let b100 = s.doc.body[100].as_para().unwrap();
assert!(b100.runs.iter().all(|r| r.props.del.is_some()) && b100.mark.del.is_some(), "block 100 is deleted whole");
let b4401 = s.doc.body[4401].as_para().unwrap();
assert!(b4401.runs.iter().all(|r| r.props.ins.is_some()) && b4401.mark.ins.is_some(), "block 4401 is inserted whole");
let b4600 = s.doc.body[4600].as_para().unwrap();
assert!(b4600.runs.iter().any(|r| r.props.del.is_some()) && b4600.runs.iter().any(|r| r.props.ins.is_some()), "block 4600 is text replacement");
s.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
}
/// Verifies that `mark_block` records paragraph mark revisions on whole-block deletions,
/// whole-block insertions, empty paragraphs, and ensures clean resolution via `review.acceptAll`.
#[test]
fn compare_mark_block_records_paragraph_mark_revisions_on_whole_block_changes() {
// Whole block delete/insert: test with differing paragraph count so whole-block paths trigger
let orig_multi = "Keep start.\nDelete me.\nKeep end.";
let rev_multi = "Keep start.\nKeep end.";
let mut s2 = Session::new(Document::from_text(orig_multi));
if let Some(Block::Para(p)) = s2.doc.body.get_mut(1).map(Arc::make_mut) {
p.mark.bold = Some(true);
}
s2.run("review.compare", &json!({"text": rev_multi})).unwrap();
// In s2.doc.body, block 1 was deleted whole.
// Verify that p.mark.del is recorded by mark_block and formatting is preserved.
let del_para = s2.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(del_para.mark.del.is_some(), "whole-block delete must record revision on paragraph mark");
assert_eq!(del_para.mark.bold, Some(true), "paragraph mark formatting must be preserved on deleted block");
// Accept all changes: deleted paragraph is removed
s2.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s2.doc.plain_text(StoryRef::Body), rev_multi);
// Test whole-block insertion
let mut s3 = Session::new(Document::from_text(rev_multi));
s3.run("review.compare", &json!({"text": orig_multi})).unwrap();
let ins_para = s3.doc.body[1].as_para().expect("block 1 must be paragraph");
assert!(ins_para.mark.ins.is_some(), "whole-block insert must record revision on paragraph mark");
s3.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s3.doc.plain_text(StoryRef::Body), orig_multi);
// Test empty paragraph deletion and insertion (len == 0, where only mark carries the revision)
let orig_empty = "Before.\n\nAfter.";
let rev_empty = "Before.\nAfter.";
let mut s4 = Session::new(Document::from_text(orig_empty));
s4.run("review.compare", &json!({"text": rev_empty})).unwrap();
let empty_del = s4.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_del.len(), 0);
assert!(empty_del.mark.del.is_some(), "empty paragraph deletion must set mark.del");
s4.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s4.doc.plain_text(StoryRef::Body), rev_empty);
let mut s5 = Session::new(Document::from_text(rev_empty));
s5.run("review.compare", &json!({"text": orig_empty})).unwrap();
let empty_ins = s5.doc.body[1].as_para().expect("empty paragraph block");
assert_eq!(empty_ins.len(), 0);
assert!(empty_ins.mark.ins.is_some(), "empty paragraph insertion must set mark.ins");
s5.run("review.acceptAll", &json!({})).unwrap();
assert_eq!(s5.doc.plain_text(StoryRef::Body), orig_empty);
}
#[test]
fn macros_record_and_run() {
let mut s = Session::new(Document::new());
+14
View File
@@ -851,11 +851,25 @@ pub fn save_path_with(path: &std::path::Path, doc: &Document, password: Option<&
safe_save::write(path, &bytes).map_err(|e| format!("{}: {e}", path.display()))
}
/// Saves a document to `path` with optional password and export options on web targets.
#[cfg(target_arch = "wasm32")]
pub fn save_path_with(path: &std::path::Path, _doc: &Document, _password: Option<&str>, _opts: &ExportOptions) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`].
#[cfg(not(target_arch = "wasm32"))]
pub fn save_text_path(path: &std::path::Path, text: &str) -> Result<(), String> {
crate::file_access::check_write(&path.to_string_lossy())?;
safe_save::write(path, text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))
}
/// Write `text` to `path` as it is (a Markdown file's own text), as safely as [`save_path_with`].
#[cfg(target_arch = "wasm32")]
pub fn save_text_path(path: &std::path::Path, _text: &str) -> Result<(), String> {
Err(format!("{}: files are saved through the browser on the web", path.display()))
}
/// Decode text bytes: UTF-8 (with or without BOM), UTF-16 LE/BE (BOM or sniffed), else the
/// legacy encoding they look like — GBK, Big5, Shift_JIS, Windows-1252… (see
/// [`wordcraft_formats::txt::decode`]).
+4
View File
@@ -164,6 +164,10 @@ pub struct Session {
typing: Option<TypedWord>,
/// Picture edits: edited media key → original media key (Reset Picture).
pub originals: std::collections::HashMap<String, String>,
/// View › Markdown Source: its text, while it is open.
pub md_source: Option<crate::cmd::md_source::MdSource>,
/// A Markdown file's own text, so Save can write it unchanged.
pub md_file: crate::cmd::md_source::FileText,
/// Picture effects, styles and borders: drawn media key → what was drawn on which bitmap,
/// so the next one replaces them instead of drawing over them (#2052).
pub picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
+15
View File
@@ -70,6 +70,8 @@ pub struct DocState {
pub(super) layout: LayoutSlots,
pub(super) typing: Option<TypedWord>,
pub(super) originals: std::collections::HashMap<String, String>,
pub(super) md_source: Option<crate::cmd::md_source::MdSource>,
pub(super) md_file: crate::cmd::md_source::FileText,
pub(super) picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
pub(super) versions: Vec<(String, String, Document)>,
pub(super) autosave: bool,
@@ -124,6 +126,8 @@ impl DocState {
layout: Default::default(),
typing: None,
originals: Default::default(),
md_source: None,
md_file: Default::default(),
picture_looks: Default::default(),
versions: Vec::new(),
autosave: true,
@@ -187,6 +191,7 @@ fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitle
impl Session {
/// Swap the active document's state with `d`'s: `d` then holds the document that was active.
fn swap_doc(&mut self, d: &mut DocState) {
crate::cmd::md_source::apply(self);
use std::mem::swap;
// Every field is listed, so a new one doesn't build until someone decides whether each
// open document has its own (then it goes in `DocState` and is swapped below) or the
@@ -224,6 +229,8 @@ impl Session {
layout: _,
typing: _,
originals: _,
md_source: _,
md_file: _,
picture_looks: _,
versions: _,
autosave: _,
@@ -318,6 +325,8 @@ impl Session {
layout,
typing,
originals,
md_source,
md_file,
picture_looks,
versions,
autosave,
@@ -365,6 +374,8 @@ impl Session {
swap(&mut self.layout, layout);
swap(&mut self.typing, typing);
swap(&mut self.originals, originals);
swap(&mut self.md_source, md_source);
swap(&mut self.md_file, md_file);
swap(&mut self.picture_looks, picture_looks);
swap(&mut self.versions, versions);
swap(&mut self.autosave, autosave);
@@ -389,6 +400,7 @@ impl Session {
if id == self.document_id {
return Ok(());
}
crate::cmd::md_source::apply(self);
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
let mut d = self.others.swap_remove(at);
self.swap_doc(&mut d);
@@ -469,7 +481,10 @@ impl Session {
self.add(doc, 0)
}
/// Adds `doc` to the session and makes it active, parking the previously active document
/// and applying any pending Markdown Source text first.
fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {
crate::cmd::md_source::apply(self);
doc.ensure_nonempty();
let mut view = self.view.clone();
view.outline.collapsed.clear();
+5
View File
@@ -26,6 +26,11 @@ impl Session {
true
}
/// Take back the newest undo step: a command that joins an earlier step of its own.
pub(crate) fn drop_last_undo_step(&mut self) {
self.history.pop();
}
/// 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.
pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) {
+4
View File
@@ -68,6 +68,8 @@ impl Session {
layout,
typing,
originals,
md_source,
md_file,
picture_looks,
versions,
autosave,
@@ -136,6 +138,8 @@ impl Session {
layout,
typing,
originals,
md_source,
md_file,
picture_looks,
last_command: None,
recording: None,
+17 -2
View File
@@ -46,13 +46,28 @@ impl Session {
/// 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
doc.same_content(&self.doc)
&& *sel == self.sel
&& *column == self.column
&& *pending == self.pending
&& !self.md_source.as_ref().is_some_and(|src| src.is_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.
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
if self.md_source.is_some()
&& let Some(mutates) = self.registry.get(id).map(|spec| spec.mutates)
{
crate::cmd::md_source::before_command(self, id, mutates)?;
}
self.run_command(id, params)
}
/// Dispatches `id` through the command registry with transactional rollback on failure,
/// tracking history, undo checkpoints, and IME outcomes.
fn run_command(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
// The document the command starts in, and the first id a document it opens can have.
let home = self.document_id;
let mark = document_id_mark();
@@ -160,7 +175,7 @@ impl Session {
// 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 {
if spec.mutates && spec.id != "view.markdownSourceText" {
let label = if typing { "Typing" } else { spec.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).
+7 -1
View File
@@ -7,6 +7,8 @@ impl Session {
pub fn set_document(&mut self, doc: Document) {
self.autocap = None;
let replaced = self.document_id;
self.md_source = None;
self.md_file = Default::default();
self.document_id = next_document_id();
if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) {
*at = self.document_id;
@@ -110,6 +112,8 @@ impl Session {
// The word being typed, like `column`: valid only while `sel` is where typing left it.
typing: _,
originals: _,
md_source: _,
md_file: _,
picture_looks: _,
last_command: _,
recording: _,
@@ -253,7 +257,9 @@ impl Session {
/// Restrict Editing refuses outright. `None` when it can run (or doesn't exist).
pub fn why_disabled(&self, id: &str) -> Option<&'static str> {
let spec = self.registry.get(id)?;
(spec.enabled)(self).or_else(|| if spec.mutates { crate::cmd::protect::greyed_out(self, id, || self.protect_facts()) } else { None })
(spec.enabled)(self)
.or_else(|| crate::cmd::md_source::refusal(self, id, spec.mutates))
.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
+52 -2
View File
@@ -5623,16 +5623,66 @@ fn saving_a_text_file_over_itself_saves_the_document() {
assert!(!s.dirty, "{name}: saved by its own path");
// Plain text somewhere else is still a copy: the document keeps its file and its changes.
// (Markdown saved as Markdown moves there: see the test below.)
run(&mut s, "text.insert", json!({"text": "!"}));
let copy = dir.join(format!("copy-{name}"));
run(&mut s, "file.saveAs", json!({"path": copy.to_string_lossy()}));
assert!(copy.exists());
assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved");
assert_eq!(s.path.as_deref(), Some(path.as_path()));
if name == "note.txt" {
assert!(s.dirty, "{name}: a copy elsewhere leaves it unsaved");
assert_eq!(s.path.as_deref(), Some(path.as_path()));
}
}
let _ = std::fs::remove_dir_all(&dir);
}
/// A Markdown file saved as another Markdown file is that file from now on: the next save goes
/// there and the first file stays as it was. Other formats keep the #2376 rule.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn markdown_saved_as_markdown_moves_to_the_new_file() {
let dir = std::env::temp_dir().join(format!("wordcraft-engine-md-save-as-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let first = dir.join("a.md");
std::fs::write(&first, b"First\n").unwrap();
for name in ["b.md", "c.markdown", "D.MD"] {
let mut s = s();
std::fs::write(&first, b"First\n").unwrap();
let second = dir.join(name);
run(&mut s, "file.open", json!({"path": first.to_string_lossy()}));
run(&mut s, "text.insert", json!({"text": "Hello "}));
run(&mut s, "file.saveAs", json!({"path": second.to_string_lossy()}));
assert!(!s.dirty, "{name}: saved");
assert_eq!(s.path.as_deref(), Some(second.as_path()), "{name}");
run(&mut s, "text.insert", json!({"text": "Later "}));
run(&mut s, "file.save", json!({}));
assert!(!s.dirty, "{name}: saved again");
assert!(std::fs::read_to_string(&second).unwrap().contains("Later"), "{name}: the next save goes to the new file");
assert_eq!(std::fs::read_to_string(&first).unwrap(), "First\n", "{name}: the first file is left alone");
}
// Markdown to plain text is still a copy.
let mut s = s();
run(&mut s, "file.open", json!({"path": first.to_string_lossy()}));
run(&mut s, "text.insert", json!({"text": "Hello "}));
run(&mut s, "file.saveAs", json!({"path": dir.join("copy.txt").to_string_lossy()}));
assert!(s.dirty, "a text copy leaves it unsaved");
assert_eq!(s.path.as_deref(), Some(first.as_path()));
// So is plain text to Markdown.
let note = dir.join("note.txt");
std::fs::write(&note, b"Note\n").unwrap();
let mut text = self::s();
run(&mut text, "file.open", json!({"path": note.to_string_lossy()}));
run(&mut text, "text.insert", json!({"text": "Hello "}));
run(&mut text, "file.saveAs", json!({"path": dir.join("copy.md").to_string_lossy()}));
assert!(text.dirty, "a Markdown copy of a text file leaves it unsaved");
assert_eq!(text.path.as_deref(), Some(note.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
/// A save that fails to write (no permission, disk full, a bad path) leaves the document's own
/// save stamps untouched: `stamp_save` runs before the fallible write, so a prior version of this
/// left `modified`/`created`/`last_modified_by`/`revision`/`zoom` changed for a save that never
+2 -2
View File
@@ -27,7 +27,7 @@ mod inlines;
/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out.
pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18;
pub use blocks::{Refs, import, import_on, parse};
pub use blocks::{Refs, import, import_on, import_on_with_lines, parse, parse_with_lines};
pub(crate) use export::escape;
pub use export::export;
#[cfg(test)]
@@ -35,6 +35,6 @@ use export::{escape_line_start, inlines_md, table_md, write_blocks};
pub use inlines::parse_inlines;
#[cfg(test)]
use blocks::split_row;
use blocks::{remap_original_lines, split_row};
#[cfg(test)]
mod tests;
+195 -81
View File
@@ -101,12 +101,18 @@ fn link_definition(line: &str) -> Option<(String, String)> {
/// definition. A definition anywhere else stays in the document and reads as text, which is what
/// it did before definitions were understood at all; deleting a line that was part of a
/// paragraph would lose it silently.
fn take_definitions(lines: &mut Vec<String>) -> Refs {
///
/// Returns the parsed references and the 0-based indexes of the removed lines.
fn take_definitions(lines: &mut Vec<String>) -> (Refs, Vec<usize>) {
let mut refs = Refs::new();
let mut open: Option<(char, usize)> = None;
// The start of the document begins a block.
let mut starts_block = true;
let mut removed = Vec::new();
let mut at = 0usize;
lines.retain(|line| {
let this = at;
at += 1;
let t = line.trim_start_matches([' ', '\t']);
// Four spaces of indentation is code, not a fence (`parse_blocks`), on both sides.
let marker = (indent(line) < 4).then(|| fence(t)).flatten();
@@ -130,26 +136,97 @@ fn take_definitions(lines: &mut Vec<String>) -> Refs {
// The first definition of a label wins, as CommonMark says, and definitions may
// follow one another.
refs.entry(label).or_insert(dest.into());
removed.push(this);
return false;
}
starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t);
true
});
refs
(refs, removed)
}
/// Maps monotonically non-decreasing line indexes in lines filtered of link definitions back to
/// their original source lines in a single linear pass.
pub(super) fn remap_original_lines(block_lines: &mut [usize], removed: &[usize]) {
let mut rem_iter = removed.iter().copied().peekable();
let mut shift = 0;
for l in block_lines.iter_mut() {
while let Some(&r) = rem_iter.peek() {
if r <= *l + shift {
shift += 1;
rem_iter.next();
} else {
break;
}
}
*l += shift;
}
}
/// Context for block parsing: depth, nesting, reference definitions, cell budget, and line recording.
struct BlockCtx<'a> {
depth: usize,
level: u8,
refs: &'a Refs,
budget: &'a std::cell::Cell<usize>,
lines_out: Option<&'a mut Vec<usize>>,
}
impl BlockCtx<'_> {
/// Records the starting line of a top-level block if line tracking is active.
fn record_line(&mut self, line: usize) {
if self.depth == 0
&& let Some(lo) = self.lines_out.as_mut()
{
lo.push(line);
}
}
/// Records `count` blocks originating at `line` if line tracking is active.
fn record_lines(&mut self, line: usize, count: usize) {
if self.depth == 0
&& let Some(lo) = self.lines_out.as_mut()
{
for _ in 0..count {
lo.push(line);
}
}
}
}
/// Parse Markdown text into the flow model.
pub fn parse(text: &str) -> model::Flow {
parse_with_lines(text).0
}
/// [`parse`] returning the flow model and the 0-based starting line of each top-level block.
pub fn parse_with_lines(text: &str) -> (model::Flow, Vec<usize>) {
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let mut lines: Vec<String> = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect();
let refs = take_definitions(&mut lines);
let (refs, removed) = take_definitions(&mut lines);
let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS);
let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget);
let mut block_lines = Vec::new();
let mut ctx = BlockCtx { depth: 0, level: 0, refs: &refs, budget: &budget, lines_out: Some(&mut block_lines) };
let mut blocks = parse_blocks(&lines, &mut ctx);
// A leading "Title" from front matter isn't supported; a first-level heading stays a heading.
if blocks.is_empty() {
blocks.push(FBlock::Para(Para::default()));
block_lines.push(0);
}
model::Flow { blocks, meta: Default::default() }
remap_original_lines(&mut block_lines, &removed);
(model::Flow { blocks, meta: Default::default() }, block_lines)
}
/// [`import_on`] returning the document and the 0-based starting line of each top-level block.
pub fn import_on_with_lines(text: &str, page: &wordcraft_doc::SectionProps) -> (Document, Vec<usize>) {
model::begin_import();
let (flow, mut lines) = parse_with_lines(text);
let doc = model::to_doc_in(&flow, page.clone());
if doc.body.len() > lines.len() {
let end_line = text.as_bytes().iter().filter(|&&b| b == b'\n').count();
lines.resize(doc.body.len(), end_line);
}
(doc, lines)
}
/// Leading tabs → spaces (tab stops of 4); the rest of the line is kept.
@@ -369,18 +446,22 @@ fn interrupts(l: &str) -> bool {
|| list_marker(t).is_some_and(|m| (!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or("")))
}
fn flush_para(lines: &mut Vec<String>, out: &mut Vec<FBlock>, depth: usize, refs: &Refs) {
/// Flushes accumulated paragraph text into `out`, recording its starting line if tracked.
fn flush_para(lines: &mut Vec<String>, start_line: &mut Option<usize>, out: &mut Vec<FBlock>, ctx: &mut BlockCtx<'_>) {
if lines.is_empty() {
*start_line = None;
return;
}
let line_no = start_line.take().unwrap_or(0);
let text = join_lines(lines);
lines.clear();
let inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
let inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs);
if inlines.is_empty() {
return;
}
let p = Para { inlines, ..Default::default() };
out.push(FBlock::Para(p));
ctx.record_line(line_no);
}
/// Paragraph lines → text with `\n` for hard breaks and spaces for soft ones.
@@ -405,32 +486,83 @@ fn join_lines(lines: &[String]) -> String {
s
}
/// Creates a single code block paragraph for an indented code line.
fn code_para(line: &str) -> FBlock {
let mut p = Para::new(Kind::Code);
p.push_text(line, &Fmt::default());
FBlock::Para(p)
}
/// Parse block structure. `level` is the list nesting level for lists found here.
/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]).
fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>) -> Vec<FBlock> {
/// Attempts to parse a table starting at `lines[*i]`. Returns `true` if a table was parsed.
fn parse_table(lines: &[String], i: &mut usize, t: &str, ctx: &mut BlockCtx<'_>, out: &mut Vec<FBlock>) -> bool {
let Some((aligns, width)) = lines.get(*i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) else { return false };
let (head, head_width) = split_row(t, MAX_COLS);
if head_width != width {
return false;
}
let table_start = *i;
let mut table = FTable::default();
let cell = |s: &str, header: bool, a: Option<Align>| {
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), ctx.depth, ctx.refs), ..Default::default() };
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
};
let cols = aligns.len();
let fits = |rows: usize| rows < MAX_ROWS && ctx.budget.get() >= cols;
let mut cut = head_width > cols;
if fits(0) {
ctx.budget.set(ctx.budget.get() - cols);
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
} else {
cut = true;
}
*i += 2;
while let Some(l) = lines.get(*i) {
if is_blank(l) || !l.contains('|') || interrupts(l) {
break;
}
if !table.rows.is_empty() && fits(table.rows.len()) {
ctx.budget.set(ctx.budget.get() - cols);
let (mut cells, _) = split_row(l, cols);
cells.resize(cols, String::new());
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
} else {
cut = true;
}
*i += 1;
}
if cut {
model::dropped(model::Dropped::TableCells);
}
if !table.rows.is_empty() {
out.push(FBlock::Table(table));
ctx.record_line(table_start);
}
true
}
/// Parse block structure.
fn parse_blocks(lines: &[String], ctx: &mut BlockCtx<'_>) -> Vec<FBlock> {
let mut out = Vec::new();
let mut para: Vec<String> = Vec::new();
let mut para_start = None;
let mut i = 0;
while let Some(line) = lines.get(i) {
if is_blank(line) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
i += 1;
continue;
}
let ind = indent(line);
if ind >= 4 {
if !para.is_empty() {
if para_start.is_none() {
para_start = Some(i);
}
para.push(line.clone());
i += 1;
continue;
}
parse_indented_code(lines, &mut i, &mut out);
parse_indented_code(lines, &mut i, &mut out, ctx);
continue;
}
let t = line.trim_start_matches([' ', '\t']);
@@ -439,44 +571,50 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
let u = t.trim_end();
if !u.is_empty() && (u.chars().all(|c| c == '=') || (u.chars().all(|c| c == '-'))) {
let lvl = if u.starts_with('=') { 1 } else { 2 };
let line_no = para_start.take().unwrap_or(i);
let text = join_lines(&para);
para.clear();
let mut p = Para::new(Kind::Heading(lvl));
p.inlines = parse_inlines(&text, &Fmt::default(), depth, refs);
p.inlines = parse_inlines(&text, &Fmt::default(), ctx.depth, ctx.refs);
out.push(FBlock::Para(p));
ctx.record_line(line_no);
i += 1;
continue;
}
}
if let Some((n, body)) = atx_heading(t) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
let mut p = Para::new(Kind::Heading(n));
p.inlines = parse_inlines(body, &Fmt::default(), depth, refs);
p.inlines = parse_inlines(body, &Fmt::default(), ctx.depth, ctx.refs);
out.push(FBlock::Para(p));
ctx.record_line(i);
i += 1;
continue;
}
if thematic_break(t) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
out.push(FBlock::Para(Para::new(Kind::Rule)));
ctx.record_line(i);
i += 1;
continue;
}
if let Some((fc, fl)) = fence(t) {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
i += 1;
while let Some(l) = lines.get(i) {
let cur = i;
i += 1;
let lt = l.trim_start_matches([' ', '\t']);
if indent(l) < 4 && fence(lt).is_some_and(|(c, n)| c == fc && n >= fl) && lt.trim_start_matches(fc).trim().is_empty() {
break;
}
out.push(code_para(strip_indent(l, ind)));
ctx.record_line(cur);
}
continue;
}
if t.starts_with("<!--") {
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
while let Some(l) = lines.get(i) {
i += 1;
if l.contains("-->") {
@@ -486,89 +624,58 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget:
continue;
}
if t.starts_with('>') {
flush_para(&mut para, &mut out, depth, refs);
parse_quote(lines, &mut i, depth, level, refs, budget, &mut out);
flush_para(&mut para, &mut para_start, &mut out, ctx);
let quote_start = i;
let before = out.len();
parse_quote(lines, &mut i, ctx, &mut out);
ctx.record_lines(quote_start, out.len() - before);
continue;
}
if para.is_empty()
&& t.contains('|')
&& let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS))
{
let (head, head_width) = split_row(t, MAX_COLS);
if head_width == width {
let mut table = FTable::default();
let cell = |s: &str, header: bool, a: Option<Align>| {
let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() };
Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() }
};
// The document keeps no more columns or rows than this, nor cells past its budget,
// so those aren't built; their lines are still the table's.
let cols = aligns.len();
let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols;
let mut cut = head_width > cols;
if fits(0) {
budget.set(budget.get() - cols);
table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect());
} else {
cut = true;
}
i += 2;
while let Some(l) = lines.get(i) {
if is_blank(l) || !l.contains('|') || interrupts(l) {
break;
}
if !table.rows.is_empty() && fits(table.rows.len()) {
budget.set(budget.get() - cols);
let (mut cells, _) = split_row(l, cols);
cells.resize(cols, String::new());
table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect());
} else {
cut = true;
}
i += 1;
}
if cut {
model::dropped(model::Dropped::TableCells);
}
if !table.rows.is_empty() {
out.push(FBlock::Table(table));
}
continue;
}
if para.is_empty() && t.contains('|') && parse_table(lines, &mut i, t, ctx, &mut out) {
continue;
}
if let Some(m) = list_marker(t)
&& (para.is_empty() || ((!m.ordered || m.start == 1) && !is_blank(t.get(m.len..).unwrap_or(""))))
{
flush_para(&mut para, &mut out, depth, refs);
i = parse_list(lines, i, depth, level, &mut out, refs, budget);
flush_para(&mut para, &mut para_start, &mut out, ctx);
i = parse_list(lines, i, ctx, &mut out);
continue;
}
if para_start.is_none() {
para_start = Some(i);
}
para.push(line.clone());
i += 1;
}
flush_para(&mut para, &mut out, depth, refs);
flush_para(&mut para, &mut para_start, &mut out, ctx);
out
}
fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>) {
let mut code: Vec<&str> = Vec::new();
/// Parses indented code block starting at `lines[*i]`.
fn parse_indented_code(lines: &[String], i: &mut usize, out: &mut Vec<FBlock>, ctx: &mut BlockCtx<'_>) {
let mut code: Vec<(usize, &str)> = Vec::new();
while let Some(line) = lines.get(*i) {
let cur = *i;
if is_blank(line) {
code.push("");
code.push((cur, ""));
} else if indent(line) >= 4 {
code.push(strip_indent(line, 4));
code.push((cur, strip_indent(line, 4)));
} else {
break;
}
*i += 1;
}
while code.last().is_some_and(|line| line.is_empty()) {
while code.last().is_some_and(|(_, line)| line.is_empty()) {
code.pop();
}
out.extend(code.into_iter().map(code_para));
for (line_idx, text) in code {
out.push(code_para(text));
ctx.record_line(line_idx);
}
}
fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell<usize>, out: &mut Vec<FBlock>) {
/// Parses block quote starting at `lines[*i]`.
fn parse_quote(lines: &[String], i: &mut usize, ctx: &BlockCtx<'_>, out: &mut Vec<FBlock>) {
let mut inner = Vec::new();
while let Some(line) = lines.get(*i) {
let trimmed = line.trim_start_matches([' ', '\t']);
@@ -582,13 +689,14 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
}
*i += 1;
}
if depth >= MAX_NEST {
if ctx.depth >= MAX_NEST {
let mut p = Para::new(Kind::Quote);
p.push_text(&inner.join(" "), &Fmt::default());
out.push(FBlock::Para(p));
return;
}
for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) {
let mut child = BlockCtx { depth: ctx.depth + 1, level: ctx.level, refs: ctx.refs, budget: ctx.budget, lines_out: None };
for mut block in parse_blocks(&inner, &mut child) {
if let FBlock::Para(p) = &mut block
&& p.kind == Kind::Normal
{
@@ -598,6 +706,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &
}
}
/// Computes columns and byte count for spaces following a list item marker.
fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
let mut cols = 0;
let mut bytes = 0;
@@ -626,7 +735,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) {
}
/// Parse a list starting at `lines[i]`; returns the index after it.
fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec<FBlock>, refs: &Refs, budget: &std::cell::Cell<usize>) -> usize {
fn parse_list(lines: &[String], mut i: usize, ctx: &mut BlockCtx<'_>, out: &mut Vec<FBlock>) -> usize {
let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 };
while let Some(line) = lines.get(i) {
let ind = indent(line);
@@ -638,6 +747,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
if m.ordered != first.ordered || m.ch != first.ch {
break;
}
let item_start = i;
let after = t.get(m.len..).unwrap_or("");
let (spaces, skip) = if indent(after) > 4 {
(1, 1)
@@ -667,14 +777,17 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
while item.len() > 1 && item.last().is_some_and(String::is_empty) {
item.pop();
}
let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start, ..Default::default() };
let inner = if depth >= MAX_NEST {
let li = ListInfo { ordered: first.ordered, level: ctx.level.min(8), start: first.start, ..Default::default() };
let inner = if ctx.depth >= MAX_NEST {
let mut p = Para::default();
p.push_text(&item.join(" "), &Fmt::default());
vec![FBlock::Para(p)]
} else {
parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget)
let mut child =
BlockCtx { depth: ctx.depth + 1, level: ctx.level.saturating_add(1).min(8), refs: ctx.refs, budget: ctx.budget, lines_out: None };
parse_blocks(&item, &mut child)
};
let before_count = out.len();
let mut placed = false;
if inner.is_empty() {
out.push(FBlock::Para(Para { list: Some(li), ..Default::default() }));
@@ -712,6 +825,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
}
}
}
ctx.record_lines(item_start, out.len() - before_count);
// Blank lines between items.
while lines.get(i).is_some_and(|l| is_blank(l)) {
let next_is_item = lines.get(i + 1).is_some_and(|l| {
+50
View File
@@ -776,6 +776,7 @@ fn exported_link_destinations_read_back_unchanged() {
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
}
/// Verifies that trailing escaped hashes on headings survive round-trip export.
#[test]
fn heading_trailing_hash_survives_round_trip() {
let heading = |md: &str| {
@@ -797,3 +798,52 @@ fn heading_trailing_hash_survives_round_trip() {
assert_eq!(export(&import(&md)), want, "second export of {src:?}");
}
}
/// Verifies that block line mapping tracks source-line indexes accurately across definitions.
#[test]
fn block_line_mapping() {
let md = "# Title\n\nFirst paragraph.\n\n## Second\n\n- one\n- two\n\n```\ncode\n```\n\nLast paragraph.\n";
let (flow, lines) = parse_with_lines(md);
assert_eq!(flow.blocks.len(), lines.len());
let (doc, doc_lines) = import_on_with_lines(md, &wordcraft_doc::SectionProps::default());
assert_eq!(doc.body.len(), doc_lines.len());
assert_eq!(lines, doc_lines);
assert_eq!(lines[0], 0);
assert_eq!(lines[1], 2);
assert_eq!(lines[2], 4);
// Reference definitions are removed from blocks, but block line numbers must match original source lines.
let md_refs = "[link]: https://example.com\n# Title\n\n[other]: https://example.org\nParagraph with [link][].\n";
let (flow, lines) = parse_with_lines(md_refs);
assert_eq!(flow.blocks.len(), lines.len());
assert_eq!(lines[0], 1, "heading on line 1 after definition on line 0");
assert_eq!(lines[1], 4, "paragraph on line 4 after definition on line 3");
}
/// Verifies linear remapping of original lines after removing definitions.
#[test]
fn remap_original_lines_cases() {
let mut lines = vec![0, 1, 2, 3, 4];
remap_original_lines(&mut lines, &[2, 5]);
assert_eq!(lines, vec![0, 1, 3, 4, 6]);
let mut lines = vec![0, 1];
remap_original_lines(&mut lines, &[1, 2, 3]);
assert_eq!(lines, vec![0, 4]);
let mut lines = vec![0];
remap_original_lines(&mut lines, &[0]);
assert_eq!(lines, vec![1]);
let mut lines = vec![1, 1, 2];
remap_original_lines(&mut lines, &[1]);
assert_eq!(lines, vec![2, 2, 3]);
let mut lines = vec![0, 2];
remap_original_lines(&mut lines, &[]);
assert_eq!(lines, vec![0, 2]);
let mut empty: Vec<usize> = Vec::new();
remap_original_lines(&mut empty, &[1, 2]);
assert!(empty.is_empty());
}
+1
View File
@@ -22,6 +22,7 @@ mod pictures;
mod prefs;
pub(crate) use commands::works_without_document;
pub(crate) use frame::STATUS_MS;
pub(crate) use input::control_click_as_secondary;
impl WordApp {
+11
View File
@@ -28,6 +28,10 @@ impl WordApp {
/// Close, Envelopes, Labels and Finish & Merge on a document with unsaved changes first ask
/// Save / Don't Save / Cancel, and the command runs once that is answered (`ui.saveChanges`).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
// While Markdown Source is open, Undo and Redo work on its typing, as Ctrl+Z does there.
if matches!(id, "edit.undo" | "edit.redo") && self.session.md_source.is_some() {
return crate::md_source::undo(self, id == "edit.redo");
}
// Comment text still being typed is written first, so a save or export has it and a
// close, New or Open counts it as a change (#1348). A draft the document refuses is not
// part of it: saving and exporting go on without it (the refusal stays on the status
@@ -256,6 +260,13 @@ impl WordApp {
{
self.autosave_path = self.session.path.clone();
}
// A Markdown file edited in the formatted view was written out again, not kept as it was.
if let Ok(v) = &r
&& matches!(id, "file.save" | "file.saveAs")
&& v.get("rewritten").and_then(Value::as_bool) == Some(true)
{
self.status_long(tl!("Saved. Edits in the formatted view meant writing the Markdown out again, so its own layout wasn't kept."));
}
// A renamed file is the same file: AutoSave keeps covering it, and Recent drops the old name.
if r.is_ok()
&& id == "file.rename"
+7 -4
View File
@@ -297,16 +297,19 @@ impl WordApp {
self.default_save_name()
}
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros),
/// otherwise .docx. A document never saved and without a title of its own is named after its
/// first words, as Word suggests them (#5793), instead of `Document1`.
/// The name Save suggests: the open file's own Word format (so a .docm keeps its macros) or,
/// in a dialog, its own Markdown format (a browser's download keeps the whole document as
/// .docx), otherwise .docx. A document never saved and without a title of its own is named after
/// its first words, as Word suggests them (#5793), instead of `Document1`.
pub(crate) fn default_save_name(&self) -> String {
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_string());
let unnamed = self.session.path.is_none() && self.session.file_name.is_none() && self.session.doc.core.title.trim().is_empty();
let first = || self.session.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).find_map(|t| suggested_stem(&t));
let stem = unnamed.then(first).flatten().unwrap_or_else(|| self.title_stem());
let markdown =
self.services.download.is_none() && self.session.path.as_deref().is_some_and(wordcraft_engine::cmd::md_source::is_markdown_file);
match ext {
Some(e) if wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
Some(e) if markdown || wordcraft_engine::io::is_word_package(&e) => format!("{stem}.{e}"),
_ => format!("{stem}.docx"),
}
}
+17 -1
View File
@@ -7,6 +7,11 @@ use super::{
use crate::i18n;
/// How long the status bar shows a message, in milliseconds.
pub(crate) const STATUS_MS: f64 = 6000.0;
/// How long [`WordApp::status_long`] shows one: a warning that is easy to miss in six seconds.
const STATUS_LONG_MS: f64 = 20000.0;
impl WordApp {
/// A file that wouldn't open (#1103): log it, and say on the status bar which file and why,
/// whatever handed it over (the command line, a macOS open event, a drop). Returns the message.
@@ -17,10 +22,17 @@ impl WordApp {
msg
}
/// Sets a temporary status message in the bottom status bar.
pub fn status(&mut self, s: impl Into<String>) {
self.status_msg = Some((s.into(), now_ms()));
}
/// A status message that stays [`STATUS_LONG_MS`] instead of the usual six seconds: the bar
/// shows a message until six seconds after its time, so its time is set that much later.
pub fn status_long(&mut self, s: impl Into<String>) {
self.status_msg = Some((s.into(), now_ms() + STATUS_LONG_MS - STATUS_MS));
}
/// Write the current preferences out through the host's hook, if it has one (#2716). The
/// desktop app saves them at exit; a change that should survive a crash, like taking a
/// document off the recent list, needs a save at the moment of the change. `None`: the host
@@ -99,7 +111,11 @@ impl WordApp {
}
let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas };
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| {
canvas::show(self, ui);
if self.session.md_source.is_some() {
crate::md_source::show(self, ui);
} else {
canvas::show(self, ui);
}
});
if self.focus_ribbon && !moved(self) {
chrome::focus_ribbon(self, ctx);
+106 -4
View File
@@ -824,6 +824,7 @@ struct CanvasFinish<'a> {
vruler: Option<Rect>,
scroll_base: f32,
rescaled: bool,
scroll_style: egui::style::ScrollStyle,
}
/// What a window's own scrolling is measured against for Synchronous Scrolling (#322).
@@ -833,8 +834,11 @@ struct ScrollSync {
base: f32,
/// The zoom changed: the offset moved, nobody scrolled.
rescaled: bool,
/// The scroll style applied to the canvas scroll area (#5853).
style: egui::style::ScrollStyle,
}
/// Update scroll offset, scrollbar tip, and current viewed page state for the canvas frame.
fn update_canvas_scroll_state(
app: &mut WordApp,
ui: &mut Ui,
@@ -858,12 +862,13 @@ fn update_canvas_scroll_state(
viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1))
});
crate::ink::replay_bar(app, ui, out.inner_rect);
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
scrollbar_track_paging(app, ui, out, sync.style);
// This window's own scrolling (wheel, scroll bar, keys, caret), for a partner window.
let own = app.canvas.scroll_offset.y - sync.base;
if app.session.windows.sync_scroll && !sync.rescaled && own.abs() > 0.01 && geo.scale > 0.0 {
app.canvas.sync_out += own / geo.scale;
}
app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO);
if app.session.view.read_mode {
let step = app.session.view.read_width * geo.scale + GAP;
if step.is_finite() && step > 0.0 {
@@ -876,6 +881,82 @@ fn update_canvas_scroll_state(
app.canvas.viewport = Some(out.inner_rect);
}
/// Advance the vertical scroll by one screen when clicking in the track above or below the elevator (#5853).
///
/// In Word, clicking in the vertical scrollbar track above the elevator moves one screen up,
/// and clicking below it moves one screen down. Sensed here over the track regions outside the
/// elevator so egui does not jump the scroll offset to the clicked position.
///
/// Paging regions track egui's selected vertical scrollbar side (`ui.style().right_to_left`)
/// and allocated scrollbar width (`scroll_style.allocated_width()`), placing the paging bounds
/// on the left in RTL and on the right in LTR across solid, floating, and thin scroll styles.
fn scrollbar_track_paging(
app: &mut WordApp,
ui: &mut Ui,
out: &egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>,
scroll_style: egui::style::ScrollStyle,
) {
let scroll_id = out.id.with(1usize);
let dragging_bar = ui.ctx().is_being_dragged(scroll_id);
if app.session.view.read_mode || !out.inner_rect.is_finite() || !out.content_size.is_finite() || app.canvas.scroll_max.y <= 0.0 || dragging_bar {
return;
}
let step = out.inner_rect.height();
if !step.is_finite() || step <= 0.0 {
return;
}
let inner_rect = out.inner_rect;
let full_width = scroll_style.bar_width;
let outer_margin = scroll_style.bar_outer_margin;
let allocated_width = scroll_style.allocated_width();
let vertical_bar_on_left = ui.style().right_to_left;
let (bar_left, bar_right) = if vertical_bar_on_left {
let outer_edge = (inner_rect.min.x - allocated_width).max(ui.clip_rect().min.x) + outer_margin;
(outer_edge, outer_edge + full_width)
} else {
let outer_edge = (inner_rect.max.x + allocated_width).min(ui.clip_rect().max.x) - outer_margin;
(outer_edge - full_width, outer_edge)
};
if !bar_left.is_finite() || !bar_right.is_finite() || bar_right <= bar_left {
return;
}
let handle_min_length = ui.spacing().scroll.handle_min_length;
let content_h = out.content_size.y;
let offset_y = app.canvas.scroll_offset.y;
let max_offset_y = app.canvas.scroll_max.y;
let from_content = |content: f32| egui::remap_clamp(content, 0.0..=content_h, inner_rect.min.y..=inner_rect.max.y);
let handle_size = (from_content(offset_y + step) - from_content(offset_y)).max(handle_min_length);
let handle_start = if max_offset_y > 0.0 {
egui::remap_clamp(offset_y, 0.0..=max_offset_y, inner_rect.min.y..=(inner_rect.max.y - handle_size))
} else {
inner_rect.min.y
};
let handle_end = handle_start + handle_size;
let track_above = Rect::from_min_max(pos2(bar_left, inner_rect.min.y), pos2(bar_right, handle_start));
if track_above.height() > 0.0 {
let resp = ui.interact(track_above, out.id.with("track_above"), Sense::click_and_drag());
if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| track_above.contains(p)) {
let target_y = (offset_y - step).max(0.0);
app.canvas.scroll_request = Some(vec2(app.canvas.scroll_offset.x, target_y));
app.canvas.scroll_offset.y = target_y;
ui.ctx().request_repaint();
}
}
let track_below = Rect::from_min_max(pos2(bar_left, handle_end), pos2(bar_right, inner_rect.max.y));
if track_below.height() > 0.0 {
let resp = ui.interact(track_below, out.id.with("track_below"), Sense::click_and_drag());
if resp.clicked() && resp.interact_pointer_pos().is_some_and(|p| track_below.contains(p)) {
let target_y = (offset_y + step).min(max_offset_y);
app.canvas.scroll_request = Some(vec2(app.canvas.scroll_offset.x, target_y));
app.canvas.scroll_offset.y = target_y;
ui.ctx().request_repaint();
}
}
}
fn update_canvas_focus(app: &mut WordApp, ui: &mut Ui, resp: &egui::Response, ime_geometry: Option<(Rect, Rect)>, ime_interrupt: bool) {
// Space and Enter also set `Response::clicked` on a focused click-sensing widget
// (egui's fake primary click). Re-requesting an already focused widget no longer
@@ -1328,9 +1409,25 @@ fn handle_secondary_selection(app: &mut WordApp, resp: &egui::Response, rects: &
}
}
/// Finish the canvas frame: update scroll state, handle focus, input, accessibility, and context menus.
fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec<Rect>)>) {
let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler, scroll_base, rescaled } = finish;
update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled });
let CanvasFinish {
app,
ui,
layout,
geo,
caret,
ime_geometry,
ime_interrupt,
area,
dim_body,
hruler,
vruler,
scroll_base,
rescaled,
scroll_style,
} = finish;
update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled, style: scroll_style });
let (resp, rects) = out.inner;
// A click on the page leaves the selected comment balloon.
dismiss_balloon_on_canvas_click(app, ui, &resp);
@@ -1526,6 +1623,7 @@ fn live_selection_screen_rects(app: &mut WordApp, rects: &[Rect], scale: f32) ->
.collect()
}
/// Render the document canvas, pages, scroll bars, rulers, and interactive controls for the current frame.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let full = ui.available_rect_before_wrap();
@@ -1539,7 +1637,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
// The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back.
let inherited = std::sync::Arc::clone(ui_area.style());
ui_area.spacing_mut().scroll = canvas_scroll_style();
if ui.spacing().scroll == egui::style::ScrollStyle::floating() {
ui_area.spacing_mut().scroll = canvas_scroll_style();
}
canvas_scroll_thumb(ui_area.visuals_mut(), &t);
let mut ime_geometry: Option<(Rect, Rect)> = None;
let mut ime_interrupt = false;
@@ -1828,6 +1928,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
crate::table_mouse::paint(app, &painter, &t, &layout, &rects, geo.scale);
(resp, rects)
});
let scroll_style = ui_area.spacing().scroll;
finish_canvas_frame(
CanvasFinish {
app,
@@ -1843,6 +1944,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
vruler,
scroll_base,
rescaled,
scroll_style,
},
out,
);
+264
View File
@@ -699,6 +699,7 @@ fn dragging_the_scrollbar_names_the_page_at_the_top() {
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("view.hideWhiteSpace", json!({"value": hide_white_space})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let (rev, sel) = (a.session.rev(), a.session.sel.clone());
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
@@ -835,6 +836,269 @@ fn the_document_scroll_bars_show_with_the_pointer_away() {
}
}
/// #5853: clicking in the vertical scrollbar track below the elevator moves one screen down,
/// and clicking above the elevator moves one screen up, rather than jumping to that position in the document.
#[test]
fn clicking_vertical_scrollbar_track_advances_one_screen() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(h.state_mut().session.layout().pages.len(), 12);
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
h.event(Event::PointerMoved(at));
h.step();
h.event(button(at, true));
h.step();
h.event(button(at, false));
for _ in 0..3 {
h.step();
}
};
let start_offset = h.state().canvas.scroll_offset.y;
assert_eq!(start_offset, 0.0, "starts at the top");
let viewport_height = h.state().canvas.viewport.expect("viewport").height();
// Click near the bottom of the scrollbar (well below the elevator).
let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0);
click(&mut h, near_bottom);
let after_click_down = h.state().canvas.scroll_offset.y;
assert!((after_click_down - viewport_height).abs() < 2.0, "advances one screen down: expected ~{viewport_height}, got {after_click_down}");
// Now click near the top of the scrollbar (above the elevator).
let near_top = pos2(bar.center().x, bar.top() + 10.0);
click(&mut h, near_top);
let after_click_up = h.state().canvas.scroll_offset.y;
assert!(after_click_up.abs() < 2.0, "advances one screen up back to top: expected ~0.0, got {after_click_up}");
}
/// #5853: in a right-to-left interface, egui places the vertical scrollbar on the left;
/// clicking in the scrollbar track advances one screen up/down along that left edge.
#[test]
fn clicking_vertical_scrollbar_track_advances_one_screen_in_rtl() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
ui.style_mut().set_right_to_left(true);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(h.state_mut().session.layout().pages.len(), 12);
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
assert!(bar.center().x < 100.0, "scrollbar is on the left in RTL: {bar:?}");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
h.event(Event::PointerMoved(at));
h.step();
h.event(button(at, true));
h.step();
h.event(button(at, false));
for _ in 0..3 {
h.step();
}
};
let start_offset = h.state().canvas.scroll_offset.y;
assert_eq!(start_offset, 0.0, "starts at the top");
let viewport_height = h.state().canvas.viewport.expect("viewport").height();
let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0);
click(&mut h, near_bottom);
let after_click_down = h.state().canvas.scroll_offset.y;
assert!((after_click_down - viewport_height).abs() < 2.0, "advances one screen down in RTL: expected ~{viewport_height}, got {after_click_down}");
let near_top = pos2(bar.center().x, bar.top() + 10.0);
click(&mut h, near_top);
let after_click_up = h.state().canvas.scroll_offset.y;
assert!(after_click_up.abs() < 2.0, "advances one screen up back to top in RTL: expected ~0.0, got {after_click_up}");
}
/// #5853: clicking within the vertical scrollbar track under `ScrollStyle::thin()` advances one screen.
#[test]
fn clicking_thin_vertical_scrollbar_track_advances_one_screen() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "Page text"})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
ui.spacing_mut().scroll = egui::style::ScrollStyle::thin();
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
assert_eq!(h.state_mut().session.layout().pages.len(), 12);
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let click = |h: &mut egui_kittest::Harness<'_, WordApp>, at: Pos2| {
h.event(Event::PointerMoved(at));
h.step();
h.event(button(at, true));
h.step();
h.event(button(at, false));
for _ in 0..3 {
h.step();
}
};
let start_offset = h.state().canvas.scroll_offset.y;
assert_eq!(start_offset, 0.0, "starts at the top");
let viewport_height = h.state().canvas.viewport.expect("viewport").height();
let near_bottom = pos2(bar.center().x, bar.bottom() - 20.0);
click(&mut h, near_bottom);
let after_click_down = h.state().canvas.scroll_offset.y;
assert!(
(after_click_down - viewport_height).abs() < 2.0,
"advances one screen down with thin scrollbar: expected ~{viewport_height}, got {after_click_down}"
);
let near_top = pos2(bar.center().x, bar.top() + 10.0);
click(&mut h, near_top);
let after_click_up = h.state().canvas.scroll_offset.y;
assert!(after_click_up.abs() < 2.0, "advances one screen up back to top with thin scrollbar: expected ~0.0, got {after_click_up}");
}
/// #5853: clicks and drags on page content just outside the vertical scrollbar track do not trigger
/// paging and can place the caret or start a selection.
#[test]
fn clicking_and_dragging_outside_scrollbar_does_not_page() {
use egui::accesskit::Role;
use egui::{Event, PointerButton};
use egui_kittest::kittest::Queryable;
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
for _ in 0..11 {
a.run("text.insert", json!({"text": "A long line of sample text that fills across the page for testing selections."})).unwrap();
a.run("layout.break", json!({"kind": "page"})).unwrap();
}
a.run("view.commentsPane", json!({"value": false})).unwrap();
a.run("caret.docStart", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
);
for _ in 0..6 {
h.step();
}
let bar = h
.query_all_by_role(Role::ScrollBar)
.map(|n| n.rect())
.filter(|r| r.height() > r.width())
.max_by(|a, b| a.height().total_cmp(&b.height()))
.expect("the canvas's vertical scrollbar");
let button = |at: Pos2, pressed: bool| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
// Point just to the left of the scrollbar track (outside egui's scrollbar hit rect).
let just_outside = pos2(bar.min.x - 2.0, bar.bottom() - 20.0);
h.event(Event::PointerMoved(just_outside));
h.step();
h.event(button(just_outside, true));
h.step();
h.event(button(just_outside, false));
for _ in 0..3 {
h.step();
}
assert_eq!(h.state().canvas.scroll_offset.y, 0.0, "clicking outside the scrollbar track does not page");
// Dragging just outside the scrollbar does not page.
let drag_from = pos2(bar.min.x - 3.0, 300.0);
let drag_to = pos2(bar.min.x - 3.0, 350.0);
h.event(Event::PointerMoved(drag_from));
h.step();
h.event(button(drag_from, true));
h.step();
for k in 1..=4 {
h.event(Event::PointerMoved(drag_from + (drag_to - drag_from) * (k as f32 / 4.0)));
h.step();
}
h.event(button(drag_to, false));
for _ in 0..3 {
h.step();
}
assert_eq!(h.state().canvas.scroll_offset.y, 0.0, "dragging outside the scrollbar track does not page");
}
/// #3880: the status bar names the page being looked at. With the caret scrolled out of view, that
/// is the page at the top of the window; while the caret is on screen, its page.
#[test]
+5 -1
View File
@@ -53,6 +53,8 @@ const QAT_MENU: [&str; 10] =
/// Match the toolbar's enabled state for keyboard activation too.
pub(crate) fn qat_enabled(app: &WordApp, id: &str) -> bool {
match id {
// The Markdown Source pane's arrows undo its typing.
"edit.undo" | "edit.redo" if app.session.md_source.is_some() => crate::md_source::can_undo(app, id == "edit.redo"),
"edit.undo" => app.session.can_undo(),
"edit.redo" => app.session.can_redo(),
"file.save" => app.has_document(),
@@ -82,6 +84,8 @@ fn quick_access(ui: &mut Ui, app: &mut WordApp) {
}
crate::widgets::command_menu(&resp, app, id);
if id == "edit.undo" {
// The list is the document's steps, not the Markdown Source pane's typing.
let enabled = enabled && app.session.md_source.is_none();
undo_list_button(ui, app, enabled);
}
}
@@ -962,7 +966,7 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
}
});
if let Some((msg, at)) = &app.status_msg
&& crate::now_ms() - at < 6000.0
&& crate::now_ms() - at < crate::app::STATUS_MS
{
let font = TypeRung::Caption.regular();
let track = TypeRung::Caption.tracking();
+9
View File
@@ -1034,6 +1034,15 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool {
pen.line(&[(12.5, 15.5), (17.0, 15.5)]);
}
"draft" => pen.lines(3.0, 17.0, &[4.0, 7.0, 10.0, 13.0, 16.0]),
// A heading mark, then lines of text.
"markdownSource" => {
pen.line_c(&[(4.0, 3.0), (4.0, 9.0)], a);
pen.line_c(&[(7.5, 3.0), (7.5, 9.0)], a);
pen.line_c(&[(2.5, 5.0), (9.0, 5.0)], a);
pen.line_c(&[(2.5, 7.0), (9.0, 7.0)], a);
pen.lines(3.0, 17.0, &[12.0, 15.0]);
pen.lines(11.0, 17.0, &[4.0, 7.0]);
}
"focus" => {
pen.line(&[(3.0, 7.0), (3.0, 3.0), (7.0, 3.0)]);
pen.line(&[(13.0, 3.0), (17.0, 3.0), (17.0, 7.0)]);
+1
View File
@@ -374,6 +374,7 @@ fn controls(tab: &str) -> Vec<Control> {
c("Read Mode", "view.readMode"),
c("Print Layout", "view.printLayout"),
c("Web Layout", "view.webLayout"),
c("Markdown Source", "view.markdownSource"),
c("Outline", "view.outline"),
c("Draft", "view.draft"),
c("Focus", "view.focus"),
+1
View File
@@ -54,6 +54,7 @@ pub mod keys;
pub mod keytips;
pub(crate) mod language_packs;
mod local_time;
pub mod md_source;
pub mod mini_toolbar;
pub mod objects;
pub mod options;
+386
View File
@@ -0,0 +1,386 @@
//! View › Markdown Source: the text pane that takes the page's place. The text itself and what it
//! means for the document are the engine's (`wordcraft_engine::cmd::md_source`).
use egui::text::{CCursor, CCursorRange};
use egui::{FontId, Key, KeyboardShortcut, Modifiers, Ui};
use serde_json::json;
use crate::WordApp;
use crate::clock::now_ms;
use crate::theme::Tokens;
/// How often a text with edits reaches the document while it is being typed, in milliseconds, so
/// crash recovery has them.
const APPLY_EVERY_MS: f64 = 5000.0;
/// Draw the pane in the place of the page.
pub fn show(app: &mut WordApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// The page isn't drawn, so it can't have the keyboard.
app.canvas.focused = false;
app.canvas.want_focus = false;
// A focused text box turns the global shortcuts off.
if ui.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::S))) {
let _ = app.run("file.save", json!({}));
}
let now = now_ms();
if app.session.md_source.as_ref().is_some_and(|src| src.is_pending() && now - src.applied_at_ms > APPLY_EVERY_MS) {
wordcraft_engine::cmd::md_source::apply(&mut app.session);
if let Some(src) = app.session.md_source.as_mut() {
src.applied_at_ms = now;
}
}
let doc_id = app.session.document_id();
let id = box_id(doc_id);
let mut changed = false;
let mut refused = false;
{
let Some(src) = app.session.md_source.as_mut() else { return };
if src.reset_undo {
src.reset_undo = false;
reset_undo_history(ui.ctx(), doc_id);
}
if src.loses_content {
egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(12, 6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(tl!(
"This document has content Markdown can't hold. Changing the text here removes it; one Undo afterwards brings it back."
));
});
}
let has_input = ui.input(|i| i.events.iter().any(|e| !matches!(e, egui::Event::PointerMoved(_))));
let prev_text = if has_input { Some(src.text.clone()) } else { None };
let prev_caret = src.caret;
egui::ScrollArea::vertical().id_salt(id).auto_shrink(false).show(ui, |ui| {
let size = ui.available_size();
let mut out = egui::TextEdit::multiline(&mut src.text)
.id(id)
.hint_text(tl!("Markdown Source"))
.font(FontId::monospace(14.0))
.desired_width(f32::INFINITY)
.min_size(size)
.frame(egui::Frame::NONE.inner_margin(16))
.text_color(t.text)
.char_limit(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES)
.lock_focus(true)
.show(ui);
changed = out.response.changed();
if changed && src.text.len() > wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES {
if let Some(prev) = prev_text {
src.text = prev;
src.caret = prev_caret;
} else {
let limit = src.text.floor_char_boundary(wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES);
src.text.truncate(limit);
src.caret = src.caret.min(limit);
}
changed = false;
refused = true;
src.place_caret = true;
}
let caret_changed = if src.place_caret {
src.place_caret = false;
let char_idx = src.text.get(..src.caret).map_or(0, |s| s.chars().count());
let caret = CCursor::new(char_idx);
out.state.cursor.set_char_range(Some(CCursorRange::one(caret)));
out.state.store(ui.ctx(), id);
out.response.request_focus();
let spot = out.galley.pos_from_cursor(caret).translate(out.galley_pos.to_vec2());
ui.scroll_to_rect(spot, Some(egui::Align::Center));
true
} else if let Some(range) = out.cursor_range {
let old_caret = src.caret;
let char_idx: usize = range.primary.index.into();
src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b);
old_caret != src.caret
} else {
false
};
if changed || caret_changed {
invalidate_undo_cache(ui.ctx(), doc_id);
}
});
}
if refused {
app.status(tl!("Markdown Source takes up to 2 MB of text"));
}
if changed {
app.session.dirty = true;
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(APPLY_EVERY_MS / 1000.0));
}
}
/// The text box of a document's pane, found the same way by Undo and Redo as by the pane.
fn box_id(document: u64) -> egui::Id {
egui::Id::unique("md_source").with(document)
}
/// The context storage key for caching the pane's undo/redo availability.
fn undo_cache_id(document: u64) -> egui::Id {
egui::Id::unique("md_source_undo_cache").with(document)
}
/// Cached undo and redo capabilities for a document's Markdown Source text box.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct CachedUndoState {
can_undo: bool,
can_redo: bool,
}
/// Invalidates the cached undo and redo capability for `document`.
pub(crate) fn invalidate_undo_cache(ctx: &egui::Context, document: u64) {
ctx.data_mut(|d| d.remove::<CachedUndoState>(undo_cache_id(document)));
}
/// Clears the stored typing history for `document`'s Markdown Source text box and invalidates the cache.
pub(crate) fn reset_undo_history(ctx: &egui::Context, document: u64) {
let id = box_id(document);
if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) {
state.clear_undoer();
state.store(ctx, id);
}
invalidate_undo_cache(ctx, document);
}
/// A text box's state for its typing history: the selection and the text.
type Typed = (CCursorRange, String);
/// The text box's typing history, with the state Undo and Redo start from.
fn history(app: &WordApp) -> Option<(egui::Context, egui::text_edit::TextEditState, egui::util::undoer::Undoer<Typed>, Typed)> {
let ctx = app.ctx.clone()?;
let src = app.session.md_source.as_ref()?;
let mut state = egui::text_edit::TextEditState::load(&ctx, box_id(app.session.document_id()))?;
if src.reset_undo {
state.clear_undoer();
}
let undoer = state.undoer();
let caret_chars = src.text[..src.text.floor_char_boundary(src.caret)].chars().count();
let range = state.cursor.char_range().unwrap_or(CCursorRange::one(CCursor::new(caret_chars)));
Some((ctx, state, undoer, (range, src.text.clone())))
}
/// Whether the pane's Undo (or Redo, with `redo`) has a step to take.
pub fn can_undo(app: &WordApp, redo: bool) -> bool {
let Some(ctx) = app.ctx.as_ref() else { return false };
let doc_id = app.session.document_id();
if app.session.md_source.as_ref().is_some_and(|s| s.reset_undo) {
reset_undo_history(ctx, doc_id);
}
let cache_key = undo_cache_id(doc_id);
if let Some(cached) = ctx.data(|d| d.get_temp::<CachedUndoState>(cache_key)) {
return if redo { cached.can_redo } else { cached.can_undo };
}
let Some((ctx, _, undoer, now)) = history(app) else { return false };
let can_u = undoer.has_undo(&now);
let can_r = undoer.has_redo(&now);
ctx.data_mut(|d| d.insert_temp(cache_key, CachedUndoState { can_undo: can_u, can_redo: can_r }));
if redo { can_r } else { can_u }
}
/// Undo (or Redo, with `redo`) typing in the pane, as Ctrl+Z (Ctrl+Y) does in it: the toolbar's
/// arrows and the Undo and Redo commands work on the text while it is open, not on the document.
pub fn undo(app: &mut WordApp, redo: bool) -> Result<serde_json::Value, String> {
let Some((ctx, mut state, mut undoer, now)) = history(app) else { return Err(tl!("Nothing to undo").into()) };
let step = if redo { undoer.redo(&now) } else { undoer.undo(&now) }.cloned();
let Some((range, text)) = step else {
return Err(if redo { tl!("Nothing to redo") } else { tl!("Nothing to undo") }.into());
};
state.cursor.set_char_range(Some(range));
state.set_undoer(undoer);
state.store(&ctx, box_id(app.session.document_id()));
if let Some(src) = app.session.md_source.as_mut() {
src.text = text;
let char_idx: usize = range.primary.index.into();
src.caret = src.text.char_indices().nth(char_idx).map_or(src.text.len(), |(b, _)| b);
}
invalidate_undo_cache(&ctx, app.session.document_id());
app.session.dirty = true;
ctx.request_repaint();
Ok(json!({"text": true}))
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::kittest::Queryable;
/// Builds a test harness for Markdown Source UI integration tests.
fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> {
egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
a,
)
}
/// A saved Markdown file, with its folder for the test to remove.
fn opened(name: &str) -> (WordApp, std::path::PathBuf) {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"wordcraft-ui-md-source-{name}-{}-{}-{}",
std::process::id(),
id,
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("note.md");
std::fs::write(&path, "# Heading\n\nSome text.\n").unwrap();
let mut a = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default());
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
(a, dir)
}
/// Verifies Markdown Source pane displays text and captures keyboard focus.
#[test]
fn the_pane_shows_the_text_and_takes_the_keyboard() {
let (mut a, dir) = opened("keys");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
assert!(!h.state().canvas.focused, "the page isn't drawn");
assert!(h.ctx.text_edit_focused(), "the text box has the keyboard");
assert!(h.get_all_by_value("# Heading\n\nSome text.\n").next().is_some(), "the Markdown is shown");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies typing in source view marks document dirty and Ctrl+S saves it.
#[test]
fn typing_is_an_unsaved_change_and_ctrl_s_saves_it() {
let (mut a, dir) = opened("save");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
h.state_mut().session.md_source.as_mut().unwrap().text.insert_str(0, "Typed. ");
h.state_mut().session.dirty = true;
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S);
h.run();
let saved = std::fs::read_to_string(dir.join("note.md")).unwrap();
assert!(saved.contains("Typed."), "{saved}");
assert!(!h.state().session.dirty);
assert!(h.state().session.md_source.is_some(), "saving keeps the pane");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies QAT undo and redo arrows work for typing in the source pane.
#[test]
fn the_arrows_undo_and_redo_typing_in_the_pane() {
let (mut a, dir) = opened("arrows");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "nothing typed yet");
h.event(egui::Event::Text("Typed. ".into()));
h.run();
let typed = h.state().session.md_source.as_ref().unwrap().text.clone();
assert!(typed.contains("Typed. "), "{typed}");
assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"));
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run();
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, "# Heading\n\nSome text.\n", "Undo takes the typing back");
assert!(h.state().session.md_source.is_some(), "and stays in the pane");
assert!(crate::chrome::qat_enabled(h.state(), "edit.redo"));
h.state_mut().run("edit.redo", json!({})).unwrap();
h.run();
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, typed, "Redo puts it back");
assert!(h.state_mut().run("edit.redo", json!({})).is_err(), "nothing more to redo");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies rewrite warning stays displayed for twenty seconds on the status bar.
#[test]
fn the_rewrite_warning_stays_longer_than_other_messages() {
let (mut a, dir) = opened("warning");
a.run("text.insert", json!({"text": "Formatted edit. "})).unwrap();
a.run("file.save", json!({})).unwrap();
let (msg, at) = a.status_msg.clone().unwrap();
assert!(msg.contains("writing the Markdown out again"), "{msg}");
let left = at + crate::app::STATUS_MS - now_ms();
assert!(left > 15_000.0, "shown for about 20 seconds, {left} ms left");
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies formatting ribbon controls are greyed out while Markdown Source pane is active.
#[test]
fn the_ribbon_greys_out_edits_while_the_pane_is_open() {
let (mut a, dir) = opened("grey");
assert_eq!(a.session.why_disabled("format.bold"), None);
a.run("view.markdownSource", json!({})).unwrap();
assert_eq!(a.session.why_disabled("format.bold"), Some("leave Markdown Source first"));
assert_eq!(a.session.why_disabled("view.markdownSource"), None);
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies that typing or pasting beyond `MAX_SOURCE_BYTES` is refused, keeping the previous text and warning.
#[test]
fn text_exceeding_max_source_bytes_is_refused_and_warns() {
let (mut a, dir) = opened("oversize");
a.run("view.markdownSource", json!({})).unwrap();
let limit = wordcraft_engine::cmd::md_source::MAX_SOURCE_BYTES;
let half = limit / 2;
let original = format!("START {} END", "é".repeat(half - 10));
let original_len = original.len();
a.session.md_source.as_mut().unwrap().text = original.clone();
let mut h = harness(a);
h.run();
h.event(egui::Event::Text("é".repeat(100)));
h.run();
let current_text = h.state().session.md_source.as_ref().unwrap().text.clone();
assert_eq!(current_text, original, "over-limit text must be refused without truncating document end");
assert_eq!(current_text.len(), original_len);
let status = h.state().status_msg.as_ref().map(|(s, _)| s.as_str());
assert_eq!(status, Some("Markdown Source takes up to 2 MB of text"));
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies that pointer movement alone does not take a snapshot or alter Markdown Source state.
#[test]
fn pointer_movement_does_not_mutate_or_affect_markdown_source() {
let (mut a, dir) = opened("pointer");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
let before_text = h.state().session.md_source.as_ref().unwrap().text.clone();
h.event(egui::Event::PointerMoved(egui::pos2(100.0, 200.0)));
h.run();
assert_eq!(h.state().session.md_source.as_ref().unwrap().text, before_text);
assert!(!h.state().session.dirty);
std::fs::remove_dir_all(&dir).unwrap();
}
/// Verifies that reopening Markdown Source after formatted edits resets undo history
/// so that previous visit edits cannot be restored by Undo.
#[test]
fn reopening_pane_resets_undo_history_from_previous_visit() {
let (mut a, dir) = opened("reopen_undo");
a.run("view.markdownSource", json!({})).unwrap();
let mut h = harness(a);
h.run();
h.event(egui::Event::Text("First visit. ".into()));
h.run();
assert!(crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo enabled after typing");
// Leave pane back to formatted view
h.state_mut().run("view.markdownSource", json!({})).unwrap();
h.run();
assert!(h.state().session.md_source.is_none(), "left source view");
// Make formatted edits in document
h.state_mut().run("text.insert", json!({"text": "Formatted edit. "})).unwrap();
h.run();
// Reopen pane
h.state_mut().run("view.markdownSource", json!({})).unwrap();
h.run();
// Undo must NOT be enabled for the Markdown pane, and edit.undo must return error
assert!(!crate::chrome::qat_enabled(h.state(), "edit.undo"), "undo must be reset on reopening pane");
assert!(h.state_mut().run("edit.undo", json!({})).is_err(), "undo command must fail when nothing typed in current visit");
std::fs::remove_dir_all(&dir).unwrap();
}
}
+3
View File
@@ -5,6 +5,7 @@ use super::{Ui, WordApp, big, big_toggle, group, json, menu_button, small, vec2}
/// and Templates groups.
pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
let v = app.session.view.clone();
let v_source = app.session.md_source.is_some();
group(ui, "Views", None, app, |ui, app| {
big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false);
big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false);
@@ -12,6 +13,8 @@ pub(super) fn view(app: &mut WordApp, ui: &mut Ui) {
stack(ui, |ui| {
small(ui, app, "outline", Some("Outline"), "Outline", crate::widgets::ButtonAction::new("view.outline", json!({}), false));
small(ui, app, "draft", Some("Draft"), "Draft", crate::widgets::ButtonAction::new("view.draft", json!({}), false));
let source = crate::widgets::ButtonAction::new("view.markdownSource", json!({}), v_source);
small(ui, app, "markdownSource", Some("Markdown"), "Markdown Source", source);
});
});
group(ui, "Immersive", None, app, |ui, app| {
+14
View File
@@ -3274,6 +3274,20 @@ fn window_close_save_writes_the_file_and_quits() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As suggests a Markdown file's own format, as it does a Word file's; anything else is .docx.
#[test]
fn save_as_suggests_a_markdown_files_own_format() {
let dir = scratch("save-name");
for (name, suggested) in [("notes.md", "notes.md"), ("notes.markdown", "notes.markdown"), ("notes.MD", "notes.MD"), ("notes.txt", "notes.docx")] {
let path = dir.join(name);
std::fs::write(&path, b"Hello\n").unwrap();
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(a.default_save_name(), suggested, "{name}");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// #2376: a text file opened, edited and saved still asked to save on close, and Save in that
/// prompt wrote the file but left the window open; only Don't Save closed it.
#[test]
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.wordcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux display server setting in File › Options › Advanced, #2184); previously trivial (dialogs are modal except Find and Replace, Symbol, Mark Index Entry, Cross-refeLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** trivial (Save As on Linux: the desktop portal's Save dialog reports the file type picked, updating the extension so saving as PDF writes a .pdf, #6180); previously trivial (Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word (#5853)); previously trivial (a press the canvas handles, such as Ctrl+click on a link, no longer also shows the link tooltip that frame, #4683); previously trivial (the window moves by its title bar behind a modal dialog, as in Word for Mac, #5124); previously trivial (pointer zoom stretches page rasters until it stops); previously trivial (dark-theme interface text on Linux and the BSDs no longer looks semi-bold); previously trivial (the document's scroll bars show wherever the pointer is, not only at the window's edge, #5746); previously trivial (Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged; previously trivial: Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (dragging an In Line with Text object shows a bold insertion cursor, as in Word, instead of a faint dashed one, #4195); previously trivial (the shapes, pictures and text boxes inside a Drawing Canvas are picked, moved and resized on their own, #2637); previously trivial (Shape Format › Size shows the interface unit and takes any typed unit, #4196); previously trivial (over a turned picture, shape or text box the resize arrows turn with it, #3932); previously trivial (the status bar's count is words by default and can be characters with or without spaces, chosen from its right-click menu or File › Options › Display and kept between runs, #2797); previously trivial (Linux dispLine truncated
> **Page Setup update:** 2026-11-07 · Page Setup number boxes keep long input inside the field, including accepted margin values after focus leaves (#5650); accepted ranges and readiness estimates are unchanged.
@@ -58,6 +58,7 @@ Document title opens Rename Document; the account badge opens Options › Genera
| Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — |
| Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — |
| Document scroll bars | ✅ always drawn beside the page in the room left for them, wherever the pointer is, with a grey thumb that stands out from its track in the light and the dark theme, and more so under the pointer and while dragged; they were invisible until the pointer reached the window's right edge (#5746, `canvas::tests::the_document_scroll_bars_show_with_the_pointer_away`); hidden in Read Mode, as in Word | always shown | 90% | — |
| Vertical scrollbar track paging | ✅ clicking the vertical scrollbar track above the elevator moves one screen up, and below it moves one screen down, as in Word (#5853, `canvas::tests::clicking_vertical_scrollbar_track_advances_one_screen`); it previously jumped to the clicked position | moves one screen | 90% | — |
| Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 |
| Interface text on screen (Linux, BSD) | Drawn by egui with Inter's ttfautohint hints, which match FreeType's light autohinting: measured against headless Firefox (Inter, 18.1 and 15.2 px at 1.45x), the same rows of pixels and weight (ink within 1 %) in the light theme. The dark theme no longer looks semi-bold: egui's dark default thickened light-on-dark text (about 21 % more ink than Firefox); it now adds a light 7 % (plain coverage, Firefox's weight, looked soft on the dark tab row; chosen side by side on a 145 % desktop). Not yet: subpixel (LCD) edges | sharp interface text | 90% | — |
| Page text on screen (Linux, BSD) | Lightly hinted as FreeType's `hintslight` draws it (Firefox and other apps there): FreeType's autohinter puts baselines, x-heights and cap heights on whole pixels, nothing moves sideways, so lines break as before. Measured against headless Firefox (Arial, 21.27 px): the same rows of pixels and weight. Not yet: subpixel (LCD) edges; the interface text (egui) is hinted separately. macOS keeps its darkened unhinted text; Windows and exports are unchanged | sharp document text | 90% | 4–8 (subpixel edges) |
@@ -127,6 +128,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | trivial | Vertical scrollbar track: clicking above the elevator moves one screen up and clicking below it moves one screen down, as in Word, instead of jumping to that position in the document (#5853) |
| 2026-11-04 | trivial | Canvas: a press the canvas handles itself (Ctrl+click on a link, switching into a header or footer, equation entry) skips the link tooltip that frame; hovering shows it again; a regression from the #4501 refactor (#4683) |
| 2026-11-07 | trivial | Page Setup also bounds the unfocused margin display after a long numeric paste; the accepted value is retained (#5650). |
| 2026-11-06 | trivial | The document's scroll bars are always drawn, in the room beside the page left for them, with a grey thumb that stands out from its track in both themes; they were invisible until the pointer reached the window's right edge, so without a mouse wheel nothing showed how to reach page 2 (#5746) |
@@ -17,6 +17,8 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: wordcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,6 +18,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: wordcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
+5 -1
View File
@@ -25,7 +25,8 @@ description: |-
# The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the
# GPU (Vulkan, EGL/GL) are loaded at runtime, so they are dependencies/recommendations here
# rather than ELF NEEDED entries. Read Aloud speaks through espeak-ng (or Speech Dispatcher), a
# program it starts, so that is a recommendation too.
# program it starts, so that is a recommendation too. Without the X11 libraries the app stays on
# native Wayland rather than starting through XWayland (see `uses_x11` in main.rs).
overrides:
deb:
depends:
@@ -37,6 +38,7 @@ overrides:
- libwayland-client0
- libx11-6
- libx11-xcb1
- libxkbcommon-x11-0
- libxcursor1
- libxi6
- libxrandr2
@@ -55,6 +57,8 @@ overrides:
recommends:
- libwayland-client
- libX11
- libX11-xcb
- libxkbcommon-x11
- libXcursor
- libXi
- libXrandr