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

This commit is contained in:
FredApps committed 2026-11-12 18:13:35 +01:00
commit 11318f9226
30 files changed
+2142 -103

No files matched your search

+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: #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 asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previoLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** 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 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 asking, #2376; previLine 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.
+4
View File
@@ -209,6 +209,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());
@@ -301,6 +303,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");
+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");
}
}
+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
@@ -5669,16 +5669,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]
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture reaches the system clipboard as PNG, with HTML where the host writes it, while a copy of only shapes stays in WordCraft, #6205); 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 EntrLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** trivial (clipboard: a copied picture reaches the system clipboard as PNG, with HTML where the host writes it, while a copy of only shapes stays in WordCraft, #6205); 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 triviaLine 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) |
@@ -128,6 +129,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | trivial | Clipboard: a copied picture is published as PNG (and HTML where the host writes it); WordCraft's own paste still keeps the original fragment, and a copy of only shapes carries no HTML or RTF (#6205). Native external-app paste remains untested. |
| 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) |