//! The start page: New Design, Open, the recent designs and the built-in samples, each with a //! picture of the design rendered from it. Shown on launch without a file and from the house //! button or the first document tab; selecting a design returns to its view. use std::collections::HashMap; use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2}; use solvecraft_engine::Session; use solvecraft_engine::render::{Camera, StandardView}; use crate::SolveApp; use crate::theme::Tokens; /// Recent designs kept. pub const MAX_RECENT: usize = 12; const THUMB: [usize; 2] = [240, 150]; /// What a background job makes: a key ("sample:N" or a path), the picture, and for samples the /// built design (opened at once when picked). type Made = (String, Option, Option); #[derive(Default)] pub struct HomeState { pub open: bool, /// Recent design paths, newest first (kept in the preferences). pub recent: Vec, pub opened_at: std::collections::BTreeMap, thumbs: HashMap>, samples: HashMap, /// Keys asked for and not made yet. queued: Vec, #[cfg(not(target_arch = "wasm32"))] rx: Option>, #[cfg(not(target_arch = "wasm32"))] remembering: Option>, /// A sample picked before its design was ready. wanted: Option, } impl HomeState { pub fn ready_recents(&self) -> usize { self.recent.iter().filter(|p| self.thumbs.get(*p).is_some_and(Option::is_some)).count() } pub fn show(&mut self, _rev: u64) { self.open = true; } /// Put a design at the top of the recent list. pub fn add_recent(&mut self, path: &str) { self.recent.retain(|p| p != path); self.recent.insert(0, path.to_string()); self.recent.truncate(MAX_RECENT); self.opened_at.insert(path.to_string(), web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)); self.opened_at.retain(|p, _| self.recent.contains(p)); self.thumbs.remove(path); } } /// A picture of a design: iso view, fitted. pub fn thumbnail(s: &Session) -> Option { let mut cam = Camera::default(); cam.set_view(StandardView::Iso); let b = solvecraft_engine::view::bounds(s); if b.is_empty() { return None; } cam.fit(&b); // Fill the card: the fit leaves room for a whole window's margins. cam.distance *= 0.7; let scene = solvecraft_engine::view::scene(s, &cam); let c = solvecraft_engine::render::render(&scene, &cam, THUMB[0], THUMB[1]); (c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba)) } /// File identity, not just its path: saving or replacing a design invalidates its picture. #[cfg(not(target_arch = "wasm32"))] fn cache_path(key: &str) -> Option { use std::hash::{Hash, Hasher}; let meta = std::fs::metadata(key).ok()?; let mut h = std::collections::hash_map::DefaultHasher::new(); key.hash(&mut h); meta.len().hash(&mut h); meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.hash(&mut h); let root = std::env::var_os("LOCALAPPDATA") .or_else(|| std::env::var_os("XDG_CACHE_HOME")) .map(std::path::PathBuf::from) .unwrap_or_else(std::env::temp_dir); Some(root.join("SolveCraft").join("thumbnails").join(format!("{:016x}.png", h.finish()))) } #[cfg(not(target_arch = "wasm32"))] fn cached(key: &str) -> Option { let path = cache_path(key)?; if std::fs::metadata(&path).ok()?.len() > 512 * 1024 { return None; } let bytes = std::fs::read(path).ok()?; let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png); let mut limits = image::Limits::default(); limits.max_image_width = Some(THUMB[0] as u32); limits.max_image_height = Some(THUMB[1] as u32); limits.max_alloc = Some((THUMB[0] * THUMB[1] * 8) as u64); reader.limits(limits); let image = reader.decode().ok()?.to_rgba8(); (image.width() as usize == THUMB[0] && image.height() as usize == THUMB[1]) .then(|| egui::ColorImage::from_rgba_unmultiplied(THUMB, image.as_raw())) } /// Render from the model already built by Open/Save, rather than evaluating its file again. pub(crate) fn remember(s: &Session) -> Option { #[cfg(not(target_arch = "wasm32"))] let identity = s.path.as_deref().and_then(cache_path); remember_at( s, #[cfg(not(target_arch = "wasm32"))] identity, ) } fn remember_at(s: &Session, #[cfg(not(target_arch = "wasm32"))] identity: Option) -> Option { let img = thumbnail(s)?; #[cfg(not(target_arch = "wasm32"))] if let Some(path) = identity.filter(|p| s.path.as_deref().and_then(cache_path).as_ref() == Some(p)) { let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); if let Some(bytes) = solvecraft_engine::render::png(THUMB[0], THUMB[1], rgba) && let Some(dir) = path.parent() && std::fs::create_dir_all(dir).is_ok() { let _ = solvecraft_engine::io::write_atomic(&path, &bytes, false); } } Some(img) } pub(crate) fn remember_session(h: &mut HomeState, s: &Session) { #[cfg(not(target_arch = "wasm32"))] if let Some(path) = &s.path { let identity = cache_path(path); let mut snapshot = s.scratch(); snapshot.path = Some(path.clone()); let key = path.clone(); let (tx, rx) = std::sync::mpsc::channel(); if std::thread::Builder::new() .name("saved-thumbnail".into()) .spawn(move || { let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| remember_at(&snapshot, identity))).ok().flatten(); let _ = tx.send((key, img, None)); }) .is_ok() { h.remembering = Some(rx); h.queued.retain(|k| k != path); h.thumbs.insert(path.clone(), None); } } #[cfg(target_arch = "wasm32")] { let _ = (h, s); } } pub(crate) fn install(h: &mut HomeState, ctx: &egui::Context, key: &str, img: Option) { let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR)); h.thumbs.insert(key.to_string(), tex); } /// Build what a key stands for (runs off the UI thread where threads exist). fn make(key: &str) -> Made { #[cfg(not(target_arch = "wasm32"))] if let Some(img) = cached(key) { return (key.to_string(), Some(img), None); } let mut s = Session::default(); let built = match key.strip_prefix("sample:").and_then(|i| i.parse::().ok()) { Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| { let ok = s.run_script(&(smp.script)()).is_ok(); s.doc_mut().name = smp.name.to_string(); s.undo.clear(); s.mark_saved(); ok }), None => s.execute("doc.open", &serde_json::json!({ "path": key })).is_ok(), }; if !built { return (key.to_string(), None, None); } let img = remember(&s); (key.to_string(), img, key.starts_with("sample:").then_some(s)) } #[cfg(not(target_arch = "wasm32"))] fn start_jobs(h: &mut HomeState) { if h.rx.is_some() || h.queued.is_empty() { return; } let mut keys = std::mem::take(&mut h.queued); // Cached recents first, then cold recents, then samples. A sample must never hold up // pictures of the user's work; a cold file must not hold up already cached pictures. keys.sort_by_key(|k| { if k.starts_with("sample:") { 2 } else if cache_path(k).is_some_and(|p| p.exists()) { 0 } else { 1 } }); let (tx, rx) = std::sync::mpsc::channel(); let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || { for k in keys { let made = std::panic::catch_unwind(|| make(&k)).unwrap_or((k, None, None)); if tx.send(made).is_err() { return; } } }); if spawned.is_ok() { h.rx = Some(rx); } } /// Take finished pictures and designs (one job per frame on the web, where there are no /// threads). fn poll(app: &mut SolveApp, ctx: &egui::Context) { let mut done: Vec = Vec::new(); #[cfg(not(target_arch = "wasm32"))] let mut remembered = None; #[cfg(not(target_arch = "wasm32"))] { if let Some(rx) = &app.home.remembering { match rx.try_recv() { Ok(m) => { remembered = Some(m); app.home.remembering = None; } Err(std::sync::mpsc::TryRecvError::Disconnected) => app.home.remembering = None, Err(std::sync::mpsc::TryRecvError::Empty) => { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } } } start_jobs(&mut app.home); if let Some(rx) = &app.home.rx { loop { match rx.try_recv() { Ok(m) => { // A cold job started before a save/open cannot overwrite its fresh picture. if m.0.starts_with("sample:") || !app.home.thumbs.get(&m.0).is_some_and(Option::is_some) { done.push(m); } } Err(std::sync::mpsc::TryRecvError::Empty) => break, Err(std::sync::mpsc::TryRecvError::Disconnected) => { app.home.rx = None; break; } } } } if let Some(m) = remembered { done.push(m); } if app.home.rx.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } #[cfg(target_arch = "wasm32")] if !app.home.queued.is_empty() { let k = app.home.queued.remove(0); done.push(make(&k)); ctx.request_repaint(); } for (key, img, session) in done { if session.is_none() && key.starts_with("sample:") && app.home.wanted.is_some_and(|w| key == format!("sample:{w}")) { app.home.wanted = None; app.set_status(format!("the sample could not be built ({key})"), true); } let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR)); app.home.thumbs.insert(key.clone(), tex); if let (Some(s), Some(i)) = (session, key.strip_prefix("sample:").and_then(|i| i.parse::().ok())) { app.home.samples.insert(i, s); } } if let Some(i) = app.home.wanted && app.home.samples.contains_key(&i) { app.home.wanted = None; open_sample(app, i); } } /// Ask for a key's picture (once: an empty entry marks it as asked for). fn want(h: &mut HomeState, key: &str) { if !h.thumbs.contains_key(key) { h.queued.push(key.to_string()); h.thumbs.insert(key.to_string(), None); } } /// Open a built-in sample (a copy of the design made for its picture). pub fn open_sample(app: &mut SolveApp, i: usize) { match app.home.samples.get(&i) { Some(s) => { let mut copy = s.scratch(); copy.mark_saved(); crate::documents::adopt(app, copy); app.home.open = false; } None => { app.home.wanted = Some(i); want(&mut app.home, &format!("sample:{i}")); } } } pub fn open_recent(app: &mut SolveApp, path: &str) { if !solvecraft_engine::io::vfs::exists(path) { app.set_status(format!("{path} is no longer there"), true); return; } app.home.open = false; app.open_path_async(path); } /// The page itself (in place of the toolbar, browser and viewport). pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { poll(app, ui.ctx()); let t = Tokens::get(); let samples = solvecraft_engine::sample::samples(); // 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| solvecraft_engine::io::vfs::exists(p)) { want(&mut app.home, p); } let mut action: Option> = None; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(36, 24))).show(ui, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal(|ui| { ui.label(RichText::new("SolidCraft").size(26.0).strong().color(t.text)); ui.add_space(12.0); ui.label(RichText::new("Start").size(16.0).color(t.text_dim)); }); ui.add_space(16.0); ui.horizontal(|ui| { let big = |ui: &mut egui::Ui, icon: &str, label: &str, primary: bool| -> bool { let (r, resp) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::click()); let fill = if primary { t.accent } else if resp.hovered() { t.hover } else { t.field }; ui.painter().rect(r, 6.0, fill, Stroke::new(1.0, if primary { t.accent } else { t.border }), egui::StrokeKind::Inside); let ink = if primary { Color32::WHITE } else { t.icon }; crate::icons::paint( ui.painter(), egui::Rect::from_center_size(r.left_center() + vec2(30.0, 0.0), vec2(30.0, 30.0)), icon, ink, t.icon_fill, ink, ); ui.painter().text( r.left_center() + vec2(56.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(15.0), if primary { Color32::WHITE } else { t.text }, ); resp.clicked() }; if big(ui, "new", "New Design", true) { action = Some(Box::new(|app: &mut SolveApp| crate::documents::new_design(app))); } ui.add_space(10.0); if big(ui, "open", "Open…", false) { action = Some(Box::new(|app: &mut SolveApp| { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { app.home.open = false; app.open_path_async(&p); } })); } }); ui.add_space(24.0); ui.horizontal_wrapped(|ui| { ui.hyperlink_to("Documentation", crate::help::DOCS_URL); ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solvecraft/issues/new"); ui.hyperlink_to("What's new", "https://github.com/bherbruck/solvecraft/releases/latest"); if ui.link("Keyboard Shortcuts").clicked() { app.keymap.open = true; app.keymap.read_only = true; } if ui.link("Preferences").clicked() { crate::prefs::open_at(app, 0); } }); ui.add_space(16.0); section(ui, "Recent"); if recent.is_empty() { ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim)); } else { grid(ui, recent.len(), |ui, i| { let Some(p) = recent.get(i) else { return }; 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 missing = !solvecraft_engine::io::vfs::exists(p); let mut 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 { p.clone() }; if let Some(time) = app.home.opened_at.get(p) { let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time); let seconds = now.saturating_sub(*time); let ago = if seconds < 60 { "just now".to_string() } else if seconds < 3600 { format!("{} min ago", seconds / 60) } else if seconds < 86400 { format!("{} h ago", seconds / 3600) } else { format!("{} days ago", seconds / 86400) }; sub.push_str(&format!("\nOpened {ago}")); } let response = 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()); crate::scenario::publish_handle(&format!("home:recent:{i}"), response.rect.center()); response.context_menu(|ui| { if ui.button("Remove from list").clicked() { app.home.recent.retain(|x| x != p); app.home.opened_at.remove(p); ui.close(); } if !cfg!(target_arch = "wasm32") && ui.button("Show in folder").clicked() { if let Some(show) = &app.services.show_in_folder { show(p); } ui.close(); } }); if response.clicked() { let p = p.clone(); action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p))); } }); } ui.add_space(24.0); section(ui, "Samples"); grid(ui, samples.len(), |ui, i| { let Some(s) = samples.get(i) else { return }; let key = format!("sample:{i}"); let busy = app.home.wanted == Some(i); let sub = if busy { "opening…" } else { s.about }; 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))); } }); }); }); if let Some(f) = action { f(app); } } fn section(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(); ui.label(RichText::new(title).size(15.0).strong().color(t.text)); ui.add_space(8.0); } /// Cards in rows that fill the width. fn grid(ui: &mut egui::Ui, n: usize, mut each: impl FnMut(&mut egui::Ui, usize)) { let w = THUMB[0] as f32 + 16.0; let per_row = ((ui.available_width() + 14.0) / (w + 14.0)).floor().max(1.0) as usize; for row in 0..n.div_ceil(per_row) { ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 14.0; for i in row * per_row..((row + 1) * per_row).min(n) { each(ui, i); } }); ui.add_space(14.0); } } /// 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, 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 + 80.0); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); let hover = resp.hovered(); ui.painter().rect( r, 6.0, if hover { t.hover } else { t.field }, Stroke::new(1.0, if hover { t.accent } else { t.border }), 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 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); } 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))); } } } let ty = img.bottom() + 8.0; let clip = egui::Rect::from_min_max(egui::pos2(r.left() + 8.0, ty), egui::pos2(r.right() - 8.0, r.bottom())); let p = ui.painter().with_clip_rect(clip); p.text(egui::pos2(r.left() + 10.0, ty), egui::Align2::LEFT_TOP, name, egui::FontId::proportional(14.0), if dim { t.text_dim } else { t.text }); p.text(egui::pos2(r.left() + 10.0, ty + 20.0), egui::Align2::LEFT_TOP, sub, egui::FontId::proportional(11.5), t.text_dim); if hover { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } resp } #[cfg(test)] mod tests { use super::*; #[cfg(not(target_arch = "wasm32"))] #[test] fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() { let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let path = std::env::temp_dir().join(format!("solvecraft-thumb-{}-{nonce}.solvecraft", std::process::id())); // Deliberately invalid design: a cached recent must not parse/evaluate it. let bytes = b"{\"thumbnail_test\":true}"; std::fs::write(&path, bytes).unwrap(); let key = path.to_string_lossy().to_string(); let mut s = Session::default(); s.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap(); s.path = Some(key.clone()); let image = remember(&s).unwrap(); let png = cache_path(&key).unwrap(); assert_eq!(std::fs::read(&path).unwrap(), bytes); let (_, cached_image, session) = make(&key); assert!(session.is_none()); assert_eq!(cached_image.unwrap().pixels, image.pixels); std::fs::write(&path, b"changed file identity").unwrap(); assert!(cached(&key).is_none()); std::fs::remove_file(path).unwrap(); std::fs::remove_file(png).unwrap(); } #[test] fn recent_list_is_newest_first_and_bounded() { let mut h = HomeState::default(); for i in 0..20 { h.add_recent(&format!("/d/{i}.solvecraft")); } h.add_recent("/d/5.solvecraft"); assert_eq!(h.recent.len(), MAX_RECENT); assert_eq!(h.recent.first().map(String::as_str), Some("/d/5.solvecraft")); assert_eq!(h.recent.iter().filter(|p| p.as_str() == "/d/5.solvecraft").count(), 1); } /// Samples build in the background and make a picture; picking one opens its design. #[test] fn sample_card_makes_a_picture_and_opens() { let (key, img, session) = make("sample:0"); assert_eq!(key, "sample:0"); let img = img.unwrap(); assert_eq!(img.size, THUMB); let s = session.unwrap(); assert_eq!(s.doc.name, "Sample Plate"); assert!(!s.is_dirty() && s.undo.is_empty()); let mut app = SolveApp::new(Session::default(), crate::Services::default()); app.home.samples.insert(0, s); app.home.show(app.session.revision); open_sample(&mut app, 0); assert!(!app.home.open); assert_eq!(app.session.doc.name, "Sample Plate"); assert!(!app.session.is_dirty()); } }