mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
The panels and dialogs draw in their own egui families (`ui`, `ui-semibold`, `mono`), and egui panics drawing text in a family no font is bound to. Only `theme::install_fonts` binds them, and only `VectorcraftApp::logic` calls it, on its first frame. An embedder that draws the panels or dialogs itself (`dialogs::show`, a panel's `ui`) on its own context, without `logic`, panics on the first semibold title it draws, an effect dialog's for one. Calling `install_fonts` avoids that, but it embeds the interface's five faces (about 2 MB) whatever the embedder wants: today such an embedder keeps them out of its binary only by never calling it, so that the linker drops them. vectorcraft-ui-egui gets an `interface-fonts` feature, on by default, that embeds those faces. Without it `install_fonts` binds the same three families to egui's built-in fonts, so an embedder can always call it. Those lack some symbols the panels show (→ ▾ ● ✓), which the docs say. With the feature, `install_fonts` installs exactly what it did before: the same font data and the same family stacks. `tests/install_fonts.rs` draws an effect dialog on a context only `install_fonts` prepared, and checks the face at the head of each family in both configurations. `cargo xtask ci`'s `no-bundled-fonts` step runs it without default features and lints the crate that way. Without the `install_fonts` call, or without the semibold binding, both tests fail with egui's "FontFamily::Name(\"ui-semibold\") is not bound to any fonts". The two lib tests about the interface faces are adjusted: the graphics-loss test checks a family install_fonts binds, and the catalogs' glyph coverage test needs `interface-fonts`. docs/development.md says when an embedder calls `install_fonts`, and that it replaces the context's font definitions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
10 KiB
Rust
235 lines
10 KiB
Rust
//! 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| st.same_content(&c.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");
|
||
}
|
||
|
||
/// After the graphics are lost, the app uploads its canvas and previews to the new context and
|
||
/// applies its theme and fonts there 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");
|
||
let families = new.fonts(|f| f.definitions().families.clone());
|
||
assert!(families.contains_key(&egui::FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into())), "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"));
|
||
}
|
||
}
|