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), memory and measured
214/// fps, color-coded by how close each is to its budget. The CPU fractions and `used` are the
215/// once-a-second peaks `StatsMeter` publishes rather than this frame's readings, so the digits
216/// hold still long enough to read. `used` is the cart's committed-memory high-water in bytes; it
217/// shows as KB and as a fraction of the 128 K cap (see `GameVm::mem_used_bytes`). Columns are
218/// aligned: the memory KB sits under the per-call CPU labels and every percentage lines up. A
219/// solid black panel keeps it legible over any cart output; it draws in screen space and accepts
220/// whatever camera the cart left 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 a reading stays on screen before the next window's peak replaces it.
248const STATS_WINDOW: Duration = Duration::from_secs(1);
249
250/// A one-second tumbling peak of the cart's CPU and memory usage, feeding the F1 overlay.
251///
252/// `GameVm::cpu_update`/`cpu_draw` report the fuel spent by the *last completed* call, which is
253/// exactly what a cart wants but changes every single frame: fed straight into the overlay it
254/// churns so hard that the digit left of the decimal point is unreadable. So the meter keeps the
255/// worst sample of the window it is accumulating and publishes that when the window closes,
256/// leaving the displayed numbers still for the whole second in between. The peak and not the mean
257/// because the frame an author is hunting for is the one that blows the budget, and a mean buries
258/// it among its well-behaved neighbours.
259///
260/// Memory rides the same beat but is deliberately *not* sampled per frame. `GameVm::mem_used_bytes`
261/// is a call into the cart's `pixel8_mem_used` export under its own fuel budget, and the figure it
262/// returns is a high-water mark that only ever ratchets up. The reading taken as the window closes
263/// is therefore already that window's peak, and polling it every frame would spend a wasm call per
264/// frame to learn nothing. So `sample` takes a closure over the VM instead of a figure, and calls
265/// it only on the frame that closes a window — the once-a-second read is then impossible to forget
266/// and impossible to do twice.
267///
268/// The first sample after a reset publishes immediately, so the overlay reads true the moment F1
269/// turns it on rather than showing zeros until the first window closes; that sample stands for
270/// itself alone and the window it opens starts empty.
271#[derive(Debug)]
272struct StatsMeter {
273    /// When the window being accumulated started.
274    t0: Instant,
275    /// The worst fuel fractions seen so far in that window.
276    peak_update: f32,
277    peak_draw: f32,
278    /// The peaks published by the last closed window, held until the next one closes. `None` until
279    /// the first sample arrives, which publishes itself.
280    published: Option<(f32, f32)>,
281    /// The memory high-water read as the last window closed, in bytes.
282    mem_used: u32,
283}
284
285impl StatsMeter {
286    /// The worst fraction of `update`'s fuel budget the cart reached over the window.
287    fn cpu_update(&self) -> f32 {
288        self.published.map_or(0.0, |(u, _)| u)
289    }
290
291    /// The same, for `draw`.
292    fn cpu_draw(&self) -> f32 {
293        self.published.map_or(0.0, |(_, d)| d)
294    }
295
296    /// The cart's committed-memory high-water as of the last window boundary, in bytes.
297    fn mem_used(&self) -> u32 {
298        self.mem_used
299    }
300
301    /// Record one logical frame's fuel fractions, closing the window if this frame ends it. `mem`
302    /// reads the cart's memory high-water and runs only on the frame that closes one; see the type
303    /// docs for why that reading is worth exactly one wasm call a second. `now` is a parameter
304    /// rather than an `Instant::now()` inside so tests can drive the boundaries without sleeping.
305    fn sample<F>(&mut self, now: Instant, cpu_update: f32, cpu_draw: f32, mem: F)
306    where
307        F: FnOnce() -> u32,
308    {
309        self.peak_update = self.peak_update.max(cpu_update);
310        self.peak_draw = self.peak_draw.max(cpu_draw);
311        if self.published.is_none() || now.duration_since(self.t0) >= STATS_WINDOW {
312            self.publish(now, mem);
313        }
314    }
315
316    /// Forget everything measured so far. Called when the VM is replaced, so a freshly started or
317    /// hot-reloaded cart never shows the numbers of the one before it.
318    fn reset(&mut self) {
319        *self = Self::default();
320    }
321
322    /// Close the current window: publish its peaks alongside one fresh memory reading, and start an
323    /// empty window at `now`.
324    fn publish<F>(&mut self, now: Instant, mem: F)
325    where
326        F: FnOnce() -> u32,
327    {
328        self.published = Some((self.peak_update, self.peak_draw));
329        self.mem_used = mem();
330        self.peak_update = 0.0;
331        self.peak_draw = 0.0;
332        self.t0 = now;
333    }
334}
335
336impl Default for StatsMeter {
337    fn default() -> Self {
338        Self {
339            // Overwritten by the first sample, which publishes on the spot.
340            t0: Instant::now(),
341            peak_update: 0.0,
342            peak_draw: 0.0,
343            published: None,
344            mem_used: 0,
345        }
346    }
347}
348
349/// How long the F6 camera-flash overlay lasts, in frames (~0.1s at 60fps).
350const CAPTURE_FLASH_FRAMES: u32 = 6;
351
352/// Paint the camera-flash feedback over the running cart's screen: a bright
353/// full-screen white pop, like a camera shutter. `cls` is used deliberately —
354/// it ignores any camera offset or clip the cart left active, so the flash
355/// always covers the whole screen and touches no cart-visible state.
356fn capture_flash_overlay(fb: &mut Framebuffer) {
357    fb.cls(col::WHITE);
358}
359
360enum ConsoleLine {
361    Text {
362        text: String,
363        color: u8,
364    },
365    /// Decorative palette stripe shown at boot.
366    Stripe,
367}
368
369pub struct Shell {
370    pub mode: Mode,
371    last_editor: Mode,
372    loaded: Loaded,
373    vm: Option<GameVm>,
374    audio: AudioHandle,
375    fb: Framebuffer,
376    frame: u64,
377
378    // Console state.
379    lines: VecDeque<ConsoleLine>,
380    input: String,
381    cursor: usize,
382    history: Vec<String>,
383    history_pos: Option<usize>,
384    scroll_back: usize,
385
386    // Build state.
387    build: Option<BuildJob>,
388    run_after_build: bool,
389    /// Transient feedback shown in the editor bottom bar:
390    /// (text, color, frame it expires at).
391    toast: Option<(String, u8, u64)>,
392
393    // Hot reload.
394    wasm_mtime: Option<SystemTime>,
395
396    // Disk watching for external-edit live-reload.
397    project_watch: Option<ProjectWatch>,
398    cart_watch: Option<CartWatch>,
399
400    // Mouse, shared with editors.
401    pub mouse: Mouse,
402
403    // Editors.
404    code_ed: CodeEditor,
405    file_picker: FilePicker,
406    /// The file edited just before the current one, for the picker's default
407    /// selection (alt-tab style).
408    previous_file: Option<String>,
409    sprite_ed: SpriteEditor,
410    map_ed: MapEditor,
411    sfx_ed: SfxEditor,
412    music_ed: MusicEditor,
413
414    pub want_exit: bool,
415    /// Where `new` creates projects and `ls` looks: the host working dir.
416    cwd: PathBuf,
417    sdk_path: PathBuf,
418    /// Cart save files land under this directory instead of the user's
419    /// cache directory when set. Tests use it to stay hermetic.
420    storage_root: Option<PathBuf>,
421
422    /// F1 toggles the CPU/memory/fps resource overlay.
423    show_stats: bool,
424    // Wall-clock fps, measured over a moving window, shown in the overlay.
425    fps_frames: u32,
426    fps_t0: Instant,
427    fps_val: f32,
428    /// The overlay's CPU and memory rows, peaked over one second so they stay readable.
429    stats_meter: StatsMeter,
430
431    /// Frames remaining of the camera-flash overlay shown after an F6 capture.
432    capture_flash: u32,
433
434    /// Suppress the software mouse cursor. The windowed console draws its own
435    /// pixel-art cursor and hides the OS one; a terminal frontend can't hide
436    /// the terminal's mouse pointer, so it hides this one instead to avoid a
437    /// distracting double cursor.
438    hide_cursor: bool,
439}
440
441const TEXT_COLS: usize = 31;
442const PROMPT_COL: u8 = col::WHITE;
443
444impl Shell {
445    pub fn new(audio: AudioHandle, sdk_path: PathBuf) -> Self {
446        let mut shell = Self {
447            mode: Mode::Console,
448            last_editor: Mode::Code,
449            loaded: Loaded::None,
450            vm: None,
451            audio,
452            fb: Framebuffer::new(),
453            frame: 0,
454            lines: VecDeque::new(),
455            input: String::new(),
456            cursor: 0,
457            history: Vec::new(),
458            history_pos: None,
459            scroll_back: 0,
460            build: None,
461            run_after_build: false,
462            toast: None,
463            wasm_mtime: None,
464            project_watch: None,
465            cart_watch: None,
466            mouse: Mouse::default(),
467            code_ed: CodeEditor::new(),
468            file_picker: FilePicker::new(),
469            previous_file: None,
470            sprite_ed: SpriteEditor::new(),
471            map_ed: MapEditor::new(),
472            sfx_ed: SfxEditor::new(),
473            music_ed: MusicEditor::new(),
474            want_exit: false,
475            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
476            sdk_path,
477            storage_root: None,
478            show_stats: false,
479            fps_frames: 0,
480            fps_t0: Instant::now(),
481            fps_val: 0.0,
482            stats_meter: StatsMeter::default(),
483            capture_flash: 0,
484            hide_cursor: false,
485        };
486        shell.boot();
487        shell
488    }
489
490    /// Hide the console's software mouse cursor (used by frontends that show
491    /// the host's own pointer, like the terminal, to avoid a double cursor).
492    pub fn set_hide_cursor(&mut self, hide: bool) {
493        self.hide_cursor = hide;
494    }
495
496    fn boot(&mut self) {
497        self.lines.push_back(ConsoleLine::Stripe);
498        self.say(&format!("Pixel8 {VERSION}"), col::WHITE);
499        self.say("A fantasy console for Rust", col::LIGHT_GREY);
500        self.say("", col::WHITE);
501        self.say("Type help for help", col::LIGHT_GREY);
502        self.say("", col::WHITE);
503    }
504
505    /// Print a (wrapped) line to the console.
506    pub fn say(&mut self, text: &str, color: u8) {
507        if text.is_empty() {
508            self.push_line(String::new(), color);
509            return;
510        }
511        for raw in text.split('\n') {
512            let mut rest = raw;
513            loop {
514                let take = rest
515                    .char_indices()
516                    .nth(TEXT_COLS)
517                    .map(|(i, _)| i)
518                    .unwrap_or(rest.len());
519                self.push_line(rest[..take].to_string(), color);
520                rest = &rest[take..];
521                if rest.is_empty() {
522                    break;
523                }
524            }
525        }
526    }
527
528    /// Flash a message in the editor bottom bar for `secs` seconds.
529    fn toast(&mut self, text: &str, color: u8, secs: f32) {
530        self.toast = Some((text.to_string(), color, self.frame + (secs * 30.0) as u64));
531    }
532
533    fn push_line(&mut self, text: String, color: u8) {
534        self.lines.push_back(ConsoleLine::Text { text, color });
535        while self.lines.len() > 300 {
536            self.lines.pop_front();
537        }
538        self.scroll_back = 0;
539    }
540
541    // -----------------------------------------------------------------
542    // Loaded-state helpers
543    // -----------------------------------------------------------------
544
545    pub fn assets(&self) -> Option<&Assets> {
546        match &self.loaded {
547            Loaded::None => None,
548            Loaded::Project(p) => Some(&p.assets),
549            Loaded::Cart { cart, .. } => Some(&cart.assets),
550        }
551    }
552
553    pub fn assets_mut(&mut self) -> Option<&mut Assets> {
554        match &mut self.loaded {
555            Loaded::None => None,
556            Loaded::Project(p) => Some(&mut p.assets),
557            Loaded::Cart { cart, .. } => Some(&mut cart.assets),
558        }
559    }
560
561    fn code(&self) -> Option<&str> {
562        match &self.loaded {
563            Loaded::None => None,
564            Loaded::Project(p) => Some(&p.code),
565            Loaded::Cart { cart, .. } => cart.source.as_deref(),
566        }
567    }
568
569    fn set_code(&mut self, code: String) {
570        match &mut self.loaded {
571            Loaded::None => {}
572            Loaded::Project(p) => p.code = code,
573            Loaded::Cart { cart, .. } => cart.source = Some(code),
574        }
575    }
576
577    fn project_file_names(&self) -> Vec<String> {
578        match &self.loaded {
579            Loaded::Project(p) => p.file_names(),
580            _ => Vec::new(),
581        }
582    }
583
584    fn current_file_name(&self) -> String {
585        match &self.loaded {
586            Loaded::Project(p) => p.current.clone(),
587            _ => String::new(),
588        }
589    }
590
591    fn run_picker_action(&mut self, action: PickerAction) {
592        match action {
593            PickerAction::Switch(name) => self.select_file(&name),
594            PickerAction::Create(name) => self.create_file_in_project(&name),
595        }
596    }
597
598    /// Open the file picker for the current project, pre-selecting the most
599    /// likely target (the previously edited file, else the first non-current).
600    fn open_file_picker(&mut self) {
601        let files = self.project_file_names();
602        let refs: Vec<&str> = files.iter().map(String::as_str).collect();
603        let current = self.current_file_name();
604        self.file_picker
605            .open(&refs, &current, self.previous_file.as_deref());
606    }
607
608    /// Persist the open file, switch to `name`, and re-point the code watcher.
609    /// If saving the open file fails, surface the error and stay put rather than
610    /// dropping the unsaved buffer.
611    fn select_file(&mut self, name: &str) {
612        enum R {
613            NoOp,
614            Err(String),
615            Ok(String, PathBuf, String),
616        }
617        let r = match &mut self.loaded {
618            Loaded::Project(p) if p.current != name => {
619                let previous = p.current.clone();
620                match p.save() {
621                    Err(e) => R::Err(format!("could not save {previous}: {e}")),
622                    Ok(()) => match p.switch_to(name) {
623                        Ok(()) => R::Ok(p.code.clone(), p.dir.join("src").join(name), previous),
624                        Err(e) => R::Err(format!("{e}")),
625                    },
626                }
627            }
628            _ => R::NoOp,
629        };
630        match r {
631            R::Ok(code, path, previous) => {
632                self.previous_file = Some(previous);
633                self.code_ed.set_text(&code);
634                if let Some(w) = &mut self.project_watch {
635                    w.code = FileWatch::new(path, code.into_bytes());
636                    // Saving the previous file bumped its mtime; absorb it so a
637                    // plain file switch does not trigger a rebuild.
638                    w.source_tree.sync();
639                }
640            }
641            R::Err(msg) => {
642                self.say(&msg, col::RED);
643                self.toast(&msg, col::RED, 3.0);
644            }
645            R::NoOp => {}
646        }
647    }
648
649    /// Create a new module, open it, and re-point the code watcher.
650    fn create_file_in_project(&mut self, name: &str) {
651        enum R {
652            Err(String),
653            Ok(String, PathBuf, String),
654        }
655        let r = match &mut self.loaded {
656            Loaded::Project(p) => {
657                let previous = p.current.clone();
658                match p.save() {
659                    Err(e) => R::Err(format!("could not save {previous}: {e}")),
660                    Ok(()) => match p.create_file(name) {
661                        Ok(new) => R::Ok(p.code.clone(), p.dir.join("src").join(&new), previous),
662                        Err(e) => R::Err(format!("{e}")),
663                    },
664                }
665            }
666            _ => return,
667        };
668        match r {
669            R::Ok(code, path, previous) => {
670                self.previous_file = Some(previous);
671                self.code_ed.set_text(&code);
672                if let Some(w) = &mut self.project_watch {
673                    w.code = FileWatch::new(path, code.into_bytes());
674                    // We wrote lib.rs + the new file ourselves; absorb the bump.
675                    w.source_tree.sync();
676                }
677            }
678            R::Err(msg) => {
679                self.say(&msg, col::RED);
680                self.toast(&msg, col::RED, 3.0);
681            }
682        }
683    }
684
685    fn cart_name(&self) -> String {
686        self.assets()
687            .map(|a| a.meta.name.clone())
688            .unwrap_or_else(|| "no cart".into())
689    }
690
691    /// The OS window title: the loaded cart's name plus the console name, or
692    /// just the console name when nothing is loaded.
693    pub fn window_title(&self) -> String {
694        match &self.loaded {
695            Loaded::None => "Pixel8".into(),
696            _ => format!("{} - Pixel8", self.cart_name()),
697        }
698    }
699
700    // -----------------------------------------------------------------
701    // Input
702    // -----------------------------------------------------------------
703
704    /// Feed a game button (host already mapped keys to buttons 0..6).
705    pub fn set_button(&mut self, b: usize, down: bool) {
706        if let Some(vm) = &mut self.vm {
707            vm.state_mut().input.set_button(b, down);
708        }
709    }
710
711    pub fn key(&mut self, key: Key, mods: Mods) {
712        // The file picker, when open, captures all keys.
713        if self.file_picker.is_open() {
714            let files = self.project_file_names();
715            let refs: Vec<&str> = files.iter().map(String::as_str).collect();
716            if let Some(action) = self.file_picker.key(key, mods, &refs) {
717                self.run_picker_action(action);
718            }
719            return;
720        }
721        // Global shortcuts.
722        if key == Key::ToggleStats {
723            self.show_stats = !self.show_stats;
724            return;
725        }
726        if mods.ctrl {
727            match key {
728                Key::Char('r') => {
729                    self.cmd_run();
730                    return;
731                }
732                Key::Char('s') => {
733                    self.cmd_save_quiet();
734                    return;
735                }
736                Key::Char('o') => {
737                    if self.mode == Mode::Code && matches!(self.loaded, Loaded::Project(_)) {
738                        self.open_file_picker();
739                    }
740                    return;
741                }
742                _ => {}
743            }
744        }
745
746        match self.mode {
747            Mode::Run => {
748                if key == Key::Escape {
749                    self.stop_run("");
750                } else if key == Key::CaptureLabel {
751                    self.capture_label();
752                }
753            }
754            Mode::Console => self.console_key(key, mods),
755            _ => self.editor_key(key, mods),
756        }
757    }
758
759    fn editor_key(&mut self, key: Key, mods: Mods) {
760        if key == Key::Escape {
761            self.mode = Mode::Console;
762            return;
763        }
764        // Alt+Left/Right cycles editor tabs.
765        if mods.alt {
766            let cur = EDITOR_MODES
767                .iter()
768                .position(|m| *m == self.mode)
769                .unwrap_or(0);
770            match key {
771                Key::Left => {
772                    self.switch_editor(
773                        EDITOR_MODES[(cur + EDITOR_MODES.len() - 1) % EDITOR_MODES.len()],
774                    );
775                    return;
776                }
777                Key::Right => {
778                    self.switch_editor(EDITOR_MODES[(cur + 1) % EDITOR_MODES.len()]);
779                    return;
780                }
781                _ => {}
782            }
783        }
784        // Ctrl+C/X/V move data through the system clipboard for every editor, including
785        // the map (its tile region uses the native format).
786        if mods.ctrl {
787            match key {
788                Key::Char('c')
789                    if matches!(
790                        self.mode,
791                        Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
792                    ) =>
793                {
794                    self.cmd_copy();
795                    return;
796                }
797                Key::Char('x') if matches!(self.mode, Mode::Code | Mode::Map) => {
798                    self.cmd_cut();
799                    return;
800                }
801                Key::Char('v')
802                    if matches!(
803                        self.mode,
804                        Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
805                    ) =>
806                {
807                    self.cmd_paste();
808                    return;
809                }
810                _ => {}
811            }
812        }
813        if self.loaded_none() {
814            return;
815        }
816        let audio = self.audio.clone();
817        match self.mode {
818            Mode::Code => {
819                let mut code = self.code().unwrap_or_default().to_string();
820                self.code_ed.key(key, mods, &mut code);
821                self.set_code(code);
822            }
823            Mode::Sprite => {
824                if let Some(a) = assets_of(&mut self.loaded) {
825                    self.sprite_ed.key(key, mods, a);
826                }
827            }
828            Mode::Map => {
829                if let Some(a) = assets_of(&mut self.loaded) {
830                    self.map_ed.key(key, mods, a);
831                }
832            }
833            Mode::Sfx => {
834                if let Some(a) = assets_of(&mut self.loaded) {
835                    self.sfx_ed.key(key, mods, a, &audio);
836                }
837            }
838            Mode::Music => {
839                if let Some(a) = assets_of(&mut self.loaded) {
840                    self.music_ed.key(key, mods, a, &audio);
841                }
842            }
843            _ => {}
844        }
845    }
846
847    pub fn switch_editor(&mut self, mode: Mode) {
848        self.file_picker.close();
849        // Abandon any in-progress map-editor drag so it can't commit a stale
850        // selection or move once the editor regains focus.
851        self.map_ed.cancel_drag();
852        if self.loaded_none() {
853            self.say("No cart loaded. Try: new mygame", col::RED);
854            self.mode = Mode::Console;
855            return;
856        }
857        if mode == Mode::Code {
858            let code = self.code().unwrap_or_default().to_string();
859            self.code_ed.set_text(&code);
860        }
861        self.mode = mode;
862        self.last_editor = mode;
863    }
864
865    fn loaded_none(&self) -> bool {
866        matches!(self.loaded, Loaded::None)
867    }
868
869    fn console_key(&mut self, key: Key, _mods: Mods) {
870        match key {
871            Key::Char(c) => {
872                self.input.insert(self.byte_at(self.cursor), c);
873                self.cursor += 1;
874            }
875            Key::Backspace => {
876                if self.cursor > 0 {
877                    self.cursor -= 1;
878                    let at = self.byte_at(self.cursor);
879                    self.input.remove(at);
880                }
881            }
882            Key::Delete => {
883                if self.cursor < self.input.chars().count() {
884                    let at = self.byte_at(self.cursor);
885                    self.input.remove(at);
886                }
887            }
888            Key::Left => self.cursor = self.cursor.saturating_sub(1),
889            Key::Right => self.cursor = (self.cursor + 1).min(self.input.chars().count()),
890            Key::Home => self.cursor = 0,
891            Key::End => self.cursor = self.input.chars().count(),
892            Key::Up => {
893                if !self.history.is_empty() {
894                    let pos = match self.history_pos {
895                        None => self.history.len() - 1,
896                        Some(p) => p.saturating_sub(1),
897                    };
898                    self.history_pos = Some(pos);
899                    self.input = self.history[pos].clone();
900                    self.cursor = self.input.chars().count();
901                }
902            }
903            Key::Down => {
904                if let Some(p) = self.history_pos {
905                    if p + 1 < self.history.len() {
906                        self.history_pos = Some(p + 1);
907                        self.input = self.history[p + 1].clone();
908                    } else {
909                        self.history_pos = None;
910                        self.input.clear();
911                    }
912                    self.cursor = self.input.chars().count();
913                }
914            }
915            Key::PageUp => self.scroll_back = (self.scroll_back + 5).min(self.lines.len()),
916            Key::PageDown => self.scroll_back = self.scroll_back.saturating_sub(5),
917            Key::Enter => {
918                let cmd = self.input.clone();
919                self.say(&format!("> {cmd}"), col::LIGHT_GREY);
920                if !cmd.trim().is_empty() {
921                    self.history.push(cmd.clone());
922                }
923                self.history_pos = None;
924                self.input.clear();
925                self.cursor = 0;
926                self.exec(&cmd);
927            }
928            Key::Escape => {
929                if !self.loaded_none() {
930                    self.switch_editor(self.last_editor);
931                }
932            }
933            Key::Tab | Key::CaptureLabel | Key::ToggleStats => {}
934        }
935    }
936
937    fn byte_at(&self, char_idx: usize) -> usize {
938        self.input
939            .char_indices()
940            .nth(char_idx)
941            .map(|(i, _)| i)
942            .unwrap_or(self.input.len())
943    }
944
945    // -----------------------------------------------------------------
946    // Commands
947    // -----------------------------------------------------------------
948
949    fn exec(&mut self, cmd: &str) {
950        let parts: Vec<&str> = cmd.split_whitespace().collect();
951        let Some(&verb) = parts.first() else { return };
952        let args = &parts[1..];
953        let result = match verb.to_ascii_lowercase().as_str() {
954            "help" => {
955                self.cmd_help(args);
956                Ok(())
957            }
958            "new" => self.cmd_new(args),
959            "load" => self.cmd_load(args),
960            "reload" => self.cmd_reload(),
961            "save" => self.cmd_save(args),
962            "run" => {
963                self.cmd_run();
964                Ok(())
965            }
966            "export" => self.cmd_export(args),
967            "import" => self.cmd_import(args),
968            "import-pico8" | "importp8" => self.cmd_import_pico8(args),
969            "info" => {
970                self.cmd_info();
971                Ok(())
972            }
973            "ls" | "dir" => self.cmd_ls(),
974            "cls" => {
975                self.lines.clear();
976                Ok(())
977            }
978            "title" => self.cmd_meta(args, |a, v| a.meta.name = v),
979            "author" => self.cmd_meta(args, |a, v| a.meta.author = v),
980            "code" => {
981                self.switch_editor(Mode::Code);
982                Ok(())
983            }
984            "sprite" | "gfx" => {
985                self.switch_editor(Mode::Sprite);
986                Ok(())
987            }
988            "map" => {
989                self.switch_editor(Mode::Map);
990                Ok(())
991            }
992            "sfx" => {
993                self.switch_editor(Mode::Sfx);
994                Ok(())
995            }
996            "music" => {
997                self.switch_editor(Mode::Music);
998                Ok(())
999            }
1000            "keys" => {
1001                self.cmd_keys();
1002                Ok(())
1003            }
1004            "reboot" => {
1005                self.vm = None;
1006                self.audio.stop_all();
1007                self.loaded = Loaded::None;
1008                self.lines.clear();
1009                self.boot();
1010                Ok(())
1011            }
1012            "exit" | "quit" | "shutdown" => {
1013                self.want_exit = true;
1014                Ok(())
1015            }
1016            other => Err(anyhow!("Syntax error: {other}\nType help for help")),
1017        };
1018        if let Err(e) = result {
1019            self.say(&e.to_string(), col::RED);
1020        }
1021    }
1022
1023    fn cmd_help(&mut self, args: &[&str]) {
1024        if args.first() == Some(&"keys") {
1025            self.cmd_keys();
1026            return;
1027        }
1028        for (c, d) in [
1029            ("new <name>", "Create a project"),
1030            ("load <dir|cart.png>", "Load a cart"),
1031            ("reload", "Re-read from disk, drop edits"),
1032            ("save", "Save project to disk"),
1033            ("run", "Build + run (esc stops)"),
1034            ("export <f.png|f.html>", "Export cart (PNG or web)"),
1035            ("import <f.png> <dir>", "Cart -> project"),
1036            ("import-pico8 <f> [dir]", "PICO-8 cart -> new project"),
1037            (
1038                "import-pico8 <f> --into ...",
1039                "Append assets to loaded project",
1040            ),
1041            ("info", "Cart metadata"),
1042            ("title/author <text>", "Set metadata"),
1043            ("code/sprite/map/sfx/music", "Editors (esc)"),
1044            ("ls, cls, keys, reboot, exit", ""),
1045        ] {
1046            self.say(c, col::WHITE);
1047            if !d.is_empty() {
1048                self.say(&format!("  {d}"), col::LIGHT_GREY);
1049            }
1050        }
1051    }
1052
1053    fn cmd_keys(&mut self) {
1054        for (k, d) in [
1055            ("esc", "Console <-> editor / stop"),
1056            ("ctrl+r", "Run cart"),
1057            ("ctrl+s", "Save + build check"),
1058            ("ctrl+z / ctrl+y", "Undo / redo (in editors)"),
1059            ("alt+left/right", "Switch editor"),
1060            ("arrows + z/x", "Game buttons"),
1061            ("f1", "Toggle resource stats"),
1062            ("f6", "Capture label (running)"),
1063        ] {
1064            self.say(&format!("{k:14} {d}"), col::LIGHT_GREY);
1065        }
1066    }
1067
1068    fn cmd_new(&mut self, args: &[&str]) -> Result<()> {
1069        let Some(name) = args.first() else {
1070            bail!("Usage: new <name>");
1071        };
1072        let dir = self.cwd.join(name);
1073        let project = Project::create(&dir, name)?;
1074        self.say(&format!("Created ./{name}"), col::GREEN);
1075        self.code_ed.set_text(&project.code);
1076        self.project_watch = Some(ProjectWatch::new(&project));
1077        self.cart_watch = None;
1078        self.loaded = Loaded::Project(project);
1079        Ok(())
1080    }
1081
1082    /// Load a cart/project given on the command line at boot.
1083    pub fn startup_load(&mut self, path: &str) {
1084        if let Err(e) = self.cmd_load(&[path]) {
1085            self.say(&e.to_string(), col::RED);
1086        }
1087    }
1088
1089    /// Run the cart/project loaded at boot, as if the user typed `run`. Used by
1090    /// the `pixel8 run <path>` launch mode. A cart enters Run mode immediately; a
1091    /// project spawns its build and enters Run mode once it succeeds.
1092    pub fn startup_run(&mut self) {
1093        self.cmd_run();
1094    }
1095
1096    fn cmd_load(&mut self, args: &[&str]) -> Result<()> {
1097        let Some(path) = args.first() else {
1098            bail!("Usage: load <dir|cart.png>");
1099        };
1100        let path = self.cwd.join(path);
1101        if path.extension().is_some_and(|e| e == "png") {
1102            let cart = cart::load_png(&path)?;
1103            let name = cart.assets.meta.name.clone();
1104            let has_src = cart.source.is_some();
1105            self.code_ed.set_text(
1106                cart.source
1107                    .as_deref()
1108                    .unwrap_or("// No source in this cart"),
1109            );
1110            self.project_watch = None;
1111            let cart_baseline = encode_assets(&cart.assets).unwrap_or_default();
1112            self.cart_watch = Some(CartWatch::new(path.clone(), cart_baseline));
1113            self.loaded = Loaded::Cart { cart, path };
1114            self.say(&format!("Loaded cart: {name}"), col::GREEN);
1115            if !has_src {
1116                self.say("(Playable cart, no source)", col::LIGHT_GREY);
1117            }
1118        } else {
1119            let project = Project::load(&path)?;
1120            self.code_ed.set_text(&project.code);
1121            self.say(&format!("Loaded {}", project.name), col::GREEN);
1122            self.project_watch = Some(ProjectWatch::new(&project));
1123            self.cart_watch = None;
1124            self.loaded = Loaded::Project(project);
1125        }
1126        Ok(())
1127    }
1128
1129    /// Re-read the current project/cart from disk, discarding in-console edits.
1130    /// Resolves a conflict in favour of the external version.
1131    fn cmd_reload(&mut self) -> Result<()> {
1132        let path = match &self.loaded {
1133            Loaded::None => bail!("Nothing loaded"),
1134            Loaded::Project(p) => p.dir.clone(),
1135            Loaded::Cart { path, .. } => path.clone(),
1136        };
1137        let path_str = path.to_string_lossy().into_owned();
1138        self.cmd_load(&[&path_str])
1139    }
1140
1141    fn cmd_save(&mut self, _args: &[&str]) -> Result<()> {
1142        let message = match &mut self.loaded {
1143            Loaded::None => bail!("Nothing to save"),
1144            Loaded::Project(p) => {
1145                p.save()?;
1146                "Saved".to_string()
1147            }
1148            Loaded::Cart { cart, path } => {
1149                cart::save_png(cart, path)?;
1150                format!("Saved {}", path.display())
1151            }
1152        };
1153        // After saving a project, pixel8's own write must not look external.
1154        if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1155            w.sync(p);
1156        }
1157        // After saving a PNG cart, pixel8's own write must not look external.
1158        if let (Loaded::Cart { cart, .. }, Some(w)) = (&self.loaded, &mut self.cart_watch) {
1159            w.mark_synced(encode_assets(&cart.assets).unwrap_or_default());
1160        }
1161        self.say(&message, col::GREEN);
1162        Ok(())
1163    }
1164
1165    /// Read the system clipboard, decode a PICO-8 asset blob, and paste it into
1166    /// the active editor. Any failure shows a short message in the editor's bar.
1167    fn cmd_paste(&mut self) {
1168        // Bar messages are short fixed strings so they always fit the 31-char bar.
1169        let text = match crate::clipboard::read_text() {
1170            Ok(t) => t,
1171            Err(_) => return self.set_editor_status("no text on clipboard".into()),
1172        };
1173        if self.mode == Mode::Code {
1174            if self.code().is_none() {
1175                return self.set_editor_status("load a project first".into());
1176            }
1177            let mut code = self.code().unwrap_or_default().to_string();
1178            self.code_ed.paste_text(&mut code, &text);
1179            self.set_code(code);
1180            return;
1181        }
1182        match pixel8_runtime::clipboard::parse(&text) {
1183            Ok(pasted) => self.apply_paste(pasted),
1184            Err(_) => self.set_editor_status("nothing to paste".into()),
1185        }
1186    }
1187
1188    /// Encode the active editor's current item and put it on the system clipboard.
1189    fn cmd_copy(&mut self) {
1190        let blob = match self.mode {
1191            Mode::Code => {
1192                if self.code().is_none() {
1193                    return self.set_editor_status("load a project first".into());
1194                }
1195                let code = self.code().unwrap_or_default().to_string();
1196                self.code_ed.copy(&code)
1197            }
1198            Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Map => {
1199                let Some(a) = assets_of(&mut self.loaded) else {
1200                    return self.set_editor_status("load a project first".into());
1201                };
1202                match self.mode {
1203                    Mode::Sprite => Some(self.sprite_ed.copy(a)),
1204                    Mode::Sfx => Some(self.sfx_ed.copy(a)),
1205                    Mode::Music => Some(self.music_ed.copy(a)),
1206                    Mode::Map => self.map_ed.copy_selection(a, false),
1207                    _ => unreachable!(),
1208                }
1209            }
1210            _ => None,
1211        };
1212        if let Some(text) = blob {
1213            if crate::clipboard::write_text(&text).is_err() {
1214                self.set_editor_status("clipboard unavailable".into());
1215            }
1216        }
1217    }
1218
1219    /// Cut the current selection to the system clipboard (code text or map tiles).
1220    fn cmd_cut(&mut self) {
1221        let blob = match self.mode {
1222            Mode::Code => {
1223                if self.code().is_none() {
1224                    return self.set_editor_status("load a project first".into());
1225                }
1226                let mut code = self.code().unwrap_or_default().to_string();
1227                let cut = self.code_ed.cut(&mut code);
1228                if cut.is_some() {
1229                    self.set_code(code);
1230                }
1231                cut
1232            }
1233            Mode::Map => {
1234                let Some(a) = assets_of(&mut self.loaded) else {
1235                    return self.set_editor_status("load a project first".into());
1236                };
1237                self.map_ed.copy_selection(a, true)
1238            }
1239            _ => None,
1240        };
1241        if let Some(text) = blob {
1242            if crate::clipboard::write_text(&text).is_err() {
1243                self.set_editor_status("clipboard unavailable".into());
1244            }
1245        }
1246    }
1247
1248    /// Dispatch a decoded clipboard blob to the active editor. Separated from
1249    /// the clipboard read so it can be tested without a display server.
1250    fn apply_paste(&mut self, pasted: Pasted) {
1251        let Some(a) = assets_of(&mut self.loaded) else {
1252            return self.set_editor_status("load a project first".into());
1253        };
1254        match self.mode {
1255            Mode::Sprite => self.sprite_ed.paste(&pasted, a),
1256            Mode::Sfx => self.sfx_ed.paste(&pasted, a),
1257            Mode::Music => self.music_ed.paste(&pasted, a),
1258            Mode::Map => self.map_ed.paste(&pasted),
1259            _ => {}
1260        }
1261    }
1262
1263    /// Show a transient message in the active editor's bottom bar.
1264    fn set_editor_status(&mut self, msg: String) {
1265        match self.mode {
1266            Mode::Sprite => self.sprite_ed.set_status(msg),
1267            Mode::Sfx => self.sfx_ed.set_status(msg),
1268            Mode::Music => self.music_ed.set_status(msg),
1269            Mode::Code => self.code_ed.set_status(msg),
1270            Mode::Map => self.map_ed.set_status(msg),
1271            _ => {}
1272        }
1273    }
1274
1275    /// Ctrl+S: save, flash feedback where the user is looking, and (for
1276    /// projects) start a background build so compile errors show up
1277    /// while editing instead of at `run` time.
1278    fn cmd_save_quiet(&mut self) {
1279        if let Err(e) = self.cmd_save(&[]) {
1280            let msg = e.to_string();
1281            self.say(&msg, col::RED);
1282            self.toast(&msg, col::RED, 3.0);
1283            return;
1284        }
1285        self.toast("Saved", col::GREEN, 1.5);
1286        if self.build.is_none() {
1287            if let Loaded::Project(p) = &self.loaded {
1288                let dir = p.dir.clone();
1289                self.build = Some(spawn_build(&dir));
1290                self.run_after_build = false;
1291            }
1292        }
1293    }
1294
1295    pub fn cmd_run(&mut self) {
1296        self.audio.stop_all();
1297        self.vm = None;
1298        match &self.loaded {
1299            Loaded::None => self.say("No cart loaded", col::RED),
1300            Loaded::Cart { .. } => match self.start_vm_from_loaded() {
1301                Ok(()) => {}
1302                Err(e) => self.show_error("boot", &e.to_string()),
1303            },
1304            Loaded::Project(p) => {
1305                let dir = p.dir.clone();
1306                if self.build.is_some() {
1307                    self.say("Already compiling...", col::ORANGE);
1308                    self.toast("Already building...", col::ORANGE, 1.5);
1309                    return;
1310                }
1311                // Pick up external edits and flush in-console edits without
1312                // clobbering either. Abort the run on an unresolved conflict.
1313                if !self.reconcile_for_build() {
1314                    self.say("Disk & editor both changed", col::ORANGE);
1315                    self.say("Save or reload to resolve", col::ORANGE);
1316                    self.toast("Conflict: save or reload", col::ORANGE, 3.0);
1317                    return;
1318                }
1319                self.mode = Mode::Console;
1320                self.say("Compiling...", col::LIGHT_GREY);
1321                self.build = Some(spawn_build(&dir));
1322                self.run_after_build = true;
1323            }
1324        }
1325    }
1326
1327    /// Reconcile a project's disk and in-memory copies in preparation for a
1328    /// build. Adopts clean external changes, flushes in-console edits to disk,
1329    /// and returns `false` (build should abort) on an unresolved conflict.
1330    fn reconcile_for_build(&mut self) -> bool {
1331        let Loaded::Project(_) = &self.loaded else {
1332            return true;
1333        };
1334        // Snapshot the in-memory bytes to feed the watchers.
1335        let (code_mem, assets_mem) = match &self.loaded {
1336            Loaded::Project(p) => (
1337                p.code.clone().into_bytes(),
1338                encode_assets(&p.assets).unwrap_or_default(),
1339            ),
1340            _ => return true,
1341        };
1342        let Some(w) = &mut self.project_watch else {
1343            return true;
1344        };
1345        // Adopt external changes first (clean memory), or bail on conflict.
1346        let code_change = w.code.poll(&code_mem);
1347        let assets_change = w.assets.poll(&assets_mem);
1348        // Absorb the source-tree high-water mark so the flush below + the build
1349        // it triggers are not re-detected as an external change next poll.
1350        w.source_tree.poll();
1351        // Bail on a conflict — including a *standing* one from an earlier poll.
1352        // A later poll returns `None` once the mtime is absorbed, so the latch
1353        // is what keeps `run` from flushing the stale copy over disk until the
1354        // user resolves it with `save` (keep mine) or `reload` (take disk).
1355        if matches!(code_change, FileChange::Conflict)
1356            || matches!(assets_change, FileChange::Conflict)
1357            || w.code.in_conflict()
1358            || w.assets.in_conflict()
1359        {
1360            return false;
1361        }
1362        if let FileChange::Adopt(bytes) = code_change {
1363            let text = String::from_utf8_lossy(&bytes).into_owned();
1364            self.code_ed.set_text(&text);
1365            if let Loaded::Project(p) = &mut self.loaded {
1366                p.code = text;
1367            }
1368        }
1369        if let FileChange::Adopt(bytes) = assets_change {
1370            match decode_assets(&bytes) {
1371                Ok(assets) => {
1372                    if let Loaded::Project(p) = &mut self.loaded {
1373                        p.assets = assets;
1374                    }
1375                }
1376                // Malformed disk file: re-sync the watcher to the current
1377                // in-memory encoding so we do not flush over it.
1378                Err(_) => self.resync_assets_watcher(),
1379            }
1380        }
1381        // Flush any in-console edits that are not yet on disk, so cargo builds
1382        // exactly what the editors show. (No-op when nothing is dirty.)
1383        let needs_flush = match (&self.loaded, &self.project_watch) {
1384            (Loaded::Project(p), Some(w)) => {
1385                p.code.as_bytes() != w.code.baseline()
1386                    || encode_assets(&p.assets).unwrap_or_default() != w.assets.baseline()
1387            }
1388            _ => false,
1389        };
1390        if needs_flush {
1391            if let Loaded::Project(p) = &self.loaded {
1392                let _ = p.save();
1393            }
1394            if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1395                w.sync(p);
1396            }
1397        }
1398        true
1399    }
1400
1401    /// Re-baseline the assets watcher to the current in-memory encoding. Used
1402    /// when an external `assets.pixel8.json` is unreadable, so we neither flush over
1403    /// it nor keep re-detecting it.
1404    fn resync_assets_watcher(&mut self) {
1405        if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1406            w.assets
1407                .mark_synced(encode_assets(&p.assets).unwrap_or_default());
1408        }
1409    }
1410
1411    fn start_vm_from_loaded(&mut self) -> Result<()> {
1412        // Drop any previous VM first: dropping saves its storage, and the
1413        // new VM must load the freshest save from disk.
1414        self.vm = None;
1415        let (wasm, assets) = match &self.loaded {
1416            Loaded::None => bail!("No cart loaded"),
1417            Loaded::Cart { cart, .. } => (cart.wasm.clone(), cart.assets.clone()),
1418            Loaded::Project(p) => {
1419                let path = p.wasm_path();
1420                let wasm = std::fs::read(&path)
1421                    .map_err(|_| anyhow!("Cart not built yet ({})", path.display()))?;
1422                self.wasm_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
1423                (wasm, p.assets.clone())
1424            }
1425        };
1426        let storage = match &self.storage_root {
1427            Some(root) => Storage::for_cart_in(root, &assets.meta.name),
1428            None => Storage::for_cart(&assets.meta.name),
1429        };
1430        let vm = GameVm::load(&wasm, &assets, self.audio.clone(), storage)?;
1431        self.vm = Some(vm);
1432        // Every VM swap lands here — `run` and hot reload alike — so this is the one place the
1433        // meter must forget the previous cart's peaks and memory figure.
1434        self.stats_meter.reset();
1435        self.mode = Mode::Run;
1436        Ok(())
1437    }
1438
1439    fn cmd_export(&mut self, args: &[&str]) -> Result<()> {
1440        let mut include_source = true;
1441        let mut controls = true;
1442        let mut file = None;
1443        for a in args {
1444            match *a {
1445                "-nosrc" => include_source = false,
1446                "-noctrl" => controls = false,
1447                _ => file = Some(*a),
1448            }
1449        }
1450        let file = file
1451            .map(|f| f.to_string())
1452            .unwrap_or_else(|| format!("{}.png", self.cart_name()));
1453        let out = self.cwd.join(&file);
1454        if file.ends_with(".html") {
1455            // Web export: one self-contained playable page.
1456            let cart = self.make_cart(false)?;
1457            self.say("Exporting for web...", col::LIGHT_GREY);
1458            let web_dir = crate::webexport::web_crate_dir(&self.sdk_path);
1459            crate::webexport::export_html(&cart, &out, &web_dir, controls)?;
1460        } else {
1461            let cart = self.make_cart(include_source)?;
1462            cart::save_png(&cart, &out)?;
1463        }
1464        self.say(&format!("Exported {file}"), col::GREEN);
1465        Ok(())
1466    }
1467
1468    fn make_cart(&self, include_source: bool) -> Result<Cart> {
1469        match &self.loaded {
1470            Loaded::None => bail!("No cart loaded"),
1471            Loaded::Cart { cart, .. } => Ok(Cart {
1472                wasm: cart.wasm.clone(),
1473                assets: cart.assets.clone(),
1474                source: if include_source {
1475                    cart.source.clone()
1476                } else {
1477                    None
1478                },
1479            }),
1480            Loaded::Project(p) => {
1481                let wasm = std::fs::read(p.wasm_path())
1482                    .map_err(|_| anyhow!("Cart not built yet. Type run first"))?;
1483                Ok(Cart {
1484                    wasm,
1485                    assets: p.assets.clone(),
1486                    source: include_source.then(|| p.lib_source()),
1487                })
1488            }
1489        }
1490    }
1491
1492    fn cmd_import(&mut self, args: &[&str]) -> Result<()> {
1493        let (Some(png), Some(dir)) = (args.first(), args.get(1)) else {
1494            bail!("Usage: import <cart.png> <dir>");
1495        };
1496        let cart = cart::load_png(&self.cwd.join(png))?;
1497        let Some(source) = &cart.source else {
1498            bail!("Cart has no source (playable-only)");
1499        };
1500        let dir = self.cwd.join(dir);
1501        let mut project = Project::create(&dir, &cart.assets.meta.name)?;
1502        project.code = source.clone();
1503        project.assets = cart.assets.clone();
1504        project.save()?;
1505        self.say(&format!("Imported into {}", dir.display()), col::GREEN);
1506        self.code_ed.set_text(&project.code);
1507        self.loaded = Loaded::Project(project);
1508        if let Loaded::Project(p) = &self.loaded {
1509            self.project_watch = Some(ProjectWatch::new(p));
1510        }
1511        self.cart_watch = None;
1512        Ok(())
1513    }
1514
1515    /// Import a PICO-8 cart's assets into a fresh project. Only the graphics,
1516    /// map, sound and music transfer; the cart's Lua code is ignored.
1517    fn cmd_import_pico8(&mut self, args: &[&str]) -> Result<()> {
1518        if args.contains(&"--into") {
1519            return self.cmd_import_pico8_into(args);
1520        }
1521        let Some(src) = args.first() else {
1522            bail!("Usage: import-pico8 <cart.p8|cart.p8.png> [dir]");
1523        };
1524        let src = self.cwd.join(src);
1525        // The destination defaults to the cart's name when omitted.
1526        let dir = match args.get(1) {
1527            Some(d) => self.cwd.join(d),
1528            None => self.cwd.join(pixel8_runtime::pico8::default_dir_name(&src)),
1529        };
1530        let project = pixel8_runtime::pico8::import_project(&src, &dir)?;
1531        self.say(
1532            &format!("Imported assets into {}", dir.display()),
1533            col::GREEN,
1534        );
1535        self.code_ed.set_text(&project.code);
1536        self.loaded = Loaded::Project(project);
1537        if let Loaded::Project(p) = &self.loaded {
1538            self.project_watch = Some(ProjectWatch::new(p));
1539        }
1540        self.cart_watch = None;
1541        Ok(())
1542    }
1543
1544    /// Append selected PICO-8 assets into the currently-loaded project.
1545    /// `import-pico8 <src> --into [--sprites R] [--sfx R] [--music R]`.
1546    fn cmd_import_pico8_into(&mut self, args: &[&str]) -> Result<()> {
1547        let (mut src, mut sprites, mut sfx, mut music) = (None, None, None, None);
1548        let mut it = args.iter();
1549        while let Some(&a) = it.next() {
1550            match a {
1551                "--into" => {} // marks additive mode; the target is the loaded project.
1552                "--sprites" => sprites = Some(flag_value(it.next(), "--sprites")?),
1553                "--sfx" => sfx = Some(flag_value(it.next(), "--sfx")?),
1554                "--music" => music = Some(flag_value(it.next(), "--music")?),
1555                flag if flag.starts_with("--") => bail!("unknown flag {flag}"),
1556                pos if src.is_none() => src = Some(pos),
1557                pos => bail!("unexpected argument {pos}"),
1558            }
1559        }
1560        let Some(src) = src else {
1561            bail!("Usage: import-pico8 <cart> --into [--sprites R] [--sfx R] [--music R]");
1562        };
1563        let sel = pixel8_runtime::pico8::Selection::parse(sprites, sfx, music)?;
1564
1565        let report = {
1566            let Loaded::Project(project) = &mut self.loaded else {
1567                bail!("import-pico8 --into needs a project loaded; use `new` or `load` first");
1568            };
1569            let assets = pixel8_runtime::pico8::parse_file(&self.cwd.join(src))?;
1570            let report =
1571                pixel8_runtime::pico8::append_pico8_assets(&mut project.assets, &assets, &sel)?;
1572            project.save()?;
1573            report
1574        };
1575        // The save rewrote src/lib.rs too; re-baseline the whole watcher (code +
1576        // assets + source tree) so pixel8's own write isn't seen as an external edit.
1577        if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
1578            w.sync(p);
1579        }
1580        for line in report.summary_lines() {
1581            self.say(&format!("Imported {line}"), col::GREEN);
1582        }
1583        for w in &report.warnings {
1584            self.say(w, col::YELLOW);
1585        }
1586        Ok(())
1587    }
1588
1589    fn cmd_info(&mut self) {
1590        match self.assets() {
1591            None => self.say("No cart loaded", col::RED),
1592            Some(a) => {
1593                let (name, author, version) = (
1594                    a.meta.name.clone(),
1595                    a.meta.author.clone(),
1596                    a.meta.version.clone(),
1597                );
1598                let label = if a.label.is_some() {
1599                    "Captured"
1600                } else {
1601                    "Default"
1602                };
1603                let kind = match &self.loaded {
1604                    Loaded::Project(p) => format!("Project {}", p.dir.display()),
1605                    Loaded::Cart { path, .. } => format!("Cart {}", path.display()),
1606                    Loaded::None => unreachable!(),
1607                };
1608                self.say(&format!("Title:   {name}"), col::WHITE);
1609                self.say(&format!("Author:  {author}"), col::WHITE);
1610                self.say(&format!("Version: {version}"), col::WHITE);
1611                self.say(&format!("Label:   {label}"), col::LIGHT_GREY);
1612                self.say(&kind, col::LIGHT_GREY);
1613            }
1614        }
1615    }
1616
1617    fn cmd_ls(&mut self) -> Result<()> {
1618        let mut entries: Vec<_> = std::fs::read_dir(&self.cwd)?
1619            .filter_map(|e| e.ok())
1620            .map(|e| {
1621                let name = e.file_name().to_string_lossy().into_owned();
1622                let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
1623                (name, is_dir)
1624            })
1625            .filter(|(n, _)| !n.starts_with('.'))
1626            .collect();
1627        entries.sort();
1628        for (name, is_dir) in entries.into_iter().take(40) {
1629            if is_dir {
1630                self.say(&format!("{name}/"), col::BLUE);
1631            } else if name.ends_with(".png") {
1632                self.say(&name, col::PINK);
1633            } else {
1634                self.say(&name, col::LIGHT_GREY);
1635            }
1636        }
1637        Ok(())
1638    }
1639
1640    fn cmd_meta(&mut self, args: &[&str], set: impl FnOnce(&mut Assets, String)) -> Result<()> {
1641        if args.is_empty() {
1642            bail!("Missing text");
1643        }
1644        let value = args.join(" ");
1645        match self.assets_mut() {
1646            None => bail!("No cart loaded"),
1647            Some(a) => {
1648                set(a, value);
1649                self.say("OK", col::GREEN);
1650                Ok(())
1651            }
1652        }
1653    }
1654
1655    fn capture_label(&mut self) {
1656        let Some(vm) = &self.vm else { return };
1657        let pixels = vm.state().fb.pixels().to_vec();
1658        if let Some(a) = self.assets_mut() {
1659            a.label = Some(pixels);
1660        }
1661        // A brief on-screen camera flash, plus a console line for the record.
1662        self.capture_flash = CAPTURE_FLASH_FRAMES;
1663        self.say("Label captured", col::GREEN);
1664    }
1665
1666    fn stop_run(&mut self, message: &str) {
1667        self.vm = None;
1668        self.audio.stop_all();
1669        self.mode = Mode::Console;
1670        if !message.is_empty() {
1671            self.say(message, col::RED);
1672        }
1673    }
1674
1675    fn show_error(&mut self, phase: &str, message: &str) {
1676        self.stop_run("");
1677        self.say("", col::WHITE);
1678        self.say(&format!("** Error in {phase} **"), col::RED);
1679        for line in message.lines().take(12) {
1680            self.say(line, col::ORANGE);
1681        }
1682    }
1683
1684    fn runtime_error(&mut self, e: RuntimeError) {
1685        self.show_error(e.phase, &e.message);
1686    }
1687
1688    // -----------------------------------------------------------------
1689    // Per-frame logic
1690    // -----------------------------------------------------------------
1691
1692    /// The rate the host should tick at: a running cart's frame rate (30 or
1693    /// 60), else 30. Running the whole Run-mode tick at the cart's rate is
1694    /// what gets the display to refresh at 60 too.
1695    pub fn tick_fps(&self) -> u32 {
1696        match (self.mode, &self.vm) {
1697            (Mode::Run, Some(vm)) => vm.fps(),
1698            _ => UI_FPS,
1699        }
1700    }
1701
1702    pub fn tick(&mut self) {
1703        self.frame += 1;
1704
1705        // Poll the background build.
1706        if let Some(job) = &self.build {
1707            if let Some(result) = job.poll() {
1708                self.build = None;
1709                if result.success {
1710                    let msg = format!("Build ok ({:.1}s)", result.duration.as_secs_f32());
1711                    self.say(&msg, col::GREEN);
1712                    self.toast(&msg, col::GREEN, 2.0);
1713                    for w in &result.warnings {
1714                        self.say(w, col::ORANGE);
1715                    }
1716                    if self.run_after_build {
1717                        self.run_after_build = false;
1718                        if let Err(e) = self.start_vm_from_loaded() {
1719                            self.show_error("boot", &e.to_string());
1720                        }
1721                    }
1722                } else {
1723                    self.run_after_build = false;
1724                    // A failed build must never pass silently: whether it was
1725                    // kicked off from an editor or by an external edit while a
1726                    // cart is running, drop straight back to the console (and
1727                    // stop the now-stale cart) so the whole error list is on
1728                    // screen, rather than relying on a toast that the editor
1729                    // clips off the right edge of the screen.
1730                    self.stop_run("");
1731                    let n = result
1732                        .errors
1733                        .iter()
1734                        .filter(|l| l.starts_with("error"))
1735                        .count();
1736                    self.say(&format!("Build failed ({n} errors)"), col::RED);
1737                    for line in &result.errors {
1738                        let color = if line.starts_with("error") {
1739                            col::RED
1740                        } else {
1741                            col::ORANGE
1742                        };
1743                        self.say(line, color);
1744                    }
1745                }
1746            }
1747        }
1748
1749        self.poll_project_watch();
1750        self.poll_cart_watch();
1751        self.check_hot_reload();
1752
1753        match self.mode {
1754            Mode::Run => {
1755                if self.vm.is_some() {
1756                    let fps_val = self.fps_val;
1757                    let vm = self.vm.as_mut().unwrap();
1758                    vm.state_mut().set_measured_fps(fps_val);
1759                    let logs = std::mem::take(&mut vm.state_mut().logs);
1760                    let result = vm.call_update().and_then(|()| vm.call_draw());
1761                    let (cpu_u, cpu_d) = (vm.cpu_update(), vm.cpu_draw());
1762                    // Sample per *logical* frame, not per presented one: the presenter runs at its
1763                    // own cadence, so sampling in `draw` would count a 30 fps cart's frames twice
1764                    // on a 60 Hz window. Sample whether or not the overlay is showing, so F1
1765                    // reveals a settled reading straight away. The closure is the once-a-second
1766                    // memory read, and the meter calls it only when a window closes.
1767                    self.stats_meter
1768                        .sample(Instant::now(), cpu_u, cpu_d, || vm.mem_used_bytes());
1769                    for l in logs {
1770                        self.say(&l, col::LIGHT_GREY);
1771                    }
1772                    if let Err(e) = result {
1773                        self.runtime_error(e);
1774                    }
1775                } else {
1776                    self.mode = Mode::Console;
1777                }
1778            }
1779            Mode::Console => {}
1780            _ => {
1781                // Tab bar clicks work in every editor (but not while the picker is open).
1782                if !self.file_picker.is_open() {
1783                    if let Some(target) = ui::tab_bar_click(&self.mouse) {
1784                        self.switch_editor(EDITOR_MODES[target]);
1785                    }
1786                }
1787                let mouse = self.mouse;
1788                let audio = self.audio.clone();
1789                match self.mode {
1790                    Mode::Code => {
1791                        if self.file_picker.is_open() {
1792                            let files = self.project_file_names();
1793                            let refs: Vec<&str> = files.iter().map(String::as_str).collect();
1794                            if let Some(action) = self.file_picker.tick(&mouse, &refs) {
1795                                self.run_picker_action(action);
1796                            }
1797                        } else if !self.loaded_none() {
1798                            // Click the top-left filename to open the picker (projects only).
1799                            if ui::filename_clicked(&mouse, &self.current_file_name())
1800                                && matches!(self.loaded, Loaded::Project(_))
1801                            {
1802                                self.open_file_picker();
1803                            } else {
1804                                let code = self.code().unwrap_or_default().to_string();
1805                                self.code_ed.tick(&mouse, &code);
1806                            }
1807                        }
1808                    }
1809                    Mode::Sprite => {
1810                        if let Some(a) = assets_of(&mut self.loaded) {
1811                            self.sprite_ed.tick(&mouse, a);
1812                        }
1813                    }
1814                    Mode::Map => {
1815                        if let Some(a) = assets_of(&mut self.loaded) {
1816                            self.map_ed.tick(&mouse, a);
1817                        }
1818                    }
1819                    Mode::Sfx => {
1820                        if let Some(a) = assets_of(&mut self.loaded) {
1821                            self.sfx_ed.tick(&mouse, a, &audio);
1822                        }
1823                    }
1824                    Mode::Music => {
1825                        if let Some(a) = assets_of(&mut self.loaded) {
1826                            self.music_ed.tick(&mouse, a, &audio);
1827                        }
1828                        // The pencil on a channel jumps to that SFX for editing.
1829                        if let Some(n) = self.music_ed.take_edit_request() {
1830                            self.sfx_ed.select(n);
1831                            self.switch_editor(Mode::Sfx);
1832                        }
1833                    }
1834                    _ => {}
1835                }
1836            }
1837        }
1838        self.mouse.end_frame();
1839    }
1840
1841    fn check_hot_reload(&mut self) {
1842        if !self.frame.is_multiple_of(30) {
1843            return;
1844        }
1845        let Loaded::Project(p) = &self.loaded else {
1846            return;
1847        };
1848        let Ok(meta) = std::fs::metadata(p.wasm_path()) else {
1849            return;
1850        };
1851        let Ok(mtime) = meta.modified() else {
1852            return;
1853        };
1854        match self.wasm_mtime {
1855            Some(prev) if mtime > prev => {
1856                self.wasm_mtime = Some(mtime);
1857                // Only swap the running VM; in other modes the fresh wasm is
1858                // simply ready for the next run.
1859                if self.mode == Mode::Run {
1860                    match self.start_vm_from_loaded() {
1861                        Ok(()) => self.say("Hot reloaded", col::GREEN),
1862                        Err(e) => self.show_error("reload", &e.to_string()),
1863                    }
1864                }
1865            }
1866            Some(_) => {}
1867            None => self.wasm_mtime = Some(mtime),
1868        }
1869    }
1870
1871    /// Poll project watchers and react to external edits: adopt clean changes,
1872    /// warn on conflicts, and kick off a rebuild (code/source) or VM reload
1873    /// (assets). Runs on a 30-frame cadence; skipped while a build is in flight.
1874    fn poll_project_watch(&mut self) {
1875        if !self.frame.is_multiple_of(30) || self.build.is_some() {
1876            return;
1877        }
1878        let (code_mem, assets_mem) = match &self.loaded {
1879            Loaded::Project(p) => (
1880                p.code.clone().into_bytes(),
1881                encode_assets(&p.assets).unwrap_or_default(),
1882            ),
1883            _ => return,
1884        };
1885        let Some(w) = &mut self.project_watch else {
1886            return;
1887        };
1888        let assets_change = w.assets.poll(&assets_mem);
1889        let code_change = w.code.poll(&code_mem);
1890        let source_changed = w.source_tree.poll();
1891        let code_conflicted = w.code.in_conflict();
1892
1893        // Assets: no rebuild needed; adopt and reload the running VM.
1894        match assets_change {
1895            FileChange::Adopt(bytes) => match decode_assets(&bytes) {
1896                Ok(assets) => {
1897                    if let Loaded::Project(p) = &mut self.loaded {
1898                        p.assets = assets;
1899                    }
1900                    self.say("Assets reloaded from disk", col::GREEN);
1901                    if self.mode == Mode::Run {
1902                        if let Err(e) = self.start_vm_from_loaded() {
1903                            self.show_error("reload", &e.to_string());
1904                        }
1905                    }
1906                }
1907                // Malformed disk file: re-sync so we do not loop on it.
1908                Err(_) => {
1909                    self.resync_assets_watcher();
1910                    self.say("assets.pixel8.json on disk is unreadable", col::ORANGE);
1911                }
1912            },
1913            FileChange::Conflict => {
1914                self.say("assets.pixel8.json changed on disk;", col::ORANGE);
1915                self.say("You have unsaved edits", col::ORANGE);
1916            }
1917            FileChange::None => {}
1918        }
1919
1920        // Code: adopt into the editor; build is driven by source_changed below.
1921        match code_change {
1922            FileChange::Adopt(bytes) => {
1923                let text = String::from_utf8_lossy(&bytes).into_owned();
1924                self.code_ed.set_text(&text);
1925                if let Loaded::Project(p) = &mut self.loaded {
1926                    p.code = text;
1927                }
1928            }
1929            FileChange::Conflict => {
1930                self.say(
1931                    &format!("src/{} changed on disk;", self.current_file_name()),
1932                    col::ORANGE,
1933                );
1934                self.say("Save or reload to resolve", col::ORANGE);
1935            }
1936            FileChange::None => {}
1937        }
1938
1939        // Any source change (lib.rs or another module) rebuilds — unless the
1940        // mirrored code is in an unresolved conflict (we must not build a state
1941        // the user has not chosen).
1942        if source_changed && !code_conflicted {
1943            let dir = match &self.loaded {
1944                Loaded::Project(p) => p.dir.clone(),
1945                _ => return,
1946            };
1947            self.say("Source changed, rebuilding...", col::LIGHT_GREY);
1948            self.toast("Rebuilding...", col::LIGHT_GREY, 1.5);
1949            self.build = Some(spawn_build(&dir));
1950            // Re-run from the console or while already running; stay put if the
1951            // user is in an editor.
1952            self.run_after_build = matches!(self.mode, Mode::Run | Mode::Console);
1953        }
1954    }
1955
1956    /// Poll a loaded PNG cart's file: on external change re-parse and adopt it
1957    /// (when there are no in-console asset edits), else warn about a conflict.
1958    fn poll_cart_watch(&mut self) {
1959        if !self.frame.is_multiple_of(30) {
1960            return;
1961        }
1962        let (path, in_memory, baseline) = match (&self.loaded, &mut self.cart_watch) {
1963            (Loaded::Cart { cart, .. }, Some(w)) => {
1964                if w.advanced().is_none() {
1965                    return;
1966                }
1967                (
1968                    w.path.clone(),
1969                    encode_assets(&cart.assets).unwrap_or_default(),
1970                    w.baseline.clone(),
1971                )
1972            }
1973            _ => return,
1974        };
1975        let new_cart = match cart::load_png(&path) {
1976            Ok(c) => c,
1977            Err(e) => {
1978                self.show_error("reload", &e.to_string());
1979                return;
1980            }
1981        };
1982        let disk = encode_assets(&new_cart.assets).unwrap_or_default();
1983        match crate::watch::reconcile(&baseline, &disk, &in_memory) {
1984            crate::watch::Reconcile::Unchanged => {}
1985            crate::watch::Reconcile::Adopt(_) => {
1986                self.code_ed.set_text(
1987                    new_cart
1988                        .source
1989                        .as_deref()
1990                        .unwrap_or("// No source in this cart"),
1991                );
1992                if let Some(w) = &mut self.cart_watch {
1993                    w.baseline = disk;
1994                }
1995                self.loaded = Loaded::Cart {
1996                    cart: new_cart,
1997                    path,
1998                };
1999                self.say("Cart reloaded from disk", col::GREEN);
2000                if self.mode == Mode::Run {
2001                    if let Err(e) = self.start_vm_from_loaded() {
2002                        self.show_error("reload", &e.to_string());
2003                    }
2004                }
2005            }
2006            crate::watch::Reconcile::Conflict => {
2007                // No latch: each new external write re-warns. The `reload`
2008                // command (next task) takes the disk version to resolve this.
2009                self.say("Cart changed on disk;", col::ORANGE);
2010                self.say("You have unsaved edits", col::ORANGE);
2011            }
2012        }
2013    }
2014
2015    // -----------------------------------------------------------------
2016    // Drawing
2017    // -----------------------------------------------------------------
2018
2019    /// Draw the current mode and return the framebuffer to present.
2020    /// Count presented frames over ~0.5 s windows for the fps meter. The
2021    /// cart can't measure this itself — `time()` is a logical clock — so the
2022    /// host counts real draws against the wall clock.
2023    fn meter_fps(&mut self) {
2024        self.fps_frames += 1;
2025        let elapsed = self.fps_t0.elapsed();
2026        if elapsed >= Duration::from_millis(500) {
2027            self.fps_val = self.fps_frames as f32 / elapsed.as_secs_f32();
2028            self.fps_frames = 0;
2029            self.fps_t0 = Instant::now();
2030        }
2031    }
2032
2033    pub fn draw(&mut self) -> &Framebuffer {
2034        self.meter_fps();
2035        match self.mode {
2036            Mode::Run => {
2037                if self.show_stats {
2038                    let fps = self.fps_val;
2039                    // Everything but the fps comes from the one-second meter `tick` feeds, so the
2040                    // presenter's own cadence cannot make the rows churn.
2041                    let cpu_u = self.stats_meter.cpu_update();
2042                    let cpu_d = self.stats_meter.cpu_draw();
2043                    let used = self.stats_meter.mem_used();
2044                    if let Some(vm) = self.vm.as_mut() {
2045                        let target = vm.fps();
2046                        stats_overlay(&mut vm.state_mut().fb, cpu_u, cpu_d, used, fps, target);
2047                    }
2048                }
2049                if self.capture_flash > 0 {
2050                    if let Some(vm) = self.vm.as_mut() {
2051                        capture_flash_overlay(&mut vm.state_mut().fb);
2052                    }
2053                    self.capture_flash -= 1;
2054                }
2055                if let Some(vm) = &self.vm {
2056                    return &vm.state().fb;
2057                }
2058                &self.fb
2059            }
2060            Mode::Console => {
2061                self.draw_console();
2062                &self.fb
2063            }
2064            _ => {
2065                self.fb.reset_state();
2066                self.fb.cls(col::DARK_GREY);
2067                let mouse = self.mouse;
2068                match self.mode {
2069                    Mode::Code => {
2070                        let code = self.code().unwrap_or_default().to_string();
2071                        self.code_ed.draw(&mut self.fb, &code);
2072                        if self.file_picker.is_open() {
2073                            let files = self.project_file_names();
2074                            let refs: Vec<&str> = files.iter().map(String::as_str).collect();
2075                            let current = self.current_file_name();
2076                            self.file_picker.draw(&mut self.fb, &refs, &current);
2077                        }
2078                    }
2079                    Mode::Sprite => {
2080                        if let Some(a) = assets_ref(&self.loaded) {
2081                            self.sprite_ed.draw(&mut self.fb, a);
2082                        }
2083                    }
2084                    Mode::Map => {
2085                        if let Some(a) = assets_ref(&self.loaded) {
2086                            self.map_ed.draw(&mut self.fb, a);
2087                        }
2088                    }
2089                    Mode::Sfx => {
2090                        if let Some(a) = assets_ref(&self.loaded) {
2091                            self.sfx_ed.draw(&mut self.fb, a, &self.audio);
2092                        }
2093                    }
2094                    Mode::Music => {
2095                        if let Some(a) = assets_ref(&self.loaded) {
2096                            self.music_ed.draw(&mut self.fb, a, &self.audio);
2097                        }
2098                    }
2099                    _ => {}
2100                }
2101                ui::draw_tab_bar(&mut self.fb, self.mode);
2102                // Per-editor top-left content: the code filename (click to pick
2103                // a file), the SFX mode buttons, and the sprite/map view buttons.
2104                match self.mode {
2105                    Mode::Code => {
2106                        let name = self.current_file_name();
2107                        ui::code_filename(&mut self.fb, &name);
2108                    }
2109                    Mode::Sfx => ui::mode_buttons(&mut self.fb, self.sfx_ed.is_pitch()),
2110                    Mode::Sprite => ui::view_buttons(&mut self.fb, self.sprite_ed.is_fullscreen()),
2111                    Mode::Map => ui::view_buttons(&mut self.fb, self.map_ed.is_fullscreen()),
2112                    Mode::Music => {
2113                        ui::mode_buttons(&mut self.fb, !self.music_ed.is_grid());
2114                        if self.music_ed.is_grid() {
2115                            ui::pat_sfx_toggle(&mut self.fb, self.music_ed.grid_sfx());
2116                        }
2117                    }
2118                    _ => {}
2119                }
2120                self.draw_toast();
2121                // Name the hovered view in the bottom bar, but not while the
2122                // file picker is open — its tabs are inert then, so a hint
2123                // would invite a click that does nothing.
2124                if !self.file_picker.is_open() {
2125                    if let Some(i) = ui::tab_bar_hover(&mouse) {
2126                        ui::status_bar(&mut self.fb, ui::tab_name(i));
2127                    }
2128                }
2129                if !self.hide_cursor {
2130                    ui::draw_cursor(&mut self.fb, &mouse);
2131                }
2132                &self.fb
2133            }
2134        }
2135    }
2136
2137    /// Bottom-bar feedback in editor modes: a live "building..." while a
2138    /// build runs, otherwise the most recent toast until it expires.
2139    fn draw_toast(&mut self) {
2140        let msg = if self.build.is_some() {
2141            let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
2142            Some((format!("Building{dots}"), col::ORANGE))
2143        } else {
2144            match &self.toast {
2145                Some((text, color, expires)) if self.frame < *expires => {
2146                    Some((text.clone(), *color))
2147                }
2148                _ => {
2149                    self.toast = None;
2150                    None
2151                }
2152            }
2153        };
2154        if let Some((text, color)) = msg {
2155            self.fb.rectfill(0, 120, 127, 127, col::BLACK);
2156            self.fb.print(&text, 2, 121, color);
2157        }
2158    }
2159
2160    fn draw_console(&mut self) {
2161        self.fb.reset_state();
2162        self.fb.cls(col::BLACK);
2163        // Lines fit between the top margin and the bottom status line, scaled
2164        // to the font's line height.
2165        let rows = ((120 - 2) / font::GLYPH_H) as usize;
2166
2167        // Gather visible lines: history tail + prompt line.
2168        let total = self.lines.len();
2169        let end = total.saturating_sub(self.scroll_back);
2170        let start = end.saturating_sub(rows);
2171        let mut y = 2;
2172        for line in self.lines.iter().skip(start).take(end - start) {
2173            match line {
2174                ConsoleLine::Text { text, color } => {
2175                    self.fb.print(text, 2, y, *color);
2176                }
2177                ConsoleLine::Stripe => {
2178                    for (i, c) in [8u8, 9, 10, 11, 12, 13, 14, 15].iter().enumerate() {
2179                        self.fb
2180                            .rectfill(2 + i as i32 * 6, y, 2 + i as i32 * 6 + 4, y + 3, *c);
2181                    }
2182                }
2183            }
2184            y += font::GLYPH_H;
2185        }
2186
2187        // Prompt with blinking cursor (skipped while compiling).
2188        if self.build.is_some() {
2189            let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
2190            self.fb
2191                .print(&format!("Compiling{dots}"), 2, y, col::ORANGE);
2192            return;
2193        }
2194        let prompt = format!("> {}", self.input);
2195        self.fb.print(&prompt, 2, y, PROMPT_COL);
2196        if (self.frame / 8).is_multiple_of(2) {
2197            let cx = 2 + (2 + self.cursor as i32) * 4;
2198            self.fb
2199                .rectfill(cx, y, cx + 3, y + font::GLYPH_H - 2, col::RED);
2200        }
2201    }
2202}
2203
2204/// The value following a flag, rejecting a missing value or another flag taken
2205/// as the value (e.g. `--into --sfx 0`).
2206fn flag_value<'a>(next: Option<&'a &'a str>, flag: &str) -> Result<&'a str> {
2207    match next {
2208        Some(&v) if !v.starts_with("--") => Ok(v),
2209        _ => bail!("{flag} needs a value"),
2210    }
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215    use super::*;
2216    use std::path::Path;
2217
2218    #[test]
2219    fn stats_overlay_draws_top_right_panel() {
2220        let text_pixels = |fb: &Framebuffer, xr: std::ops::Range<i32>| {
2221            let mut n = 0;
2222            for y in 0..28 {
2223                for x in xr.clone() {
2224                    if fb.pget(x, y) != col::BLACK {
2225                        n += 1;
2226                    }
2227                }
2228            }
2229            n
2230        };
2231        let mut fb = Framebuffer::new();
2232        stats_overlay(&mut fb, 0.342, 0.51, 32768, 30.0, 30);
2233        // With one-decimal CPU the panel widens (12-char rows -> x0=78), so the
2234        // decimal digit shows colored text in 78..86 — blank with the old
2235        // integer panel (x0=86). This is what makes the test red-before-green.
2236        assert!(
2237            text_pixels(&fb, 78..86) > 0,
2238            "decimal CPU widened the panel left"
2239        );
2240        assert_eq!(
2241            text_pixels(&fb, 0..60),
2242            0,
2243            "overlay leaves the top-left untouched"
2244        );
2245    }
2246
2247    /// A blank meter plus the instant its first window starts from. Tests step that instant by
2248    /// hand so nothing has to sleep.
2249    fn stats_meter() -> (StatsMeter, Instant) {
2250        (StatsMeter::default(), Instant::now())
2251    }
2252
2253    /// Drive one logical frame the way `tick` does, with `mem` standing in for the wasm call.
2254    /// `reads` collects every reading the meter asks for, so a test can see both how often the VM
2255    /// would have been called and what it answered.
2256    fn stats_frame(
2257        m: &mut StatsMeter,
2258        now: Instant,
2259        cpu: (f32, f32),
2260        mem: u32,
2261        reads: &mut Vec<u32>,
2262    ) {
2263        m.sample(now, cpu.0, cpu.1, || {
2264            reads.push(mem);
2265            mem
2266        });
2267    }
2268
2269    #[test]
2270    fn stats_meter_publishes_its_first_sample_at_once() {
2271        let (mut m, t) = stats_meter();
2272        let mut reads = Vec::new();
2273        assert_eq!((m.cpu_update(), m.mem_used()), (0.0, 0));
2274        stats_frame(&mut m, t, (0.4, 0.2), 4096, &mut reads);
2275        assert_eq!((m.cpu_update(), m.cpu_draw()), (0.4, 0.2));
2276        // Without this the overlay would read 0K for the cart's first second.
2277        assert_eq!(m.mem_used(), 4096);
2278    }
2279
2280    /// The bug this meter exists for: within one window the reading must not move, however wildly
2281    /// the per-frame fuel fractions swing.
2282    #[test]
2283    fn stats_meter_holds_its_reading_within_the_window() {
2284        let (mut m, t) = stats_meter();
2285        m.sample(t, 0.4, 0.2, || 0);
2286        for (i, cpu) in [0.9_f32, 0.05, 0.7, 0.01, 0.99].iter().enumerate() {
2287            m.sample(
2288                t + Duration::from_millis(100 * (i as u64 + 1)),
2289                *cpu,
2290                *cpu,
2291                || 0,
2292            );
2293            let held = (m.cpu_update(), m.cpu_draw());
2294            assert_eq!(held, (0.4, 0.2), "ratcheted mid-window");
2295        }
2296    }
2297
2298    #[test]
2299    fn stats_meter_publishes_the_window_peak_at_the_boundary() {
2300        let (mut m, t) = stats_meter();
2301        // The first sample publishes on the spot, opening an empty window at `t`; of the three
2302        // that follow, only the worst of each column survives to the boundary.
2303        m.sample(t, 0.9, 0.9, || 0);
2304        m.sample(t + Duration::from_millis(200), 0.2, 0.5, || 0);
2305        m.sample(t + Duration::from_millis(500), 0.6, 0.3, || 0);
2306        m.sample(t + Duration::from_millis(1000), 0.4, 0.1, || 0);
2307        assert_eq!((m.cpu_update(), m.cpu_draw()), (0.6, 0.5));
2308    }
2309
2310    #[test]
2311    fn stats_meter_starts_a_fresh_window_after_publishing() {
2312        let (mut m, t) = stats_meter();
2313        m.sample(t, 1.0, 1.0, || 0);
2314        m.sample(t + Duration::from_millis(1000), 1.0, 1.0, || 0);
2315        // A second window of nothing but 0.1 must read 0.1: the 1.0s of the window before it are
2316        // gone, not carried forward as a high-water mark.
2317        m.sample(t + Duration::from_millis(1500), 0.1, 0.1, || 0);
2318        m.sample(t + Duration::from_millis(2000), 0.1, 0.1, || 0);
2319        assert_eq!((m.cpu_update(), m.cpu_draw()), (0.1, 0.1));
2320    }
2321
2322    /// A cart that goes idle after a busy spell must fall back to 0.0 rather than keep showing the
2323    /// 0.5 of the window before it: the published peak is that window's own, not a high-water mark
2324    /// over the whole session.
2325    #[test]
2326    fn stats_meter_publishes_a_window_of_zeros_as_zero() {
2327        let (mut m, t) = stats_meter();
2328        m.sample(t, 0.5, 0.5, || 0);
2329        assert_eq!((m.cpu_update(), m.cpu_draw()), (0.5, 0.5));
2330        for i in 1..=10 {
2331            m.sample(t + Duration::from_millis(100 * i), 0.0, 0.0, || 0);
2332        }
2333        assert_eq!((m.cpu_update(), m.cpu_draw()), (0.0, 0.0));
2334    }
2335
2336    /// A cart stalling for longer than the whole window leaves that window a single sample, which
2337    /// must still publish rather than wait for company.
2338    #[test]
2339    fn stats_meter_publishes_a_frame_longer_than_the_window() {
2340        let (mut m, t) = stats_meter();
2341        let mut reads = Vec::new();
2342        stats_frame(&mut m, t, (0.1, 0.1), 1024, &mut reads);
2343        stats_frame(
2344            &mut m,
2345            t + Duration::from_secs(4),
2346            (0.8, 0.7),
2347            2048,
2348            &mut reads,
2349        );
2350        assert_eq!((m.cpu_update(), m.cpu_draw()), (0.8, 0.7));
2351        assert_eq!(reads, [1024, 2048]);
2352    }
2353
2354    #[test]
2355    fn stats_meter_publishes_memory_on_the_window_beat() {
2356        let (mut m, t) = stats_meter();
2357        let mut reads = Vec::new();
2358        stats_frame(&mut m, t, (0.1, 0.1), 1024, &mut reads);
2359        stats_frame(
2360            &mut m,
2361            t + Duration::from_millis(500),
2362            (0.1, 0.1),
2363            8192,
2364            &mut reads,
2365        );
2366        assert_eq!(m.mem_used(), 1024, "mid-window memory must not move");
2367        stats_frame(
2368            &mut m,
2369            t + Duration::from_millis(1000),
2370            (0.1, 0.1),
2371            8192,
2372            &mut reads,
2373        );
2374        assert_eq!(m.mem_used(), 8192);
2375    }
2376
2377    /// Memory costs a wasm call into `pixel8_mem_used`, and the figure it returns only ratchets
2378    /// up, so the boundary reading is already the window's peak: one call a second, not one a
2379    /// frame.
2380    #[test]
2381    fn stats_meter_reads_memory_once_per_window() {
2382        let (mut m, t) = stats_meter();
2383        let mut reads = Vec::new();
2384        // Three seconds of 60 fps frames, the last landing exactly on the third boundary.
2385        for i in 0..=180 {
2386            let now = t + Duration::from_millis(1000 * i / 60);
2387            stats_frame(&mut m, now, (0.1, 0.1), 1024 * (i as u32 + 1), &mut reads);
2388        }
2389        // The immediate first publish, then one per second closed — not one per frame.
2390        assert_eq!(reads.len(), 4, "read memory {} times, not 4", reads.len());
2391    }
2392
2393    #[test]
2394    fn stats_meter_reset_clears_the_reading() {
2395        let (mut m, t) = stats_meter();
2396        let mut reads = Vec::new();
2397        stats_frame(&mut m, t, (0.5, 0.5), 4096, &mut reads);
2398        m.reset();
2399        assert_eq!((m.cpu_update(), m.cpu_draw()), (0.0, 0.0));
2400        assert_eq!(m.mem_used(), 0, "kept the old cart's memory figure");
2401        // Reset re-arms the publish-at-once path, so the new cart's very first frame shows
2402        // immediately instead of waiting out a window.
2403        stats_frame(
2404            &mut m,
2405            t + Duration::from_millis(10),
2406            (0.3, 0.7),
2407            512,
2408            &mut reads,
2409        );
2410        assert_eq!(
2411            (m.cpu_update(), m.cpu_draw(), m.mem_used()),
2412            (0.3, 0.7, 512)
2413        );
2414    }
2415
2416    fn test_shell() -> Shell {
2417        let sdk = Path::new(env!("CARGO_MANIFEST_DIR")).join("../pixel8");
2418        let mut shell = Shell::new(AudioHandle::dummy(), sdk);
2419        // Keep cart saves out of the real user cache directory: tests that
2420        // run carts must be hermetic.
2421        shell.storage_root =
2422            Some(std::env::temp_dir().join(format!("pixel8_test_storage_{}", std::process::id())));
2423        shell
2424    }
2425
2426    /// Restarting a cart must save the old VM's storage *before* the new VM
2427    /// loads its copy from disk — the `self.vm = None` at the top of
2428    /// `start_vm_from_loaded` is load-bearing. The cart increments a stored
2429    /// counter in `pixel8_init`; two starts must produce 2, not 1.
2430    #[test]
2431    fn restart_saves_storage_before_the_new_vm_loads() {
2432        use pixel8_runtime::{assets::Assets, cart::Cart, storage::Storage};
2433        const COUNTER_CART: &str = r#"
2434            (module
2435              (import "pixel8" "storage_get" (func $sget (param i32 i32 i32 i32) (result i32)))
2436              (import "pixel8" "storage_set" (func $sset (param i32 i32 i32 i32) (result i32)))
2437              (memory (export "memory") 1)
2438              (data (i32.const 0) "n")
2439              (func (export "pixel8_init")
2440                (local $n i32)
2441                (if (i32.eq (call $sget (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))
2442                            (i32.const 1))
2443                  (then (local.set $n (i32.sub (i32.load8_u (i32.const 8)) (i32.const 48)))))
2444                (i32.store8 (i32.const 8) (i32.add (i32.const 49) (local.get $n)))
2445                (drop (call $sset (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))))
2446              (func (export "pixel8_update"))
2447              (func (export "pixel8_draw")))
2448        "#;
2449        let root = std::env::temp_dir().join(format!("pixel8_restart_{}", std::process::id()));
2450        let _ = std::fs::remove_dir_all(&root);
2451        let mut shell = test_shell();
2452        shell.storage_root = Some(root.clone());
2453        shell.loaded = Loaded::Cart {
2454            cart: Cart {
2455                wasm: wat::parse_str(COUNTER_CART).unwrap(),
2456                assets: Assets::default(),
2457                source: None,
2458            },
2459            path: PathBuf::from("counter.png"),
2460        };
2461        shell.start_vm_from_loaded().expect("first start"); // n = 1
2462        shell.start_vm_from_loaded().expect("restart"); // saves 1, reads it, n = 2
2463        shell.vm = None; // Final save.
2464        let s = Storage::for_cart_in(&root, &Assets::default().meta.name);
2465        assert_eq!(
2466            s.get_json("n").as_deref(),
2467            Some("2"),
2468            "the restart must persist the first run's write before the second reads"
2469        );
2470        std::fs::remove_dir_all(&root).unwrap();
2471    }
2472
2473    /// The once-a-second memory read is a decision about the *call sites*: `tick` asks the VM only
2474    /// on the frame that closes a window, and `draw` no longer asks it at all. Neither is visible
2475    /// to `StatsMeter`'s own tests, so drive a real `Shell` over a cart that counts the calls.
2476    #[test]
2477    fn running_a_cart_reads_its_memory_once_a_second() {
2478        use pixel8_runtime::{assets::Assets, cart::Cart};
2479        // `pixel8_mem_used` answers 1K times the number of times it has been called, so whatever
2480        // figure the shell ends up holding names the call it came from.
2481        const COUNTING_CART: &str = r#"
2482            (module
2483              (memory (export "memory") 1)
2484              (global $calls (mut i32) (i32.const 0))
2485              (func (export "pixel8_init"))
2486              (func (export "pixel8_update"))
2487              (func (export "pixel8_draw"))
2488              (func (export "pixel8_mem_used") (result i32)
2489                (global.set $calls (i32.add (global.get $calls) (i32.const 1)))
2490                (i32.mul (global.get $calls) (i32.const 1024))))
2491        "#;
2492        let mut shell = test_shell();
2493        shell.loaded = Loaded::Cart {
2494            cart: Cart {
2495                wasm: wat::parse_str(COUNTING_CART).unwrap(),
2496                assets: Assets::default(),
2497                source: None,
2498            },
2499            path: PathBuf::from("counting.png"),
2500        };
2501        shell.show_stats = true;
2502        shell.start_vm_from_loaded().expect("start");
2503        // Thirty logical frames all land inside the first window, each with a presented frame over
2504        // the top of it.
2505        for _ in 0..30 {
2506            shell.tick();
2507            shell.draw();
2508        }
2509        assert_eq!(
2510            shell.stats_meter.mem_used(),
2511            1024,
2512            "the overlay is not holding the first frame's reading"
2513        );
2514        // The cart's counter is the ground truth: exactly one call so far makes this probe the
2515        // second, whatever the meter chose to keep.
2516        let probe = shell.vm.as_mut().unwrap().mem_used_bytes();
2517        assert_eq!(
2518            probe,
2519            2048,
2520            "pixel8_mem_used ran {} times over 30 frames, not once",
2521            probe / 1024 - 1
2522        );
2523    }
2524
2525    /// Rewrite a freshly-scaffolded project's `pixel8` git dep to a path dep on the
2526    /// in-tree SDK, so test builds match this host's ABI and stay offline.
2527    fn point_cart_at_local_sdk(project_dir: &Path) {
2528        let sdk = Path::new(env!("CARGO_MANIFEST_DIR"))
2529            .join("../pixel8")
2530            .canonicalize()
2531            .unwrap();
2532        let manifest_path = project_dir.join("Cargo.toml");
2533        let manifest = std::fs::read_to_string(&manifest_path).unwrap();
2534        // A scaffolded project depends on the released SDK; tests must build against the working
2535        // tree instead. Every workspace crate shares one version, so this crate's major.minor is
2536        // the requirement the template wrote.
2537        let dep = format!(
2538            "version = \"{}.{}\"",
2539            env!("CARGO_PKG_VERSION_MAJOR"),
2540            env!("CARGO_PKG_VERSION_MINOR"),
2541        );
2542        let patched = manifest.replace(&dep, &format!("path = {:?}", sdk.display().to_string()));
2543        assert_ne!(patched, manifest, "no `{dep}` to redirect in:\n{manifest}");
2544        std::fs::write(&manifest_path, patched).unwrap();
2545    }
2546
2547    #[test]
2548    fn window_title_reflects_loaded_cart() {
2549        let dir = std::env::temp_dir().join(format!("pixel8_title_{}", std::process::id()));
2550        let _ = std::fs::remove_dir_all(&dir);
2551        let mut shell = test_shell();
2552        assert_eq!(shell.window_title(), "Pixel8");
2553
2554        let project_dir = dir.join("game");
2555        Project::create(&project_dir, "game").unwrap();
2556        shell
2557            .cmd_load(&[project_dir.to_str().unwrap()])
2558            .expect("load project");
2559        assert_eq!(shell.window_title(), "game - Pixel8");
2560        std::fs::remove_dir_all(&dir).unwrap();
2561    }
2562
2563    /// Ctrl+S in an editor saves, flashes feedback, kicks off a real
2564    /// background build, and reports the result in the bottom bar.
2565    #[test]
2566    fn ctrl_s_saves_and_builds_with_feedback() {
2567        let dir = std::env::temp_dir().join(format!("pixel8_shell_test_{}", std::process::id()));
2568        let _ = std::fs::remove_dir_all(&dir);
2569        let mut shell = test_shell();
2570        let project_dir = dir.join("game");
2571        Project::create(&project_dir, "game").unwrap();
2572        point_cart_at_local_sdk(&project_dir);
2573
2574        shell
2575            .cmd_load(&[project_dir.to_str().unwrap()])
2576            .expect("load project");
2577        shell.switch_editor(Mode::Code);
2578
2579        // Add a comment line at the top (keeping the code valid), then Ctrl+S.
2580        for c in "//x".chars() {
2581            shell.key(Key::Char(c), Mods::default());
2582        }
2583        shell.key(Key::Enter, Mods::default());
2584        shell.key(
2585            Key::Char('s'),
2586            Mods {
2587                ctrl: true,
2588                ..Default::default()
2589            },
2590        );
2591
2592        // Saved to disk, toast shown, build started.
2593        let code = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
2594        assert!(code.starts_with("//x\n"), "edit was saved");
2595        assert_eq!(shell.toast.as_ref().unwrap().0, "Saved");
2596        assert!(shell.build.is_some(), "background build spawned");
2597
2598        // While building, the editor bottom bar shows progress.
2599        shell.draw();
2600        assert_eq!(shell.fb.pget(0, 120), col::BLACK, "toast bar drawn");
2601
2602        // Wait for the real cargo build (template code must compile).
2603        for _ in 0..(120 * 30) {
2604            shell.tick();
2605            if shell.build.is_none() {
2606                break;
2607            }
2608            std::thread::sleep(std::time::Duration::from_millis(33));
2609        }
2610        assert!(shell.build.is_none(), "build finished in time");
2611        let (text, color, _) = shell.toast.as_ref().unwrap();
2612        assert!(text.starts_with("Build ok"), "got: {text}");
2613        assert_eq!(*color, col::GREEN);
2614        assert!(
2615            shell.mode == Mode::Code,
2616            "stays in the editor; no mode switch"
2617        );
2618        std::fs::remove_dir_all(&dir).unwrap();
2619    }
2620
2621    /// A broken cart reports a failing build without leaving the editor.
2622    #[test]
2623    fn save_build_failure_is_reported() {
2624        let dir = std::env::temp_dir().join(format!("pixel8_shell_fail_{}", std::process::id()));
2625        let _ = std::fs::remove_dir_all(&dir);
2626        let mut shell = test_shell();
2627        let project_dir = dir.join("game");
2628        let mut project = Project::create(&project_dir, "game").unwrap();
2629        project.code = "fn broken( {".into();
2630        project.save().unwrap();
2631
2632        shell
2633            .cmd_load(&[project_dir.to_str().unwrap()])
2634            .expect("load project");
2635        shell.switch_editor(Mode::Code);
2636        shell.key(
2637            Key::Char('s'),
2638            Mods {
2639                ctrl: true,
2640                ..Default::default()
2641            },
2642        );
2643        assert!(shell.build.is_some());
2644        for _ in 0..(120 * 30) {
2645            shell.tick();
2646            if shell.build.is_none() {
2647                break;
2648            }
2649            std::thread::sleep(std::time::Duration::from_millis(33));
2650        }
2651        // The build-failure summary is now printed to the console buffer (not
2652        // the toast), and the mode drops back to Console.
2653        assert_eq!(
2654            shell.mode,
2655            Mode::Console,
2656            "expected console mode after build failure"
2657        );
2658        let summary = shell.lines.iter().find_map(|l| match l {
2659            ConsoleLine::Text { text, color } if text.starts_with("Build failed") => {
2660                Some((text.clone(), *color))
2661            }
2662            _ => None,
2663        });
2664        let (text, color) = summary.expect("Build failed summary not found in console lines");
2665        assert!(text.starts_with("Build failed"), "got: {text}");
2666        assert_eq!(color, col::RED);
2667        std::fs::remove_dir_all(&dir).unwrap();
2668    }
2669
2670    /// `run` after an external edit (clean in-console state) builds the
2671    /// external version and does NOT overwrite it with the stale in-memory copy.
2672    #[test]
2673    fn run_does_not_clobber_external_edits() {
2674        let dir = std::env::temp_dir().join(format!("pixel8_run_noclobber_{}", 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");
2682
2683        // Simulate an external editor changing src/lib.rs to a still-valid file.
2684        let lib = project_dir.join("src/lib.rs");
2685        let original = std::fs::read_to_string(&lib).unwrap();
2686        let edited = format!("// EXTERNAL EDIT\n{original}");
2687        // Bump mtime so the watcher sees it as newer than load time.
2688        std::fs::write(&lib, &edited).unwrap();
2689        let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2690        std::fs::OpenOptions::new()
2691            .write(true)
2692            .open(&lib)
2693            .unwrap()
2694            .set_modified(later)
2695            .unwrap();
2696
2697        shell.cmd_run();
2698
2699        // The on-disk file must still contain the external edit — not be
2700        // reverted to the stale in-memory copy.
2701        let after = std::fs::read_to_string(&lib).unwrap();
2702        assert!(
2703            after.starts_with("// EXTERNAL EDIT\n"),
2704            "external edit survived run; got:\n{after}"
2705        );
2706
2707        // Let the build finish so we leave no thread dangling.
2708        for _ in 0..(120 * 30) {
2709            shell.tick();
2710            if shell.build.is_none() {
2711                break;
2712            }
2713            std::thread::sleep(std::time::Duration::from_millis(33));
2714        }
2715        std::fs::remove_dir_all(&dir).unwrap();
2716    }
2717
2718    /// `startup_run` (the `pixel8 run <path>` launch mode) loads-then-runs: on a
2719    /// project it kicks off the async build and arms the deferred run, exactly
2720    /// as the in-console `run` verb does.
2721    #[test]
2722    fn startup_run_arms_the_deferred_run() {
2723        let dir = std::env::temp_dir().join(format!("pixel8_startup_run_{}", std::process::id()));
2724        let _ = std::fs::remove_dir_all(&dir);
2725        let mut shell = test_shell();
2726        let project_dir = dir.join("game");
2727        Project::create(&project_dir, "game").unwrap();
2728
2729        shell.startup_load(project_dir.to_str().unwrap());
2730        assert_eq!(shell.mode, Mode::Console, "loaded, still at the console");
2731
2732        shell.startup_run();
2733        assert!(shell.build.is_some(), "startup_run spawns the build");
2734        assert!(
2735            shell.run_after_build,
2736            "and arms the run to start once it is built"
2737        );
2738
2739        // The build runs on a detached thread; don't wait for cargo, just clean up.
2740        std::fs::remove_dir_all(&dir).unwrap();
2741    }
2742
2743    /// `new` must arm the disk watcher, otherwise in-console edits are dropped
2744    /// before the build (reconcile_for_build skips the flush when unwatched).
2745    #[test]
2746    fn new_arms_the_project_watcher() {
2747        let dir = std::env::temp_dir().join(format!("pixel8_new_watch_{}", std::process::id()));
2748        let _ = std::fs::remove_dir_all(&dir);
2749        let mut shell = test_shell();
2750        shell.cwd = dir.clone();
2751        shell.cmd_new(&["game"]).expect("new");
2752        assert!(shell.project_watch.is_some(), "new should arm the watcher");
2753        std::fs::remove_dir_all(&dir).unwrap();
2754    }
2755
2756    /// Editing project source externally while idle at the console triggers an
2757    /// automatic build and starts the cart running.
2758    #[test]
2759    fn external_edit_auto_builds_and_runs() {
2760        let dir = std::env::temp_dir().join(format!("pixel8_autobuild_{}", std::process::id()));
2761        let _ = std::fs::remove_dir_all(&dir);
2762        let mut shell = test_shell();
2763        let project_dir = dir.join("game");
2764        Project::create(&project_dir, "game").unwrap();
2765        point_cart_at_local_sdk(&project_dir);
2766
2767        shell
2768            .cmd_load(&[project_dir.to_str().unwrap()])
2769            .expect("load");
2770        assert_eq!(shell.mode, Mode::Console);
2771
2772        // External edit, mtime bumped so the watcher sees it.
2773        let lib = project_dir.join("src/lib.rs");
2774        let original = std::fs::read_to_string(&lib).unwrap();
2775        std::fs::write(&lib, format!("// auto\n{original}")).unwrap();
2776        let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2777        std::fs::OpenOptions::new()
2778            .write(true)
2779            .open(&lib)
2780            .unwrap()
2781            .set_modified(later)
2782            .unwrap();
2783
2784        // Drive ticks: poll fires on a 30-frame cadence, then the build runs.
2785        let mut entered_run = false;
2786        for _ in 0..(180 * 30) {
2787            shell.tick();
2788            if shell.mode == Mode::Run {
2789                entered_run = true;
2790                break;
2791            }
2792            std::thread::sleep(std::time::Duration::from_millis(33));
2793        }
2794        assert!(entered_run, "external edit should auto-build and run");
2795        std::fs::remove_dir_all(&dir).unwrap();
2796    }
2797
2798    /// Re-exporting a loaded PNG cart on disk reloads it (assets adopted) when
2799    /// there are no in-console edits.
2800    #[test]
2801    fn external_png_change_reloads_cart() {
2802        use pixel8_runtime::cart::{self, Cart};
2803        let dir = std::env::temp_dir().join(format!("pixel8_pngreload_{}", std::process::id()));
2804        let _ = std::fs::remove_dir_all(&dir);
2805        std::fs::create_dir_all(&dir).unwrap();
2806        let mut shell = test_shell();
2807
2808        let project_dir = dir.join("game");
2809        let project = Project::create(&project_dir, "game").unwrap();
2810        let png = dir.join("game.png");
2811
2812        // A valid cart needs the 4-byte wasm magic (plus version); the codec
2813        // checks for it on save and load. The VM never runs here.
2814        let mut cart = Cart {
2815            wasm: b"\0asm\x01\0\0\0".to_vec(),
2816            assets: project.assets.clone(),
2817            source: Some("// v1".into()),
2818        };
2819        cart::save_png(&cart, &png).unwrap();
2820        shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
2821        let before = shell.cart_name();
2822
2823        // Re-export with a different cart name, bump mtime.
2824        cart.assets.meta.name = "renamed".into();
2825        cart::save_png(&cart, &png).unwrap();
2826        let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2827        std::fs::OpenOptions::new()
2828            .write(true)
2829            .open(&png)
2830            .unwrap()
2831            .set_modified(later)
2832            .unwrap();
2833
2834        for _ in 0..(60 * 30) {
2835            shell.tick();
2836            if shell.cart_name() != before {
2837                break;
2838            }
2839            std::thread::sleep(std::time::Duration::from_millis(33));
2840        }
2841        assert_eq!(shell.cart_name(), "renamed", "external PNG change adopted");
2842        std::fs::remove_dir_all(&dir).unwrap();
2843    }
2844
2845    /// Saving a PNG cart re-baselines its watcher, so the next poll does not
2846    /// mistake pixel8's own write for an external change (no false conflict).
2847    #[test]
2848    fn saving_png_does_not_self_conflict() {
2849        use pixel8_runtime::{
2850            cart::{self, Cart},
2851            project::encode_assets,
2852        };
2853        let dir = std::env::temp_dir().join(format!("pixel8_pngsave_{}", std::process::id()));
2854        let _ = std::fs::remove_dir_all(&dir);
2855        std::fs::create_dir_all(&dir).unwrap();
2856        let mut shell = test_shell();
2857        let project = Project::create(&dir.join("game"), "game").unwrap();
2858        let png = dir.join("game.png");
2859        let cart = Cart {
2860            wasm: b"\0asm\x01\0\0\0".to_vec(),
2861            assets: project.assets.clone(),
2862            source: Some("// v1".into()),
2863        };
2864        cart::save_png(&cart, &png).unwrap();
2865        shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
2866
2867        // Edit the loaded cart's assets in-console, then save.
2868        if let Some(a) = shell.assets_mut() {
2869            a.meta.name = "edited".into();
2870        }
2871        shell.cmd_save(&[]).expect("save");
2872
2873        // The watcher baseline must now match the saved in-memory assets.
2874        let in_mem = encode_assets(shell.assets().unwrap()).unwrap_or_default();
2875        assert_eq!(
2876            shell.cart_watch.as_ref().unwrap().baseline,
2877            in_mem,
2878            "save re-baselined the cart watcher"
2879        );
2880
2881        // Ticking must not flip into a conflict / reload state.
2882        for _ in 0..(2 * 30) {
2883            shell.tick();
2884        }
2885        assert_eq!(shell.cart_name(), "edited");
2886        std::fs::remove_dir_all(&dir).unwrap();
2887    }
2888
2889    /// `reload` discards in-console edits and re-reads the project from disk,
2890    /// resolving a conflict in favour of the external version.
2891    #[test]
2892    fn reload_takes_disk_version() {
2893        let dir = std::env::temp_dir().join(format!("pixel8_reload_{}", std::process::id()));
2894        let _ = std::fs::remove_dir_all(&dir);
2895        let mut shell = test_shell();
2896        let project_dir = dir.join("game");
2897        Project::create(&project_dir, "game").unwrap();
2898        shell
2899            .cmd_load(&[project_dir.to_str().unwrap()])
2900            .expect("load");
2901
2902        // External edit on disk.
2903        let lib = project_dir.join("src/lib.rs");
2904        std::fs::write(&lib, "// DISK VERSION\n").unwrap();
2905
2906        shell.cmd_reload().expect("reload");
2907        let code = shell.code().unwrap_or_default().to_string();
2908        assert!(
2909            code.starts_with("// DISK VERSION"),
2910            "reload took disk; got:\n{code}"
2911        );
2912        std::fs::remove_dir_all(&dir).unwrap();
2913    }
2914
2915    /// A conflict (both disk and editor changed) must abort `run` and keep
2916    /// aborting on a *second* `run` until resolved — never flushing the stale
2917    /// in-console copy over the external edit.
2918    #[test]
2919    fn run_aborts_on_conflict_and_does_not_clobber() {
2920        let dir = std::env::temp_dir().join(format!("pixel8_run_conflict_{}", std::process::id()));
2921        let _ = std::fs::remove_dir_all(&dir);
2922        let mut shell = test_shell();
2923        let project_dir = dir.join("game");
2924        Project::create(&project_dir, "game").unwrap();
2925        shell
2926            .cmd_load(&[project_dir.to_str().unwrap()])
2927            .expect("load");
2928
2929        // In-console edit: make the in-memory copy dirty.
2930        if let Loaded::Project(p) = &mut shell.loaded {
2931            p.code = "// IN-CONSOLE EDIT\n".into();
2932        }
2933        // External edit on disk, mtime bumped so the watcher sees it.
2934        let lib = project_dir.join("src/lib.rs");
2935        std::fs::write(&lib, "// EXTERNAL EDIT\n").unwrap();
2936        let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
2937        std::fs::OpenOptions::new()
2938            .write(true)
2939            .open(&lib)
2940            .unwrap()
2941            .set_modified(later)
2942            .unwrap();
2943
2944        // First run: conflict → abort, no build, disk keeps the external edit.
2945        shell.cmd_run();
2946        assert_eq!(shell.mode, Mode::Console, "first run aborts on conflict");
2947        assert!(shell.build.is_none(), "no build started on conflict");
2948        assert_eq!(
2949            std::fs::read_to_string(&lib).unwrap(),
2950            "// EXTERNAL EDIT\n",
2951            "disk untouched after the first run"
2952        );
2953
2954        // Second run without resolving: must STILL abort and STILL not clobber.
2955        shell.cmd_run();
2956        assert_eq!(shell.mode, Mode::Console, "second run still aborts");
2957        assert!(shell.build.is_none(), "second run starts no build");
2958        assert_eq!(
2959            std::fs::read_to_string(&lib).unwrap(),
2960            "// EXTERNAL EDIT\n",
2961            "disk still untouched after the second run"
2962        );
2963
2964        std::fs::remove_dir_all(&dir).unwrap();
2965    }
2966
2967    #[test]
2968    fn picker_creates_and_switches_files_in_the_shell() {
2969        let dir = std::env::temp_dir().join(format!("pixel8_pick_{}", std::process::id()));
2970        let _ = std::fs::remove_dir_all(&dir);
2971        let mut shell = test_shell();
2972        let project_dir = dir.join("game");
2973        Project::create(&project_dir, "game").unwrap();
2974        shell
2975            .cmd_load(&[project_dir.to_str().unwrap()])
2976            .expect("load project");
2977        shell.switch_editor(Mode::Code);
2978
2979        // Ctrl+O opens the picker; create a new file from it.
2980        let ctrl = Mods {
2981            ctrl: true,
2982            ..Default::default()
2983        };
2984        shell.key(Key::Char('o'), ctrl);
2985        assert!(shell.file_picker.is_open());
2986        shell.key(Key::Down, Mods::default()); // -> "+ new file"
2987        shell.key(Key::Enter, Mods::default()); // -> new-file input
2988        for c in "enemy".chars() {
2989            shell.key(Key::Char(c), Mods::default());
2990        }
2991        shell.key(Key::Enter, Mods::default()); // create
2992
2993        assert!(!shell.file_picker.is_open());
2994        assert!(project_dir.join("src/enemy.rs").exists());
2995        let lib = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
2996        // `mod` is wired in after the template's `#![no_std]` inner attribute.
2997        assert!(lib.starts_with("#![no_std]"), "lib.rs:\n{lib}");
2998        assert!(lib.contains("\nmod enemy;\n"), "lib.rs:\n{lib}");
2999        // The new (empty) file is now the open buffer.
3000        assert_eq!(shell.code().unwrap(), "");
3001
3002        // Switch back to lib.rs via the picker.
3003        shell.key(Key::Char('o'), ctrl);
3004        shell.key(Key::Enter, Mods::default()); // sel 0 == lib.rs
3005        assert!(shell.code().unwrap().contains("\nmod enemy;\n"));
3006        std::fs::remove_dir_all(&dir).unwrap();
3007    }
3008
3009    #[test]
3010    fn clicking_the_filename_opens_the_picker() {
3011        let dir = std::env::temp_dir().join(format!("pixel8_fnclick_{}", std::process::id()));
3012        let _ = std::fs::remove_dir_all(&dir);
3013        let mut shell = test_shell();
3014        let project_dir = dir.join("game");
3015        Project::create(&project_dir, "game").unwrap();
3016        shell
3017            .cmd_load(&[project_dir.to_str().unwrap()])
3018            .expect("load project");
3019        shell.switch_editor(Mode::Code);
3020
3021        assert!(!shell.file_picker.is_open());
3022        // A left-press on the top-left filename.
3023        let name = shell.current_file_name();
3024        shell.mouse = Mouse {
3025            x: 3,
3026            y: 2,
3027            left_pressed: true,
3028            ..Default::default()
3029        };
3030        assert!(ui::filename_clicked(&shell.mouse, &name));
3031        shell.tick();
3032        assert!(
3033            shell.file_picker.is_open(),
3034            "filename click opens the picker"
3035        );
3036        std::fs::remove_dir_all(&dir).unwrap();
3037    }
3038
3039    /// A save failure during a file switch aborts the switch and surfaces the
3040    /// error via both the console log and the toast bar.
3041    #[test]
3042    fn select_file_save_failure_aborts_and_reports() {
3043        let dir = std::env::temp_dir().join(format!("pixel8_savefail_{}", std::process::id()));
3044        let _ = std::fs::remove_dir_all(&dir);
3045        let mut shell = test_shell();
3046        let project_dir = dir.join("game");
3047        Project::create(&project_dir, "game").unwrap();
3048
3049        // Write a second source file so there is something to switch to.
3050        std::fs::write(project_dir.join("src/other.rs"), "").unwrap();
3051
3052        shell
3053            .cmd_load(&[project_dir.to_str().unwrap()])
3054            .expect("load project");
3055        shell.switch_editor(Mode::Code);
3056
3057        // Sabotage save: replace src/lib.rs (file) with a directory of the
3058        // same name so that fs::write fails with EISDIR.
3059        let lib_path = project_dir.join("src/lib.rs");
3060        std::fs::remove_file(&lib_path).unwrap();
3061        std::fs::create_dir(&lib_path).unwrap();
3062
3063        // Attempt to switch to other.rs — save fails, switch must be aborted.
3064        shell.select_file("other.rs");
3065
3066        // The current file must not have changed.
3067        assert_eq!(
3068            shell.current_file_name(),
3069            "lib.rs",
3070            "switch must be aborted when save fails"
3071        );
3072
3073        // The error must be surfaced via the toast bar in red.
3074        let toast = shell
3075            .toast
3076            .as_ref()
3077            .expect("toast must be set on save error");
3078        assert_eq!(toast.1, col::RED, "toast must be red");
3079        assert!(
3080            toast.0.contains("lib.rs"),
3081            "toast must name the file that failed to save; got: {}",
3082            toast.0
3083        );
3084
3085        std::fs::remove_dir_all(&dir).unwrap();
3086    }
3087
3088    #[test]
3089    fn import_pico8_into_appends_to_loaded_project() {
3090        let dir = std::env::temp_dir().join(format!("pixel8_shell_into_{}", std::process::id()));
3091        let _ = std::fs::remove_dir_all(&dir);
3092        std::fs::create_dir_all(&dir).unwrap();
3093
3094        // A source cart with sprite 0 pixel (0,0) = 0xc.
3095        let mut row0 = vec![b'0'; 128];
3096        row0[0] = b'c';
3097        let p8 = format!(
3098            "pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
3099            String::from_utf8(row0).unwrap()
3100        );
3101        std::fs::write(dir.join("src.p8"), p8).unwrap();
3102
3103        let mut shell = test_shell();
3104        shell.cwd = dir.clone();
3105        // `new` creates the project under cwd and loads it as the destination.
3106        shell.cmd_new(&["dest"]).expect("new");
3107        // Mark sprite 0 of the loaded project as used, so an additive import must
3108        // land the imported sprite at slot 1 (after the last used slot).
3109        shell.assets_mut().expect("loaded").sprites.set(0, 0, 5);
3110
3111        shell.exec("import-pico8 src.p8 --into --sprites 0");
3112
3113        // The command must append INTO the loaded `dest` project, not replace it
3114        // with a freshly-created one: the loaded project is still `dest`...
3115        match &shell.loaded {
3116            Loaded::Project(p) => assert_eq!(p.name, "dest"),
3117            _ => panic!("expected the dest project to remain loaded"),
3118        }
3119        let assets = shell.assets().expect("a project is loaded");
3120        // ...the pre-existing sprite 0 is untouched...
3121        assert_eq!(assets.sprites.get(0, 0), 5);
3122        // ...and the imported sprite landed at slot 1 (sheet (8, 0)).
3123        assert_eq!(assets.sprites.get(8, 0), 0xc);
3124
3125        std::fs::remove_dir_all(&dir).unwrap();
3126    }
3127
3128    #[test]
3129    fn additive_import_does_not_trigger_a_rebuild() {
3130        let dir =
3131            std::env::temp_dir().join(format!("pixel8_into_norebuild_{}", std::process::id()));
3132        let _ = std::fs::remove_dir_all(&dir);
3133        std::fs::create_dir_all(&dir).unwrap();
3134
3135        let mut row0 = vec![b'0'; 128];
3136        row0[0] = b'c';
3137        let p8 = format!(
3138            "pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
3139            String::from_utf8(row0).unwrap()
3140        );
3141        std::fs::write(dir.join("src.p8"), p8).unwrap();
3142
3143        let mut shell = test_shell();
3144        shell.cwd = dir.clone();
3145        shell.cmd_new(&["dest"]).expect("new");
3146
3147        shell.exec("import-pico8 src.p8 --into --sprites 0");
3148
3149        // The save rewrote src/lib.rs too; the import must re-baseline the whole
3150        // watcher. Tick past the source-tree poll interval and confirm no rebuild was
3151        // triggered and we stayed at the console (no spontaneous run into Run mode).
3152        for _ in 0..40 {
3153            shell.tick();
3154        }
3155        assert!(
3156            shell.build.is_none(),
3157            "additive import must not spawn a build"
3158        );
3159        assert_eq!(shell.mode, Mode::Console, "must stay at the console");
3160        assert!(!shell.run_after_build, "must not arm a run");
3161
3162        std::fs::remove_dir_all(&dir).unwrap();
3163    }
3164
3165    #[test]
3166    fn apply_paste_routes_map_blobs_to_the_map_editor() {
3167        use pixel8_runtime::clipboard::Pasted;
3168        let dir = std::env::temp_dir().join(format!("pixel8_mappaste_{}", std::process::id()));
3169        let _ = std::fs::remove_dir_all(&dir);
3170        std::fs::create_dir_all(&dir).unwrap();
3171
3172        let mut shell = test_shell();
3173        shell.cwd = dir.clone();
3174        shell.cmd_new(&["dest"]).expect("new");
3175        shell.mode = Mode::Map;
3176
3177        shell.apply_paste(Pasted::Map {
3178            w: 1,
3179            h: 1,
3180            tiles: vec![7],
3181        });
3182        assert!(shell.map_ed.has_paste_buffer());
3183
3184        std::fs::remove_dir_all(&dir).unwrap();
3185    }
3186
3187    #[test]
3188    fn apply_paste_routes_sfx_to_the_sfx_editor() {
3189        use pixel8_runtime::clipboard::{Pasted, SfxClip, Slotted};
3190        let dir = std::env::temp_dir().join(format!("pixel8_paste_{}", std::process::id()));
3191        let _ = std::fs::remove_dir_all(&dir);
3192        std::fs::create_dir_all(&dir).unwrap();
3193
3194        let mut shell = test_shell();
3195        shell.cwd = dir.clone();
3196        shell.cmd_new(&["dest"]).expect("new");
3197        shell.mode = Mode::Sfx; // the SFX editor's slot defaults to 0.
3198
3199        let mut sfx = pixel8_runtime::assets::Sfx::default();
3200        sfx.notes[0].pitch = 21;
3201        sfx.notes[0].volume = 4;
3202        let clip = SfxClip {
3203            records: vec![Slotted { src: 0, value: sfx }],
3204            patterns: vec![],
3205        };
3206        shell.apply_paste(Pasted::Sfx(clip));
3207
3208        let assets = assets_ref(&shell.loaded).unwrap();
3209        assert_eq!(assets.sfx[0].notes[0].pitch, 21);
3210
3211        std::fs::remove_dir_all(&dir).unwrap();
3212    }
3213
3214    #[test]
3215    fn hide_cursor_suppresses_the_software_cursor() {
3216        let mut shell = test_shell();
3217        // An editor mode: the software mouse cursor is drawn there.
3218        shell.mode = Mode::Sprite;
3219        shell.mouse.x = 40;
3220        shell.mouse.y = 40;
3221        let with_cursor = shell.draw().pixels().to_vec();
3222
3223        shell.set_hide_cursor(true);
3224        let hidden = shell.draw().pixels().to_vec();
3225        assert_ne!(
3226            with_cursor, hidden,
3227            "hiding the cursor must change the frame"
3228        );
3229
3230        // Hiding the cursor draws exactly what the mouse being off-screen does.
3231        shell.set_hide_cursor(false);
3232        shell.mouse = Mouse::default();
3233        let no_pointer = shell.draw().pixels().to_vec();
3234        assert_eq!(
3235            hidden, no_pointer,
3236            "a hidden cursor leaves no pointer behind"
3237        );
3238    }
3239}