diff --git a/crates/dtmm/src/controller/app.rs b/crates/dtmm/src/controller/app.rs index 01dc22c..31a79e2 100644 --- a/crates/dtmm/src/controller/app.rs +++ b/crates/dtmm/src/controller/app.rs @@ -9,6 +9,7 @@ use tokio::fs; use zip::ZipArchive; use crate::state::{ModInfo, PackageInfo, State}; +use crate::util::config::Config; #[tracing::instrument(skip(state))] pub(crate) async fn import_mod(state: State, info: FileInfo) -> Result { @@ -103,3 +104,23 @@ pub(crate) async fn delete_mod(state: State, info: &ModInfo) -> Result<()> { Ok(()) } + +#[tracing::instrument(skip(state))] +pub(crate) async fn save_settings(state: State) -> Result<()> { + // TODO: Avoid allocations, especially once the config grows, by + // creating a separate struct with only borrowed data to serialize from. + let cfg = Config { + path: state.config_path.as_ref().clone(), + game_dir: Some(state.game_dir.as_ref().clone()), + data_dir: Some(state.data_dir.as_ref().clone()), + }; + + tracing::info!("Saving settings to '{}'", state.config_path.display()); + tracing::debug!(?cfg); + + let data = serde_sjson::to_string(&cfg).wrap_err("failed to serialize config")?; + + fs::write(&cfg.path, &data) + .await + .wrap_err_with(|| format!("failed to write config to '{}'", cfg.path.display())) +} diff --git a/crates/dtmm/src/controller/worker.rs b/crates/dtmm/src/controller/worker.rs index 1c11b2a..80abf53 100644 --- a/crates/dtmm/src/controller/worker.rs +++ b/crates/dtmm/src/controller/worker.rs @@ -9,6 +9,7 @@ use tokio::sync::RwLock; use crate::controller::app::*; use crate::controller::game::*; use crate::state::AsyncAction; +use crate::state::ACTION_FINISH_SAVE_SETTINGS; use crate::state::{ ACTION_FINISH_ADD_MOD, ACTION_FINISH_DELETE_SELECTED_MOD, ACTION_FINISH_DEPLOY, ACTION_FINISH_RESET_DEPLOYMENT, ACTION_LOG, @@ -81,6 +82,17 @@ async fn handle_action( .submit_command(ACTION_FINISH_RESET_DEPLOYMENT, (), Target::Auto) .expect("failed to send command"); }), + AsyncAction::SaveSettings(state) => tokio::spawn(async move { + if let Err(err) = save_settings(state).await { + tracing::error!("Failed to save settings: {:?}", err); + } + + event_sink + .write() + .await + .submit_command(ACTION_FINISH_SAVE_SETTINGS, (), Target::Auto) + .expect("failed to send command"); + }), }; } } diff --git a/crates/dtmm/src/state/data.rs b/crates/dtmm/src/state/data.rs index 17916a5..63bfe41 100644 --- a/crates/dtmm/src/state/data.rs +++ b/crates/dtmm/src/state/data.rs @@ -82,10 +82,18 @@ pub(crate) struct State { pub selected_mod_index: Option, pub is_deployment_in_progress: bool, pub is_reset_in_progress: bool, + pub is_save_in_progress: bool, + pub is_next_save_pending: bool, pub game_dir: Arc, pub data_dir: Arc, - pub ctx: Arc, pub log: Arc, + + #[lens(ignore)] + #[data(ignore)] + pub config_path: Arc, + #[lens(ignore)] + #[data(ignore)] + pub ctx: Arc, } impl State { @@ -102,8 +110,11 @@ impl State { selected_mod_index: None, is_deployment_in_progress: false, is_reset_in_progress: false, - game_dir: Arc::new(config.game_dir().cloned().unwrap_or_default()), - data_dir: Arc::new(config.data_dir().cloned().unwrap_or_default()), + is_save_in_progress: false, + is_next_save_pending: false, + config_path: Arc::new(config.path), + game_dir: Arc::new(config.game_dir.unwrap_or_default()), + data_dir: Arc::new(config.data_dir.unwrap_or_default()), log: Arc::new(String::new()), } } diff --git a/crates/dtmm/src/state/delegate.rs b/crates/dtmm/src/state/delegate.rs index 4f44a68..6aab016 100644 --- a/crates/dtmm/src/state/delegate.rs +++ b/crates/dtmm/src/state/delegate.rs @@ -28,11 +28,17 @@ pub(crate) const ACTION_FINISH_ADD_MOD: Selector> = pub(crate) const ACTION_LOG: Selector> = Selector::new("dtmm.action.log"); +pub(crate) const ACTION_START_SAVE_SETTINGS: Selector = + Selector::new("dtmm.action.start-save-settings"); +pub(crate) const ACTION_FINISH_SAVE_SETTINGS: Selector = + Selector::new("dtmm.action.finish-save-settings"); + pub(crate) enum AsyncAction { DeployMods(State), ResetDeployment(State), AddMod((State, FileInfo)), DeleteMod((State, ModInfo)), + SaveSettings(State), } pub(crate) struct Delegate { @@ -49,12 +55,16 @@ impl AppDelegate for Delegate { #[tracing::instrument(name = "Delegate", skip_all)] fn command( &mut self, - _ctx: &mut DelegateCtx, + ctx: &mut DelegateCtx, _target: Target, cmd: &Command, state: &mut State, _env: &Env, ) -> Handled { + if cfg!(debug_assertions) && !cmd.is(ACTION_LOG) { + tracing::trace!(?cmd); + } + match cmd { cmd if cmd.is(ACTION_START_DEPLOY) => { if self @@ -152,6 +162,8 @@ impl AppDelegate for Delegate { }; state.mods.remove(index); + ctx.submit_command(ACTION_START_SAVE_SETTINGS); + Handled::Yes } cmd if cmd.is(ACTION_ADD_MOD) => { @@ -173,6 +185,7 @@ impl AppDelegate for Delegate { .expect("command type matched but didn't contain the expected value"); if let Some(info) = info.take() { state.add_mod(info); + ctx.submit_command(ACTION_START_SAVE_SETTINGS); } Handled::Yes } @@ -185,6 +198,31 @@ impl AppDelegate for Delegate { } Handled::Yes } + cmd if cmd.is(ACTION_START_SAVE_SETTINGS) => { + if state.is_save_in_progress { + state.is_next_save_pending = true; + } else if self + .sender + .send(AsyncAction::SaveSettings(state.clone())) + .is_ok() + { + state.is_save_in_progress = true; + } else { + tracing::error!("Failed to queue action to save settings"); + } + + Handled::Yes + } + cmd if cmd.is(ACTION_FINISH_SAVE_SETTINGS) => { + state.is_save_in_progress = false; + + if state.is_next_save_pending { + state.is_next_save_pending = false; + ctx.submit_command(ACTION_START_SAVE_SETTINGS); + } + + Handled::Yes + } cmd => { if cfg!(debug_assertions) { tracing::warn!("Unknown command: {:?}", cmd); diff --git a/crates/dtmm/src/state/util.rs b/crates/dtmm/src/state/util.rs index 804b751..1776d19 100644 --- a/crates/dtmm/src/state/util.rs +++ b/crates/dtmm/src/state/util.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use std::sync::Arc; use druid::text::Formatter; +use druid::widget::{TextBoxEvent, ValidationDelegate}; +use druid::EventCtx; pub(crate) struct PathBufFormatter; @@ -29,3 +31,19 @@ impl Formatter> for PathBufFormatter { Ok(Arc::new(p)) } } + +pub struct TextBoxOnChanged(F); + +impl TextBoxOnChanged { + pub fn new(f: F) -> Self { + Self(f) + } +} + +impl ValidationDelegate for TextBoxOnChanged { + fn event(&mut self, ctx: &mut EventCtx, event: TextBoxEvent, current_text: &str) { + if let TextBoxEvent::Complete = event { + (self.0)(ctx, current_text) + } + } +} diff --git a/crates/dtmm/src/ui/window/main.rs b/crates/dtmm/src/ui/window/main.rs index fc64315..d6d4f9c 100644 --- a/crates/dtmm/src/ui/window/main.rs +++ b/crates/dtmm/src/ui/window/main.rs @@ -8,7 +8,10 @@ use druid::{ TextAlignment, Widget, WidgetExt, WindowDesc, }; -use crate::state::{ModInfo, PathBufFormatter, State, View, ACTION_START_RESET_DEPLOYMENT}; +use crate::state::{ + ModInfo, PathBufFormatter, State, TextBoxOnChanged, View, ACTION_START_RESET_DEPLOYMENT, + ACTION_START_SAVE_SETTINGS, +}; use crate::state::{ ACTION_ADD_MOD, ACTION_SELECTED_MOD_DOWN, ACTION_SELECTED_MOD_UP, ACTION_SELECT_MOD, ACTION_START_DELETE_SELECTED_MOD, ACTION_START_DEPLOY, @@ -246,6 +249,9 @@ fn build_view_settings() -> impl Widget { .with_flex_child( TextBox::new() .with_formatter(PathBufFormatter::new()) + .delegate(TextBoxOnChanged::new(|ctx, _| { + ctx.submit_command(ACTION_START_SAVE_SETTINGS) + })) .expand_width() .lens(State::data_dir), 1., @@ -260,6 +266,9 @@ fn build_view_settings() -> impl Widget { .with_flex_child( TextBox::new() .with_formatter(PathBufFormatter::new()) + .delegate(TextBoxOnChanged::new(|ctx, _| { + ctx.submit_command(ACTION_START_SAVE_SETTINGS) + })) .expand_width() .lens(State::game_dir), 1., diff --git a/crates/dtmm/src/util/config.rs b/crates/dtmm/src/util/config.rs index 4483c53..0008edf 100644 --- a/crates/dtmm/src/util/config.rs +++ b/crates/dtmm/src/util/config.rs @@ -1,6 +1,6 @@ use std::fs; use std::io::ErrorKind; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use clap::{parser::ValueSource, ArgMatches}; use color_eyre::{eyre::Context, Result}; @@ -8,18 +8,10 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct Config { - data_dir: Option, - game_dir: Option, -} - -impl Config { - pub fn game_dir(&self) -> Option<&PathBuf> { - self.game_dir.as_ref() - } - - pub fn data_dir(&self) -> Option<&PathBuf> { - self.data_dir.as_ref() - } + #[serde(skip)] + pub path: PathBuf, + pub data_dir: Option, + pub game_dir: Option, } #[cfg(not(arget_os = "windows"))] @@ -60,22 +52,26 @@ pub fn get_default_data_dir() -> PathBuf { PathBuf::from(data_dir).join("dtmm") } -pub(crate) fn read_config>( - default_config_path: P, - matches: &ArgMatches, -) -> Result { +#[tracing::instrument(skip(matches),fields(path = ?matches.get_one::("config")))] +pub(crate) fn read_config

