use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::Sender;
use std::sync::{Arc, Mutex};
pub enum WatchMsg {
Retarget(PathBuf),
Changed,
}
#[derive(Clone, PartialEq)]
pub struct ScenarioEntry {
pub path: PathBuf,
pub name: String,
pub flat_index: usize,
}
#[derive(Clone, PartialEq)]
pub struct Group {
pub name: String,
pub entries: Vec<ScenarioEntry>,
}
pub struct LibraryState {
pub workspace: PathBuf,
pub groups: Vec<Group>,
pub recents: Vec<ScenarioEntry>,
pub index: Vec<PathBuf>,
pub thumb_cache: HashMap<PathBuf, Arc<Vec<u8>>>,
pub start_in_editor: bool,
pub watch_tx: Option<Sender<WatchMsg>>,
}
pub type SharedLibrary = Arc<Mutex<LibraryState>>;
impl LibraryState {
pub fn new(workspace: PathBuf, start_in_editor: bool) -> Self {
let mut s = Self {
workspace,
groups: Vec::new(),
recents: Vec::new(),
index: Vec::new(),
thumb_cache: HashMap::new(),
start_in_editor,
watch_tx: None,
};
s.refresh();
s
}
pub fn retarget_watch(&self, path: &Path) {
if let Some(tx) = &self.watch_tx {
let _ = tx.send(WatchMsg::Retarget(path.to_path_buf()));
}
}
pub fn refresh(&mut self) {
self.groups = scan_workspace(&self.workspace);
self.recents = load_recents()
.into_iter()
.map(|p| ScenarioEntry {
name: file_title(&p),
path: p,
flat_index: 0,
})
.collect();
self.reindex();
}
pub fn note_opened(&mut self, path: &Path) {
push_recent(path);
self.refresh();
if !self.index.iter().any(|p| p == path) {
self.index.push(path.to_path_buf());
}
}
pub fn path_at(&self, i: usize) -> Option<PathBuf> {
self.index.get(i).cloned()
}
fn reindex(&mut self) {
let mut index: Vec<PathBuf> = Vec::new();
let mut pos: HashMap<PathBuf, usize> = HashMap::new();
for g in &self.groups {
for e in &g.entries {
pos.entry(e.path.clone()).or_insert_with(|| {
index.push(e.path.clone());
index.len() - 1
});
}
}
for e in &self.recents {
pos.entry(e.path.clone()).or_insert_with(|| {
index.push(e.path.clone());
index.len() - 1
});
}
for g in &mut self.groups {
for e in &mut g.entries {
e.flat_index = pos[&e.path];
}
}
for e in &mut self.recents {
e.flat_index = pos[&e.path];
}
self.index = index;
}
}
pub fn is_scenario_json(v: &serde_json::Value) -> bool {
v.get("video").map(|x| x.is_object()).unwrap_or(false)
&& (v.get("scenes").map(|x| x.is_array()).unwrap_or(false)
|| v.get("composition").is_some())
}
pub fn scan_workspace(root: &Path) -> Vec<Group> {
let mut default = Vec::new();
let mut subgroups: Vec<Group> = Vec::new();
if let Ok(rd) = std::fs::read_dir(root) {
let mut dirs: Vec<PathBuf> = Vec::new();
for entry in rd.flatten() {
let p = entry.path();
if p.is_dir() {
dirs.push(p);
} else if is_scenario_file(&p) {
default.push(entry_for(&p));
}
}
dirs.sort();
for dir in dirs {
let mut entries = Vec::new();
if let Ok(sub) = std::fs::read_dir(&dir) {
for e in sub.flatten() {
let p = e.path();
if is_scenario_file(&p) {
entries.push(entry_for(&p));
}
}
}
if !entries.is_empty() {
entries.sort_by(|a, b| a.name.cmp(&b.name));
subgroups.push(Group {
name: file_title(&dir),
entries,
});
}
}
}
let mut groups = Vec::new();
if !default.is_empty() {
default.sort_by(|a, b| a.name.cmp(&b.name));
groups.push(Group {
name: "Default".to_string(),
entries: default,
});
}
groups.extend(subgroups);
groups
}
fn is_scenario_file(p: &Path) -> bool {
match p.extension().and_then(|e| e.to_str()) {
Some("json") => std::fs::read_to_string(p)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.map(|v| is_scenario_json(&v))
.unwrap_or(false),
Some("html") | Some("htm") => std::fs::read_to_string(p)
.map(|s| s.contains("<rustmotion"))
.unwrap_or(false),
_ => false,
}
}
fn entry_for(p: &Path) -> ScenarioEntry {
ScenarioEntry {
name: file_title(p),
path: p.to_path_buf(),
flat_index: 0,
}
}
fn file_title(p: &Path) -> String {
p.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled")
.to_string()
}
const RECENTS_CAP: usize = 15;
fn recents_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("rustmotion").join("recent.json"))
}
pub fn load_recents() -> Vec<PathBuf> {
let path = match recents_path() {
Some(p) => p,
None => return Vec::new(),
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let list: Vec<String> = serde_json::from_str(&raw).unwrap_or_default();
list.into_iter()
.map(PathBuf::from)
.filter(|p| p.exists())
.collect()
}
fn save_recents(list: &[PathBuf]) {
if let Some(path) = recents_path() {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let strs: Vec<String> = list.iter().map(|p| p.display().to_string()).collect();
if let Ok(s) = serde_json::to_string_pretty(&strs) {
let _ = std::fs::write(&path, s);
}
}
}
pub fn push_recent(path: &Path) {
let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let list = mru_insert(load_recents(), abs, RECENTS_CAP);
save_recents(&list);
}
pub fn mru_insert(mut list: Vec<PathBuf>, path: PathBuf, cap: usize) -> Vec<PathBuf> {
list.retain(|p| p != &path);
list.insert(0, path);
list.truncate(cap);
list
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn is_scenario_json_accepts_and_rejects() {
assert!(is_scenario_json(&json!({ "video": {}, "scenes": [] })));
assert!(is_scenario_json(&json!({ "video": {}, "composition": [] })));
assert!(!is_scenario_json(&json!({ "foo": 1 })));
assert!(!is_scenario_json(&json!({ "video": {} })));
}
#[test]
fn mru_insert_moves_to_front_dedups_caps() {
let a = PathBuf::from("/a");
let b = PathBuf::from("/b");
let c = PathBuf::from("/c");
let list = mru_insert(vec![a.clone(), b.clone()], c.clone(), 15);
assert_eq!(list, vec![c.clone(), a.clone(), b.clone()]);
let list = mru_insert(list, a.clone(), 15);
assert_eq!(list, vec![a.clone(), c.clone(), b.clone()]);
let list = mru_insert(vec![a.clone(), b.clone()], c.clone(), 2);
assert_eq!(list, vec![c, a]);
}
#[test]
fn scan_workspace_groups_examples() {
let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
let groups = scan_workspace(&examples);
let default = groups
.iter()
.find(|g| g.name == "Default")
.expect("a Default group with the example scenarios");
assert!(
default.entries.iter().any(|e| e.name == "demo"),
"expected demo.json in the Default group"
);
}
}