From 8c7f6df4a8894b544da1c6480659ee26ea28f342 Mon Sep 17 00:00:00 2001 From: Luke Date: Sun, 2 Jun 2024 13:39:03 -0700 Subject: [PATCH] feat: added config and save/sram state persistence to web (#274) --- Cargo.lock | 1 + Cargo.toml | 1 + tetanes-core/Cargo.toml | 2 + tetanes-core/src/control_deck.rs | 37 ++++----- tetanes-core/src/fs.rs | 10 +++ tetanes-core/src/sys/fs/os.rs | 5 ++ tetanes-core/src/sys/fs/wasm.rs | 121 +++++++++++++++++++++++++--- tetanes/Cargo.toml | 3 +- tetanes/src/nes/audio.rs | 50 ++++++------ tetanes/src/nes/config.rs | 81 ++++++++++--------- tetanes/src/nes/emulation.rs | 81 +++++++++---------- tetanes/src/nes/emulation/replay.rs | 28 +++---- tetanes/src/nes/event.rs | 8 +- tetanes/src/nes/renderer/gui.rs | 107 ++++++++++++------------ tetanes/src/platform.rs | 3 +- tetanes/src/sys/platform/os.rs | 10 +-- tetanes/src/sys/platform/wasm.rs | 25 ++++-- 17 files changed, 350 insertions(+), 223 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 062224f3..ec9a61f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3656,6 +3656,7 @@ dependencies = [ "thiserror", "tracing", "tracing-subscriber", + "web-sys", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index bd1cb97a..9787be90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tracing = { version = "0.1", default-features = false, features = [ tracing-subscriber = "0.3" serde_json = "1.0" web-time = "0.2" # FIXME: winit is using an old version +web-sys = "0.3" # Playable framerates in development [profile.dev] diff --git a/tetanes-core/Cargo.toml b/tetanes-core/Cargo.toml index c635b146..41589ba0 100644 --- a/tetanes-core/Cargo.toml +++ b/tetanes-core/Cargo.toml @@ -43,6 +43,7 @@ enum_dispatch = "0.3" flate2 = "1.0" rand = "0.8" serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tracing.workspace = true @@ -52,6 +53,7 @@ puffin = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] puffin = { workspace = true, features = ["web"], optional = true } web-time.workspace = true +web-sys = { workspace = true, features = ["Storage", "Window"] } [dev-dependencies] anyhow.workspace = true diff --git a/tetanes-core/src/control_deck.rs b/tetanes-core/src/control_deck.rs index 4717cc47..a79a30d1 100644 --- a/tetanes-core/src/control_deck.rs +++ b/tetanes-core/src/control_deck.rs @@ -39,6 +39,9 @@ pub enum Error { /// Save state error. #[error("save state error: {0:?}")] SaveState(fs::Error), + /// When trying to load a save state that doesn't exist. + #[error("no save state found")] + NoSaveStateFound, /// Operational error indicating a ROM must be loaded first. #[error("no rom is loaded")] RomNotLoaded, @@ -133,7 +136,7 @@ pub struct Config { /// Headless mode. pub headless_mode: HeadlessMode, /// Data directory for storing battery-backed RAM. - pub data_dir: Option, + pub data_dir: PathBuf, /// Which mapper revisions to emulate for any ROM loaded that uses this mapper. pub mapper_revisions: MapperRevisionsConfig, /// Whether to emulate PPU warmup where writes to certain registers are ignored. Can result in @@ -152,15 +155,15 @@ impl Config { /// Returns the default directory where TetaNES data is stored. #[inline] #[must_use] - pub fn default_data_dir() -> Option { - dirs::data_local_dir().map(|dir| dir.join(Self::BASE_DIR)) + pub fn default_data_dir() -> PathBuf { + dirs::data_local_dir().map_or_else(|| PathBuf::from("data"), |dir| dir.join(Self::BASE_DIR)) } /// Returns the directory used to store battery-backed Cart RAM. #[inline] #[must_use] - pub fn sram_dir(&self) -> Option { - self.data_dir.as_ref().map(|dir| dir.join(Self::SRAM_DIR)) + pub fn sram_dir(&self) -> PathBuf { + self.data_dir.join(Self::SRAM_DIR) } } @@ -208,7 +211,7 @@ pub struct ControlDeck { /// The currently loaded ROM [`Cart`], if any. loaded_rom: Option, /// Directory for storing battery-backed Cart RAM if a ROM is loaded. - sram_dir: Option, + sram_dir: PathBuf, /// Mapper revisions to emulate for any ROM loaded that matches the given mappers. mapper_revisions: MapperRevisionsConfig, /// Whether to auto-detect the region based on the loaded Cart. @@ -276,8 +279,8 @@ impl ControlDeck { /// Returns the path to the SRAM save file for a given ROM name which is used to store /// battery-backed Cart RAM. Returns `None` when the current platform doesn't have a /// `data` directory and no custom `data_dir` was configured. - pub fn sram_dir(&self, name: &str) -> Option { - self.sram_dir.as_ref().map(|dir| dir.join(name)) + pub fn sram_dir(&self, name: &str) -> PathBuf { + self.sram_dir.join(name) } /// Loads a ROM cartridge into memory @@ -304,10 +307,9 @@ impl ControlDeck { self.update_mapper_revisions(); self.reset(ResetKind::Hard); self.running = true; - if let Some(dir) = self.sram_dir(&name) { - if let Err(err) = self.load_sram(dir) { - error!("failed to load SRAM: {err:?}"); - } + let sram_dir = self.sram_dir(&name); + if let Err(err) = self.load_sram(sram_dir) { + error!("failed to load SRAM: {err:?}"); } self.loaded_rom = Some(loaded_rom.clone()); Ok(loaded_rom) @@ -336,10 +338,9 @@ impl ControlDeck { /// If the loaded [`Cart`] is battery-backed and saving fails, then an error is returned. pub fn unload_rom(&mut self) -> Result<()> { if let Some(rom) = &self.loaded_rom { - if let Some(dir) = self.sram_dir(&rom.name) { - if let Err(err) = self.save_sram(dir) { - error!("failed to save SRAM: {err:?}"); - } + let sram_dir = self.sram_dir(&rom.name); + if let Err(err) = self.save_sram(sram_dir) { + error!("failed to save SRAM: {err:?}"); } } self.loaded_rom = None; @@ -515,7 +516,7 @@ impl ControlDeck { return Err(Error::RomNotLoaded); }; let path = path.as_ref(); - if path.exists() { + if fs::exists(path) { fs::load::(path) .map_err(Error::SaveState) .map(|mut cpu| { @@ -523,7 +524,7 @@ impl ControlDeck { self.load_cpu(cpu) }) } else { - Ok(()) + Err(Error::NoSaveStateFound) } } diff --git a/tetanes-core/src/fs.rs b/tetanes-core/src/fs.rs index 271beab1..4d324cf7 100644 --- a/tetanes-core/src/fs.rs +++ b/tetanes-core/src/fs.rs @@ -116,6 +116,9 @@ where let mut writer = fs::writer_impl(path)?; write_header(&mut writer).map_err(Error::WriteHeaderFailed)?; encode(&mut writer, &data).map_err(Error::EncodingFailed)?; + writer + .flush() + .map_err(|err| Error::io(err, "failed to save data"))?; Ok(()) } @@ -124,6 +127,9 @@ pub fn save_raw(path: impl AsRef, value: &[u8]) -> Result<()> { writer .write_all(value) .map_err(|err| Error::io(err, "failed to save data"))?; + writer + .flush() + .map_err(|err| Error::io(err, "failed to save data"))?; Ok(()) } @@ -160,6 +166,10 @@ pub fn clear_dir(path: impl AsRef) -> Result<()> { fs::clear_dir_impl(path) } +pub fn exists(path: &Path) -> bool { + fs::exists_impl(path) +} + pub fn filename(path: &Path) -> &str { path.file_name() .and_then(std::ffi::OsStr::to_str) diff --git a/tetanes-core/src/sys/fs/os.rs b/tetanes-core/src/sys/fs/os.rs index 82b35352..8e58f472 100644 --- a/tetanes-core/src/sys/fs/os.rs +++ b/tetanes-core/src/sys/fs/os.rs @@ -30,3 +30,8 @@ pub fn clear_dir_impl(path: impl AsRef) -> Result<()> { remove_dir_all(path) .map_err(|source| Error::io(source, format!("failed to remove directory {path:?}"))) } + +pub fn exists_impl(path: impl AsRef) -> bool { + let path = path.as_ref(); + path.exists() +} diff --git a/tetanes-core/src/sys/fs/wasm.rs b/tetanes-core/src/sys/fs/wasm.rs index 47344d5e..fb665663 100644 --- a/tetanes-core/src/sys/fs/wasm.rs +++ b/tetanes-core/src/sys/fs/wasm.rs @@ -2,21 +2,120 @@ use crate::fs::{Error, Result}; use std::{ - io::{Empty, Read, Write}, - path::Path, + io::{self, Read, Write}, + mem, + path::{Path, PathBuf}, }; +use web_sys::js_sys; -pub fn writer_impl(_path: impl AsRef) -> Result { - // TODO: provide file download - Err::(Error::custom("not implemented: wasm write")) +#[derive(Debug)] +#[must_use] +pub struct StoreWriter { + path: PathBuf, + data: Vec, } -pub fn reader_impl(_path: impl AsRef) -> Result { - // TODO: provide file upload? - Err::(Error::custom("not implemented: wasm read")) +pub struct StoreReader { + cursor: io::Cursor>, } -pub fn clear_dir_impl(_path: impl AsRef) -> Result<()> { - // TODO: clear storage - Err::<(), _>(Error::custom("not implemented: wasm clear dir")) +fn local_storage() -> Result { + let window = web_sys::window().ok_or_else(|| Error::custom("failed to get js window"))?; + window + .local_storage() + .map_err(|err| { + tracing::error!("failed to get local storage: {err:?}"); + Error::custom(format!("failed to get storage")) + })? + .ok_or_else(|| Error::custom("no storage available")) +} + +impl Write for StoreWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.data.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + let local_storage = local_storage().map_err(io::Error::other)?; + + let key = self.path.to_string_lossy(); + let data = mem::take(&mut self.data); + let value = match serde_json::to_string(&data) { + Ok(value) => value, + Err(err) => { + self.data = data; + tracing::error!("failed to serialize data: {err:?}"); + return Err(io::Error::other("failed to serialize data")); + } + }; + + if let Err(err) = local_storage.set_item(&key, &value) { + self.data = data; + tracing::error!("failed to store data in local storage: {err:?}"); + return Err(io::Error::other("failed to write data")); + } + + Ok(()) + } +} + +impl Read for StoreReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.cursor.read(buf) + } +} + +pub fn writer_impl(path: impl AsRef) -> Result { + let path = path.as_ref(); + Ok(StoreWriter { + path: path.to_path_buf(), + data: Vec::new(), + }) +} + +pub fn reader_impl(path: impl AsRef) -> Result { + let path = path.as_ref(); + let local_storage = local_storage()?; + + let key = path.to_string_lossy().into_owned(); + let data = local_storage + .get_item(&key) + .map_err(|_| Error::custom("failed to find data for {key}"))? + .map(|value| { + serde_json::from_str(&value).map_err(|err| { + tracing::error!("failed to deserialize data: {err:?}"); + Error::custom("failed to deserialize data") + }) + }) + .unwrap_or_else(|| Ok(Vec::new()))?; + + Ok(StoreReader { + cursor: io::Cursor::new(data), + }) +} + +pub fn clear_dir_impl(path: impl AsRef) -> Result<()> { + let path = path.as_ref().to_string_lossy(); + let local_storage = local_storage()?; + + for key in js_sys::Object::keys(&local_storage) + .iter() + .filter_map(|key| key.as_string()) + .filter(|key| key.starts_with(&*path)) + { + let _ = local_storage.remove_item(&key); + } + + Ok(()) +} + +pub fn exists_impl(path: impl AsRef) -> bool { + let path = path.as_ref(); + let Ok(local_storage) = local_storage() else { + return false; + }; + + let key = path.to_string_lossy(); + matches!(local_storage.get_item(&key), Ok(Some(_))) } diff --git a/tetanes/Cargo.toml b/tetanes/Cargo.toml index c8146fa2..bc280076 100644 --- a/tetanes/Cargo.toml +++ b/tetanes/Cargo.toml @@ -100,8 +100,7 @@ getrandom = { version = "0.2", features = ["js"] } puffin = { workspace = true, features = ["web"], optional = true } tracing-web = "0.1" wgpu = { version = "0.19", features = ["webgl"] } -web-sys = { version = "0.3", features = [ - "Blob", +web-sys = { workspace = true, features = [ "Document", "DomTokenList", "Element", diff --git a/tetanes/src/nes/audio.rs b/tetanes/src/nes/audio.rs index 17b3b4ca..71b45b0a 100644 --- a/tetanes/src/nes/audio.rs +++ b/tetanes/src/nes/audio.rs @@ -499,34 +499,32 @@ impl Mixer { fn start_recording(&mut self) -> anyhow::Result<()> { let _ = self.stop_recording(); - if let Some(dir) = Config::default_audio_dir() { - let path = dir - .join( - chrono::Local::now() - .format("recording_%Y-%m-%d_at_%H_%M_%S") - .to_string(), - ) - .with_extension("wav"); - if let Some(parent) = path.parent() { - if !parent.exists() { - std::fs::create_dir_all(parent).with_context(|| { - format!( - "failed to create audio recording directory: {}", - parent.display() - ) - })?; - } + let path = Config::default_audio_dir() + .join( + chrono::Local::now() + .format("recording_%Y-%m-%d_at_%H_%M_%S") + .to_string(), + ) + .with_extension("wav"); + if let Some(parent) = path.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create audio recording directory: {}", + parent.display() + ) + })?; } - let spec = hound::WavSpec { - channels: self.channels, - sample_rate: self.sample_rate, - bits_per_sample: 32, - sample_format: hound::SampleFormat::Float, - }; - let writer = hound::WavWriter::create(&path, spec) - .context("failed to create audio recording")?; - self.recording = Some((path, writer)); } + let spec = hound::WavSpec { + channels: self.channels, + sample_rate: self.sample_rate, + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }; + let writer = + hound::WavWriter::create(&path, spec).context("failed to create audio recording")?; + self.recording = Some((path, writer)); Ok(()) } diff --git a/tetanes/src/nes/config.rs b/tetanes/src/nes/config.rs index 8c5557f8..82a52b73 100644 --- a/tetanes/src/nes/config.rs +++ b/tetanes/src/nes/config.rs @@ -241,38 +241,49 @@ impl Config { pub const FILENAME: &'static str = "config.json"; #[must_use] - pub fn default_config_dir() -> Option { - dirs::config_local_dir().map(|dir| dir.join(DeckConfig::BASE_DIR)) + pub fn default_config_dir() -> PathBuf { + dirs::config_local_dir().map_or_else( + || PathBuf::from("config"), + |dir| dir.join(DeckConfig::BASE_DIR), + ) } #[must_use] - pub fn default_data_dir() -> Option { - dirs::data_local_dir().map(|dir| dir.join(DeckConfig::BASE_DIR)) + pub fn default_data_dir() -> PathBuf { + dirs::data_local_dir().map_or_else( + || PathBuf::from("data"), + |dir| dir.join(DeckConfig::BASE_DIR), + ) } #[must_use] - pub fn default_picture_dir() -> Option { - dirs::picture_dir().map(|dir| dir.join(DeckConfig::BASE_DIR)) + pub fn default_picture_dir() -> PathBuf { + dirs::picture_dir().map_or_else( + || PathBuf::from("pictures"), + |dir| dir.join(DeckConfig::BASE_DIR), + ) } #[must_use] - pub fn default_audio_dir() -> Option { - dirs::audio_dir().map(|dir| dir.join(DeckConfig::BASE_DIR)) + pub fn default_audio_dir() -> PathBuf { + dirs::audio_dir().map_or_else( + || PathBuf::from("music"), + |dir| dir.join(DeckConfig::BASE_DIR), + ) } #[must_use] - pub fn config_path() -> Option { - Self::default_config_dir().map(|dir| dir.join(Self::FILENAME)) + pub fn config_path() -> PathBuf { + Self::default_config_dir().join(Self::FILENAME) } #[must_use] - pub fn save_path(name: &str, slot: u8) -> Option { - Self::default_data_dir().map(|dir| { - dir.join(Self::SAVE_DIR) - .join(name) - .join(format!("slot-{}", slot)) - .with_extension("sav") - }) + pub fn save_path(name: &str, slot: u8) -> PathBuf { + Self::default_data_dir() + .join(Self::SAVE_DIR) + .join(name) + .join(format!("slot-{}", slot)) + .with_extension("sav") } pub fn reset(&mut self) { @@ -280,31 +291,27 @@ impl Config { } pub fn save(&self) -> anyhow::Result<()> { - if let Some(path) = Config::config_path() { - let data = serde_json::to_vec_pretty(&self).context("failed to serialize config")?; - fs::save_raw(path, &data).context("failed to save config")?; - info!("Saved configuration"); - } + let path = Config::config_path(); + let data = serde_json::to_vec_pretty(&self).context("failed to serialize config")?; + fs::save_raw(path, &data).context("failed to save config")?; + info!("Saved configuration"); Ok(()) } pub fn load(path: Option) -> Self { - path.or_else(Config::config_path) - .and_then(|path| { - path.exists().then(|| { - info!("Loading saved configuration"); - fs::load_raw(&path) - .context("failed to load config") - .and_then(|data| Ok(serde_json::from_slice::(&data)?)) - .with_context(|| format!("failed to parse {path:?}")) - .unwrap_or_else(|err| { - error!( - "Invalid config: {path:?}, reverting to defaults. Error: {err:?}", - ); - Self::default() - }) - }) + let path = path.unwrap_or_else(Config::config_path); + fs::exists(&path) + .then(|| { + info!("Loading saved configuration"); + fs::load_raw(&path) + .context("failed to load config") + .and_then(|data| Ok(serde_json::from_slice::(&data)?)) + .with_context(|| format!("failed to parse {path:?}")) + .unwrap_or_else(|err| { + error!("Invalid config: {path:?}, reverting to defaults. Error: {err:?}",); + Self::default() + }) }) .unwrap_or_else(|| { info!("Loading default configuration"); diff --git a/tetanes/src/nes/emulation.rs b/tetanes/src/nes/emulation.rs index fb132a33..4507d454 100644 --- a/tetanes/src/nes/emulation.rs +++ b/tetanes/src/nes/emulation.rs @@ -11,7 +11,7 @@ use crate::{ }, thread, }; -use anyhow::{anyhow, bail}; +use anyhow::anyhow; use chrono::Local; use crossbeam::channel; use egui::ViewportId; @@ -645,25 +645,28 @@ impl State { fn save_state(&mut self, slot: u8, auto: bool) { if let Some(rom) = self.control_deck.loaded_rom() { - if let Some(data_dir) = Config::save_path(&rom.name, slot) { - match self.control_deck.save_state(data_dir) { - Ok(_) => { - if !auto { - self.add_message(MessageType::Info, format!("State {slot} Saved")); - } + let data_dir = Config::save_path(&rom.name, slot); + match self.control_deck.save_state(data_dir) { + Ok(_) => { + if !auto { + self.add_message(MessageType::Info, format!("State {slot} Saved")); } - Err(err) => self.on_error(err), } + Err(err) => self.on_error(err), } } } fn load_state(&mut self, slot: u8) { if let Some(rom) = self.control_deck.loaded_rom() { - if let Some(path) = Config::save_path(&rom.name, slot) { - match self.control_deck.load_state(path) { - Ok(_) => self.add_message(MessageType::Info, format!("State {slot} Loaded")), - Err(err) => self.on_error(err), + let save_path = Config::save_path(&rom.name, slot); + match self.control_deck.load_state(save_path) { + Ok(_) => self.add_message(MessageType::Info, format!("State {slot} Loaded")), + Err(control_deck::Error::NoSaveStateFound) => { + self.add_message(MessageType::Warn, format!("State {slot} Not Found")); + } + Err(err) => { + self.on_error(err); } } } @@ -672,10 +675,9 @@ impl State { fn unload_rom(&mut self) { if let Some(rom) = self.control_deck.loaded_rom() { if self.auto_save { - if let Some(path) = Config::save_path(&rom.name, self.save_slot) { - if let Err(err) = self.control_deck.save_state(path) { - self.on_error(err); - } + let save_path = Config::save_path(&rom.name, self.save_slot); + if let Err(err) = self.control_deck.save_state(save_path) { + self.on_error(err); } } self.replay_record(false); @@ -695,10 +697,9 @@ impl State { fn on_load_rom(&mut self, rom: LoadedRom) { if self.auto_load { - if let Some(path) = Config::save_path(&rom.name, self.save_slot) { - if let Err(err) = self.control_deck.load_state(path) { - error!("failed to load state: {err:?}"); - } + let save_path = Config::save_path(&rom.name, self.save_slot); + if let Err(err) = self.control_deck.load_state(save_path) { + error!("failed to load state: {err:?}"); } } if let Err(err) = self.audio.start() { @@ -805,27 +806,23 @@ impl State { } fn save_screenshot(&mut self) -> anyhow::Result { - match Config::default_picture_dir() { - Some(picture_dir) => { - let filename = picture_dir - .join( - Local::now() - .format("screenshot_%Y-%m-%d_at_%H_%M_%S") - .to_string(), - ) - .with_extension("png"); - let image = image::ImageBuffer::, &[u8]>::from_raw( - Ppu::WIDTH, - Ppu::HEIGHT, - self.control_deck.frame_buffer(), - ) - .ok_or_else(|| anyhow!("failed to create image buffer"))?; - - // TODO: provide wasm download - Ok(image.save(&filename).map(|_| filename)?) - } - None => bail!("failed to find default picture directory"), - } + let picture_dir = Config::default_picture_dir(); + let filename = picture_dir + .join( + Local::now() + .format("screenshot_%Y-%m-%d_at_%H_%M_%S") + .to_string(), + ) + .with_extension("png"); + let image = image::ImageBuffer::, &[u8]>::from_raw( + Ppu::WIDTH, + Ppu::HEIGHT, + self.control_deck.frame_buffer(), + ) + .ok_or_else(|| anyhow!("failed to create image buffer"))?; + + // TODO: provide wasm download + Ok(image.save(&filename).map(|_| filename)?) } fn park_timeout(&self) -> Option { @@ -932,7 +929,7 @@ impl State { self.rewind.set_enabled(false); self.on_error(err); } - if self.last_auto_save.elapsed() > self.auto_save_interval { + if self.auto_save && self.last_auto_save.elapsed() > self.auto_save_interval { self.last_auto_save = Instant::now(); self.save_state(self.save_slot, true); } diff --git a/tetanes/src/nes/emulation/replay.rs b/tetanes/src/nes/emulation/replay.rs index 05f211bb..f6de83eb 100644 --- a/tetanes/src/nes/emulation/replay.rs +++ b/tetanes/src/nes/emulation/replay.rs @@ -56,24 +56,24 @@ impl Record { let Some(start) = self.start.take() else { return Ok(None); }; + if self.events.is_empty() { tracing::debug!("not saving - no replay events"); return Ok(None); } - if let Some(dir) = Config::default_data_dir() { - let path = dir - .join( - Local::now() - .format(&format!("tetanes_replay_{name}_%Y-%m-%d_%H.%M.%S")) - .to_string(), - ) - .with_extension("replay"); - let events = std::mem::take(&mut self.events); - fs::save(&path, &State((start, events)))?; - Ok(Some(path)) - } else { - Err(anyhow::anyhow!("failed to find document directory")) - } + + let replay_path = Config::default_data_dir() + .join( + Local::now() + .format(&format!("tetanes_replay_{name}_%Y-%m-%d_%H.%M.%S")) + .to_string(), + ) + .with_extension("replay"); + let events = std::mem::take(&mut self.events); + + fs::save(&replay_path, &State((start, events)))?; + + Ok(Some(replay_path)) } } diff --git a/tetanes/src/nes/event.rs b/tetanes/src/nes/event.rs index 3d60340e..3cc1b3b2 100644 --- a/tetanes/src/nes/event.rs +++ b/tetanes/src/nes/event.rs @@ -506,7 +506,7 @@ impl Running { .renderer .roms_path .as_ref() - .map(|p| p.to_path_buf()), + .map_or_else(|| PathBuf::from("."), |p| p.to_path_buf()), ) { Ok(maybe_path) => { if let Some(path) = maybe_path { @@ -852,7 +852,7 @@ impl Running { | DeckAction::ZapperAimOffscreen | DeckAction::ZapperTrigger => (), DeckAction::SetSaveSlot(slot) if released => { - if platform::supports(platform::Feature::Filesystem) { + if platform::supports(platform::Feature::Storage) { if self.cfg.emulation.save_slot != slot { self.cfg.emulation.save_slot = slot; self.renderer.add_message( @@ -868,7 +868,7 @@ impl Running { } } DeckAction::SaveState if released && is_root_window => { - if platform::supports(platform::Feature::Filesystem) { + if platform::supports(platform::Feature::Storage) { self.nes_event(EmulationEvent::SaveState(self.cfg.emulation.save_slot)); } else { self.renderer.add_message( @@ -878,7 +878,7 @@ impl Running { } } DeckAction::LoadState if released && is_root_window => { - if platform::supports(platform::Feature::Filesystem) { + if platform::supports(platform::Feature::Storage) { self.nes_event(EmulationEvent::LoadState(self.cfg.emulation.save_slot)); } else { self.renderer.add_message( diff --git a/tetanes/src/nes/renderer/gui.rs b/tetanes/src/nes/renderer/gui.rs index 9dc5e663..6b5f4920 100644 --- a/tetanes/src/nes/renderer/gui.rs +++ b/tetanes/src/nes/renderer/gui.rs @@ -934,7 +934,9 @@ impl Gui { }); ui.separator(); + } + if platform::supports(platform::Feature::Storage) { ui.add_enabled_ui(self.loaded_rom.is_some(), |ui| { let button = Button::new("πŸ’Ύ Save State") .shortcut_text(self.fmt_shortcut(DeckAction::SaveState)); @@ -963,7 +965,10 @@ impl Gui { ui.menu_button("σΎ ¬ Save Slot...", |ui| { self.save_slot_radio(ui, cfg, ShowShortcut::Yes); }); + } + #[cfg(not(target_arch = "wasm32"))] + { ui.separator(); let button = Button::new("βŽ† Quit").shortcut_text(self.fmt_shortcut(UiAction::Quit)); @@ -1036,25 +1041,23 @@ impl Gui { ui.separator(); ui.add_enabled_ui(self.loaded_rom.is_some(), |ui| { - if platform::supports(platform::Feature::Filesystem) { - ui.add_enabled_ui(cfg.emulation.rewind, |ui| { - let button = Button::new("⟲ Instant Rewind") - .shortcut_text(self.fmt_shortcut(Feature::InstantRewind)); - let disabled_hover_text = if self.loaded_rom.is_none() { - Self::NO_ROM_LOADED - } else { - "Rewind can be enabled under the `Config` menu." - }; - let res = ui - .add(button) - .on_hover_text("Instantly rewind state to a previous point.") - .on_disabled_hover_text(disabled_hover_text); - if res.clicked() { - self.tx.nes_event(EmulationEvent::InstantRewind); - ui.close_menu(); - }; - }); - } + ui.add_enabled_ui(cfg.emulation.rewind, |ui| { + let button = Button::new("⟲ Instant Rewind") + .shortcut_text(self.fmt_shortcut(Feature::InstantRewind)); + let disabled_hover_text = if self.loaded_rom.is_none() { + Self::NO_ROM_LOADED + } else { + "Rewind can be enabled under the `Config` menu." + }; + let res = ui + .add(button) + .on_hover_text("Instantly rewind state to a previous point.") + .on_disabled_hover_text(disabled_hover_text); + if res.clicked() { + self.tx.nes_event(EmulationEvent::InstantRewind); + ui.close_menu(); + }; + }); let button = Button::new("πŸ”ƒ Reset") .shortcut_text(self.fmt_shortcut(DeckAction::Reset(ResetKind::Soft))); @@ -1727,22 +1730,18 @@ impl Gui { self.joypad_keybinds = Self::joypad_keybinds(&cfg.input.joypad_bindings); self.tx.nes_event(ConfigEvent::InputBindings); } - if platform::supports(platform::Feature::Filesystem) { - if let Some(data_dir) = Config::default_data_dir() { - if ui.button("Clear Save States").clicked() { - match fs::clear_dir(data_dir) { - Ok(_) => { - self.add_message(MessageType::Info, "Save States cleared.") - } - Err(_) => self.add_message( - MessageType::Error, - "Failed to clear Save States.", - ), + if platform::supports(platform::Feature::Storage) { + let data_dir = Config::default_data_dir(); + if ui.button("Clear Save States").clicked() { + match fs::clear_dir(data_dir) { + Ok(_) => self.add_message(MessageType::Info, "Save States cleared."), + Err(_) => { + self.add_message(MessageType::Error, "Failed to clear Save States.") } } - if ui.button("Clear Recent ROMs").clicked() { - cfg.renderer.recent_roms.clear(); - } + } + if ui.button("Clear Recent ROMs").clicked() { + cfg.renderer.recent_roms.clear(); } } }); @@ -2249,29 +2248,25 @@ impl Gui { ui.horizontal_wrapped(|ui| { let grid = Grid::new("directories").num_columns(2).spacing([40.0, 6.0]); grid.show(ui, |ui| { - if let Some(config_dir) = Config::default_config_dir() { - ui.strong("Preferences:"); - ui.label(format!("{}", config_dir.display())); - ui.end_row(); - } - - if let Some(data_dir) = Config::default_data_dir() { - ui.strong("Save States/RAM, Replays: "); - ui.label(format!("{}", data_dir.display())); - ui.end_row(); - } - - if let Some(picture_dir) = Config::default_picture_dir() { - ui.strong("Screenshots: "); - ui.label(format!("{}", picture_dir.display())); - ui.end_row(); - } - - if let Some(audio_dir) = Config::default_audio_dir() { - ui.strong("Audio Recordings: "); - ui.label(format!("{}", audio_dir.display())); - ui.end_row(); - } + let config_dir = Config::default_config_dir(); + ui.strong("Preferences:"); + ui.label(format!("{}", config_dir.display())); + ui.end_row(); + + let data_dir = Config::default_data_dir(); + ui.strong("Save States/RAM, Replays: "); + ui.label(format!("{}", data_dir.display())); + ui.end_row(); + + let picture_dir = Config::default_picture_dir(); + ui.strong("Screenshots: "); + ui.label(format!("{}", picture_dir.display())); + ui.end_row(); + + let audio_dir = Config::default_audio_dir(); + ui.strong("Audio Recordings: "); + ui.label(format!("{}", audio_dir.display())); + ui.end_row(); }); }); } diff --git a/tetanes/src/platform.rs b/tetanes/src/platform.rs index 0f044451..fbcdc9c4 100644 --- a/tetanes/src/platform.rs +++ b/tetanes/src/platform.rs @@ -26,7 +26,7 @@ pub fn open_file_dialog( title: impl Into, name: impl Into, extensions: &[impl ToString], - dir: Option, + dir: PathBuf, ) -> anyhow::Result> { platform::open_file_dialog_impl(title, name, extensions, dir) } @@ -35,6 +35,7 @@ pub fn open_file_dialog( #[must_use] pub enum Feature { Filesystem, + Storage, Viewports, Suspend, } diff --git a/tetanes/src/sys/platform/os.rs b/tetanes/src/sys/platform/os.rs index b8eea41b..b4ecde0c 100644 --- a/tetanes/src/sys/platform/os.rs +++ b/tetanes/src/sys/platform/os.rs @@ -13,7 +13,7 @@ use winit::{ pub const fn supports_impl(feature: Feature) -> bool { match feature { Feature::Suspend => cfg!(target_os = "android"), - Feature::Filesystem | Feature::Viewports => true, + Feature::Filesystem | Feature::Storage | Feature::Viewports => true, } } @@ -21,14 +21,12 @@ pub fn open_file_dialog_impl( title: impl Into, name: impl Into, extensions: &[impl ToString], - dir: Option, + dir: PathBuf, ) -> anyhow::Result> { - let mut dialog = rfd::FileDialog::new() + let dialog = rfd::FileDialog::new() .set_title(title) + .set_directory(dir) .add_filter(name, extensions); - if let Some(dir) = dir { - dialog = dialog.set_directory(dir); - } Ok(dialog.pick_file()) } diff --git a/tetanes/src/sys/platform/wasm.rs b/tetanes/src/sys/platform/wasm.rs index edd8adb0..f59391e1 100644 --- a/tetanes/src/sys/platform/wasm.rs +++ b/tetanes/src/sys/platform/wasm.rs @@ -17,30 +17,40 @@ use winit::{ window::WindowBuilder, }; -pub const fn supports_impl(_feature: Feature) -> bool { - false +pub const fn supports_impl(feature: Feature) -> bool { + match feature { + Feature::Storage => true, + Feature::Filesystem | Feature::Viewports | Feature::Suspend => false, + } } pub fn open_file_dialog_impl( _title: impl Into, _name: impl Into, extensions: &[impl ToString], - _dir: Option, + _dir: PathBuf, ) -> anyhow::Result> { let input_id = match extensions[0].to_string().as_str() { "nes" => html_ids::ROM_INPUT, "replay" => html_ids::REPLAY_INPUT, _ => bail!("unsupported file extension"), }; + let input = web_sys::window() .and_then(|window| window.document()) .and_then(|document| document.get_element_by_id(input_id)) .and_then(|input| input.dyn_into::().ok()); match input { - Some(input) => input.click(), + Some(input) => { + // To prevent event loop receiving events while dialog is open + if let Some(canvas) = get_canvas() { + let _ = canvas.blur(); + } + input.click(); + } None => bail!("failed to find file input element"), } - focus_canvas(); + Ok(None) } @@ -110,7 +120,10 @@ impl Initialize for Running { let on_cancel = Closure::::new({ let tx = self.tx.clone(); - move |_: web_sys::Event| tx.nes_event(UiEvent::FileDialogCancelled) + move |_: web_sys::Event| { + focus_canvas(); + tx.nes_event(UiEvent::FileDialogCancelled); + } }); let input = document