//! Figure libraries (plan D6): folders of `.fig` files the user places in a //! drawing, as xfig's library mode does. //! //! A library is a folder with `.fig` files; folders inside it are libraries //! of their own, named `Outer / Inner`. RustFig bundles xfig's libraries //! (`libraries/` in the source tree, next to the program when installed, or //! the folders in `RUSTFIG_LIBRARIES`) and reads the user's own from the //! `library_dir` preference. The web build has no file system: it reads the //! bundle from the served `libraries/` folder through an index ([`web`]). // // Ported from xfig src/w_library.c and src/e_placelib.c (xfig licence, see LICENSE-xfig). use crate::tools::{Button, Key, ToolEvent, ToolKind, ToolState}; use crate::{Engine, EngineError, UiRequest}; use rustfig_geom::{Affine, Point, Rect}; use rustfig_model::{Compound, ObjectKind}; use rustfig_model::{Document, Object, ObjectId}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; /// The bundled libraries in the browser (C2). #[path = "library_web.rs"] pub mod web; /// Most libraries listed. pub const MAX_LIBRARIES: usize = 5_000; /// Most objects listed in one library. pub const MAX_OBJECTS: usize = 20_000; /// Deepest folder nesting scanned. #[cfg(not(target_arch = "wasm32"))] const MAX_DEPTH: usize = 8; /// Largest library figure read. pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; /// Most characters of an object name. const MAX_NAME: usize = 120; /// The name xfig gives the figures lying in the top folder itself. pub const TOP_NAME: &str = "(Toplevel Directory)"; /// One library: a folder with figures. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Library { /// `Electronic / Schematic / Classic`; unique in the list. pub name: String, pub path: PathBuf, /// From the user's folder, not bundled with RustFig. pub user: bool, /// How many figures it holds. pub objects: usize, } /// A change made to a library object in hand before it is placed (xfig's /// keys r, l, h, v, s, S while placing). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LibOp { RotateCw, RotateCcw, FlipH, FlipV, Smaller, Larger, } impl LibOp { pub fn from_name(s: &str) -> Option { Some(match s { "rotate_cw" => LibOp::RotateCw, "rotate_ccw" => LibOp::RotateCcw, "flip_h" => LibOp::FlipH, "flip_v" => LibOp::FlipV, "smaller" => LibOp::Smaller, "larger" => LibOp::Larger, _ => return None, }) } pub fn name(self) -> &'static str { match self { LibOp::RotateCw => "rotate_cw", LibOp::RotateCcw => "rotate_ccw", LibOp::FlipH => "flip_h", LibOp::FlipV => "flip_v", LibOp::Smaller => "smaller", LibOp::Larger => "larger", } } /// xfig's key for it (`transform_lib_obj`). pub fn from_key(c: char) -> Option { Some(match c { 'r' => LibOp::RotateCw, 'l' => LibOp::RotateCcw, 'h' => LibOp::FlipH, 'v' => LibOp::FlipV, 's' => LibOp::Smaller, 'S' => LibOp::Larger, _ => return None, }) } fn affine(self) -> Affine { match self { LibOp::RotateCw => Affine::rotate(-std::f64::consts::FRAC_PI_2), LibOp::RotateCcw => Affine::rotate(std::f64::consts::FRAC_PI_2), LibOp::FlipH => Affine::scale(-1.0, 1.0), LibOp::FlipV => Affine::scale(1.0, -1.0), LibOp::Smaller => Affine::scale(0.9, 0.9), LibOp::Larger => Affine::scale(1.1, 1.1), } } } /// A library object read from its file, ready to place. #[derive(Debug, Clone, PartialEq)] pub struct LibObject { /// One compound holding the figure, where the file has it. pub object: Object, /// Its top-left corner in the file. pub nw: Point, } /// Where the packages put the libraries, relative to the program's folder: /// next to it (Windows zip, Linux tarball), `../Resources/libraries` (macOS /// app bundle), `../share/rustfig/libraries` (.deb, `install.sh`, Homebrew), /// and `../../libraries` (`cargo run`: the program is in target//). #[cfg(not(target_arch = "wasm32"))] pub fn beside_program(dir: &Path) -> [PathBuf; 4] { [ dir.join("libraries"), dir.join("../Resources/libraries"), dir.join("../share/rustfig/libraries"), dir.join("../../libraries"), ] } /// The folders bundled libraries are looked for in, most specific first: /// `RUSTFIG_LIBRARIES` (folders separated as `PATH` is), the places the /// packages install them ([`beside_program`]), and the source tree's own. #[cfg(not(target_arch = "wasm32"))] pub fn bundled_roots() -> Vec { let mut v: Vec = Vec::new(); if let Some(p) = std::env::var_os("RUSTFIG_LIBRARIES") { v.extend(std::env::split_paths(&p).filter(|p| !p.as_os_str().is_empty())); } if let Some(dir) = std::env::current_exe() .ok() .and_then(|e| e.parent().map(Path::to_path_buf)) { v.extend(beside_program(&dir)); } // The source tree this build came from (development builds and tests). v.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../libraries")); let mut out: Vec = Vec::new(); for p in v { if p.is_dir() && !out.iter().any(|o| same_dir(o, &p)) { out.push(p); } } // The first folder that exists is the bundle; the others would only repeat it // unless `RUSTFIG_LIBRARIES` named several on purpose. if std::env::var_os("RUSTFIG_LIBRARIES").is_none() { out.truncate(1); } out } /// The browser: the bundle is the `libraries/` folder next to the page; its /// figures come from the index and the fetch cache of [`web`]. #[cfg(target_arch = "wasm32")] pub fn bundled_roots() -> Vec { vec![PathBuf::from(web::ROOT)] } #[cfg(not(target_arch = "wasm32"))] fn same_dir(a: &Path, b: &Path) -> bool { match (a.canonicalize(), b.canonicalize()) { (Ok(x), Ok(y)) => x == y, _ => a == b, } } /// The user's library folder from the `library_dir` preference (`~` is the /// home folder), if set and not empty. pub fn user_root(pref: &str) -> Option { let s = pref.trim(); if s.is_empty() { return None; } if let Some(rest) = s.strip_prefix('~') { let home = home_dir()?; let rest = rest.trim_start_matches(['/', '\\']); return Some(if rest.is_empty() { home } else { home.join(rest) }); } Some(PathBuf::from(s)) } #[cfg(not(target_arch = "wasm32"))] fn home_dir() -> Option { std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from) } #[cfg(target_arch = "wasm32")] fn home_dir() -> Option { None } /// One folder libraries are read from. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LibRoot { pub path: PathBuf, /// The user's folder, not the bundled one. pub user: bool, /// From a library list file: the name of the library this folder is /// (its subfolders are `name / sub`). Without a name the folder's own /// subfolders are the top-level libraries. pub name: Option, } /// One line of a library list file. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListEntry { pub name: String, pub path: PathBuf, } /// The most bytes read from a library list file. pub const MAX_LIST_BYTES: u64 = 256 * 1024; /// The most entries taken from a library list file. pub const MAX_LIST_ENTRIES: usize = 500; const MAX_LIST_LINE: usize = 4096; /// Parse a library list file (xfig's `MakeLibrary`: a `library_dir` that is /// a file names the library folders, one per line as `PATH [NAME]`; a line /// starting with `#` is a comment). Without NAME the last folder of PATH /// names the library. Beyond xfig: blank lines are skipped, `~` is the home /// folder, a relative PATH is taken from `base` (the list file's folder), /// and overlong lines, names with control characters, and entries past /// `MAX_LIST_ENTRIES` are dropped. pub fn parse_list(text: &str, base: &Path) -> Vec { let mut out: Vec = Vec::new(); for line in text.lines() { if out.len() >= MAX_LIST_ENTRIES { break; } if line.starts_with('#') || line.len() > MAX_LIST_LINE || line.chars().any(|c| c.is_control() && c != '\t') { continue; } let line = line.trim(); if line.is_empty() { continue; } let (path_text, name_text) = match line.find(char::is_whitespace) { Some(i) => (line.get(..i).unwrap_or(line), line.get(i..).unwrap_or("").trim()), None => (line, ""), }; let Some(path) = user_root(path_text) else { continue }; let path = if path.is_absolute() { path } else { base.join(path) }; let name = if name_text.is_empty() { path_text .trim_end_matches(['/', '\\']) .rsplit(['/', '\\']) .next() .unwrap_or("") .to_string() } else { name_text.to_string() }; if name.is_empty() || name.chars().count() > MAX_NAME || name == "." || name == ".." || name == TOP_NAME { continue; } out.push(ListEntry { name, path }); } out } /// What the preference `library_dir` names. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Source { /// A folder whose subfolders are the libraries. Folder(PathBuf), /// A list file and the folders it names. List(PathBuf, Vec), /// Nothing there (or unreadable). Missing(PathBuf), } /// Look at `library_dir`: a folder, a list file, or nothing. pub fn source(pref: &str) -> Option { let path = user_root(pref)?; let Ok(meta) = std::fs::metadata(&path) else { return Some(Source::Missing(path)); }; if meta.is_dir() { return Some(Source::Folder(path)); } if meta.len() > MAX_LIST_BYTES { return Some(Source::Missing(path)); } // Lossy: a stray non-UTF-8 byte only spoils its own line, as in the C reader. let Ok(bytes) = std::fs::read(&path) else { return Some(Source::Missing(path)); }; let text = String::from_utf8_lossy(&bytes); let base = path.parent().map(Path::to_path_buf).unwrap_or_default(); let entries = parse_list(&text, &base); Some(Source::List(path, entries)) } /// The folders to scan for the user's libraries (`library_dir`). pub fn user_roots(pref: &str) -> Vec { match source(pref) { None | Some(Source::Missing(_)) => Vec::new(), Some(Source::Folder(p)) => vec![LibRoot { path: p, user: true, name: None, }], Some(Source::List(_, entries)) => entries .into_iter() .map(|e| LibRoot { path: e.path, user: true, name: Some(e.name), }) .collect(), } } /// Every library under the folders. pub fn scan(roots: &[LibRoot]) -> Vec { scan_cancellable(roots, &AtomicBool::new(false)) } /// `scan`, stopping early (with what was found so far) once `cancel` is /// set: xfig's Stop button while a big library loads. pub fn scan_cancellable(roots: &[LibRoot], cancel: &AtomicBool) -> Vec { let mut out: Vec = Vec::new(); for LibRoot { path: root, user, name } in roots { if cancel.load(Ordering::Relaxed) { break; } let mut found = Vec::new(); match name { Some(n) => scan_dir(root, n, *user, false, 0, cancel, &mut found), None => scan_dir(root, "", *user, true, 0, cancel, &mut found), } found.sort_by(|a, b| { a.name .to_lowercase() .cmp(&b.name.to_lowercase()) .then(a.name.cmp(&b.name)) }); for mut l in found { if out.iter().any(|o| o.name == l.name) { l.name = format!("{} ({})", l.name, if *user { "user" } else { "bundled" }); } if out.len() >= MAX_LIBRARIES { return out; } out.push(l); } } out } #[cfg(not(target_arch = "wasm32"))] fn scan_dir(dir: &Path, name: &str, user: bool, top: bool, depth: usize, cancel: &AtomicBool, out: &mut Vec) { if depth > MAX_DEPTH || cancel.load(Ordering::Relaxed) || out.len() >= MAX_LIBRARIES { return; } let Ok(rd) = std::fs::read_dir(dir) else { return }; let mut figs = 0; let mut subs = Vec::new(); for e in rd.flatten() { let fname = e.file_name().to_string_lossy().into_owned(); let path = e.path(); if fname.starts_with('.') { continue; } if path.is_dir() { subs.push((fname, path)); } else if is_fig_name(&fname) { figs += 1; } } if figs > 0 { let lname = if top { TOP_NAME.to_string() } else { name.to_string() }; out.push(Library { name: lname, path: dir.to_path_buf(), user, objects: figs, }); } for (fname, path) in subs { let sub = if top { fname } else { format!("{name} / {fname}") }; scan_dir(&path, &sub, user, false, depth + 1, cancel, out); } } #[cfg(target_arch = "wasm32")] fn scan_dir( dir: &Path, _name: &str, user: bool, _top: bool, _depth: usize, _cancel: &AtomicBool, out: &mut Vec, ) { if !user && dir == Path::new(web::ROOT) { out.extend(web::libraries()); } } #[cfg(not(target_arch = "wasm32"))] fn is_fig_name(n: &str) -> bool { n.len() > 4 && n.to_ascii_lowercase().ends_with(".fig") } /// The names of the figures in a library folder, sorted (without `.fig`). #[cfg(not(target_arch = "wasm32"))] pub fn object_names(dir: &Path) -> Vec { let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new(); }; let mut v: Vec = rd .flatten() .filter(|e| e.path().is_file()) .filter_map(|e| { let n = e.file_name().to_string_lossy().into_owned(); (is_fig_name(&n) && !n.starts_with('.')).then(|| n[..n.len() - 4].to_string()) }) .collect(); v.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then(a.cmp(b))); v.truncate(MAX_OBJECTS); v } #[cfg(target_arch = "wasm32")] pub fn object_names(dir: &Path) -> Vec { web::figure_names(dir) } /// Whether `name` can be a figure's file name without the extension: no /// folders, nothing hidden. pub fn valid_name(name: &str) -> bool { !name.is_empty() && name.chars().count() <= MAX_NAME && !name.starts_with('.') && !name .chars() .any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')) } /// The comment lines at the top of a library figure (what xfig shows under /// "Object comments"), read without parsing the whole file. #[cfg(not(target_arch = "wasm32"))] pub fn object_comments(dir: &Path, name: &str) -> Vec { use std::io::{BufRead, BufReader}; if !valid_name(name) { return Vec::new(); } let Ok(f) = std::fs::File::open(dir.join(format!("{name}.fig"))) else { return Vec::new(); }; let lines: Vec = BufReader::new(f).lines().map_while(Result::ok).take(200).collect(); comments_of_lines(lines) } /// The browser: from the figure's fetched bytes (none before it is fetched). #[cfg(target_arch = "wasm32")] pub fn object_comments(dir: &Path, name: &str) -> Vec { match web::cached(dir, name) { Some(b) => comments_of_bytes(&b), None => Vec::new(), } } /// The comment lines at the top of a figure's bytes. pub fn comments_of_bytes(bytes: &[u8]) -> Vec { let text = String::from_utf8_lossy(bytes.get(..bytes.len().min(64 * 1024)).unwrap_or_default()); comments_of_lines(text.lines().take(200).map(str::to_string).collect()) } fn comments_of_lines(lines: Vec) -> Vec { let mut out = Vec::new(); // The `#FIG` line (and `#encoding`), then seven header lines (orientation, // justification, units, paper size, magnification, pages, transparent // colour); the figure's comment lines follow. // When the figure has none, the comments of its first object (after the // resolution line, e.g. a library part's compound) are shown instead. let first = lines.iter().take_while(|l| l.starts_with('#')).count(); let mut past_resolution = false; for l in lines.into_iter().skip(first + 7) { let Some(c) = l.strip_prefix('#') else { if out.is_empty() && !past_resolution && is_resolution_line(&l) { past_resolution = true; continue; } break; }; out.push(c.strip_prefix(' ').unwrap_or(c).to_string()); if out.len() >= 100 { break; } } out } /// The Fig 3.2 resolution line: "1200 2" (resolution and coordinate system). fn is_resolution_line(l: &str) -> bool { let mut it = l.split_whitespace(); matches!((it.next(), it.next(), it.next()), (Some(a), Some(b), None) if a.parse::().is_ok() && b.parse::().is_ok()) } /// Read a library figure as one drawing whose only object is one compound: /// the file's compound where it holds nothing else, else all of its objects /// in a new one; the figure's comments become the compound's. Pictures it /// names are loaded from the library folder. The user colours stay the /// file's own. #[cfg(not(target_arch = "wasm32"))] pub fn read_figure(dir: &Path, name: &str) -> Result { if !valid_name(name) { return Err(format!("bad object name {name:?}")); } let path = dir.join(format!("{name}.fig")); let meta = std::fs::metadata(&path).map_err(|e| format!("{}: {e}", path.display()))?; if meta.len() > MAX_FILE_BYTES { return Err(format!("{}: too large", path.display())); } let bytes = std::fs::read(&path).map_err(|e| format!("{}: {e}", path.display()))?; let mut doc = figure_from_bytes(&bytes, name, Some(dir))?; absolutize_pictures(&mut doc.objects, dir); Ok(doc) } /// A library figure's bytes as [`read_figure`]'s one-compound drawing /// (pictures it names are loaded from `dir` where there is one). pub fn figure_from_bytes(bytes: &[u8], name: &str, dir: Option<&Path>) -> Result { let (mut doc, _) = rustfig_format::read_any_messages(bytes).map_err(|e| format!("{name}.fig: {e}"))?; // A picture that fails to load draws as its empty frame, as in xfig. let _ = crate::import::load_all_pictures(&mut doc, dir); let comments = std::mem::take(&mut doc.comments); let objects: Vec = std::mem::take(&mut doc.objects) .into_iter() .map(std::sync::Arc::unwrap_or_clone) .collect(); if objects.is_empty() { return Err(format!("{name}.fig: empty library file")); } let mut one = match <[Object; 1]>::try_from(objects) { Ok([o]) if matches!(o.kind, ObjectKind::Compound(_)) => o, Ok([o]) => Object::new(ObjectKind::Compound(Compound { objects: vec![o] })), Err(objects) => Object::new(ObjectKind::Compound(Compound { objects })), }; if !comments.is_empty() { one.comments = comments; } doc.objects = vec![std::sync::Arc::new(one)]; Ok(doc) } /// The browser: from the fetch cache; a figure not fetched yet is an error /// that says so (the panel fetches before it asks). #[cfg(target_arch = "wasm32")] pub fn read_figure(dir: &Path, name: &str) -> Result { if !valid_name(name) { return Err(format!("bad object name {name:?}")); } let bytes = web::cached(dir, name).ok_or_else(|| format!("{name}.fig is still loading, try again"))?; figure_from_bytes(&bytes, name, None) } /// Picture names in library figures are relative to the library folder; /// placed in a drawing they must keep pointing at the same files (xfig's /// `REMAP_IMAGES`). #[cfg(not(target_arch = "wasm32"))] fn absolutize_pictures(objs: &mut [std::sync::Arc], dir: &Path) { fn walk(o: &mut Object, dir: &Path) { match &mut o.kind { ObjectKind::Compound(c) => c.objects.iter_mut().for_each(|o| walk(o, dir)), ObjectKind::Polyline(p) => { if let Some(pic) = &mut p.picture { if !pic.file.is_empty() && !Path::new(&pic.file).is_absolute() { pic.file = dir.join(&pic.file).to_string_lossy().into_owned(); } } } _ => {} } } for o in objs { walk(std::sync::Arc::make_mut(o), dir); } } /// The compound with `ops` applied about its centre (xfig applies them about /// the pointer, which is the same up to where the result is placed). pub fn with_ops(obj: &Object, ops: &[LibOp]) -> Object { let mut o = obj.clone(); let bb = obj.geometry_bbox(); if bb.is_empty() { return o; } let c = bb.center(); for op in ops { o.transform( &Affine::about(op.affine(), c), *op == LibOp::Smaller || *op == LibOp::Larger, ); } o } /// Top-left corner of an object's geometry. pub fn nw_of(o: &Object) -> Point { let b: Rect = o.geometry_bbox(); if b.is_empty() { Point::ZERO } else { b.min } } impl Engine { /// The folders libraries are read from: the bundled ones, then the /// user's (`library_dir`). pub fn library_roots(&self) -> Vec { let mut v: Vec = bundled_roots() .into_iter() .map(|path| LibRoot { path, user: false, name: None, }) .collect(); let pref = self .prefs .get("library_dir") .and_then(|v| v.as_str().map(str::to_string)) .unwrap_or_else(|| "~/xfiglib".to_string()); v.extend(user_roots(&pref)); v } /// All libraries, bundled first. pub fn libraries(&self) -> Vec { scan(&self.library_roots()) } /// The library called `name`. pub fn find_library(&self, name: &str) -> Result { self.libraries() .into_iter() .find(|l| l.name == name) .ok_or_else(|| EngineError::bad_params(format!("no library {name:?} (see library.list)"))) } /// Read a library object for placing, its user colours mapped into the /// drawing (as xfig's merge does). pub fn library_object(&mut self, library: &str, name: &str) -> Result { let lib = self.find_library(library)?; self.library_object_in(&lib, name) } /// [`Engine::library_object`] in a library already found. pub fn library_object_in(&mut self, lib: &Library, name: &str) -> Result { let doc = read_figure(&lib.path, name).map_err(EngineError::bad_params)?; let mut objs = crate::cmd::file::mapped_objects(self, doc); let object = objs .pop() .ok_or_else(|| EngineError::bad_params(format!("{name}.fig holds no objects")))?; let nw = nw_of(&object); Ok(LibObject { object, nw }) } /// Library mode: the object follows the pointer (xfig's `put_selected`). pub fn begin_library(&mut self, library: &str, name: &str, ops: Vec) -> Result<(), EngineError> { let lo = self.library_object(library, name)?; let hand = with_ops(&lo.object, &ops); let nw = nw_of(&hand); let v = self.view.visible(); let cursor = if v.is_empty() { nw } else { Point::new(v.min.x + 600.0, v.min.y + 600.0) }; // xfig turns the current mode off: the library has its own. self.set_tool(ToolKind::None); self.tool_state = ToolState::Library { library: library.to_string(), name: name.to_string(), orig: Box::new(lo.object), ops, hand: Box::new(hand), nw, cursor, draw_box: false, }; self.set_status(format!("Placing library object {name}.fig")); Ok(()) } /// The library object in hand: (library, name, ops), if any. pub fn library_in_hand(&self) -> Option<(&str, &str, &[LibOp])> { match &self.tool_state { ToolState::Library { library, name, ops, .. } => Some((library, name, ops)), _ => None, } } /// Apply a change to the library object in hand (xfig's keys). pub fn library_transform(&mut self, op: LibOp) -> Result<(), EngineError> { let ToolState::Library { orig, ops, hand, nw, .. } = &mut self.tool_state else { return Err(EngineError::bad_params("no library object in hand")); }; ops.push(op); // Keep the list short: four turns are none, two flips are none. if ops.len() > 64 { ops.remove(0); } **hand = with_ops(orig, ops); *nw = nw_of(hand); Ok(()) } pub(crate) fn library_event(&mut self, ev: ToolEvent) -> Result<(), EngineError> { let ToolState::Library { library, name, ops, .. } = &self.tool_state else { return Ok(()); }; let (library, name) = (library.clone(), name.clone()); let op_names: Vec<&str> = ops.iter().map(|o| o.name()).collect(); let op_names = json!(op_names); match ev { ToolEvent::Move { pos } if pos.is_finite() => { let p = self.snap(pos); if let ToolState::Library { cursor, .. } = &mut self.tool_state { *cursor = p; } } ToolEvent::Press { pos, button, shift } if pos.is_finite() => match (button, shift) { (Button::Left, shift) => { let p = self.snap(pos); let r = self.execute( "library.place", &json!({"library": library, "name": name, "ops": op_names, "x": p.x, "y": p.y}), )?; if shift { // xfig: edit the object just placed. if let Some(id) = r.get("id").and_then(Value::as_u64) { self.request(UiRequest::EditObject { id: ObjectId(id) }); } } } (Button::Middle, false) => { // "new object": back to the library panel. self.tool_state = ToolState::Idle; self.request(UiRequest::OpenLibrary); } (Button::Middle, true) => { if let ToolState::Library { draw_box, .. } = &mut self.tool_state { *draw_box = !*draw_box; } } (Button::Right, true) => { self.execute( "library.place", &json!({"library": library, "name": name, "ops": op_names, "orig": true}), )?; } (Button::Right, false) => { self.tool_state = ToolState::Idle; self.set_status("Library mode cancelled."); } }, ToolEvent::Key(Key::Escape) => { self.tool_state = ToolState::Idle; self.set_status("Library mode cancelled."); } ToolEvent::Text(t) => { for c in t.chars().take(16) { if let Some(op) = LibOp::from_key(c) { self.library_transform(op)?; } } } _ => {} } Ok(()) } } /// Whether `text` holds every word of `query` (case-insensitive), as the font /// chooser's search matches. pub fn matches(text: &str, query: &str) -> bool { let text = text.to_lowercase(); query.split_whitespace().all(|w| text.contains(&w.to_lowercase())) } /// Most hits `library.search` returns. pub const MAX_HITS: usize = 1_000; /// Longest query used. const MAX_QUERY: usize = 200; /// One search result: a figure and its library. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Hit { pub library: String, pub name: String, pub user: bool, /// Found by its comments, not its name. pub by_comment: bool, } /// Comments read so far, by file: (modification time, comment text). A /// figure is read again when its file changed. #[cfg(not(target_arch = "wasm32"))] type CommentCache = std::collections::HashMap, String)>; #[cfg(not(target_arch = "wasm32"))] fn comment_cache() -> &'static std::sync::Mutex { static C: std::sync::OnceLock> = std::sync::OnceLock::new(); C.get_or_init(Default::default) } /// Search the figures of all libraries for `query` (every word, case /// insensitive): by name first, then (`comments`) by the comment lines at the /// top of the file. The names come from the folders each time (a few /// milliseconds for xfig's 2,540 figures); comments are read on the first /// search that needs them and kept until the file changes. At most `limit` /// hits (capped at [`MAX_HITS`] + 1); an empty query finds nothing. pub fn search(libs: &[Library], query: &str, limit: usize, comments: bool) -> Vec { let query: String = query.chars().take(MAX_QUERY).collect(); let limit = limit.min(MAX_HITS + 1); let mut out: Vec = Vec::new(); if query.trim().is_empty() || limit == 0 { return out; } let mut rest: Vec<(&Library, String)> = Vec::new(); 'libs: for l in libs { for n in object_names(&l.path) { if matches(&n, &query) { if out.len() >= limit { break 'libs; } out.push(Hit { library: l.name.clone(), name: n, user: l.user, by_comment: false, }); } else if comments { rest.push((l, n)); } } } #[cfg(not(target_arch = "wasm32"))] { let mut cache = comment_cache().lock().ok(); for (l, n) in rest { if out.len() >= limit { break; } let path = l.path.join(format!("{n}.fig")); let mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok(); let cached = cache .as_ref() .and_then(|c| c.get(&path)) .filter(|(t, _)| *t == mtime) .map(|(_, s)| s.clone()); let text = match cached { Some(s) => s, None => { let s = object_comments(&l.path, &n).join("\n"); if let Some(c) = cache.as_mut() { if c.len() > 100_000 { c.clear(); } c.insert(path, (mtime, s.clone())); } s } }; if !text.is_empty() && matches(&text, &query) { out.push(Hit { library: l.name.clone(), name: n, user: l.user, by_comment: true, }); } } } #[cfg(target_arch = "wasm32")] let _ = rest; out } #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; /// Each package's layout puts the libraries where the program looks /// (`cargo xtask package`, the Homebrew formula). #[test] fn packaged_libraries_are_found_beside_the_program() { let base = std::env::temp_dir().join(format!("rustfig-libroots-{}", std::process::id())); for (program_dir, libs) in [ ("zip", "zip/libraries"), ("RustFig.app/Contents/MacOS", "RustFig.app/Contents/Resources/libraries"), ("usr/bin", "usr/share/rustfig/libraries"), ] { let (dir, lib) = (base.join(program_dir), base.join(libs)); std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&lib).unwrap(); let found = beside_program(&dir).into_iter().find(|p| p.is_dir()).unwrap(); assert!(same_dir(&found, &lib), "{program_dir}: {}", found.display()); } let _ = std::fs::remove_dir_all(&base); } } .