feat(dtmm): Save settings to config file

Closes #18.
This commit is contained in:
Lucas Schwiderski 2023-03-01 14:13:11 +01:00
parent 55335c0fdc
commit e6c9fe834c
Signed by: lucas
GPG key ID: AA12679AAA6DF4D8
7 changed files with 134 additions and 28 deletions

View file

@ -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<ModInfo> {
@ -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()))
}

View file

@ -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");
}),
};
}
}

View file

@ -82,10 +82,18 @@ pub(crate) struct State {
pub selected_mod_index: Option<usize>,
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<PathBuf>,
pub data_dir: Arc<PathBuf>,
pub ctx: Arc<sdk::Context>,
pub log: Arc<String>,
#[lens(ignore)]
#[data(ignore)]
pub config_path: Arc<PathBuf>,
#[lens(ignore)]
#[data(ignore)]
pub ctx: Arc<sdk::Context>,
}
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()),
}
}

View file

@ -28,11 +28,17 @@ pub(crate) const ACTION_FINISH_ADD_MOD: Selector<SingleUse<ModInfo>> =
pub(crate) const ACTION_LOG: Selector<SingleUse<String>> = 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<State> 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<State> 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<State> 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<State> 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);

View file

@ -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<Arc<PathBuf>> for PathBufFormatter {
Ok(Arc::new(p))
}
}
pub struct TextBoxOnChanged<F: Fn(&mut EventCtx, &str)>(F);
impl<F: Fn(&mut EventCtx, &str)> TextBoxOnChanged<F> {
pub fn new(f: F) -> Self {
Self(f)
}
}
impl<F: Fn(&mut EventCtx, &str)> ValidationDelegate for TextBoxOnChanged<F> {
fn event(&mut self, ctx: &mut EventCtx, event: TextBoxEvent, current_text: &str) {
if let TextBoxEvent::Complete = event {
(self.0)(ctx, current_text)
}
}
}

View file

@ -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<State> {
.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<State> {
.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.,

View file

@ -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<PathBuf>,
game_dir: Option<PathBuf>,
}
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<PathBuf>,
pub game_dir: Option<PathBuf>,
}
#[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<P: AsRef<Path>>(
default_config_path: P,
matches: &ArgMatches,
) -> Result<Config> {
#[tracing::instrument(skip(matches),fields(path = ?matches.get_one::<PathBuf>("config")))]
pub(crate) fn read_config<P>(default: P, matches: &ArgMatches) -> Result<Config>
where
P: Into<PathBuf> + std::fmt::Debug,
{
let path = matches
.get_one::<PathBuf>("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<P: AsRef<Path>>(
}
{
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<P: AsRef<Path>>(
}
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<P: AsRef<Path>>(
{
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()
)
})?;
}