Merge feature/recent-projects (Tim t998u.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 -- --status15
15
16
## Project en bodies16
## Project en bodies17
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
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.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
@@ -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
22
pub struct EditorApp {22
pub 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.recent162
}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
1108
impl Default for EditorApp {1136
impl 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
app1111
}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 ui1265
.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();@@ -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;28
pub mod preview_gl;28
pub mod preview_gl;29
pub mod production;29
pub mod production;30
pub mod project;30
pub mod project;31
pub mod recent;31
pub mod render_job;32
pub mod render_job;32
pub mod resume;33
pub mod resume;33
pub mod session;34
pub mod session;@@ -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; see4
//! [`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) or14
/// `$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 doc47
.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
out63
}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 = manifest68
.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
manifest91
.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
dir112
}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
}