diff --git a/ROADMAP.md b/ROADMAP.md index e296c96695..f8afeca851 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -529,6 +529,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures | | 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. | | 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line | +| 2026-10-26 | View › Window across the native windows (#322): Arrange All, View Side by Side, Synchronous Scrolling | | 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. | | 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. | | 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) | @@ -1076,6 +1077,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-10-17 | trivial | A line breaks after a hyphen before a digit, like Word (#2712) | | 2026-10-15 | trivial | Progress log: text above a float's anchor wraps around it, #2048 | | 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) | +| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29 | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) | | 2026-10-19 | trivial | Progress log: render threads reused while typing (#3027) | diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 7b46c598c9..0c2c490ba1 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_doc::{Zoom, ZoomFit}; use wordcraft_layout::{NumeralMode, ViewMode}; @@ -150,6 +151,30 @@ pub fn specs() -> Vec { }) .key("Mod+Alt+S") .pure(), + // View › Window across WordCraft windows (#322): the desktop app's native windows + // perform these (see `Windows`), elsewhere they are disabled. + 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(), // The window fills the screen, OS-level full screen (#980). F11 as in browsers and most // desktop apps; ⌃⌘F first on macOS, where Ctrl is a key of its own (elsewhere Mod is Ctrl, // so "Mod+Ctrl+F" would read Ctrl+Ctrl+F). Escape also leaves it. The front end applies it. @@ -179,6 +204,83 @@ pub fn specs() -> Vec { ] } +/// View › Window (#322): the other native windows of the app as the window being drawn sees them +/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front +/// end keeps these current as each window draws; 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})); + } + // 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. fn full_screen_available(_: &Session) -> Option<&'static str> { cfg!(target_arch = "wasm32").then_some("Use the browser's full screen (F11)") diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fbc54e6de2..f1a839006f 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -183,6 +183,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. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index ccbbdf4120..be763af1fd 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -250,6 +250,8 @@ impl Session { author: _, initials: _, painter: _, + // What the drawn window knows of the others: its front end sets it as it draws. + windows: _, status: _, last_command: _, recording: _, diff --git a/crates/engine/src/session/lifecycle.rs b/crates/engine/src/session/lifecycle.rs index 614e5df96b..d6a03e2490 100644 --- a/crates/engine/src/session/lifecycle.rs +++ b/crates/engine/src/session/lifecycle.rs @@ -157,6 +157,7 @@ impl Session { ribbon: crate::RibbonLayout::default(), editing_lang: None, read_aloud: Default::default(), + windows: Default::default(), password, #[cfg(not(target_arch = "wasm32"))] recovery: None, diff --git a/crates/engine/src/session/snapshot.rs b/crates/engine/src/session/snapshot.rs index 2a9283f3f8..7d9c7c056d 100644 --- a/crates/engine/src/session/snapshot.rs +++ b/crates/engine/src/session/snapshot.rs @@ -139,6 +139,8 @@ impl Session { pending_ime_application_change: _, ime_origin: _, 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 be07c120fb..c92879d742 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10789,6 +10789,44 @@ fn insert_picture_takes_a_windows_metafile() { 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.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.sideBySide", &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.sideBySide", &json!({})).unwrap_err().to_string(); + assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}"); + assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window"); + assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err()); + // 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"); + 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}))); + // 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})]); + 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"); +} + /// Change Picture must keep the replacement's own format: the part's name and content type /// come from the extension, so a JPEG stored as `imageN.png` saves declared `image/png` (Word /// and LibreOffice then show a broken picture). diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index de8c151544..b2533e5f1f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -377,6 +377,7 @@ impl WordApp { /// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the /// active window and, with native windows, as each window draws. pub(crate) fn window_work(&mut self, ctx: &egui::Context) { + crate::windows::arrange::poll(self, ctx); if self.windows.native() { let doc = self.session.document_id(); if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) { diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 76aa548700..9c10e5f659 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -474,6 +474,7 @@ impl WordApp { if req.get("close").is_some() { self.quit_requested = true; } + crate::windows::arrange::request(self, req); } /// A request made for document `document`, which isn't shown and which no other window diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 92aa25890b..647941308a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -223,6 +223,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). @@ -440,6 +444,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, @@ -525,6 +531,8 @@ struct CanvasView { dim_body: bool, grid_story: Option, caret: Option, + /// The zoom changed this frame (#322): the scroll offset moved with it, nobody scrolled. + rescaled: bool, } fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> CanvasView { @@ -575,6 +583,8 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C 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); let anchored = if let Some((p, (page, x, y))) = zoom_anchor @@ -602,7 +612,7 @@ fn prepare_view(app: &mut WordApp, ui: &Ui, full: Rect, layout: &DocLayout) -> C { app.session.page_hint = c.page; } - CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret } + CanvasView { area, hruler, vruler, paper, animate, opts, geo, dim_body, grid_story, caret, rescaled } } fn update_caret_scroll(app: &mut WordApp, view: &CanvasView) -> Option { @@ -706,7 +716,9 @@ fn scroll_motion(app: &mut WordApp, ui: &Ui, full: Rect, view: &CanvasView) -> S ScrollMotion { wheel: wheel_delta, auto: auto_delta, middle: middle_delta } } -fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: ScrollMotion) -> egui::ScrollArea { +/// The scroll area, and the vertical offset this frame starts from once the partner window's +/// scrolling is applied (Synchronous Scrolling, #322): what the window scrolls beyond it is its own. +fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: ScrollMotion) -> (egui::ScrollArea, f32) { let delta = motion.wheel + motion.auto + motion.middle; // The canvas scrolls itself on wheel/touchpad input (crate::scroll): touchpads 1:1 with // momentum, wheel notches eased in (or all at once with animation effects off). Applied @@ -722,8 +734,11 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: 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) * view.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 * view.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() { @@ -731,6 +746,13 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: 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 && motion.wheel != egui::Vec2::ZERO { app.canvas.wheel.hit_edge(); } @@ -743,7 +765,7 @@ fn configure_scroll_area(app: &mut WordApp, ui: &Ui, view: &CanvasView, motion: if app.canvas.wheel.is_animating() { ui.ctx().request_repaint(); } - scroll_area + (scroll_area, before.y + from_partner) } fn prepare_layout(app: &mut WordApp, ui: &Ui, full: Rect) -> std::sync::Arc { @@ -780,6 +802,17 @@ struct CanvasFinish<'a> { dim_body: bool, hruler: Option, vruler: Option, + scroll_base: f32, + rescaled: bool, +} + +/// What a window's own scrolling is measured against for Synchronous Scrolling (#322). +#[derive(Clone, Copy)] +struct ScrollSync { + /// The vertical offset the frame started from, the partner's scrolling included. + base: f32, + /// The zoom changed: the offset moved, nobody scrolled. + rescaled: bool, } fn update_canvas_scroll_state( @@ -789,6 +822,7 @@ fn update_canvas_scroll_state( geo: &Geometry, caret: Option, out: &egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec)>, + sync: ScrollSync, ) { app.canvas.scroll_offset = out.state.clamped_offset(); scrollbar_page_tip(app, ui, out.id, &geo.rects, &geo.bands, layout.pages.len()); @@ -804,6 +838,11 @@ fn update_canvas_scroll_state( viewed_page(extent, &geo.rects, &geo.bands, app.canvas.scroll_offset.y, out.inner_rect.height()).min(layout.pages.len().saturating_sub(1)) }); crate::ink::replay_bar(app, ui, out.inner_rect); + // This window's own scrolling (wheel, scroll bar, keys, caret), for a partner window. + let own = app.canvas.scroll_offset.y - sync.base; + if app.session.windows.sync_scroll && !sync.rescaled && own.abs() > 0.01 && geo.scale > 0.0 { + app.canvas.sync_out += own / geo.scale; + } app.canvas.scroll_max = (out.content_size - out.inner_rect.size()).max(egui::Vec2::ZERO); if app.session.view.read_mode { let step = app.session.view.read_width * geo.scale + GAP; @@ -1202,8 +1241,8 @@ fn handle_secondary_selection(app: &mut WordApp, resp: &egui::Response, rects: & } fn finish_canvas_frame(finish: CanvasFinish<'_>, out: egui::scroll_area::ScrollAreaOutput<(egui::Response, Vec)>) { - let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler } = finish; - update_canvas_scroll_state(app, ui, layout, geo, caret, &out); + let CanvasFinish { app, ui, layout, geo, caret, ime_geometry, ime_interrupt, area, dim_body, hruler, vruler, scroll_base, rescaled } = finish; + update_canvas_scroll_state(app, ui, layout, geo, caret, &out, ScrollSync { base: scroll_base, rescaled }); let (resp, rects) = out.inner; // A click on the page leaves the selected comment balloon. dismiss_balloon_on_canvas_click(app, ui, &resp); @@ -1406,8 +1445,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { let view = prepare_view(app, ui, full, &layout); let scroll_target = if hold_scroll_for_preview(app) { None } else { update_caret_scroll(app, &view) }; let motion = scroll_motion(app, ui, full, &view); - let scroll_area = configure_scroll_area(app, ui, &view, motion); - let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, .. } = view; + let (scroll_area, scroll_base) = configure_scroll_area(app, ui, &view, motion); + let CanvasView { area, hruler, vruler, paper, geo, dim_body, grid_story, caret, rescaled, .. } = view; let mut origin = area.min; let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area)); // The bars are drawn with `ui_area`'s style; what the pages draw gets the inherited one back. @@ -1697,7 +1736,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 }, + CanvasFinish { + app, + ui, + layout: &layout, + geo: &geo, + caret, + ime_geometry, + ime_interrupt, + area, + dim_body, + hruler, + vruler, + scroll_base, + rescaled, + }, out, ); } diff --git a/crates/ui-egui/src/control/handler.rs b/crates/ui-egui/src/control/handler.rs index 55421b265c..c2416efda3 100644 --- a/crates/ui-egui/src/control/handler.rs +++ b/crates/ui-egui/src/control/handler.rs @@ -20,6 +20,7 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { json!({ "ui": app.prefs(), "view": app.session.view, + "windows": app.session.windows, "proofing": app.spelling_review, "dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()), "window": [r.width(), r.height()], diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 79717c7c95..33ccaec873 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1129,10 +1129,26 @@ fn paint_view_and_tables(pen: &Pen, name: &str) -> bool { pen.line_c(&[(15.0, 14.0), (12.0, 11.5)], a); pen.line_c(&[(5.0, 14.0), (8.0, 11.5)], 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 1c092e61df..47b4c6b4b2 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -389,6 +389,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("Full Screen", "view.fullScreen"), c("Macros", "tools.macros"), ], diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs index e2fa6671dd..c9226a2c65 100644 --- a/crates/ui-egui/src/ribbon/view.rs +++ b/crates/ui-egui/src/ribbon/view.rs @@ -93,6 +93,23 @@ pub(super) 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 the app's windows (#322; `windows`). + let w = app.session.windows.clone(); + stack(ui, |ui| { + if w.side_by_side.is_none() && w.others.len() > 1 { + menu_button( + ui, + app, + crate::widgets::MenuButtonOptions::new("sideBySide", Some("View Side by Side"), "View Side by Side", false, "view.sideBySide"), + |ui, app| window_list(ui, app, &w.others, "view.sideBySide"), + ); + } else { + let action = crate::widgets::ButtonAction::new("view.sideBySide", json!({}), w.side_by_side.is_some()); + small(ui, app, "sideBySide", Some("View Side by Side"), "View Side by Side", action); + } + let action = crate::widgets::ButtonAction::new("view.syncScroll", json!({}), w.sync_scroll); + small(ui, app, "syncScroll", Some("Synchronous Scrolling"), "Synchronous Scrolling", action); + }); menu_button( ui, app, @@ -186,3 +203,16 @@ pub(super) fn outlining(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "closeOutline", "Close Outline\nView", "outline.close", json!({}), false); }); } + +/// 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(); + } + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 421a3428ab..4acbed1af7 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -36,6 +36,8 @@ use wordcraft_engine::WindowView; use crate::{WordApp, documents, window_ui::WindowUi}; +pub(crate) mod arrange; + /// The window title for a document called `name`. pub(crate) fn title(name: &str, dirty: bool) -> String { format!("{name}{} - WordCraft", if dirty { " •" } else { "" }) @@ -90,6 +92,8 @@ pub struct Windows { input_plugin: bool, /// The viewport driver draws the windows this frame, and titles them. driven: bool, + /// View › Window: Side by Side and Synchronous Scrolling (#322). + arranged: arrange::State, } impl Windows { @@ -113,6 +117,7 @@ impl Windows { builder: egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_min_inner_size([760.0, 480.0]), input_plugin: false, driven: false, + arranged: arrange::State::default(), } } @@ -691,6 +696,7 @@ impl WordApp { return; } self.window_ui(ui); + arrange::forward_scroll(self, &ctx); // Drawing can close the window or bring another forward: the fields then hold another // window's state. if self.windows.shown != key { diff --git a/crates/ui-egui/src/windows/arrange.rs b/crates/ui-egui/src/windows/arrange.rs new file mode 100644 index 0000000000..9ee478dd41 --- /dev/null +++ b/crates/ui-egui/src/windows/arrange.rs @@ -0,0 +1,308 @@ +//! View › Window across the app's windows (#322): Arrange All, View Side by Side and Synchronous +//! Scrolling. The engine's `view.*` window commands decide and record what to do +//! (`Session::windows`, as the drawn window sees it) and push a request; [`request`] carries it +//! out by placing the windows' viewports, and the pairing lives here. Without native windows (the +//! web app, embedded viewports) the commands are disabled. +//! +//! Synchronous Scrolling hands scrolling from one window to its partner as document points: the +//! canvas adds up what the window scrolled itself (`CanvasState::sync_out`); [`forward_scroll`] +//! files it for the partner, and [`poll`] gives the partner what is waiting for it +//! (`CanvasState::sync_in`) before it draws. + +use std::collections::HashMap; + +use egui::{Rect, Vec2, ViewportCommand, ViewportId, pos2, vec2}; +use serde_json::Value; +use wordcraft_engine::cmd::view::OtherWindow; + +use crate::WordApp; + +/// 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; + +/// View Side by Side and Synchronous Scrolling for the app's windows. +#[derive(Default)] +pub(crate) struct State { + /// The two windows (their keys) viewed side by side. + pair: Option<(u64, u64)>, + /// They scroll together. + sync: bool, + /// Scrolling (document points) the partner did, waiting for the window with this key. + waiting: HashMap, +} + +impl State { + /// The window that `key` is viewed beside. + fn partner(&self, key: u64) -> Option { + match self.pair? { + (a, b) if a == key => Some(b), + (a, b) if b == key => Some(a), + _ => None, + } + } + + fn clear(&mut self) { + self.pair = None; + self.sync = false; + self.waiting.clear(); + } +} + +/// As the drawn window starts its frame: tell the session which other windows there are and +/// whether this one is viewed beside one, and give the window the scrolling its partner did. +pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) { + let key = app.windows.shown; + let open: Vec = app.windows.open.iter().map(|w| w.key).collect(); + let st = &mut app.windows.arranged; + if st.pair.is_some_and(|(a, b)| !open.contains(&a) || !open.contains(&b)) { + // One of the two closed. + st.clear(); + } + let partner = st.partner(key); + let sync = partner.is_some() && st.sync; + let waiting = st.waiting.remove(&key).unwrap_or(0.0); + let native = app.windows.native && app.windows.driven; + let others: Vec = if native { + app.switch_windows_entries().into_iter().filter(|(k, ..)| *k != key).map(|(id, title, _)| OtherWindow { id, title }).collect() + } else { + Vec::new() + }; + let w = &mut app.session.windows; + w.available = native; + w.others = others; + w.side_by_side = partner; + w.sync_scroll = sync; + 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); + } +} + +/// After the drawn window drew: what it scrolled itself goes to its partner. +pub(crate) fn forward_scroll(app: &mut WordApp, ctx: &egui::Context) { + let key = app.windows.shown; + let dy = std::mem::take(&mut app.canvas.sync_out); + let st = &mut app.windows.arranged; + let Some(partner) = st.partner(key).filter(|_| st.sync && dy.is_finite() && dy != 0.0) else { return }; + let total = st.waiting.entry(partner).or_default(); + *total = (*total + dy).clamp(-MAX_SCROLL, MAX_SCROLL); + ctx.request_repaint_of(app.windows.viewport(partner)); +} + +/// Carry out a window request a `view.*` command made (`{"windows": …}`). +pub(crate) fn request(app: &mut WordApp, req: &Value) { + let Some(what) = req.get("windows").and_then(Value::as_str) else { return }; + let key = app.windows.shown; + 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(); + } + if let Some(with) = id("with").filter(|w| *w != key && app.windows.get(*w).is_some()) { + let st = &mut app.windows.arranged; + st.waiting.clear(); + st.pair = Some((key, with)); + st.sync = true; + if let Some(area) = monitor_of(app, key) { + let [left, right] = halves(area); + place(app, key, left); + place(app, with, right); + } + } + } + "syncScroll" => { + app.canvas.sync_in = 0.0; + if let Some(on) = req.get("value").and_then(Value::as_bool) { + app.windows.arranged.sync = on; + } + } + "arrange" => { + let Some(area) = monitor_of(app, key) else { return }; + let keys: Vec = app.windows.open.iter().map(|w| w.key).collect(); + for (k, r) in keys.into_iter().zip(tile(app.windows.open.len(), area)) { + place(app, k, r); + } + } + _ => {} + } + if let Some(ctx) = app.ctx.as_ref() { + ctx.request_repaint(); + } +} + +/// The monitor window `key` is on. +fn monitor_of(app: &WordApp, key: u64) -> Option { + if !app.windows.native || !app.windows.driven { + return None; + } + let ctx = app.ctx.as_ref()?; + ctx.input_for(app.windows.viewport(key), |i| monitor_area(i.viewport())) +} + +/// Move window `key`'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(app: &WordApp, key: u64, r: Rect) { + let Some(ctx) = app.ctx.as_ref() else { return }; + let viewport: ViewportId = app.windows.viewport(key); + let border = ctx.input_for(viewport, |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)); + for cmd in [ + ViewportCommand::Fullscreen(false), + ViewportCommand::Minimized(false), + ViewportCommand::Maximized(false), + ViewportCommand::MinInnerSize(inner.min(crate::window_geometry::MIN_SIZE)), + ViewportCommand::InnerSize(inner), + ViewportCommand::OuterPosition(r.min), + ] { + ctx.send_viewport_cmd_to(viewport, cmd); + } +} + +/// The monitor a 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; with monitors of different sizes it can +/// pick the wrong one. +pub(crate) 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(crate) 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(crate) 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 serde_json::json; + + use super::*; + use crate::{Services, WordApp}; + + #[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 side_by_side_scrolls_with_its_partner_only() { + let ctx = egui::Context::default(); + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + app.ctx = Some(ctx.clone()); + app.new_window(); + let keys: Vec = app.windows.open.iter().map(|w| w.key).collect(); + assert_eq!(keys.len(), 2); + let (first, second) = (keys[0], keys[1]); + // Headless: no native viewports, so the commands stay disabled. + poll(&mut app, &ctx); + assert!(!app.session.windows.available); + assert!(app.run("view.sideBySide", json!({})).is_err()); + // Pair the two windows as the request would, then scroll the drawn one. + let shown = app.windows.shown; + let other = if shown == first { second } else { first }; + app.windows.arranged.pair = Some((shown, other)); + app.windows.arranged.sync = true; + poll(&mut app, &ctx); + assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (Some(other), true)); + app.canvas.sync_out = 40.0; + forward_scroll(&mut app, &ctx); + 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, 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); + assert_eq!(app.windows.arranged.waiting.get(&other), Some(&40.0)); + // Scrolling that is out of range is clamped, not trusted. + app.windows.arranged.sync = true; + app.canvas.sync_out = 1.0e30; + forward_scroll(&mut app, &ctx); + 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)); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8dae7daf5f..0025f465ad 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,6 +50,17 @@ 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. +## View › Window + +The app's native windows (one per document, or several on one document) take part in View › +Window (#322): `view.arrangeAll` tiles them over the monitor, `view.sideBySide {value?, window?}` +places this window and another one in the left and right halves and turns on Synchronous +Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?}` lists +and switches documents. `window` is a window id from `ui.inspect` → `windows.others`, the windows +other than the one the command runs in (optional when only one other window is open). The +commands are disabled where there is one window (web, CLI, MCP without the app). Wayland doesn't +let apps move windows, so there Arrange All and Side by Side only resize. + ## Methods | Method | Params | Result | @@ -58,7 +69,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `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"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; 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.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.statusCount` (`{"value": "words"\|"characters"\|"charactersNoSpaces"}`; no value reads it; what the status bar's count shows — words (the default), characters with spaces, or characters without spaces — kept between runs, #2797), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?`, `doc?` | 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; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) | +| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `windows`: the other windows as the drawn window sees them (`available`, `others`, `sideBySide`, `syncScroll`); `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) | | `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 a2ba24d360..62b067883c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -114,7 +114,7 @@ Page drawing indexes text-box warp lookups by paragraph and story for the curren | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | -| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All; a window per open document (the engine holds several open documents and Switch Windows lists and switches them) | | [parity-checklist.md](parity-checklist.md) (View 26/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | +| 27 | **View windows**: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling work across the native windows, one per open document or New Window (#322). Left: Split is a placeholder; Wayland doesn't let apps place windows | | [parity-checklist.md](parity-checklist.md) (View 29/29 ids) | Comparing documents | 2–4 | [ui-parity.md](ui-parity.md) | | 26 | **Minor formats**: saving MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word); MHT opens and documents' `w:altChunk` content is brought in | Rare | 12–23 | [file-format-parity.md](file-format-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); typed `\int`, `\sum`… + Space wait for their limits, and `\int_0^1` corrects on `_`, as observed in Word for Windows on #4938 (#4917); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -325,6 +325,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-10-18 | trivial | Gap 8 narrowed: German text hyphenates by the TeX German patterns (reformed spelling); German spelling and grammar remain | | 2026-10-19 | trivial | Restrict Editing: with Filling in forms enforced, inserting content controls, their Properties and Remove Content Control are refused, as in Word (it greys them out), so a form's fields stay as they are and only their content is filled in | | 2026-10-19 | trivial | Content controls: text typed over a placeholder takes the control's own run properties (`w:sdtPr/w:rPr`, ECMA-376 §17.5.2.27), as in Word, and the placeholder style is recognised by its built-in name "Placeholder Text" whatever its localized id (German Word: `Platzhaltertext`), so entries in forms from localized Word no longer stay grey | +| 2026-10-11 | trivial | #27: Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling landed (#322); New Window and Split left | | 2026-10-17 | trivial | #16: RTL paragraph direction (`w:bidi`) is implemented for HTML/ODT/RTF in #2695 (pending merge) (`dir="rtl"` / CSS `direction:rtl`, `style:writing-mode="rl-tb"`, `\rtlpar`); kashida and the six old RTL reports remain open | | 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) | | 2026-10-19 | trivial | #2964: a centred or end-aligned list label after a leading page break uses its text line's alignment. Synthetic LTR/RTL numbered/bulleted controls and the issue's DOCX export; broader pagination gaps and readiness estimates unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 5708be0658..998d62e09c 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. -**505 of 520 catalog features have commands (97.1%).** +**508 of 520 catalog features have commands (97.7%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | References | 25 | 25 | | Mailings | 23 | 23 | | Review | 29 | 31 | -| View | 26 | 29 | +| View | 29 | 29 | | Read Mode | 2 | 3 | | Developer | 11 | 11 | | Table Design | 8 | 8 | @@ -56,12 +56,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`) - ### Read Mode - View › Layout (`view.readLayout`) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdbbcf1276..f3e7b1b15e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -75,7 +75,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); typed `\int`, `\sum`… + Space wait for their limits (`_a^b`), any character that can't continue `\name` corrects it (`\int_0^1`), and an operator built without an operand takes the caret into it, as observed in Word for Windows on #4938 (#4917, `equation::tests::an_autocorrected_operator_waits_for_its_limits`). 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, arrow, 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 the native windows (#322). Missing: 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). Two lines in one and horizontal in vertical (`w:eastAsianLayout`) are kept on save but not laid out. No enclose characters, Asian line-break options. | @@ -253,6 +253,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29; View row text only, percentages unchanged | | 2026-10-30 | trivial | Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits: `_x^y` then Space gives the operator its limits, Space alone builds it without, and the operand typed next goes inside it; any character that can't continue `\name` corrects it (`\int_0^1`); observed in Word for Windows on #4938 (#4917); no percentage change | | 2026-10-30 | trivial | Tables row: tables nest 18 deep at most, so a `.json` save or AutoRecover snapshot reads back, rich content in the deepest cell included (it was 24, which a `.json` save could not reopen); the .docx reader and writer still keep 24 (#3618); no percentage change | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index fb4467a487..663050c6cb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -54,6 +54,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | 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 | | Headers and footers on the page: double-click the top or bottom margin to edit them, the body to leave | ✅ in Print Layout, also on a page with none yet: the header or footer that page shows is created (first page, even page or the default) and the caret put in it; one left empty is taken away again on leaving and is never saved (#960). Making one is an undo step, refused in a protected document. On a later section's page with no header anywhere it goes in the first section and shows on every section linked to it, as Word links to the previous section by default. With Different First Page (or Odd & Even) on, a double-click goes into the header that page shows even from another header being edited, and Insert › Header/Footer and Edit Header with the caret on page 1 use page 1's own (#2826). Closing it (Esc, Close Header and Footer) puts the caret back where it was in the body, as Word does, instead of on page 1 (#3331); a double-click on the body puts it where clicked. While one is being edited, a single click in the other (the footer while editing the header, or another page's own) edits that one with the caret where clicked, and the view stays put. Web Layout, Draft and Read Mode show no margins and leave it alone. A click in the margin band from the body leaves the caret and the view where they are, so double-clicking a later page's footer opens it there (#2103) | ✅ | 95% | 0.5 | | Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), 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 the native windows; Split is a placeholder; Wayland ignores placement and may only flash for focus | partial | ✅ | 60% | 2–4 | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Document scroll bars | ✅ always drawn beside the page in the room left for them, wherever the pointer is, with a grey thumb that stands out from its track in the light and the dark theme, and more so under the pointer and while dragged; they were invisible until the pointer reached the window's right edge (#5746, `canvas::tests::the_document_scroll_bars_show_with_the_pointer_away`); hidden in Read Mode, as in Word | always shown | 90% | — | @@ -365,6 +366,7 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 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-26 | trivial | View › Window across the native 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) |