BlueGit

golden-age paperdoll-renderer

Merge feature/new-project-name (Tim t998u.3)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
fad1ad5b7a2c482c3a57bc6cc01b9d8c3e7d83d6
Pipeline
Passed
5 files changed +225 −13
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. 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 -- --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. 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.
18Het 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
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/dialogs.rs +3 −1
@@ -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 {
119fn native_pick(purpose: &DialogPurpose) -> Option<PathBuf> {
119fn 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 => dialog
123 .set_title("Map voor het nieuwe project")
124 .pick_folder(),
123 DialogPurpose::LoadProject => dialog
125 DialogPurpose::LoadProject => dialog
124 .set_title("Project laden")
126 .set_title("Project laden")
125 .add_filter("Paperdoll-project", &["ppd"])
127 .add_filter("Paperdoll-project", &["ppd"])
src/editor.rs +163 −10
@@ -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;
19use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
19use crate::render_job::{spawn, ImageBuf, RenderEvent, RenderJob, RenderUiState};
20use crate::session::{AfterChoice, Intent, Screen, Session, Sex};
20use crate::session::{AfterChoice, Intent, Screen, Session, Sex};
21
21
22#[derive(Debug, Clone, Default)]
23struct NewProjectForm {
24 parent: Option<PathBuf>,
25 name: String,
26 error: Option<String>,
27}
28
22pub struct EditorApp {
29pub 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!(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 #[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());
src/project.rs +44 −0
@@ -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.
388pub 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
386pub fn create_project(root: &Path, name: &str) -> Result<OpenedProject, ProjectError> {
430pub 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"));
src/session.rs +14 −1
@@ -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 = opened
334 let root = opened
326 .manifest_path
335 .manifest_path
@@ -410,7 +419,11 @@ impl Default for Session {
410}
419}
411
420
412pub fn create_seeded_project(root: &Path) -> Result<OpenedProject, ProjectError> {
421pub 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
425pub 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),