//! The library panel (plan D6; xfig's "Select an object or library" window, //! `w_library.c`): Load a Library, status, the objects as icons or a list, //! icon size, the selected object with its comments, Cancel and Select //! object. Select object runs `library.hand`; the object then follows the //! pointer (library mode of the engine). Thumbnails are the objects drawn by //! the canvas renderer. // // Ported from xfig src/w_library.c (panel layout and texts; xfig licence, see LICENSE-xfig). use crate::App; use egui::{vec2, Color32, Sense, TextureOptions}; use rustfig_engine::library::{self, LibRoot, Library}; use rustfig_geom::Affine; use rustfig_model::Document; use rustfig_render::raster::{rasterize, RasterOptions}; use rustfig_render::SceneOptions; use serde_json::json; use std::collections::HashMap; #[cfg(not(target_arch = "wasm32"))] use std::sync::{atomic::AtomicBool, atomic::Ordering, mpsc, Arc}; /// xfig's icon sizes. pub const ICON_SIZES: [u32; 5] = [40, 60, 80, 100, 120]; /// Thumbnails drawn per frame, so a big library opens at once. const PER_FRAME: usize = 6; /// Side of the preview, in points. const PREVIEW: usize = 180; /// A library scan running on its own thread (xfig's Stop button stops it). #[cfg(not(target_arch = "wasm32"))] struct ScanJob { rx: mpsc::Receiver>, cancel: Arc, } /// State of the library panel. #[derive(Default)] pub struct LibraryPanel { pub open: bool, #[cfg(not(target_arch = "wasm32"))] scan: Option, /// The browser: the roots to scan once the served index is in (C2). #[cfg(target_arch = "wasm32")] web_wait: Option>, /// Stop was pressed: the icons not yet drawn stay blank until Resume. pub stopped: bool, /// The folder or list file field (preference `library_dir`). pub source: String, libs: Vec, /// The loaded library's name. pub current: Option, objects: Vec, /// The picked object. pub selected: Option, comments: String, thumbs: HashMap<(String, String, u32), Option>, preview: Option<((String, String), Option)>, thumb_px: u32, /// Copy to library: the figure's name and the folder in the user library. save_name: String, save_folder: String, message: String, /// The search text, and the text `hits` were found for. query: String, searched: String, /// Search results: library, figure, found by comment. hits: Vec<(String, String, bool)>, truncated: bool, } impl LibraryPanel { /// Open the panel (the engine asked for it) and scan the library /// folders on a thread: a big library list does not freeze the window, /// and Stop ends the scan with what was found. `source` is the /// preference `library_dir`. pub fn start_scan(&mut self, roots: Vec, source: String) { self.open = true; self.stopped = false; self.source = source; #[cfg(not(target_arch = "wasm32"))] { if let Some(old) = self.scan.take() { old.cancel.store(true, Ordering::Relaxed); } let cancel = Arc::new(AtomicBool::new(false)); let (tx, rx) = mpsc::channel(); let (c, r) = (cancel.clone(), roots.clone()); let spawned = std::thread::Builder::new() .name("library-scan".into()) .spawn(move || { // The panel may be gone by now: nobody to tell then. let _ = tx.send(library::scan_cancellable(&r, &c)); }) .is_ok(); if spawned { // Small libraries are there at once; a big one finishes in the background. match rx.recv_timeout(std::time::Duration::from_millis(250)) { Ok(libs) => self.show_panel(libs), Err(_) => self.scan = Some(ScanJob { rx, cancel }), } return; } } // The browser: the bundle's index is fetched first (C2). #[cfg(target_arch = "wasm32")] if !library::web::index_loaded() { crate::web_fetch::request_index(); self.web_wait = Some(roots); self.show_panel(Vec::new()); return; } self.show_panel(library::scan(&roots)); } /// How many icons are drawn so far (tests). pub fn icons_drawn(&self) -> usize { self.thumbs.len() } /// Whether a scan is still running. pub fn scanning(&self) -> bool { #[cfg(not(target_arch = "wasm32"))] { self.scan.is_some() } #[cfg(target_arch = "wasm32")] { self.web_wait.is_some() } } /// Take the scan's result once it is in. fn poll_scan(&mut self) { #[cfg(target_arch = "wasm32")] { if library::web::index_loaded() { if let Some(roots) = self.web_wait.take() { self.show_panel(library::scan(&roots)); } } else if self.web_wait.is_some() { if let Some(e) = crate::web_fetch::take_index_error() { self.web_wait = None; self.message = format!("The libraries could not be loaded: {e}"); } } } #[cfg(not(target_arch = "wasm32"))] if let Some(job) = &self.scan { match job.rx.try_recv() { Ok(libs) => { self.scan = None; self.show_panel(libs); } Err(mpsc::TryRecvError::Empty) => {} Err(mpsc::TryRecvError::Disconnected) => self.scan = None, } } } /// xfig's Stop: end the scan and stop drawing icons. pub fn stop(&mut self) { #[cfg(not(target_arch = "wasm32"))] if let Some(job) = &self.scan { job.cancel.store(true, Ordering::Relaxed); } self.stopped = true; } /// Set the libraries a scan found. pub fn show_panel(&mut self, app_libs: Vec) { self.open = true; // The libraries may have changed: search again. self.searched = "\u{0}".into(); self.libs = app_libs; if let Some(c) = self.current.clone() { if !self.libs.iter().any(|l| l.name == c) { self.current = None; self.objects.clear(); self.selected = None; } } if self.current.is_none() { if let Some(first) = self.libs.first().map(|l| l.name.clone()) { self.load(&first); } } } fn lib(&self) -> Option<&Library> { let c = self.current.as_deref()?; self.libs.iter().find(|l| l.name == c) } /// Load a library by name (the panel's "Load a Library" menu). pub fn load(&mut self, name: &str) { self.current = Some(name.to_string()); self.stopped = false; self.objects = self .libs .iter() .find(|l| l.name == name) .map(|l| library::object_names(&l.path)) .unwrap_or_default(); self.selected = None; self.comments.clear(); self.preview = None; } /// Pick a search result: load its library and pick the figure. fn pick_hit(&mut self, library: &str, name: &str) { if self.current.as_deref() != Some(library) { self.load(library); } if let Some(i) = self.objects.iter().position(|n| n == name) { self.pick(i); } } fn pick(&mut self, i: usize) { self.selected = Some(i); let Some(lib) = self.lib().cloned() else { return }; let Some(name) = self.objects.get(i).cloned() else { return; }; self.comments = library::object_comments(&lib.path, &name).join("\n"); self.preview = None; } } /// The figure drawn into a square of `px` pixels (a margin around it). fn render(doc: &Document, px: usize) -> Option { let bb = doc .objects .iter() .fold(rustfig_geom::Rect::EMPTY, |r, o| r.union(&o.geometry_bbox())); if bb.is_empty() || !bb.min.x.is_finite() || !bb.max.x.is_finite() || !bb.min.y.is_finite() || !bb.max.y.is_finite() { return None; } let (w, h) = ((bb.max.x - bb.min.x).max(1.0), (bb.max.y - bb.min.y).max(1.0)); let margin = (px as f64 * 0.06).max(2.0); let s = ((px as f64 - 2.0 * margin) / w.max(h)).max(1e-6); let (ox, oy) = ((px as f64 - w * s) / 2.0, (px as f64 - h * s) / 2.0); let t = Affine::translate(-bb.min.x, -bb.min.y) .then(&Affine::scale(s, s)) .then(&Affine::translate(ox, oy)); let scene = rustfig_render::build_scene( doc, &SceneOptions { approx_latex: true, ..SceneOptions::default() }, ); let mut pm = rustfig_render::raster::Pixmap::new(px as u32, px as u32)?; rasterize( &scene, &mut pm, &RasterOptions { transform: t, min_line_px: 1.0, adjust_px: 0.0, pattern_k: 1, pattern: rustfig_render::pattern::PatternFrame::screen(), }, ); Some(egui::ColorImage::from_rgba_premultiplied([px, px], pm.data())) } /// The figure's texture: `None` while the browser still fetches the figure /// (ask again later), `Some(None)` when it can't be drawn. fn thumbnail( ctx: &egui::Context, lib: &Library, name: &str, px: usize, label: &str, ) -> Option> { #[cfg(target_arch = "wasm32")] if !crate::web_fetch::figure_ready(&lib.path, name) { return None; } let tex = library::read_figure(&lib.path, name) .ok() .and_then(|doc| render(&doc, px)) .map(|img| ctx.load_texture(label, img, TextureOptions::LINEAR)); Some(tex) } /// Show the window when it is open. pub fn show(app: &mut App, ctx: &egui::Context) { if !app.library.open { return; } let mut open = true; app.library.poll_scan(); if app.library.scanning() { ctx.request_repaint_after(std::time::Duration::from_millis(50)); } let mut stop = false; let mut resume = false; let mut use_source = false; let mut load: Option = None; let mut pick: Option = None; let mut pick_hit: Option<(String, String)> = None; if app.library.open && app.library.query != app.library.searched { let q = app.library.query.clone(); app.library.searched = q.clone(); app.library.hits.clear(); app.library.truncated = false; if !q.trim().is_empty() { if let Ok(v) = app.try_run("library.search", &json!({"query": q, "limit": 300})) { let s = |h: &serde_json::Value, k: &str| h[k].as_str().unwrap_or("").to_string(); app.library.hits = v["hits"] .as_array() .map(|a| { a.iter() .map(|h| (s(h, "library"), s(h, "name"), h["comment"].as_bool().unwrap_or(false))) .collect() }) .unwrap_or_default(); app.library.truncated = v["truncated"].as_bool().unwrap_or(false); } } } let mut select = false; let mut cancel = false; let mut save = false; let icon_view = app .engine .prefs .get("icon_view") .and_then(|v| v.as_bool()) .unwrap_or(true); let size = app .engine .prefs .get("library_icon_size") .and_then(|v| v.as_integer()) .and_then(|v| u32::try_from(v).ok()) .unwrap_or(60) .clamp(ICON_SIZES[0], ICON_SIZES[ICON_SIZES.len() - 1]); let mut set_view: Option = None; let mut set_size: Option = None; let have_objects = !app.engine.doc().objects.is_empty(); egui::Window::new("Select an object or library") .open(&mut open) .default_size(vec2(440.0, 560.0)) .collapsible(false) .show(ctx, |ui| { ui.vertical_centered(|ui| ui.strong("Load a Library")); let p = &mut app.library; ui.horizontal(|ui| { ui.label("Library:"); let text = match &p.current { Some(c) => c.clone(), None if p.libs.is_empty() => "No libraries".to_string(), None => "None Loaded".to_string(), }; let combo = egui::ComboBox::from_id_salt("library_choice").selected_text(text).width(300.0); let r = combo.show_ui(ui, |ui| { for l in &p.libs { let sel = p.current.as_deref() == Some(l.name.as_str()); let label = if l.user { format!("{} (yours)", l.name) } else { l.name.clone() }; if ui.selectable_label(sel, label).clicked() { load = Some(l.name.clone()); } } }); r.response .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Library")); }); ui.horizontal(|ui| { ui.label("Source:"); if cfg!(target_arch = "wasm32") { ui.label(source_text(true, &p.source)); return; } let r = ui.add( egui::TextEdit::singleline(&mut p.source) .hint_text("folder, or a list file naming several folders") .desired_width(260.0), ); r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Library source")); if ui.button("Use").clicked() || (r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) { use_source = true; } }); ui.horizontal(|ui| { ui.label("Status:"); let status = match &p.current { Some(_) => format!("{} library objects in library", p.objects.len()), None if p.libs.is_empty() => { "No libraries found: set the preference library_dir".to_string() } None => String::new(), }; ui.label(status); }); ui.horizontal(|ui| { ui.label("Selected object:"); let name = p.selected.and_then(|i| p.objects.get(i)).cloned().unwrap_or_default(); ui.strong(name); }); ui.horizontal(|ui| { ui.label("View:"); egui::ComboBox::from_id_salt("library_view") .selected_text(if icon_view { "Icon View" } else { "List View" }) .show_ui(ui, |ui| { if ui.selectable_label(icon_view, "Icon View").clicked() { set_view = Some(true); } if ui.selectable_label(!icon_view, "List View").clicked() { set_view = Some(false); } }); if icon_view { ui.label("Icon size:"); egui::ComboBox::from_id_salt("library_icon_size") .selected_text(size.to_string()) .width(50.0) .show_ui(ui, |ui| { for s in ICON_SIZES { if ui.selectable_label(s == size, s.to_string()).clicked() { set_size = Some(s); } } }); } }); ui.horizontal(|ui| { ui.label("Search:"); let r = ui.add( egui::TextEdit::singleline(&mut p.query) .hint_text("name or comment, in all libraries") .desired_width(220.0), ); r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Search libraries")); if !p.query.is_empty() && ui.button("Clear").clicked() { p.query.clear(); } }); ui.separator(); let searching = !p.query.trim().is_empty(); let lib = p.lib().cloned(); let n = p.objects.len(); let budget = std::cell::Cell::new(PER_FRAME); let pending = std::cell::Cell::new(false); let area = 240.0_f32; if searching { egui::ScrollArea::vertical() .id_salt("library_search_results") .max_height(area) .auto_shrink([false, false]) .show(ui, |ui| { if p.hits.is_empty() { ui.label("No figure found."); } for (l, n, by_comment) in &p.hits { let sel = p.current.as_deref() == Some(l.as_str()) && p.selected.and_then(|i| p.objects.get(i)) == Some(n); let text = format!("{n} ({l}{})", if *by_comment { ", comment" } else { "" }); let r = ui.selectable_label(sel, text); let label = format!("Search result {n} in {l}"); r.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, sel, label.clone())); if r.clicked() { pick_hit = Some((l.clone(), n.clone())); } if r.double_clicked() { pick_hit = Some((l.clone(), n.clone())); select = true; } } if p.truncated { ui.weak("More figures match: type more of the name."); } }); } else if let Some(lib) = lib { let px = size as usize; if p.thumb_px != size { p.thumb_px = size; p.thumbs.clear(); } egui::ScrollArea::vertical() .id_salt("library_objects") .max_height(area) .auto_shrink([false, false]) .show(ui, |ui| { if icon_view { ui.spacing_mut().item_spacing = vec2(3.0, 3.0); ui.horizontal_wrapped(|ui| { for i in 0..n { let Some(name) = p.objects.get(i).cloned() else { continue }; let side = size as f32 + 6.0; let (rect, resp) = ui.allocate_exact_size(vec2(side, side), Sense::click()); let selected = p.selected == Some(i); if ui.is_rect_visible(rect) { let key = (lib.name.clone(), name.clone(), size); if !p.thumbs.contains_key(&key) { if p.stopped { // Stop was pressed: leave it blank. } else if budget.get() > 0 { budget.set(budget.get() - 1); match thumbnail(ui.ctx(), &lib, &name, px, &format!("lib-{name}")) { Some(t) => { p.thumbs.insert(key.clone(), t); } // Still being fetched (the browser). None => pending.set(true), } } else { pending.set(true); } } ui.painter().rect_filled(rect, 2.0, Color32::WHITE); if let Some(Some(tex)) = p.thumbs.get(&key) { let img = egui::Rect::from_min_size(rect.min + vec2(3.0, 3.0), vec2(size as f32, size as f32)); ui.painter().image( tex.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE, ); } let stroke = if selected { egui::Stroke::new(2.0_f32, Color32::BLACK) } else { egui::Stroke::new(1.0_f32, Color32::from_gray(190)) }; ui.painter().rect_stroke(rect, 2.0, stroke, egui::StrokeKind::Inside); } let label = format!("Library object {name}"); resp.widget_info(|| { egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label.clone()) }); if resp.clone().on_hover_text(name.clone()).clicked() { pick = Some(i); } if resp.double_clicked() { pick = Some(i); select = true; } } }); } else { for i in 0..n { let Some(name) = p.objects.get(i).cloned() else { continue }; let r = ui.selectable_label(p.selected == Some(i), &name); if r.clicked() { pick = Some(i); } if r.double_clicked() { pick = Some(i); select = true; } } } }); if pending.get() { ui.ctx().request_repaint(); } } else { ui.allocate_space(vec2(1.0, area)); } let scanning = p.scanning(); if scanning || pending.get() || p.stopped { ui.horizontal(|ui| { if scanning || pending.get() { ui.spinner(); ui.label(if scanning { "Scanning libraries..." } else { "Loading icons..." }); if ui.button("Stop").clicked() { stop = true; } } else { ui.label("Stopped: icons not drawn yet."); if ui.button("Resume").clicked() { resume = true; } } }); } ui.separator(); ui.horizontal(|ui| { // The preview of the picked object (xfig: List View's preview canvas). let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW as f32 * 0.6, PREVIEW as f32 * 0.6), Sense::hover()); ui.painter().rect_filled(rect, 2.0, Color32::WHITE); ui.painter() .rect_stroke(rect, 2.0, egui::Stroke::new(1.0_f32, Color32::from_gray(150)), egui::StrokeKind::Inside); if let (Some(i), Some(lib)) = (p.selected, p.lib().cloned()) { if let Some(name) = p.objects.get(i).cloned() { let key = (lib.name.clone(), name.clone()); if p.preview.as_ref().map(|(k, _)| k) != Some(&key) { if let Some(t) = thumbnail(ui.ctx(), &lib, &name, PREVIEW, "lib-preview") { // The browser reads the comments once the figure is in. #[cfg(target_arch = "wasm32")] { p.comments = library::object_comments(&lib.path, &name).join("\n"); } p.preview = Some((key, t)); } else { ui.ctx().request_repaint_after(std::time::Duration::from_millis(50)); } } if let Some((_, Some(tex))) = &p.preview { ui.painter().image( tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE, ); } } } ui.vertical(|ui| { ui.strong("Object comments:"); let mut c = p.comments.clone(); ui.add( egui::TextEdit::multiline(&mut c) .desired_rows(4) .desired_width(f32::INFINITY) .interactive(false), ) .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, false, "Object comments")); }); }); ui.separator(); ui.horizontal(|ui| { if ui.button("Cancel").clicked() { cancel = true; } let can = p.selected.is_some(); if ui.add_enabled(can, egui::Button::new("Select object")).clicked() { select = true; } }); ui.collapsing("Copy to library", |ui| { ui.label("Saves the selected objects (else the whole figure) as a library figure in your library folder (preference library_dir)."); ui.horizontal(|ui| { ui.label("Name:"); ui.text_edit_singleline(&mut p.save_name) .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Library figure name")); }); ui.horizontal(|ui| { ui.label("Library folder:"); ui.text_edit_singleline(&mut p.save_folder) .widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, "Library folder")); }); let ok = !p.save_name.trim().is_empty() && have_objects; if ui.add_enabled(ok, egui::Button::new("Copy to library")).clicked() { save = true; } if !p.message.is_empty() { ui.label(p.message.clone()); } }); }); if let Some(v) = set_view { app.run("prefs.set", json!({"resource": "icon_view", "value": v})); } if let Some(s) = set_size { app.run("prefs.set", json!({"resource": "library_icon_size", "value": s})); } if stop { app.library.stop(); } if resume { app.library.stopped = false; } if use_source { let path = app.library.source.clone(); app.library.message = match app.try_run("library.use", &json!({"path": path})) { Ok(_) => { let roots = app.engine.library_roots(); app.library.start_scan(roots, path); String::new() } Err(m) => m, }; } if let Some(l) = load { app.library.load(&l); } if let Some(i) = pick { app.library.pick(i); } if let Some((l, n)) = pick_hit { app.library.pick_hit(&l, &n); } if save { let name = app.library.save_name.trim().to_string(); let mut p = json!({"name": name}); let folder = app.library.save_folder.trim().to_string(); if !folder.is_empty() { p["library"] = json!(folder); } let r = app.try_run("library.save", &p); app.library.message = match r { Ok(v) => { let libs = app.engine.libraries(); let cur = app.library.current.clone(); app.library.show_panel(libs); if let Some(c) = cur { if app.library.libs.iter().any(|l| l.name == c) { app.library.load(&c); } } format!("Saved {}", v["path"].as_str().unwrap_or("")) } Err(e) => e, }; } if select { let (lib, obj) = ( app.library.current.clone(), app.library.selected.and_then(|i| app.library.objects.get(i).cloned()), ); if let (Some(lib), Some(obj)) = (lib, obj) { if app.run("library.hand", json!({"library": lib, "name": obj})).is_some() { app.library.open = false; } } } if cancel || !open { app.library.open = false; } } /// The library "Source" line: on the web the bundled libraries (there is no /// folder to choose), on the desktop the folder as typed. pub fn source_text(web: bool, source: &str) -> String { if web { "bundled libraries".to_string() } else { source.to_string() } } #[cfg(test)] mod source_tests { use super::source_text; #[test] fn web_source_is_bundled() { assert_eq!(source_text(true, "~/xfiglib"), "bundled libraries"); assert_eq!(source_text(false, "~/xfiglib"), "~/xfiglib"); } } .