Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
649656b092 | ||
|
|
04643e1109 | ||
|
|
43c0df66a3 | ||
|
|
a9ae2fd513 | ||
|
|
0a0357143d |
No files matched your search
+2
-2
@@ -42,7 +42,7 @@ since 2026-10-01.
|
||||
| **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing |
|
||||
| **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine |
|
||||
| **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); Windows/Linux packaging and accessibility are pending; an experimental, unsupported Windows 7 x64 OpenGL build exists (community-contributed, not verified by maintainers) |
|
||||
| **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel |
|
||||
|
||||
### Where we're lacking (in priority order)
|
||||
@@ -149,7 +149,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export. EPS import runs Illustrator's AGM/CoolType prologs (resource categories, `resourceforall`, `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters, `cshow`, metadata skipped with `flushfile`), so Illustrator 30 EPS files open as vectors, and falls back to its palette TIFF preview. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole).
|
||||
- Place and the Links panel, Package, File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours.
|
||||
- **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so the desktop app asks for the power-saving GPU by default (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306). Preferences › Performance › Graphics Processor (`gpuPreference`) or `WGPU_POWER_PREF` picks the discrete one; Help › About and `ui.inspect` name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369).
|
||||
- **Tests and robustness:** ~2,840 automated tests: model-based property tests, a junk-parameter sweep over every command, import fuzzing (SVG, PDF, libraries), golden renders, and MCP end-to-end tests over stdio. Shipped code never panics (workspace lints and a rollback safety net; see `docs/development.md`).
|
||||
|
||||
## Milestones and estimates
|
||||
|
||||
+219
-54
@@ -1,12 +1,14 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
//! The browser shell: web `Services`, drag-and-drop, the eframe web runner, and starting again
|
||||
//! with new graphics when the page loses them (#369).
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::cmd::recovery::{Hold, RecoveryStore};
|
||||
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
|
||||
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
|
||||
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
||||
@@ -23,58 +25,123 @@ type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
|
||||
const CANVAS_ID: &str = "vectorcraft_canvas";
|
||||
const LOADING_ID: &str = "vectorcraft_loading";
|
||||
|
||||
/// How many times the page starts again with new graphics after losing them before it gives up
|
||||
/// (a GPU that keeps failing).
|
||||
const MAX_RESTARTS: u32 = 3;
|
||||
|
||||
/// What the page keeps while the app runs, across graphics restarts.
|
||||
#[derive(Clone)]
|
||||
struct Host {
|
||||
runner: eframe::WebRunner,
|
||||
inbox: Inbox,
|
||||
place_inbox: PlaceInbox,
|
||||
drag: DragPos,
|
||||
/// The page's Web Locks for Data Recovery, started once before the first frame: a graphics
|
||||
/// restart keeps them, so the tab stays the owner of its recovery area.
|
||||
locks: Option<WebLocks>,
|
||||
/// Graphics restarts so far.
|
||||
restarts: u32,
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||
log::error!("no document");
|
||||
return;
|
||||
};
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
let drag = track_drag(&canvas);
|
||||
let Some(canvas) = element(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// Before the first frame, which looks for copies a crash left behind.
|
||||
let locks = WebLocks::start(RECOVERY_PREFIX).await;
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
let host = Host {
|
||||
runner: eframe::WebRunner::new(),
|
||||
inbox: Arc::default(),
|
||||
place_inbox: Arc::default(),
|
||||
drag: DragPos::default(),
|
||||
locks,
|
||||
restarts: 0,
|
||||
};
|
||||
run(host, canvas, None).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// An app whose graphics were lost, on its way to new ones.
|
||||
struct Moving {
|
||||
app: VectorcraftApp,
|
||||
/// Its unsaved changes are kept in recovery copies ([`VectorcraftApp::graphics_lost`]).
|
||||
kept: bool,
|
||||
}
|
||||
|
||||
/// Run the app on `canvas`: the `moving` one when starting again after losing the graphics, else
|
||||
/// a new one.
|
||||
async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Moving>) {
|
||||
track_drag(&canvas, &host.drag);
|
||||
let kept = moving.as_ref().map(|m| m.kept);
|
||||
// The app until the new runner takes it (still here if starting fails before).
|
||||
let slot = Rc::new(RefCell::new(moving.map(|m| m.app)));
|
||||
let (runner, taken, page) = (host.runner.clone(), slot.clone(), canvas.clone());
|
||||
let result = runner
|
||||
.start(
|
||||
canvas,
|
||||
web_options(),
|
||||
Box::new(move |cc| {
|
||||
let ctx = &cc.egui_ctx;
|
||||
let loss = GraphicsLoss::default();
|
||||
watch_canvas(&page, &loss, ctx);
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
let (loss, ctx) = (loss.clone(), ctx.clone());
|
||||
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
|
||||
}
|
||||
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone());
|
||||
let app = match taken.borrow_mut().take() {
|
||||
Some(mut app) => {
|
||||
// The old services asked the old context for frames.
|
||||
app.services = services;
|
||||
app.status("The graphics were lost and have been restarted");
|
||||
app
|
||||
}
|
||||
let inbox: Inbox = Arc::default();
|
||||
let place_inbox: PlaceInbox = Arc::default();
|
||||
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone(), locks));
|
||||
Ok(Box::new(WebShell { app, inbox, place_inbox, drag }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => el.set_inner_html(&format!("<p>VectorCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
|
||||
None => VectorcraftApp::new(Session::new(), services),
|
||||
};
|
||||
Ok(Box::new(WebShell { app: Some(app), host, canvas: page, loss }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match (result, kept) {
|
||||
(Ok(()), _) => {
|
||||
if let Some(el) = element(LOADING_ID) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
(Err(e), None) => notice(&[&format!("VectorCraft failed to start: {}", js_err(e)), "A browser with WebGPU or WebGL2 is required."]),
|
||||
(Err(e), Some(kept)) => {
|
||||
log::error!("vectorcraft-web: couldn't restart the graphics: {}", js_err(e));
|
||||
give_up(slot.borrow_mut().take(), kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The runner's options: wgpu (WebGPU where available, else WebGL2; `?webgl` forces WebGL2).
|
||||
fn web_options() -> eframe::WebOptions {
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't
|
||||
/// get during a drag.
|
||||
fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
|
||||
let pos = DragPos::default();
|
||||
fn element(id: &str) -> Option<web_sys::Element> {
|
||||
web_sys::window()?.document()?.get_element_by_id(id)
|
||||
}
|
||||
|
||||
/// Follow file drags over `canvas` into `pos`: drag events carry the pointer position, which egui
|
||||
/// doesn't get during a drag.
|
||||
fn track_drag(canvas: &web_sys::HtmlCanvasElement, pos: &DragPos) {
|
||||
let p = pos.clone();
|
||||
let on_drag = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::DragEvent)>::new(move |e: web_sys::DragEvent| {
|
||||
p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key())));
|
||||
@@ -84,29 +151,123 @@ fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
|
||||
log::error!("couldn't follow {kind} events: {e:?}");
|
||||
}
|
||||
}
|
||||
// The listener lives as long as the page.
|
||||
// The listener lives as long as the canvas.
|
||||
on_drag.forget();
|
||||
pos
|
||||
}
|
||||
|
||||
/// Report the loss of `canvas`'s WebGL context to `loss` (WebGPU devices report theirs through
|
||||
/// wgpu).
|
||||
fn watch_canvas(canvas: &web_sys::HtmlCanvasElement, loss: &GraphicsLoss, ctx: &egui::Context) {
|
||||
let (loss, ctx) = (loss.clone(), ctx.clone());
|
||||
let on_lost = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::Event)>::new(move |_: web_sys::Event| {
|
||||
loss.report(&ctx, "the WebGL context was lost");
|
||||
});
|
||||
if let Err(e) = canvas.add_event_listener_with_callback("webglcontextlost", on_lost.as_ref().unchecked_ref()) {
|
||||
log::error!("couldn't watch for a lost WebGL context: {e:?}");
|
||||
}
|
||||
// The listener lives as long as the canvas.
|
||||
on_lost.forget();
|
||||
}
|
||||
|
||||
/// A new canvas in place of `old`, whose graphics were lost (a canvas whose WebGL context is lost
|
||||
/// can't get another).
|
||||
fn fresh_canvas(old: &web_sys::HtmlCanvasElement) -> Result<web_sys::HtmlCanvasElement, String> {
|
||||
let new: web_sys::HtmlCanvasElement = old.clone_node().map_err(js_err)?.dyn_into().map_err(|_| "not a canvas")?;
|
||||
old.replace_with_with_node_1(&new).map_err(js_err)?;
|
||||
Ok(new)
|
||||
}
|
||||
|
||||
/// The graphics are lost for good: the dead canvas goes (it would show a stale picture), the app
|
||||
/// (if still here) lets go of its recovery copies so the reloaded page offers them at once, and
|
||||
/// the page says what to do.
|
||||
fn give_up(app: Option<VectorcraftApp>, kept: bool) {
|
||||
if let Some(mut app) = app {
|
||||
recovery::leave(&mut app.session);
|
||||
}
|
||||
if let Some(canvas) = element(CANVAS_ID) {
|
||||
canvas.remove();
|
||||
}
|
||||
notice(&[
|
||||
"The graphics were lost and couldn't be restarted.",
|
||||
if kept {
|
||||
"Your unsaved changes are kept in this browser: reload the page to get them back."
|
||||
} else {
|
||||
"Reload the page to go on (changes not saved yet may be lost)."
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/// Show `lines` in the middle of the page (where the loading message was).
|
||||
fn notice(lines: &[&str]) {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let el = match document.get_element_by_id(LOADING_ID) {
|
||||
Some(el) => el,
|
||||
None => {
|
||||
let Ok(el) = document.create_element("div") else { return };
|
||||
el.set_id(LOADING_ID);
|
||||
let Some(body) = document.body() else { return };
|
||||
if let Err(e) = body.append_child(&el) {
|
||||
log::error!("couldn't show a message: {e:?}");
|
||||
}
|
||||
el
|
||||
}
|
||||
};
|
||||
el.set_text_content(None);
|
||||
for line in lines {
|
||||
if let Ok(p) = document.create_element("p") {
|
||||
p.set_text_content(Some(line));
|
||||
// Best effort: the log has the message too.
|
||||
el.append_child(&p).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
|
||||
/// When the graphics are lost it hands the app to a new runner on a new canvas.
|
||||
struct WebShell {
|
||||
app: VectorcraftApp,
|
||||
inbox: Inbox,
|
||||
place_inbox: PlaceInbox,
|
||||
drag: DragPos,
|
||||
/// `None` once handed on.
|
||||
app: Option<VectorcraftApp>,
|
||||
host: Host,
|
||||
canvas: web_sys::HtmlCanvasElement,
|
||||
loss: GraphicsLoss,
|
||||
}
|
||||
|
||||
impl WebShell {
|
||||
/// The graphics were lost (`why`): keep recovery copies, then start again on a new canvas
|
||||
/// with the same app once this frame is over (the runner is busy with it until then).
|
||||
fn restart(&mut self, why: &str) {
|
||||
let Some(mut app) = self.app.take() else { return };
|
||||
let kept = app.graphics_lost(why);
|
||||
let mut host = self.host.clone();
|
||||
host.restarts += 1;
|
||||
let canvas =
|
||||
if host.restarts > MAX_RESTARTS { Err(format!("the graphics were lost {MAX_RESTARTS} times")) } else { fresh_canvas(&self.canvas) };
|
||||
match canvas {
|
||||
Ok(canvas) => wasm_bindgen_futures::spawn_local(run(host, canvas, Some(Moving { app, kept }))),
|
||||
Err(e) => {
|
||||
log::error!("vectorcraft-web: giving up on the graphics: {e}");
|
||||
let runner = host.runner;
|
||||
wasm_bindgen_futures::spawn_local(async move { runner.destroy() });
|
||||
give_up(Some(app), kept);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Some(why) = self.loss.take() {
|
||||
self.restart(&why);
|
||||
}
|
||||
let Some(app) = &mut self.app else { return };
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
if !dropped.is_empty() {
|
||||
let at = self.drag.take();
|
||||
let at = self.host.drag.take();
|
||||
let z = ctx.zoom_factor();
|
||||
let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
|
||||
let target = app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
|
||||
for f in dropped {
|
||||
let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone());
|
||||
let (inbox, place_inbox, ctx) = (self.host.inbox.clone(), self.host.place_inbox.clone(), ctx.clone());
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
@@ -124,15 +285,19 @@ impl eframe::App for WebShell {
|
||||
});
|
||||
}
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
app.logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
if let Some(app) = &mut self.app {
|
||||
app.raw_input_hook(raw);
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
if let Some(app) = &mut self.app {
|
||||
app.ui(ui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,45 +25,69 @@ mod window;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::{FilePick, Services, VectorcraftApp};
|
||||
|
||||
struct App(VectorcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
struct App {
|
||||
app: VectorcraftApp,
|
||||
/// Reported by wgpu when the window's graphics device is lost (a driver reset).
|
||||
graphics_loss: GraphicsLoss,
|
||||
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
|
||||
graphics_lost: bool,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: Option<native_menu::NativeMenu>,
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Some(why) = self.graphics_loss.take() {
|
||||
// eframe can't give a window a new device: the user saves and starts again.
|
||||
self.graphics_lost = self.app.graphics_lost(&why);
|
||||
self.app.status(if self.graphics_lost {
|
||||
"The graphics device was lost: unsaved changes are kept for Data Recovery. Save your documents and restart VectorCraft"
|
||||
} else {
|
||||
"The graphics device was lost: save your documents and restart VectorCraft"
|
||||
});
|
||||
}
|
||||
if self.graphics_lost && ctx.input(|i| i.viewport().close_requested()) {
|
||||
// The window can't show the Save Changes question: it closes, and the next launch
|
||||
// offers the changes back.
|
||||
vectorcraft_ui_egui::background::wait_all(&mut self.app);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.1.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
|
||||
if self.menu.is_none() && std::env::var_os("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.menu = Some(native_menu::NativeMenu::install(&mut self.app));
|
||||
}
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0);
|
||||
if let Some(m) = &mut self.menu {
|
||||
m.poll(&mut self.app);
|
||||
}
|
||||
open_files(&mut self.0, open_documents::take());
|
||||
open_files(&mut self.app, open_documents::take());
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
window::track(ctx, &mut self.0.ui.window);
|
||||
if self.0.ui.status == "quit" {
|
||||
self.app.logic(ctx);
|
||||
window::track(ctx, &mut self.app.ui.window);
|
||||
if self.app.ui.status == "quit" {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
self.app.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
if self.0.take_ime_discard() {
|
||||
if self.app.take_ime_discard() {
|
||||
discard_marked_text();
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "windows7"))]
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
save_prefs(&self.app);
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
save_prefs(&self.0);
|
||||
save_prefs(&self.app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,11 +387,14 @@ fn main() -> eframe::Result {
|
||||
let recovery = prefs_path().and_then(|p| Some(p.parent()?.join("Data Recovery").to_string_lossy().to_string()));
|
||||
app.session.recovery.set_default_folder(recovery);
|
||||
}
|
||||
let graphics_loss = GraphicsLoss::default();
|
||||
#[cfg(feature = "wgpu")]
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
let summary = adapter_summary(&rs.adapter.get_info());
|
||||
log::info!("vectorcraft: rendering with {summary} (power preference {power:?})");
|
||||
app.graphics_adapter = Some(summary);
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
|
||||
}
|
||||
#[cfg(feature = "windows7")]
|
||||
{
|
||||
@@ -382,11 +409,13 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
open_documents::set_ui(&cc.egui_ctx);
|
||||
open_files(&mut app, files);
|
||||
Ok(Box::new(App(
|
||||
Ok(Box::new(App {
|
||||
app,
|
||||
graphics_loss,
|
||||
graphics_lost: false,
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
)))
|
||||
menu: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -487,6 +487,22 @@ pub fn release(s: &mut Session) {
|
||||
store.remove_area(&area);
|
||||
}
|
||||
|
||||
/// Let go of this session's area but leave its copies, as a crash would, so the next app started
|
||||
/// offers them at once instead of waiting for the heartbeat to go stale: for an app that can't go
|
||||
/// on (the web page lost its graphics and has to be reloaded). Its documents keep no copies.
|
||||
pub fn leave(s: &mut Session) {
|
||||
for st in &mut s.docs {
|
||||
st.recovery = None;
|
||||
}
|
||||
let Some(Own { store, area, hold, beats }) = s.recovery.own.take() else { return };
|
||||
if beats {
|
||||
// Best effort: if the heartbeat stays, the copies are offered once it is stale.
|
||||
let _ = store.remove(&format!("{area}/{BEAT}"));
|
||||
}
|
||||
// The lock or announcement goes too.
|
||||
drop(hold);
|
||||
}
|
||||
|
||||
/// A document's recovery copy: its entry and the document it holds.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecoveryCopy {
|
||||
|
||||
@@ -269,6 +269,23 @@ fn announced_areas_stay_held_while_their_app_is_paused() {
|
||||
assert!(offered(&mut c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_app_that_leaves_has_its_copies_offered_at_once() {
|
||||
for store in [MemoryStore::without_locks(), MemoryStore::announcing(), MemoryStore::default()] {
|
||||
let store = Arc::new(store);
|
||||
let mut a = session_in(&store);
|
||||
a.execute("file.recovery.save", &json!({})).unwrap();
|
||||
let mut b = relaunch(&store);
|
||||
assert!(offered(&mut b).is_empty(), "A still runs");
|
||||
// A can't go on (its page lost its graphics): its copies stay, for the next app at once.
|
||||
cmd::recovery::leave(&mut a);
|
||||
assert!(a.doc().unwrap().recovery.is_none());
|
||||
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
|
||||
assert_eq!(offered(&mut b), ["Untitled-1"]);
|
||||
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_areas_are_locked_by_running_apps() {
|
||||
let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id()));
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//! only those. Preferences › Type › Enable in-menu font previews and Font Preview Size turn the
|
||||
//! samples off or size the rows.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -480,7 +479,7 @@ impl Samples {
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SAMPLES: RefCell<Samples> = RefCell::new(Samples::default());
|
||||
static SAMPLES: crate::graphics::TexCache<Samples> = crate::graphics::TexCache::default();
|
||||
}
|
||||
|
||||
/// The sample of `family` for a row `height` points high: the selected text set in it (else a
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Losing the graphics device, and the textures uploaded to it (#369).
|
||||
//!
|
||||
//! A window can lose its graphics device: a driver reset or a GPU hang on the desktop, the browser
|
||||
//! dropping the page's WebGPU device or WebGL context. Everything uploaded to it is gone and
|
||||
//! nothing it draws reaches the screen any more, while the app itself goes on.
|
||||
//!
|
||||
//! - The host reports the loss through a [`GraphicsLoss`] (from wgpu's device-lost callback, the
|
||||
//! canvas's `webglcontextlost` event), and the app keeps recovery copies of the modified
|
||||
//! documents at once ([`VectorcraftApp::graphics_lost`]).
|
||||
//! - The web host then starts again with new graphics and a new egui context, handing it the same
|
||||
//! app. The app notices the new context and makes everything it uploaded to the old one again:
|
||||
//! its fonts and theme, the canvas, and every texture cache ([`TexCache`]).
|
||||
|
||||
use std::cell::{Cell, Ref, RefCell, RefMut};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
/// Raised by the host when the window's graphics device is lost (from any thread), read on the UI
|
||||
/// thread. Cheap to clone; the clones share the report.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct GraphicsLoss(Arc<Mutex<Option<String>>>);
|
||||
|
||||
impl GraphicsLoss {
|
||||
/// The graphics device was lost (`why`, for the log): the next frame of `ctx` handles it. A
|
||||
/// second report before then adds nothing.
|
||||
pub fn report(&self, ctx: &egui::Context, why: impl Into<String>) {
|
||||
let why = why.into();
|
||||
log::error!("vectorcraft: the graphics device was lost: {why}");
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert(why);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The loss reported since the last call, if any.
|
||||
pub fn take(&self) -> Option<String> {
|
||||
self.0.lock().unwrap_or_else(|e| e.into_inner()).take()
|
||||
}
|
||||
}
|
||||
|
||||
/// A number naming egui context `ctx`, the same every call and different from every other
|
||||
/// context's.
|
||||
pub(crate) fn context_tag(ctx: &egui::Context) -> u64 {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||||
let id = egui::Id::new("vectorcraft-context-tag");
|
||||
if let Some(tag) = ctx.data(|d| d.get_temp::<u64>(id)) {
|
||||
return tag;
|
||||
}
|
||||
ctx.data_mut(|d| *d.get_temp_mut_or_insert_with(id, || NEXT.fetch_add(1, Ordering::Relaxed)))
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// Bumped when the UI moves to a new egui context: [`TexCache`]s made before are emptied.
|
||||
static EPOCH: Cell<u64> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The UI moved to a new egui context: every [`TexCache`] of this thread empties at its next use.
|
||||
pub(crate) fn forget_textures() {
|
||||
EPOCH.with(|e| e.set(e.get().wrapping_add(1)));
|
||||
}
|
||||
|
||||
/// A per-thread cache holding textures (thumbnails, previews, samples). Textures belong to the
|
||||
/// egui context they were uploaded to, so the cache goes back to `T::default()` when the UI moves
|
||||
/// to another one ([`forget_textures`]). Used like the `RefCell` it wraps.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TexCache<T> {
|
||||
epoch: Cell<u64>,
|
||||
value: RefCell<T>,
|
||||
}
|
||||
|
||||
impl<T: Default> TexCache<T> {
|
||||
/// Empty the cache if it was filled for another context.
|
||||
fn refresh(&self) {
|
||||
let now = EPOCH.with(Cell::get);
|
||||
if self.epoch.replace(now) != now {
|
||||
*self.value.borrow_mut() = T::default();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn borrow(&self) -> Ref<'_, T> {
|
||||
self.refresh();
|
||||
self.value.borrow()
|
||||
}
|
||||
|
||||
pub(crate) fn borrow_mut(&self) -> RefMut<'_, T> {
|
||||
self.refresh();
|
||||
self.value.borrow_mut()
|
||||
}
|
||||
}
|
||||
|
||||
impl VectorcraftApp {
|
||||
/// The window's graphics device was lost (`why`, from [`GraphicsLoss::take`]): recovery copies
|
||||
/// of the modified documents are written now, since the window may never draw again (even
|
||||
/// with Data Recovery turned off: they go when the documents are saved or closed) → whether
|
||||
/// every modified document has an up-to-date copy (none modified counts).
|
||||
pub fn graphics_lost(&mut self, why: &str) -> bool {
|
||||
if let Err(e) = self.session.execute("file.recovery.save", &serde_json::json!({})) {
|
||||
log::warn!("vectorcraft: no recovery copies after losing the graphics ({why}): {e}");
|
||||
}
|
||||
self.session.documents().iter().filter(|st| st.is_dirty()).all(|st| st.recovery.as_ref().is_some_and(|c| Arc::ptr_eq(&c.doc, &st.doc)))
|
||||
}
|
||||
|
||||
/// Textures live in one egui context. When the host hands the app a new one (it started again
|
||||
/// after losing its graphics), everything uploaded to the old one is made again: the fonts and
|
||||
/// theme, the canvas, the place cursor's thumbnails and every [`TexCache`].
|
||||
pub(crate) fn adopt_context(&mut self, ctx: &egui::Context) {
|
||||
let tag = context_tag(ctx);
|
||||
if self.context == tag {
|
||||
return;
|
||||
}
|
||||
if self.context != 0 {
|
||||
log::info!("vectorcraft: new graphics context: uploading the UI again");
|
||||
forget_textures();
|
||||
self.styled = false;
|
||||
self.fonts_ready = false;
|
||||
self.ui_fonts = Default::default();
|
||||
self.canvas.texture = None;
|
||||
self.canvas.key = None;
|
||||
self.place.forget_textures();
|
||||
}
|
||||
self.context = tag;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::recovery::{MemoryStore, RecoveryStore};
|
||||
|
||||
use super::*;
|
||||
use crate::Services;
|
||||
|
||||
/// One headless frame of the whole window (800×600) in `ctx`.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// The name of texture `id` in `ctx`, if `ctx` has it.
|
||||
fn texture_name(ctx: &egui::Context, id: egui::TextureId) -> Option<String> {
|
||||
ctx.tex_manager().read().meta(id).map(|m| m.name.clone())
|
||||
}
|
||||
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loss_is_reported_once_from_any_thread() {
|
||||
let (loss, ctx) = (GraphicsLoss::default(), egui::Context::default());
|
||||
let (l, c) = (loss.clone(), ctx.clone());
|
||||
std::thread::spawn(move || {
|
||||
l.report(&c, "Unknown: driver reset");
|
||||
l.report(&c, "Destroyed");
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
assert_eq!(loss.take().as_deref(), Some("Unknown: driver reset"), "the first report wins");
|
||||
assert_eq!(loss.take(), None, "handled once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tex_cache_empties_when_the_ui_moves_to_a_new_context() {
|
||||
thread_local! {
|
||||
static CACHE: TexCache<Vec<u32>> = TexCache::default();
|
||||
}
|
||||
CACHE.with(|c| c.borrow_mut().push(7));
|
||||
assert_eq!(CACHE.with(|c| c.borrow().clone()), [7]);
|
||||
forget_textures();
|
||||
assert!(CACHE.with(|c| c.borrow().is_empty()), "emptied for the new context");
|
||||
CACHE.with(|c| c.borrow_mut().push(8));
|
||||
assert_eq!(CACHE.with(|c| c.borrow().clone()), [8], "and filled again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_uploads_everything_again_in_a_new_context() {
|
||||
let mut app = app();
|
||||
let old = egui::Context::default();
|
||||
// Fonts, then the canvas.
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &old);
|
||||
}
|
||||
let canvas = app.canvas.texture.as_ref().map(egui::TextureHandle::id).expect("the canvas is drawn");
|
||||
assert_eq!(texture_name(&old, canvas).as_deref(), Some("canvas"));
|
||||
let preview = |ctx: &egui::Context| {
|
||||
let mut tex = None;
|
||||
ctx.run_ui(Default::default(), |ui| {
|
||||
tex = crate::widgets::doc_preview(ui, "loss", egui::vec2(8.0, 8.0), |w, h| Some(vectorcraft_doc::Document::new(w, h)))
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
tex.expect("a preview").id()
|
||||
};
|
||||
assert_eq!(texture_name(&old, preview(&old)).as_deref(), Some("preview-8x8@1:loss"));
|
||||
|
||||
// The host lost its graphics and started again: same app, new context.
|
||||
let new = egui::Context::default();
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, &new);
|
||||
}
|
||||
let canvas = app.canvas.texture.as_ref().map(egui::TextureHandle::id).expect("the canvas is drawn again");
|
||||
assert_eq!(texture_name(&new, canvas).as_deref(), Some("canvas"), "uploaded to the new context");
|
||||
assert_eq!(texture_name(&new, preview(&new)).as_deref(), Some("preview-8x8@1:loss"), "cached previews are made again");
|
||||
assert_eq!(new.global_style().visuals.panel_fill, old.global_style().visuals.panel_fill, "the theme is applied again");
|
||||
assert!(new.fonts(|f| f.definitions().font_data.contains_key("SourceSans3")), "and the fonts installed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_the_graphics_keeps_recovery_copies_at_once() {
|
||||
let store = Arc::new(MemoryStore::default());
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services { recovery_store: Some(store.clone()), ..Default::default() });
|
||||
assert!(app.graphics_lost("test"), "nothing modified: nothing to lose");
|
||||
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
app.session.prefs.autosave_recovery = false;
|
||||
assert!(app.graphics_lost("test"), "copied even with Data Recovery off");
|
||||
assert_eq!(store.list().unwrap().iter().filter(|n| n.ends_with(".vectorcraft")).count(), 1);
|
||||
// Without a store the work isn't safe.
|
||||
assert!(!self::app().graphics_lost("test"));
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ pub mod dialogs;
|
||||
pub mod dock;
|
||||
pub mod find_font;
|
||||
pub mod font_menu;
|
||||
pub mod graphics;
|
||||
pub mod i18n;
|
||||
pub mod icon_data;
|
||||
pub mod icons;
|
||||
@@ -280,6 +281,9 @@ pub struct VectorcraftApp {
|
||||
pub synthetic: Vec<egui::Event>,
|
||||
styled: bool,
|
||||
fonts_ready: bool,
|
||||
/// The egui context the UI's textures were uploaded to (0: none yet; see
|
||||
/// [`Self::adopt_context`]).
|
||||
context: u64,
|
||||
/// Installed fonts added to the UI's for characters its own fonts lack (CJK names…).
|
||||
ui_fonts: ui_fonts::UiFonts,
|
||||
frame: u64,
|
||||
@@ -369,6 +373,7 @@ impl VectorcraftApp {
|
||||
synthetic: vec![],
|
||||
styled: false,
|
||||
fonts_ready: false,
|
||||
context: 0,
|
||||
ui_fonts: Default::default(),
|
||||
frame: 0,
|
||||
last_time: 0.0,
|
||||
@@ -689,6 +694,7 @@ impl VectorcraftApp {
|
||||
|
||||
fn logic_frame(&mut self, ctx: &egui::Context) {
|
||||
i18n::set_current(self.ui_language());
|
||||
self.adopt_context(ctx);
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
theme::apply(ctx, self.ui.brightness);
|
||||
|
||||
@@ -49,7 +49,7 @@ pub(crate) fn paint_thumb(ui: &Ui, st: &DocState, a: &ExportAsset, r: Rect) {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
/// The addresses of each asset's objects in one document revision (no tree walks per frame).
|
||||
static KEYS: RefCell<(Revision, HashMap<u64, Look>)> = RefCell::new(Default::default());
|
||||
static CACHE: RefCell<HashMap<(Look, u32), egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||
static CACHE: crate::graphics::TexCache<HashMap<(Look, u32), egui::TextureHandle>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
ui.painter().rect_filled(r, 0.0, Color32::WHITE);
|
||||
if !ui.is_rect_visible(r) {
|
||||
|
||||
@@ -116,6 +116,12 @@ struct Cache {
|
||||
view: View,
|
||||
generation: u64,
|
||||
renderer: Option<vectorcraft_render::Renderer>,
|
||||
drawn: crate::graphics::TexCache<Drawn>,
|
||||
}
|
||||
|
||||
/// The preview as drawn: for what, and its texture.
|
||||
#[derive(Default)]
|
||||
struct Drawn {
|
||||
key: Option<Key>,
|
||||
tex: Option<egui::TextureHandle>,
|
||||
}
|
||||
@@ -268,8 +274,10 @@ fn preview_area(ui: &mut Ui, t: &Tokens, h: Highlight, overprints: Overprints) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
let Cache { shot, view, renderer, key, tex, .. } = &mut *c;
|
||||
let Cache { shot, view, renderer, drawn, .. } = &mut *c;
|
||||
let Some(shot) = shot.as_ref() else { return };
|
||||
let mut drawn = drawn.borrow_mut();
|
||||
let Drawn { key, tex } = &mut *drawn;
|
||||
let visible = view.visible(shot.fit);
|
||||
if resp.double_clicked() {
|
||||
*view = View::default();
|
||||
@@ -420,7 +428,7 @@ mod tests {
|
||||
let mut app = app();
|
||||
frame(&mut app);
|
||||
assert!(CACHE.with(|c| c.borrow().shot.is_some()), "a first look refreshes");
|
||||
assert!(CACHE.with(|c| c.borrow().tex.is_some()), "the preview is drawn");
|
||||
assert!(CACHE.with(|c| c.borrow().drawn.borrow().tex.is_some()), "the preview is drawn");
|
||||
let v = command(&mut app, &json!({"highlight": "allAffected"})).unwrap();
|
||||
assert_eq!(v["counts"]["allAffected"], 2);
|
||||
assert_eq!(app.ui.open_panel.as_deref(), Some(ID));
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! outlines. Double-click inserts the character at the Type tool's caret (or appends it to the
|
||||
//! selected text objects).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use egui::{Color32, Sense, Ui, vec2};
|
||||
@@ -29,7 +28,7 @@ const SUBSETS: [(&str, u32, u32); 7] = [
|
||||
];
|
||||
|
||||
thread_local! {
|
||||
static TEX: RefCell<HashMap<(u32, u32, u32), egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||
static TEX: crate::graphics::TexCache<HashMap<(u32, u32, u32), egui::TextureHandle>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
|
||||
/// Antialiased coverage mask (nonzero winding) of `path` in a `w`×`h` pixel grid.
|
||||
|
||||
@@ -68,7 +68,7 @@ const UV: Rect = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
thread_local! {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
/// Thumbnails by (look hash, pixels, preview shape, Override Character Color on type).
|
||||
static THUMBS: RefCell<HashMap<(u64, u32, Preview, bool), TextureHandle>> = RefCell::new(HashMap::new());
|
||||
static THUMBS: crate::graphics::TexCache<HashMap<(u64, u32, Preview, bool), TextureHandle>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
|
||||
// ---------- thumbnails ----------
|
||||
|
||||
@@ -714,7 +714,7 @@ pub(crate) fn real_thumb(ui: &Ui, doc: &vectorcraft_doc::Document, n: &Node, r:
|
||||
use std::collections::HashMap;
|
||||
thread_local! {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
static CACHE: RefCell<HashMap<(usize, u64), egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||
static CACHE: crate::graphics::TexCache<HashMap<(usize, u64), egui::TextureHandle>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
if !ui.is_rect_visible(r) {
|
||||
return true;
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//! document revision) with the red view box you drag to pan, zoom out/in buttons, a zoom slider
|
||||
//! and a zoom field.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_geom::{Point, Rect as DRect};
|
||||
@@ -13,6 +11,7 @@ use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
renderer: Option<vectorcraft_render::Renderer>,
|
||||
key: Option<(usize, u64, u32, u32, bool)>,
|
||||
@@ -20,7 +19,7 @@ struct Cache {
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<Cache> = const { RefCell::new(Cache { renderer: None, key: None, tex: None }) };
|
||||
static CACHE: crate::graphics::TexCache<Cache> = crate::graphics::TexCache::default();
|
||||
}
|
||||
|
||||
/// Map a document point into the thumbnail rect (region → rect, aspect preserved).
|
||||
|
||||
@@ -169,11 +169,10 @@ fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
|
||||
|
||||
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
|
||||
fn pattern_thumb(app: &VectorcraftApp, ui: &Ui, r: Rect, paint: &Paint) {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
type Key = (String, Vec<usize>, String, u32);
|
||||
thread_local! {
|
||||
static CACHE: RefCell<HashMap<Key, Option<egui::TextureHandle>>> = RefCell::new(HashMap::new());
|
||||
static CACHE: crate::graphics::TexCache<HashMap<Key, Option<egui::TextureHandle>>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
let Paint::Pattern { pattern, .. } = paint else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::widgets::{self, PanelDrag, menu_item};
|
||||
fn thumb(ui: &Ui, doc: &Document, name: &str, size: f32) -> Option<egui::TextureHandle> {
|
||||
thread_local! {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
static CACHE: RefCell<HashMap<(usize, String, u32), egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||
static CACHE: crate::graphics::TexCache<HashMap<(usize, String, u32), egui::TextureHandle>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
let sym = doc.symbols.iter().find(|s| s.name == name)?;
|
||||
let px = (size * ui.ctx().pixels_per_point()).round().max(8.0) as u32;
|
||||
|
||||
+19
-14
@@ -51,16 +51,26 @@ pub enum DropTarget {
|
||||
pub struct PlaceState {
|
||||
/// Web: the bytes of the files picked for the Place dialog, by name.
|
||||
picked: Vec<(String, Vec<u8>)>,
|
||||
/// The loaded cursor's thumbnails by file name: decoded when the cursor is loaded, uploaded
|
||||
/// when first drawn.
|
||||
/// The loaded cursor's thumbnails by file name.
|
||||
thumbs: Vec<(String, Thumb)>,
|
||||
/// The Control bar's `image.info`, for (document uid, revision, image id).
|
||||
info: Option<((u64, u64, u64), Value)>,
|
||||
}
|
||||
|
||||
enum Thumb {
|
||||
Decoded(egui::ColorImage),
|
||||
Uploaded(egui::TextureHandle),
|
||||
/// A place cursor thumbnail: decoded when the cursor is loaded, uploaded when first drawn (and
|
||||
/// again in a new graphics context).
|
||||
struct Thumb {
|
||||
image: std::sync::Arc<egui::ColorImage>,
|
||||
tex: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
impl PlaceState {
|
||||
/// The UI moved to a new egui context: the thumbnails are uploaded again when drawn.
|
||||
pub(crate) fn forget_textures(&mut self) {
|
||||
for (_, th) in &mut self.thumbs {
|
||||
th.tex = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run engine command `id` itself (past the UI's handling of the same id); an error shows in the
|
||||
@@ -230,7 +240,7 @@ pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
|
||||
let png = vectorcraft_format::base64_decode(f["thumbnailBase64"].as_str()?)?;
|
||||
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()?.to_rgba8();
|
||||
let color = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw());
|
||||
Some((f["name"].as_str()?.to_string(), Thumb::Decoded(color)))
|
||||
Some((f["name"].as_str()?.to_string(), Thumb { image: std::sync::Arc::new(color), tex: None }))
|
||||
})
|
||||
.collect();
|
||||
Ok(r)
|
||||
@@ -243,15 +253,10 @@ pub fn paint_cursor(app: &mut VectorcraftApp, ctx: &egui::Context, painter: &egu
|
||||
let name = opts["name"].as_str().unwrap_or_default();
|
||||
let t = Tokens::get(ctx);
|
||||
let tex = app.place.thumbs.iter_mut().find(|(n, _)| n == name).map(|(n, th)| {
|
||||
if let Thumb::Decoded(img) = th {
|
||||
*th = Thumb::Uploaded(ctx.load_texture(format!("place-thumb-{n}"), std::mem::take(img), egui::TextureOptions::LINEAR));
|
||||
}
|
||||
match th {
|
||||
Thumb::Uploaded(tex) => Some((tex.id(), tex.size_vec2())),
|
||||
Thumb::Decoded(_) => None,
|
||||
}
|
||||
let tex = th.tex.get_or_insert_with(|| ctx.load_texture(format!("place-thumb-{n}"), th.image.clone(), egui::TextureOptions::LINEAR));
|
||||
(tex.id(), tex.size_vec2())
|
||||
});
|
||||
let (id, size) = tex.flatten().unzip();
|
||||
let (id, size) = tex.unzip();
|
||||
let size = size.unwrap_or(egui::vec2(THUMB as f32, THUMB as f32 * 0.75));
|
||||
let k = THUMB as f32 / size.x.max(size.y).max(1.0);
|
||||
let r = egui::Rect::from_min_size(pos + THUMB_OFFSET, size * k);
|
||||
|
||||
@@ -95,9 +95,13 @@ pub(super) fn blue_png() -> Vec<u8> {
|
||||
png
|
||||
}
|
||||
|
||||
/// One headless frame of app logic (shortcuts too, from the second frame on) delivering `events`.
|
||||
/// 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>) {
|
||||
let ctx = egui::Context::default();
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1269,7 +1269,7 @@ pub fn doc_preview(ui: &Ui, key: &str, size: Vec2, build: impl FnOnce(f64, f64)
|
||||
use std::collections::HashMap;
|
||||
thread_local! {
|
||||
static RENDERER: RefCell<vectorcraft_render::Renderer> = RefCell::new(vectorcraft_render::Renderer::new());
|
||||
static CACHE: RefCell<HashMap<String, egui::TextureHandle>> = RefCell::new(HashMap::new());
|
||||
static CACHE: crate::graphics::TexCache<HashMap<String, egui::TextureHandle>> = crate::graphics::TexCache::default();
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point() as f64;
|
||||
let (w, h) = (size.x as f64, size.y as f64);
|
||||
|
||||
@@ -22,6 +22,15 @@ How the web shell (`apps/vectorcraft-web/src/web.rs`) differs from desktop:
|
||||
- **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary `<a download>`, all created from Rust. There is no save dialog, so the suggested name becomes the download name.
|
||||
- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox (files dropped on a canvas go to `Services::place_inbox` with their `place::DropAt`: the document they were dropped on, by uid, and the point). A file that arrives after another document became active still lands in its own document, which becomes active again; if that document closed meanwhile, the file is not placed and the status bar says so. (The app's synchronous drop path is compiled out on wasm32.)
|
||||
- **Data Recovery** keeps its copies in `localStorage` (`vectorcraft-recovery/<area>/<name>`, shared by the site's tabs), so tabs can't lock their areas the way desktop apps lock a folder (`RecoveryStore::lock`). Each tab holds its area with a heartbeat it refreshes every minute and, where the browser has Web Locks (secure pages: `https://` or localhost), with a Web Lock named after the area (`apps/vectorcraft-web/src/locks.rs`, through `js_sys::Reflect` since web-sys has them only as an unstable API). The browser releases the lock only when the tab is gone, not while its timers are paused in the background, which a heartbeat can't tell apart (#367). `navigator.locks.query()` is asynchronous, so the shell keeps a snapshot refreshed every 10 seconds (taken once before the first frame); a snapshot older than a minute counts every area as held. An area is offered as a crash's leftovers only when no tab holds its lock and its heartbeat is older than three intervals. Without Web Locks a paused tab can still be taken for gone; it repairs that when it resumes: its next heartbeat (or recovery save) writes again the copies missing from its area, and a heartbeat newer than the one the other tab wrote taking the area over makes the area running again for that tab.
|
||||
- **Losing the graphics (#369):** the browser can drop the page's WebGPU device or WebGL context (a GPU reset, a driver update, too many tabs on the GPU; or a script calling `device.destroy()` / `WEBGL_lose_context.loseContext()`). The app goes on without it, so the shell watches for it: wgpu's device-lost callback for WebGPU, the canvas's `webglcontextlost` event for WebGL2. Both report to a `graphics::GraphicsLoss`, and the next frame of `WebShell::logic` handles it:
|
||||
1. `VectorcraftApp::graphics_lost` writes Data Recovery copies of the modified documents at once (to browser storage, also when Data Recovery is turned off).
|
||||
2. The shell puts a fresh `<canvas>` in place of the dead one (a canvas whose WebGL context is lost can't get another) and starts the same `WebRunner` again on it, with new graphics and a new egui context, handing over the same `VectorcraftApp`: documents, undo history, selection and panels stay as they were. Its `Services` are made again for the new context, and the status bar says the graphics were restarted.
|
||||
3. Textures belong to the egui context they were uploaded to. The app notices the new context (`VectorcraftApp::adopt_context`) and uploads everything again: fonts and theme, the canvas, the place cursor's thumbnails, and every per-thread texture cache (thumbnails, previews, font samples), which are `graphics::TexCache`s for that reason. A new texture cache must be a `TexCache` (or live in the context's `ctx.data`), or it shows stale or wrong pictures after a restart.
|
||||
4. If no new graphics can be had (no adapter, WebGL blocked after repeated losses) or the graphics were lost more than three times, the shell gives up: the dead canvas is removed, the app lets go of its recovery area but not its copies (`recovery::leave`: its heartbeat and Web Lock go, so the reloaded page offers the copies at once). The Web Locks are started once, before the first frame, and kept across graphics restarts, so a restarted tab stays the owner of its area and the page says to reload.
|
||||
|
||||
To try it, in the browser console: for WebGL2 (`?webgl`), `document.querySelector('canvas').getContext('webgl2').getExtension('WEBGL_lose_context').loseContext()`. For WebGPU, keep the device the page creates (wrap `GPUAdapter.prototype.requestDevice` before the page loads) and call its `destroy()`.
|
||||
|
||||
The desktop app can't make a new device for its window (eframe can't), so on a lost device (a driver reset) it logs the reason, writes the recovery copies, and says in the status bar to save and restart. Once the copies are written, closing the window skips the Save Changes question, which it can't show: the next launch offers the changes back.
|
||||
- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`.
|
||||
- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter).
|
||||
|
||||
|
||||
Reference in new issue
Block a user