mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
When the browser dropped the page's WebGPU device or WebGL context, the editor kept running but nothing it drew reached the screen: Chrome and WebKit showed a frozen, stale canvas and Firefox a blank page, while edits and Save went on. - ui-egui: new `graphics` module. `GraphicsLoss` carries a loss report from any thread (wgpu's device-lost callback, the canvas's `webglcontextlost` event) to the UI thread. `VectorcraftApp:: graphics_lost` writes Data Recovery copies of the modified documents at once. The app now notices when the host hands it a new egui context and uploads everything again (fonts and theme, the canvas, place cursor thumbnails); every per-thread texture cache (layer, symbol, swatch, style, asset and glyph thumbnails, previews, font samples, Navigator, Flattener Preview) is a `TexCache` that empties for a new context. Without this, the moved app drew stale or wrong textures and egui panicked on its missing font families. - web: the shell watches both loss signals; on a loss it puts a fresh canvas in place of the dead one and starts the same `WebRunner` again, handing over the same app (documents, undo, panels), with services made for the new context and a status-bar note. If no new graphics can be had (or after three losses) it removes the stale canvas, lets go of the recovery copies and says to reload. - engine: `recovery::leave` lets go of a session's area but keeps its copies, so the reloaded page offers them at once instead of after the heartbeat goes stale. - desktop: eframe can't give a window a new device, so a lost device (driver reset) is logged, the copies are written and the status bar says to save and restart; closing then skips the Save Changes question it can't show. - Tests: loss reporting across threads, `TexCache` emptying, the app uploading the canvas, a cached preview, theme and fonts again in a new context (fails without the fix), copies written on loss, and `recovery::leave` offering copies at once with both lock and heartbeat stores. docs/development.md (web section) and ROADMAP updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
227 lines
9.5 KiB
Rust
227 lines
9.5 KiB
Rust
//! The system clipboard's formats through a headless fake service (M4.57): what Copy publishes,
|
||
//! what Paste takes from other apps (bitmaps, PDF, text), the Paste menu and keyboard paste.
|
||
|
||
use std::cell::RefCell;
|
||
use std::rc::Rc;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{Node, NodeKind};
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_engine::cmd::clipboard::{BITMAP, Flavour, PDF, PNG, SVG, TEXT};
|
||
use vectorcraft_geom::Point;
|
||
|
||
use crate::{Services, SystemClipboard, VectorcraftApp, menus};
|
||
|
||
/// The fake clipboard's contents: what the app wrote, or what "another app" copied.
|
||
#[derive(Default)]
|
||
pub(super) struct Board {
|
||
flavours: Vec<Flavour>,
|
||
ours: bool,
|
||
}
|
||
|
||
struct Fake(Rc<RefCell<Board>>);
|
||
|
||
impl Fake {
|
||
fn find(&self, mimes: &[&'static str]) -> Option<Flavour> {
|
||
let b = self.0.borrow();
|
||
let matches = |m: &str, f: &Flavour| f.mime == m || (m == BITMAP && f.mime.starts_with("image/") && f.mime != SVG);
|
||
mimes.iter().find_map(|m| b.flavours.iter().find(|f| matches(m, f)).cloned())
|
||
}
|
||
}
|
||
|
||
impl SystemClipboard for Fake {
|
||
fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> {
|
||
*self.0.borrow_mut() = Board { flavours: flavours.to_vec(), ours: true };
|
||
Ok(())
|
||
}
|
||
fn holds_ours(&mut self) -> bool {
|
||
self.0.borrow().ours
|
||
}
|
||
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
|
||
self.find(mimes)
|
||
}
|
||
fn has(&mut self, mimes: &[&'static str]) -> bool {
|
||
self.find(mimes).is_some()
|
||
}
|
||
}
|
||
|
||
/// An app with a document and a fake system clipboard, the view centred on (250, 180).
|
||
pub(super) fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
|
||
let board = Rc::new(RefCell::new(Board::default()));
|
||
let services = Services { system_clipboard: Some(Box::new(Fake(board.clone()))), ..Default::default() };
|
||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||
run(&mut app, "file.new", json!({"width": 400, "height": 300}));
|
||
let v = app.view_mut().unwrap();
|
||
(v.center, v.fitted) = (Point::new(250.0, 180.0), true);
|
||
(app, board)
|
||
}
|
||
|
||
/// Another app copies `flavours`.
|
||
pub(super) fn copy_elsewhere(board: &Rc<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
|
||
let flavours = flavours.into_iter().map(|(mime, data)| Flavour { mime, data }).collect();
|
||
*board.borrow_mut() = Board { flavours, ours: false };
|
||
}
|
||
|
||
pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||
app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||
}
|
||
|
||
fn copy_rect(app: &mut VectorcraftApp) {
|
||
let id = run(app, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 20}))["id"].clone();
|
||
run(app, "paint.setFill", json!({"color": "#ff0000"}));
|
||
run(app, "select.set", json!({"ids": [id]}));
|
||
run(app, "edit.copy", json!({}));
|
||
}
|
||
|
||
fn written(board: &Rc<RefCell<Board>>) -> Vec<&'static str> {
|
||
board.borrow().flavours.iter().map(|f| f.mime).collect()
|
||
}
|
||
|
||
/// The objects the last paste selected.
|
||
pub(super) fn pasted(app: &VectorcraftApp) -> Vec<Node> {
|
||
let st = app.session.active().unwrap();
|
||
st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect()
|
||
}
|
||
|
||
fn count(app: &VectorcraftApp) -> usize {
|
||
app.session.active().unwrap().doc.layers[0].children().unwrap().len()
|
||
}
|
||
|
||
/// A 4 × 2 px blue PNG.
|
||
pub(super) fn blue_png() -> Vec<u8> {
|
||
let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255]));
|
||
let mut png = vec![];
|
||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||
png
|
||
}
|
||
|
||
/// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`,
|
||
/// in the test's one context (in a new one the app starts its fonts over).
|
||
fn frame(app: &mut VectorcraftApp, events: Vec<egui::Event>) {
|
||
thread_local! {
|
||
static CTX: egui::Context = egui::Context::default();
|
||
}
|
||
let ctx = CTX.with(Clone::clone);
|
||
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| app.logic(ui.ctx())).textures_delta.clear();
|
||
}
|
||
|
||
fn key(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
|
||
egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
|
||
}
|
||
|
||
#[test]
|
||
fn copy_publishes_pdf_to_the_service_only_with_the_preference_on() {
|
||
let (mut app, board) = app();
|
||
copy_rect(&mut app);
|
||
assert_eq!(written(&board), [TEXT, SVG, PNG]);
|
||
assert!(String::from_utf8_lossy(&board.borrow().flavours[0].data).contains("<svg"));
|
||
run(&mut app, "prefs.set", json!({"key": "copyAsPdf", "value": true}));
|
||
run(&mut app, "edit.cut", json!({}));
|
||
assert_eq!(written(&board), [TEXT, SVG, PDF, PNG]);
|
||
assert!(board.borrow().flavours[2].data.starts_with(b"%PDF"));
|
||
// Nothing goes through egui's text output when the service is there.
|
||
assert!(app.clipboard_out.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn our_own_copy_pastes_the_lossless_internal_clipboard() {
|
||
let (mut app, board) = app();
|
||
copy_rect(&mut app);
|
||
// As if the clipboard held only the PNG: still ours, so the rectangle comes back as a path.
|
||
board.borrow_mut().flavours.retain(|f| f.mime == PNG);
|
||
run(&mut app, "edit.paste", json!({}));
|
||
assert!(matches!(pasted(&app)[0].kind, NodeKind::Path { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn a_bitmap_from_another_app_pastes_as_an_embedded_image_at_the_view_centre() {
|
||
let (mut app, board) = app();
|
||
copy_rect(&mut app);
|
||
copy_elsewhere(&board, vec![(PNG, blue_png())]);
|
||
run(&mut app, "edit.paste", json!({}));
|
||
let n = &pasted(&app)[0];
|
||
let NodeKind::Image(im) = &n.kind else { panic!("not an image: {:?}", n.kind) };
|
||
assert!(im.link.is_none() && (im.width, im.height) == (4, 2));
|
||
let b = n.geometric_bounds().unwrap();
|
||
assert!((b.center() - Point::new(250.0, 180.0)).hypot() < 1e-6, "{b:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn text_from_another_app_pastes_as_point_text_and_svg_text_as_art() {
|
||
let (mut app, board) = app();
|
||
// Word processors offer a picture of the text too: the text wins.
|
||
copy_elsewhere(&board, vec![(TEXT, b"Pasted words".to_vec()), (PNG, blue_png())]);
|
||
run(&mut app, "edit.paste", json!({}));
|
||
let NodeKind::Text(t) = &pasted(&app)[0].kind else { panic!("not type") };
|
||
assert_eq!(t.plain_text(), "Pasted words");
|
||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="5" r="5"/><circle cx="20" cy="5" r="5"/></svg>"##;
|
||
copy_elsewhere(&board, vec![(TEXT, svg.as_bytes().to_vec())]);
|
||
run(&mut app, "edit.paste", json!({}));
|
||
assert_eq!(pasted(&app).len(), 2);
|
||
assert!(pasted(&app).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||
// Text that can't be pasted pastes nothing (not the stale internal clipboard).
|
||
let before = count(&app);
|
||
copy_elsewhere(&board, vec![(TEXT, b" \r\n".to_vec())]);
|
||
assert!(app.run("edit.paste", json!({})).is_err());
|
||
assert!(app.ui.status.starts_with("Couldn't paste"), "{}", app.ui.status);
|
||
assert_eq!(count(&app), before);
|
||
}
|
||
|
||
#[test]
|
||
fn a_pdf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() {
|
||
let (mut app, board) = app();
|
||
let mut other = Session::new();
|
||
other.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||
let id = other.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 30, "height": 30})).unwrap()["id"].clone();
|
||
other.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
other.execute("edit.copy", &json!({})).unwrap();
|
||
let pdf = other.clipboard_pdf().unwrap().unwrap();
|
||
copy_elsewhere(&board, vec![(PNG, blue_png()), (PDF, pdf)]);
|
||
run(&mut app, "edit.paste", json!({}));
|
||
let p = pasted(&app);
|
||
assert!(!p.is_empty() && p.iter().all(|n| !matches!(n.kind, NodeKind::Image(_))), "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_bitmap_on_the_system_clipboard_enables_paste() {
|
||
let (mut app, board) = app();
|
||
copy_elsewhere(&board, vec![(PNG, blue_png())]);
|
||
assert!(!menus::enabled(&app, "edit.paste"), "not looked at before a frame");
|
||
frame(&mut app, vec![]);
|
||
assert!(menus::enabled(&app, "edit.paste") && menus::enabled(&app, "edit.pasteInPlace"));
|
||
menus::invoke(&mut app, "edit.paste", json!({}));
|
||
assert!(matches!(pasted(&app)[0].kind, NodeKind::Image(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn keyboard_paste_of_a_bitmap_without_a_paste_event() {
|
||
let (mut app, board) = app();
|
||
let (cmd, none) = (egui::Modifiers::COMMAND, egui::Modifiers::NONE);
|
||
copy_elsewhere(&board, vec![(PNG, blue_png())]);
|
||
frame(&mut app, vec![]);
|
||
// Cmd+V: egui swallows the press and sends no Paste event (no text); only the release arrives.
|
||
frame(&mut app, vec![key(egui::Key::V, false, cmd)]);
|
||
assert_eq!(count(&app), 1);
|
||
assert!(matches!(pasted(&app)[0].kind, NodeKind::Image(_)));
|
||
// A V typed (its press reported) is no paste, whatever is released with it.
|
||
frame(&mut app, vec![key(egui::Key::V, true, none)]);
|
||
frame(&mut app, vec![key(egui::Key::V, false, cmd)]);
|
||
assert_eq!(count(&app), 1);
|
||
// Text on the clipboard: egui's Paste event pastes once, its release adds nothing.
|
||
copy_elsewhere(&board, vec![(TEXT, b"words".to_vec())]);
|
||
frame(&mut app, vec![egui::Event::Paste("words".into())]);
|
||
frame(&mut app, vec![key(egui::Key::V, false, cmd)]);
|
||
assert_eq!(count(&app), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn without_the_service_plain_text_is_still_not_pasted() {
|
||
// The web: SVG text through egui only (see shortcuts' copy_and_paste_events_use_the_system_clipboard).
|
||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||
run(&mut app, "file.new", json!({"width": 200, "height": 200}));
|
||
frame(&mut app, vec![]);
|
||
frame(&mut app, vec![key(egui::Key::V, false, egui::Modifiers::COMMAND)]);
|
||
frame(&mut app, vec![egui::Event::Paste("hello".into())]);
|
||
assert_eq!(count(&app), 0);
|
||
}
|