BlueGit

golden-age paperdoll-renderer

Merge feature/recent-projects (Tim t998u.1)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
faa9c89f166e476ce67251f2c4baf52406647363
4 files changed +232 −2
README.md +1 −1
@@ -15,7 +15,7 @@ cargo run -- --status
15 15
16 16 ## Project en bodies
17 17
18 Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
18 Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. Onder de knoppen staan de laatste 5 geopende of aangemaakte projecten (**Recent geopend**); klik op een naam om het project te openen. Een project dat niet meer bestaat staat grijs met *niet gevonden*. De lijst staat in `recent.json` in de gebruikersconfiguratiemap (`$XDG_CONFIG_HOME/paperdoll-renderer/`, standaard `~/.config/paperdoll-renderer/`, onder Windows `%APPDATA%\paperdoll-renderer\`; aan te passen met `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
19 19
20 20 De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
21 21
@@ -15,7 +15,7 @@ cargo run -- --status
15
15
16## Project en bodies
16## Project en bodies
17
17
18Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
18Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project maakt `character.ppd` en de mappen `assets/body/`, `assets/equipment/`, `presets/`, `cache/` en `renders/`. De CC0-testlichamen worden in `assets/body/` voorbereid. Onder de knoppen staan de laatste 5 geopende of aangemaakte projecten (**Recent geopend**); klik op een naam om het project te openen. Een project dat niet meer bestaat staat grijs met *niet gevonden*. De lijst staat in `recent.json` in de gebruikersconfiguratiemap (`$XDG_CONFIG_HOME/paperdoll-renderer/`, standaard `~/.config/paperdoll-renderer/`, onder Windows `%APPDATA%\paperdoll-renderer\`; aan te passen met `PAPERDOLL_CONFIG_DIR`). `--preview-fixture` maakt een tijdelijk voorbeeldproject en opent de mannelijke body.
19
19
20De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
20De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert een model. Een extern bestand wordt naar de projectmap gekopieerd; bij een bestaande naam krijgt de kopie een eigen naam. Modellen met bijbestanden kun je met hun relatieve mappenstructuur in de projectmap zetten en daar selecteren. **Importoverzicht** toont meshes, materialen, textures, joints en clips van glTF/GLB, FBX en OBJ+MTL. FBX-lichamen worden bij het laden één keer genormaliseerd (rechtshandig Y-up, meters) en daarna net als glTF/GLB in de live 3D-preview en de productierender getekend, met al hun animatiestacks als clips.
21
21
src/editor.rs +77 −1
@@ -21,6 +21,9 @@ use crate::session::{AfterChoice, Intent, Screen, Session, Sex};
21 21
22 22 pub struct EditorApp {
23 23 session: Session,
24 /// User config dir for the recent-projects list; `None` disables it (tests).
25 recent_dir: Option<std::path::PathBuf>,
26 recent: Vec<std::path::PathBuf>,
24 27 dialogs: Box<dyn Dialogs>,
25 28 controls: PreviewControls,
26 29 preview_male: Option<PreviewCharacter>,
@@ -111,6 +114,8 @@ impl EditorApp {
111 114 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
112 115 Self {
113 116 session: Session::new(),
117 recent_dir: None,
118 recent: Vec::new(),
114 119 dialogs,
115 120 controls: PreviewControls::default(),
116 121 preview_male: None,
@@ -146,6 +151,26 @@ impl EditorApp {
146 151 }
147 152 }
148 153
154 /// Use `dir` for the recent-projects list (`None` = off) and load it.
155 pub fn set_recent_dir(&mut self, dir: Option<PathBuf>) {
156 self.recent = dir.as_deref().map(crate::recent::load).unwrap_or_default();
157 self.recent_dir = dir;
158 }
159
160 pub fn recent_projects(&self) -> &[PathBuf] {
161 &self.recent
162 }
163
164 /// Remember the open project's manifest (never fatal).
165 fn remember_project(&mut self) {
166 let (Some(dir), Some(opened)) = (&self.recent_dir, &self.session.opened) else {
167 return;
168 };
169 if let Ok(list) = crate::recent::record(dir, &opened.manifest_path) {
170 self.recent = list;
171 }
172 }
173
149 174 pub fn open_path(&mut self, path: PathBuf) {
150 175 let candidate = std::env::current_dir()
151 176 .map_err(|e| e.to_string())
@@ -162,6 +187,7 @@ impl EditorApp {
162 187 match open_project(&path) {
163 188 Ok(opened) => {
164 189 self.session.install_loaded(opened);
190 self.remember_project();
165 191 self.reset_render();
166 192 self.sync_fields();
167 193 self.reload_bodies();
@@ -262,6 +288,7 @@ impl EditorApp {
262 288 if let Err(error) = self.session.install_new_project(&path) {
263 289 self.session.fail(error.to_string());
264 290 } else {
291 self.remember_project();
265 292 self.reset_render();
266 293 self.sync_fields();
267 294 self.reload_bodies();
@@ -270,6 +297,7 @@ impl EditorApp {
270 297 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
271 298 Ok(opened) => {
272 299 self.session.install_loaded(opened);
300 self.remember_project();
273 301 self.reset_render();
274 302 self.sync_fields();
275 303 self.reload_bodies();
@@ -1107,7 +1135,9 @@ impl EditorApp {
1107 1135
1108 1136 impl Default for EditorApp {
1109 1137 fn default() -> Self {
1110 Self::new(Box::new(SystemDialogs::new()))
1138 let mut app = Self::new(Box::new(SystemDialogs::new()));
1139 app.set_recent_dir(crate::recent::config_dir());
1140 app
1111 1141 }
1112 1142 }
1113 1143
@@ -1224,6 +1254,29 @@ impl EditorApp {
1224 1254 if ui.button("Nieuw project").clicked() {
1225 1255 self.begin(Intent::PickNew);
1226 1256 }
1257 if !self.recent.is_empty() {
1258 ui.add_space(16.0);
1259 ui.label(egui::RichText::new("Recent geopend").strong());
1260 let mut open = None;
1261 for path in &self.recent {
1262 let name = crate::recent::label(path);
1263 if path.is_file() {
1264 if ui
1265 .link(&name)
1266 .on_hover_text(path.display().to_string())
1267 .clicked()
1268 {
1269 open = Some(path.clone());
1270 }
1271 } else {
1272 ui.add_enabled(false, egui::Label::new(format!("{name} (niet gevonden)")))
1273 .on_disabled_hover_text(path.display().to_string());
1274 }
1275 }
1276 if let Some(path) = open {
1277 self.open_path(path);
1278 }
1279 }
1227 1280 });
1228 1281 }
1229 1282
@@ -2316,6 +2369,29 @@ mod tests {
2316 2369 ))
2317 2370 }
2318 2371
2372 #[test]
2373 fn opened_and_created_projects_are_remembered_for_the_start_screen() {
2374 let config = root();
2375 let project = root();
2376 let mut dialogs = ScriptedDialogs::default();
2377 dialogs.push(Some(project.clone()));
2378 let mut app = EditorApp::new(Box::new(dialogs));
2379 app.set_recent_dir(Some(config.clone()));
2380 assert!(app.recent_projects().is_empty());
2381 app.begin(Intent::PickNew);
2382 app.poll_dialogs();
2383 let manifest = project.join("character.ppd").canonicalize().unwrap();
2384 assert_eq!(app.recent_projects(), std::slice::from_ref(&manifest));
2385 // A fresh app (next start) reads the list and can reopen from it.
2386 let mut next = EditorApp::new(Box::new(ScriptedDialogs::default()));
2387 next.set_recent_dir(Some(config.clone()));
2388 assert_eq!(next.recent_projects(), std::slice::from_ref(&manifest));
2389 next.open_path(next.recent_projects()[0].clone());
2390 assert_eq!(next.session.screen, Screen::Bodies);
2391 let _ = std::fs::remove_dir_all(config);
2392 let _ = std::fs::remove_dir_all(project);
2393 }
2394
2319 2395 #[test]
2320 2396 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
2321 2397 let root = root();
@@ -21,6 +21,9 @@ use crate::session::{AfterChoice, Intent, Screen, Session, Sex};
21
21
22pub struct EditorApp {
22pub struct EditorApp {
23 session: Session,
23 session: Session,
24 /// User config dir for the recent-projects list; `None` disables it (tests).
25 recent_dir: Option<std::path::PathBuf>,
26 recent: Vec<std::path::PathBuf>,
24 dialogs: Box<dyn Dialogs>,
27 dialogs: Box<dyn Dialogs>,
25 controls: PreviewControls,
28 controls: PreviewControls,
26 preview_male: Option<PreviewCharacter>,
29 preview_male: Option<PreviewCharacter>,
@@ -111,6 +114,8 @@ impl EditorApp {
111 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
114 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
112 Self {
115 Self {
113 session: Session::new(),
116 session: Session::new(),
117 recent_dir: None,
118 recent: Vec::new(),
114 dialogs,
119 dialogs,
115 controls: PreviewControls::default(),
120 controls: PreviewControls::default(),
116 preview_male: None,
121 preview_male: None,
@@ -146,6 +151,26 @@ impl EditorApp {
146 }
151 }
147 }
152 }
148
153
154 /// Use `dir` for the recent-projects list (`None` = off) and load it.
155 pub fn set_recent_dir(&mut self, dir: Option<PathBuf>) {
156 self.recent = dir.as_deref().map(crate::recent::load).unwrap_or_default();
157 self.recent_dir = dir;
158 }
159
160 pub fn recent_projects(&self) -> &[PathBuf] {
161 &self.recent
162 }
163
164 /// Remember the open project's manifest (never fatal).
165 fn remember_project(&mut self) {
166 let (Some(dir), Some(opened)) = (&self.recent_dir, &self.session.opened) else {
167 return;
168 };
169 if let Ok(list) = crate::recent::record(dir, &opened.manifest_path) {
170 self.recent = list;
171 }
172 }
173
149 pub fn open_path(&mut self, path: PathBuf) {
174 pub fn open_path(&mut self, path: PathBuf) {
150 let candidate = std::env::current_dir()
175 let candidate = std::env::current_dir()
151 .map_err(|e| e.to_string())
176 .map_err(|e| e.to_string())
@@ -162,6 +187,7 @@ impl EditorApp {
162 match open_project(&path) {
187 match open_project(&path) {
163 Ok(opened) => {
188 Ok(opened) => {
164 self.session.install_loaded(opened);
189 self.session.install_loaded(opened);
190 self.remember_project();
165 self.reset_render();
191 self.reset_render();
166 self.sync_fields();
192 self.sync_fields();
167 self.reload_bodies();
193 self.reload_bodies();
@@ -262,6 +288,7 @@ impl EditorApp {
262 if let Err(error) = self.session.install_new_project(&path) {
288 if let Err(error) = self.session.install_new_project(&path) {
263 self.session.fail(error.to_string());
289 self.session.fail(error.to_string());
264 } else {
290 } else {
291 self.remember_project();
265 self.reset_render();
292 self.reset_render();
266 self.sync_fields();
293 self.sync_fields();
267 self.reload_bodies();
294 self.reload_bodies();
@@ -270,6 +297,7 @@ impl EditorApp {
270 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
297 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
271 Ok(opened) => {
298 Ok(opened) => {
272 self.session.install_loaded(opened);
299 self.session.install_loaded(opened);
300 self.remember_project();
273 self.reset_render();
301 self.reset_render();
274 self.sync_fields();
302 self.sync_fields();
275 self.reload_bodies();
303 self.reload_bodies();
@@ -1107,7 +1135,9 @@ impl EditorApp {
1107
1135
1108impl Default for EditorApp {
1136impl Default for EditorApp {
1109 fn default() -> Self {
1137 fn default() -> Self {
1110 Self::new(Box::new(SystemDialogs::new()))
1138 let mut app = Self::new(Box::new(SystemDialogs::new()));
1139 app.set_recent_dir(crate::recent::config_dir());
1140 app
1111 }
1141 }
1112}
1142}
1113
1143
@@ -1224,6 +1254,29 @@ impl EditorApp {
1224 if ui.button("Nieuw project").clicked() {
1254 if ui.button("Nieuw project").clicked() {
1225 self.begin(Intent::PickNew);
1255 self.begin(Intent::PickNew);
1226 }
1256 }
1257 if !self.recent.is_empty() {
1258 ui.add_space(16.0);
1259 ui.label(egui::RichText::new("Recent geopend").strong());
1260 let mut open = None;
1261 for path in &self.recent {
1262 let name = crate::recent::label(path);
1263 if path.is_file() {
1264 if ui
1265 .link(&name)
1266 .on_hover_text(path.display().to_string())
1267 .clicked()
1268 {
1269 open = Some(path.clone());
1270 }
1271 } else {
1272 ui.add_enabled(false, egui::Label::new(format!("{name} (niet gevonden)")))
1273 .on_disabled_hover_text(path.display().to_string());
1274 }
1275 }
1276 if let Some(path) = open {
1277 self.open_path(path);
1278 }
1279 }
1227 });
1280 });
1228 }
1281 }
1229
1282
@@ -2316,6 +2369,29 @@ mod tests {
2316 ))
2369 ))
2317 }
2370 }
2318
2371
2372 #[test]
2373 fn opened_and_created_projects_are_remembered_for_the_start_screen() {
2374 let config = root();
2375 let project = root();
2376 let mut dialogs = ScriptedDialogs::default();
2377 dialogs.push(Some(project.clone()));
2378 let mut app = EditorApp::new(Box::new(dialogs));
2379 app.set_recent_dir(Some(config.clone()));
2380 assert!(app.recent_projects().is_empty());
2381 app.begin(Intent::PickNew);
2382 app.poll_dialogs();
2383 let manifest = project.join("character.ppd").canonicalize().unwrap();
2384 assert_eq!(app.recent_projects(), std::slice::from_ref(&manifest));
2385 // A fresh app (next start) reads the list and can reopen from it.
2386 let mut next = EditorApp::new(Box::new(ScriptedDialogs::default()));
2387 next.set_recent_dir(Some(config.clone()));
2388 assert_eq!(next.recent_projects(), std::slice::from_ref(&manifest));
2389 next.open_path(next.recent_projects()[0].clone());
2390 assert_eq!(next.session.screen, Screen::Bodies);
2391 let _ = std::fs::remove_dir_all(config);
2392 let _ = std::fs::remove_dir_all(project);
2393 }
2394
2319 #[test]
2395 #[test]
2320 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
2396 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
2321 let root = root();
2397 let root = root();
src/lib.rs +1 −0
@@ -28,6 +28,7 @@ pub mod preview2d;
28 28 pub mod preview_gl;
29 29 pub mod production;
30 30 pub mod project;
31 pub mod recent;
31 32 pub mod render_job;
32 33 pub mod resume;
33 34 pub mod session;
@@ -28,6 +28,7 @@ pub mod preview2d;
28pub mod preview_gl;
28pub mod preview_gl;
29pub mod production;
29pub mod production;
30pub mod project;
30pub mod project;
31pub mod recent;
31pub mod render_job;
32pub mod render_job;
32pub mod resume;
33pub mod resume;
33pub mod session;
34pub mod session;
src/recent.rs added +153 −0
@@ -0,0 +1,153 @@
1 //! Recently opened projects for the start screen (Tim t998u.1).
2 //!
3 //! Stored per user as `recent.json` in the user config directory; see
4 //! [`config_dir`]. Never fatal: an unreadable list is treated as empty.
5 use std::fs;
6 use std::path::{Path, PathBuf};
7
8 /// Maximum number of remembered projects.
9 pub const MAX_RECENT: usize = 5;
10 pub const FILE_NAME: &str = "recent.json";
11 const MAX_FILE_BYTES: u64 = 64 * 1024;
12
13 /// `PAPERDOLL_CONFIG_DIR`, else `%APPDATA%\paperdoll-renderer` (Windows) or
14 /// `$XDG_CONFIG_HOME/paperdoll-renderer` (default `~/.config/paperdoll-renderer`).
15 pub fn config_dir() -> Option<PathBuf> {
16 if let Some(dir) = std::env::var_os("PAPERDOLL_CONFIG_DIR").filter(|v| !v.is_empty()) {
17 return Some(PathBuf::from(dir));
18 }
19 let base = if cfg!(windows) {
20 std::env::var_os("APPDATA").map(PathBuf::from)
21 } else {
22 std::env::var_os("XDG_CONFIG_HOME")
23 .filter(|v| !v.is_empty())
24 .map(PathBuf::from)
25 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
26 }?;
27 Some(base.join("paperdoll-renderer"))
28 }
29
30 /// Remembered `character.ppd` paths, newest first (at most [`MAX_RECENT`]).
31 pub fn load(dir: &Path) -> Vec<PathBuf> {
32 let path = dir.join(FILE_NAME);
33 let Ok(meta) = fs::metadata(&path) else {
34 return Vec::new();
35 };
36 if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
37 return Vec::new();
38 }
39 let Ok(bytes) = fs::read(&path) else {
40 return Vec::new();
41 };
42 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
43 return Vec::new();
44 };
45 let mut out: Vec<PathBuf> = Vec::new();
46 for item in doc
47 .get("projects")
48 .and_then(|v| v.as_array())
49 .into_iter()
50 .flatten()
51 {
52 if let Some(text) = item.as_str() {
53 let p = PathBuf::from(text);
54 if p.is_absolute() && !out.contains(&p) {
55 out.push(p);
56 }
57 }
58 if out.len() == MAX_RECENT {
59 break;
60 }
61 }
62 out
63 }
64
65 /// Put `manifest` first, dedupe, cap at [`MAX_RECENT`] and write atomically.
66 pub fn record(dir: &Path, manifest: &Path) -> Result<Vec<PathBuf>, String> {
67 let manifest = manifest
68 .canonicalize()
69 .unwrap_or_else(|_| manifest.to_path_buf());
70 let mut list = load(dir);
71 list.retain(|p| *p != manifest);
72 list.insert(0, manifest);
73 list.truncate(MAX_RECENT);
74 fs::create_dir_all(dir).map_err(|e| e.to_string())?;
75 let doc = serde_json::json!({
76 "projects": list.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>(),
77 });
78 let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| e.to_string())?;
79 let temp = dir.join(format!("{FILE_NAME}.tmp-{}", std::process::id()));
80 fs::write(&temp, bytes).map_err(|e| e.to_string())?;
81 fs::rename(&temp, dir.join(FILE_NAME)).map_err(|e| {
82 let _ = fs::remove_file(&temp);
83 e.to_string()
84 })?;
85 Ok(list)
86 }
87
88 /// Display label: the project folder name.
89 pub fn label(manifest: &Path) -> String {
90 manifest
91 .parent()
92 .and_then(|p| p.file_name())
93 .map(|n| n.to_string_lossy().to_string())
94 .unwrap_or_else(|| manifest.display().to_string())
95 }
96
97 #[cfg(test)]
98 mod tests {
99 use super::*;
100
101 fn temp(tag: &str) -> PathBuf {
102 let dir = std::env::temp_dir().join(format!(
103 "paperdoll-recent-{tag}-{}-{}",
104 std::process::id(),
105 std::time::SystemTime::now()
106 .duration_since(std::time::UNIX_EPOCH)
107 .unwrap()
108 .as_nanos()
109 ));
110 fs::create_dir_all(&dir).unwrap();
111 dir
112 }
113
114 #[test]
115 fn records_newest_first_dedupes_caps_and_survives_damage() {
116 let dir = temp("list");
117 assert!(load(&dir).is_empty());
118 let projects: Vec<PathBuf> = (0..7)
119 .map(|i| {
120 let p = dir.join(format!("p{i}"));
121 fs::create_dir_all(&p).unwrap();
122 let m = p.join("character.ppd");
123 fs::write(&m, b"{}").unwrap();
124 m.canonicalize().unwrap()
125 })
126 .collect();
127 for p in &projects {
128 record(&dir, p).unwrap();
129 }
130 record(&dir, &projects[3]).unwrap();
131 let list = load(&dir);
132 assert_eq!(
133 list,
134 vec![
135 projects[3].clone(),
136 projects[6].clone(),
137 projects[5].clone(),
138 projects[4].clone(),
139 projects[2].clone()
140 ]
141 );
142 assert_eq!(label(&list[0]), "p3");
143 // A missing project stays listed (shown greyed by the UI).
144 fs::remove_dir_all(projects[6].parent().unwrap()).unwrap();
145 assert!(load(&dir).contains(&projects[6]));
146 // Damaged file: empty, and the next record repairs it.
147 fs::write(dir.join(FILE_NAME), b"not json").unwrap();
148 assert!(load(&dir).is_empty());
149 record(&dir, &projects[0]).unwrap();
150 assert_eq!(load(&dir), vec![projects[0].clone()]);
151 let _ = fs::remove_dir_all(dir);
152 }
153 }
@@ -0,0 +1,153 @@
1//! Recently opened projects for the start screen (Tim t998u.1).
2//!
3//! Stored per user as `recent.json` in the user config directory; see
4//! [`config_dir`]. Never fatal: an unreadable list is treated as empty.
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Maximum number of remembered projects.
9pub const MAX_RECENT: usize = 5;
10pub const FILE_NAME: &str = "recent.json";
11const MAX_FILE_BYTES: u64 = 64 * 1024;
12
13/// `PAPERDOLL_CONFIG_DIR`, else `%APPDATA%\paperdoll-renderer` (Windows) or
14/// `$XDG_CONFIG_HOME/paperdoll-renderer` (default `~/.config/paperdoll-renderer`).
15pub fn config_dir() -> Option<PathBuf> {
16 if let Some(dir) = std::env::var_os("PAPERDOLL_CONFIG_DIR").filter(|v| !v.is_empty()) {
17 return Some(PathBuf::from(dir));
18 }
19 let base = if cfg!(windows) {
20 std::env::var_os("APPDATA").map(PathBuf::from)
21 } else {
22 std::env::var_os("XDG_CONFIG_HOME")
23 .filter(|v| !v.is_empty())
24 .map(PathBuf::from)
25 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
26 }?;
27 Some(base.join("paperdoll-renderer"))
28}
29
30/// Remembered `character.ppd` paths, newest first (at most [`MAX_RECENT`]).
31pub fn load(dir: &Path) -> Vec<PathBuf> {
32 let path = dir.join(FILE_NAME);
33 let Ok(meta) = fs::metadata(&path) else {
34 return Vec::new();
35 };
36 if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
37 return Vec::new();
38 }
39 let Ok(bytes) = fs::read(&path) else {
40 return Vec::new();
41 };
42 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
43 return Vec::new();
44 };
45 let mut out: Vec<PathBuf> = Vec::new();
46 for item in doc
47 .get("projects")
48 .and_then(|v| v.as_array())
49 .into_iter()
50 .flatten()
51 {
52 if let Some(text) = item.as_str() {
53 let p = PathBuf::from(text);
54 if p.is_absolute() && !out.contains(&p) {
55 out.push(p);
56 }
57 }
58 if out.len() == MAX_RECENT {
59 break;
60 }
61 }
62 out
63}
64
65/// Put `manifest` first, dedupe, cap at [`MAX_RECENT`] and write atomically.
66pub fn record(dir: &Path, manifest: &Path) -> Result<Vec<PathBuf>, String> {
67 let manifest = manifest
68 .canonicalize()
69 .unwrap_or_else(|_| manifest.to_path_buf());
70 let mut list = load(dir);
71 list.retain(|p| *p != manifest);
72 list.insert(0, manifest);
73 list.truncate(MAX_RECENT);
74 fs::create_dir_all(dir).map_err(|e| e.to_string())?;
75 let doc = serde_json::json!({
76 "projects": list.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>(),
77 });
78 let bytes = serde_json::to_vec_pretty(&doc).map_err(|e| e.to_string())?;
79 let temp = dir.join(format!("{FILE_NAME}.tmp-{}", std::process::id()));
80 fs::write(&temp, bytes).map_err(|e| e.to_string())?;
81 fs::rename(&temp, dir.join(FILE_NAME)).map_err(|e| {
82 let _ = fs::remove_file(&temp);
83 e.to_string()
84 })?;
85 Ok(list)
86}
87
88/// Display label: the project folder name.
89pub fn label(manifest: &Path) -> String {
90 manifest
91 .parent()
92 .and_then(|p| p.file_name())
93 .map(|n| n.to_string_lossy().to_string())
94 .unwrap_or_else(|| manifest.display().to_string())
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 fn temp(tag: &str) -> PathBuf {
102 let dir = std::env::temp_dir().join(format!(
103 "paperdoll-recent-{tag}-{}-{}",
104 std::process::id(),
105 std::time::SystemTime::now()
106 .duration_since(std::time::UNIX_EPOCH)
107 .unwrap()
108 .as_nanos()
109 ));
110 fs::create_dir_all(&dir).unwrap();
111 dir
112 }
113
114 #[test]
115 fn records_newest_first_dedupes_caps_and_survives_damage() {
116 let dir = temp("list");
117 assert!(load(&dir).is_empty());
118 let projects: Vec<PathBuf> = (0..7)
119 .map(|i| {
120 let p = dir.join(format!("p{i}"));
121 fs::create_dir_all(&p).unwrap();
122 let m = p.join("character.ppd");
123 fs::write(&m, b"{}").unwrap();
124 m.canonicalize().unwrap()
125 })
126 .collect();
127 for p in &projects {
128 record(&dir, p).unwrap();
129 }
130 record(&dir, &projects[3]).unwrap();
131 let list = load(&dir);
132 assert_eq!(
133 list,
134 vec![
135 projects[3].clone(),
136 projects[6].clone(),
137 projects[5].clone(),
138 projects[4].clone(),
139 projects[2].clone()
140 ]
141 );
142 assert_eq!(label(&list[0]), "p3");
143 // A missing project stays listed (shown greyed by the UI).
144 fs::remove_dir_all(projects[6].parent().unwrap()).unwrap();
145 assert!(load(&dir).contains(&projects[6]));
146 // Damaged file: empty, and the next record repairs it.
147 fs::write(dir.join(FILE_NAME), b"not json").unwrap();
148 assert!(load(&dir).is_empty());
149 record(&dir, &projects[0]).unwrap();
150 assert_eq!(load(&dir), vec![projects[0].clone()]);
151 let _ = fs::remove_dir_all(dir);
152 }
153}