Skip to main content

pixel8_console/
shell.rs

1//! The console shell: boot screen, command prompt, mode switching, the
2//! run loop, build orchestration and error screens. If Pixel8 has a
3//! personality, it lives here.
4
5use 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/// Keys as the shell sees them, decoupled from the windowing library.
40#[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    /// F6: capture the screen as the cart label while running.
57    CaptureLabel,
58    /// F1: toggle the resource-usage overlay (CPU, memory, fps).
59    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
80/// The editor tabs, in tab-bar order.
81pub const EDITOR_MODES: [Mode; 5] = [Mode::Code, Mode::Sprite, Mode::Map, Mode::Sfx, Mode::Music];
82
83/// What is currently loaded into the console.
84enum Loaded {
85    None,
86    /// A project directory: full edit/build/run workflow.
87    Project(Project),
88    /// A PNG cart loaded directly: runs as-is; source (if any) is shown
89    /// read-only until imported into a project with `import`.
90    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
112/// Disk watchers for the currently-loaded *project*: the two files pixel8
113/// mirrors in memory plus the crate's source tree for build triggering.
114struct 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    /// Re-baseline every watcher to the project's current in-memory state and
131    /// the current source tree (after pixel8 saved the files itself).
132    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
140/// Disk watcher for a loaded PNG cart: re-parses on external change and
141/// reconciles its assets against any in-console edits.
142struct CartWatch {
143    path: PathBuf,
144    synced_mtime: Option<SystemTime>,
145    /// Encoded assets as of the last sync (the editable, comparable part).
146    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    /// Re-baseline after pixel8 wrote the cart itself (save), so our own write
160    /// is not seen as an external change.
161    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    /// The new mtime if the file advanced past the last sync, else `None`.
169    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            // Absorb the mtime now; a transiently-corrupt PNG mid-write is
176            // ignored until the next write rather than retried every poll.
177            self.synced_mtime = Some(mtime);
178            Some(mtime)
179        } else {
180            None
181        }
182    }
183}
184
185/// Color a budget fraction: green with headroom, yellow from 70%, red above
186/// 90%.
187fn 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
197/// Color the fps reading by how close it is to the cart's target rate.
198fn 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
213/// Draw the resource-usage overlay in the top-right: CPU (update and draw),
214/// memory and measured fps, color-coded by how close each is to its budget.
215/// `used` is the cart's committed-memory high-water in bytes; it shows as KB
216/// and as a fraction of the 128 K cap (see `GameVm::mem_used_bytes`). Columns
217/// are aligned: the memory KB sits under the per-call CPU labels and every
218/// percentage lines up. A solid black panel keeps it legible over any cart
219/// output; it draws in screen space and accepts whatever camera the cart left
220/// active (carts reset it each draw).
221fn 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
247/// How long the F6 camera-flash overlay lasts, in frames (~0.1s at 60fps).
248const CAPTURE_FLASH_FRAMES: u32 = 6;
249
250/// Paint the camera-flash feedback over the running cart's screen: a bright
251/// full-screen white pop, like a camera shutter. `cls` is used deliberately —
252/// it ignores any camera offset or clip the cart left active, so the flash
253/// always covers the whole screen and touches no cart-visible state.
254fn 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    /// Decorative palette stripe shown at boot.
264    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    // Console state.
277    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 state.
285    build: Option<BuildJob>,
286    run_after_build: bool,
287    /// Transient feedback shown in the editor bottom bar:
288    /// (text, color, frame it expires at).
289    toast: Option<(String, u8, u64)>,
290
291    // Hot reload.
292    wasm_mtime: Option<SystemTime>,
293
294    // Disk watching for external-edit live-reload.
295    project_watch: Option<ProjectWatch>,
296    cart_watch: Option<CartWatch>,
297
298    // Mouse, shared with editors.
299    pub mouse: Mouse,
300
301    // Editors.
302    code_ed: CodeEditor,
303    file_picker: FilePicker,
304    /// The file edited just before the current one, for the picker's default
305    /// selection (alt-tab style).
306    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    /// Where `new` creates projects and `ls` looks: the host working dir.
314    cwd: PathBuf,
315    sdk_path: PathBuf,
316    /// Cart save files land under this directory instead of the user's
317    /// cache directory when set. Tests use it to stay hermetic.
318    storage_root: Option<PathBuf>,
319
320    /// F1 toggles the CPU/memory/fps resource overlay.
321    show_stats: bool,
322    // Wall-clock fps, measured over a moving window, shown in the overlay.
323    fps_frames: u32,
324    fps_t0: Instant,
325    fps_val: f32,
326
327    /// Frames remaining of the camera-flash overlay shown after an F6 capture.
328    capture_flash: u32,
329
330    /// Suppress the software mouse cursor. The windowed console draws its own
331    /// pixel-art cursor and hides the OS one; a terminal frontend can't hide
332    /// the terminal's mouse pointer, so it hides this one instead to avoid a
333    /// distracting double cursor.
334    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    /// Hide the console's software mouse cursor (used by frontends that show
386    /// the host's own pointer, like the terminal, to avoid a double cursor).
387    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    /// Print a (wrapped) line to the console.
401    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    /// Flash a message in the editor bottom bar for `secs` seconds.
424    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    // -----------------------------------------------------------------
437    // Loaded-state helpers
438    // -----------------------------------------------------------------
439
440    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    /// Open the file picker for the current project, pre-selecting the most
494    /// likely target (the previously edited file, else the first non-current).
495    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, &current, self.previous_file.as_deref());
501    }
502
503    /// Persist the open file, switch to `name`, and re-point the code watcher.
504    /// If saving the open file fails, surface the error and stay put rather than
505    /// dropping the unsaved buffer.
506    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                    // Saving the previous file bumped its mtime; absorb it so a
532                    // plain file switch does not trigger a rebuild.
533                    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    /// Create a new module, open it, and re-point the code watcher.
545    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                    // We wrote lib.rs + the new file ourselves; absorb the bump.
570                    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    /// The OS window title: the loaded cart's name plus the console name, or
587    /// just the console name when nothing is loaded.
588    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    // -----------------------------------------------------------------
596    // Input
597    // -----------------------------------------------------------------
598
599    /// Feed a game button (host already mapped keys to buttons 0..6).
600    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        // The file picker, when open, captures all keys.
608        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        // Global shortcuts.
617        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        // Alt+Left/Right cycles editor tabs.
660        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        // Ctrl+C/X/V move data through the system clipboard for every editor, including
680        // the map (its tile region uses the native format).
681        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        // Abandon any in-progress map-editor drag so it can't commit a stale
745        // selection or move once the editor regains focus.
746        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    // -----------------------------------------------------------------
841    // Commands
842    // -----------------------------------------------------------------
843
844    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    /// Load a cart/project given on the command line at boot.
978    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    /// Run the cart/project loaded at boot, as if the user typed `run`. Used by
985    /// the `pixel8 run <path>` launch mode. A cart enters Run mode immediately; a
986    /// project spawns its build and enters Run mode once it succeeds.
987    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    /// Re-read the current project/cart from disk, discarding in-console edits.
1025    /// Resolves a conflict in favour of the external version.
1026    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        // After saving a project, pixel8's own write must not look external.
1049        if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1050            w.sync(p);
1051        }
1052        // After saving a PNG cart, pixel8's own write must not look external.
1053        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    /// Read the system clipboard, decode a PICO-8 asset blob, and paste it into
1061    /// the active editor. Any failure shows a short message in the editor's bar.
1062    fn cmd_paste(&mut self) {
1063        // Bar messages are short fixed strings so they always fit the 31-char bar.
1064        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    /// Encode the active editor's current item and put it on the system clipboard.
1084    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    /// Cut the current selection to the system clipboard (code text or map tiles).
1115    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    /// Dispatch a decoded clipboard blob to the active editor. Separated from
1144    /// the clipboard read so it can be tested without a display server.
1145    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    /// Show a transient message in the active editor's bottom bar.
1159    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    /// Ctrl+S: save, flash feedback where the user is looking, and (for
1171    /// projects) start a background build so compile errors show up
1172    /// while editing instead of at `run` time.
1173    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                // Pick up external edits and flush in-console edits without
1207                // clobbering either. Abort the run on an unresolved conflict.
1208                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    /// Reconcile a project's disk and in-memory copies in preparation for a
1223    /// build. Adopts clean external changes, flushes in-console edits to disk,
1224    /// and returns `false` (build should abort) on an unresolved conflict.
1225    fn reconcile_for_build(&mut self) -> bool {
1226        let Loaded::Project(_) = &self.loaded else {
1227            return true;
1228        };
1229        // Snapshot the in-memory bytes to feed the watchers.
1230        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        // Adopt external changes first (clean memory), or bail on conflict.
1241        let code_change = w.code.poll(&code_mem);
1242        let assets_change = w.assets.poll(&assets_mem);
1243        // Absorb the source-tree high-water mark so the flush below + the build
1244        // it triggers are not re-detected as an external change next poll.
1245        w.source_tree.poll();
1246        // Bail on a conflict — including a *standing* one from an earlier poll.
1247        // A later poll returns `None` once the mtime is absorbed, so the latch
1248        // is what keeps `run` from flushing the stale copy over disk until the
1249        // user resolves it with `save` (keep mine) or `reload` (take disk).
1250        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                // Malformed disk file: re-sync the watcher to the current
1272                // in-memory encoding so we do not flush over it.
1273                Err(_) => self.resync_assets_watcher(),
1274            }
1275        }
1276        // Flush any in-console edits that are not yet on disk, so cargo builds
1277        // exactly what the editors show. (No-op when nothing is dirty.)
1278        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    /// Re-baseline the assets watcher to the current in-memory encoding. Used
1297    /// when an external `assets.pixel8.json` is unreadable, so we neither flush over
1298    /// it nor keep re-detecting it.
1299    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        // Drop any previous VM first: dropping saves its storage, and the
1308        // new VM must load the freshest save from disk.
1309        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            // Web export: one self-contained playable page.
1347            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    /// Import a PICO-8 cart's assets into a fresh project. Only the graphics,
1407    /// map, sound and music transfer; the cart's Lua code is ignored.
1408    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        // The destination defaults to the cart's name when omitted.
1417        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    /// Append selected PICO-8 assets into the currently-loaded project.
1436    /// `import-pico8 <src> --into [--sprites R] [--sfx R] [--music R]`.
1437    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" => {} // marks additive mode; the target is the loaded project.
1443                "--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        // The save rewrote src/lib.rs too; re-baseline the whole watcher (code +
1467        // assets + source tree) so pixel8's own write isn't seen as an external edit.
1468        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        // A brief on-screen camera flash, plus a console line for the record.
1553        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    // -----------------------------------------------------------------
1580    // Per-frame logic
1581    // -----------------------------------------------------------------
1582
1583    /// The rate the host should tick at: a running cart's frame rate (30 or
1584    /// 60), else 30. Running the whole Run-mode tick at the cart's rate is
1585    /// what gets the display to refresh at 60 too.
1586    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        // Poll the background build.
1597        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                    // A failed build must never pass silently: whether it was
1616                    // kicked off from an editor or by an external edit while a
1617                    // cart is running, drop straight back to the console (and
1618                    // stop the now-stale cart) so the whole error list is on
1619                    // screen, rather than relying on a toast that the editor
1620                    // clips off the right edge of the screen.
1621                    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                // Tab bar clicks work in every editor (but not while the picker is open).
1668                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                            // Click the top-left filename to open the picker (projects only).
1685                            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                        // The pencil on a channel jumps to that SFX for editing.
1715                        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                // Only swap the running VM; in other modes the fresh wasm is
1744                // simply ready for the next run.
1745                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    /// Poll project watchers and react to external edits: adopt clean changes,
1758    /// warn on conflicts, and kick off a rebuild (code/source) or VM reload
1759    /// (assets). Runs on a 30-frame cadence; skipped while a build is in flight.
1760    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        // Assets: no rebuild needed; adopt and reload the running VM.
1780        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                // Malformed disk file: re-sync so we do not loop on it.
1794                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        // Code: adopt into the editor; build is driven by source_changed below.
1807        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        // Any source change (lib.rs or another module) rebuilds — unless the
1826        // mirrored code is in an unresolved conflict (we must not build a state
1827        // the user has not chosen).
1828        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            // Re-run from the console or while already running; stay put if the
1837            // user is in an editor.
1838            self.run_after_build = matches!(self.mode, Mode::Run | Mode::Console);
1839        }
1840    }
1841
1842    /// Poll a loaded PNG cart's file: on external change re-parse and adopt it
1843    /// (when there are no in-console asset edits), else warn about a conflict.
1844    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                // No latch: each new external write re-warns. The `reload`
1894                // command (next task) takes the disk version to resolve this.
1895                self.say("Cart changed on disk;", col::ORANGE);
1896                self.say("You have unsaved edits", col::ORANGE);
1897            }
1898        }
1899    }
1900
1901    // -----------------------------------------------------------------
1902    // Drawing
1903    // -----------------------------------------------------------------
1904
1905    /// Draw the current mode and return the framebuffer to present.
1906    /// Count presented frames over ~0.5 s windows for the fps meter. The
1907    /// cart can't measure this itself — `time()` is a logical clock — so the
1908    /// host counts real draws against the wall clock.
1909    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, &current);
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                // Per-editor top-left content: the code filename (click to pick
1987                // a file), the SFX mode buttons, and the sprite/map view buttons.
1988                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                // Name the hovered view in the bottom bar, but not while the
2006                // file picker is open — its tabs are inert then, so a hint
2007                // would invite a click that does nothing.
2008                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    /// Bottom-bar feedback in editor modes: a live "building..." while a
2022    /// build runs, otherwise the most recent toast until it expires.
2023    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        // Lines fit between the top margin and the bottom status line, scaled
2048        // to the font's line height.
2049        let rows = ((120 - 2) / font::GLYPH_H) as usize;
2050
2051        // Gather visible lines: history tail + prompt line.
2052        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        // Prompt with blinking cursor (skipped while compiling).
2072        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
2088/// The value following a flag, rejecting a missing value or another flag taken
2089/// as the value (e.g. `--into --sfx 0`).
2090fn 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        // With one-decimal CPU the panel widens (12-char rows -> x0=78), so the
2118        // decimal digit shows colored text in 78..86 — blank with the old
2119        // integer panel (x0=86). This is what makes the test red-before-green.
2120        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        // Keep cart saves out of the real user cache directory: tests that
2135        // run carts must be hermetic.
2136        shell.storage_root =
2137            Some(std::env::temp_dir().join(format!("pixel8_test_storage_{}", std::process::id())));
2138        shell
2139    }
2140
2141    /// Restarting a cart must save the old VM's storage *before* the new VM
2142    /// loads its copy from disk — the `self.vm = None` at the top of
2143    /// `start_vm_from_loaded` is load-bearing. The cart increments a stored
2144    /// counter in `pixel8_init`; two starts must produce 2, not 1.
2145    #[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"); // n = 1
2177        shell.start_vm_from_loaded().expect("restart"); // saves 1, reads it, n = 2
2178        shell.vm = None; // Final save.
2179        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    /// Rewrite a freshly-scaffolded project's `pixel8` git dep to a path dep on the
2189    /// in-tree SDK, so test builds match this host's ABI and stay offline.
2190    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        // A scaffolded project depends on the released SDK; tests must build against the working
2198        // tree instead. Every workspace crate shares one version, so this crate's major.minor is
2199        // the requirement the template wrote.
2200        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    /// Ctrl+S in an editor saves, flashes feedback, kicks off a real
2227    /// background build, and reports the result in the bottom bar.
2228    #[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        // Add a comment line at the top (keeping the code valid), then Ctrl+S.
2243        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        // Saved to disk, toast shown, build started.
2256        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        // While building, the editor bottom bar shows progress.
2262        shell.draw();
2263        assert_eq!(shell.fb.pget(0, 120), col::BLACK, "toast bar drawn");
2264
2265        // Wait for the real cargo build (template code must compile).
2266        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    /// A broken cart reports a failing build without leaving the editor.
2285    #[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        // The build-failure summary is now printed to the console buffer (not
2315        // the toast), and the mode drops back to Console.
2316        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    /// `run` after an external edit (clean in-console state) builds the
2334    /// external version and does NOT overwrite it with the stale in-memory copy.
2335    #[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        // Simulate an external editor changing src/lib.rs to a still-valid file.
2347        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        // Bump mtime so the watcher sees it as newer than load time.
2351        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        // The on-disk file must still contain the external edit — not be
2363        // reverted to the stale in-memory copy.
2364        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        // Let the build finish so we leave no thread dangling.
2371        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    /// `startup_run` (the `pixel8 run <path>` launch mode) loads-then-runs: on a
2382    /// project it kicks off the async build and arms the deferred run, exactly
2383    /// as the in-console `run` verb does.
2384    #[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        // The build runs on a detached thread; don't wait for cargo, just clean up.
2403        std::fs::remove_dir_all(&dir).unwrap();
2404    }
2405
2406    /// `new` must arm the disk watcher, otherwise in-console edits are dropped
2407    /// before the build (reconcile_for_build skips the flush when unwatched).
2408    #[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    /// Editing project source externally while idle at the console triggers an
2420    /// automatic build and starts the cart running.
2421    #[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        // External edit, mtime bumped so the watcher sees it.
2436        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        // Drive ticks: poll fires on a 30-frame cadence, then the build runs.
2448        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    /// Re-exporting a loaded PNG cart on disk reloads it (assets adopted) when
2462    /// there are no in-console edits.
2463    #[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        // A valid cart needs the 4-byte wasm magic (plus version); the codec
2476        // checks for it on save and load. The VM never runs here.
2477        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        // Re-export with a different cart name, bump mtime.
2487        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    /// Saving a PNG cart re-baselines its watcher, so the next poll does not
2509    /// mistake pixel8's own write for an external change (no false conflict).
2510    #[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        // Edit the loaded cart's assets in-console, then save.
2531        if let Some(a) = shell.assets_mut() {
2532            a.meta.name = "edited".into();
2533        }
2534        shell.cmd_save(&[]).expect("save");
2535
2536        // The watcher baseline must now match the saved in-memory assets.
2537        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        // Ticking must not flip into a conflict / reload state.
2545        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    /// `reload` discards in-console edits and re-reads the project from disk,
2553    /// resolving a conflict in favour of the external version.
2554    #[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        // External edit on disk.
2566        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    /// A conflict (both disk and editor changed) must abort `run` and keep
2579    /// aborting on a *second* `run` until resolved — never flushing the stale
2580    /// in-console copy over the external edit.
2581    #[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        // In-console edit: make the in-memory copy dirty.
2593        if let Loaded::Project(p) = &mut shell.loaded {
2594            p.code = "// IN-CONSOLE EDIT\n".into();
2595        }
2596        // External edit on disk, mtime bumped so the watcher sees it.
2597        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        // First run: conflict → abort, no build, disk keeps the external edit.
2608        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        // Second run without resolving: must STILL abort and STILL not clobber.
2618        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        // Ctrl+O opens the picker; create a new file from it.
2643        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()); // -> "+ new file"
2650        shell.key(Key::Enter, Mods::default()); // -> new-file input
2651        for c in "enemy".chars() {
2652            shell.key(Key::Char(c), Mods::default());
2653        }
2654        shell.key(Key::Enter, Mods::default()); // create
2655
2656        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        // `mod` is wired in after the template's `#![no_std]` inner attribute.
2660        assert!(lib.starts_with("#![no_std]"), "lib.rs:\n{lib}");
2661        assert!(lib.contains("\nmod enemy;\n"), "lib.rs:\n{lib}");
2662        // The new (empty) file is now the open buffer.
2663        assert_eq!(shell.code().unwrap(), "");
2664
2665        // Switch back to lib.rs via the picker.
2666        shell.key(Key::Char('o'), ctrl);
2667        shell.key(Key::Enter, Mods::default()); // sel 0 == lib.rs
2668        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        // A left-press on the top-left filename.
2686        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    /// A save failure during a file switch aborts the switch and surfaces the
2703    /// error via both the console log and the toast bar.
2704    #[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        // Write a second source file so there is something to switch to.
2713        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        // Sabotage save: replace src/lib.rs (file) with a directory of the
2721        // same name so that fs::write fails with EISDIR.
2722        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        // Attempt to switch to other.rs — save fails, switch must be aborted.
2727        shell.select_file("other.rs");
2728
2729        // The current file must not have changed.
2730        assert_eq!(
2731            shell.current_file_name(),
2732            "lib.rs",
2733            "switch must be aborted when save fails"
2734        );
2735
2736        // The error must be surfaced via the toast bar in red.
2737        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        // A source cart with sprite 0 pixel (0,0) = 0xc.
2758        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        // `new` creates the project under cwd and loads it as the destination.
2769        shell.cmd_new(&["dest"]).expect("new");
2770        // Mark sprite 0 of the loaded project as used, so an additive import must
2771        // land the imported sprite at slot 1 (after the last used slot).
2772        shell.assets_mut().expect("loaded").sprites.set(0, 0, 5);
2773
2774        shell.exec("import-pico8 src.p8 --into --sprites 0");
2775
2776        // The command must append INTO the loaded `dest` project, not replace it
2777        // with a freshly-created one: the loaded project is still `dest`...
2778        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        // ...the pre-existing sprite 0 is untouched...
2784        assert_eq!(assets.sprites.get(0, 0), 5);
2785        // ...and the imported sprite landed at slot 1 (sheet (8, 0)).
2786        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        // The save rewrote src/lib.rs too; the import must re-baseline the whole
2813        // watcher. Tick past the source-tree poll interval and confirm no rebuild was
2814        // triggered and we stayed at the console (no spontaneous run into Run mode).
2815        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; // the SFX editor's slot defaults to 0.
2861
2862        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        // An editor mode: the software mouse cursor is drawn there.
2881        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        // Hiding the cursor draws exactly what the mouse being off-screen does.
2894        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}