use crate::{
builder::{spawn_build, BuildJob},
editor::{
code::CodeEditor,
file_picker::{FilePicker, PickerAction},
map::MapEditor,
music::MusicEditor,
sfx::SfxEditor,
sprite::SpriteEditor,
},
ui::{self, Mouse},
watch::{FileChange, FileWatch, SourceTreeWatch},
};
use anyhow::{anyhow, bail, Result};
use pixel8_runtime::{
assets::Assets,
audio::AudioHandle,
cart::{self, Cart},
clipboard::Pasted,
fb::Framebuffer,
font,
palette::col,
project::{decode_assets, encode_assets, Project},
storage::Storage,
vm::{GameVm, RuntimeError, UI_FPS},
};
use std::{
collections::VecDeque,
path::PathBuf,
time::{Duration, Instant, SystemTime},
};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Key {
Char(char),
Left,
Right,
Up,
Down,
Backspace,
Delete,
Enter,
Tab,
Escape,
Home,
End,
PageUp,
PageDown,
CaptureLabel,
ToggleStats,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Mods {
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Console,
Run,
Code,
Sprite,
Map,
Sfx,
Music,
}
pub const EDITOR_MODES: [Mode; 5] = [Mode::Code, Mode::Sprite, Mode::Map, Mode::Sfx, Mode::Music];
enum Loaded {
None,
Project(Project),
Cart {
cart: Cart,
path: PathBuf,
},
}
fn assets_of(loaded: &mut Loaded) -> Option<&mut Assets> {
match loaded {
Loaded::None => None,
Loaded::Project(p) => Some(&mut p.assets),
Loaded::Cart { cart, .. } => Some(&mut cart.assets),
}
}
fn assets_ref(loaded: &Loaded) -> Option<&Assets> {
match loaded {
Loaded::None => None,
Loaded::Project(p) => Some(&p.assets),
Loaded::Cart { cart, .. } => Some(&cart.assets),
}
}
struct ProjectWatch {
code: FileWatch,
assets: FileWatch,
source_tree: SourceTreeWatch,
}
impl ProjectWatch {
fn new(p: &Project) -> Self {
let assets_baseline = encode_assets(&p.assets).unwrap_or_default();
Self {
code: FileWatch::new(p.dir.join("src/lib.rs"), p.code.clone().into_bytes()),
assets: FileWatch::new(p.dir.join("assets.pixel8.json"), assets_baseline),
source_tree: SourceTreeWatch::new(&p.dir),
}
}
fn sync(&mut self, p: &Project) {
self.code.mark_synced(p.code.clone().into_bytes());
self.assets
.mark_synced(encode_assets(&p.assets).unwrap_or_default());
self.source_tree.sync();
}
}
struct CartWatch {
path: PathBuf,
synced_mtime: Option<SystemTime>,
baseline: Vec<u8>,
}
impl CartWatch {
fn new(path: PathBuf, baseline: Vec<u8>) -> Self {
let synced_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
Self {
path,
synced_mtime,
baseline,
}
}
fn mark_synced(&mut self, baseline: Vec<u8>) {
self.baseline = baseline;
self.synced_mtime = std::fs::metadata(&self.path)
.and_then(|m| m.modified())
.ok();
}
fn advanced(&mut self) -> Option<SystemTime> {
let mtime = std::fs::metadata(&self.path)
.and_then(|m| m.modified())
.ok()?;
let advanced = self.synced_mtime.map(|prev| mtime > prev).unwrap_or(true);
if advanced {
self.synced_mtime = Some(mtime);
Some(mtime)
} else {
None
}
}
}
fn stat_color(frac: f32) -> u8 {
if frac < 0.7 {
col::GREEN
} else if frac <= 0.9 {
col::YELLOW
} else {
col::RED
}
}
fn fps_color(measured: f32, target: u32) -> u8 {
let ratio = if target > 0 {
measured / target as f32
} else {
1.0
};
if ratio >= 0.95 {
col::GREEN
} else if ratio >= 0.8 {
col::YELLOW
} else {
col::RED
}
}
fn stats_overlay(fb: &mut Framebuffer, cpu_u: f32, cpu_d: f32, used: u32, fps: f32, target: u32) {
let used_frac = used as f32 / 131_072.0;
let lines = [
format!("CPU U {:>5.1}%", cpu_u * 100.0),
format!("CPU D {:>5.1}%", cpu_d * 100.0),
format!(
"MEM {:<4}{:>5.1}%",
format!("{}K", used / 1024),
used_frac * 100.0
),
format!("FPS {:>6.1}", fps),
];
let colors = [
stat_color(cpu_u),
stat_color(cpu_d),
stat_color(used_frac),
fps_color(fps, target),
];
let w = lines.iter().map(|l| l.len()).max().unwrap_or(0) as i32 * 4 + 1;
let x0 = 127 - w;
fb.rectfill(x0, 0, 127, 4 * 7, col::BLACK);
for (i, (line, &color)) in lines.iter().zip(colors.iter()).enumerate() {
fb.print(line, x0 + 1, 1 + i as i32 * 7, color);
}
}
const CAPTURE_FLASH_FRAMES: u32 = 6;
fn capture_flash_overlay(fb: &mut Framebuffer) {
fb.cls(col::WHITE);
}
enum ConsoleLine {
Text {
text: String,
color: u8,
},
Stripe,
}
pub struct Shell {
pub mode: Mode,
last_editor: Mode,
loaded: Loaded,
vm: Option<GameVm>,
audio: AudioHandle,
fb: Framebuffer,
frame: u64,
lines: VecDeque<ConsoleLine>,
input: String,
cursor: usize,
history: Vec<String>,
history_pos: Option<usize>,
scroll_back: usize,
build: Option<BuildJob>,
run_after_build: bool,
toast: Option<(String, u8, u64)>,
wasm_mtime: Option<SystemTime>,
project_watch: Option<ProjectWatch>,
cart_watch: Option<CartWatch>,
pub mouse: Mouse,
code_ed: CodeEditor,
file_picker: FilePicker,
previous_file: Option<String>,
sprite_ed: SpriteEditor,
map_ed: MapEditor,
sfx_ed: SfxEditor,
music_ed: MusicEditor,
pub want_exit: bool,
cwd: PathBuf,
sdk_path: PathBuf,
storage_root: Option<PathBuf>,
show_stats: bool,
fps_frames: u32,
fps_t0: Instant,
fps_val: f32,
capture_flash: u32,
hide_cursor: bool,
}
const TEXT_COLS: usize = 31;
const PROMPT_COL: u8 = col::WHITE;
impl Shell {
pub fn new(audio: AudioHandle, sdk_path: PathBuf) -> Self {
let mut shell = Self {
mode: Mode::Console,
last_editor: Mode::Code,
loaded: Loaded::None,
vm: None,
audio,
fb: Framebuffer::new(),
frame: 0,
lines: VecDeque::new(),
input: String::new(),
cursor: 0,
history: Vec::new(),
history_pos: None,
scroll_back: 0,
build: None,
run_after_build: false,
toast: None,
wasm_mtime: None,
project_watch: None,
cart_watch: None,
mouse: Mouse::default(),
code_ed: CodeEditor::new(),
file_picker: FilePicker::new(),
previous_file: None,
sprite_ed: SpriteEditor::new(),
map_ed: MapEditor::new(),
sfx_ed: SfxEditor::new(),
music_ed: MusicEditor::new(),
want_exit: false,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
sdk_path,
storage_root: None,
show_stats: false,
fps_frames: 0,
fps_t0: Instant::now(),
fps_val: 0.0,
capture_flash: 0,
hide_cursor: false,
};
shell.boot();
shell
}
pub fn set_hide_cursor(&mut self, hide: bool) {
self.hide_cursor = hide;
}
fn boot(&mut self) {
self.lines.push_back(ConsoleLine::Stripe);
self.say(&format!("Pixel8 {VERSION}"), col::WHITE);
self.say("A fantasy console for Rust", col::LIGHT_GREY);
self.say("", col::WHITE);
self.say("Type help for help", col::LIGHT_GREY);
self.say("", col::WHITE);
}
pub fn say(&mut self, text: &str, color: u8) {
if text.is_empty() {
self.push_line(String::new(), color);
return;
}
for raw in text.split('\n') {
let mut rest = raw;
loop {
let take = rest
.char_indices()
.nth(TEXT_COLS)
.map(|(i, _)| i)
.unwrap_or(rest.len());
self.push_line(rest[..take].to_string(), color);
rest = &rest[take..];
if rest.is_empty() {
break;
}
}
}
}
fn toast(&mut self, text: &str, color: u8, secs: f32) {
self.toast = Some((text.to_string(), color, self.frame + (secs * 30.0) as u64));
}
fn push_line(&mut self, text: String, color: u8) {
self.lines.push_back(ConsoleLine::Text { text, color });
while self.lines.len() > 300 {
self.lines.pop_front();
}
self.scroll_back = 0;
}
pub fn assets(&self) -> Option<&Assets> {
match &self.loaded {
Loaded::None => None,
Loaded::Project(p) => Some(&p.assets),
Loaded::Cart { cart, .. } => Some(&cart.assets),
}
}
pub fn assets_mut(&mut self) -> Option<&mut Assets> {
match &mut self.loaded {
Loaded::None => None,
Loaded::Project(p) => Some(&mut p.assets),
Loaded::Cart { cart, .. } => Some(&mut cart.assets),
}
}
fn code(&self) -> Option<&str> {
match &self.loaded {
Loaded::None => None,
Loaded::Project(p) => Some(&p.code),
Loaded::Cart { cart, .. } => cart.source.as_deref(),
}
}
fn set_code(&mut self, code: String) {
match &mut self.loaded {
Loaded::None => {}
Loaded::Project(p) => p.code = code,
Loaded::Cart { cart, .. } => cart.source = Some(code),
}
}
fn project_file_names(&self) -> Vec<String> {
match &self.loaded {
Loaded::Project(p) => p.file_names(),
_ => Vec::new(),
}
}
fn current_file_name(&self) -> String {
match &self.loaded {
Loaded::Project(p) => p.current.clone(),
_ => String::new(),
}
}
fn run_picker_action(&mut self, action: PickerAction) {
match action {
PickerAction::Switch(name) => self.select_file(&name),
PickerAction::Create(name) => self.create_file_in_project(&name),
}
}
fn open_file_picker(&mut self) {
let files = self.project_file_names();
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
let current = self.current_file_name();
self.file_picker
.open(&refs, ¤t, self.previous_file.as_deref());
}
fn select_file(&mut self, name: &str) {
enum R {
NoOp,
Err(String),
Ok(String, PathBuf, String),
}
let r = match &mut self.loaded {
Loaded::Project(p) if p.current != name => {
let previous = p.current.clone();
match p.save() {
Err(e) => R::Err(format!("could not save {previous}: {e}")),
Ok(()) => match p.switch_to(name) {
Ok(()) => R::Ok(p.code.clone(), p.dir.join("src").join(name), previous),
Err(e) => R::Err(format!("{e}")),
},
}
}
_ => R::NoOp,
};
match r {
R::Ok(code, path, previous) => {
self.previous_file = Some(previous);
self.code_ed.set_text(&code);
if let Some(w) = &mut self.project_watch {
w.code = FileWatch::new(path, code.into_bytes());
w.source_tree.sync();
}
}
R::Err(msg) => {
self.say(&msg, col::RED);
self.toast(&msg, col::RED, 3.0);
}
R::NoOp => {}
}
}
fn create_file_in_project(&mut self, name: &str) {
enum R {
Err(String),
Ok(String, PathBuf, String),
}
let r = match &mut self.loaded {
Loaded::Project(p) => {
let previous = p.current.clone();
match p.save() {
Err(e) => R::Err(format!("could not save {previous}: {e}")),
Ok(()) => match p.create_file(name) {
Ok(new) => R::Ok(p.code.clone(), p.dir.join("src").join(&new), previous),
Err(e) => R::Err(format!("{e}")),
},
}
}
_ => return,
};
match r {
R::Ok(code, path, previous) => {
self.previous_file = Some(previous);
self.code_ed.set_text(&code);
if let Some(w) = &mut self.project_watch {
w.code = FileWatch::new(path, code.into_bytes());
w.source_tree.sync();
}
}
R::Err(msg) => {
self.say(&msg, col::RED);
self.toast(&msg, col::RED, 3.0);
}
}
}
fn cart_name(&self) -> String {
self.assets()
.map(|a| a.meta.name.clone())
.unwrap_or_else(|| "no cart".into())
}
pub fn window_title(&self) -> String {
match &self.loaded {
Loaded::None => "Pixel8".into(),
_ => format!("{} - Pixel8", self.cart_name()),
}
}
pub fn set_button(&mut self, b: usize, down: bool) {
if let Some(vm) = &mut self.vm {
vm.state_mut().input.set_button(b, down);
}
}
pub fn key(&mut self, key: Key, mods: Mods) {
if self.file_picker.is_open() {
let files = self.project_file_names();
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
if let Some(action) = self.file_picker.key(key, mods, &refs) {
self.run_picker_action(action);
}
return;
}
if key == Key::ToggleStats {
self.show_stats = !self.show_stats;
return;
}
if mods.ctrl {
match key {
Key::Char('r') => {
self.cmd_run();
return;
}
Key::Char('s') => {
self.cmd_save_quiet();
return;
}
Key::Char('o') => {
if self.mode == Mode::Code && matches!(self.loaded, Loaded::Project(_)) {
self.open_file_picker();
}
return;
}
_ => {}
}
}
match self.mode {
Mode::Run => {
if key == Key::Escape {
self.stop_run("");
} else if key == Key::CaptureLabel {
self.capture_label();
}
}
Mode::Console => self.console_key(key, mods),
_ => self.editor_key(key, mods),
}
}
fn editor_key(&mut self, key: Key, mods: Mods) {
if key == Key::Escape {
self.mode = Mode::Console;
return;
}
if mods.alt {
let cur = EDITOR_MODES
.iter()
.position(|m| *m == self.mode)
.unwrap_or(0);
match key {
Key::Left => {
self.switch_editor(
EDITOR_MODES[(cur + EDITOR_MODES.len() - 1) % EDITOR_MODES.len()],
);
return;
}
Key::Right => {
self.switch_editor(EDITOR_MODES[(cur + 1) % EDITOR_MODES.len()]);
return;
}
_ => {}
}
}
if mods.ctrl {
match key {
Key::Char('c')
if matches!(
self.mode,
Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
) =>
{
self.cmd_copy();
return;
}
Key::Char('x') if matches!(self.mode, Mode::Code | Mode::Map) => {
self.cmd_cut();
return;
}
Key::Char('v')
if matches!(
self.mode,
Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Code | Mode::Map
) =>
{
self.cmd_paste();
return;
}
_ => {}
}
}
if self.loaded_none() {
return;
}
let audio = self.audio.clone();
match self.mode {
Mode::Code => {
let mut code = self.code().unwrap_or_default().to_string();
self.code_ed.key(key, mods, &mut code);
self.set_code(code);
}
Mode::Sprite => {
if let Some(a) = assets_of(&mut self.loaded) {
self.sprite_ed.key(key, mods, a);
}
}
Mode::Map => {
if let Some(a) = assets_of(&mut self.loaded) {
self.map_ed.key(key, mods, a);
}
}
Mode::Sfx => {
if let Some(a) = assets_of(&mut self.loaded) {
self.sfx_ed.key(key, mods, a, &audio);
}
}
Mode::Music => {
if let Some(a) = assets_of(&mut self.loaded) {
self.music_ed.key(key, mods, a, &audio);
}
}
_ => {}
}
}
pub fn switch_editor(&mut self, mode: Mode) {
self.file_picker.close();
self.map_ed.cancel_drag();
if self.loaded_none() {
self.say("No cart loaded. Try: new mygame", col::RED);
self.mode = Mode::Console;
return;
}
if mode == Mode::Code {
let code = self.code().unwrap_or_default().to_string();
self.code_ed.set_text(&code);
}
self.mode = mode;
self.last_editor = mode;
}
fn loaded_none(&self) -> bool {
matches!(self.loaded, Loaded::None)
}
fn console_key(&mut self, key: Key, _mods: Mods) {
match key {
Key::Char(c) => {
self.input.insert(self.byte_at(self.cursor), c);
self.cursor += 1;
}
Key::Backspace => {
if self.cursor > 0 {
self.cursor -= 1;
let at = self.byte_at(self.cursor);
self.input.remove(at);
}
}
Key::Delete => {
if self.cursor < self.input.chars().count() {
let at = self.byte_at(self.cursor);
self.input.remove(at);
}
}
Key::Left => self.cursor = self.cursor.saturating_sub(1),
Key::Right => self.cursor = (self.cursor + 1).min(self.input.chars().count()),
Key::Home => self.cursor = 0,
Key::End => self.cursor = self.input.chars().count(),
Key::Up => {
if !self.history.is_empty() {
let pos = match self.history_pos {
None => self.history.len() - 1,
Some(p) => p.saturating_sub(1),
};
self.history_pos = Some(pos);
self.input = self.history[pos].clone();
self.cursor = self.input.chars().count();
}
}
Key::Down => {
if let Some(p) = self.history_pos {
if p + 1 < self.history.len() {
self.history_pos = Some(p + 1);
self.input = self.history[p + 1].clone();
} else {
self.history_pos = None;
self.input.clear();
}
self.cursor = self.input.chars().count();
}
}
Key::PageUp => self.scroll_back = (self.scroll_back + 5).min(self.lines.len()),
Key::PageDown => self.scroll_back = self.scroll_back.saturating_sub(5),
Key::Enter => {
let cmd = self.input.clone();
self.say(&format!("> {cmd}"), col::LIGHT_GREY);
if !cmd.trim().is_empty() {
self.history.push(cmd.clone());
}
self.history_pos = None;
self.input.clear();
self.cursor = 0;
self.exec(&cmd);
}
Key::Escape => {
if !self.loaded_none() {
self.switch_editor(self.last_editor);
}
}
Key::Tab | Key::CaptureLabel | Key::ToggleStats => {}
}
}
fn byte_at(&self, char_idx: usize) -> usize {
self.input
.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(self.input.len())
}
fn exec(&mut self, cmd: &str) {
let parts: Vec<&str> = cmd.split_whitespace().collect();
let Some(&verb) = parts.first() else { return };
let args = &parts[1..];
let result = match verb.to_ascii_lowercase().as_str() {
"help" => {
self.cmd_help(args);
Ok(())
}
"new" => self.cmd_new(args),
"load" => self.cmd_load(args),
"reload" => self.cmd_reload(),
"save" => self.cmd_save(args),
"run" => {
self.cmd_run();
Ok(())
}
"export" => self.cmd_export(args),
"import" => self.cmd_import(args),
"import-pico8" | "importp8" => self.cmd_import_pico8(args),
"info" => {
self.cmd_info();
Ok(())
}
"ls" | "dir" => self.cmd_ls(),
"cls" => {
self.lines.clear();
Ok(())
}
"title" => self.cmd_meta(args, |a, v| a.meta.name = v),
"author" => self.cmd_meta(args, |a, v| a.meta.author = v),
"code" => {
self.switch_editor(Mode::Code);
Ok(())
}
"sprite" | "gfx" => {
self.switch_editor(Mode::Sprite);
Ok(())
}
"map" => {
self.switch_editor(Mode::Map);
Ok(())
}
"sfx" => {
self.switch_editor(Mode::Sfx);
Ok(())
}
"music" => {
self.switch_editor(Mode::Music);
Ok(())
}
"keys" => {
self.cmd_keys();
Ok(())
}
"reboot" => {
self.vm = None;
self.audio.stop_all();
self.loaded = Loaded::None;
self.lines.clear();
self.boot();
Ok(())
}
"exit" | "quit" | "shutdown" => {
self.want_exit = true;
Ok(())
}
other => Err(anyhow!("Syntax error: {other}\nType help for help")),
};
if let Err(e) = result {
self.say(&e.to_string(), col::RED);
}
}
fn cmd_help(&mut self, args: &[&str]) {
if args.first() == Some(&"keys") {
self.cmd_keys();
return;
}
for (c, d) in [
("new <name>", "Create a project"),
("load <dir|cart.png>", "Load a cart"),
("reload", "Re-read from disk, drop edits"),
("save", "Save project to disk"),
("run", "Build + run (esc stops)"),
("export <f.png|f.html>", "Export cart (PNG or web)"),
("import <f.png> <dir>", "Cart -> project"),
("import-pico8 <f> [dir]", "PICO-8 cart -> new project"),
(
"import-pico8 <f> --into ...",
"Append assets to loaded project",
),
("info", "Cart metadata"),
("title/author <text>", "Set metadata"),
("code/sprite/map/sfx/music", "Editors (esc)"),
("ls, cls, keys, reboot, exit", ""),
] {
self.say(c, col::WHITE);
if !d.is_empty() {
self.say(&format!(" {d}"), col::LIGHT_GREY);
}
}
}
fn cmd_keys(&mut self) {
for (k, d) in [
("esc", "Console <-> editor / stop"),
("ctrl+r", "Run cart"),
("ctrl+s", "Save + build check"),
("ctrl+z / ctrl+y", "Undo / redo (in editors)"),
("alt+left/right", "Switch editor"),
("arrows + z/x", "Game buttons"),
("f1", "Toggle resource stats"),
("f6", "Capture label (running)"),
] {
self.say(&format!("{k:14} {d}"), col::LIGHT_GREY);
}
}
fn cmd_new(&mut self, args: &[&str]) -> Result<()> {
let Some(name) = args.first() else {
bail!("Usage: new <name>");
};
let dir = self.cwd.join(name);
let project = Project::create(&dir, name)?;
self.say(&format!("Created ./{name}"), col::GREEN);
self.code_ed.set_text(&project.code);
self.project_watch = Some(ProjectWatch::new(&project));
self.cart_watch = None;
self.loaded = Loaded::Project(project);
Ok(())
}
pub fn startup_load(&mut self, path: &str) {
if let Err(e) = self.cmd_load(&[path]) {
self.say(&e.to_string(), col::RED);
}
}
pub fn startup_run(&mut self) {
self.cmd_run();
}
fn cmd_load(&mut self, args: &[&str]) -> Result<()> {
let Some(path) = args.first() else {
bail!("Usage: load <dir|cart.png>");
};
let path = self.cwd.join(path);
if path.extension().is_some_and(|e| e == "png") {
let cart = cart::load_png(&path)?;
let name = cart.assets.meta.name.clone();
let has_src = cart.source.is_some();
self.code_ed.set_text(
cart.source
.as_deref()
.unwrap_or("// No source in this cart"),
);
self.project_watch = None;
let cart_baseline = encode_assets(&cart.assets).unwrap_or_default();
self.cart_watch = Some(CartWatch::new(path.clone(), cart_baseline));
self.loaded = Loaded::Cart { cart, path };
self.say(&format!("Loaded cart: {name}"), col::GREEN);
if !has_src {
self.say("(Playable cart, no source)", col::LIGHT_GREY);
}
} else {
let project = Project::load(&path)?;
self.code_ed.set_text(&project.code);
self.say(&format!("Loaded {}", project.name), col::GREEN);
self.project_watch = Some(ProjectWatch::new(&project));
self.cart_watch = None;
self.loaded = Loaded::Project(project);
}
Ok(())
}
fn cmd_reload(&mut self) -> Result<()> {
let path = match &self.loaded {
Loaded::None => bail!("Nothing loaded"),
Loaded::Project(p) => p.dir.clone(),
Loaded::Cart { path, .. } => path.clone(),
};
let path_str = path.to_string_lossy().into_owned();
self.cmd_load(&[&path_str])
}
fn cmd_save(&mut self, _args: &[&str]) -> Result<()> {
let message = match &mut self.loaded {
Loaded::None => bail!("Nothing to save"),
Loaded::Project(p) => {
p.save()?;
"Saved".to_string()
}
Loaded::Cart { cart, path } => {
cart::save_png(cart, path)?;
format!("Saved {}", path.display())
}
};
if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
w.sync(p);
}
if let (Loaded::Cart { cart, .. }, Some(w)) = (&self.loaded, &mut self.cart_watch) {
w.mark_synced(encode_assets(&cart.assets).unwrap_or_default());
}
self.say(&message, col::GREEN);
Ok(())
}
fn cmd_paste(&mut self) {
let text = match crate::clipboard::read_text() {
Ok(t) => t,
Err(_) => return self.set_editor_status("no text on clipboard".into()),
};
if self.mode == Mode::Code {
if self.code().is_none() {
return self.set_editor_status("load a project first".into());
}
let mut code = self.code().unwrap_or_default().to_string();
self.code_ed.paste_text(&mut code, &text);
self.set_code(code);
return;
}
match pixel8_runtime::clipboard::parse(&text) {
Ok(pasted) => self.apply_paste(pasted),
Err(_) => self.set_editor_status("nothing to paste".into()),
}
}
fn cmd_copy(&mut self) {
let blob = match self.mode {
Mode::Code => {
if self.code().is_none() {
return self.set_editor_status("load a project first".into());
}
let code = self.code().unwrap_or_default().to_string();
self.code_ed.copy(&code)
}
Mode::Sprite | Mode::Sfx | Mode::Music | Mode::Map => {
let Some(a) = assets_of(&mut self.loaded) else {
return self.set_editor_status("load a project first".into());
};
match self.mode {
Mode::Sprite => Some(self.sprite_ed.copy(a)),
Mode::Sfx => Some(self.sfx_ed.copy(a)),
Mode::Music => Some(self.music_ed.copy(a)),
Mode::Map => self.map_ed.copy_selection(a, false),
_ => unreachable!(),
}
}
_ => None,
};
if let Some(text) = blob {
if crate::clipboard::write_text(&text).is_err() {
self.set_editor_status("clipboard unavailable".into());
}
}
}
fn cmd_cut(&mut self) {
let blob = match self.mode {
Mode::Code => {
if self.code().is_none() {
return self.set_editor_status("load a project first".into());
}
let mut code = self.code().unwrap_or_default().to_string();
let cut = self.code_ed.cut(&mut code);
if cut.is_some() {
self.set_code(code);
}
cut
}
Mode::Map => {
let Some(a) = assets_of(&mut self.loaded) else {
return self.set_editor_status("load a project first".into());
};
self.map_ed.copy_selection(a, true)
}
_ => None,
};
if let Some(text) = blob {
if crate::clipboard::write_text(&text).is_err() {
self.set_editor_status("clipboard unavailable".into());
}
}
}
fn apply_paste(&mut self, pasted: Pasted) {
let Some(a) = assets_of(&mut self.loaded) else {
return self.set_editor_status("load a project first".into());
};
match self.mode {
Mode::Sprite => self.sprite_ed.paste(&pasted, a),
Mode::Sfx => self.sfx_ed.paste(&pasted, a),
Mode::Music => self.music_ed.paste(&pasted, a),
Mode::Map => self.map_ed.paste(&pasted),
_ => {}
}
}
fn set_editor_status(&mut self, msg: String) {
match self.mode {
Mode::Sprite => self.sprite_ed.set_status(msg),
Mode::Sfx => self.sfx_ed.set_status(msg),
Mode::Music => self.music_ed.set_status(msg),
Mode::Code => self.code_ed.set_status(msg),
Mode::Map => self.map_ed.set_status(msg),
_ => {}
}
}
fn cmd_save_quiet(&mut self) {
if let Err(e) = self.cmd_save(&[]) {
let msg = e.to_string();
self.say(&msg, col::RED);
self.toast(&msg, col::RED, 3.0);
return;
}
self.toast("Saved", col::GREEN, 1.5);
if self.build.is_none() {
if let Loaded::Project(p) = &self.loaded {
let dir = p.dir.clone();
self.build = Some(spawn_build(&dir));
self.run_after_build = false;
}
}
}
pub fn cmd_run(&mut self) {
self.audio.stop_all();
self.vm = None;
match &self.loaded {
Loaded::None => self.say("No cart loaded", col::RED),
Loaded::Cart { .. } => match self.start_vm_from_loaded() {
Ok(()) => {}
Err(e) => self.show_error("boot", &e.to_string()),
},
Loaded::Project(p) => {
let dir = p.dir.clone();
if self.build.is_some() {
self.say("Already compiling...", col::ORANGE);
self.toast("Already building...", col::ORANGE, 1.5);
return;
}
if !self.reconcile_for_build() {
self.say("Disk & editor both changed", col::ORANGE);
self.say("Save or reload to resolve", col::ORANGE);
self.toast("Conflict: save or reload", col::ORANGE, 3.0);
return;
}
self.mode = Mode::Console;
self.say("Compiling...", col::LIGHT_GREY);
self.build = Some(spawn_build(&dir));
self.run_after_build = true;
}
}
}
fn reconcile_for_build(&mut self) -> bool {
let Loaded::Project(_) = &self.loaded else {
return true;
};
let (code_mem, assets_mem) = match &self.loaded {
Loaded::Project(p) => (
p.code.clone().into_bytes(),
encode_assets(&p.assets).unwrap_or_default(),
),
_ => return true,
};
let Some(w) = &mut self.project_watch else {
return true;
};
let code_change = w.code.poll(&code_mem);
let assets_change = w.assets.poll(&assets_mem);
w.source_tree.poll();
if matches!(code_change, FileChange::Conflict)
|| matches!(assets_change, FileChange::Conflict)
|| w.code.in_conflict()
|| w.assets.in_conflict()
{
return false;
}
if let FileChange::Adopt(bytes) = code_change {
let text = String::from_utf8_lossy(&bytes).into_owned();
self.code_ed.set_text(&text);
if let Loaded::Project(p) = &mut self.loaded {
p.code = text;
}
}
if let FileChange::Adopt(bytes) = assets_change {
match decode_assets(&bytes) {
Ok(assets) => {
if let Loaded::Project(p) = &mut self.loaded {
p.assets = assets;
}
}
Err(_) => self.resync_assets_watcher(),
}
}
let needs_flush = match (&self.loaded, &self.project_watch) {
(Loaded::Project(p), Some(w)) => {
p.code.as_bytes() != w.code.baseline()
|| encode_assets(&p.assets).unwrap_or_default() != w.assets.baseline()
}
_ => false,
};
if needs_flush {
if let Loaded::Project(p) = &self.loaded {
let _ = p.save();
}
if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
w.sync(p);
}
}
true
}
fn resync_assets_watcher(&mut self) {
if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
w.assets
.mark_synced(encode_assets(&p.assets).unwrap_or_default());
}
}
fn start_vm_from_loaded(&mut self) -> Result<()> {
self.vm = None;
let (wasm, assets) = match &self.loaded {
Loaded::None => bail!("No cart loaded"),
Loaded::Cart { cart, .. } => (cart.wasm.clone(), cart.assets.clone()),
Loaded::Project(p) => {
let path = p.wasm_path();
let wasm = std::fs::read(&path)
.map_err(|_| anyhow!("Cart not built yet ({})", path.display()))?;
self.wasm_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
(wasm, p.assets.clone())
}
};
let storage = match &self.storage_root {
Some(root) => Storage::for_cart_in(root, &assets.meta.name),
None => Storage::for_cart(&assets.meta.name),
};
let vm = GameVm::load(&wasm, &assets, self.audio.clone(), storage)?;
self.vm = Some(vm);
self.mode = Mode::Run;
Ok(())
}
fn cmd_export(&mut self, args: &[&str]) -> Result<()> {
let mut include_source = true;
let mut file = None;
for a in args {
if *a == "-nosrc" {
include_source = false;
} else {
file = Some(*a);
}
}
let file = file
.map(|f| f.to_string())
.unwrap_or_else(|| format!("{}.png", self.cart_name()));
let out = self.cwd.join(&file);
if file.ends_with(".html") {
let cart = self.make_cart(false)?;
self.say("Exporting for web...", col::LIGHT_GREY);
let web_dir = crate::webexport::web_crate_dir(&self.sdk_path);
crate::webexport::export_html(&cart, &out, &web_dir)?;
} else {
let cart = self.make_cart(include_source)?;
cart::save_png(&cart, &out)?;
}
self.say(&format!("Exported {file}"), col::GREEN);
Ok(())
}
fn make_cart(&self, include_source: bool) -> Result<Cart> {
match &self.loaded {
Loaded::None => bail!("No cart loaded"),
Loaded::Cart { cart, .. } => Ok(Cart {
wasm: cart.wasm.clone(),
assets: cart.assets.clone(),
source: if include_source {
cart.source.clone()
} else {
None
},
}),
Loaded::Project(p) => {
let wasm = std::fs::read(p.wasm_path())
.map_err(|_| anyhow!("Cart not built yet. Type run first"))?;
Ok(Cart {
wasm,
assets: p.assets.clone(),
source: include_source.then(|| p.lib_source()),
})
}
}
}
fn cmd_import(&mut self, args: &[&str]) -> Result<()> {
let (Some(png), Some(dir)) = (args.first(), args.get(1)) else {
bail!("Usage: import <cart.png> <dir>");
};
let cart = cart::load_png(&self.cwd.join(png))?;
let Some(source) = &cart.source else {
bail!("Cart has no source (playable-only)");
};
let dir = self.cwd.join(dir);
let mut project = Project::create(&dir, &cart.assets.meta.name)?;
project.code = source.clone();
project.assets = cart.assets.clone();
project.save()?;
self.say(&format!("Imported into {}", dir.display()), col::GREEN);
self.code_ed.set_text(&project.code);
self.loaded = Loaded::Project(project);
if let Loaded::Project(p) = &self.loaded {
self.project_watch = Some(ProjectWatch::new(p));
}
self.cart_watch = None;
Ok(())
}
fn cmd_import_pico8(&mut self, args: &[&str]) -> Result<()> {
if args.contains(&"--into") {
return self.cmd_import_pico8_into(args);
}
let Some(src) = args.first() else {
bail!("Usage: import-pico8 <cart.p8|cart.p8.png> [dir]");
};
let src = self.cwd.join(src);
let dir = match args.get(1) {
Some(d) => self.cwd.join(d),
None => self.cwd.join(pixel8_runtime::pico8::default_dir_name(&src)),
};
let project = pixel8_runtime::pico8::import_project(&src, &dir)?;
self.say(
&format!("Imported assets into {}", dir.display()),
col::GREEN,
);
self.code_ed.set_text(&project.code);
self.loaded = Loaded::Project(project);
if let Loaded::Project(p) = &self.loaded {
self.project_watch = Some(ProjectWatch::new(p));
}
self.cart_watch = None;
Ok(())
}
fn cmd_import_pico8_into(&mut self, args: &[&str]) -> Result<()> {
let (mut src, mut sprites, mut sfx, mut music) = (None, None, None, None);
let mut it = args.iter();
while let Some(&a) = it.next() {
match a {
"--into" => {} "--sprites" => sprites = Some(flag_value(it.next(), "--sprites")?),
"--sfx" => sfx = Some(flag_value(it.next(), "--sfx")?),
"--music" => music = Some(flag_value(it.next(), "--music")?),
flag if flag.starts_with("--") => bail!("unknown flag {flag}"),
pos if src.is_none() => src = Some(pos),
pos => bail!("unexpected argument {pos}"),
}
}
let Some(src) = src else {
bail!("Usage: import-pico8 <cart> --into [--sprites R] [--sfx R] [--music R]");
};
let sel = pixel8_runtime::pico8::Selection::parse(sprites, sfx, music)?;
let report = {
let Loaded::Project(project) = &mut self.loaded else {
bail!("import-pico8 --into needs a project loaded; use `new` or `load` first");
};
let assets = pixel8_runtime::pico8::parse_file(&self.cwd.join(src))?;
let report =
pixel8_runtime::pico8::append_pico8_assets(&mut project.assets, &assets, &sel)?;
project.save()?;
report
};
if let (Loaded::Project(p), Some(w)) = (&self.loaded, &mut self.project_watch) {
w.sync(p);
}
for line in report.summary_lines() {
self.say(&format!("Imported {line}"), col::GREEN);
}
for w in &report.warnings {
self.say(w, col::YELLOW);
}
Ok(())
}
fn cmd_info(&mut self) {
match self.assets() {
None => self.say("No cart loaded", col::RED),
Some(a) => {
let (name, author, version) = (
a.meta.name.clone(),
a.meta.author.clone(),
a.meta.version.clone(),
);
let label = if a.label.is_some() {
"Captured"
} else {
"Default"
};
let kind = match &self.loaded {
Loaded::Project(p) => format!("Project {}", p.dir.display()),
Loaded::Cart { path, .. } => format!("Cart {}", path.display()),
Loaded::None => unreachable!(),
};
self.say(&format!("Title: {name}"), col::WHITE);
self.say(&format!("Author: {author}"), col::WHITE);
self.say(&format!("Version: {version}"), col::WHITE);
self.say(&format!("Label: {label}"), col::LIGHT_GREY);
self.say(&kind, col::LIGHT_GREY);
}
}
}
fn cmd_ls(&mut self) -> Result<()> {
let mut entries: Vec<_> = std::fs::read_dir(&self.cwd)?
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
(name, is_dir)
})
.filter(|(n, _)| !n.starts_with('.'))
.collect();
entries.sort();
for (name, is_dir) in entries.into_iter().take(40) {
if is_dir {
self.say(&format!("{name}/"), col::BLUE);
} else if name.ends_with(".png") {
self.say(&name, col::PINK);
} else {
self.say(&name, col::LIGHT_GREY);
}
}
Ok(())
}
fn cmd_meta(&mut self, args: &[&str], set: impl FnOnce(&mut Assets, String)) -> Result<()> {
if args.is_empty() {
bail!("Missing text");
}
let value = args.join(" ");
match self.assets_mut() {
None => bail!("No cart loaded"),
Some(a) => {
set(a, value);
self.say("OK", col::GREEN);
Ok(())
}
}
}
fn capture_label(&mut self) {
let Some(vm) = &self.vm else { return };
let pixels = vm.state().fb.pixels().to_vec();
if let Some(a) = self.assets_mut() {
a.label = Some(pixels);
}
self.capture_flash = CAPTURE_FLASH_FRAMES;
self.say("Label captured", col::GREEN);
}
fn stop_run(&mut self, message: &str) {
self.vm = None;
self.audio.stop_all();
self.mode = Mode::Console;
if !message.is_empty() {
self.say(message, col::RED);
}
}
fn show_error(&mut self, phase: &str, message: &str) {
self.stop_run("");
self.say("", col::WHITE);
self.say(&format!("** Error in {phase} **"), col::RED);
for line in message.lines().take(12) {
self.say(line, col::ORANGE);
}
}
fn runtime_error(&mut self, e: RuntimeError) {
self.show_error(e.phase, &e.message);
}
pub fn tick_fps(&self) -> u32 {
match (self.mode, &self.vm) {
(Mode::Run, Some(vm)) => vm.fps(),
_ => UI_FPS,
}
}
pub fn tick(&mut self) {
self.frame += 1;
if let Some(job) = &self.build {
if let Some(result) = job.poll() {
self.build = None;
if result.success {
let msg = format!("Build ok ({:.1}s)", result.duration.as_secs_f32());
self.say(&msg, col::GREEN);
self.toast(&msg, col::GREEN, 2.0);
for w in &result.warnings {
self.say(w, col::ORANGE);
}
if self.run_after_build {
self.run_after_build = false;
if let Err(e) = self.start_vm_from_loaded() {
self.show_error("boot", &e.to_string());
}
}
} else {
self.run_after_build = false;
self.stop_run("");
let n = result
.errors
.iter()
.filter(|l| l.starts_with("error"))
.count();
self.say(&format!("Build failed ({n} errors)"), col::RED);
for line in &result.errors {
let color = if line.starts_with("error") {
col::RED
} else {
col::ORANGE
};
self.say(line, color);
}
}
}
}
self.poll_project_watch();
self.poll_cart_watch();
self.check_hot_reload();
match self.mode {
Mode::Run => {
if self.vm.is_some() {
let fps_val = self.fps_val;
let (logs, result) = {
let vm = self.vm.as_mut().unwrap();
vm.state_mut().set_measured_fps(fps_val);
let logs = std::mem::take(&mut vm.state_mut().logs);
let r = vm.call_update().and_then(|()| vm.call_draw());
(logs, r)
};
for l in logs {
self.say(&l, col::LIGHT_GREY);
}
if let Err(e) = result {
self.runtime_error(e);
}
} else {
self.mode = Mode::Console;
}
}
Mode::Console => {}
_ => {
if !self.file_picker.is_open() {
if let Some(target) = ui::tab_bar_click(&self.mouse) {
self.switch_editor(EDITOR_MODES[target]);
}
}
let mouse = self.mouse;
let audio = self.audio.clone();
match self.mode {
Mode::Code => {
if self.file_picker.is_open() {
let files = self.project_file_names();
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
if let Some(action) = self.file_picker.tick(&mouse, &refs) {
self.run_picker_action(action);
}
} else if !self.loaded_none() {
if ui::filename_clicked(&mouse, &self.current_file_name())
&& matches!(self.loaded, Loaded::Project(_))
{
self.open_file_picker();
} else {
let code = self.code().unwrap_or_default().to_string();
self.code_ed.tick(&mouse, &code);
}
}
}
Mode::Sprite => {
if let Some(a) = assets_of(&mut self.loaded) {
self.sprite_ed.tick(&mouse, a);
}
}
Mode::Map => {
if let Some(a) = assets_of(&mut self.loaded) {
self.map_ed.tick(&mouse, a);
}
}
Mode::Sfx => {
if let Some(a) = assets_of(&mut self.loaded) {
self.sfx_ed.tick(&mouse, a, &audio);
}
}
Mode::Music => {
if let Some(a) = assets_of(&mut self.loaded) {
self.music_ed.tick(&mouse, a, &audio);
}
if let Some(n) = self.music_ed.take_edit_request() {
self.sfx_ed.select(n);
self.switch_editor(Mode::Sfx);
}
}
_ => {}
}
}
}
self.mouse.end_frame();
}
fn check_hot_reload(&mut self) {
if !self.frame.is_multiple_of(30) {
return;
}
let Loaded::Project(p) = &self.loaded else {
return;
};
let Ok(meta) = std::fs::metadata(p.wasm_path()) else {
return;
};
let Ok(mtime) = meta.modified() else {
return;
};
match self.wasm_mtime {
Some(prev) if mtime > prev => {
self.wasm_mtime = Some(mtime);
if self.mode == Mode::Run {
match self.start_vm_from_loaded() {
Ok(()) => self.say("Hot reloaded", col::GREEN),
Err(e) => self.show_error("reload", &e.to_string()),
}
}
}
Some(_) => {}
None => self.wasm_mtime = Some(mtime),
}
}
fn poll_project_watch(&mut self) {
if !self.frame.is_multiple_of(30) || self.build.is_some() {
return;
}
let (code_mem, assets_mem) = match &self.loaded {
Loaded::Project(p) => (
p.code.clone().into_bytes(),
encode_assets(&p.assets).unwrap_or_default(),
),
_ => return,
};
let Some(w) = &mut self.project_watch else {
return;
};
let assets_change = w.assets.poll(&assets_mem);
let code_change = w.code.poll(&code_mem);
let source_changed = w.source_tree.poll();
let code_conflicted = w.code.in_conflict();
match assets_change {
FileChange::Adopt(bytes) => match decode_assets(&bytes) {
Ok(assets) => {
if let Loaded::Project(p) = &mut self.loaded {
p.assets = assets;
}
self.say("Assets reloaded from disk", col::GREEN);
if self.mode == Mode::Run {
if let Err(e) = self.start_vm_from_loaded() {
self.show_error("reload", &e.to_string());
}
}
}
Err(_) => {
self.resync_assets_watcher();
self.say("assets.pixel8.json on disk is unreadable", col::ORANGE);
}
},
FileChange::Conflict => {
self.say("assets.pixel8.json changed on disk;", col::ORANGE);
self.say("You have unsaved edits", col::ORANGE);
}
FileChange::None => {}
}
match code_change {
FileChange::Adopt(bytes) => {
let text = String::from_utf8_lossy(&bytes).into_owned();
self.code_ed.set_text(&text);
if let Loaded::Project(p) = &mut self.loaded {
p.code = text;
}
}
FileChange::Conflict => {
self.say(
&format!("src/{} changed on disk;", self.current_file_name()),
col::ORANGE,
);
self.say("Save or reload to resolve", col::ORANGE);
}
FileChange::None => {}
}
if source_changed && !code_conflicted {
let dir = match &self.loaded {
Loaded::Project(p) => p.dir.clone(),
_ => return,
};
self.say("Source changed, rebuilding...", col::LIGHT_GREY);
self.toast("Rebuilding...", col::LIGHT_GREY, 1.5);
self.build = Some(spawn_build(&dir));
self.run_after_build = matches!(self.mode, Mode::Run | Mode::Console);
}
}
fn poll_cart_watch(&mut self) {
if !self.frame.is_multiple_of(30) {
return;
}
let (path, in_memory, baseline) = match (&self.loaded, &mut self.cart_watch) {
(Loaded::Cart { cart, .. }, Some(w)) => {
if w.advanced().is_none() {
return;
}
(
w.path.clone(),
encode_assets(&cart.assets).unwrap_or_default(),
w.baseline.clone(),
)
}
_ => return,
};
let new_cart = match cart::load_png(&path) {
Ok(c) => c,
Err(e) => {
self.show_error("reload", &e.to_string());
return;
}
};
let disk = encode_assets(&new_cart.assets).unwrap_or_default();
match crate::watch::reconcile(&baseline, &disk, &in_memory) {
crate::watch::Reconcile::Unchanged => {}
crate::watch::Reconcile::Adopt(_) => {
self.code_ed.set_text(
new_cart
.source
.as_deref()
.unwrap_or("// No source in this cart"),
);
if let Some(w) = &mut self.cart_watch {
w.baseline = disk;
}
self.loaded = Loaded::Cart {
cart: new_cart,
path,
};
self.say("Cart reloaded from disk", col::GREEN);
if self.mode == Mode::Run {
if let Err(e) = self.start_vm_from_loaded() {
self.show_error("reload", &e.to_string());
}
}
}
crate::watch::Reconcile::Conflict => {
self.say("Cart changed on disk;", col::ORANGE);
self.say("You have unsaved edits", col::ORANGE);
}
}
}
fn meter_fps(&mut self) {
self.fps_frames += 1;
let elapsed = self.fps_t0.elapsed();
if elapsed >= Duration::from_millis(500) {
self.fps_val = self.fps_frames as f32 / elapsed.as_secs_f32();
self.fps_frames = 0;
self.fps_t0 = Instant::now();
}
}
pub fn draw(&mut self) -> &Framebuffer {
self.meter_fps();
match self.mode {
Mode::Run => {
if self.show_stats {
let fps = self.fps_val;
if let Some(vm) = self.vm.as_mut() {
let target = vm.fps();
let cpu_u = vm.cpu_update();
let cpu_d = vm.cpu_draw();
let used = vm.mem_used_bytes();
stats_overlay(&mut vm.state_mut().fb, cpu_u, cpu_d, used, fps, target);
}
}
if self.capture_flash > 0 {
if let Some(vm) = self.vm.as_mut() {
capture_flash_overlay(&mut vm.state_mut().fb);
}
self.capture_flash -= 1;
}
if let Some(vm) = &self.vm {
return &vm.state().fb;
}
&self.fb
}
Mode::Console => {
self.draw_console();
&self.fb
}
_ => {
self.fb.reset_state();
self.fb.cls(col::DARK_GREY);
let mouse = self.mouse;
match self.mode {
Mode::Code => {
let code = self.code().unwrap_or_default().to_string();
self.code_ed.draw(&mut self.fb, &code);
if self.file_picker.is_open() {
let files = self.project_file_names();
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
let current = self.current_file_name();
self.file_picker.draw(&mut self.fb, &refs, ¤t);
}
}
Mode::Sprite => {
if let Some(a) = assets_ref(&self.loaded) {
self.sprite_ed.draw(&mut self.fb, a);
}
}
Mode::Map => {
if let Some(a) = assets_ref(&self.loaded) {
self.map_ed.draw(&mut self.fb, a);
}
}
Mode::Sfx => {
if let Some(a) = assets_ref(&self.loaded) {
self.sfx_ed.draw(&mut self.fb, a, &self.audio);
}
}
Mode::Music => {
if let Some(a) = assets_ref(&self.loaded) {
self.music_ed.draw(&mut self.fb, a, &self.audio);
}
}
_ => {}
}
ui::draw_tab_bar(&mut self.fb, self.mode);
match self.mode {
Mode::Code => {
let name = self.current_file_name();
ui::code_filename(&mut self.fb, &name);
}
Mode::Sfx => ui::mode_buttons(&mut self.fb, self.sfx_ed.is_pitch()),
Mode::Sprite => ui::view_buttons(&mut self.fb, self.sprite_ed.is_fullscreen()),
Mode::Map => ui::view_buttons(&mut self.fb, self.map_ed.is_fullscreen()),
Mode::Music => {
ui::mode_buttons(&mut self.fb, !self.music_ed.is_grid());
if self.music_ed.is_grid() {
ui::pat_sfx_toggle(&mut self.fb, self.music_ed.grid_sfx());
}
}
_ => {}
}
self.draw_toast();
if !self.file_picker.is_open() {
if let Some(i) = ui::tab_bar_hover(&mouse) {
ui::status_bar(&mut self.fb, ui::tab_name(i));
}
}
if !self.hide_cursor {
ui::draw_cursor(&mut self.fb, &mouse);
}
&self.fb
}
}
}
fn draw_toast(&mut self) {
let msg = if self.build.is_some() {
let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
Some((format!("Building{dots}"), col::ORANGE))
} else {
match &self.toast {
Some((text, color, expires)) if self.frame < *expires => {
Some((text.clone(), *color))
}
_ => {
self.toast = None;
None
}
}
};
if let Some((text, color)) = msg {
self.fb.rectfill(0, 120, 127, 127, col::BLACK);
self.fb.print(&text, 2, 121, color);
}
}
fn draw_console(&mut self) {
self.fb.reset_state();
self.fb.cls(col::BLACK);
let rows = ((120 - 2) / font::GLYPH_H) as usize;
let total = self.lines.len();
let end = total.saturating_sub(self.scroll_back);
let start = end.saturating_sub(rows);
let mut y = 2;
for line in self.lines.iter().skip(start).take(end - start) {
match line {
ConsoleLine::Text { text, color } => {
self.fb.print(text, 2, y, *color);
}
ConsoleLine::Stripe => {
for (i, c) in [8u8, 9, 10, 11, 12, 13, 14, 15].iter().enumerate() {
self.fb
.rectfill(2 + i as i32 * 6, y, 2 + i as i32 * 6 + 4, y + 3, *c);
}
}
}
y += font::GLYPH_H;
}
if self.build.is_some() {
let dots = ".".repeat(1 + (self.frame as usize / 10) % 3);
self.fb
.print(&format!("Compiling{dots}"), 2, y, col::ORANGE);
return;
}
let prompt = format!("> {}", self.input);
self.fb.print(&prompt, 2, y, PROMPT_COL);
if (self.frame / 8).is_multiple_of(2) {
let cx = 2 + (2 + self.cursor as i32) * 4;
self.fb
.rectfill(cx, y, cx + 3, y + font::GLYPH_H - 2, col::RED);
}
}
}
fn flag_value<'a>(next: Option<&'a &'a str>, flag: &str) -> Result<&'a str> {
match next {
Some(&v) if !v.starts_with("--") => Ok(v),
_ => bail!("{flag} needs a value"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn stats_overlay_draws_top_right_panel() {
let text_pixels = |fb: &Framebuffer, xr: std::ops::Range<i32>| {
let mut n = 0;
for y in 0..28 {
for x in xr.clone() {
if fb.pget(x, y) != col::BLACK {
n += 1;
}
}
}
n
};
let mut fb = Framebuffer::new();
stats_overlay(&mut fb, 0.342, 0.51, 32768, 30.0, 30);
assert!(
text_pixels(&fb, 78..86) > 0,
"decimal CPU widened the panel left"
);
assert_eq!(
text_pixels(&fb, 0..60),
0,
"overlay leaves the top-left untouched"
);
}
fn test_shell() -> Shell {
let sdk = Path::new(env!("CARGO_MANIFEST_DIR")).join("../pixel8");
let mut shell = Shell::new(AudioHandle::dummy(), sdk);
shell.storage_root =
Some(std::env::temp_dir().join(format!("pixel8_test_storage_{}", std::process::id())));
shell
}
#[test]
fn restart_saves_storage_before_the_new_vm_loads() {
use pixel8_runtime::{assets::Assets, cart::Cart, storage::Storage};
const COUNTER_CART: &str = r#"
(module
(import "pixel8" "storage_get" (func $sget (param i32 i32 i32 i32) (result i32)))
(import "pixel8" "storage_set" (func $sset (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "n")
(func (export "pixel8_init")
(local $n i32)
(if (i32.eq (call $sget (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))
(i32.const 1))
(then (local.set $n (i32.sub (i32.load8_u (i32.const 8)) (i32.const 48)))))
(i32.store8 (i32.const 8) (i32.add (i32.const 49) (local.get $n)))
(drop (call $sset (i32.const 0) (i32.const 1) (i32.const 8) (i32.const 1))))
(func (export "pixel8_update"))
(func (export "pixel8_draw")))
"#;
let root = std::env::temp_dir().join(format!("pixel8_restart_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut shell = test_shell();
shell.storage_root = Some(root.clone());
shell.loaded = Loaded::Cart {
cart: Cart {
wasm: wat::parse_str(COUNTER_CART).unwrap(),
assets: Assets::default(),
source: None,
},
path: PathBuf::from("counter.png"),
};
shell.start_vm_from_loaded().expect("first start"); shell.start_vm_from_loaded().expect("restart"); shell.vm = None; let s = Storage::for_cart_in(&root, &Assets::default().meta.name);
assert_eq!(
s.get_json("n").as_deref(),
Some("2"),
"the restart must persist the first run's write before the second reads"
);
std::fs::remove_dir_all(&root).unwrap();
}
fn point_cart_at_local_sdk(project_dir: &Path) {
let sdk = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../pixel8")
.canonicalize()
.unwrap();
let manifest_path = project_dir.join("Cargo.toml");
let manifest = std::fs::read_to_string(&manifest_path).unwrap();
let dep = format!(
"version = \"{}.{}\"",
env!("CARGO_PKG_VERSION_MAJOR"),
env!("CARGO_PKG_VERSION_MINOR"),
);
let patched = manifest.replace(&dep, &format!("path = {:?}", sdk.display().to_string()));
assert_ne!(patched, manifest, "no `{dep}` to redirect in:\n{manifest}");
std::fs::write(&manifest_path, patched).unwrap();
}
#[test]
fn window_title_reflects_loaded_cart() {
let dir = std::env::temp_dir().join(format!("pixel8_title_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
assert_eq!(shell.window_title(), "Pixel8");
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load project");
assert_eq!(shell.window_title(), "game - Pixel8");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ctrl_s_saves_and_builds_with_feedback() {
let dir = std::env::temp_dir().join(format!("pixel8_shell_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
point_cart_at_local_sdk(&project_dir);
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load project");
shell.switch_editor(Mode::Code);
for c in "//x".chars() {
shell.key(Key::Char(c), Mods::default());
}
shell.key(Key::Enter, Mods::default());
shell.key(
Key::Char('s'),
Mods {
ctrl: true,
..Default::default()
},
);
let code = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
assert!(code.starts_with("//x\n"), "edit was saved");
assert_eq!(shell.toast.as_ref().unwrap().0, "Saved");
assert!(shell.build.is_some(), "background build spawned");
shell.draw();
assert_eq!(shell.fb.pget(0, 120), col::BLACK, "toast bar drawn");
for _ in 0..(120 * 30) {
shell.tick();
if shell.build.is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(33));
}
assert!(shell.build.is_none(), "build finished in time");
let (text, color, _) = shell.toast.as_ref().unwrap();
assert!(text.starts_with("Build ok"), "got: {text}");
assert_eq!(*color, col::GREEN);
assert!(
shell.mode == Mode::Code,
"stays in the editor; no mode switch"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn save_build_failure_is_reported() {
let dir = std::env::temp_dir().join(format!("pixel8_shell_fail_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
let mut project = Project::create(&project_dir, "game").unwrap();
project.code = "fn broken( {".into();
project.save().unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load project");
shell.switch_editor(Mode::Code);
shell.key(
Key::Char('s'),
Mods {
ctrl: true,
..Default::default()
},
);
assert!(shell.build.is_some());
for _ in 0..(120 * 30) {
shell.tick();
if shell.build.is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(33));
}
assert_eq!(
shell.mode,
Mode::Console,
"expected console mode after build failure"
);
let summary = shell.lines.iter().find_map(|l| match l {
ConsoleLine::Text { text, color } if text.starts_with("Build failed") => {
Some((text.clone(), *color))
}
_ => None,
});
let (text, color) = summary.expect("Build failed summary not found in console lines");
assert!(text.starts_with("Build failed"), "got: {text}");
assert_eq!(color, col::RED);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn run_does_not_clobber_external_edits() {
let dir = std::env::temp_dir().join(format!("pixel8_run_noclobber_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load");
let lib = project_dir.join("src/lib.rs");
let original = std::fs::read_to_string(&lib).unwrap();
let edited = format!("// EXTERNAL EDIT\n{original}");
std::fs::write(&lib, &edited).unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
std::fs::OpenOptions::new()
.write(true)
.open(&lib)
.unwrap()
.set_modified(later)
.unwrap();
shell.cmd_run();
let after = std::fs::read_to_string(&lib).unwrap();
assert!(
after.starts_with("// EXTERNAL EDIT\n"),
"external edit survived run; got:\n{after}"
);
for _ in 0..(120 * 30) {
shell.tick();
if shell.build.is_none() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(33));
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn startup_run_arms_the_deferred_run() {
let dir = std::env::temp_dir().join(format!("pixel8_startup_run_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
shell.startup_load(project_dir.to_str().unwrap());
assert_eq!(shell.mode, Mode::Console, "loaded, still at the console");
shell.startup_run();
assert!(shell.build.is_some(), "startup_run spawns the build");
assert!(
shell.run_after_build,
"and arms the run to start once it is built"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn new_arms_the_project_watcher() {
let dir = std::env::temp_dir().join(format!("pixel8_new_watch_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
shell.cwd = dir.clone();
shell.cmd_new(&["game"]).expect("new");
assert!(shell.project_watch.is_some(), "new should arm the watcher");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn external_edit_auto_builds_and_runs() {
let dir = std::env::temp_dir().join(format!("pixel8_autobuild_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
point_cart_at_local_sdk(&project_dir);
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load");
assert_eq!(shell.mode, Mode::Console);
let lib = project_dir.join("src/lib.rs");
let original = std::fs::read_to_string(&lib).unwrap();
std::fs::write(&lib, format!("// auto\n{original}")).unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
std::fs::OpenOptions::new()
.write(true)
.open(&lib)
.unwrap()
.set_modified(later)
.unwrap();
let mut entered_run = false;
for _ in 0..(180 * 30) {
shell.tick();
if shell.mode == Mode::Run {
entered_run = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(33));
}
assert!(entered_run, "external edit should auto-build and run");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn external_png_change_reloads_cart() {
use pixel8_runtime::cart::{self, Cart};
let dir = std::env::temp_dir().join(format!("pixel8_pngreload_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut shell = test_shell();
let project_dir = dir.join("game");
let project = Project::create(&project_dir, "game").unwrap();
let png = dir.join("game.png");
let mut cart = Cart {
wasm: b"\0asm\x01\0\0\0".to_vec(),
assets: project.assets.clone(),
source: Some("// v1".into()),
};
cart::save_png(&cart, &png).unwrap();
shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
let before = shell.cart_name();
cart.assets.meta.name = "renamed".into();
cart::save_png(&cart, &png).unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
std::fs::OpenOptions::new()
.write(true)
.open(&png)
.unwrap()
.set_modified(later)
.unwrap();
for _ in 0..(60 * 30) {
shell.tick();
if shell.cart_name() != before {
break;
}
std::thread::sleep(std::time::Duration::from_millis(33));
}
assert_eq!(shell.cart_name(), "renamed", "external PNG change adopted");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn saving_png_does_not_self_conflict() {
use pixel8_runtime::{
cart::{self, Cart},
project::encode_assets,
};
let dir = std::env::temp_dir().join(format!("pixel8_pngsave_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut shell = test_shell();
let project = Project::create(&dir.join("game"), "game").unwrap();
let png = dir.join("game.png");
let cart = Cart {
wasm: b"\0asm\x01\0\0\0".to_vec(),
assets: project.assets.clone(),
source: Some("// v1".into()),
};
cart::save_png(&cart, &png).unwrap();
shell.cmd_load(&[png.to_str().unwrap()]).expect("load png");
if let Some(a) = shell.assets_mut() {
a.meta.name = "edited".into();
}
shell.cmd_save(&[]).expect("save");
let in_mem = encode_assets(shell.assets().unwrap()).unwrap_or_default();
assert_eq!(
shell.cart_watch.as_ref().unwrap().baseline,
in_mem,
"save re-baselined the cart watcher"
);
for _ in 0..(2 * 30) {
shell.tick();
}
assert_eq!(shell.cart_name(), "edited");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn reload_takes_disk_version() {
let dir = std::env::temp_dir().join(format!("pixel8_reload_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load");
let lib = project_dir.join("src/lib.rs");
std::fs::write(&lib, "// DISK VERSION\n").unwrap();
shell.cmd_reload().expect("reload");
let code = shell.code().unwrap_or_default().to_string();
assert!(
code.starts_with("// DISK VERSION"),
"reload took disk; got:\n{code}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn run_aborts_on_conflict_and_does_not_clobber() {
let dir = std::env::temp_dir().join(format!("pixel8_run_conflict_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load");
if let Loaded::Project(p) = &mut shell.loaded {
p.code = "// IN-CONSOLE EDIT\n".into();
}
let lib = project_dir.join("src/lib.rs");
std::fs::write(&lib, "// EXTERNAL EDIT\n").unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(10);
std::fs::OpenOptions::new()
.write(true)
.open(&lib)
.unwrap()
.set_modified(later)
.unwrap();
shell.cmd_run();
assert_eq!(shell.mode, Mode::Console, "first run aborts on conflict");
assert!(shell.build.is_none(), "no build started on conflict");
assert_eq!(
std::fs::read_to_string(&lib).unwrap(),
"// EXTERNAL EDIT\n",
"disk untouched after the first run"
);
shell.cmd_run();
assert_eq!(shell.mode, Mode::Console, "second run still aborts");
assert!(shell.build.is_none(), "second run starts no build");
assert_eq!(
std::fs::read_to_string(&lib).unwrap(),
"// EXTERNAL EDIT\n",
"disk still untouched after the second run"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn picker_creates_and_switches_files_in_the_shell() {
let dir = std::env::temp_dir().join(format!("pixel8_pick_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load project");
shell.switch_editor(Mode::Code);
let ctrl = Mods {
ctrl: true,
..Default::default()
};
shell.key(Key::Char('o'), ctrl);
assert!(shell.file_picker.is_open());
shell.key(Key::Down, Mods::default()); shell.key(Key::Enter, Mods::default()); for c in "enemy".chars() {
shell.key(Key::Char(c), Mods::default());
}
shell.key(Key::Enter, Mods::default());
assert!(!shell.file_picker.is_open());
assert!(project_dir.join("src/enemy.rs").exists());
let lib = std::fs::read_to_string(project_dir.join("src/lib.rs")).unwrap();
assert!(lib.starts_with("#![no_std]"), "lib.rs:\n{lib}");
assert!(lib.contains("\nmod enemy;\n"), "lib.rs:\n{lib}");
assert_eq!(shell.code().unwrap(), "");
shell.key(Key::Char('o'), ctrl);
shell.key(Key::Enter, Mods::default()); assert!(shell.code().unwrap().contains("\nmod enemy;\n"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn clicking_the_filename_opens_the_picker() {
let dir = std::env::temp_dir().join(format!("pixel8_fnclick_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load project");
shell.switch_editor(Mode::Code);
assert!(!shell.file_picker.is_open());
let name = shell.current_file_name();
shell.mouse = Mouse {
x: 3,
y: 2,
left_pressed: true,
..Default::default()
};
assert!(ui::filename_clicked(&shell.mouse, &name));
shell.tick();
assert!(
shell.file_picker.is_open(),
"filename click opens the picker"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn select_file_save_failure_aborts_and_reports() {
let dir = std::env::temp_dir().join(format!("pixel8_savefail_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut shell = test_shell();
let project_dir = dir.join("game");
Project::create(&project_dir, "game").unwrap();
std::fs::write(project_dir.join("src/other.rs"), "").unwrap();
shell
.cmd_load(&[project_dir.to_str().unwrap()])
.expect("load project");
shell.switch_editor(Mode::Code);
let lib_path = project_dir.join("src/lib.rs");
std::fs::remove_file(&lib_path).unwrap();
std::fs::create_dir(&lib_path).unwrap();
shell.select_file("other.rs");
assert_eq!(
shell.current_file_name(),
"lib.rs",
"switch must be aborted when save fails"
);
let toast = shell
.toast
.as_ref()
.expect("toast must be set on save error");
assert_eq!(toast.1, col::RED, "toast must be red");
assert!(
toast.0.contains("lib.rs"),
"toast must name the file that failed to save; got: {}",
toast.0
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn import_pico8_into_appends_to_loaded_project() {
let dir = std::env::temp_dir().join(format!("pixel8_shell_into_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut row0 = vec![b'0'; 128];
row0[0] = b'c';
let p8 = format!(
"pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
String::from_utf8(row0).unwrap()
);
std::fs::write(dir.join("src.p8"), p8).unwrap();
let mut shell = test_shell();
shell.cwd = dir.clone();
shell.cmd_new(&["dest"]).expect("new");
shell.assets_mut().expect("loaded").sprites.set(0, 0, 5);
shell.exec("import-pico8 src.p8 --into --sprites 0");
match &shell.loaded {
Loaded::Project(p) => assert_eq!(p.name, "dest"),
_ => panic!("expected the dest project to remain loaded"),
}
let assets = shell.assets().expect("a project is loaded");
assert_eq!(assets.sprites.get(0, 0), 5);
assert_eq!(assets.sprites.get(8, 0), 0xc);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn additive_import_does_not_trigger_a_rebuild() {
let dir =
std::env::temp_dir().join(format!("pixel8_into_norebuild_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut row0 = vec![b'0'; 128];
row0[0] = b'c';
let p8 = format!(
"pico-8 cartridge // http://www.pico-8.com\nversion 41\n__gfx__\n{}\n",
String::from_utf8(row0).unwrap()
);
std::fs::write(dir.join("src.p8"), p8).unwrap();
let mut shell = test_shell();
shell.cwd = dir.clone();
shell.cmd_new(&["dest"]).expect("new");
shell.exec("import-pico8 src.p8 --into --sprites 0");
for _ in 0..40 {
shell.tick();
}
assert!(
shell.build.is_none(),
"additive import must not spawn a build"
);
assert_eq!(shell.mode, Mode::Console, "must stay at the console");
assert!(!shell.run_after_build, "must not arm a run");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn apply_paste_routes_map_blobs_to_the_map_editor() {
use pixel8_runtime::clipboard::Pasted;
let dir = std::env::temp_dir().join(format!("pixel8_mappaste_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut shell = test_shell();
shell.cwd = dir.clone();
shell.cmd_new(&["dest"]).expect("new");
shell.mode = Mode::Map;
shell.apply_paste(Pasted::Map {
w: 1,
h: 1,
tiles: vec![7],
});
assert!(shell.map_ed.has_paste_buffer());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn apply_paste_routes_sfx_to_the_sfx_editor() {
use pixel8_runtime::clipboard::{Pasted, SfxClip, Slotted};
let dir = std::env::temp_dir().join(format!("pixel8_paste_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut shell = test_shell();
shell.cwd = dir.clone();
shell.cmd_new(&["dest"]).expect("new");
shell.mode = Mode::Sfx;
let mut sfx = pixel8_runtime::assets::Sfx::default();
sfx.notes[0].pitch = 21;
sfx.notes[0].volume = 4;
let clip = SfxClip {
records: vec![Slotted { src: 0, value: sfx }],
patterns: vec![],
};
shell.apply_paste(Pasted::Sfx(clip));
let assets = assets_ref(&shell.loaded).unwrap();
assert_eq!(assets.sfx[0].notes[0].pitch, 21);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn hide_cursor_suppresses_the_software_cursor() {
let mut shell = test_shell();
shell.mode = Mode::Sprite;
shell.mouse.x = 40;
shell.mouse.y = 40;
let with_cursor = shell.draw().pixels().to_vec();
shell.set_hide_cursor(true);
let hidden = shell.draw().pixels().to_vec();
assert_ne!(
with_cursor, hidden,
"hiding the cursor must change the frame"
);
shell.set_hide_cursor(false);
shell.mouse = Mouse::default();
let no_pointer = shell.draw().pixels().to_vec();
assert_eq!(
hidden, no_pointer,
"a hidden cursor leaves no pointer behind"
);
}
}