BlueGit

golden-age paperdoll-renderer

Files

paperdoll-renderer / src / editor.rs

editor.rs

editor.rs 29.8 KiB Download Raw
1//! Project window: start screen, body, equipment, and the render queue.
2
3use std::path::PathBuf;
4use std::sync::mpsc::Receiver;
5
6use eframe::egui;
7
8use crate::dialogs::{load_candidate, DialogPoll, DialogPurpose, Dialogs, SystemDialogs};
9use crate::import::{import_project_model, ImportedModel};
10use crate::preview::{load_gltf_file, PreviewCharacter, PreviewControls, PreviewSex};
11use crate::preview_gl::Viewport;
12use crate::project::open_project;
13use crate::render_job::{spawn, RenderEvent, RenderJob, RenderUiState};
14use crate::session::{adopt_file, AfterChoice, Intent, Screen, Session, Sex};
15
16pub struct EditorApp {
17 session: Session,
18 dialogs: Box<dyn Dialogs>,
19 controls: PreviewControls,
20 preview_male: Option<PreviewCharacter>,
21 preview_female: Option<PreviewCharacter>,
22 viewport: Viewport,
23 inventories: [Option<Result<ImportedModel, String>>; 2],
24 render_rx: Option<Receiver<RenderEvent>>,
25 render_ui: RenderUiState,
26 applied_shell: Option<[f32; 2]>,
27 width_text: String,
28 height_text: String,
29 fps_text: String,
30 allow_close: bool,
31 dialog_pending: bool,
32}
33
34impl EditorApp {
35 pub fn new(dialogs: Box<dyn Dialogs>) -> Self {
36 Self {
37 session: Session::new(),
38 dialogs,
39 controls: PreviewControls::default(),
40 preview_male: None,
41 preview_female: None,
42 viewport: Viewport::new(),
43 inventories: [None, None],
44 render_rx: None,
45 render_ui: RenderUiState::idle(),
46 applied_shell: None,
47 width_text: "128".to_string(),
48 height_text: "128".to_string(),
49 fps_text: "12".to_string(),
50 allow_close: false,
51 dialog_pending: false,
52 }
53 }
54
55 pub fn open_path(&mut self, path: PathBuf) {
56 let candidate = std::env::current_dir()
57 .map_err(|e| e.to_string())
58 .and_then(|base| {
59 crate::file_assoc::resolve_project_argument(&base, &path.to_string_lossy())
60 });
61 let path = match candidate {
62 Ok(path) => path,
63 Err(error) => {
64 self.session.fail(error);
65 return;
66 }
67 };
68 match open_project(&path) {
69 Ok(opened) => {
70 self.session.install_loaded(opened);
71 self.reset_render();
72 self.sync_fields();
73 self.reload_bodies();
74 }
75 Err(error) => {
76 self.session.fail(error.to_string());
77 }
78 }
79 }
80
81 pub fn open_fixture_project(&mut self) {
82 let root = std::env::temp_dir().join(format!(
83 "paperdoll-renderer-fixture-{}-{}",
84 std::process::id(),
85 std::time::SystemTime::now()
86 .duration_since(std::time::UNIX_EPOCH)
87 .unwrap_or_default()
88 .as_nanos()
89 ));
90 if let Err(error) = self.session.install_new_project(&root) {
91 self.session.fail(error.to_string());
92 return;
93 }
94 self.sync_fields();
95 self.reload_bodies();
96 self.session.show_body(Sex::Male);
97 self.controls.sex = PreviewSex::Male;
98 self.controls.select_clip("walk");
99 }
100
101 fn reset_render(&mut self) {
102 self.render_rx = None;
103 self.render_ui = RenderUiState::idle();
104 }
105
106 fn sync_fields(&mut self) {
107 if let Some(opened) = &self.session.opened {
108 self.width_text = opened.document.render.target_width.to_string();
109 self.height_text = opened.document.render.target_height.to_string();
110 self.fps_text = opened.document.render.fps.to_string();
111 }
112 }
113
114 fn reload_bodies(&mut self) {
115 self.preview_male = None;
116 self.preview_female = None;
117 self.inventories = [None, None];
118 let Some(root) = self.session.project_root() else {
119 return;
120 };
121 for (index, sex) in [Sex::Male, Sex::Female].into_iter().enumerate() {
122 if let Some(relative) = self.session.body_path(sex) {
123 let imported =
124 import_project_model(&root, relative).map_err(|error| error.to_string());
125 // Validate the source and its dependencies before the viewport reads it.
126 if imported.is_ok()
127 && matches!(
128 std::path::Path::new(relative)
129 .extension()
130 .and_then(|e| e.to_str()),
131 Some("gltf" | "glb")
132 )
133 {
134 match load_gltf_file(&root.join(relative)) {
135 Ok(character) => match sex {
136 Sex::Male => self.preview_male = Some(character),
137 Sex::Female => self.preview_female = Some(character),
138 },
139 Err(error) => self.session.fail(format!("3D: {error}")),
140 }
141 }
142 self.inventories[index] = Some(imported);
143 }
144 }
145 }
146
147 fn character(&self) -> Option<&PreviewCharacter> {
148 match self.controls.sex {
149 PreviewSex::Male => self.preview_male.as_ref(),
150 PreviewSex::Female => self.preview_female.as_ref(),
151 }
152 }
153
154 fn poll_dialogs(&mut self) {
155 let poll = self.dialogs.poll();
156 self.dialog_pending = matches!(poll, DialogPoll::Pending);
157 if let DialogPoll::Ready { purpose, path } = poll {
158 let Some(path) = path else {
159 return;
160 };
161 match purpose {
162 DialogPurpose::NewProject => {
163 if let Err(error) = self.session.install_new_project(&path) {
164 self.session.fail(error.to_string());
165 } else {
166 self.reset_render();
167 self.sync_fields();
168 self.reload_bodies();
169 }
170 }
171 DialogPurpose::LoadProject => match open_project(&load_candidate(&path)) {
172 Ok(opened) => {
173 self.session.install_loaded(opened);
174 self.reset_render();
175 self.sync_fields();
176 self.reload_bodies();
177 }
178 Err(error) => self.session.fail(error.to_string()),
179 },
180 DialogPurpose::Body(sex) => self.take_model(sex, &path),
181 DialogPurpose::EquipmentModel(index) => self.take_equipment(index, &path),
182 }
183 }
184 }
185
186 fn take_model(&mut self, sex: Sex, path: &std::path::Path) {
187 let Some(root) = self.session.project_root() else {
188 return;
189 };
190 match adopt_file(&root, path, "assets/body") {
191 Ok(relative) => {
192 self.session.set_body_path(sex, relative.clone());
193 self.reload_bodies();
194 self.reset_render();
195 let index = if sex == Sex::Male { 0 } else { 1 };
196 if let Some(Err(error)) = &self.inventories[index] {
197 self.session.fail(format!("{relative}: {error}"));
198 } else {
199 self.session.notice = format!("{relative} staat in het project.");
200 self.session.notice_ok = true;
201 }
202 }
203 Err(error) => self.session.fail(error),
204 }
205 }
206
207 fn take_equipment(&mut self, index: usize, path: &std::path::Path) {
208 let Some(root) = self.session.project_root() else {
209 return;
210 };
211 match adopt_file(&root, path, "assets/equipment") {
212 Ok(relative) => {
213 self.session.set_equipment_model(index, relative.clone());
214 self.session.notice = format!("{relative} hoort bij deze equipment.");
215 self.session.notice_ok = true;
216 }
217 Err(error) => self.session.fail(error),
218 }
219 }
220
221 fn begin(&mut self, intent: Intent) {
222 if self.session.gate(intent) {
223 self.run_intent(intent);
224 }
225 }
226
227 fn run_intent(&mut self, intent: Intent) {
228 match intent {
229 Intent::Quit => {}
230 Intent::PickNew => self.dialogs.start(DialogPurpose::NewProject),
231 Intent::PickLoad => self.dialogs.start(DialogPurpose::LoadProject),
232 }
233 }
234
235 fn apply_choice(&mut self, choice: crate::session::Choice, ctx: &egui::Context) {
236 match self.session.resolve(choice) {
237 AfterChoice::Stay => {}
238 AfterChoice::SaveThen(intent) => match self.session.save() {
239 Ok(()) => self.finish_intent(intent, ctx),
240 Err(error) => self.session.fail(error.to_string()),
241 },
242 AfterChoice::Go(intent) => self.finish_intent(intent, ctx),
243 }
244 }
245
246 fn finish_intent(&mut self, intent: Intent, ctx: &egui::Context) {
247 if intent == Intent::Quit {
248 self.allow_close = true;
249 ctx.send_viewport_cmd(egui::ViewportCommand::Close);
250 } else {
251 self.run_intent(intent);
252 }
253 }
254
255 fn save(&mut self) {
256 if let Err(error) = self.session.save() {
257 self.session.fail(error.to_string());
258 }
259 }
260
261 fn apply_render_fields(&mut self) {
262 let width = self.width_text.parse::<u32>().unwrap_or(0);
263 let height = self.height_text.parse::<u32>().unwrap_or(0);
264 let fps = self.fps_text.parse::<u32>().unwrap_or(0);
265 self.session.set_render(width, height, fps);
266 }
267
268 /// Start the same worker used by the Render button (also used by the GUI smoke fixture).
269 pub fn render_current_pose(&mut self) {
270 self.start_render([480, 640]);
271 }
272
273 fn start_render(&mut self, preview: [u32; 2]) {
274 if let Some(opened) = &self.session.opened {
275 if let Err(error) = opened.document.validate_shape() {
276 self.session.fail(error.to_string());
277 return;
278 }
279 }
280 let Some(character) = self.character().cloned() else {
281 self.session
282 .fail("dit lichaam heeft geen glTF dat de preview kan tekenen");
283 return;
284 };
285 let Some(root) = self.session.project_root() else {
286 return;
287 };
288 let Some(opened) = &self.session.opened else {
289 return;
290 };
291 if let Err(error) =
292 crate::project::prepare_output_dir(&root, &opened.document.output_directory)
293 {
294 self.session.fail(error.to_string());
295 return;
296 }
297 let sex = match self.controls.sex {
298 PreviewSex::Male => "male",
299 PreviewSex::Female => "female",
300 };
301 let output = root
302 .join(&opened.document.output_directory)
303 .join(format!("body-{sex}-still.png"));
304 let job = RenderJob {
305 character,
306 controls: self.controls.clone(),
307 target_width: opened.document.render.target_width.max(1),
308 target_height: opened.document.render.target_height.max(1),
309 preview_width: preview[0].max(1),
310 preview_height: preview[1].max(1),
311 output,
312 };
313 let thread = spawn(job);
314 self.render_rx = Some(thread.rx);
315 self.render_ui = RenderUiState::idle();
316 self.render_ui.running = true;
317 let _ = thread.handle;
318 }
319
320 fn drain_render(&mut self) {
321 let Some(rx) = &self.render_rx else {
322 return;
323 };
324 loop {
325 let event = match rx.try_recv() {
326 Ok(event) => event,
327 Err(std::sync::mpsc::TryRecvError::Empty) => break,
328 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
329 let message = "De renderworker stopte zonder eindresultaat.".to_string();
330 self.session.fail(message.clone());
331 self.render_ui.apply(RenderEvent::Failed(message));
332 self.render_rx = None;
333 break;
334 }
335 };
336 let finished = matches!(event, RenderEvent::Finished { .. } | RenderEvent::Failed(_));
337 if let RenderEvent::Finished { saved: Some(path) } = &event {
338 self.session.notice = format!("Standbeeld opgeslagen: {path}");
339 self.session.notice_ok = true;
340 }
341 if let RenderEvent::Failed(message) = &event {
342 self.session.fail(message.clone());
343 }
344 self.render_ui.apply(event);
345 if finished {
346 self.render_rx = None;
347 break;
348 }
349 }
350 }
351}
352
353impl Default for EditorApp {
354 fn default() -> Self {
355 Self::new(Box::new(SystemDialogs::new()))
356 }
357}
358
359impl eframe::App for EditorApp {
360 fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
361 let shell = self.session.shell_size();
362 if self.applied_shell != Some(shell) {
363 ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
364 shell[0], shell[1],
365 )));
366 let min = if shell == crate::session::START_WINDOW {
367 egui::vec2(420.0, 220.0)
368 } else {
369 egui::vec2(900.0, 560.0)
370 };
371 ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(min));
372 self.applied_shell = Some(shell);
373 }
374 if ctx.input(|input| input.viewport().close_requested())
375 && !self.allow_close
376 && self.session.confirm.is_none()
377 {
378 if !self.session.gate(Intent::Quit) {
379 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
380 }
381 } else if self.session.confirm.is_some()
382 && ctx.input(|input| input.viewport().close_requested())
383 {
384 ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
385 }
386 self.poll_dialogs();
387 self.drain_render();
388 if self.render_ui.running {
389 ctx.request_repaint();
390 }
391 egui::TopBottomPanel::bottom("notice").show(ctx, |ui| {
392 let color = if self.session.notice_ok {
393 egui::Color32::LIGHT_GREEN
394 } else {
395 egui::Color32::LIGHT_RED
396 };
397 ui.colored_label(color, &self.session.notice);
398 });
399 match self.session.screen {
400 Screen::Start => self.show_start(ctx),
401 Screen::Bodies => self.show_bodies(ctx),
402 Screen::Body(_) => self.show_body(ctx),
403 Screen::EquipmentList => self.show_equipment_list(ctx),
404 Screen::Equipment(_) => self.show_equipment(ctx),
405 }
406 self.show_confirm(ctx);
407 if self.dialog_pending {
408 ctx.request_repaint_after(std::time::Duration::from_millis(50));
409 }
410 }
411}
412
413impl EditorApp {
414 fn show_confirm(&mut self, ctx: &egui::Context) {
415 let Some(_intent) = self.session.confirm else {
416 return;
417 };
418 let mut choice = None;
419 egui::Window::new("Wijzigingen")
420 .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
421 .collapsible(false)
422 .resizable(false)
423 .show(ctx, |ui| {
424 ui.label("Er zijn wijzigingen die nog niet zijn opgeslagen.");
425 ui.horizontal(|ui| {
426 if ui.button("Opslaan").clicked() {
427 choice = Some(crate::session::Choice::Save);
428 }
429 if ui.button("Niet opslaan").clicked() {
430 choice = Some(crate::session::Choice::Discard);
431 }
432 if ui.button("Annuleren").clicked() {
433 choice = Some(crate::session::Choice::Cancel);
434 }
435 });
436 });
437 if let Some(choice) = choice {
438 self.apply_choice(choice, ctx);
439 }
440 }
441
442 fn show_start(&mut self, ctx: &egui::Context) {
443 egui::CentralPanel::default().show(ctx, |ui| {
444 if self.session.confirm.is_some() || self.dialog_pending {
445 ui.disable();
446 }
447 ui.heading("Paperdoll Renderer");
448 ui.label("Open een bestaand character.ppd of kies een map voor een nieuw project.");
449 ui.add_space(12.0);
450 if ui.button("Project laden").clicked() {
451 self.begin(Intent::PickLoad);
452 }
453 if ui.button("Nieuw project").clicked() {
454 self.begin(Intent::PickNew);
455 }
456 });
457 }
458
459 fn show_bodies(&mut self, ctx: &egui::Context) {
460 self.toolbar(ctx, true);
461 egui::CentralPanel::default().show(ctx, |ui| {
462 if self.session.confirm.is_some() || self.dialog_pending {
463 ui.disable();
464 }
465 ui.heading("Body");
466 if let Some(opened) = &mut self.session.opened {
467 ui.horizontal(|ui| {
468 ui.label("Naam");
469 ui.text_edit_singleline(&mut opened.document.name);
470 });
471 }
472 self.body_row(ui, Sex::Male, "Man", "Kies man", "Open man");
473 self.body_row(ui, Sex::Female, "Vrouw", "Kies vrouw", "Open vrouw");
474 ui.add_space(8.0);
475 ui.horizontal(|ui| {
476 ui.label("Breedte");
477 ui.add(egui::TextEdit::singleline(&mut self.width_text).desired_width(64.0));
478 ui.label("Hoogte");
479 ui.add(egui::TextEdit::singleline(&mut self.height_text).desired_width(64.0));
480 ui.label("fps");
481 ui.add(egui::TextEdit::singleline(&mut self.fps_text).desired_width(48.0));
482 });
483 self.apply_render_fields();
484 });
485 }
486
487 fn body_row(&mut self, ui: &mut egui::Ui, sex: Sex, title: &str, pick: &str, open: &str) {
488 ui.horizontal(|ui| {
489 ui.label(title);
490 let path = self
491 .session
492 .body_path(sex)
493 .unwrap_or("geen model")
494 .to_string();
495 ui.label(path);
496 if ui.button(pick).clicked() {
497 self.dialogs.start(DialogPurpose::Body(sex));
498 }
499 if ui.button(open).clicked() {
500 self.controls.sex = match sex {
501 Sex::Male => PreviewSex::Male,
502 Sex::Female => PreviewSex::Female,
503 };
504 self.session.show_body(sex);
505 }
506 });
507 let index = if sex == Sex::Male { 0 } else { 1 };
508 if let Some(inventory) = &self.inventories[index] {
509 ui.collapsing(format!("Importoverzicht {title}"), |ui| match inventory {
510 Ok(model) => {
511 for mesh in &model.meshes {
512 ui.label(format!(
513 "{}: {} vertices, {} driehoeken",
514 mesh.name, mesh.vertex_count, mesh.triangle_count
515 ));
516 }
517 for material in &model.materials {
518 ui.label(format!("materiaal {}", material.name));
519 }
520 for texture in &model.textures {
521 ui.label(format!(
522 "texture {}: {}×{}",
523 texture.source, texture.width, texture.height
524 ));
525 }
526 ui.label(format!("{} joints", model.joints.len()));
527 for clip in &model.clips {
528 ui.label(format!("{}: {:.2} s", clip.name, clip.duration_seconds));
529 }
530 for notice in &model.notices {
531 ui.label(notice);
532 }
533 }
534 Err(message) => {
535 ui.colored_label(egui::Color32::LIGHT_RED, message);
536 }
537 });
538 }
539 }
540
541 fn show_body(&mut self, ctx: &egui::Context) {
542 self.toolbar(ctx, true);
543 let sex = match self.session.screen {
544 Screen::Body(sex) => sex,
545 _ => Sex::Male,
546 };
547 self.controls.sex = match sex {
548 Sex::Male => PreviewSex::Male,
549 Sex::Female => PreviewSex::Female,
550 };
551 egui::CentralPanel::default().show(ctx, |ui| {
552 if self.session.confirm.is_some() || self.dialog_pending {
553 ui.disable();
554 }
555 ui.horizontal_wrapped(|ui| {
556 if ui.button("Terug").clicked() {
557 self.session.show_bodies();
558 }
559 if ui.button("Equipment").clicked() {
560 self.session.show_equipment_list();
561 }
562 if ui
563 .add_enabled(!self.render_ui.running, egui::Button::new("Render"))
564 .clicked()
565 {
566 self.start_render([480, 640]);
567 }
568 if ui.button("Live 3D").clicked() {
569 self.render_ui.preview = None;
570 }
571 });
572 if self.render_ui.progress.total > 0 {
573 let fraction = self.render_ui.progress.completed as f32
574 / self.render_ui.progress.total.max(1) as f32;
575 ui.add(egui::ProgressBar::new(fraction).text(format!(
576 "{} / {} {}",
577 self.render_ui.progress.completed,
578 self.render_ui.progress.total,
579 self.render_ui.progress.label
580 )));
581 }
582 if let Some(image) = &self.render_ui.preview {
583 self.paint_pass(ui, image);
584 } else {
585 let character = match self.controls.sex {
586 PreviewSex::Male => self.preview_male.as_ref(),
587 PreviewSex::Female => self.preview_female.as_ref(),
588 };
589 if character.is_some() {
590 self.viewport.show(ui, &mut self.controls, character);
591 } else {
592 ui.label("Open een glTF/GLB-lichaam voor de live 3D-weergave.");
593 }
594 }
595 });
596 }
597
598 fn paint_pass(&self, ui: &mut egui::Ui, image: &crate::render_job::ImageBuf) {
599 let color = egui::ColorImage::from_rgba_unmultiplied(
600 [image.width as usize, image.height as usize],
601 &image.rgba,
602 );
603 let texture = ui.ctx().load_texture(
604 "render-pass",
605 color,
606 egui::TextureOptions {
607 magnification: egui::TextureFilter::Nearest,
608 minification: egui::TextureFilter::Nearest,
609 wrap_mode: egui::TextureWrapMode::ClampToEdge,
610 ..Default::default()
611 },
612 );
613 let (bounds, _) = ui.allocate_exact_size(ui.available_size(), egui::Sense::hover());
614 let fit = crate::render_job::fit_preview(
615 [image.width, image.height],
616 [bounds.width(), bounds.height()],
617 );
618 let rect = egui::Rect::from_min_size(
619 bounds.min + egui::vec2(fit.offset[0], fit.offset[1]),
620 egui::vec2(fit.size[0], fit.size[1]),
621 );
622 ui.painter().rect_filled(bounds, 0.0, egui::Color32::BLACK);
623 ui.painter().image(
624 texture.id(),
625 rect,
626 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
627 egui::Color32::WHITE,
628 );
629 }
630
631 fn show_equipment_list(&mut self, ctx: &egui::Context) {
632 self.toolbar(ctx, true);
633 egui::CentralPanel::default().show(ctx, |ui| {
634 if self.session.confirm.is_some() || self.dialog_pending {
635 ui.disable();
636 }
637 ui.heading(format!(
638 "Bodies → {} → Equipment",
639 match self.session.active_body {
640 Some(Sex::Male) => "Man",
641 Some(Sex::Female) => "Vrouw",
642 None => "Body",
643 }
644 ));
645 if ui.button("Terug naar body").clicked() {
646 self.session.back();
647 }
648 let mut open = None;
649 for (index, item) in self.session.equipment.iter().enumerate() {
650 let label = match &item.model {
651 Some(model) => format!("{} {model}", item.slug),
652 None => item.slug.clone(),
653 };
654 if ui.button(label).clicked() {
655 open = Some(index);
656 }
657 }
658 if ui.button("Nieuw").clicked() {
659 self.session.add_equipment();
660 }
661 if let Some(index) = open {
662 self.session.show_equipment(index);
663 }
664 });
665 }
666
667 fn show_equipment(&mut self, ctx: &egui::Context) {
668 self.toolbar(ctx, true);
669 let Screen::Equipment(index) = self.session.screen else {
670 return;
671 };
672 egui::CentralPanel::default().show(ctx, |ui| {
673 if self.session.confirm.is_some() || self.dialog_pending {
674 ui.disable();
675 }
676 ui.heading("Equipment");
677 if ui.button("Terug").clicked() {
678 self.session.show_equipment_list();
679 }
680 if let Some(item) = self.session.equipment.get_mut(index) {
681 ui.horizontal(|ui| {
682 ui.label("Naam");
683 ui.text_edit_singleline(&mut item.slug);
684 });
685 let model = item
686 .model
687 .clone()
688 .unwrap_or_else(|| "geen model".to_string());
689 ui.label(model);
690 }
691 if ui.button("Kies model").clicked() {
692 self.dialogs.start(DialogPurpose::EquipmentModel(index));
693 }
694 if ui.button("Verwijder").clicked() {
695 self.session.delete_equipment(index);
696 }
697 });
698 }
699
700 fn toolbar(&mut self, ctx: &egui::Context, project: bool) {
701 if !project {
702 return;
703 }
704 egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
705 if self.session.confirm.is_some() || self.dialog_pending {
706 ui.disable();
707 }
708 ui.horizontal(|ui| {
709 if ui.button("Body").clicked() {
710 self.session.show_bodies();
711 }
712 if ui.button("Opslaan").clicked() {
713 self.save();
714 }
715 if ui
716 .add_enabled(!self.render_ui.running, egui::Button::new("Nieuw project"))
717 .clicked()
718 {
719 self.begin(Intent::PickNew);
720 }
721 if ui
722 .add_enabled(!self.render_ui.running, egui::Button::new("Ander project"))
723 .clicked()
724 {
725 self.begin(Intent::PickLoad);
726 }
727 });
728 });
729 }
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735 use crate::dialogs::ScriptedDialogs;
736 use crate::session::Choice;
737
738 fn root() -> PathBuf {
739 std::env::temp_dir().join(format!(
740 "paperdoll-editor-{}",
741 std::time::SystemTime::now()
742 .duration_since(std::time::UNIX_EPOCH)
743 .unwrap()
744 .as_nanos()
745 ))
746 }
747
748 #[test]
749 fn picker_creates_project_and_cancelled_switch_preserves_edits() {
750 let root = root();
751 let mut dialogs = ScriptedDialogs::default();
752 dialogs.push(Some(root.clone()));
753 dialogs.push(None);
754 let mut app = EditorApp::new(Box::new(dialogs));
755 assert_eq!(app.session.shell_size(), crate::session::START_WINDOW);
756 app.begin(Intent::PickNew);
757 app.poll_dialogs();
758 assert_eq!(app.session.screen, Screen::Bodies);
759 assert!(app.preview_male.is_some());
760 assert!(app.preview_female.is_some());
761 app.session.set_name("Keep my edit".into());
762 app.begin(Intent::PickLoad);
763 assert_eq!(app.session.confirm, Some(Intent::PickLoad));
764 app.apply_choice(Choice::Discard, &egui::Context::default());
765 app.poll_dialogs(); // OS picker cancel; no actual project switch happened
766 assert_eq!(
767 app.session.opened.as_ref().unwrap().document.name,
768 "Keep my edit"
769 );
770 assert!(app.session.is_dirty());
771 let _ = std::fs::remove_dir_all(root);
772 }
773
774 #[test]
775 fn failed_save_never_closes_or_switches_and_invalid_open_keeps_current() {
776 let root = root();
777 let mut app = EditorApp::default();
778 app.session.install_new_project(&root).unwrap();
779 app.session.set_render(0, 128, 12);
780 app.begin(Intent::Quit);
781 app.apply_choice(Choice::Save, &egui::Context::default());
782 assert!(!app.allow_close);
783 assert_eq!(app.session.confirm, Some(Intent::Quit));
784 assert!(app.session.is_dirty());
785 app.open_path(root.join("missing.ppd"));
786 assert!(app.session.opened.is_some());
787 assert_eq!(app.session.screen, Screen::Bodies);
788 let _ = std::fs::remove_dir_all(root);
789 }
790
791 #[test]
792 fn open_with_project_path_loads_bodies_and_models() {
793 let root = root().join("project with spaces");
794 let opened = crate::session::create_seeded_project(&root).unwrap();
795 let mut app = EditorApp::default();
796 app.open_path(opened.manifest_path.clone());
797 assert_eq!(app.session.screen, Screen::Bodies);
798 assert!(app.preview_male.is_some());
799 assert_eq!(
800 app.session.opened.unwrap().manifest_path,
801 opened.manifest_path
802 );
803 let _ = std::fs::remove_dir_all(root.parent().unwrap());
804 }
805
806 #[test]
807 fn renderer_disconnect_is_reported() {
808 let mut app = EditorApp::default();
809 let (tx, rx) = std::sync::mpsc::channel();
810 app.render_rx = Some(rx);
811 app.render_ui.running = true;
812 drop(tx);
813 app.drain_render();
814 assert!(!app.render_ui.running);
815 assert!(app.render_ui.error.is_some());
816 }
817}