Skip to main content

gled_model/
asset.rs

1pub mod animation;
2pub mod curve;
3pub mod midi_controller;
4pub mod output_device;
5pub mod palette;
6pub mod project;
7pub mod scene;
8
9use crate::asset_id::AssetId;
10use crate::collection::Collections;
11use crate::naming::unique_name;
12use serde::{Deserialize, Serialize, de::DeserializeOwned};
13use std::{fmt::Debug, io::Read, sync::Arc};
14
15/// The format of asset files this gled writes. Every file carries its
16/// version next to its name; a file of an older version is migrated when gled
17/// starts (`gled-migrate`), one of a newer version is refused.
18///
19/// - 0: files before versions. Projects may still embed their installation
20///   as an SVG with lamp groups instead of a patch.
21/// - 1: projects hold a patch; lamps are fixture definitions.
22pub const ASSET_VERSION: u32 = 1;
23
24pub trait AssetTrait:
25    Serialize + DeserializeOwned + Debug + Default + Send + Sync + Clone + 'static + PartialEq
26{
27    const DIR_NAME: &'static str;
28    const NAME: &'static str;
29    const SHOW_NAME_IF_SELECTED: bool;
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(bound = "T: DeserializeOwned + Serialize")]
34struct AssetOnDisk<T: AssetTrait> {
35    name: Vec<String>,
36    /// The file's format, see [`ASSET_VERSION`]; files from before versions
37    /// have none and are version 0.
38    #[serde(default)]
39    version: u32,
40    #[serde(flatten)]
41    data: T,
42}
43
44/// Why an asset file could not be read.
45#[derive(Debug, thiserror::Error)]
46pub enum ReadError {
47    #[error(transparent)]
48    Json(#[from] serde_json::Error),
49    #[error("written by a newer gled (asset version {version}, this gled reads up to {supported})")]
50    Newer { version: u32, supported: u32 },
51}
52
53/// An asset with its id. Serialized as such for the wire; files on disk use
54/// `AssetOnDisk`, which takes the id from the file name.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(bound = "T: DeserializeOwned + Serialize")]
57pub struct Asset<T: AssetTrait> {
58    pub id: AssetId<T>,
59    pub path: Vec<String>,
60    pub data: T,
61}
62
63impl<T: AssetTrait> Default for Asset<T> {
64    fn default() -> Self {
65        Self::new(vec![format!("New {}", T::NAME)])
66    }
67}
68
69impl<T: AssetTrait> Asset<T> {
70    pub fn new(path: Vec<String>) -> Self {
71        Self {
72            id: AssetId::<T>::new(),
73            path,
74            data: T::default(),
75        }
76    }
77
78    /// Create a copy of an asset with a new uuid and a name no other asset in
79    /// its folder has ("Name copy", "Name copy 2", …), so the copy shows up
80    /// as its own entry in the asset tree.
81    pub fn copy(&self, collections: &Collections) -> Self {
82        let mut path = self.path.clone();
83        path.pop();
84        let all = Self::all(collections);
85        let siblings = all
86            .iter()
87            .filter_map(|asset| asset.path.split_last())
88            .filter(|(_, folder)| *folder == path.as_slice())
89            .map(|(name, _)| name.as_str());
90        path.push(unique_name(&format!("{} copy", self.name()), siblings));
91
92        Self {
93            id: AssetId::<T>::new(),
94            path,
95            data: self.data.clone(),
96        }
97    }
98
99    pub fn get(id: AssetId<T>, collections: &Collections) -> Option<Arc<Self>> {
100        collections.get::<T>()?.get(&id).cloned()
101    }
102
103    pub fn get_asset_from_index(index: usize, collections: &Collections) -> Option<Arc<Self>> {
104        match index {
105            0 => None,
106            index => Self::all(collections).get(index - 1).cloned(),
107        }
108    }
109
110    pub fn all(collections: &Collections) -> Vec<Arc<Asset<T>>> {
111        let Some(collections) = collections.get::<T>() else {
112            return vec![];
113        };
114
115        collections.assets()
116    }
117
118    /// Reads an asset from its JSON file. Older versions read as far as serde
119    /// takes them; migrating them is `gled-migrate`'s, before loading.
120    ///
121    /// A file whose `name` is empty is named after its id, the name of the
122    /// file it came from, in the top folder. Its content stays reachable and
123    /// everything that refers to it by id keeps working; the user can rename
124    /// it, and the next save writes the name.
125    pub fn read(reader: impl Read, id: AssetId<T>) -> Result<Arc<Self>, ReadError> {
126        let asset: AssetOnDisk<T> = serde_json::from_reader(reader)?;
127        if asset.version > ASSET_VERSION {
128            return Err(ReadError::Newer {
129                version: asset.version,
130                supported: ASSET_VERSION,
131            });
132        }
133
134        let mut path = asset.name;
135        if path.is_empty() {
136            tracing::warn!(
137                "The {} {} has no name; it is named after its id",
138                T::NAME,
139                id.id
140            );
141            path.push(id.id.to_string());
142        }
143
144        Ok(Arc::new(Self {
145            id,
146            path,
147            data: asset.data,
148        }))
149    }
150
151    pub fn into_json(self) -> Result<String, serde_json::Error> {
152        let asset = AssetOnDisk {
153            name: self.path,
154            version: ASSET_VERSION,
155            data: self.data,
156        };
157
158        serde_json::to_string_pretty(&asset)
159    }
160
161    /// The folders the asset is in: its path without its own name. An asset
162    /// without a path (one built in code or received over the wire; files
163    /// get a name when read) is in the top folder.
164    pub fn dir(&self) -> Vec<String> {
165        self.path
166            .split_last()
167            .map(|(_, dir)| dir.to_vec())
168            .unwrap_or_default()
169    }
170
171    pub fn name(&self) -> &str {
172        self.path
173            .last()
174            .map(|name| name.as_str())
175            .unwrap_or("No name")
176    }
177
178    pub fn change_dir(&mut self, new_dir: &[String]) {
179        let name = self.path.pop();
180        self.path = new_dir.to_owned();
181        if let Some(name) = name {
182            self.path.push(name);
183        }
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::asset::palette::Palette;
191
192    fn palette_file(version: Option<u32>) -> String {
193        let version = version
194            .map(|v| format!(r#""version": {v},"#))
195            .unwrap_or_default();
196        format!(r#"{{"name": ["Warm"], {version} "primary": {{"rgb": [1.0, 0.5, 0.0]}}}}"#)
197    }
198
199    #[test]
200    fn a_file_from_before_versions_reads_and_saves_with_the_current_version() {
201        let asset =
202            Asset::<Palette>::read(palette_file(None).as_bytes(), AssetId::new()).expect("reads");
203        let saved: serde_json::Value =
204            serde_json::from_str(&(*asset).clone().into_json().expect("saves")).expect("json");
205        assert_eq!(saved["version"], ASSET_VERSION);
206        assert_eq!(saved["name"], serde_json::json!(["Warm"]));
207    }
208
209    #[test]
210    fn a_file_from_a_newer_gled_is_refused() {
211        let newer = palette_file(Some(ASSET_VERSION + 1));
212        assert!(matches!(
213            Asset::<Palette>::read(newer.as_bytes(), AssetId::new()),
214            Err(ReadError::Newer { .. })
215        ));
216    }
217
218    #[test]
219    fn a_file_without_a_name_is_named_after_its_id() {
220        let file = r#"{"name": [], "primary": {"rgb": [1.0, 0.5, 0.0]}}"#;
221        let id = AssetId::new();
222        let asset = Asset::<Palette>::read(file.as_bytes(), id).expect("reads");
223
224        assert_eq!(asset.path, [id.id.to_string()]);
225        assert_eq!(asset.name(), id.id.to_string());
226        assert!(asset.dir().is_empty());
227    }
228
229    #[test]
230    fn an_asset_without_a_path_is_in_the_top_folder_and_copies() {
231        let mut collections = Collections::default();
232        let nameless = Asset::<Palette>::new(vec![]);
233        collections.get_mut::<Palette>().set_asset(nameless.clone());
234
235        assert!(nameless.dir().is_empty());
236        assert_eq!(nameless.name(), "No name");
237        assert_eq!(nameless.copy(&collections).path, ["No name copy"]);
238    }
239
240    fn names(collections: &Collections) -> Vec<Vec<String>> {
241        let mut names: Vec<_> = Asset::<Palette>::all(collections)
242            .iter()
243            .map(|asset| asset.path.clone())
244            .collect();
245        names.sort();
246        names
247    }
248
249    #[test]
250    fn a_copy_gets_a_name_no_other_asset_in_its_folder_has() {
251        let mut collections = Collections::default();
252        let original = Asset::<Palette>::new(vec!["Show".into(), "Warm".into()]);
253        collections.get_mut::<Palette>().set_asset(original.clone());
254
255        let first = original.copy(&collections);
256        collections.get_mut::<Palette>().set_asset(first.clone());
257        let second = original.copy(&collections);
258        collections.get_mut::<Palette>().set_asset(second.clone());
259
260        assert_ne!(first.id, original.id);
261        assert_eq!(first.data, original.data);
262        assert_eq!(
263            names(&collections),
264            [
265                vec!["Show".to_owned(), "Warm".to_owned()],
266                vec!["Show".to_owned(), "Warm copy".to_owned()],
267                vec!["Show".to_owned(), "Warm copy 2".to_owned()],
268            ]
269        );
270    }
271}