diff --git a/Cargo.lock b/Cargo.lock index 19b46405..7f0b7069 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3260,7 +3260,9 @@ version = "0.1.0" dependencies = [ "eframe", "egui", + "js-sys", "log", + "serde_json", "solvecraft-engine", "solvecraft-ui-egui", "wasm-bindgen", diff --git a/apps/solvecraft-web/Cargo.toml b/apps/solvecraft-web/Cargo.toml index 0a093c3c..e29ae198 100644 --- a/apps/solvecraft-web/Cargo.toml +++ b/apps/solvecraft-web/Cargo.toml @@ -23,4 +23,10 @@ log = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] } +js-sys = "0.3" +serde_json = { workspace = true } +web-sys = { version = "0.3", features = [ + "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", + "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream", + "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage", +] } diff --git a/apps/solvecraft-web/src/files.rs b/apps/solvecraft-web/src/files.rs new file mode 100644 index 00000000..8f20438e --- /dev/null +++ b/apps/solvecraft-web/src/files.rs @@ -0,0 +1,476 @@ +//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into +//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a +//! command writes one. Files come in from the computer through a file picker (the File System +//! Access API where the browser has it, an `` otherwise) or a drop on the page, +//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept +//! in OPFS too and offered again after a reload or a crash. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use js_sys::{Array, Function, Object, Reflect, Uint8Array}; +use solvecraft_engine::io::vfs; +use wasm_bindgen::{JsCast as _, JsValue, closure::Closure}; +use wasm_bindgen_futures::{JsFuture, spawn_local}; +use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream}; + +/// Where OPFS files appear to the engine. +pub const OPFS: &str = "/opfs/"; +/// Paths written here are handed to the user as downloads. +pub const DOWNLOADS: &str = "/downloads/"; +/// Files brought in from the computer. +pub const UPLOAD: &str = "/upload/"; +/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`). +const RECOVERY: &str = "recovery.solvecraft"; +const RECOVERY_META: &str = "recovery.json"; + +/// What to do with a file brought in from the computer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Intent { + /// Open it (a design, or STEP/mesh as a new design). + Open, + /// Add its bodies to the open design (STEP, 3MF, STL). + Insert, +} + +/// A stored design, as the file panel lists it. +#[derive(Clone, Debug)] +pub struct Entry { + pub name: String, + pub size: u64, + /// Last written (ms since 1970; 0 when unknown). + pub modified: f64, +} + +/// State shared between the async browser calls and the app's frames. +#[derive(Default)] +pub struct Files { + pub entries: RefCell>, + /// Files read from the computer: (name, bytes, what to do). + pub incoming: RefCell, Intent)>>, + /// Messages for the status line (errors and confirmations). + pub messages: RefCell>, + /// The stored designs are loaded (the app may open them). + pub ready: Cell, + /// The file panel is open. + pub panel: Cell, + /// An autosave left by an earlier session: its design's name and file. + pub recovery: RefCell)>>, + /// The app's context, to wake it when a browser call finishes. + pub ctx: RefCell>, +} + +impl Files { + fn say(&self, text: impl Into, error: bool) { + self.messages.borrow_mut().push((text.into(), error)); + self.wake(); + } + + /// A file arrived from the computer. + fn arrive(&self, name: String, bytes: Vec, intent: Intent) { + self.incoming.borrow_mut().push((name, bytes, intent)); + self.wake(); + } + + /// Run a frame (the app sleeps until something happens). + fn wake(&self) { + if let Some(c) = self.ctx.borrow().as_ref() { + c.request_repaint(); + } + } +} + +thread_local! { + /// The private file system isn't there (an insecure page: http on a network address, or an + /// old browser): designs are kept in local storage instead. + static LOCAL: Cell = const { Cell::new(false) }; +} + +/// Local storage keys of stored files. +const LOCAL_PREFIX: &str = "solvecraft.file."; + +fn local() -> Result { + web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string()) +} + +/// Text as is, anything else as hex (local storage holds strings). +fn encode(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(t) => format!("t{t}"), + Err(_) => { + let mut s = String::with_capacity(bytes.len() * 2 + 1); + s.push('x'); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s + } + } +} + +fn decode(s: &str) -> Vec { + if let Some(t) = s.strip_prefix('t') { + return t.as_bytes().to_vec(); + } + let h = s.strip_prefix('x').unwrap_or(""); + (0..h.len() / 2).filter_map(|i| h.get(2 * i..2 * i + 2).and_then(|x| u8::from_str_radix(x, 16).ok())).collect() +} + +/// Is the browser's storage local storage (no OPFS here)? +pub fn local_only() -> bool { + LOCAL.with(Cell::get) +} + +fn err(e: JsValue) -> String { + e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}")) +} + +/// The browser's storage manager: missing on insecure pages (http on a network address) and in +/// old browsers, where calling into it would throw. +fn storage_manager() -> Option { + let nav = web_sys::window()?.navigator(); + let sm = Reflect::get(&nav, &"storage".into()).ok().filter(|v| v.is_object())?; + Some(sm.unchecked_into()) +} + +/// Does the storage manager have `method`? +fn has(sm: &web_sys::StorageManager, method: &str) -> bool { + Reflect::get(sm, &method.into()).is_ok_and(|f| f.is_function()) +} + +async fn root() -> Result { + let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?; + let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?; + dir.dyn_into::().map_err(|_| "this browser has no private file storage (OPFS)".to_string()) +} + +async fn read_handle(h: &FileSystemFileHandle) -> Result, String> { + let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?; + blob_bytes(&file).await +} + +async fn blob_bytes(b: &web_sys::Blob) -> Result, String> { + let buf = JsFuture::from(b.array_buffer()).await.map_err(err)?; + Ok(Uint8Array::new(&buf).to_vec()) +} + +async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool) -> Result { + let opts = FileSystemGetFileOptions::new(); + opts.set_create(create); + JsFuture::from(dir.get_file_handle_with_options(name, &opts)).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string()) +} + +async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> { + if local_only() { + return local()? + .set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes)) + .map_err(|e| format!("local storage is full or blocked: {}", err(e))); + } + let dir = root().await?; + let h = file_handle(&dir, name, true).await?; + let w: FileSystemWritableFileStream = + JsFuture::from(h.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?; + JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?; + JsFuture::from(w.close()).await.map_err(err)?; + Ok(()) +} + +async fn remove_file(name: &str) -> Result<(), String> { + if local_only() { + return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err); + } + let dir = root().await?; + JsFuture::from(dir.remove_entry(name)).await.map_err(err)?; + Ok(()) +} + +/// Every stored file in local storage. +fn read_local() -> Result, f64)>, String> { + let st = local()?; + let mut out = Vec::new(); + for i in 0..st.length().unwrap_or(0).min(10_000) { + let Some(k) = st.key(i).ok().flatten() else { continue }; + let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue }; + if let Some(v) = st.get_item(&k).ok().flatten() { + out.push((name.to_string(), decode(&v), 0.0)); + } + } + Ok(out) +} + +/// Every stored file: (name, bytes). Without OPFS, local storage takes over from here on. +async fn read_all() -> Result, f64)>, String> { + let dir = match root().await { + Ok(d) => d, + Err(e) => { + log::warn!("no OPFS ({e}): designs go to local storage"); + LOCAL.with(|l| l.set(true)); + return read_local(); + } + }; + let iter: JsValue = dir.values().into(); + let next: Function = Reflect::get(&iter, &"next".into()).map_err(err)?.dyn_into().map_err(|_| "no iterator".to_string())?; + let mut out = Vec::new(); + for _ in 0..10_000 { + let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iter).map_err(err)?)).await.map_err(err)?; + if Reflect::get(&step, &"done".into()).ok().and_then(|d| d.as_bool()).unwrap_or(true) { + break; + } + let Ok(h) = Reflect::get(&step, &"value".into()).map_err(err)?.dyn_into::() else { continue }; + let file = + match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::().map_err(|_| "not a file".to_string())) { + Ok(f) => f, + Err(e) => { + log::warn!("{}: {e}", h.name()); + continue; + } + }; + match blob_bytes(&file).await { + Ok(b) => out.push((h.name(), b, file.last_modified())), + Err(e) => log::warn!("{}: {e}", h.name()), + } + } + Ok(out) +} + +/// Ask the browser to keep the storage (not evict it under pressure), then load every stored +/// file into the engine's files. +pub fn start(files: &Rc) { + if let Some(sm) = storage_manager().filter(|sm| has(sm, "persist")) + && let Ok(p) = sm.persist() + { + spawn_local(async move { + let _ = JsFuture::from(p).await; + }); + } + let files = files.clone(); + spawn_local(async move { + match read_all().await { + Ok(all) => { + if local_only() { + files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false); + } + let mut entries = Vec::new(); + for (name, bytes, modified) in all { + if name == RECOVERY_META { + let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default(); + let n = meta["name"].as_str().unwrap_or("Untitled").to_string(); + *files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string))); + continue; + } + if name != RECOVERY { + entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified }); + } + vfs::insert(&format!("{OPFS}{name}"), bytes); + } + entries.sort_by_key(|a| a.name.to_lowercase()); + *files.entries.borrow_mut() = entries; + // An autosave without its design file is nothing to recover. + if !vfs::exists(&format!("{OPFS}{RECOVERY}")) { + *files.recovery.borrow_mut() = None; + } + } + Err(e) => files.say(format!("browser storage: {e}"), true), + } + files.ready.set(true); + files.wake(); + }); +} + +/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads. +pub fn flush(files: &Rc) { + for (path, bytes) in vfs::take_writes() { + if let Some(name) = path.strip_prefix(OPFS) { + let name = name.to_string(); + { + let mut e = files.entries.borrow_mut(); + e.retain(|x| x.name != name); + if name != RECOVERY && name != RECOVERY_META { + e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() }); + e.sort_by_key(|a| a.name.to_lowercase()); + } + } + let files = files.clone(); + spawn_local(async move { + if let Err(e) = write_file(&name, &bytes).await { + files.say(format!("{name}: {e}"), true); + } + }); + } else if let Some(name) = path.strip_prefix(DOWNLOADS) + && let Err(e) = download(name, &bytes) + { + files.say(format!("{name}: {e}"), true); + } + } +} + +/// Hand `bytes` to the user as a download named `name`. +pub fn download(name: &str, bytes: &[u8]) -> Result<(), String> { + let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let parts = Array::new(); + parts.push(&Uint8Array::from(bytes)); + let opts = web_sys::BlobPropertyBag::new(); + opts.set_type("application/octet-stream"); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?; + let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?; + let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(err)?.dyn_into().map_err(|_| "no anchor".to_string())?; + a.set_href(&url); + a.set_download(name); + a.click(); + // Let the browser start the download before the URL goes. + let revoke = Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + if let Some(w) = web_sys::window() { + let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000); + } + Ok(()) +} + +/// Delete a stored design. +pub fn delete(files: &Rc, name: &str) { + let _ = vfs::remove(&format!("{OPFS}{name}")); + files.entries.borrow_mut().retain(|e| e.name != name); + let (files, name) = (files.clone(), name.to_string()); + spawn_local(async move { + if let Err(e) = remove_file(&name).await { + files.say(format!("{name}: {e}"), true); + } + }); +} + +/// Rename a stored design (a copy under the new name, then the old one goes: not every browser +/// can move OPFS files). +pub fn rename(files: &Rc, from: &str, to: &str) -> Result<(), String> { + let to = to.trim(); + if to.is_empty() || to.contains('/') || to.contains('\\') || to.len() > 200 { + return Err("give a file name without slashes".into()); + } + if files.entries.borrow().iter().any(|e| e.name == to) { + return Err(format!("there is already a file named {to}")); + } + let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?; + vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?; + delete(files, from); + flush(files); + Ok(()) +} + +/// Keep (or drop) the autosave of the open design. +pub fn autosave(files: &Rc, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) { + let files = files.clone(); + match doc { + Some((d, path)) => { + let bytes = solvecraft_engine::io::write_design(d); + let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string(); + spawn_local(async move { + let r = async { + write_file(RECOVERY, &bytes).await?; + write_file(RECOVERY_META, meta.as_bytes()).await + }; + if let Err(e) = r.await { + files.say(format!("autosave: {e}"), true); + } + }); + } + None => spawn_local(async move { + let _ = remove_file(RECOVERY).await; + let _ = remove_file(RECOVERY_META).await; + }), + } +} + +/// A stored path for `name` that no stored file has: `name`, else `name (2)`, `name (3)`… +pub fn free_name(name: &str) -> String { + let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new())); + for i in 1..1000 { + let n = if i == 1 { name.to_string() } else { format!("{stem} ({i}){ext}") }; + let p = format!("{OPFS}{n}"); + if !vfs::exists(&p) { + return p; + } + } + format!("{OPFS}{stem} ({}){ext}", js_sys::Date::now() as u64) +} + +/// The autosaved design's path (in the engine's files) for opening it. +pub fn recovery_path() -> String { + format!("{OPFS}{RECOVERY}") +} + +/// Pick files on the computer: the File System Access picker where there is one, else an +/// ``. Read files arrive in `files.incoming`. +pub fn pick_from_disk(files: &Rc, intent: Intent) { + let Some(window) = web_sys::window() else { return }; + let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::().ok()); + match picker { + Some(f) => { + let opts = Object::new(); + let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE); + let promise = f.call1(&window, &opts); + let files = files.clone(); + spawn_local(async move { + let r = async { + let p: js_sys::Promise = promise.map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?; + let handles: Array = JsFuture::from(p).await.map_err(err)?.dyn_into().map_err(|_| "no files".to_string())?; + for h in handles.iter() { + let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?; + let bytes = read_handle(&h).await?; + files.arrive(h.name(), bytes, intent); + } + Ok::<(), String>(()) + }; + match r.await { + Ok(()) => {} + // Closing the picker is no error. + Err(e) if e.contains("abort") || e.contains("Abort") => {} + Err(e) => files.say(format!("open: {e}"), true), + } + }); + } + None => input_picker(files, intent), + } +} + +fn input_picker(files: &Rc, intent: Intent) { + let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return }; + let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; + input.set_type("file"); + input.set_accept(".solvecraft,.step,.stp,.3mf,.stl"); + // Attached (hidden) while it is open: some browsers ignore a click on a detached input. + let _ = input.set_attribute("style", "display:none"); + if let Some(body) = doc.body() { + let _ = body.append_child(&input); + } + let (files, el) = (files.clone(), input.clone()); + let on_change = Closure::once_into_js(move || { + el.remove(); + let Some(list) = el.files() else { return }; + for i in 0..list.length() { + let Some(f) = list.get(i) else { continue }; + let files = files.clone(); + spawn_local(async move { + match blob_bytes(&f).await { + Ok(b) => files.arrive(f.name(), b, intent), + Err(e) => files.say(format!("{}: {e}", f.name()), true), + } + }); + } + }); + input.set_onchange(Some(on_change.unchecked_ref())); + input.click(); +} + +/// Read files dropped on the page. +pub fn take_drops(files: &Rc, raw: &mut egui::RawInput) { + for f in std::mem::take(&mut raw.dropped_files) { + let files = files.clone(); + 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 { + Ok(b) => files.arrive(name, b, Intent::Insert), + Err(e) => files.say(format!("{name}: {e}"), true), + } + }); + } +} diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs index b64f779d..4dde3a15 100644 --- a/apps/solvecraft-web/src/main.rs +++ b/apps/solvecraft-web/src/main.rs @@ -6,30 +6,304 @@ //! sample part. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#[cfg(target_arch = "wasm32")] +mod files; + #[cfg(target_arch = "wasm32")] mod web { + use std::rc::Rc; + use solvecraft_engine::Session; + use solvecraft_engine::io::vfs; use solvecraft_ui_egui::{Services, SolveApp}; use wasm_bindgen::JsCast as _; + use crate::files::{self, Files, Intent}; + const CANVAS_ID: &str = "solvecraft_canvas"; const LOADING_ID: &str = "solvecraft_loading"; const DEPTH_BITS: u8 = 24; - struct Shell(SolveApp); + /// Seconds between autosaves of unsaved work. + const AUTOSAVE_S: f64 = 30.0; + + struct Shell { + app: SolveApp, + files: Rc, + /// When the last autosave was (seconds), and the revision it holds (none stored: `None`). + autosaved_at: f64, + autosaved: Option, + /// A stored design being renamed: (its name, the new name). + renaming: Option<(String, String)>, + /// The rename field takes the keyboard when it appears. + rename_focus: bool, + /// The start page's recent designs were filled from the stored ones. + recents_synced: bool, + } + + impl Shell { + /// Files from the computer, writes to store, messages and the autosave. + fn files_tick(&mut self, ctx: &egui::Context) { + if self.files.ctx.borrow().is_none() { + *self.files.ctx.borrow_mut() = Some(ctx.clone()); + } + files::flush(&self.files); + for (text, error) in self.files.messages.borrow_mut().drain(..) { + self.app.set_status(text, error); + } + let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect(); + for (name, bytes, intent) in incoming { + let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT)); + let path = if design && intent == Intent::Open { + // A design from the computer is kept in the browser (a copy), so Save works. + let path = files::free_name(&name); + if let Err(e) = vfs::write(&path, &bytes) { + self.app.set_status(e.to_string(), true); + } + files::flush(&self.files); + path + } else { + let path = format!("{}{name}", files::UPLOAD); + vfs::insert(&path, bytes); + path + }; + match intent { + Intent::Open => self.app.open_path(&path), + Intent::Insert => self.app.drop_path(&path), + } + self.files.panel.set(false); + } + if self.files.ready.get() && !self.recents_synced { + self.recents_synced = true; + self.sync_recents(); + } + let now = ctx.input(|i| i.time); + // Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave. + if self.files.ready.get() && self.files.recovery.borrow().is_none() { + let s = &self.app.session; + let changed = s.is_dirty() && self.autosaved != Some(s.revision); + let wait = AUTOSAVE_S - (now - self.autosaved_at); + if changed && wait <= 0.0 { + files::autosave(&self.files, Some((&s.doc, s.path.as_deref()))); + self.autosaved = Some(s.revision); + self.autosaved_at = now; + } else if changed { + // Come back for it even when nothing else happens. + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } else if !s.is_dirty() && self.autosaved.is_some() { + files::autosave(&self.files, None); + self.autosaved = None; + } + } + if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + + /// The start page's recent designs are the stored ones: the remembered order first (those + /// still stored), then the others, newest first. + fn sync_recents(&mut self) { + let mut entries = self.files.entries.borrow().clone(); + entries.sort_by(|a, b| b.modified.total_cmp(&a.modified)); + let recent = &mut self.app.home.recent; + recent.retain(|p| vfs::exists(p)); + for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) { + let p = format!("{}{}", files::OPFS, e.name); + if !recent.contains(&p) { + recent.push(p); + } + } + recent.truncate(solvecraft_ui_egui::home::MAX_RECENT); + } + + /// The stored designs: open, insert, rename, delete, download; open from the computer. + fn file_panel(&mut self, ctx: &egui::Context) { + if !self.files.panel.get() { + return; + } + let mut open = true; + let mut act: Option<(&'static str, String)> = None; + egui::Window::new("Files in this browser") + .open(&mut open) + .collapsible(false) + .resizable(true) + .default_width(520.0) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) + .show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("Open from computer…").clicked() { + files::pick_from_disk(&self.files, Intent::Open); + } + if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() { + files::pick_from_disk(&self.files, Intent::Insert); + } + if ui.button("Download open design").clicked() { + act = Some(("download_open", String::new())); + } + }); + ui.separator(); + if !self.files.ready.get() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Reading the browser's storage…"); + }); + return; + } + let entries = self.files.entries.borrow().clone(); + if entries.is_empty() { + ui.label("Designs you save appear here. They stay in this browser."); + } + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| { + for e in &entries { + match &mut self.renaming { + Some((from, to)) if *from == e.name => { + let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0)); + if std::mem::take(&mut self.rename_focus) { + r.request_focus(); + } + if r.lost_focus() { + act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + ("rename", to.clone()) + } else { + ("rename_cancel", String::new()) + }); + } + } + _ => { + ui.label(&e.name); + } + } + ui.label(format!("{:.1} KB", e.size as f64 / 1024.0)); + ui.horizontal(|ui| { + if ui.small_button("Open").clicked() { + act = Some(("open", e.name.clone())); + } + let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT)); + if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() { + act = Some(("insert", e.name.clone())); + } + if ui.small_button("Rename").clicked() { + self.renaming = Some((e.name.clone(), e.name.clone())); + self.rename_focus = true; + } + if ui.small_button("Download").clicked() { + act = Some(("download", e.name.clone())); + } + if ui.small_button("Delete").clicked() { + act = Some(("delete", e.name.clone())); + } + }); + ui.end_row(); + } + }); + }); + }); + if !open { + self.files.panel.set(false); + self.renaming = None; + } + let Some((what, name)) = act else { return }; + let path = format!("{}{name}", files::OPFS); + match what { + "open" => { + self.app.open_path(&path); + self.files.panel.set(false); + } + "insert" => { + self.app.insert_path(&path); + self.files.panel.set(false); + } + "download" => match vfs::read(&path) { + Ok(b) => { + if let Err(e) = files::download(&name, &b) { + self.app.set_status(e, true); + } + } + Err(e) => self.app.set_status(e.to_string(), true), + }, + "download_open" => { + let d = &self.app.session.doc; + let bytes = solvecraft_engine::io::write_design(d); + if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) { + self.app.set_status(e, true); + } + } + "delete" => { + files::delete(&self.files, &name); + self.app.home.recent.retain(|r| *r != path); + } + "rename_cancel" => self.renaming = None, + "rename" => { + if let Some((from, _)) = self.renaming.take() { + match files::rename(&self.files, &from, &name) { + Ok(()) => { + // Recent designs follow the new name. + let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS)); + for r in &mut self.app.home.recent { + if *r == old { + *r = new.clone(); + } + } + } + Err(e) => self.app.set_status(e, true), + } + } + } + _ => {} + } + } + + /// Offer the autosave an earlier session left. + fn recovery_prompt(&mut self, ctx: &egui::Context) { + let Some((name, path)) = self.files.recovery.borrow().clone() else { return }; + let mut answer = None; + egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show( + ctx, + |ui| { + ui.label(format!("“{name}” had unsaved changes when this page last closed.")); + ui.horizontal(|ui| { + if ui.button("Recover").clicked() { + answer = Some(true); + } + if ui.button("Discard").clicked() { + answer = Some(false); + } + }); + }, + ); + let Some(recover) = answer else { return }; + *self.files.recovery.borrow_mut() = None; + if recover { + self.app.open_path(&files::recovery_path()); + // It belongs to its own file again (saving goes there); it is unsaved. + self.app.session.path = path; + self.app.session.doc_mut().name = name; + self.app.session.mark_unsaved(); + self.app.home.open = false; + } + files::autosave(&self.files, None); + } + } impl eframe::App for Shell { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - self.0.logic(ctx); + self.app.logic(ctx); + self.files_tick(ctx); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + files::take_drops(&self.files, 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); + let ctx = ui.ctx().clone(); + self.file_panel(&ctx); + self.recovery_prompt(&ctx); } fn save(&mut self, storage: &mut dyn eframe::Storage) { - storage.set_string(PREFS_KEY, self.0.prefs()); + storage.set_string(PREFS_KEY, self.app.prefs()); } } @@ -59,7 +333,23 @@ mod web { canvas, options, Box::new(move |cc| { - let mut app = SolveApp::new(Session::default(), Services::default()); + let files = Rc::new(Files::default()); + files::start(&files); + let panel = files.clone(); + let services = Services { + // Open shows the browser's files (and the computer's, from there). + pick_open: Some(Box::new(move || { + panel.panel.set(true); + None + })), + // Designs are kept in the browser; everything else is downloaded. + pick_save: Some(Box::new(|name: &str, exts: &[&str]| { + let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT); + Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS })) + })), + ..Default::default() + }; + let mut app = SolveApp::new(Session::default(), services); if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) { app.load_prefs(&p); } @@ -73,7 +363,19 @@ mod web { app.session.doc_mut().name = "Sample Plate".into(); app.session.mark_saved(); } - Ok(Box::new(Shell(app))) + if !query().contains("sample") { + let rev = app.session.revision; + app.home.show(rev); + } + Ok(Box::new(Shell { + app, + files, + autosaved_at: f64::NEG_INFINITY, + autosaved: None, + renaming: None, + rename_focus: false, + recents_synced: false, + })) }), ) .await; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d991a280..db754d77 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -51,11 +51,11 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { /// Read a STEP file as an Import feature. fn read_step(path: &str) -> Result { - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() > solvecraft_io::MAX_STEP_BYTES as u64 { + let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta > solvecraft_io::MAX_STEP_BYTES as u64 { return Err(EngineError::Other(format!("{path}: file too large"))); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -80,11 +80,11 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result { /// Add a MeshImport feature for a 3MF or STL file. fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result { - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() > solvecraft_io::MAX_3MF_BYTES as u64 { + let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta > solvecraft_io::MAX_3MF_BYTES as u64 { return Err(EngineError::Other(format!("{path}: file too large"))); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string(); let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?; @@ -132,11 +132,11 @@ fn open(s: &mut Session, p: &Value) -> Result { let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]})); } - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES { + let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta as usize > solvecraft_io::MAX_DESIGN_BYTES { return Err(EngineError::Other(format!("{path}: file too large"))); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; let doc = solvecraft_io::read_design(&bytes)?; *s = Session::new(doc); s.path = Some(path.to_string()); @@ -175,7 +175,7 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu // Bodies carry the colour they are shown in (appearance, material or imported). solvecraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)? }; - std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + solvecraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")})) } diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs index 1ca10f9e..282a4463 100644 --- a/crates/engine/src/cmd/parameters.rs +++ b/crates/engine/src/cmd/parameters.rs @@ -219,7 +219,7 @@ fn export(s: &mut Session, p: &Value) -> Result { other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))), }; if let Some(path) = path { - std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; } Ok(json!({"format": format, "path": path, "text": text})) } @@ -229,11 +229,11 @@ fn import(s: &mut Session, p: &Value) -> Result { let text = match (str_(p, "text"), str_(p, "path")) { (Some(t), _) => t.to_string(), (None, Some(path)) => { - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() > 16 << 20 { + let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta > 16 << 20 { return Err(bad(cmd, "file too large")); } - std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))? + solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))? } _ => return Err(bad(cmd, "give `text` or `path`")), }; diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 0a8228ee..9f2807c5 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -369,7 +369,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result { let sh = sheet_of(s, p, cmd)?; let text = flat_dxf(&sh)?; if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) { - std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; return Ok(json!({"path": path, "bends": sh.bends().len()})); } Ok(json!({"dxf": text, "bends": sh.bends().len()})) diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs index 9c973aef..731664f0 100644 --- a/crates/engine/src/cmd/sketch_canvas.rs +++ b/crates/engine/src/cmd/sketch_canvas.rs @@ -31,11 +31,11 @@ fn image_bytes(p: &Value, cmd: &str) -> Result> { base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))? } else { let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?; - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() as usize > MAX_CANVAS_BYTES { + let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta as usize > MAX_CANVAS_BYTES { return Err(bad(cmd, "the image is too large")); } - std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))? + solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))? }; if b.len() > MAX_CANVAS_BYTES { return Err(bad(cmd, "the image is too large")); diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 58c4362f..fd405b0d 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -34,11 +34,11 @@ fn source_text(p: &Value, cmd: &str) -> Result { return Ok(t.to_string()); } let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?; - let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; - if meta.len() as usize > MAX_DRAWING_BYTES { + let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta as usize > MAX_DRAWING_BYTES { return Err(EngineError::Other(format!("{path}: file too large"))); } - let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; Ok(String::from_utf8_lossy(&bytes).to_string()) } @@ -186,7 +186,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result { } let text = solvecraft_io::write_dxf(&geom); if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) { - std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; return Ok(json!({"path": path, "entities": geom.len()})); } Ok(json!({"dxf": text, "entities": geom.len()})) diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 77c63f20..f1155f1c 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -10,6 +10,7 @@ pub mod safe_write; mod sketch2d; mod svg; mod threemf; +pub mod vfs; pub use dxf::{read_dxf, write_dxf}; pub use safe_write::{backup_path, write_atomic}; diff --git a/crates/io/src/safe_write.rs b/crates/io/src/safe_write.rs index 65109953..2f6fb41e 100644 --- a/crates/io/src/safe_write.rs +++ b/crates/io/src/safe_write.rs @@ -87,6 +87,11 @@ fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option) -> s /// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as /// `.bak` first. pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> std::io::Result<()> { + // In the browser the host stores files (one write is already atomic there). + if cfg!(target_arch = "wasm32") { + let _ = backup; + return crate::vfs::write(&path.to_string_lossy(), bytes); + } write_steps(path, bytes, backup, None) } diff --git a/crates/io/src/vfs.rs b/crates/io/src/vfs.rs new file mode 100644 index 00000000..839a246d --- /dev/null +++ b/crates/io/src/vfs.rs @@ -0,0 +1,102 @@ +//! File access for the engine. Natively it is the disk. In the browser (wasm32) there is no +//! file system to call synchronously, so files live in memory here: the web host loads them +//! (from the browser's private storage, a file picker or a drop) before a command reads them, +//! and takes what commands wrote ([`take_writes`]) to store it or hand it out as a download. + +use std::io::{Error, ErrorKind, Result}; + +#[cfg(not(target_arch = "wasm32"))] +mod imp { + use super::*; + + pub fn read(path: &str) -> Result> { + std::fs::read(path) + } + pub fn write(path: &str, bytes: &[u8]) -> Result<()> { + std::fs::write(path, bytes) + } + pub fn len(path: &str) -> Result { + std::fs::metadata(path).map(|m| m.len()) + } + pub fn exists(path: &str) -> bool { + std::path::Path::new(path).exists() + } + pub fn remove(path: &str) -> Result<()> { + std::fs::remove_file(path) + } +} + +#[cfg(target_arch = "wasm32")] +mod imp { + use std::cell::RefCell; + use std::collections::BTreeMap; + + use super::*; + + thread_local! { + static FILES: RefCell>> = const { RefCell::new(BTreeMap::new()) }; + static WRITES: RefCell> = const { RefCell::new(Vec::new()) }; + } + + fn missing(path: &str) -> Error { + Error::new(ErrorKind::NotFound, format!("{path}: no such file in this browser")) + } + + pub fn read(path: &str) -> Result> { + FILES.with(|f| f.borrow().get(path).cloned()).ok_or_else(|| missing(path)) + } + pub fn write(path: &str, bytes: &[u8]) -> Result<()> { + FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes.to_vec())); + WRITES.with(|w| { + let mut w = w.borrow_mut(); + w.retain(|p| p != path); + w.push(path.to_string()); + }); + Ok(()) + } + pub fn len(path: &str) -> Result { + FILES.with(|f| f.borrow().get(path).map(|b| b.len() as u64)).ok_or_else(|| missing(path)) + } + pub fn exists(path: &str) -> bool { + FILES.with(|f| f.borrow().contains_key(path)) + } + pub fn remove(path: &str) -> Result<()> { + FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path)) + } + pub fn insert(path: &str, bytes: Vec) { + FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes)); + } + pub fn take_writes() -> Vec<(String, Vec)> { + let paths = WRITES.with(|w| std::mem::take(&mut *w.borrow_mut())); + paths.into_iter().filter_map(|p| FILES.with(|f| f.borrow().get(&p).cloned()).map(|b| (p, b))).collect() + } + pub fn list(prefix: &str) -> Vec<(String, u64)> { + FILES.with(|f| f.borrow().iter().filter(|(p, _)| p.starts_with(prefix)).map(|(p, b)| (p.clone(), b.len() as u64)).collect()) + } +} + +pub use imp::*; + +/// A file's text. +pub fn read_to_string(path: &str) -> Result { + String::from_utf8(read(path)?).map_err(|e| Error::new(ErrorKind::InvalidData, e)) +} + +#[cfg(test)] +mod tests { + #[test] + fn files_round_trip() { + let dir = std::env::temp_dir().join(format!("solvecraft-vfs-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join("a.txt").to_string_lossy().to_string(); + assert!(!super::exists(&p)); + assert!(super::read(&p).is_err()); + super::write(&p, b"hello").unwrap(); + assert!(super::exists(&p)); + assert_eq!(super::len(&p).unwrap(), 5); + assert_eq!(super::read_to_string(&p).unwrap(), "hello"); + super::remove(&p).unwrap(); + assert!(!super::exists(&p)); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 5fa2768f..24feb9f9 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -180,7 +180,7 @@ pub fn open_sample(app: &mut SolveApp, i: usize) { } fn open_recent(app: &mut SolveApp, path: &str) { - if !std::path::Path::new(path).exists() { + if !solvecraft_engine::io::vfs::exists(path) { app.home.recent.retain(|p| p != path); app.set_status(format!("{path} is no longer there"), true); return; @@ -198,11 +198,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { poll(app, ui.ctx()); let t = Tokens::get(); let samples = solvecraft_engine::sample::samples(); - for i in 0..samples.len() { - want(&mut app.home, &format!("sample:{i}")); + // In the browser a picture blocks the page while it builds (no threads) and a kernel + // failure there can't be caught: samples are built when they are opened. + if !cfg!(target_arch = "wasm32") { + for i in 0..samples.len() { + want(&mut app.home, &format!("sample:{i}")); + } } let recent = app.home.recent.clone(); - for p in recent.iter().filter(|p| std::path::Path::new(p.as_str()).exists()) { + for p in recent.iter().filter(|p| solvecraft_engine::io::vfs::exists(p)) { want(&mut app.home, p); } let mut action: Option> = None; @@ -266,9 +270,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let path = std::path::Path::new(p); let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()); let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); - let missing = !path.exists(); - let sub = if missing { "missing".to_string() } else { folder }; - if card(ui, app.home.thumbs.get(p.as_str()).and_then(Option::as_ref), &name, &sub, missing).on_hover_text(p.as_str()).clicked() { + let missing = !solvecraft_engine::io::vfs::exists(p); + let sub = if missing { + "missing".to_string() + } else if p.starts_with("/opfs/") { + // The web build keeps designs in the browser's own storage. + "in this browser".to_string() + } else { + folder + }; + if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing) + .on_hover_text(p.as_str()) + .clicked() + { let p = p.clone(); action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p))); } @@ -281,7 +295,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let key = format!("sample:{i}"); let busy = app.home.wanted == Some(i); let sub = if busy { "opening…" } else { s.about }; - if card(ui, app.home.thumbs.get(&key).and_then(Option::as_ref), s.name, sub, false).on_hover_text(s.about).clicked() { + let tex = match app.home.thumbs.get(&key) { + Some(t) => Pic::Ready(t.as_ref()), + None => Pic::Icon("box"), + }; + if card(ui, tex, s.name, sub, false).on_hover_text(s.about).clicked() { action = Some(Box::new(move |app: &mut SolveApp| open_sample(app, i))); } }); @@ -313,8 +331,14 @@ fn grid(ui: &mut egui::Ui, n: usize, mut each: impl FnMut(&mut egui::Ui, usize)) } } +/// A card's picture: rendered (`None` while it renders), or an icon until it is asked for. +enum Pic<'a> { + Ready(Option<&'a egui::TextureHandle>), + Icon(&'static str), +} + /// A design card: its picture (a spinner while it renders), name and a line under it. -fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, name: &str, sub: &str, dim: bool) -> egui::Response { +fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response { let t = Tokens::get(); let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); @@ -327,12 +351,16 @@ fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, name: &str, sub: & egui::StrokeKind::Inside, ); let img = egui::Rect::from_min_size(r.min + vec2(8.0, 8.0), vec2(THUMB[0] as f32, THUMB[1] as f32)); - match tex { - Some(tx) => { + match pic { + Pic::Icon(icon) => { + ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header); + crate::icons::paint(ui.painter(), egui::Rect::from_center_size(img.center(), vec2(56.0, 56.0)), icon, t.icon, t.icon_fill, t.icon); + } + Pic::Ready(Some(tx)) => { let tint = if dim { Color32::from_white_alpha(90) } else { Color32::WHITE }; ui.painter().image(tx.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); } - None => { + Pic::Ready(None) => { ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header); if !dim { egui::Spinner::new().size(18.0).paint_at(ui, egui::Rect::from_center_size(img.center(), vec2(18.0, 18.0)));