From 12a1316f109cd7b333b58603f6175d00c17583fa Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 22:23:43 +0200 Subject: [PATCH 001/126] =?UTF-8?q?View=20=E2=80=BA=20Window=20across=20Wo?= =?UTF-8?q?rdCraft=20windows:=20Switch=20Windows,=20Arrange=20All,=20Side?= =?UTF-8?q?=20by=20Side,=20Synchronous=20Scrolling=20(#322)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each desktop window is its own process with one document, so the window commands work across processes. Every window joins a small registry in the settings folder (windows/.json, mode 0600, rewritten every 30 s, removed on exit; stale and malformed entries are ignored or cleaned up) and serves a minimal authenticated loopback channel that can only focus, place and scroll the window. The full control channel stays opt-in. - engine: view.switchWindows, view.arrangeAll, view.sideBySide and view.syncScroll record what to do in Session::windows and ask the UI; disabled where there is no window host (web, CLI, MCP). - ui-egui: WindowHost service, tiling and half-screen geometry, sync-scroll plumbing in the canvas, ribbon buttons and keytips, strings in every catalog. - desktop app: registry and peer channel (4 KiB lines, constant-time key check, 2 s first-line deadline, 16 connections at most). - Parity 393/434 -> 397/434; docs updated. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 + apps/wordcraft/src/control_server.rs | 13 +- apps/wordcraft/src/main.rs | 22 +- apps/wordcraft/src/windows.rs | 456 +++++++++++++++++++++++++++ crates/engine/src/cmd/view.rs | 105 ++++++ crates/engine/src/session.rs | 5 + crates/engine/src/tests.rs | 37 +++ crates/ui-egui/src/canvas.rs | 27 +- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/et.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/i18n/pt-br.tsv | 6 + crates/ui-egui/src/i18n/ru.tsv | 6 + crates/ui-egui/src/i18n/sr-latn.tsv | 6 + crates/ui-egui/src/i18n/sr.tsv | 6 + crates/ui-egui/src/i18n/tr.tsv | 6 + crates/ui-egui/src/i18n/uk.tsv | 6 + crates/ui-egui/src/i18n/zh-hans.tsv | 6 + crates/ui-egui/src/i18n/zh-hant.tsv | 6 + crates/ui-egui/src/icons.rs | 18 +- crates/ui-egui/src/keytips.rs | 3 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/ribbon.rs | 28 ++ crates/ui-egui/src/windows.rs | 373 ++++++++++++++++++++++ docs/control-protocol.md | 22 +- docs/gaps.md | 3 +- docs/target-app-parity.md | 3 +- docs/ui-parity.md | 4 +- 29 files changed, 1178 insertions(+), 15 deletions(-) create mode 100644 apps/wordcraft/src/windows.rs create mode 100644 crates/ui-egui/src/windows.rs diff --git a/ROADMAP.md b/ROADMAP.md index c331514cf3..01e5c5644a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | View › Window across WordCraft windows (#322): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | @@ -273,6 +274,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): catalog 402/434 → 406/434 (93.5%) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/apps/wordcraft/src/control_server.rs b/apps/wordcraft/src/control_server.rs index 54828d5bdc..7ddc44deaa 100644 --- a/apps/wordcraft/src/control_server.rs +++ b/apps/wordcraft/src/control_server.rs @@ -75,7 +75,7 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<( } /// A new key: 32 random bytes from the operating system, as hex. -fn random_key() -> Result { +pub(crate) fn random_key() -> Result { let mut b = [0u8; 32]; getrandom::fill(&mut b).map_err(|e| e.to_string())?; Ok(b.iter().map(|x| format!("{x:02x}")).collect()) @@ -83,7 +83,7 @@ fn random_key() -> Result { /// Write the key file: a stale one (from a run that did not exit cleanly) is replaced; on Unix /// the new file is created with mode 0600. -fn write_key_file(p: &Path, key: &str) -> Result<(), String> { +pub(crate) fn write_key_file(p: &Path, key: &str) -> Result<(), String> { if let Some(d) = p.parent() { std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?; } @@ -98,7 +98,7 @@ fn write_key_file(p: &Path, key: &str) -> Result<(), String> { /// Compare a key with the window's in time that does not depend on where they differ. An /// empty key never matches. -fn keys_match(given: &str, key: &str) -> bool { +pub(crate) fn keys_match(given: &str, key: &str) -> bool { let (a, b) = (given.as_bytes(), key.as_bytes()); !b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } @@ -117,7 +117,7 @@ fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Durat /// The read side of a connection with an optional overall deadline: each read waits only for /// the time left, so blank lines, bad JSON or a slow drip of bytes cannot extend it. -struct Timed { +pub(crate) struct Timed { s: TcpStream, deadline: Option, } @@ -133,6 +133,11 @@ impl Read for Timed { } impl Timed { + /// Reads from `s` that all end by `deadline`. + pub(crate) fn new(s: TcpStream, deadline: Instant) -> Self { + Timed { s, deadline: Some(deadline) } + } + fn set_deadline(&mut self, d: Option) { self.deadline = d; if d.is_none() { diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 988b4f67b1..6ace1c7585 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -21,6 +21,7 @@ mod graphics; mod logging; mod print; mod region; +mod windows; use wordcraft_engine::Session; use wordcraft_ui_egui::{ @@ -29,20 +30,22 @@ use wordcraft_ui_egui::{ }; /// The app, the restored window geometry until the first frame has checked it, the control -/// server's key file (removed on exit), the file dialogs the app asked for and, on macOS, the -/// documents opened from Finder. +/// server's key file (removed on exit), the file dialogs the app asked for, this window's entry in +/// the window registry (removed on exit; View › Window, #322) and, on macOS, the documents opened +/// from Finder. struct App( WordApp, Option, Option, file_dialogs::Launcher, + Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox, ); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] - apple_events::poll(&self.4, &mut self.0, ctx); + apple_events::poll(&self.5, &mut self.0, ctx); self.0.logic(ctx); self.3.show(frame); let prev = self.0.ui.window; @@ -83,6 +86,9 @@ impl eframe::App for App { if let Some(key_file) = &self.2 { key_file.remove(); } + if let Some(entry) = &self.4 { + entry.remove(); + } } } @@ -317,7 +323,14 @@ fn main() -> eframe::Result { let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx); let mut session = Session::new(doc); session.system_region = region; - let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx)); + let mut services = services(file_dialog, &cc.egui_ctx); + // The other WordCraft windows, for View › Window (#322). + let registry = wordcraft_control_key::settings_dir().and_then(|d| windows::start(&d.join("windows"), cc.egui_ctx.clone())); + let registry = registry.map(|(host, file)| { + services.windows = Some(Box::new(host)); + file + }); + let mut app = WordApp::new(session, services); load_prefs(&mut app); load_user_dictionary(&mut app); // A region chosen in File › Options replaces the system's for the first blank @@ -352,6 +365,7 @@ fn main() -> eframe::Result { restored, key_file, dialogs, + registry, #[cfg(target_os = "macos")] apple_events.connect(&cc.egui_ctx), ))) diff --git a/apps/wordcraft/src/windows.rs b/apps/wordcraft/src/windows.rs new file mode 100644 index 0000000000..a98659c591 --- /dev/null +++ b/apps/wordcraft/src/windows.rs @@ -0,0 +1,456 @@ +//! The desktop app's side of View › Window (#322): a registry of the running WordCraft windows +//! and a small authenticated loopback channel between them. +//! +//! Each window is its own process with one document. At start it binds `127.0.0.1` on a free +//! port, picks a random id and key, and writes `/windows/.json` (mode 0600 on Unix): +//! `{"id", "port", "key", "title"}`. The file is removed when the window closes. Other windows +//! list the folder in the background: an entry whose port refuses connections, or that doesn't +//! answer and hasn't been rewritten for two minutes (each window rewrites its entry every 30 s), is +//! left over from a crash and is removed; unreadable, oversized or malformed entries are ignored. +//! +//! The channel takes one JSON line per connection, `{"key", "from", "msg"}` with `msg` a +//! [`PeerMessage`], and nothing else: it can bring the window to the front, place it and scroll +//! it in step with its Side by Side partner, never run commands or read the document. Lines are +//! at most 4 KiB, the key is compared in constant time, a connection has 2 s to send its line, +//! and at most 16 are served at once. The full control channel (`--control`) stays opt-in. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use wordcraft_engine::cmd::view::OtherWindow; +use wordcraft_ui_egui::windows::{Envelope, PeerMessage, WindowHost}; + +use crate::control_server::{Timed, keys_match, random_key, write_key_file}; + +/// Longest request or reply line, and largest registry entry read. +const MAX_LINE: usize = 4096; +/// How long a connection has to send its line. +const FIRST_LINE: Duration = Duration::from_secs(2); +/// Connect and reply timeout when talking to another window. +const IO_TIMEOUT: Duration = Duration::from_millis(500); +/// How often the list of other windows is refreshed. +const REFRESH: Duration = Duration::from_secs(1); +/// At most this many other windows are listed, and this many connections served at once. +const MAX_WINDOWS: usize = 64; +const MAX_CONNECTIONS: usize = 16; +/// Titles are cut to this many characters. +const MAX_TITLE: usize = 200; +/// Each window rewrites its entry this often… +const HEARTBEAT: Duration = Duration::from_secs(30); +/// …so an entry that doesn't answer and is older than this is left over from a crash. +const STALE: Duration = Duration::from_secs(120); + +/// A registry entry: how to reach a window. +#[derive(Clone, Debug, PartialEq)] +struct Entry { + id: u64, + port: u16, + key: String, + title: String, + /// When the file was last written (not part of it). + modified: Option, +} + +impl Entry { + fn to_json(&self) -> Value { + json!({"id": self.id, "port": self.port, "key": self.key, "title": self.title}) + } + + /// The entry in a registry file named `.json`, if it is well formed: the id matches + /// the file name, the port isn't 0, the key is 64 hex digits. The title loses control + /// characters and is cut to [`MAX_TITLE`] characters. + fn parse(stem: &str, text: &str) -> Option { + let v: Value = serde_json::from_str(text).ok()?; + let id = v.get("id")?.as_u64().filter(|id| id.to_string() == stem)?; + let port = v.get("port")?.as_u64().and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0)?; + let key = v.get("key")?.as_str().filter(|k| k.len() == 64 && k.bytes().all(|b| b.is_ascii_hexdigit()))?.to_string(); + let title = v.get("title").and_then(Value::as_str).unwrap_or("").chars().filter(|c| !c.is_control()).take(MAX_TITLE).collect(); + Some(Entry { id, port, key, title, modified: None }) + } + + fn other(&self) -> OtherWindow { + let title = if self.title.trim().is_empty() { "WordCraft".to_string() } else { self.title.clone() }; + OtherWindow { id: self.id, title } + } +} + +/// The registry file of this window; [`RegistryFile::remove`] it when the window closes. +pub struct RegistryFile { + path: PathBuf, + /// Held while the file is written; `true` once it is removed, so it isn't written again. + closed: Arc>, +} + +impl RegistryFile { + pub fn remove(&self) { + let mut closed = self.closed.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *closed = true; + let _ = std::fs::remove_file(&self.path); + } +} + +/// This window's [`WindowHost`]: the list the background thread keeps and the queue it sends. +pub struct Windows { + id: u64, + others: Arc>>, + title: Arc>, + out: Sender<(u64, PeerMessage)>, + incoming: Receiver, +} + +impl WindowHost for Windows { + fn id(&self) -> u64 { + self.id + } + fn others(&self) -> Vec { + self.others.lock().map(|o| o.clone()).unwrap_or_default() + } + fn send(&self, to: u64, msg: PeerMessage) { + let _ = self.out.send((to, msg)); + } + fn publish(&self, title: &str) { + if let Ok(mut t) = self.title.lock() + && *t != title + { + *t = title.to_string(); + } + } + fn incoming(&self) -> Vec { + self.incoming.try_iter().collect() + } +} + +/// Join the registry in `dir`: serve the channel, write this window's entry and keep the list of +/// the others in the background. `None` (View › Window stays disabled) when there is no port, +/// random key or writable folder. +pub fn start(dir: &Path, ctx: egui::Context) -> Option<(Windows, RegistryFile)> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| log::warn!("window channel: {e}")).ok()?; + let port = listener.local_addr().ok()?.port(); + let key = random_key().map_err(|e| log::warn!("window channel: {e}")).ok()?; + let id = random_id()?; + let me = Entry { id, port, key: key.clone(), title: String::new(), modified: None }; + let file = RegistryFile { path: dir.join(format!("{id}.json")), closed: Arc::new(Mutex::new(false)) }; + if let Err(e) = write_entry(dir, &me, &file.closed) { + log::warn!("window registry: {e}"); + return None; + } + let (tx, incoming) = channel(); + serve_on(listener, key, tx, ctx); + let (out, queue) = channel(); + let host = Windows { id, others: Arc::default(), title: Arc::default(), out, incoming }; + let worker = Worker { + dir: dir.to_path_buf(), + me, + closed: file.closed.clone(), + others: host.others.clone(), + title: host.title.clone(), + known: Vec::new(), + written: Instant::now(), + }; + std::thread::spawn(move || worker.run(&queue)); + Some((host, file)) +} + +/// A random window id that survives JSON numbers (below 2⁵³) and is never 0. +fn random_id() -> Option { + let mut b = [0u8; 8]; + getrandom::fill(&mut b).map_err(|e| log::warn!("window id: {e}")).ok()?; + Some((u64::from_le_bytes(b) & ((1 << 53) - 1)).max(1)) +} + +/// Write `.json` whole (a temporary file renamed over it), unless the window has closed. +fn write_entry(dir: &Path, e: &Entry, closed: &Mutex) -> Result<(), String> { + let closed = closed.lock().map_err(|_| "registry lock poisoned".to_string())?; + if *closed { + return Ok(()); + } + let tmp = dir.join(format!("{}.json.tmp", e.id)); + write_key_file(&tmp, &e.to_json().to_string())?; + std::fs::rename(&tmp, dir.join(format!("{}.json", e.id))).map_err(|err| format!("cannot write {}: {err}", dir.display())) +} + +/// The well-formed entries in `dir` other than `me`'s, by id. +fn read_entries(dir: &Path, me: u64) -> Vec { + let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut out = Vec::new(); + for f in rd.flatten().take(MAX_WINDOWS * 4) { + let path = f.path(); + let Some(stem) = path.file_name().and_then(|n| n.to_str()).and_then(|n| n.strip_suffix(".json")) else { continue }; + if stem == me.to_string() || !f.metadata().is_ok_and(|m| m.is_file() && m.len() <= MAX_LINE as u64) { + continue; + } + if let Some(e) = std::fs::read_to_string(&path).ok().and_then(|t| Entry::parse(stem, &t)) { + out.push(Entry { modified: f.metadata().and_then(|m| m.modified()).ok(), ..e }); + } + } + out.sort_by_key(|e| e.id); + out +} + +/// The entries in `dir` whose windows answer. Entries left over from a crash are removed: their +/// port refuses connections, or (Windows answers a closed port only after a few seconds) they +/// don't answer and are older than [`STALE`]. +fn live_entries(dir: &Path, me: u64) -> Vec { + let mut live = Vec::new(); + for e in read_entries(dir, me) { + match send_to(&e, me, &PeerMessage::Ping) { + Ok(()) => live.push(e), + Err(err) => { + let old = e.modified.and_then(|m| m.elapsed().ok()).is_some_and(|age| age > STALE); + if err.kind() == std::io::ErrorKind::ConnectionRefused || old { + log::info!("window registry: removing stale entry {}", e.id); + let _ = std::fs::remove_file(dir.join(format!("{}.json", e.id))); + } + } + } + if live.len() >= MAX_WINDOWS { + break; + } + } + live +} + +/// Send one message to a window and wait for its answer. +fn send_to(e: &Entry, from: u64, msg: &PeerMessage) -> std::io::Result<()> { + let mut s = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, e.port)), IO_TIMEOUT)?; + s.set_write_timeout(Some(IO_TIMEOUT))?; + let line = json!({"key": e.key, "from": from, "msg": msg}); + writeln!(s, "{line}")?; + let mut reply = Vec::new(); + BufReader::new(Timed::new(s, Instant::now() + IO_TIMEOUT)).take(MAX_LINE as u64).read_until(b'\n', &mut reply)?; + let ok = serde_json::from_slice::(&reply).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true); + if ok { Ok(()) } else { Err(std::io::Error::other("refused")) } +} + +/// The background thread: sends queued messages and refreshes the list and this window's entry. +struct Worker { + dir: PathBuf, + me: Entry, + closed: Arc>, + others: Arc>>, + title: Arc>, + /// The other windows at the last refresh. + known: Vec, + /// When this window's entry was last written. + written: Instant, +} + +impl Worker { + fn run(mut self, queue: &Receiver<(u64, PeerMessage)>) { + let mut refreshed: Option = None; + loop { + let mut batch = Vec::new(); + match queue.recv_timeout(REFRESH) { + Ok(m) => { + // Scrolling queues a message a frame: take what else arrives right away. + std::thread::sleep(Duration::from_millis(10)); + batch.push(m); + batch.extend(queue.try_iter()); + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return, + } + for (to, msg) in coalesce(batch) { + if !self.known.iter().any(|e| e.id == to) { + self.known = read_entries(&self.dir, self.me.id); + } + if let Some(e) = self.known.iter().find(|e| e.id == to) + && let Err(err) = send_to(e, self.me.id, &msg) + { + log::info!("window {to}: {err}"); + } + } + if refreshed.is_none_or(|t| t.elapsed() >= REFRESH) { + self.refresh(); + refreshed = Some(Instant::now()); + } + } + } + + fn refresh(&mut self) { + let title = self.title.lock().map(|t| t.chars().filter(|c| !c.is_control()).take(MAX_TITLE).collect::()).unwrap_or_default(); + if title != self.me.title || self.written.elapsed() >= HEARTBEAT { + self.me.title = title; + self.written = Instant::now(); + if let Err(e) = write_entry(&self.dir, &self.me, &self.closed) { + log::warn!("window registry: {e}"); + } + } + self.known = live_entries(&self.dir, self.me.id); + if let Ok(mut o) = self.others.lock() { + *o = self.known.iter().map(Entry::other).collect(); + } + } +} + +/// Queued messages in order, with runs of scrolls to the same window added up. +fn coalesce(batch: Vec<(u64, PeerMessage)>) -> Vec<(u64, PeerMessage)> { + let mut out: Vec<(u64, PeerMessage)> = Vec::new(); + for (to, msg) in batch { + if let (Some((last_to, PeerMessage::Scroll { dy: sum })), PeerMessage::Scroll { dy }) = (out.last_mut(), &msg) + && *last_to == to + { + *sum += dy; + continue; + } + out.push((to, msg)); + } + out +} + +fn serve_on(listener: TcpListener, key: String, tx: Sender, ctx: egui::Context) { + let key: Arc = key.into(); + let busy = Arc::new(AtomicUsize::new(0)); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + if busy.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + busy.fetch_sub(1, Ordering::SeqCst); + continue; + } + let (key, tx, ctx, busy) = (key.clone(), tx.clone(), ctx.clone(), busy.clone()); + std::thread::spawn(move || { + serve(stream, &key, &tx, &ctx); + busy.fetch_sub(1, Ordering::SeqCst); + }); + } + }); +} + +/// One connection: one line in, one reply out. +fn serve(stream: TcpStream, key: &str, tx: &Sender, ctx: &egui::Context) { + let Ok(read) = stream.try_clone() else { return }; + let mut out = stream; + let mut line = Vec::new(); + let mut reader = BufReader::new(Timed::new(read, Instant::now() + FIRST_LINE)); + if reader.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut line).is_err() { + return; + } + let reply = match handle(&line, key) { + Ok(Some(env)) => { + let sent = tx.send(env).is_ok(); + ctx.request_repaint(); + if sent { json!({"ok": true}) } else { json!({"ok": false, "error": "closing"}) } + } + Ok(None) => json!({"ok": true}), + Err(e) => json!({"ok": false, "error": e}), + }; + let _ = writeln!(out, "{reply}"); +} + +/// A request line: the message for the UI, `None` for a ping, or why it was refused. +fn handle(line: &[u8], key: &str) -> Result, &'static str> { + if line.len() > MAX_LINE { + return Err("line too long"); + } + let v: Value = serde_json::from_slice(line).map_err(|_| "bad JSON")?; + if !keys_match(v.get("key").and_then(Value::as_str).unwrap_or(""), key) { + return Err("unauthorized"); + } + let from = v.get("from").and_then(Value::as_u64).ok_or("missing `from`")?; + let msg = + v.get("msg").cloned().and_then(|m| serde_json::from_value::(m).ok()).and_then(PeerMessage::sanitized).ok_or("bad message")?; + Ok((msg != PeerMessage::Ping).then_some(Envelope { from, msg })) +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn temp_dir(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("wordcraft-windows-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn registry_ignores_hostile_entries_and_drops_stale_ones() { + let dir = temp_dir("registry"); + let write = |name: &str, text: &str| std::fs::write(dir.join(name), text).unwrap(); + let entry = |id: u64, port: u16| Entry { id, port, key: KEY.into(), title: "Notes\n\u{7}.docx".into(), modified: None }.to_json().to_string(); + // A window that is gone: nothing listens on its port any more, and it stopped rewriting + // its entry (Windows doesn't refuse a closed port right away). + let closed_port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port(); + write("11.json", &entry(11, closed_port)); + let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600); + std::fs::File::options().write(true).open(dir.join("11.json")).unwrap().set_modified(hour_ago).unwrap(); + // Malformed or hostile entries. + write("12.json", &entry(99, 4000)); // the id doesn't match the file name + write("13.json", r#"{"id": 13, "port": 0, "key": "0123"}"#); + write("14.json", r#"{"id": 14, "port": 70000, "key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}"#); + write("15.json", &format!(r#"{{"id": 15, "port": 4000, "key": "{}"}}"#, "z".repeat(64))); + write("16.json", "not json"); + write("17.json", &" ".repeat(MAX_LINE + 1)); + write("notes.txt", &entry(18, 4000)); + std::fs::create_dir_all(dir.join("19.json")).unwrap(); + let listed = read_entries(&dir, 1); + assert_eq!(listed.iter().map(|e| e.id).collect::>(), [11], "only the well-formed entry: {listed:?}"); + assert_eq!(listed[0].title, "Notes.docx", "control characters are dropped"); + assert!( + Entry::parse("20", &Entry { id: 20, port: 1, key: KEY.into(), title: "x".repeat(10_000), modified: None }.to_json().to_string()) + .is_some_and(|e| e.title.chars().count() == MAX_TITLE) + ); + assert!(live_entries(&dir, 1).is_empty()); + assert!(!dir.join("11.json").exists(), "the stale entry is removed"); + assert!(dir.join("16.json").exists(), "entries that aren't ours to judge are left alone"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn windows_find_and_message_each_other() { + let dir = temp_dir("peers"); + let ctx = egui::Context::default(); + let (a, a_file) = start(&dir, ctx.clone()).unwrap(); + let (b, b_file) = start(&dir, ctx).unwrap(); + a.publish("Notes.docx"); + let deadline = Instant::now() + Duration::from_secs(10); + while !b.others().iter().any(|o| o.id == a.id() && o.title == "Notes.docx") { + assert!(Instant::now() < deadline, "b never saw a: {:?}", b.others()); + std::thread::sleep(Duration::from_millis(50)); + } + b.send(a.id(), PeerMessage::Scroll { dy: 10.0 }); + b.send(a.id(), PeerMessage::Scroll { dy: 5.0 }); + b.send(a.id(), PeerMessage::Focus); + let mut got = Vec::new(); + while got.iter().all(|e: &Envelope| e.msg != PeerMessage::Focus) { + assert!(Instant::now() < deadline, "a got {got:?}"); + got.extend(a.incoming()); + std::thread::sleep(Duration::from_millis(20)); + } + let scrolled: f32 = got.iter().map(|e| if let PeerMessage::Scroll { dy } = e.msg { dy } else { 0.0 }).sum(); + assert_eq!(scrolled, 15.0); + assert!(got.iter().all(|e| e.from == b.id())); + // Without the key, or with a bad message, nothing reaches the window. + let port = read_entries(&dir, b.id()).iter().find(|e| e.id == a.id()).unwrap().port; + let wrong = Entry { id: a.id(), port, key: "f".repeat(64), title: String::new(), modified: None }; + assert!(send_to(&wrong, 1, &PeerMessage::Focus).is_err()); + assert_eq!(handle(br#"{"key": "x", "from": 1, "msg": {"type": "focus"}}"#, KEY), Err("unauthorized")); + let place = format!(r#"{{"key": "{KEY}", "from": 1, "msg": {{"type": "place", "x": 1e30, "y": 0, "width": 10, "height": 10}}}}"#); + assert_eq!(handle(place.as_bytes(), KEY), Err("bad message")); + assert_eq!(handle(&vec![b' '; MAX_LINE + 1], KEY), Err("line too long")); + std::thread::sleep(Duration::from_millis(200)); + assert!(a.incoming().is_empty()); + // A closed window's entry is gone. + a_file.remove(); + assert!(!dir.join(format!("{}.json", a.id())).exists()); + b_file.remove(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn scrolls_add_up_in_order() { + let s = |dy| PeerMessage::Scroll { dy }; + assert_eq!( + coalesce(vec![(1, s(1.0)), (1, s(2.0)), (2, s(4.0)), (1, PeerMessage::Focus), (1, s(8.0))]), + vec![(1, s(3.0)), (2, s(4.0)), (1, PeerMessage::Focus), (1, s(8.0))] + ); + } +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index da156c596b..7c75eb1507 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -1,5 +1,6 @@ //! View tab and status bar: views, zoom, show/hide, panes. +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use wordcraft_layout::{NumeralMode, ViewMode}; @@ -90,6 +91,38 @@ pub fn specs() -> Vec { }) .key("Mod+Alt+S") .pure(), + // View › Window across WordCraft windows (#322): each window is its own process; the + // desktop app performs these (see `Windows`), elsewhere they are disabled. + CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", |s, v| { + let id = pick_window(s, v)?; + s.ui_requests.push(json!({"windows": "focus", "id": id})); + Ok(json!({"window": id})) + }) + .params(r#"{"window"?: id from ui.inspect `windows.others` (optional when one other window is open)}"#) + .when(has_other_window) + .pure(), + CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| { + s.ui_requests.push(json!({"windows": "arrange"})); + Ok(json!({"windows": s.windows.others.len() + 1})) + }) + .when(windows_available) + .pure(), + CommandSpec::new("view.sideBySide", "View Side by Side", "View › Window", side_by_side) + .params(r#"{"value"?: bool, "window"?: id of the other window (optional when one other window is open)}"#) + .when(|s| match windows_available(s) { + None if s.windows.side_by_side.is_none() => has_other_window(s), + r => r, + }) + .pure(), + CommandSpec::new("view.syncScroll", "Synchronous Scrolling", "View › Window", |s, v| { + let Some(with) = s.windows.side_by_side else { return Err(CmdError::Disabled("turn on View Side by Side first".into())) }; + s.windows.sync_scroll = p::bool(v, "value").unwrap_or(!s.windows.sync_scroll); + s.ui_requests.push(json!({"windows": "syncScroll", "with": with, "value": s.windows.sync_scroll})); + Ok(json!({"value": s.windows.sync_scroll})) + }) + .params(r#"{"value"?: bool}"#) + .when(|s| if s.windows.side_by_side.is_some() { None } else { Some("turn on View Side by Side first") }) + .pure(), CommandSpec::new("view.state", "View State", "View", |s, _| serde_json::to_value(&s.view).map_err(|e| CmdError::Failed(e.to_string()))) .pure(), // Word › File › Options › Advanced › Numeral: how digits look on screen (display @@ -111,6 +144,78 @@ pub fn specs() -> Vec { ] } +/// View › Window (#322): the other WordCraft windows as the desktop app last saw them, and the +/// Side by Side pairing. The front end keeps `available` and `others` current; elsewhere (web, +/// CLI, MCP without the app) they stay empty and the window commands are disabled. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Windows { + /// The host can list and move its windows (the desktop app). + pub available: bool, + /// The other open windows, by id. + pub others: Vec, + /// View Side by Side is on, with this window. + pub side_by_side: Option, + /// Synchronous Scrolling (only while Side by Side is on). + pub sync_scroll: bool, +} + +/// Another open WordCraft window. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct OtherWindow { + pub id: u64, + pub title: String, +} + +fn windows_available(s: &Session) -> Option<&'static str> { + if s.windows.available { None } else { Some("only the desktop app has several windows") } +} + +fn has_other_window(s: &Session) -> Option<&'static str> { + windows_available(s).or(if s.windows.others.is_empty() { Some("no other WordCraft window is open") } else { None }) +} + +/// The window a command means: `window`, or the only other one. +fn pick_window(s: &Session, v: &Value) -> Result { + let others = &s.windows.others; + match p::u64(v, "window") { + Some(id) if others.iter().any(|w| w.id == id) => Ok(id), + Some(id) => Err(CmdError::Params(format!("no open window {id}"))), + None => match others.as_slice() { + [only] => Ok(only.id), + [] => Err(CmdError::Disabled("no other WordCraft window is open".into())), + several => { + let list: Vec = several.iter().map(|w| format!("{} ({})", w.id, w.title)).collect(); + Err(CmdError::Params(format!("pass `window`: one of {}", list.join(", ")))) + } + }, + } +} + +/// View Side by Side: on with a window (left this one, right that one, scrolling together), or +/// off. +fn side_by_side(s: &mut Session, v: &Value) -> CmdResult { + let on = p::bool(v, "value").unwrap_or(s.windows.side_by_side.is_none() || v.get("window").is_some()); + if !on { + if let Some(id) = s.windows.side_by_side.take() { + s.ui_requests.push(json!({"windows": "sideBySide", "off": id})); + } + s.windows.sync_scroll = false; + return Ok(json!({"value": false})); + } + let id = match (s.windows.side_by_side, v.get("window")) { + (Some(id), None) => id, + _ => pick_window(s, v)?, + }; + if let Some(old) = s.windows.side_by_side.filter(|old| *old != id) { + s.ui_requests.push(json!({"windows": "sideBySide", "off": old})); + } + s.windows.side_by_side = Some(id); + s.windows.sync_scroll = true; + s.ui_requests.push(json!({"windows": "sideBySide", "with": id})); + Ok(json!({"value": true, "window": id, "syncScroll": true})) +} + fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { if read && !s.view.read_mode { // Reading controls are session-only, and reset on each entry to the view. diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1f4734c5cc..c80f06dcc1 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -402,6 +402,8 @@ pub struct Session { pub merge: crate::cmd::mailings::MergeState, /// Requests from commands to the UI (open a dialog, scroll…), drained by the front end. pub ui_requests: Vec, + /// View › Window: the other WordCraft windows and Side by Side (#322). + pub windows: crate::cmd::view::Windows, /// Read Aloud player (Review › Speech). pub read_aloud: crate::speech::ReadAloud, /// Preferences the front end saves between runs. @@ -613,6 +615,7 @@ impl Session { editing_lang: None, read_aloud: Default::default(), password: None, + windows: Default::default(), column: None, column_mode: false, widened: None, @@ -1034,6 +1037,8 @@ impl Session { ui_requests: _, document_id: _, read_aloud: _, + // The other windows, like `view`: not part of the document or its history. + windows: _, prefs: _, system_region: _, keymap: _, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6ed7b0d2c3..d071f8e70d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5006,3 +5006,40 @@ fn insert_picture_takes_a_windows_metafile() { assert!(err.to_string().contains("WMF, EMF"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn window_commands_need_the_desktop_and_name_a_window() { + use crate::cmd::view::OtherWindow; + let mut s = s(); + // Web, CLI, MCP: one window, so View › Window is disabled. + for id in ["view.switchWindows", "view.arrangeAll", "view.sideBySide", "view.syncScroll"] { + assert!(s.run(id, &json!({})).is_err(), "{id} without a window host"); + } + s.windows.available = true; + run(&mut s, "view.arrangeAll", json!({})); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"}))); + assert!(s.run("view.switchWindows", &json!({})).is_err(), "no other window"); + s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }]; + let e = s.run("view.switchWindows", &json!({})).unwrap_err().to_string(); + assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}"); + assert!(s.run("view.switchWindows", &json!({"window": 8})).is_err(), "an unknown window"); + assert!(s.run("view.switchWindows", &json!({"window": "9"})).is_err()); + run(&mut s, "view.switchWindows", json!({"window": 9})); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "focus", "id": 9}))); + // Side by Side pairs with one window and scrolls together until turned off. + assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on"); + assert!(s.run("view.sideBySide", &json!({})).is_err(), "two others: which one?"); + let r = run(&mut s, "view.sideBySide", json!({"window": 7})); + assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true)); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7}))); + run(&mut s, "view.syncScroll", json!({})); + assert!(!s.windows.sync_scroll); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "syncScroll", "with": 7, "value": false}))); + // Another partner replaces the first; the bare button turns it off. + run(&mut s, "view.sideBySide", json!({"window": 9})); + assert_eq!(std::mem::take(&mut s.ui_requests), [json!({"windows": "sideBySide", "off": 7}), json!({"windows": "sideBySide", "with": 9})]); + run(&mut s, "view.sideBySide", json!({})); + assert_eq!((s.windows.side_by_side, s.windows.sync_scroll), (None, false)); + assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "off": 9}))); + assert!(!s.dirty, "window commands never touch the document"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8c4012d5ca..d5bcb8ea44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -106,6 +106,10 @@ pub struct CanvasState { /// The viewport (the content's visible rect on screen) at the end of last frame; the /// drag-select edge auto-scroll decides this frame's scroll from it (#688). pub(crate) viewport: Option, + /// Synchronous Scrolling (#322): document points the partner window scrolled, still to apply… + pub(crate) sync_in: f32, + /// …and this window's own scrolling since the last frame, for the partner. + pub(crate) sync_out: f32, /// Pages per row last frame; when it changes the caret's page is scrolled back into view. pub cols: usize, /// The comment whose balloon is selected (its text is editable in place, like Word's). @@ -198,6 +202,8 @@ impl Default for CanvasState { scroll_max: egui::Vec2::ZERO, scroll_request: None, viewport: None, + sync_in: 0.0, + sync_out: 0.0, cols: 1, balloon: None, balloon_focus: false, @@ -769,6 +775,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if (geo.scale - app.canvas.scale).abs() > 1e-6 { ui.ctx().request_repaint(); } + // A zoom moves the scroll offset without anyone scrolling: not passed on to a partner window. + let rescaled = (geo.scale - app.canvas.scale).abs() > 1e-4; app.canvas.scale = geo.scale; sync_fit_zoom(app, geo.scale); // Zooming or resizing reflowed the pages into a different number of columns: the caret's page @@ -876,8 +884,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if app.session.view.read_mode { scroll_area = scroll_area.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); } - if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode { - let before = app.canvas.scroll_offset; + // Synchronous Scrolling (#322): the partner window's scrolling, in document points. + let synced = std::mem::take(&mut app.canvas.sync_in) * geo.scale; + let before = app.canvas.scroll_offset; + let mut from_partner = 0.0; + if delta != egui::Vec2::ZERO || app.canvas.scroll_request.is_some() || app.session.view.read_mode || synced != 0.0 { let step = app.session.view.read_width * geo.scale + GAP; let snapped = if step.is_finite() && step > 0.0 { vec2((before.x / step).round() * step, 0.0) } else { egui::Vec2::ZERO }; let after = match app.canvas.scroll_request.take() { @@ -885,6 +896,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { None if app.session.view.read_mode => snapped.clamp(egui::Vec2::ZERO, app.canvas.scroll_max), None => clamped_scroll(before, delta, app.canvas.scroll_max), }; + let after = if synced != 0.0 { + let y = (after.y + synced).clamp(0.0, app.canvas.scroll_max.y); + from_partner = y - after.y; + vec2(after.x, y) + } else { + after + }; if after == before && wheel_delta != egui::Vec2::ZERO { app.canvas.wheel.hit_edge(); } @@ -1206,6 +1224,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); app.canvas.scroll_offset = out.state.offset; crate::ink::replay_bar(app, ui, out.inner_rect); + // This window's own scrolling (wheel, scroll bar, keys, caret), for a partner window. + let own = out.state.offset.y - before.y - from_partner; + if app.session.windows.sync_scroll && !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; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index dda9412f44..883673cca4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -49,6 +49,7 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { json!({ "ui": app.prefs(), "view": app.session.view, + "windows": app.session.windows, "dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), "window": [r.width(), r.height()], "canvasRect": app.canvas.canvas_rect.map(|c| [c.left(), c.top(), c.width(), c.height()]), diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 772227a9b0..04c152ce17 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1456,6 +1456,12 @@ Previous Field Campo anterior Selected Fields Campos seleccionados Edit Field… Editar campo… + Switch Windows Cambiar ventanas + Switch\nWindows Cambiar\nventanas + Arrange All Organizar todo + View Side by Side Ver en paralelo + Synchronous Scrolling Desplazamiento sincrónico + No other WordCraft windows are open. No hay otras ventanas de WordCraft abiertas. Formatted Con formato Deleted Eliminado Interface size: Tamaño de la interfaz: diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index dce73b871c..291a738091 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1435,6 +1435,12 @@ Trademark Sign Kaubamärgi märk Choose the sheet that holds the recipients. Valige leht, millel on adressaadid. First row of data contains column headers Andmete esimene rida sisaldab veerupäiseid + Switch Windows Vaheta akent + Switch\nWindows Vaheta\nakent + Arrange All Korralda kõik + View Side by Side Kuva kõrvuti + Synchronous Scrolling Sünkroonne kerimine + No other WordCraft windows are open. Muid WordCrafti aknaid pole avatud. Formatted Vormindatud Deleted Kustutatud Interface size: Kasutajaliidese suurus: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ad54cb9cbd..19942e15f9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1416,6 +1416,12 @@ Previous Field 前のフィールド Selected Fields 選択したフィールド Edit Field… フィールドの編集… + Switch Windows ウィンドウの切り替え + Switch\nWindows ウィンドウの\n切り替え + Arrange All 整列 + View Side by Side 並べて比較 + Synchronous Scrolling 同時にスクロール + No other WordCraft windows are open. ほかに開いている WordCraft のウィンドウはありません。 Formatted 書式変更 Deleted 削除 Interface size: 表示サイズ: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index c553d6b3ec..9090885f6d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1428,6 +1428,12 @@ Previous Field Campo anterior Selected Fields Campos selecionados Edit Field… Editar campo… + Switch Windows Alternar janelas + Switch\nWindows Alternar\njanelas + Arrange All Organizar tudo + View Side by Side Exibir lado a lado + Synchronous Scrolling Rolagem sincronizada + No other WordCraft windows are open. Nenhuma outra janela do WordCraft está aberta. Formatted Formatado Deleted Excluído Interface size: Tamanho da interface: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b038ff5faa..5c580cf49d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2139,6 +2139,12 @@ preview Title Пример Add Series Добавить ряд Edit\nData Изменить\nданные Change\nChart Type Изменить\nтип диаграммы + Switch Windows Перейти в другое окно + Switch\nWindows Перейти в\nдругое окно + Arrange All Упорядочить все + View Side by Side Рядом + Synchronous Scrolling Синхронная прокрутка + No other WordCraft windows are open. Других открытых окон WordCraft нет. Align Text Выравнивание текста Create Link Создать связь Break Link Разорвать связь diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 242cfb0222..c30702aa38 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1426,6 +1426,12 @@ Previous Field Prethodno polje Selected Fields Izabrana polja Edit Field… Uredi polje… + Switch Windows Promeni prozor + Switch\nWindows Promeni\nprozor + Arrange All Rasporedi sve + View Side by Side Prikaži uporedo + Synchronous Scrolling Sinhrono pomeranje + No other WordCraft windows are open. Nema drugih otvorenih WordCraft prozora. Formatted Formatirano Deleted Izbrisano Interface size: Veličina interfejsa: diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 085652894a..4f2fdb1298 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1430,6 +1430,12 @@ Previous Field Претходно поље Selected Fields Изабрана поља Edit Field… Уреди поље… + Switch Windows Промени прозор + Switch\nWindows Промени\nпрозор + Arrange All Распореди све + View Side by Side Прикажи упоредо + Synchronous Scrolling Синхроно померање + No other WordCraft windows are open. Нема других отворених WordCraft прозора. Formatted Форматирано Deleted Избрисано Interface size: Величина интерфејса: diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 636cbfe02e..2bdaf3cac4 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2141,6 +2141,12 @@ preview Title Örnek Add Series Seri Ekle Edit\nData Verileri\nDüzenle Change\nChart Type Grafik Türünü\nDeğiştir + Switch Windows Pencere Değiştir + Switch\nWindows Pencere\nDeğiştir + Arrange All Tümünü Düzenle + View Side by Side Yan Yana Görüntüle + Synchronous Scrolling Eşzamanlı Kaydırma + No other WordCraft windows are open. Başka açık WordCraft penceresi yok. Align Text Metni Hizala Create Link Bağlantı Oluştur Break Link Bağlantıyı Kes diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 03b83976f2..66f2857304 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1428,6 +1428,12 @@ Previous Field Попереднє поле Selected Fields Вибрані поля Edit Field… Змінити поле… + Switch Windows Перемкнути вікна + Switch\nWindows Перемкнути\nвікна + Arrange All Упорядкувати все + View Side by Side Переглянути поруч + Synchronous Scrolling Синхронне прокручування + No other WordCraft windows are open. Інших вікон WordCraft не відкрито. Formatted Відформатовано Deleted Видалено Interface size: Розмір інтерфейсу: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 94b8846fd2..1e09af2fff 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1428,6 +1428,12 @@ Previous Field 上一个域 Selected Fields 所选域 Edit Field… 编辑域… + Switch Windows 切换窗口 + Switch\nWindows 切换\n窗口 + Arrange All 全部重排 + View Side by Side 并排查看 + Synchronous Scrolling 同步滚动 + No other WordCraft windows are open. 没有其他打开的 WordCraft 窗口。 Formatted 已设置格式 Deleted 已删除 Interface size: 界面大小: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 482e7c7c91..c645f0cb52 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1417,6 +1417,12 @@ Previous Field 上一個功能變數 Selected Fields 選取的功能變數 Edit Field… 編輯功能變數… + Switch Windows 切換視窗 + Switch\nWindows 切換\n視窗 + Arrange All 全部排列 + View Side by Side 並排檢視 + Synchronous Scrolling 同步捲動 + No other WordCraft windows are open. 沒有其他開啟的 WordCraft 視窗。 Formatted 已設定格式 Deleted 已刪除 Interface size: 介面大小: diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 87ae0ebd22..cad3804a07 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1050,10 +1050,26 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) { pen.rect(3.0, 3.0, 17.0, 17.0, c); pen.line_c(&[(3.0, 10.0), (17.0, 10.0)], a); } - "sideBySide" | "syncScroll" | "switchWindows" => { + "sideBySide" => { pen.rect(2.0, 4.0, 9.5, 16.0, c); pen.rect(10.5, 4.0, 18.0, 16.0, a); } + "syncScroll" => { + // Two windows side by side, both scrolling down. + pen.rect(2.0, 3.0, 9.5, 17.0, c); + pen.rect(10.5, 3.0, 18.0, 17.0, c); + for x in [5.75, 14.25] { + pen.line_c(&[(x, 6.0), (x, 12.0)], a); + pen.fill(&[(x - 2.0, 11.0), (x + 2.0, 11.0), (x, 14.0)], a); + } + } + "switchWindows" => { + // A window behind another, with a title bar each. + pen.rect(2.5, 7.0, 13.0, 17.5, c); + pen.line_c(&[(2.5, 9.5), (13.0, 9.5)], c); + pen.rect(7.0, 2.5, 17.5, 13.0, a); + pen.line_c(&[(7.0, 5.0), (17.5, 5.0)], a); + } "macros" => { pen.rect(3.0, 3.0, 17.0, 17.0, c); pen.fill(&[(8.0, 6.5), (8.0, 13.5), (14.0, 10.0)], a); diff --git a/crates/ui-egui/src/keytips.rs b/crates/ui-egui/src/keytips.rs index c50ebd4e0e..99ce322258 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -333,6 +333,9 @@ fn controls(tab: &str) -> Vec { c("New Window", "view.newWindow"), c("Arrange All", "view.arrangeAll"), c("Split", "view.split"), + c("View Side by Side", "view.sideBySide"), + c("Synchronous Scrolling", "view.syncScroll"), + m("Switch Windows", "view.switchWindows"), c("Macros", "tools.macros"), ], "Help" => vec![d("Help", "about"), c("Community Discord", "ui.discord"), d("Commands", "commands")], diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a4afbd939d..6e232b31e0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -75,6 +75,7 @@ pub mod table_ruler; pub mod theme; pub mod widgets; pub mod window_geometry; +pub mod windows; pub mod wordart_menus; pub mod zotero; @@ -147,6 +148,8 @@ pub struct Services { /// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder /// instead. An error means nothing changed. pub make_default_app: Option Result>>, + /// Desktop: the other WordCraft windows and a channel to them, for View › Window (#322). + pub windows: Option>, } /// What [`Services::make_default_app`] did. @@ -913,6 +916,7 @@ impl WordApp { if req.get("close").is_some() { self.quit_requested = true; } + windows::request(self, req); } /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no @@ -1364,6 +1368,7 @@ impl WordApp { self.drain_control(ctx); zotero::poll(self, ctx); read_aloud::poll(self, ctx); + windows::poll(self, ctx); self.clear_stale_change_picture(); let _ = self.poll_file_dialog(); self.drain_inbox(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f558bd7ffd..ff789de65f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1672,6 +1672,21 @@ fn view(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); big(ui, app, "split", "Split", "view.split", json!({}), false); + // Across WordCraft windows (#322; `windows`): each is its own process and document. + let w = app.session.windows.clone(); + stack(ui, |ui| { + if w.side_by_side.is_none() && w.others.len() > 1 { + menu_button(ui, app, "sideBySide", Some("View Side by Side"), "View Side by Side", false, |ui, app| { + window_list(ui, app, &w.others, "view.sideBySide") + }); + } else { + small(ui, app, "sideBySide", Some("View Side by Side"), "View Side by Side", "view.sideBySide", json!({}), w.side_by_side.is_some()); + } + small(ui, app, "syncScroll", Some("Synchronous Scrolling"), "Synchronous Scrolling", "view.syncScroll", json!({}), w.sync_scroll); + }); + menu_button(ui, app, "switchWindows", Some("Switch\nWindows"), "Switch Windows", true, |ui, app| { + window_list(ui, app, &w.others, "view.switchWindows") + }); }); group(ui, "Macros", None, app, |ui, app| { big(ui, app, "macros", "Macros", "tools.macros", json!({}), false); @@ -1739,6 +1754,19 @@ fn outlining(app: &mut WordApp, ui: &mut Ui) { }); } +/// The other WordCraft windows by title; picking one runs `id` with it. +fn window_list(ui: &mut Ui, app: &mut WordApp, others: &[wordcraft_engine::cmd::view::OtherWindow], id: &str) { + if others.is_empty() { + ui.add_enabled(false, egui::Label::new(tl!("No other WordCraft windows are open."))); + } + for o in others { + if ui.add(egui::Button::new(o.title.as_str()).min_size(vec2(200.0, 0.0))).clicked() { + let _ = app.run(id, json!({"window": o.id})); + ui.close(); + } + } +} + fn help(app: &mut WordApp, ui: &mut Ui) { group(ui, "Help", None, app, |ui, app| { big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs new file mode 100644 index 0000000000..ff29549e5b --- /dev/null +++ b/crates/ui-egui/src/windows.rs @@ -0,0 +1,373 @@ +//! View › Window across WordCraft windows (#322): Switch Windows, Arrange All, View Side by Side +//! and Synchronous Scrolling. +//! +//! Every desktop window is its own process with one document. The host (the desktop app) gives +//! the UI a [`WindowHost`]: the other running windows (a registry in the settings folder) and a +//! small authenticated channel to send them a [`PeerMessage`]. The engine's `view.*` window +//! commands only decide and record what to do (`Session::windows`); [`request`] carries it out +//! and [`poll`] applies what other windows ask of this one. Without a host (web, tests) the +//! commands are disabled. + +use egui::{Rect, Vec2, pos2, vec2}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use wordcraft_engine::cmd::view::OtherWindow; + +use crate::WordApp; + +/// What one window asks of another. Coordinates and sizes are egui points. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PeerMessage { + /// Are you there? (The registry's liveness check; never reaches the UI.) + Ping, + /// Come to the front. + Focus, + /// Move and resize the window's frame to this rect. + Place { x: f32, y: f32, width: f32, height: f32 }, + /// View Side by Side with the sender turned on or off. + SideBySide { on: bool }, + /// The sender turned Synchronous Scrolling on or off. + SyncScroll { on: bool }, + /// The sender scrolled by this many document points (positive: down). + Scroll { dy: f32 }, +} + +/// Positions farther from the origin than this, and larger sizes, are refused. +const MAX_COORD: f32 = 32768.0; +/// Larger scroll steps are refused. +const MAX_SCROLL: f32 = 1.0e6; + +impl PeerMessage { + /// The message if its numbers are usable: finite, positions within ±32768 points, sizes + /// positive and at most 32768, scroll steps at most 10⁶ points. Messages come from other + /// processes, so they are checked like any input. + pub fn sanitized(self) -> Option { + let ok = match &self { + PeerMessage::Place { x, y, width, height } => { + [*x, *y].iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD) + && [*width, *height].iter().all(|c| c.is_finite() && *c >= 1.0 && *c <= MAX_COORD) + } + PeerMessage::Scroll { dy } => dy.is_finite() && dy.abs() <= MAX_SCROLL, + _ => true, + }; + ok.then_some(self) + } +} + +/// A message and the window it came from. +#[derive(Clone, Debug, PartialEq)] +pub struct Envelope { + pub from: u64, + pub msg: PeerMessage, +} + +/// The host's side of View › Window: the other windows and a channel to them. Nothing here +/// blocks the UI thread: the list is refreshed and messages are sent in the background. +pub trait WindowHost { + /// This window's id in the registry. + fn id(&self) -> u64; + /// The other open windows as last seen, in a stable order. + fn others(&self) -> Vec; + /// Send `msg` to window `to` (queued; a window that has gone away is skipped). + fn send(&self, to: u64, msg: PeerMessage); + /// This window's title, for the other windows' Switch Windows lists. + fn publish(&self, title: &str); + /// Messages other windows sent since the last call. + fn incoming(&self) -> Vec; +} + +/// Each frame: keep `Session::windows` current, publish the title and apply messages from other +/// windows. +pub fn poll(app: &mut WordApp, ctx: &egui::Context) { + let Some(host) = app.services.windows.as_ref() else { return }; + let others = host.others(); + let title = app.title_stem(); + host.publish(&title); + let incoming = host.incoming(); + let w = &mut app.session.windows; + w.available = true; + w.others = others; + if let Some(p) = w.side_by_side + && !w.others.iter().any(|o| o.id == p) + { + // The other window closed. + w.side_by_side = None; + w.sync_scroll = false; + } + for env in incoming { + apply(app, ctx, env); + } + // This window scrolled (the canvas adds it up): the partner follows. + let dy = std::mem::take(&mut app.canvas.sync_out); + if let (Some(p), true) = (app.session.windows.side_by_side, app.session.windows.sync_scroll) + && dy != 0.0 + && let Some(host) = app.services.windows.as_ref() + { + host.send(p, PeerMessage::Scroll { dy }); + } +} + +/// What another window asked of this one. +fn apply(app: &mut WordApp, ctx: &egui::Context, env: Envelope) { + let w = &mut app.session.windows; + let partner = w.side_by_side == Some(env.from); + match env.msg { + PeerMessage::Ping => {} + PeerMessage::Focus => { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + app.canvas.want_focus = true; + } + PeerMessage::Place { x, y, width, height } => place(ctx, Rect::from_min_size(pos2(x, y), vec2(width, height))), + PeerMessage::SideBySide { on: true } => { + // A new partner replaces the old one, which is told. + if let Some(old) = w.side_by_side.filter(|old| *old != env.from) + && let Some(host) = app.services.windows.as_ref() + { + host.send(old, PeerMessage::SideBySide { on: false }); + } + w.side_by_side = Some(env.from); + w.sync_scroll = true; + } + PeerMessage::SideBySide { on: false } if partner => { + w.side_by_side = None; + w.sync_scroll = false; + } + PeerMessage::SyncScroll { on } if partner => w.sync_scroll = on, + PeerMessage::Scroll { dy } if partner && w.sync_scroll => { + app.canvas.sync_in += dy; + ctx.request_repaint(); + } + _ => {} + } + ctx.request_repaint(); +} + +/// Carry out a window request a `view.*` command made (`{"windows": …}`). +pub fn request(app: &mut WordApp, req: &Value) { + let Some(what) = req.get("windows").and_then(Value::as_str) else { return }; + let (Some(host), Some(ctx)) = (app.services.windows.as_ref(), app.ctx.as_ref()) else { return }; + let id = |k: &str| req.get(k).and_then(Value::as_u64); + let area = ctx.input(|i| monitor_area(i.viewport())); + match what { + "focus" => { + if let Some(to) = id("id") { + host.send(to, PeerMessage::Focus); + } + } + "arrange" => { + let Some(area) = area else { return }; + let mut ids: Vec = app.session.windows.others.iter().map(|o| o.id).chain([host.id()]).collect(); + ids.sort_unstable(); + for (id, r) in ids.iter().zip(tile(ids.len(), area)) { + if *id == host.id() { + place(ctx, r); + } else { + host.send(*id, place_msg(r)); + } + } + } + "sideBySide" => { + if let Some(off) = id("off") { + host.send(off, PeerMessage::SideBySide { on: false }); + } + if let Some(with) = id("with") { + if let Some(area) = area { + let [left, right] = halves(area); + place(ctx, left); + host.send(with, place_msg(right)); + } + host.send(with, PeerMessage::SideBySide { on: true }); + } + } + "syncScroll" => { + if let (Some(with), Some(on)) = (id("with"), req.get("value").and_then(Value::as_bool)) { + host.send(with, PeerMessage::SyncScroll { on }); + } + } + _ => {} + } +} + +fn place_msg(r: Rect) -> PeerMessage { + PeerMessage::Place { x: r.min.x, y: r.min.y, width: r.width(), height: r.height() } +} + +/// Move this window's frame to `r`: un-maximized, its content `r` less the frame's borders, and +/// allowed below the usual minimum size so two halves fit a small screen. Platforms that don't +/// let apps place windows (Wayland) ignore the position. +fn place(ctx: &egui::Context, r: Rect) { + let border = ctx.input(|i| { + let v = i.viewport(); + match (v.outer_rect, v.inner_rect) { + (Some(o), Some(inner)) => (o.size() - inner.size()).max(Vec2::ZERO), + _ => Vec2::ZERO, + } + }); + let inner = (r.size() - border).max(vec2(200.0, 150.0)); + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(inner.min(crate::window_geometry::MIN_SIZE))); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner)); + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(r.min)); +} + +/// The monitor this window is on, in points. egui reports the monitor's size but not where it +/// is, so this assumes monitors of that size laid out in a grid from the primary monitor at the +/// origin, and picks the cell under the window's centre. +pub fn monitor_area(v: &egui::ViewportInfo) -> Option { + let size = v.monitor_size.filter(|s| s.is_finite() && s.x >= 1.0 && s.y >= 1.0)?; + let centre = v.outer_rect.or(v.inner_rect).map(|r| r.center()).filter(|c| c.is_finite()).unwrap_or(egui::Pos2::ZERO); + let cell = |c: f32, s: f32| ((c / s).floor() * s).clamp(-MAX_COORD, MAX_COORD); + Some(Rect::from_min_size(pos2(cell(centre.x, size.x), cell(centre.y, size.y)), size)) +} + +/// Arrange All: `n` windows tiled over `area`. One or two windows stack top to bottom, like +/// Word; more fill a grid of ⌈√n⌉ columns, the last row's windows sharing its full width. +pub fn tile(n: usize, area: Rect) -> Vec { + let n = n.min(64); + if n == 0 { + return Vec::new(); + } + let cols = if n <= 2 { 1 } else { (n as f32).sqrt().ceil() as usize }; + let rows = n.div_ceil(cols); + let h = area.height() / rows as f32; + (0..n) + .map(|i| { + let (row, col) = (i / cols, i % cols); + let in_row = if row + 1 == rows { n - row * cols } else { cols }; + let w = area.width() / in_row as f32; + Rect::from_min_size(pos2(area.min.x + col as f32 * w, area.min.y + row as f32 * h), vec2(w, h)) + }) + .collect() +} + +/// View Side by Side: the left and right halves of `area`. +pub fn halves(area: Rect) -> [Rect; 2] { + let mid = area.center().x; + [Rect::from_min_max(area.min, pos2(mid, area.max.y)), Rect::from_min_max(pos2(mid, area.min.y), area.max)] +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::cell::RefCell; + use std::rc::Rc; + + #[test] + fn tiles_cover_the_monitor_without_overlap() { + let area = Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 1080.0)); + assert!(tile(0, area).is_empty()); + assert_eq!(tile(1, area), vec![area]); + // Two windows stack, like Word's Arrange All. + assert_eq!( + tile(2, area), + vec![Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 540.0)), Rect::from_min_size(pos2(1920.0, 540.0), vec2(1920.0, 540.0))] + ); + for n in 3..=10 { + let tiles = tile(n, area); + assert_eq!(tiles.len(), n); + let covered: f32 = tiles.iter().map(|r| r.area()).sum(); + assert!((covered - area.area()).abs() < 1.0, "{n} windows cover the monitor"); + for (i, a) in tiles.iter().enumerate() { + assert!(area.expand(0.01).contains_rect(*a), "{n}: {a:?}"); + for b in &tiles[i + 1..] { + assert!(a.intersect(*b).area() < 0.01, "{n}: {a:?} overlaps {b:?}"); + } + } + } + assert_eq!(tile(10_000, area).len(), 64, "hostile counts are capped"); + let [l, r] = halves(area); + assert_eq!( + (l, r), + (Rect::from_min_size(pos2(1920.0, 0.0), vec2(960.0, 1080.0)), Rect::from_min_size(pos2(2880.0, 0.0), vec2(960.0, 1080.0))) + ); + // The monitor is the one under the window's centre. + let info = |x: f32| egui::ViewportInfo { + monitor_size: Some(vec2(1920.0, 1080.0)), + outer_rect: Some(Rect::from_min_size(pos2(x, 100.0), vec2(800.0, 600.0))), + ..Default::default() + }; + assert_eq!(monitor_area(&info(100.0)), Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1920.0, 1080.0)))); + assert_eq!(monitor_area(&info(2000.0)), Some(area)); + assert_eq!(monitor_area(&egui::ViewportInfo::default()), None); + assert_eq!(monitor_area(&egui::ViewportInfo { monitor_size: Some(vec2(f32::NAN, 1.0)), ..Default::default() }), None); + } + + #[test] + fn hostile_messages_are_refused() { + let place = |x: f32, w: f32| PeerMessage::Place { x, y: 0.0, width: w, height: 600.0 }.sanitized(); + assert!(place(10.0, 800.0).is_some()); + assert!(place(f32::NAN, 800.0).is_none()); + assert!(place(1e9, 800.0).is_none()); + assert!(place(0.0, -5.0).is_none()); + assert!(place(0.0, f32::INFINITY).is_none()); + assert!(PeerMessage::Scroll { dy: f32::NAN }.sanitized().is_none()); + assert!(PeerMessage::Scroll { dy: 1e30 }.sanitized().is_none()); + assert_eq!(PeerMessage::Scroll { dy: -12.5 }.sanitized(), Some(PeerMessage::Scroll { dy: -12.5 })); + let parsed: PeerMessage = serde_json::from_value(json!({"type": "sideBySide", "on": true})).unwrap(); + assert_eq!(parsed, PeerMessage::SideBySide { on: true }); + assert!(serde_json::from_value::(json!({"type": "engine.execute"})).is_err()); + } + + /// A host that records what was sent and hands out queued messages. + #[derive(Clone, Default)] + struct Fake { + sent: Rc>>, + inbox: Rc>>, + } + + impl WindowHost for Fake { + fn id(&self) -> u64 { + 1 + } + fn others(&self) -> Vec { + vec![OtherWindow { id: 2, title: "Notes".into() }, OtherWindow { id: 3, title: "Draft".into() }] + } + fn send(&self, to: u64, msg: PeerMessage) { + self.sent.borrow_mut().push((to, msg)); + } + fn publish(&self, _title: &str) {} + fn incoming(&self) -> Vec { + self.inbox.borrow_mut().drain(..).collect() + } + } + + #[test] + fn side_by_side_scrolls_with_its_partner_only() { + let fake = Fake::default(); + let services = crate::Services { windows: Some(Box::new(fake.clone())), ..Default::default() }; + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + let ctx = egui::Context::default(); + app.ctx = Some(ctx.clone()); + poll(&mut app, &ctx); + assert!(app.session.windows.available); + assert_eq!(app.session.windows.others.len(), 2); + app.run("view.sideBySide", json!({"window": 3})).unwrap(); + // No monitor size headless: nothing is placed, but the partner hears about it. + assert_eq!(fake.sent.borrow_mut().drain(..).collect::>(), [(3, PeerMessage::SideBySide { on: true })]); + app.run("view.switchWindows", json!({"window": 2})).unwrap(); + assert_eq!(fake.sent.borrow_mut().pop(), Some((2, PeerMessage::Focus))); + // Scrolls from the partner move this window; anyone else's are ignored. + fake.inbox + .borrow_mut() + .extend([Envelope { from: 3, msg: PeerMessage::Scroll { dy: 40.0 } }, Envelope { from: 2, msg: PeerMessage::Scroll { dy: 1000.0 } }]); + poll(&mut app, &ctx); + assert_eq!(app.canvas.sync_in, 40.0); + // This window's own scrolling goes to the partner. + app.canvas.sync_out = -15.0; + poll(&mut app, &ctx); + assert_eq!(fake.sent.borrow_mut().pop(), Some((3, PeerMessage::Scroll { dy: -15.0 }))); + // Turned off on the partner's side: no more syncing. + fake.inbox.borrow_mut().push(Envelope { from: 3, msg: PeerMessage::SideBySide { on: false } }); + poll(&mut app, &ctx); + assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (None, false)); + app.canvas.sync_out = 5.0; + poll(&mut app, &ctx); + assert!(fake.sent.borrow().is_empty()); + assert!(app.run("view.syncScroll", json!({})).is_err()); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4e71ecb54a..f344a49939 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,6 +46,26 @@ any other host it sends no key, says so, and the app refuses its requests: to dr another machine, forward its port to `127.0.0.1` (for example `ssh -L 7981:127.0.0.1:7981 host`) and set `WORDCRAFT_CONTROL_KEY` to that app's key. +## Window channel (View › Window) + +Separately from the control channel (which stays off unless `--control` is given), every desktop +window joins a small registry so View › Window works across windows (#322): Switch Windows, +Arrange All, View Side by Side and Synchronous Scrolling. Each window is its own process with one +document. It binds a free loopback port, writes `/windows/.json` (mode 0600 on Unix: +`{"id", "port", "key", "title"}`, a random id and 256-bit key), rewrites it every 30 s and +removes it on exit. Entries whose port refuses connections, or that don't answer and are over two +minutes old, are left from a crash and removed; malformed ones are ignored. + +That port takes one line per connection, `{"key": "…", "from": id, "msg": {"type": …}}`, where +`type` is `ping`, `focus`, `place` (`x`, `y`, `width`, `height` in points), `sideBySide` / +`syncScroll` (`on`) or `scroll` (`dy` in document points), and answers `{"ok": true}` or an +error. Nothing else: it can't run commands or read the document. Lines over 4 KiB, a wrong key, +non-finite or out-of-range numbers and connections silent for 2 s are refused; at most 16 +connections are served at once. The commands are `view.switchWindows {window?}`, +`view.arrangeAll`, `view.sideBySide {value?, window?}` and `view.syncScroll {value?}`; the window +ids and titles are in `ui.inspect` → `windows.others`. Wayland doesn't let apps move windows, so +there Arrange All and Side by Side only resize, and Switch Windows may only flash the window. + ## Methods | Method | Params | Result | @@ -54,7 +74,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"|"on"|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | -| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | +| `ui.inspect` | — | UI state, view, other windows (`windows`), dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | | `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates | | `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page | | `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag | diff --git a/docs/gaps.md b/docs/gaps.md index 04a985cc91..04ee14d3c4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -41,7 +41,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | | 26 | **Minor formats**: MHT, Works/WordPerfect, saving Word 2003 XML | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) | Rare | 15–28 | [file-format-parity.md](file-format-parity.md) | -| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | +| 27 | **View windows**: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling landed across WordCraft windows (#322). Left: New Window (a second window on the same document) and Split are placeholders; Wayland doesn't let apps place or raise windows | | [parity-checklist.md](parity-checklist.md) (View 29/29 ids) | Comparing documents | 2–4 | [ui-parity.md](ui-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -73,6 +73,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #27: Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling landed (#322); New Window and Split left | | 2026-10-11 | trivial | Table column and row markers on the rulers landed (#700): the ruler's column markers are off the UI/UX gaps | | 2026-10-11 | trivial | UI/UX gaps: the table context menu landed, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); objects' menus and Border Styles remain | | 2026-10-11 | trivial | Tables: ordinary drags, Shift+arrows and Shift+click over cells select a rectangle of whole cells (into or out of a table, whole rows); removed from the UI/UX gaps | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2ff414d687..68d9a09f77 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -73,7 +73,7 @@ full parity for the area. | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | | Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts: Insert › Chart makes column, bar (clustered and stacked), line, area, pie, doughnut and scatter charts with Chart Design (change type, Edit Data grid, title, legend, data labels) and saves them as real chart parts (#409); charts from Word are drawn (#292) and kept on save (#319) but stay read-only; SmartArt is drawn and kept (#292, #319); Insert › SmartArt (#497) creates and edits list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics with our own layouts, a Text Pane and the SmartArt Design tab, saved as diagram parts with the drawing; SmartArt from other programs stays read-only. | -| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Missing: View Side by Side, Synchronous Scrolling, Arrange All, Switch Windows, Master Document, Immersive Reader depth. | +| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling across WordCraft windows (#322). Missing: New Window on the same document, Split, Master Document, Immersive Reader depth. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | | East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Vertical text in sections, text boxes and cells with upright ideographs (#491; no `vert` glyph alternates). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). No `w:eastAsianLayout`, enclose characters, Asian line-break options. | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): catalog 402/434 → 406/434 (93.5%); View row text only, percentages unchanged | | 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: column and row markers on the rulers (#700); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: the table context menu, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); Tables row updated, no percentage change | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 58f85401ae..20316f11d4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -6,7 +6,7 @@ How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mo Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our own (`AGENTS.md`: no Microsoft iconography, ever). -**Dimension: ~65% (estimated), 61–94 h to full** (the sum of the rows below; dialog depth is counted here, not under features). +**Dimension: ~65% (estimated), 63–98 h to full** (the sum of the rows below; dialog depth is counted here, not under features). | Area | WordCraft | Word | Parity | Hours | |---|---|---|---|---| @@ -30,6 +30,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251), still one per row after One Page or Page Width (#768) | ✅ | ✅ | 85% | — | | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 | | Zoom: Ctrl+scroll, pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | +| Windows (View › Window): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) across WordCraft windows, one process each; New Window and Split are placeholders; Wayland ignores placement and may only flash for focus | partial | ✅ | 60% | 2–4 | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | Reading Mode | Existing Read Mode ribbon button opens a read-only, viewport-column layout with animated horizontal paging, transient width/text/spacing controls, heading navigation (Navigation pane) and resize anchoring; print/export layout unchanged | reading-optimized view and navigation | 75% | 1–2 | | Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ (the language is the caret's; a click sets the proofing language) | ✅ | 85% | — | @@ -108,6 +109,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | | 2026-10-11 | trivial | Font list ends with Add Font… (TTF, OTF, TTC, WOFF, WOFF2; also by dropping the file); added fonts are listed at once and kept between runs (#582) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) | +| 2026-10-11 | trivial | View › Window across WordCraft windows: Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) | | 2026-10-11 | trivial | Templates and Add-ins (template path, Attach…, automatically update document styles) and Organizer (copy styles from another file, delete, rename) dialogs (#377); styles update from the template only when the user asks (OK with the box ticked, or Update Styles), never on open; the three Modal dialogs rows merged into one | | 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu | | 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) | From 0164ac86faad46a05bb2022e08b2589559e365c0 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 19:28:33 +0200 Subject: [PATCH 002/126] #322: translations for the other locales, catalog count 491/502, parity checklist after rebase Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 4 ++-- crates/ui-egui/src/i18n/bg.tsv | 6 ++++++ crates/ui-egui/src/i18n/cs.tsv | 6 ++++++ crates/ui-egui/src/i18n/de.tsv | 6 ++++++ crates/ui-egui/src/i18n/fr.tsv | 6 ++++++ crates/ui-egui/src/i18n/hu.tsv | 6 ++++++ crates/ui-egui/src/i18n/it.tsv | 6 ++++++ crates/ui-egui/src/i18n/pl.tsv | 6 ++++++ crates/ui-egui/src/ribbon.rs | 4 ++-- docs/parity-checklist.md | 11 ++--------- docs/target-app-parity.md | 4 ++-- 11 files changed, 50 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 01e5c5644a..2c1d22cf6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ agent control. This page is the summary; the assessment is | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | +| Ribbon/menu catalog coverage | **491 / 502 (97.8%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) | | **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated | | **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions | | **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) | @@ -274,7 +274,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| -| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): catalog 402/434 → 406/434 (93.5%) | +| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): catalog 487/502 → 491/502 (97.8%) | | 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) | | 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) | | 2026-10-11 | trivial | Progress log: table markers on the rulers (#700) | diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index cccd22fc58..5417233642 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2464,3 +2464,9 @@ Table Properties… Свойства на таблица… Search Търсене Title Bar Заглавна лента + Switch Windows Превключване на прозорци + Switch\nWindows Превключване\nна прозорци + Arrange All Подреди всички + View Side by Side Преглед един до друг + Synchronous Scrolling Синхронно превъртане + No other WordCraft windows are open. Няма други отворени прозорци на WordCraft. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 51b9962ca3..d07c68f40f 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2080,3 +2080,9 @@ Automatic ({region}) Automaticky ({region}) Search Hledat Title Bar Záhlaví okna + Switch Windows Přepnout okna + Switch\nWindows Přepnout\nokna + Arrange All Uspořádat vše + View Side by Side Zobrazit vedle sebe + Synchronous Scrolling Synchronní posouvání + No other WordCraft windows are open. Nejsou otevřena žádná další okna WordCraft. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4d97ea4b8f..6c852ea5ff 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2074,3 +2074,9 @@ Automatic ({region}) Automatisch ({region}) Search Suchen Title Bar Titelleiste + Switch Windows Fenster wechseln + Switch\nWindows Fenster\nwechseln + Arrange All Alle anordnen + View Side by Side Nebeneinander anzeigen + Synchronous Scrolling Synchrones Scrollen + No other WordCraft windows are open. Es sind keine weiteren WordCraft-Fenster geöffnet. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 2717aff6f1..2a4d37fd67 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2083,3 +2083,9 @@ Table Properties… Propriétés du tableau… Search Rechercher Title Bar Barre de titre + Switch Windows Changer de fenêtre + Switch\nWindows Changer de\nfenêtre + Arrange All Réorganiser tout + View Side by Side Afficher côte à côte + Synchronous Scrolling Défilement synchrone + No other WordCraft windows are open. Aucune autre fenêtre WordCraft n'est ouverte. diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 91429c6599..46e07f861f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2473,3 +2473,9 @@ Table Properties… Táblázat tulajdonságai… Search Keresés Title Bar Címsor + Switch Windows Ablakváltás + Switch\nWindows Ablak-\nváltás + Arrange All Összes rendezése + View Side by Side Egymás melletti nézet + Synchronous Scrolling Szinkron görgetés + No other WordCraft windows are open. Nincs másik megnyitott WordCraft-ablak. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index cb3da2b440..5bdfcdfa62 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2459,3 +2459,9 @@ preview Title Esempio Automatic ({region}) Automatico ({region}) Search Cerca Title Bar Barra del titolo + Switch Windows Cambia finestra + Switch\nWindows Cambia\nfinestra + Arrange All Disponi tutto + View Side by Side Visualizza affiancate + Synchronous Scrolling Scorrimento sincrono + No other WordCraft windows are open. Nessun'altra finestra di WordCraft è aperta. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 589aaa9df9..b4c21e1275 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2468,3 +2468,9 @@ preview Title Tytuł Typing Pisanie Search Wyszukaj Title Bar Pasek tytułu + Switch Windows Przełącz okna + Switch\nWindows Przełącz\nokna + Arrange All Rozmieść wszystko + View Side by Side Wyświetl obok siebie + Synchronous Scrolling Przewijanie synchroniczne + No other WordCraft windows are open. Brak innych otwartych okien WordCraft. diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index ff789de65f..61a5b8b929 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1676,7 +1676,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { let w = app.session.windows.clone(); stack(ui, |ui| { if w.side_by_side.is_none() && w.others.len() > 1 { - menu_button(ui, app, "sideBySide", Some("View Side by Side"), "View Side by Side", false, |ui, app| { + menu_button(ui, app, "sideBySide", Some("View Side by Side"), "View Side by Side", false, "view.sideBySide", |ui, app| { window_list(ui, app, &w.others, "view.sideBySide") }); } else { @@ -1684,7 +1684,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { } small(ui, app, "syncScroll", Some("Synchronous Scrolling"), "Synchronous Scrolling", "view.syncScroll", json!({}), w.sync_scroll); }); - menu_button(ui, app, "switchWindows", Some("Switch\nWindows"), "Switch Windows", true, |ui, app| { + menu_button(ui, app, "switchWindows", Some("Switch\nWindows"), "Switch Windows", true, "view.switchWindows", |ui, app| { window_list(ui, app, &w.others, "view.switchWindows") }); }); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0f22c6d717..b088aea03e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**487 of 502 catalog features have commands (97.0%).** +**491 of 502 catalog features have commands (97.8%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | References | 25 | 25 | | Mailings | 17 | 17 | | Review | 29 | 31 | -| View | 25 | 29 | +| View | 29 | 29 | | Developer | 11 | 11 | | Table Design | 8 | 8 | | Table Layout | 33 | 33 | @@ -55,13 +55,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Language › Translate (`review.translate`) - Protect › Block Authors (`review.blockAuthors`) -### View - -- Window › Arrange All (`view.arrangeAll`) -- Window › View Side by Side (`view.sideBySide`) -- Window › Synchronous Scrolling (`view.syncScroll`) -- Window › Switch Windows (`view.switchWindows`) - ### Equation - Tools › Ink Equation (`insert.inkEquation`) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 68d9a09f77..a0608b1c0c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **491 / 502 (97.8%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -251,7 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| -| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): catalog 402/434 → 406/434 (93.5%); View row text only, percentages unchanged | +| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): catalog 487/502 → 491/502 (97.8%); View row text only, percentages unchanged | | 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: column and row markers on the rulers (#700); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: the table context menu, its items following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); Tables row updated, no percentage change | From f0c1b64bc8569a3f6cc61035a5473b86b0c1ae87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:00:56 +0200 Subject: [PATCH 003/126] RTF: footnotes, endnotes and comments are read and written (#1053) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/formats/src/rtf.rs | 587 +++++++++++++++++++++++++++++++++++- crates/formats/src/tests.rs | 111 +++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 4 +- 5 files changed, 689 insertions(+), 19 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c331514cf3..42981f4ba3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-12 | RTF reads and writes footnotes, endnotes and comments (author, initials, date, commented range), so they survive a round trip through RTF (#1053) | | 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word | | 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them | | 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started | diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 31320221fe..c38bdf9c34 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -4,20 +4,27 @@ //! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for //! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page //! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`), -//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`) and tables -//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading). +//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables +//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and +//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` … +//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}` +//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`). //! -//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations, headers, -//! footers, notes and objects are skipped), `\par \line \tab \page`, `\uN` with `\ucN` +//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects +//! are skipped; headers, footers, notes and comments are read into stories of their own), `\par \line \tab \page`, `\uN` with `\ucN` //! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`) or the //! current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek…; Windows-1252 when //! none is named), the formatting words above, stylesheet-based //! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and -//! tables. Group nesting and picture sizes are capped. +//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date, +//! text, the commented range). Group nesting, picture sizes and the number of notes and comments +//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside +//! headers, footers and other notes, tables inside note and comment texts (flattened). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; -use wordcraft_doc::{Align, Document, Rgb}; +use wordcraft_doc::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb}; use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, make_img}; @@ -25,6 +32,15 @@ use crate::model::{self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo const MAX_GROUPS: usize = 512; /// Largest picture we decode (bytes). const MAX_PICT: usize = 64 << 20; +/// Most notes, and most comments, one file may bring in. +const MAX_NOTES: usize = 5_000; +/// Most blocks kept of one note's or comment's text. +const MAX_NOTE_BLOCKS: usize = 500; +/// Brackets of a place marker in text: a note reference or a comment range end travels through the +/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the +/// writer keep out of real text) and becomes an inline object once the document exists. +const MARK_OPEN: char = '\u{F8F0}'; +const MARK_CLOSE: char = '\u{F8F1}'; // --------------------------------------------------------------------------------------------- // Export @@ -83,7 +99,30 @@ impl Tables { } } +/// What a place marker in the exported text stands for (see [`MARK_OPEN`]). +enum Mark { + Note { + endnote: bool, + blocks: Vec, + }, + RangeStart(u32), + /// The end of a comment's range; the first end of each comment also carries its annotation. + RangeEnd { + id: u32, + ann: Option, + }, + Done, +} + +struct Annotation { + author: String, + initials: String, + date: String, + blocks: Vec, +} + struct Writer { + marks: Vec, t: Tables, /// List definitions: per list, the ordered flag per level. lists: Vec<[bool; 9]>, @@ -102,7 +141,68 @@ fn style_num(k: Kind) -> Option { } impl Writer { + /// A run of text; place markers in it become notes and comment ranges. fn run(&mut self, t: &str, f: &Fmt, out: &mut String) { + if !t.contains(MARK_OPEN) { + return self.run_plain(t, f, out); + } + let mut rest = t; + while !rest.is_empty() { + let Some((pre, after)) = rest.split_once(MARK_OPEN) else { + self.run_plain(rest, f, out); + break; + }; + if !pre.is_empty() { + self.run_plain(pre, f, out); + } + let Some((tok, tail)) = after.split_once(MARK_CLOSE) else { + self.run_plain(after, f, out); + break; + }; + self.mark(tok, out); + rest = tail; + } + } + + /// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`. + fn mark(&mut self, tok: &str, out: &mut String) { + if tok == "S" { + out.push_str("{\\super\\chftn}"); + return; + } + let Some(m) = tok.parse::().ok().and_then(|i| self.marks.get_mut(i)) else { return }; + match std::mem::replace(m, Mark::Done) { + Mark::Note { endnote, blocks } => { + out.push_str("{\\super\\chftn}{\\footnote"); + if endnote { + out.push_str("\\ftnalt"); + } + out.push(' '); + self.blocks(&blocks, false, "\\par", out, 0); + out.push('}'); + } + Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")), + Mark::RangeEnd { id, ann } => { + out.push_str(&format!("{{\\*\\atrfend {id}}}")); + if let Some(a) = ann { + out.push_str(&format!( + "{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}", + esc(&a.initials), + esc(&a.author) + )); + if let Some(d) = rtf_date(&a.date) { + out.push_str(&format!("{{\\*\\atndate {d}}}")); + } + out.push('\n'); + self.blocks(&a.blocks, false, "\\par", out, 0); + out.push('}'); + } + } + Mark::Done => {} + } + } + + fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) { let mut ctl = String::new(); if f.bold { ctl.push_str("\\b"); @@ -388,6 +488,11 @@ fn level_def(ordered: bool, l: usize) -> String { } } +/// An ISO 8601 date-time from year, month, day, hour and minute. +fn iso_date((y, mo, d, h, mi): (u32, u32, u32, u32, u32)) -> String { + format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59)) +} + fn rtf_date(s: &str) -> Option { let num = |a: usize, b: usize| s.get(a..b).and_then(|x| x.parse::().ok()); let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); @@ -395,11 +500,131 @@ fn rtf_date(s: &str) -> Option { Some(format!("\\yr{y}\\mo{mo}\\dy{d}\\hr{h}\\min{mi}")) } +fn has_marks(bl: &Blocks, depth: usize) -> bool { + bl.iter().any(|b| match &**b { + Block::Para(p) => { + p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. })) + } + Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))), + }) +} + +/// A copy of `doc` whose note references and comment range ends are place markers in the text +/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither. +fn mark_doc(doc: &Document) -> Option<(Document, Vec)> { + if !has_marks(&doc.body, 0) { + return None; + } + let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() }; + let body = m.blocks(&doc.body, None, 0); + let mut d = doc.clone(); + d.body = body; + Some((d, m.marks)) +} + +/// The story id standing for "inside a note or comment": no nested notes, no comment ranges. +const IN_STORY: u32 = u32::MAX; + +struct Marker<'a> { + doc: &'a Document, + marks: Vec, + annotated: HashSet, +} + +impl Marker<'_> { + fn token(&mut self, m: Mark) -> String { + self.marks.push(m); + self.marks.len().saturating_sub(1).to_string() + } + + /// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs. + fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec) { + for b in bl.iter().take(MAX_NOTE_BLOCKS) { + match &**b { + Block::Para(p) => { + let q = self.para(p, Some(note)); + out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para)); + } + Block::Table(t) if depth < 4 => { + for c in t.rows.iter().flat_map(|r| r.cells.iter()) { + self.story(&c.blocks, note, depth + 1, out); + } + } + Block::Table(_) => {} + } + } + } + + fn blocks(&mut self, bl: &Blocks, note: Option, depth: usize) -> Blocks { + bl.iter() + .map(|b| match &**b { + Block::Para(p) if p.objects.is_empty() => b.clone(), + Block::Para(p) => Arc::new(Block::Para(self.para(p, note))), + Block::Table(t) if depth < 8 => { + let mut t = t.clone(); + for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + c.blocks = self.blocks(&c.blocks, note, depth + 1); + } + Arc::new(Block::Table(t)) + } + Block::Table(_) => b.clone(), + }) + .collect() + } + + /// `p` with its note references and comment range ends replaced by place markers. `note` is + /// the note being written, when `p` is part of one. + fn para(&mut self, p: &Paragraph, note: Option) -> Paragraph { + let mut q = p.clone(); + for off in p.object_offsets().into_iter().rev() { + let Some(obj) = q.object_at(off).cloned() else { continue }; + let tok = match obj { + InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")), + InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()), + InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| { + let mut blocks = Vec::new(); + self.story(&part.blocks, id, 0, &mut blocks); + self.token(Mark::Note { endnote: kind == wordcraft_doc::para::NoteKind::Endnote, blocks }) + }), + InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))), + InlineObject::CommentEnd { id } if note.is_none() => { + let ann = if self.annotated.insert(id) { self.annotation(id) } else { None }; + Some(self.token(Mark::RangeEnd { id, ann })) + } + _ => None, + }; + let Some(tok) = tok else { continue }; + let end = off + wordcraft_doc::para::OBJ.len_utf8(); + let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") }; + if q.delete(off, end).is_err() { + continue; + } + if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() { + continue; + } + } + q + } + + fn annotation(&mut self, id: u32) -> Option { + let c = self.doc.comments.get(&id)?; + let part = self.doc.parts.get(&c.part)?; + let mut blocks = Vec::new(); + self.story(&part.blocks, IN_STORY, 0, &mut blocks); + Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks }) + } +} + /// RTF text of a document. pub fn export(doc: &Document) -> String { + let (marked, marks) = match mark_doc(doc) { + Some((d, m)) => (Some(d), m), + None => (None, Vec::new()), + }; + let doc = marked.as_ref().unwrap_or(doc); let flow = model::from_doc(doc); let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| wordcraft_doc::styles::BODY_FONT.to_string()); - let mut w = Writer { t: Tables { fonts: vec![base_font], colors: Vec::new() }, lists: Vec::new() }; + let mut w = Writer { marks, t: Tables { fonts: vec![base_font], colors: Vec::new() }, lists: Vec::new() }; let mut body = String::new(); w.blocks(&flow.blocks, false, "\\par", &mut body, 0); export_with(&flow.meta, &w, &body, &doc.last_section) @@ -480,6 +705,32 @@ enum Dest { FldInst, Pict, Bookmark, + /// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`, + /// 5 `\atndate`/`\atntime`, 6 `\atnref`. + Atn(u8), +} + +/// A footnote, endnote or comment group being read into an output of its own. +struct Sub { + depth: usize, + comment: bool, + endnote: bool, + saved: Box, +} + +/// A note read: its kind and text. +struct NoteData { + endnote: bool, + blocks: Vec, +} + +/// A comment read: the id its range markers carry, who wrote it and when, and its text. +struct CommentData { + rtf_id: i32, + author: String, + initials: String, + date: String, + blocks: Vec, } /// Which header or footer a `\header…`/`\footer…` group is: footer or header, and the pages @@ -708,6 +959,22 @@ struct Reader { in_creatim: bool, rule_pending: bool, info_depth: Option, + // Notes and comments (only when the caller wants them). + marks_on: bool, + sub: Option, + notes: Vec, + comments: Vec, + atn_text: String, + atn_initials: String, + atn_author: String, + atn_date: String, + atn_parts: Option<(u32, u32, u32, u32, u32)>, + atn_ref: Option, + last_end: Option, + range_starts: HashSet, + range_ends: HashSet, + claimed: HashSet, + synth: i32, } impl Reader { @@ -769,6 +1036,97 @@ impl Reader { } } + /// A footnote, endnote or comment group starts: read it into an output of its own. + fn start_sub(&mut self, comment: bool) { + if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES { + self.st.dest = Dest::Skip; + return; + } + let saved = self.swap_output(Output::default()); + self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) }); + self.st.intbl = false; + } + + /// A place marker as text in the current paragraph. + fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) { + self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt); + self.para_has = true; + } + + /// The footnote or comment group closed: keep its text and leave a marker in the body. + fn end_sub(&mut self) { + let Some(sub) = self.sub.take() else { return }; + if self.para_has || !self.para.inlines.is_empty() { + self.end_para(false); + } + self.close_table(); + let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default(); + let mut inner = self.swap_output(*sub.saved); + inner.body.truncate(MAX_NOTE_BLOCKS); + if !sub.comment { + self.push_mark('N', self.notes.len() as i64, fmt); + self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body }); + return; + } + let want = self.atn_ref.take().or(self.last_end.take()); + let id = match want { + Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i, + _ => { + // No range of its own: the comment sits at this point. + self.synth = self.synth.saturating_sub(1); + let id = self.synth; + self.push_mark('B', i64::from(id), fmt.clone()); + self.push_mark('E', i64::from(id), fmt); + id + } + }; + let date = + if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() }; + self.atn_parts = None; + self.comments.push(CommentData { + rtf_id: id, + author: std::mem::take(&mut self.atn_author), + initials: std::mem::take(&mut self.atn_initials), + date, + blocks: inner.body, + }); + } + + /// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…). + fn commit_atn(&mut self, kind: u8) { + let text = std::mem::take(&mut self.atn_text); + let text = text.trim(); + let id = text.parse::().ok(); + let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default(); + match kind { + 1 => { + if let Some(id) = id { + self.range_starts.insert(id); + self.push_mark('B', i64::from(id), fmt); + } + } + 2 => { + if let Some(id) = id { + self.range_ends.insert(id); + self.last_end = Some(id); + self.push_mark('E', i64::from(id), fmt); + } + } + 3 => self.atn_initials = text.to_string(), + 4 => self.atn_author = text.to_string(), + 5 => { + // Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number. + if let Some(parts) = self.atn_parts.take() { + self.atn_date = iso_date(parts); + } else if let Some(n) = text.parse::().ok().filter(|n| *n != 0) { + self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F)); + } + } + 6 => self.atn_ref = id, + _ => {} + } + } + fn open(&mut self) { self.skip_chars = 0; if self.stack.len() >= MAX_GROUPS { @@ -784,6 +1142,9 @@ impl Reader { self.overflow -= 1; return; } + if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) { + self.end_sub(); + } let closing = self.st.dest; // Destination-level commits. match closing { @@ -801,6 +1162,7 @@ impl Reader { } self.in_creatim = false; } + Dest::Atn(k) => self.commit_atn(k), _ => {} } let Some(prev) = self.stack.pop() else { return }; @@ -873,6 +1235,11 @@ impl Reader { return; } let f = self.st.fmt.clone(); + if s.contains([MARK_OPEN, MARK_CLOSE]) { + self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f); + self.para_has = true; + return; + } self.para.push_text(s, &f); self.para_has = true; } @@ -906,6 +1273,11 @@ impl Reader { } } Dest::Info(_) => self.info_text.push_str(s), + Dest::Atn(_) => { + if self.atn_text.len() < 256 { + self.atn_text.push_str(s); + } + } Dest::FldInst => { if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i)) && f.len() < 4096 @@ -1147,6 +1519,26 @@ impl Reader { "shppict" => return, "pict" => Some(Dest::Pict), "bkmkstart" => Some(Dest::Bookmark), + "footnote" | "annotation" if self.marks_on && self.hf.is_none() && self.sub.is_none() && self.st.dest == Dest::Body => { + self.start_sub(w == "annotation"); + return; + } + "atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref" + if self.marks_on + && self.hf.is_none() + && self.st.dest == Dest::Body + && self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) => + { + self.atn_text.clear(); + Some(Dest::Atn(match w { + "atrfstart" => 1, + "atrfend" => 2, + "atnid" => 3, + "atnauthor" => 4, + "atndate" | "atntime" => 5, + _ => 6, + })) + } "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" if self.hf.is_none() && self.st.dest == Dest::Body && !self.st.intbl => { @@ -1309,6 +1701,23 @@ impl Reader { } return; } + Dest::Atn(k) => { + if k == 5 { + let v = pv.clamp(0, 9999) as u32; + let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0)); + match w { + "yr" => c.0 = v, + "mo" => c.1 = v, + "dy" => c.2 = v, + "hr" => c.3 = v, + "min" => c.4 = v, + _ => {} + } + } + if !matches!(w, "u" | "uc") { + return; + } + } // Text destinations take Unicode escapes (with fallback skipping) like the body. Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(w, "u" | "uc") => return, Dest::Info(_) | Dest::FldInst | Dest::Bookmark => {} @@ -1492,6 +1901,17 @@ impl Reader { self.defs.push(d); } } + // A footnote group is an endnote when it says so. + "ftnalt" => { + if let Some(sub) = self.sub.as_mut() { + sub.endnote = true; + } + } + // The note's own number, at the start of its text. + "chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => { + let f = self.st.fmt.clone(); + self.push_mark('S', 0, f); + } "field" => self.start_field(), "fldrslt" => { let link = self.st.field.and_then(|i| self.fields.get(i)).and_then(|s| hyperlink(s)); @@ -1543,7 +1963,7 @@ pub fn parse(bytes: &[u8]) -> Result { /// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which /// is the font of all text that doesn't select one. fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option), String> { - parse_full(bytes).map(|r| (r.flow, r.page, r.default_font)) + parse_full(bytes, false).map(|r| (r.flow, r.page, r.default_font)) } /// Everything an import takes from an RTF file. @@ -1553,14 +1973,18 @@ struct Parsed { default_font: Option, headers_footers: Vec<(HeaderFooter, Vec)>, title_page: bool, + notes: Vec, + comments: Vec, } -fn parse_full(bytes: &[u8]) -> Result { +/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for +/// [`import`] to turn into objects); without it they are skipped. +fn parse_full(bytes: &[u8], marks: bool) -> Result { let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0); if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) { return Err("not an RTF document (it doesn't start with {\\rtf)".into()); } - let mut r = Reader::default(); + let mut r = Reader { marks_on: marks, ..Reader::default() }; let mut i = start; let mut first_in_group = false; let mut starred = false; @@ -1630,7 +2054,7 @@ fn parse_full(bytes: &[u8]) -> Result { r.info_depth = Some(r.depth()); } if r.skip_chars > 0 - && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark) + && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::Atn(_)) && !matches!(word, "par" | "pard" | "cell" | "row") { r.skip_chars -= 1; @@ -1710,7 +2134,8 @@ fn parse_full(bytes: &[u8]) -> Result { } } } - // A header or footer left open by a truncated file ends here. + // A note, comment, header or footer left open by a truncated file ends here. + r.end_sub(); r.end_header_footer(); if r.para_has || !r.para.inlines.is_empty() { r.end_para(false); @@ -1726,7 +2151,15 @@ fn parse_full(bytes: &[u8]) -> Result { blocks.push(FBlock::Para(Para::default())); } let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned(); - Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, default_font, headers_footers: r.hf_parts, title_page: r.title_page }) + Ok(Parsed { + flow: Flow { blocks, meta: r.meta }, + page: r.page, + default_font, + headers_footers: r.hf_parts, + title_page: r.title_page, + notes: r.notes, + comments: r.comments, + }) } /// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid @@ -1768,7 +2201,7 @@ fn column_grid(rows: &mut [Vec], rights: &[Vec]) -> Vec { /// Parse RTF bytes into a document. pub fn import(bytes: &[u8]) -> Result { - let Parsed { flow, page, default_font, headers_footers, title_page } = parse_full(bytes)?; + let Parsed { flow, page, default_font, headers_footers, title_page, notes, comments } = parse_full(bytes, true)?; let mut doc = model::to_doc_in(&flow, page.section()); for (which, blocks) in &headers_footers { let blocks = model::part_blocks(&mut doc, blocks); @@ -1781,6 +2214,7 @@ pub fn import(bytes: &[u8]) -> Result { } } doc.last_section.title_page = title_page; + place_notes_and_comments(&mut doc, notes, comments); // RTF's paragraph defaults are single spacing and no space around paragraphs; what a file // wants beyond that it states on each paragraph. let dp = &mut doc.styles.default_para; @@ -1793,6 +2227,129 @@ pub fn import(bytes: &[u8]) -> Result { Ok(doc) } +/// Turn the place markers the reader left in the text into note references and comment ranges, +/// and add the notes' and comments' stories to `doc`. +fn place_notes_and_comments(doc: &mut Document, notes: Vec, comments: Vec) { + use wordcraft_doc::para::NoteKind; + use wordcraft_doc::{Comment, PartKind}; + if notes.is_empty() && comments.is_empty() { + return; + } + // (kind, part id) per note, by index. + let mut note_parts: Vec<(NoteKind, u32)> = Vec::new(); + for n in notes { + let (pk, nk, style) = + if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") }; + let mut blocks = model::part_blocks(doc, &n.blocks); + for b in &mut blocks { + if let Block::Para(p) = Arc::make_mut(b) + && p.props.style.is_none() + { + p.props.style = Some(style.to_string()); + } + } + note_parts.push((nk, doc.add_part(pk, blocks))); + } + // RTF range id to the comment's id in the document. + let mut by_rtf: HashMap = HashMap::new(); + for (k, c) in comments.into_iter().enumerate() { + let id = k as u32; + let blocks = model::part_blocks(doc, &c.blocks); + let part = doc.add_part(PartKind::Comment, blocks); + doc.comments.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part }); + by_rtf.insert(i64::from(c.rtf_id), id); + } + let ctx = Placer { note_parts, by_rtf }; + let mut body = std::mem::take(&mut doc.body); + ctx.blocks(&mut body, None, 0); + doc.body = body; + let ids: Vec = doc.parts.keys().copied().collect(); + for id in ids { + let Some(part) = doc.parts.get_mut(&id) else { continue }; + let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied(); + ctx.blocks(&mut part.blocks, own, 0); + // A note starts with its own number, as Word's do. + if let Some((kind, pid)) = own + && let Some(first) = part.blocks.first_mut() + && let Block::Para(p) = Arc::make_mut(first) + && !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid)) + { + let props = Placer::ref_props(kind); + if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() { + // The paragraph can't take it; the note simply has no number mark. + } + } + } +} + +struct Placer { + note_parts: Vec<(wordcraft_doc::para::NoteKind, u32)>, + by_rtf: HashMap, +} + +impl Placer { + fn ref_props(kind: wordcraft_doc::para::NoteKind) -> wordcraft_doc::CharProps { + let style = if kind == wordcraft_doc::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" }; + wordcraft_doc::CharProps { style: Some(style.into()), ..Default::default() } + } + + fn blocks(&self, bl: &mut Blocks, own: Option<(wordcraft_doc::para::NoteKind, u32)>, depth: usize) { + for b in bl.iter_mut() { + let needs = match &**b { + Block::Para(p) => p.text.contains(MARK_OPEN), + Block::Table(_) => depth < 8, + }; + if !needs { + continue; + } + match Arc::make_mut(b) { + Block::Para(p) => self.para(p, own), + Block::Table(t) => { + for r in &mut t.rows { + for c in &mut r.cells { + self.blocks(&mut c.blocks, own, depth + 1); + } + } + } + } + } + } + + fn para(&self, p: &mut Paragraph, own: Option<(wordcraft_doc::para::NoteKind, u32)>) { + // Every pass removes one marker, so this ends; the bound is only a seat belt. + for _ in 0..100_000 { + let Some(a) = p.text.find(MARK_OPEN) else { return }; + let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return }; + let end = a + len + MARK_CLOSE.len_utf8(); + let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string(); + let mut chars = inner.chars(); + let tag = chars.next().unwrap_or(' '); + let n = chars.as_str().parse::().unwrap_or(i64::MIN); + let (obj, props) = match tag { + 'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) { + Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)), + None => (None, Default::default()), + }, + 'S' => match own { + Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)), + None => (None, Default::default()), + }, + 'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()), + 'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()), + _ => (None, Default::default()), + }; + if p.delete(a, end).is_err() { + return; + } + if let Some(o) = obj + && p.insert_object(a, o, &props).is_err() + { + return; + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index d4a8190025..99d1a53a5e 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -662,3 +662,114 @@ fn exported_links_keep_only_safe_targets() { assert!(!md.contains(&format!("[{text}](")), "md: {md}"); } } + +// --------------------------------------------------------------------------------------------- +// RTF footnotes, endnotes and comments (#1053) + +/// The note and comment texts of `d` as `(kind, text)` strings, and the references in the body. +fn rtf_notes(d: &Document) -> (Vec, Vec) { + use wordcraft_doc::PartKind; + let story = |bl: &wordcraft_doc::Blocks| { + bl.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p.plain_text().trim().to_string()) } else { None }).collect::>().join("|") + }; + let notes = d + .parts + .values() + .filter(|p| matches!(p.kind, PartKind::Footnote | PartKind::Endnote)) + .map(|p| format!("{:?}:{}", p.kind, story(&p.blocks))) + .collect(); + let comments = d.comments.values().filter_map(|c| d.parts.get(&c.part)).map(|p| story(&p.blocks)).collect(); + (notes, comments) +} + +#[test] +fn rtf_footnote_and_endnote_round_trip() { + use wordcraft_doc::para::NoteKind; + let rtf = br"{\rtf1\ansi{\fonttbl{\f0 Arial;}} +\pard Alpha{\super\chftn}{\footnote \pard\plain {\super\chftn} First {\b note}\par} beta +gamma{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} The end\par\pard second line\par} delta.\par}"; + let d = import("rtf", rtf).unwrap().unwrap(); + let body = d.body.iter().find_map(|b| b.as_para()).unwrap(); + let refs: Vec = + body.objects.iter().filter_map(|o| if let InlineObject::NoteRef { kind, .. } = o { Some(*kind) } else { None }).collect(); + assert_eq!(refs, [NoteKind::Footnote, NoteKind::Endnote]); + assert_eq!(body.plain_text().trim(), "Alpha beta gamma delta."); + let (notes, _) = rtf_notes(&d); + assert_eq!(notes, ["Footnote:First note", "Endnote:The end|second line"]); + // The note's own number sits at the start of its text, and its first words are bold. + let foot = d.parts.values().find(|p| p.kind == wordcraft_doc::PartKind::Footnote).unwrap(); + let fp = foot.blocks.iter().find_map(|b| b.as_para()).unwrap(); + assert!(matches!(fp.objects.first(), Some(InlineObject::NoteRef { kind: NoteKind::Footnote, .. }))); + // Out and in again keeps both. + let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap(); + assert!(out.contains("\\footnote") && out.contains("\\footnote\\ftnalt") && out.contains("\\chftn"), "{out}"); + let d2 = import("rtf", out.as_bytes()).unwrap().unwrap(); + assert_eq!(rtf_notes(&d2).0, notes); + let body2 = d2.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(body2.objects.len(), 2); + assert_eq!(body2.plain_text().trim(), "Alpha beta gamma delta."); + // The flow reader (no document) still just skips them. + let flow = crate::rtf::parse(rtf).unwrap(); + assert!(matches!(&flow.blocks[0], FBlock::Para(p) if p.text().trim() == "Alpha beta gamma delta.")); +} + +#[test] +fn rtf_comment_round_trip_with_author_and_date() { + let rtf = br"{\rtf1\ansi +\pard Before {\*\atrfstart 7}commented words{\*\atrfend 7}{\*\atnid AK}{\*\atnauthor Ada Kline}\chatn{\*\annotation{\*\atnref 7}{\*\atndate \yr2026\mo3\dy9\hr14\min5}\pard\plain Please \b check\b0 this.\par} after.\par +\pard Second {\*\atrfstart 8}one{\*\atrfend 8}{\*\atnid BX}{\*\atnauthor Bo}\chatn{\*\annotation{\*\atnref 8}\pard\plain Fine.\par}\par}"; + let d = import("rtf", rtf).unwrap().unwrap(); + assert_eq!(d.comments.len(), 2); + let c = d.comments.values().next().unwrap(); + assert_eq!((c.author.as_str(), c.initials.as_str(), c.date.as_str()), ("Ada Kline", "AK", "2026-03-09T14:05:00Z")); + assert_eq!(rtf_notes(&d).1, ["Please check this.", "Fine."]); + let p = d.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p.plain_text().trim(), "Before commented words after."); + // The range wraps exactly the commented words. + let (s, e) = (p.object_offsets()[0], p.object_offsets()[1]); + assert!(matches!(p.object_at(s), Some(InlineObject::CommentStart { .. })) && matches!(p.object_at(e), Some(InlineObject::CommentEnd { .. }))); + assert_eq!(p.text.get(s + 3..e), Some("commented words")); + let out = String::from_utf8(export("rtf", &d).unwrap().unwrap()).unwrap(); + assert!(out.contains("\\atrfstart") && out.contains("\\atnauthor Ada Kline") && out.contains("\\annotation"), "{out}"); + let d2 = import("rtf", out.as_bytes()).unwrap().unwrap(); + assert_eq!(d2.comments.len(), 2); + let (a, b) = (d.comments.values().next().unwrap(), d2.comments.values().next().unwrap()); + assert_eq!((&a.author, &a.initials, &a.date), (&b.author, &b.initials, &b.date)); + assert_eq!(rtf_notes(&d2).1, ["Please check this.", "Fine."]); + let p2 = d2.body.iter().find_map(|b| b.as_para()).unwrap(); + assert_eq!(p2.objects.len(), 2); +} + +#[test] +fn rtf_hostile_notes_and_comments_do_not_panic() { + // Truncated everywhere, unbalanced groups, forged markers, huge ids, nesting. + let good = br"{\rtf1\ansi Hi{\super\chftn}{\footnote\ftnalt \pard\plain {\super\chftn} Note \u8364? text\par} {\*\atrfstart 1}x{\*\atrfend 1}{\*\atnid A}{\*\atnauthor Z}{\*\annotation{\*\atnref 1}{\*\atndate 123456789}\pard c\par}\par}"; + for n in 0..good.len() { + let _ = import("rtf", &good[..n]); + } + let junk: [&[u8]; 6] = [ + br"{\rtf1 \u63472?\u63473?\u63472?N0\u63473? {\footnote x}{\footnote {\footnote deep}}}", + br"{\rtf1 {\*\atrfstart 99999999999}{\*\atrfend -5}{\*\annotation{\*\atnref 99999999999}x}{\*\annotation}}", + br"{\rtf1 {\footnote{\annotation{\footnote{\*\annotation{\*\atrfstart 1}", + br"{\rtf1 {\*\atndate \yr99999\mo-4\dy0\hr77\min999}{\*\annotation y}{\*\atndate 4294967295}{\*\annotation z}}", + b"{\\rtf1 {\\footnote \\trowd\\cellx100\\pard\\intbl a\\cell\\row}}", + b"{\\rtf1 {\\annotation \\chatn\\chftn\\atnparent 3}{\\header {\\footnote q}}}", + ]; + for j in junk { + let d = import("rtf", j).unwrap().unwrap(); + // Whatever came in, a second trip through RTF is fine too. + let out = export("rtf", &d).unwrap().unwrap(); + assert!(import("rtf", &out).unwrap().is_ok()); + } + // Many notes: bounded. + let mut many = String::from("{\\rtf1 "); + for _ in 0..6000 { + many.push_str("{\\footnote x}"); + } + many.push('}'); + let d = import("rtf", many.as_bytes()).unwrap().unwrap(); + assert!(d.parts.len() <= 5000); + // Private-use marker characters in ordinary text stay text. + let d = import("rtf", "{\\rtf1 a\u{F8F0}N0\u{F8F1}b}".as_bytes()).unwrap().unwrap(); + assert!(d.parts.is_empty()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 200f259e22..2a73f58200 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -25,7 +25,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` (56) | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Freeform shapes, z-order and Word 6/95 or encrypted files are dropped or refused. No writer | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133), links, outline, tagging. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | -| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Thin: comments, notes, revisions, sections, RTL | +| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Footnotes, endnotes (`\footnote`, `\ftnalt`) and comments (`\atrfstart`/`\atrfend` ranges, `\annotation` with author, initials and date) are read and written (#1053; synthetic strings only, not compared with Word's RTF). Not kept: custom note marks, comment replies, notes in headers and footers. Thin: revisions, sections, RTL | | `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs` | Styles, lists (#87), tables, pictures, headers/footers, page breaks (#86), page size, orientation and margins (#810). No footnotes, comments, tracked changes, fields or RTL | | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`. Word's encoding choice dialog and line-ending options missing; such files save only through Save As | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687) | @@ -163,6 +163,7 @@ raise readiness estimates. No Word-derived files are fixtures. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | RTF reads and writes footnotes, endnotes and comments with author, initials, date and commented range (#1053) | | 2026-10-12 | trivial | Paste keeps the formatting of HTML and RTF on the system clipboard; Paste Special lists them and pictures (#687, #833) | | 2026-10-12 | trivial | ODT page size, orientation and margins read back from `styles.xml` on open, so they survive a save and reopen (#810) | | 2026-10-12 | minor | RTF reads 8-bit text in the document's or the font's code page (`\ansicpg`, `\fcharset`, `\cpg`) instead of always Windows-1252; a `.doc`/`.docx`/`.rtf` whose content is another of these formats opens by signature. Found on a private corpus of real Russian documents: 38 of 44 `.doc` files were RTF, all now open with their Cyrillic text | diff --git a/docs/gaps.md b/docs/gaps.md index 04a985cc91..b9bceaa445 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (RTF footnotes, endnotes and comments, #1053; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -33,7 +33,7 @@ Native save reliability: synthetic cases on macOS reproduced loss through the pr | 16 | **Right-to-left completeness**: kashida, RTL in HTML/ODT/RTF; eight older RTL bug reports to re-verify after #207 (#215, #211, #199, #66, #63, #48, #19). RTL tables and sections landed (#362) | | [layout-parity.md](layout-parity.md) | Arabic, Persian, Hebrew writers | 7–11 | [layout-parity.md](layout-parity.md) | | 17 | **East Asian typography**: Asian Typography options (Phonetic Guide landed, #288; Enclose Characters landed, #297; document grid landed, #391; vertical text landed, #491) | | Catalog misses `para.asianTypography` | Chinese, Japanese and Korean documents | 11–19 | [layout-parity.md](layout-parity.md) | | 18 | **Screen-reader access** to the document canvas: exposed, not yet tried with real screen readers | | The canvas describes paragraphs, headings, lists, tables, links, pictures and the caret to AccessKit (#488), tested headlessly; no NVDA, VoiceOver or Orca run yet | Blind and low-vision users; public-sector procurement | 3–6 | [ui-parity.md](ui-parity.md) | -| 19 | **RTF and ODT depth**: notes, comments, revisions, sections, RTL | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | +| 19 | **RTF and ODT depth**: revisions, sections, RTL (RTF footnotes, endnotes and comments landed, #1053) | | ~1,500 lines each in `crates/formats` | Users exchanging with LibreOffice or older tools | 18–27 | [file-format-parity.md](file-format-parity.md) | | 20 | **Performance on large real-world documents** unmeasured (500+ pages, many pictures) | | Only the 188-page sample benchmark; scrolling a 1,096-page one-table document went from ~600 ms to ~2–5 ms a frame (#811), but opening it still lays out for seconds | Thesis and book authors | 10–20 | [layout-parity.md](layout-parity.md) | | 21 | **Mail merge data sources**: no Outlook/Contacts, Access or database sources, no email merge; CSV, typed lists (#247) and Excel/OpenDocument spreadsheets (#334) only | | Select Recipients reads .csv/.tsv, .xlsx/.xlsm and .ods | Office administrators | 4–8 | [target-app-parity.md](target-app-parity.md) | | 22 | **Ecosystem**: no VBA execution (macros preserved only), no Office add-ins, no cloud storage, no real-time co-authoring | | `.docm` keeps macros (#172) | Enterprise users with macro templates; teams | 40–80 + owner decisions | [target-app-parity.md](target-app-parity.md) | From 25e4986998f65f119b7957f7d4cdcac883ca460e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 16 Oct 2026 22:27:46 +0200 Subject: [PATCH 004/126] MCP: insert_table tool; insert.spreadsheet takes values, header and style (#2242) Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/tools.rs | 71 ++++++++++++++++++++++++++++++++-- crates/mcp/src/tests.rs | 41 ++++++++++++++++++++ crates/mcp/src/tools.rs | 28 +++++++++++++- docs/mcp.md | 3 +- 5 files changed, 138 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 40407d18b6..91e8e6b177 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -135,6 +135,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log | Date | What landed | +| 2026-10-16 | MCP `insert_table` tool (#2242): a filled table in one call from `values`, with `header` (repeating first row) and `style`; `insert.spreadsheet` takes the same `values`/`header`/`style` and rejects ragged, oversized or non-array input | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 1243ba4f24..8035e5cb9b 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -510,10 +510,10 @@ pub fn specs() -> Vec { }) .params(r#"{"path": string}"#), CommandSpec::new("insert.spreadsheet", "Spreadsheet Table", "Insert › Tables", |s, v| { - let csv = p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"); - let rows = super::mailings::parse_csv(csv); + let rows = spreadsheet_rows(v)?; let cols = rows.iter().map(Vec::len).max().unwrap_or(1).clamp(1, 63); - s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": "GridTable1Light"}))?; + let style = p::str(v, "style").unwrap_or("GridTable1Light"); + s.run("insert.table", &json!({"rows": rows.len().max(1), "cols": cols, "style": style}))?; let Some((tp, _, _)) = s.sel.focus.path.cell() else { return sel_result(s) }; let t = s.doc.table_mut(s.sel.focus.story, &tp)?; for (r, row) in rows.iter().enumerate() { @@ -523,9 +523,14 @@ pub fn specs() -> Vec { } } } + if p::bool(v, "header").unwrap_or(false) + && let Some(first) = t.rows.first_mut() + { + first.props.header = true; + } sel_result(s) }) - .params(r#"{"csv"?: string}"#), + .params(r#"{"csv"?: string, "values"?: [[cell, …], …] (rows of equal length, at most 1000 rows × 63 columns; wins over csv), "header"?: bool (first row repeats on each page), "style"?: table style name (default GridTable1Light)}"#), CommandSpec::new("layout.linkToPrevious", "Link to Previous", "Header & Footer › Navigation", |s, v| { let header = p::bool(v, "footer").map(|f| !f).unwrap_or(true); let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize; @@ -1764,6 +1769,39 @@ fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult { Ok(Value::Object(out)) } +/// The rows of an `insert.spreadsheet` call: `values` (a rectangular array of cells) or `csv`. +fn spreadsheet_rows(v: &Value) -> Result>, CmdError> { + let Some(values) = v.get("values").filter(|x| !x.is_null()) else { + return Ok(super::mailings::parse_csv(p::str(v, "csv").unwrap_or("A,B,C\n1,2,3\n4,5,6"))); + }; + let list = values.as_array().ok_or_else(|| CmdError::Params("`values` must be an array of rows".into()))?; + if list.is_empty() || list.len() > 1000 { + return Err(CmdError::Params("`values` needs 1 to 1000 rows".into())); + } + let mut rows: Vec> = Vec::with_capacity(list.len()); + for (r, row) in list.iter().enumerate() { + let cells = row.as_array().ok_or_else(|| CmdError::Params(format!("`values` row {} is not an array", r + 1)))?; + if cells.is_empty() || cells.len() > 63 { + return Err(CmdError::Params(format!("`values` row {} needs 1 to 63 cells", r + 1))); + } + if rows.first().is_some_and(|f| f.len() != cells.len()) { + return Err(CmdError::Params(format!("`values` row {} has {} cells, row 1 has {}", r + 1, cells.len(), rows.first().map_or(0, Vec::len)))); + } + let mut out = Vec::with_capacity(cells.len()); + for cell in cells { + out.push(match cell { + Value::String(x) => x.clone(), + Value::Number(x) => x.to_string(), + Value::Bool(x) => x.to_string(), + Value::Null => String::new(), + _ => return Err(CmdError::Params(format!("`values` row {} holds a cell that is not text, a number or a boolean", r + 1))), + }); + } + rows.push(out); + } + Ok(rows) +} + #[cfg(test)] mod tests { use super::*; @@ -1866,4 +1904,29 @@ mod tests { assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE")); assert!(s.run("review.language", &json!({"lang": "