New project: parent folder + validated name creates <parent>/<name>/
@@ -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. 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.
18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project vraagt een bovenliggende map (**Map kiezen…**) en een projectnaam, en maakt dan `<map>/<naam>/` aan. De naam mag niet leeg zijn, geen `/ \ < > : " | ? *` of stuurtekens bevatten en niet op een punt eindigen; bestaat de map al, dan moet die leeg zijn. Fouten staan in het formulier en er wordt dan niets aangemaakt. Daarin komen `character.ppd` (met die naam) 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. 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.18
Het startvenster biedt **Project laden** en **Nieuw project**, beide met een systeembestandkiezer. Nieuw project vraagt een bovenliggende map (**Map kiezen…**) en een projectnaam, en maakt dan `<map>/<naam>/` aan. De naam mag niet leeg zijn, geen `/ \ < > : " | ? *` of stuurtekens bevatten en niet op een punt eindigen; bestaat de map al, dan moet die leeg zijn. Fouten staan in het formulier en er wordt dan niets aangemaakt. Daarin komen `character.ppd` (met die naam) 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
@@ -119,7 +119,9 @@ impl Dialogs for SystemDialogs {
119
119
fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
120
120
let dialog = rfd::FileDialog::new();
121
121
match purpose {
122
DialogPurpose::NewProject => dialog.set_title("Nieuwe projectmap").pick_folder(),
122
DialogPurpose::NewProject => dialog
123
.set_title("Map voor het nieuwe project")
124
.pick_folder(),
123
125
DialogPurpose::LoadProject => dialog
124
126
.set_title("Project laden")
125
127
.add_filter("Paperdoll-project", &["ppd"])
@@ -119,7 +119,9 @@ impl Dialogs for SystemDialogs {119
fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {119
fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {120
let dialog = rfd::FileDialog::new();120
let dialog = rfd::FileDialog::new();121
match purpose {121
match purpose {122
DialogPurpose::NewProject => dialog.set_title("Nieuwe projectmap").pick_folder(),122
DialogPurpose::NewProject => dialog123
.set_title("Map voor het nieuwe project")124
.pick_folder(),123
DialogPurpose::LoadProject => dialog125
DialogPurpose::LoadProject => dialog124
.set_title("Project laden")126
.set_title("Project laden")125
.add_filter("Paperdoll-project", &["ppd"])127
.add_filter("Paperdoll-project", &["ppd"])@@ -19,6 +19,13 @@ use crate::project::open_project;
19
19
use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
20
20
use crate::session::{AfterChoice, Intent, Screen, Session, Sex};
21
21
22
#[derive(Debug, Clone, Default)]
23
struct NewProjectForm {
24
parent: Option<PathBuf>,
25
name: String,
26
error: Option<String>,
27
}
28
22
29
pub struct EditorApp {
23
30
session: Session,
24
31
/// User config dir for the recent-projects list; `None` disables it (tests).
@@ -42,6 +49,8 @@ pub struct EditorApp {
42
49
fps_text: String,
43
50
allow_close: bool,
44
51
dialog_pending: bool,
52
/// Open "Nieuw project" form (parent folder + name), Tim t998u.3.
53
new_form: Option<NewProjectForm>,
45
54
equipment_part: usize,
46
55
preview2d: Preview2dState,
47
56
preview2d_catalog: Vec<CatalogEntry>,
@@ -134,6 +143,7 @@ impl EditorApp {
134
143
fps_text: crate::project::DEFAULT_FPS.to_string(),
135
144
allow_close: false,
136
145
dialog_pending: false,
146
new_form: None,
137
147
equipment_part: 0,
138
148
preview2d: Preview2dState::default(),
139
149
preview2d_catalog: Vec::new(),
@@ -171,6 +181,86 @@ impl EditorApp {
171
181
}
172
182
}
173
183
184
/// "Aanmaken" in the new-project form.
185
fn create_from_form(&mut self) {
186
let Some(form) = self.new_form.as_mut() else {
187
return;
188
};
189
let Some(parent) = form.parent.clone() else {
190
form.error = Some("kies eerst een map waarin het project komt".into());
191
return;
192
};
193
let name = form.name.clone();
194
match self.session.install_named_project(&parent, &name) {
195
Ok(()) => {
196
self.new_form = None;
197
self.remember_project();
198
self.reset_render();
199
self.sync_fields();
200
self.reload_bodies();
201
}
202
Err(error) => {
203
if let Some(form) = self.new_form.as_mut() {
204
form.error = Some(error);
205
}
206
}
207
}
208
}
209
210
fn show_new_project_form(&mut self, ctx: &egui::Context) {
211
let Some(form) = self.new_form.as_mut() else {
212
return;
213
};
214
let mut pick = false;
215
let mut create = false;
216
let mut cancel = false;
217
egui::Window::new("Nieuw project")
218
.collapsible(false)
219
.resizable(false)
220
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
221
.show(ctx, |ui| {
222
ui.horizontal(|ui| {
223
ui.label("Map");
224
match &form.parent {
225
Some(p) => ui.monospace(p.display().to_string()),
226
None => ui.weak("nog niet gekozen"),
227
};
228
pick = ui.button("Map kiezen…").clicked();
229
});
230
ui.horizontal(|ui| {
231
ui.label("Naam");
232
let edit = ui.text_edit_singleline(&mut form.name);
233
if edit.changed() {
234
form.error = None;
235
}
236
create |= edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
237
});
238
if let Some(parent) = &form.parent {
239
if !form.name.trim().is_empty() {
240
ui.weak(format!(
241
"Wordt aangemaakt: {}",
242
parent.join(form.name.trim()).display()
243
));
244
}
245
}
246
if let Some(error) = &form.error {
247
ui.colored_label(egui::Color32::from_rgb(230, 90, 80), error);
248
}
249
ui.horizontal(|ui| {
250
create |= ui.button("Aanmaken").clicked();
251
cancel = ui.button("Annuleren").clicked();
252
});
253
});
254
if pick {
255
self.dialogs.start(DialogPurpose::NewProject);
256
}
257
if cancel {
258
self.new_form = None;
259
} else if create {
260
self.create_from_form();
261
}
262
}
263
174
264
pub fn open_path(&mut self, path: PathBuf) {
175
265
let candidate = std::env::current_dir()
176
266
.map_err(|e| e.to_string())
@@ -285,14 +375,9 @@ impl EditorApp {
285
375
};
286
376
match purpose {
287
377
DialogPurpose::NewProject => {
288
if let Err(error) = self.session.install_new_project(&path) {
289
self.session.fail(error.to_string());
290
} else {
291
self.remember_project();
292
self.reset_render();
293
self.sync_fields();
294
self.reload_bodies();
295
}
378
let form = self.new_form.get_or_insert_with(NewProjectForm::default);
379
form.parent = Some(path);
380
form.error = None;
296
381
}
297
382
DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
298
383
Ok(opened) => {
@@ -432,7 +517,7 @@ impl EditorApp {
432
517
fn run_intent(&mut self, intent: Intent) {
433
518
match intent {
434
519
Intent::Quit => {}
435
Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
520
Intent::PickNew => self.new_form = Some(NewProjectForm::default()),
436
521
Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
437
522
}
438
523
}
@@ -1205,6 +1290,7 @@ impl eframe::App for EditorApp {
1205
1290
Screen::Preview2d => self.show_preview2d(ctx),
1206
1291
}
1207
1292
self.show_confirm(ctx);
1293
self.show_new_project_form(ctx);
1208
1294
if self.dialog_pending {
1209
1295
ctx.request_repaint_after(std::time::Duration::from_millis(50));
1210
1296
}
@@ -2369,18 +2455,75 @@ mod tests {
2369
2455
))
2370
2456
}
2371
2457
2458
#[test]
2459
fn new_project_form_validates_name_and_folder_with_clear_errors() {
2460
let parent = root();
2461
std::fs::create_dir_all(&parent).unwrap();
2462
let mut dialogs = ScriptedDialogs::default();
2463
dialogs.push(Some(parent.clone()));
2464
let mut app = EditorApp::new(Box::new(dialogs));
2465
app.begin(Intent::PickNew);
2466
app.create_from_form();
2467
assert!(app
2468
.new_form
2469
.as_ref()
2470
.unwrap()
2471
.error
2472
.as_ref()
2473
.unwrap()
2474
.contains("map"));
2475
app.dialogs.start(DialogPurpose::NewProject);
2476
app.poll_dialogs();
2477
for (name, part) in [
2478
("", "naam"),
2479
("a/b", "mag geen"),
2480
("a\\b", "mag geen"),
2481
("..", "punt"),
2482
] {
2483
app.new_form.as_mut().unwrap().name = name.into();
2484
app.create_from_form();
2485
let err = app.new_form.as_ref().unwrap().error.clone().unwrap();
2486
assert!(err.contains(part), "{name}: {err}");
2487
}
2488
std::fs::create_dir_all(parent.join("bezet")).unwrap();
2489
std::fs::write(parent.join("bezet/x"), b"x").unwrap();
2490
app.new_form.as_mut().unwrap().name = "bezet".into();
2491
app.create_from_form();
2492
assert!(app
2493
.new_form
2494
.as_ref()
2495
.unwrap()
2496
.error
2497
.as_ref()
2498
.unwrap()
2499
.contains("niet leeg"));
2500
assert_eq!(app.session.screen, Screen::Start);
2501
assert!(!parent.join("bezet/character.ppd").exists());
2502
// An existing empty folder is fine.
2503
std::fs::create_dir_all(parent.join("leeg")).unwrap();
2504
app.new_form.as_mut().unwrap().name = " leeg ".into();
2505
app.create_from_form();
2506
assert!(app.new_form.is_none());
2507
assert!(parent.join("leeg/character.ppd").is_file());
2508
let _ = std::fs::remove_dir_all(parent);
2509
}
2510
2372
2511
#[test]
2373
2512
fn opened_and_created_projects_are_remembered_for_the_start_screen() {
2374
2513
let config = root();
2375
2514
let project = root();
2515
std::fs::create_dir_all(&project).unwrap();
2376
2516
let mut dialogs = ScriptedDialogs::default();
2377
2517
dialogs.push(Some(project.clone()));
2378
2518
let mut app = EditorApp::new(Box::new(dialogs));
2379
2519
app.set_recent_dir(Some(config.clone()));
2380
2520
assert!(app.recent_projects().is_empty());
2381
2521
app.begin(Intent::PickNew);
2522
app.new_form.as_mut().unwrap().name = "p".into();
2523
app.dialogs.start(DialogPurpose::NewProject);
2382
2524
app.poll_dialogs();
2383
let manifest = project.join("character.ppd").canonicalize().unwrap();
2525
app.create_from_form();
2526
let manifest = project.join("p/character.ppd").canonicalize().unwrap();
2384
2527
assert_eq!(app.recent_projects(), std::slice::from_ref(&manifest));
2385
2528
// A fresh app (next start) reads the list and can reopen from it.
2386
2529
let mut next = EditorApp::new(Box::new(ScriptedDialogs::default()));
@@ -2395,14 +2538,24 @@ mod tests {
2395
2538
#[test]
2396
2539
fn picker_creates_project_and_cancelled_switch_preserves_edits() {
2397
2540
let root = root();
2541
std::fs::create_dir_all(&root).unwrap();
2398
2542
let mut dialogs = ScriptedDialogs::default();
2399
2543
dialogs.push(Some(root.clone()));
2400
2544
dialogs.push(None);
2401
2545
let mut app = EditorApp::new(Box::new(dialogs));
2402
2546
assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
2403
2547
app.begin(Intent::PickNew);
2548
app.new_form.as_mut().unwrap().name = "Mijn held".into();
2549
app.dialogs.start(DialogPurpose::NewProject);
2404
2550
app.poll_dialogs();
2551
app.create_from_form();
2552
assert!(app.new_form.is_none());
2405
2553
assert_eq!(app.session.screen, Screen::Bodies);
2554
assert!(root.join("Mijn held/character.ppd").is_file());
2555
assert_eq!(
2556
app.session.opened.as_ref().unwrap().document.name,
2557
"Mijn held"
2558
);
2406
2559
assert!(app.preview_male.is_some());
2407
2560
assert!(app.preview_female.is_some());
2408
2561
app.session.set_name("Keep my edit".into());
@@ -19,6 +19,13 @@ use crate::project::open_project;19
use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};19
use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};20
use crate::session::{AfterChoice, Intent, Screen, Session, Sex};20
use crate::session::{AfterChoice, Intent, Screen, Session, Sex};21
21
22
#[derive(Debug, Clone, Default)]23
struct NewProjectForm {24
parent: Option<PathBuf>,25
name: String,26
error: Option<String>,27
}28
22
pub struct EditorApp {29
pub struct EditorApp {23
session: Session,30
session: Session,24
/// User config dir for the recent-projects list; `None` disables it (tests).31
/// User config dir for the recent-projects list; `None` disables it (tests).@@ -42,6 +49,8 @@ pub struct EditorApp {42
fps_text: String,49
fps_text: String,43
allow_close: bool,50
allow_close: bool,44
dialog_pending: bool,51
dialog_pending: bool,52
/// Open "Nieuw project" form (parent folder + name), Tim t998u.3.53
new_form: Option<NewProjectForm>,45
equipment_part: usize,54
equipment_part: usize,46
preview2d: Preview2dState,55
preview2d: Preview2dState,47
preview2d_catalog: Vec<CatalogEntry>,56
preview2d_catalog: Vec<CatalogEntry>,@@ -134,6 +143,7 @@ impl EditorApp {134
fps_text: crate::project::DEFAULT_FPS.to_string(),143
fps_text: crate::project::DEFAULT_FPS.to_string(),135
allow_close: false,144
allow_close: false,136
dialog_pending: false,145
dialog_pending: false,146
new_form: None,137
equipment_part: 0,147
equipment_part: 0,138
preview2d: Preview2dState::default(),148
preview2d: Preview2dState::default(),139
preview2d_catalog: Vec::new(),149
preview2d_catalog: Vec::new(),@@ -171,6 +181,86 @@ impl EditorApp {171
}181
}172
}182
}173
183
184
/// "Aanmaken" in the new-project form.185
fn create_from_form(&mut self) {186
let Some(form) = self.new_form.as_mut() else {187
return;188
};189
let Some(parent) = form.parent.clone() else {190
form.error = Some("kies eerst een map waarin het project komt".into());191
return;192
};193
let name = form.name.clone();194
match self.session.install_named_project(&parent, &name) {195
Ok(()) => {196
self.new_form = None;197
self.remember_project();198
self.reset_render();199
self.sync_fields();200
self.reload_bodies();201
}202
Err(error) => {203
if let Some(form) = self.new_form.as_mut() {204
form.error = Some(error);205
}206
}207
}208
}209
210
fn show_new_project_form(&mut self, ctx: &egui::Context) {211
let Some(form) = self.new_form.as_mut() else {212
return;213
};214
let mut pick = false;215
let mut create = false;216
let mut cancel = false;217
egui::Window::new("Nieuw project")218
.collapsible(false)219
.resizable(false)220
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])221
.show(ctx, |ui| {222
ui.horizontal(|ui| {223
ui.label("Map");224
match &form.parent {225
Some(p) => ui.monospace(p.display().to_string()),226
None => ui.weak("nog niet gekozen"),227
};228
pick = ui.button("Map kiezen…").clicked();229
});230
ui.horizontal(|ui| {231
ui.label("Naam");232
let edit = ui.text_edit_singleline(&mut form.name);233
if edit.changed() {234
form.error = None;235
}236
create |= edit.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));237
});238
if let Some(parent) = &form.parent {239
if !form.name.trim().is_empty() {240
ui.weak(format!(241
"Wordt aangemaakt: {}",242
parent.join(form.name.trim()).display()243
));244
}245
}246
if let Some(error) = &form.error {247
ui.colored_label(egui::Color32::from_rgb(230, 90, 80), error);248
}249
ui.horizontal(|ui| {250
create |= ui.button("Aanmaken").clicked();251
cancel = ui.button("Annuleren").clicked();252
});253
});254
if pick {255
self.dialogs.start(DialogPurpose::NewProject);256
}257
if cancel {258
self.new_form = None;259
} else if create {260
self.create_from_form();261
}262
}263
174
pub fn open_path(&mut self, path: PathBuf) {264
pub fn open_path(&mut self, path: PathBuf) {175
let candidate = std::env::current_dir()265
let candidate = std::env::current_dir()176
.map_err(|e| e.to_string())266
.map_err(|e| e.to_string())@@ -285,14 +375,9 @@ impl EditorApp {285
};375
};286
match purpose {376
match purpose {287
DialogPurpose::NewProject => {377
DialogPurpose::NewProject => {288
if let Err(error) = self.session.install_new_project(&path) {378
let form = self.new_form.get_or_insert_with(NewProjectForm::default);289
self.session.fail(error.to_string());379
form.parent = Some(path);290
} else {380
form.error = None;291
self.remember_project();292
self.reset_render();293
self.sync_fields();294
self.reload_bodies();295
}296
}381
}297
DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {382
DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {298
Ok(opened) => {383
Ok(opened) => {@@ -432,7 +517,7 @@ impl EditorApp {432
fn run_intent(&mut self, intent: Intent) {517
fn run_intent(&mut self, intent: Intent) {433
match intent {518
match intent {434
Intent::Quit => {}519
Intent::Quit => {}435
Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),520
Intent::PickNew => self.new_form = Some(NewProjectForm::default()),436
Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),521
Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),437
}522
}438
}523
}@@ -1205,6 +1290,7 @@ impl eframe::App for EditorApp {1205
Screen::Preview2d => self.show_preview2d(ctx),1290
Screen::Preview2d => self.show_preview2d(ctx),1206
}1291
}1207
self.show_confirm(ctx);1292
self.show_confirm(ctx);1293
self.show_new_project_form(ctx);1208
if self.dialog_pending {1294
if self.dialog_pending {1209
ctx.request_repaint_after(std::time::Duration::from_millis(50));1295
ctx.request_repaint_after(std::time::Duration::from_millis(50));1210
}1296
}@@ -2369,18 +2455,75 @@ mod tests {2369
))2455
))2370
}2456
}2371
2457
2458
#[test]2459
fn new_project_form_validates_name_and_folder_with_clear_errors() {2460
let parent = root();2461
std::fs::create_dir_all(&parent).unwrap();2462
let mut dialogs = ScriptedDialogs::default();2463
dialogs.push(Some(parent.clone()));2464
let mut app = EditorApp::new(Box::new(dialogs));2465
app.begin(Intent::PickNew);2466
app.create_from_form();2467
assert!(app2468
.new_form2469
.as_ref()2470
.unwrap()2471
.error2472
.as_ref()2473
.unwrap()2474
.contains("map"));2475
app.dialogs.start(DialogPurpose::NewProject);2476
app.poll_dialogs();2477
for (name, part) in [2478
("", "naam"),2479
("a/b", "mag geen"),2480
("a\\b", "mag geen"),2481
("..", "punt"),2482
] {2483
app.new_form.as_mut().unwrap().name = name.into();2484
app.create_from_form();2485
let err = app.new_form.as_ref().unwrap().error.clone().unwrap();2486
assert!(err.contains(part), "{name}: {err}");2487
}2488
std::fs::create_dir_all(parent.join("bezet")).unwrap();2489
std::fs::write(parent.join("bezet/x"), b"x").unwrap();2490
app.new_form.as_mut().unwrap().name = "bezet".into();2491
app.create_from_form();2492
assert!(app2493
.new_form2494
.as_ref()2495
.unwrap()2496
.error2497
.as_ref()2498
.unwrap()2499
.contains("niet leeg"));2500
assert_eq!(app.session.screen, Screen::Start);2501
assert!(!parent.join("bezet/character.ppd").exists());2502
// An existing empty folder is fine.2503
std::fs::create_dir_all(parent.join("leeg")).unwrap();2504
app.new_form.as_mut().unwrap().name = " leeg ".into();2505
app.create_from_form();2506
assert!(app.new_form.is_none());2507
assert!(parent.join("leeg/character.ppd").is_file());2508
let _ = std::fs::remove_dir_all(parent);2509
}2510
2372
#[test]2511
#[test]2373
fn opened_and_created_projects_are_remembered_for_the_start_screen() {2512
fn opened_and_created_projects_are_remembered_for_the_start_screen() {2374
let config = root();2513
let config = root();2375
let project = root();2514
let project = root();2515
std::fs::create_dir_all(&project).unwrap();2376
let mut dialogs = ScriptedDialogs::default();2516
let mut dialogs = ScriptedDialogs::default();2377
dialogs.push(Some(project.clone()));2517
dialogs.push(Some(project.clone()));2378
let mut app = EditorApp::new(Box::new(dialogs));2518
let mut app = EditorApp::new(Box::new(dialogs));2379
app.set_recent_dir(Some(config.clone()));2519
app.set_recent_dir(Some(config.clone()));2380
assert!(app.recent_projects().is_empty());2520
assert!(app.recent_projects().is_empty());2381
app.begin(Intent::PickNew);2521
app.begin(Intent::PickNew);2522
app.new_form.as_mut().unwrap().name = "p".into();2523
app.dialogs.start(DialogPurpose::NewProject);2382
app.poll_dialogs();2524
app.poll_dialogs();2383
let manifest = project.join("character.ppd").canonicalize().unwrap();2525
app.create_from_form();2526
let manifest = project.join("p/character.ppd").canonicalize().unwrap();2384
assert_eq!(app.recent_projects(), std::slice::from_ref(&manifest));2527
assert_eq!(app.recent_projects(), std::slice::from_ref(&manifest));2385
// A fresh app (next start) reads the list and can reopen from it.2528
// A fresh app (next start) reads the list and can reopen from it.2386
let mut next = EditorApp::new(Box::new(ScriptedDialogs::default()));2529
let mut next = EditorApp::new(Box::new(ScriptedDialogs::default()));@@ -2395,14 +2538,24 @@ mod tests {2395
#[test]2538
#[test]2396
fn picker_creates_project_and_cancelled_switch_preserves_edits() {2539
fn picker_creates_project_and_cancelled_switch_preserves_edits() {2397
let root = root();2540
let root = root();2541
std::fs::create_dir_all(&root).unwrap();2398
let mut dialogs = ScriptedDialogs::default();2542
let mut dialogs = ScriptedDialogs::default();2399
dialogs.push(Some(root.clone()));2543
dialogs.push(Some(root.clone()));2400
dialogs.push(None);2544
dialogs.push(None);2401
let mut app = EditorApp::new(Box::new(dialogs));2545
let mut app = EditorApp::new(Box::new(dialogs));2402
assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);2546
assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);2403
app.begin(Intent::PickNew);2547
app.begin(Intent::PickNew);2548
app.new_form.as_mut().unwrap().name = "Mijn held".into();2549
app.dialogs.start(DialogPurpose::NewProject);2404
app.poll_dialogs();2550
app.poll_dialogs();2551
app.create_from_form();2552
assert!(app.new_form.is_none());2405
assert_eq!(app.session.screen, Screen::Bodies);2553
assert_eq!(app.session.screen, Screen::Bodies);2554
assert!(root.join("Mijn held/character.ppd").is_file());2555
assert_eq!(2556
app.session.opened.as_ref().unwrap().document.name,2557
"Mijn held"2558
);2406
assert!(app.preview_male.is_some());2559
assert!(app.preview_male.is_some());2407
assert!(app.preview_female.is_some());2560
assert!(app.preview_female.is_some());2408
app.session.set_name("Keep my edit".into());2561
app.session.set_name("Keep my edit".into());@@ -383,6 +383,50 @@ pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {
383
383
})
384
384
}
385
385
386
/// Validate a typed project name and return `<parent>/<name>` (Tim t998u.3).
387
/// The folder must not exist yet, or be empty.
388
pub fn new_project_dir(parent: &Path, name: &str) -> Result<PathBuf, String> {
389
let name = name.trim();
390
if name.is_empty() {
391
return Err("geef het project een naam".into());
392
}
393
if name.chars().count() > 100 {
394
return Err("de projectnaam is te lang (maximaal 100 tekens)".into());
395
}
396
if name.chars().any(|c| {
397
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
398
}) {
399
return Err("de projectnaam mag geen / \\ < > : \" | ? * of stuurtekens bevatten".into());
400
}
401
if name == "." || name == ".." || name.ends_with('.') {
402
return Err("de projectnaam mag niet op een punt eindigen".into());
403
}
404
if !parent.is_dir() {
405
return Err("kies eerst een bestaande map waarin het project komt".into());
406
}
407
let dir = parent.join(name);
408
match fs::symlink_metadata(&dir) {
409
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(dir),
410
Err(e) => Err(format!("{}: {e}", dir.display())),
411
Ok(meta) if meta.is_dir() => {
412
let empty = fs::read_dir(&dir)
413
.map_err(|e| format!("{}: {e}", dir.display()))?
414
.next()
415
.is_none();
416
if empty {
417
Ok(dir)
418
} else {
419
Err(format!(
420
"de map '{name}' bestaat al en is niet leeg; kies een andere naam"
421
))
422
}
423
}
424
Ok(_) => Err(format!(
425
"er bestaat al een bestand '{name}'; kies een andere naam"
426
)),
427
}
428
}
429
386
430
pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
387
431
if root.exists() && !root.is_dir() {
388
432
return Err(io_error(root, "de projectmap is een bestand"));
@@ -383,6 +383,50 @@ pub fn parse_manifest(text: &str) -> Result<ProjectFile, ProjectError> {383
})383
})384
}384
}385
385
386
/// Validate a typed project name and return `<parent>/<name>` (Tim t998u.3).387
/// The folder must not exist yet, or be empty.388
pub fn new_project_dir(parent: &Path, name: &str) -> Result<PathBuf, String> {389
let name = name.trim();390
if name.is_empty() {391
return Err("geef het project een naam".into());392
}393
if name.chars().count() > 100 {394
return Err("de projectnaam is te lang (maximaal 100 tekens)".into());395
}396
if name.chars().any(|c| {397
c.is_control() || matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')398
}) {399
return Err("de projectnaam mag geen / \\ < > : \" | ? * of stuurtekens bevatten".into());400
}401
if name == "." || name == ".." || name.ends_with('.') {402
return Err("de projectnaam mag niet op een punt eindigen".into());403
}404
if !parent.is_dir() {405
return Err("kies eerst een bestaande map waarin het project komt".into());406
}407
let dir = parent.join(name);408
match fs::symlink_metadata(&dir) {409
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(dir),410
Err(e) => Err(format!("{}: {e}", dir.display())),411
Ok(meta) if meta.is_dir() => {412
let empty = fs::read_dir(&dir)413
.map_err(|e| format!("{}: {e}", dir.display()))?414
.next()415
.is_none();416
if empty {417
Ok(dir)418
} else {419
Err(format!(420
"de map '{name}' bestaat al en is niet leeg; kies een andere naam"421
))422
}423
}424
Ok(_) => Err(format!(425
"er bestaat al een bestand '{name}'; kies een andere naam"426
)),427
}428
}429
386
pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {430
pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {387
if root.exists() && !root.is_dir() {431
if root.exists() && !root.is_dir() {388
return Err(io_error(root, "de projectmap is een bestand"));432
return Err(io_error(root, "de projectmap is een bestand"));@@ -321,6 +321,15 @@ impl Session {
321
321
Ok(())
322
322
}
323
323
324
/// Create `<parent>/<name>/` (validated) and open it as a new project.
325
pub fn install_named_project(&mut self, parent: &Path, name: &str) -> Result<(), String> {
326
let dir = crate::project::new_project_dir(parent, name)?;
327
let opened = create_seeded_project_named(&dir, name.trim()).map_err(|e| e.to_string())?;
328
self.install_opened(opened);
329
self.ok("Project aangemaakt, met de testlichamen in assets/body.");
330
Ok(())
331
}
332
324
333
pub fn install_loaded(&mut self, opened: OpenedProject) {
325
334
let root = opened
326
335
.manifest_path
@@ -410,7 +419,11 @@ impl Default for Session {
410
419
}
411
420
412
421
pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {
413
let mut opened = create_project(root, "Nieuw project")?;
422
create_seeded_project_named(root, "Nieuw project")
423
}
424
425
pub fn create_seeded_project_named(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
426
let mut opened = create_project(root, name)?;
414
427
for (relative, bytes) in [
415
428
(MALE_ASSET, MALE_GLB),
416
429
(FEMALE_ASSET, FEMALE_GLB),
@@ -321,6 +321,15 @@ impl Session {321
Ok(())321
Ok(())322
}322
}323
323
324
/// Create `<parent>/<name>/` (validated) and open it as a new project.325
pub fn install_named_project(&mut self, parent: &Path, name: &str) -> Result<(), String> {326
let dir = crate::project::new_project_dir(parent, name)?;327
let opened = create_seeded_project_named(&dir, name.trim()).map_err(|e| e.to_string())?;328
self.install_opened(opened);329
self.ok("Project aangemaakt, met de testlichamen in assets/body.");330
Ok(())331
}332
324
pub fn install_loaded(&mut self, opened: OpenedProject) {333
pub fn install_loaded(&mut self, opened: OpenedProject) {325
let root = opened334
let root = opened326
.manifest_path335
.manifest_path@@ -410,7 +419,11 @@ impl Default for Session {410
}419
}411
420
412
pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {421
pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {413
let mut opened = create_project(root, "Nieuw project")?;422
create_seeded_project_named(root, "Nieuw project")423
}424
425
pub fn create_seeded_project_named(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {426
let mut opened = create_project(root, name)?;414
for (relative, bytes) in [427
for (relative, bytes) in [415
(MALE_ASSET, MALE_GLB),428
(MALE_ASSET, MALE_GLB),416
(FEMALE_ASSET, FEMALE_GLB),429
(FEMALE_ASSET, FEMALE_GLB),