From 12a1316f109cd7b333b58603f6175d00c17583fa Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 22:23:43 +0200 Subject: [PATCH 1/8] =?UTF-8?q?View=20=E2=80=BA=20Window=20across=20WordCr?= =?UTF-8?q?aft=20windows:=20Switch=20Windows,=20Arrange=20All,=20Side=20by?= =?UTF-8?q?=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 2/8] #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 c3703ebc73afc5c83e03c099fb30b106a352949a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:10:30 +0200 Subject: [PATCH 3/8] Regenerate parity checklist and headline coverage after merging main Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- docs/parity-checklist.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 93c93b61bb..73c4745c39 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 | **496 / 507 (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) | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e150afbd25..23ba243ee7 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. -**492 of 507 catalog features have commands (97.0%).** +**496 of 507 catalog features have commands (97.8%).** | Tab | Live | Total | |---|---|---| From ee649d03da1e291492620f0dd8dae5bce10c5b55 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 19 Oct 2026 02:28:02 +0200 Subject: [PATCH 4/8] i18n: translate the View Side by Side disabled-reason strings The two disabled reasons added by the Window commands ("only the desktop app has several windows", "turn on View Side by Side first") had no catalog rows, so i18n::audit::untranslated_interface_text and the cs/vi coverage tests failed. Add rows to ru, hu, tr, bg, it, sv, hr, pl, cs and vi. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/i18n/bg.tsv | 2 ++ crates/ui-egui/src/i18n/cs.tsv | 2 ++ crates/ui-egui/src/i18n/hr.tsv | 2 ++ crates/ui-egui/src/i18n/hu.tsv | 2 ++ crates/ui-egui/src/i18n/it.tsv | 2 ++ crates/ui-egui/src/i18n/pl.tsv | 2 ++ crates/ui-egui/src/i18n/ru.tsv | 2 ++ crates/ui-egui/src/i18n/sv.tsv | 2 ++ crates/ui-egui/src/i18n/tr.tsv | 2 ++ crates/ui-egui/src/i18n/vi.tsv | 2 ++ 10 files changed, 20 insertions(+) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 7c76a212a1..7b2ed0bf81 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2540,6 +2540,8 @@ View Side by Side Преглед един до друг Synchronous Scrolling Синхронно превъртане No other WordCraft windows are open. Няма други отворени прозорци на WordCraft. + only the desktop app has several windows няколко прозореца има само в настолното приложение + turn on View Side by Side first първо включете Преглед един до друг Copy to Копиране в # Document naming and account controls. diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 0f3f1f7087..13a287b6cf 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2543,6 +2543,8 @@ preview Title Ukázka 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. + only the desktop app has several windows více oken má jen desktopová aplikace + turn on View Side by Side first nejdřív zapněte Zobrazit vedle sebe Copy to Kopírovat do # Document naming and account controls. diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 6a5bc06204..975327a692 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2652,6 +2652,8 @@ preview Title Naslov View Side by Side Prikaži jedan pored drugog Synchronous Scrolling Sinkrono pomicanje No other WordCraft windows are open. Nema drugih otvorenih WordCraft prozora. + only the desktop app has several windows samo desktop aplikacija ima više prozora + turn on View Side by Side first najprije uključite Prikaži jedan pored drugog # Split Cells and Convert Text to Table (#2592) Merge cells before split Spoji ćelije prije razdvajanja Table size Veličina tablice diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index e1947d5eeb..352c063b10 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2546,6 +2546,8 @@ 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. + only the desktop app has several windows csak az asztali alkalmazásban van több ablak + turn on View Side by Side first előbb kapcsolja be az Egymás melletti nézetet Copy to Másolás ide # Document naming and account controls. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a3abbca3a4..19b2ab1244 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2534,6 +2534,8 @@ preview Title Esempio View Side by Side Visualizza affiancate Synchronous Scrolling Scorrimento sincrono No other WordCraft windows are open. Nessun'altra finestra di WordCraft è aperta. + only the desktop app has several windows solo l'app desktop ha più finestre + turn on View Side by Side first attiva prima Visualizza affiancate Copy to Copia in # Document naming and account controls. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index afde3e1ede..9bf143f83c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2543,6 +2543,8 @@ preview Title Tytuł View Side by Side Wyświetl obok siebie Synchronous Scrolling Przewijanie synchroniczne No other WordCraft windows are open. Brak innych otwartych okien WordCraft. + only the desktop app has several windows tylko aplikacja desktopowa ma wiele okien + turn on View Side by Side first najpierw włącz Wyświetl obok siebie Copy to Kopiuj do # Document naming and account controls. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a727fe5b1e..4d33e58de1 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2162,6 +2162,8 @@ preview Title Пример View Side by Side Рядом Synchronous Scrolling Синхронная прокрутка No other WordCraft windows are open. Других открытых окон WordCraft нет. + only the desktop app has several windows несколько окон есть только в настольном приложении + turn on View Side by Side first сначала включите «Рядом» Align Text Выравнивание текста Create Link Создать связь Break Link Разорвать связь diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 66f3eb79ee..47ec01c94f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2667,6 +2667,8 @@ preview Title Titel View Side by Side Visa sida vid sida Synchronous Scrolling Synkron rullning No other WordCraft windows are open. Inga andra WordCraft-fönster är öppna. + only the desktop app has several windows bara skrivbordsappen har flera fönster + turn on View Side by Side first aktivera Visa sida vid sida först # Split Cells and Convert Text to Table (#2592) Merge cells before split Sammanfoga celler före delning Table size Tabellstorlek diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 484f91f29d..2f8a34132c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2164,6 +2164,8 @@ preview Title Örnek 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. + only the desktop app has several windows birden çok pencere yalnızca masaüstü uygulamasında vardır + turn on View Side by Side first önce Yan Yana Görüntüle'yi açın Align Text Metni Hizala Create Link Bağlantı Oluştur Break Link Bağlantıyı Kes diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 45dbc93691..a72900c2d7 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2694,6 +2694,8 @@ template Letter Thư View Side by Side Xem song song Synchronous Scrolling Cuộn đồng bộ No other WordCraft windows are open. Không có cửa sổ WordCraft nào khác đang mở. + only the desktop app has several windows chỉ ứng dụng máy tính mới có nhiều cửa sổ + turn on View Side by Side first hãy bật Xem song song trước # Split Cells and Convert Text to Table (#2592) Merge cells before split Gộp ô trước khi tách Table size Kích thước bảng From 38253611d298de8d45aa2a225d415f9e2b835649 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 22 Oct 2026 19:02:20 +0200 Subject: [PATCH 5/8] Add View Window strings to the da, nl, ro and sl catalogs --- crates/ui-egui/src/i18n/da.tsv | 6 ++++++ crates/ui-egui/src/i18n/nl.tsv | 6 ++++++ crates/ui-egui/src/i18n/ro.tsv | 8 ++++++++ crates/ui-egui/src/i18n/sl.tsv | 8 ++++++++ 4 files changed, 28 insertions(+) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 418fa3670a..e11bc67f3d 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2599,3 +2599,9 @@ preview Title Titel Full Screen View Full Screen View Personal Templates Personal Templates Underline Color Underline Color + Switch Windows Skift vindue + Switch\nWindows Skift\nvindue + Arrange All Arranger alle + View Side by Side Vis side om side + Synchronous Scrolling Synkron rulning + No other WordCraft windows are open. Der er ingen andre WordCraft-vinduer åbne. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 85421447df..c95ca154e4 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2669,3 +2669,9 @@ preview Title Titel Full Screen View Full Screen View Personal Templates Personal Templates Underline Color Underline Color + Switch Windows Venster wisselen + Switch\nWindows Venster\nwisselen + Arrange All Alles schikken + View Side by Side Naast elkaar weergeven + Synchronous Scrolling Synchroon schuiven + No other WordCraft windows are open. Er zijn geen andere WordCraft-vensters geopend. diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 1f1098962c..ad5206046b 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2762,3 +2762,11 @@ template Letter Scrisoare cm cm mm mm pi pi + Switch Windows Comută ferestrele + Switch\nWindows Comută\nferestrele + Arrange All Aranjează toate + View Side by Side Vizualizare alăturată + Synchronous Scrolling Derulare sincronă + No other WordCraft windows are open. Nu sunt deschise alte ferestre WordCraft. + only the desktop app has several windows doar aplicația desktop are mai multe ferestre + turn on View Side by Side first activează mai întâi Vizualizare alăturată diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 8bf77d27ae..b27cca53bc 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2763,3 +2763,11 @@ preview Title Naslov {names} and {n} more {names} and {n} more {name} options {name} options {width} × {height} pixels {width} × {height} pixels + Switch Windows Preklopi okna + Switch\nWindows Preklopi\nokna + Arrange All Razporedi vse + View Side by Side Ogled drug ob drugem + Synchronous Scrolling Sinhrono pomikanje + No other WordCraft windows are open. Ni odprtih drugih oken WordCraft. + only the desktop app has several windows samo namizna aplikacija ima več oken + turn on View Side by Side first najprej vklopi Ogled drug ob drugem From e11943f818805b1116e0359ea4bf1144f42f0083 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 26 Oct 2026 01:00:10 +0100 Subject: [PATCH 6/8] Read the canvas scroll offset from the frame's clamped state after the merge Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d71f3b6925..a13095a2a9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1792,7 +1792,7 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }); 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; + let own = app.canvas.scroll_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; } From 49a3cd223bae5f3661766b2b66253743e8cd697c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 29 Oct 2026 22:40:27 +0100 Subject: [PATCH 7/8] Format canvas.rs after the merge Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3f45ea6dd1..6a01e7710a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1635,7 +1635,21 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { (resp, rects) }); finish_canvas_frame( - CanvasFinish { app, ui, layout: &layout, geo: &geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler, scroll_base, rescaled }, + CanvasFinish { + app, + ui, + layout: &layout, + geo: &geo, + caret, + ime_geometry, + ime_interrupt, + area, + dim_body, + hruler, + vruler, + scroll_base, + rescaled, + }, out, ); } From 9fd9ce54b7e36b439f68d97d83aa12a84c507e07 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:27:10 +0100 Subject: [PATCH 8/8] Side by Side keeps the scrolling choice for an unchanged partner; drop queued partner scroll on pairing changes Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/view.rs | 12 ++++++++---- crates/engine/src/tests.rs | 4 ++++ crates/ui-egui/src/windows/arrange.rs | 19 +++++++++++++++++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9729ae6a65..7ef7a36646 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -262,10 +262,14 @@ fn side_by_side(s: &mut Session, v: &Value) -> CmdResult { 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})) + // The partner it already has keeps the user's Synchronous Scrolling choice and is not asked + // again; a new pairing starts with scrolling on. + if s.windows.side_by_side != Some(id) { + 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": s.windows.sync_scroll})) } /// The browser keeps full screen to itself (its own F11); the web app can't ask for it. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c9f7861d9b..cbf708daef 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10490,6 +10490,10 @@ fn window_commands_need_the_desktop_and_name_a_window() { 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}))); + // Asking again for the partner it already has keeps scrolling off and pairs nothing anew. + let r = run(&mut s, "view.sideBySide", json!({"window": 7, "value": true})); + assert_eq!((r["syncScroll"].as_bool(), s.windows.sync_scroll), (Some(false), false)); + assert!(s.ui_requests.is_empty()); // 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})]); diff --git a/crates/ui-egui/src/windows/arrange.rs b/crates/ui-egui/src/windows/arrange.rs index 76f70a94d7..9ee478dd41 100644 --- a/crates/ui-egui/src/windows/arrange.rs +++ b/crates/ui-egui/src/windows/arrange.rs @@ -74,7 +74,10 @@ pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) { w.others = others; w.side_by_side = partner; w.sync_scroll = sync; - if sync && waiting.is_finite() { + if !sync { + // Scrolling still queued for a partner that is gone, replaced or no longer synced. + app.canvas.sync_in = 0.0; + } else if waiting.is_finite() { app.canvas.sync_in += waiting.clamp(-MAX_SCROLL, MAX_SCROLL); } } @@ -97,6 +100,7 @@ pub(crate) fn request(app: &mut WordApp, req: &Value) { let id = |k: &str| req.get(k).and_then(Value::as_u64); match what { "sideBySide" => { + app.canvas.sync_in = 0.0; if id("off").is_some() { app.windows.arranged.clear(); } @@ -113,6 +117,7 @@ pub(crate) fn request(app: &mut WordApp, req: &Value) { } } "syncScroll" => { + app.canvas.sync_in = 0.0; if let Some(on) = req.get("value").and_then(Value::as_bool) { app.windows.arranged.sync = on; } @@ -275,7 +280,15 @@ mod tests { assert_eq!(app.canvas.sync_out, 0.0); assert_eq!(app.windows.arranged.waiting.get(&other), Some(&40.0)); assert!(!app.windows.arranged.waiting.contains_key(&shown)); - // Nothing is passed on once Synchronous Scrolling is off. + // Nothing is passed on once Synchronous Scrolling is off, and scrolling still queued for + // this window is dropped. + app.canvas.sync_in = 9.0; + app.windows.arranged.sync = false; + poll(&mut app, &ctx); + assert_eq!(app.canvas.sync_in, 0.0); + app.canvas.sync_in = 9.0; + request(&mut app, &json!({"windows": "syncScroll", "value": true})); + assert_eq!(app.canvas.sync_in, 0.0); app.windows.arranged.sync = false; app.canvas.sync_out = 5.0; forward_scroll(&mut app, &ctx); @@ -287,7 +300,9 @@ mod tests { assert_eq!(app.windows.arranged.waiting.get(&other), Some(&MAX_SCROLL)); // The partner closing ends the pairing. app.windows.open.retain(|w| w.key == shown); + app.canvas.sync_in = 3.0; poll(&mut app, &ctx); + assert_eq!(app.canvas.sync_in, 0.0); assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (None, false)); } }