1use crate::{
6 builder::{spawn_build, BuildJob},
7 editor::{
8 code::CodeEditor,
9 file_picker::{FilePicker, PickerAction},
10 map::MapEditor,
11 music::MusicEditor,
12 sfx::SfxEditor,
13 sprite::SpriteEditor,
14 },
15 ui::{self, Mouse},
16 watch::{FileChange, FileWatch, SourceTreeWatch},
17};
18use anyhow::{anyhow, bail, Result};
19use pixel8_runtime::{
20 assets::Assets,
21 audio::AudioHandle,
22 cart::{self, Cart},
23 clipboard::Pasted,
24 fb::Framebuffer,
25 font,
26 palette::col,
27 project::{decode_assets, encode_assets, Project},
28 storage::Storage,
29 vm::{GameVm, RuntimeError, UI_FPS},
30};
31use std::{
32 collections::VecDeque,
33 path::PathBuf,
34 time::{Duration, Instant, SystemTime},
35};
36
37pub const VERSION: &str = env!("CARGO_PKG_VERSION");
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum Key {
42 Char(char),
43 Left,
44 Right,
45 Up,
46 Down,
47 Backspace,
48 Delete,
49 Enter,
50 Tab,
51 Escape,
52 Home,
53 End,
54 PageUp,
55 PageDown,
56 CaptureLabel,
58 ToggleStats,
60}
61
62#[derive(Debug, Clone, Copy, Default)]
63pub struct Mods {
64 pub ctrl: bool,
65 pub shift: bool,
66 pub alt: bool,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Mode {
71 Console,
72 Run,
73 Code,
74 Sprite,
75 Map,
76 Sfx,
77 Music,
78}
79
80pub const EDITOR_MODES: [Mode; 5] = [Mode::Code, Mode::Sprite, Mode::Map, Mode::Sfx, Mode::Music];
82
83enum Loaded {
85 None,
86 Project(Project),
88 Cart {
91 cart: Cart,
92 path: PathBuf,
93 },
94}
95
96fn assets_of(loaded: &mut Loaded) -> Option<&mut Assets> {
97 match loaded {
98 Loaded::None => None,
99 Loaded::Project(p) => Some(&mut p.assets),
100 Loaded::Cart { cart, .. } => Some(&mut cart.assets),
101 }
102}
103
104fn assets_ref(loaded: &Loaded) -> Option<&Assets> {
105 match loaded {
106 Loaded::None => None,
107 Loaded::Project(p) => Some(&p.assets),
108 Loaded::Cart { cart, .. } => Some(&cart.assets),
109 }
110}
111
112struct ProjectWatch {
115 code: FileWatch,
116 assets: FileWatch,
117 source_tree: SourceTreeWatch,
118}
119
120impl ProjectWatch {
121 fn new(p: &Project) -> Self {
122 let assets_baseline = encode_assets(&p.assets).unwrap_or_default();
123 Self {
124 code: FileWatch::new(p.dir.join("src/lib.rs"), p.code.clone().into_bytes()),
125 assets: FileWatch::new(p.dir.join("assets.pixel8.json"), assets_baseline),
126 source_tree: SourceTreeWatch::new(&p.dir),
127 }
128 }
129
130 fn sync(&mut self, p: &Project) {
133 self.code.mark_synced(p.code.clone().into_bytes());
134 self.assets
135 .mark_synced(encode_assets(&p.assets).unwrap_or_default());
136 self.source_tree.sync();
137 }
138}
139
140struct CartWatch {
143 path: PathBuf,
144 synced_mtime: Option<SystemTime>,
145 baseline: Vec<u8>,
147}
148
149impl CartWatch {
150 fn new(path: PathBuf, baseline: Vec<u8>) -> Self {
151 let synced_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
152 Self {
153 path,
154 synced_mtime,
155 baseline,
156 }
157 }
158
159 fn mark_synced(&mut self, baseline: Vec<u8>) {
162 self.baseline = baseline;
163 self.synced_mtime = std::fs::metadata(&self.path)
164 .and_then(|m| m.modified())
165 .ok();
166 }
167
168 fn advanced(&mut self) -> Option<SystemTime> {
170 let mtime = std::fs::metadata(&self.path)
171 .and_then(|m| m.modified())
172 .ok()?;
173 let advanced = self.synced_mtime.map(|prev| mtime > prev).unwrap_or(true);
174 if advanced {
175 self.synced_mtime = Some(mtime);
178 Some(mtime)
179 } else {
180 None
181 }
182 }
183}
184
185fn stat_color(frac: f32) -> u8 {
188 if frac < 0.7 {
189 col::GREEN
190 } else if frac <= 0.9 {
191 col::YELLOW
192 } else {
193 col::RED
194 }
195}
196
197fn fps_color(measured: f32, target: u32) -> u8 {
199 let ratio = if target > 0 {
200 measured / target as f32
201 } else {
202 1.0
203 };
204 if ratio >= 0.95 {
205 col::GREEN
206 } else if ratio >= 0.8 {
207 col::YELLOW
208 } else {
209 col::RED
210 }
211}
212
213fn stats_overlay(fb: &mut Framebuffer, cpu_u: f32, cpu_d: f32, used: u32, fps: f32, target: u32) {
222 let used_frac = used as f32 / 131_072.0;
223 let lines = [
224 format!("CPU U {:>5.1}%", cpu_u * 100.0),
225 format!("CPU D {:>5.1}%", cpu_d * 100.0),
226 format!(
227 "MEM {:<4}{:>5.1}%",
228 format!("{}K", used / 1024),
229 used_frac * 100.0
230 ),
231 format!("FPS {:>6.1}", fps),
232 ];
233 let colors = [
234 stat_color(cpu_u),
235 stat_color(cpu_d),
236 stat_color(used_frac),
237 fps_color(fps, target),
238 ];
239 let w = lines.iter().map(|l| l.len()).max().unwrap_or(0) as i32 * 4 + 1;
240 let x0 = 127 - w;
241 fb.rectfill(x0, 0, 127, 4 * 7, col::BLACK);
242 for (i, (line, &color)) in lines.iter().zip(colors.iter()).enumerate() {
243 fb.print(line, x0 + 1, 1 + i as i32 * 7, color);
244 }
245}
246
247const CAPTURE_FLASH_FRAMES: u32 = 6;
249
250fn capture_flash_overlay(fb: &mut Framebuffer) {
255 fb.cls(col::WHITE);
256}
257
258enum ConsoleLine {
259 Text {
260 text: String,
261 color: u8,
262 },
263 Stripe,
265}
266
267pub struct Shell {
268 pub mode: Mode,
269 last_editor: Mode,
270 loaded: Loaded,
271 vm: Option<GameVm>,
272 audio: AudioHandle,
273 fb: Framebuffer,
274 frame: u64,
275
276 lines: VecDeque<ConsoleLine>,
278 input: String,
279 cursor: usize,
280 history: Vec<String>,
281 history_pos: Option<usize>,
282 scroll_back: usize,
283
284 build: Option<BuildJob>,
286 run_after_build: bool,
287 toast: Option<(String, u8, u64)>,
290
291 wasm_mtime: Option<SystemTime>,
293
294 project_watch: Option<ProjectWatch>,
296 cart_watch: Option<CartWatch>,
297
298 pub mouse: Mouse,
300
301 code_ed: CodeEditor,
303 file_picker: FilePicker,
304 previous_file: Option<String>,
307 sprite_ed: SpriteEditor,
308 map_ed: MapEditor,
309 sfx_ed: SfxEditor,
310 music_ed: MusicEditor,
311
312 pub want_exit: bool,
313 cwd: PathBuf,
315 sdk_path: PathBuf,
316 storage_root: Option<PathBuf>,
319
320 show_stats: bool,
322 fps_frames: u32,
324 fps_t0: Instant,
325 fps_val: f32,
326
327 capture_flash: u32,
329
330 hide_cursor: bool,
335}
336
337const TEXT_COLS: usize = 31;
338const PROMPT_COL: u8 = col::WHITE;
339
340impl Shell {
341 pub fn new(audio: AudioHandle, sdk_path: PathBuf) -> Self {
342 let mut shell = Self {
343 mode: Mode::Console,
344 last_editor: Mode::Code,
345 loaded: Loaded::None,
346 vm: None,
347 audio,
348 fb: Framebuffer::new(),
349 frame: 0,
350 lines: VecDeque::new(),
351 input: String::new(),
352 cursor: 0,
353 history: Vec::new(),
354 history_pos: None,
355 scroll_back: 0,
356 build: None,
357 run_after_build: false,
358 toast: None,
359 wasm_mtime: None,
360 project_watch: None,
361 cart_watch: None,
362 mouse: Mouse::default(),
363 code_ed: CodeEditor::new(),
364 file_picker: FilePicker::new(),
365 previous_file: None,
366 sprite_ed: SpriteEditor::new(),
367 map_ed: MapEditor::new(),
368 sfx_ed: SfxEditor::new(),
369 music_ed: MusicEditor::new(),
370 want_exit: false,
371 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
372 sdk_path,
373 storage_root: None,
374 show_stats: false,
375 fps_frames: 0,
376 fps_t0: Instant::now(),
377 fps_val: 0.0,
378 capture_flash: 0,
379 hide_cursor: false,
380 };
381 shell.boot();
382 shell
383 }
384
385 pub fn set_hide_cursor(&mut self, hide: bool) {
388 self.hide_cursor = hide;
389 }
390
391 fn boot(&mut self) {
392 self.lines.push_back(ConsoleLine::Stripe);
393 self.say(&format!("Pixel8 {VERSION}"), col::WHITE);
394 self.say("A fantasy console for Rust", col::LIGHT_GREY);
395 self.say("", col::WHITE);
396 self.say("Type help for help", col::LIGHT_GREY);
397 self.say("", col::WHITE);
398 }
399
400 pub fn say(&mut self, text: &str, color: u8) {
402 if text.is_empty() {
403 self.push_line(String::new(), color);
404 return;
405 }
406 for raw in text.split('\n') {
407 let mut rest = raw;
408 loop {
409 let take = rest
410 .char_indices()
411 .nth(TEXT_COLS)
412 .map(|(i, _)| i)
413 .unwrap_or(rest.len());
414 self.push_line(rest[..take].to_string(), color);
415 rest = &rest[take..];
416 if rest.is_empty() {
417 break;
418 }
419 }
420 }
421 }
422
423 fn toast(&mut self, text: &str, color: u8, secs: f32) {
425 self.toast = Some((text.to_string(), color, self.frame + (secs * 30.0) as u64));
426 }
427
428 fn push_line(&mut self, text: String, color: u8) {
429 self.lines.push_back(ConsoleLine::Text { text, color });
430 while self.lines.len() > 300 {
431 self.lines.pop_front();
432 }
433 self.scroll_back = 0;
434 }
435
436 pub fn assets(&self) -> Option<&Assets> {
441 match &self.loaded {
442 Loaded::None => None,
443 Loaded::Project(p) => Some(&p.assets),
444 Loaded::Cart { cart, .. } => Some(&cart.assets),
445 }
446 }
447
448 pub fn assets_mut(&mut self) -> Option<&mut Assets> {
449 match &mut self.loaded {
450 Loaded::None => None,
451 Loaded::Project(p) => Some(&mut p.assets),
452 Loaded::Cart { cart, .. } => Some(&mut cart.assets),
453 }
454 }
455
456 fn code(&self) -> Option<&str> {
457 match &self.loaded {
458 Loaded::None => None,
459 Loaded::Project(p) => Some(&p.code),
460 Loaded::Cart { cart, .. } => cart.source.as_deref(),
461 }
462 }
463
464 fn set_code(&mut self, code: String) {
465 match &mut self.loaded {
466 Loaded::None => {}
467 Loaded::Project(p) => p.code = code,
468 Loaded::Cart { cart, .. } => cart.source = Some(code),
469 }
470 }
471
472 fn project_file_names(&self) -> Vec<String> {
473 match &self.loaded {
474 Loaded::Project(p) => p.file_names(),
475 _ => Vec::new(),
476 }
477 }
478
479 fn current_file_name(&self) -> String {
480 match &self.loaded {
481 Loaded::Project(p) => p.current.clone(),
482 _ => String::new(),
483 }
484 }
485
486 fn run_picker_action(&mut self, action: PickerAction) {
487 match action {
488 PickerAction::Switch(name) => self.select_file(&name),
489 PickerAction::Create(name) => self.create_file_in_project(&name),
490 }
491 }
492
493 fn open_file_picker(&mut self) {
496 let files = self.project_file_names();
497 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
498 let current = self.current_file_name();
499 self.file_picker
500 .open(&refs, ¤t, self.previous_file.as_deref());
501 }
502
503 fn select_file(&mut self, name: &str) {
507 enum R {
508 NoOp,
509 Err(String),
510 Ok(String, PathBuf, String),
511 }
512 let r = match &mut self.loaded {
513 Loaded::Project(p) if p.current != name => {
514 let previous = p.current.clone();
515 match p.save() {
516 Err(e) => R::Err(format!("could not save {previous}: {e}")),
517 Ok(()) => match p.switch_to(name) {
518 Ok(()) => R::Ok(p.code.clone(), p.dir.join("src").join(name), previous),
519 Err(e) => R::Err(format!("{e}")),
520 },
521 }
522 }
523 _ => R::NoOp,
524 };
525 match r {
526 R::Ok(code, path, previous) => {
527 self.previous_file = Some(previous);
528 self.code_ed.set_text(&code);
529 if let Some(w) = &mut self.project_watch {
530 w.code = FileWatch::new(path, code.into_bytes());
531 w.source_tree.sync();
534 }
535 }
536 R::Err(msg) => {
537 self.say(&msg, col::RED);
538 self.toast(&msg, col::RED, 3.0);
539 }
540 R::NoOp => {}
541 }
542 }
543
544 fn create_file_in_project(&mut self, name: &str) {
546 enum R {
547 Err(String),
548 Ok(String, PathBuf, String),
549 }
550 let r = match &mut self.loaded {
551 Loaded::Project(p) => {
552 let previous = p.current.clone();
553 match p.save() {
554 Err(e) => R::Err(format!("could not save {previous}: {e}")),
555 Ok(()) => match p.create_file(name) {
556 Ok(new) => R::Ok(p.code.clone(), p.dir.join("src").join(&new), previous),
557 Err(e) => R::Err(format!("{e}")),
558 },
559 }
560 }
561 _ => return,
562 };
563 match r {
564 R::Ok(code, path, previous) => {
565 self.previous_file = Some(previous);
566 self.code_ed.set_text(&code);
567 if let Some(w) = &mut self.project_watch {
568 w.code = FileWatch::new(path, code.into_bytes());
569 w.source_tree.sync();
571 }
572 }
573 R::Err(msg) => {
574 self.say(&msg, col::RED);
575 self.toast(&msg, col::RED, 3.0);
576 }
577 }
578 }
579
580 fn cart_name(&self) -> String {
581 self.assets()
582 .map(|a| a.meta.name.clone())
583 .unwrap_or_else(|| "no cart".into())
584 }
585
586 pub fn window_title(&self) -> String {
589 match &self.loaded {
590 Loaded::None => "Pixel8".into(),
591 _ => format!("{} - Pixel8", self.cart_name()),
592 }
593 }
594
595 pub fn set_button(&mut self, b: usize, down: bool) {
601 if let Some(vm) = &mut self.vm {
602 vm.state_mut().input.set_button(b, down);
603 }
604 }
605
606 pub fn key(&mut self, key: Key, mods: Mods) {
607 if self.file_picker.is_open() {
609 let files = self.project_file_names();
610 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
611 if let Some(action) = self.file_picker.key(key, mods, &refs) {
612 self.run_picker_action(action);
613 }
614 return;
615 }
616 if key == Key::ToggleStats {
618 self.show_stats = !self.show_stats;
619 return;
620 }
621 if mods.ctrl {
622 match key {
623 Key::Char('r') => {
624 self.cmd_run();
625 return;
626 }
627 Key::Char('s') => {
628 self.cmd_save_quiet();
629 return;
630 }
631 Key::Char('o') => {
632 if self.mode == Mode::Code && matches!(self.loaded, Loaded::Project(_)) {
633 self.open_file_picker();
634 }
635 return;
636 }
637 _ => {}
638 }
639 }
640
641 match self.mode {
642 Mode::Run => {
643 if key == Key::Escape {
644 self.stop_run("");
645 } else if key == Key::CaptureLabel {
646 self.capture_label();
647 }
648 }
649 Mode::Console => self.console_key(key, mods),
650 _ => self.editor_key(key, mods),
651 }
652 }
653
654 fn editor_key(&mut self, key: Key, mods: Mods) {
655 if key == Key::Escape {
656 self.mode = Mode::Console;
657 return;
658 }
659 if mods.alt {
661 let cur = EDITOR_MODES
662 .iter()
663 .position(|m| *m == self.mode)
664 .unwrap_or(0);
665 match key {
666 Key::Left => {
667 self.switch_editor(
668 EDITOR_MODES[(cur + EDITOR_MODES.len() - 1) % EDITOR_MODES.len()],
669 );
670 return;
671 }
672 Key::Right => {
673 self.switch_editor(EDITOR_MODES[(cur + 1) % EDITOR_MODES.len()]);
674 return;
675 }
676 _ => {}
677 }
678 }
679 if mods.ctrl {
682 match key {
683 Key::Char('c')
684 if matches!(
685 self.mode,
686 Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
687 ) =>
688 {
689 self.cmd_copy();
690 return;
691 }
692 Key::Char('x') if matches!(self.mode, Mode::Code | Mode::Map) => {
693 self.cmd_cut();
694 return;
695 }
696 Key::Char('v')
697 if matches!(
698 self.mode,
699 Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
700 ) =>
701 {
702 self.cmd_paste();
703 return;
704 }
705 _ => {}
706 }
707 }
708 if self.loaded_none() {
709 return;
710 }
711 let audio = self.audio.clone();
712 match self.mode {
713 Mode::Code => {
714 let mut code = self.code().unwrap_or_default().to_string();
715 self.code_ed.key(key, mods, &mut code);
716 self.set_code(code);
717 }
718 Mode::Sprite => {
719 if let Some(a) = assets_of(&mut self.loaded) {
720 self.sprite_ed.key(key, mods, a);
721 }
722 }
723 Mode::Map => {
724 if let Some(a) = assets_of(&mut self.loaded) {
725 self.map_ed.key(key, mods, a);
726 }
727 }
728 Mode::Sfx => {
729 if let Some(a) = assets_of(&mut self.loaded) {
730 self.sfx_ed.key(key, mods, a, &audio);
731 }
732 }
733 Mode::Music => {
734 if let Some(a) = assets_of(&mut self.loaded) {
735 self.music_ed.key(key, mods, a, &audio);
736 }
737 }
738 _ => {}
739 }
740 }
741
742 pub fn switch_editor(&mut self, mode: Mode) {
743 self.file_picker.close();
744 self.map_ed.cancel_drag();
747 if self.loaded_none() {
748 self.say("No cart loaded. Try: new mygame", col::RED);
749 self.mode = Mode::Console;
750 return;
751 }
752 if mode == Mode::Code {
753 let code = self.code().unwrap_or_default().to_string();
754 self.code_ed.set_text(&code);
755 }
756 self.mode = mode;
757 self.last_editor = mode;
758 }
759
760 fn loaded_none(&self) -> bool {
761 matches!(self.loaded, Loaded::None)
762 }
763
764 fn console_key(&mut self, key: Key, _mods: Mods) {
765 match key {
766 Key::Char(c) => {
767 self.input.insert(self.byte_at(self.cursor), c);
768 self.cursor += 1;
769 }
770 Key::Backspace => {
771 if self.cursor > 0 {
772 self.cursor -= 1;
773 let at = self.byte_at(self.cursor);
774 self.input.remove(at);
775 }
776 }
777 Key::Delete => {
778 if self.cursor < self.input.chars().count() {
779 let at = self.byte_at(self.cursor);
780 self.input.remove(at);
781 }
782 }
783 Key::Left => self.cursor = self.cursor.saturating_sub(1),
784 Key::Right => self.cursor = (self.cursor + 1).min(self.input.chars().count()),
785 Key::Home => self.cursor = 0,
786 Key::End => self.cursor = self.input.chars().count(),
787 Key::Up => {
788 if !self.history.is_empty() {
789 let pos = match self.history_pos {
790 None => self.history.len() - 1,
791 Some(p) => p.saturating_sub(1),
792 };
793 self.history_pos = Some(pos);
794 self.input = self.history[pos].clone();
795 self.cursor = self.input.chars().count();
796 }
797 }
798 Key::Down => {
799 if let Some(p) = self.history_pos {
800 if p + 1 < self.history.len() {
801 self.history_pos = Some(p + 1);
802 self.input = self.history[p + 1].clone();
803 } else {
804 self.history_pos = None;
805 self.input.clear();
806 }
807 self.cursor = self.input.chars().count();
808 }
809 }
810 Key::PageUp => self.scroll_back = (self.scroll_back + 5).min(self.lines.len()),
811 Key::PageDown => self.scroll_back = self.scroll_back.saturating_sub(5),
812 Key::Enter => {
813 let cmd = self.input.clone();
814 self.say(&format!("> {cmd}"), col::LIGHT_GREY);
815 if !cmd.trim().is_empty() {
816 self.history.push(cmd.clone());
817 }
818 self.history_pos = None;
819 self.input.clear();
820 self.cursor = 0;
821 self.exec(&cmd);
822 }
823 Key::Escape => {
824 if !self.loaded_none() {
825 self.switch_editor(self.last_editor);
826 }
827 }
828 Key::Tab | Key::CaptureLabel | Key::ToggleStats => {}
829 }
830 }
831
832 fn byte_at(&self, char_idx: usize) -> usize {
833 self.input
834 .char_indices()
835 .nth(char_idx)
836 .map(|(i, _)| i)
837 .unwrap_or(self.input.len())
838 }
839
840 fn exec(&mut self, cmd: &str) {
845 let parts: Vec<&str> = cmd.split_whitespace().collect();
846 let Some(&verb) = parts.first() else { return };
847 let args = &parts[1..];
848 let result = match verb.to_ascii_lowercase().as_str() {
849 "help" => {
850 self.cmd_help(args);
851 Ok(())
852 }
853 "new" => self.cmd_new(args),
854 "load" => self.cmd_load(args),
855 "reload" => self.cmd_reload(),
856 "save" => self.cmd_save(args),
857 "run" => {
858 self.cmd_run();
859 Ok(())
860 }
861 "export" => self.cmd_export(args),
862 "import" => self.cmd_import(args),
863 "import-pico8" | "importp8" => self.cmd_import_pico8(args),
864 "info" => {
865 self.cmd_info();
866 Ok(())
867 }
868 "ls" | "dir" => self.cmd_ls(),
869 "cls" => {
870 self.lines.clear();
871 Ok(())
872 }
873 "title" => self.cmd_meta(args, |a, v| a.meta.name = v),
874 "author" => self.cmd_meta(args, |a, v| a.meta.author = v),
875 "code" => {
876 self.switch_editor(Mode::Code);
877 Ok(())
878 }
879 "sprite" | "gfx" => {
880 self.switch_editor(Mode::Sprite);
881 Ok(())
882 }
883 "map" => {
884 self.switch_editor(Mode::Map);
885 Ok(())
886 }
887 "sfx" => {
888 self.switch_editor(Mode::Sfx);
889 Ok(())
890 }
891 "music" => {
892 self.switch_editor(Mode::Music);
893 Ok(())
894 }
895 "keys" => {
896 self.cmd_keys();
897 Ok(())
898 }
899 "reboot" => {
900 self.vm = None;
901 self.audio.stop_all();
902 self.loaded = Loaded::None;
903 self.lines.clear();
904 self.boot();
905 Ok(())
906 }
907 "exit" | "quit" | "shutdown" => {
908 self.want_exit = true;
909 Ok(())
910 }
911 other => Err(anyhow!("Syntax error: {other}\nType help for help")),
912 };
913 if let Err(e) = result {
914 self.say(&e.to_string(), col::RED);
915 }
916 }
917
918 fn cmd_help(&mut self, args: &[&str]) {
919 if args.first() == Some(&"keys") {
920 self.cmd_keys();
921 return;
922 }
923 for (c, d) in [
924 ("new <name>", "Create a project"),
925 ("load <dir|cart.png>", "Load a cart"),
926 ("reload", "Re-read from disk, drop edits"),
927 ("save", "Save project to disk"),
928 ("run", "Build + run (esc stops)"),
929 ("export <f.png|f.html>", "Export cart (PNG or web)"),
930 ("import <f.png> <dir>", "Cart -> project"),
931 ("import-pico8 <f> [dir]", "PICO-8 cart -> new project"),
932 (
933 "import-pico8 <f> --into ...",
934 "Append assets to loaded project",
935 ),
936 ("info", "Cart metadata"),
937 ("title/author <text>", "Set metadata"),
938 ("code/sprite/map/sfx/music", "Editors (esc)"),
939 ("ls, cls, keys, reboot, exit", ""),
940 ] {
941 self.say(c, col::WHITE);
942 if !d.is_empty() {
943 self.say(&format!(" {d}"), col::LIGHT_GREY);
944 }
945 }
946 }
947
948 fn cmd_keys(&mut self) {
949 for (k, d) in [
950 ("esc", "Console <-> editor / stop"),
951 ("ctrl+r", "Run cart"),
952 ("ctrl+s", "Save + build check"),
953 ("ctrl+z / ctrl+y", "Undo / redo (in editors)"),
954 ("alt+left/right", "Switch editor"),
955 ("arrows + z/x", "Game buttons"),
956 ("f1", "Toggle resource stats"),
957 ("f6", "Capture label (running)"),
958 ] {
959 self.say(&format!("{k:14} {d}"), col::LIGHT_GREY);
960 }
961 }
962
963 fn cmd_new(&mut self, args: &[&str]) -> Result<()> {
964 let Some(name) = args.first() else {
965 bail!("Usage: new <name>");
966 };
967 let dir = self.cwd.join(name);
968 let project = Project::create(&dir, name)?;
969 self.say(&format!("Created ./{name}"), col::GREEN);
970 self.code_ed.set_text(&project.code);
971 self.project_watch = Some(ProjectWatch::new(&project));
972 self.cart_watch = None;
973 self.loaded = Loaded::Project(project);
974 Ok(())
975 }
976
977 pub fn startup_load(&mut self, path: &str) {
979 if let Err(e) = self.cmd_load(&[path]) {
980 self.say(&e.to_string(), col::RED);
981 }
982 }
983
984 pub fn startup_run(&mut self) {
988 self.cmd_run();
989 }
990
991 fn cmd_load(&mut self, args: &[&str]) -> Result<()> {
992 let Some(path) = args.first() else {
993 bail!("Usage: load <dir|cart.png>");
994 };
995 let path = self.cwd.join(path);
996 if path.extension().is_some_and(|e| e == "png") {
997 let cart = cart::load_png(&path)?;
998 let name = cart.assets.meta.name.clone();
999 let has_src = cart.source.is_some();
1000 self.code_ed.set_text(
1001 cart.source
1002 .as_deref()
1003 .unwrap_or("// No source in this cart"),
1004 );
1005 self.project_watch = None;
1006 let cart_baseline = encode_assets(&cart.assets).unwrap_or_default();
1007 self.cart_watch = Some(CartWatch::new(path.clone(), cart_baseline));
1008 self.loaded = Loaded::Cart { cart, path };
1009 self.say(&format!("Loaded cart: {name}"), col::GREEN);
1010 if !has_src {
1011 self.say("(Playable cart, no source)", col::LIGHT_GREY);
1012 }
1013 } else {
1014 let project = Project::load(&path)?;
1015 self.code_ed.set_text(&project.code);
1016 self.say(&format!("Loaded {}", project.name), col::GREEN);
1017 self.project_watch = Some(ProjectWatch::new(&project));
1018 self.cart_watch = None;
1019 self.loaded = Loaded::Project(project);
1020 }
1021 Ok(())
1022 }
1023
1024 fn cmd_reload(&mut self) -> Result<()> {
1027 let path = match &self.loaded {
1028 Loaded::None => bail!("Nothing loaded"),
1029 Loaded::Project(p) => p.dir.clone(),
1030 Loaded::Cart { path, .. } => path.clone(),
1031 };
1032 let path_str = path.to_string_lossy().into_owned();
1033 self.cmd_load(&[&path_str])
1034 }
1035
1036 fn cmd_save(&mut self, _args: &[&str]) -> Result<()> {
1037 let message = match &mut self.loaded {
1038 Loaded::None => bail!("Nothing to save"),
1039 Loaded::Project(p) => {
1040 p.save()?;
1041 "Saved".to_string()
1042 }
1043 Loaded::Cart { cart, path } => {
1044 cart::save_png(cart, path)?;
1045 format!("Saved {}", path.display())
1046 }
1047 };
1048 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1050 w.sync(p);
1051 }
1052 if let (Loaded::Cart { cart, .. }, Some(w)) = (&self.loaded, &mut self.cart_watch) {
1054 w.mark_synced(encode_assets(&cart.assets).unwrap_or_default());
1055 }
1056 self.say(&message, col::GREEN);
1057 Ok(())
1058 }
1059
1060 fn cmd_paste(&mut self) {
1063 let text = match crate::clipboard::read_text() {
1065 Ok(t) => t,
1066 Err(_) => return self.set_editor_status("no text on clipboard".into()),
1067 };
1068 if self.mode == Mode::Code {
1069 if self.code().is_none() {
1070 return self.set_editor_status("load a project first".into());
1071 }
1072 let mut code = self.code().unwrap_or_default().to_string();
1073 self.code_ed.paste_text(&mut code, &text);
1074 self.set_code(code);
1075 return;
1076 }
1077 match pixel8_runtime::clipboard::parse(&text) {
1078 Ok(pasted) => self.apply_paste(pasted),
1079 Err(_) => self.set_editor_status("nothing to paste".into()),
1080 }
1081 }
1082
1083 fn cmd_copy(&mut self) {
1085 let blob = match self.mode {
1086 Mode::Code => {
1087 if self.code().is_none() {
1088 return self.set_editor_status("load a project first".into());
1089 }
1090 let code = self.code().unwrap_or_default().to_string();
1091 self.code_ed.copy(&code)
1092 }
1093 Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Map => {
1094 let Some(a) = assets_of(&mut self.loaded) else {
1095 return self.set_editor_status("load a project first".into());
1096 };
1097 match self.mode {
1098 Mode::Sprite => Some(self.sprite_ed.copy(a)),
1099 Mode::Sfx => Some(self.sfx_ed.copy(a)),
1100 Mode::Music => Some(self.music_ed.copy(a)),
1101 Mode::Map => self.map_ed.copy_selection(a, false),
1102 _ => unreachable!(),
1103 }
1104 }
1105 _ => None,
1106 };
1107 if let Some(text) = blob {
1108 if crate::clipboard::write_text(&text).is_err() {
1109 self.set_editor_status("clipboard unavailable".into());
1110 }
1111 }
1112 }
1113
1114 fn cmd_cut(&mut self) {
1116 let blob = match self.mode {
1117 Mode::Code => {
1118 if self.code().is_none() {
1119 return self.set_editor_status("load a project first".into());
1120 }
1121 let mut code = self.code().unwrap_or_default().to_string();
1122 let cut = self.code_ed.cut(&mut code);
1123 if cut.is_some() {
1124 self.set_code(code);
1125 }
1126 cut
1127 }
1128 Mode::Map => {
1129 let Some(a) = assets_of(&mut self.loaded) else {
1130 return self.set_editor_status("load a project first".into());
1131 };
1132 self.map_ed.copy_selection(a, true)
1133 }
1134 _ => None,
1135 };
1136 if let Some(text) = blob {
1137 if crate::clipboard::write_text(&text).is_err() {
1138 self.set_editor_status("clipboard unavailable".into());
1139 }
1140 }
1141 }
1142
1143 fn apply_paste(&mut self, pasted: Pasted) {
1146 let Some(a) = assets_of(&mut self.loaded) else {
1147 return self.set_editor_status("load a project first".into());
1148 };
1149 match self.mode {
1150 Mode::Sprite => self.sprite_ed.paste(&pasted, a),
1151 Mode::Sfx => self.sfx_ed.paste(&pasted, a),
1152 Mode::Music => self.music_ed.paste(&pasted, a),
1153 Mode::Map => self.map_ed.paste(&pasted),
1154 _ => {}
1155 }
1156 }
1157
1158 fn set_editor_status(&mut self, msg: String) {
1160 match self.mode {
1161 Mode::Sprite => self.sprite_ed.set_status(msg),
1162 Mode::Sfx => self.sfx_ed.set_status(msg),
1163 Mode::Music => self.music_ed.set_status(msg),
1164 Mode::Code => self.code_ed.set_status(msg),
1165 Mode::Map => self.map_ed.set_status(msg),
1166 _ => {}
1167 }
1168 }
1169
1170 fn cmd_save_quiet(&mut self) {
1174 if let Err(e) = self.cmd_save(&[]) {
1175 let msg = e.to_string();
1176 self.say(&msg, col::RED);
1177 self.toast(&msg, col::RED, 3.0);
1178 return;
1179 }
1180 self.toast("Saved", col::GREEN, 1.5);
1181 if self.build.is_none() {
1182 if let Loaded::Project(p) = &self.loaded {
1183 let dir = p.dir.clone();
1184 self.build = Some(spawn_build(&dir));
1185 self.run_after_build = false;
1186 }
1187 }
1188 }
1189
1190 pub fn cmd_run(&mut self) {
1191 self.audio.stop_all();
1192 self.vm = None;
1193 match &self.loaded {
1194 Loaded::None => self.say("No cart loaded", col::RED),
1195 Loaded::Cart { .. } => match self.start_vm_from_loaded() {
1196 Ok(()) => {}
1197 Err(e) => self.show_error("boot", &e.to_string()),
1198 },
1199 Loaded::Project(p) => {
1200 let dir = p.dir.clone();
1201 if self.build.is_some() {
1202 self.say("Already compiling...", col::ORANGE);
1203 self.toast("Already building...", col::ORANGE, 1.5);
1204 return;
1205 }
1206 if !self.reconcile_for_build() {
1209 self.say("Disk & editor both changed", col::ORANGE);
1210 self.say("Save or reload to resolve", col::ORANGE);
1211 self.toast("Conflict: save or reload", col::ORANGE, 3.0);
1212 return;
1213 }
1214 self.mode = Mode::Console;
1215 self.say("Compiling...", col::LIGHT_GREY);
1216 self.build = Some(spawn_build(&dir));
1217 self.run_after_build = true;
1218 }
1219 }
1220 }
1221
1222 fn reconcile_for_build(&mut self) -> bool {
1226 let Loaded::Project(_) = &self.loaded else {
1227 return true;
1228 };
1229 let (code_mem, assets_mem) = match &self.loaded {
1231 Loaded::Project(p) => (
1232 p.code.clone().into_bytes(),
1233 encode_assets(&p.assets).unwrap_or_default(),
1234 ),
1235 _ => return true,
1236 };
1237 let Some(w) = &mut self.project_watch else {
1238 return true;
1239 };
1240 let code_change = w.code.poll(&code_mem);
1242 let assets_change = w.assets.poll(&assets_mem);
1243 w.source_tree.poll();
1246 if matches!(code_change, FileChange::Conflict)
1251 || matches!(assets_change, FileChange::Conflict)
1252 || w.code.in_conflict()
1253 || w.assets.in_conflict()
1254 {
1255 return false;
1256 }
1257 if let FileChange::Adopt(bytes) = code_change {
1258 let text = String::from_utf8_lossy(&bytes).into_owned();
1259 self.code_ed.set_text(&text);
1260 if let Loaded::Project(p) = &mut self.loaded {
1261 p.code = text;
1262 }
1263 }
1264 if let FileChange::Adopt(bytes) = assets_change {
1265 match decode_assets(&bytes) {
1266 Ok(assets) => {
1267 if let Loaded::Project(p) = &mut self.loaded {
1268 p.assets = assets;
1269 }
1270 }
1271 Err(_) => self.resync_assets_watcher(),
1274 }
1275 }
1276 let needs_flush = match (&self.loaded, &self.project_watch) {
1279 (Loaded::Project(p), Some(w)) => {
1280 p.code.as_bytes() != w.code.baseline()
1281 || encode_assets(&p.assets).unwrap_or_default() != w.assets.baseline()
1282 }
1283 _ => false,
1284 };
1285 if needs_flush {
1286 if let Loaded::Project(p) = &self.loaded {
1287 let _ = p.save();
1288 }
1289 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1290 w.sync(p);
1291 }
1292 }
1293 true
1294 }
1295
1296 fn resync_assets_watcher(&mut self) {
1300 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1301 w.assets
1302 .mark_synced(encode_assets(&p.assets).unwrap_or_default());
1303 }
1304 }
1305
1306 fn start_vm_from_loaded(&mut self) -> Result<()> {
1307 self.vm = None;
1310 let (wasm, assets) = match &self.loaded {
1311 Loaded::None => bail!("No cart loaded"),
1312 Loaded::Cart { cart, .. } => (cart.wasm.clone(), cart.assets.clone()),
1313 Loaded::Project(p) => {
1314 let path = p.wasm_path();
1315 let wasm = std::fs::read(&path)
1316 .map_err(|_| anyhow!("Cart not built yet ({})", path.display()))?;
1317 self.wasm_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
1318 (wasm, p.assets.clone())
1319 }
1320 };
1321 let storage = match &self.storage_root {
1322 Some(root) => Storage::for_cart_in(root, &assets.meta.name),
1323 None => Storage::for_cart(&assets.meta.name),
1324 };
1325 let vm = GameVm::load(&wasm, &assets, self.audio.clone(), storage)?;
1326 self.vm = Some(vm);
1327 self.mode = Mode::Run;
1328 Ok(())
1329 }
1330
1331 fn cmd_export(&mut self, args: &[&str]) -> Result<()> {
1332 let mut include_source = true;
1333 let mut file = None;
1334 for a in args {
1335 if *a == "-nosrc" {
1336 include_source = false;
1337 } else {
1338 file = Some(*a);
1339 }
1340 }
1341 let file = file
1342 .map(|f| f.to_string())
1343 .unwrap_or_else(|| format!("{}.png", self.cart_name()));
1344 let out = self.cwd.join(&file);
1345 if file.ends_with(".html") {
1346 let cart = self.make_cart(false)?;
1348 self.say("Exporting for web...", col::LIGHT_GREY);
1349 let web_dir = crate::webexport::web_crate_dir(&self.sdk_path);
1350 crate::webexport::export_html(&cart, &out, &web_dir)?;
1351 } else {
1352 let cart = self.make_cart(include_source)?;
1353 cart::save_png(&cart, &out)?;
1354 }
1355 self.say(&format!("Exported {file}"), col::GREEN);
1356 Ok(())
1357 }
1358
1359 fn make_cart(&self, include_source: bool) -> Result<Cart> {
1360 match &self.loaded {
1361 Loaded::None => bail!("No cart loaded"),
1362 Loaded::Cart { cart, .. } => Ok(Cart {
1363 wasm: cart.wasm.clone(),
1364 assets: cart.assets.clone(),
1365 source: if include_source {
1366 cart.source.clone()
1367 } else {
1368 None
1369 },
1370 }),
1371 Loaded::Project(p) => {
1372 let wasm = std::fs::read(p.wasm_path())
1373 .map_err(|_| anyhow!("Cart not built yet. Type run first"))?;
1374 Ok(Cart {
1375 wasm,
1376 assets: p.assets.clone(),
1377 source: include_source.then(|| p.lib_source()),
1378 })
1379 }
1380 }
1381 }
1382
1383 fn cmd_import(&mut self, args: &[&str]) -> Result<()> {
1384 let (Some(png), Some(dir)) = (args.first(), args.get(1)) else {
1385 bail!("Usage: import <cart.png> <dir>");
1386 };
1387 let cart = cart::load_png(&self.cwd.join(png))?;
1388 let Some(source) = &cart.source else {
1389 bail!("Cart has no source (playable-only)");
1390 };
1391 let dir = self.cwd.join(dir);
1392 let mut project = Project::create(&dir, &cart.assets.meta.name)?;
1393 project.code = source.clone();
1394 project.assets = cart.assets.clone();
1395 project.save()?;
1396 self.say(&format!("Imported into {}", dir.display()), col::GREEN);
1397 self.code_ed.set_text(&project.code);
1398 self.loaded = Loaded::Project(project);
1399 if let Loaded::Project(p) = &self.loaded {
1400 self.project_watch = Some(ProjectWatch::new(p));
1401 }
1402 self.cart_watch = None;
1403 Ok(())
1404 }
1405
1406 fn cmd_import_pico8(&mut self, args: &[&str]) -> Result<()> {
1409 if args.contains(&"--into") {
1410 return self.cmd_import_pico8_into(args);
1411 }
1412 let Some(src) = args.first() else {
1413 bail!("Usage: import-pico8 <cart.p8|cart.p8.png> [dir]");
1414 };
1415 let src = self.cwd.join(src);
1416 let dir = match args.get(1) {
1418 Some(d) => self.cwd.join(d),
1419 None => self.cwd.join(pixel8_runtime::pico8::default_dir_name(&src)),
1420 };
1421 let project = pixel8_runtime::pico8::import_project(&src, &dir)?;
1422 self.say(
1423 &format!("Imported assets into {}", dir.display()),
1424 col::GREEN,
1425 );
1426 self.code_ed.set_text(&project.code);
1427 self.loaded = Loaded::Project(project);
1428 if let Loaded::Project(p) = &self.loaded {
1429 self.project_watch = Some(ProjectWatch::new(p));
1430 }
1431 self.cart_watch = None;
1432 Ok(())
1433 }
1434
1435 fn cmd_import_pico8_into(&mut self, args: &[&str]) -> Result<()> {
1438 let (mut src, mut sprites, mut sfx, mut music) = (None, None, None, None);
1439 let mut it = args.iter();
1440 while let Some(&a) = it.next() {
1441 match a {
1442 "--into" => {} "--sprites" => sprites = Some(flag_value(it.next(), "--sprites")?),
1444 "--sfx" => sfx = Some(flag_value(it.next(), "--sfx")?),
1445 "--music" => music = Some(flag_value(it.next(), "--music")?),
1446 flag if flag.starts_with("--") => bail!("unknown flag {flag}"),
1447 pos if src.is_none() => src = Some(pos),
1448 pos => bail!("unexpected argument {pos}"),
1449 }
1450 }
1451 let Some(src) = src else {
1452 bail!("Usage: import-pico8 <cart> --into [--sprites R] [--sfx R] [--music R]");
1453 };
1454 let sel = pixel8_runtime::pico8::Selection::parse(sprites, sfx, music)?;
1455
1456 let report = {
1457 let Loaded::Project(project) = &mut self.loaded else {
1458 bail!("import-pico8 --into needs a project loaded; use `new` or `load` first");
1459 };
1460 let assets = pixel8_runtime::pico8::parse_file(&self.cwd.join(src))?;
1461 let report =
1462 pixel8_runtime::pico8::append_pico8_assets(&mut project.assets, &assets, &sel)?;
1463 project.save()?;
1464 report
1465 };
1466 if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1469 w.sync(p);
1470 }
1471 for line in report.summary_lines() {
1472 self.say(&format!("Imported {line}"), col::GREEN);
1473 }
1474 for w in &report.warnings {
1475 self.say(w, col::YELLOW);
1476 }
1477 Ok(())
1478 }
1479
1480 fn cmd_info(&mut self) {
1481 match self.assets() {
1482 None => self.say("No cart loaded", col::RED),
1483 Some(a) => {
1484 let (name, author, version) = (
1485 a.meta.name.clone(),
1486 a.meta.author.clone(),
1487 a.meta.version.clone(),
1488 );
1489 let label = if a.label.is_some() {
1490 "Captured"
1491 } else {
1492 "Default"
1493 };
1494 let kind = match &self.loaded {
1495 Loaded::Project(p) => format!("Project {}", p.dir.display()),
1496 Loaded::Cart { path, .. } => format!("Cart {}", path.display()),
1497 Loaded::None => unreachable!(),
1498 };
1499 self.say(&format!("Title: {name}"), col::WHITE);
1500 self.say(&format!("Author: {author}"), col::WHITE);
1501 self.say(&format!("Version: {version}"), col::WHITE);
1502 self.say(&format!("Label: {label}"), col::LIGHT_GREY);
1503 self.say(&kind, col::LIGHT_GREY);
1504 }
1505 }
1506 }
1507
1508 fn cmd_ls(&mut self) -> Result<()> {
1509 let mut entries: Vec<_> = std::fs::read_dir(&self.cwd)?
1510 .filter_map(|e| e.ok())
1511 .map(|e| {
1512 let name = e.file_name().to_string_lossy().into_owned();
1513 let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1514 (name, is_dir)
1515 })
1516 .filter(|(n, _)| !n.starts_with('.'))
1517 .collect();
1518 entries.sort();
1519 for (name, is_dir) in entries.into_iter().take(40) {
1520 if is_dir {
1521 self.say(&format!("{name}/"), col::BLUE);
1522 } else if name.ends_with(".png") {
1523 self.say(&name, col::PINK);
1524 } else {
1525 self.say(&name, col::LIGHT_GREY);
1526 }
1527 }
1528 Ok(())
1529 }
1530
1531 fn cmd_meta(&mut self, args: &[&str], set: impl FnOnce(&mut Assets, String)) -> Result<()> {
1532 if args.is_empty() {
1533 bail!("Missing text");
1534 }
1535 let value = args.join(" ");
1536 match self.assets_mut() {
1537 None => bail!("No cart loaded"),
1538 Some(a) => {
1539 set(a, value);
1540 self.say("OK", col::GREEN);
1541 Ok(())
1542 }
1543 }
1544 }
1545
1546 fn capture_label(&mut self) {
1547 let Some(vm) = &self.vm else { return };
1548 let pixels = vm.state().fb.pixels().to_vec();
1549 if let Some(a) = self.assets_mut() {
1550 a.label = Some(pixels);
1551 }
1552 self.capture_flash = CAPTURE_FLASH_FRAMES;
1554 self.say("Label captured", col::GREEN);
1555 }
1556
1557 fn stop_run(&mut self, message: &str) {
1558 self.vm = None;
1559 self.audio.stop_all();
1560 self.mode = Mode::Console;
1561 if !message.is_empty() {
1562 self.say(message, col::RED);
1563 }
1564 }
1565
1566 fn show_error(&mut self, phase: &str, message: &str) {
1567 self.stop_run("");
1568 self.say("", col::WHITE);
1569 self.say(&format!("** Error in {phase} **"), col::RED);
1570 for line in message.lines().take(12) {
1571 self.say(line, col::ORANGE);
1572 }
1573 }
1574
1575 fn runtime_error(&mut self, e: RuntimeError) {
1576 self.show_error(e.phase, &e.message);
1577 }
1578
1579 pub fn tick_fps(&self) -> u32 {
1587 match (self.mode, &self.vm) {
1588 (Mode::Run, Some(vm)) => vm.fps(),
1589 _ => UI_FPS,
1590 }
1591 }
1592
1593 pub fn tick(&mut self) {
1594 self.frame += 1;
1595
1596 if let Some(job) = &self.build {
1598 if let Some(result) = job.poll() {
1599 self.build = None;
1600 if result.success {
1601 let msg = format!("Build ok ({:.1}s)", result.duration.as_secs_f32());
1602 self.say(&msg, col::GREEN);
1603 self.toast(&msg, col::GREEN, 2.0);
1604 for w in &result.warnings {
1605 self.say(w, col::ORANGE);
1606 }
1607 if self.run_after_build {
1608 self.run_after_build = false;
1609 if let Err(e) = self.start_vm_from_loaded() {
1610 self.show_error("boot", &e.to_string());
1611 }
1612 }
1613 } else {
1614 self.run_after_build = false;
1615 self.stop_run("");
1622 let n = result
1623 .errors
1624 .iter()
1625 .filter(|l| l.starts_with("error"))
1626 .count();
1627 self.say(&format!("Build failed ({n} errors)"), col::RED);
1628 for line in &result.errors {
1629 let color = if line.starts_with("error") {
1630 col::RED
1631 } else {
1632 col::ORANGE
1633 };
1634 self.say(line, color);
1635 }
1636 }
1637 }
1638 }
1639
1640 self.poll_project_watch();
1641 self.poll_cart_watch();
1642 self.check_hot_reload();
1643
1644 match self.mode {
1645 Mode::Run => {
1646 if self.vm.is_some() {
1647 let fps_val = self.fps_val;
1648 let (logs, result) = {
1649 let vm = self.vm.as_mut().unwrap();
1650 vm.state_mut().set_measured_fps(fps_val);
1651 let logs = std::mem::take(&mut vm.state_mut().logs);
1652 let r = vm.call_update().and_then(|()| vm.call_draw());
1653 (logs, r)
1654 };
1655 for l in logs {
1656 self.say(&l, col::LIGHT_GREY);
1657 }
1658 if let Err(e) = result {
1659 self.runtime_error(e);
1660 }
1661 } else {
1662 self.mode = Mode::Console;
1663 }
1664 }
1665 Mode::Console => {}
1666 _ => {
1667 if !self.file_picker.is_open() {
1669 if let Some(target) = ui::tab_bar_click(&self.mouse) {
1670 self.switch_editor(EDITOR_MODES[target]);
1671 }
1672 }
1673 let mouse = self.mouse;
1674 let audio = self.audio.clone();
1675 match self.mode {
1676 Mode::Code => {
1677 if self.file_picker.is_open() {
1678 let files = self.project_file_names();
1679 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
1680 if let Some(action) = self.file_picker.tick(&mouse, &refs) {
1681 self.run_picker_action(action);
1682 }
1683 } else if !self.loaded_none() {
1684 if ui::filename_clicked(&mouse, &self.current_file_name())
1686 && matches!(self.loaded, Loaded::Project(_))
1687 {
1688 self.open_file_picker();
1689 } else {
1690 let code = self.code().unwrap_or_default().to_string();
1691 self.code_ed.tick(&mouse, &code);
1692 }
1693 }
1694 }
1695 Mode::Sprite => {
1696 if let Some(a) = assets_of(&mut self.loaded) {
1697 self.sprite_ed.tick(&mouse, a);
1698 }
1699 }
1700 Mode::Map => {
1701 if let Some(a) = assets_of(&mut self.loaded) {
1702 self.map_ed.tick(&mouse, a);
1703 }
1704 }
1705 Mode::Sfx => {
1706 if let Some(a) = assets_of(&mut self.loaded) {
1707 self.sfx_ed.tick(&mouse, a, &audio);
1708 }
1709 }
1710 Mode::Music => {
1711 if let Some(a) = assets_of(&mut self.loaded) {
1712 self.music_ed.tick(&mouse, a, &audio);
1713 }
1714 if let Some(n) = self.music_ed.take_edit_request() {
1716 self.sfx_ed.select(n);
1717 self.switch_editor(Mode::Sfx);
1718 }
1719 }
1720 _ => {}
1721 }
1722 }
1723 }
1724 self.mouse.end_frame();
1725 }
1726
1727 fn check_hot_reload(&mut self) {
1728 if !self.frame.is_multiple_of(30) {
1729 return;
1730 }
1731 let Loaded::Project(p) = &self.loaded else {
1732 return;
1733 };
1734 let Ok(meta) = std::fs::metadata(p.wasm_path()) else {
1735 return;
1736 };
1737 let Ok(mtime) = meta.modified() else {
1738 return;
1739 };
1740 match self.wasm_mtime {
1741 Some(prev) if mtime > prev => {
1742 self.wasm_mtime = Some(mtime);
1743 if self.mode == Mode::Run {
1746 match self.start_vm_from_loaded() {
1747 Ok(()) => self.say("Hot reloaded", col::GREEN),
1748 Err(e) => self.show_error("reload", &e.to_string()),
1749 }
1750 }
1751 }
1752 Some(_) => {}
1753 None => self.wasm_mtime = Some(mtime),
1754 }
1755 }
1756
1757 fn poll_project_watch(&mut self) {
1761 if !self.frame.is_multiple_of(30) || self.build.is_some() {
1762 return;
1763 }
1764 let (code_mem, assets_mem) = match &self.loaded {
1765 Loaded::Project(p) => (
1766 p.code.clone().into_bytes(),
1767 encode_assets(&p.assets).unwrap_or_default(),
1768 ),
1769 _ => return,
1770 };
1771 let Some(w) = &mut self.project_watch else {
1772 return;
1773 };
1774 let assets_change = w.assets.poll(&assets_mem);
1775 let code_change = w.code.poll(&code_mem);
1776 let source_changed = w.source_tree.poll();
1777 let code_conflicted = w.code.in_conflict();
1778
1779 match assets_change {
1781 FileChange::Adopt(bytes) => match decode_assets(&bytes) {
1782 Ok(assets) => {
1783 if let Loaded::Project(p) = &mut self.loaded {
1784 p.assets = assets;
1785 }
1786 self.say("Assets reloaded from disk", col::GREEN);
1787 if self.mode == Mode::Run {
1788 if let Err(e) = self.start_vm_from_loaded() {
1789 self.show_error("reload", &e.to_string());
1790 }
1791 }
1792 }
1793 Err(_) => {
1795 self.resync_assets_watcher();
1796 self.say("assets.pixel8.json on disk is unreadable", col::ORANGE);
1797 }
1798 },
1799 FileChange::Conflict => {
1800 self.say("assets.pixel8.json changed on disk;", col::ORANGE);
1801 self.say("You have unsaved edits", col::ORANGE);
1802 }
1803 FileChange::None => {}
1804 }
1805
1806 match code_change {
1808 FileChange::Adopt(bytes) => {
1809 let text = String::from_utf8_lossy(&bytes).into_owned();
1810 self.code_ed.set_text(&text);
1811 if let Loaded::Project(p) = &mut self.loaded {
1812 p.code = text;
1813 }
1814 }
1815 FileChange::Conflict => {
1816 self.say(
1817 &format!("src/{} changed on disk;", self.current_file_name()),
1818 col::ORANGE,
1819 );
1820 self.say("Save or reload to resolve", col::ORANGE);
1821 }
1822 FileChange::None => {}
1823 }
1824
1825 if source_changed && !code_conflicted {
1829 let dir = match &self.loaded {
1830 Loaded::Project(p) => p.dir.clone(),
1831 _ => return,
1832 };
1833 self.say("Source changed, rebuilding...", col::LIGHT_GREY);
1834 self.toast("Rebuilding...", col::LIGHT_GREY, 1.5);
1835 self.build = Some(spawn_build(&dir));
1836 self.run_after_build = matches!(self.mode, Mode::Run | Mode::Console);
1839 }
1840 }
1841
1842 fn poll_cart_watch(&mut self) {
1845 if !self.frame.is_multiple_of(30) {
1846 return;
1847 }
1848 let (path, in_memory, baseline) = match (&self.loaded, &mut self.cart_watch) {
1849 (Loaded::Cart { cart, .. }, Some(w)) => {
1850 if w.advanced().is_none() {
1851 return;
1852 }
1853 (
1854 w.path.clone(),
1855 encode_assets(&cart.assets).unwrap_or_default(),
1856 w.baseline.clone(),
1857 )
1858 }
1859 _ => return,
1860 };
1861 let new_cart = match cart::load_png(&path) {
1862 Ok(c) => c,
1863 Err(e) => {
1864 self.show_error("reload", &e.to_string());
1865 return;
1866 }
1867 };
1868 let disk = encode_assets(&new_cart.assets).unwrap_or_default();
1869 match crate::watch::reconcile(&baseline, &disk, &in_memory) {
1870 crate::watch::Reconcile::Unchanged => {}
1871 crate::watch::Reconcile::Adopt(_) => {
1872 self.code_ed.set_text(
1873 new_cart
1874 .source
1875 .as_deref()
1876 .unwrap_or("// No source in this cart"),
1877 );
1878 if let Some(w) = &mut self.cart_watch {
1879 w.baseline = disk;
1880 }
1881 self.loaded = Loaded::Cart {
1882 cart: new_cart,
1883 path,
1884 };
1885 self.say("Cart reloaded from disk", col::GREEN);
1886 if self.mode == Mode::Run {
1887 if let Err(e) = self.start_vm_from_loaded() {
1888 self.show_error("reload", &e.to_string());
1889 }
1890 }
1891 }
1892 crate::watch::Reconcile::Conflict => {
1893 self.say("Cart changed on disk;", col::ORANGE);
1896 self.say("You have unsaved edits", col::ORANGE);
1897 }
1898 }
1899 }
1900
1901 fn meter_fps(&mut self) {
1910 self.fps_frames += 1;
1911 let elapsed = self.fps_t0.elapsed();
1912 if elapsed >= Duration::from_millis(500) {
1913 self.fps_val = self.fps_frames as f32 / elapsed.as_secs_f32();
1914 self.fps_frames = 0;
1915 self.fps_t0 = Instant::now();
1916 }
1917 }
1918
1919 pub fn draw(&mut self) -> &Framebuffer {
1920 self.meter_fps();
1921 match self.mode {
1922 Mode::Run => {
1923 if self.show_stats {
1924 let fps = self.fps_val;
1925 if let Some(vm) = self.vm.as_mut() {
1926 let target = vm.fps();
1927 let cpu_u = vm.cpu_update();
1928 let cpu_d = vm.cpu_draw();
1929 let used = vm.mem_used_bytes();
1930 stats_overlay(&mut vm.state_mut().fb, cpu_u, cpu_d, used, fps, target);
1931 }
1932 }
1933 if self.capture_flash > 0 {
1934 if let Some(vm) = self.vm.as_mut() {
1935 capture_flash_overlay(&mut vm.state_mut().fb);
1936 }
1937 self.capture_flash -= 1;
1938 }
1939 if let Some(vm) = &self.vm {
1940 return &vm.state().fb;
1941 }
1942 &self.fb
1943 }
1944 Mode::Console => {
1945 self.draw_console();
1946 &self.fb
1947 }
1948 _ => {
1949 self.fb.reset_state();
1950 self.fb.cls(col::DARK_GREY);
1951 let mouse = self.mouse;
1952 match self.mode {
1953 Mode::Code => {
1954 let code = self.code().unwrap_or_default().to_string();
1955 self.code_ed.draw(&mut self.fb, &code);
1956 if self.file_picker.is_open() {
1957 let files = self.project_file_names();
1958 let refs: Vec<&str> = files.iter().map(String::as_str).collect();
1959 let current = self.current_file_name();
1960 self.file_picker.draw(&mut self.fb, &refs, ¤t);
1961 }
1962 }
1963 Mode::Sprite => {
1964 if let Some(a) = assets_ref(&self.loaded) {
1965 self.sprite_ed.draw(&mut self.fb, a);
1966 }
1967 }
1968 Mode::Map => {
1969 if let Some(a) = assets_ref(&self.loaded) {
1970 self.map_ed.draw(&mut self.fb, a);
1971 }
1972 }
1973 Mode::Sfx => {
1974 if let Some(a) = assets_ref(&self.loaded) {
1975 self.sfx_ed.draw(&mut self.fb, a, &self.audio);
1976 }
1977 }
1978 Mode::Music => {
1979 if let Some(a) = assets_ref(&self.loaded) {
1980 self.music_ed.draw(&mut self.fb, a, &self.audio);
1981 }
1982 }
1983 _ => {}
1984 }
1985 ui::draw_tab_bar(&mut self.fb, self.mode);
1986 match self.mode {
1989 Mode::Code => {
1990 let name = self.current_file_name();
1991 ui::code_filename(&mut self.fb, &name);
1992 }
1993 Mode::Sfx => ui::mode_buttons(&mut self.fb, self.sfx_ed.is_pitch()),
1994 Mode::Sprite => ui::view_buttons(&mut self.fb, self.sprite_ed.is_fullscreen()),
1995 Mode::Map => ui::view_buttons(&mut self.fb, self.map_ed.is_fullscreen()),
1996 Mode::Music => {
1997 ui::mode_buttons(&mut self.fb, !self.music_ed.is_grid());
1998 if self.music_ed.is_grid() {
1999 ui::pat_sfx_toggle(&mut self.fb, self.music_ed.grid_sfx());
2000 }
2001 }
2002 _ => {}
2003 }
2004 self.draw_toast();
2005 if !self.file_picker.is_open() {
2009 if let Some(i) = ui::tab_bar_hover(&mouse) {
2010 ui::status_bar(&mut self.fb, ui::tab_name(i));
2011 }
2012 }
2013 if !self.hide_cursor {
2014 ui::draw_cursor(&mut self.fb, &mouse);
2015 }
2016 &self.fb
2017 }
2018 }
2019 }
2020
2021 fn draw_toast(&mut self) {
2024 let msg = if self.build.is_some() {
2025 let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
2026 Some((format!("Building{dots}"), col::ORANGE))
2027 } else {
2028 match &self.toast {
2029 Some((text, color, expires)) if self.frame < *expires => {
2030 Some((text.clone(), *color))
2031 }
2032 _ => {
2033 self.toast = None;
2034 None
2035 }
2036 }
2037 };
2038 if let Some((text, color)) = msg {
2039 self.fb.rectfill(0, 120, 127, 127, col::BLACK);
2040 self.fb.print(&text, 2, 121, color);
2041 }
2042 }
2043
2044 fn draw_console(&mut self) {
2045 self.fb.reset_state();
2046 self.fb.cls(col::BLACK);
2047 let rows = ((120 - 2) / font::GLYPH_H) as usize;
2050
2051 let total = self.lines.len();
2053 let end = total.saturating_sub(self.scroll_back);
2054 let start = end.saturating_sub(rows);
2055 let mut y = 2;
2056 for line in self.lines.iter().skip(start).take(end - start) {
2057 match line {
2058 ConsoleLine::Text { text, color } => {
2059 self.fb.print(text, 2, y, *color);
2060 }
2061 ConsoleLine::Stripe => {
2062 for (i, c) in [8u8, 9, 10, 11, 12, 13, 14, 15].iter().enumerate() {
2063 self.fb
2064 .rectfill(2 + i as i32 * 6, y, 2 + i as i32 * 6 + 4, y + 3, *c);
2065 }
2066 }
2067 }
2068 y += font::GLYPH_H;
2069 }
2070
2071 if self.build.is_some() {
2073 let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
2074 self.fb
2075 .print(&format!("Compiling{dots}"), 2, y, col::ORANGE);
2076 return;
2077 }
2078 let prompt = format!("> {}", self.input);
2079 self.fb.print(&prompt, 2, y, PROMPT_COL);
2080 if (self.frame / 8).is_multiple_of(2) {
2081 let cx = 2 + (2 + self.cursor as i32) * 4;
2082 self.fb
2083 .rectfill(cx, y, cx + 3, y + font::GLYPH_H - 2, col::RED);
2084 }
2085 }
2086}
2087
2088fn flag_value<'a>(next: Option<&'a &'a str>, flag: &str) -> Result<&'a str> {
2091 match next {
2092 Some(&v) if !v.starts_with("--") => Ok(v),
2093 _ => bail!("{flag} needs a value"),
2094 }
2095}
2096
2097#[cfg(test)]
2098mod tests {
2099 use super::*;
2100 use std::path::Path;
2101
2102 #[test]
2103 fn stats_overlay_draws_top_right_panel() {
2104 let text_pixels = |fb: &Framebuffer, xr: std::ops::Range<i32>| {
2105 let mut n = 0;
2106 for y in 0..28 {
2107 for x in xr.clone() {
2108 if fb.pget(x, y) != col::BLACK {
2109 n += 1;
2110 }
2111 }
2112 }
2113 n
2114 };
2115 let mut fb = Framebuffer::new();
2116 stats_overlay(&mut fb, 0.342, 0.51, 32768, 30.0, 30);
2117 assert!(
2121 text_pixels(&fb, 78..86) > 0,
2122 "decimal CPU widened the panel left"
2123 );
2124 assert_eq!(
2125 text_pixels(&fb, 0..60),
2126 0,
2127 "overlay leaves the top-left untouched"
2128 );
2129 }
2130
2131 fn test_shell() -> Shell {
2132 let sdk = Path::new(env!("CARGO_MANIFEST_DIR")).join("../pixel8");
2133 let mut shell = Shell::new(AudioHandle::dummy(), sdk);
2134 shell.storage_root =
2137 Some(std::env::temp_dir().join(format!("pixel8_test_storage_{}", std::process::id())));
2138 shell
2139 }
2140
2141 #[test]
2146 fn restart_saves_storage_before_the_new_vm_loads() {
2147 use pixel8_runtime::{assets::Assets, cart::Cart, storage::Storage};
2148 const COUNTER_CART: &str = r#"
2149 (module
2150 (import "pixel8" "storage_get" (func $sget (param i32 i32 i32 i32) (result i32)))
2151 (import "pixel8" "storage_set" (func $sset (param i32 i32 i32 i32) (result i32)))
2152 (memory (export "memory") 1)
2153 (data (i32.const 0) "n")
2154 (func (export "pixel8_init")
2155 (local $n i32)
2156 (if (i32.eq (call $sget (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))
2157 (i32.const 1))
2158 (then (local.set $n (i32.sub (i32.load8_u (i32.const 8)) (i32.const 48)))))
2159 (i32.store8 (i32.const 8) (i32.add (i32.const 49) (local.get $n)))
2160 (drop (call $sset (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))))
2161 (func (export "pixel8_update"))
2162 (func (export "pixel8_draw")))
2163 "#;
2164 let root = std::env::temp_dir().join(format!("pixel8_restart_{}", std::process::id()));
2165 let _ = std::fs::remove_dir_all(&root);
2166 let mut shell = test_shell();
2167 shell.storage_root = Some(root.clone());
2168 shell.loaded = Loaded::Cart {
2169 cart: Cart {
2170 wasm: wat::parse_str(COUNTER_CART).unwrap(),
2171 assets: Assets::default(),
2172 source: None,
2173 },
2174 path: PathBuf::from("counter.png"),
2175 };
2176 shell.start_vm_from_loaded().expect("first start"); shell.start_vm_from_loaded().expect("restart"); shell.vm = None; let s = Storage::for_cart_in(&root, &Assets::default().meta.name);
2180 assert_eq!(
2181 s.get_json("n").as_deref(),
2182 Some("2"),
2183 "the restart must persist the first run's write before the second reads"
2184 );
2185 std::fs::remove_dir_all(&root).unwrap();
2186 }
2187
2188 fn point_cart_at_local_sdk(project_dir: &Path) {
2191 let sdk = Path::new(env!("CARGO_MANIFEST_DIR"))
2192 .join("../pixel8")
2193 .canonicalize()
2194 .unwrap();
2195 let manifest_path = project_dir.join("Cargo.toml");
2196 let manifest = std::fs::read_to_string(&manifest_path).unwrap();
2197 let dep = format!(
2201 "version = \"{}.{}\"",
2202 env!("CARGO_PKG_VERSION_MAJOR"),
2203 env!("CARGO_PKG_VERSION_MINOR"),
2204 );
2205 let patched = manifest.replace(&dep, &format!("path = {:?}", sdk.display().to_string()));
2206 assert_ne!(patched, manifest, "no `{dep}` to redirect in:\n{manifest}");
2207 std::fs::write(&manifest_path, patched).unwrap();
2208 }
2209
2210 #[test]
2211 fn window_title_reflects_loaded_cart() {
2212 let dir = std::env::temp_dir().join(format!("pixel8_title_{}", std::process::id()));
2213 let _ = std::fs::remove_dir_all(&dir);
2214 let mut shell = test_shell();
2215 assert_eq!(shell.window_title(), "Pixel8");
2216
2217 let project_dir = dir.join("game");
2218 Project::create(&project_dir, "game").unwrap();
2219 shell
2220 .cmd_load(&[project_dir.to_str().unwrap()])
2221 .expect("load project");
2222 assert_eq!(shell.window_title(), "game - Pixel8");
2223 std::fs::remove_dir_all(&dir).unwrap();
2224 }
2225
2226 #[test]
2229 fn ctrl_s_saves_and_builds_with_feedback() {
2230 let dir = std::env::temp_dir().join(format!("pixel8_shell_test_{}", std::process::id()));
2231 let _ = std::fs::remove_dir_all(&dir);
2232 let mut shell = test_shell();
2233 let project_dir = dir.join("game");
2234 Project::create(&project_dir, "game").unwrap();
2235 point_cart_at_local_sdk(&project_dir);
2236
2237 shell
2238 .cmd_load(&[project_dir.to_str().unwrap()])
2239 .expect("load project");
2240 shell.switch_editor(Mode::Code);
2241
2242 for c in "//x".chars() {
2244 shell.key(Key::Char(c), Mods::default());
2245 }
2246 shell.key(Key::Enter, Mods::default());
2247 shell.key(
2248 Key::Char('s'),
2249 Mods {
2250 ctrl: true,
2251 ..Default::default()
2252 },
2253 );
2254
2255 let code = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
2257 assert!(code.starts_with("//x\n"), "edit was saved");
2258 assert_eq!(shell.toast.as_ref().unwrap().0, "Saved");
2259 assert!(shell.build.is_some(), "background build spawned");
2260
2261 shell.draw();
2263 assert_eq!(shell.fb.pget(0, 120), col::BLACK, "toast bar drawn");
2264
2265 for _ in 0..(120 * 30) {
2267 shell.tick();
2268 if shell.build.is_none() {
2269 break;
2270 }
2271 std::thread::sleep(std::time::Duration::from_millis(33));
2272 }
2273 assert!(shell.build.is_none(), "build finished in time");
2274 let (text, color, _) = shell.toast.as_ref().unwrap();
2275 assert!(text.starts_with("Build ok"), "got: {text}");
2276 assert_eq!(*color, col::GREEN);
2277 assert!(
2278 shell.mode == Mode::Code,
2279 "stays in the editor; no mode switch"
2280 );
2281 std::fs::remove_dir_all(&dir).unwrap();
2282 }
2283
2284 #[test]
2286 fn save_build_failure_is_reported() {
2287 let dir = std::env::temp_dir().join(format!("pixel8_shell_fail_{}", std::process::id()));
2288 let _ = std::fs::remove_dir_all(&dir);
2289 let mut shell = test_shell();
2290 let project_dir = dir.join("game");
2291 let mut project = Project::create(&project_dir, "game").unwrap();
2292 project.code = "fn broken( {".into();
2293 project.save().unwrap();
2294
2295 shell
2296 .cmd_load(&[project_dir.to_str().unwrap()])
2297 .expect("load project");
2298 shell.switch_editor(Mode::Code);
2299 shell.key(
2300 Key::Char('s'),
2301 Mods {
2302 ctrl: true,
2303 ..Default::default()
2304 },
2305 );
2306 assert!(shell.build.is_some());
2307 for _ in 0..(120 * 30) {
2308 shell.tick();
2309 if shell.build.is_none() {
2310 break;
2311 }
2312 std::thread::sleep(std::time::Duration::from_millis(33));
2313 }
2314 assert_eq!(
2317 shell.mode,
2318 Mode::Console,
2319 "expected console mode after build failure"
2320 );
2321 let summary = shell.lines.iter().find_map(|l| match l {
2322 ConsoleLine::Text { text, color } if text.starts_with("Build failed") => {
2323 Some((text.clone(), *color))
2324 }
2325 _ => None,
2326 });
2327 let (text, color) = summary.expect("Build failed summary not found in console lines");
2328 assert!(text.starts_with("Build failed"), "got: {text}");
2329 assert_eq!(color, col::RED);
2330 std::fs::remove_dir_all(&dir).unwrap();
2331 }
2332
2333 #[test]
2336 fn run_does_not_clobber_external_edits() {
2337 let dir = std::env::temp_dir().join(format!("pixel8_run_noclobber_{}", std::process::id()));
2338 let _ = std::fs::remove_dir_all(&dir);
2339 let mut shell = test_shell();
2340 let project_dir = dir.join("game");
2341 Project::create(&project_dir, "game").unwrap();
2342 shell
2343 .cmd_load(&[project_dir.to_str().unwrap()])
2344 .expect("load");
2345
2346 let lib = project_dir.join("src/lib.rs");
2348 let original = std::fs::read_to_string(&lib).unwrap();
2349 let edited = format!("// EXTERNAL EDIT\n{original}");
2350 std::fs::write(&lib, &edited).unwrap();
2352 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2353 std::fs::OpenOptions::new()
2354 .write(true)
2355 .open(&lib)
2356 .unwrap()
2357 .set_modified(later)
2358 .unwrap();
2359
2360 shell.cmd_run();
2361
2362 let after = std::fs::read_to_string(&lib).unwrap();
2365 assert!(
2366 after.starts_with("// EXTERNAL EDIT\n"),
2367 "external edit survived run; got:\n{after}"
2368 );
2369
2370 for _ in 0..(120 * 30) {
2372 shell.tick();
2373 if shell.build.is_none() {
2374 break;
2375 }
2376 std::thread::sleep(std::time::Duration::from_millis(33));
2377 }
2378 std::fs::remove_dir_all(&dir).unwrap();
2379 }
2380
2381 #[test]
2385 fn startup_run_arms_the_deferred_run() {
2386 let dir = std::env::temp_dir().join(format!("pixel8_startup_run_{}", std::process::id()));
2387 let _ = std::fs::remove_dir_all(&dir);
2388 let mut shell = test_shell();
2389 let project_dir = dir.join("game");
2390 Project::create(&project_dir, "game").unwrap();
2391
2392 shell.startup_load(project_dir.to_str().unwrap());
2393 assert_eq!(shell.mode, Mode::Console, "loaded, still at the console");
2394
2395 shell.startup_run();
2396 assert!(shell.build.is_some(), "startup_run spawns the build");
2397 assert!(
2398 shell.run_after_build,
2399 "and arms the run to start once it is built"
2400 );
2401
2402 std::fs::remove_dir_all(&dir).unwrap();
2404 }
2405
2406 #[test]
2409 fn new_arms_the_project_watcher() {
2410 let dir = std::env::temp_dir().join(format!("pixel8_new_watch_{}", std::process::id()));
2411 let _ = std::fs::remove_dir_all(&dir);
2412 let mut shell = test_shell();
2413 shell.cwd = dir.clone();
2414 shell.cmd_new(&["game"]).expect("new");
2415 assert!(shell.project_watch.is_some(), "new should arm the watcher");
2416 std::fs::remove_dir_all(&dir).unwrap();
2417 }
2418
2419 #[test]
2422 fn external_edit_auto_builds_and_runs() {
2423 let dir = std::env::temp_dir().join(format!("pixel8_autobuild_{}", std::process::id()));
2424 let _ = std::fs::remove_dir_all(&dir);
2425 let mut shell = test_shell();
2426 let project_dir = dir.join("game");
2427 Project::create(&project_dir, "game").unwrap();
2428 point_cart_at_local_sdk(&project_dir);
2429
2430 shell
2431 .cmd_load(&[project_dir.to_str().unwrap()])
2432 .expect("load");
2433 assert_eq!(shell.mode, Mode::Console);
2434
2435 let lib = project_dir.join("src/lib.rs");
2437 let original = std::fs::read_to_string(&lib).unwrap();
2438 std::fs::write(&lib, format!("// auto\n{original}")).unwrap();
2439 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2440 std::fs::OpenOptions::new()
2441 .write(true)
2442 .open(&lib)
2443 .unwrap()
2444 .set_modified(later)
2445 .unwrap();
2446
2447 let mut entered_run = false;
2449 for _ in 0..(180 * 30) {
2450 shell.tick();
2451 if shell.mode == Mode::Run {
2452 entered_run = true;
2453 break;
2454 }
2455 std::thread::sleep(std::time::Duration::from_millis(33));
2456 }
2457 assert!(entered_run, "external edit should auto-build and run");
2458 std::fs::remove_dir_all(&dir).unwrap();
2459 }
2460
2461 #[test]
2464 fn external_png_change_reloads_cart() {
2465 use pixel8_runtime::cart::{self, Cart};
2466 let dir = std::env::temp_dir().join(format!("pixel8_pngreload_{}", std::process::id()));
2467 let _ = std::fs::remove_dir_all(&dir);
2468 std::fs::create_dir_all(&dir).unwrap();
2469 let mut shell = test_shell();
2470
2471 let project_dir = dir.join("game");
2472 let project = Project::create(&project_dir, "game").unwrap();
2473 let png = dir.join("game.png");
2474
2475 let mut cart = Cart {
2478 wasm: b"\0asm\x01\0\0\0".to_vec(),
2479 assets: project.assets.clone(),
2480 source: Some("// v1".into()),
2481 };
2482 cart::save_png(&cart, &png).unwrap();
2483 shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
2484 let before = shell.cart_name();
2485
2486 cart.assets.meta.name = "renamed".into();
2488 cart::save_png(&cart, &png).unwrap();
2489 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2490 std::fs::OpenOptions::new()
2491 .write(true)
2492 .open(&png)
2493 .unwrap()
2494 .set_modified(later)
2495 .unwrap();
2496
2497 for _ in 0..(60 * 30) {
2498 shell.tick();
2499 if shell.cart_name() != before {
2500 break;
2501 }
2502 std::thread::sleep(std::time::Duration::from_millis(33));
2503 }
2504 assert_eq!(shell.cart_name(), "renamed", "external PNG change adopted");
2505 std::fs::remove_dir_all(&dir).unwrap();
2506 }
2507
2508 #[test]
2511 fn saving_png_does_not_self_conflict() {
2512 use pixel8_runtime::{
2513 cart::{self, Cart},
2514 project::encode_assets,
2515 };
2516 let dir = std::env::temp_dir().join(format!("pixel8_pngsave_{}", std::process::id()));
2517 let _ = std::fs::remove_dir_all(&dir);
2518 std::fs::create_dir_all(&dir).unwrap();
2519 let mut shell = test_shell();
2520 let project = Project::create(&dir.join("game"), "game").unwrap();
2521 let png = dir.join("game.png");
2522 let cart = Cart {
2523 wasm: b"\0asm\x01\0\0\0".to_vec(),
2524 assets: project.assets.clone(),
2525 source: Some("// v1".into()),
2526 };
2527 cart::save_png(&cart, &png).unwrap();
2528 shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
2529
2530 if let Some(a) = shell.assets_mut() {
2532 a.meta.name = "edited".into();
2533 }
2534 shell.cmd_save(&[]).expect("save");
2535
2536 let in_mem = encode_assets(shell.assets().unwrap()).unwrap_or_default();
2538 assert_eq!(
2539 shell.cart_watch.as_ref().unwrap().baseline,
2540 in_mem,
2541 "save re-baselined the cart watcher"
2542 );
2543
2544 for _ in 0..(2 * 30) {
2546 shell.tick();
2547 }
2548 assert_eq!(shell.cart_name(), "edited");
2549 std::fs::remove_dir_all(&dir).unwrap();
2550 }
2551
2552 #[test]
2555 fn reload_takes_disk_version() {
2556 let dir = std::env::temp_dir().join(format!("pixel8_reload_{}", std::process::id()));
2557 let _ = std::fs::remove_dir_all(&dir);
2558 let mut shell = test_shell();
2559 let project_dir = dir.join("game");
2560 Project::create(&project_dir, "game").unwrap();
2561 shell
2562 .cmd_load(&[project_dir.to_str().unwrap()])
2563 .expect("load");
2564
2565 let lib = project_dir.join("src/lib.rs");
2567 std::fs::write(&lib, "// DISK VERSION\n").unwrap();
2568
2569 shell.cmd_reload().expect("reload");
2570 let code = shell.code().unwrap_or_default().to_string();
2571 assert!(
2572 code.starts_with("// DISK VERSION"),
2573 "reload took disk; got:\n{code}"
2574 );
2575 std::fs::remove_dir_all(&dir).unwrap();
2576 }
2577
2578 #[test]
2582 fn run_aborts_on_conflict_and_does_not_clobber() {
2583 let dir = std::env::temp_dir().join(format!("pixel8_run_conflict_{}", std::process::id()));
2584 let _ = std::fs::remove_dir_all(&dir);
2585 let mut shell = test_shell();
2586 let project_dir = dir.join("game");
2587 Project::create(&project_dir, "game").unwrap();
2588 shell
2589 .cmd_load(&[project_dir.to_str().unwrap()])
2590 .expect("load");
2591
2592 if let Loaded::Project(p) = &mut shell.loaded {
2594 p.code = "// IN-CONSOLE EDIT\n".into();
2595 }
2596 let lib = project_dir.join("src/lib.rs");
2598 std::fs::write(&lib, "// EXTERNAL EDIT\n").unwrap();
2599 let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2600 std::fs::OpenOptions::new()
2601 .write(true)
2602 .open(&lib)
2603 .unwrap()
2604 .set_modified(later)
2605 .unwrap();
2606
2607 shell.cmd_run();
2609 assert_eq!(shell.mode, Mode::Console, "first run aborts on conflict");
2610 assert!(shell.build.is_none(), "no build started on conflict");
2611 assert_eq!(
2612 std::fs::read_to_string(&lib).unwrap(),
2613 "// EXTERNAL EDIT\n",
2614 "disk untouched after the first run"
2615 );
2616
2617 shell.cmd_run();
2619 assert_eq!(shell.mode, Mode::Console, "second run still aborts");
2620 assert!(shell.build.is_none(), "second run starts no build");
2621 assert_eq!(
2622 std::fs::read_to_string(&lib).unwrap(),
2623 "// EXTERNAL EDIT\n",
2624 "disk still untouched after the second run"
2625 );
2626
2627 std::fs::remove_dir_all(&dir).unwrap();
2628 }
2629
2630 #[test]
2631 fn picker_creates_and_switches_files_in_the_shell() {
2632 let dir = std::env::temp_dir().join(format!("pixel8_pick_{}", std::process::id()));
2633 let _ = std::fs::remove_dir_all(&dir);
2634 let mut shell = test_shell();
2635 let project_dir = dir.join("game");
2636 Project::create(&project_dir, "game").unwrap();
2637 shell
2638 .cmd_load(&[project_dir.to_str().unwrap()])
2639 .expect("load project");
2640 shell.switch_editor(Mode::Code);
2641
2642 let ctrl = Mods {
2644 ctrl: true,
2645 ..Default::default()
2646 };
2647 shell.key(Key::Char('o'), ctrl);
2648 assert!(shell.file_picker.is_open());
2649 shell.key(Key::Down, Mods::default()); shell.key(Key::Enter, Mods::default()); for c in "enemy".chars() {
2652 shell.key(Key::Char(c), Mods::default());
2653 }
2654 shell.key(Key::Enter, Mods::default()); assert!(!shell.file_picker.is_open());
2657 assert!(project_dir.join("src/enemy.rs").exists());
2658 let lib = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
2659 assert!(lib.starts_with("#![no_std]"), "lib.rs:\n{lib}");
2661 assert!(lib.contains("\nmod enemy;\n"), "lib.rs:\n{lib}");
2662 assert_eq!(shell.code().unwrap(), "");
2664
2665 shell.key(Key::Char('o'), ctrl);
2667 shell.key(Key::Enter, Mods::default()); assert!(shell.code().unwrap().contains("\nmod enemy;\n"));
2669 std::fs::remove_dir_all(&dir).unwrap();
2670 }
2671
2672 #[test]
2673 fn clicking_the_filename_opens_the_picker() {
2674 let dir = std::env::temp_dir().join(format!("pixel8_fnclick_{}", std::process::id()));
2675 let _ = std::fs::remove_dir_all(&dir);
2676 let mut shell = test_shell();
2677 let project_dir = dir.join("game");
2678 Project::create(&project_dir, "game").unwrap();
2679 shell
2680 .cmd_load(&[project_dir.to_str().unwrap()])
2681 .expect("load project");
2682 shell.switch_editor(Mode::Code);
2683
2684 assert!(!shell.file_picker.is_open());
2685 let name = shell.current_file_name();
2687 shell.mouse = Mouse {
2688 x: 3,
2689 y: 2,
2690 left_pressed: true,
2691 ..Default::default()
2692 };
2693 assert!(ui::filename_clicked(&shell.mouse, &name));
2694 shell.tick();
2695 assert!(
2696 shell.file_picker.is_open(),
2697 "filename click opens the picker"
2698 );
2699 std::fs::remove_dir_all(&dir).unwrap();
2700 }
2701
2702 #[test]
2705 fn select_file_save_failure_aborts_and_reports() {
2706 let dir = std::env::temp_dir().join(format!("pixel8_savefail_{}", std::process::id()));
2707 let _ = std::fs::remove_dir_all(&dir);
2708 let mut shell = test_shell();
2709 let project_dir = dir.join("game");
2710 Project::create(&project_dir, "game").unwrap();
2711
2712 std::fs::write(project_dir.join("src/other.rs"), "").unwrap();
2714
2715 shell
2716 .cmd_load(&[project_dir.to_str().unwrap()])
2717 .expect("load project");
2718 shell.switch_editor(Mode::Code);
2719
2720 let lib_path = project_dir.join("src/lib.rs");
2723 std::fs::remove_file(&lib_path).unwrap();
2724 std::fs::create_dir(&lib_path).unwrap();
2725
2726 shell.select_file("other.rs");
2728
2729 assert_eq!(
2731 shell.current_file_name(),
2732 "lib.rs",
2733 "switch must be aborted when save fails"
2734 );
2735
2736 let toast = shell
2738 .toast
2739 .as_ref()
2740 .expect("toast must be set on save error");
2741 assert_eq!(toast.1, col::RED, "toast must be red");
2742 assert!(
2743 toast.0.contains("lib.rs"),
2744 "toast must name the file that failed to save; got: {}",
2745 toast.0
2746 );
2747
2748 std::fs::remove_dir_all(&dir).unwrap();
2749 }
2750
2751 #[test]
2752 fn import_pico8_into_appends_to_loaded_project() {
2753 let dir = std::env::temp_dir().join(format!("pixel8_shell_into_{}", std::process::id()));
2754 let _ = std::fs::remove_dir_all(&dir);
2755 std::fs::create_dir_all(&dir).unwrap();
2756
2757 let mut row0 = vec![b'0'; 128];
2759 row0[0] = b'c';
2760 let p8 = format!(
2761 "pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
2762 String::from_utf8(row0).unwrap()
2763 );
2764 std::fs::write(dir.join("src.p8"), p8).unwrap();
2765
2766 let mut shell = test_shell();
2767 shell.cwd = dir.clone();
2768 shell.cmd_new(&["dest"]).expect("new");
2770 shell.assets_mut().expect("loaded").sprites.set(0, 0, 5);
2773
2774 shell.exec("import-pico8 src.p8 --into --sprites 0");
2775
2776 match &shell.loaded {
2779 Loaded::Project(p) => assert_eq!(p.name, "dest"),
2780 _ => panic!("expected the dest project to remain loaded"),
2781 }
2782 let assets = shell.assets().expect("a project is loaded");
2783 assert_eq!(assets.sprites.get(0, 0), 5);
2785 assert_eq!(assets.sprites.get(8, 0), 0xc);
2787
2788 std::fs::remove_dir_all(&dir).unwrap();
2789 }
2790
2791 #[test]
2792 fn additive_import_does_not_trigger_a_rebuild() {
2793 let dir =
2794 std::env::temp_dir().join(format!("pixel8_into_norebuild_{}", std::process::id()));
2795 let _ = std::fs::remove_dir_all(&dir);
2796 std::fs::create_dir_all(&dir).unwrap();
2797
2798 let mut row0 = vec![b'0'; 128];
2799 row0[0] = b'c';
2800 let p8 = format!(
2801 "pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
2802 String::from_utf8(row0).unwrap()
2803 );
2804 std::fs::write(dir.join("src.p8"), p8).unwrap();
2805
2806 let mut shell = test_shell();
2807 shell.cwd = dir.clone();
2808 shell.cmd_new(&["dest"]).expect("new");
2809
2810 shell.exec("import-pico8 src.p8 --into --sprites 0");
2811
2812 for _ in 0..40 {
2816 shell.tick();
2817 }
2818 assert!(
2819 shell.build.is_none(),
2820 "additive import must not spawn a build"
2821 );
2822 assert_eq!(shell.mode, Mode::Console, "must stay at the console");
2823 assert!(!shell.run_after_build, "must not arm a run");
2824
2825 std::fs::remove_dir_all(&dir).unwrap();
2826 }
2827
2828 #[test]
2829 fn apply_paste_routes_map_blobs_to_the_map_editor() {
2830 use pixel8_runtime::clipboard::Pasted;
2831 let dir = std::env::temp_dir().join(format!("pixel8_mappaste_{}", std::process::id()));
2832 let _ = std::fs::remove_dir_all(&dir);
2833 std::fs::create_dir_all(&dir).unwrap();
2834
2835 let mut shell = test_shell();
2836 shell.cwd = dir.clone();
2837 shell.cmd_new(&["dest"]).expect("new");
2838 shell.mode = Mode::Map;
2839
2840 shell.apply_paste(Pasted::Map {
2841 w: 1,
2842 h: 1,
2843 tiles: vec![7],
2844 });
2845 assert!(shell.map_ed.has_paste_buffer());
2846
2847 std::fs::remove_dir_all(&dir).unwrap();
2848 }
2849
2850 #[test]
2851 fn apply_paste_routes_sfx_to_the_sfx_editor() {
2852 use pixel8_runtime::clipboard::{Pasted, SfxClip, Slotted};
2853 let dir = std::env::temp_dir().join(format!("pixel8_paste_{}", std::process::id()));
2854 let _ = std::fs::remove_dir_all(&dir);
2855 std::fs::create_dir_all(&dir).unwrap();
2856
2857 let mut shell = test_shell();
2858 shell.cwd = dir.clone();
2859 shell.cmd_new(&["dest"]).expect("new");
2860 shell.mode = Mode::Sfx; let mut sfx = pixel8_runtime::assets::Sfx::default();
2863 sfx.notes[0].pitch = 21;
2864 sfx.notes[0].volume = 4;
2865 let clip = SfxClip {
2866 records: vec![Slotted { src: 0, value: sfx }],
2867 patterns: vec![],
2868 };
2869 shell.apply_paste(Pasted::Sfx(clip));
2870
2871 let assets = assets_ref(&shell.loaded).unwrap();
2872 assert_eq!(assets.sfx[0].notes[0].pitch, 21);
2873
2874 std::fs::remove_dir_all(&dir).unwrap();
2875 }
2876
2877 #[test]
2878 fn hide_cursor_suppresses_the_software_cursor() {
2879 let mut shell = test_shell();
2880 shell.mode = Mode::Sprite;
2882 shell.mouse.x = 40;
2883 shell.mouse.y = 40;
2884 let with_cursor = shell.draw().pixels().to_vec();
2885
2886 shell.set_hide_cursor(true);
2887 let hidden = shell.draw().pixels().to_vec();
2888 assert_ne!(
2889 with_cursor, hidden,
2890 "hiding the cursor must change the frame"
2891 );
2892
2893 shell.set_hide_cursor(false);
2895 shell.mouse = Mouse::default();
2896 let no_pointer = shell.draw().pixels().to_vec();
2897 assert_eq!(
2898 hidden, no_pointer,
2899 "a hidden cursor leaves no pointer behind"
2900 );
2901 }
2902}