mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
780 lines
36 KiB
Rust
780 lines
36 KiB
Rust
use std::cell::RefCell;
|
||
use std::collections::HashMap;
|
||
|
||
use egui::{RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportIdMap, ViewportInfo};
|
||
use serde_json::json;
|
||
use wordcraft_doc::StoryRef;
|
||
use wordcraft_engine::Session;
|
||
|
||
use crate::{Services, WordApp};
|
||
|
||
thread_local! {
|
||
/// What the test backend tells each viewport about itself (focus, close requests).
|
||
static INFOS: RefCell<ViewportIdMap<ViewportInfo>> = RefCell::new(ViewportIdMap::default());
|
||
/// Input events for each viewport's next pass.
|
||
static EVENTS: RefCell<HashMap<ViewportId, Vec<egui::Event>>> = RefCell::new(HashMap::new());
|
||
/// The viewports that drew in the last frame, in order.
|
||
static DRAWN: RefCell<Vec<ViewportId>> = const { RefCell::new(Vec::new()) };
|
||
/// Viewport commands the app sent, by viewport.
|
||
static COMMANDS: RefCell<HashMap<ViewportId, Vec<ViewportCommand>>> = RefCell::new(HashMap::new());
|
||
/// Textures uploaded (page rasters, the font atlas) since the counter was reset.
|
||
static UPLOADS: RefCell<usize> = const { RefCell::new(0) };
|
||
/// Each viewport's last input-method output.
|
||
static IME: RefCell<HashMap<ViewportId, Option<egui::output::IMEOutput>>> = RefCell::new(HashMap::new());
|
||
/// Viewport builders passed to immediate viewports, by viewport id.
|
||
static BUILDERS: RefCell<HashMap<ViewportId, egui::ViewportBuilder>> = RefCell::new(HashMap::new());
|
||
}
|
||
|
||
fn input(id: ViewportId) -> RawInput {
|
||
let mut raw =
|
||
RawInput { viewport_id: id, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), ..Default::default() };
|
||
raw.viewports = INFOS.with(|i| i.borrow().clone());
|
||
raw.focused = raw.viewports.entry(id).or_default().focused.unwrap_or(false);
|
||
raw.events = EVENTS.with(|e| e.borrow_mut().remove(&id).unwrap_or_default());
|
||
raw
|
||
}
|
||
|
||
fn keep(out: egui::FullOutput, id: ViewportId) {
|
||
DRAWN.with(|d| d.borrow_mut().push(id));
|
||
UPLOADS.with(|u| *u.borrow_mut() += out.textures_delta.set.len());
|
||
IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime.clone()));
|
||
for (v, o) in &out.viewport_output {
|
||
COMMANDS.with(|c| c.borrow_mut().entry(*v).or_default().extend(o.commands.iter().cloned()));
|
||
}
|
||
out.drop_without_applying_deltas();
|
||
}
|
||
|
||
/// A backend with native viewports, as eframe's: each immediate viewport runs a pass of its
|
||
/// own, with its own input.
|
||
fn backend() -> egui::Context {
|
||
INFOS.with(|i| i.borrow_mut().clear());
|
||
EVENTS.with(|e| e.borrow_mut().clear());
|
||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||
UPLOADS.with(|u| *u.borrow_mut() = 0);
|
||
BUILDERS.with(|b| b.borrow_mut().clear());
|
||
let ctx = egui::Context::default();
|
||
ctx.set_embed_viewports(false);
|
||
egui::Context::set_immediate_viewport_renderer(|ctx, viewport| {
|
||
let id = viewport.ids.this;
|
||
BUILDERS.with(|b| b.borrow_mut().insert(id, viewport.builder.clone()));
|
||
let mut raw = input(id);
|
||
raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent);
|
||
let mut cb = viewport.viewport_ui_cb;
|
||
keep(ctx.run_ui(raw, |ui| cb(ui)), id);
|
||
});
|
||
ctx
|
||
}
|
||
|
||
/// One frame of every window.
|
||
fn frame(ctx: &egui::Context, a: &mut WordApp) {
|
||
DRAWN.with(|d| d.borrow_mut().clear());
|
||
let mut raw = input(ViewportId::ROOT);
|
||
a.raw_input_hook(ctx, &mut raw);
|
||
let out = ctx.run_ui(raw, |ui| {
|
||
a.logic(ui.ctx());
|
||
a.ui(ui);
|
||
});
|
||
keep(out, ViewportId::ROOT);
|
||
INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear()));
|
||
}
|
||
|
||
fn frames(ctx: &egui::Context, a: &mut WordApp, n: usize) {
|
||
for _ in 0..n {
|
||
frame(ctx, a);
|
||
}
|
||
}
|
||
|
||
/// Focus viewport `id` alone, as the desktop does when a window is clicked.
|
||
fn focus(id: ViewportId) {
|
||
INFOS.with(|i| {
|
||
let mut i = i.borrow_mut();
|
||
for v in i.values_mut() {
|
||
v.focused = Some(false);
|
||
}
|
||
i.entry(id).or_default().focused = Some(true);
|
||
});
|
||
}
|
||
|
||
fn sent(id: ViewportId, cmd: &ViewportCommand) -> bool {
|
||
COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.contains(cmd)))
|
||
}
|
||
|
||
fn app(text: &str) -> WordApp {
|
||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||
a.run("text.insert", json!({"text": text})).unwrap();
|
||
a
|
||
}
|
||
|
||
fn text(a: &WordApp) -> String {
|
||
a.session.doc.plain_text(StoryRef::Body).trim().to_string()
|
||
}
|
||
|
||
fn prompt(a: &WordApp) -> Option<&'static str> {
|
||
a.dialog.as_ref().map(|d| d.name())
|
||
}
|
||
|
||
/// New opens the document in a second native window, focused, with a fresh interface; the
|
||
/// first keeps its document, its ribbon tab and its open dialog, and gets them back when it
|
||
/// is focused again.
|
||
#[test]
|
||
fn new_opens_a_second_window_and_each_keeps_its_state() {
|
||
let ctx = backend();
|
||
let mut a = app("First");
|
||
let first = a.session.document_id();
|
||
frames(&ctx, &mut a, 3);
|
||
a.ui.tab = "Insert".into();
|
||
a.run("ui.dialog", json!({"name": "paragraph"})).unwrap();
|
||
assert_eq!(prompt(&a), Some("paragraph"));
|
||
|
||
a.run("file.new", json!({})).unwrap();
|
||
let second = a.session.document_id();
|
||
assert_ne!(second, first);
|
||
assert_eq!(a.window_count(), 2);
|
||
assert!(a.dialog.is_none() && a.ui.tab == crate::DEFAULT_TAB, "the new window starts afresh");
|
||
frames(&ctx, &mut a, 3);
|
||
let child = a.active_viewport();
|
||
assert_ne!(child, ViewportId::ROOT, "the new window has a viewport of its own");
|
||
assert_eq!(DRAWN.with(|d| d.borrow().clone()), [child, ViewportId::ROOT], "both windows draw each frame");
|
||
frames(&ctx, &mut a, 3);
|
||
assert!(UPLOADS.with(|u| *u.borrow()) > 0, "the pages were rendered");
|
||
UPLOADS.with(|u| *u.borrow_mut() = 0);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(UPLOADS.with(|u| *u.borrow()), 0, "a window whose document didn't change renders no page again");
|
||
assert!(a.sent_title.starts_with("Document2"), "{}", a.sent_title);
|
||
assert_eq!(a.session.document_id(), second);
|
||
a.run("text.insert", json!({"text": "Second"})).unwrap();
|
||
|
||
focus(ViewportId::ROOT);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT);
|
||
assert_eq!(a.session.document_id(), first);
|
||
assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("First".into(), "Insert", Some("paragraph")));
|
||
focus(child);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("Second".into(), crate::DEFAULT_TAB, None));
|
||
}
|
||
|
||
fn docx_on_disk(name: &str, body: &str) -> std::path::PathBuf {
|
||
let dir = std::env::temp_dir().join(format!("wordcraft-windows-{name}-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join(format!("{name}.docx"));
|
||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||
s.run("text.insert", &json!({"text": body})).unwrap();
|
||
s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap();
|
||
path
|
||
}
|
||
|
||
/// Open of a file a window already shows brings that window to the front; Open with only an
|
||
/// untouched blank document open replaces it in its own window.
|
||
#[test]
|
||
fn open_focuses_the_files_window_or_reuses_a_blank_one() {
|
||
let path = docx_on_disk("report", "Report");
|
||
let ctx = backend();
|
||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||
frames(&ctx, &mut a, 2);
|
||
let window = a.windows.shown();
|
||
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||
assert_eq!((a.window_count(), a.windows.shown(), text(&a).as_str()), (1, window, "Report"), "the blank document's window shows it");
|
||
let report = a.session.document_id();
|
||
|
||
a.run("file.new", json!({})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.window_count(), 2);
|
||
assert_ne!(a.session.document_id(), report);
|
||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||
let r = a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
|
||
assert_eq!(r["alreadyOpen"], true);
|
||
assert_eq!((a.window_count(), a.windows.shown(), a.session.document_id()), (2, window, report));
|
||
frames(&ctx, &mut a, 1);
|
||
assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "the file's window comes to the front");
|
||
assert_eq!(a.session.document_id(), report);
|
||
std::fs::remove_dir_all(path.parent().unwrap()).ok();
|
||
}
|
||
|
||
/// A window's close button closes it, asking first about unsaved changes; after Don't Save the
|
||
/// other window comes forward. When the root viewport's window closes, the other window moves
|
||
/// into the root viewport.
|
||
#[test]
|
||
fn closing_a_window_asks_first_and_the_root_adopts_another() {
|
||
let ctx = backend();
|
||
let mut a = app("Kept");
|
||
a.run("file.save", json!({"path": docx_on_disk("kept", "Kept").to_string_lossy()})).unwrap();
|
||
let kept = a.session.document_id();
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
a.run("text.insert", json!({"text": "Dropped"})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
let child = a.active_viewport();
|
||
|
||
INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close));
|
||
frame(&ctx, &mut a);
|
||
assert_eq!(prompt(&a), Some("saveChanges"), "the dirty window asks");
|
||
assert_eq!((a.window_count(), a.active_viewport()), (2, child));
|
||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||
assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, kept, "Kept"));
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT], "the closed window's viewport is gone");
|
||
|
||
a.run("file.new", json!({})).unwrap();
|
||
let other = a.windows.shown();
|
||
frames(&ctx, &mut a, 2);
|
||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||
INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close));
|
||
assert!(a.closes_a_window(), "the host leaves the root's close to the app");
|
||
frame(&ctx, &mut a);
|
||
assert!(sent(ViewportId::ROOT, &ViewportCommand::CancelClose), "the app keeps running");
|
||
assert!(!a.quit_requested);
|
||
assert_eq!((a.window_count(), a.windows.shown()), (1, other));
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the remaining window moved into the root viewport");
|
||
assert_eq!(a.canvas.scroll_request, Some(a.canvas.scroll_offset), "its page stays where it was");
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT]);
|
||
assert!(a.has_document() && a.session.documents().len() == 1);
|
||
}
|
||
|
||
/// A request an agent's command makes for the document of another window is that window's:
|
||
/// Print opens the Print page there when it draws, and the active window and document stay
|
||
/// as they are, so the agent's next command works where it did.
|
||
#[test]
|
||
fn a_request_for_another_windows_document_is_handled_there() {
|
||
let ctx = backend();
|
||
let mut a = app("First");
|
||
let first = a.session.document_id();
|
||
let first_window = a.windows.shown();
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
let second = a.session.document_id();
|
||
frames(&ctx, &mut a, 2);
|
||
let child = a.active_viewport();
|
||
a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap();
|
||
for req in std::mem::take(&mut a.session.ui_requests) {
|
||
assert_eq!(req["document"], first);
|
||
a.handle_request(&req);
|
||
}
|
||
assert_eq!((a.session.document_id(), a.active_viewport()), (second, child), "the active document stays");
|
||
assert!(!a.ui.backstage);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!((a.session.document_id(), a.active_viewport()), (second, child));
|
||
assert!(!a.ui.backstage, "the active window keeps its page");
|
||
let other = a.windows.get(first_window).and_then(|w| w.ui.as_ref()).map(|w| (w.backstage, w.backstage_page.as_str()));
|
||
assert_eq!(other, Some((true, "print")), "the Print page opened in the document's window");
|
||
}
|
||
|
||
/// Quitting from the Start screen's empty window asks about the documents other windows
|
||
/// show (one an agent made and typed in).
|
||
#[test]
|
||
fn quitting_from_the_empty_window_asks_about_the_other_windows() {
|
||
let ctx = backend();
|
||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.close", json!({})).unwrap();
|
||
assert!(!a.has_document());
|
||
let empty = a.windows.shown();
|
||
let placeholder = a.session.document_id();
|
||
let other = a.session.with_document(placeholder, |s| s.add_document(wordcraft_doc::Document::new())).unwrap();
|
||
a.session.with_document(other, |s| s.run("text.insert", &json!({"text": "Agent"}))).unwrap().unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!((a.window_count(), a.windows.shown()), (2, empty), "the agent's document has a window behind");
|
||
assert!(!a.has_document());
|
||
|
||
assert!(!a.quit());
|
||
assert_eq!((a.session.document_id(), prompt(&a), text(&a).as_str()), (other, Some("saveChanges"), "Agent"));
|
||
assert_ne!(a.windows.shown(), empty, "the document's own window asks");
|
||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||
assert!(a.quit_requested);
|
||
}
|
||
|
||
/// Mod+W in a window with no changes closes it; the keys typed after it in the same frame
|
||
/// don't reach the window that comes forward.
|
||
#[test]
|
||
fn keys_after_closing_a_window_go_nowhere() {
|
||
let ctx = backend();
|
||
let mut a = app("First");
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
let child = a.active_viewport();
|
||
focus(child);
|
||
frames(&ctx, &mut a, 8);
|
||
assert!(a.canvas.focused);
|
||
let mac = cfg!(target_os = "macos");
|
||
let modifiers = egui::Modifiers { ctrl: !mac, mac_cmd: mac, command: true, ..Default::default() };
|
||
EVENTS.with(|e| {
|
||
e.borrow_mut().entry(child).or_default().extend([
|
||
egui::Event::Key { key: egui::Key::W, physical_key: None, pressed: true, repeat: false, modifiers },
|
||
egui::Event::Text("x".into()),
|
||
])
|
||
});
|
||
frame(&ctx, &mut a);
|
||
assert_eq!(a.window_count(), 1, "Mod+W closed the window");
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(text(&a), "First");
|
||
}
|
||
|
||
/// A window whose document closed without the windows following (an agent closed it) isn't
|
||
/// drawn or made active; the next frame closes it.
|
||
#[test]
|
||
fn a_window_whose_document_closed_is_not_entered() {
|
||
let ctx = backend();
|
||
let mut a = app("First");
|
||
let first = a.session.document_id();
|
||
let first_window = a.windows.shown();
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
let (second, second_window) = (a.session.document_id(), a.windows.shown());
|
||
frames(&ctx, &mut a, 2);
|
||
a.session.close_document(first).unwrap();
|
||
assert!(!a.enter_window(first_window));
|
||
assert_eq!((a.windows.shown(), a.session.document_id()), (second_window, second));
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!((a.window_count(), a.session.document_id()), (1, second));
|
||
}
|
||
|
||
/// A composition in one window shows and commits only there: the other window draws no
|
||
/// composition and its document gets none of the text.
|
||
#[test]
|
||
fn a_composition_stays_in_its_window() {
|
||
let ctx = backend();
|
||
let mut a = app("A");
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
a.run("text.insert", json!({"text": "B"})).unwrap();
|
||
let b = a.active_viewport();
|
||
focus(ViewportId::ROOT);
|
||
frames(&ctx, &mut a, 8);
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT);
|
||
assert!(a.canvas.focused, "the page has the keyboard");
|
||
EVENTS.with(|e| {
|
||
e.borrow_mut()
|
||
.entry(ViewportId::ROOT)
|
||
.or_default()
|
||
.push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほん".into(), active_range_chars: None }))
|
||
});
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.canvas.ime_preedit, "にほん");
|
||
let other = a.windows.open.iter().find_map(|w| w.ui.as_ref());
|
||
assert!(other.is_some_and(|w| w.canvas.ime_preedit.is_empty()), "the other window composes nothing");
|
||
assert!(IME.with(|i| i.borrow().get(&ViewportId::ROOT).cloned().flatten()).is_some(), "the composing window places the candidate window");
|
||
|
||
focus(b);
|
||
frames(&ctx, &mut a, 2);
|
||
assert!(a.canvas.ime_preedit.is_empty(), "the focused window has no composition");
|
||
EVENTS.with(|e| e.borrow_mut().entry(ViewportId::ROOT).or_default().push(egui::Event::Ime(egui::ImeEvent::Commit("にほん".into()))));
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(text(&a), "B", "a commit in the other window doesn't land here");
|
||
}
|
||
|
||
/// Control-channel typing goes to the active window when it has a viewport of its own (the
|
||
/// root's raw-input hook doesn't see that window's input), once.
|
||
#[test]
|
||
fn control_typing_reaches_the_active_window() {
|
||
let ctx = backend();
|
||
let mut a = app("First");
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
let child = a.active_viewport();
|
||
focus(child);
|
||
frames(&ctx, &mut a, 8);
|
||
assert!(a.canvas.focused, "the new window's page has the keyboard");
|
||
let (req, _reply) = crate::ControlRequest::new("ui.text", json!({"text": "Typed"}));
|
||
assert!(matches!(crate::control::handle(&mut a, &ctx, &req), crate::control::Outcome::Done(_)));
|
||
frames(&ctx, &mut a, 4);
|
||
assert_eq!(text(&a), "Typed");
|
||
assert!(a.synthetic.is_empty());
|
||
focus(ViewportId::ROOT);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(text(&a), "First", "the other window got none of it");
|
||
}
|
||
|
||
/// `ui.screenshot` captures the window in the root viewport: capturing a secondary document
|
||
/// window in a separate native viewport is not supported by the viewport renderer and is refused
|
||
/// immediately at admission with an error directing to `view.switchWindows` (#6159). A capture
|
||
/// still pending when another window comes forward fails with an error instead of capturing that
|
||
/// window. When a window is adopted into the root viewport after the first window closes, it can
|
||
/// be captured.
|
||
#[test]
|
||
fn screenshot_captures_the_root_window_and_refuses_secondary_windows() {
|
||
let ctx = backend();
|
||
let mut a = app("First");
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
let child = a.active_viewport();
|
||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||
INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect));
|
||
focus(child);
|
||
frames(&ctx, &mut a, 4);
|
||
|
||
let (reply, replies) = std::sync::mpsc::channel();
|
||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||
let v = replies.try_recv().expect("a secondary window capture is refused at admission");
|
||
assert_eq!(v["ok"], false, "{v}");
|
||
let err = v["error"].as_str().expect("error message");
|
||
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error explains why: {err}");
|
||
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error tells how to activate the root window: {err}");
|
||
assert!(a.shots.requests.is_empty(), "refused capture is never queued");
|
||
|
||
focus(ViewportId::ROOT);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the root window is now active");
|
||
let (reply, replies) = std::sync::mpsc::channel();
|
||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||
assert!(replies.try_recv().is_err(), "the capture of the root window is admitted");
|
||
assert_eq!(a.shots.requests.len(), 1);
|
||
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
|
||
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
|
||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||
frames(&ctx, &mut a, 4);
|
||
let shot_to =
|
||
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
|
||
assert!(shot_to(ViewportId::ROOT), "the root window is captured");
|
||
assert!(!shot_to(child), "the child window is not");
|
||
|
||
let (reply, later) = std::sync::mpsc::channel();
|
||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||
focus(child);
|
||
frames(&ctx, &mut a, 2);
|
||
let v = later.try_recv().expect("the capture was answered");
|
||
assert_eq!(v["ok"], false);
|
||
assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}");
|
||
assert!(a.shots.requests.is_empty());
|
||
|
||
// When the first window closes, the child window is adopted into the root viewport:
|
||
let first_key = a.windows.open.iter().find(|w| w.id == a.windows.viewports.as_ref().unwrap().root()).unwrap().key;
|
||
let child_id = a.windows.open.iter().find(|w| w.key != first_key).unwrap().id;
|
||
a.close_window(first_key);
|
||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.window_count(), 1);
|
||
assert_eq!(a.windows.viewports.as_ref().unwrap().root(), child_id);
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "the adopted window now occupies the root viewport");
|
||
let (reply, replies) = std::sync::mpsc::channel();
|
||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||
assert!(replies.try_recv().is_err(), "the adopted window's capture is admitted");
|
||
assert_eq!(a.shots.requests.len(), 1);
|
||
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
|
||
}
|
||
|
||
/// #6159: when two windows are open on the same document (`view.newWindow`), `ui.screenshot`
|
||
/// while the secondary window is active is refused at admission. Using `view.switchWindows` with
|
||
/// `window` selects the root window, allowing a screenshot retry to be admitted and issued.
|
||
#[test]
|
||
fn screenshot_refuses_secondary_window_on_same_document_and_recovers_via_switch_windows() {
|
||
let ctx = backend();
|
||
let mut a = app("Shared Doc");
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("view.newWindow", json!({})).unwrap();
|
||
let child = a.active_viewport();
|
||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||
INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect));
|
||
focus(child);
|
||
frames(&ctx, &mut a, 4);
|
||
assert_ne!(a.active_viewport(), ViewportId::ROOT);
|
||
|
||
let (reply, replies) = std::sync::mpsc::channel();
|
||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||
let v = replies.try_recv().expect("secondary window screenshot refused at admission");
|
||
assert_eq!(v["ok"], false, "{v}");
|
||
let err = v["error"].as_str().expect("error message");
|
||
assert!(err.contains("capturing a secondary document window is not supported by the viewport renderer"), "error: {err}");
|
||
assert!(err.contains("view.switchWindows {\"window\": 1}"), "error recommends root window selection: {err}");
|
||
|
||
// Recovery path: select the root window via view.switchWindows {"window": 1}
|
||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||
a.run("view.switchWindows", json!({"window": 1})).unwrap();
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "switchWindows selected the root window");
|
||
// Simulate the desktop applying the focus request:
|
||
focus(ViewportId::ROOT);
|
||
frames(&ctx, &mut a, 2);
|
||
let root_focused = COMMANDS.with(|c| c.borrow().get(&ViewportId::ROOT).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Focus))));
|
||
assert!(root_focused, "switchWindows requested focus on root viewport");
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT, "root window is now active");
|
||
|
||
let (reply, replies) = std::sync::mpsc::channel();
|
||
crate::control::admit_screenshot(&mut a, &ctx, None, reply);
|
||
assert!(replies.try_recv().is_err(), "screenshot admitted on retry");
|
||
assert_eq!(a.shots.requests.len(), 1);
|
||
assert_eq!(a.shots.requests[0].viewport, ViewportId::ROOT);
|
||
|
||
a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0);
|
||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||
frames(&ctx, &mut a, 4);
|
||
let shot_to =
|
||
|id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_)))));
|
||
assert!(shot_to(ViewportId::ROOT), "root window screenshot was issued");
|
||
assert!(!shot_to(child), "secondary window was not issued");
|
||
}
|
||
|
||
/// Quitting with two documents of unsaved changes asks about each in its window, the window
|
||
/// coming to the front; Cancel keeps both.
|
||
#[test]
|
||
fn quitting_asks_about_each_document_in_its_window() {
|
||
let ctx = backend();
|
||
let mut a = app("One");
|
||
let one = a.session.document_id();
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.new", json!({})).unwrap();
|
||
a.run("text.insert", json!({"text": "Two"})).unwrap();
|
||
let two = a.session.document_id();
|
||
let child = a.active_viewport();
|
||
frames(&ctx, &mut a, 2);
|
||
|
||
assert!(!a.quit());
|
||
assert_eq!((a.session.document_id(), prompt(&a)), (two, Some("saveChanges")), "the active window asks first");
|
||
a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
|
||
assert!(!a.quit_requested);
|
||
assert_eq!((a.window_count(), a.session.documents().len()), (2, 2), "Cancel keeps both");
|
||
|
||
assert!(!a.quit());
|
||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||
assert_eq!((a.session.document_id(), prompt(&a)), (one, Some("saveChanges")), "then the other window asks");
|
||
assert_eq!(a.window_count(), 1);
|
||
COMMANDS.with(|c| c.borrow_mut().clear());
|
||
frame(&ctx, &mut a);
|
||
assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "its window comes to the front");
|
||
assert!(!DRAWN.with(|d| d.borrow().contains(&child)));
|
||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||
assert!(a.quit_requested);
|
||
}
|
||
|
||
fn window_labels(a: &WordApp) -> Vec<String> {
|
||
let mut l: Vec<String> = a.switch_windows_entries().into_iter().map(|(_, label, _)| label).collect();
|
||
l.sort();
|
||
l
|
||
}
|
||
|
||
/// View › New Window opens a second window on the document, titled `Name:1` / `Name:2`. Text
|
||
/// typed in one shows in the other; each keeps its own caret and zoom. Typing in a window
|
||
/// whose caret was in text the other window deleted lands at the end of what is left, and
|
||
/// Undo in that window undoes its own typing, then the other window's delete.
|
||
#[test]
|
||
fn new_window_shows_the_document_twice_each_with_its_own_caret_and_zoom() {
|
||
let ctx = backend();
|
||
let mut a = app("Hello world");
|
||
let doc = a.session.document_id();
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("view.newWindow", json!({})).unwrap();
|
||
assert_eq!((a.window_count(), a.session.document_id()), (2, doc));
|
||
frames(&ctx, &mut a, 3);
|
||
let b = a.active_viewport();
|
||
assert_ne!(b, ViewportId::ROOT);
|
||
assert_eq!(window_labels(&a), ["Document1:1 •", "Document1:2 •"]);
|
||
assert_eq!(a.window_title(), super::title("Document1:2", true));
|
||
|
||
a.run("view.zoom", json!({"value": 150})).unwrap();
|
||
a.run("text.insert", json!({"text": "!"})).unwrap();
|
||
focus(ViewportId::ROOT);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.active_viewport(), ViewportId::ROOT);
|
||
assert_eq!(text(&a), "Hello world!", "the edit shows in the other window");
|
||
assert_eq!((a.session.view.zoom, a.session.sel.focus.off), (1.0, 11), "window A keeps its zoom and caret");
|
||
|
||
a.run("select.text", json!({"text": " world!"})).unwrap();
|
||
a.run("text.delete", json!({})).unwrap();
|
||
focus(b);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.active_viewport(), b);
|
||
assert!((a.session.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom");
|
||
a.run("text.insert", json!({"text": "?"})).unwrap();
|
||
assert_eq!(text(&a), "Hello?", "B's caret was clamped into what is left");
|
||
a.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(text(&a), "Hello");
|
||
a.run("edit.undo", json!({})).unwrap();
|
||
assert_eq!(text(&a), "Hello world!", "Undo in B undoes A's delete");
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.session.documents().len(), 1);
|
||
}
|
||
|
||
/// Closing one of two windows on a document asks nothing, even with unsaved changes, and the
|
||
/// other window goes back to the plain name; closing the last one asks. Quitting with a
|
||
/// document in two windows asks once.
|
||
#[test]
|
||
fn closing_one_of_two_windows_on_a_document_asks_nothing() {
|
||
let ctx = backend();
|
||
let mut a = app("Kept");
|
||
let doc = a.session.document_id();
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("view.newWindow", json!({})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
let child = a.active_viewport();
|
||
INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close));
|
||
frame(&ctx, &mut a);
|
||
assert_eq!(prompt(&a), None, "no prompt while another window shows the document");
|
||
assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, doc, "Kept"));
|
||
assert!(a.session.dirty);
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.window_title(), super::title("Document1", true));
|
||
a.run("file.close", json!({})).unwrap();
|
||
assert_eq!(prompt(&a), Some("saveChanges"), "the last window asks");
|
||
a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
|
||
|
||
a.run("view.newWindow", json!({})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.window_count(), 2);
|
||
assert!(!a.quit());
|
||
assert_eq!(prompt(&a), Some("saveChanges"));
|
||
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
|
||
assert!(a.quit_requested, "one prompt for the document");
|
||
}
|
||
|
||
/// #3274: a programmatic `file.close` on a document shown in two windows closes the window
|
||
/// alone, as the window's own close does: the document and its work stay open, so nothing is
|
||
/// lost and the control channel must not refuse it as unsaved work it would never lose.
|
||
#[test]
|
||
fn a_programmatic_close_of_one_of_two_windows_on_a_document_is_accepted() {
|
||
let ctx = backend();
|
||
let mut a = app("Kept");
|
||
let doc = a.session.document_id();
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("view.newWindow", json!({})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.window_count(), 2);
|
||
assert!(a.session.dirty, "the document has unsaved work");
|
||
|
||
// The control channel's refusal: it must not stand in the way of a close that keeps the
|
||
// document open.
|
||
let refused = crate::control::close_refused(&a, "file.close", &json!({}));
|
||
assert!(refused.is_none(), "closing a window alone loses nothing: {refused:?}");
|
||
|
||
// And the command itself runs, closing the window and leaving the document and its work.
|
||
let r = crate::control::handle(&mut a, &ctx, &crate::control::ControlRequest::new("engine.execute", json!({"command": "file.close"})).0);
|
||
let r = match r {
|
||
crate::control::Outcome::Done(v) => v,
|
||
_ => json!({"ok": false, "error": "not a reply"}),
|
||
};
|
||
assert_eq!(r["ok"], true, "{r}");
|
||
assert_eq!(a.window_count(), 1, "the window closed");
|
||
assert_eq!(a.session.document_id(), doc, "the document stays open");
|
||
assert!(a.session.dirty, "its work stays");
|
||
assert_eq!(text(&a), "Kept");
|
||
|
||
// The last window closes the document, so the same command is refused for its unsaved work.
|
||
let refused = crate::control::close_refused(&a, "file.close", &json!({}));
|
||
assert!(refused.is_some(), "closing the document would lose the work: {refused:?}");
|
||
}
|
||
|
||
/// Two windows, the second (focused) on an untouched blank document: an app whose host
|
||
/// answers file dialogs only when the test does, plus the dialog's answer channel.
|
||
fn blank_beside_a_document() -> (egui::Context, WordApp, Shown, u64) {
|
||
let shown: Shown = std::rc::Rc::default();
|
||
let log = shown.clone();
|
||
let services = Services {
|
||
file_dialog: Some(Box::new(move |_| {
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
log.borrow_mut().push(tx);
|
||
rx
|
||
})),
|
||
..Default::default()
|
||
};
|
||
let ctx = backend();
|
||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
|
||
frames(&ctx, &mut a, 2);
|
||
a.run("file.open", json!({"path": docx_on_disk("first", "First").to_string_lossy()})).unwrap();
|
||
a.run("file.new", json!({})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
assert!(a.window_count() == 2 && a.session.is_untouched_blank());
|
||
let blank = a.windows.shown();
|
||
(ctx, a, shown, blank)
|
||
}
|
||
|
||
type Shown = std::rc::Rc<RefCell<Vec<std::sync::mpsc::Sender<crate::file_dialogs::FileDialogAnswer>>>>;
|
||
|
||
/// Each way of opening a file replaces an untouched blank document in its own window, even
|
||
/// when the file arrives while another window draws: File › Open, ⌘O and the File page's
|
||
/// Browse (an asynchronous dialog), the File page's and Start screen's recent list, and
|
||
/// Finder (between frames, in the focused window).
|
||
#[test]
|
||
fn every_open_replaces_an_untouched_blank_document_in_its_window() {
|
||
let second = docx_on_disk("second", "Second");
|
||
let path = second.to_string_lossy().to_string();
|
||
let opens: [(&str, fn(&mut WordApp, &egui::Context, &Shown, &str)); 3] = [
|
||
("dialog", |a, ctx, shown, path| {
|
||
a.run("ui.backstage", json!({"value": true, "page": "open"})).unwrap();
|
||
a.run("ui.openFileDialog", json!({})).unwrap();
|
||
frames(ctx, a, 2);
|
||
let tx = shown.borrow_mut().pop().unwrap();
|
||
tx.send(Ok(vec![path.to_string()])).unwrap();
|
||
frames(ctx, a, 3);
|
||
}),
|
||
("recent", |a, ctx, _, path| {
|
||
crate::backstage::open_recent(a, path).unwrap();
|
||
frames(ctx, a, 2);
|
||
}),
|
||
("finder", |a, ctx, _, path| {
|
||
a.open_file(path, None).unwrap();
|
||
frames(ctx, a, 2);
|
||
}),
|
||
];
|
||
for (how, open) in opens {
|
||
let (ctx, mut a, shown, blank) = blank_beside_a_document();
|
||
open(&mut a, &ctx, &shown, &path);
|
||
assert_eq!(a.window_count(), 2, "{how}: no third window");
|
||
assert!(a.enter_window(blank), "{how}: the blank document's window is still open");
|
||
assert_eq!(text(&a), "Second", "{how}: it shows the opened file");
|
||
}
|
||
std::fs::remove_dir_all(second.parent().unwrap()).ok();
|
||
}
|
||
|
||
/// Opening a second document when another is already open constrains the new window's requested
|
||
/// dimensions so that it fits within the active monitor's bounds, even on displays with high
|
||
/// DPI scaling (such as 2560x1440 at 200% scale = 1280x720 pt) where the unconstrained default
|
||
/// size (1440x920 pt) would extend past the right edge (#5871).
|
||
#[test]
|
||
fn newly_opened_window_does_not_exceed_screen_width() {
|
||
let doc1 = docx_on_disk("doc1", "Doc One");
|
||
let doc2 = docx_on_disk("doc2", "Doc Two");
|
||
let ctx = backend();
|
||
|
||
// Simulate 2560x1440 at 200% scale (1280x720 pt).
|
||
let screen = egui::vec2(1280.0, 720.0);
|
||
let frame_decorations = egui::vec2(16.0, 39.0);
|
||
INFOS.with(|i| {
|
||
let mut infos = i.borrow_mut();
|
||
let root = infos.entry(ViewportId::ROOT).or_default();
|
||
root.monitor_size = Some(screen);
|
||
root.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(48.0, 48.0), egui::vec2(1168.0, 585.0)));
|
||
root.outer_rect = Some(egui::Rect::from_min_size(egui::pos2(40.0, 17.0), egui::vec2(1184.0, 624.0)));
|
||
});
|
||
|
||
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default());
|
||
frames(&ctx, &mut a, 2);
|
||
|
||
// Open first document in the primary window.
|
||
a.run("file.open", json!({"path": doc1.to_string_lossy()})).unwrap();
|
||
frames(&ctx, &mut a, 2);
|
||
assert_eq!(a.window_count(), 1);
|
||
|
||
// Open second document, creating a second window while the first is active.
|
||
a.run("file.open", json!({"path": doc2.to_string_lossy()})).unwrap();
|
||
frames(&ctx, &mut a, 3);
|
||
assert_eq!(a.window_count(), 2);
|
||
|
||
// Find the immediate viewport created for the new window.
|
||
let child_builder = BUILDERS.with(|b| {
|
||
b.borrow()
|
||
.iter()
|
||
.find(|(id, _)| **id != ViewportId::ROOT)
|
||
.map(|(_, builder)| builder.clone())
|
||
.expect("child window viewport builder was recorded")
|
||
});
|
||
|
||
let inner_size = child_builder.inner_size.expect("child window has an inner size requested");
|
||
assert!(inner_size.x <= 1168.0, "new window width ({}) must not exceed available width on 1280pt monitor (1168pt)", inner_size.x);
|
||
assert!(
|
||
inner_size.x + frame_decorations.x <= screen.x,
|
||
"new window outer width ({} + {}) must fit within screen width ({})",
|
||
inner_size.x,
|
||
frame_decorations.x,
|
||
screen.x
|
||
);
|
||
assert!(
|
||
inner_size.y + frame_decorations.y <= screen.y,
|
||
"new window outer height ({} + {}) must fit within screen height ({})",
|
||
inner_size.y,
|
||
frame_decorations.y,
|
||
screen.y
|
||
);
|
||
|
||
std::fs::remove_dir_all(doc1.parent().unwrap()).ok();
|
||
std::fs::remove_dir_all(doc2.parent().unwrap()).ok();
|
||
}
|