(default: P, matches: &ArgMatches) -> Result +where + P: Into + std::fmt::Debug, +{ let path = matches .get_one::("config") .expect("argument missing despite default"); - let default_config_path = default_config_path.as_ref(); + let default_path = default.into(); match fs::read(path) { Ok(data) => { let data = String::from_utf8(data).wrap_err_with(|| { format!("config file {} contains invalid UTF-8", path.display()) })?; - serde_sjson::from_str(&data) - .wrap_err_with(|| format!("invalid config file {}", path.display())) + let mut cfg: Config = serde_sjson::from_str(&data) + .wrap_err_with(|| format!("invalid config file {}", path.display()))?; + + cfg.path = path.clone(); + Ok(cfg) } Err(err) if err.kind() == ErrorKind::NotFound => { if matches.value_source("config") != Some(ValueSource::DefaultValue) { @@ -84,7 +80,7 @@ pub(crate) fn read_config>( } { - let parent = default_config_path + let parent = default_path .parent() .expect("a file path always has a parent directory"); fs::create_dir_all(parent).wrap_err_with(|| { @@ -93,6 +89,7 @@ pub(crate) fn read_config>( } let config = Config { + path: default_path, data_dir: Some(get_default_data_dir()), game_dir: None, }; @@ -100,10 +97,10 @@ pub(crate) fn read_config>( { let data = serde_sjson::to_string(&config) .wrap_err("failed to serialize default config value")?; - fs::write(default_config_path, data).wrap_err_with(|| { + fs::write(&config.path, data).wrap_err_with(|| { format!( "failed to write default config to {}", - default_config_path.display() + config.path.display() ) })?; }